Skip to content

fix(tq): default --tq-mode to auto — KVarN where supported, Lloyd-Max fallback with quality warning (#432) - #436

Merged
pekkah merged 2 commits into
masterfrom
fix/432-tq-mode-auto
Jul 11, 2026
Merged

fix(tq): default --tq-mode to auto — KVarN where supported, Lloyd-Max fallback with quality warning (#432)#436
pekkah merged 2 commits into
masterfrom
fix/432-tq-mode-auto

Conversation

@pekkah

@pekkah pekkah commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Problem

--tq (Lloyd-Max 3-bit, the default TurboQuant codec) collapses on Qwen3: wikitext-2 PPL 945.6 vs 15.47 fp32 at c=3072, and 19/20 math-eval generations degenerate into token loops (#432).

Root cause

Three probes, all on real Qwen3-0.6B K/V vectors captured from the live forward pass (full analysis in #432 comment):

  1. Not the Hadamard sign-flip ordering. TurboQuantOps applies the sign flip after the WHT, which is mathematically a bare un-randomized WHT — but an A/B on real K/V shows identical reconstruction error for both orderings (relErr ≈ 0.18).
  2. Not a read-path bug. The FastScan tile/LUT machinery tracks the per-block DequantDot/Decompress reference to ~1% of the quantizer's own error.
  3. Intrinsic 3-bit distortion × Qwen3's logit scale. ~7% relative K-score error meets QK-norm attention logits with RMS ≈ 1000 → absolute logit errors of tens of units → softmax destroyed. 4-bit Lloyd-Max measures PPL 51.8 — better, still unusable. KVarN measures 15.67.

Fix

Make the validated KVarN codec the default wherever it is supported, per the issue's own recommendation:

  • CLI (run + perplexity): --tq-mode defaults to auto → KVarN on CPU (any pow-2 head dim in [8, 1024]) and full-CUDA-offload dense (head dim ≤ 256); falls back to Lloyd-Max with a loud quality warning citing TurboQuant Lloyd-Max 3-bit collapses on Qwen3 (PPL 61x, degenerate decode) — pre-existing on master #432 on Vulkan, partial CUDA offload, MoE-on-GPU, SnapKV, or window < 128. Explicit --tq-mode lloydmax keeps the old behavior and silences the warning; explicit kvarn still errors on unsupported combos.
  • Server: new TqMode option (default auto) with the same per-path resolution in InferenceEngineLoader (explicit kvarn on an unsupported path fails model load); SHARPI_TQ / SHARPI_TQ_MODE env vars in the Host.
  • Docs: flag descriptions, CLI README, and quality warnings on TqQuantizer.LloydMax / ForwardPass.EnableTurboQuant XML docs.

Validation

  • Default --tq on the issue's exact gate now runs tq-kvarn-k4v2 at PPL 15.6741 (was 945.56); explicit --tq-mode lloydmax reproduces the old numbers.
  • SHARPI_SNAPKV_BUDGET + --tq prints the Lloyd-Max fallback warning and runs.
  • run --tq -g -1 on CUDA resolves to KVarN K4V2.
  • Solution builds clean (warnings-as-errors); Tests.Cli 93/93, Tests.Server 154/154 (new tests cover the auto-resolution matrix).

Follow-up

The GPU Lloyd-Max TqAttention V-basis defect found during this audit (compressed-region V aggregate never un-rotated on CUDA/Vulkan) is tracked separately as #435.

Closes #432

🤖 Generated with Claude Code

https://claude.ai/code/session_01EuBdnPNJ2XdaFp9PjYoJyz

… fallback with quality warning (#432)

Lloyd-Max 3-bit collapses on QK-norm models: Qwen3-0.6B wikitext-2 PPL
945.6 vs 15.47 fp32, degenerate decode. Root cause is intrinsic codec
distortion (~7% relative K-score error) meeting Qwen3's large attention
logits — not a read-path bug (FastScan tracks the per-block reference to
~1%, and the sign-flip-after-WHT ordering is a distributional no-op on
real K/V). 4-bit Lloyd-Max measures PPL 51.8 — better, still unusable.
KVarN measures 15.67, so it becomes the default wherever supported.

- CLI (run + perplexity): --tq-mode defaults to auto → KVarN on CPU
  (any pow-2 head dim) and full-CUDA-offload dense (head dim ≤ 256);
  falls back to Lloyd-Max with a loud #432 quality warning on Vulkan,
  partial offload, MoE-on-GPU, SnapKV, or window < 128. Explicit
  lloydmax keeps the old behavior silently; explicit kvarn still errors
  on unsupported combos.
- Server: new TqMode option (default auto) resolved per-path in
  InferenceEngineLoader; SHARPI_TQ / SHARPI_TQ_MODE env vars in Host.
- Docs: flag descriptions, CLI README, quality warnings on
  TqQuantizer.LloydMax / ForwardPass.EnableTurboQuant XML docs.

Validated on the issue's gate: default --tq now runs tq-kvarn-k4v2 at
PPL 15.6741 (c=3072, window=256); SnapKV fallback prints the warning;
CUDA -g -1 resolves to KVarN K4V2. The GPU Lloyd-Max V-basis defect
found during the audit is tracked separately as #435.

Closes #432

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuBdnPNJ2XdaFp9PjYoJyz

@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 introduces an "auto" mode for the TurboQuant KV cache compression quantizer, which automatically selects the higher-quality KVarN quantizer where supported and falls back to Lloyd-Max with a warning on unsupported configurations. It also adds environment variable support for these options in the server host and updates the CLI commands, documentation, and tests. The review feedback highlights two important issues in the server's InferenceEngineLoader: first, an early head-dimension check unconditionally blocks KVarN for head dimensions other than 128 or 256; second, the CUDA path lacks a check to fall back to Lloyd-Max when the head dimension exceeds 256.

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 85 to +105
if (turboQuant && hp.HeadDim is not 128 and not 256)
throw new InvalidOperationException(
$"TurboQuant requires head dimension 128 or 256; this model has head dim {hp.HeadDim}.");
bool tqModeIsAuto = false;
TqQuantizer tqQuantizer;
switch ((opts.TqMode ?? "auto").Trim().ToLowerInvariant())
{
case "" or "auto":
tqModeIsAuto = true;
tqQuantizer = TqQuantizer.LloydMax; // resolved per-path in BuildForwardPass
break;
case "lloydmax" or "lloyd-max":
tqQuantizer = TqQuantizer.LloydMax;
break;
case "kvarn":
tqQuantizer = TqQuantizer.KVarN;
break;
default:
throw new InvalidOperationException(
$"Unknown TqMode '{opts.TqMode}'. Expected one of: auto, lloydmax, kvarn.");
}

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

The current head dimension check is executed before tqQuantizer and tqModeIsAuto are resolved, and it unconditionally rejects any head dimension other than 128 or 256 when turboQuant is enabled. This prevents loading models with other power-of-2 head dimensions (such as 64, 512, or 1024) when using KVarN on the server, even though KVarN supports them. Moving the check after the switch statement and validating based on the resolved quantizer fixes this issue.

        bool tqModeIsAuto = false;
        TqQuantizer tqQuantizer;
        switch ((opts.TqMode ?? "auto").Trim().ToLowerInvariant())
        {
            case "" or "auto":
                tqModeIsAuto = true;
                tqQuantizer = TqQuantizer.LloydMax; // resolved per-path in BuildForwardPass
                break;
            case "lloydmax" or "lloyd-max":
                tqQuantizer = TqQuantizer.LloydMax;
                break;
            case "kvarn":
                tqQuantizer = TqQuantizer.KVarN;
                break;
            default:
                throw new InvalidOperationException(
                    $"Unknown TqMode '{opts.TqMode}'. Expected one of: auto, lloydmax, kvarn.");
        }

        if (turboQuant)
        {
            bool isKVarN = tqModeIsAuto || tqQuantizer == TqQuantizer.KVarN;
            if (isKVarN)
            {
                bool headDimOk = hp.HeadDim >= 8 && hp.HeadDim <= 1024 && (hp.HeadDim & (hp.HeadDim - 1)) == 0;
                if (!headDimOk)
                    throw new InvalidOperationException(
                        $"TurboQuant KVarN requires a power-of-2 head dimension in [8, 1024]; this model has head dim {hp.HeadDim}.");
            }
            else if (hp.HeadDim is not 128 and not 256)
            {
                throw new InvalidOperationException(
                    $"TurboQuant Lloyd-Max requires head dimension 128 or 256; this model has head dim {hp.HeadDim}.");
            }
        }

// per-sequence-eviction decode). An explicit SHARPI_SNAPKV_BUDGET>0 still wins and
// composes with batching via #196 Option 1.
var cfwd = new CudaForwardPass(model, cuda, hp, ctxSize, enableTurboQuant: turboQuant,
tqQuantizer: ResolveTq(hp.IsMoE ? "KVarN on CUDA supports dense models only" : null),

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

When resolving the quantizer for CudaForwardPass, we should check if the head dimension exceeds 256 on CUDA. If it does, we should block KVarN and fall back to Lloyd-Max (with a warning in auto mode) instead of potentially failing to load the model later.

                    tqQuantizer: ResolveTq(hp.IsMoE ? "KVarN on CUDA supports dense models only" : hp.HeadDim > 256 ? $"KVarN on CUDA requires head dim ≤ 256; this model has {hp.HeadDim}" : null),

…/cleanup from review

Code review of the #432 fix found a new crash path: a dense model with a
pow-2 head dim outside {128,256} (e.g. 64) run with --tq -g -1 on a GPU too
small for full offload resolves auto→KVarN (passes the pow-2 gate), skips the
Lloyd-Max 128/256 gate, then the partial-offload branch downgraded to
Lloyd-Max without re-checking — constructing the forward pass with a head dim
Lloyd-Max has no codebook for, throwing an uncaught NotSupportedException
instead of the clean CLI error explicit --tq-mode lloydmax produces.

- RunCommand: before the partial-offload KVarN→Lloyd-Max downgrade, reject
  head dims outside {128,256} with an actionable error pointing at -g 0 (CPU
  KVarN supports them).
- RunCommand: drop the dead KVarN branch in the Vulkan CPU-fallback message
  (KVarN can never reach the Vulkan backend).
- CLAUDE.md: TurboQuant now describes both codecs and the KVarN default.

Tests.Cli 93/93; full solution builds clean.
@pekkah

pekkah commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

Code review (high effort, 8 finder angles + verify)

Ran an 8-angle multi-agent review (line-by-line, removed-behavior, cross-file, reuse, simplification, efficiency, altitude, conventions). One real regression this PR introduced, fixed in 80c1445; the rest are pre-existing or tech-debt, filed as follow-ups.

Fixed (80c1445)

  • CONFIRMED regression — partial-offload crash. A dense model with a pow-2 head dim outside {128,256} (e.g. 64) run with --tq -g -1 on a GPU too small for full offload resolved auto→KVarN (passes the pow-2 gate), skipped the Lloyd-Max 128/256 gate, then the wantHybrid branch downgraded to Lloyd-Max without re-checkingnew CudaHybridForwardPass(turboQuant:true) throws an uncaught NotSupportedException (CudaHybridForwardPass.cs:386) instead of the clean CLI error explicit --tq-mode lloydmax gives. Pre-PR this model errored cleanly at the 128/256 gate. Now guarded: the downgrade rejects non-{128,256} head dims with an actionable "use -g 0 for CPU KVarN" message.
  • Cleanup: dropped a dead KVarN K4V2 message branch in the Vulkan CPU-fallback (KVarN can never reach the Vulkan backend — explicit is rejected, auto falls back to Lloyd-Max). Updated the stale TurboQuant description in CLAUDE.md.

Follow-ups (not blocking — pre-existing or tech-debt)

  • Server head-dim gate (InferenceEngineLoader.cs:85) rejects every head dim ∉{128,256} whenever TQ is on, before quantizer resolution — so KVarN's broader head-dim support (32/64/512/1024) is unreachable on the server, and explicit TqMode=kvarn on such a model gets a misleading "128 or 256" error. Not a regression (head-dim-64+TQ never worked on the server's Lloyd-Max-only path), but the CLI now runs it. Worth extending the server to match.
  • Triplicated support matrix. The KVarN-support rules (SnapKV / head dim / backend / offload / MoE) and the TurboQuant Lloyd-Max 3-bit collapses on Qwen3 (PPL 61x, degenerate decode) — pre-existing on master #432 warning text are hand-copied across RunCommand, PerplexityCommand.ResolveAutoQuantizer, and InferenceEngineLoader.ResolveTq with duplicated reason strings. The crash above was a direct symptom of one copy drifting. A single Engine-level TqSupport.ResolveAuto(...) next to TqQuantizer (only the perplexity copy is unit-tested today) would prevent recurrence.
  • SnapKV + Lloyd-Max on CUDA full-offload prints a "falling back to Lloyd-Max (SnapKV…)" warning before an unavoidable throw — the fallback reason is only valid on CPU (where Lloyd-Max+SnapKV works); on CUDA neither codec composes with SnapKV. Cosmetic; the actionable throw survives.
  • Server silently ignores TqMode=kvarn when TurboQuant=false (CLI rejects the same combo). Minor.

I'll open a tracking issue for the matrix centralization + server head-dim extension.

@pekkah
pekkah merged commit 7f0ff46 into master Jul 11, 2026
1 check passed
@pekkah
pekkah deleted the fix/432-tq-mode-auto branch July 11, 2026 16:37
pekkah added a commit that referenced this pull request Jul 12, 2026
…arN defaults (#439)

- Document the Grammar/ subsystem in Core (ITokenConstraint,
  JsonSchemaOutputConstraint, per-family tool-arg constraints, #423/#425)
- Add the perplexity CLI command and -j/--json-schema structured-output flags
- Note --tq-mode auto default (KVarN where supported, Lloyd-Max fallback, #436)
  and KVarN CPU/CUDA decode paths (#180)
- Add SnapKvEval benchmark project; grammar coverage in Tests.Core


Claude-Session: https://claude.ai/code/session_01QNixcRevA5NPyFsKUeCySr

Co-authored-by: Claude <noreply@anthropic.com>
pekkah added a commit that referenced this pull request Jul 12, 2026
…N support matrix (#437) (#438)

* fix(tq): un-rotate the compressed-region V aggregate on GPU Lloyd-Max attention (#435)

Both GPU Lloyd-Max TurboQuant attention kernels (CUDA `llm_tq_attention` and the
Vulkan `TqAttention` shader) aggregated the compressed-region V contribution in the
rotated/sign-flipped basis and never applied the inverse transform, mixing it with
the unrotated FP32-window contribution. This scrambled attention output for ANY
model once context exceeded the 256-token FP32 window, on CUDA and Vulkan alike.

Rewrite Phase 3 to mirror the CPU `TurboQuantKvCache.ComputeVAggregation` and the
KVarN kernel: accumulate the compressed V aggregate in the rotated domain, apply the
deferred per-head sign-flip + inverse WHT once (reusing shared scratch), then add the
FP32-window contribution in the original domain. Thread the per-head sign patterns
into both backends' `TqAttention` host methods (CUDA arg array 16->17; Vulkan binding
9, count 9->10) and all four engine call sites.

Rewrite `TqAttention_NeedleInHaystack` to compare against the ORIGINAL basis via a new
`ReconstructOriginal` helper; the test previously enshrined the wrong basis.

Validated on CUDA (RTX 4070): needle test passes in the fully-compressed region;
end-to-end Qwen3-0.6B Lloyd-Max stays coherent past the window and matches the CPU
path. Vulkan is a faithful port of the same pattern but its shader source changed —
run `scripts/gen-spirv.ps1` on a Vulkan-SDK machine to regenerate
`Shaders.Precompiled.g.cs` before the Vulkan drift tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tq): centralize the KVarN support matrix into TqSupport + extend to the server (#437)

The KVarN-vs-Lloyd-Max TurboQuant support matrix — which combinations of (SnapKV,
head dim, backend, full-vs-partial offload, MoE, FP32 window) support KVarN — was
hand-copied across three frontends (RunCommand, PerplexityCommand.ResolveAutoQuantizer,
InferenceEngineLoader.ResolveTq), with the #432 quality warning copied 4x. A missed
edit silently resolved auto -> Lloyd-Max on one surface only, reintroducing the #432
collapse (the exact shape of the partial-offload crash fixed in #436).

Add `SharpInference.Engine.TqSupport` as the single source of truth: `KVarNBlockedReason`
(the auto matrix), head-dim envelope predicates (IsKVarNHeadDim pow2[8,1024],
IsKVarNCudaHeadDim pow2[8,256], IsLloydMaxHeadDim {128,256}), reason-string constants,
and `QualityWarningReason`. All three frontends now delegate.

Extend to the server:
- Relax the InferenceEngineLoader up-front head-dim gate from a hard {128,256} reject
  to the codec-aware envelope, so KVarN's broader head dims (32/64/512/1024) are
  reachable on the server CPU path, as they already are on the CLI.
- ResolveTq now throws a clean error instead of silently downgrading auto -> Lloyd-Max
  on a head dim Lloyd-Max has no codebook for (would have crashed in the fwd-pass ctor).
- Reject explicit TqMode=kvarn when TurboQuant=false (the CLI already did).

Unit-test the matrix once in TqSupportTests (28 cases incl. the Vulkan / no-CUDA GPU
sub-cases the Perplexity wrapper can't express). Existing Perplexity + server tests
unchanged and green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tq): use exact 1/sqrt(D) for the Vulkan un-rotate WHT normalization (review)

Code-review nit on #435: the Vulkan TqAttention un-rotate used inversesqrt() for the
inverse-WHT normalization while the CPU WalshHadamard.Transform (which this inverts)
and the CUDA kernel use exact 1/sqrt(D). inversesqrt is a lower-precision approximation
on some GPUs and no Vulkan-vs-CPU parity test would catch the drift. Match the exact
convention (same as the TqRotateQuery shader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build(vulkan): regenerate precompiled SPIR-V for the #435 TqAttention un-rotate

The TqAttention shader source changed in this PR (deferred per-head sign flip
+ inverse WHT of the compressed-region V aggregate), leaving its precompiled
SPIR-V blob and FNV hash key stale. Regenerated via scripts/gen-spirv.ps1
(Vulkan SDK 1.4.341.1); only the TqAttention entry changed (9668 -> 10972 bytes).

VulkanPrecompiledShaderTests (drift guard) and the on-device
VulkanShaderTests.TqAttention paths now pass, alongside the CUDA TQ/KVarN suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNwRmR1tuioHTiiabU34Rp

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TurboQuant Lloyd-Max 3-bit collapses on Qwen3 (PPL 61x, degenerate decode) — pre-existing on master

1 participant