Repository navigation
KV cache: FP16 sliding window + attention sinks, and an optional 2-bit K / 4-bit V cache scheme (--kv q2k) [EXPERIMENTAL] - #2318
bsorensen110 wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Group-granular q2 sizing, partial-group scaling, and staging copies can corrupt or incompletely populate KV data.
15 open findings
Round pool capacity up to complete q2 groups · New Divide partial-group scale by available cell count · New Copy complete q2 residency units during staging · New Copy complete q2 units during reverse DMA · New Repair headers for shortened non-native runs · New Repair dump headers for all shortened runs · New Return cleanly after detecting truncated dump bodies · New Keep tail probes within the overlay window · New Handle six-column failure rows safely · New Match vault scores to the instructed code pair · New Reject responses containing the letter e · New Reject non-positive vocabulary dimensions · New Remove the stale dump-logits known-gap claim · New Correct overlay and q4_0 footprint calculations · New Include slack slots in footprint estimates · New
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.
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.
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)
…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)
… 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)
…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)
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)
…-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.
|
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;
} |
…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)
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
|
Build points from reading the branch (not built here): (1) SYCL: the shared header |
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.
|
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. |
|
Numbers from a 7900 XTX (gfx1100) that bear on the K side of the proposal (engine 0.1.45-8eb3e85d, IQ3_S, 256
So the interesting direction is a K-side format that is fast in prefill; a V-side-only change is not, on this card. |
|
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:
The rules are in CONTRIBUTING.md. |



KV cache: FP16 sliding window + attention sinks, and an optional 2-bit K / 4-bit V cache scheme (
--kv q2k) [EXPERIMENTAL]Summary
Two composable KV-cache features behind opt-in flags, plus a correction that makes them work together:
--kv-window N(--kv-sink S) — an FP16 overlay over the firstScells (attention sinks) and the most recentNcells 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).--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).--kv-k-gain B(q2/q2k) — a K reconstruction gain: the stored K scale isB× 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.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:q2k)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: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.
--kv-k-gainstores the K scaleB× 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).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_restorereproduces 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)
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_paritynow 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):/nand +1.3% with shrink./n) and 30% better than the pre-review kernel.Logits (512-token prompts, prompt mode, vs FP16: top-1 agreement / mean KL) — re-measured with the CPU share off
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.
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.
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)
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)
Reference papers
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:
/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).previs 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-logitsheader repair on every path, not only the native pack (CUDA and SYCL).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.tools/kv_bench_env.py: repo root,Strata-datanext to it,strata-<model>.json, engines underengine/andbuild/) with an environment override for each; the READMEs carry a generic CUDA 13.0 build recipe instead of local scripts.Known limits
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.Commits
kv_window— FP16 sliding window + attention sinks over the quantized KV cachekv_q2— 2-bit per-channel KV format, parity gate, measurement toolingkv_q2k— K 2-bit per-channel + V 4-bit mixed-width formatkv_q2— whole 32-cell groups wherever a q2/q2k pool is sized or movedkv_q2— a fresh partial group's scale is the RMS of the cells it haskv_q2_parity— pins for the review fixes, and the inverted K-vs-V labelsgenerate— repair the--dump-logitsheader on every pathtools— crash fixes and (vault, code) pair scoringkv_pr_report— tail probes inside the windowdocs— footprint arithmetic, the tail-probe correction, the stale dump-logits gap, the AsymKV citationtools + scripts— no machine-specific paths: repo-relative defaults, env overridesdocs— a generic CUDA 13.0 build recipe instead of local paths and scriptskv_q2_parity— q2k under the engine's append patterns (spec verify, chunked prefill, prompt + decode)kv_q2— a fresh partial group's scale shrinks toward the previous group's, in the batch path tookv_q2— K reconstruction gain (--kv-k-gain): undo the 2-bit score shrink, codes unchanged, zero bytes