Keep CP shared-KV cache hits page-aligned without short fallback

CP shared KV and HiCache need a stable contract where physical cache coverage is page-aligned, while scheduler/radix-visible hit length remains the valid token length. This records the contract, adds page-aligned extent metadata, keeps owner assignment on actual tail pages instead of short-prefix fallback, and updates partial current reuse tests around tail-page masking.

Constraint: CP owner lanes operate on page units while scheduler and radix hit accounting must remain token-valid.

Rejected: Pad short suffixes to cp_size or 2*cp_size pages | wastes KV capacity and can turn a small tail into a much larger physical span.

Rejected: Silent direct-write or prefetch fallback | production fallback must be warning-visible for diagnosis.

Confidence: medium

Scope-risk: moderate

Directive: Do not reintroduce replicated short-radix fallback without checking docs/advanced_features/nsa_prefill_cp_page_aligned_cache_contract.md.

Tested: local py_compile for touched runtime, utility, owner, and unit-test files.

Tested: remote g0034 container three-file suite: 122 passed, 5 warnings.

Not-tested: full local pytest, blocked by missing runtime dependencies such as orjson.

Not-tested: CUDA E2E runtime for this commit.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
laoyao0822
2026-05-29 05:04:50 +08:00
parent 1e1807f2bc
commit c551623ca8
11 changed files with 1369 additions and 127 deletions

View File

@@ -326,7 +326,7 @@ Minimum tests before enabling true batch > 1:
- two cache-hit requests with different prefix lengths;
- page-aligned and non-page-aligned suffixes;
- short radix-hit suffix with replicated compute path;
- short radix-hit suffix with zero-owner-page segments;
- HiCache host hit + load-back;
- draft/MTP enabled with target/draft KV mirrored;
- compare batch > 1 output against sequential batch-size-1 execution;

View File

@@ -0,0 +1,847 @@
# NSA Prefill CP Page-Aligned Cache Contract
This note records the contract we need before continuing MLA/index partial
reuse under CP shared KV + HiCache + EAGLE/MTP.
The immediate trigger was a production hang where the prefill process stopped
after target-layer MLA partial current reuse:
```text
/mnt/beegfs/cjy/sglang_cp_hicache_20260528_161408.log
layer=2
prefix_lens=[13184]
extend_lens=[37024]
page_size=64
prefix_pages=206
suffix_slots=579
current_rows=37024
kv_rows=87328
used_prefetch=True
```
`suffix_slots * page_size = 579 * 64 = 37056`, but only `37024` current
rows were appended. The fast path consumed prefetched prefix pages and appended
only real current rows, so the last suffix page had `32` rows of page slack with
no explicit valid/invalid contract.
The target design is **not** to fall back for these short or unaligned suffixes.
Instead, all physical cache paths should share one page-aligned contract while
logical token semantics stay unpadded.
## Contract
Use two lengths everywhere the path crosses a logical/physical boundary:
```text
valid_tokens = real request/cache tokens
padded_pages = ceil(valid_tokens / page_size)
padded_tokens = padded_pages * page_size
padding_tokens = padded_tokens - valid_tokens
```
### Logical layer: always valid-token based
The following must continue to use `valid_tokens`:
- request token length,
- `seq_lens_cpu`,
- `extend_seq_lens_cpu`,
- radix tree key/logical node length,
- cache-hit logical length exposed to scheduler,
- decode position and max-context checks,
- EAGLE/MTP verification token count,
- attention-visible sequence length.
Padding tokens must not become request tokens.
### Physical layer: always page-aligned
The following should use `padded_pages` / `padded_tokens`:
- GPU KV pool allocation and owner-lane capacity,
- draft KV mirror allocation,
- HiCache host reservation,
- backup/load/evict accounting,
- disaggregated target/draft transfer page lists,
- CP shared-KV MLA/index dense materialization buffers,
- CP owner-lane capacity planning and snapshots.
Padding rows may exist in temporary dense buffers or physical pages, but they
must be invisible to attention and radix semantics.
### Visibility rule
Every remap path must preserve this invariant:
```text
logical loc maps to a dense row only if it is a real valid token.
padding/slack locs map to -1.
```
Padding KV rows can be zero-filled. Correctness comes from masking/remapping
them out, not from their value.
### Kernel ownership
Production hot paths should use `tai-kernel` for page-padded compose/remap and
copy-style operations. Python/Torch implementations are acceptable as CPU unit
test references and explicit warning fallbacks only; they should not be the
normal CUDA runtime path.
For MLA partial-current reuse, the intended runtime primitive is:
```text
preallocated slot-dense KV pages
+ current suffix KV copied into its slot-dense rows
+ zero padding rows left invisible
-> same dense KV buffer + mixed loc remap + current mask
```
This should be one `tai_kernel.nsa_prefill.cp_shared_kv_materialize` primitive,
not a sequence of `torch.sort/searchsorted/cat/zeros` operations in the
attention hot path. If the TAI primitive is unavailable, SGLang must emit a
visible warning fallback rather than silently taking the slow path.
The TAI primitive should extend the existing materialize/remap kernel family:
- copy each valid `current_locs[i]` row into `page_inverse[page] * page_size + offset`;
- build a current-page inverse from page-grouped current locs;
- keep real current locs visible by exact loc check;
- map non-current rows inside current pages to `-1`.
This avoids the previous compact-current append path where tail-page slack had
slot-dense locs but no valid current KV row.
## Root-cause record: unsafe partial-current prefetch
The current MLA partial-current prefetch path has a narrower contract than the
page-level metadata it consumes.
Evidence:
- `cp_shared_kv_prefetch.py::consume_prefix_with_current()` says the prefetched
prefix is already materialized while current/suffix pages are not copied from
the shared pool; it then appends `current_kv_cache` directly.
- `cp_shared_kv_runtime.py::merge_materialized_and_current_kv()` only remaps
entries present in `current_locs`; all non-current entries keep the existing
`materialized_dense_locs`.
- `nsa_backend.py` calls `consume_prefix_with_current()` whenever an MLA
prefetcher exists on a mixed prefix/current batch, before falling back to the
older partial materialize path.
That is safe only when the page-level suffix represented by the page table is
exactly covered by `current_locs`. It is not safe when:
```text
ceil(extend_len / page_size) * page_size > extend_len
```
The older non-prefetch partial path is safer because it materializes all
non-current logical locations and only substitutes rows that match
`current_locs`.
The fix should not be a permanent fallback. The intended fix is to make the
prefetch compose path create a page-padded current suffix buffer and map suffix
slack to `-1`.
## Existing evidence in the codebase
### Page-aligned split already treats tail as a page unit
`build_page_aligned_in_seq_split_list()` computes:
```python
full_pages = extend_len // page_size
tail_tokens = extend_len % page_size
num_page_units = full_pages + (1 if tail_tokens > 0 else 0)
```
The Phase 4 doc explicitly states that the tail unit is not split across CP
segments. However, the current `split_list` still sums to `extend_len`, not
`padded_tokens`. That is correct for compute tokens, but it means later cache
paths must not assume `split_list` size equals physical page coverage.
### HiCache metadata currently requires page-aligned logical_len
`CpHiCacheNodeMetadata.__post_init__()` rejects `logical_len % page_size != 0`,
and `reserve_write_cp()` rejects non-page-aligned `device_indices`.
This is already a physical-cache assumption. If logical radix length remains
valid-token based, the metadata needs a clearer name/split:
```text
valid_len # radix-visible logical length
padded_len # physical cache/host metadata length
page_owners # length padded_len / page_size
owned_positions # local positions inside padded_len
```
Without this split, node split/load checks can accidentally force logical cache
semantics to be page-aligned.
### Owner-lane accounting is already page-derived
`HiRadixCache._cp_owner_token_counts()` counts `page_size` tokens per owner page.
The no-collective capacity plan already describes a per-owner page ledger. This
means the capacity side is structurally ready for page-based accounting, but all
inputs to the ledger must be padded-page based. A valid-token scalar is not
enough for CP owner-lane admission.
`cp_shared_kv_compute_owner.build_in_seq_page_compute_owners()` should therefore
return owners for every physical page unit, including short chunks where some
zigzag segments own zero pages. Falling back only because `num_pages < 2 *
cp_size` breaks the page-owner lane contract and can force later materialize or
HiCache paths back to token-balanced behavior.
This owner assignment remains page-count based; `out_cache_loc` remains
valid-token based. `alloc_extend_compute_owner()` uses the owner list to choose
whole physical pages, then `alloc_extend_naive()` returns exactly the valid
extend token locs. The padded tail rows exist because the selected logical page
exists, not because fake token locs are appended to `out_cache_loc`.
### Load must replay page owners
`HiCacheController.load_cp()` reconstructs device allocation via
`alloc_pages_with_owners(page_owners)`. This confirms that load-back is already
page-pattern based and should stay physical-page based.
## Impact surface
### 1. CP split and metadata
Files:
- `python/sglang/srt/layers/attention/nsa/utils.py`
Needed changes:
- keep `split_list` and `actual_token_count` valid-token based;
- add explicit padded page metadata to `NSAContextParallelMetadata` or an
attached helper object;
- keep `segment_page_starts/ends` page-based, including tail page coverage;
- add a helper that returns `valid_tokens`, `padded_tokens`, `padded_pages`, and
`padding_tokens` for the current extend/cache-hit suffix.
Risk:
- changing `split_list` to padded tokens would leak fake tokens into compute and
logits paths. Do not do that.
### 2. MLA partial current reuse
Files:
- `python/sglang/srt/layers/attention/nsa/cp_shared_kv_runtime.py`
- `python/sglang/srt/layers/attention/nsa/cp_shared_kv_prefetch.py`
- `python/sglang/srt/layers/attention/nsa_backend.py`
Needed changes:
- use the existing page-slot dense buffer for mixed prefix/current reuse;
- copy valid current suffix rows into their slot-dense page rows with
`tai-kernel` in the CUDA path;
- keep padding rows in the tail suffix page physically present but zero/unused;
- remap valid current locs to their slot-dense rows;
- map suffix slack/future locs to `-1`;
- keep existing full/current-only path behavior unchanged.
Short-term safe gate if this cannot be implemented atomically:
- WARNING fallback to the older partial materialize path when suffix slack is
detected. This is a stopgap only; the target contract is page-padded compose,
not fallback.
### 3. NSA index partial reuse
Files:
- `python/sglang/srt/layers/attention/nsa/nsa_indexer.py`
- `python/sglang/srt/layers/attention/nsa/cp_shared_kv_prefetch.py`
Needed changes:
- apply the same valid/padded contract as MLA;
- index page metadata can be reused across layers, but returned dense page/table
remaps must not expose padding locs as valid;
- current index reuse should not assume that all locations in a suffix page are
current tokens.
Risk:
- index is page-first by nature, so it is easy to accidentally treat the whole
tail page as visible. Tests must include non-page-aligned suffixes.
### 4. HiCache host metadata and radix split
Files:
- `python/sglang/srt/mem_cache/hiradix_cache.py`
- `python/sglang/srt/managers/cache_controller.py`
Needed changes:
- distinguish radix-visible `valid_len` from physical `padded_len`;
- store `page_owners` for `padded_len / page_size` pages;
- keep split rules page-aware: a radix split inside an in-flight or host-backed
padded page must either split at a page boundary or keep the child pending
until the physical operation is committed;
- make `_node_host_len()` / host-hit accounting return valid length to the
scheduler but use padded length for load/evict/host free.
Risk:
- if `host_hit_length` becomes padded, scheduler may believe fake tokens were
cached. If physical metadata uses valid length, host reservation/load can
under-allocate the tail page.
### 5. Capacity and owner-lane admission
Files:
- `python/sglang/srt/mem_cache/hiradix_cache.py`
- `python/sglang/srt/mem_cache/allocator.py`
- `python/sglang/srt/managers/cache_controller.py`
Needed changes:
- all required capacity should be expressed as pages or per-owner padded tokens;
- owner-lane vectors should be derived from padded `page_owners`, not valid
token counts;
- target and draft reservation must use the same padded page pattern;
- no hot-path collective should be introduced to discover the mismatch.
Risk:
- if target uses padded capacity and draft uses valid capacity, draft can miss
the tail page while target succeeds, causing cache-hit accept-rate corruption.
### 6. Disaggregated transfer
Files:
- `python/sglang/srt/disaggregation/prefill.py`
- `python/sglang/srt/disaggregation/decode.py`
- `python/sglang/srt/disaggregation/mooncake/conn.py`
- `python/sglang/srt/disaggregation/nixl/conn.py`
Needed changes:
- transfer page lists remain page-based and may include the padded tail page;
- request metadata sent across disaggregation must keep valid token length;
- decode should not infer real prompt length from transferred padded page count;
- draft payload follows target pages, not independent draft token length.
Risk:
- page transfer count and logical request length diverge by up to
`page_size - 1` tokens. Any path that uses one as the other is wrong.
### 7. EAGLE/MTP draft KV
Files:
- `python/sglang/srt/speculative/eagle_worker.py`
- `python/sglang/srt/speculative/eagle_info.py`
- `python/sglang/srt/managers/cache_controller.py`
Needed changes:
- draft physical pages mirror target padded pages;
- draft valid length mirrors target valid length;
- draft cannot independently choose a shorter valid-only capacity path;
- draft cache-hit prefetch/current reuse must use the same visibility mask.
Risk:
- draft and target can both be physically page-aligned while disagreeing on
which rows are valid. That still breaks verification/accept rate.
## Missing work / correction ledger
This section is the working checklist for the current implementation. Each item
records what is still missing or what existing code needs to be corrected before
we can treat the page-aligned contract as complete.
### C1. Add an explicit page extent object
Current state:
- `build_page_aligned_in_seq_split_list()` computes `num_page_units` locally.
- Callers still pass raw lengths such as `extend_len`, `logical_len`, and
`required_slots` without naming whether they are valid-token or padded-page
quantities.
Correction:
- Add a small helper/data object, for example:
```text
PageAlignedCacheExtent(
valid_tokens,
padded_pages,
padded_tokens,
padding_tokens,
)
```
- Use it where the code crosses logical/physical boundaries.
- `valid_tokens=100, page_size=64` must produce `padded_pages=2` and
`padded_tokens=128`; it must not depend on `cp_size`.
Tests:
- Unit-test exact examples such as `0`, `64`, `100`, and `128` valid tokens.
- Add an assertion that `100` tokens with `page_size=64, cp_size=8` does not
become `1024` physical tokens.
### C2. Extend CP split metadata without padding `split_list`
Current state:
- `split_list` is correct because it sums to valid tokens.
- `PageAlignedInSeqSplitInfo` and `NSAContextParallelMetadata` expose only
`page_aligned`, `page_size`, `extend_prefix_len`, and segment page ranges.
- There is no explicit field carrying padded suffix coverage.
Correction:
- Keep `split_list`, `actual_token_count`, and query compute valid-token based.
- Add explicit metadata such as `extend_valid_tokens`, `extend_padded_pages`,
`extend_padded_tokens`, and `extend_padding_tokens`.
- Keep `segment_page_starts/ends` page-based and include the tail page coverage.
- Do not change `cp_split_and_rebuild_data()` to consume fake padding tokens.
Tests:
- `extend_len=100, page_size=64, cp_size=8` should have:
- `sum(split_list) == 100`;
- `extend_padded_pages == 2`;
- `extend_padded_tokens == 128`;
- zero-token segments for ranks/zigzag segments without pages.
### C3. Remove short-suffix fallback completely, but only as metadata padding
Current state:
- The old `num_pages < cp_size` / `num_pages < 2 * cp_size` fallback was removed
from the split and compute-owner helpers.
- `should_use_replicated_compute_for_short_radix_hit()` now returns `False`.
- `can_cp_split()` still has generic CP minimum-token checks; those are compute
feasibility checks, not page-padding rules.
Correction:
- Keep the principle: no fallback solely because a suffix has fewer than
`cp_size` or `2 * cp_size` pages.
- Padding means "cover the partial tail page physically"; it does not mean
"round up to cp_size pages".
- If a truly tiny batch cannot run through CP compute, the reason must be a
clearly named compute limitation, not `too_short_for_page_aligned`.
Tests:
- `extend_len=100, page_size=64, cp_size=8` should use two physical page units.
- `extend_len=3 pages, cp_size=8` should produce three compute owners, not
eight or sixteen.
### C4. MLA partial-current prefetch compose is partially corrected
Current state:
- `CpSharedKVMlaPrefetcher.consume_prefix_with_current()` now uses a page-slot
compose helper instead of compact-appending current KV rows.
- `fill_current_kv_page_slots_and_remap_locs()` copies valid current rows into
their dense page slots and masks non-current locs in current pages to `-1`.
- A TAI primitive exists for the CUDA hot path, with a visible warning fallback
to the Torch reference when unavailable.
Correction still needed:
- Verify that every MLA mixed prefix/current path uses the page-slot compose
helper when a dense slot buffer already exists.
- Audit the non-prefetch fallback in `nsa_backend.py`: it still calls
`merge_materialized_and_current_kv()`, which is the compact-current helper.
That path may be correct only when the materialized loc table has already
masked suffix slack. This must be proven by tests or changed to the same
page-slot helper.
- Keep `merge_materialized_and_current_kv()` documented as compact-materialize
only; do not silently use it for page-slot prefetch compose.
Tests:
- Existing MLA slack test covers a tail page where only part of the suffix page
is valid.
- Add a non-prefetch partial-current test with the same tail-page shape before
changing the fallback path.
- CUDA execution of the TAI primitive must be verified remotely; do not test
CUDA locally.
### C5. NSA index partial reuse still needs an equivalent padding contract
Current state:
- `CpSharedKVIndexPrefetcher.consume()` materializes suffix page slots into a
dense page buffer and returns dense pages/block tables.
- Index prefetch is page-first, so it can easily treat a whole tail page as
visible unless query/cache sequence lengths keep the tail rows masked.
- There is no dedicated unit test proving tail-page slack is invisible for NSA
index partial reuse.
Correction:
- Apply the same valid/padded distinction as MLA:
- page tables may include the padded tail page;
- valid sequence length must remain unpadded;
- any loc/table remap that can expose token rows must map padding rows to
invalid/sentinel entries or rely on a proven sequence-length mask.
- Current code inspection shows NSA index uses page ids plus explicit valid
sequence lengths: `GetKAndS` iterates `token_ids < seq_len`, and paged MQA
logits receive `seqlens_32` / `indexer_seq_lens`. Therefore tail-page slack
is not visible if those lengths remain valid-token based.
- The required immediate fix is a regression test that proves index prefetch keeps
the padded tail page in the dense page table while relying on valid
`indexer_seq_lens` to exclude slack. If a future path consumes index page
tables without an accompanying valid length, it must add an explicit
sentinel/remap or warning fallback before using the table.
- If index prefetch cannot prove this contract for a shape, emit a warning
fallback with a narrow reason instead of silently using a risky path.
Tests:
- Add an index partial reuse case with a non-page-aligned suffix:
- tail page included in dense pages;
- dense page table has only `ceil(valid_tokens / page_size)` physical pages,
not cp-size padding;
- valid sequence length remains the original unpadded token count so the
index kernels exclude tail slack.
### C6. HiCache metadata conflates logical and physical lengths
Current state:
- `CpHiCacheNodeMetadata.logical_len` is required to be page-aligned.
- `reserve_write_cp()` rejects non-page-aligned `device_indices`.
- `_build_cp_load_back_plan()` and `load_cp()` use `node.host_len` as both the
logical cache-hit length and physical load length.
Correction:
- Split the metadata into:
```text
valid_len # radix/scheduler-visible tokens
padded_len # physical host/device cache span
page_owners # length padded_len / page_size
owned_positions # positions inside padded_len
```
- Scheduler-facing cache hit length must stay `valid_len`.
- Host reservation, backup, load, eviction, and owner-lane capacity must use
`padded_len`.
- `load_cp()` should validate allocation length against padded physical length,
while returning enough information for the radix/scheduler path to expose only
valid tokens.
Tests:
- Metadata with `valid_len=100, padded_len=128, page_size=64`.
- Host hit reports `100` to scheduler but reserves/loads `128` physical slots.
- `page_owners` length is `2`.
### C7. Radix page alignment currently floors partial tails
Current state:
- `page_align_keys()` truncates keys to a page boundary.
- `prepare_write_backup_for_req()` calls `page_align_keys()` before probing and
reserving CP HiCache backup.
- This can preserve correctness, but a short extend can be omitted from host
cache until it completes a full page. Repeated short suffixes can therefore
keep future hits on a fallback/partial path.
Correction:
- Do not append fake tokens to radix keys.
- Keep radix keys valid-token based, but attach physical padded metadata to the
node/backup record.
- If a radix split would land inside an in-flight padded physical page, either:
- delay/pending the split; or
- force the split to a page boundary and keep the valid-tail metadata attached
to the child.
Tests:
- Write a 100-token suffix and verify the radix-visible key length remains 100
while physical metadata covers 128 tokens.
- Split a node around the tail page and verify valid/padded lengths remain
consistent.
### C8. Owner-lane capacity must be padded-page based end to end
Current state:
- Compute-owner allocation returns one owner per physical page unit, including a
tail page.
- HiCache capacity planning counts `page_size` tokens per owner page.
- Some entry points still pass scalar valid lengths or page-aligned-only
`device_indices`.
Correction:
- Derive required capacity from padded `page_owners`, not valid token counts.
- Admission should use a per-owner vector built from the padded page list.
- Do not introduce an all-reduce/collective to discover capacity agreement; the
owner pattern should make the required vector deterministic.
Tests:
- A 100-token suffix with owners `[0, 1]` should require one page on owner 0 and
one page on owner 1.
- Target and draft required vectors must match when draft HiCache is enabled.
### C9. Draft/EAGLE currently has safe fallbacks, not the final contract
Current state:
- Draft HiCache piggybacks target reservations/positions when
`draft_host_indices` are present.
- Draft partial-current reuse/prefetch has been disabled or pushed to older
materialization paths in some cases to avoid EAGLE hangs.
Correction:
- Draft physical pages must mirror target padded pages.
- Draft valid length must mirror target valid length.
- Draft cannot independently choose a valid-only capacity or prefetch path.
- Re-enable draft/EAGLE partial reuse only after the same page-slot visibility
mask is proven for draft.
Tests:
- Target/draft reservation with a non-page-aligned suffix uses identical
`owned_positions` and padded physical length.
- EAGLE one-layer path does not prefetch or consume an unmasked padded tail.
### C10. Disaggregated transfer has not been audited for valid/padded split
Current state:
- Transfer APIs are page-oriented in several paths, but request metadata remains
token-length oriented.
- There is no current checklist proving that decode never infers prompt length
from transferred padded page count.
Correction:
- Transfer page lists may include the padded tail page.
- Request/bootstrap metadata must carry valid token length separately.
- Decode-visible prompt length and max-context checks must use valid length.
- Draft transfer follows target page pattern.
Tests:
- Send a non-page-aligned prompt through disaggregation:
- transferred pages include the padded tail page;
- decode sees the original valid length;
- draft and target transfer page counts match when draft KV is enabled.
### C11. Fallback logging must be audited
Current state:
- MLA prefix misalignment now logs a warning fallback.
- Some prefetch misses use debug logs or normal fallback paths.
- Historical silent fallbacks made it hard to identify that async/per-layer or
page-aligned paths were not actually active.
Correction:
- Production-visible fallback from an intended hot path must emit
`[CP_SHARED_KV_FALLBACK]` or `[CP_HICACHE_FALLBACK]` at warning level with a
narrow reason.
- Debug-only logs are acceptable only for expected non-fallback states.
- Avoid per-layer spam for expected steady-state events; warn on path changes,
not every successful layer.
Tests:
- Unit-test representative fallback logs:
- MLA prefix not page-aligned;
- index prefetch consume miss after start layer;
- CP HiCache reservation/load capacity failure.
### C12. Verification gaps before claiming completion
Current state:
- Python syntax checks pass for the edited SGLang files.
- Local pytest collection can be blocked by missing optional dependencies such
as `pybase64`; this workspace also hit `orjson` during targeted
`test_nsa_cp_utils.py` collection, then `starlette` after temporary stubbing.
`test_cp_shared_kv_layout.py` collection also hit `transformers` via
`sglang.test.test_utils`.
- CUDA kernels must not be tested locally.
Correction:
- Use a temporary local test stub only for optional import blockers such as
`pybase64` / `orjson`; do not commit stubs.
- If dependency stubbing starts expanding into unrelated web/server packages,
stop the dependency-stub path and use isolated module loading or
`py_compile` for the targeted helper. Do not spend the implementation loop
repeatedly rediscovering local environment gaps.
- Run CPU/unit tests for extent/split/owner/runtime helpers.
- Run CUDA/TAI kernel verification only on the remote CUDA environment.
- Record remote log evidence for:
- TAI page-slot compose being used;
- no warning fallback on the expected hot path;
- cache-hit request completing without detokenizer hang.
Tests:
- Targeted CPU pytest for:
- `test_nsa_cp_utils.py`;
- `test_cp_shared_kv_layout.py`;
- `test_cp_shared_kv_runtime.py`.
- Remote CUDA benchmark/runtime for the TAI materialize primitive and full ETE
prefill run.
### C13. Remote source sync can expose mixed-version symbols
Current state:
- Remote container targeted new tests passed after scp sync.
- A wider remote run of:
```text
PYTHONPATH=python python -m pytest -q \
test/registered/unit/layers/test_nsa_cp_utils.py \
test/registered/unit/mem_cache/test_cp_shared_kv_layout.py \
test/registered/unit/mem_cache/test_cp_shared_kv_runtime.py
```
produced 102 passes and 19 failures.
- The dominant failure is an import mismatch:
```text
nsa_indexer.py / nsa_backend.py import
cp_shared_kv_mla_prefetch_should_trace_tiny_extend
from cp_shared_kv_runtime.py, but the synced runtime file does not export it.
```
Correction:
- Treat this as a mixed-version/sync integrity problem before making logic
changes.
- Compare local and remote `cp_shared_kv_runtime.py`, `nsa_indexer.py`, and
`nsa_backend.py` symbol expectations.
- Either restore/export the expected helper or sync the matching caller files.
- Re-run the same remote wider suite after the symbol contract is repaired.
Tests:
- Import-level check:
`python - <<'PY' ... import cp_shared_kv_mla_prefetch_should_trace_tiny_extend`.
- Re-run the three-file remote pytest suite.
### C14. Wider remote unit suite still exposes non-page-padding regressions
Current state:
- After directly overwriting remote `nsa_indexer.py` and `nsa_backend.py` to
remove the mixed-version import mismatch, remote py_compile passed.
- The three-file remote unit suite improved to:
```text
114 passed, 7 failed, 5 warnings
```
- Remaining failures are:
1. `test_local_out_cache_loc_logs_every_fallback_event` expects the old
fallback text `"CP shared KV direct-write fallback"`, while current code
emits the newer warning prefix
`[CP_SHARED_KV_FALLBACK][direct_write]`.
2. MLA and index prefetch stream tests expect prefix materialization to run
inside the prefetch stream context, but current code records materialize on
the current stream and only the all-reduce on the prefetch stream.
3. MLA and index `wait_attention_window()` tests expect the pending event to
be waited/launched. Current code returns without waiting in those unit
setups.
4. `test_mla_prefetch_min_prefix_pages_uses_cached_token_default_and_can_override`
expects `SGLANG_CP_SHARED_KV_MLA_PREFETCH_MIN_PREFIX_PAGES=-2` to return
`16` for `cp_size=4,page_size=64`, but current code returns the dynamic
token default of `8`.
Correction:
- Treat these as separate from the page-boundary extent work.
- The fallback-log test should be updated to the new warning prefix because C11
defines `[CP_SHARED_KV_FALLBACK]` warning-level logging as the intended
production contract.
- Code inspection plus the Phase 8 prefetch doc confirm the current stream/wait
behavior is intentional: prefix materialize runs on the current stream because
MLA/index materialize kernels consume SM and should remain ordered; only the
async all-reduce is enqueued on the prefetch stream. `wait_attention_window()`
is a historical hook and must not wait; the real wait remains at next-layer
`consume(...)` immediately before use. The stale unit tests should assert this
deferred-consume model instead of forcing attention-tail waits.
- `SGLANG_CP_SHARED_KV_MLA_PREFETCH_MIN_PREFIX_PAGES=-2` has no separate runtime
contract in docs or code; all negative values use the same dynamic default:
`max(cp_size, ceil(_MLA_PREFETCH_DEFAULT_MIN_PREFIX_TOKENS / page_size))`. The
expected value changed from 16 to 8 after the default token threshold was reset
from 1024 to 512; the stale test expectation should be updated, not the code.
Tests:
- Re-run the exact failed tests after each fix, then the three-file remote
suite.
## Testing requirements
Add targeted tests before implementation:
1. `extend_len % page_size != 0` MLA partial current reuse:
- prefix page-aligned,
- suffix has page slack,
- dense buffer includes padded suffix rows,
- slack locs remap to `-1`.
2. Equivalent NSA index partial reuse case:
- tail page included in page table,
- valid current tokens remap,
- slack positions invalid.
3. HiCache metadata:
- valid length and padded length differ,
- `page_owners` length is padded-page count,
- host hit exposed to scheduler is valid length.
4. Capacity:
- per-owner required capacity is derived from padded pages;
- target/draft required counts match.
5. Transfer:
- page list includes padded tail page;
- decode-visible sequence length remains valid length.
## Implementation guidance
1. Introduce a small helper/data object for page-aligned cache extents. Do not
pass raw `len`, `num_tokens`, or `required_slots` through new code without
naming whether it is valid or padded.
2. Update MLA prefetch compose first, because it is the observed failing path.
3. Update index partial reuse only after MLA has tests for slack masking.
4. Update HiCache metadata/capacity before enabling this contract for host-hit
load/backup paths.
5. Any temporary fallback must be a `WARNING` with a narrow reason and should be
removed after page-padded compose is complete.
## Non-goals for this pass
- Do not change request/logical token length semantics.
- Do not make `split_list` include fake tokens.
- Do not add a hot-path CP collective to reconcile capacity.
- Do not make draft KV manage an independent padded/valid contract.
- Do not silently fall back from the page-aligned contract.

View File

@@ -234,21 +234,17 @@ Phase 4 MVP 建议:
fallback 到旧 token-average split
```
当前实现采用保守 gate但对 radix-hit suffix 放宽
当前 page-aligned contract 已去掉 short-suffix fallback
```text
如果 extend_prefix_len == 0 且 num_units < 2 * cp_size:
fallback 到旧 token-average split
如果 extend_prefix_len > 0 且 prefix page-aligned:
允许 cp_size <= num_units < 2 * cp_size未覆盖的 segment 长度为 0
如果 extend_prefix_len > 0 且 prefix page-aligned 且 num_units < cp_size:
不启用 CP split保留 replicated compute所有 rank 计算 short suffix
但 compute-owner allocator 仍按 page owner 分配 logical page。
如果 prefix page-aligned 且 extend_len > 0:
使用 page-aligned split
每个物理 page unit 只归属一个 zigzag segment
没有 page 的 segment 长度为 0
out_cache_loc 仍只包含 valid tokens不追加 fake padding tokens
```
原因是 CP 本身不适合无 cache 的短序列;但长 agent 上下文里 radix cache hit 很频繁,命中后 current suffix 可能只有少量 page。如果继续因为 suffix 太短 fallback会导致 compute-owner allocation/write 在高频 radix-hit 路径失效。放宽后的约束仍然prefix 必须 page-aligned实际 suffix page 不被切开。`cp_size <= num_units < 2 * cp_size` 时仍走 page-aligned CP split每个 CP rank 至少拿到一个 page unit`num_units < cp_size` 时避免构造 zero-token CP rank改走 replicated compute由已有 shared-KV write filter 只写本 rank 拥有的 page。
原因是 CP 本身不适合无 cache 的短序列;但长 agent 上下文里 radix cache hit 很频繁,命中后 current suffix 可能只有少量 page。如果继续因为 suffix 太短 fallback会导致 compute-owner allocation/write 在高频 radix-hit 路径失效。当前目标约束prefix 必须 page-aligned实际 suffix page 不被切开。即使 `num_units < cp_size`,也保持 page-aligned CP split使用 zero-token segment 表示没有 page 的 rank shared-KV write filter 只写本 rank 拥有的 page。
---
@@ -469,7 +465,7 @@ test/registered/unit/attention/test_nsa_cp_page_aligned_split.py
- MVP fallback 到旧 split或显式返回 `page_aligned=False`
6. too-short case
- cache-miss: `num_units < 2 * cp_size` fallback 或 `page_aligned=False`
- radix-hit 且 prefix page-aligned: `cp_size <= num_units < 2 * cp_size` 允许;`num_units < cp_size` 不构造 CP split改走 replicated computecompute-owner allocation 仍可用。
- radix-hit 且 prefix page-aligned: `num_units < 2 * cp_size` 允许;没有 page 的 segment 使用 zero-token segmentcompute-owner allocation 仍可用。
### 7.2 Invariant tests

View File

@@ -493,7 +493,7 @@ radix prefix 命中会让 current extend 从已有 logical pages 之后开始。
Phase 4/5 对 radix-hit short suffix 放宽 too-short gate
-`extend_prefix_len > 0` 且 prefix page-aligned并且 current suffix page 数至少为 `cp_size` 时,即使 page 数小于 `2 * cp_size`,仍生成 page-aligned split / compute-owner page owner list未覆盖的第二段 zigzag segment 长度为 0。
- suffix page 数小于 `cp_size` 时,仍允许 compute-owner page allocation,但不启用 CP split。此时沿用 SGLang 原有 replicated compute 行为:所有 rank 计算 short suffixshared-KV 的 MLA/index write filter 只保留本 rank owner page 的写入。这样避免 zero-token CP rank 的通信/kernel 边界问题,同时避免 radix-hit 高频短 suffix 回退到 legacy allocation。
-前 page-aligned contract 已废弃 short suffix 的 replicated compute 特例。suffix page 数小于 `cp_size`compute-owner page allocation 仍返回实际 page 的 owner listCP split 使用 zero-token segments 表示没有 page 的 rank。这样保持 owner-lane / HiCache / materialize 的物理 page pattern不再因为短 suffix 回退到 legacy allocation。
如果命中到 partial pageMVP fallback。

View File

@@ -18,11 +18,11 @@ from sglang.srt.layers.attention.nsa.cp_shared_kv_runtime import (
cp_shared_kv_mla_prefetch_should_log_layer,
filter_locs_mappable_to_physical_pool,
filter_pages_mappable_to_physical_pool,
fill_current_kv_page_slots_and_remap_locs,
get_or_build_shared_paged_buffer_slot_remap,
get_or_build_shared_token_kv_slot_remap,
materialize_local_paged_buffer_page_slots_into,
materialize_local_token_kv_page_slots_into,
merge_materialized_and_current_kv,
remap_logical_pages_to_slot_dense_pages,
remap_logical_locs_to_slot_dense_locs_optimized,
slot_range_to_page_slice,
@@ -39,6 +39,14 @@ def _prefetch_log(message: str, *args) -> None:
cp_shared_kv_mla_prefetch_log(message, *args)
def _mla_prefetch_fallback_log(reason: str, message: str, *args) -> None:
logger.warning(
"[CP_SHARED_KV_FALLBACK][mla_prefetch] reason=%s " + message,
reason,
*args,
)
def _index_prefetch_fallback_log(reason: str, message: str, *args) -> None:
logger.warning(
"[CP_SHARED_KV_FALLBACK][index_prefetch] reason=%s " + message,
@@ -384,8 +392,10 @@ class CpSharedKVMlaPrefetcher:
return None
extend_prefix_len = int(extend_prefix_lens_cpu[0])
if extend_prefix_len <= 0 or extend_prefix_len % page_size != 0:
_prefetch_log(
"create_skip reason=prefix_not_page_aligned prefix_len=%s page_size=%s",
_mla_prefetch_fallback_log(
"prefix_not_page_aligned",
"prefix length is zero or not page-aligned. "
"prefix_len=%s page_size=%s",
extend_prefix_len,
page_size,
)
@@ -726,14 +736,15 @@ class CpSharedKVMlaPrefetcher:
page_inverse=self.page_inverse,
page_size=self.page_size,
)
mixed_kv_cache, mixed_locs, _ = merge_materialized_and_current_kv(
materialized_kv_cache=dense_kv_cache,
mixed_kv_cache, mixed_locs, _ = fill_current_kv_page_slots_and_remap_locs(
dense_kv_cache=dense_kv_cache,
materialized_dense_locs=dense_locs,
current_kv_cache=current_kv_cache,
logical_locs=logical_locs,
current_locs=current_locs,
page_size=current_remap_page_size,
logical_page_capacity=current_remap_logical_page_capacity,
page_inverse=self.page_inverse,
page_size=self.page_size,
mask_non_current_in_current_pages=True,
)
remap_ms = _cpu_timing_ms(remap_cpu)
total_ms = _cpu_timing_ms(consume_cpu)

View File

@@ -642,6 +642,135 @@ def remap_logical_locs_to_slot_dense_locs_optimized(
)
def _try_tai_fill_current_kv_page_slots_and_remap_locs(
*,
dense_kv_cache: torch.Tensor,
materialized_dense_locs: torch.Tensor,
current_kv_cache: torch.Tensor,
logical_locs: torch.Tensor,
current_locs: torch.Tensor,
page_inverse: torch.Tensor,
page_size: int,
mask_non_current_in_current_pages: bool,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None:
if not _tai_materialize_runtime_enabled():
return None
kernels = _load_tai_materialize_kernels()
if kernels is None:
return None
fill_kernel = getattr(
kernels,
"fill_current_token_kv_page_slots_and_remap_locs",
None,
)
if fill_kernel is None:
_log_tai_materialize_fallback(
"fill_current_missing",
"CP shared KV tai current-slot fill kernel is unavailable; "
"falling back to torch reference. Upgrade tai-kernel to keep this "
"hot path off PyTorch. page_size=%s current_rows=%s query_locs=%s",
page_size,
int(current_kv_cache.shape[0]),
int(logical_locs.numel()),
limit=1,
)
return None
try:
return fill_kernel(
_contiguous_for_tai(dense_kv_cache),
_contiguous_for_tai(materialized_dense_locs),
_contiguous_for_tai(current_kv_cache),
_contiguous_for_tai(logical_locs),
_contiguous_for_tai(current_locs.reshape(-1)),
_contiguous_for_tai(page_inverse),
page_size=int(page_size),
mask_non_current_in_current_pages=bool(mask_non_current_in_current_pages),
)
except Exception as exc:
_log_tai_materialize_fallback(
"fill_current_failed",
"CP shared KV tai current-slot fill failed; falling back to torch "
"reference. error=%s page_size=%s current_rows=%s query_locs=%s",
exc,
page_size,
int(current_kv_cache.shape[0]),
int(logical_locs.numel()),
)
return None
def fill_current_kv_page_slots_and_remap_locs(
*,
dense_kv_cache: torch.Tensor,
materialized_dense_locs: torch.Tensor,
current_kv_cache: torch.Tensor,
logical_locs: torch.Tensor,
current_locs: torch.Tensor,
page_inverse: torch.Tensor,
page_size: int,
mask_non_current_in_current_pages: bool = True,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Fill current suffix KV into preallocated dense page slots and remap locs.
This is the CP shared-KV prefetch compose path. The dense buffer already has
page-aligned slots for prefix and suffix pages; current rows should be copied
into those suffix slots instead of appended to a second compact suffix.
"""
tai_result = _try_tai_fill_current_kv_page_slots_and_remap_locs(
dense_kv_cache=dense_kv_cache,
materialized_dense_locs=materialized_dense_locs,
current_kv_cache=current_kv_cache,
logical_locs=logical_locs,
current_locs=current_locs,
page_inverse=page_inverse,
page_size=page_size,
mask_non_current_in_current_pages=mask_non_current_in_current_pages,
)
if tai_result is not None:
return tai_result
if dense_kv_cache.is_cuda:
_log_tai_materialize_fallback(
"fill_current_torch_reference_cuda",
"CP shared KV current-slot fill is using the torch reference on CUDA; "
"this is a fallback and should not be the steady-state hot path. "
"page_size=%s current_rows=%s query_locs=%s",
page_size,
int(current_kv_cache.shape[0]),
int(logical_locs.numel()),
limit=1,
)
current_dense_locs = remap_logical_locs_to_slot_dense_locs_optimized(
current_locs.reshape(-1),
page_inverse=page_inverse,
page_size=page_size,
)
valid_current_rows = current_dense_locs >= 0
if torch.any(valid_current_rows):
dense_kv_cache[current_dense_locs[valid_current_rows].to(torch.long)] = (
current_kv_cache[valid_current_rows]
)
current_mask, _ = build_current_loc_remap(logical_locs, current_locs)
mixed_locs = materialized_dense_locs
if mask_non_current_in_current_pages:
current_page_mask = build_current_page_mask(
logical_locs,
current_locs,
page_size=page_size,
)
mixed_locs = torch.where(
current_page_mask & (~current_mask),
torch.full_like(materialized_dense_locs, -1),
materialized_dense_locs,
)
return dense_kv_cache, mixed_locs, current_mask
def _copy_tai_dense_slot_range_body(
*,
tai_dense_kv_cache: torch.Tensor,
@@ -866,6 +995,43 @@ def current_loc_remap_fast_path_args(
return page_size, logical_page_capacity
def build_current_page_mask(
query_locs: torch.Tensor,
current_locs: torch.Tensor,
*,
page_size: int,
) -> torch.Tensor:
"""Return true for query locs that fall inside a current suffix page.
Partial-current reuse can have a tail page where only the first N rows are
real current tokens. The page itself is part of the physical suffix, but
rows after the valid current tokens are padding/slack and must be invisible
to attention.
"""
mask = torch.zeros_like(query_locs, dtype=torch.bool)
if page_size <= 1 or query_locs.numel() == 0 or current_locs.numel() == 0:
return mask
query_flat = query_locs.reshape(-1).to(torch.long)
current_flat = current_locs.reshape(-1).to(torch.long)
current_pages = torch.unique(
torch.div(current_flat, page_size, rounding_mode="floor")
)
if current_pages.numel() == 0:
return mask
sorted_pages, _ = torch.sort(current_pages)
valid_query = query_flat >= 0
safe_query = torch.where(valid_query, query_flat, torch.zeros_like(query_flat))
query_pages = torch.div(safe_query, page_size, rounding_mode="floor")
insert_positions = torch.searchsorted(sorted_pages, query_pages)
safe_positions = torch.clamp(insert_positions, max=sorted_pages.numel() - 1)
in_range = insert_positions < sorted_pages.numel()
matched = valid_query & in_range & (sorted_pages[safe_positions] == query_pages)
return matched.reshape(query_locs.shape)
def merge_materialized_and_current_kv(
*,
materialized_kv_cache: torch.Tensor,
@@ -882,6 +1048,11 @@ def merge_materialized_and_current_kv(
materialization path. Entries corresponding to current extend tokens are
replaced with offsets into the appended ``current_kv_cache``. Non-current
entries remain untouched, including ``-1`` invalid sentinels.
This helper is for compact prefix materialization that appends a compact
current suffix. Prefetch paths with existing page slots must use
:func:`fill_current_kv_page_slots_and_remap_locs` instead, so the physical
page padding stays in the original dense slot layout.
"""
current_mask, current_rows = build_current_loc_remap(

View File

@@ -173,6 +173,32 @@ def pad_nsa_cache_seqlens(forward_batch: "ForwardBatch", nsa_cache_seqlens):
return nsa_cache_seqlens
@dataclass(frozen=True)
class PageAlignedCacheExtent:
valid_tokens: int
padded_pages: int
padded_tokens: int
padding_tokens: int
def build_page_aligned_cache_extent(
*, valid_tokens: int, page_size: int
) -> PageAlignedCacheExtent:
if valid_tokens < 0:
raise ValueError(f"valid_tokens must be non-negative, got {valid_tokens}")
if page_size <= 0:
raise ValueError(f"page_size must be positive, got {page_size}")
padded_pages = ceil_div(valid_tokens, page_size) if valid_tokens > 0 else 0
padded_tokens = padded_pages * page_size
return PageAlignedCacheExtent(
valid_tokens=valid_tokens,
padded_pages=padded_pages,
padded_tokens=padded_tokens,
padding_tokens=padded_tokens - valid_tokens,
)
@dataclass
class PageAlignedInSeqSplitInfo:
page_aligned: bool = False
@@ -180,6 +206,10 @@ class PageAlignedInSeqSplitInfo:
extend_prefix_len: int = 0
segment_page_starts: List[int] = None
segment_page_ends: List[int] = None
extend_valid_tokens: int = 0
extend_padded_pages: int = 0
extend_padded_tokens: int = 0
extend_padding_tokens: int = 0
@dataclass
@@ -208,6 +238,10 @@ class NSAContextParallelMetadata:
extend_prefix_len: int = 0
segment_page_starts: List[int] = None
segment_page_ends: List[int] = None
extend_valid_tokens: int = 0
extend_padded_pages: int = 0
extend_padded_tokens: int = 0
extend_padding_tokens: int = 0
def build_token_balanced_in_seq_split_list(total_len: int, cp_size: int) -> List[int]:
@@ -233,6 +267,10 @@ def _fallback_page_aligned_split_info(
extend_prefix_len=extend_prefix_len,
segment_page_starts=[],
segment_page_ends=[],
extend_valid_tokens=0,
extend_padded_pages=0,
extend_padded_tokens=0,
extend_padding_tokens=0,
)
@@ -246,13 +284,10 @@ def build_page_aligned_in_seq_split_list(
) -> Tuple[List[int], PageAlignedInSeqSplitInfo]:
"""Build an in-seq split list whose real-token boundaries do not cut pages.
Phase 4 deliberately uses a conservative gate for cache-miss chunks: at
least `2 * cp_size` page units are required so every zigzag segment has at
least one page unit. For radix-hit suffixes with a page-aligned prefix, the
gate is relaxed to `cp_size` page units so every CP rank still receives at
least one local page while second zigzag segments may be empty. When the
gate does not hold, this helper falls back to the existing token-balanced
split and marks the result as not page-aligned.
The split remains valid-token based, but page metadata covers the physical
page units. Short chunks keep the page-aligned contract by assigning zero
valid tokens to surplus zigzag segments instead of falling back to
token-balanced splits that would poison later shared-KV/HiCache reuse.
"""
if extend_len < 0:
@@ -271,14 +306,14 @@ def build_page_aligned_in_seq_split_list(
if page_size <= 1 or extend_len <= 0 or extend_prefix_len % page_size != 0:
return fallback_split, fallback_info
extent = build_page_aligned_cache_extent(
valid_tokens=extend_len,
page_size=page_size,
)
full_pages = extend_len // page_size
tail_tokens = extend_len % page_size
num_page_units = full_pages + (1 if tail_tokens > 0 else 0)
num_page_units = extent.padded_pages
cp_segment_num = cp_size * 2
if num_page_units < cp_size or (
num_page_units < cp_segment_num and extend_prefix_len == 0
):
return fallback_split, fallback_info
base_units = num_page_units // cp_segment_num
remainder_units = num_page_units % cp_segment_num
@@ -315,6 +350,10 @@ def build_page_aligned_in_seq_split_list(
extend_prefix_len=extend_prefix_len,
segment_page_starts=segment_page_starts,
segment_page_ends=segment_page_ends,
extend_valid_tokens=extent.valid_tokens,
extend_padded_pages=extent.padded_pages,
extend_padded_tokens=extent.padded_tokens,
extend_padding_tokens=extent.padding_tokens,
)
@@ -356,50 +395,12 @@ def should_use_replicated_compute_for_short_radix_hit(
) -> bool:
"""Return whether a short radix-hit suffix should avoid CP splitting.
With CP shared KV, radix-hit suffixes can be page-aligned but shorter than
one page per CP rank. A page-aligned CP split would give some ranks zero
local tokens, which is unsafe for parts of the current CP collective/kernel
path. Instead, keep the original non-CP behavior: every rank computes the
short suffix, while shared-KV write filters persist only pages owned by the
local rank.
Kept as a compatibility hook for older callers. The page-aligned cache
contract no longer uses replicated compute for short suffixes: zero-length
CP segments are preferred over breaking the physical page-owner pattern.
"""
if (
forward_batch is None
or cp_size <= 0
or not getattr(forward_batch, "uses_cp_shared_kv", False)
):
return False
extend_seq_lens_cpu = getattr(forward_batch, "extend_seq_lens_cpu", None)
extend_prefix_lens_cpu = getattr(forward_batch, "extend_prefix_lens_cpu", None)
if (
extend_seq_lens_cpu is None
or extend_prefix_lens_cpu is None
or len(extend_seq_lens_cpu) != 1
or len(extend_prefix_lens_cpu) != 1
):
return False
token_to_kv_pool = getattr(forward_batch, "token_to_kv_pool", None)
page_size = getattr(token_to_kv_pool, "page_size", None)
if page_size is None:
return False
page_size = int(page_size)
if page_size <= 1:
return False
extend_len = int(extend_seq_lens_cpu[0])
extend_prefix_len = int(extend_prefix_lens_cpu[0])
if (
extend_len <= 0
or extend_prefix_len <= 0
or extend_prefix_len % page_size != 0
):
return False
num_page_units = ceil_div(extend_len, page_size)
return 0 < num_page_units < cp_size
return False
def can_cp_split(seq_len: int, cp_size: int, use_nsa: bool, forward_batch):
@@ -1177,6 +1178,10 @@ def prepare_input_dp_with_cp_dsa(
extend_prefix_len=page_split_info.extend_prefix_len,
segment_page_starts=page_split_info.segment_page_starts,
segment_page_ends=page_split_info.segment_page_ends,
extend_valid_tokens=page_split_info.extend_valid_tokens,
extend_padded_pages=page_split_info.extend_padded_pages,
extend_padded_tokens=page_split_info.extend_padded_tokens,
extend_padding_tokens=page_split_info.extend_padding_tokens,
)
return nsa_cp_metadata

View File

@@ -24,9 +24,6 @@ def get_in_seq_page_compute_owner_unavailable_reason(
full_pages = extend_len // page_size
tail_tokens = extend_len % page_size
num_page_units = full_pages + (1 if tail_tokens > 0 else 0)
if num_page_units < cp_size * 2 and extend_prefix_len == 0:
return "too_short_for_page_aligned"
return None
@@ -39,12 +36,11 @@ def build_in_seq_page_compute_owners(
) -> Optional[List[int]]:
"""Return compute-owner CP rank for each newly allocated current page.
This mirrors the Phase 4 page-aligned `in-seq-split` segmentation for
normal CP chunks, but it only returns page-unit owners for the real extend
chunk. Short radix-hit suffixes with fewer pages than CP ranks are also
allowed: runtime keeps replicated compute for those chunks and the shared
KV write filters persist only locally owned pages. `None` means the batch
must stay on the legacy allocation/write path.
This mirrors the page-aligned `in-seq-split` segmentation and returns one
owner per physical page unit, including a tail page. Short chunks keep the
page-aligned owner pattern by assigning zero pages to surplus zigzag
segments instead of falling back to legacy allocation. `None` means the
batch must stay on the legacy allocation/write path.
"""
if cp_size <= 0:

View File

@@ -5,6 +5,8 @@ from unittest.mock import patch
from sglang.srt.layers.attention.nsa.utils import (
NSAContextParallelMetadata,
PageAlignedCacheExtent,
build_page_aligned_cache_extent,
_get_in_seq_last_token_owner_and_offset,
build_page_aligned_in_seq_split_list,
build_token_balanced_in_seq_split_list,
@@ -22,6 +24,36 @@ from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=1, suite="stage-a-test-cpu")
class TestPageAlignedCacheExtent(unittest.TestCase):
def test_extent_uses_page_boundary_not_cp_size(self):
extent = build_page_aligned_cache_extent(valid_tokens=100, page_size=64)
self.assertEqual(extent.valid_tokens, 100)
self.assertEqual(extent.padded_pages, 2)
self.assertEqual(extent.padded_tokens, 128)
self.assertEqual(extent.padding_tokens, 28)
def test_extent_handles_empty_and_aligned_lengths(self):
self.assertEqual(
build_page_aligned_cache_extent(valid_tokens=0, page_size=64),
PageAlignedCacheExtent(
valid_tokens=0,
padded_pages=0,
padded_tokens=0,
padding_tokens=0,
),
)
self.assertEqual(
build_page_aligned_cache_extent(valid_tokens=128, page_size=64),
PageAlignedCacheExtent(
valid_tokens=128,
padded_pages=2,
padded_tokens=128,
padding_tokens=0,
),
)
class TestNSAInSeqCPUtils(unittest.TestCase):
def test_contiguous_valid_cp_query_count(self):
from sglang.srt.layers.attention.nsa.nsa_indexer import (
@@ -118,6 +150,24 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
split_list, extend_prefix_len=0, extend_len=1100, page_size=64
)
def test_page_aligned_split_exposes_padded_extent_without_padding_split_list(self):
split_list, info = build_page_aligned_in_seq_split_list(
total_len=100,
extend_len=100,
extend_prefix_len=0,
page_size=64,
cp_size=8,
)
self.assertTrue(info.page_aligned)
self.assertEqual(sum(split_list), 100)
self.assertEqual(split_list[:2], [64, 36])
self.assertEqual(split_list[2:], [0] * 14)
self.assertEqual(info.extend_valid_tokens, 100)
self.assertEqual(info.extend_padded_pages, 2)
self.assertEqual(info.extend_padded_tokens, 128)
self.assertEqual(info.extend_padding_tokens, 28)
def test_page_aligned_split_falls_back_when_prefix_is_not_page_aligned(self):
split_list, info = build_page_aligned_in_seq_split_list(
total_len=1024,
@@ -130,7 +180,7 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
self.assertFalse(info.page_aligned)
self.assertEqual(split_list, build_token_balanced_in_seq_split_list(1024, 8))
def test_page_aligned_split_falls_back_when_page_units_are_too_short(self):
def test_page_aligned_split_pads_zero_segments_when_page_units_are_short(self):
split_list, info = build_page_aligned_in_seq_split_list(
total_len=512,
extend_len=512,
@@ -139,8 +189,13 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
cp_size=8,
)
self.assertFalse(info.page_aligned)
self.assertEqual(split_list, build_token_balanced_in_seq_split_list(512, 8))
self.assertTrue(info.page_aligned)
self.assertEqual(sum(split_list), 512)
self.assertEqual(split_list[:8], [64] * 8)
self.assertEqual(split_list[8:], [0] * 8)
self.assert_page_aligned_boundaries(
split_list, extend_prefix_len=0, extend_len=512, page_size=64
)
def test_page_aligned_split_allows_radix_hit_suffix_with_one_page_per_rank(self):
split_list, info = build_page_aligned_in_seq_split_list(
@@ -159,7 +214,7 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
split_list, extend_prefix_len=54464, extend_len=512, page_size=64
)
def test_page_aligned_split_falls_back_when_radix_hit_suffix_has_zero_rank(self):
def test_page_aligned_split_keeps_short_radix_hit_suffix_page_aligned(self):
split_list, info = build_page_aligned_in_seq_split_list(
total_len=256,
extend_len=256,
@@ -168,10 +223,15 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
cp_size=8,
)
self.assertFalse(info.page_aligned)
self.assertEqual(split_list, build_token_balanced_in_seq_split_list(256, 8))
self.assertTrue(info.page_aligned)
self.assertEqual(sum(split_list), 256)
self.assertEqual(split_list[:4], [64] * 4)
self.assertEqual(split_list[4:], [0] * 12)
self.assert_page_aligned_boundaries(
split_list, extend_prefix_len=54464, extend_len=256, page_size=64
)
def test_can_cp_split_uses_replicated_compute_for_short_radix_hit_suffix(self):
def test_can_cp_split_keeps_cp_for_short_radix_hit_suffix(self):
class Mode:
def is_context_parallel_extend(self):
return True
@@ -194,7 +254,7 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
return_value=True,
),
):
self.assertFalse(can_cp_split(256, 8, True, forward_batch))
self.assertTrue(can_cp_split(256, 8, True, forward_batch))
def test_can_cp_split_keeps_cp_for_radix_hit_suffix_with_one_page_per_rank(self):
class Mode:
@@ -478,15 +538,15 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
)
with self.assertLogs(
"sglang.srt.layers.attention.nsa.utils", level="INFO"
"sglang.srt.layers.attention.nsa.utils", level="WARNING"
) as cm:
self.assertIsNone(get_cp_shared_kv_local_out_cache_loc(forward_batch))
self.assertIsNone(get_cp_shared_kv_local_out_cache_loc(forward_batch))
self.assertEqual(len(cm.output), 2)
self.assertIn("CP shared KV direct-write fallback", cm.output[0])
self.assertIn("[CP_SHARED_KV_FALLBACK][direct_write]", cm.output[0])
self.assertIn("metadata is not page-aligned", cm.output[0])
self.assertIn("CP shared KV direct-write fallback", cm.output[1])
self.assertIn("[CP_SHARED_KV_FALLBACK][direct_write]", cm.output[1])
self.assertIn("metadata is not page-aligned", cm.output[1])
def test_indexer_direct_write_does_not_log_missing_metadata_for_non_cp_batch(self):

View File

@@ -150,7 +150,7 @@ class TestCPSharedPagedAllocator(CustomTestCase):
[0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 2, 2, 1, 1, 0, 0],
)
def test_compute_owner_page_assignment_falls_back_for_short_extend(self):
def test_compute_owner_page_assignment_keeps_page_aligned_short_extend(self):
from sglang.srt.mem_cache.cp_shared_kv_compute_owner import (
build_in_seq_page_compute_owners,
get_in_seq_page_compute_owner_unavailable_reason,
@@ -163,17 +163,30 @@ class TestCPSharedPagedAllocator(CustomTestCase):
cp_size=4,
)
self.assertIsNone(owners)
self.assertEqual(
self.assertEqual(owners, [0, 1, 2, 3, 3, 2, 1])
self.assertIsNone(
get_in_seq_page_compute_owner_unavailable_reason(
extend_len=64 * 7,
extend_prefix_len=0,
page_size=64,
cp_size=4,
),
"too_short_for_page_aligned",
)
)
def test_compute_owner_page_assignment_uses_tail_page_not_cp_padding(self):
from sglang.srt.mem_cache.cp_shared_kv_compute_owner import (
build_in_seq_page_compute_owners,
)
owners = build_in_seq_page_compute_owners(
extend_len=100,
extend_prefix_len=0,
page_size=64,
cp_size=8,
)
self.assertEqual(owners, [0, 1])
def test_compute_owner_page_assignment_allows_radix_hit_suffix_with_one_page_per_rank(
self,
):
@@ -199,7 +212,7 @@ class TestCPSharedPagedAllocator(CustomTestCase):
)
)
def test_compute_owner_page_assignment_allows_short_radix_hit_suffix_with_replicated_compute(
def test_compute_owner_page_assignment_keeps_short_radix_hit_suffix_page_aligned(
self,
):
from sglang.srt.mem_cache.cp_shared_kv_compute_owner import (

View File

@@ -48,7 +48,7 @@ class _FakeExtendForwardMode:
class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
def test_mla_prefetch_materializes_and_reduces_on_prefetch_stream(
def test_mla_prefetch_materializes_on_current_stream_and_reduces_on_prefetch_stream(
self,
):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
@@ -137,7 +137,7 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.assertEqual(pool.prefetch_getter_streams, [(1, "prefetch")])
self.assertEqual(
calls,
[("materialize", "prefetch"), ("reduce", "prefetch", "prefetch")],
[("materialize", "current"), ("reduce", "prefetch", "prefetch")],
)
self.assertEqual(prefetch_stream.waited, ["current"])
@@ -145,11 +145,11 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.assertEqual(
calls,
[("materialize", "prefetch"), ("reduce", "prefetch", "prefetch")],
[("materialize", "current"), ("reduce", "prefetch", "prefetch")],
)
self.assertEqual(prefetch_stream.waited, ["current"])
def test_index_prefetch_materializes_and_reduces_on_prefetch_stream(
def test_index_prefetch_materializes_on_current_stream_and_reduces_on_prefetch_stream(
self,
):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
@@ -239,7 +239,7 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.assertEqual(pool.prefetch_getter_streams, [(1, "prefetch")])
self.assertEqual(
calls,
[("materialize", "prefetch"), ("reduce", "prefetch", "prefetch")],
[("materialize", "current"), ("reduce", "prefetch", "prefetch")],
)
self.assertEqual(prefetch_stream.waited, ["current"])
@@ -247,7 +247,7 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.assertEqual(
calls,
[("materialize", "prefetch"), ("reduce", "prefetch", "prefetch")],
[("materialize", "current"), ("reduce", "prefetch", "prefetch")],
)
self.assertEqual(prefetch_stream.waited, ["current"])
@@ -539,6 +539,41 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
)
self.assertEqual(mixed_locs.tolist(), [[4, 8, -1], [9, 7, -1]])
def test_fill_current_kv_page_slots_masks_suffix_page_slack(self):
from sglang.srt.layers.attention.nsa.cp_shared_kv_runtime import (
fill_current_kv_page_slots_and_remap_locs,
)
page_size = 4
dense_kv = torch.arange(0, 16, dtype=torch.float32).view(16, 1, 1)
original_dense_kv = dense_kv.clone()
current_kv = torch.arange(100, 102, dtype=torch.float32).view(2, 1, 1)
logical_locs = torch.tensor([[4, 20, 21, 22, 23]], dtype=torch.int32)
# The prefetched dense buffer has physical rows for the whole suffix page,
# but only locs 20 and 21 are real current tokens. Rows for 22 and 23 are
# page slack and must not stay visible.
materialized_locs = torch.tensor([[4, 12, 13, 14, 15]], dtype=torch.int32)
current_locs = torch.tensor([20, 21], dtype=torch.int64)
page_inverse = torch.tensor([0, 1, 2, -1, -1, 3], dtype=torch.int64)
mixed_kv, mixed_locs, current_mask = fill_current_kv_page_slots_and_remap_locs(
dense_kv_cache=dense_kv,
materialized_dense_locs=materialized_locs,
current_kv_cache=current_kv,
logical_locs=logical_locs,
current_locs=current_locs,
page_inverse=page_inverse,
page_size=page_size,
mask_non_current_in_current_pages=True,
)
expected_kv = original_dense_kv.clone()
expected_kv[12:14] = current_kv
self.assertEqual(int(mixed_kv.shape[0]), 16)
self.assertTrue(torch.equal(mixed_kv, expected_kv))
self.assertEqual(current_mask.tolist(), [[False, True, True, False, False]])
self.assertEqual(mixed_locs.tolist(), [[4, 12, 13, -1, -1]])
def test_mla_prefetch_consume_prefix_with_current_skips_suffix_materialize(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
@@ -553,6 +588,7 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
current_stream = FakeCurrentStream()
fake_event = object()
dense_kv = torch.arange(0, 16, dtype=torch.float32).view(16, 1, 1)
original_dense_kv = dense_kv.clone()
current_kv = torch.arange(100, 102, dtype=torch.float32).view(2, 1, 1)
page_inverse = torch.tensor([0, 1, 2, -1, -1, 3], dtype=torch.int64)
prefetcher = prefetch.CpSharedKVMlaPrefetcher(
@@ -583,7 +619,9 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
mixed_kv, mixed_locs = prefetcher.consume_prefix_with_current(
layer_id=1,
kv_cache=torch.zeros((64, 1, 1), dtype=torch.float32),
logical_locs=torch.tensor([[4, 20], [21, 7]], dtype=torch.int32),
logical_locs=torch.tensor(
[[4, 20], [21, 7], [22, 23]], dtype=torch.int32
),
current_kv_cache=current_kv,
current_locs=torch.tensor([20, 21], dtype=torch.int64),
)
@@ -591,11 +629,12 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.assertEqual(current_stream.events, [fake_event])
self.assertEqual(prefetcher.handles, {})
self.assertIsNone(prefetcher.pending_attention_handle)
self.assertTrue(torch.equal(mixed_kv[:16], dense_kv))
self.assertTrue(torch.equal(mixed_kv[16:], current_kv))
self.assertEqual(mixed_locs.tolist(), [[4, 16], [17, 7]])
expected_kv = original_dense_kv.clone()
expected_kv[12:14] = current_kv
self.assertTrue(torch.equal(mixed_kv, expected_kv))
self.assertEqual(mixed_locs.tolist(), [[4, 12], [13, 7], [-1, -1]])
def test_mla_prefetch_attention_window_waits_on_pending_event(self):
def test_mla_prefetch_attention_window_defers_pending_event_wait(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
@@ -631,11 +670,11 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
):
prefetcher.wait_attention_window()
self.assertEqual(current_stream.events, [fake_event])
self.assertIsNone(prefetcher.pending_attention_handle)
self.assertEqual(current_stream.events, [])
self.assertIs(prefetcher.pending_attention_handle, handle)
self.assertIs(prefetcher.handles[1], handle)
def test_mla_prefetch_attention_window_launches_pending_reduce_before_wait(self):
def test_mla_prefetch_attention_window_does_not_launch_pending_reduce(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
@@ -647,7 +686,6 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
self.events.append(event)
current_stream = FakeCurrentStream()
fake_event = object()
prefetcher = prefetch.CpSharedKVMlaPrefetcher(
layout=CpSharedKVLayout(page_size=4, cp_size=2, cp_rank=0),
page_size=4,
@@ -666,21 +704,19 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
prefetcher.handles[1] = handle
prefetcher.pending_attention_handle = handle
def finish_reduce():
handle.event = fake_event
with patch.object(
prefetch.torch.cuda, "current_stream", return_value=current_stream
), patch.object(
prefetcher, "launch_pending_reduce", side_effect=finish_reduce
prefetcher, "launch_pending_reduce"
) as launch_pending_reduce:
prefetcher.wait_attention_window()
launch_pending_reduce.assert_called_once_with()
self.assertEqual(current_stream.events, [fake_event])
self.assertIsNone(prefetcher.pending_attention_handle)
launch_pending_reduce.assert_not_called()
self.assertEqual(current_stream.events, [])
self.assertIs(prefetcher.pending_attention_handle, handle)
self.assertIsNone(handle.event)
def test_index_prefetch_attention_window_waits_on_pending_event(self):
def test_index_prefetch_attention_window_defers_pending_event_wait(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
@@ -715,10 +751,69 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
):
prefetcher.wait_attention_window()
self.assertEqual(current_stream.events, [fake_event])
self.assertIsNone(prefetcher.pending_attention_handle)
self.assertEqual(current_stream.events, [])
self.assertIs(prefetcher.pending_attention_handle, handle)
self.assertIs(prefetcher.handles[1], handle)
def test_index_prefetch_tail_page_keeps_valid_length_unpadded(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
class FakeCurrentStream:
def __init__(self):
self.events = []
def wait_event(self, event):
self.events.append(event)
page_size = 4
valid_tokens = 10
padded_pages = (valid_tokens + page_size - 1) // page_size
cp_size = 8
page_buffer = torch.arange(0, 8 * 3, dtype=torch.uint8).view(8, 3)
dense_page_buffer = torch.zeros((padded_pages + 1, 3), dtype=torch.uint8)
dense_page_buffer[1] = page_buffer[1]
fake_event = object()
prefetcher = prefetch.CpSharedKVIndexPrefetcher(
layout=CpSharedKVLayout(page_size=page_size, cp_size=cp_size, cp_rank=0),
prefix_pages=1,
slot_logical_pages=torch.tensor([1, 2, 3], dtype=torch.int64),
page_inverse=torch.tensor([-1, 1, 2, 3], dtype=torch.int64),
dense_num_pages=padded_pages,
stream=object(),
)
handle = prefetch.CpSharedKVIndexPrefetchHandle(
layer_id=1,
dense_page_buffer=dense_page_buffer,
prefix_rows=slice(1, 2),
event=fake_event,
)
prefetcher.handles[1] = handle
prefetcher.pending_attention_handle = handle
with patch.object(
prefetch.torch.cuda, "current_stream", return_value=FakeCurrentStream()
), patch.object(
prefetch, "_all_reduce_materialized_buffer_range", _identity_all_reduce
):
dense_pages_buffer, dense_pages = prefetcher.consume(
layer_id=1,
page_buffer=page_buffer,
logical_pages=torch.tensor([[1, 2, 3]], dtype=torch.int32),
)
self.assertEqual(valid_tokens, 10)
self.assertEqual(padded_pages, 3)
self.assertEqual(dense_pages.tolist(), [[1, 2, 3]])
self.assertEqual(dense_pages.shape[1], padded_pages)
self.assertLess(dense_pages.shape[1], cp_size)
self.assertEqual(list(dense_pages_buffer.shape), [padded_pages + 1, 3])
self.assertTrue(torch.equal(dense_pages_buffer[1], page_buffer[1]))
# Index kernels receive valid sequence lengths separately; the dense
# table includes the tail page, but no fake 12-token length is exposed.
self.assertLess(valid_tokens, padded_pages * page_size)
self.assertEqual(valid_tokens, 10)
def test_materialize_local_token_kv_pages(self):
from sglang.srt.layers.attention.nsa.cp_shared_kv_runtime import (
build_dense_page_remap,
@@ -1023,6 +1118,54 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
with envs.SGLANG_CP_SHARED_KV_LOG_MLA_PREFETCH.override(True):
self.assertTrue(cp_shared_kv_mla_prefetch_log_enabled())
def test_mla_prefetch_prefix_misalignment_logs_warning_fallback(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
class Mode:
def is_context_parallel_extend(self):
return True
forward_batch = SimpleNamespace(
uses_cp_shared_kv=True,
hisparse_coordinator=None,
forward_mode=Mode(),
batch_size=1,
token_to_kv_pool=SimpleNamespace(page_size=64),
cp_shared_kv_layout=SimpleNamespace(cp_size=1, cp_rank=0),
extend_prefix_lens_cpu=[65],
)
metadata = SimpleNamespace(
real_page_table=torch.tensor([1, 2], dtype=torch.int64),
page_table_1=torch.tensor([[64, 65]], dtype=torch.int32),
)
with patch.object(
prefetch, "cp_shared_kv_mla_prefetch_enabled", return_value=True
), patch.object(
prefetch, "cp_shared_kv_debug_enabled", return_value=False
), patch.object(
prefetch.torch.cuda, "is_available", return_value=True
), patch.object(
prefetch, "_is_cuda_stream_capturing", return_value=False
), patch.object(
prefetch, "is_nsa_prefill_cp_in_seq_split", return_value=True
), patch.object(
prefetch.logger, "warning"
) as logger:
result = prefetch.CpSharedKVMlaPrefetcher.maybe_create(
forward_batch=forward_batch,
metadata=metadata,
topk_transform_is_paged=True,
)
self.assertIsNone(result)
logger.assert_called_once()
self.assertIn(
"[CP_SHARED_KV_FALLBACK][mla_prefetch]",
logger.call_args.args[0],
)
self.assertIn("prefix_not_page_aligned", logger.call_args.args[1])
def test_mla_prefetch_min_prefix_pages_uses_cached_token_default_and_can_override(self):
from sglang.srt.environ import envs
from sglang.srt.layers.attention.nsa import cp_shared_kv_runtime as runtime
@@ -1053,7 +1196,7 @@ class TestCpSharedKVRuntimeHelpers(unittest.TestCase):
with envs.SGLANG_CP_SHARED_KV_MLA_PREFETCH_MIN_PREFIX_PAGES.override(-2):
self.assertEqual(
runtime.cp_shared_kv_mla_prefetch_min_prefix_pages(4, page_size=64),
16,
max(4, expected_pages),
)
with patch.object(runtime, "_MLA_PREFETCH_DEFAULT_MIN_PREFIX_TOKENS", 2048):