Skip to content

perf(cuda): dtype-aware auto-context for Gemma 4 (#220) - #229

Merged
pekkah merged 3 commits into
masterfrom
perf/gemma4-dtype-aware-autoctx-220
Jun 13, 2026
Merged

perf(cuda): dtype-aware auto-context for Gemma 4 (#220)#229
pekkah merged 3 commits into
masterfrom
perf/gemma4-dtype-aware-autoctx-220

Conversation

@pekkah

@pekkah pekkah commented Jun 13, 2026

Copy link
Copy Markdown
Owner

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-type bought 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 hardcoded sizeof(float) (and used NumKvHeads, no pow2 round-up) — so it was dtype-blind and could drift from the real allocator. (TierPlanner.RecommendedCtxSize is computed but discarded for the full-GPU case; the live source is EstimateMaxContext.)

Fix

Replace EstimateMaxContext's hand-rolled SWA 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); 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-type before after
fp32 1770 2048 (allocator-exact)
bf16 1770 4096 (2×)
q8_0 1770 30840 (17×)

q8_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):

  • dtype response in the linear regime — bf16 = 2× fp32, q8_0 ~3.76× (q8_0's 34/32-byte blocks fall short of a clean 4×);
  • allocator-maximal on the SWA shape (fits, never under-reserves, maximal);
  • uniform models ignore dtype (the "no change for uniform models" guard).

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), because EstimateAvailableKvVram reserves a blunt max(VRAM/3, 2GB). That's tracked in #228 (gated on a spill-cliff A/B) — deliberately out of scope here.

🤖 Generated with Claude Code

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +4101 to 4129
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
    }

pekkah and others added 2 commits June 13, 2026 14:52
…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>
@pekkah

pekkah commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Review cycle complete

Ran 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 (int) cast is only on the unchanged uniform path).

Addressed findings:

  • gemini (HIGH): Math.Clamp(_, 512, cap) in the uniform branch threw ArgumentException for ContextLength < 512. Fixed with a top-level if (cap <= floorCtx) return cap; guard (95b5d27) — both branches now safe + consistent; added a uniform-shape cap<512 test.
  • pr-test-analyzer (2× must-fix): the SWA-saturation super-linear claim and the floor/cap clamp had no coverage. Added SolveMaxCtxForKv_SwaSaturation_DtypeGainExceedsWidthRatio, _ClampsToFloorAndModelMax, _MixedPerLayerHeadDim_IsAllocatorMaximal (1f73171).

Final: 6/6 SolveMaxCtxForKv unit tests green, existing KV-dtype/coherence/ring tests green, Release build clean under TreatWarningsAsErrors + AOT analyzers. Merging.

@pekkah
pekkah merged commit 01332c2 into master Jun 13, 2026
1 check passed
@pekkah
pekkah deleted the perf/gemma4-dtype-aware-autoctx-220 branch June 13, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant