Skip to content

KVarN variance-normalized KV-cache quantization: CPU + CUDA (#180) - #434

Merged
pekkah merged 7 commits into
masterfrom
feat/180-kvarn
Jul 11, 2026
Merged

KVarN variance-normalized KV-cache quantization: CPU + CUDA (#180)#434
pekkah merged 7 commits into
masterfrom
feat/180-kvarn

Conversation

@pekkah

@pekkah pekkah commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Implements KVarN (arXiv:2606.03458) end-to-end per the phased plan in docs/kvarn-feasibility-research.md and #180: calibration-free 4-bit-key / 2-bit-value KV-cache quantization as a selectable quantizer inside the existing TurboQuant machinery, on the CPU (scalar + AVX2) and CUDA paths. Clean-room from the paper — no code from the reference vLLM fork.

Commits (each implemented + adversarially reviewed before landing)

  • 82efc4f Core quantizer — Hadamard rotation (reuses WalshHadamard, K and V), log-space dual-axis Sinkhorn variance normalization, asymmetric RTN K4/V2 in 128-token tiles (channel-major K / token-major V packing), 2-bit BitPacking.
  • 6040076 Cache + attention integrationTqQuantizer enum in TurboQuantKvCache (KVarN mode: the FP32 window is the staging; whole-tile promotion), TqAttention dispatch, CLI --tq-mode <lloydmax|kvarn>.
  • f95fb3e Accuracy gate — new perplexity CLI command (teacher-forced NLL, position buckets) + scripts/kvarn-gate/ (wikitext fetcher, 20-problem math eval). P0 gate: strong pass.
  • 44a5e1a P1 AVX2 — fused KeyScores/AggregateValues (9.7×/8.4× per-kernel), scalar fallback + ForceScalar parity hook.
  • 0d7cd28 P2 CUDA correctnessllm_kvarn_rotate_query / llm_kvarn_compress_tile / llm_kvarn_attention; packed codes byte-identical to the CPU compressor; EstimateMaxContextKvarn; --tq-mode kvarn -g -1; perplexity -g -1.
  • e43f3f0 P2 CUDA decode perf — CUDA-graph re-entry (two topologies, tracked params, promotion hoisted), warp-per-channel / flat-index attention walks.
  • 99c3773 Chunked batched prefillllm_kvarn_prefill_attention (single streaming softmax over compressed tiles + FP32 window, deferred inverse WHT), schedule-independent tiles (re-chunking is byte-exact).

Measured (RTX 4070 Laptop 8 GB · Qwen3-0.6B-Q8_0 / Qwen3-8B-Q4_K_M · wikitext-2)

Claim Result
Accuracy at FP16 parity PPL +1.26% (0.6B c=3072) / +0.98% (8B c=4096) vs fp32; math eval 18/20 = fp32; depth penalty flattens at ~+2.2% (no drift accumulation)
~4× KV capacity 8B auto-context 2376 → 18304 (7.7×) in 8 GB
Throughput ≥ FP16 CPU decode 1.57–2.20× faster than fp32; CUDA 8B decode ≥ fp32 (+13% at 4k depth); prefill 7.7–13× vs per-token (15.5k prompt + 300 tokens: 98 s, was ~610 s)

Gates that must keep holding: perplexity (0.6B c=3072 kvarn ≈ 15.60, 8B c=4096 ≈ 8.99), compress byte-exactness tests, graph-vs-direct decode parity (bit-identical over promotion boundaries).

Scope notes

Closes #180 P0–P2 (P3 Vulkan optional, tracked in #433).

Tests: 32 KVarN (ForwardPass) + 7 graph + 78 TurboQuant + 80 CLI green; zero-warning Release build (TreatWarningsAsErrors, AOT/trim analyzers on).

🤖 Generated with Claude Code

https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ

pekkah and others added 7 commits July 10, 2026 00:42
…c RTN K4V2 (#180)

Clean-room implementation of the KVarN per-tile pipeline (arXiv:2606.03458)
as a sibling quantizer inside SharpInference.TurboQuant:

- Hadamard rotation (reuses WalshHadamard) on K and V channel dims; V stays
  in the rotated domain through aggregation with one deferred inverse WHT
  per head (UnrotateOutput).
- Log-space dual-axis Sinkhorn variance normalization (columns first, rows
  last; RMS-about-zero so the asymmetric zero-point absorbs the mean).
- Asymmetric RTN: 4-bit keys per-channel (Sinkhorn channel factor folded
  into the affine), 2-bit values per-token per-128-channel group.
- Channel-major K codes for sequential score walks (query-side fold of
  chanStep + chanMin bias once per tile); token-major V codes.
- 2-bit pack/unpack added to BitPacking (masked RMW, dirty-buffer safe).

Measured on Gaussian tiles: K4 round-trip relFrob ~0.099, V2 ~0.50 (at the
2-bit min/max RTN floor); score-error vs TurboQuant 4-bit Lloyd-Max ratio
0.980 (Gaussian) / 1.047 (planted outliers). Fused-kernel parity vs
decompress-reference <= 3.3e-5. 25 new tests.

Review round: RotateQuery span-length guards, split ArgumentOutOfRange
paramName for tokens/dim, corrected V-aggregate error comment (model-level
gate decides 2-bit acceptability, not unit tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
…-mode flag (#180)

Wires KVarNCompressor into the existing TurboQuant machinery as a
selectable quantizer (research doc §6.3 — one cache type, two codecs):

- TurboQuantKvCache: TqQuantizer enum (LloydMax | KVarN). KVarN mode uses
  the FP32 window itself as staging (window >= 128 enforced): promotion
  compresses the oldest 128 window rows into one whole tile when the
  window overflows, so TQ length grows in 128-steps and staging count is
  always 0. Lloyd-Max path behaviorally unchanged.
- TqAttention: mode-dispatching RotateQuery; KVarN K-scores fold rowScale
  and attention scale into real q·k units so one softmax spans compressed
  + FP32-window segments; V aggregates in the rotated domain with one
  UnrotateOutput per head, window contribution added un-rotated.
- SnapKV eviction deferred: rejected at EnableTurboQuant and the CLI,
  Compact throws in KVarN mode as defense-in-depth (#180 follow-up).
- CLI: --tq-mode <lloydmax|kvarn> (kvarn requires --tq and -g 0; head-dim
  gate relaxed to pow2 in [8,1024] for kvarn). README row added.
- 13 new tests: promotion boundaries (win 128/192, multi-tile), GQA,
  wiring parity vs decompressed reference ~2e-6, end-to-end vs FP32 truth
  0.39-0.57 rel-L2 (the 2-bit V codec floor — model-level gate decides
  acceptability), Reset reuse, TruncateTo, config rejections, Qwen3-8B
  prefill/decode smoke (model-gated).

Review round: documented the reduced KVarN rewind depth (window − 127) on
EnableTurboQuant/TruncateTo for future speculation composition; corrected
the stale "ring buffer" comment (window is a linear shift-down buffer).

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

Adds the P0 go/no-go measurement harness and records the gate result:

- New `perplexity` subcommand: teacher-forced token-by-token NLL on the
  CPU forward pass (never Prefill, so every step reads through the TQ
  decode path), full-vocab log-softmax with double accumulation,
  position-bucket breakdown keyed off --tq-window so compressed-region
  degradation is visible. Same --tq/--tq-mode validation as run.
- scripts/kvarn-gate/: wikitext-2-raw fetcher + 20-problem greedy math
  micro-eval (640-token prepend forces reasoning over a compressed
  cache; invalid CLI invocations abort instead of scoring as failures).
- 9 model-free CLI tests (flag validation, NLL math properties).

Gate results (window 256, wiki.test.raw):
- Qwen3-0.6B c=3072: PPL fp32 15.47 / kvarn 15.67 (+1.26%) / lloydmax
  945.6 (collapse; verified pre-existing on master, not a regression).
- Qwen3-0.6B c=8192 (review probe): kvarn +1.90% aggregate; per-token
  penalty FLATTENS at ~+2.2% ppl-equiv in deep buckets, no acceleration.
  kvarn decoded faster than fp32 at depth (19.1 vs 17.4 tok/s, scalar).
- Qwen3-8B c=1024: +0.47%.
- Math eval: fp32 18/20, kvarn 18/20 (coherent, no loops), lloydmax 0/20.
- FP32-window parity: [1,256) bucket bit-identical across all configs.

P0 verdict per docs/kvarn-feasibility-research.md §6: 2-bit V accuracy
holds on models we serve -> proceed to P1 (AVX2) and P2 (CUDA) kernels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
…epth (#180)

P1 from the research doc: vectorize the two hot KVarN decode kernels,
dispatching on Avx2+Fma with the Task-1 scalar bodies as the untouched
fallback (internal ForceScalar hook for parity tests and benchmarks):

- KeyScoresAvx2: channel-major walk kept; per channel, one 16-byte load
  = 32 nibbles, psrlw+mask nibble split, punpcklbw/hbw restore token
  order, vpmovzxbd+cvtdq2ps widen, 4 FMAs vs the broadcast folded query
  weight into 16 YMM score accumulators. All Vector128 shuffles — no
  cross-lane unpack hazard by construction.
- AggregateValuesAvx2: token-major walk kept; per 16 code bytes = 64
  channels, 4 crumb planes (shift 0/2/4/6 + 0x03 mask), two-level
  byte->word interleave restores channel order, FMA vs broadcast
  w·tokStep. d%64!=0 dims fall back to scalar (all shipping head dims
  vectorize); skip semantics (qw==0/w==0/ws==0) identical to scalar.

Measured (Core Ultra 9 185H): KeyScores 8312->861 ns/tile (9.7x),
AggregateValues 8732->1039 ns/tile (8.4x), zero managed allocs. Parity
vs scalar worst 2.6e-6 rel (12 new tests + D=512 case). End-to-end
Qwen3-0.6B wikitext c=3072: PPL 15.6741 (+0.036% vs scalar — FP
reassociation + FMA contraction only), 52.6 tok/s vs FP32 33.5 (1.57x);
c=8192: PPL 21.1409 (+0.004% vs scalar), 38.3 tok/s vs FP32 17.4
(2.20x). The paper's "throughput >= FP16" claim now holds on CPU.

Review round: xunit.runner.json added to Tests.TurboQuant (ForceScalar
is a bare static; disable collection parallelism like sibling test
projects), FMA-contraction wording, D=512 parity coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
…P2 Task 5a)

Clones the CUDA Lloyd-Max TurboQuant seam for KVarN, correctness-first:

- Three NVRTC kernels in CudaTextKernels: llm_kvarn_rotate_query (WHT, no
  sign flip), llm_kvarn_compress_tile (gather 128 oldest window rows ->
  per-row WHT of K and V -> dual-axis Sinkhorn (log-space, columns first)
  -> asymmetric RTN K4/V2 pack; working tile in global scratch since a
  128x128 f32 tile exceeds the 48 KB shared ceiling; amortized once per
  128 decode steps), llm_kvarn_attention (llm_tq_attention's 3-phase
  hybrid: folded-query K-tile scores + fp32-window scores under one
  softmax, V accumulated in the rotated domain, one deferred inverse WHT
  per head, >4096-score global spill).
- Packed codes are BYTE-IDENTICAL to the CPU KVarNCompressor
  (__fadd_rn/__fmul_rn block FMA contraction feeding codes,
  __float2int_rn ties-to-even, sequential reduction order matches CPU);
  stored scales within 1e-5 rel (device logf/expf ulp). Tiles download-
  verifiable with the CPU decompressor — asserted in tests.
- CudaForwardPass: tqQuantizer ctor param; linear shift-down window
  mirroring the CPU cache position-for-position (promotion at ring-full,
  scratch-bounced D2D shifts, same-stream ordering); TruncateTo resync;
  EstimateMaxContextKvarn wired into the auto -c solve; all TQ
  exclusivity guards inherited (SnapKV, narrowed KV, batching, MoE,
  graphs bail). Head dim >256 stays CPU-only (shared WHT cap).
- CLI: --tq-mode kvarn now allowed with -g -1 full CUDA offload of dense
  models; perplexity command gains -g -1. CPU-fallback path now forwards
  the quantizer (latent P0 bug: fallback silently ran Lloyd-Max).
- 12 new CUDA parity tests (TryCreate/skip): byte-exact compress, rotate
  1e-4, attention vs CPU oracle per-dim max(1e-3,1e-2|x|) cosine>0.999
  incl. GQA + spill path, promotion cadence, guard rejections.

Measured (RTX 4070 Laptop 8 GB):
- Qwen3-0.6B c=3072: CUDA kvarn PPL 15.6266 (CPU 15.6684), fp32 15.4711;
  59.9 tok/s vs fp32 90.2.
- Qwen3-8B c=4096: kvarn PPL 8.9904 vs fp32 8.9032 (+0.98%), 22.8 vs
  27.6 tok/s (-17.5%); [1,256) bucket identical to fp32 by design.
- Capacity: auto-context on Qwen3-8B at -g -1 grows 2376 -> 18304
  (7.7x vs fp32 KV; ~8.5x/token in the compressed region) at ~6.1 GiB
  peak — beats the research doc's ~4x promise.

Review round: no blockers; corrected the bytes-per-token doc comment to
compare K+V against K+V. 5b targets: CUDA-graph re-entry (gap is launch-
overhead-dominated: 34% on 0.6B -> 17.5% on 8B), phase-1a coalescing,
packed prefill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
…beats fp32 (#180 P2 Task 5b)

Closes the CUDA decode gap where it matters. Two mechanisms:

1. Graph re-entry. Whole-tile promotion is hoisted out of RunDeviceRegion
   into Forward (direct launches once per 128 tokens, byte-exact
   reordering — promotion touches only its own layer's window/tiles, all
   one stream). The region then has two static topologies keyed on
   tqLen==0: pre-promotion (append + plain fp32 attention) and
   steady-state (append + rotate + kvarn attention), captured once each
   into per-id multi-graphs (ids -101/-102). Three tracked params
   (KvarnAppendSlot/KvarnFp32SeqLen/KvarnTqLen) published per replay;
   capture-scoped GraphKvarnTracking redirects KvAppend/Attention arg
   registration; shared-vs-scratch stays an in-kernel branch on tracked
   scalars. ResetCache/TruncateTo need no invalidation. Instance latch
   prevents replaying a disposed sibling's baked pointers.
2. Kernel rewrites (the actual bottleneck at depth): phase-1a K-scores
   warp-per-channel (a channel's 64-byte code run = 2 coalesced 32B
   sectors per warp) and phase-3a V-aggregate flat-index walks (256
   consecutive bytes per block pass, per-tile w-folds in shared).
   Compress kernel untouched — byte-exactness preserved.

Measured (RTX 4070 Laptop 8 GB, greedy CLI, warm):
- Qwen3-8B: kvarn >= fp32 everywhere — shallow 38.0 vs 37.2, deep-4k
  31.9 vs 28.2 (+13%). The paper's "throughput >= FP16" claim now holds
  on CUDA for the model class the 7.7x-capacity win targets.
- Qwen3-0.6B: shallow parity (190-196 t/s), deep 17.4 -> 35.4 (2.0x)
  but fp32's split-KV flash still wins at depth (92.9) — the kvarn
  attention grid (numHeads=16 blocks) underfills 36 SMs; split-tile+LSE
  redesign documented as follow-up.
- KVarN prefill 2x via graph replay (0.6B 30.6->66, 8B 25.8->35 t/s);
  chunked batched prefill remains the big prefill lever.
- PPL gate: 0.6B 15.6004, 8B 8.9887 (within reassociation noise of 5a;
  pre-promotion bucket bit-identical to fp32). New graph-vs-direct
  parity test: 400 tokens across two promotions, max |dlogit| = 0.0.

Review round: no blockers; parity test now hard-fails when graphs fail
to engage on a capture-capable device (was a silent soft-skip);
GraphMaxKernelArgs 16 -> 20 with the widest-op note corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
…98 s (#180 Task 6)

Replaces the per-token KVarN prefill loop with 128-token batched chunks
through the existing GEMM trunk, plus the depth measurements that settle
the deferred split-tile decision.

- New llm_kvarn_prefill_attention: one streaming (online) softmax per
  query spanning BOTH regions — tile K-scores (rotated folded-q over
  channel-major nibbles) then causal fp32-window dots (unrotated q),
  shared running max/sum; two V partials (tile in rotated domain, window
  in original), inverse WHT applied once to the tile partial at the end
  (sound by WHT linearity). 16 queries/block, no score storage -> no
  4096-position cap. Sub-warp __shfl_*_sync UB on odd-tail chunks found
  and fixed during hardening (all reductions now full-mask uniform).
- PrefillKvarnChunked driver: promote-on-window-full before appending
  position W+128k (tile k covers [128k,128k+128) for ANY window size —
  same invariant as decode's 5b hoist, so tiles are schedule-independent:
  re-chunking the same prompt yields 0 differing tile bytes over 10.3 MB,
  with a promotion inside a split PrefillWithCache call). Pre-promotion
  chunks ride the existing flash path. Decode graphs untouched.
- Fallbacks: SHARPI_KVARN_BATCHED_PREFILL=0 or any unsupported shape
  (head_dim 256, MoE, SWA, attn-bias, ...) -> per-token loop, never the
  non-KVarN trunk. 6 new tests incl. CPU-oracle parity (cosine 1.000000,
  33-tile deep walk), logit parity (argmax-equal), greedy continuation.

Measured (RTX 4070 Laptop 8 GB):
- Prefill: 8B@4k 35.3->460 t/s (13.1x), 8B@15.5k 26.4->203 (7.7x),
  0.6B@4k 145->1490, 0.6B@15.5k 55->583. Decode unchanged.
- Headline: 8B, 15.5k-token prompt + 300-token decode = 98 s wall-clock
  (was ~610 s). PPL gates unchanged (0.6B 15.6004, 8B 8.9887).
- Depth curve (settles the split-tile deferral): 8B kvarn decode 30.2 ->
  25.3 -> 20.0 t/s at 4k/8k/15.5k — ~0.9 t/s per 1k depth from the
  serial tile walk; interactive at max context where fp32 cannot run at
  all. Split-tile decode + int8-TC K-score prefill GEMM (203->~700 t/s
  class) documented as follow-ups.

Review round: no blockers, no should-fixes; kernel-header plane-count
comment corrected (four MQ x 128 planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EWo1niax1g8A5E79RSALJ
@pekkah
pekkah merged commit 5d54d75 into master Jul 11, 2026
1 check passed
@pekkah
pekkah deleted the feat/180-kvarn branch July 11, 2026 09:19

@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 implements the KVarN KV-cache quantizer (4-bit keys / 2-bit values) as an alternative to Lloyd-Max codebooks, adding CPU and CUDA support (including CUDA graph capture/replay and chunked prefill), a CLI perplexity evaluation command, and comprehensive tests. Feedback is provided regarding a critical bug in the CUDA prefill attention kernel (llm_kvarn_prefill_attention) where uninitialized shared memory s_r can contain garbage values (such as NaN or Infinity) and poison the accumulator s_racc during the first tile loop iteration; initializing s_r to 1.0f is recommended to prevent this.

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.

int i16 = tid >> 4;
int lane16 = tid & 15;

if (tid < KPF_MQ) { s_m[tid] = sharpi_neg_inf(); s_l[tid] = 0.0f; }

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

In the first iteration of the tile loop (tile = 0), s_racc[idx] *= s_r[idx / head_dim] is executed. At this point, s_racc is 0.0f, but s_r is uninitialized and contains garbage values from shared memory. If s_r contains NaN or Infinity, the multiplication 0.0f * NaN or 0.0f * Infinity will result in NaN, which will poison s_racc and propagate to the output. To prevent this, initialize s_r to 1.0f at the start of the kernel.

    if (tid < KPF_MQ) { s_m[tid] = sharpi_neg_inf(); s_l[tid] = 0.0f; s_r[tid] = 1.0f; }

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.

Implement KVarN variance-normalized KV-cache quantization (2-bit values)

1 participant