Skip to content

feat(kvcache): store the KV cache as fp8 e4m3 codes (--kv-cache-dtype… - #354

Open
ArqAlice wants to merge 4 commits into
FlashML-org:mainfrom
ArqAlice:feat/fp8-quantization
Open

feat(kvcache): store the KV cache as fp8 e4m3 codes (--kv-cache-dtype…#354
ArqAlice wants to merge 4 commits into
FlashML-org:mainfrom
ArqAlice:feat/fp8-quantization

Conversation

@ArqAlice

@ArqAlice ArqAlice commented Sep 2, 2026

Copy link
Copy Markdown

… fp8)

One (token, kv head) row of K and of V becomes head_dim e4m3 codes plus ONE fp32 symmetric scale, in a code buffer with exactly the geometry of the 16-bit KV buffer -- only the element type changes. That halves the bytes per cached token (the scale sidecar costs 4/head_dim of it back, ~3% at head_dim 128), and it is what lets Qwen3.8-Flash-Next serve a 1M-token context on this card.

Codes are kept in a plain uint8 buffer on EVERY architecture, and the fp8e4nv type never appears in a kernel signature. Both ways of choosing that per target failed on real hardware and are recorded here so nobody reopens them: the compile-time fp8-native probe (e4m3_compat.e4m3_native_cx) answers the question independently from the host that allocated the buffer and disagreed with it on sm_100, and branching on a pointer's element type is NOT statically pruned -- triton still type-checked the dead arm, whose int mask fill is illegal against an fp8 pointer ("cannot cast int32 to fp8e4nv", raised at CUDA graph capture). What remains is the software encode/decode that already runs wherever the fp8 type is unavailable and is bit-exact per e4m3_compat's header, so the cache holds the same bytes and produces the same numbers on every card (docs/cli.md).

  • server/args.py, engine/config.py: --kv-cache-dtype {auto,bf16,fp8}, refused at startup for the pools and backends that cannot apply the row scales (attention/init.py: BackendInfo.supports_fp8_kv) rather than ignored.
  • kernel/triton/kv_quant.py: fused quantize+scatter -- one launch under CUDA graph capture, where the slot ids arrive as a device tensor.
  • kvcache: unit_bytes() counts codes plus the scale sidecar, so ft ctl stats and cache --kv N follow the smaller footprint, and rebuild reallocates the scale buffers alongside the codes (mha, hybrid-SWA and QSA pools).
  • kvcache/base.py: pool.dtype is the COMPUTE dtype -- what store_kv receives and what a backend sizes its scratch with -- while pool.store_dtype is what the buffer holds. Reporting codes as dtype handed e4m3 to QSA's 16-bit indexer and died compiling qsa_mqa_paged; the contract is now asserted at backend init and in the kernel wrapper. QSA's block-selection keys stay 16-bit: only the selected K/V rows are read back as codes.

Tested on: sm_100, 148 SMs, Linux; 524,480 fp8 KV tokens = 6.47 GiB,
Qwen3.8-Flash-Next with: ft serve --kv-cache-dtype fp8 -> 1M-token context.
Covered by tests/kernels/test_kv_fp8.py, tests/kernels/test_qsa_fp8.py,
tests/kernels/test_triton_attention.py, tests/kernels/test_e4m3_compat.py,
tests/kvcache/test_mha_pool_fp8.py, tests/kvcache/test_qsa_pool_fp8.py and
tests/engine/test_kv_quant_config.py (CUDA-gated; not run on the Windows
development box, which has neither triton nor pytest installed).

Not included here, on purpose: unifying the two fp8-native probes (triton's cache-key walk rejects a constexpr function that defers to a host one, so warn_if_probes_disagree() reports the disagreement instead), and a hardware decode fast path on sm_89+ (that needs a constexpr flag threaded from the host plus the matching AOT variants, since testing the dtype does not prune).

… fp8)

One (token, kv head) row of K and of V becomes head_dim e4m3 codes plus ONE
fp32 symmetric scale, in a code buffer with exactly the geometry of the 16-bit
KV buffer -- only the element type changes. That halves the bytes per cached
token (the scale sidecar costs 4/head_dim of it back, ~3% at head_dim 128), and
it is what lets Qwen3.8-Flash-Next serve a 1M-token context on this card.

Codes are kept in a plain uint8 buffer on EVERY architecture, and the fp8e4nv
type never appears in a kernel signature. Both ways of choosing that per target
failed on real hardware and are recorded here so nobody reopens them: the
compile-time fp8-native probe (e4m3_compat.e4m3_native_cx) answers the question
independently from the host that allocated the buffer and disagreed with it on
sm_100, and branching on a pointer's element type is NOT statically pruned --
triton still type-checked the dead arm, whose int mask fill is illegal against
an fp8 pointer ("cannot cast int32 to fp8e4nv", raised at CUDA graph capture).
What remains is the software encode/decode that already runs wherever the fp8
type is unavailable and is bit-exact per e4m3_compat's header, so the cache
holds the same bytes and produces the same numbers on every card (docs/cli.md).

- server/args.py, engine/config.py: --kv-cache-dtype {auto,bf16,fp8}, refused at
  startup for the pools and backends that cannot apply the row scales
  (attention/__init__.py: BackendInfo.supports_fp8_kv) rather than ignored.
- kernel/triton/kv_quant.py: fused quantize+scatter -- one launch under CUDA
  graph capture, where the slot ids arrive as a device tensor.
- kvcache: unit_bytes() counts codes plus the scale sidecar, so ft ctl stats and
  cache --kv N follow the smaller footprint, and rebuild reallocates the scale
  buffers alongside the codes (mha, hybrid-SWA and QSA pools).
- kvcache/base.py: pool.dtype is the COMPUTE dtype -- what store_kv receives and
  what a backend sizes its scratch with -- while pool.store_dtype is what the
  buffer holds. Reporting codes as dtype handed e4m3 to QSA's 16-bit indexer and
  died compiling qsa_mqa_paged; the contract is now asserted at backend init and
  in the kernel wrapper. QSA's block-selection keys stay 16-bit: only the
  selected K/V rows are read back as codes.

Tested on: sm_100, 148 SMs, Linux; 524,480 fp8 KV tokens = 6.47 GiB,
  Qwen3.8-Flash-Next with: ft serve --kv-cache-dtype fp8  ->  1M-token context.
  Covered by tests/kernels/test_kv_fp8.py, tests/kernels/test_qsa_fp8.py,
  tests/kernels/test_triton_attention.py, tests/kernels/test_e4m3_compat.py,
  tests/kvcache/test_mha_pool_fp8.py, tests/kvcache/test_qsa_pool_fp8.py and
  tests/engine/test_kv_quant_config.py (CUDA-gated; not run on the Windows
  development box, which has neither triton nor pytest installed).

Not included here, on purpose: unifying the two fp8-native probes (triton's
cache-key walk rejects a constexpr function that defers to a host one, so
warn_if_probes_disagree() reports the disagreement instead), and a hardware
decode fast path on sm_89+ (that needs a constexpr flag threaded from the host
plus the matching AOT variants, since testing the dtype does not prune).
@MT-z

MT-z commented Sep 2, 2026

Copy link
Copy Markdown

Thanks for building this -- a smaller KV cache is the single thing that would help this box most,
so I pulled the branch and ran the suite on an Ada card. Nine tests fail that pass on main, and I
wanted to let you know before digging any deeper.

I could not tell from the description whether the suite has been run anywhere yet -- the note says

CUDA-gated; not run on the Windows development box, which has neither triton nor pytest installed

and the sm_100 figures look like they come from the serving run (ft serve --kv-cache-dtype fp8
reaching a 1M-token context). I mention it because four of the nine do not depend on the GPU at
all
: they are host-side Python errors that fire before any kernel launches, and one of them still
reproduces with the GPU hidden entirely (CUDA_VISIBLE_DEVICES=""). So I suspect those four are not
an Ada thing and would show up wherever you run them -- which is the main reason I am reporting now
rather than assuming it is my card.

Setup. RTX 4090 24 GB (sm_89, 128 SMs), i9-14900KF, driver 595.84 (CUDA 13.2), CUDA toolkit
13.3, triton from the repo's pin. This PR rebased onto main (6eca2d7): one commit, no conflicts.
Counts below are from running each test in its own process -- see item 3 for why that matters.


Group A -- fails without touching the GPU (4)

tests/kernels/test_triton_attention.py::test_triton_backend_stores_kv_and_matches_reference
tests/kernels/test_triton_attention.py::test_triton_backend_passes_attention_sinks_to_paged_kernel
  AttributeError: 'FakeKVCache' object has no attribute 'k_scale'   (attention/triton.py:160)

Both are upstream tests from 3af9d90 and pass on main. attention/triton.py:160 now calls
self.kvcache.k_scale(layer_id) unconditionally; FakeKVCache in the test is a plain CPU stub
with k_cache/v_cache/store_kv and no scale accessors, so it raises before any launch. The
second one still fails with CUDA_VISIBLE_DEVICES="" (the first skips on the CUDA gate). Any pool
object built by a caller that predates the scale sidecar hits the same line, and bf16 is still the
default, so a getattr/store_dtype guard there would cover both the tests and real callers.

tests/kvcache/test_mha_pool_fp8.py::test_layer_ids_remap_applies_to_scales_too
  ValueError: KV layer id 3 outside [0, 3)   (kvcache/mha_pool.py:67)

Reads like the remap indexes the scale buffers with the unmapped layer id.

tests/kernels/test_kv_fp8.py::test_encoder_inverts_the_grid_through_the_scale_one_path
  ValueError: Pointer argument (at 1) cannot be accessed from Triton (cpu tensor?)

The scale-one path reaches the kernel with a CPU tensor.

Group B -- the store kernel mis-reads a strided qkv slice (1, the interesting one)

tests/kernels/test_kv_fp8.py::test_codes_match_the_reference_quantizer_and_reconstruction_is_close
fails with 2684 code mismatches of 3072. Every mismatch is in V; K is byte-perfect. The test's own
comment names the condition -- "the row pitch is then wider than the row, which the store kernel
must honour" -- and that is exactly it. Same data, three ways (its own tokens=8, heads=3, dim=128,
seed 1):

how K and V reach the store K mismatches V mismatches
qkv.split() views, V scaled by 0.01 (as the test does) 0 / 3072 2684 / 3072
the same tensors .contiguous().clone()d first 0 / 3072 0 / 3072
qkv.split() views, but K scaled by 0.01 and V by 5.0 0 / 3072 0 / 3072

Making the input contiguous fixes it, and swapping which slice carries the small values fixes it
(K is then the small one and stays correct), so it is neither the e4m3 encoding nor V's magnitude:
it is the third slice of the qkv buffer, at offset 2 * heads * dim, read with the wrong
stride. Supporting detail: the wrong bytes are not a permutation of the right ones
(got.sort() != exp.sort()), i.e. the kernel reads other rows rather than mis-rounding; no
subnormals are involved (0 elements with |x| < 2^-6 in the failing set); and the count scales with
the tensor (8x2x64: 894/2048, 4x2x64: 382/1024, 8x3x64: 1333/3072, 8x2x128: 1774/4096,
16x2x64: 1905/4096).

Worth knowing: the second assertion in that same test passes -- dequantised error is 0.035
against a 0.08 tolerance -- so a check that only looks at reconstruction quality does not catch
this. That may be why the serving run looked healthy.

This is the one I would guess could be arch-specific (block shape or vector width making the
addresses coincide on sm_100), but I would not assume it.

Group C -- a device-side assert, and why the raw failure count misleads (1)

tests/kvcache/test_qsa_pool_fp8.py::test_store_kv_writes_the_slot_the_attend_kernel_will_read
trips vectorized_gather_kernel: Assertion 'ind >= 0 && ind < ind_dim_size' -- an out-of-range
gather index. That is sticky: every later CUDA call in the same process fails with
device-side assert triggered, so it drags unrelated tests down with it.

run failures in tests/models/qwen4_exp/test_qsa_backend.py
that file alone 3 (the same 3 that fail on main here)
immediately after test_qsa_pool_fp8.py 10
all changed test files in one pytest run 21 total, vs 9 real

The extra 7 are collateral. A host-side bounds check on that index would turn this into a readable
Python error instead of a poisoned context -- and would keep a single bad index from making the
suite look far worse than it is.

Group D -- remaining (3)

  • tests/kernels/test_qsa_fp8.py::test_fp8_codes_match_the_bf16_cache_bit_for_bit[16-2-64]:
    fp8 QSA attend diverged from the same data in a bf16 cache (max diff 1.953e-03).
  • tests/kernels/test_triton_attention.py::test_extend_paged_attention_decodes_fp8_scales[True|False]
    (new in this PR). I did not dig past Group B, since a store writing wrong V codes would explain
    a decode mismatch.

And the good news: tests/engine/test_kv_quant_config.py is 15/15 green. The flag, the config
plumbing and the per-backend refusals all behave; what is broken is under them.

Not your bug

tests/kernels/test_e4m3_compat.py::test_forced_emu_matches_native also fails, but it fails on
main on this box too (one of 7 standing failures here), so I have left it out of the counts. And
the item you deliberately left out of this PR is not what is biting: both native probes agree here
(e4m3_native() and e4m3_native_cx() are both True on sm_89, warn_if_probes_disagree() silent).


