Conversation
Compressed hybrid DSA inflates the radix-tree page to lcm(page_size, pool.page_size * index_kpool) so one compressed index row stays atomic; the storage tier previously required tree page == host page and raised "Compressed hybrid DSA currently supports L2 HiCache only". This lifts the gate for span-capable backends: - HiCacheController.storage_page_size: the storage hash chain runs at the radix-tree page granularity; one key covers storage_page_size tokens (K consecutive host pages per pool, sidecar pools included) - HiCacheFile: multi-page span objects - concat K host pages on set, split and restore per page on get; batch_exists_v2 stays key-granular - supports_page_spans capability flag + StorageBackendFactory check - init_hicache / attach_storage_backend: accept span-capable backends (e.g. file) for compressed hybrid DSA instead of raising Unit tests: span roundtrip through a fake page-aligned host pool, size-mismatch guard, batch_exists_v2 prefix semantics, tree-vs-controller hash-chain alignment at the tree page size, backend capability check.
Self-review fix on top of c4dc2aa2: - _generate_storage_config passes storage_page_size into HiCacheStorageConfig so backends created at runtime attach see the span factor - init_hicache also patches the live backend instance: the hybrid stack attaches the startup backend inside the controller constructor, before the injection point - attach_storage_backend (runtime) upgrades the controller into span mode itself when a span-capable backend passes the gate
…ce tests PoolName.KV value is 'kv' (lowercase) so result dicts are keyed by the enum. Rejection tests now use the non-span 'sim' backend; new runtime/startup acceptance tests cover the lifted gate for span-capable backends (file). Native-hash alignment test skips off little-endian Linux.
|
@ormandj hey! This is my first PR to SGLang, so I would like to ask you to do a short review. So once your PR is merged I'll be able to open mine against sglang main branch. |
ormandj
left a comment
There was a problem hiding this comment.
Thanks for working on this, @d3lavar. I reviewed 07687285 and reproduced two issues:
-
Preserve each pool's transfer granularity. In
HiCacheFile._batch_io_v2,storage_page_sizeapplies to every pool, but Mamba supplies one checkpoint slot per key. A 256-token span therefore rejects a valid one-slot checkpoint. This also affects ordinary non-span hybrid file storage: with a 64-token KV page, the controller now passesstorage_page_size=64, so the backend expects 64 Mamba slots per key. In a matched CPU roundtrip with two KV keys and two Mamba keys, the base restores both components; this head rejects the Mamba transfers. Please preserve independent state-pool granularity while validating KV and KV-derived spans, and add mixed KV/Mamba roundtrips for both configurations using controller-generated config. Without the checkpoints, the stored KV cannot satisfy a component-complete cache hit. -
Use storage granularity for skipped-KV backup accounting. The
backup_skipbranch still multiplies the hash count byself.page_size. With successful sidecar writes, one 256-token storage key reports 64 completed tokens on nonzero MLA TP ranks. Please usestorage_page_sizeand test both branches so the backup-token metrics agree across ranks. -
Align the description and validation with the submitted revision. The body describes a derived per-pool stride and
test_mixed_kv_and_state_pool_roundtrip, but neither is present at this head. These may be in an unpushed revision. Please push the described fix/test and identify the commit used for validation, then update the non-compressed compatibility claim accordingly, so reviewers can connect the reported results to the code being merged.
Production regression caught live on the l3span node: _batch_io_v2 demanded the KV span stride (storage_page_size slots per key) from every pool, but independent state pools (mamba) carry one checkpoint slot per tree page. Their transfers were rejected, so mamba objects never reached L3 and batch_exists_v2 component checks zeroed every hit - the node filled with unusable KV-only objects. The stride is now derived from the transfer itself: KV/KV-derived pools must carry exactly storage_page_size slots per key, other pools any len(keys)-divisible count. Covered by a mixed KV+state roundtrip unit test.
The backup_skip fast path still multiplied the hash count by page_size, so a 256-token span key reported 64 completed tokens on the ranks that own sidecar writes; backup-token metrics disagreed across MLA TP ranks. Both branches (sidecar-ok / sidecar-failed) now account against storage_page_size; covered by a two-branch unit test.
Per review: exercise HiCacheController._generate_storage_config end to end (parallel getters stubbed, every config field produced by the controller) and run a mixed KV + Mamba roundtrip for both wirings — degenerate non-span (storage_page_size == page_size) and span (4x inflation). Also covers the backup_skip token accounting for both sidecar branches.
|
Thanks for the sharp review — all three findings confirmed and fixed. The root cause of (1)/(3): the stack was assembled by cherry-picking the feature branch, and the stride-derivation commit was dropped in transfer — the pushed head predated the fix the production validation had actually run on. My mistake, thanks for catching it. 1. Per-pool granularity — fixed by re-landing the stride-derivation change as 2. 3. Description/revision alignment — the body now identifies the validation build (feature-branch tree identical to the stacked commits, including New tests in |
230102d to
0ee5167
Compare
Motivation
Compressed hybrid DSA models (GLM-5.3-Flash /
glm5_nextfamily) cannot usethe L3 HiCache storage tier today. sgl-project#38212 (correctly) refuses to combine the
storage tier with compressed-index pools:
The reason is a real granularity mismatch, not a missing flag. For compressed
k-pools,
_compressed_index_tree_params(sgl-project#38212) inflates the radix-tree pageto
lcm(page_size, pool.page_size * index_kpool)(e.g. 64 → 256) so that onecompressed index row stays atomic: a split inside the group would let children
overwrite a parent's index row that may already be backed up to host memory.
The L3 storage tier, however, hashes keys at tree-page granularity while
transferring one host page per key — with the inflated tree page a single key
corresponds to a span of several physical pages, which the current storage
contract cannot express.
Meanwhile, L3 already works for every other DSA configuration:
[HiCache]: Optimize hybrid/DSA L3 prefetch result sync and usable-prefix clamping sgl-project/sglang#31443, nightly CI coverage [UnifiedTree]: Add nightly hicache ci for dsa model sgl-project/sglang#25348;
The compressed-kpool family is the only gap. This PR closes it with a
"span mode": the radix-tree page becomes the storage page, and one storage
object covers several consecutive host pages.
Design
Storage page size = radix-tree page size.
HiCacheControllergainsstorage_page_size(defaults topage_size). In span mode it equals theinflated tree page, so the controller's hash chain, hit queries, host-index
slicing and token accounting align with the tree's
node.hash_valuebyconstruction — tree-produced backup keys and controller-computed prefetch keys
are the same keys, no translation layer.
Per-pool strides, derived not assumed. One storage key maps to different
per-pool footprints:
PoolTransferKV(anchor)storage_page_sizetoken slots (e.g. 4 × 64)indices_from_pool=KV)The stride is derived from the transfer itself (
numel // len(keys)) insteadof being predicted; KV/KV-derived pools must match
storage_page_sizeexactly,other pools only need a
len(keys)-divisible count. This contract wasvalidated the hard way: the first production run caught Mamba transfers
(one state per tree page) being rejected by an earlier uniform-stride check,
which silently produced KV-only L3 objects that could never satisfy the
component-aware hit check — the derived-stride rule plus a dedicated mixed
KV+state roundtrip test (
test_mixed_kv_and_state_pool_roundtrip) close thathole.
Capability-gated. Backends declare
supports_page_spans;StorageBackendFactory.backend_supports_page_spans()gates both startup(
init_hicache) and runtime attach. The blanket rejection remains forspan-incapable backends — the gate is lifted only where the contract is
actually implemented (file backend in this PR). sgl-project#37122's multi-buffer
_batch_io_v2packing is the natural path for Mooncake to opt in later.No behavior change otherwise. For every non-compressed model
storage_page_size == page_size, spans degenerate to one page per key, andevery touched code path executes its original instructions. For non-span
hybrid file storage the derived stride keeps each pool's natural granularity
(KV: page slots per key; independent state pools: one checkpoint slot per
key), matching pre-PR restore behavior — verified by the non-span mixed
roundtrip.
Implementation
managers/cache_controller.py—storage_page_sizeon the controller;hash chain (
_storage_hit_query), hit accounting, host-index slicing andcompleted-token arithmetic run at the storage page; span-aware generic
page get/set (multi-page dummies, per-page restore) + span propagation in
_generate_storage_config.mem_cache/hybrid_cache/hybrid_cache_controller.py— hybrid_storage_hit_queryaligned to the storage page;backup_skiptokenaccounting counts one storage page per hash key on both sidecar branches
(ok / failed), so backup-token metrics agree across MLA TP ranks.
mem_cache/hicache_storage.py—HiCacheStorageConfig.storage_page_size;supports_page_spanscapability;HiCacheFilespan objects: K host pagesconcatenated on
batch_set_v2, split per page onbatch_get_v2;batch_exists_v2stays key-granular (unchanged).mem_cache/storage/backend_factory.py— capability lookup.mem_cache/unified_radix_cache.py— the gate becomes a capability check;span mode injected at init (live backend patched, since the hybrid stack
attaches its startup backend inside the controller constructor) and at
runtime attach.
test_hicache_file_span.py(new) andtest_hybrid_dsa_hicache.py(rejection tests now use a non-span backend; new acceptance tests cover the
lifted gate for startup and runtime attach).
Relationship to prior work
alignment (the lcm) whose existence makes span mode necessary, and the
L2-only gate whose lift this PR implements. The restore-completeness
discipline from [Bug] GLM-5.3-Flash (DSA): HiCache host-tier load-back corrupts generation even without speculative decoding — dropped tool calls, degenerate repetition loops (8×H100, TP8) sgl-project/sglang#38031/Preserve DSA indexes and recurrent checkpoints in HiCache sgl-project/sglang#38212 is what the adequacy validation below probes.
the unified key namespace digest already includes page size, so spanned and
non-spanned deployments land in disjoint keyspaces. Unified keys are a
natural follow-up on top of span mode.
configuration; this PR covers the non-split compressed path. The
multi-buffer packing from Support Mooncake L3 storage under DSA cache layer split sgl-project/sglang#37122 is the template for Mooncake to declare
supports_page_spans = True.(backends, prefetch sync, CI) that this PR extends to compressed k-pools.
verification signal below.
Validation
Unit tests
HiCacheFile(2 keys × 4 pages, byte-exact restoreafter wiping the host buffers);
regression described above);
_generate_storage_configpath (parallel getters stubbed, every configfield produced by the controller), for both wirings — degenerate non-span
(
storage_page_size == page_size) and span (4x inflation);backup_skipcompleted-token accounting: one storage page per hash on thesidecar-ok branch, zero on the sidecar-failed branch;
batch_exists_v2prefix semantics under spans;get_hash_str(tokens, last_hash, page_size=tree_page)chaining equals per-node tree hashing — the invariant that makes
tree-produced backup keys and controller-computed prefetch keys
interchangeable.
Full
test/registered/unit/mem_cache/suite: identical results to thepre-change baseline (1244 passed; the failure set before and after the change
is byte-identical, i.e. zero regressions; the only delta is this PR's own
tests).
Production validation methodology
Ran live on an 8xH100 fleet node (GLM-5.3-Flash, TP8 / DP4 dp-attention /
EP8, EAGLE 5/1/6, fp8 KV), driven end-to-end through the production API
gateway — first under synthetic staged traffic, then left running on real
production fleet traffic (Stage 5). Validation builds were assembled from
this PR's feature branch; the tree matches the stacked commits including
the stride-derivation fix, which re-lands here as
2cb24950(plus the fp8KV patch, sgl-project#36904). Three deliberate choices shaped the test:
--hicache-size 8shrinks the host pool to670,400 tokens/rank (device pool stays 1,803,584/rank). With production
sizes the first L3 write needs ~16M unique tokens (~25 min of load); with
the test sizes the full write→evict→read cycle completes in minutes, so
bugs surface in minutes too — which is exactly how the Mamba stride bug
was caught live.
the cache-aware router must return repeated prefixes to the node holding
them for any hit to be observable at all.
mode this whole effort guards against (the [Bug] GLM-5.3-Flash (DSA): HiCache host-tier load-back corrupts generation even without speculative decoding — dropped tool calls, degenerate repetition loops (8×H100, TP8) sgl-project/sglang#38031/Preserve DSA indexes and recurrent checkpoints in HiCache sgl-project/sglang#38212 class) is
confident garbage from a partially restored context. A request that
returns HTTP 200 proves nothing; only content-sensitive oracles do.
Stage 1 — startup gate
With
--hicache-storage-backend filethe server initializes withCreating storage backend 'file'on all 8 TP ranks and reaches servingstate. On the pre-fix code this exact combination aborts at init with the
L2 HiCache onlyValueError, so a clean boot is itself a gate assertion.Stage 2 — write path under eviction pressure
1800 unique ~20K-token prompts (~40M tokens) driven through the gateway.
sglang:hicache_host_used_tokensclimbed 0 → 670,400 and pinned at capacity —the steady state where every new page displaces an old one into L3. The
pre-fix run had produced
_write_span indices length mismatch for mamba: expected 256, got 1on all ranks at exactly this stage (the Mamba stridebug, fixed in this PR); post-fix the stage runs clean.
Stage 3 — L3 read path, counted
20 prompts taken from the start of the fill were re-sent. Their eviction is
not assumed but forced and checkable: 9.4M subsequent tokens churned through
per-rank device pools of 1.8M (~2.35M/rank, > capacity) while the host pool
sat pinned at capacity — the re-sent prefixes physically could not survive in
L2.
prefill_effective_tokens_total{mode="storage_hit"}moved0.002M → 0.267M: ~100% of the re-sent prefixes (≈265K tokens) were served
from L3.
storage_prefetch_unfulfilled_tokens_totalstayed 0 for the entiresession. Node logs independently confirm the restore machinery:
completed/matchedare exact multiples of the 256-token tree page — spangranularity is directly observable in production logs.
Stage 4 — answer adequacy (the sgl-project#38212 corruption class, probed directly)
The dangerous failure mode of a wrong restore is not an error — it is fluent
garbage: the model answers confidently from a context with holes. HTTP 200
and even low perplexity prove nothing, so the probe is content-sensitive:
code
XZ-NNNNembedded at depths 10%, 20%, …, 90%, 95% — deliberatelysweeping every span-boundary region, since a partial restore corrupts
regions of the context, not answers uniformly; a ~32K-token context
crosses ~125 span objects;
temperature=0(greedy),max_tokens=256; answers compared acrosscontentandreasoning_content(the model is a reasoning model);can solve the probe at all;
device and host pools (both bounded, both exceeded);
10/10 answers byte-identical to the cold ones, 0 corrupt.
A hole at any span boundary would make the needle at that depth unrecoverable
or the answer incoherent — the probe covers the full depth axis precisely
because restore corruption is positional, not uniform.
Caveat recorded for reproducibility:
prefill_effective_tokens_totalisupdated on scheduler log intervals, so on idle traffic the counters lag the
events; verdicts were therefore anchored on the content oracle and on
counter deltas measured across stages, not on live scrape values.
Stage 5 — production traffic soak (8xH100 node on live fleet load)
After the staged tests the node went back to serving real production traffic
with the production host-pool size restored (
--hicache-size 170→14.48M tokens/rank) and carried the morning fleet load through the gateway.
The cache stack under real traffic:
ranks) with backups and evictions churning continuously — the steady state
span writes are designed for;
(
device_hit+34.8M tokens vs +2.8M cold recompute over a 28-minutewindow); TTFT p90 settled at 1.8 s after warm-up;
storage_hitdoubled within the window) — real request repetition isalready reaching the disk tier, not only the synthetic replay;
stable outputs on repeated requests, no repetition loops, and no malformed
tool calls in agentic traffic — the exact failure classes a partially
restored context produces (the [Bug] GLM-5.3-Flash (DSA): HiCache host-tier load-back corrupts generation even without speculative decoding — dropped tool calls, degenerate repetition loops (8×H100, TP8) sgl-project/sglang#38031/Preserve DSA indexes and recurrent checkpoints in HiCache sgl-project/sglang#38212 class);
monotonic across the soak).
Pending
docker restartwipesdevice + host while the NVMe L3 directory persists; re-running Stage 3/4
after a restart must reproduce the same storage_hit signature. Mechanics
are already proven live; this closes the loop on durability.
supports_page_spans(transport-side multi-buffer packing per Support Mooncake L3 storage under DSA cache layer split sgl-project/sglang#37122) — next PR in the stack.
Dependency / stacking
Based on
pr/hybrid-dsa-hicache-main(sgl-project#38212): the lcm alignment and the gatelive there. fp8 KV (sgl-project#36904) is not required — validation ran on a build that
included it, but the L3 changes are file-disjoint from it.