[GDN][KDA][mem_cache] int8 checkpoint pool for the linear-attn prefix cache (#28185)

Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
This commit is contained in:
Yuan Luo
2026-06-17 20:41:46 -07:00
committed by GitHub
co-authored by luoyuan.luo
parent 53318911ca
commit 3340f4e3da
10 changed files with 1010 additions and 18 deletions
@@ -0,0 +1,165 @@
"""Benchmark: int8 linear-attention checkpoint pool — prefix-reuse capacity & latency.
Drives a *running* SGLang server that serves a linear-attention (KDA / GDN) hybrid
model, and measures how prefix reuse — and the probe-phase prefill latency that
depends on it — holds up as the number of DISTINCT cached prefixes grows.
The active bf16 mamba state pool caches one state per distinct prefix and is sized
to the running set; once the number of distinct cached prefixes exceeds it, reuse
collapses (states get evicted and recomputed). With ``--enable-int8-mamba-checkpoint``
the radix-cached states live in a separate int8 pool holding ~2x more slots at ~the
same memory, so the collapse knee moves out ~2x and the probe-phase prefill stays a
cheap cache hit well past the bf16 pool size.
Method, per K in ``--num-prefixes``:
flush -> WARM: send K distinct ~P-token prefixes once (populate the cache)
-> PROBE: re-send each prefix with a short different suffix; read
meta_info.cached_tokens (= reused prefix length) and time each request.
reuse_frac = sum(cached) / sum(prefix_tokens); probe throughput = K / wall_time.
Run the server twice and compare (same flags, toggle int8):
python -m sglang.launch_server --model-path <gdn-or-kda-hybrid> --tp 4 \
--trust-remote-code --mamba-scheduler-strategy extra_buffer \
--max-mamba-cache-size 256 [--enable-int8-mamba-checkpoint] --port 30000
python benchmark/bench_linear_attention/bench_int8_checkpoint_reuse.py \
--port 30000 --prefix-tokens 1000 --num-prefixes 128 384 640 --parallel 8
NOTE: prefix-tokens must cross the mamba cache chunk granularity (typically ~512),
otherwise nothing is cacheable and reuse is 0 by construction (not a regression).
Use ``--mamba-scheduler-strategy extra_buffer`` on the server: ``no_buffer`` only
snapshots state at the full-sequence leaf, so a divergent-suffix probe never reuses.
"""
import argparse
import random
import statistics
import time
from concurrent.futures import ThreadPoolExecutor
import requests
# A small word pool so each prefix is distinct but realistic English text.
_VOCAB = (
"time year people way day man thing woman life child world school state family "
"student group country problem hand part place case week company system program "
"question work government number night point home water room mother area money "
"story fact month lot right study book eye job word business issue side kind head "
"house service friend father power hour game line end member law car city community "
"name president team minute idea body information back parent face level office door "
"health person art war history party result change morning reason research girl guy "
"moment air teacher force education foot boy age policy process music market sense "
"nation plan college interest death course someone experience behavior career goal"
).split()
def make_prefix(i: int, n_words: int) -> str:
rng = random.Random(1000 + i)
head = f"Document {i} unique tag {i * 7919 % 100000}. "
return head + " ".join(rng.choice(_VOCAB) for _ in range(n_words))
def make_suffix(i: int, salt: int, n_words: int) -> str:
rng = random.Random(salt * i + salt)
return " ".join(rng.choice(_VOCAB) for _ in range(n_words))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=30000)
ap.add_argument(
"--num-prefixes",
type=int,
nargs="+",
default=[128, 384, 640],
help="distinct-prefix counts (K) to sweep",
)
ap.add_argument(
"--prefix-tokens",
type=int,
default=1000,
help="approx prefix length in words (must cross the ~512 chunk granularity)",
)
ap.add_argument("--suffix-tokens", type=int, default=8)
ap.add_argument("--parallel", type=int, default=8)
ap.add_argument("--timeout", type=int, default=600)
args = ap.parse_args()
base = f"http://{args.host}:{args.port}"
gen_url = base + "/generate"
def send(prompt):
t0 = time.perf_counter()
try:
r = requests.post(
gen_url,
headers={"Content-Type": "application/json"},
json={
"text": prompt,
"sampling_params": {"max_new_tokens": 1, "temperature": 0.0},
},
timeout=args.timeout,
)
dt = time.perf_counter() - t0
mi = r.json().get("meta_info", {})
return mi.get("prompt_tokens"), mi.get("cached_tokens"), dt, None
except Exception as e: # noqa: BLE001
return None, None, time.perf_counter() - t0, str(e)[:80]
def flush():
try:
requests.post(base + "/flush_cache", timeout=60)
time.sleep(1.5)
except Exception:
pass
print(
f"server={base} prefix_tokens~{args.prefix_tokens} suffix_tokens={args.suffix_tokens} "
f"parallel={args.parallel} K_sweep={args.num_prefixes}"
)
header = (
f"{'K':>6} {'reuse_frac':>11} {'probe_p50_ms':>13} {'probe_p90_ms':>13} "
f"{'probe_thru_rps':>15} {'errors':>7}"
)
print(header)
print("-" * len(header))
for K in args.num_prefixes:
flush()
prefixes = [make_prefix(i, args.prefix_tokens) for i in range(K)]
warm = [
p + " " + make_suffix(i, 7, args.suffix_tokens)
for i, p in enumerate(prefixes)
]
probe = [
p + " " + make_suffix(i, 13, args.suffix_tokens)
for i, p in enumerate(prefixes)
]
with ThreadPoolExecutor(args.parallel) as ex:
list(ex.map(send, warm)) # WARM: populate the cache
t0 = time.perf_counter()
results = list(ex.map(send, probe)) # PROBE: measured
wall = time.perf_counter() - t0
ok = [(pt, ct, dt) for (pt, ct, dt, err) in results if err is None and pt]
errs = [r for r in results if r[3] is not None]
if not ok:
print(f"{K:>6} ALL-ERR e.g. {errs[:1]}")
continue
sum_prompt = sum(pt for pt, _, _ in ok)
sum_cached = sum((ct or 0) for _, ct, _ in ok)
lat_ms = sorted(dt * 1000.0 for _, _, dt in ok)
p50 = statistics.median(lat_ms)
p90 = lat_ms[min(len(lat_ms) - 1, int(0.9 * len(lat_ms)))]
thru = len(ok) / wall if wall > 0 else 0.0
print(
f"{K:>6} {sum_cached / max(1, sum_prompt):>11.3f} {p50:>13.1f} "
f"{p90:>13.1f} {thru:>15.1f} {len(errs):>7}"
)
if __name__ == "__main__":
main()
@@ -64,9 +64,20 @@ class MambaAttnBackendBase(AttentionBackend):
forward_batch.mamba_cow_src_indices is not None
and len(forward_batch.mamba_cow_src_indices) > 0
):
self.req_to_token_pool.mamba_pool.copy_from(
forward_batch.mamba_cow_src_indices, forward_batch.mamba_cow_dst_indices
)
ckpt_pool = getattr(self.req_to_token_pool, "mamba_ckpt_pool", None)
if ckpt_pool is not None:
# int8 checkpoints: dequantize the cached state (src = int8 ckpt slot)
# into the request's active bf16 slot (dst).
ckpt_pool.load_to_active(
self.req_to_token_pool.mamba_pool,
forward_batch.mamba_cow_src_indices,
forward_batch.mamba_cow_dst_indices,
)
else:
self.req_to_token_pool.mamba_pool.copy_from(
forward_batch.mamba_cow_src_indices,
forward_batch.mamba_cow_dst_indices,
)
forward_batch.mamba_clear_indices = None
forward_batch.mamba_cow_src_indices = None
forward_batch.mamba_cow_dst_indices = None
@@ -114,6 +114,9 @@ class SchedulerInvariantChecker:
)
def _check_mamba_pool(self, ps: PoolStats) -> Tuple[bool, str]:
ckpt_pool = getattr(self.req_to_token_pool, "mamba_ckpt_pool", None)
if ckpt_pool is not None:
return self._check_mamba_pool_with_int8(ps, ckpt_pool)
leak, msg = self._check_pool_invariant(
"mamba",
ps.mamba_available_size,
@@ -150,6 +153,37 @@ class SchedulerInvariantChecker:
)
return leak, msg
def _check_mamba_pool_with_int8(self, ps: PoolStats, ckpt_pool) -> Tuple[bool, str]:
"""Two-pool invariant for int8 mamba checkpoints.
The radix-cached states live in the int8 checkpoint pool, NOT the active
bf16 pool. So the single-pool equation (active.available + radix_cached ==
active.size) is wrong -- it double-counts the radix states against a pool
that does not hold them. Instead check the two pools independently:
* active bf16 pool: backs running requests only; the radix owns ZERO
active slots. Checked at idle (in-flight == 0) -> available == total.
* int8 checkpoint pool: backs the radix-cached states; its occupancy is
exactly the radix evictable + protected counts.
"""
active_leak, active_msg = self._check_pool_invariant(
"mamba-active",
ps.mamba_available_size,
ps.mamba_evictable_size, # 0 in int8 mode (radix owns no active slots)
0,
self.pool_stats_observer.session_held_mamba_slots(),
self.req_to_token_pool.mamba_pool.size,
)
int8_leak, int8_msg = self._check_pool_invariant(
"mamba-int8",
ckpt_pool.available_size(),
self.tree_cache.mamba_evictable_size(),
self.tree_cache.mamba_protected_size(),
0,
ckpt_pool.num_slots,
)
return active_leak or int8_leak, active_msg + "\n" + int8_msg
def _get_total_uncached_sizes(
self,
) -> Tuple[int, int]:
@@ -247,8 +247,19 @@ class SchedulerPoolStatsObserver:
self.tree_cache.full_evictable_size() if is_mamba_radix_cache else 0
)
mamba_available_size = self.req_to_token_pool.mamba_allocator.available_size()
# `mamba_usage`/`mamba_num_used` track the ACTIVE bf16 pool occupancy (running
# requests) -- this feeds throttle decisions (get_max_pool_usage) which asserts
# usage >= 0. With int8 checkpoints the radix-cached states live in a SEPARATE
# int8 pool, so they own ZERO active slots: report evictable=0 against the active
# pool (otherwise active.size - (available + radix_cached) goes negative). The
# int8 cache pool's own occupancy is validated separately in the invariant check.
has_int8_ckpt = (
getattr(self.req_to_token_pool, "mamba_ckpt_pool", None) is not None
)
mamba_evictable_size = (
self.tree_cache.mamba_evictable_size() if is_mamba_radix_cache else 0
self.tree_cache.mamba_evictable_size()
if (is_mamba_radix_cache and not has_int8_ckpt)
else 0
)
full_num_used = self.token_to_kv_pool_allocator.size - (
full_available_size + full_evictable_size
@@ -0,0 +1,373 @@
"""
Copyright 2023-2026 SGLang Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
MambaCheckpointPool — the radix prefix cache's int8-compressed store for cached
linear-attention (KDA / GDN / Mamba2 gated-delta-rule) recurrent states.
It decouples the *cached* states (radix-owned, idle, compressed) from the *active*
``MambaPool`` (running requests, full precision, kernel-facing). The radix stores
one cached state per node HERE; on a prefix-cache hit it is dequantized back into
a fresh active slot (copy-on-write).
Per cached slot it holds:
* the SSM temporal state in **int8** (per-(head,k-channel) symmetric), via the
embedded ``Int8CheckpointStore`` — ~2x more cached states than bf16,
quality-safe (quantized once on store, dequantized once on a hit; never
re-enters the recurrence as a quant->dequant loop).
* the conv1d window state at its native dtype (tiny, W-1 tokens; not worth
quantizing).
Why int8 (not fp8): a cached checkpoint is loaded ONCE on a cache hit, then
decoding continues at full precision, so the only error is a single rounding of
S. The temporal state is roughly uniformly distributed, so int8-per-(head,
k-channel) beats fp8-e4m3 at the same 1 byte (fp8 wastes bits on the exponent).
The scale axis (reduces over d_v) matches the per-k-channel decay diag(alpha), so
the large state entries keep ~bf16 precision and the error concentrates on small
entries that barely affect the readout. Storing cached states int8 gives ~2x the
cached-prefix capacity at fixed memory, and composes with host-offload
(HiMambaRadixCache) which it also halves.
This is strategy-agnostic: whether the active slot to be cached was produced by
the ``no_buffer`` donate (copy_from) or the ``extra_buffer`` ping-pong track
buffer (spec path), both converge on "an active slot becomes the cached
``mamba_value``" — which is exactly the (store_from_active) hook here. Slot
lifecycle is owned by the caller via the embedded ``MambaSlotAllocator``.
"""
from __future__ import annotations
import logging
from typing import List, Optional
import torch
from sglang.srt.mem_cache.allocator.mamba import MambaSlotAllocator
logger = logging.getLogger(__name__)
class Int8CheckpointStore:
"""int8 store for cached multi-layer linear-attn states.
Tensors (slot index handed out by the caller's allocator):
qdata : [L, num_slots, H, d_v, d_k] int8 (the quantized state)
scale : [L, num_slots, H, 1, d_k] scale_dtype (per layer,slot,head,k-chan)
A "state" spans all L mamba layers for one cached point (matching how the
radix caches one full state per node). The reduction axis for the scale is
d_v (dim=-2), so each (head, k-channel) gets its own scale — aligned with the
per-k-channel decay diag(alpha).
``scale_dtype`` should match the source state's dtype (bf16 / fp16 / fp32) so
that quantize and dequantize use the identical scale — it is NOT required to
be bf16.
"""
QMAX = 127
def __init__(
self,
*,
num_layers: int,
num_slots: int,
num_heads: int,
head_v_dim: int,
head_k_dim: int,
device: str,
scale_dtype: torch.dtype = torch.bfloat16,
):
self.num_layers = num_layers
self.num_slots = num_slots
self.H = num_heads
self.d_v = head_v_dim
self.d_k = head_k_dim
self.device = device
self.qdata = torch.empty(
num_layers,
num_slots,
num_heads,
head_v_dim,
head_k_dim,
dtype=torch.int8,
device=device,
)
self.scale = torch.empty(
num_layers,
num_slots,
num_heads,
1,
head_k_dim,
dtype=scale_dtype,
device=device,
)
# ---- (de)quant math (also usable standalone for probes/tests) ----
@classmethod
def quantize(cls, state: torch.Tensor):
"""state [..., H, d_v, d_k] -> (qint8, scale[..., H, 1, d_k]).
amax / scale / round are computed in float32 so quantizing a low-precision
state doesn't lose precision in the intermediate (symmetric with
``dequantize``, which is already float32). The scale is rounded to the
state dtype (its storage precision) BEFORE the division, so quantize and
dequantize use the identical scale."""
state_fp32 = state.to(torch.float32)
amax = state_fp32.abs().amax(dim=-2, keepdim=True).clamp(min=1e-8)
scale = (amax / cls.QMAX).to(state.dtype)
q = (
torch.round(state_fp32 / scale.to(torch.float32))
.clamp(-cls.QMAX, cls.QMAX)
.to(torch.int8)
)
return q, scale
@staticmethod
def dequantize(q: torch.Tensor, scale: torch.Tensor, out_dtype: torch.dtype):
return (q.to(torch.float32) * scale.to(torch.float32)).to(out_dtype)
# ---- store / load (caller supplies slot indices) ----
def store(self, slots: torch.Tensor, state: torch.Tensor) -> None:
"""Quantize and write states. state: [L, N, H, d_v, d_k] for the N slots
(or [L, H, d_v, d_k] when slots is a scalar/len-1)."""
if state.dim() == 4:
state = state.unsqueeze(1)
q, scale = self.quantize(state)
self.qdata[:, slots] = q
self.scale[:, slots] = scale.to(self.scale.dtype)
def load(self, slots: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor:
"""Dequantize states at slots -> [L, N, H, d_v, d_k] in out_dtype."""
return self.dequantize(self.qdata[:, slots], self.scale[:, slots], out_dtype)
def copy_to_pool(
self,
dst_temporal: torch.Tensor,
src_slots: torch.Tensor,
dst_slots: torch.Tensor,
) -> None:
"""Dequantize checkpoints at ``src_slots`` directly into the active pool
tensor ``dst_temporal`` [L, pool_slots, H, d_v, d_k] at ``dst_slots`` (the
copy-on-write on a cache hit). Output dtype follows ``dst_temporal``."""
dst_temporal[:, dst_slots] = self.load(src_slots, dst_temporal.dtype)
def store_from_pool(
self,
src_temporal: torch.Tensor,
src_slots: torch.Tensor,
dst_slots: torch.Tensor,
) -> None:
"""Quantize states from an active pool tensor into checkpoint slots (cache
store / donate)."""
self.store(dst_slots, src_temporal[:, src_slots])
def mem_usage_bytes(self) -> int:
return (
self.qdata.numel() * self.qdata.element_size()
+ self.scale.numel() * self.scale.element_size()
)
def bytes_per_slot(self) -> int:
return self.mem_usage_bytes() // max(1, self.num_slots)
class MambaCheckpointPool:
def __init__(
self,
*,
num_layers: int,
num_slots: int,
num_heads: int,
head_v_dim: int,
head_k_dim: int,
conv_shapes: List[tuple],
conv_dtype: torch.dtype,
device: str,
temporal_dtype: Optional[torch.dtype] = None,
):
self.num_slots = num_slots
self.device = device
self.temporal = Int8CheckpointStore(
num_layers=num_layers,
num_slots=num_slots + 1, # slot 0 reserved (matches MambaSlotAllocator)
num_heads=num_heads,
head_v_dim=head_v_dim,
head_k_dim=head_k_dim,
device=device,
# store the scale in the temporal state's own dtype so quantize and
# dequantize use the identical scale (not hard-coded to bf16)
scale_dtype=(
temporal_dtype if temporal_dtype is not None else torch.bfloat16
),
)
# conv windows stay at their native dtype (small); one buffer per conv
# tensor in the State
self.conv = [
torch.empty(
(num_layers, num_slots + 1) + tuple(shape),
dtype=conv_dtype,
device=device,
)
for shape in conv_shapes
]
self.allocator = MambaSlotAllocator(size=num_slots, device=device)
# ---- lifecycle (delegates to the embedded allocator) ----
def alloc(self, n: int = 1):
return self.allocator.alloc(n)
def free(self, slots: torch.Tensor):
self.allocator.free(slots)
def available_size(self) -> int:
return self.allocator.available_size()
def clear(self) -> None:
"""Release every checkpoint slot (radix flush/reset). The int8 qdata is
left as-is; slots are reused/overwritten on the next store."""
self.allocator.clear()
# ---- state transfer between the active MambaPool and this store ----
def store_from_active(self, active_mamba_pool, active_slots, ckpt_slots) -> None:
"""Quantize temporal + copy conv from the active pool into checkpoint slots
(the radix donate / cache-store)."""
cache = active_mamba_pool.mamba_cache
self.temporal.store_from_pool(cache.temporal, active_slots, ckpt_slots)
for i, c in enumerate(self.conv):
c[:, ckpt_slots] = cache.conv[i][:, active_slots]
def load_to_active(self, active_mamba_pool, ckpt_slots, active_slots) -> None:
"""Dequantize temporal + copy conv from checkpoint slots into the active pool
(the cache-hit copy-on-write)."""
cache = active_mamba_pool.mamba_cache
self.temporal.copy_to_pool(cache.temporal, ckpt_slots, active_slots)
for i, c in enumerate(self.conv):
cache.conv[i][:, active_slots] = c[:, ckpt_slots].to(cache.conv[i].dtype)
@staticmethod
def estimate_mem_usage_bytes(
*,
num_layers: int,
num_slots: int,
num_heads: int,
head_v_dim: int,
head_k_dim: int,
conv_shapes: List[tuple],
conv_dtype: torch.dtype,
temporal_dtype: torch.dtype,
) -> dict:
"""Estimate the pool's HBM footprint (bytes) WITHOUT allocating, so a
caller can check it against free memory before construction. Mirrors the
real layout: int8 qdata + per-(head,k) scale + bf16 conv windows, including
the reserved slot 0."""
slots = num_slots + 1 # slot 0 reserved (matches MambaSlotAllocator)
scale_isz = torch.empty((), dtype=temporal_dtype).element_size()
conv_isz = torch.empty((), dtype=conv_dtype).element_size()
qdata = num_layers * slots * num_heads * head_v_dim * head_k_dim # int8 = 1B
scale = num_layers * slots * num_heads * head_k_dim * scale_isz
conv = 0
for shape in conv_shapes:
n = 1
for s in shape:
n *= int(s)
conv += num_layers * slots * n * conv_isz
return {
"qdata": qdata,
"scale": scale,
"conv": conv,
"total": qdata + scale + conv,
}
def mem_usage_bytes(self) -> int:
conv_bytes = sum(c.numel() * c.element_size() for c in self.conv)
return self.temporal.mem_usage_bytes() + conv_bytes
def maybe_init_int8_mamba_checkpoint_pool(
*,
mamba_size: int,
cache_params,
mamba_layer_ids: List[int],
device: str,
) -> Optional[MambaCheckpointPool]:
"""Build the optional int8 ``MambaCheckpointPool`` when
``--enable-int8-mamba-checkpoint`` is set (and a global server-args context
exists), else return ``None``. The radix caches states here (int8) instead of
in the active bf16 pool -> ~2x cached-prefix capacity at fixed memory.
Estimates the pool's HBM footprint and checks it against free memory BEFORE
allocating, so an oversized ``--int8-mamba-ckpt-size`` fails with an actionable
message instead of a cryptic mid-allocation CUDA OOM.
"""
from sglang.srt.server_args import get_global_server_args
try:
_sa = get_global_server_args()
except ValueError:
# Some unit-test / mock runners construct HybridReqToTokenPool directly
# without a global server-args context. The int8 checkpoint pool is opt-in
# via a CLI flag, so an unset context unambiguously means it is off.
_sa = None
if not getattr(_sa, "enable_int8_mamba_checkpoint", False):
return None
GB = 1 << 30
H, d_v, d_k = cache_params.shape.temporal
ckpt_size = _sa.int8_mamba_ckpt_size or (2 * mamba_size)
kwargs = dict(
num_layers=len(mamba_layer_ids),
num_slots=ckpt_size,
num_heads=H,
head_v_dim=d_v,
head_k_dim=d_k,
conv_shapes=list(cache_params.shape.conv),
conv_dtype=cache_params.dtype.conv,
temporal_dtype=cache_params.dtype.temporal,
)
est = MambaCheckpointPool.estimate_mem_usage_bytes(**kwargs)
free_bytes = None
if isinstance(device, str) and device.startswith("cuda"):
try:
free_bytes, _ = torch.cuda.mem_get_info(device)
except Exception:
free_bytes = None
logger.info(
f"int8 mamba checkpoint pool: {ckpt_size} slots, "
f"{est['total'] / GB:.2f}GB (qdata {est['qdata'] / GB:.2f} + scale "
f"{est['scale'] / GB:.2f} + conv {est['conv'] / GB:.2f}); active mamba "
f"pool {mamba_size} slots"
+ (f"; free HBM {free_bytes / GB:.2f}GB" if free_bytes is not None else "")
)
if free_bytes is not None and est["total"] >= free_bytes:
raise RuntimeError(
f"int8 mamba checkpoint pool needs ~{est['total'] / GB:.2f}GB but only "
f"{free_bytes / GB:.2f}GB HBM is free. Lower --int8-mamba-ckpt-size "
f"(currently {ckpt_size}) or --mem-fraction-static."
)
pool = MambaCheckpointPool(device=device, **kwargs)
# NOTE: this pool's HBM is NOT subtracted from the KV-cache budget
# (max_total_num_tokens); it is allocated from --mem-fraction-static headroom.
# The estimate check above guards against an oversized pool; accounting it in
# the KV budget is a follow-up.
logger.warning(
f"int8 mamba checkpoint pool ({est['total'] / GB:.2f}GB) is allocated from "
f"--mem-fraction-static headroom and is not reflected in "
f"max_total_num_tokens; ensure headroom covers it."
)
return pool
@@ -562,22 +562,29 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
mamba_ping_pong_track_buffer_to_keep = (
self.req_to_token_pool.get_mamba_ping_pong_keep_idx(req)
)
mamba_value = (
req.mamba_ping_pong_track_buffer[
mamba_ping_pong_track_buffer_to_keep
]
.unsqueeze(-1)
.clone()
)
assert mamba_value.item() != -1, (
src_active = req.mamba_ping_pong_track_buffer[
mamba_ping_pong_track_buffer_to_keep
].unsqueeze(-1)
assert src_active.item() != -1, (
f"Cached mamba slot is -1: keep_idx={mamba_ping_pong_track_buffer_to_keep}, "
f"buf={req.mamba_ping_pong_track_buffer.tolist()}, "
f"next_track_idx={req.mamba_next_track_idx}, "
f"last_track_seqlen={req.mamba_last_track_seqlen}, "
f"rid={req.rid}"
)
if self.int8_ckpt_pool is not None:
mamba_value = self._commit_int8_checkpoint(src_active)
# quantized -> no ping-pong slot needs keeping
mamba_ping_pong_track_buffer_to_keep = None
else:
mamba_value = src_active.clone()
else:
mamba_value = req.mamba_pool_idx.unsqueeze(-1).clone()
if self.int8_ckpt_pool is not None:
mamba_value = self._commit_int8_checkpoint(
req.mamba_pool_idx.unsqueeze(-1)
)
else:
mamba_value = req.mamba_pool_idx.unsqueeze(-1).clone()
mamba_ping_pong_track_buffer_to_keep = None
result = self.insert(
@@ -589,6 +596,9 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
)
)
mamba_exist = result.mamba_exist
if mamba_exist and self.int8_ckpt_pool is not None:
# state already cached -> the int8 slot we just allocated is a duplicate
self.int8_ckpt_pool.free(mamba_value)
else:
self.token_to_kv_pool_allocator.free(kv_indices[req.cache_protected_len :])
mamba_exist = True
@@ -596,7 +606,13 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
if mamba_exist:
mamba_ping_pong_track_buffer_to_keep = None
free_mamba_cache = True if self.enable_mamba_extra_buffer else mamba_exist
# With int8 checkpoints the radix owns an int8 slot (not the request's active
# slot), so the active mamba slot must always be returned to the active pool.
free_mamba_cache = (
True
if (self.enable_mamba_extra_buffer or self.int8_ckpt_pool is not None)
else mamba_exist
)
if free_mamba_cache:
self.req_to_token_pool.free_mamba_cache(
@@ -649,7 +665,21 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
# Donate the mamba index to the radix cache instead of copying.
# This avoids a data copy that would race with the forward stream.
if self.enable_mamba_extra_buffer:
if self.int8_ckpt_pool is not None:
# int8 path: quantize the to-be-cached active state into an int8 slot
# (strategy-agnostic donate hook).
if self.enable_mamba_extra_buffer:
new_slot = self._alloc_mamba_slot()
src_active = self.req_to_token_pool.donate_mamba_ping_pong_slot(
req, new_slot
)
mamba_value_donated = self._commit_int8_checkpoint(src_active)
self.req_to_token_pool.mamba_allocator.free(src_active)
else:
mamba_value_donated = self._commit_int8_checkpoint(
req.mamba_pool_idx.view(-1)
)
elif self.enable_mamba_extra_buffer:
new_slot = self._alloc_mamba_slot()
mamba_value_donated = self.req_to_token_pool.donate_mamba_ping_pong_slot(
req, new_slot
@@ -671,7 +701,7 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
)
new_prefix_len, mamba_exist = result.prefix_len, result.mamba_exist
if mamba_exist:
self.req_to_token_pool.mamba_allocator.free(mamba_value_donated)
self._free_mamba_value(mamba_value_donated)
# The prefix indices could be updated, reuse it
match_result = self.match_prefix(
@@ -729,7 +759,7 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
self._record_remove_event(x)
self.token_to_kv_pool_allocator.free(x.value)
full_num_evicted = len(x.value)
self.req_to_token_pool.mamba_allocator.free(x.mamba_value)
self._free_mamba_value(x.mamba_value)
mamba_num_evicted = len(x.mamba_value)
# 2. get the next node, update the lru lists
@@ -782,7 +812,7 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
if len(x.children) > 0:
# 1. an internal node, free mamba tokens.
self.req_to_token_pool.mamba_allocator.free(x.mamba_value)
self._free_mamba_value(x.mamba_value)
mamba_num_evicted += len(x.mamba_value)
# 2. get the next node, update the lru lists
@@ -954,6 +984,41 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
assert slot is not None, "Can not alloc mamba cache"
return slot
@property
def int8_ckpt_pool(self):
"""The int8 checkpoint pool, or None when --enable-int8-mamba-checkpoint is off.
When enabled, radix-cached mamba states live HERE (int8), not in the active
bf16 pool -> ~2x cached-prefix capacity at fixed memory."""
return getattr(self.req_to_token_pool, "mamba_ckpt_pool", None)
def _alloc_int8_ckpt_slot(self) -> torch.Tensor:
"""Allocate one int8 checkpoint slot, evicting cached states if the pool is full."""
slot = self.int8_ckpt_pool.alloc(1)
if slot is None:
self.evict(EvictParams(num_tokens=0, mamba_num=1))
slot = self.int8_ckpt_pool.alloc(1)
assert slot is not None, "Can not alloc int8 mamba checkpoint slot"
return slot
def _commit_int8_checkpoint(self, active_slots: torch.Tensor) -> torch.Tensor:
"""Quantize the active-pool state at ``active_slots`` into a fresh int8
checkpoint slot and return that slot. Strategy-agnostic donate hook: both
no_buffer (copy_from) and extra_buffer (ping-pong) converge here. The caller
frees ``active_slots`` separately."""
ckpt_slot = self._alloc_int8_ckpt_slot()
self.int8_ckpt_pool.store_from_active(
self.req_to_token_pool.mamba_pool, active_slots, ckpt_slot
)
return ckpt_slot
def _free_mamba_value(self, mamba_value: torch.Tensor) -> None:
"""Free a node's mamba_value to the right allocator (int8 ckpt pool or the
active mamba allocator)."""
if self.int8_ckpt_pool is not None:
self.int8_ckpt_pool.free(mamba_value)
else:
self.req_to_token_pool.mamba_allocator.free(mamba_value)
def _match_prefix_helper(
self, key: RadixKey
) -> Tuple[List[torch.Tensor], TreeNode, int]:
@@ -612,6 +612,20 @@ class HybridReqToTokenPool(ReqToTokenPool):
)
self.mamba_map = {layer_id: i for i, layer_id in enumerate(mamba_layer_ids)}
# Optional int8 checkpoint pool: the radix caches states here (int8) instead
# of holding them in the active bf16 pool -> ~2x cached-prefix capacity at
# fixed memory. Strategy-agnostic (no_buffer / extra_buffer / spec).
from sglang.srt.mem_cache.mamba_checkpoint_pool import (
maybe_init_int8_mamba_checkpoint_pool,
)
self.mamba_ckpt_pool = maybe_init_int8_mamba_checkpoint_pool(
mamba_size=mamba_size,
cache_params=cache_params,
mamba_layer_ids=mamba_layer_ids,
device=device,
)
self.device = device
req_pool_size = self.req_to_token.shape[0]
self.req_index_to_mamba_index_mapping: torch.Tensor = torch.zeros(
@@ -821,6 +835,12 @@ class HybridReqToTokenPool(ReqToTokenPool):
logger.info("Reset HybridReqToTokenPool")
super().clear()
self.mamba_allocator.clear()
# The int8 checkpoint pool holds radix-cached states in its own slots; a
# flush/reset drops the radix tree, so its slots must be released too,
# otherwise the (now unreferenced) slots leak and break the int8-pool
# invariant (int8_available + radix_cached != int8_total).
if self.mamba_ckpt_pool is not None:
self.mamba_ckpt_pool.clear()
self.req_index_to_mamba_index_mapping.zero_()
if self.enable_mamba_extra_buffer:
self.req_index_to_mamba_ping_pong_track_buffer_mapping.zero_()
+42
View File
@@ -694,6 +694,12 @@ class ServerArgs:
mamba_full_memory_ratio: float = 0.9
mamba_scheduler_strategy: str = "auto"
mamba_track_interval: int = 256
# int8-compress radix-cached linear-attn (mamba) checkpoints -> ~2x cached
# prefixes at fixed memory (quality-safe; see mem_cache/mamba_checkpoint_pool.py).
enable_int8_mamba_checkpoint: bool = False
int8_mamba_ckpt_size: Optional[int] = (
None # #int8 checkpoint slots; default 2x the active pool
)
linear_attn_backend: str = "triton"
linear_attn_decode_backend: Optional[str] = None
linear_attn_prefill_backend: Optional[str] = None
@@ -1009,6 +1015,7 @@ class ServerArgs:
self._handle_deterministic_inference()
self._handle_attention_backend_compatibility()
self._handle_mamba_backend()
self._handle_int8_mamba_checkpoint()
self._handle_linear_attn_backend()
self._handle_kv4_compatibility()
self._handle_page_size()
@@ -3376,6 +3383,28 @@ class ServerArgs:
"FlashInfer mamba module not available, please check flashinfer installation."
)
def _handle_int8_mamba_checkpoint(self):
# The int8 mamba checkpoint pool is only wired into the built-in
# MambaRadixCache. The host-offload variant (HiMambaRadixCache, enabled by
# --enable-hierarchical-cache) and custom radix-cache backends are NOT
# int8-aware: they would read int8 checkpoint slots as bf16 active slots
# (wrong pool / out-of-range). Reject the combination up front rather than
# silently corrupting state.
if not self.enable_int8_mamba_checkpoint:
return
if self.enable_hierarchical_cache:
raise ValueError(
"--enable-int8-mamba-checkpoint is not supported together with "
"--enable-hierarchical-cache: the host-offload path "
"(HiMambaRadixCache) is not int8-aware. Disable one of them."
)
if self.radix_cache_backend is not None:
raise ValueError(
"--enable-int8-mamba-checkpoint only supports the built-in mamba "
f"radix cache; --radix-cache-backend={self.radix_cache_backend!r} "
"is not int8-aware. Omit --radix-cache-backend."
)
def _handle_linear_attn_backend(self):
import torch
@@ -6444,6 +6473,19 @@ class ServerArgs:
default=ServerArgs.max_mamba_cache_size,
help="The maximum size of the mamba cache.",
)
parser.add_argument(
"--enable-int8-mamba-checkpoint",
action="store_true",
help="Store radix-cached linear-attn (mamba) states in int8 (separate "
"checkpoint pool) for ~2x cached-prefix capacity at fixed memory.",
)
parser.add_argument(
"--int8-mamba-ckpt-size",
type=int,
default=ServerArgs.int8_mamba_ckpt_size,
help="Number of int8 mamba checkpoint slots (default: 2x the active "
"mamba pool size).",
)
parser.add_argument(
"--mamba-ssm-dtype",
type=str,
@@ -0,0 +1,93 @@
"""
End-to-end test for the int8 mamba checkpoint pool on a real GDN-hybrid model.
Launches Qwen3-Next-80B-A3B (a gated-delta-net / linear-attention hybrid) with
``--enable-int8-mamba-checkpoint`` and checks two things while the int8 dequant
path is exercised:
* KLDivergenceMixin — on a prefix/decode cache HIT the generated logprobs are
compared (KL) against a full recompute. This is the *sensitive* precision
guard: it directly bounds how far the int8-reused state moves the output
distribution from the exact-recompute distribution.
* test_gsm8k — end-to-end task accuracy holds.
NOTE: the int8 checkpoint is only engaged when a cached prefix is reused FROM the
int8 pool, which requires ``--mamba-scheduler-strategy extra_buffer`` — the default
``no_buffer`` only snapshots the recurrent state at the full-sequence leaf, so a
fixed-prefix / divergent-question workload reuses ~0 mamba state and the int8 path
would never fire.
Usage:
python3 -m unittest test_int8_mamba_checkpoint_e2e
"""
import unittest
from types import SimpleNamespace
from urllib.parse import urlparse
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kits.kl_divergence_kit import KLDivergenceMixin
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")
class TestInt8MambaCheckpointE2E(KLDivergenceMixin, DefaultServerBase):
"""int8 mamba checkpoint pool on Qwen3-Next-80B-A3B (GDN-hybrid)."""
model = DEFAULT_HYBRID_MAMBA_MODEL_NAME_FOR_TEST
# Cache-hit KL: int8 is a lossy codec, so its cache-hit divergence is
# inherently larger than the bf16/fp8 reuse the other KL tests bound (~0.005),
# and it grows with context length (a longer prefix = a fuller state = larger
# absolute rounding error in the logits). Measured on a Qwen3.5-35B stand-in
# over LongBench-V2 prompts: prefill ~0.044, decode ~0.024. Thresholds are set
# to ~2x that, to cover model differences (80B) and the reuse path's
# run-to-run noise while still catching a real int8 regression.
kl_div_thres = 0.06
kl_div_thres_prefill = 0.10
kl_div_thres_decode = 0.06
kl_div_max_samples = 16
kl_div_prefill_max_new_tokens = 512
kl_div_decode_max_new_tokens = 512
gsm8k_threshold = 0.90
num_gsm8k_questions = 100
num_shots = 8
parallel = 8
other_args = [
"--trust-remote-code",
"--tp-size",
"4",
"--mem-fraction-static",
"0.7",
"--enable-int8-mamba-checkpoint",
"--mamba-scheduler-strategy",
"extra_buffer",
]
def test_gsm8k(self):
from sglang.test.few_shot_gsm8k import run_eval as run_few_shot_gsm8k
url = urlparse(self.base_url)
args = SimpleNamespace(
num_shots=self.num_shots,
data_path=None,
num_questions=self.num_gsm8k_questions,
max_new_tokens=512,
parallel=self.parallel,
host=f"http://{url.hostname}",
port=int(url.port),
)
metrics = run_few_shot_gsm8k(args)
print(
f"[{self.__class__.__name__}] GSM8K accuracy: {metrics['accuracy']:.3f} "
f"(threshold: {self.gsm8k_threshold})"
)
self.assertGreaterEqual(metrics["accuracy"], self.gsm8k_threshold)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,178 @@
"""Tests for Int8CheckpointStore (int8-compressed cached linear-attn states).
CPU tests cover the codec error bound, store/load round-trip, and the active-pool
copy-on-write helpers. The CUDA test reproduces the validated decode-output error
(int8 checkpoint loaded once then decoded continues bf16) ~ 0.5%, far below the
bf16-baseline-relative threshold that GSM8K showed is quality-safe.
python -m pytest test/srt/mem_cache/test_int8_checkpoint_store.py -v
"""
import unittest
import torch
from sglang.srt.mem_cache.mamba_checkpoint_pool import (
Int8CheckpointStore,
MambaCheckpointPool,
)
H, V, K = 32, 128, 128
L = 4
def _rand_state(n, device="cpu"):
# KDA-like state magnitudes (see fp8_checkpoint_probe: |S| mean ~6e-2)
return torch.randn(L, n, H, V, K, device=device) * 6e-2
class TestInt8CheckpointCodec(unittest.TestCase):
def test_quantize_dequantize_error_bound(self):
s = _rand_state(8)
q, scale = Int8CheckpointStore.quantize(s)
self.assertEqual(q.dtype, torch.int8)
self.assertEqual(scale.shape, (L, 8, H, 1, K)) # per (layer,slot,head,k-chan)
deq = Int8CheckpointStore.dequantize(q, scale, torch.float32)
rel = (deq - s).norm() / s.norm()
# uniform int8 per-channel on a ~uniform state: well under 1%
self.assertLess(rel.item(), 1e-2, f"int8 codec rel err too high: {rel}")
def test_symmetric_and_zero(self):
s = torch.zeros(L, 1, H, V, K)
q, scale = Int8CheckpointStore.quantize(s)
self.assertTrue(torch.equal(q, torch.zeros_like(q)))
deq = Int8CheckpointStore.dequantize(q, scale, torch.float32)
self.assertTrue(torch.equal(deq, s))
def test_store_load_roundtrip(self):
store = Int8CheckpointStore(
num_layers=L,
num_slots=16,
num_heads=H,
head_v_dim=V,
head_k_dim=K,
device="cpu",
)
s = _rand_state(4)
slots = torch.tensor([1, 3, 5, 7])
store.store(slots, s)
out = store.load(slots, torch.float32)
# load == dequant of stored
q, scale = Int8CheckpointStore.quantize(s)
ref = Int8CheckpointStore.dequantize(
q, scale.to(store.scale.dtype), torch.float32
)
self.assertLess((out - ref).abs().max().item(), 1e-3)
def test_cow_helpers(self):
store = Int8CheckpointStore(
num_layers=L,
num_slots=16,
num_heads=H,
head_v_dim=V,
head_k_dim=K,
device="cpu",
)
active = torch.zeros(L, 10, H, V, K) # bf16/fp32 active pool
active[:, 2] = _rand_state(1).squeeze(1)
# store active slot 2 -> ckpt slot 4
store.store_from_pool(active, torch.tensor([2]), torch.tensor([4]))
# load ckpt slot 4 -> active slot 6 (cache-hit COW)
store.copy_to_pool(active, torch.tensor([4]), torch.tensor([6]))
rel = (active[:, 6] - active[:, 2]).norm() / active[:, 2].norm()
self.assertLess(rel.item(), 1e-2)
def test_memory_is_half_of_bf16(self):
store = Int8CheckpointStore(
num_layers=L,
num_slots=100,
num_heads=H,
head_v_dim=V,
head_k_dim=K,
device="cpu",
)
bf16_per_slot = L * H * V * K * 2
# int8 data (1B) + small per-(head,k) bf16 scale -> well under bf16; ~2x slots
self.assertLess(store.bytes_per_slot(), bf16_per_slot * 0.6)
def test_estimate_matches_actual_mem(self):
# the pre-allocation estimate (used to fit-check HBM before building the
# pool) must equal the real allocated footprint, for any temporal dtype
for tdt in (torch.bfloat16, torch.float32):
kw = dict(
num_layers=L,
num_slots=64,
num_heads=H,
head_v_dim=V,
head_k_dim=K,
conv_shapes=[(4, K)],
conv_dtype=torch.bfloat16,
temporal_dtype=tdt,
)
est = MambaCheckpointPool.estimate_mem_usage_bytes(**kw)
pool = MambaCheckpointPool(**kw, device="cpu")
self.assertEqual(est["qdata"] + est["scale"] + est["conv"], est["total"])
self.assertEqual(est["total"], pool.mem_usage_bytes())
@unittest.skipUnless(torch.cuda.is_available(), "needs CUDA + fla kernels")
class TestInt8CheckpointDecodeError(unittest.TestCase):
def test_decode_error_within_bound(self):
try:
from sglang.srt.layers.attention.fla.kda import fused_recurrent_kda
except (ImportError, ModuleNotFoundError) as e:
self.skipTest(f"fla kernels unavailable: {e}")
dev = "cuda"
torch.manual_seed(0)
def synth(T, s):
torch.manual_seed(s)
q = torch.randn(1, T, H, K, device=dev, dtype=torch.bfloat16) * 0.5
k = torch.randn(1, T, H, K, device=dev, dtype=torch.bfloat16) * 0.5
v = (torch.randn(1, T, H, V, device=dev) * 0.5).bfloat16()
beta = torch.rand(1, T, H, device=dev, dtype=torch.bfloat16)
g = -torch.rand(1, T, H, K, device=dev, dtype=torch.float32) * 0.1 - 0.005
return q, k, v, g, beta
def decode(state, inp):
st = state.clone()
o, _ = fused_recurrent_kda(
q=inp[0],
k=inp[1],
v=inp[2],
g=inp[3],
beta=inp[4],
scale=K**-0.5,
initial_state=st,
inplace_final_state=True,
use_qk_l2norm_in_kernel=True,
cu_seqlens=None,
)
return o.float()
S = torch.zeros(1, H, V, K, device=dev, dtype=torch.float32)
pre = synth(512, 0)
fused_recurrent_kda(
q=pre[0],
k=pre[1],
v=pre[2],
g=pre[3],
beta=pre[4],
scale=K**-0.5,
initial_state=S,
inplace_final_state=True,
use_qk_l2norm_in_kernel=True,
cu_seqlens=None,
)
dec = synth(128, 1)
o_ref = decode(S, dec)
q, scale = Int8CheckpointStore.quantize(S) # [1,H,V,K]
S_int8 = Int8CheckpointStore.dequantize(q, scale, torch.float32)
o_int8 = decode(S_int8, dec)
rel = (o_int8 - o_ref).norm() / o_ref.norm()
self.assertLess(rel.item(), 1.5e-2, f"int8 decode err {rel} too high")
if __name__ == "__main__":
unittest.main()