I have not measured throughput or context length -- with V codes wrong there is nothing worth
benchmarking yet. Happy to re-run anything, bisect further, or test a fix; an Ada box is the one
thing I can usefully offer here. For context on why I am keen: on this card the KV/expert trade-off
is steep (Qwen3.8-Flash-Next at 262144 KV tokens leaves 829 expert slots and 2.73 tok/s; at 131072
it leaves 2465 slots and 6.92 tok/s), so halving KV bytes buys real throughput.

Written with AI assistance; every number above was measured on my hardware and I can
reproduce it.

@Kaempferia

Copy link
Copy Markdown

Environment

Result on PR's own tests

42 passed, 5 failed (the 5 that actually execute the new Triton kernels):

FAILED tests/kernels/test_kv_fp8.py::test_codes_match_the_reference_quantizer_and_reconstruction_is_close
FAILED tests/kernels/test_kv_fp8.py::test_encoder_inverts_the_grid_through_the_scale_one_path
FAILED tests/kvcache/test_mha_pool_fp8.py::test_layer_ids_remap_applies_to_scales_too
FAILED tests/kvcache/test_qsa_pool_fp8.py::test_store_kv_writes_the_slot_the_attend_kernel_will_read
FAILED tests/kvcache/test_qsa_pool_fp8.py::test_factory_threads_kv_quant_into_the_qsa_pool

Clearing ~/.triton/cache and re-running does not change anything.

Narrowed it down (not a precision problem — stores are lost)

Driving quantize_kv_to_cache directly with a 2×2 matrix of
{contiguous vs wide-pitch (qkv slice) source} × {V normal vs V ×0.01 subnormal},
T=8, H=3, D=128, out_loc = arange(T), counting code slots left at 0x00:

source V normal V ×0.01
contiguous (stride(0) == H*D) K ok, V ok K ok, V ok
wide pitch (stride(0) == 1152 != H*D) K ok, V: 5 of 8 slots never written K ok, V: 3 of 8 slots never written

Key observations:

  1. The K path is bit-exact against a torch-side RNE reference in all four
    cases
    (kernel == (x/scale).clamp(±448).to(float8_e4m3fn), 0/3072 code
    mismatches), so the calling convention, the environment and the quantization
    math are fine.
  2. Only the V store loses slots, and only with a wide source pitch — the
    exact layout real attention backends hand over (_store in
    tests/kernels/test_kv_fp8.py exercises precisely this).
  3. The number of dropped slots varies with the input data (3 vs 5 for two
    inputs through the identical kernel), which smells like a race / undefined
    ordering rather than a deterministic indexing bug.
  4. The original test failure signature matches: V codes are 0x00 from some
    token onward while the oracle expects real codes (got[-8:] = [0]*8,
    expected[-8:] = [94, 112, 233, 220, 234, 118, 113, 248]).

The minimal driver (matrix above) is ~30 lines around quantize_kv_to_cache
with alloc_codes((T,H,D)) and an arange out_loc — happy to share it, or
to test a fix; the box is set up and each run takes seconds.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Independent confirmation from the other end of the hardware range: I see the same thing on
sm_89 (RTX 4090, 128 SMs, driver 595.84 / CUDA 13.2, nvcc 13.3.73, torch 2.11.0+cu130 and
triton 3.6.0 -- the same versions you ran
, so the toolkit and the GPU generation are the only
differences between our two boxes; this PR rebased onto main 6eca2d7). Same shape of result -- K byte-exact, V wrong, and only when the source has a wide row
pitch -- so this is not Ada-specific and not Blackwell-specific. That also retires the caveat I
put in my own report, where I had left open the possibility that it was an Ada gap.

Where our two runs agree, with the test's own parameters (tokens=8, heads=3, dim=128, seed 1):

how K and V reach the store K mismatches V mismatches
qkv.split() views, V scaled by 0.01 0 / 3072 2684 / 3072
the same tensors .contiguous().clone()d first 0 / 3072 0 / 3072
qkv.split() views, K scaled by 0.01 and V by 5.0 0 / 3072 0 / 3072

The third row is the one I would add to your 2x2: swapping which slice carries the small values
also fixes it. K is the second slice of the qkv buffer and V the third, so with the magnitudes
swapped K becomes the "V-like" small tensor and stays correct. Combined with your finding that the
count varies with the data, that points away from "the small-magnitude tensor is handled wrong"
and toward the third slice's addressing specifically.

Two more data points from my side, one of which does not obviously fit the dropped-writes model:

  1. It is non-deterministic even with identical input, and there are two failure modes at
    once. Running the exact same build (same seed, same tensors, five times in one process):

    run V mismatches left 0x00 non-zero junk fully-zero (token,head) slots
    1 2684 / 3072 1920 764 15 / 24
    2 2684 / 3072 1920 764 15 / 24
    3 2671 / 3072 1152 1519 9 / 24
    4 2671 / 3072 1152 1519 9 / 24
    5 2655 / 3072 16 2639 0 / 24

    K was 0/3072 in all five. So it is not only that stores are lost: codes that were never
    expected are also written (got.sort() != exp.sort() every run), and the balance between the
    two shifts run to run -- by run 5 almost nothing was left at zero and nearly every mismatch was
    junk. That strengthens your read: your "the count varies with the data" holds even with the
    data held fixed, so the ordering is what varies, not the input.

  2. The count scales with the tensor, so it is not an edge lane or a tail block:
    8x2x64: 894/2048, 4x2x64: 382/1024, 8x3x64: 1333/3072, 8x2x128: 1774/4096,
    16x2x64: 1905/4096 (K is 0 in every one of these).

One thing worth flagging for whoever picks this up: the second assertion in
test_codes_match_the_reference_quantizer_and_reconstruction_is_close passes even while the codes
are wrong -- dequantised error came out at 0.035 against its 0.08 tolerance on my box. A check that
only looks at reconstruction quality will not catch this, which may be why the serving run in the
PR description looked healthy.

Happy to run your minimal driver here for a second architecture, or to test a fix -- an Ada box is
what I can offer.

Written with AI assistance; every number above was measured on my hardware and I can
reproduce it.

@Kaempferia

Copy link
Copy Markdown

Thank you for the detailed follow-up, and for taking the time to verify this independently — between the two machines this now spans sm_89 and sm_120, which is exactly what whoever fixes it needs.

Two things in your data move this forward. The magnitude-swap row (K small / V large on the same views → clean) is a sharper probe than my original 2x2: it helps rule out a magnitude-dependent path and narrows things toward the third slice's addressing — since with the swap K becomes the "V-like" tensor and stays correct. And the five-run table — the zero-slot count drifting 15 → 9 → 0 while junk codes grow, with the input held fixed — is what rules out a data-dependent cause and points at undefined ordering, which fits the fingerprint we saw on our side too.

One point I would ask to be carried into any fix, because it plausibly explains how the serving run in the PR description could look healthy: the reconstruction assertion only checks dequantised error (0.035 < 0.08 here) and passes while the codes are wrong. Validation of a fix needs to happen at the codes level, not by perplexity-style error.

A corroborating observation from our side, offered only in case it shortens the search — a hypothesis rather than a verified mechanism: we see the same split in environment, not just run-to-run. Inside the pytest process the test fails consistently (2684/3072, K clean), while an identical standalone script — same build, same seed, same tensors, codes compared against a torch-side RNE reference — ran clean in 10/10 repeats (0/3072 each). With the kernel and inputs byte-identical, the trigger appears to depend on process memory layout (allocator state when v_cache is allocated), which would also explain the within-process drift in your five-run table.

Both machines remain available to test a fix — ours covers the Blackwell end (sm_120, RTX 5090 D), yours the Ada end. We would be glad to run any candidate patch through the PR's own tests plus a service-level smoke check on our side.


About this reply: like your own note, drafted with AI assistance. Every number above was measured on our hardware (RTX 5090 D, sm_120) — please take the measurements over the phrasing, as AI-assisted wording can misrepresent the intended meaning across languages.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Thanks — and likewise for the careful write-up. It is genuinely useful having a Blackwell box on
the other end of this; between the two of us the fix has somewhere to be checked before it lands.
Your note about taking the measurements over the phrasing landed with me too: same situation here,
and it is a good habit to state.

Your allocator-state hypothesis is worth pinning down, because on sm_89 it comes out the other way
round — and the disagreement is itself informative.

My five-run table was not from pytest. It was a standalone script: importlib the test module,
call _store directly, compare against the same torch-side RNE reference. So on this box the
standalone path fails too, and it is the one that drifts:

sm_120 (yours) sm_89 (mine)
inside pytest fails, consistent 2684/3072 fails, 2684/3072 in all three runs
standalone script 10/10 clean (0/3072) fails: 2684, 2671, 2655

(I just re-ran the pytest side three times to be sure of that row: 2684 every time, no drift.)

So the stable-vs-drifting halves are swapped between our two machines. That does not contradict
"depends on process memory layout" — it is what that hypothesis predicts if the layout that happens
to be safe differs per allocator history. What it does rule out is a simpler reading someone might
take from your result alone: that the bug needs pytest, or that a standalone reproducer is a clean
baseline to validate a fix against. On Ada it is neither. A candidate patch that only clears the
standalone script here would still be broken.

If it helps narrow it: my standalone run allocates the code buffers through the test module's own
alloc_codes immediately before the store, with nothing else on the device except the CUDA context
(no server, ~490 MiB used, one process). That is about as quiet as the allocator gets on this box,
and it still drifts run to run inside a single process — the 15 → 9 → 0 zero-slot progression came
from five consecutive _store calls with the tensors rebuilt identically each time. Whatever the
ordering depends on, it moves within a process here, not just between processes.

Fully agreed on validating at the codes level. For whoever picks this up, the concrete check that
catches it and that the current test does not: compare got.sort() against exp.sort() as well as
elementwise. Every failing run here had the multiset differ, which is strictly stronger than
counting mismatches — it says values that were never expected got written, so a fix that merely
reduces the mismatch count has not necessarily fixed anything.

Ada box stays available for any candidate patch, on the PR's own tests plus the standalone matrix.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, sm_89)
and I can reproduce it.

@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Good news — I think this one is solved. The GPU freed up here, so I put compute-sanitizer on it,
and it named the line on the first run. It is a plain addressing bug, not undefined ordering, and
the mechanism explains both of our machines' data at once.

What found it

TRITON_DISABLE_LINE_INFO=0 compute-sanitizer --tool initcheck \
  python -m pytest -x tests/kernels/test_kv_fp8.py::test_codes_match_the_reference_quantizer_and_reconstruction_is_close

========= Uninitialized __global__ memory read of size 8 bytes
=========     at _kv_quant_scatter_kernel+0xe0 in kv_quant.py:110
=========     by thread (0,0,0) in block (6,0,0)

The other two tools were informative by staying quiet: memcheck reported 0 errors and
racecheck 0 hazards. Worth recording for anyone else chasing a Triton kernel — memcheck is
blind to this class, because PyTorch's caching allocator rounds allocations up and the bad read
stays inside the pooled segment. initcheck is the one that sees it.

The bug

kv_quant.py:174 passes one row pitch and the kernel uses it for two tensors:

_kv_quant_scatter_kernel[(tokens, heads)](k, v, ..., k.stride(0), ...)   # K's pitch only
# kernel, line 108
src = t * stride_xs + h * D + d
xk = tl.load(k_src + src, ...)     # K -- correct, this is K's pitch
xv = tl.load(v_src + src, ...)     # V -- reads V with K's pitch

The guard at line 153 checks only the inner stride:

assert k.stride(1) == 1 and v.stride(1) == 1, "K/V rows must be contiguous"

It never asserts k.stride(0) == v.stride(0). So the kernel has a real contract — K and V share
one row pitch
— that is neither documented nor checked.

The test breaks that contract at test_kv_fp8.py:10-11, and I think correctly so:

k = k_rows.view(tokens, heads, dim)                          # view  -> stride(0) = 1152
v = v_rows.view(tokens, heads, dim).clamp(-FP8_MAX, FP8_MAX) # clamp -> stride(0) =  384

.clamp() materialises a fresh contiguous tensor. V is then read at t * 1152 instead of
t * 384, and V holds 3072 elements:

token read at correct result
0 0 0 correct by coincidence
1–2 1152, 2304 384, 768 in range, wrong rows
3–7 3456 … 8064 1152 … 2688 past the initialised data

That is why it is V only and K byte-perfect: K's pitch is 1152.

Why both of our boxes were right

This is the part I think resolves the ordering question. Splitting the mismatches by region, same
input, five consecutive _store calls in one process on sm_89:

run mismatches of which token ≥ 3 remainder
1 2684 1920 764
2 2684 1920 764
3 2684 1920 764
4 2663 1899 764
5 2676 1912 764

The deterministic half is exactly 764 every time — tokens 0–2, where the wrong address still
lands on initialised data. All of the drift I reported earlier (2684 → 2671 → 2655) sits in the
tokens 3–7 region, where the read is uninitialised and its contents depend on allocator history.

So @Kaempferia's allocator hypothesis was half right, and the better half: the values really do
depend on process memory layout. What is not layout-dependent is the addressing — that is fixed
and wrong. Which also explains why your standalone script came out clean 10/10 on sm_120 while mine
drifted on sm_89: whether the uninitialised region happens to hold bytes that match is luck, and
different allocator histories buy different luck. It was never two different bugs.

Fix

Pass V's pitch as its own argument and load each tensor with its own:

     idx_ptr,
-    stride_xs,  # source row pitch, in elements (the qkv slice is wider than one row)
+    stride_xs,  # K source row pitch, in elements (the qkv slice is wider than one row)
+    stride_vx,  # V source row pitch: K and V need not share one. A .clamp()/.contiguous()
+                # on one side alone leaves it densely packed while the other keeps the
+                # qkv pitch, and reusing K's pitch then reads V off the end of its rows.
     stride_kd,  # K cache row pitch, in elements (== HEADS * D)
@@
-    src = t * stride_xs + h * D + d
-    xk = tl.load(k_src + src, mask=mask, other=0.0).to(tl.float32)
-    xv = tl.load(v_src + src, mask=mask, other=0.0).to(tl.float32)
+    off = h * D + d
+    xk = tl.load(k_src + t * stride_xs + off, mask=mask, other=0.0).to(tl.float32)
+    xv = tl.load(v_src + t * stride_vx + off, mask=mask, other=0.0).to(tl.float32)
@@
         k.stride(0),
+        v.stride(0),
         k_cache.stride(0),

Verification on sm_89, each file in its own process, across the seven *fp8* /
test_triton_attention.py files:

  • 12 failures → 11. The one that flips is exactly
    test_codes_match_the_reference_quantizer_and_reconstruction_is_close. Nothing else changed
    state in either direction.
  • initcheck on the patched build: ERROR SUMMARY: 0 errors.
  • Five consecutive standalone runs: K 0/3072, V 0/3072 every time, and got.sort() equals
    exp.sort() — the multiset check I suggested last time now passes too. No drift left.

The remaining 11 are the other items from my first comment (the FakeKVCache.k_scale pair, the
layer-id remap, the CPU-tensor path, the device-side assert); this patch does not touch them.

How bad is it

Latent, not a live serving bug — which I think is the honest reading, and it also explains why
the serving run in the description looked healthy.

The models that use fp8 KV go qkv.split(...) then rope, and rope here is
apply_rope_with_cos_sin_cache_inplace — it returns the same tensors, so K keeps the qkv pitch and
k.stride(0) == v.stride(0) holds. The one caller that passes two genuinely independent tensors
(attention/dsa.py:224, c_kv / k_rope) is on the MLA/DSA pool, which rejects fp8 outright at
kvcache/__init__.py:232. So nothing in the tree breaks the contract today.

But the contract is invisible, and the failure mode is silent. Any future backend that materialises
K separately — a non-in-place rope, a .contiguous(), a different fused-QKV layout — corrupts V
with no error anywhere.

And to reinforce @Kaempferia's point, because this is the sharp edge: the test's second
assertion (dequantised error ≤ 0.08) passes at 0.035 while V is wrong. A reconstruction-level or
perplexity-level check will not catch this. Only the exact-codes assertion does.

Happy to open this as a PR against your branch, or leave it here for you to take — whichever you
prefer. The Ada box stays available for anything else you want run.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, sm_89)
and I can reproduce it.

@Kaempferia

Copy link
Copy Markdown

Excellent work — compute-sanitizer naming the line on the first run, and the explanation that one shared pitch reads both tensors fits every data point we exchanged. The "memcheck is blind because the caching allocator rounds up and the bad read stays inside the pooled segment" note is worth keeping for anyone else debugging a Triton kernel — that is a genuinely non-obvious failure mode.

Independent confirmation from the Blackwell end, with your patch applied to our tree (the fp8 PR head cherry-picked onto the PLE-disk line, same environment as before — sm_120, RTX 5090 D, torch 2.11.0+cu130, triton 3.6.0):

  • The fp8 test set we share went from 5 failures to 4, and the one that flipped is exactly test_codes_match_the_reference_quantizer_and_reconstruction_is_close — matching what you saw on sm_89 (your 12 -> 11 with the same single flip).
  • 43 passed in the suite; the remaining 4 failures are the items you listed from your first comment (encoder grid path, layer-id remap, qsa store/attend) and are untouched by this patch, as you said.
  • The multiset property now holds here too: five consecutive standalone runs, K 0/3072 and V 0/3072 every time.

On your allocator-hypothesis verdict — agreed, "half right, and the better half": the addressing is fixed and wrong, only the contents of the uninitialised region were layout-dependent. That also cleanly explains why our standalone script came out 10/10 clean on sm_120: luck, with different allocator histories buying different luck. It was never two bugs. And I fully agree the sharper edge is the reconstruction assertion passing at 0.035 — worth re-stating in the PR that codes-level checking is the only thing that catches this class.

One practical point on where the fix should land: our tree is a local branch of a clone — there is no GitHub fork or branch behind it that a PR could target. The right home for this fix is the PR itself: if you push it to feat/fp8-quantization (or open a follow-up PR against main), the fix travels with #354 into upstream. Both machines are then covered when it merges.

Thank you for chasing this to the actual line — that is the kind of root-cause work worth recording. Ada and Blackwell both stand ready to test anything else you want run.

1 similar comment
@Kaempferia

Copy link
Copy Markdown

Excellent work — compute-sanitizer naming the line on the first run, and the explanation that one shared pitch reads both tensors fits every data point we exchanged. The "memcheck is blind because the caching allocator rounds up and the bad read stays inside the pooled segment" note is worth keeping for anyone else debugging a Triton kernel — that is a genuinely non-obvious failure mode.

Independent confirmation from the Blackwell end, with your patch applied to our tree (the fp8 PR head cherry-picked onto the PLE-disk line, same environment as before — sm_120, RTX 5090 D, torch 2.11.0+cu130, triton 3.6.0):

  • The fp8 test set we share went from 5 failures to 4, and the one that flipped is exactly test_codes_match_the_reference_quantizer_and_reconstruction_is_close — matching what you saw on sm_89 (your 12 -> 11 with the same single flip).
  • 43 passed in the suite; the remaining 4 failures are the items you listed from your first comment (encoder grid path, layer-id remap, qsa store/attend) and are untouched by this patch, as you said.
  • The multiset property now holds here too: five consecutive standalone runs, K 0/3072 and V 0/3072 every time.

On your allocator-hypothesis verdict — agreed, "half right, and the better half": the addressing is fixed and wrong, only the contents of the uninitialised region were layout-dependent. That also cleanly explains why our standalone script came out 10/10 clean on sm_120: luck, with different allocator histories buying different luck. It was never two bugs. And I fully agree the sharper edge is the reconstruction assertion passing at 0.035 — worth re-stating in the PR that codes-level checking is the only thing that catches this class.

One practical point on where the fix should land: our tree is a local branch of a clone — there is no GitHub fork or branch behind it that a PR could target. The right home for this fix is the PR itself: if you push it to feat/fp8-quantization (or open a follow-up PR against main), the fix travels with #354 into upstream. Both machines are then covered when it merges.

Thank you for chasing this to the actual line — that is the kind of root-cause work worth recording. Ada and Blackwell both stand ready to test anything else you want run.

@MT-z

MT-z commented Sep 4, 2026

Copy link
Copy Markdown

Thanks for running it on the Blackwell end — same single flip, same multiset property, from a
different card and a different tree. That is the confirmation this needed.

On where the fix should live: you are right that the PR is the natural home. Since
feat/fp8-quantization is @ArqAlice's branch, I have put the commit on a branch of my own fork
rather than reaching into theirs — it sits directly on the current PR head, so it is a one-line
cherry-pick for whoever carries it:

git fetch https://github.com/MT-z/FreeToken.git fix/kv-fp8-vstore-pitch
git cherry-pick FETCH_HEAD

9890fec, parent 03fb043. Verified it fetches from an empty clone, so it does not depend on my
local state. One file, one kernel argument.

@ArqAlice — this is yours to take or leave. It is a fix to your PR, not a competing one, and I
have deliberately not opened anything against main that would fragment the work. If you would
rather have it as a suggested change, a patch in a comment, or shaped differently, say the word.
If the branch is easier, it is there.

The commit message carries the full derivation rather than just the diff — the read offsets per
token, why the mismatch count drifts while the mismatching positions do not, and the
compute-sanitizer invocation that named the line — so the reasoning travels with the change
instead of living only in this thread.

I also put your point about the reconstruction assertion in it, since it is the part most likely to
bite someone later:

the test's SECOND assertion (dequantised error <= 0.08) passes at 0.035 while V is wrong, so a
reconstruction-level check does not catch this class. Only the exact-code assertion does.

For completeness, the numbers on this side against the real PR head 03fb043 (rather than my
earlier rebase): tests/kernels/test_kv_fp8.py goes 2 failed → 1 failed, the flip being
test_codes_match_the_reference_quantizer_and_reconstruction_is_close. The remaining one is the
encoder scale-one path from my first comment, untouched by this.

The other items from that first comment — the FakeKVCache.k_scale pair, the layer-id remap, and
the qsa store/attend device assert — are not in this branch. They are separate and I have not
fixed them; I did not want to bundle unrelated changes into something whose whole value is that it
is small and verified twice.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, sm_89)
and I can reproduce it.

@ArqAlice

ArqAlice commented Sep 4, 2026

Copy link
Copy Markdown
Author

@MT-z san
Thank you so much for tracking this down to the root cause and for the detailed explanation and verification.

I'd be very happy to accept your fix. If you don't mind, could you open a PR against my feat/fp8-quantization branch? I think that would be the cleanest way to preserve your contribution and the investigation behind it.

Once it's up, I'll review and merge it into #354.

Thanks again for all the testing and debugging — especially for verifying this on sm_89. It's been extremely helpful.

`quantize_kv_to_cache` passed `k.stride(0)` as the only source pitch and
`_kv_quant_scatter_kernel` used it for both tensors:

    src = t * stride_xs + h * D + d
    xk = tl.load(k_src + src, ...)
    xv = tl.load(v_src + src, ...)

The guard above it checks only the inner stride (`k.stride(1) == 1 and
v.stride(1) == 1`), never `k.stride(0) == v.stride(0)`, so the kernel carries an
undocumented contract: K and V must share one row pitch.

When they do not, V is read at K's pitch. In the failing test K is a view of the
qkv slice (pitch 1152) while V is materialised by `.clamp()` (pitch 384), so with
8 tokens of 3072 elements:

    token 0     reads 0                 correct by coincidence
    token 1-2   reads 1152, 2304        in range, WRONG rows
    token 3-7   reads 3456 .. 8064      past the initialised data

2684 of 3072 codes wrong, all in V, K byte-perfect. Deterministic addressing;
only the contents of the uninitialised tail vary with allocator history, which is
why the mismatch count drifts (2684 / 2663 / 2676 across runs) while the
mismatching positions do not -- the in-range half is exactly 764 every time.

Found with `compute-sanitizer --tool initcheck` (TRITON_DISABLE_LINE_INFO=0),
which named `kv_quant.py:110`. `memcheck` reports 0 errors because PyTorch's
caching allocator rounds allocations up and the bad read stays inside the pooled
segment; `racecheck` reports 0 hazards because it is not a race.

Fix: pass `v.stride(0)` as its own kernel argument and load each tensor with its
own pitch.

Verified on RTX 4090 (sm_89): tests/kernels/test_kv_fp8.py 2 failed -> 1 failed,
the flip being test_codes_match_the_reference_quantizer_and_reconstruction_is_close;
five consecutive standalone runs give K 0/3072 and V 0/3072 with got.sort() ==
exp.sort(); compute-sanitizer initcheck reports 0 errors on the patched build.
Independently confirmed on RTX 5090 D (sm_120) by @Kaempferia: same single flip,
same multiset property, 5 runs clean.

Note for reviewers: the test's SECOND assertion (dequantised error <= 0.08)
passes at 0.035 while V is wrong, so a reconstruction-level check does not catch
this class. Only the exact-code assertion does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MT-z

MT-z commented Sep 4, 2026

Copy link
Copy Markdown

It is up: ArqAlice#1 -- one commit, one file, +7/-4.

Before opening it I re-ran the counts on your branch head (03fb043), with and without the
patch, on this box (RTX 4090, sm_89), and noticed something about my own earlier report. The
"12 -> 11" came from a tree I had assembled myself as "main at 6eca2d7 + this PR", which sits
four commits behind your actual base (parent e05cff8), and one of those four already fixes a
test. On your branch it is 11 -> 10. The delta and the flipping test are unchanged
(test_codes_match_the_reference_quantizer_and_reconstruction_is_close, 2684 of 3072 code
mismatches before, 0 after). The PR body carries both figures and the reason for the difference.

compute-sanitizer --tool initcheck re-run on the patched build: ERROR SUMMARY: 0 errors.

I also shortened the new kernel comment from three lines to two, having noticed the "one or two
lines" line in AGENTS.md that landed this morning. The code is byte-identical to the diff I
posted here.

One thing I noticed while testing and have not mentioned yet. On this box the KV/expert budget
is what decides whether a model runs at all, more than how fast it runs: Qwen3.8-Flash-Next at
--kv-reserve-tokens 262144 leaves 829 expert slots and 2.73 tok/s here, and at 131072 it is
2465 slots and 6.92 tok/s. So at this end of the range, halving the KV bytes reads to me as the
difference between usable and not usable. Of everything open against this repo, this is the one
I have been most looking forward to.

The Ada box stays available for anything you want run on sm_89, any time.

Assisted-by: Claude Opus 5

@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown

Ran this on 2 x RTX 6000 Ada (sm_89, 48 GB each, PCIe 4 x16, no NVLink) with Qwen3.8-Flash-Next (RadixArk NVFP4, modelopt), TP=2 over both cards, offload backend, pinned PLE, vision tower loaded, load-time per-tensor FP8 dense (#389). Tree: main af71ba4 + TP (#385) + #389 + #392 + this PR at 3e5bbdd (which already carries MT-z's V-pitch fix), merged with one docstring conflict. torch 2.11.0+cu130, triton 3.6.0 (the pins).

Tests, one process per file on one card:

file result
tests/models/qwen4_exp/test_qsa_backend.py 10 passed, including the new test_fp8_kv_pool_keeps_selection_and_output
tests/engine/test_kv_quant_config.py 15 passed
tests/kernels/test_qsa_fp8.py 8 passed, 1 failed: [16-2-64] is one bf16 ulp off (max diff 1.953e-03) against the bf16-cache reference; [1-1-64] is bit-exact
tests/kvcache/test_qsa_pool_fp8.py 5 passed, 2 failed: the slot round-trip writes row 256 into a 4-page (256-slot) pool, so codes[out_loc] trips the device assert, which then takes the factory test down with it
tests/kvcache/test_mha_pool_fp8.py 9 passed, 1 failed: the remap test builds a 3-layer pool with layer_ids=(1, 3)
tests/kernels/test_kv_fp8.py 5 passed, 1 failed: the scale-one encoder path hands a CPU tensor to the kernel
tests/kernels/test_triton_attention.py 34 passed, 4 failed: the two upstream FakeKVCache tests (no k_scale), and test_extend_paged_attention_decodes_fp8_scales[True/False] with 21/2560 and 3/2560 elements off by up to 0.28 at the 0.03 tolerance
tests/kernels/test_e4m3_compat.py 8 passed, 1 failed: test_forced_emu_matches_native (blk_aq_y: EMU output differs from native)

The same list MT-z and Kaempferia reported, plus the emulation one. Nothing in the failing set is on the path this model serves through (QSA pool + qsa_sparse), and the serving numbers below agree with that.

Serving. Same harness as my other reports: single-stream = median of three 256-token generations, aggregate = eight concurrent 256-token requests, TTFT on a ~1k-token prompt, residency = expert slots / 24,576, probe = an 8-question greedy smoke test (thinking off). The cache status route reports 13,056 B per token per rank in bf16 and 7,009 B in fp8 for this model (12 QSA layers, 2 local KV heads, the bf16 index tier included).

run (TP=2, production flags) KV pool KV GiB/rank residency single-stream 8 concurrent TTFT 1k probe
bf16 KV, 262,144 tokens, 16 running (production before) 1 context 3.2 95.8% 100.9 tok/s 331.9 tok/s 0.71 s 6/8
fp8 KV, 262,144 tokens, 16 running 1 context 1.7 100% 95.9 337.4 0.81 s 7/8
fp8 KV, 2,097,152 tokens, 8 running, first fp8 process on the box 8 contexts 13.7 70.9% 80.3 (*) 255.7 (*) 2.63 s (*) 7/8
fp8 KV, 2,097,152 tokens, 8 running, kernels cached (production, three benches) 8 contexts 13.7 70.9% 100.5 / 102.4 / 102.0 350.5 / 355.3 / 355.2 0.69 / 0.67 / 0.67 s
bf16 KV, 2,097,152 tokens, 8 running 8 contexts 25.5 33.7% 84.8 191.1 1.65 s 6/8
bf16 KV, 262,144 tokens, 8 running (running count is not a factor) 1 context 3.2 100% 100.0 334.1 0.82 s

(*) Client-side numbers of the first fp8 process: your Triton kernels compiled during that benchmark (the Triton cache shows the entries landing in the bench window), while the server's own per-batch decode rates in the same run were 94-101 tok/s at one running request and ~448 at eight, the same as every 262k-pool run. The same flags with the kernels cached give the production row. So on this box the fp8 attend kernel costs nothing measurable at an equal pool (the 262k fp8 row also had two compiles inside its bench; its server-side rates were 92-101 vs 99-101), and the halved KV is what makes eight full contexts free: 70.9% residency decodes like 95.8%, where bf16's 33.7% loses 15% single-stream and 42% at 8 concurrent.

Long-context checks on the fp8 8-context run, all greedy: a planted-fact transcript recalled at 105,683 and 214,673 prompt tokens (same 3/4 as bf16; the fourth is my scorer); 8 concurrent users with 177k-token prompts each all got their planted id back (1.42M prompt tokens in flight, 389 s wall for the eight prefills), and a second round on the same eight conversations hit the full cached prefix for every user (cached 177,472 per user in the log) and decoded at 324 tok/s aggregate; image prompts keep their prefix-cache hits (320 / 26,304 cached tokens, no false hit on a different image). Greedy text against the bf16 production run: the fp8 runs diverge after 37 / 29 / 38 words on the three prompts (the bf16 run at the 8x pool is word-identical to production on all three, so that is the cache quantization, not run-to-run noise).

This is now the production configuration on that box (fp8, 8 x 262,144 tokens, 8 running).

MT-z added a commit to MT-z/FreeToken that referenced this pull request Sep 5, 2026
…ike text

037f102 narrowed the rule from "the whole prompt in one chunk" to "the image span in one
chunk", which is what a 196-token sprite in a 166k-token turn needs. The span is [first
image token, last+1) because ``mm_embeds`` is one concatenated tensor scattered in one
forward -- so it grows with the TEXT between two screenshots, not just with the pictures.
An agent conversation reaches the limit by talking:

  400 prompt with images needs 10392 contiguous tokens in one prefill chunk
      (the image tokens span [160334, 170726) and cannot be split)

Nothing configurable moves that. Cheaper images (~490 tokens each after the clamp) only buy
more turns before the gap between the first and last one exceeds a chunk, and raising
--max-prefill-length OOMs long before it helps: a 32k chunk's activations do not fit beside
a 5 GiB KV pool on a 24 GiB card (measured -- it took the worker down twice today).

So the concatenated tensor stops being scattered whole. ``_merge_multimodal`` takes the rows
belonging to the placeholders inside ITS OWN forward -- the ones an earlier chunk or a
prefix-cache hit already consumed sit in front of the window -- and the adder chunks an image
prompt exactly like a text one. ``Req.mm_scatter`` and the whole pull-back / reject path go
away with it, ~90 lines. Both families that carry a tower here are converted; the approach is
gdevenyi's, from FlashML-org#386 (28fd56d).

The span cap 09ea814 put in ``match_req`` goes too. It existed because a hit landing inside
a placeholder run left half the run cached and half to forward, which the all-in-one-forward
scatter could not represent; the window skips the cached half instead. Without the cap a
prompt that ends with its image keeps its prefix -- 20,800 of 20,840 tokens on the repeat
here, 6.0 s -> 1.2 s, and a different image at the same position still misses (answered
"Green" where the cached one answers "Blue").

Measured on Ornith-1.5-35B-A3B-NVFP4, one 4090, --max-prefill-length left at its 8192 default:

  2 images with 9k of text between   span ~19k   10,186 tokens,  3.2 s   (was a 400)
  6 images with 9k between each      span ~50k   55,360 tokens, 18.1 s   (was a 400)
  A(blue) 9k B(green), and reversed              "Blue, Green" / "green blue"
                                                 -- read across the boundary, in order

tests/tokenizer 58, tests/scheduler 90, tests/kvcache/radix 142: all passed. Twelve tests
pinning the removed rule are gone and three cover the window (a span wider than a chunk now
admits; a chunk scatters only its own rows; a chunk holding no placeholder scatters nothing).
The ``_NoSwa`` stub gained the ``page_size`` the reservation math has been reading, which is
what had six of these failing on this branch already. A cold system-test run is
character-identical to the same branch without this commit, all seven cases.

Assisted-by: Claude Opus 5

Re-verified on this branch (no FlashML-org#337/FlashML-org#354/FlashML-org#287 under it): tests/tokenizer 58, tests/scheduler
88, tests/kvcache/radix 142 all passed; a cold system-test run is character-identical to the
same change on the daily branch, all seven cases; the two shapes that used to 400 (spans of
~19k and ~50k tokens) answer at the 8192 default.
@MT-z

MT-z commented Sep 5, 2026

Copy link
Copy Markdown

Thank you for the table -- it sent me back to my own report, which was not wrong but was
incomplete. I measured this branch one file per process, the same workaround you used, and
never ran what AGENTS.md actually asks for. Two things came out of following your four
diagnoses through, and out of repeating your serving benchmark on very different hardware.

Repeating the serving numbers on one 24 GiB card

Ornith-1.5-35B-A3B-NVFP4, offload backend, single RTX 4090 (sm_89), i9-14900KF, torch
2.11.0+cu130, triton 3.6.0 (the pins). Your definitions: single-stream = median of three
256-token generations, aggregate = eight concurrent 256-token requests, TTFT on a ~1k-token
prompt, probe = an 8-question greedy smoke test with thinking off. Residency is expert slots
over this model's 10,240 (40 layers x 256).

run KV GiB slots residency single-stream 8 concurrent TTFT 1k probe
bf16 KV, 262,144 tokens 5.00 6,580 64.3% 112.5 tok/s 276.4 tok/s 0.88 s 8/8
fp8 KV, 262,144 tokens 2.54 8,076 78.9% 111.9 288.3 0.89 8/8
fp8 KV, 524,288 tokens 5.08 6,541 63.9% 111.7 275.7 0.88 8/8

Same conclusion as yours, from the other end of the hardware range. The attend kernel costs
nothing measurable at an equal pool -- 111.9 against 112.5 single-stream -- and the third row
is what the halved KV buys on a card this size: double the context at the same VRAM, the same
residency to within half a point, and the same rates. Where your box turned that into eight
full contexts, this one turns it into 2x the pool; the shape is the same.

Following the four diagnoses

which then takes the factory test down with it turned out to reach past the factory test.
Under pytest tests/ -m "not slow" in one process, everything scheduled after it comes out
as a failure as well -- reported, rather than broken; there is simply no context left for
them to run in:

tree result
main af71ba4 6 failed, 1551 passed
this PR at 3e5bbdd 229 failed, 1368 passed, 14 errors
3e5bbdd + the four below 9 failed, 1602 passed

Each of the four you named is one line. I measured them one at a time, so each accounts for a
named set rather than a smaller number:

change observed
test_qsa_pool_fp8: num_pages 4 → 5, so the row list that reaches 256 has a page to reach into 229 → 13; those 216 gone, none new
test_kv_fp8: the scale-one encoder's V tensor gets device=DEV that test gone
test_mha_pool_fp8: the remap pool is built num_layers=4, since layer_ids=(1, 3) needs id 3 to exist and LAYERS is 3 that test gone
test_triton_attention: both FakeKVCache doubles get k_scale / v_scale returning None, which a 16-bit pool answers and the backend reads exactly those two gone

One line in the report moved that I had not prescribed:
tests/moe/test_prefill_hit_d2d.py::test_batch_memcpy_roundtrip appeared once and vanished
once. It is flaky on this box independently of this branch -- three whole-suite runs on main
gave 7, 6, 6, and the 7 was that test; it passes 5/5 in isolation. Not from here.

Two details that were not in your table, in case they are useful. The write at row 256 is
out of range as well, and silent: store_kv does not bounds-check out_loc -- reasonably, it
comes from the page table -- so row 256 of layer 3 lands in the next layer's region of the
same _k_buffer, and writing it changes k_cache(5) here. It stays inside the allocation, so
compute-sanitizer --tool memcheck reports 0 errors; only the read raises, and only because
it is a plain torch gather against a 256-row view. And with the four applied the harness stops
mattering -- 9 whole-suite, the same 9 one file per process -- which is what I was after, not
the count.

test_extend_paged_attention_decodes_fp8_scales I could not call, so I am leaving it where
you put it. I could not put it down to fp8 loss -- the reference is built from the same codes
-- and this is as far as I got on the mismatching elements:

split=False   21/2560 outside atol=3e-2 rtol=3e-2, max |diff| 0.281
              |output| over the tensor: max 50.5
              |reference| at the mismatches: min 0.059, median 0.527, max 5.25
split=True     3/2560, max |diff| 0.250; |reference| 0.279 .. 0.801

They sit on small outputs, and the 1.33 relative figure is an element whose reference is
0.059. The test's own comment says a dropped scale would be "off by orders of magnitude", and
it is not -- so it reads to me more like the tolerance meeting a construction that scales V by
30 than a scale going astray. That is a guess from outside the kernel.

The four came straight out of your table; I am in your debt for it, and for the patience
of writing it out in that detail. They are ArqAlice#2, against the branch head.

Assisted-by: Claude Opus 5

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.

4 participants