Skip to content

Perf/kv fp8 read path - #3

Open
naerymdan wants to merge 2 commits into
ArqAlice:feat/fp8-quantizationfrom
naerymdan:perf/kv-fp8-read-path
Open

Perf/kv fp8 read path#3
naerymdan wants to merge 2 commits into
ArqAlice:feat/fp8-quantizationfrom
naerymdan:perf/kv-fp8-read-path

Conversation

@naerymdan

Copy link
Copy Markdown

I used it for a 1 hour agentic coding session to make sure it... you knwo, still worked.

What this changes

Two independent costs on the fp8 KV read path — the one a chunked prefill
re-runs over the whole growing prefix, which is why the penalty scaled as N².

1. The dequant scale multiplied the largest tensor in the loop.
It is per (token, kv_head), so it is constant down each tl.dot's reduction
dim and never has to touch K or V:

scores[m,n] = (sum_d q[m,d] * k[d,n]) * s_k[n]
p @ (diag(s_v) @ v) = (p * s_v[None,:]) @ v

Scaling the BLOCK_M x BLOCK_N dot output instead of the BLOCK_D x BLOCK_N K
tile and BLOCK_N x BLOCK_DV V tile is head_dim/BLOCK_M fewer multiplies.
(p must stay unscaled — l_i accumulates it as the softmax denominator.)

That also removes the only reason the tile was widened to fp32:
kv_load_e4m3_tile_f32 widens purely so a * 256.0 can restore the true e4m3
scale, and 2^8 is a power of two, so it folds into the dequant scale exactly.
kv_load_e4m3_tile_scaled16 stops before the widen and keeps the tile 16-bit.

Accuracy improves. Today the general scale multiplies before the narrow to
the compute dtype, so the product rounds. Folding it out means the tile reaches
the dot exactly (3 mantissa bits, |x| <= 1.75) and the scale is applied in
fp32 after. Worst-case error in test_extend_paged_attention_decodes_fp8_scales
drops 0.281 -> 0.0996.

2. The tile heuristic charged K/V at 2 bytes/element regardless of cache dtype.
_select_extend_tile budgets (BLOCK_M + 2*BLOCK_N) * BLOCK_D * 2. The q tile
is always 2 bytes/element, but K and V follow the cache — so a 1-byte fp8 cache
is billed for twice the shared memory it uses and drops a rung. Billing K/V at
the real element size gives (BLOCK_M*2 + 2*BLOCK_N*kv_bytes) * BLOCK_D, which
at kv_bytes=2 is algebraically the old expression, so every 16-bit cache
keeps the tile it had
. At head_dim 256 on a 99KB opt-in card the fp8 arm goes
BLOCK_N 32 -> 64. This is the larger half of the win, and it is plain
shared-memory accounting, so it should pay off anywhere the fp8 arm is a rung low.

Result

RTX 3070 (sm_86, 8GB), Qwen3.6-35B-A3B-NVFP4, --moe-backend hybrid, fp8 KV,
180k window. Base is 3e5bbdd; both builds resolved an identical memory plan
(moe_cache_size=1077), so none of this is expert-residency drift.

ctx TTFT Δ decode Δ
~32.7k 34.13 -> 28.18 s −17.4% 47.04 -> 47.94 tok/s +1.9%
~65.1k 88.79 -> 64.36 s −27.5% 38.99 -> 40.92 tok/s +4.9%
~99.3k 168.27 -> 112.78 s −33.0% 33.59 -> 36.26 tok/s +8.0%

Per commit at ~99k, so either can be taken alone: 168.27 -> 160.42 s (−4.7%,
commit 1, mostly a decode gain) -> 112.78 s (−29.7%, commit 2).

That takes fp8 prefill from 18–35% slower than bf16 to faster than it, so
there is no longer a break-even output length to respect.

Tests

pytest tests/kernels/test_e4m3_compat.py tests/kernels/test_triton_attention.py -m "not slow" -> 4 failed, 39 passed. The same 4 fail on 3e5bbdd unpatched
(two are a FakeKVCache double missing k_scale; two are the fp8 accuracy test
exceeding its 2e-2 tolerance on sm_86) — this branch introduces none. Two tests
added: all-256-code equivalence of the two loaders, and both halves of the tile
change (fp8 grows, 16-bit does not move).

Authored by Opus 5 seriously

naerymdan and others added 2 commits September 6, 2026 21:01
…e tile

The per-(token, kv_head) dequant scale is constant down each dot's reduction
dim, so it never has to touch K or V:

    scores[m,n]         = (sum_d q[m,d] * k[d,n]) * s_k[n]
    p @ (diag(s_v) @ v) = (p * s_v[None,:]) @ v

Scaling the BLOCK_M x BLOCK_N result instead of the BLOCK_D x BLOCK_N K tile
and the BLOCK_N x BLOCK_DV V tile is head_dim/BLOCK_M fewer multiplies. p
itself stays unscaled, since l_i accumulates it as the softmax denominator and
knows nothing about V's quantization.

That also removes the only reason the tile was widened to fp32.
kv_load_e4m3_tile_f32 builds an fp16 bit pattern and widens purely so a
* 256.0 can put the value back on the true e4m3 scale, and 2^8 is a power of
two, so once the scale rides the dot output it folds into that scale exactly.
kv_load_e4m3_tile_scaled16 stops before the widen and leaves the fold to the
caller, keeping the tile 16-bit through the whole loop.

Accuracy improves rather than degrades. The general scale used to multiply
before the narrow to the compute dtype, so the product rounded; now the tile
reaches the dot exactly (the code's own 3 mantissa bits, |x| <= 1.75) and the
scale is applied in fp32 afterwards. Worst-case absolute error in
test_extend_paged_attention_decodes_fp8_scales drops 0.281 -> 0.0996 on sm_86.
(That test still exceeds its 2e-2 tolerance on this card both before and after
-- it fails on 3e5bbdd unpatched too, so it is not introduced here.)

The loader's bit placement is also the same number in 4 ops instead of 7: for
v = 128s + r, ((v & 0x80) << 8) | ((v & 0x7F) << 7) and (v + (v & 0x80)) << 7
are both (256s + r) << 7. Verified identical on all 256 codes, NaN patterns
included, by the new test in tests/kernels/test_e4m3_compat.py.

RTX 3070 (sm_86, 8GB, driver 610.57.04), i7-11700KF, Qwen3.6-35B-A3B-NVFP4,
--moe-backend hybrid --kv-cache-dtype fp8 --max-seq-len-override 180000
--memory-ratio 0.9 --max-running-requests 1 --max-prefill-length 1024,
2 reps, median, 127 output tokens, unique nonce per request:

    ctx      TTFT 3e5bbdd -> here      decode 3e5bbdd -> here
     33k     34.13 -> 33.95 s          47.04 -> 48.05 t/s
     65k     88.79 -> 85.03 s          38.99 -> 41.61 t/s
    100k    168.27 -> 160.42 s         33.59 -> 37.13 t/s

Most of the prefill win needs the tile-sizing fix in the next commit; this one
is mainly a decode gain on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_select_extend_tile budgets shared memory as

    (BLOCK_M + 2 * BLOCK_N) * BLOCK_D * 2

which charges K and V at 2 bytes/element whatever the cache actually holds. The
q tile is always 2 bytes/element, but K and V follow the cache, so a 1-byte fp8
cache is billed for twice the shared memory it uses and falls through to a
smaller tile than it has room for. On an RTX 3070 (sm_86, 99KB opt-in) at
head_dim 256 that is BLOCK_N 32 where 64 fits.

Take the element size as a parameter and bill K/V at it:

    (BLOCK_M * 2 + 2 * BLOCK_N * kv_bytes) * BLOCK_D

kv_bytes=2 is algebraically the previous expression, so every 16-bit cache
keeps the tile it had; the existing parametrisation in
test_select_extend_tile_is_shared_memory_aware still passes unchanged. The
head_dim <= 256 ladder gains a 64x64 rung between 128x64 and 64x32, which only
an fp8 cache can reach on a consumer card.

The budget stays a conservative proxy rather than an exact model. On this card
it correctly rejects both tiles that fail to launch (128x64 and 64x128, which
raise OutOfResources: shared memory, Required: 114688, Hardware limit: 101376)
and correctly accepts the two the ladder uses. It also rejects 128x32 and
32x128, which do launch -- but those are not on the ladder, and rejecting a
tile that would have worked only costs a smaller tile, never a failure.

Same setup as the previous commit, measured on top of it:

    ctx      TTFT before -> after      decode before -> after
     33k     33.95 -> 28.18 s          48.05 -> 47.94 t/s
     65k     85.03 -> 64.36 s          41.61 -> 40.92 t/s
    100k    160.42 -> 112.78 s         37.13 -> 36.26 t/s

Decode is untouched by this commit (it only moves the extend/prefill tile); the
small differences there are run-to-run noise at 2 reps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant