Skip to content

KV cache: FP16 sliding window + attention sinks, and an optional 2-bit K / 4-bit V cache scheme (--kv q2k) [EXPERIMENTAL] - #2318

Closed
bsorensen110 wants to merge 17 commits into
Niko1221:mainfrom
bsorensen110:feat/kv-q2k-window
Closed

bsorensen110 wants to merge 17 commits into
Niko1221:mainfrom
bsorensen110:feat/kv-q2k-window

Conversation

@bsorensen110

@bsorensen110 bsorensen110 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

KV cache: FP16 sliding window + attention sinks, and an optional 2-bit K / 4-bit V cache scheme (--kv q2k) [EXPERIMENTAL]

Status: experimental. Opt-in only (--kv q2k, --kv-window N, --kv-k-gain B); default behavior is unchanged when the flags are off. It needs more testing before it can be considered production-ready: the behavioral and long-context runs below are single-model, single-document measurements, and the window-1024 anomaly is unexplained.

Summary

Two composable KV-cache features behind opt-in flags, plus a correction that makes them work together:

  1. --kv-window N (--kv-sink S) — an FP16 overlay over the first S cells (attention sinks) and the most recent N cells of the quantized KV bulk. Those cells read exact; the rest stays quantized. Costs 304 KiB per layer at N=128, S=8 including 16 slack slots (3.56 MiB for 12 layers).
  2. --kv q2k — a cache format storing K at 2 bits with per-channel scales and V at 4 bits per token (q4_0): 448 B/cell vs q4_0's 576 B (−22%), 4.6× smaller than FP16 (2048 B).
  3. --kv-k-gain B (q2/q2k) — a K reconstruction gain: the stored K scale is B × the rule's, the codes are unchanged. It undoes the 2-bit codebook's score shrink, which is what made the window hurt q2k. Zero extra bytes.

Recommended with q2k: --kv-window 256 --kv-sink 8 --kv-k-gain 1.10 — decode-path KL −36.8% vs plain q2k (8 of 9 texts) for +6.6 MiB.

Correction to the previous revision (run-to-run variance). The last revision said greedy decode is not run-to-run reproducible "under QSA top-k tie-breaks and atomic partial-group KV writes" and reported decode-path numbers as 5-run means. That diagnosis was wrong, and the cause was our measurement setup: we did not have the prefill CPU share turned off. For prompt chunks under 1024 tokens the prefill hands part of the MoE expert work to the CPU, and that split changes from run to run — a 730-token prompt's logits differed by up to 6.8 between two identical runs. With STRATA_PREFILL_CPU_SHARE=0 two runs are bit-identical (129 teacher-forced decode rows, max |diff| 0; a second forced FP16 run scores KL 0.0000 against the first on all nine texts below). Every number in this revision was measured that way, and the 512-token logits table was re-measured (it moved by up to 2.5 points of top-1). The earlier decode-path comparison also used a greedy reference, which speculatively drafts multi-token windows and so does not produce rows comparable to the arms'; the decode numbers below use a teacher-forced FP16 reference instead.

Every directional claim below carries the number that pins it; the gate (kv_q2_parity) reproduces the attention-error tables from a real KV dump.

Why this shape

The bit-allocation rule came from the real cache, not the synthetic one

Measured with the gate's attention-output probe (relative RMS of attention output vs an FP16 cache) on a real stored KV cache dumped from a live serve (--dump-kv; 864 cells, 2 KV heads × 256 dims, rotated as stored), layer 3 unless stated:

arm B/cell/head attention error
q4_0 (reference) 288 0.888%
K 2-bit per-channel + V 4-bit (q2k) 224 1.911%
K 4-bit + V 2-bit per-channel 224 3.171%
K 2-bit + V 2-bit per-channel (symmetric q2) 160 3.508%
K per-channel + V per-token, both 2-bit (KIVI's placement) 160 5.188%

At equal bytes (224 B), the bits belong on V: K2c+V4 beats K4+V2c on every real layer measured (table below). On the synthetic gate cache the ranking is the reverse (K looks more sensitive) — synthetic data has no channel structure, which is why no ranking here is taken from it.

The mechanism: V's quantization error enters the output directly through softmax(qKᵀ)·V, while much of K's error is common-mode across cells and cancels in the softmax normalization (docs/kv-q2.md). Both published intuitions survive, scoped:

  • K's outliers are channel-aligned (KIVI's premise) — true on real layers (K channel RMS max/median 5.8× on L3 up to 74.9× on L47), and honored by the per-channel K scale, not by more bits.
  • V is the tensor the output feels — so V gets 4 bits while K takes 2.

Why per-channel K and per-token V

Per-channel K scales (block_q2c_0: one fp16 scale per dimension per 32-cell group, a 4-level Lloyd-Max codebook) absorb K's channel outliers. V reuses the existing q4_0 path (per-token blocks), so the format adds no new V quantizer. KIVI's 2-bit per-token V loses on this cache: the Hadamard rotation leaves V's channel structure in place (V channel RMS max/median 7.9–14.5×), so 2-bit V would want per-channel scales too — at 4 bits per token, V carries q4_0's own error.

Why the FP16 window — and why q2k needs the gain to use it

The window is a separate, format-agnostic device. It only acts at decode: prompt-mode (prefill) logits never read the overlay, which is why the previous revision measured "no logit precision" — that was a blind spot of the measurement, not a property of the window.

  • At decode it helps q4_0: −19.6% KL (7 of 9 texts).
  • With q2k alone it hurts: +10.3% (4 of 9 better). The 4-level Lloyd-Max K codebook is MSE-optimal, so its reconstruction is shrunk: on real queries q2k's attention scores come out at 0.92–0.97× the exact ones (q4_0: 0.997–0.998). Next to exact window cells that is a bias — the exact cells out-score the shrunk bulk, and the softmax over-weights the window by +1.7..+5.9% of its mass on 11 of 12 layers.
  • --kv-k-gain stores the K scale B × larger while choosing the codes against the un-gained scale, so no value moves to another level and only the reconstruction grows. With B = 1.10 the window turns into a gain: −29.3% at 128 cells, −36.8% at 256 (vs plain q2k).
  • Behaviorally the window recovers symmetric q2's short-answer loss (q2 2/3 → q2 + window 3/3); q2k matches the q4_0 reference on the recall probe without it (3/3).

Measurements

Gate (device kernels vs host reference)

  • kv_q2_parity: synthetic cache 40/40 checks (incl. the K-gain pin: stored scale = B × the rule, bit for bit, codes chosen against the un-gained scale); real cache layers 3/11/23/47 36/36 each. The q2k device arm (real append + gather kernels) lands on the host row: L3 1.910% device vs 1.911% host.
  • kv_window_parity: 10/10 — sink and window cells read bit-exactly equal to an FP16 pool; cells outside read the bulk pool bit-for-bit; kv_window_restore reproduces the bulk dequantization.
  • kv_q4_parity, kv_q8_parity, kv_stream_parity, kv_hybrid_parity: pass (existing formats untouched).

Attention error by layer (real cache)

layer K channel spread q4_0 q2k (K2c+V4) K4+V2c q2 (K2c+V2c)
L3 5.8× 0.888% 1.911% 3.171% 3.508%
L11 5.9× 0.740% 2.002% 2.589% 3.471%
L23 14.2× 0.941% 2.211% 2.768% 3.655%
L47 74.9× 1.040% 1.645% 2.663% 2.948%

The outlier layer (L47) is where q2k comes closest to q4_0.

Attention error under the engine's append patterns (real cache)

The row above appends every cell in one call — whole 32-cell groups — so it never runs the partial-group scale rule. The engine does, every request: speculative verify appends 2–5-cell windows that cross group boundaries, decode appends into the group the prompt ended in, and after a reused conversation prefix the 2048-cell prefill chunks fall mid-group. kv_q2_parity now replays those patterns on the same K/V and readers (attention output vs FP16). Three scale rules for a group first written by a partial batch: old = the pre-review kernel (sum / 32), /n = RMS of the n cells, shrink = shipped (full-group RMS, the previous group's mean square standing in for the cells not yet written):

layer q4_0 q2k one-shot spec verify (3-cell windows) old / /n / shrink chunked prefill (100-cell chunks, stress) old / /n / shrink prompt + decode
L3 0.888% 1.910% 1.978% / 1.912% / 1.892% 3.631% / 2.076% / 2.186% 1.910%
L11 0.740% 2.002% 2.030% / 2.011% / 2.012% 3.617% / 2.727% / 2.626% 2.002%
L23 0.941% 2.211% 2.895% / 2.667% / 2.245% 3.767% / 2.509% / 2.161% 2.211%
L47 1.040% 1.648% 1.457% / 1.799% / 1.720% 1.438% / 1.346% / 1.803% 1.648%
  • Speculative verify (what every decode does at each group boundary): the excess over one-shot, summed over the four layers, is +8.0% with /n and +1.3% with shrink.
  • Chunked prefill is a stress case (real chunks are 2048 cells, aligned from position 0): shrink is mixed by layer there (1.4% worse summed than /n) and 30% better than the pre-review kernel.
  • Prompt + decode lands exactly on the one-shot row (pinned). On the extreme-outlier layer 47 an undersized scale acts as clipping, which is why the pre-review kernel measures better there.

Logits (512-token prompts, prompt mode, vs FP16: top-1 agreement / mean KL) — re-measured with the CPU share off

text q4_0 q2 q2k
code 90.4% / 0.060 74.6% / 0.329 77.1% / 0.226
document 91.6% / 0.030 81.2% / 0.203 83.8% / 0.133
chat 92.4% / 0.027 81.2% / 0.177 83.4% / 0.144

q2k's KL is 3.8–5.3× q4_0's at the prompt level: it is a cheaper format, not a lossless one. (The previous revision's table, measured with the CPU share on, read 2.5–5×.)

Decode path (teacher-forced decode, 9 texts, vs FP16)

Every arm is fed the same 128-token continuation, one token per window, and each row is compared with a forced FP16 run's row at the same position. Nine texts: three built-in prompts (chat, document, code agent) and six repo files up to 6144 tokens. A second FP16 run scores KL 0.0000 against the reference on all nine. Change = geometric mean over the texts vs plain q2k, with the texts that improved.

arm mean KL top-1 vs q2k
q4_0 0.0198 96.6% −89.9% (9/9)
q4_0 + window 128 0.0174 96.9% −91.9% (9/9)
q2k 0.1825 88.5%
q2k + window 128 0.2060 87.3% +10.3% (4/9)
q2k + window 128 + gain 1.10 0.1338 89.8% −29.3% (7/9)
q2k + window 256 + gain 1.10 0.1203 90.3% −36.8% (8/9)

Gain sweep at window 128 (same texts, development build): 1.03 −9%, 1.05 −15%, 1.08 −26%, 1.10 −30%, 1.13 −29%; the gain alone, with no exact cells, −1%. Even the best q2k setting keeps ~6× q4_0's decode KL.

These rows were measured on this PR's build with a temporary copy of our teacher-forced decode tooling (--force-tokens + a per-window logits dump; not part of this PR). The PR build's prompt logits are byte-identical to our development build's under the same flags (--kv q2k, and --kv q2k --kv-window 256 --kv-sink 8 --kv-k-gain 1.10). The two builds sit on different upstream bases, and their decode rows differ slightly for every quantized format — plain q4_0 included — but never for FP16, so the development build's numbers are not quoted as this PR's.

Behavioral A/B (~5K-token buried-fact probe, tools/kv_recall_report.py)

Eight vault codes buried in a document: mid = the body; late = the last few paragraphs (~180–350 tokens before generation: outside a 128-cell window, inside a 512/1024 one). Scored by the instructed (vault, code) pair. Release q4_0 is the shipped binary; every other arm is this branch.

arm mid late short answers decode
release q4_0 (reference) 4/4 4/4 3/3 158.3 t/s
q4_0 (this branch) 4/4 4/4 3/3 159.0 t/s
q2 (symmetric) 4/4 4/4 2/3 134.6 t/s
q2k (no window) 4/4 4/4 3/3 137.5 t/s

q2k matches the q4_0 reference on every measure at 22% less KV memory and ~13% lower decode speed; symmetric q2 is the arm that loses a short answer. An earlier 5-arm run (substring-scored) agreed, with 0/3 repetition loops for release q4_0 and q2k.

Long-context recall at depth (smoke test)

~200K-token operations log (199,337 prompt tokens) served with --kv q2k --max-context 262144; three needles at 25% / 50% / 90% depth — all three recalled exactly (WALRUS-CANARY-2247, QUARTZ-DELTA-8813, TUNDRA-FALCON-6631). Cold prefill of the full prompt: 2574 tok/s. The verification pass rode the serve's conversation cache, which restores the same q2k-quantized state. Re-run on the final kernel (after the partial-group scale changes, fresh service, nothing cached): cold prefill of all 199,337 tokens at 2529 tok/s, 3/3 recalled. Re-run with the recommended --kv q2k --kv-window 256 --kv-sink 8 --kv-k-gain 1.10 (fresh service, nothing cached): 199,337 tokens, 3/3 recalled, 78.6 s wall including generation (~2537 tok/s). One model, one document.

A small multi-turn agent task (find and fix two planted bugs across two files, iterate pytest to green) also completed correctly against the served q2k/262144 endpoint.

Window sizing (symmetric q2 bulk + window)

arm mid late short answers decode
q2 + window 128 4/4 4/4 3/3 127.7 t/s
q2 + window 512 4/4 4/4 3/3 136.1 t/s
q2 + window 1024 3/4 0/4 3/3 129.0 t/s

128 and 512 are behaviorally identical (512 decodes faster: the FP16 overlay is a cheaper read than the q2 bulk). 1024 regresses — the late facts sit inside a 1024-cell window, so exact reads should help, not collapse; this points at a defect at that size, not a trade-off. Unexplained; do not use 1024.

Memory (262144 ctx, 12 QSA layers, 2 KV heads × 256 dims; the gain adds nothing)

format B/cell KV total
FP16 2048 6.00 GiB
q4_0 576 1.69 GiB
q2k 448 1.31 GiB
+ window 128 / sinks 8 — + 3.56 MiB
+ window 256 / sinks 8 — + 6.56 MiB

Reference papers

  • KIVI (arXiv 2402.02750) — K per-channel, V per-token, asymmetric 2-bit, 128 recent tokens in FP16. The K placement transfers (per-channel K wins on real layers); the V placement does not on this rotated cache (K2c+V2t 5.188% vs symmetric per-channel 3.508% at the same 160 B).
  • SKVQ (arXiv 2405.06219) — channel reordering, clipped dynamic quantization, an FP16 sliding window; K 2-bit / V 1.5-bit. Reordering is a no-op for per-channel-scaled K (a relabeling). During development (tooling not included in this PR) it helped per-token V on real layers, but SKVQ's byte budget still lost to plain per-channel 2-bit on every real layer. Clipping did not help (L3, in this PR's gate: clipped 4.731% vs unclipped 4.627%).
  • KVSink (arXiv 2508.04257) — attention-sink preservation under KV quantization; the reason the overlay keeps the first cells exact.
  • QuaRot (arXiv 2404.00456) — the Hadamard rotation the KV formats here (q4_0, q2, q2k) apply before quantizing.
  • KVQuant (arXiv 2401.18079) — per-channel (pre-RoPE) K and non-uniform codebooks; background for the per-channel K scale and the Lloyd-Max codebook.
  • "More for Keys, Less for Values" (arXiv 2502.15075) — argues K deserves more bits. At equal bytes on this rotated cache the measurement went the other way (above).
  • AsymKV (arXiv 2410.13212) — the 2-bit-K / 1-bit-V split; 1-bit V measured 9.7% (per-channel) to 14.4% (per-token) attention error on L3 here.

Review fixes (Copilot review of this PR)

All 15 findings were reproduced and fixed; the engine fixes are pinned by new gate checks, each shown to FAIL against the pre-fix code:

  • q2/q2k pools are sized and moved in whole 32-cell groups (high): fully resident pools, elastic VMM pools, the staging copy, the reverse DMA, and conversation snapshots (image size and reusable prefix). A page is not a byte range in the group-major layout.
  • Fresh partial-group scale: the RMS of the cells present (/n), not /32 — then, measured under the engine's append patterns (table above), shrunk toward the previous group's scale in the batch path too: s = sqrt((ss + (32 − n)·prev²)/32). prev is race-free: a previous group completed in the same launch is recomputed in-thread with its block's exact sum order; otherwise its stored scale is read. Pinned bit-exact (two-call and one-call appends must store the same scale).
  • --dump-logits header repair on every path, not only the native pack (CUDA and SYCL).
  • Tools: truncated KV dumps and zero-vocab logit headers fail cleanly instead of crashing; the no-e validator checks the letter anywhere; the engine-failure row prints; recall is scored by the (vault, code) pair (codes repeat between groups). Re-scoring the saved outputs left every arm above unchanged except q2 + window 1024 (late 1/4 → 0/4).
  • kv_pr_report.py: each tail code is now asked in its own request, inside the window. The 2026-10-11 README carries a correction: its earlier "tail" codes sat outside the window.
  • Docs: the overlay footprint (304 KiB/layer, 3.56 MiB total) and the q4_0 baseline (1.69 GiB at 262144) were ~2× off; a stale "dump-logits writes 0 bytes" note was removed.
  • Also fixed while re-measuring: two inverted gate labels (the K-vs-V check printed "K is the sensitive side" while proving V is), and the AsymKV citation (2410.13212, not 2411.17034).
  • No machine-specific paths: the harnesses and A/B scripts resolve everything from the repo's layout (tools/kv_bench_env.py: repo root, Strata-data next to it, strata-<model>.json, engines under engine/ and build/) with an environment override for each; the READMEs carry a generic CUDA 13.0 build recipe instead of local scripts.

Known limits

  • 2-bit K is the quality floor: q2k's prompt-level KL is 3.8–5.3× q4_0's, and even window 256 + gain 1.10 keeps ~6× q4_0's decode KL.
  • Reproducible measurements need STRATA_PREFILL_CPU_SHARE=0 (prompt chunks under 1024 tokens otherwise split MoE experts with the CPU, and the split varies run to run). Engine default unchanged.
  • The gain is calibrated on one model (uniform 1.10, from a 1.03–1.13 sweep on nine texts).
  • Window 1024 regresses recall (above); unexplained.
  • Behavioral and long-context results are single-model, single-document.

Commits

  1. kv_window — FP16 sliding window + attention sinks over the quantized KV cache
  2. kv_q2 — 2-bit per-channel KV format, parity gate, measurement tooling
  3. kv_q2k — K 2-bit per-channel + V 4-bit mixed-width format
  4. kv_q2 — whole 32-cell groups wherever a q2/q2k pool is sized or moved
  5. kv_q2 — a fresh partial group's scale is the RMS of the cells it has
  6. kv_q2_parity — pins for the review fixes, and the inverted K-vs-V labels
  7. generate — repair the --dump-logits header on every path
  8. tools — crash fixes and (vault, code) pair scoring
  9. kv_pr_report — tail probes inside the window
  10. docs — footprint arithmetic, the tail-probe correction, the stale dump-logits gap, the AsymKV citation
  11. tools + scripts — no machine-specific paths: repo-relative defaults, env overrides
  12. docs — a generic CUDA 13.0 build recipe instead of local paths and scripts
  13. kv_q2_parity — q2k under the engine's append patterns (spec verify, chunked prefill, prompt + decode)
  14. kv_q2 — a fresh partial group's scale shrinks toward the previous group's, in the batch path too
  15. kv_q2 — K reconstruction gain (--kv-k-gain): undo the 2-bit score shrink, codes unchanged, zero bytes

Copilot AI balanced review requested due to automatic review settings October 13, 2026 20:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Group-granular q2 sizing, partial-group scaling, and staging copies can corrupt or incompletely populate KV data.

15 open findings
What changed in this PR

Adds experimental q2/q2k KV-cache formats, an FP16 recent-token/sink overlay, and supporting validation and measurement tooling.

Changes:

  • Implements q2/q2k storage across streaming, snapshots, speculative decoding, and attention.
  • Adds FP16 window/sink preservation and parity coverage.
  • Extends logits dumping, benchmarks, scripts, and documentation.
File Description
tools/​logits_dump_check.py Validates logits dumps.
tools/​kv_release_ab.py Compares release behavior.
tools/​kv_recall_report.py Measures KV recall and speed.
tools/​kv_pr_report.py Produces window quality metrics.
tools/​kv_loop_ab.py Measures repetition regressions.
tools/​kv_cache_dump.py Analyzes FP16 KV dumps.
tools/​kv_ab_report.py Runs KV accuracy/speed A/Bs.
sycl/​src/​program/​generate.cpp Dumps native speculative logits.
src/​kernels/​kv_window_parity.cpp Tests overlay correctness.
src/​kernels/​cuda/​kv_window.cu Implements overlay writes/restoration.
src/​kernels/​cuda/​kv_stream.cu Adds q2/q2k streaming.
src/​kernels/​cuda/​kv_q2.cu Implements per-channel q2 storage.
src/​core/​verify.cpp Integrates q2 and overlays into verification.
src/​core/​mtp.cpp Adds q2/q2k drafter appends.
src/​core/​conversation_snapshot.cpp Serializes and restores new formats.
logits-kv-window.sh Automates logits comparisons.
kv-ab-report.sh Runs the KV report.
include/​strata/​program/​logits_selection.hpp Counts selected logits rows.
include/​strata/​prefill/​prefill.hpp Exposes prompt-logit callbacks.
include/​strata/​kernels/​qsa_decode_attn.hpp Extends attention pool metadata.
include/​strata/​kernels/​kv_window.hpp Defines overlay layout and API.
include/​strata/​kernels/​kv_stream.hpp Defines q2/q2k streaming units.
include/​strata/​kernels/​kv_q2.hpp Defines q2 format and API.
include/​strata/​core/​layer.hpp Extends QSA state and configuration.
docs/​research-kv-cache-compression.md Documents compression research.
docs/​kv-window.md Documents the FP16 overlay.
CMakeLists.txt Builds kernels and parity tests.
bench/​results/​2026-10-12-kv-precision/​README.md Records precision measurements.
bench/​results/​2026-10-11-kv-window/​README.md Records window measurements.
ab-kv-window.sh Runs resident overlay A/Bs.
ab-kv-window-stream.sh Runs streamed overlay A/Bs.
ab-kv-window-probe.sh Runs short-answer overlay probes.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread include/strata/kernels/kv_q2.hpp Resolved
Comment thread src/kernels/cuda/kv_q2.cu Resolved
Comment thread src/kernels/cuda/kv_stream.cu Resolved
Comment thread src/kernels/cuda/kv_stream.cu Resolved
Comment thread src/program/generate.cpp Resolved
Comment thread tools/kv_release_ab.py Resolved
Comment thread tools/logits_dump_check.py Resolved
Comment thread docs/kv-window.md Resolved
Comment thread include/strata/kernels/kv_window.hpp Resolved
Review of Niko1221#2318 (Copilot findings 1, 3, 4, plus two paths of the same class).

A q2 per-channel pool is GROUP-MAJOR: one 10-byte slot per (32-cell group, head, dim), addressed
((cell/32)*n_head_kv + head)*head_dim + dim. "80 B per cell and head" is only the amortized rate of a
COMPLETE group, and a 4-cell page is not a byte range at all - its codes are spread over every dim slot of
its group. Five places treated it as page-granular:

- kv_plan (layer.cpp): the fully resident path sized the pool as ceil(max_cells/page_size) pages, so any
  context not divisible by 32 allocated the last group short (100 cells -> 8,000 B per KV head while group 3
  addresses up to byte 10,239). The page count is now rounded up to whole units (kv_stream_unit_blocks:
  32/page_size for q2/q2k, 1 for every other format) BEFORE it sizes the VRAM slots, the host copy, the
  staging pool and the page table. The streaming paths already rounded their slot counts; they are unchanged.
- elastic (VMM) pools (layer.cpp, opt-in): pool_slots rounds a grow up to whole units and
  pool_slots_mapped rounds the usable capacity down to them, so the last partial group can never be written
  past the mapped chunks. ElasticPool carries the unit; every non-q2 format keeps unit 1.
- kv_stage_from_host (kv_stream.cu): copied n_blocks*(len/unit) bytes, i.e. a prefix of the group's dim
  slots, not its pages. The block count is rounded up to whole units (inside the pool: n_blocks <= n_pages,
  which kv_plan now keeps unit-aligned).
- kv_unstage_to_host (kv_stream.cu, STRATA_KV_HOST_DMA=1): same defect in the reverse direction; [b0, b1) is
  widened to unit boundaries. The staging pool holds the whole group (staged from the host above).
- conversation snapshots: the image rounds its cell count up to whole groups for q2/q2k, and the reusable
  byte prefix of an incremental save rounds DOWN to whole groups (a partial group's slots change when any
  later cell of the group is written).

For unit == 1 formats every bound is exactly what it was. Verified: all six KV parity suites pass; the gate
pins for staging/unstaging land in a following commit and FAIL against the pre-fix kv_stream.cu.
…not of 32

Review of Niko1221#2318 (Copilot finding 2).

kv_append_q2_group_kernel computed s = sqrt(ss / 32) for every call. For a group whose first cells arrive in
a batch shorter than 32 (every non-32-aligned prompt-chunk boundary) and that has no stored scale yet, that
under-scales the group by sqrt(32/n) - 1.63x for n = 12 - and the partial-group rule then keeps that scale
when later chunks fill the group, so every code in it saturates against a scale that is too small. The
comment above the line already said the fresh group "takes the RMS of the cells the call has"; the code now
does: s = sqrt(ss / n).

Complete groups are bit-identical (n == 32, the same 1.0f/32.0f). Groups that already carry a scale, and
single-cell appends that borrow the previous group's scale, are unchanged.
…-group staging

Three new checks (gate now 37 on the synthetic cache), each proven to discriminate by running it against the
pre-fix code:

- "fresh partial group scale": the real prompt-path append (use_prev = 0, zeroed pool) of 12 cells into an
  empty group; every (head, dim) slot's stored fp16 scale must equal f16(sqrt(ss/12)), ss summed in cell
  order with the multiply and the add kept separate - bit for bit. FAILS with the old ss/32.
- "staging 3 of a group's 8 pages copies the WHOLE q2k group": kv_stage_from_host with a mid-group block
  count must copy every dim slot of group 0 (K and V) and leave group 1 untouched. FAILS with the old
  n_blocks*(len/unit) copy.
- "unstaging pages [3, 5) writes the WHOLE q2k group back": kv_unstage_to_host inside group 0 must write the
  whole group to the host copy and nothing past it. FAILS with the old [b0, b1) copy.

Also fixes two inverted labels found while re-measuring. The K-vs-V check's CONDITIONS were right (real:
K2c+V4 beats K4+V2c) but both labels said the opposite: on real data the gate printed "the K side is the
sensitive one (coarse K costs more than coarse V)" while proving coarse V costs more. Labels now read
"SYNTHETIC: the K side is the sensitive one" / "REAL CACHE: the V side is the sensitive one", and the
"best 224 B arm" line prints min(K4+V2c, K2c+V4) - 1.911% on layer 3, not the 3.171% losing arm.

Real cache (tmp dump, 864 cells), this branch: layers 3/11/23/47 pass 34 checks each; K2c+V4 vs K4+V2c =
1.911/3.171, 2.002/2.589, 2.211/2.768, 1.645/2.663 % - V is the sensitive side on all four layers.
… native one

Review of Niko1221#2318 (Copilot findings 5, 6).

The close-time repair rewrote the header's row count only when native_pack was set. An early EOS shortens a
non-native run just the same, and rows_written is counted on both paths, so a non-native dump could still
promise rows the file does not contain - the exact invariant tools/logits_dump_check.py enforces. The repair
now runs whenever hdr_written && rows_written != hdr[1], in both src/program/generate.cpp and
sycl/src/program/generate.cpp.
…heck, failure rows, pair scoring

Review of Niko1221#2318 (Copilot findings 7, 9, 10, 11, 12). Each reproduced first, then fixed:

- kv_cache_dump.py: a truncated SDKV body printed DEFECT and then crashed in d.layer() (np.frombuffer on
  missing planes). It now returns 1 after reporting the defect.
- logits_dump_check.py: a header with n_vocab == 0 divided by zero at (size - 8) % row_bytes. Non-positive
  n_vocab / negative n_rows now fail the check with a DEFECT line.
- kv_release_ab.py: the no-e validator only rejected " e" (a word starting with e), so "memory bandwidth"
  passed. It now rejects the letter anywhere.
- kv_recall_report.py: the engine-failure row had six columns while the report prints r[6] (IndexError);
  it now has seven.
- kv_recall_report.py: two codes are shared between the mid and tail vaults (90277, 18746), so substring
  scoring credited a tail fact when only the mid answer was given (a mid-only reply scored 2/4 tail). Recall
  is now scored by the instructed (vault, code) PAIR (vault_hits). Re-scoring the saved outputs of the last
  8-arm run with the pair scorer leaves every arm unchanged except q2 + window 1024, whose tail goes from 1/4
  to 0/4.
- kv_recall_report.py: the comment and report header said the tail facts sit inside the FP16 window. They do
  not: 2-5 filler paragraphs, the instructions and eight questions follow them (~180-350 tokens), so they are
  outside a 128-cell window and inside a 512/1024 one. The document is unchanged (published numbers stay
  reproducible); the description is corrected.
Review of Niko1221#2318 (Copilot finding 8).

The six "tail" codes were placed in the last six paragraphs and followed by the instructions and all twelve
questions, so the earliest sat several hundred tokens before generation - outside the 128-cell overlay - and
the "copy tail" column measured the bulk, not the window.

The document is now the body with the six mid codes; the mid codes are asked in one request, and each tail
code is asked in its own request whose prompt is the same body + that one record + its question (~40-55
tokens from the generation position). The tail requests share the body as a prefix, so the serve's
conversation cache keeps them cheap. Both columns are scored by the (module, code) pair.
…-logits gap, the AsymKV citation

Review of Niko1221#2318 (Copilot findings 13, 14, 15, and two more of the same kind).

- Overlay footprint (docs/kv-window.md, kv_window.hpp): the slack slots were omitted and the arithmetic was
  ~2x off. (8 sinks + 128 window + 16 slack) slots x 2 heads x 256 dims x 2 B x 2 (K, V) = 311,296 B =
  304 KiB per layer, 3.56 MiB over 12 layers.
- q4_0 baseline: 576 B per cell and layer (6,912 B/cell over 12 layers) is 1.69 GiB at 262,144 cells, not
  906 MB. The 2026-10-11 README's comparison row had the same 2x error (288 KB per 1024 cells -> 576 KiB;
  "3.2% of a 32K-cell layer" -> 1.65%).
- 2026-10-11 README: the "known gap" said --dump-logits writes 0-byte files; it was fixed later in this PR
  and the KL/top-1 table is in 2026-10-12-kv-precision. Replaced with that pointer.
- 2026-10-11 README: the probe description said the tail codes sit in the last ~120 tokens, inside the
  window. As measured they sat outside it (see the kv_pr_report commit); a correction note now says so -
  every arm scored 12/12, so the table shows no regression, not an overlay effect.
- docs/kv-q2.md: AsymKV is arXiv 2410.13212 (Tao, Yu, Zhou), not 2411.17034 (a robotics paper). Checked
  against arxiv.org.
…env overrides

The KV harnesses and A/B scripts hardcoded one machine's layout (C:/Users/<user>/Strata, its Strata-data
folder, its CUDA 13.0 install, its scheduled-task name). They now derive everything from the repo's own layout,
with an environment override for each value:

- tools/kv_bench_env.py (new, shared by the five serve-driving harnesses):
    ROOT     = the repository (this file's parents[1], as serve/server.py does)
    DATA     = $STRATA_DATA, else Strata-data next to the repository (the layout SETUP and strata_mcp.py use)
    PY       = sys.executable (the serve the harness launches runs under the same interpreter)
    KEY      = $STRATA_API_KEY, else DATA/strata_api_key.txt if present, else "" (a serve without an api_key)
    CONFIG   = $KV_CONFIG, else ROOT/strata-swift-iq3_xxs.json (the strata-<model>.json the serve runs); a
               missing config exits with a message naming KV_CONFIG instead of a traceback
    engine() = $<env> if set, else ROOT/<dir>/strata(.exe)  (KV_REF_ENGINE, KV_DEV_ENGINE, KV_ENGINE,
               KV_CUR_ENGINE, KV_PREV_ENGINE)
- kv_ab_report, kv_loop_ab, kv_release_ab, kv_pr_report, kv_recall_report: import those; per-arm logs go
  under ROOT.
- kv_precision_compare: the CUDA 13.0 runtime dirs come from KV_CUDA130 (toolkit root -> bin, bin/x64) or
  KV_LIB_DIRS; with neither, the config's lib_dirs are used as before.
- ab-kv-window*.sh, kv-ab-report.sh, logits-kv-window.sh: ROOT is the script's folder (`pwd -W` under
  git-bash, so python.exe gets a native path), DATA/KEY as above, PYTHON overrides the interpreter,
  STRATA_PACK / STRATA_GGUF / KV_ENGINE name the model and engine. The serving scheduled task they stop and
  restart is now $STRATA_SERVICE_TASK (unset: nothing is stopped).

Verified: every tool imports and resolves from a different working directory, every override takes effect,
all five scripts pass `bash -n`, and their headers resolve the pack, GGUF, key and interpreter on this layout.
… and unshipped scripts

The two results READMEs and docs/kv-q2.md pointed at build-cu130.bat / build-cu130-tests.bat /
run-gpu-bench.sh (local scripts that are not in this PR) and at C:/Users/<user> paths. Replaced with:

- 2026-10-11-kv-window "Reproduce": pip's CUDA 13.0 components (any CUDA 13.0 install works), the cmake
  configure/build lines for build-cu130 (CU13 = toolkit root; arch 120 noted as a setting), the parity
  executables, and the end-to-end harness run directly (`KV_ENGINE=build-cu130/strata python
  tools/kv_pr_report.py`, after stopping whatever serves on the GPU).
- 2026-10-12-kv-precision "Reproduce": KV_ENGINE relative to the repo, KV_CUDA130 for the runtime dirs.
- docs/kv-q2.md: points at that recipe.
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
Review of Niko1221#2318 (Copilot findings 1, 3, 4, plus two paths of the same class).

A q2 per-channel pool is GROUP-MAJOR: one 10-byte slot per (32-cell group, head, dim), addressed
((cell/32)*n_head_kv + head)*head_dim + dim. "80 B per cell and head" is only the amortized rate of a
COMPLETE group, and a 4-cell page is not a byte range at all - its codes are spread over every dim slot of
its group. Five places treated it as page-granular:

- kv_plan (layer.cpp): the fully resident path sized the pool as ceil(max_cells/page_size) pages, so any
  context not divisible by 32 allocated the last group short (100 cells -> 8,000 B per KV head while group 3
  addresses up to byte 10,239). The page count is now rounded up to whole units (kv_stream_unit_blocks:
  32/page_size for q2/q2k, 1 for every other format) BEFORE it sizes the VRAM slots, the host copy, the
  staging pool and the page table. The streaming paths already rounded their slot counts; they are unchanged.
- elastic (VMM) pools (layer.cpp, opt-in): pool_slots rounds a grow up to whole units and
  pool_slots_mapped rounds the usable capacity down to them, so the last partial group can never be written
  past the mapped chunks. ElasticPool carries the unit; every non-q2 format keeps unit 1.
- kv_stage_from_host (kv_stream.cu): copied n_blocks*(len/unit) bytes, i.e. a prefix of the group's dim
  slots, not its pages. The block count is rounded up to whole units (inside the pool: n_blocks <= n_pages,
  which kv_plan now keeps unit-aligned).
- kv_unstage_to_host (kv_stream.cu, STRATA_KV_HOST_DMA=1): same defect in the reverse direction; [b0, b1) is
  widened to unit boundaries. The staging pool holds the whole group (staged from the host above).
- conversation snapshots: the image rounds its cell count up to whole groups for q2/q2k, and the reusable
  byte prefix of an incremental save rounds DOWN to whole groups (a partial group's slots change when any
  later cell of the group is written).

For unit == 1 formats every bound is exactly what it was. Verified: all six KV parity suites pass; the gate
pins for staging/unstaging land in a following commit and FAIL against the pre-fix kv_stream.cu.

(cherry picked from commit 20ae0cb)
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
…not of 32

Review of Niko1221#2318 (Copilot finding 2).

kv_append_q2_group_kernel computed s = sqrt(ss / 32) for every call. For a group whose first cells arrive in
a batch shorter than 32 (every non-32-aligned prompt-chunk boundary) and that has no stored scale yet, that
under-scales the group by sqrt(32/n) - 1.63x for n = 12 - and the partial-group rule then keeps that scale
when later chunks fill the group, so every code in it saturates against a scale that is too small. The
comment above the line already said the fresh group "takes the RMS of the cells the call has"; the code now
does: s = sqrt(ss / n).

Complete groups are bit-identical (n == 32, the same 1.0f/32.0f). Groups that already carry a scale, and
single-cell appends that borrow the previous group's scale, are unchanged.

(cherry picked from commit cd8e5fa)
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
… native one

Review of Niko1221#2318 (Copilot findings 5, 6).

The close-time repair rewrote the header's row count only when native_pack was set. An early EOS shortens a
non-native run just the same, and rows_written is counted on both paths, so a non-native dump could still
promise rows the file does not contain - the exact invariant tools/logits_dump_check.py enforces. The repair
now runs whenever hdr_written && rows_written != hdr[1], in both src/program/generate.cpp and
sycl/src/program/generate.cpp.

(cherry picked from commit c0d8549)
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
…heck, failure rows, pair scoring

Review of Niko1221#2318 (Copilot findings 7, 9, 10, 11, 12). Each reproduced first, then fixed:

- kv_cache_dump.py: a truncated SDKV body printed DEFECT and then crashed in d.layer() (np.frombuffer on
  missing planes). It now returns 1 after reporting the defect.
- logits_dump_check.py: a header with n_vocab == 0 divided by zero at (size - 8) % row_bytes. Non-positive
  n_vocab / negative n_rows now fail the check with a DEFECT line.
- kv_release_ab.py: the no-e validator only rejected " e" (a word starting with e), so "memory bandwidth"
  passed. It now rejects the letter anywhere.
- kv_recall_report.py: the engine-failure row had six columns while the report prints r[6] (IndexError);
  it now has seven.
- kv_recall_report.py: two codes are shared between the mid and tail vaults (90277, 18746), so substring
  scoring credited a tail fact when only the mid answer was given (a mid-only reply scored 2/4 tail). Recall
  is now scored by the instructed (vault, code) PAIR (vault_hits). Re-scoring the saved outputs of the last
  8-arm run with the pair scorer leaves every arm unchanged except q2 + window 1024, whose tail goes from 1/4
  to 0/4.
- kv_recall_report.py: the comment and report header said the tail facts sit inside the FP16 window. They do
  not: 2-5 filler paragraphs, the instructions and eight questions follow them (~180-350 tokens), so they are
  outside a 128-cell window and inside a 512/1024 one. The document is unchanged (published numbers stay
  reproducible); the description is corrected.

(cherry picked from commit 63726fe)
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
Review of Niko1221#2318 (Copilot finding 8).

The six "tail" codes were placed in the last six paragraphs and followed by the instructions and all twelve
questions, so the earliest sat several hundred tokens before generation - outside the 128-cell overlay - and
the "copy tail" column measured the bulk, not the window.

The document is now the body with the six mid codes; the mid codes are asked in one request, and each tail
code is asked in its own request whose prompt is the same body + that one record + its question (~40-55
tokens from the generation position). The tail requests share the body as a prefix, so the serve's
conversation cache keeps them cheap. Both columns are scored by the (module, code) pair.

(cherry picked from commit 38ea09d)
bsorensen110 added a commit to bsorensen110/Strata that referenced this pull request Oct 13, 2026
…-logits gap, the AsymKV citation

Review of Niko1221#2318 (Copilot findings 13, 14, 15, and two more of the same kind).

- Overlay footprint (docs/kv-window.md, kv_window.hpp): the slack slots were omitted and the arithmetic was
  ~2x off. (8 sinks + 128 window + 16 slack) slots x 2 heads x 256 dims x 2 B x 2 (K, V) = 311,296 B =
  304 KiB per layer, 3.56 MiB over 12 layers.
- q4_0 baseline: 576 B per cell and layer (6,912 B/cell over 12 layers) is 1.69 GiB at 262,144 cells, not
  906 MB. The 2026-10-11 README's comparison row had the same 2x error (288 KB per 1024 cells -> 576 KiB;
  "3.2% of a 32K-cell layer" -> 1.65%).
- 2026-10-11 README: the "known gap" said --dump-logits writes 0-byte files; it was fixed later in this PR
  and the KL/top-1 table is in 2026-10-12-kv-precision. Replaced with that pointer.
- 2026-10-11 README: the probe description said the tail codes sit in the last ~120 tokens, inside the
  window. As measured they sat outside it (see the kv_pr_report commit); a correction note now says so -
  every arm scored 12/12, so the table shows no regression, not an overlay effect.
- docs/kv-q2.md: AsymKV is arXiv 2410.13212 (Tao, Yu, Zhou), not 2411.17034 (a robotics paper). Checked
  against arxiv.org.

(cherry picked from commit 1f0b858)
…retest after the review fixes

The q2k device arm appends all cells in ONE call (whole groups), so it never runs the partial-group scale rule
the review fixes changed. Three engine-shaped arms now run on the same K/V and readers, against the same FP16
reference and the q4_0 row:

- chunked:        K prefilled in 100-cell chunks (every boundary splits a group) - a stress case; real prefill
                  chunks are 2048 cells, mid-group only after a reused conversation prefix
- prompt+decode:  an N-14-cell prompt (ends mid-group), then 14 single-cell decode appends (step-addressed)
- spec verify:    an aligned prompt, then 3-cell accepted verify windows through the batch append - what every
                  speculative decode does at each group boundary

Attention output vs FP16 (real dump; old = pre-fix kernel, fresh partial group / 32):

  layer   q4_0    one-shot   chunked old -> new   verify old -> new   prompt+decode
  L3      0.888%  1.910%     3.631% -> 2.076%     1.978% -> 1.912%    1.910%
  L11     0.740%  2.002%     3.617% -> 2.727%     2.030% -> 2.011%    2.002%
  L23     0.941%  2.211%     3.767% -> 2.509%     2.895% -> 2.667%    2.211%
  L47     1.040%  1.648%     1.438% -> 1.346%     1.457% -> 1.799%    1.648%
  syn     2.052%  5.710%     6.164% -> 5.859%     5.710% -> 5.710%    5.710%

Pinned: prompt + decode lands on the one-shot row (within 1%; it is exact to 3 decimals on every cache).
Measured, not pinned: chunked and verify. A 5% pin on them was tried and FAILS on layers 3/11/23 - a group first
written from a few cells keeps that small-sample RMS for life, unbiased since the /n fix but noisy. On the
extreme-outlier layer 47 an undersized scale acts as clipping and measures better (why the old kernel looks
better there). Recorded in docs/kv-q2.md.

Gate: synthetic 73/73, real layers 3/11/23/47 69/69 each.
(cherry picked from commit bb5d19f)
…p's - the batch path too

After the /n fix a group first written by a partial BATCH append (a speculative verify window crossing a group
boundary, a prefill chunk boundary after a reused prefix) carried the plain RMS of its 1..31 cells for life:
unbiased, but a small-sample estimate. Only single-cell appends could borrow the previous group's scale, because
in a batch the previous group may be written by the same launch.

The rule is now, for any fresh partial group with a reachable previous scale:

    s = sqrtf((ss + (32 - n) * prev * prev) / 32)

- the full-group RMS, the previous group's mean square standing in for the 32 - n cells not yet written. n == 32
is untouched (complete groups are bit-identical), and with no previous scale it falls back to the RMS of the n
cells. `prev` is taken race-free:
  - single-cell append: the stored scale of group g-1 (appends are ordered on one stream);
  - batch, group g-1 complete in THIS launch: recomputed in-thread from this call's cells with the same sum order
    as its block - exactly f16(sqrt(ss_prev/32)) - instead of reading the pool its block is writing;
  - batch, group g-1 not in this launch, or partial with a stored scale: the stored scale (stable).
Single-cell appends use the same formula with n = 1 (~97% prev): one rule for both paths.

Gate: new bit-exact pin "fresh partial group after a complete one shrinks toward the previous scale" - a 12-cell
append after a complete group, in two calls (prev read from the pool) and in one 44-cell call (prev recomputed
in-thread), must store the host's f16(sqrt((ss1 + 20*prev^2)/32)) and agree with each other bit for bit.

Attention output vs FP16 on the real dump (one-shot q2k: L3 1.910 / L11 2.002 / L23 2.211 / L47 1.648 %):
  spec verify (3-cell windows), /n -> shrink:  1.912 -> 1.892, 2.011 -> 2.012, 2.667 -> 2.245, 1.799 -> 1.720
    summed excess over one-shot: +8.0% -> +1.3%
  chunked (100-cell chunks, stress case):      2.076 -> 2.186, 2.727 -> 2.626, 2.509 -> 2.161, 1.346 -> 1.803
    mixed by layer, 1.4% worse summed than /n, 30% better than the pre-fix kernel
  prompt + decode: unchanged (exact on the one-shot row)

All six KV parity suites pass; kv_q2_parity synthetic 39/39, real layers 3/11/23/47 36/36 each. End to end on
the dev build carrying the same kernel: a cold 199,337-token prompt prefilled at 2529 tok/s under --kv q2k,
3/3 needles recalled at ~25/50/90% depth.
@Tsury

Tsury commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Validated the overlay-only INT8 path from1aee5c90ffa6f0ddfb40c22db51ea3aacc1f66e8, which is unchanged for this path at head b47ec5b. CUDA13.3/SM120, window128/sinks8, resident identity page table, H=2 and D=256. The q2/q2k additions were not exercised.

Both the existing window parity gate and this small INT8 adaptation pass. Fresh sink/recent attention is bit-identical to the FP16 reference; cold reads are unchanged. Batch, single-step and six-step speculative writers agree. Restore equals half(INT8 dequantization), as expected, rather than recovering original FP16. In the seeded synthetic fixture, sink/recent K RMS error goes0.573%/0.575% to0%; cold error remains0.592%;134021/139264restored hot K/V values differ from original FP16. These are synthetic precision observations, not model-quality claims.

Reproduce by copying src/kernels/kv_window_parity.cpp to src/kernels/kv_window_int8_parity.cpp, applying the patch below, and adding a target alongside kv_window_parity:

add_executable(kv_window_int8_parity src/kernels/kv_window_int8_parity.cpp)
target_include_directories(kv_window_int8_parity PRIVATE )
target_link_libraries(kv_window_int8_parity PRIVATE strata_kernels )

Build with STRATA_ENABLE_CUDA=ON and STRATA_BUILD_TESTS=ON, then run kv_window_int8_parity. The existing kv_q8_parity, kv_stream_parity and conversation_snapshot_test gates also pass. The restored-precision limitation matches the documented snapshot behavior.

Synthetic INT8 parity adaptation; no model or private inputs
--- a/src/kernels/kv_window_int8_parity.cpp
+++ b/src/kernels/kv_window_int8_parity.cpp
@@ -1,4 +1,4 @@
-// src/kernels/kv_window_parity.cpp - the FP16 sliding window + attention sinks (kv_window.hpp) against a
+// src/kernels/kv_window_int8_parity.cpp - the FP16 sliding window + attention sinks (kv_window.hpp) against a
 // full-precision reference and against the quantized path it overlays (GPU, no model).
 //
 // The pools are built by hand: a Q4_0 pool and the FP16 overlay on one side, an FP16 pool holding the same cells on
@@ -10,13 +10,14 @@
 //      is untouched;
 //   4. kv_window_restore reproduces the bulk's own dequantization bitwise for the sinks and for the window.
 #include "strata/kernels/f16_bits.hpp"
-#include "strata/kernels/kv_q4.hpp"
+#include "strata/kernels/kv_q8.hpp"
 #include "strata/kernels/kv_window.hpp"
 #include "strata/kernels/qsa.hpp"
 #include "strata/kernels/qsa_decode_attn.hpp"
 
 #include <cuda_runtime.h>
 
+#include <algorithm>
 #include <cmath>
 #include <cstdio>
 #include <cstring>
@@ -59,7 +60,7 @@
     const int pages = N / P;
     const k::KvWindowShape shape{WINDOW, SINKS};
     const int rows = N * H;                    // one row per (cell, kv head) in the identity layout
-    const size_t q4_bytes = (size_t) rows * k::kv_q4_bytes_per_head(D);
+    const size_t q4_bytes = (size_t) rows * D;
 
     std::mt19937 rng(23);
     std::normal_distribution<float> nd(0.f, 1.f);
@@ -77,7 +78,8 @@
     int32_t* d_table = dalloc<int32_t>(pages);
     ck(cudaMemcpy(d_table, table.data(), pages * 4, cudaMemcpyHostToDevice), "table");
 
-    uint8_t *kq4 = dalloc<uint8_t>(q4_bytes), *vq4 = dalloc<uint8_t>(q4_bytes);
+    int8_t *kq4 = dalloc<int8_t>(q4_bytes), *vq4 = dalloc<int8_t>(q4_bytes);
+    uint16_t *ks = dalloc<uint16_t>((size_t) rows * (D / 64)), *vs = dalloc<uint16_t>((size_t) rows * (D / 64));
     uint16_t *kp = dalloc<uint16_t>((size_t) rows * D), *vp = dalloc<uint16_t>((size_t) rows * D);
     const int64_t win_rows = k::kv_window_slots(shape) * H * D;
     uint16_t* kw = dalloc<uint16_t>((size_t) win_rows);
@@ -95,7 +97,7 @@
         ck(cudaMemcpy(vcur, vc, (size_t) H * D * 4, cudaMemcpyHostToDevice), "v");
         int32_t hstep[k::kStepCount] = {cell, cell + 1, 0, 0};
         ck(cudaMemcpy(step, hstep, sizeof hstep, cudaMemcpyHostToDevice), "step");
-        k::kv_append_q4_step(kq4, vq4, d_table, step, kcur, vcur, s, nullptr, nullptr);
+        k::kv_append_q8_step(kq4, vq4, ks, vs, d_table, step, kcur, vcur, s, nullptr, nullptr);
         k::kv_append_step(kp, vp, d_table, step, kcur, vcur, s, nullptr, nullptr);
     }
     ck(cudaDeviceSynchronize(), "appends");
@@ -128,8 +130,8 @@
             p.k_pool = kp;
             p.v_pool = vp;
         } else {
-            p.k_q4 = kq4;
-            p.v_q4 = vq4;
+            p.k_q = kq4; p.k_scale = ks;
+            p.v_q = vq4; p.v_scale = vs;
         }
         if (overlay) {
             p.k_win = kw;
@@ -153,7 +155,7 @@
     const std::vector<float> a_win = run(hot, true, false);
     const std::vector<float> a_q4 = run(hot, false, false);
     check(max_diff(a_win, a_ref) == 0.0f, "sinks + window read exactly (bitwise equal to the FP16 pool)");
-    check(max_diff(a_q4, a_ref) > 0.0f, "q4_0 alone is not exact (the overlay is what removes the rounding)");
+    check(max_diff(a_q4, a_ref) > 0.0f, "INT8 alone is not exact (the overlay is what removes the rounding)");
 
     // 3. cells outside the window and the sinks: the overlay changes nothing
     std::vector<int32_t> cold;
@@ -168,18 +170,21 @@
     ck(cudaMemset(vw, 0, (size_t) win_rows * 2), "clear v overlay");
     k::QsaAttnPools bulk;
     bulk.page_table = d_table;
-    bulk.k_q4 = kq4;
-    bulk.v_q4 = vq4;
-    k::kv_window_restore({kw, vw}, shape, bulk, 2, N, s, nullptr);
+    bulk.k_q = kq4; bulk.k_scale = ks;
+    bulk.v_q = vq4; bulk.v_scale = vs;
+    k::kv_window_restore({kw, vw}, shape, bulk, 1, N, s, nullptr);
     ck(cudaDeviceSynchronize(), "restore");
-    std::vector<uint8_t> hq4(q4_bytes), hv4(q4_bytes);
+    std::vector<int8_t> hq4(q4_bytes), hv4(q4_bytes);
+    std::vector<uint16_t> hks((size_t) rows * (D/64)), hvs((size_t) rows * (D/64));
+    ck(cudaMemcpy(hks.data(), ks, hks.size()*2, cudaMemcpyDeviceToHost), "read K scales");
+    ck(cudaMemcpy(hvs.data(), vs, hvs.size()*2, cudaMemcpyDeviceToHost), "read V scales");
     ck(cudaMemcpy(hq4.data(), kq4, q4_bytes, cudaMemcpyDeviceToHost), "read k q4");
     ck(cudaMemcpy(hv4.data(), vq4, q4_bytes, cudaMemcpyDeviceToHost), "read v q4");
     std::vector<uint16_t> hkwin((size_t) win_rows), hvwin((size_t) win_rows);
     ck(cudaMemcpy(hkwin.data(), kw, (size_t) win_rows * 2, cudaMemcpyDeviceToHost), "read k overlay");
     ck(cudaMemcpy(hvwin.data(), vw, (size_t) win_rows * 2, cudaMemcpyDeviceToHost), "read v overlay");
     int mismatch = 0;
-    const int row_bytes = (int) k::kv_q4_bytes_per_head(D);
+    const int row_bytes = D;
     for (int cell = 0; cell < N; ++cell) {
         const int32_t slot = k::kv_window_slot(cell, N, WINDOW, SINKS);
         if (slot < 0) continue;
@@ -188,23 +193,49 @@
             const int row = (cell / P * H + h) * P + (cell % P);
             const int64_t dst = ((int64_t) slot * H + h) * D;
             for (int d = 0; d < D; ++d) {
-                const int rem = d % 32, blk = d / 32;
-                const uint8_t* kb = hq4.data() + (size_t) row * row_bytes + (size_t) blk * sizeof(k::block_q4_0);
-                const uint8_t* kb_d = kb;
-                const uint16_t kd = (uint16_t) (kb_d[0] | (kb_d[1] << 8));
-                const uint8_t* knib = kb_d + 2;
-                const int kcode = rem < 16 ? (knib[rem] & 0x0F) : (knib[rem - 16] >> 4);
-                const uint16_t want_k = k::f16_from_f32((float) (kcode - 8) * k::f32_from_f16(kd));
-                const uint8_t* vb = hv4.data() + (size_t) row * row_bytes + (size_t) blk * sizeof(k::block_q4_0);
-                const uint16_t vd = (uint16_t) (vb[0] | (vb[1] << 8));
-                const uint8_t* vnib = vb + 2;
-                const int vcode = rem < 16 ? (vnib[rem] & 0x0F) : (vnib[rem - 16] >> 4);
-                const uint16_t want_v = k::f16_from_f32((float) (vcode - 8) * k::f32_from_f16(vd));
+                const uint16_t want_k = k::f16_from_f32((float) hq4[(size_t)row*D+d] * k::f32_from_f16(hks[(size_t)row*(D/64)+d/64]));
+                const uint16_t want_v = k::f16_from_f32((float) hv4[(size_t)row*D+d] * k::f32_from_f16(hvs[(size_t)row*(D/64)+d/64]));
                 if (hkwin[dst + d] != want_k || hvwin[dst + d] != want_v) ++mismatch;
             }
         }
     }
     check(mismatch == 0, "kv_window_restore reproduces the bulk dequantization (bitwise)");
+    std::vector<uint16_t> exact_k((size_t)rows*D), exact_v((size_t)rows*D);
+    ck(cudaMemcpy(exact_k.data(), kp, exact_k.size()*2, cudaMemcpyDeviceToHost), "read exact K");
+    ck(cudaMemcpy(exact_v.data(), vp, exact_v.size()*2, cudaMemcpyDeviceToHost), "read exact V");
+    size_t loss=0;
+    for(int cell=0;cell<N;++cell) {
+        const int slot=k::kv_window_slot(cell,N,WINDOW,SINKS);
+        if(slot<0) continue;
+        for(int h=0;h<H;++h) for(int d=0;d<D;++d) {
+            const int row=(cell/P*H+h)*P+cell%P;
+            const size_t dst=((size_t)slot*H+h)*D+d;
+            loss+=(hkwin[dst]!=exact_k[(size_t)row*D+d]);
+            loss+=(hvwin[dst]!=exact_v[(size_t)row*D+d]);
+        }
+    }
+    check(loss>0,"restored hot cells lose original FP16 precision (INT8 bulk reconstructed)");
+    std::printf("restored hot K/V values differing from originalFP16: %zu\n",loss);
+    // Batch, single-step and speculative multi-step writers must agree on every live overlay cell.
+    std::vector<int32_t> all_steps((size_t)N*k::kStepCount);
+    for(int cell=0;cell<N;++cell) {all_steps[(size_t)cell*k::kStepCount]=cell;all_steps[(size_t)cell*k::kStepCount+1]=cell+1;}
+    int32_t* steps_dev=dalloc<int32_t>(all_steps.size());
+    ck(cudaMemcpy(steps_dev,all_steps.data(),all_steps.size()*4,cudaMemcpyHostToDevice),"steps");
+    uint16_t* swk=dalloc<uint16_t>((size_t)win_rows);uint16_t* swv=dalloc<uint16_t>((size_t)win_rows);
+    for(int cell=0;cell<N;++cell) k::kv_window_write_step({swk,swv},shape,steps_dev+(size_t)cell*k::kStepCount,dK+(size_t)cell*H*D,dV+(size_t)cell*H*D,s,nullptr);
+    std::vector<uint16_t> single_k((size_t)win_rows),single_v((size_t)win_rows),multi_k((size_t)win_rows),multi_v((size_t)win_rows),batch_k((size_t)win_rows),batch_v((size_t)win_rows);
+    ck(cudaMemcpy(single_k.data(),swk,win_rows*2,cudaMemcpyDeviceToHost),"singleK");ck(cudaMemcpy(single_v.data(),swv,win_rows*2,cudaMemcpyDeviceToHost),"singleV");
+    ck(cudaMemset(swk,0,win_rows*2),"clear multiK");ck(cudaMemset(swv,0,win_rows*2),"clear multiV");
+    for(int cell=0;cell<N;cell+=6) k::kv_window_write_steps({swk,swv},shape,steps_dev+(size_t)cell*k::kStepCount,k::kStepCount,std::min(6,N-cell),dK+(size_t)cell*H*D,dV+(size_t)cell*H*D,s,nullptr);
+    ck(cudaMemcpy(multi_k.data(),swk,win_rows*2,cudaMemcpyDeviceToHost),"multiK");ck(cudaMemcpy(multi_v.data(),swv,win_rows*2,cudaMemcpyDeviceToHost),"multiV");
+    k::kv_window_write_batch({swk,swv},shape,0,N,dK,dV,s,nullptr);
+    ck(cudaMemcpy(batch_k.data(),swk,win_rows*2,cudaMemcpyDeviceToHost),"batchK");ck(cudaMemcpy(batch_v.data(),swv,win_rows*2,cudaMemcpyDeviceToHost),"batchV");
+    bool writers=true;
+    for(int cell=0;cell<N;++cell){int slot=k::kv_window_slot(cell,N,WINDOW,SINKS);if(slot<0)continue;
+        for(int h=0;h<H;++h) for(int d=0;d<D;++d){size_t at=((size_t)slot*H+h)*D+d;
+            writers=writers && single_k[at]==batch_k[at] && multi_k[at]==batch_k[at] && single_v[at]==batch_v[at] && multi_v[at]==batch_v[at];}}
+    check(writers,"promptbatch/singleappend/speculative6step writers agree on every live hot cell");
+
     // Membership, as the properties the readers rely on: a cell is in the overlay exactly when it is a sink or
     // inside the window; every slot is in range; and the sinks and the window together own DISTINCT slots - a
     // collision would have two live cells reading each other's K/V.
@@ -239,8 +270,8 @@
         ck(cudaMemcpy(step, hstep, sizeof hstep, cudaMemcpyHostToDevice), "step");
         k::QsaAttnPools p0, p1;
         p0.page_table = d_table;
-        p0.k_q4 = kq4;
-        p0.v_q4 = vq4;
+        p0.k_q = kq4; p0.k_scale = ks;
+        p0.v_q = vq4; p0.v_scale = vs;
         p1 = p0;
         p1.k_win = kw;
         p1.v_win = vw;
@@ -266,14 +297,14 @@
         const double off = time_it(p0, 300), on = time_it(p1, 300);
         std::printf("\ndecode attention over %d cells: %.3f ms without the overlay, %.3f ms with it (%.1f%%)\n",
                     cap2, off, on, 100.0 * (on - off) / off);
-        check(on / off < 1.25, "the overlay costs less than 25% of the attention kernel");
+        std::printf("attention cost is a measured tradeoff, not a local rejection threshold\n");
     }
 
     // 6. quality in error terms, for the pull request: how far each stored form is from an FP16 cache, and what the
     // overlay changes about it - reported per position bucket, since the overlay only covers two of them.
     {
         std::vector<uint16_t> hk((size_t) rows * D), hv((size_t) rows * D), hw((size_t) win_rows), hvw((size_t) win_rows);
-        std::vector<uint8_t> hq4(q4_bytes);
+        std::vector<int8_t> hq4(q4_bytes);
         // the restore above overwrote the overlay with dequantized bulk; put the appends' exact copies back, since
         // this section measures what a running cache holds
         k::kv_window_write_batch({kw, vw}, shape, 0, N, dK, dV, s, nullptr);
@@ -283,13 +314,13 @@
         ck(cudaMemcpy(hq4.data(), kq4, q4_bytes, cudaMemcpyDeviceToHost), "read q4 pool");
         ck(cudaMemcpy(hw.data(), kw, (size_t) win_rows * 2, cudaMemcpyDeviceToHost), "read overlay");
         ck(cudaMemcpy(hvw.data(), vw, (size_t) win_rows * 2, cudaMemcpyDeviceToHost), "read overlay");
-        const int row_bytes = (int) k::kv_q4_bytes_per_head(D);
+        const int row_bytes = D;
         struct Bucket { const char* name; int lo, hi; };
         const Bucket buckets[3] = {{"sinks (first 8)", 0, SINKS},
                                    {"window (last 128)", N - WINDOW, N},
                                    {"bulk (the rest)", SINKS, N - WINDOW}};
         std::printf("\nK error against an FP16 cache (V is stored and read the same way), relative RMS:\n");
-        std::printf("  %-18s %14s %18s\n", "bucket", "q4_0", "q4_0 + overlay");
+        std::printf("  %-18s %14s %18s\n", "bucket", "INT8", "INT8 + overlay");
         for (const Bucket& b : buckets) {
             double eq = 0.0, er = 0.0, ref = 0.0;
             long long n = 0;
@@ -300,11 +331,7 @@
                     const int64_t dst = slot >= 0 ? ((int64_t) slot * H + h) * D : 0;
                     for (int d = 0; d < D; ++d) {
                         const float exact = k::f32_from_f16(hk[(size_t) row * D + d]);   // the FP16 pool = reference
-                        const int blk = d / 32, rem = d % 32;
-                        const uint8_t* qb = hq4.data() + (size_t) row * row_bytes + (size_t) blk * sizeof(k::block_q4_0);
-                        const uint16_t qd = (uint16_t) (qb[0] | (qb[1] << 8));
-                        const int code = rem < 16 ? (qb[2 + rem] & 0x0F) : (qb[2 + rem - 16] >> 4);
-                        const double q4 = (double) (code - 8) * k::f32_from_f16(qd);
+                        const double q4 = (double) hq4[(size_t)row*D+d] * k::f32_from_f16(hks[(size_t)row*(D/64)+d/64]);
                         const double w4 = slot >= 0 ? k::f32_from_f16(hw[dst + d]) : q4;   // what the readers see
                         eq += (q4 - exact) * (q4 - exact);
                         er += (w4 - exact) * (w4 - exact);
@@ -319,14 +346,14 @@
             if (b.lo != SINKS || b.hi != N - WINDOW)
                 check(rms_w == 0.0, "     that bucket reads exactly FP16 with the overlay (0.000%)");
             else
-                check(rms_w == rms_q4, "     the bulk is untouched by the overlay (same error as q4_0)");
+                check(rms_w == rms_q4, "     the bulk is untouched by the overlay (same error as INT8)");
         }
         std::printf("  footprint: overlay %lld KB per layer for %d cells (%d sinks + %d window + %d slack); "
-                    "q4_0 bulk %zu KB for %d cells\n",
+                    "INT8 bulk %zu KB for %d cells\n",
                     (long long) (k::kv_window_bytes(s, shape) / 1024), (int) k::kv_window_slots(shape), SINKS, WINDOW,
                     (int) k::kKvWindowSlack, q4_bytes / 1024, N);
     }
 
-    std::printf(g_fail == 0 ? "\nkv_window_parity: PASS\n" : "\nkv_window_parity: %d FAILURES\n", g_fail);
+    std::printf(g_fail == 0 ? "\nkv_window_int8_parity: PASS\n" : "\nkv_window_int8_parity: %d FAILURES\n", g_fail);
     return g_fail == 0 ? 0 : 1;
 }

monscoro added a commit to monscoro/Strata that referenced this pull request Oct 14, 2026
…not a byte layout

The question the survey left open: the fixed orthonormal FWHT-256 already costs
0.30 dB of codebook error at 4 bits, and WUSH-KV's calibration buys ~nothing
there (docs/KV_QUANT_RESEARCH.md 6.9), so a leap has to come from bit
allocation - and the field disagrees on which tensor wants the bits (llama.cpp
PR Niko1221#2318: K2c+V4 > K4+V2c, while arXiv 2502.15075 has K4V2 keeping 98.3% of
K4V4). Before a 3-bit layout is built, this measures the 3-bit *codebook* in the
4-bit *bytes*: the same 18-byte block, rotation and readers, with Max-Lloyd's
8-level table for N(0,1) ({-69,-43,-24,-8,8,24,43,69} x32, d = max|x|/69) in
place of the 16-level one.

Three modes, so the tensor question is asked at an equal average width (3.5
bits): 8 on both, 8 on K / 16 on V, 16 on K / 8 on V.

  Q4Mode::kLloyd8 / kLloyd8K / kLloyd8V (KvFormat kKvQ4lm8/8K/8V, kv_fmt_q4)
  q4l_group<FIT, N> with the table as N, and a per-tensor map (q4l_map_k/v)
  that the fused readers pick at runtime from the state's own mode - so the
  reader stays one template and the K and V halves of a split arm are read with
  the two tables they were written with.  The 16-level paths are unchanged:
  kv_q4_parity is 7/7 with the same numbers as before (0.30 dB, 0.61 dB, 78.8%).

CUDA/HIP only on purpose: the SYCL port has its own writer (kQ4lMapDev) and its
--kv validation rejects the new flags with exit 2, so no backend can silently
mis-read them.  This is research tooling on KV_QUANT_research and is dropped
before the WP-1/WP-2 PR.

Measured (kv_q4_parity test 7, synthetic N(0,1), 498,688 rotated values,
gfx1200), MSE next to q4lm32's 2.740e-02:
  q4lm8   8/8   1.259e-01   the whole 3-bit/4-bit codebook gap, ~-6.6 dB
  q4lm8k  8/16  7.672e-02
  q4lm8v  16/8  7.662e-02   0.1% from q4lm8k: on the codebook alone the two K/V
                            directions are indistinguishable, so only the model
                            can decide (the cwe-2k sweep, WP-2)
monscoro added a commit to monscoro/Strata that referenced this pull request Oct 14, 2026
The probe's decision test ran on cwe-2k with a fresh fp16 reference and the
q4lm32/int8 anchors, and it self-diagnoses exactly: q4lm32 reproduces KL 0.02022
and int8 0.01033 to the digit, fp16 PPL 3.06695 against 3.0669.  So the per-tensor
table wiring is proven and cwe-2k reproduces across sessions.

Result.  Whole-segment KL against q4lm32 0.02022: q4lm8v (K16/V8) 0.02619 (t
-2.39, 1/8 up), q4lm8k (K8/V16) 0.02748 (t -3.25, 0/8), q4lm8 (K8/V8) 0.03328
(t -5.04, 0/8).  The direct byte-exact pair q4lm8k vs q4lm8v is +0.00129, t +0.60,
4/8 - not resolved by that readout alone.  The answer region resolves it
categorically and only along K: every 16-level-K arm reads 90.98%, every 8-level-K
arm 89.10%, whatever V does.  Exact McNemar (areport --paired, new here):
q4lm32 vs q4lm8v 2:2 p 1.000 - cutting V 16->8 leaves the task untouched;
q4lm8k vs q4lm8v 0:5 p 0.062 - cutting K 16->8 costs it.  And PPL is blind:
q4lm8 reads +0.81% against q4lm32's +0.86% while its KL is 65% higher.

So the direction is K wants the bits - the direction of arXiv 2502.15075 and of
the shipped k8v4 (Niko1221#120), and against PR Niko1221#2318's headline.  Stated as a direction,
not an established effect: no single readout crosses the conventional threshold
at 8 segments, but all three agree in sign.

Measurement basis.  This is Q2_0, and Q2_0 is the damped regime (+10.4% PPL vs
Q8_0), so these gaps are a lower bound.  From here the basis is Swift IQ3_XXS and
the K-vs-V decision is repeated there.  Recorded in KV_QUANT_WORKPACKAGE 7.5/8 and
KV_QUANT_RESEARCH 6.11; bench README carries the same caveat.
… score shrink, codes unchanged, zero bytes

The 4-level Lloyd-Max codebook is MSE-optimal, and an MSE-optimal quantizer shrinks its reconstruction: on real
queries the q2k attention scores come out at 0.92-0.97x the exact ones (q4_0: 0.997-0.998). Alone that is mostly
common-mode, but next to EXACT cells - the --kv-window overlay and the sinks - it is a bias: the exact cells
out-score the shrunk bulk and the softmax over-weights them (+1.7..+5.9% of the attention mass on 11 of 12 layers).
That is why the window helped q4_0 at decode and hurt q2k.

  - kv_q2.cu: a per-layer gain `k_gain` (the new last parameter of kv_append_q2 / _step / _steps, default 1.0).
    The K plane STORES k_gain x the scale the rule computes; the codes are chosen against the rule's (un-gained)
    scale, so the code assignment is unchanged and only the reconstruction grows. The rule runs in the raw domain
    (a stored previous scale is divided by the gain before the partial-group shrink uses it; a previous group
    completed in the same launch is recomputed with the gain, as stored). V never gets a gain; with 1.0 every
    multiply by the gain or its inverse is exact, so the default is bit-identical to the rule without it.
  - QsaState::kv_k_gain + a model-layer table (qsa_set_kv_k_gain / qsa_kv_k_gain); session_init copies it into
    each main layer; every main-layer q2 K append (decode step, verify batch and per-token, prompt prefill) passes
    it. The MTP drafter's ring keeps 1.0.
  - generate: --kv-k-gain SPEC - a bare B (every QSA layer) and/or L:B items (model layers, named last wins, a
    named L:1.0 turns L back off), 1.0 <= B <= 1.5. Refused by name (exit 2): B out of range, a layer that is not
    a QSA layer of the model, a non-1.0 gain without --kv q2 / q2k. Startup line: "--kv-k-gain: K reconstruction
    gain (default 1.100) - the stored K scale is beta times the rule's, codes unchanged, V untouched".
  - kv_q2_parity: "K reconstruction gain 1.1" - stored scale = beta x the rule (complete group) and beta x the
    raw-domain shrink (fresh partial group), bit for bit, in one call or two; codes chosen against the UN-gained
    scale (0 group-0 codes differ). 39 -> 40 checks.

  - docs/kv-q2.md: the mechanism, the contract, the decode-path table below and the recommended flags.

Verified on this tree: all six KV parity suites pass (kv_q2_parity 40/40, kv_window_parity 10/10). Prompt logits
(4108-token prompt) are byte-identical to the development branch's build under the same flags, for --kv q2k and for
--kv q2k --kv-window 256 --kv-sink 8 --kv-k-gain 1.10, and the gain changes them. --kv-k-gain 1.0 is byte-identical
to no flag; `1.10,7:1.0` differs from `1.10` (layer 7 back off); the five refusals exit 2 by name.

Decode-path KL vs FP16 (teacher-forced, 128 forced tokens, nine texts, STRATA_PREFILL_CPU_SHARE=0; measured on this
build with a temporary copy of the forced-decode tooling, not part of this PR; geometric-mean change vs plain q2k):
  q4_0                          0.0198   -89.9%  9/9
  q4_0 + window 128             0.0174   -91.9%  9/9
  q2k                           0.1825
  q2k + window 128              0.2060   +10.3%  4/9   (the shrink: exact cells over-weighted)
  q2k + window 128 + gain 1.10  0.1338   -29.3%  7/9
  q2k + window 256 + gain 1.10  0.1203   -36.8%  8/9   recommended: --kv-window 256 --kv-sink 8 --kv-k-gain 1.10
@qiuzan2001

Copy link
Copy Markdown

Build points from reading the branch (not built here): (1) SYCL: the shared header include/strata/kernels/kv_stream.hpp:137 now declares kv_ring_table(..., void* stream, int unit = 1), but the SYCL tree (which has no kv_stream.hpp of its own) still defines only the 4-argument version at sycl/src/kernels/cuda/kv_stream.dp.cpp:394, and sycl/src/core/layer.cpp:975, sycl/src/kernels/kv_stream_parity.cpp:307 and sycl/src/core/conversation_snapshot_test.cpp:103 call it with 4 arguments, which bind to the new 5-argument declaration that nothing in that tree defines (undefined symbol at link). (2) Metal compiles src/core/layer.cpp, verify.cpp, mtp.cpp and conversation_snapshot.cpp, which now call kv_append_q2*, kv_gather_q2*, kv_window_* and the new kv_ring_table; metal/src/engine/stubs.cpp has none of them. (3) The PR also carries the --dump-logits header repair on every path and root-level ab-*.sh scripts (Windows taskkill/schtasks, port 8083), which are separate from the KV formats.

include/strata/kernels/kv_stream.hpp now declares
kv_ring_table(page_table, n_blocks, n_slots, stream, int unit = 1), and the
shared host sources call it with that signature. The SYCL definition still
had the four-argument form, so the default-argument call bound to a
declaration nothing in the SYCL tree defines (an undefined symbol at link).

The SYCL ring kernel takes the same unit as the CUDA one and computes
(i / unit) % (n_slots / unit) * unit + i % unit. With unit == 1 that is
i % n_slots, the table the four-argument form built, so every existing
caller gets the same table. Not compiled here: no DPC++ toolchain on this
machine.
The shared host sources that Metal compiles (src/core/layer.cpp, verify.cpp,
mtp.cpp, conversation_snapshot.cpp, src/prefill/prefill.cpp) now call the
q2/q2k append and gather kernels, the window's write and restore, and the
five-argument kv_ring_table. metal/src/engine/stubs.cpp has none of them, so
the strata link would fail on undefined symbols.

They go in a hand-written stubs_kv_q2.cpp (CMake already globs
src/engine/*.cpp), not in the generated stubs.cpp: each stops the engine with
its name, as the others do, and stubs.cpp is regenerated from a link log on a
Mac. The new kv_ring_table stub has the header's five-argument form; the old
four-argument stub in stubs.cpp is not called by anything and goes away on the
next regeneration.

kv_unstage_to_host is declared in kv_stream.hpp and called by
src/prefill/prefill.cpp, and the base tree has no stub for it either, so it is
covered here as well.

Checked on Windows, MSVC 14.44, compile only: stubs_kv_q2.cpp compiles clean at
/W3, and a TU that takes the address of each stubbed name compiles, which
fails if a stub's parameters differ from the header's (a negative control with
the old four-argument signature fails as expected). Not linked: the Metal link
needs macOS (ld64) and was not run.
@Niko1221

Copy link
Copy Markdown
Owner

Thanks for the detailed tables. It saves K/V memory rather than time and changes the numerics, so it is outside this speed round and not ported now. It would need our KL and needle checks before it can be offered as an experimental option.

@xyzzing

xyzzing commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Numbers from a 7900 XTX (gfx1100) that bear on the K side of the proposal (engine 0.1.45-8eb3e85d, IQ3_S, 256
generated tokens):

  • Per cell per layer the tree's formats are: int8 K+V 1056 B, K8V4 816 B (K is 512 B of it plus 16 B scales;
    V ~288 B), a hypothetical rotated K4V4 ~544 B. The bytes are in K, so a 2-bit K is where the saving is.
  • Format alone, same ring: K8V4 buys only +2.3 % decode @128k (-1.6 % @32k) and costs -24 % prefill
    (1,834 vs 2,408 tok/s) and +17 s TTFT at 128K — the V-Q4 hybrid path is slow in prefill attention. Whatever K
    format lands, the prefill path needs to support it before the format shows a win.
  • Residency alone, same format: ring 32,768 -> 49,152 = +21.6 % @128k / +11.7 % @32k with prefill untouched.

So the interesting direction is a K-side format that is fast in prefill; a V-side-only change is not, on this card.

@Niko1221

Copy link
Copy Markdown
Owner

Thanks for the pull request.

We're resetting the tracker for 0.2.0-beta. There are more than 1,300 open issues and pull requests, too many for us to review properly, so we're closing everything that isn't being worked on right now. Closed here does not mean rejected.

To reopen it takes about two minutes:

  1. Rebase on main if it no longer applies, and re-run your checks on 0.2.0-beta.
  2. Start the title with its area: [Engine], [GUI], [TUI], [Benchmark], [Docs] or [Other].
  3. Fill in the pull request template: the problem and result, and the validation (what you ran, on which revision, what you saw).
  4. Comment /reopen. A check runs and either reopens the pull request or lists what is still missing.

The rules are in CONTRIBUTING.md.

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.

6 participants