perf(cuda): dtype-aware auto-context for Gemma 4 (#220) - #229
Conversation
The dense full-GPU Gemma path's auto-context (-g -1, no -c) came from CudaForwardPass.EstimateMaxContext, which priced KV at fp32 unconditionally — so --kv-type bf16/q8_0 bought NO extra context. On a 4070 Ti 12 GB, Gemma 4 12B was pinned to 1770 tokens (model max 262144) for fp32 AND bf16 AND q8_0. EstimateMaxContext already had a Gemma SWA/KV-share/per-layer-headdim branch but hardcoded `sizeof(float)` (and used NumKvHeads, no pow2 round-up), so it was dtype-blind and could drift from the real allocator. Replace its hand-rolled math with a binary search against EstimateKvCacheBytes(hp, ctx, kvDType) — the single source of truth the ctor's allocation and TierPlanner.SolveGpuCtxForPerLayerKv both use (dtype + SWA ring + KV-share skip + per-layer KV heads + pow2 round-up). The core is factored into the pure, GPU/GGUF-free CudaForwardPass.SolveMaxCtxForKv (mirrors the ResolveKvDType factoring) and the call site passes the requested KV dtype (ResolveConfiguredKvDType). Uniform-attention models keep the flat fp32 formula unchanged. Result (4070 Ti 12 GB, gemma-4-12b-it-qat-q4_0, -g -1): fp32 1770 -> 2048 (allocator-exact), bf16 -> 4096 (2x), q8_0 -> 30840 (17x). q8_0 exceeds 4x because SWA layers stop growing past their ring cap, so beyond it only the global layers consume KV per token. Verified the q8_0 30840-ctx run constructs + decodes coherently (53 t/s, no OOM); Qwen3-8B dense auto-ctx unchanged (12080). Tests: 3 GGUF-free unit tests for SolveMaxCtxForKv (dtype response in the linear regime: bf16 = 2x fp32, q8_0 ~3.76x; allocator-maximal on the SWA shape; uniform models ignore dtype). Existing KV-dtype parity + Gemma coherence + SWA ring-wrap tests green (they pass explicit ctx, unaffected). Release build clean under TreatWarningsAsErrors + AOT analyzers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request updates the auto-context length estimation in CudaForwardPass to be aware of the requested KV data type (kvDType) for Gemma 4-style models, allowing narrower data types like bf16 and q8_0 to correctly expand the context window. Uniform-attention models remain unchanged, and comprehensive unit tests have been added to verify these behaviors. The reviewer identified a potential ArgumentException in SolveMaxCtxForKv when hp.ContextLength is less than floorCtx (512) due to invalid arguments in Math.Clamp, and provided a code suggestion to handle this case early.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| internal static int SolveMaxCtxForKv(ModelHyperparams hp, long availableKvBytes, DType kvDType) | ||
| { | ||
| long available = EstimateAvailableKvVram(model, gpu, hp); | ||
| int headDim = hp.HeadDim; | ||
| const int floorCtx = 512; | ||
| int cap = hp.ContextLength; | ||
|
|
||
| // Gemma 4 per-layer head-dim path: each layer's K/V buffer takes its own | ||
| // head_dim, and SWA layers cap at SlidingWindowSize regardless of the | ||
| // global context window. Solve for the largest maxCtx s.t. the summed | ||
| // per-layer bytes still fit in `available`. Without this branch the | ||
| // non-gemma4 formula (NumLayers × headDim × maxCtx) wildly under- or | ||
| // over-counts depending on which side of the head-dim mix dominates. | ||
| if (hp.LayerHeadDim is { } lhd && hp.IsSwaLayer is { } swa) | ||
| if (hp.LayerHeadDim is not null && hp.IsSwaLayer is not null) | ||
| { | ||
| int swaWindow = hp.SlidingWindowSize > 0 ? hp.SlidingWindowSize : int.MaxValue; | ||
| // SWA layers are sized as a ring of window + SwaRingHeadroom positions (issue | ||
| // #162), so the cap for the per-token byte formula is the ring size, not the | ||
| // bare window. Guard against overflow when swaWindow is "unbounded". | ||
| long swaCap = swaWindow == int.MaxValue ? long.MaxValue : (long)swaWindow + SwaRingHeadroom; | ||
| long globalKvDimPerToken = 0; | ||
| long swaKvDimPerToken = 0; | ||
| for (int i = 0; i < hp.NumLayers; i++) | ||
| // EstimateKvCacheBytes is monotonic non-decreasing in ctx, so an upper-bound binary | ||
| // search converges. Floor 512 mirrors the uniform clamp below. | ||
| if (cap <= floorCtx || EstimateKvCacheBytes(hp, floorCtx, kvDType) > availableKvBytes) | ||
| return Math.Min(floorCtx, cap); | ||
| int lo = floorCtx, hi = cap; | ||
| while (lo < hi) | ||
| { | ||
| // KV-share layers don't allocate their own pages (the source layer | ||
| // already counted). Skip from both buckets. | ||
| if (hp.KvSourceLayer is { } ksl && ksl[i] >= 0) continue; | ||
| long layerKvDim = 2L * hp.NumKvHeads * lhd[i] * sizeof(float); | ||
| if (swa[i]) swaKvDimPerToken += layerKvDim; | ||
| else globalKvDimPerToken += layerKvDim; | ||
| } | ||
| // For a given maxCtx C: bytes = globalKvDimPerToken * C | ||
| // + swaKvDimPerToken * min(C, swaCap) | ||
| // Solve for the largest C ≤ hp.ContextLength that fits in `available`. | ||
| // Branch on whether C ≤ swaCap: | ||
| // if C ≤ swaCap: bytes = (global+swa) * C | ||
| // else: bytes = global * C + swa * swaCap | ||
| long globalPlusSwa = globalKvDimPerToken + swaKvDimPerToken; | ||
| int candA = globalPlusSwa > 0 ? (int)(available / globalPlusSwa) : int.MaxValue; | ||
| int maxCtxL; | ||
| if (candA <= swaCap) | ||
| { | ||
| maxCtxL = candA; | ||
| } | ||
| else | ||
| { | ||
| // swaCap is finite here (candA ≤ long.MaxValue always takes the branch | ||
| // above when swaCap is unbounded), so swaKvDimPerToken * swaCap is safe. | ||
| long remain = available - swaKvDimPerToken * swaCap; | ||
| int candB = globalKvDimPerToken > 0 && remain > 0 | ||
| ? (int)(remain / globalKvDimPerToken) : 0; | ||
| maxCtxL = (int)Math.Max(swaCap, candB); | ||
| int mid = lo + (hi - lo + 1) / 2; | ||
| if (EstimateKvCacheBytes(hp, mid, kvDType) <= availableKvBytes) | ||
| lo = mid; | ||
| else | ||
| hi = mid - 1; | ||
| } | ||
| return Math.Clamp(maxCtxL, 512, hp.ContextLength); | ||
| return lo; | ||
| } | ||
|
|
||
| long bytesPerToken = 2L * hp.NumLayers * hp.NumKvHeads * headDim * sizeof(float); | ||
| int maxCtx = (int)(available / bytesPerToken); | ||
| return Math.Clamp(maxCtx, 512, hp.ContextLength); | ||
| // Uniform-attention models: unchanged flat fp32 formula (#220 is scoped to the | ||
| // SWA/per-layer Gemma path; dtype-aware sizing for uniform models is out of scope). | ||
| long bytesPerToken = 2L * hp.NumLayers * hp.NumKvHeads * hp.HeadDim * sizeof(float); | ||
| int maxCtx = (int)(availableKvBytes / bytesPerToken); | ||
| return Math.Clamp(maxCtx, floorCtx, cap); | ||
| } |
There was a problem hiding this comment.
If hp.ContextLength (cap) is less than floorCtx (512), Math.Clamp(maxCtx, floorCtx, cap) will throw an ArgumentException because the minimum value (512) is greater than the maximum value (cap).
To prevent this, we can check if cap <= floorCtx at the beginning of the method and return cap immediately. This also simplifies the SWA branch and avoids potential division-by-zero issues if bytesPerToken is zero.
internal static int SolveMaxCtxForKv(ModelHyperparams hp, long availableKvBytes, DType kvDType)
{
const int floorCtx = 512;
int cap = hp.ContextLength;
if (cap <= floorCtx)
return cap;
if (hp.LayerHeadDim is not null && hp.IsSwaLayer is not null)
{
// EstimateKvCacheBytes is monotonic non-decreasing in ctx, so an upper-bound binary
// search converges. Floor 512 mirrors the uniform clamp below.
if (EstimateKvCacheBytes(hp, floorCtx, kvDType) > availableKvBytes)
return floorCtx;
int lo = floorCtx, hi = cap;
while (lo < hi)
{
int mid = lo + (hi - lo + 1) / 2;
if (EstimateKvCacheBytes(hp, mid, kvDType) <= availableKvBytes)
lo = mid;
else
hi = mid - 1;
}
return lo;
}
// Uniform-attention models: unchanged flat fp32 formula (#220 is scoped to the
// SWA/per-layer Gemma path; dtype-aware sizing for uniform models is out of scope).
long bytesPerToken = 2L * hp.NumLayers * hp.NumKvHeads * hp.HeadDim * sizeof(float);
if (bytesPerToken == 0)
return floorCtx;
int maxCtx = (int)(availableKvBytes / bytesPerToken);
return Math.Clamp(maxCtx, floorCtx, cap);
}…cap, mixed head_dim (#220 review) Review-cycle follow-up addressing the pr-test-analyzer's two must-fix gaps: - SolveMaxCtxForKv_SwaSaturation_DtypeGainExceedsWidthRatio: the headline #220 claim (past the SWA ring cap, narrowing the KV dtype gains MORE than the bare width ratio because only the global layers grow) had zero coverage — the linear-regime test uses isSwa all-false. 1 global + 5 SWA layers, budget sized past the ring, asserts bf16 > 2× fp32 and q8 > 4× fp32 (relational, not magic numbers) + q8 allocator-maximal. - SolveMaxCtxForKv_ClampsToFloorAndModelMax: the floor early-return / cap clamp on the per-layer branch (the sibling SolveGpuCtxForPerLayerKv floor test doesn't transitively cover it — different signature). budget=1 → 512; ContextLength 256 → 256; huge budget → model max. - SolveMaxCtxForKv_MixedPerLayerHeadDim_IsAllocatorMaximal: distinct per-layer head_dim, allocator-maximal — guards the per-layer pricing not collapsing to hp.HeadDim. All 6 SolveMaxCtxForKv tests green. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gemini-code-assist (HIGH) flagged that the uniform branch's Math.Clamp(maxCtx, floorCtx, cap) throws ArgumentException when hp.ContextLength < 512 (min > max). Pre-existing on master (the refactor preserved it verbatim), but the SWA branch already handled cap <= floor gracefully — an ugly asymmetry. Hoist a single `if (cap <= floorCtx) return cap;` guard to the top so both branches are safe and consistent, and simplify the SWA early-return (cap > floorCtx is now guaranteed). Added a uniform-shape cap<512 case to SolveMaxCtxForKv_ClampsToFloorAndModelMax (would have thrown before). 6/6 SolveMaxCtxForKv tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review cycle completeRan three independent review lenses (correctness, silent-failure/OOM, test-coverage) plus the automated gemini-code-assist review. Correctness + silent-failure: no high-confidence issues. Confirmed the binary search is allocator-exact and strictly safer than master on OOM — master's old Gemma formula summed raw bytes with no pow2 round-up and could hand back a context that cudaMalloc-fails; the new search rounds each buffer exactly like the pool. Requested-vs-actual KV dtype can't diverge to under-reserve (auto-narrow only narrows, never widens). No integer overflow (long arithmetic; the Addressed findings:
Final: 6/6 |
Summary
Fixes #220. On a 12 GB card, Gemma 4 12B's auto-context (
-g -1, no-c) was pinned at 1770 tokens for fp32 AND bf16 AND q8_0 —--kv-typebought no extra context, despite the model supporting 262144.The dense full-GPU path's auto-context comes from
CudaForwardPass.EstimateMaxContext, which already had a Gemma SWA / KV-share / per-layer-headdim branch but hardcodedsizeof(float)(and usedNumKvHeads, no pow2 round-up) — so it was dtype-blind and could drift from the real allocator. (TierPlanner.RecommendedCtxSizeis computed but discarded for the full-GPU case; the live source isEstimateMaxContext.)Fix
Replace
EstimateMaxContext's hand-rolled SWA math with a binary search againstEstimateKvCacheBytes(hp, ctx, kvDType)— the single source of truth the ctor's allocation andTierPlanner.SolveGpuCtxForPerLayerKvboth use (dtype + SWA ring + KV-share skip + per-layer KV heads + pow2 round-up). The core is factored into the pure, GPU/GGUF-freeCudaForwardPass.SolveMaxCtxForKv(mirrors theResolveKvDTypefactoring); the call site passes the requested KV dtype (ResolveConfiguredKvDType()). Uniform-attention models keep the flat fp32 formula unchanged.Result (4070 Ti 12 GB,
gemma-4-12b-it-qat-q4_0,-g -1)--kv-typeq8_0 exceeds 4× because SWA layers stop growing past their ring cap, so beyond it only the global layers consume KV per token. Verified the q8_0 30840-ctx run constructs + decodes coherently (53 t/s, no OOM); Qwen3-8B dense auto-ctx unchanged (12080).
Tests
3 new GGUF-free unit tests for
SolveMaxCtxForKv(CudaForwardPassKvDtypeTests):Existing KV-dtype parity + Gemma coherence + SWA ring-wrap tests stay green (they pass explicit ctx, so are unaffected). Release build clean under TreatWarningsAsErrors + AOT analyzers.
Follow-up (separate)
The auto value (30840) is still ~8× below what actually fits (full 256K runs with
-c), becauseEstimateAvailableKvVramreserves a bluntmax(VRAM/3, 2GB). That's tracked in #228 (gated on a spill-cliff A/B) — deliberately out of scope here.🤖 Generated with Claude Code