Skip to content

Unified Full KV Cache Layout in L3 - #33651

Open
Shunkangz wants to merge 12 commits into
sgl-project:mainfrom
Shunkangz:unified_kv_l3
Open

Shunkangz wants to merge 12 commits into
sgl-project:mainfrom
Shunkangz:unified_kv_l3

Conversation

@Shunkangz

@Shunkangz Shunkangz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Motivation

HiCache's L3 tier names every stored KV object by who wrote it: each backend appends its own rank/topology suffix (mooncake {hash}_{tp_rank}_{pp_rank}_k, file {hash}_{model}_{tp_rank}_{tp_size}_{pp_size}_{pp_rank}_cp{r}_{s}.bin, nixl/eic/hf3fs/simm/umbp each different again). This has two costs:

No cross-topology reuse, by construction. A page written by one parallel configuration is invisible to any other, even when the reader needs exactly the bytes that are already stored (MLA KV is bit-identical on every TP rank; a TP4 reader's head shard is exactly half of a TP8 writer's two objects).

Modifications

New Server Args

--hicache-storage-key-scheme unified switches L3 keys from rank-suffixed names to a topology-free coordinate:

{page_hash}_{namespace_digest}_L{start_layer}-{end_layer}[_H{head_group_index}]_{k|v}
Field Meaning
page_hash The existing chained SHA-256 over token ids — already topology-free.
namespace_digest Digest of a KVCacheNamespace descriptor: model id, logical KV dtype, page size, head grid, layer grid, object byte order, optional numerics id. Any mismatch lands in a disjoint keyspace, so deployments miss instead of colliding.
L{start}-{end} The rank's absolute layer range. Any PP partition attaches, uneven stages included (e.g. 61-layer models); different partitions derive disjoint keys.
H{j} The canonical kv-head-group index from the descriptor. Omitted for MLA-family pools, which replicate KV across ranks and have no head axis.
`{k v}`

Use --hicache-storage-backend-extra-config to partition a page into finer-grained chunks. The example below makes 1 kv head x 6 layers the smallest independently stored unit:

--hicache-storage-backend-extra-config '{"head_group": 1, "layer_partition": 6}'

