Repository navigation
[GDN][KDA][mem_cache] int8 checkpoint pool for the linear-attn prefix cache - #28185
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an int8-compressed checkpoint pool (via Int8CheckpointStore and MambaCheckpointPool) for caching linear-attention (Mamba) recurrent states, which effectively doubles the cached-prefix capacity at fixed memory. It integrates this pool with the built-in MambaRadixCache, updates the scheduler's invariant checking and pool statistics to handle the two-pool setup, and adds corresponding CLI arguments and unit tests. The review feedback is highly constructive, suggesting improvements for device placement of index tensors, performing quantization math in float32 to avoid precision loss, using .view(-1) for robust tensor flattening, making memory calculations more explicit, and gracefully skipping CUDA tests if the fla package is missing.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
a78a230 to
f75adf1
Compare
f75adf1 to
f6b118f
Compare
f6b118f to
9a94235
Compare
9a94235 to
3457e7b
Compare
c430e49 to
3510380
Compare
1669af4 to
11fbd2d
Compare
Adds an optional dedicated int8 checkpoint pool for the radix-cached linear-attention (KDA / GDN) states, behind --enable-int8-mamba-checkpoint (off by default). Each cached temporal state is stored int8-per-(head,k-channel) instead of bf16, so at ~the same memory the cache holds ~2x more distinct prefixes, moving the reuse-collapse knee out by 2x. The conv window stays bf16. Quality-safe by construction: a state is quantized once on store and dequantized once on a cache hit, never re-entering the recurrence. Design (separate pools): the recurrence kernels need bf16 working memory, so the active bf16 pool (kernel-facing, sized to the running set) and the int8 pool (radix-cached states) are distinct. A donate quantizes active -> int8; a cache hit copy-on-writes dequantize int8 -> active on the forward stream. The donate hook sits at the common point where both scheduler strategies hand an active slot to the radix (no_buffer copy, extra_buffer ping-pong/track-buffer), so one implementation covers both incl. the future spec path. The two-pool split makes the radix-cached states no longer live in the pool that running requests allocate from, so the mamba accounting is made two-pool-aware: - invariant_checker: split into active-pool and int8-pool invariants - pool_stats_observer: int8-mode usage tracks the active pool (non-negative, correct admission throttle) - HybridReqToTokenPool.clear()/MambaCheckpointPool.clear(): flush releases int8 int8 is only wired into the built-in MambaRadixCache; server_args rejects it combined with --enable-hierarchical-cache (HiMambaRadixCache host-offload) or a custom --radix-cache-backend, which are not int8-aware. Flags: --enable-int8-mamba-checkpoint, --int8-mamba-ckpt-size (default 2x max-mamba-cache-size).
11fbd2d to
24bdaaa
Compare
24bdaaa to
3f81d89
Compare
| @@ -0,0 +1,178 @@ | |||
| """Tests for Int8CheckpointStore (int8-compressed cached linear-attn states). | |||
There was a problem hiding this comment.
This folder test/srt is deprecated. please never add files under this folder.
if the test manual, move it under test/manual
| from sglang.test.server_fixtures.default_fixture import DefaultServerBase | ||
| from sglang.test.test_utils import DEFAULT_HYBRID_MAMBA_MODEL_NAME_FOR_TEST | ||
|
|
||
| register_cuda_ci(est_time=400, stage="base-c", runner_config="4-gpu-h100") |
There was a problem hiding this comment.
this should go to extra, we do not want to waste 400 s for this feature.
…e to extra Addresses review comments on sgl-project#28185: - test/srt is deprecated: move test_int8_checkpoint_store.py to test/registered/mem_cache/ and register it on the cheapest tier (register_cpu_ci, base-a-test-cpu). It is an automated unit test, not a manual one; the lone CUDA decode-error case self-skips on CPU runners and its GPU path is covered by the e2e test. - don't waste 400s in base CI: move test_int8_mamba_checkpoint_e2e.py from base-c to extra-b (4-gpu-h100), so it only runs under the run-ci-extra label instead of on every PR.
… cache (sgl-project#28185) Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
Motivation
Linear-attention models (KDA, GDN / Gated DeltaNet) keep one compact recurrent state per sequence instead of a growing KV cache. SGLang already caches these states in the radix tree (
MambaRadixCache) so a shared prefix's state can be restored on a hit instead of recomputed.The problem is capacity. The active mamba state pool stores one bf16 state per cached prefix, and that pool is sized to the running set, not to the cache. Measured on a linear-attn model: the token-KV pool holds millions of tokens, but the mamba pool saturates at a small number of checkpoints and is length-independent (one full state per cached sequence). As the number of distinct cached prefixes grows past the pool size, prefix reuse collapses — the KV tokens are still resident, but the states get evicted and recomputed. This is the dominant cost for workloads with many distinct short/medium prefixes (multi-doc RAG, many short sessions, agentic fan-out).
Modifications
This PR introduces an optional, dedicated int8 checkpoint pool for the radix-cached linear-attn states, behind
--enable-int8-mamba-checkpoint(off by default).(head, k-channel)symmetric scale (Int8CheckpointStore, already in tree). The conv1d window stays bf16 (tiny).Why int8, and why a separate pool
Strategy-agnostic donate hook (spec-KDA-ready)
Both scheduler strategies converge on the same point — "an active slot becomes the cached
mamba_value":no_buffervia a copy,extra_buffervia the ping-pong track buffer (the spec path). The quantize hook lives at that common convergence point, so a single implementation covers both, including the future spec path.Design
Today (baseline) the radix-cached linear-attn states live in the same bf16
MambaPoolas the running requests: a cached node'smamba_valueis just a slot in that one pool, and on a hit it iscopy_from'd bf16→bf16 into a fresh slot. So the cache and the working set compete for one pool, and every cached prefix costs a full bf16 state — the pool saturates at a small number of checkpoints and prefix reuse collapses. This PR splits them: a small bf16 working pool (running set, kernel-facing) + a separate, compact int8 checkpoint pool (the cache). The expensive, growing part — the cache — is the one that gets compressed.mamba_value= bf16 slot)mamba_value= int8 slot)pool_size − running_set, bf16-expensive → smallcopy_frombf16→bf16 (no quant)Advantages of the int8 design:
Flags
--enable-int8-mamba-checkpoint(bool, default off): store radix-cached linear-attn states as int8.--int8-mamba-ckpt-size(int, optional): number of int8 checkpoint slots; defaults to2 × max-mamba-cache-size.Validation
Validated on Qwen3.5-35B-A3B (GDN-hybrid) on 4×H20-3e. The int8 mem-cache path is GDN/KDA-agnostic (both flow through the same
MambaPool+MambaRadixCache+chunk_gated_delta_rulepath), so this validates the integration model-independently.Capacity + latency/throughput — A/B
Via the committed benchmark
benchmark/bench_linear_attention/bench_int8_checkpoint_reuse.py(Qwen3.5-35B-A3B,extra_buffer,--max-mamba-cache-size 256→ active 256 / int8 512, prefix ~1000 tokens, parallel 8), run against the server with vs. without--enable-int8-mamba-checkpoint:The reuse-collapse knee moves 256 → 512 (≈2×). In the band 256 < K ≤ 512 — where the baseline has collapsed but int8 still serves prefixes from cache — int8 delivers ~47× more prefix reuse, ~26% lower probe-prefill latency, and ~33% higher throughput (K=384), because the probe is a cheap cache hit instead of a full prefix recompute. Below the bf16 pool size (K=128) int8 is on par (no overhead); above the int8 pool size (K=640) both collapse, as expected. 0 errors / crashes / leaks in both runs.
Memory efficiency — same cache, less mamba HBM, KV decoupled
The other way to spend the 2× density: cache the same number of distinct prefixes using far less mamba HBM, and keep the KV budget high. int8 decouples the cache from the active pool, so a small active pool drives the KV budget while a large int8 pool holds the cache (Qwen3.5-35B-A3B, per card):
bf16 forces a trade-off — a big cache needs a big pool, which drops the KV budget (8.32M). int8 gives a big cache and the high KV budget of a small pool (9.93M), caching ~1024 prefixes at ~4 MB each vs bf16's ~15 MB. Caveat (follow-up): the int8 pool's HBM is not yet subtracted from the KV-budget computation (int8's max_total equals the 256-pool value; the pool currently rides on GPU headroom). It should be accounted; even when accounted the mamba footprint (8 vs 19.2 GB) wins.
Quality / precision — does int8 degrade output?
Three levels of evidence, each isolating the int8 effect from the serving stack's noise. Headline: no systematic task-accuracy degradation; the perturbation is small, bounded, and does not compound with cache depth.
1. Mechanism (why it's bounded). The state is quantized once on store and dequantized once on a hit — it never re-enters the recurrence as a quant→dequant loop. On chained cache extensions the dequantized state does continue the recurrence, but the gated-delta rule is contractive (per-channel decay α<1 plus a negative-semidefinite delta-rule Jacobian), so an injected quantization error is attenuated geometrically. Measured on a faithful fp32 gated-delta recurrence driven through the real codec: the accumulated state error saturates (does not grow with depth) — ~6.6e-3 at 1 checkpoint → ~1.3e-2 by 32 chained checkpoints → flat through 128 (seq 2048). The per-(head,k-channel) scale matches the per-k-channel decay, so the dominant large state entries keep ~bf16 precision and the error concentrates on small entries that barely affect the readout
o = Sᵀq.2. Logit-level isolation (real model, deterministic). On prefix-extension cache hits where int8 dequant actually fires (Qwen3.5-35B-A3B, TP1 greedy → run-to-run deterministic, so the noise floor is exactly 0), the next-token distribution shifts by KL ≈ 6e-3 (max ~0.5 nats on a tail token), with greedy top-1 unchanged in every probe. For reference, loading a bf16 cached state instead of recomputing is itself not bit-exact (KL ≈ 5e-4); int8 is ~10× that already-accepted baseline and, crucially, never diverges from the bf16 path.
3. Task accuracy (GSM8K, int8 dequant confirmed firing). Qwen3.5-35B-A3B, 200 questions, 5-shot, greedy,
extra_buffer, with the int8 dequant path instrumented and confirmed firing 599× (≈ every question × 3 runs). WARM = cache reused from the int8 pool; COLD =flush_cacheper question = full bf16 recompute (deterministic baseline); a bf16-reuse server is the control.int8's WARM band sits inside bf16's; the ~1pp mean gap is well within the reuse path's run-to-run churn. Both WARM means exceed the COLD baseline (0.805), which confirms the warm-vs-cold spread is trajectory-shuffling noise, not a quality change — quantization cannot raise accuracy. The cache-reuse path is itself non-deterministic under overlap scheduling (even bf16 warm-vs-warm agrees on only ~89% of answers, and the reused-token count varies run-to-run), which is why accuracy is reported as a band over a deterministic cold baseline rather than a single number.
Methodology note (important for reproducing). int8 is only exercised when the cached prefix is genuinely reused from the int8 pool, which requires
--mamba-scheduler-strategy extra_buffer:no_bufferonly snapshots state at the full-sequence leaf, so a fixed-prefix / divergent-question workload (few-shot GSM8K, or the divergent-suffix benchmark) reuses ~0 mamba state and int8 dequant almost never fires (measured: 2 dequant calls in a 50-questionno_bufferrun vs 599 withextra_buffer). Any int8 quality claim must instrument the dequant count to confirm the path is actually live — a naive GSM8K run under the defaultno_buffermeasures bf16, not int8.Correctness — both scheduler strategies
extra_buffer: store / match / int8-dequant-COW correct; reuse stable across repeated probes.no_buffer: int8 store/match/COW also correct (an appended-suffix probe hits cleanly, cached 663/666). NOTE:no_buffershows low prefix reuse on a divergent-suffix workload by strategy design (it only snapshots state at the full-sequence leaf, so a divergent suffix lands on a_split_nodewithmamba_value=None) — this is unchanged from baselineno_buffer, not specific to int8. Mid-sequence prefix reuse requiresextra_buffer's track-buffer.Checklist
Review and Merge Process
/tag-and-rerun-ci,/tag-run-ci-label,/rerun-failed-ciCI States
Latest PR Test (Base): ✅ Run #27668615858
Latest PR Test (Extra): ✅ Run #28150073323