feat(eval): perplexity layer-select/offload plus a layer-cycling mode (#395) - #413
Open
jamesburton wants to merge 10 commits into
Open
feat(eval): perplexity layer-select/offload plus a layer-cycling mode (#395)#413jamesburton wants to merge 10 commits into
jamesburton wants to merge 10 commits into
Conversation
…#395) `dotllm perplexity` could only score a model wholly on one device, so any model larger than a single device's memory could not be measured at all. - `--gpu-layers` on `perplexity`, with the same semantics as run/chat/serve (including that an explicit count decides the backend), routed through the same per-architecture prefix-offload dispatch. - `--first-layer` makes the GPU block an arbitrary contiguous window rather than a prefix, which is what full-coverage verification on an undersized device needs. - `--cycle` slides a GPU window across the whole trunk within ONE corpus pass, checkpointing the boundary hidden state at each layer cut and replaying the next window from it, so every layer is GPU-executed exactly once instead of paying for N CPU-bottlenecked passes. - `ILayerWindowModel` / `ILayerWindowExecutor`: the backend-neutral hidden-in/hidden-out layer-window contract, implemented for CPU (dense + Qwen3MoeHybrid) and CUDA (dense/GQA via CudaPipelineStage). - `PerplexityWindowPlan` is now the single corpus-window enumeration shared by `PerplexityEvaluator` and the cycling driver, so the passes cannot desynchronise. - The result table always prints `Layer placement`, whole-device runs included, so a scraped table cannot report a split or cycled run as a single-device one. Recurrent state is deliberately NOT carried across a layer cut: state is per layer and each layer belongs to one window that replays the whole corpus in window order, so the correct starting state is zero in a cycled and a whole-device run alike. The failure that does occur is a missing per-corpus-window reset (issue #261's bug, multiplied), and the tests discriminate on exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
…395) Stride 8 on a context of 8 derives an unscored prefix of 0, which the plan rejects. Pin the prefix at 4 so the shape matches the discriminating test in everything but the window count — which is the only thing the test is about. Also drops a leftover scratch test file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
…395) A placement renders layer ranges as "[0..8)", and Spectre reads a leading '[' as the start of a markup tag — so every windowed or cycled run aborted with "Encountered malformed markup tag" instead of printing its result, and the prefix offload path did too as soon as the placement row was added. Found by running the three CLI paths end-to-end against Llama-3.2-1B-Q8_0; the library-level tests could not have caught it, since they never render a table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
HybridTransformerModel returns the last logit row only, so --gpu-layers without --cycle cannot run sliding-window mode. Found by running the CLI end-to-end; recorded next to the placement table so nobody plans a sweep around the wrong assumption. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
There was a problem hiding this comment.
Pull request overview
Adds layer-window execution and a layer-cycling perplexity mode so dotllm perplexity can evaluate models larger than GPU memory by offloading/partitioning the transformer trunk (including arbitrary contiguous GPU windows) and replaying from checkpointed boundary activations.
Changes:
- Introduces
ILayerWindowModel/ILayerWindowExecutorplus CPU/CUDA implementations and aCompositeLayerWindowModelfor per-window device assignment. - Implements
CyclingPerplexityEvaluatorandPerplexityWindowPlan, and refactorsPerplexityEvaluatorto share the single window-enumeration plan. - Extends
dotllm perplexityCLI with--gpu-layers,--first-layer, and--cycle, plus unit/integration test coverage and updated GPU docs.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Evaluation/LayerCyclingPerplexityTests.cs | Adds unit coverage for cycling correctness, recurrent reset discrimination, and per-window diagnostics parity. |
| tests/DotLLM.Tests.Integration/Cuda/CudaLayerCyclingPerplexityTests.cs | Adds CUDA integration acceptance tests comparing cycled vs whole-device runs within tolerances. |
| src/DotLLM.Models/Gguf/SyntheticQwen35MoeGguf.cs | Extends synthetic hybrid fixture generation to support variable even layer counts for window/cycle coverage. |
| src/DotLLM.Models/Evaluation/CpuLayerWindowModel.cs | Exposes CPU models via the layer-window contract for cycling and parity validation. |
| src/DotLLM.Models/Architectures/TransformerModel.cs | Adds trunk window execution and output-head application helpers to support cycling/windowing. |
| src/DotLLM.Models/Architectures/Qwen3MoeHybridTransformerModel.cs | Adds windowed trunk execution and output-head application for hybrid (recurrent) fixture/cycling. |
| src/DotLLM.Engine/Evaluation/PerplexityWindowPlan.cs | Centralizes and validates corpus window enumeration shared by whole-device and cycling evaluation. |
| src/DotLLM.Engine/Evaluation/PerplexityEvaluator.cs | Refactors sliding-window evaluation to use PerplexityWindowPlan for shared enumeration/BOS handling. |
| src/DotLLM.Engine/Evaluation/CyclingPerplexityEvaluator.cs | Implements cycling driver: boundary checkpointing, replay adapter, and partition validation. |
| src/DotLLM.Engine/Evaluation/CompositeLayerWindowModel.cs | Composes multiple ILayerWindowModel backings to express arbitrary GPU windows and CPU/GPU splits. |
| src/DotLLM.Cuda/Evaluation/CudaLayerWindowModel.cs | Implements CUDA per-window upload/free execution via CudaPipelineStage, with host-side output head. |
| src/DotLLM.Core/Evaluation/ILayerWindowModel.cs | Adds backend-neutral window execution and reset contract for cycling/offload evaluation. |
| src/DotLLM.Cli/Commands/PerplexityCommand.cs | Adds CLI flags and execution paths for prefix offload, arbitrary window placement, and cycling. |
| docs/GPU.md | Documents new perplexity placements, cycling rationale, constraints, and scope limits. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
174
to
+178
| // When a BOS id is supplied, each window's first token is replaced by it, mirroring | ||
| // llama.cpp's perplexity: every chunk is a fresh sequence and is given a sequence start. | ||
| // The substituted slot sits inside the unscored prefix, so no scored target is altered. | ||
| int[]? windowBuffer = bosTokenId >= 0 ? new int[context] : null; | ||
| // The plan applies the substitution so that the cycling driver cannot disagree about it. | ||
| var windowBuffer = new int[context]; |
Comment on lines
+23
to
+31
| public readonly struct PerplexityWindowPlan | ||
| { | ||
| private readonly int _tokenCount; | ||
|
|
||
| private PerplexityWindowPlan(int tokenCount, int contextLength, int stride, int unscoredPrefix, int bosTokenId) | ||
| { | ||
| _tokenCount = tokenCount; | ||
| ContextLength = contextLength; | ||
| Stride = stride; |
…guard (#395) D1, blocking. CudaPipelineStage hands LaunchRoPE only theta / dim / type, and no CUDA LaunchRoPE overload takes YaRN's scaling factor, original context length, attn factor or beta fast/slow — while the CPU reference does apply them (PrecomputeFrequencyTableYarn). Scaled RoPE changes the frequency table rather than the layer graph, so a YaRN GGUF cleared every structural rejection and would then have been scored with the UNSCALED table: a plausible, authoritative, wrong perplexity, which is precisely what the guard's own doc promises cannot happen. Mitigating context, so this reads at its true size: the gap is pre-existing and backend-wide (whole-device `--device cuda` is equally unscaled) and this does not fix it — it only keeps the NEW guard's promise honest. Llama-3's `llama3` scaling maps to RoPEScalingType.None in GgufModelConfigExtractor, so the Llama-3.x family including the 1B test fixture is unaffected, and SmolLM3-128k is already caught by the NoRopeLayers rejection. Small affected set, but nonzero. ValidateSupported becomes internal so the guard can be tested without a GPU or a checkpoint per rejected feature — a rejection that silently stopped firing would look exactly like a passing build. CudaLayerWindowScopeGuardTests covers all five scaling types, plus a positive control and an explicit "unscaled RoPE still accepted" case so the guard cannot pass by rejecting everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
…ounds (#395) D3 — hybrid + --cycle died with a stack trace. CudaLayerWindowModel.LoadFromGguf was constructed with `using var` ABOVE the try, so the load-time rejection the feature advertises escaped unhandled. Every MoE / hybrid model takes that path. Model construction moves inside the try. Verified end-to-end: a synthetic Qwen3MoeHybrid GGUF now exits 1 with the one-line "mixture-of-experts FFN routing is not implemented ..." message and no stack trace. D2 — the window containing the last layer uploaded an LM head it never launches. CudaWeights derived isHybrid purely from the layer range, but CudaLayerWindowModel always builds isFinalStage: false because the head runs on the host. A new skipOutputHead flag, driven from isFinalStage rather than the layer range, drops it. Controlled A/B on the same idle baseline (757 MiB), full-trunk window, Llama-3.2-1B-Q8_0: peak 2465 -> 2325 MiB, i.e. 140 MiB. Smaller than a naive vocab x hidden estimate because this model has tied embeddings and Q8_0 dedup, so treat 140 MiB as the LOW end; an untied head is a separate allocation. Existing CudaPipelineTransformerModel behaviour is unchanged (stage 1 is isFinalStage). D4 — the CUDA tests scored random token ids at mean NLL 12.18 against a uniform floor of ln(128256) ~ 11.76, so the figure was pinned near chance and a 1% bound was satisfiable by an almost arbitrarily damaged implementation. Switched to real English through the model's own tokenizer (mean NLL ~3.0) and tightened the bounds from 1%/0.5% to 0.3%/0.2%. Added CycledScoring_DetectsAPerturbedBoundaryCheckpoint per CLAUDE.md / #418. It found two things worth recording. A UNIFORM scale of the boundary is nearly invisible (0.043%, below plain rounding) because the next layer starts with RMSNorm, which is scale-invariant — so the perturbation is element-wise with alternating sign, which rotates the checkpoint's direction instead. And the calibration is now explicit: rounding alone 0.054%, a 1% boundary error 0.123%, a 5% one 0.632%. The 0.2% bound therefore resolves a ~2%+ boundary corruption and NOT a 1% one. That limit is documented on the test rather than papered over — the CPU tests are bit-identical and carry the logic load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
…ibration (#395) Two things a reader of the cycling section needs and could not otherwise know: - Scaled RoPE is rejected by the CUDA layer-window guard, and why that rejection is unlike the others — it guards a frequency table, not a layer graph. Flagged as a pre-existing backend-wide gap that this does not fix. - What the CUDA equivalence tests can and cannot resolve, as a measured table. The 0.2% bound catches a ~2%+ boundary corruption and not a 1% one, and a UNIFORM scale of the residual stream is undetectable at a layer boundary by construction, because the next layer starts with a scale-invariant RMSNorm. Worth knowing before anyone designs another activation-level boundary check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
5 tasks
…measured (#395) Review round 2. No code defect found; the bounds (0.3% / 0.2%) are validated and unchanged. What was wrong was the prose around them. - Resolving power was optimistic. "Resolves a boundary corruption of roughly 2% or more" does not survive measurement: an element-wise 2% corruption measures 0.165%, BELOW the 0.2% bound. Restated as: a 2% corruption cannot be relied on to be resolved; the demonstrated-reliable detection level is 5%. - Sub-0.1% figures were published as constants and are not. Two independently written, individually byte-reproducible harnesses disagree 5.7x on the 16-cuts-vs-1 figure (0.0540% vs 0.0094%) with identical corpus, options and partition, and the disagreement scales with boundary count (~4e-6 at one window, ~6e-4 at one-layer windows). Those rows are now ranges marked sequence-dependent, pointing at #429, which covers how thresholds get derived repo-wide. - The response is non-monotonic below ~2%: a 0.5% corruption moves the figure MORE (0.138%) than a 1% one (0.123-0.131%), because at that scale the effect is dominated by which tokens flip rather than by magnitude. Dose-response reasoning does not apply there, so the docs say so. - D2's fix is correct but my explanation was not. Direct cuMemGetInfo_v2 around the same window built isFinalStage true vs false measures the head at 268 MiB, which matches the vocab x hidden arithmetic. My 140 MiB was a peak-ACROSS-cycle delta — window [0..8) carrying the embedding table lands at the same free figure as [8..16) carrying the head. The tied-embeddings / dedup causal claim was wrong and is dropped: the last window sets skipTokenEmbed, so there is no embedding table there to dedup against. Also: de-duplicated a doubled <remarks> block on ValidateSupported, and moved CudaDevice.GetDevice out of the pre-try region in RunLayerWindowed — enumerating the device is itself a CUDA call, so a missing device reproduced, in a different failure mode, the unhandled stack trace the model construction was moved inside the try to fix. The catch now also covers CudaException / InvalidOperationException. Verified: solution builds clean; 28 unit + 3 CUDA integration tests pass; the hybrid + --cycle refusal still exits 1 with its one-line message; and both the cycled and --first-layer CLI paths still render the real device name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #395
What this adds
dotllm perplexitycould only score a model wholly on one device, so a model larger than asingle device's memory could not be measured at all — which is exactly the situation for
Nemotron-3.5-Lightning-30B-A3B (18.9 GB) and Qwen3.8-27B (~17 GB) against a 12 GB card.
Three placements now, in increasing order of what they buy:
--gpu-layers N[0..N), CPU[N..L)— ordinary prefix offload, same dispatch and semantics asrun/chat/serve--gpu-layers N --first-layer K[0..K), GPU[K..K+N), CPU[K+N..L)— an arbitrary contiguous GPU window--gpu-layers N --cycleNlayers slid across the whole trunk within one corpus passCycling checkpoints the hidden state at each layer cut and replays the next window from those saved
activations rather than from token embeddings, so every layer is GPU-executed exactly once instead of
paying for
ceil(L/N)CPU-bottlenecked passes.Design
ILayerWindowModel/ILayerWindowExecutor(DotLLM.Core.Evaluation) — a backend-neutralhidden-in/hidden-out layer-window contract. A factory shape, not a selector, because the whole
point is that only one window's weights are device-resident at a time.
CyclingPerplexityEvaluator(DotLLM.Engine.Evaluation) — the pass driver and boundarycheckpoints. Two boundaries alive at a time (read + write),
windows x context x hidden x 4 Beach.
PerplexityWindowPlan— the corpus-window enumeration, now shared byPerplexityEvaluatorandthe cycling driver. Boundary activations are indexed by window position, so a second copy of that
loop is the one thing that could silently desynchronise the passes; the replay adapter also
cross-checks each window's token ids and throws rather than scoring window i's activations
against window j's targets.
PerplexityEvaluator, running against the savedboundaries. That is what keeps
--per-window,--tokens-file,--bos,--stride,--unscored-prefix, the error bar and the whole methodology identical to a whole-device run forfree.
CompositeLayerWindowModel— per-window device assignment, so an arbitrary GPU window is athree-entry assignment over one contract rather than a fourth hand-written CPU/GPU splitter.
CpuLayerWindowModel/CudaLayerWindowModel— the backend implementations.Layer placementrow, whole-device runs included, so ascraped table cannot report a split or cycled run as a single-device one.
Why the output head runs on the host
CudaPipelineStage.FinishLogits()returns only the last row; sliding-window scoring needs logitsfor every row. So device windows are always built
isFinalStage: falseand the final norm + LMhead run host-side. Every transformer layer still runs on the GPU; the head is one GEMM against a
weight negligible next to a layer window.
Recurrent state — a deliberate reconciliation with the acceptance criterion
The criterion asks for recurrent state to be "checkpointed alongside the hidden state". The
implementation instead makes recurrent state phase-local by construction, and I want that to be
explicit rather than look like an oversight:
Recurrent state is per layer, and each layer belongs to exactly one layer window which replays the
entire corpus in window order. So the state a layer must start corpus window
wwith is zero — ina cycled run and in a whole-device run alike. Nothing recurrent needs to cross a layer cut; only the
hidden state does. Serialising a recurrent snapshot into the boundary record would be dead weight
that still would not protect against the failure that actually occurs.
The failure that actually occurs is a missing per-corpus-window reset inside a pass — issue #261's
bug, multiplied, because each pass replays the whole corpus.
ILayerWindowExecutor.ResetState()istherefore called before every corpus window in every pass, and the tests discriminate on exactly
that.
What was verified
CPU layer windowing — bit-identical. Split-then-resume vs whole-model
Forward:SyntheticQwen35MoeGguf, 4 layers, GDN at 0 and 2 so GDN state slots sit on oppositesides of the cut), cut at layer 2:
maxAbs = 0.0,maxRel = 0.0.TransformerModel(4-layer Llama-shaped fixture), cut at layer 1:maxAbs = 0.0,maxRel = 0.0.LayerCyclingPerplexityTests(new, 8 facts, all passing). On the recurrent 4-layer fixture:window;
(
CycledScoring_WithoutPerWindowReset_ProducesADifferentNumber). Without this assertion the twoequality facts would pass just as happily against a state-leaking implementation;
a one-window corpus cannot discriminate them. Recorded as a test so a later edit cannot shorten the
corpus and silently disarm the discriminator;
--per-windowdiagnostics fire in cycling mode with the same per-window figures a whole-model runreports;
CUDA layer windows — measured on a real RTX 3060 12 GB (Llama-3.2-1B-Q8_0, L=16):
[0..8)+[8..16)boundary hidden vs a single[0..16)window:maxAbs = 0.125at areference magnitude of 141.6 — exactly one FP16 ULP —
meanAbs = 0.00225. That is theboundary's separate add + RMSNorm replacing an in-window fused add-RMSNorm, i.e. rounding, not
logic.
CudaTransformerModel.Forwardlast row (vocab 128256):maxAbs = 0.150,meanAbs = 0.021, top-1 identical.nvidia-smi memory.used672 MiB idle→ 2481 MiB whole trunk → 672 MiB after dispose → 1685 MiB with
[0..8)→ 672 MiB after dispose.VRAM does not accumulate across windows.
CudaLayerCyclingPerplexityTests(new, 3 facts, all passing on the local RTX 3060) againstLlama-3.2-1B-Q8_0, which fits the card whole. Scoring real English through the model's own
tokenizer (mean NLL ~3.0), not random ids:
mean NLL
2.999327vs3.000402, 0.0358% (bound: 0.3%). Teacher-forced because thewhole-device CUDA model returns only the last logit row and therefore cannot run sliding-window
mode at all, so that is the only like-for-like comparison available.
only the number of boundary round trips, same head, same kernels — mean NLL
3.130479vs3.132169, 0.0540% (bound: 0.2%).figure 0.632%, well outside the bound.
CLI, all three placements run end-to-end on Llama-3.2-1B-Q8_0, 400 tokens, context 128:
--device cpu(reference)--gpu-layers 8 --cycle --per-window--gpu-layers 4 --first-layer 4Both device paths sit within 0.3% of the whole-CPU mean NLL, which is the expected size of FP16
device arithmetic against FP32 CPU.
--per-windowlines printed, the phase progress printed, and theLayer placementrow rendered in every case.Build: solution-wide
dotnet build -c Releaseclean, no new warnings.One real bug the CLI smoke run caught that no library test could: a placement renders as
[0..8), and Spectre reads the leading[as a markup tag, so every windowed/cycled run abortedwith "malformed markup tag" instead of printing its table. Fixed in 9310a14.
Review round (D1-D4)
D1 (blocking) — scaled RoPE now rejected.
CudaPipelineStagehandsLaunchRoPEonlytheta/dim/type, and no CUDA overload takes YaRN's scaling factor, original context length, attn factor
or beta fast/slow — while the CPU reference does apply them. Scaled RoPE changes the frequency table
rather than the layer graph, so a YaRN GGUF cleared every structural rejection and would then have been
scored with the unscaled table.
Mitigating context, so this reads at its true size: the gap is pre-existing and backend-wide
(whole-device
--device cudais equally unscaled) and this does not fix it — it keeps the newguard's promise honest.
llama3scaling maps toRoPEScalingType.None, so the Llama-3.x familyincluding the test fixture is unaffected; SmolLM3-128k is already caught by the
NoRopeLayersrejection.
Verified:
ValidateSupportedbecame internal (a rejection that silently stopped firing would lookexactly like a passing build), and
CudaLayerWindowScopeGuardTestscovers all five scaling typesplus a positive control and an explicit "unscaled RoPE still accepted" case, so the guard cannot
pass by rejecting everything — 9/9 passing.
D3 — hybrid +
--cycleno longer stack-traces. Model construction moved inside thetry.Verified end-to-end: a synthetic Qwen3MoeHybrid GGUF now exits
1with the one-linemixture-of-experts FFN routing is not implemented ...message, no stack trace.D2 — dead LM-head VRAM removed. New
skipOutputHeadonCudaWeights.LoadFromGguf, driven fromisFinalStagerather than the layer range. Two measurements, and they answer different questions:cuMemGetInfo_v2around the same window builtisFinalStage: truevsfalse:268 MiB, matching the
vocab x hiddenarithmetic for the raw quantized copy. This is the figurethat matters for "can the last window fit", and it scales with
vocab x hidden.2465 -> 2325 MiB). Lower because peak is a max over windows:[0..8)carrying the embedding tablelands at about the same figure as
[8..16)carrying the head.My original explanation for the gap — tied embeddings and Q8_0 dedup — was wrong and has been
dropped from the code comments. The last window sets
skipTokenEmbed: firstLayer != 0, so there is noembedding table there to dedup against; and a loaded Q8_0 GEMV kernel means only the raw quantized
copy is uploaded, never an extra FP16 one. Existing
CudaPipelineTransformerModelbehaviour isunchanged (stage 1 is
isFinalStage), confirmed by its split-parity tests.D4 — taken, and it found two things. Random ids scored 12.18 against a uniform floor of
ln(128256) ~ 11.76, so the figure was pinned near chance; switched to real English and tightened thebounds from 1%/0.5% to 0.3%/0.2%. The new fault-injection test then failed twice before it
passed, which is the useful part:
A uniform scale of the boundary is nearly invisible — 0.043%, below plain rounding — because
the next layer begins with RMSNorm, which is scale-invariant. The perturbation is now
element-wise with alternating sign, which rotates the checkpoint's direction instead.
Calibration is documented on the test and in
docs/GPU.md. Corrected in review round 2 after anindependent full-curve measurement:
measures 0.165%, below the 0.2% bound — so a 2% corruption cannot be relied on to be
caught. Measured curve: 0.5% → 0.138%, 1% → 0.123–0.131%, 2% → 0.165%, 5% → 0.585–0.632%.
at that scale the effect is dominated by which tokens flip rather than by magnitude. Dose-response
reasoning does not apply there.
harnesses disagree 5.7x on the 16-cuts-vs-1 figure (0.0540% vs 0.0094%) on identical inputs,
with the disagreement scaling with boundary count. Those rows are now published as ranges marked
sequence-dependent, not scalars. Filed as eval: sub-0.1% perplexity deltas are sequence artifacts — two deterministic harnesses disagree 5.7x on identical inputs #429, since it affects how thresholds are derived
repo-wide.
The bounds themselves (0.3% / 0.2%) are validated and unchanged — only the claims about what they
resolve were corrected. The bit-identical CPU tests carry the logic load.
D5 — acknowledged, not fixed.
RunLayerWindowedholds two hostTransformerWeights(the CPUmodel's and the CUDA window model's), each repacking. Irrelevant at 1B, relevant at 19 GB. Follow-up.
Round-2 nits, all fixed: the acceptance checklist's stale "1% bound"; a doubled
<remarks>blockon
ValidateSupported; the "~0.5 GB on a 1B model" code comments, now the measured 268 MiB; andCudaDevice.GetDevicestill being evaluated above thetryinRunLayerWindowed— enumerating thedevice is itself a CUDA call, so a missing device reproduced the unhandled-stack-trace class of
problem D3 fixed, in a different failure mode. The catch now also covers
CudaExceptionandInvalidOperationException.No automated test covers
--first-layer's mixed CPU+CUDA composition. The unit tests are all-CPU,the integration tests all-CUDA; only the CLI smoke run above exercised the three-way assignment.
Stating it rather than leaving it to be discovered.
What was NOT verified — please read before merging
MoE/hybrid, and
CudaPipelineStage— which the CUDA layer-window executor is built on — isdocumented dense/GQA-causal only.
CudaLayerWindowModeltherefore rejects MoE / MLA / hybrid /SSM / Gemma variants at construction with a named
NotSupportedException, rather than scoring themwrongly. For those models this PR delivers
--gpu-layersprefix offload;--cycle/--first-layeron CUDA covers dense models. Hybrid cycling is verified on CPU. GPU cycling forMoE/hybrid is follow-up work.
--gpu-layersprefix offload is teacher-forced only. I assumed the hybrid model's CPU tailwould return all rows and therefore support sliding-window scoring; the CLI smoke run showed it
does not —
HybridTransformerModelreturns the last row only, so--gpu-layerswithout--cyclefalls to the O(n²) growing-prefix path (235 s for 64 tokens on the 1B model, against 19.8 s for 400
tokens cycled). Correcting my own earlier assumption here rather than leaving it implied. Cycling
does not have this problem, because its host-side output head produces all rows — which turns out
to be a second, unplanned reason to prefer it. Making the hybrid path return all rows is out of
scope for this PR.
table is built once in the constructor keyed by absolute layer index, so a window indexes the
slot a full run would. But because state is zeroed before every sequence and each GDN layer touches
only its own slot within a run, a naive restart-the-count-at-the-window implementation would still
be injective over zeroed state and produce identical output. The parity run corroborates; it does
not prove.
--cycle/--first-layerreject Vulkan and CPU-only devices explicitlyrather than silently degrading.
hidden = 2688is ~3.6 GB per boundary, two alive at a time.arithmetic and a uniform bias can partially cancel.
docs/GPU.mdsays so and points at the savedboundary activations as the sharper instrument.
4 failed — all four
CudaMoeFfnBitNetI2SBatchedGemmTests. A fifth,CudaGraphCaptureEquivalenceTest.EagerVsGraphDecode_BitNet_CrossesGraphDepthThreshold_Match, failedon an earlier run and passed on this one, so it appears intermittent. None references any type this
branch changed — the BitNet GEMM test names none of them at all, and the graph-capture test's
TransformerModelreferences are all toCudaTransformerModel, not the CPUTransformerModelthatwas refactored. I did not re-run them against a clean
devbuild, so I am flagging rather thanasserting that they pre-date this branch.
Behaviour note
--gpu-layersmatchesrun/chat's semantics including the nuance that an explicit count decidesthe backend:
--device cpu --gpu-layers 8offloads 8 layers to CUDA, as it does inrun.Docs
docs/GPU.mdgains a "Measuring a model larger than the device" section: the three placements, whycycling exists, the prefix-offload teacher-forced caveat, the checkpoint arithmetic, the host-head
rationale, the recurrent-state invariant, the discriminator caveat, and the dense-GQA-only scope.
Acceptance criteria
--gpu-layersonperplexity, semantics matchingrun/chat/serve.--first-layer+--gpu-layers) selectable from the CLI.boundary-activation checkpointing.
discriminates the with/without forms — implemented as a phase-local invariant plus a
per-corpus-window reset rather than as a serialised snapshot; see the reconciliation section
above, which explains why a snapshot would be dead weight.
within a documented tolerance (0.0358% of mean NLL measured, 0.3% bound; plus an exact CPU
cycled-vs-whole test to 12 decimal places).
--per-windowand--tokens-filecontinue to work in cycling mode.--per-windowis coveredby a unit test and by the CLI run above.
--tokens-filewas run:--dump-tokensfrom thewhole-CPU run, then fed back with
--gpu-layers 8 --cycle, reproducing perplexity107.1435 +/- 19.74914 — identical to the corpus-file cycled run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX