Skip to content

[unified-memory] Token-major dense views for the unified memory pool (3/7) - #38592

Merged
ch-wan merged 15 commits into
sgl-project:mainfrom
caihuali95:mainline/token-major-dense-views
Oct 2, 2026
Merged

ch-wan merged 15 commits into
sgl-project:mainfrom
caihuali95:mainline/token-major-dense-views

Conversation

@caihuali95

@caihuali95 caihuali95 commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

This is one of seven PRs split out of #38592. That PR was +2105/−1366 across 41
files, twelve of them attention backends on the serving hot path, and it interleaved
hardening, a layout change and dead-code deletion in a single diff — a reviewer could not
tell which hunk was safe-by-construction and which changed behaviour. #38592 is retained as the token-major
layout PR (3/7), which is the core of the series.

Stack — each PR is based on the one above it, and every one is green on its own:

# PR what it does size
1 #40326 derive KV row addresses from strides, not shapes 21 files, +733/−118
2 #40327 build paged KV views through one helper 13 files, +704/−47
3 #38592 token-major layout 36 files, +1215/−1108
4 #40328 mark the write loc physical and check it at every write door 45 files, +935/−132
5 #40329 remove the kernel-page multiplier plumbing 17 files, +117/−230
6 #40330 stride the KV translate kernel and route every translate through it 11 files, +361/−159
7 #40331 name the fused KV translate for what it computes 28 files, +119/−121

Review order is the table order. 1 and 2 are no-ops on today's contiguous pools (2 also
fixes how HND pools with more than one KV head are read); 3 is where the behaviour changes
and deserves the most attention; 4 adds the physical write-loc check; 5 is deletion; 6
consolidates the translate; 7 is naming only.

3/7 — token-major dense views: a kernel-facing id IS the physical id

Base: #40327 (2/7). 15 commits, 36 files, +1215/−1108.

This is the core of the series and the PR that changes behaviour. The other six are
hardening, adoption sweeps, deletion or consolidation around it. If you review one, review
this one — and specifically its layout-switch commit.

Why the layout has to change

Before, one page held a uniform array of row-blocks, one per layer per side:

page bytes = [L0_K * ps | L0_V * ps | L1_K * ps | L1_V * ps | ...]

Every per-layer view was contiguous, and the block origin was folded into its storage
offset, so layer l had to be addressed by a re-numbered id
(t // ps) * (ps * blocks_per_page) + t % ps. Views ran past the last page envelope, which
is what view_tail_pad_bytes sized.

That scheme is only valid when every block has the same row width. The old builder said
so itself, and asserted it:

The page envelope [L0_K*ps | L0_V*ps | L1_K*ps | ...] is a uniform array of
2*layer_num row-blocks when K and V rows are equally wide, so it is a valid paged
pool under kernel_id(t) = (t // ps) * (ps * 2 * layer_num) + t % ps

assert head_dim == v_head_dim, (
    "build_mha_views requires uniform rows (head_dim == v_head_dim); "
    "Asymmetric-KV models cannot use the unified pool (screened out at startup).")

One multiplier, one row shape, one stride can only address a page that is a uniform array of
equal-width blocks. Heterogeneous row widths are not hard under that scheme — they are
unrepresentable.

Now one slot's ENTRY holds every part of that token at a fixed offset:

page bytes  = [ entry(slot 0) | entry(slot 1) | ... ]
entry bytes = [ K_0 | V_0 | K_1 | V_1 | ... ]

so the per-layer view is strided with the entry as its slot stride and is indexed by
page * ps + slot directly.

What that buys

Each part now carries its own geometry — DensePart has its own row_shape,
offset_bytes and layer_stride_bytes; MHASubPoolSpec.entry_bytes() sums k_row_bytes
and v_row_bytes separately. No uniform-row assert survives. DenseEntryLayout.validate()
checks that every part stays inside the entry and that no two overlap, and it requires
alignment instead of equal widths: every row, part offset and layer stride is a multiple of
16 bytes (the store kernels' vector width) and the entry a multiple of 32. A model whose
per-rank K, V or latent row is not a multiple of 16 bytes is refused with a ValueError
naming the row when the unified pool is built.

  1. Asymmetric KV — the layout no longer forbids it. Startup still refuses such models,
    because the backends' read and write paths are not audited for it; what this PR removes is
    the structural obstacle, not the screen.
  2. More parts per entry. A part carries its own row width and its own count, so
    further per-token state — for example a draft model's K and V as extra lanes of the same
    entry — can be added as parts without a new builder. Under a uniform-block page that is
    not expressible at all. Nothing in this series adds such a part.

Three more consequences

  • No re-numbering. kernel_page_multiplier is pinned to 1 (asserted, not read from the
    spec), and translate_kv_loc_for_kernel now computes what translate_kv_loc does. The
    plumbing that carried the multiplier is dead as of this PR and is removed in 5/7 — it
    is left in place here so this PR stays the layout change and nothing else.
  • No tail pad. The views end exactly at the last slot, so the raw buffer is allocated at
    total_bytes.
  • entry_bytes() is rounded up to ENTRY_ALIGN_BYTES in the switch commit rather than in
    the layout types, because it feeds page_bytes(), buffer sizing, the sub-pool anchors,
    move_kv_cache envelopes and PD transfer — the alignment has to flip together with them.

The commits, and where to spend review

commit what review weight
layout-types introduce DensePart / DenseEntryLayout / build_dense_views alongside the old builders. No caller changes. low — additive
layout-switch switch unified_memory_pool.py to them: layout(), entry_bytes() alignment, view_rows = num_pages * ps, raw sizing, the sub-pool multiplier pin, the hook message, and the byte-law tests. the whole behaviour change lives here
layout-cleanup delete the page-major builders, the per-page offset and tail-pad helpers, and the id-space probe that the new layout makes vacuous. low — pure deletion
refuse trtllm_mha decode cuda graphs a startup gate; see trtllm_mha below. low
keep main's test edits the rewritten tests keep main's est_time values and docstrings and do not bring back tests main pruned; the free *_entry_bytes helpers go, and tests derive the entry in closed form. low — tests
drop the pass-through CPU-copy overrides see #39477 below. low — deletion
describe the token-major entry help text, docs and comments that still described the layer-major page. low — text
import the paged-view helpers …, tidy the token-major layout code, take a part name and a slot count … follow 2/7's module move; build_dense_views(raw, layout=, part_name=, num_slots=); comments. low
name the entry-layout module token_major mem_cache/layout/page_major.py → token_major.py, build_page_major_mamba_views → build_mamba_entry_views, and the Mamba view test after the module. Pure rename; the --enable-page-major-kv-layout flag keeps its name. low — mechanical
tag HiCache storage keys … see HiCache storage below. medium
refuse misaligned per-rank rows …, state the backend requirement …, keep the addressing rule in docstrings … the ValueError above; help text and docs that still said the Triton backends are required; shorter docstrings. low

Interaction with #39477 (PD page-envelope transfers)

That PR added _physical_to_kernel_indices to both unified pool classes, re-numbering
physical ids by kernel_page_blocks. Under the token-major entry that formula is the
identity, so the method is removed together with the get_cpu_copy / load_cpu_copy
overrides that called it: the parents' methods take physical ids directly. Verified numerically on both pool
classes at page sizes 1 and 4, including the round trip and that neighbouring slots are
untouched. #39477's own SWA tail-allocation tests (17 CUDA cases) pass under this series.

The one upstream test that asserted the scaled form
(test_swa_transfer_page_is_physical_not_kernel_scaled) is adapted here to assert both
paths are physical, since the multiplier it pins is what this PR removes.

Tests

The view tests are rewritten against the new byte law byte(t, l, K) = t * entry + l * (k + v),
each cross-checked against an independent (page, slot) description of the same bytes at
page sizes 1 and 4, for uniform and asymmetric K/V rows. That independent description is the
safety net for the whole PR: the flat builder's addressing is verified against a second
derivation rather than against itself. test_byte_addresses_match_envelope_formula pins the
same law in closed form, independent of the builder.

Validation

Model families exercised — a hybrid of every pool shape the unified pool supports, plus a
non-hybrid control. Perf covers the gated-deltanet hybrid (Qwen3.5-9B), the MLA hybrid
(Kimi-Linear-48B-A3B), the sliding-window hybrid (gpt-oss-20b), the tri-pool hybrid
(Inkling and Inkling-Small), and dense Llama-3.1-8B as a control. Accuracy covers the same
set and additionally the Mamba hybrid, Falcon-H1-7B. Each model is run unified against a
baseline on the same model, backend and configuration, so every delta isolates the pool.

Median inter-token latency, unified vs baseline: +0.91% (measured run-to-run noise floor
for this metric: 1.9%). Median GSM8K delta at n=200: −0.25 pt (binomial delta-sigma ≈ 3.0 pt).

Re-validated on the previous base (0318a8d) after review feedback: the stack tip (7/7) gives +0.95% median inter-token latency over 40 paired configurations (median per-configuration shift against the previous run of the same 40: +0.12 pp) and −0.25 pt median GSM8K over 26, with the full unit scope green.

The accuracy median excludes trtllm_mha with decode cuda graphs, which is refused at startup
for the reason given below; including it the median is −0.50 pt.

End-to-end on B200

From this commit on, every allowlisted kernel reads per-layer views whose slot stride is a
whole entry. Besides the 2/7 kernel parity tests, each admitted SM100 backend was run with
unified memory against a baseline on the same model, flags and box (GSM8K 5-shot, n=200):

model backend unified baseline
Qwen3.5-9B, tp1, cuda graphs off trtllm_mha 0.915 0.915
gpt-oss-20b, tp1 fa4 0.545–0.555 0.550–0.560
Kimi-Linear-48B-A3B, tp2 trtllm_mla 0.900 0.900
Kimi-Linear-48B-A3B, tp2 cutedsl_mla 0.900 0.910
Kimi-Linear-48B-A3B, tp2, fp8 KV tokenspeed_mla 0.900 0.900
Kimi-Linear-48B-A3B, tp2, dcp2, fp8 KV tokenspeed_mla 0.910 0.910

trtllm_mha with decode cuda graphs is refused at startup under unified memory (below);
without graphs it decodes 9–13% slower than baseline, from the translate launches an eager
step adds. Not covered: fa4 on the gated-deltanet hybrids (fa4 cannot run head_dim 256 with a
paged cache on SM100, with or without this PR) and trtllm_mha at tp > 1.

trtllm_mha decode cuda graphs are refused under unified memory

A defect that predates this series makes trtllm_mha decode under cuda graphs read
untranslated page-table columns: under the unified pool, the page table is refilled out of
graph before each replay, bounded by cache_seqlens_int32, which only the in-graph metadata
kernel writes — so the refill uses the previous replay's lengths. Measured: GSM8K
0.000/0.020 with graphs on against 0.900/0.900 with them off, baseline 0.905/0.900 in both.

Before this PR that configuration could not boot (its prefill path OOM'd copying a whole
sub-pool). This PR makes it bootable, so it also refuses it: --enable-unified-memory with
trtllm_mha as the decode backend and decode cuda graphs on fails at startup, pointing at
--disable-cuda-graph or another decode backend. trtllm_mha as the prefill backend only
still boots. The gate goes once the refill derives its lengths from the batch.

HiCache storage

The unified host pool persists whole page envelopes. Their size does not change with this
PR, but their byte order does (layer-major → token-major), and storage keys recorded only the
model and ranks. Host pools now declare a storage_format_tag; UnifiedPageEnvelopeHostPool
sets one, and the controller appends it to the model name every storage backend keys pages
on. Pages written by an older build, or by a static pool, therefore miss instead of being
read back in the wrong layout. The page hash is unchanged, since it has to match the
router's.


CI States

Latest PR Test (Base): ⏳ Run #36944546351
Latest PR Test (Extra): ⏳ Run #36944545943
Latest PR Test (AMD ROCm 10): ⏳ Run #36944546302

@caihuali95 caihuali95 changed the title [kv-cache] Token-major dense views for the unified memory pool: a kernel id is the physical token id [unified memory] Token-major dense views for the unified memory pool: a kernel id is the physical token id Sep 9, 2026
@caihuali95 caihuali95 changed the title [unified memory] Token-major dense views for the unified memory pool: a kernel id is the physical token id [unified memory] Token-major dense views for the unified memory pool Sep 9, 2026
@caihuali95 caihuali95 changed the title [unified memory] Token-major dense views for the unified memory pool [unified-memory] Token-major dense views for the unified memory pool Sep 9, 2026
out_cache_loc_dsv4: Optional[DSV4OutCacheLoc] = None
# Id space of `out_cache_loc`: "kernel" once a pool's write door may take it,
# set by KVIndexTranslator.rebind_write_loc; capture-time batches declare it.
out_cache_loc_id_space: str = "virtual"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] out_cache_loc_id_space is a non-optional str (default "virtual", set to "kernel" by rebind_write_loc on every real batch). TBO's child builder copies a whitelist of fields and then fails if any parent field is non-None and missing from output_dict (two_batch_overlap.py around the dataclasses.fields(ForwardBatch) loop). This field is not on that whitelist, so --enable-two-batch-overlap will raise Field out_cache_loc_id_space has value, but is not yet supported on the first split, unified memory or not. Even if the completeness check were skipped, the child would default back to "virtual" and the unified write door would treat an already-rebound loc as unmarked.

Suggestion: Copy out_cache_loc_id_space (the parent's value, not the default) into the TBO child dict next to out_cache_loc / out_cache_loc_virtual. A unit case that splits a rebound parent and asserts the child keeps "kernel" would have caught this; TBO's field-completeness loop is the ratchet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. `filter_batch` now copies the parent's value:

```python
output_dict["out_cache_loc_id_space"] = batch.out_cache_loc_id_space
```

You were right that this breaks TBO regardless of the pool: the field is a non-Optional `str`, so the completeness guard fires on every split. It was also worse than latent — five of the existing `batch_overlap` tests were already red on the branch, and our GPU lane could not see it because that directory was not in the lane's test list. Both are now fixed: the lane covers `test/registered/unit/batch_overlap/`, and two cases pin the behaviour (a rebound parent hands `"kernel"` to the child; an untranslated one stays `"virtual"`).

One note for navigation: the field is introduced by #40328 (4/7), so the fix landed there rather than here. Because a cross-fork PR renders the whole stack's union, several of these threads point at code owned by a different PR in the series.

v_cache = v_cache_raw.view(
-1, self.page_size, layer.tp_v_head_num, layer.head_dim
)
k_cache = paged_view(k_cache_raw, self.page_size)

@ch-wan ch-wan Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] — revised; supersedes my original note on this thread. The .view() here does not raise, and the real failure mode is worse than the one I described.

Checked against a unified-shaped per-layer view — (1024, 4, 128) stride (32768, 128, 1) (32 layers, 4 KV heads, hd 128, so entry_elems = 32*2*4*128):

ps=256  .view(-1,256,4,128)  -> (4,256,4,128) (8388608, 32768, 128, 1)
        paged_view(...)      -> (4,256,4,128) (8388608, 32768, 128, 1)
        identical strides; addressing matches the physical token id

Splitting dim 0 into (pages, ps) never crosses the entry stride, so _reshape_paged_kv_cache's .view() succeeds on a strided unified view and, for page_size > 1, returns exactly what paged_view returns. It is not a crash — and the paged_view swap in the fmha_v2 else-branch above is therefore a no-op at page_size > 1.

The problem is that both spellings are stride-silent. Same shape, different strides:

.view(-1,256,4,128)
main (contiguous per-layer view) (131072, 512, 128, 1)
this PR (token-major) (8388608, 32768, 128, 1)

stride(1) — the in-page token stride — goes from H*D to entry_elems, a factor of 2*layer_num (64x here). On main, a kernel that derives that stride from the shape is correct by construction; under this PR it reads bytes 64x too close together, with no exception and no shape mismatch to catch it. A raise would have been the safe outcome.

So the open question is not the spelling, it is whether the trtllm-gen kernel consumes stride(0) / stride(1) at all, or computes the page address from tokens_per_page * num_heads * head_dim. canonicalize_stride sitting on this path is weak evidence that the wrapper reads strides; it says nothing about the kernel. trtllm_mha and trtllm_mla are both on the unified allowlist and neither appears in the reported accuracy or speed grid.

Suggestion: add a stride-sensitivity parity test for trtllm_mha / trtllm_mla — push identical page contents through a contiguous cache and through an over-strided view of the same data, assert equal output — and gate the allowlist on it. Rewriting .view to paged_view does not address this.

Two narrowings, for the record:

  • page_size == 1 is the only place the two spellings differ: .view gives the size-1 page dim stride H*D where paged_view gives entry_elems. Addressing is identical, but after .permute(0, 2, 1, 3) it lands at stride(2), and canonicalize_stride will not repair it — its condition is sizes[i] == 1 and strides[i] == strides[i+1], which is 512 != 1 here, and it only runs when tp_k_head_num == 1.
  • The NVFP4 arm is unreachable under the unified pool: MHASubPoolSpec.layout() declares only k and v parts and no scale parts, so an NVFP4 KV cache is not constructible there. Please disregard my earlier mention of it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for re-checking and correcting the first read — the revised version is the one that matters, and we agree with it. The `paged_view` swap is indeed a no-op at `page_size > 1`; what it buys is only the `page_size == 1` case, where `.view` gives the size-1 page dim the row stride instead of the slot stride.

Your actual point stands: nothing here proves the trtllm-gen kernel reads `stride()` rather than deriving the page address from `page_size * heads * head_dim`. We have added the parity test you describe — `test/registered/attention/test_trtllm_mha_stride_parity.py` (2/7) — which pushes identical page contents through a contiguous cache and through an `as_strided` view with the slot stride widened to a whole entry, runs both through `_reshape_paged_kv_cache` and `trtllm_batch_decode_with_kv_cache`, and asserts `torch.equal` (no tolerance, since a tolerance would hide exactly this).

To be straight about its current value: it is `skipUnless(is_sm100_supported())`, and our validation pool is SM90, so it reports a named skip rather than a verdict there. It will assert in CI on SM100. We have not gated the allowlist on it; if you would prefer `trtllm_mha`/`trtllm_mla` dropped from the unified allowlist until that test has run green somewhere, we are happy to do that.

)
if head_group_num != 1:
# Reinterpret each page's heads as `head_group_num` pseudo-pages.
key_cache = key_cache.view(

@ch-wan ch-wan Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] After get_paged_kv_buffer, the head_group_num != 1 arm still .view()s the 4-D result into extra pseudo-pages. Unlike the trtllm_mha path (see my revised note there — that .view succeeds), this one genuinely throws on a strided unified view, because it splits H and merges the factor into dim 0, which does have to cross the entry stride:

paged (4,256,8,128) stride (16777216, 65536, 128, 1)
  head_group_num=1: OK
  head_group_num=2: RuntimeError: view size is not compatible with input tensor's
                    size and stride (at least one dimension spans across two
                    contiguous subspaces)
  head_group_num=4: RuntimeError (same)
  contiguous, hg=2: OK -> (8,256,4,128) (131072, 512, 128, 1)

MiniCPM is not on the unified allowlist, so this is latent rather than a live boot path — but the helper is now the FA paged-cache door, and the head_group_num == 1 change is exactly what makes the != 1 arm layout-sensitive. Worth noting it is the better-behaved of the two: this arm is unreachable and fails loudly, while the shipping trtllm_mha path fails silently.

Suggestion: rebuild the grouped pages with as_strided from the slot stride, or make the exclusion explicit with assert head_group_num == 1 or key_cache.is_contiguous(). Do not .reshape() — that would pack a copy and drop the entry stride.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced exactly, including your stride figures — `head_group_num` 2 and 4 both raise on the strided view, and the contiguous case gives `(8,256,4,128) (131072, 512, 128, 1)`.

On the suggested fix: we looked at rebuilding with `as_strided` and it cannot express this mapping. The `.view` here is not a head split — it is a flat re-chunk of the (token, head) plane, so walking the new `page_size` axis crosses a slot boundary every `head_group_num` steps. On a contiguous buffer both steps cost the same (`slot_stride == H*D`), which is why one constant stride works; under token-major they differ, and `as_strided` needs one stride per dimension. A head split would be expressible, but only as a 5-D tensor, and flattening it back to 4-D needs `page_size == 1`.

So we took your second option and made the exclusion explicit, with the strides in the message:

```python
assert key_cache.is_contiguous() and value_cache.is_contiguous(), (
f"head_group_num={head_group_num} needs a contiguous paged KV view; ..."
)
```

`is_contiguous()` is exact rather than approximate here: for the paged view it holds precisely when `stride(1) == head_num * head_dim`, which is the condition that makes the re-chunk expressible. Covered by a case that asserts the raise on a strided pool and that `head_group_num == 1` still passes. We did not use `.reshape()`, per your warning. Landed in #40327 (2/7).

id_space: str = "virtual"

@classmethod
def for_batch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] KVWriteLoc.for_batch is the only way a unified pool accepts a write loc under SGLANG_ENABLE_ASYNC_ASSERT. Allowlisted attention backends were migrated, but model-layer writers still pass a bare forward_batch.out_cache_loc (or KVWriteLoc(...) without id_space): deepseek_common/.../forward_mha.py _set_mla_kv_buffer, layers/cp/zigzag.py materialize_full_*, models/dots3_common/modeling.py. After rebind the values are already kernel-facing, so production writes (assert off) are correct; with the assert on, DeepSeek/Kimi MLA + unified memory false-positives. The new test_write_loc_id_space.py only covers the pool door, not these producers.

Suggestion: Switch those call sites to KVWriteLoc.for_batch(forward_batch, ...). The tag is free on static pools (requires_translated_write_loc is false).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and there were more than the three you named. Writing a check to verify your list turned up seven further sites — the ROCm mirrors of the same DeepSeek MLA path (`forward_mha_rocm.py`, `forward_mla_fused_rope_rocm.py`, `forward_mla_rocm.py`), `sarvam_moe.py`, and a second call in `zigzag.py` just below the one you pointed at. Twelve sites across seven files now use `KVWriteLoc.for_batch(forward_batch, ...)`.

You were also right that the existing coverage stopped at the pool door. There is now a completeness check over `srt/models/`, `srt/layers/cp/` and `srt/speculative/` that fails on any write-door call passing `*.out_cache_loc` directly, or any `KVWriteLoc(...)` built without `id_space`. It is scoped to producers reachable under a unified pool — backends whose pools never set `requires_translated_write_loc` are left alone, since the tag is meaningless there.

Landed in #40328 (4/7), which owns the field.


# -- translate (virtual TOKEN ids -> physical TOKEN ids) --

# TODO(unified-memory): fold this onto the fused path. Under the token-major

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The ten-line TODO(unified-memory) restates the current identity of three translate helpers, names callers, and narrates why a rename is unsafe. That is PR/design history sitting on the hot translate path; the load-bearing facts (fused path requires a contiguous loc; torch path wraps a negative index; SWA relies on the -1 handling) are buried in the paragraph.

Suggestion: Keep a two-line TODO with the retirement condition, e.g. do not fold onto _translate_loc_fused until a red test covers negative-loc sink routing and strided out= slices.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the TODO is now gone entirely rather than condensed.

It was describing work that #40331 (7/7) performs: that PR folds `translate_kv_loc` onto the fused path and deletes both `_translate_kv_loc_impl` and `translate_kv_loc_for_kernel`, with the red-first tests the TODO named as its precondition (negative-loc sink routing, and strided `out=` slices via the stride commit that precedes it). With the work done and the helpers deleted, the comment had nothing left to point at, so we removed it at its source rather than shipping it and withdrawing it two commits later.

For navigation: the TODO lived in #40330 (6/7), not here.

@ch-wan ch-wan self-assigned this Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ unified: Unknown test group unified.

Known groups: accuracy, amd, attention, backends, basic_perf, bench_fn, chunked_prefill, constrained_decoding, core, cp, cpu, cuda_graph, dcp, debug_utils, disaggregation, dllm, dp_attn, dp_engine, e2e, ep, eplb, expert_pack, function_call, gb300, hicache, hicache-all, hisparse, input_embedding, kernels, kv_canary, layers, lora, mem_cache, mla, mlx, mock_model, model_loading, moe, musa, npu, observability, openai_server, ops, page_major, perf, pp, prefill_only, profiling, radix_cache, reasoning, rl, rotary, rust, rust-server, sampling, scheduler, scripted_runtime, sessions, spec, stress, tokenizer, unified-memory, unit, utils, vlm, xpu

⛔ memory: Unknown test group memory.

Known groups: accuracy, amd, attention, backends, basic_perf, bench_fn, chunked_prefill, constrained_decoding, core, cp, cpu, cuda_graph, dcp, debug_utils, disaggregation, dllm, dp_attn, dp_engine, e2e, ep, eplb, expert_pack, function_call, gb300, hicache, hicache-all, hisparse, input_embedding, kernels, kv_canary, layers, lora, mem_cache, mla, mlx, mock_model, model_loading, moe, musa, npu, observability, openai_server, ops, page_major, perf, pp, prefill_only, profiling, radix_cache, reasoning, rl, rotary, rust, rust-server, sampling, scheduler, scripted_runtime, sessions, spec, stress, tokenizer, unified-memory, unit, utils, vlm, xpu

@ch-wan

ch-wan commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

/rerun-group unified-memory

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Results for /rerun-group unified-memory:

🚀 1-gpu-h100 (5 tests): ✅ View workflow run

cd test/ && python3 registered/attention/test_gemma4_unified_swa_virtual_ids.py
cd test/ && python3 registered/attention/test_unified_memory_deterministic.py
cd test/ && python3 registered/e2e/models/test_inkling_unified.py
cd test/ && python3 registered/page_major/test_page_major_gpt_oss.py
cd test/ && python3 registered/page_major/test_page_major_qwen_hybrid.py

🚀 2-gpu-h100 (5 tests): ❌ View workflow run

cd test/ && python3 registered/disaggregation/test_disaggregation_unified_memory.py
cd test/ && python3 registered/e2e/disaggregation/test_disaggregation_unified_memory_swa.py
cd test/ && python3 registered/e2e/disaggregation/test_disaggregation_unified_memory_tri.py
cd test/ && python3 registered/e2e/hicache/test_hicache_unified_memory.py
cd test/ && python3 registered/e2e/models/test_kimi_linear_models.py

🚀 4-gpu-h100 (1 test): ✅ View workflow run

cd test/ && python3 registered/e2e/models/test_kimi_linear_unified_memory.py

🚀 4-gpu-b200 (1 test): ✅ View workflow run

cd test/ && python3 registered/e2e/models/test_kimi_linear_unified_memory_dcp_blackwell.py

🚀 ubuntu-latest (32 tests): ✅ View workflow run

cd test/ && python3 registered/unit/disaggregation/test_unified_memory_move_gate.py
cd test/ && python3 registered/unit/layers/attention/test_kv_translate_ownership.py
cd test/ && python3 registered/unit/managers/test_prefill_adder.py
cd test/ && python3 registered/unit/managers/test_scheduler_init_req_max_new_tokens.py
cd test/ && python3 registered/unit/mem_cache/test_dsv4_unified_fp8_pool.py
cd test/ && python3 registered/unit/mem_cache/test_flashkda_strided_state_access.py
cd test/ && python3 registered/unit/mem_cache/test_full_loc_fast_path.py
cd test/ && python3 registered/unit/mem_cache/test_hisparse_allocator.py
cd test/ && python3 registered/unit/mem_cache/test_hisparse_max_token_pool_size.py
cd test/ && python3 registered/unit/mem_cache/test_kda_fused_decode_strided_state.py
cd test/ && python3 registered/unit/mem_cache/test_kv_index_translator.py
cd test/ && python3 registered/unit/mem_cache/test_multi_ended_allocator.py
cd test/ && python3 registered/unit/mem_cache/test_pd_envelope_transfer_layout.py
cd test/ && python3 registered/unit/mem_cache/test_prefill_memory_budget.py
cd test/ && python3 registered/unit/mem_cache/test_swa_locked_full_recover_unified.py
cd test/ && python3 registered/unit/mem_cache/test_unified_byte_accounting.py
cd test/ && python3 registered/unit/mem_cache/test_unified_byte_budget_sizing.py
cd test/ && python3 registered/unit/mem_cache/test_unified_capacity_memo.py
cd test/ && python3 registered/unit/mem_cache/test_unified_free_no_host_sync.py
cd test/ && python3 registered/unit/mem_cache/test_unified_hicache_strided_state.py
cd test/ && python3 registered/unit/mem_cache/test_unified_mamba_views.py
cd test/ && python3 registered/unit/mem_cache/test_unified_mha_views.py
cd test/ && python3 registered/unit/mem_cache/test_unified_mla_views.py
cd test/ && python3 registered/unit/mem_cache/test_unified_npool_sweep.py
cd test/ && python3 registered/unit/mem_cache/test_unified_radix_allocation_eviction.py
cd test/ && python3 registered/unit/mem_cache/test_unified_swa_shared_virtual_ids.py
cd test/ && python3 registered/unit/mem_cache/test_unified_tri_pool.py
cd test/ && python3 registered/unit/model_executor/test_pool_configurator.py
cd test/ && python3 registered/unit/server_args/test_page_major_backend_allowlist.py
cd test/ && python3 registered/unit/server_args/test_unified_prefill_cuda_graph_gate.py
cd test/ && python3 registered/unit/server_args/test_unified_tbo_gate.py
cd test/ && python3 registered/unit/test_model_overrides.py

🚀 1-gpu-5090 (3 tests): ✅ View workflow run

cd test/ && python3 registered/unit/mem_cache/test_unified_handout_zeroing.py
cd test/ && python3 registered/unit/mem_cache/test_unified_hicache_regressions.py
cd test/ && python3 registered/unit/mem_cache/test_unified_mla_gpu_parity.py

@ch-wan
ch-wan force-pushed the mainline/token-major-dense-views branch from 7de746a to 537daa9 Compare September 29, 2026 00:46
@ch-wan

ch-wan commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

/rerun-test test_hicache_unified_memory.py

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Results for /rerun-test test_hicache_unified_memory.py:

🚀 2-gpu-h100 (1 test): ✅ View workflow run

cd test/ && python3 registered/e2e/hicache/test_hicache_unified_memory.py

@ch-wan
ch-wan force-pushed the mainline/token-major-dense-views branch from 537daa9 to 19e77e7 Compare September 29, 2026 01:33
@ch-wan ch-wan removed the run-ci CI: run the baseline test suite on this PR label Oct 1, 2026
@ch-wan
ch-wan force-pushed the mainline/token-major-dense-views branch from 19e77e7 to 00a48b8 Compare October 1, 2026 06:21
Caihua Li and others added 15 commits October 1, 2026 17:08
Introduce the description of a token-major slot entry, alongside the existing
page-major builders and used by nobody yet:

  DensePart          one row family inside the entry (K, V, or the MLA latent):
                     layer_num rows of row_shape, layer l at
                     offset_bytes + l * layer_stride_bytes.
  DenseEntryLayout   the entry's byte map -- its parts plus entry_bytes, which
                     is the slot stride of every view built over it. validate()
                     rejects overlapping parts, an unaligned offset/stride/row,
                     and any part reaching outside the entry.
  build_dense_views  per-layer views of one part: a strided
                     (num_pages * page_size, *row_shape) tensor indexed by the
                     PHYSICAL token id, with the whole entry as the slot stride.

Two alignments come with it: ENTRY_ALIGN_BYTES pads a slot so entries stay
vector-store aligned, ROW_ALIGN_BYTES is what validate() holds each part to.

Parts may differ in row width -- only their offsets differ, never the stride --
which is what lets one entry hold K and V rows of different widths. The
page-major builders cannot: they lay out one uniform row-block array per page
and address it by a re-numbered kernel-facing id.

build_page_major_mamba_views already had a nested contiguous_strides identical
to the one build_dense_views needs, so it is hoisted to module scope as
_contiguous_strides rather than copied.
Switch the unified pool's byte law from the page-major envelope to the
token-major entry, so that a kernel-facing id IS the physical token id.

Before, one page held a uniform array of row-blocks, one per layer per side:

    page bytes = [L0_K * ps | L0_V * ps | L1_K * ps | L1_V * ps | ...]

Every per-layer view was contiguous, and the block origin was folded into its
storage offset, so layer l had to be addressed by a re-numbered id
`(t // ps) * (ps * blocks_per_page) + t % ps`. Views ran past the last page
envelope, which is what view_tail_pad_bytes sized.

Now one slot's entry holds every part of that token at a fixed offset:

    page bytes  = [ entry(slot 0) | entry(slot 1) | ... ]
    entry bytes = [ K_0 | V_0 | K_1 | V_1 | ... ]

so the per-layer view is strided with the entry as its slot stride and is
indexed by `page * ps + slot` directly. Three consequences:

  * No re-numbering. kernel_page_multiplier is pinned to 1 (asserted, not
    read from the spec), and translate_kv_loc_for_kernel now computes what
    translate_kv_loc does. The plumbing is removed in a later commit.
  * No tail pad. The views end exactly at the last slot, so the raw buffer is
    allocated at total_bytes.
  * Parts may differ in row width. Only the startup screen still refuses
    asymmetric K/V, because the backends' write and read paths are not
    audited for it -- not because the views cannot hold it.

entry_bytes() is rounded up to ENTRY_ALIGN_BYTES here rather than in the
layout types: it feeds page_bytes(), buffer sizing, the sub-pool anchors,
move_kv_cache envelopes and PD transfer, so the alignment has to flip
together with them.

The view tests are rewritten against the new byte law
`byte(t, l, K) = t * entry + l * (k + v)`, each cross-checked against an
independent (page, slot) description at page sizes 1 and 4.

test_hicache.py's unified L2 case built its pool with 8 B rows (fp16, one head
of width 4), below the 16 B row alignment every token-major part needs; it
now uses 16 B rows, the smallest the layout admits. The entry, and so each
page the test copies, stays 32 B.

fa4, trtllm_mla, cutedsl_mla and tokenspeed_mla are admitted under
--enable-unified-memory and supported by this change, but not yet validated.
From this commit on their kernels read per-layer views whose slot stride is
a whole entry, and every validation run was on H20 (SM90), where those
kernels do not run. The same holds for trtllm_mha's SM100 kernels
(trtllm-gen context and decode) and for decode context parallelism
(--dcp-size > 1). Reviewers with SM100-class hardware: please run them
before merging.
Nothing reads any of it once the unified pool stores token-major; each symbol
below has zero remaining references:

  build_mha_views / build_mla_views   superseded by build_dense_views.
  SubPoolSpec.view_tail_pad_bytes     the views no longer overrun the last
                                      page, so there is no pad to size.
  SubPoolSpec.blocks_per_page         kernel-facing ids are physical ids, so
                                      there is no per-page block scale.
  KVCache.kernel_page_blocks          same, and it was the probe's only input.
  maybe_detect_kernel_facing_loc      the probe told a kernel-facing id from a
                                      virtual one by its in-page offset. With
                                      one id space left there is nothing to
                                      separate, and it was vacuous at
                                      blocks_per_page 1 anyway. The OOB probe
                                      at the same write doors is unchanged.

The unified page-envelope host pool had one caller of `kernel_page_blocks`:
it divided the ids it is handed by the kernel-view page stride to recover
physical slots. Those ids are physical here, so the conversion goes, and
its unit test reads pages straight off them.
…nified pool

Under the unified pool, trtllm_mha refills its cuda-graph page table out of
graph, before each replay, bounded by `cache_seqlens_int32`. That buffer is
written only by the fused metadata kernel recorded inside the graph, so at
refill time it still holds the previous replay's lengths: once a sequence
grows past them, the kernel reads page-table columns that were never
translated. Decoding then produces garbage without any error (GSM8K drops
to ~0 with graphs on and is at baseline with them off).

Until the refill derives its lengths from the batch, refuse the
combination at startup and point at `--disable-cuda-graph` or another
decode attention backend. trtllm_mha as the prefill backend only, or with
decode capture disabled, still boots.
The token-major test rewrites had been made on an older copy of these
files and undid later upstream edits along the way. Restore them, keeping
only the changes the layout itself requires:

- `est_time` goes back to main's values in the four retimed files;
- the tests pruned as redundant stay pruned: `TestMHASpecLayerOffsets` and
  `test_page_bytes` (both restate `test_layout_parts_match_the_entry_formula`
  and a one-line formula), the write-loc `..._without_handover` case, and
  the MLA `test_entry_bytes_and_dim` and `test_kernel_id_follows_compaction`
  (the view-addressing and `test_kernel_id_is_the_physical_id` cases pin the
  same things). The move readback is folded back into
  `test_move_kv_cache_moves_page_envelopes`, as on main;
- docstrings keep main's wording where the layout does not change their
  meaning (`reads_are_translated`, not `enabled`; no plain page-major arm
  and no `cutlass_mla` in the allowlist docstring), and drop the retired
  2L multiplier regime and the one-element loop it left behind.

Also delete `mha_entry_bytes` / `mla_entry_bytes`: nothing outside the
tests called them, and a test comparing the spec against a second copy of
the same formula checks nothing. The tests now derive the entry size in
closed form from the row sizes and `ENTRY_ALIGN_BYTES`.
With the kernel-to-physical id rewrite gone, `get_cpu_copy` and
`load_cpu_copy` on `UnifiedMHATokenToKVPool` and `UnifiedMLATokenToKVPool`
only forwarded to their parents. The parents already accept
`mamba_indices` / `req_pool_index` and ignore them, so delete all four and
let the inherited methods serve the unified pools.
…till said layer-major

The token-major layout made several descriptions false:

- the `--enable-page-major-kv-layout` help and the server-arguments page
  said a page is "layer-major within a page"; a page is now page_size token
  entries, each holding every layer's K/V;
- `_reserved_floor_bytes` and the matching `UnifiedKVPool` comment justified
  reserving all of page 0 by saying a slot-0 write touches layer blocks
  spread across the page. It touches only slot 0's entry; the whole page is
  reserved because page-aware sub-pools allocate whole pages;
- `handle_page_major_kv_layout` described the unified pool as the page-major
  envelope layout and kept an allowlist arm for plain page-major, which the
  assert above it already rejects. Drop the dead arm and its comment.
The token-major view tests use `paged_view` / `paged_row_view`, which now
live in `mem_cache/layout/paged_view.py`. Import lines only.
- MHASubPoolSpec: the entry diagram documents `layout()`, not a gap
  between two methods.
- _assert_kernel_id_bound: ids no longer scale with the layer count, so
  stop suggesting it as a remedy.
- build_dense_views: give the itemsize assert a message; drop the
  per-layer offset assert that validate()'s row alignment already implies.
- page_major: say that the Mamba views use the same per-slot entry.
- MultiEndedAllocator: drop the note about future plumbing removal.
…dense_views

`build_dense_views` took the part itself next to the layout it must come
from, so the two could disagree, and `page_size` / `num_pages` only to
multiply them: a token-major view is `num_slots` entries long whatever the
page size. Take `part_name` (looked up in `layout`) and `num_slots`.
After the switch to per-slot entries, `mem_cache/layout/page_major.py` holds
the token-major entry types (`DensePart`, `DenseEntryLayout`,
`build_dense_views`) and the Mamba per-slot views; nothing in it lays pages
out layer-major any more. Rename it to `token_major.py`,
`build_page_major_mamba_views` to `build_mamba_entry_views` (next to
`mamba_entry_bytes`), and the Mamba view test after the module. Pure rename:
no body changes. The `--enable-page-major-kv-layout` flag keeps its name.
…ormat

The unified host pool persists whole page envelopes. Their size is the same
under the old layer-major layout and the token-major one, and storage keys
record only the model and ranks, so a page written by an older build (or by a
static pool) would be read back as a token-major page without any error.

Give host pools a `storage_format_tag` (None by default) and set it on
`UnifiedPageEnvelopeHostPool`. The controller appends it to the model name
every storage backend namespaces its keys with, so pages of another format
miss instead of loading. The page hash is untouched: it has to stay equal to
the router's.
…s enforce

The `--enable-page-major-kv-layout` and `--enable-unified-memory` help and
the server-arguments page still said the Triton attention, linear-attention
and Mamba backends are required, and the page also said unified memory
supports neither PD disaggregation nor speculative decoding. Say what holds:
the backends must be stride-aware, and the allowed set and the supported
PD / speculative configurations are checked at startup. The page-major flag
is only accepted together with unified memory.
The token-major entry lays every part out in 16-byte units, so a model whose
per-rank K, V or latent row is not a multiple of 16 bytes cannot use the
unified pool. That surfaced as a bare assertion inside
`DenseEntryLayout.validate()`. Check it where the sub-pool builds its layout
and raise a `ValueError` naming the sub-pool, the row and the way out; the
assertion stays as the layout's own invariant.
…he retelling

The token-major module docstring now states the entry and the byte formula
once; the unified pool classes point at it instead of restating the layout,
and the trtllm_mha gate comment says what is refused and why in three lines.
@ch-wan
ch-wan force-pushed the mainline/token-major-dense-views branch from 00a48b8 to 9423810 Compare October 2, 2026 00:09
@ch-wan
ch-wan merged commit cdadb1c into sgl-project:main Oct 2, 2026
29 of 39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blackwell SM100/SM120 bypass-fail-fast CI: a failing job no longer aborts its siblings (lint still gates) documentation Improvements or additions to documentation hicache Hierarchical Caching for SGLang jit-kernel memory-pool parallel-stages CI: stages dispatch together instead of waiting on each other

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants