Skip to content

qwen27 CUDA: an opt-in 4-bit attention cache (262K on a 24 GB card) — agreeing on its gate #799

Closed as not planned

Description

@CanadaApollo6

Per CONTRIBUTING, an arithmetic change needs an issue first, so we can agree on its gate. This proposes an opt-in 4-bit attention cache for the qwen27 CUDA engine (TF_QWEN27_KV_BITS=4, off by default). With it, Qwen3.8-27B serves its full 262K window on one 24 GB RTX 3090. With the bf16 cache it fits about 32K (more with machinegenieorg#5).

What it is

  • Storage: each (position, KV head) keeps a 144-byte row: Hadamard-rotated groups of 32 values, as 4-bit codes plus an fp16 scale. That is 18 KiB a position over the 16 attention layers, against 64 KiB in bf16.
  • Decode rounds: pack the window's new keys and values the same way, and rotate the queries once. Then attend over the 4-bit cache in rotated space (shared 512-key chunks, each row's tail, an in-order merge), and rotate the output back once.
  • Prompt chunks: pack into the cache, and attend with prefill_attention's kernel over decoded slots.
  • Contracts: drafted output still equals "draft": false output, and concurrent replies equal solo runs, because both sides use the same cache. It is not exact against the bf16 cache, which is why it is opt-in.

Measurements so far

The kernels are ports of the CUDA code I wrote and measured first on 0.3.6's Python engine. The fidelity numbers below come from there, so the Zig port's own run is part of the gate below.

Teacher-forced vs the bf16 cache (one long real document, frozen token file, 3090):

Depth KL mean KL, worst token Same top token Perplexity, bf16 / 4-bit
1,024 0.0034 0.030 96.9% 8.91 / 8.92
16,384 0.0055 0.045 96.9% 5.71 / 5.74
49,152 0.0018 0.029 99.2% 2.12 / 2.12

The top-token differences were near-ties. The check stops at 49K because the bf16 cache fits no more on 24 GB.

Zig engine, 3090:

  • Drafted equals serial with the cache on (4/4).
  • Served at 262K, it recalled a code planted in the middle of a 192K-token prompt. Peak use was 19.6 GB.
  • Decode is ~14% faster than bf16 at 28K, because there are fewer bytes to read.

Known weak spots, reported as found:

  • An earlier version of the 4-bit cache missed one exact lookup among thousands of near-identical records in a 159K-token prompt. No bf16 window that long fits 24 GB, so I can't tell whether storage or length caused it.
  • A 20-problem math probe scored 15/20 at a 262K window with the 4-bit cache. 4-bit EXL3 with a bf16 cache scored 18/20, but that's a different engine and weights, so it isn't a clean comparison.

Proposed gate

  1. Off by default. It runs only under TF_QWEN27_KV_BITS=4 (or a serve flag, if you prefer). With it unset, nothing changes, byte for byte.
  2. Exactness that must still hold with it on: drafted equals plain, concurrent equals solo, and resumed equals fresh. Checked on the 3090 with tf-qwen27-cuda draft/multi, and on served requests.
  3. Fidelity on the Zig engine itself, teacher-forced against the bf16 cache on public text, at 1K, 8K, 32K and the deepest bf16 window that fits. Record the KL mean and worst token, and every top-token difference with both margins. Proposed bar: mean KL ≤ 0.01 and worst ≤ 0.1 at each depth.
  4. Long-context recall at 64K, 128K and ~250K with the cache on, with each prompt published.
  5. Memory and speed receipts per CONTRIBUTING: the startup gate's numbers, peak device memory, and decode at 32K and 262K.

Questions:

  • Is env-only fine, or do you want a serve flag?
  • Should it stay CUDA-only and qwen27-only for now? On GB10 or the RTX PRO 6000 the bf16 cache already fits long windows, so I'd keep the default there.
  • A related change: on pre-sm_90 cards, the rule that runs shared-input decode weights unsplit once they fill the card gives ~2-3%, but changes K splits. Should it go through the same kind of gate, in a separate issue?

Once we agree on the gate, I'll open the PR against #608's branch (after machinegenieorg#3 and #4 there) with those receipts.

🤖 Generated with Claude Code

Activity

  1. ashhart commented on Oct 14, 2026

    @ashhart
    Owner

    Hello @CanadaApollo6,

    Thank you for opening the gate before the PR, and for writing the weak spots next to the KL table.

    On main the qwen27 cache is the family's bf16 rows. There is no TF_QWEN27_KV_BITS. Qwen3.8-27B is the Metal engine (README.md:75). The CUDA registry on main is Nemotron only (zig/src/native/cuda.zig:13), so the CUDA qwen27 engine is not in this tree.

    A 4-bit attention cache changes the bits, so it stays off unless a flag asks for it. The maintainer sets the gate. Off by default, drafted equals plain, concurrent equals solo, and resumed equals fresh, are the checks that have to hold with the flag on. The KL bar, and whether the flag is an environment variable or a serve flag, are the maintainer's call. CUDA-only and qwen27-only is a fair first slice. The pre-sm_90 split change belongs in its own issue, as you said.

    Thank you again.

  2. ashhart commented on Oct 14, 2026

    @ashhart
    Owner

    Hello @CanadaApollo6,

    Thank you for the 4-bit attention cache, and for the KL table next to the weak spots.

    It is welcome as one value of --kv-cache, off by default. The default stays bf16. It waits until the 27B CUDA engine serves. The Zig teacher-forced compare against the bf16 cache, on a named fixture, has to meet the bar you named: mean KL at most 0.01, and a worst token at most 0.1.

    Thank you again.

  3. ashhart commented on Oct 19, 2026

    @ashhart
    Owner

    Thanks @CanadaApollo6; the native 27B CUDA engine now ships in 1.1.0, so its serving prerequisite is cleared.

    If you're continuing this work, please send the focused opt-in --kv-cache change and the named Zig teacher-forced comparison against bf16 at the agreed depths, with mean KL at most 0.01 and worst-token KL at most 0.1 at each depth, plus the RTX 3090 long-context, memory and exactness receipts; we'll handle integration with the current engine and keep bf16 as the default.

    Posted by Ash's AI Assistant Synthena

  4. CanadaApollo6 commented on Oct 21, 2026

    @CanadaApollo6
    ContributorAuthor

    Hello @ashhart,

    The --kv-cache q4 port to the 1.1.1 qwen3_5 CUDA engine is done, and its exactness holds. It does not meet the agreed bar, so I'm reporting the gate before opening the PR.

    The change (branch ready, not opened)

    • --kv-cache q4 for qwen3_5 on CUDA, off by default; bf16 stays the default. Each row is 144 bytes for a 256-wide head: Hadamard-turned groups of 32, as 4-bit codes plus fp16 scales.
    • qwen3_5_moe refuses the flag until it has a gate run of its own (the qwen27-only first slice).
    • The gate is tf-qwen35-cuda <checkpoint> kv-gate fixture.json out.json <bf16|fp8|q4>. It is teacher-forced against the bf16 cache, one engine at a time.
    • The fixture is every *.md in the repo at d1018b31, concatenated: 258,305 tokens. At each depth d, a passphrase is planted at d/2 and its question at d, and 256 rows are scored from d. sha256 6fd31915….
    • bf16 against bf16 through the tool gives KL 0 at every depth.

    Gate (Qwen3.8-27B-MLX-4bit 22d8d538; mean KL / worst token)

    depth q4 fp8 (on main), same fixture q4 top-1 q4 recall
    1,024 0.0065 / 0.085 0.0020 / 0.071 96.1% yes
    8,192 0.0093 / 0.171 0.0022 / 0.090 93.8% yes
    32,768 0.104 / 17.9 0.048 / 11.3 93.4% yes
    65,536 0.080 / 11.8 0.038 / 7.6 93.8% yes
    131,072 0.046 / 2.29 not run 95.3% yes
    258,048 0.0083 / 0.192 not run 95.7% yes

    What I found:

    • One 32K row can't be scored by any format.
      • Position 32944 moves about 20 logits under lossless changes alone. A diagnostic build read raw bf16 rows through the q4 kernel, with nothing rounded, and still gave KL 24.5 there.
      • Served bf16 on the same ids predicts a different token than the gate's bf16.
      • fp8 flips the same row.
    • Large flips cluster after the planted question. Every flip over 2 logits sits 100+ rows after it, where the document resumes mid-sentence.
    • Without that row, the real cost of 4 bits is still too high:
      • About 4–9x fp8's drift: 32K mean 0.034 vs 0.0038; 64K 0.034 vs 0.0083.
      • +21% perplexity at 64K.
      • Keeping keys or values exact still misses the mean bar at 64K: 0.0196 with exact keys, 0.0455 with exact values. So 8-bit keys with 4-bit values would not pass either.
    • The earlier numbers were on easier text. They came from the Python engine on a different document: 0.0018 / 0.029 at 49K. That text had perplexity 2–9; this fixture runs 4.5–95.

    Receipts (A10 24 GB sm_86 as the 24 GB stand-in, A40 48 GB for the deep bf16 reference; not yet a 3090)

    • Memory: a 262K window fits in 5.21 GiB a stream, peaking at 21,389 MiB through a 258K prompt. bf16's largest window on the same card is about 76K.
    • Recall: correct at 32K, 64K, 128K and 258K.
    • Drafted equals plain at 32K, 64K and 128K.
    • Exactness with the flag on:
      • bench_concurrent --alone --serial: 24/24 and 12/12.
      • bench_cache --require-cold qualifies.
      • Window, tree, resumed and beside all equal step byte for byte.
      • bf16 output is identical to d1018b31.
    • Speed:
      • Short-prompt decode is about 4% below bf16 at the same window.
      • Prompt reading is about 17% slower than bf16 at 32K.
    • Checks: lean_check is unchanged at 444, and zig fmt is clean.

    Which would you prefer?

    1. A bar relative to fp8 on the same fixture. For example, mean within 3x of fp8's, with the worst row reported but not gated.
    2. A fixture whose scored rows are unbroken text, with recall checked separately.
    3. A wider format, gated the same way before you look again. 5-bit codes are untested.

    I'll send the PR with 3090 receipts once we agree on which.

    Thank you again.

  5. ashhart commented on Oct 24, 2026

    @ashhart
    Owner

    Thank you @CanadaApollo6 for the careful port and for reporting the gate honestly, including the row no format can score. That kind of receipt is exactly what we ask for.

    We've decided not to take a 4-bit attention cache, and not a 5-bit one either. TensorFold's aim is exact serial decode made faster: every reply is the one the model's own arithmetic gives. Your numbers show the 4-bit cache moves the model's predictions measurably at long depths, 4 to 9 times fp8's drift with +21% perplexity at 64K. A narrower format would trade that quality for memory, and we'd rather not offer that trade, even opt-in.

    The memory problem is still real, and we'd welcome work on it in exact ways: KV pages from the shared pool (the union carrying #1156 is close), prompt-state reuse, or offloading cold pages without changing their bits. Thanks again for the time this took.

    - Synthema, Ash's AI Assistant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions