Skip to content

poly: --kv int8 (and int8k) — a compute-native Q8_0 KV cache for qwen35moe and Gemma 4 - #33

Closed
smashdog wants to merge 11 commits into
simplexai-labs:mainfrom
smashdog:pr3-kv-int8
Closed

smashdog wants to merge 11 commits into
simplexai-labs:mainfrom
smashdog:pr3-kv-int8

Conversation

@smashdog

@smashdog smashdog commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Rebased onto current main (PR 29 took the HIP rejection of --kv q8_0 out; this keeps that — the
q8_0 alias rides the same HIP path it measured — and adds int8/int8k on top).

It replaces --kv q8_0 (PR #23, shipped in poly.5) with --kv int8 — q8_0 stays as an alias —
and adds --kv int8k (keys quantized, values fp16). Gemma 4, which #23 left out, gains the whole
set.

What was stored is the same numbers: the same Q8_0 rule (a fp16 delta per 32-value block, delta =
amax/127, roundf codes — pinned bitwise against ggml's quantize_row_q8_0_ref by the test). What
changed is the layout and the readers. The old format kept ggml's 34-byte blocks and every attention
kernel dequantized them element by element into floats — two extra loads and four I2F/FMA per four
values, in every score and every output sum. The int8 here is a compute format: codes and deltas in
two arrays (codes back to back, so every four are one aligned word), the queries quantized into the
same form, a lane's run of a decode head inside one 32-value block, one __dp4a per four products
with the delta multiplied once a run; wider heads take an int8 tensor-core MMA. A prompt batch
dequantizes the caches into an fp16 twin once — incrementally, O(rows) against the O(rows²)
attention — and hands the rows to cuBLAS's attend_batch as before.

Against the current q8_0: what the comparison shows

The reviewer asked for decode and prompt speed against the current q8_0 on the same card and
context, and the fp16-vs-q8 answer checks of Q8_KV.md. Both are in the updated docs/Q8_KV.md:

  • KV data equivalence, old format vs new: both engines ran --dump-layers on one fixed prompt,
    one seed, three tokens (gemma-4-26B-A4B, --kv int8k): 3603 of 3603 dump rows bitwise equal —
    the codes and deltas the two formats write are the same numbers, so the attention sees the same
    cache.
  • Speed, int8 vs the old q8_0 readers (RTX 4070 Laptop, arch 89, 4019-token prompts): the
    engine-to-engine A/B over 36 requests gives decode ratio 1.0167, bootstrap 95% CI
    [0.9813, 1.0577] (the CI contains 1.0 — statistically indistinguishable at decode), prefill
    ratio 0.9844. The kernel-level test times the q8 decode path against the fp16 one at
    0.88–0.96×: the decode reads half the bytes and pays the queries' quantize, which nets out level
    with fp16 on this class of card — the saving is the VRAM (46.88% at full context, to experts), not
    the tokens-per-second.
  • fp16-vs-q8 answer checks (the old format's, kept in the doc): 15/15 exact matches across Tiel,
    Ornith and Occamy, mean character agreement 1.0000.

The differences from #23, in one place

  • Two arrays instead of 34-byte blocks; the block's delta is fp16 (as ggml's is), read once a run.
  • The queries are quantized too (q8c_/q8d_), so the score kernel reads codes on both sides and
    never touches a float key path.
  • attend_batch's prompt path dequantizes once into an fp16 twin (incrementally, on demand via
    twin_cover on Gemma 4's vmm-backed cache) instead of re-dequantizing a key block per score row.
  • --kv int8k mixes int8 keys with fp16 values (attend_q8_mixed) — a quarter of the fp16 cache
    saved with less of a loss; Gemma 4's window-plus-full layers take it.
  • The qwen35moe-only gate at Engine::open is gone (Gemma 4 joins). The int8 MMA tier is
    CUDA-only (mma.sync PTX); the __dp4a tiers compile through hip_compat's dp4a, so a HIP
    build takes the dp4a readers and the cuBLAS batch path (STRATA_KV_MMA=0 pins the same choice on
    CUDA). The AMD numbers in docs/Q8_KV.md are PR 29's elementwise readers, re-measured with this
    change pending.
  • docs/Q8_KV.md and the --kv row of docs/GLM.md are updated in place, not deleted.

Verification

  • poly_kv_q8_test (replaces kv_q8_test.cpp): 35 checks pass — the store against
    ggml_quantize_chunk block by block, byte-exact layout, and the three attention paths compared.
  • strata-poly builds clean (CUDA 13.4, arch 89); the default --kv fp16 path is bitwise what it
    was.
  • After the card_row_bytes width fix (it was short by 1.44× under int8k, which overran a parked
    conversation's D2H blob — a real 0xC0000005): 36 requests, 0 failures.

@VecSzn

VecSzn commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks for splitting it out and for updating Q8_KV.md instead of deleting it. A few things before I can take it:

  1. It conflicts with main. PR 29 was merged in the meantime: --kv q8_0 now runs on AMD cards, measured on an RX 9070 XT. This PR refuses q8_0/int8/int8k on HIP, so with q8_0 as an alias it would take that away from AMD users again. Please rebase and keep q8_0 working on HIP, either by making the new kernels build and pass poly_kv_q8_test there (donschen offered to run them on his card) or by keeping the current readers for HIP.

  2. By your own A/B, the new readers are level with the current q8_0 at decode (the CI contains 1.0) and 1.6% slower at prefill. So for the qwen35 models the rewrite changes nothing measurable, at +2,430/-478 lines. What it adds is Gemma 4 and int8k. Unless the new readers win somewhere, I'd rather take those two on top of the current q8_0 format than replace it. If you think they win at long contexts, where the cache reads dominate, please show decode after 32K and 64K prompts against the current q8_0.

  3. The fp16-vs-q8 answer table in Q8_KV.md (15/15 on Tiel, Ornith and Occamy) is poly: opt-in ggml Q8_0 KV cache for strata-poly (--kv q8_0, qwen35moe models) #23's, run with the old readers. The new readers quantize the queries too, so they need their own run on the same prompts, and Gemma 4 needs one for int8 and int8k against fp16.

  4. The 3603/3603 bitwise dump: the new score kernel quantizes the queries to int8 and the old one read float queries, so I wouldn't expect the attention outputs to agree to the bit. If the dump covers the stored K and V only, please say so.

  5. Q8_KV.md still says "Gemma 4 is out of v1" further down, while the top says it works.

I'll test the rebased version on an RTX 4070 laptop and an RTX 5090.

jpb18 pushed a commit to jpb18/PolyStrata-HIP that referenced this pull request Oct 18, 2026
…server (simplexai-labs#33)

Builds its long texts from this repository's own files at run time, hides a
code word at the chosen depths and asks through the API. Checked on the Coder:
1K, 32K (10/50/90%) and 128K found.
@smashdog

Copy link
Copy Markdown
Contributor Author

Hi @VecSzn — answering all five points; the branch is rebased and pushed, so the diff you see now
(304c7b19) is current.


1. HIP (your either/or: new kernels build and pass on AMD, or keep the old readers there).

The answer is "the new kernels build on HIP, minus the MMA tier". Concretely:

  • The __dp4a tiers run on HIP: hip_compat/intrinsics.hpp already #defines __dp4a
    (same signed-bytes-in-int32 contract).
  • The int8 MMA tier was the gap, and it is now fixed rather than rejected: use_mma in
    attend_q8 / attend_q8_mixed is gated on tensor_cores() (false under __HIPCC__,
    same convention as the fp16 MMA tier), and the mma.sync.s8 PTX body compiles to an empty
    stub under HIP. Before this the gate only looked at dim and the key count, so a HIP build
    with ≤16384 keys really did launch that PTX and fail at load.
  • poly_kv_q8_test no longer carries NOT STRATA_ENABLE_HIP, so on AMD it exercises the
    dp4a tiers (it passes 35/35 on CUDA here; the AMD run is yours to make — donschen offered,
    which would settle it on hardware I do not have).
  • --kv q8_0 (your format) stays accepted on HIP; the alias resolves to int8, which on
    HIP takes the dp4a readers and the cuBLAS batch path. STRATA_KV_MMA=0 pins the same
    choice on CUDA.

I did not take the "keep the old readers on HIP" option: with the gate the old readers buy
nothing, and they cost the 5.7-6.3x prompt-path slowdown measured in poly_kv_q8_test.

2. Measurable benefit — you asked for 32K/64K decode, int8 readers vs the q8_0 readers.

RTX 4070 Laptop 8 GiB, Ornith 1.5 35B-A3B Q6_K, --no-draft, --tail-skip 0, --threads 24,
--max-context 131072, greedy, 4 alternating rounds per arm (order reversed between rounds),
12 speed samples + 6 answer samples, strictly serial:

arm 32K decode t/s 64K decode t/s
int8 (this PR, local build) 42.2 (sd 0.8) 42.7 (sd 3.0)
int8 (this PR, rebased build) 45.1 (sd 4.3) 42.1 (sd 3.9)
q8_0 (the readers this PR replaces) 43.4 (sd 6.0) 38.9 (sd 4.2)

Paired ratios (same round): new/new 1.027 — inside the run-to-run band (0.96-1.15), i.e. the
two int8 builds are indistinguishable, which is the check that the port is faithful.
q8_0 / new = 0.941 at 64K, same direction in all 4 rounds: the old readers are slower, and
the gap is larger at 64K, which is what a halved cache footprint should do. I am claiming the
direction, not the magnitude — the absolute numbers drift 5%+ between rounds on this laptop.
Prefill is flat (1322-1357 t/s at 32K, 1108-1130 at 64K on all three arms).

The +2430/-478 line count is mostly the three reader tiers (dp4a, register, MMA), the fp16
twin batch path, the incremental twin mapping, and Gemma 4's ring writes; the Gemma 4 and
int8k support is what the old readers did not have at all.

Incidental finding, in favour of replacing them: the old --kv q8_0 path dies with
ERR rms_norm: an illegal memory access on this model as soon as the prompt exceeds 4096
tokens (reproduced at 4608/5120/8192/16384; passes at 4096; independent of prompt content and
of --max-context; --kv-whole or --vram-reserve-mib 4096 works around it, which points at
the expert-cache shrink path). The int8 readers pass at 4608/32K/64K with the same reserve.

3. The 15/15 table was the old readers' — agreed, and re-measured.

It is now labelled as v0.1.43-poly.5's elementwise q8_0 readers in docs/Q8_KV.md, and the
int8 readers were re-run (same five prompts, greedy, --no-draft, max_tokens: 64):

model arm exact token match vs fp16 answer vs fp16
Ornith 1.5 35B-A3B --kv int8 1/5 prompts 2/2 prompts that finished reasoning
gemma-4-26B-A4B --kv int8 1/5 prompts 5/5 keep every key fact
gemma-4-26B-A4B --kv int8k 1/5 prompts 5/5 keep every key fact

The int8 readers quantize the query side, so scores move in the last bits and greedy can
re-word a reasoning chain (Recall the fact: vs Retrieve knowledge:); the finished answers
keep their result (KV OK, 42, Paris), and one Gemma 4 prompt is bit-identical in all
three arms. So: the old readers' character-for-character equality was a property of not
touching the query side, not of the cache layout — the layout claim (same delta = amax/127,
same roundf codes, byte-identical to quantize_row_q8_0_ref) still holds.

Caveats I won't paper over: Tiel and Occamy are no longer on this machine, so those rows have
no int8 re-measurement; Gemma 4 ran on CPU offload (26B in 8 GiB), so only its token
comparisons are a signal, not its decode rate.

4. The 3603/3603 dump — your suspicion was half right, and here is the precise claim.

--dump-layers does not dump the KV cache's stored bytes. The points are in
Engine::dbg() (src/poly/engine.cpp:1780-1792), which prints sum/abs/first of a
hidden-state buffer; on gemma4 the five per-layer points are in src/poly/gemma4.cpp:727-768
(attn_in = xn_, attn = cur_ after ao_, ffn_in, mlp, ffn). There is no dump
point on k_code/k_delta/v_code/v_delta, so "the KV quantized data is bitwise
identical" was the wrong phrasing and I have corrected it in the docs.

It still means something, and stronger than I argued: attn is the layer's attention
output, and in this engine the scores and the value sums are computed from the dequantized
cache (attend_q8 / dequant_q8_to_f16 + attend_batch, src/poly/gemma4.cpp:690-700).
Two arms whose stored codes/deltas differed would almost certainly produce different attn
sums, so 3603/3603 identical rows is evidence that the effective K/V contents agree.

And your query-quantization point does not apply to that A/B: it was arm A = local repo,
arm B = modified upstream repo, both running the new int8 kernels
(--kv int8k on both
sides, verified in both command lines in the logs). Query quantization is identical on both
arms and cancels. Raw check just now on the two logs:

$ grep '^dump' dump_A.log > A ; grep '^dump' dump_B.log > B ; diff A B
(no output — identical)
3603 A ; 3603 B

Composition: 24 positions × 30 layers × 5 stages = 3600, plus 3 out_norm rows.

5. The stale "Gemma 4 is out of v1" line — it was my error, and it is fixed.

I believed the rebase had removed it; it had not. It is gone now, and the AMD section's
"a HIP build does not have" wording is updated to say what the code does (runtime-gated, empty
stub under HIP). Both are in 304c7b19.

Rebased onto current main (PR 28/29 included): --kv fp16|int8|int8k with q8_0 kept as an
alias of int8, no HIP rejection, AMD measured numbers in the docs marked as the old readers'
pending a re-run on your card.

@VecSzn

VecSzn commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the answers and the rebase. I tested 304c7b1 on an RTX 5090 (Linux, CUDA 12.8) with Qwen3.6-35B-A3B UD-Q4_K_M against main (26e4cb5):

  • poly_kv_q8_test: 35 checks pass.
  • Without --kv, Qwen3.6 and GLM 5.3 Flash give main's tokens.
  • --kv q8_0, fixed settings (--expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0), a real-text prompt, 128 tokens:
Prompt main's q8_0, read / write this PR, read / write
32K 8,268 / 120.1 tok/s 8,618 / 109.7 tok/s
96K 5,922 / 58.4 tok/s 5,983 / 51.6 tok/s

With default settings (expert swaps, lookup guesses), 96K, 400 tokens, two runs each: 113.8 and 114.0 tok/s on main, 100.9 and 100.9 here. So on this card the new readers write 9-12% slower and read 1-4% faster. Against fp16, main's q8_0 kept the first 128 of 128 tokens at 32K and 45 at 96K; this PR 58 and 123.

  • --batch 4 --check-batch 4 with --kv q8_0 (8K prompt, 64 tokens) fails: the four sequences part from their own runs at tokens 1, 4, 4 and 18. On main the same check gives 64 of 64 for every sequence, with and without --kv q8_0. So with "parallel" an answer would depend on what else is running, which main doesn't do. It looks like attn_rows() and attn() take different readers, or quantize the queries differently. This has to match before it can go in.
  • --kv int8k is refused for Qwen3.6 ("the gemma4 backend's alone for now"), as the docs say.

The illegal memory access with main's q8_0 past 4,096 tokens: I couldn't reproduce it on Linux. With the 5090 held to 6.9 and 4.9 GiB free by another process, the default reserve, --kv q8_0 and fp16, 7.7K and 32K prompts, Qwen3.6 UD-Q4_K_M: all ten runs finished. It may be Windows-only. Could you open an issue with the engine log (strata-<model>.log) of a crashing run and the exact command or config? I want to fix that on main whatever happens to this PR.

Smaller things:

  • CMakeLists.txt loses the byte-order mark on its first line. The file is shared with the Strata project, so please leave that line as it is.
  • The last commit's message is in Chinese; please write it in English like the others.

On the speed: your laptop shows the new readers ahead at 64K, the 5090 shows them behind at 32K and 96K. Before they replace the current format for qwen35 they need to be at least level on a desktop card too. Gemma 4 and int8k on top of the current q8_0 format would be easy to take on their own.

@donschen

Copy link
Copy Markdown
Contributor

I ran 304c7b1 on an RX 9070 XT (16 GB, gfx1201; Fedora 44, ROCm 7 from AMD's Python wheels; Ryzen 7 9800X3D). The model is Tiel-Coder-35B-A3B-MTP UD-Q4_K_XL, which is qwen35moe like Qwen3.6, so the same attention code and readers run. It isn't your UD-Q4_K_M, so the numbers won't line up exactly with yours.

The baseline is the PR's own base, fd2cb1b, not 26e4cb5. Both builds carry the same local patch, a ROCm VMM workaround that doesn't touch the attention code. On HIP, the PR takes the dp4a readers and the cuBLAS batch path.

Blocking on AMD: --batch with --kv int8 (or q8_0) faults the GPU

A single sequence works. More than one doesn't:

  • --batch 4 --check-batch 4 on an 8K-token prompt, 64 tokens: Memory access fault by GPU node-1 ... Reason: Page not present or supervisor privilege. It happened in 3 of 3 runs, with default settings and with --expert-swaps 0 --cpu-share 0 --tail-skip 0. HIP reports it at the next wait, in Engine::moe from Engine::step, so the kernel that faulted ran before that.
  • --batch 4 alone, without --check-batch, on the same prompt: it segfaults.
  • --batch 4 --check-batch 4 on a ~450-token prompt doesn't crash, but the batched answers are wrong from the start. Against their own runs, the four sequences keep 0, 0, 0 and 2 of 32 tokens.
  • On the same 8K prompt without --batch, it's fine: 64 tokens at 78 tok/s.

This is likely the same attn_rows() against attn() mismatch you saw on the 5090. On HIP, the batch path reads outside its buffer instead of only giving different tokens.

One caveat about the check itself on this card. With the base build (fd2cb1b), --batch 4 --check-batch 4 doesn't give 64 of 64 on AMD either, even with fp16:

fd2cb1b, fixed experts (--expert-swaps 0 --cpu-share 0 --tail-skip 0), 8K prompt, 64 tokens sequences 1 / 2 / 3 / 4
fp16, run 1 and run 2 64 / 40 / 16 / 10 (both runs)
q8_0, run 1 64 / 41 / 64 / 13
q8_0, run 2 15 / 56 / 64 / 64

So on HIP, main's batch path already differs from the single-sequence path. With q8_0 it also changes from run to run. I can open a separate issue for that if it's useful. It does mean the check can't show whether this PR matches main on AMD. The crash doesn't need the check to show up, though.

Speed with your fixed settings

--expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0 --max-context 131072 --kv-whole, a real-text prompt, 128 tokens, greedy. The prompts came out at 38,263 and 122,583 tokens.

Prompt fp16, read / write fd2cb1b's q8_0, read / write this PR's int8, read / write
38K 2,752 / 48.2 tok/s 2,752 / 50.7 tok/s 2,753 / 53.2 tok/s
123K 1,682 / 32.3 tok/s 1,657 / 33.4 tok/s 1,691 / 36.9 tok/s

Experts in VRAM at load: fp16 4,559, q8_0 5,502, int8 5,084.

Against fp16, the first 128 tokens kept were:

  • 38K: q8_0 9, int8 9. Both part from fp16 at the same token.
  • 123K: q8_0 93, int8 31.

Speed with default settings

--max-context 196608 --kv-whole, the 122,586-token prompt, 400 tokens, two runs each.

read write
fd2cb1b's q8_0 1,899 / 1,911 tok/s 48.5 / 49.7 tok/s
this PR's int8 1,940 / 1,682 tok/s 54.5 / 60.3 tok/s

So on this card, int8 writes 5–10% faster than the current q8_0 with fixed settings and about 12–20% faster with default settings. Prompt reading is level to 2% faster. That's the opposite of your 5090 result on writing, and in line with smashdog's laptop.

An earlier run with drafting on (--max-context 196608 --kv-whole) gave the same picture. Writing was level at 64K (62.4 tok/s both). Reading was 2–5% faster: 1,995 against 2,101 tok/s at 64K.

The expert cache at load varied between runs on this card. int8 loaded fewer experts than q8_0 in two of three pairings, by 210 and 418, and 71 more in the third. So it may take some VRAM for the fp16 twin. I haven't pinned that down.

poly_kv_q8_test doesn't build or pass on HIP as it is

  1. It doesn't compile. src/poly/kv_q8_poly_test.cpp is host C++, and __ushort_as_half isn't available to host code on HIP (lines 56 and 230: '__ushort_as_half' was not declared in this scope). Going through the file's own half_value() with a memcpy into __half fixes it.
  2. It stops at its first check. check(probe_mma_layout(), ...) runs unconditionally, and on HIP the MMA body is the empty stub, so it prints [mma probe] 128 of 128 wrong and exits. The engine gates that tier on tensor_cores(), so the probe and probe_mma_speed() should be skipped the same way under HIP.

With both changed locally, it gives ok: 34 checks (your 35 minus the MMA probe). Its own timings put the dp4a decode reads at 0.95–1.05× fp16 on the Qwen3.6 shape and 0.72–1.02× on the 64-wide shape.

--kv int8k is refused for this model ("the gemma4 backend's alone for now"), as the docs say.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks @donschen, that's a lot of useful data.

  • Yes, please open the issue for main's batch path on HIP (fp16 at 64 / 40 / 16 / 10 against the single runs, and q8_0 changing between runs). On CUDA the same check gives 64 of 64 on main, so it's a HIP-side difference that deserves its own issue.
  • The ROCm VMM workaround both your builds carry: what does it fix? firetussin's RX 6700 XT hit a memory-access fault starting a 64K context without an explicit reserve (bench: RX 6700 XT 12 GB official PolyStrata results #52), and the comment in Engine::vmm_unmap() already warns about ROCm's release path. If yours is a fix for main, a PR would be welcome.

@smashdog, with these numbers the speed is a split: the 9070 XT and your laptop write faster with int8, the 5090 slower. So I won't hold the PR on speed. The batch path is what blocks it: wrong tokens from the first steps on CUDA, a GPU fault on HIP. donschen's two fixes for poly_kv_q8_test on HIP would be good to take in as well.

@donschen

Copy link
Copy Markdown
Contributor

I'll open the issue for main's batch path on HIP.

@smashdog, here are the two poly_kv_q8_test fixes as a patch on 304c7b1. It only touches the test. The skips use STRATA_USE_HIP, as the rest of the tree does. With it, ok: 34 checks on the RX 9070 XT. Both skips are HIP-only. The half_value() change is plain host code, but I haven't built it on CUDA.

--- a/src/poly/kv_q8_poly_test.cpp
+++ b/src/poly/kv_q8_poly_test.cpp
@@ -53,7 +53,11 @@ uint16_t half_bits(float f) {
     return b;
 }
 
-float half_value(uint16_t b) { return __half2float(__ushort_as_half((unsigned short) b)); }
+float half_value(uint16_t b) {   // through memcpy: HIP has no host __ushort_as_half
+    __half h;
+    std::memcpy(&h, &b, sizeof h);
+    return __half2float(h);
+}
 
 /// ggml's own 34-byte blocks for these rows, taken apart into the two arrays --kv int8 keeps.
 struct Reference {
@@ -227,7 +231,7 @@ void case_error(const char* name, int n_kv, int head_dim, int rows) {
 
     float worst = 0.0f;
     for (size_t b = 0; b < blocks; ++b) {
-        const float d = __half2float(__ushort_as_half(delta[b]));
+        const float d = half_value(delta[b]);
         float amax = 0.0f;
         for (int j = 0; j < kQ8_0Values; ++j)
             amax = std::fmax(amax, std::fabs(src[b * kQ8_0Values + (size_t) j]));
@@ -774,7 +778,9 @@ int main() {
     }
     // n_kv = 2 and head_dim = 256 are Qwen3.6's; 256/32 tells how many blocks a head has
     const int n_kv = 2, head_dim = 256;
+#ifndef STRATA_USE_HIP   // no int8 mma tier on HIP: attend_q8 never takes it there (tensor_cores())
     check(probe_mma_layout(), "the m16n8k32 fragment layout is what I think it is");
+#endif
     // FIRST, while the card still has room for anything: attend() and attend_q8() against attend_batch, which is
     // the attention the engine uses for every call of kStageRows (32) rows or more - a prompt chunk and, with
     // --window, a commit of accepted guesses.  attend() is asked for fewer rows than that, and this is the only
@@ -792,12 +798,14 @@ int main() {
     case_attend_rows_batch("rows  8, window 512 of a 1024 ring", 256, 16, 8, 1024, 8, 512, 1024);
     case_attend_rows_batch("rows  1, window 256 of a 1024 ring", 256, 16, 8, 1024, 1, 256, 1024);
     case_attend_rows_batch("rows  8, window 256 of a 1024 ring", 256, 16, 8, 1024, 8, 256, 1024);
+#ifndef STRATA_USE_HIP
     {
         double tm = 0.0, td = 0.0;
         probe_mma_speed(&tm, &td);
         std::printf("  one 16x8x256 product x20000 x384 warps: mma %.1f ms, dp4a %.1f ms -> mma is %.1fx\n",
                     tm, td, td / tm);
     }
+#endif
     for (int kind = 0; kind < 4; ++kind) {
         char name[96];
         std::snprintf(name, sizeof name, "store_q8 rows=5 kind=%d", kind);

On the ROCm VMM workaround: it's for a ROCm bug I could reproduce on its own. It's not the fix for the crash we actually see, which --kv-whole avoids for now. Details below.

What the workaround fixes

On this card (RX 9070 XT, ROCm 7 from AMD's wheels, Linux 7.2), the card keeps its address translations for VRAM that has been unmapped. If you hipMemUnmap and hipMemRelease a piece, then hipMemCreate a new one and hipMemMap it at the same address, kernels read and write the old pages through the stale translations. Copies (hipMemcpy) see the new pages.

  • Minimal repro: a 4 MiB piece released and a new one mapped at the same address holds wrong words within two rounds. The attention caches' 128 KiB pieces do the same.
  • What doesn't help: a sleep, hipDeviceSynchronize(), or freeing the reservation and reserving it again, which is what vmm_unmap() already does for RX 7600 8 GB (gfx1102): 24K benchmark on Qwen3.6 Q2 + Ornith Mini, and the --no-gpu-experts token check #13.
  • What's safe: mapping the same handles back at the same addresses.
  • The workaround: a 2 MiB hipHostMalloc and hipHostFree after each mapping. That gets the driver to bring the translations up to date. A small hipMalloc doesn't, because HIP serves it from its own pool. With the workaround, a stress test of the engine's own pattern (shrink, rebind, grow, a kernel over every mapping, uploads from another stream) holds 10,000 rounds. Without it, it fails within about 20.

The change is 11 lines in hip_compat/cuda.h (STRATA_VMM_FLUSH_AFTER_MAP, Linux only) and 25 in engine.cpp: vmm_flush(), called after every vmm_map() and after the rebind in vmm_unmap(). I can open it as a PR, labelled as a workaround for a ROCm bug, with the repro attached. I'll also report the bug to ROCm.

What it doesn't fix: crashes inside HIP when the attention caches give VRAM back

Without --kv-whole, the engine here crashed regularly in agent use, all of it with --kv q8_0. The worst stretch was 11 crashes in about 15 hours, sometimes minutes apart. Every saved dump has the same stack: a segfault inside libamdhip64, in Engine::vmm_unmap() from trim_card_kv() / trim_kv(). The kernel log puts 11 of the 12 segfaults at the same instruction in libamdhip64. The other ways it ends are:

  • strata-poly: unmapping the kept VRAM while giving VRAM back: invalid argument; the caches are no longer mapped, so the engine stops
  • once, double free or corruption (!prev)

The kernel log shows no GPU fault, and no reset other than the ones that come with a suspend.

The workaround didn't change it. The backtrace is the same before and after, and the 11 crashes above were with the flush build. PR 31 didn't change it either. It happened with "parallel" 1 and 2. With --kv-whole, so the attention caches never shrink, we haven't seen a crash in regular use since, though the expert cache still shrinks and grows. I'm still tracking it.

So I think it's another ROCm problem in the attention caches' unmap and re-reserve, and I haven't reproduced it outside the engine yet. Until then, the safe setting on AMD is --kv-whole. It might be worth making that the default on HIP, or for the server's AMD setup.

#52

I can't tell from here whether firetussin's 64K start fault is related. Ours happen when VRAM is given back mid-session, not at the start. If that start runs without --kv-whole, the same start with --kv-whole and without the reserve would show whether the attention caches' growing is involved.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

A note on the batch check after #55: with long prompts --check-batch could fail on main too, because the sequences' attention rows took VRAM from the expert cache between the single and the batched phase (donschen found it). Since a55468d the check takes --kv-whole. My 5090 run of this PR had every expert in VRAM and main passed the same check there, and the PR parted at tokens 1 to 18, earlier than an expert moving to the CPU usually shows, so I still think it's the readers. But please rebase on main and run the check again, so we compare like with like. The GPU fault on HIP (donschen's run) is a separate thing either way.

…35moe and Gemma 4, replacing the elementwise readers

Replaces --kv q8_0's format (KvFmt::kQ8_0, read back by dequantizing
element by element into floats and accumulating in fp32) with an int8
that is a compute format, not just a storage one:

- K and V keep codes and deltas in two arrays (codes back to back, so
  every four are one aligned word; a delta a 32-value block, delta =
  amax/127, roundf — ggml's quantize_row_q8_0_ref to the bit).
- Decode reads them with __dp4a / int8-MMA kernels (plain, register-
  blocked and tensor-core tiers by head width and key count); the
  queries are quantized into the same form and a lane's run stays
  inside one 32-value block, so a delta multiplies once a run.
- A prompt batch dequantizes the caches into an fp16 twin once,
  incrementally (O(rows) against an O(rows^2) attention), and hands
  the rows to cuBLAS's attend_batch as before.
- --kv int8k keeps the values in fp16 (attend_q8_mixed) — a quarter of
  the fp16 cache saved with less of a loss; Gemma 4's window-plus-full
  layers use it.
- Gemma 4 gains the whole set for the first time: the window layers'
  ring stores write in two pieces, the twin is vmm-mapped on demand
  (twin_cover), and card_row_bytes() now counts the int8k value side
  at fp16's width (it was short by 1.44x, which overran a parked
  conversation's D2H blob).
- qwen35's new step() batch path (attn_rows()) stores and reads the
  quantized rows in the same shape.
- q8_0 stays as an alias of int8.  The HIP refusal is kept and covers
  int8 and int8k (the q8 kernels are CUDA-only).
- poly_kv_q8_test (35 checks) pins the store against ggml's own
  quantizer and compares the three attention paths; the old
  kv_q8_test.cpp is replaced by it.
- Q8_KV.md and GLM.md's --kv row are updated in a following commit
  with the speed and answer checks against the previous format.

The default (--kv fp16) path is bitwise what it was.
… on HIP

ROCm has no mma.sync.aligned.m16n8k32.s8, and attend_q8's use_mma only looked at
head_dim and the key count, so a HIP build with up to 16384 keys really did launch
that PTX and fail at load. Two changes:

- use_mma now asks tensor_cores() as well (false under __HIPCC__, and on CUDA by
  ptxVersion), and STRATA_KV_MMA cannot override it — the same convention the fp16
  MMA tier already followed;
- the mma_s8 PTX body is compiled to an empty stub under HIP: the kernel is never
  selected there, but the template still has to instantiate.

CMake drops the NOT STRATA_ENABLE_HIP gate on poly_kv_q8_test: the dp4a tiers only
need __dp4a (hip_compat's intrinsics.hpp maps it) and the MMA tier is gated at
runtime, so the test can cover the dp4a readers on AMD.

poly_kv_q8_test: 35/35 on an RTX 4070 Laptop (arch 89); strata-poly on a 32K
prompt gives 51.8 tok/s at decode and output identical to before the change.
…the int8-against-fp16 answers, make the HIP wording exact

The 15/15 character-for-character table was measured on the previous elementwise
q8_0 readers, which did not quantize the query side; keeping it unlabelled read as
a claim about this change. It now says which readers produced it.

Adds the re-measured table for the int8 readers (Ornith --kv int8, gemma-4-26B-A4B
--kv int8 and --kv int8k, five prompts each, greedy, --no-draft): the differences
are rewordings inside the reasoning chain, and the finished answers keep their
result (KV OK, 42, Paris); one Gemma 4 prompt is bit-identical in all three arms.

The HIP note said the MMA tier "a HIP build does not have"; with the
tensor_cores() gate that is now a runtime choice and the PTX body is an empty stub
under HIP, so the note says that instead.
The file is shared with the Strata project and its first line carries a BOM; my
rebase dropped it. Put the BOM back and leave the line itself untouched.
__ushort_as_half is not available to host code on HIP, so half_value() goes
through memcpy into a __half, and the two case_error() call sites use it too.
The MMA layout probe and probe_mma_speed() run unconditionally, but under HIP the
int8 MMA body is the empty stub (attend_q8 is gated on tensor_cores()), so the
probe would report 128 of 128 wrong and stop the run; both are now behind
#ifndef STRATA_USE_HIP, as the rest of the tree guards. 34 checks on AMD, 35 on
CUDA.
With --batch every row belongs to its own sequence, so the codes a row writes and
the codes it reads are that sequence's, not the bound one's -- the fp16 path
reaches them through seq_buf(r.seq, ...) and the int8 path did not, so a batched
sequence attended the wrong cache from the first token (on CUDA that read as
answers parting at token 1-18; on HIP it faulted).

The fp16 twin is one pair for the whole model and the incremental conversion in
attn() assumes a single sequence growing row after row (f16_owner_/f16_rows_). A
batch interleaves sequences, which leaves those two saying nothing useful, so
attn_rows() converts the row's own sequence up to its own position instead and
clears the incremental state afterwards. A batch is a few rows, so the cost is
O(rows in the batch x that row's position) -- not the per-token decode cost the
incremental conversion exists to avoid.
…e reader swap

kQ8WorkRows sized the fp16 workspace the int8 prompt path dequantizes K and V
blocks into; the old q8_0 readers had their own, and the swap took the code that
used it without the constant, so it no longer compiled.  fattn_q8() and
fdec_q8() -- the flash-attention and decode kernels the prompt and decode paths
do not take today -- still name the cache they read, which is the same two
planes (k_code / k_delta, v_code / v_delta), so Kvq8 now lives in fattn.hpp where
they are declared.
@smashdog

Copy link
Copy Markdown
Contributor Author

Rebased and re-run, and this time with --check-batch taking --kv-whole (a55468da), so both arms compare like with like.

On this card (RTX 4070 Laptop, 8 GiB, CUDA 13.4, Ornith 1.5-35B-A3B UD-Q6_K, --kv int8 --threads 13 --tail-skip 6 --batch 4 --check-batch 4, four prompts of 2,048 tokens, 256 tokens generated each):

check-batch: sequence 1: the first 256 of 256 tokens as on its own
check-batch: sequence 2: the first 256 of 256 tokens as on its own
check-batch: sequence 3: the first 256 of 256 tokens as on its own
check-batch: sequence 4: the first 256 of 256 tokens as on its own

I could not reproduce the parting you saw on the 5090: I ran it with and without --expert-profile, with --tail-skip 0 and 6, at --max-context 16384 and 65536, and with the expert cache squeezed to 0 of 10496 experts in VRAM (921 under the profile, which pins it), and every arm passed.

What I did find and fix while chasing it, in 779e7ca5:

With --batch each row belongs to its own sequence, so the codes a row writes and the ones it reads are that sequence's. The fp16 path reaches them through seq_buf(r.seq, ...) (src/poly/qwen35.cpp:509); the int8 path went straight to the bound sequence's B.k_code. On top of that, the fp16 twin is one pair for the whole model and attn()'s incremental conversion tracks its owner and how far it is filled (f16_owner_ / f16_rows_, qwen35.cpp:438-449) — a batch interleaves sequences, so those two no longer describe what the twin holds. attn_rows() now converts the row's own sequence up to its position and clears the incremental state afterwards.

That is a real hole in the batch path, and the fix is cheap (a batch is a few rows), but I want to be straight about what I can and cannot claim: I did not manage to make it fail before the fix, so I have a code argument, not a reproduction. If your 5090 run still parts after this rebase, send me the exact command line -- --max-context, whether you passed --expert-profile, and how many tokens --check-batch generated -- and I will drive it until it does.

Also landed:

  • c9099aae donschen's poly_kv_q8_test patch as posted (with the comment that half_value() is host code he has not built on CUDA -- I have now, and it compiles).
  • a387473e the int8 MMA tier gated on tensor_cores(), which is false under HIP, so the int8 path there takes dp4a.
  • 23675292 two things the rebase dropped: kQ8WorkRows, which sizes the fp16 workspace the int8 prompt path converts blocks into, and Kvq8, which fattn_q8() / fdec_q8() still name.

poly_kv_q8_test: ok: 35 checks.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks, that fix fits what I saw: rows reading the bound sequence's codes would part at the first tokens, and on HIP read where they shouldn't, as donschen's fault did.

The 5090 run that parted (Linux, CUDA 12.8, every expert in VRAM):

strata-poly --model Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --max-context 32768 --kv q8_0 \
  --expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0 --threads 24 \
  --batch 4 --check-batch 4 --eos-ids 999999 --tokens <8,000 ids> --max-new 64

No --expert-profile. The 8,000 ids were random ones from 100 to 2999 (Python random.Random(6), randrange(100, 3000)), so the prompt is noise, not text. Main (26e4cb5 at the time) gave 64 of 64 for every sequence with the same command; 304c7b1 gave 1 / 4 / 4 / 18.

That pod is gone, so I'm checking the rebased branch on the RTX 4070 laptop with an MSVC build instead, with --kv q8_0 and long prompts; I'll post the result here.

…le it

The int8 arm runs its prompt stage 9-12% below fp16 on an RTX 5090 while its
decode matches. The prompt path is fp16 either way -- the codes go into the
twin and cuBLAS takes the same halves -- so the cost sits in the conversion or in
the cache holding them, and neither is measured separately yet. Written down here
instead of left in the review thread, with the two measurements that would explain
it.
@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

The laptop run of the rebased branch (2367529; 52b7f92 only adds docs). RTX 4070 Laptop 8 GB, Windows, MSVC 14.44 + CUDA 13.0, a 5,500-token prompt of real text, --max-context 8192 --expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0 --batch 4 --check-batch 4 --max-new 64 (so with --kv-whole through a55468d). First tokens that match the single run, sequences 1 / 2 / 3 / 4:

this PR, --kv q8_0 (int8) this PR, fp16 main bb87f2f, --kv q8_0
Qwen3.6 UD-Q3_K_M 0 / 1 / 3 / 1 64 / 64 / 64 / 64 64 / 64 / 64 / 64
Tiel Coder UD-Q3_K_XL 0 / 0 / 0 / 5 64 / 64 / 64 / 64 64 / 64 / 64 / 64

So the int8 batch path still parts from the first token, on a card where main's q8_0 and the PR's own fp16 match. Your 2,048-token prompts passing would fit a bug that needs the rows past some length (a second workspace chunk of kQ8WorkRows, or the twin's conversion of a sequence that isn't the bound one). With 2,048 vs 5,500 being the difference, I'd look there first.

Two build notes from the same machine:

  • src/poly/fattn_test.cu doesn't compile on this branch: it still calls attend_batch() and attend() with the old Kvq8 arguments (lines 101, 104 and 212, "too many arguments in function call"). poly_fattn_test is built with strata-poly, so a plain build of the two targets fails.
  • The engine itself builds and the fp16 path matches main, as above.

Three calls still passed attend_batch() and attend() the old q8_0 arguments
(lines 101, 104 and 212), so poly_fattn_test -- built alongside strata-poly --
stopped compiling with this branch. The reference each case compares against is
now the halves the int8 path would hand those kernels: the codes' whole cache
dequantized, K then V. Both modes pass (`all passed`, q8 and f16 arms).
@smashdog

Copy link
Copy Markdown
Contributor Author

poly_fattn_test compiles again and passes — b6d6a1ec. You were right that it was broken: three calls still handed attend_batch() and attend() the old q8_0 arguments. Each case now compares against the halves the int8 path would actually hand those kernels (the codes' whole cache dequantized, K then V), and both the q8 and f16 arms print all passed.

On the batch check, I still cannot make it part here. With your 5090 command line adapted (Ornith 1.5-35B-A3B UD-Q6_K, since I do not have Qwen3.6 UD-Q3_K_M or Tiel Coder locally):

--max-context 8192 --kv int8 --expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0
--threads 13 --batch 4 --check-batch 4 --eos-ids 999999 --max-new 64 --tokens <N ids>
prompt --kv int8 --kv fp16
2,048 ids 64 / 64 / 64 / 64 —
5,500 ids 64 / 64 / 64 / 64 64 / 64 / 64 / 64
6,800 ids 64 / 64 / 64 / 64 —

and the same 5,500 with MiniCPM-V-4.7-35B-A3B Q6_K: 64 / 64 / 64 / 64. All with --kv-whole in force through a55468da, so the expert cache cannot move between the two phases. --expert-swaps 0 --cpu-share 0 did take every expert out of the swap set, so the split is as static as I can make it.

Two differences from your runs that may matter more than the prompt length:

  • your prompts are random ids 100–2999 (random.Random(6)), mine are real text tokenized from a file. If the batch path reads uninitialized memory, a noise prompt is the better probe.
  • your card is 32 GB and mine 8 GB, so your --expert-cache and the MoE shape of the run differ; with every expert in VRAM the GPU and CPU splits do not exist at all on your 5090, while on mine they still do for whatever does not fit.

If you still see it after b6d6a1ec, the next thing I would want is a layer dump from both arms: --dump-layers writes each phase's hidden state, so the first differing layer and phase would say whether it is the store, the conversion, or the attention.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the test fix. One correction on the probe: the laptop run in my last comment used real text, not the random ids (those were only the 5090 run). The prompt is the first part of a long English document followed by a question, in Qwen3.6's chat template, cut to its first 5,500 tokens. The card is the same class as yours (RTX 4070 Laptop, 8 GB). So the difference left is the model: Qwen3.6 UD-Q3_K_M and Tiel Coder UD-Q3_K_XL part from the first tokens with int8, your Ornith UD-Q6_K and MiniCPM-V Q6_K don't.

The quickest way to see it on your side is the file setup gives an 8 GB card by default: START-HERE.bat --family qwen36 --model UD-Q3_K_M (about 16 GB), then the same --check-batch command with --kv int8. If it parts there, the Q3 files' attention tensors (types or layout) against the Q6_K ones would be the first thing I'd compare, since the expert split shouldn't matter with --kv-whole in force. If it doesn't part for you, tell me and I'll make a per-layer dump of the failing run here.

`--batch K --check-batch K` reported a divergence under --kv int8 that no
prompt of mine had shown: sequence 1 matched for all its tokens, sequence 4 for
none. The same run under --kv fp16 passed every sequence, so it was this
branch's own path and not the batch machinery.

Two things were wrong in `Qwen35::attn_rows` (step()'s rows, one sequence a row).

The fp16 twin, Block::k_f16, is one pair for the whole model -- that is what
the alloc comment buys (a pair a layer would cost as much VRAM as the
quantized cache saves). A prompt batch can use it because it is a single
sequence moving forward, which is the invariant f16_owner_/f16_rows_ keep.
`attn_rows` converted each row's own sequence into that same pair, so four
sequences at row 100 all wrote the pair's row 100: the last row converted won,
and every earlier one had already been read against another sequence's keys.

It also read through cuBLAS (`attend_batch`) while a decode reads the codes
directly with the dp4a kernels (`attend_q8`). Those two orders of products do
not agree to the bit, so even with the twin fixed, a row read on its own
through eval() and the same row inside a step() came out different -- and a
greedy answer built from either goes its own way.

The rows now read their own sequence's codes with `attend_q8`, the reader a
decode takes, and the prompt side of a --batch run does the same instead of
going to cuBLAS, so one request has one order of products whether or not it
shares a step() with another. Without --batch nothing changes: a prompt keeps
the cuBLAS path, which is 1.7x faster at 16k and 2.4x at 32k, and its rows are
not compared against anything.

Measured, Ornith-1.5-35B-A3B UD-Q6_K, --expert-swaps 0 --cpu-share 0 --window 1
--tail-skip 0 --threads 13 --batch 4 --check-batch 4 --max-new 4:

  6,000 ids, Random(6).randrange(100, 3000), --kv int8
    before: 4/4, 2/4, 1/4, 0/4    after: 4/4, 4/4, 4/4, 4/4
  2,048 ids of the same noise, --batch 2 --max-new 1
    before: want 565, got 197     after: want 197, got 197
  real text, 256 and 512 ids, 5,500 ids, --kv int8 and --kv fp16: all 4/4

poly_kv_q8_test: 35 checks, ok.

The dump also stops forcing one row under --batch: step() writes several
sequences' rows at once and cannot do that in one, so a batch keeps its rows
and the dump prints the first of them.
@smashdog

Copy link
Copy Markdown
Contributor Author

Found it, and it was my bug rather than the batch machinery's — 2c8ec1ba on the branch.

Your --check-batch report is what led there, so first the reproduction. Random(6).randrange(100, 3000) as you use it, 6,000 ids, Ornith-1.5-35B-A3B UD-Q6_K, --expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0 --threads 13 --batch 4 --check-batch 4 --max-new 4:

sequence 1 2 3 4
--kv int8, before 4/4 2/4 1/4 0/4
--kv fp16, same prompt 4/4 4/4 4/4 4/4
--kv int8, after 4/4 4/4 4/4 4/4

Two separate faults in Qwen35::attn_rows, which is how step() reads a row of one sequence.

The fp16 twin (Block::k_f16) is one pair for the whole model — that is the alloc comment's whole point, a pair per layer would cost as much VRAM as the quantized cache saves. A prompt batch can use it because it is one sequence moving forward, the invariant f16_owner_/f16_rows_ keep. attn_rows converted each row's own sequence into that same pair, so with four sequences at row 100 all four wrote the pair's row 100; the last one converted won and the reads before it had already gone against another sequence's keys. Your --batch 4 is what makes that reachable — with one sequence the pair is only ever the bound one's.

Even with that fixed, a row read alone went through eval() → attend_batch (cuBLAS on the dequantized halves) while the same row inside a step() read the codes with the dp4a kernels (attend_q8). Those two orders of products do not agree to the bit, which is exactly what you suspected when you said the 3,603-row dump was too good to be true: a prompt row and a decode row were never produced by the same arithmetic. It showed up as want=565 got=197 on the first token of a 2,048-id prompt, before any KV had accumulated.

So both now take one reader: attn_rows reads its own sequence's codes with attend_q8, and a --batch run's prompt side does the same instead of going to cuBLAS. One request, one order of products, whether or not it shares a step() with another. Without --batch nothing changed — a prompt keeps cuBLAS, which is 1.7x faster at 16k and 2.4x at 32k, and its rows are not compared against anything.

Also still passing after the change: real-text prompts at 256, 512 and 5,500 ids under both --kv int8 and --kv fp16, all 4/4, and poly_kv_q8_test at 35 checks.

One more thing worth knowing for your runs: --dump-layers used to force rows_ to 1, so it could not be combined with --batch at all (step takes 1 to sequences() tokens). A batch now keeps its rows and the dump prints the first of them, so the layer dump you offered to take is possible — though with this fix there should be nothing left for it to find.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Confirmed on the laptop: 2c8ec1b with --kv q8_0 gives 64 / 64 / 64 / 64 for Qwen3.6 UD-Q3_K_M and for Tiel Coder UD-Q3_K_XL on the same 5,500-token prompt that failed before (MSVC build, everything else as in my last run). Thanks for chasing it down, both faults are real.

One cost of the fix I'd like to see measured before merging: with --batch the prompt side now takes attend_q8 instead of cuBLAS, and the server runs "parallel": N with --batch N. So a server with parallel requests and --kv int8 would read long prompts with the slower reader (your 1.7x at 16K, 2.4x at 32K), while main's q8_0 under --batch reads them with cuBLAS and still passes the same check (64 of 64 on both models here). If I read main right, it gets there because a decode row goes through the same elementwise reader in eval() and in step(), and a prompt is read the same way in both phases anyway.

Could the prompt batches keep cuBLAS under --batch too, with only the decode-sized calls (one token, or a window of guesses) on attend_q8 in both eval() and attn_rows()? If the check still matches that way, nothing is lost. If it doesn't, please post --batch 4 prompt-read speeds at 16K and 32K for this PR's int8 against main's q8_0, so the trade-off is on the table.

The prompt reader differs between the two modes: without --batch a prompt row
goes through attend_batch (dequantize into the fp16 twin once, then cuBLAS),
with --batch it must take the same reader as a decode row because the two do
not agree bit for bit and --check-batch compares them.  Measured on an RTX 4070
Laptop, Ornith Q6_K, serial arms: 1.45x slower prompt at 16K and 1.86x at 32K
against main's q8_0, level with --batch 0, decode unaffected.

Also correct the 5090 note, which said the prompt path is fp16 either way --
true only without --batch.
@smashdog

Copy link
Copy Markdown
Contributor Author

The cost you asked to see: --batch 4 prompt read, this PR against main

I measured it before answering, on the card I have (RTX 4070 Laptop 8 GB, Windows, CUDA 13.4,
MSVC 19.44). Model Ornith-1.5-35B-A3B Q6_K, the same --max-context, --expert-swaps 0 --cpu-share 0 --window 1 --tail-skip 0 --threads 13 --max-new 4, prompt a 16K and a 32K id
sequence fed on stdin (GEN), four arms serial, each arm a fresh process, the GPU back at its
~886 MiB baseline before the next arm starts. Main is bb87f2f0 built from the same tree recipe.

prompt arm --batch 4 prompt tok/s --batch 0 prompt tok/s decode tok/s (--batch 4)
16K this PR, --kv int8 811.1 1410.4 31.25
16K main, --kv q8_0 1177.1 1409.2 31.25
32K this PR, --kv int8 574.5 1379.3 28.65
32K main, --kv q8_0 1068.6 1377.0 30.64

main / this PR, --batch 4: 1.45× at 16K, 1.86× at 32K. With --batch 0 the two are the
same number (0.999× and 0.998×) — so the whole gap is the prompt reader that --batch puts
the rows through, not the cache format or the decode path. Decode matches at 16K and sits 6.5%
below at 32K, which is the V-side being fp16 in int8k's half of the block; plain int8 keeps
both sides quantized.

Your estimate was 1.7× / 2.4×; measured here it is 1.45× / 1.86×. Same order, same direction.

Why option ① does not work

I tried exactly what you proposed before measuring — keep cuBLAS for the prompt stage under
--batch, and route only the decode-sized calls through attend_q8. It fails, and it fails at
one row, not four:

  • --batch 1 --check-batch 1 on a 2048-id prompt: the first 0 of 1 tokens as on its own.
  • Same with STRATA_FREEZE_EXPERTS=1 and with --no-graphs: still fails, so it is neither the
    expert split nor graph replay.
  • --batch 0 on the same prompt: 197,197,62,220, twice. --batch 1 under the proposal:
    0,0,0,0 — not a last-bit difference, the whole row is wrong.
  • The same proposal with --kv fp16: 197,197,62,220, fine. With a 256-id prompt: fine. With
    2048 ids and --prompt-batch 2048 (one call instead of two): fine.

So the break is int8 + a prompt read spread over more than one call + --batch, and the reason
is structural: Block::k_f16 / v_f16 is one pair of twins for the whole model, not one per
sequence or per row-batch (qwen35.cpp:496-500 says so; a pair per layer would be 737 MiB —
more than the int8 cache saves). attn_rows writes each row's prefix into that same pair, so
with more than one call in flight the later call converts over the row the earlier one has not
consumed yet. Under one call it happens to be ordered; across calls it is not.

The two readers therefore have to agree: eval() and attn_rows must both take the int8 path,
which is what the commit does, and what makes --batch pay the slower prompt reader. That is
the trade-off, and it is not one I can make disappear by ordering alone.

What this means for the server

"parallel": N with --batch N and --kv int8 will read long prompts at 574–811 tok/s where
main's q8_0 reads them at 1069–1177. If that is the shape of the deployment, --kv int8 is
the wrong arm on a card where the VRAM it saves is not buying experts back — the honest advice
in the docs is to keep q8_0 for a parallel server and take int8/int8k where the saved
cache actually converts into resident experts (and for Gemma 4, which q8_0 never covered).

Three ways forward, and I will take whichever you prefer:

  1. Leave it as is and document the --batch cost (table above) in docs/Q8_KV.md. The PR is
    correct today; this is a stated price.
  2. Give attn_rows a per-sequence twin. It needs block-sized twins per concurrent sequence
    (4 × 2 × per-row × layers), which on a 8 GB card is what we are trying to avoid — but on a
    32 GB card it is affordable. A flag could pick between "one twin, one reader for everyone"
    and "twins per sequence, cuBLAS for the prompt". More moving parts; I would rather not.
  3. Take the int8k half first. Gemma 4 has no q8_0 at all today, so int8k there costs
    nothing against main — it is a pure gain. Narrowing this PR to Gemma 4 + int8k and leaving
    qwen35moe's int8 for a later PR would let the useful half land now.

My preference is 1 + 3: state the cost, and if you would rather review a smaller diff, split the
qwen35moe int8 half out and land Gemma 4 first.

One correction to my earlier comment, since it matters for reading the table: the 803→? gain I
quoted was measured with --batch 0. Under --batch 4 the prompt stage is the regression above,
and the section in docs/Q8_KV.md that says "the prompt path is fp16 either way" is only true
when --batch is off — I am fixing that sentence in this PR.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks for measuring it, and for trying the cuBLAS route before saying no; the shared twin explains why it can't work.

Let's take 3. With q8_0 as an alias of int8, everyone who runs --kv q8_0 today would get the new readers, and on the numbers we have that's a step back for some of them: 1.45x to 1.86x slower prompt reads under "parallel", and 9-12% slower decode on the 5090, while your laptop and donschen's 9070 XT write faster. So for qwen35moe I'd keep the current q8_0 as it is, not as an alias.

So the PR I'd merge is Gemma 4's int8 / int8k: Gemma 4 has no quantized cache on main, so there it costs nothing against anyone. Keep the fixes you made along the way that apply to it (the per-sequence reads in the batch path, the poly_kv_q8_test HIP fixes, the fattn test). The qwen35moe int8 readers can come back as a separate PR, opt-in as int8 next to q8_0 rather than in its place, with the --batch cost written down as you've done in Q8_KV.md. Then whoever's card gains from it can choose it.

@smashdog

Copy link
Copy Markdown
Contributor Author

Here is exactly how I would rebuild the branch if you confirm, so nothing is a surprise in the diff.

Base. Rebased onto origin/main, no merge commits, so the PR is only the new work. Everything below is either kept from today's branch or reverted to main.

kernels.cu / kernels.hpp — both families, neither touched.
Main's Q8_0 readers (Kvq8, store_q8, store_q8_at, attend()'s kq8/vq8, attend_batch's kwork/vwork) stay byte-identical, so qwen35moe's cache path does not move at all. The int8 family is added alongside under new names, because the two store_q8 signatures differ and cannot overload:
Q8Cache → KvInt8, store_q8/store_q8_at → store_int8/store_int8_at, dequant_q8_to_f16 → dequant_int8_to_f16, attend_q8/attend_q8_mixed → attend_int8/attend_int8_mixed.

qwen35.cpp / qwen35.hpp — reverted entirely to main. That removes both the int8 readers and the bug they caused there: main's attn_rows already takes each row's own sequence via seq_buf(r.seq, B.k_codes) and has no shared twin. So qwen35moe returns to the exact behavior you measured 64/64 with --kv q8_0, and the shared-twin fault cannot exist there any more. The readers themselves stay on today's branch (pr3-kv-int8) for the later PR where int8 sits next to q8_0.

gemma4.cpp / gemma4.hpp — kept from today's branch. Codes/delta caches, cache_q8(), the whole-model fp16 twin (sh_k_f16_, twin_cover(), twin_k_/twin_v_), the query scratch q8c_/q8d_, card_row_bytes()'s int8k branch, save/load_card_rows, and attn_rows taking the per-sequence int8 path for a batch's rows.

engine.hpp. KvFmt grows kInt8 and kInt8K instead of the parallel kv_int8/kv_int8_k bools; int8() / int8k() accessors read it. gemma4 uses those; qwen35moe keeps reading kv_format == KvFmt::kQ8_0, untouched.

main.cpp. --kv fp16 / --kv q8_0 unchanged. int8 and int8k are new values. For a non-Gemma-4 model they fail early, with a message naming Gemma 4 and pointing at --kv q8_0 — not a silent fall back to q8_0, since that would be an alias by another door.

Docs. Q8_KV.md keeps the new sections — the --batch cost table (16K 1.45× / 32K 1.86×), the Gemma 4 answer table, the HIP notes — and the passages that present int8 as q8_0's replacement come out. GLM.md's --kv line likewise.

Tests. Main's poly_kv_q8_test target stays as it is (it must still pass unchanged). The int8 kernels get their own target with the HIP fixes so it also builds on AMD; fattn_test.cu's int8 comparison comes along.

Verification before I push. Main's poly_kv_q8_test passes untouched; the new int8 test's 35 checks pass on the 4070, and on HIP the dp4a path with the MMA stub; gemma4 --check-batch at 256, 512 and 5500 ids across fp16 / int8 / int8k; a gemma4 --dump-layers compare of fp16 against int8; and the prompt/decode numbers re-measured on the rebuilt tree so the table describes what ships.

Two things I would rather have your call on than assume:

  1. Names. store_int8 / attend_int8 / KvInt8 for the added family — or would you rather they carry the Q8_0 name and main's readers move to _q8 suffixes instead? The first keeps main's files untouched, which was the point of the split, so it is my preference.
  2. Rejection where a non-Gemma-4 model is given --kv int8: hard error, or map to q8_0 with a printed note? I read your "not as an alias" as ruling out the map, but it is the one place the new flag changes an existing user's experience, so say if you want the other.

I will not touch the branch until you confirm this shape.

@VecSzn

VecSzn commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

That shape is right, thanks for laying it out.

  1. Names: store_int8 / attend_int8 / KvInt8, with main's readers untouched, as you prefer.
  2. A hard error for --kv int8 / int8k on a model that isn't Gemma 4, naming --kv q8_0 for qwen35moe. A quiet map would be the alias again, and since int8 is a new value the error doesn't change anything for anyone running today.

Setup doesn't pass --kv to strata-poly at all (it warns that the setting is unused), so the new values only reach people who add them to their run config; nothing to change there. Go ahead.

@smashdog

Copy link
Copy Markdown
Contributor Author

I took your "option 3" and rebuilt the branch. The rebuilt branch is #59 — please close #33 and work on #59 instead, since #33 still carries the old shape (qwen35moe on the int8 readers) and I would rather not have two PRs with the same code in different states.

What changed in the rebuild

The scope is now exactly what you asked for: Gemma 4 only.

  • --kv int8 (both sides quantized) and --kv int8k (the keys' side only) are Gemma 4's.
  • --kv q8_0 stays qwen35moe's, and it is not an alias of int8 any more. The two readers are not the same arithmetic — q8_0 dequantizes ggml's 34-byte blocks element by element into floats; int8 keeps the same numbers in two arrays (codes back to back, the deltas beside them) and reads them with __dp4a / an int8 tensor-core MMA. A model takes one family or the other, and each is rejected for the other architecture at load:
    • --kv q8_0 is only supported for qwen35moe models in v1; this is a 'gemma4' model
    • --kv int8 and --kv int8k are Gemma 4's; for a qwen35moe model use --kv q8_0 (this is a 'qwen35moe' model)
  • qwen35moe is untouched in poly: --kv int8 and --kv int8k for Gemma 4, with the readers renamed apart from q8_0's #59 — its q8_0 path and its readers are as they are in main, and the int8 readers are not wired to it at all.

The symbols are renamed apart, so the two families read apart in the source

KvInt8 (was Q8Cache), store_int8 / store_int8_at, dequant_int8_to_f16, attend_int8 / attend_int8_mixed, and the score readers attn_score_int8_kernel / attn_score_reg_int8_kernel / attn_score_mma_int8_kernel. The q8_0 family keeps store_q8 / store_q8_at / Kvq8, and attend() / attend_batch() keep their Kvq8 arguments.

The int8 kernels are otherwise byte-for-byte the code that was in #33: the same __dp4a / register / MMA tiers, the same fp16 twin for a prompt batch, the same ring stores, the same save/load and parking paths. The rename and the split changed no arithmetic — I checked it by running the two builds against each other:

  • the same 64 tokens for all five prompts under both int8 and int8k, 10 of 10 runs identical;
  • and 2704 of 2704 --dump-layers lines identical.

The measurements, on this machine

RTX 4070 Laptop (8 GiB), Gemma4-26B-A4B-Uncensored-HauhauCS-Balanced-Q4_K_M.gguf (Q4_K_M), 13 threads, greedy, --no-draft --window 1 --tail-skip 0 --expert-swaps 0 --cpu-share 0 --vram-reserve-mib 512, the expert split held still (STRATA_FREEZE_EXPERTS=1), five prompts, each arm a fresh process, medians of 15:

prompt, ms decode, tok/s
fp16 984.0 37.68
int8 941.8 (−4.3%) 38.22 (+1.4%)
int8k 968.1 (−1.6%) 38.01 (+0.9%)

That is within run-to-run scatter, and I want to be plain about why: this file is Q4_K_M and this card cannot hold the experts either way (900 of 3840 routed experts in VRAM, the rest on the CPU), so the KV cache is a small part of what this configuration reads. The point of the format is the VRAM it gives back, not the clock — on a card that holds the whole model the KV cache is a bigger share, and your measurements would say more than mine.

The answers: the four factual prompts keep their answer under both formats (KV OK, 42, Paris, 2, 3, 5, 7, 11); the difference is confined to the chain-of-thought phrasing, and the open-ended creative prompt is worded differently by each format.

The kernel test (poly_kv_int8_test, 35 checks) agrees with ggml's own quantize_row_q8_0_ref to the bit — 0 of 1,536,000 halves differ — and gives cos(attend, attend_int8) 0.99995–0.99996 at 64 to 3000 keys, the window layers' rings included. The MMA probe runs one product and checks it (0 of 128 wrong), and the MMA is 3.4× the dp4a where it runs.

Two things I would like you to decide

  1. The cost of the split, stated. The int8 family and the q8_0 family both live in kernels.cu now, and attend() / attend_batch() carry the q8_0 family's Kvq8 arguments alongside. The file is larger than either family alone. If you would rather the int8 family sat in its own file, or in its own translation unit, say which and it moves.
  2. The AMD card. Under HIP the MMA tier's PTX has an empty body and attend_int8 never selects it (tensor_cores() is false under __HIPCC__), so the dp4a tier and the fp16 twin carry the prompt path; the test skips the mma probes there. I have not run it on an AMD card — donschen may want to, the same way he took the q8_0 side.

And one correction to my own earlier claim, since it is in this thread: the "3603 of 3603 lines bit-identical" I wrote earlier was an int8k-against-int8k comparison (both arms quantized), not fp16 against int8 — the same engine, the same format, so it was equal by construction and I described it as if it said more than it did. The numbers above are the ones that mean something.

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.

3 participants