Skip to content

feat(dsa): implement fp8 KV cache (e4m3) with a bf16 indexer cache - #1776

Open
HadarIngonyama wants to merge 2 commits into
sgl-project:mainfrom
moonmath-ai:fp8-kv-cache
Open

HadarIngonyama wants to merge 2 commits into
sgl-project:mainfrom
moonmath-ai:fp8-kv-cache

Conversation

@HadarIngonyama

Copy link
Copy Markdown
Contributor

Motivation

--kv-cache-dtype already advertises fp8_e4m3 and fp8_e5m2 as choices, but
_init_kv_cache_dtype raises ValueError for both — only auto and bf16 are
actually implemented. This implements the fp8 options for the MLA latent KV cache.

Long-context DSA serving is KV-capacity bound: how much context a device can hold,
and how many bytes the attend has to pull from HBM, are both set by the latent
cache. Halving its element size addresses both — it roughly doubles the context
that fits, and halves the HBM→VMEM traffic of the attend, which is one of the
largest ops in a long-context prefill.

Modifications

  • model_runner_kv_cache_mixin.py: resolve fp8_e4m3 / fp8_e5m2 through a
    dtype table instead of raising.
  • memory_pool.py: add MLATokenToKVPool.indexer_dtype_for(). The DSA indexer
    key cache stays bf16 when the latent cache goes fp8; allocate_buffers now
    takes a per-buffer dtype, and profiled_bytes_per_token /
    _indexer_buffer_bytes account for the two dtypes separately so the scheduler's
    capacity math stays correct.
  • sparse_mla_prefill_qblock.py: the qblock prefill upcasts the KV block to the
    query dtype once, before the score dot. Mixing fp8 with bf16 in that dot, or
    down-casting p to the cache dtype, would put fp8's ~6% relative error on the
    softmax probabilities — the one tensor whose small values carry the attention
    tail.
  • sparse_mla.py: sparse_mla_page_level casts new KV to the cache dtype before
    the v2 kernel stores it, since the kernel writes straight into the packed cache
    and would otherwise reinterpret rather than convert.
  • python/sgl_jax/test/test_dsa_fp8_kv_tpu.py: new, and registered in the
    unit-test-tpu-v6e-1 suite in test/srt/run_suite.py.

Three design points worth flagging for review:

No per-tensor scale. Unlike int8, fp8 precision is relative, so rescaling by
a power of two buys nothing. The latent cache holds post-kv_a_layernorm values
(unit RMS) and post-rope k_pe, both O(1) — well inside e4m3's 448 range and
above its 0.0156 subnormal floor.

e4m3 over e5m2. The range headroom above is already ample, so the extra
mantissa bit is the better trade (~6% vs ~12.5% worst-case relative error).
Measured kernel error is exactly 2x worse on e5m2.

The indexer key cache deliberately stays bf16. The two caches fail differently
under quantization. The latent cache feeds a softmax, where per-element error
averages out over hundreds of keys. The indexer cache feeds a top-k that selects
which pages are read at all, so error there does not average — it flips a page in
or out, and a page the answer lives on is either fetched or lost. At 128 of 768
dims over 21 of 78 layers the indexer is 5.1% of the cache, so respecting the
asymmetry is cheap: 1.90x capacity instead of 2.00x.

Accuracy Tests

Unit (new, in this PR). Single-device gate that the 4-packed fp8 paged cache
compiles through both readers of the cache — the qblock prefill and the
sparse_mla_page_level decode path, which reach it by different routes — and that
the output error against an otherwise identical bf16 cache stays within bounds.
fp8 makes kv_packing 4 instead of 2, so the paged cache becomes
[P, ps//4, 4, Dk_pad]; whether Mosaic will DMA and reshape a 4-packed
sub-32-bit array inside these kernels is a question only the compiler answers,
which is why this gate exists.

PYTHONPATH=python python -m sgl_jax.test.test_dsa_fp8_kv_tpu

Measured on this commit, on a TPU v7x, error against the bf16 cache:

max abs Δ % of peak rel_l2
e4m3 prefill 0.15062 3.86% 3.47%
e4m3 decode 0.00977 3.73% 3.06%
e5m2 prefill 0.28154 7.21% 6.86%
e5m2 decode 0.02051 7.84% 6.32%

The e5m2/e4m3 ratio of 1.87–2.10x is the extra mantissa bit, and the test asserts
that ordering as a check that the two dtypes are genuinely plumbed through rather
than aliased. Bounds are set at roughly 1.4x the observed error. The test is
registered in unit-test-tpu-v6e-1; note it has only been run on v7x, and if
Mosaic declines to lower the 4-packed layout on another TPU generation it reports
a skip rather than a failure — happy to make that a hard failure if you'd prefer
to find out loudly.

End-to-end retrieval. An 80-sample needle sweep, both dtypes, same session:
5 needle variants × 5 depths at 500,593 tokens and 5 × 3 at 975,485 tokens.
fp8 recalls 39/40 against bf16's 38/40 — no measurable fp8 penalty. A
teacher-forced uplift probe with 7 distractors gives 5.32 nats for fp8 vs 5.49
for bf16, with the needle at p≈1.0 in nearly every cell.

Two scope limits, stated plainly. This is long-context retrieval accuracy, not a
general-capability benchmark — no GSM8K or MMLU on the fp8 cache yet. And the
end-to-end numbers here and below come from our serving deployment, which adds
16-way context parallelism over the KV cache; that change is not in this PR,
and holding a 500k–1M token context on one slice is not possible without it. This
PR is the dtype plumbing those runs used, and the unit numbers above are the part
measured on this commit alone.

Benchmarking and Profiling

  • Capacity: 2.00x on the latent cache, verified directly on this commit — the
    test reports the same page buffer as 0.6 MiB in e4m3 against 1.2 MiB in bf16.
    In a full model the bf16 indexer cache dilutes that to 1.90x tokens per byte
    of HBM.
  • Prefill: unchanged. 1.839 s (fp8) vs 1.847 s (bf16) at 8k.
  • Decode: unchanged at equal pool size. An initial +14.9% TPOT reading turned
    out to be a function of the larger pool fp8 enables, not of fp8 itself: capped
    to bf16's pool, fp8 decoded faster. Profiling attributed it to a per-layer
    full-pool copy that scales with pool size, not to unpack/convert — the heaviest
    genuine conversion op measured 0.0 ms/core. Worth knowing if you enable fp8 to
    grow the pool rather than to save bytes at a fixed pool.

The prefill and decode figures carry the same caveat as the retrieval numbers: they
are from the context-parallel deployment, not from this patch in isolation.

Checklist

  • Please use English, otherwise it will be closed.
  • The purpose of the PR, or link existing issues this PR will resolve.
  • The test plan, such as providing test command.
  • (Optional) The necessary documentation update.

`--kv-cache-dtype` already advertises `fp8_e4m3` and `fp8_e5m2` as choices, but
`_init_kv_cache_dtype` raises for both. This implements them for the MLA latent
cache and converts at the two kernel boundaries that assume a bf16 cache: the
qblock prefill upcasts the KV block to the query dtype before the score dot, and
`sparse_mla_page_level` casts new KV to the cache dtype before the v2 kernel
stores it.

No per-tensor scale is needed. fp8 precision is relative, so rescaling by a power
of two buys nothing, and the latent cache holds post-`kv_a_layernorm` values and
post-rope `k_pe`, both O(1) -- well inside e4m3's 448 range and above its 0.0156
subnormal floor. e4m3 is preferred over e5m2 for the extra mantissa bit (~6% vs
~12.5% worst-case relative error); measured kernel error is exactly 2x worse on
e5m2.

The indexer key cache deliberately stays bf16. The two caches fail differently
under quantization: the latent cache feeds a softmax, where per-element error
averages out over hundreds of keys, while the indexer cache feeds a top-k that
*selects* which pages are read at all, so error there does not average -- it
flips a page in or out, and a page the answer lives on is either fetched or lost.
At 128 of 768 dims over 21 of 78 layers the indexer is 5.1% of the cache, so
respecting that asymmetry is cheap: 1.90x capacity instead of 2.00x.

Measured on a 16-device TPU v7x slice with GLM-5.2-FP8: 1.90x the token capacity
of bf16, prefill unchanged (1.839 vs 1.847 s at 8k), and needle recall 39/40
against bf16's 38/40 over an 80-sample sweep at 500,593 and 975,485 tokens.

Adds `test_dsa_fp8_kv_tpu.py`, a single-device gate that the 4-packed fp8 paged
cache compiles through both readers of the cache -- the qblock prefill and the
page-level decode -- and that the error against an identical bf16 cache stays
within bounds set at ~1.4x the measured values (e4m3 3.47% rel_l2 on prefill and
3.06% on decode; e5m2 6.86% and 6.32%). It also asserts e5m2 is the worse of the
two, which is what the extra mantissa bit should buy and a check that the dtypes
are plumbed through rather than aliased. Registered in `unit-test-tpu-v6e-1`; a
Mosaic refusal to lower the 4-packed layout reports a skip rather than a failure,
since that is a platform statement and not a regression.
@tsvikas

tsvikas commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@cjx0709 would you have time to review this? We can't request reviewers from a fork.

This PR implements fp8_e4m3 and fp8_e5m2 for the MLA latent cache, and keeps the DSA indexer key cache in bf16. The result is 1.90× tokens per byte of HBM for DSA models, with about 3–4% relative error against the bf16 cache for e4m3 in the new TPU test.

The three design points we'd most like your eyes on are in the description: no per-tensor scale, e4m3 over e5m2, and why the indexer cache stays bf16. The scheduler's capacity math accounts for the two dtypes separately.

We merged current main; CI is rerunning now. The earlier red run was a 40-minute timeout in unit-test-4-tpu (1), inside test_kda_attention.py and test_gdn_attention.py, which this PR doesn't touch.

One overlap to be aware of: zorrofox's #1811 also touches sparse_mla_prefill_qblock.py. Our change there is small (one upcast in the qblock kernel), so if this lands first his rebase should be short, and if #1811 goes first we're happy to rebase this one instead.

cc: @zorrofox

@cjx0709
cjx0709 requested review from JamesBrianD, cjx0709 and zorrofox and removed request for zorrofox October 22, 2026 01:45
@tsvikas

tsvikas commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Heads-up before review: we found a gap in this PR. The new KV is cast to the cache dtype only in the decode path (sparse_mla_page_level). The dense path (_run_dense) still passes bf16 new_kv_c / new_k_pe, and the MLA v2 kernel rejects a dtype mismatch, so with --kv-cache-dtype fp8_e4m3 the default (dense) prefill should raise. The new TPU test only covers the qblock prefill and the page-level decode, so it doesn't catch this.

The fix is to cast once at the backend's entry instead, plus a test on the dense path. We'll push it as soon as we can; the design points in the description are unaffected, so feel free to review those in the meantime.

@krishkasibhotla

Copy link
Copy Markdown

Hi @HadarIngonyama @tsvikas, I opened #1819 for FP8 KV on MHA/GQA and standard MLA. It leaves DSA to this PR: FP8 with DSA errors out and points here.

+1 on casting at the backend entry; I did the same for standard MLA. One thing I hit on v7x: astype(jnp.float8_e4m3fn) turns |x| ≥ 464 into NaN instead of saturating. Your latents are RMS-normed, so this probably won't matter, but #1819 has a small quantize_to_kv_dtype helper that clips to finfo(dtype) first, for both e4m3 and e5m2, if you want it.

Data point for your no-scale choice: on Kimi-K2.5 (standard MLA, v7x), unscaled e4m3 gave 2x KV capacity, GSM8K 98.4 vs 98.4 and MMLU 88.4 vs 89.6 (FP8 vs bf16, N=250).

This branch has not been deployed

No deployments
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