Both entries are fleet-wide agreements, not local tuning options: they fix chunk boundaries, and boundaries are part of the namespace identity. Every member of a fleet that should share cache must pass the same values. Pick head_group = total_kv_heads / lcm(the fleet's attn-TP sizes).

Layout Support

The L2 host memory pool supports four layouts:

layer_first:        (2, layer, page * page_size, head, dim)
page_head:          (2, page, head, page_size, layer, dim)
page_first:         (2, page * page_size, layer, head, dim)
page_first_direct:  (2, page, layer, page_size, head, dim)

With the unified scheme enabled we serve page_first_direct only, overriding --hicache-mem-layout. It is the layout friendliest to both head and layer partitioning: a chunk is one contiguous run, so the transport moves it with a single descriptor instead of one per (layer, token). With a head partition the page block becomes head-group-major:

page_first_direct_group: (2, page, head_group, layer, page_size, head_in_group, dim)

A layer partition alone leaves the data naturally contiguous, so the copy engine still works. A head partition fragments it, which the copy engine handles poorly, so the kernel path is selected automatically. To avoid a host-side repack we added a transfer kernel that converts KV cache between GPU and CPU directly.

Follow Up

  • This PR supports the Mooncake store only, since it stores multiple keys per page natively. HiCache File support is a follow-up.
  • Extend the unified layout to Mamba state.

Accuracy Tests

We do a small E2E test based on Qwen3-8B-FP8 model with 36 layers and 8 KV heads and compare the accuracy on GSM8K dataset.
The launch command

python3 -m sglang.launch_server \
    --model-path /model \
    --host 0.0.0.0 \
    --port 32140 \
    --attention-backend fa4 \
    --page-size 128 \
    --fp8-gemm-backend flashinfer_trtllm \
    --enable-hierarchical-cache \
    --hicache-write-policy write_through \
    --hicache-io-backend kernel \
    --hicache-mem-layout page_first \
    --hicache-size 64 \
    --hicache-storage-backend mooncake \
    --hicache-storage-key-scheme unified \
    --hicache-storage-backend-extra-config \
      '{"head_group":1,"layer_partition":6,"prefetch_threshold":1}' \
    --hicache-storage-prefetch-policy wait_complete

With unified kv cache layout, we can achieve the same 94.5% accuracy as origin.

Speed Tests and Profiling

Measured on one B200 over PCIe Gen5 x16 — 61 layers, head_dim 128, page_size 64, 2048 tokens (~488 MiB per direction at H=8), bf16. H is kv heads, HG head groups, and run B the resulting contiguous run per pass:

H HG run B H2D @2 CTA D2H @2 CTA H2D @32 CTA D2H @32 CTA
8 1 2048 13.6 34.2 49.9 52.4
8 2 1024 13.6 33.4 49.7 52.4
8 4 512 13.4 31.6 49.5 52.5
8 8 256 12.8 29.4 49.8 52.4
16 1 4096 13.9 35.2 50.6 52.5
16 8 512 13.4 31.7 50.4 52.5
16 16 256 12.8 29.8 50.5 52.5

All figures GB/s. Cutting the head axis costs little — at 32 CTAs the run size falls 8x with no measurable change, and even at 2 CTAs the loss is under 15%. What sets the bandwidth is the CTA count, which block_quota caps. We keep the default at 2, matching the existing kvcacheio kernels, so KV copies contend minimally with model forward for SMs; the cost is that they run well below link speed. Raise block_quota per call site to trade SM occupancy for bandwidth.

Checklist

Review and Merge Process

  1. Ping Merge Oncalls to start the process. See the PR Merge Process.
  2. Get approvals from CODEOWNERS and other reviewers.
  3. Trigger CI tests with comments or contact authorized users to do so.
    • Common commands include /tag-and-rerun-ci, /tag-run-ci-label, /rerun-failed-ci
  4. After green CI and required approvals, ask Merge Oncalls or people with Write permission to merge the PR.

CI States

Latest PR Test (Base): ✅ Run #34124794944
Latest PR Test (Extra): ❌ Run #34124794759
Latest PR Test (AMD ROCm 7.2): ❌ Run #34124794947

Shunkangz and others added 5 commits August 31, 2026 20:19
Introduce --hicache-storage-key-scheme {rank-suffix, canonical-grid}. Under
canonical-grid, L3 storage keys carry a topology-free canonical cell
coordinate ({ns_digest}_L{i}[_H{j}]) instead of the per-backend
rank/pp/cp suffixes, so keys name what an object holds (a model-global
layer-range x kv-head-range rectangle of one page) rather than who wrote
it. The namespace identity is an out-of-band msgspec descriptor (JSON file
via --hicache-storage-namespace-descriptor, or derived from the deployment);
its digest prefixes every key, so mismatched descriptors partition into
disjoint keyspaces instead of colliding.

v1 scope: one cell per rank per page (grid == deployment shard shape), so
objects stay byte-identical to the rank-suffix scheme; file and mooncake
backends; plain KV pools only. Rank-replicated (MLA-family) pools share
keys across TP sizes immediately; PP partitions can no longer collide
(absolute layer-group index replaces pp_rank). Finer grids (head/layer
fan-out, subsuming tp_lcm_size split-heads), CP, draft pools, and hybrid
side pools are rejected fail-fast at attach with the remedy in the error.

Design: DESIGN_l3_canonical_shard_grid.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review direction, the staging machinery moves out of MooncakeStore
into a CellAdapter class in hicache_key_scheme.py, so other L3 backends
(nixl/eic/...) can serve partitioned cell-v1 namespaces without
reimplementing it. The adapter owns everything backend-independent:

  - per-cell key fan-out (cell_keys; single _k object for rank-replicated
    cells, _k + _v for sharded ones) in the pools' exact slab order,
  - arena-sized sub-batching (sub_batches; arenas reused from offset 0),
  - the two pinned staging arenas (one per IO direction; skipped entirely
    when every slab is pool-contiguous), with an overridable _alloc_arena
    seam and an optional register_buffer hook for RDMA transports,
  - gather / read_metas / scatter plumbing to the host pool primitives.

A backend integrates by supplying only its pointer-based batch put/get;
mooncake keeps just its own concerns (exists-filter, group ids, metrics,
dispatch). Behavior is unchanged.

Also: drop DESIGN_l3_canonical_shard_grid.md from the branch (PR ships
code + description only) and the two dangling references to it; guard
sub_batches against empty batches (was a latent ValueError on all-direct
pools). Both found by adversarial review.

Tests: TestCellAdapterStaging drives the adapter exactly as a backend
would — raw pointers, a dict as the store — covering the no-arena
all-direct case and a staged cross-layout round trip with one-page
sub-batches (cell_arena_mb=0). 53 tests pass in the dev container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename the scheme's vocabulary per review feedback — "canonical" and
"cell" were jargon; the names now say what things are:

  --hicache-storage-key-scheme unified      (was canonical-grid)
  KVCacheLayoutAdapter                      (was CellAdapter)
  UnifiedKVPlan / plan_unified_kv           (was CanonicalCellPlan /
                                             plan_canonical_cells)
  build_unified_suffixes                    (was build_canonical_cell_
                                             suffixes)
  unified_suffix / unified_layer_ranges /   (was canonical_*)
    unified_head_ranges
  object_layout "unified-v1"                (was "cell-v1")
  gather/scatter_unified_chunks,            (was *_cells_canonical,
    get_unified_chunk_meta                   cell_read_metas)
  unified_zero_copy / unified_bytes_per_page (was cells_all_direct /
                                             cell_bytes)
  staging_buffer_mb knob, staging_set/get   (was cell_arena_mb, arena_*)

Prose, log lines, and error messages follow ("the unified key scheme
..."), with the per-page pieces now called chunks and the arenas called
staging buffers. Pure rename — no behavior change; keys and digests are
unaffected except object_layout's constant, which is an identity string
of the unreleased scheme. Pre-existing unrelated uses of "canonical"/
"arena" (CP resolution, kv_vmm_backing) are untouched. 53 tests pass in
the dev container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… order

The adapter stored every chunk as (head, layer, token, dim) per K/V half --
the byte order of a host layout that does not exist. Measured consequence: an
MHA chunk decomposed into head_dim-sized fragments (256 B at D=128 bf16) on
every shipped host layout, so all of them staged 100% of their bytes through
the pinned buffer and none could be picked to avoid it.

Three changes:

1. Serialize a chunk as (layer, token, head, dim) instead -- page_first_direct's
   own page block. A chunk covering the rank's whole head shard is then a single
   contiguous range and transfers straight from pool memory, for any layer
   partition including a model's short trailing chunk at a prime layer count
   (DeepSeek-V3's L=61). Cross-PP reuse needs no padding and no special case.
   MLA is unaffected: no head axis, and its order was already
   (layer, token, dim) -- the flip generalizes the case the scheme already got
   right instead of trading it away.

2. Support only page_first and page_first_direct. _page_kv_view_unified drops
   to two branches per pool; layer_first and page_head fail fast at plan time.

3. Keep the host layout in the namespace identity: object_layout becomes
   "unified-v2:{layout}", so page_first and page_first_direct never share
   objects (their page blocks are byte-permuted), and adapter chunks never
   collide with the raw-layout objects an unpartitioned deployment writes for
   the same layout -- which the previous layout-neutral constant could do when
   a knob happened to be a no-op on the grid.

Load path: for the zero-copy configurations get_unified_chunk_meta resolves
every read target to an address inside the pool, so the transport writes L3
bytes exactly where the H2D kernel will read them, the scatter is a no-op, and
the device transfer runs unchanged -- no second copy between CPU and GPU.
Pinned by test_load_lands_in_the_pool_with_no_second_copy.

What still stages: cutting the kv-head axis. A head subgroup breaks contiguity
at the token axis on both layouts, so cross-TP GQA reuse keeps today's
conversion. A single linear order can make only one axis outermost; this one
buys the layer axis -- the axis page_first_direct stores, and the only axis MLA
has. Removing that copy too needs an offset-computing H2D kernel, not a layout
change.

Adds test/registered/unit/mem_cache/test_hicache_unified_layout_perf.py
(CPU, base-a-test-cpu, ~12 s): prices gather/scatter and the
batch_set_v1 / batch_get_v1 round trip per layout x fan-out, sweeps the
head-split penalty, and proves the span-based copy-free alternative reproduces
the staged bytes exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/tag-and-rerun-ci

@Shunkangz
Shunkangz force-pushed the unified_kv_l3 branch 3 times, most recently from 28379ee to ca2801d Compare September 4, 2026 03:16
@Shunkangz Shunkangz changed the title Unified KV Cache Layout in L3 Unified Full KV Cache Layout in L3 Sep 4, 2026
Resolve hicache_hook.handle_hicache: main added the external-linker mode gate
where we added L3 key-scheme validation. Ours runs first -- it is a no-op for
the default rank-suffix scheme, so external-linker configs are unaffected,
while a --hicache-storage-key-scheme unified flag still errors instead of
being silently ignored under a mode that has no L3.
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/tag-and-rerun-ci

1 similar comment
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/tag-and-rerun-ci

@Shunkangz

Copy link
Copy Markdown
Contributor Author

/rerun-failed-ci

@Shunkangz

Copy link
Copy Markdown
Contributor Author

/rerun-failed-ci

1 similar comment
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/rerun-failed-ci

Shunkangz and others added 3 commits September 6, 2026 22:31
Conflict: python/sglang/srt/server_args.py

main's "Config Round 6.x" refactor (sgl-project#38046-sgl-project#38113) moved every field
declaration out of ServerArgs into per-namespace classes under
arg_groups/fields/, shrinking server_args.py from 4455 to 1087 lines. The
branch's only change to that file was the hicache_storage_key_scheme
declaration, so the file takes main's version and the field moves to
arg_groups/fields/memory.py, keeping its position after
hicache_storage_prefetch_retry_max_attempts. NS("memory") is dropped: the
declaring module is now the namespace.

The field is deliberately NOT added to field_order.py. That record freezes
ServerArgs' positional constructor signature; an unlisted name sorts after
every listed one, which is the only backward-compatible slot for a new field.

Consumers needed no change: hicache_hook.py already reads through
resolving_view() and cache_controller.py through get_memory(), both of which
are unaffected by where the declaration lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in sgl-project#38280, which removes the stale is_cp_v2_active mock patches
from test_deepseek_nextn_mm_embed.py. The previous sync landed at
e24e31e, inside the 86-minute window where sgl-project#36228 had removed
is_cp_v2_active from deepseek_nextn.py but the test had not yet been
updated.
Brings in sgl-project#38314, which repairs the DSpark dp-tier test fixture after
sgl-project#34919 added self._num_token_non_padded to DraftBlockProposer. PR CI
tests refs/pull/N/merge against live main, so it picked up sgl-project#34919
during the 100-minute window before sgl-project#38314 landed.
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/rerun-failed-ci

1 similar comment
@Shunkangz

Copy link
Copy Markdown
Contributor Author

/rerun-failed-ci

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation hicache Hierarchical Caching for SGLang high priority run-ci sgl-kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants