Reduce CP shared KV overhead without changing ownership semantics

The shared-KV path now keeps more CP metadata on-device and reuses
physical out-cache locations across MLA and NSA index writes, so each
layer avoids repeating logical-to-physical remaps. The in-seq CP
all-gather rerange path now delegates to tai-kernel when available and
falls back to the existing torch split/cat path with an explicit log.

This also extends the Phase8 prefetch machinery to cover shared KV
materialization metadata and keeps debug/fallback behavior gated so the
fast path is not polluted by diagnostic checks.

Constraint: Custom CP kernels must live in tai-kernel and be imported lazily from SGLang
Constraint: Decode does not use CP; these changes target NSA prefill CP in-seq-split shared KV
Rejected: Recompute physical local cache locations separately for MLA and index writes | repeats the same remap work every layer
Rejected: Keep the in-seq rerange Triton code inline in SGLang | duplicates kernel ownership and blocks tai-kernel reuse
Confidence: medium
Scope-risk: moderate
Directive: Keep CP collective ordering identical across ranks; do not add rank-local fallback decisions inside shared KV materialize paths
Tested: Remote g0034 container py_compile for modified SGLang/tai-kernel files; remote pytest test/registered/unit/layers/test_nsa_cp_utils.py passed with 24 tests
Not-tested: Full multi-node GLM5 prefill/decode throughput after the final commit boundary
This commit is contained in:
laoyao0822
2026-05-06 05:27:43 +08:00
parent 5e5ac5e2e7
commit 43ad2fe52d
10 changed files with 1152 additions and 46 deletions
@@ -1,8 +1,8 @@
# NSA Prefill CP Phase 8: MLA prefix prefetch
# NSA Prefill CP Phase 8: MLA / index prefix prefetch
Phase 8 的目标是在不改变 Phase 2-7 shared KV 语义的前提下,为 chunked prefill / radix cache hit 场景引入 **MLA KV one-layer-ahead prefix prefetch**,把历史 prefix 的 shared KV materialize 从当前层 attention 前的同步阻塞路径里移出,并把等待点延迟到下一层真正消费 prefetched KV 时。
Phase 8 的目标是在不改变 Phase 2-7 shared KV 语义的前提下,为 chunked prefill / radix cache hit 场景引入 **MLA KV 与 NSA index K/scale 的 one-layer-ahead prefix prefetch**,把历史 prefix 的 shared KV/index materialize 从当前层同步阻塞路径里移出,并把等待点延迟到下一层真正消费 prefetched buffer 时。
本阶段只做 MLA KV prefix prefetch。暂时不做 index K/scale prefetch不引入 `SGLANG_CP_SHARED_KV_LAYER_PREFETCH_KIND` 这类选择型环境变量,也不保留 `SGLANG_CP_SHARED_KV_MLA_PREFETCH_WAIT_AFTER_ATTENTION` 这类 wait 策略开关。
本阶段不引入 `SGLANG_CP_SHARED_KV_LAYER_PREFETCH_KIND` 这类选择型环境变量,也不保留 `SGLANG_CP_SHARED_KV_MLA_PREFETCH_WAIT_AFTER_ATTENTION` 这类 wait 策略开关。`SGLANG_CP_SHARED_KV_ENABLE_MLA_PREFETCH=1` 现在同时打开 MLA KV prefix prefetch 和 NSA index K/scale prefix prefetch。
---
@@ -12,15 +12,16 @@ Phase 8 的目标是在不改变 Phase 2-7 shared KV 语义的前提下,为 ch
Phase 8 已按 deferred-consume 策略实现:
```text
Layer L index/topk 后启动 Layer L+1 prefix index K/scale prefetch
Layer L attention 前启动 Layer L+1 prefix MLA KV prefetch
Layer L forward_extend 返回时不等待 prefetch
Layer L+1 consume prefetched KV 时 wait event,再补齐 suffix/current pages
Layer L attention 返回时不强制等待 prefetch
Layer L+1 consume prefetched buffer 时 wait event,再补齐 suffix/current pages
```
保留的环境变量:
```text
SGLANG_CP_SHARED_KV_ENABLE_MLA_PREFETCH=0/1 # 生产开关,默认关闭
SGLANG_CP_SHARED_KV_ENABLE_MLA_PREFETCH=0/1 # 生产开关,默认关闭;同时控制 MLA KV 与 index K/scale prefetch
SGLANG_CP_SHARED_KV_LOG_MLA_PREFETCH=0/1 # 调试日志,只打印 probe layer,默认关闭
```
@@ -48,13 +49,13 @@ owner-sharded physical MLA KV on each CP rank
-> existing NSA attention kernel
```
在 chunked prefill 的第二个 chunk 及之后,或者 radix cache 命中时,`extend_prefix_len > 0`。这部分 prefix KV 已经在之前的 chunk/request 中写入 persistent KV pool;当前层 attention 仍会同步 materialize 整个可见 KV,包括历史 prefix 和当前 suffix。
在 chunked prefill 的第二个 chunk 及之后,或者 radix cache 命中时,`extend_prefix_len > 0`。这部分 prefix KV 与 index K/scale 已经在之前的 chunk/request 中写入 persistent pool;当前层 index/topk 和 attention 仍会同步 materialize 整个可见范围,包括历史 prefix 和当前 suffix。
Phase 8 的机会是:
```text
历史 prefix 部分已经存在,可以提前为下一层 materialize。
当前 suffix 部分必须等下一层 prepare 写入后才能 materialize。
当前 suffix 部分必须等下一层 prepare/index write 写入后才能 materialize。
```
因此 Phase 8 不做跨 chunk 的 dense KV cache 复用,而是做 **每次 forward 内、相邻 layer 之间的一层提前预取**
@@ -173,12 +174,12 @@ _get_topk_in_seq_cp_pair(...)
-> materialize_shared_paged_buffer(...)
```
Phase 8 暂时不碰 index path。原因
Index K/scale prefetch 在 MLA KV prefetch 之后补齐。当前约束
1. index materialize 已经完成 Phase 6 的一次合并;
2. indexer 在 MLA attention 之前执行,调度窗口不同;
3. 本阶段目标是先验证 MLA prefix prefetch 是否能显著隐藏最大块的 KV materialize
4. 避免同时改变 topk/index 和 attention KV 两条路径,降低正确性风险
3. 当前实现先落地 MLA KV,再补 index K/scale;两者共用同一个 page-aligned slot layout
4. index K/scale prefetch 只覆盖 PAGED topk 路径,RAGGED 仍不进入本阶段
---
@@ -192,9 +193,16 @@ Phase 8 暂时不碰 index path。原因:
Layer L attention 计算期间,
提前为 Layer L+1 materialize prefix MLA KV。
Layer L topk/indexer 完成后,
提前为 Layer L+1 materialize prefix index K/scale。
Layer L+1 attention 前,
复用已经 materialize 完成的 prefix dense KV
只同步补齐 current/suffix pages。
Layer L+1 topk/indexer 前,
复用已经 materialize 完成的 prefix dense index buffer
只同步补齐 current/suffix pages。
```
### 3.2 性能目标
@@ -225,7 +233,6 @@ next layer consume waits only if prefetch has not finished
Phase 8 不做:
- 不做 NSA index K/scale prefetch
- 不引入 `SGLANG_CP_SHARED_KV_LAYER_PREFETCH_KIND`
- 不做 bandwidth throttle/page budget
- 不做多层 dense KV 常驻缓存;
@@ -576,7 +583,7 @@ disable MLA prefetch and fallback sync materialize
2. 太早会和当前层 index materialize/topk 抢带宽;
3. 太早会增加 NCCL collective 顺序风险。
推荐在 `NativeSparseAttnBackend.forward_extend(...)` 中启动:
MLA KV 推荐在 `NativeSparseAttnBackend.forward_extend(...)` 中启动:
```text
1. 当前层需要的 kv_cache/page_table_1 已经准备好;
@@ -614,6 +621,19 @@ if prefetcher is not None:
return attn_output
```
Index K/scale 的启动点在 `Indexer.forward_cuda(...)` 末尾:
```python
topk_result = run_current_layer_topk(...)
index_prefetcher.start_next_layer_prefix(
next_layer_id=layer_id + 1,
token_to_kv_pool=forward_batch.token_to_kv_pool,
)
return topk_result
```
当前层 `_maybe_materialize_shared_index_buffer(...)` 优先消费 `cp_shared_kv_index_prefetcher.consume(...)`miss 时回退原来的 `materialize_shared_paged_buffer(...)`,因此关闭环境变量或 prefetch 条件不满足时语义不变。
`wait_attention_window()` 仍在 `forward_extend(...)` 的 finally 中调用,但当前实现不做同步等待,只保留 deferred 日志/状态检查入口。真正的同步点在下一层 `consume(...)`
```text
@@ -790,9 +810,10 @@ Phase 8 的 local copy/remap helper 应尽量复用 Phase 7 的 tai materialize
```text
SGLANG_CP_SHARED_KV_ENABLE_MLA_PREFETCH = EnvBool(False)
forward_batch.cp_shared_kv_mla_prefetcher
forward_batch.cp_shared_kv_index_prefetcher
```
### Step 2: 拆分 token materialize helper
### Step 2: 拆分 token/page materialize helper
修改:
@@ -803,6 +824,8 @@ forward_batch.cp_shared_kv_mla_prefetcher
```text
slot remap builder
range local token KV materialize into existing dense buffer
range local paged buffer materialize into existing dense buffer
logical page -> slot dense page remap
sync range all-reduce
async all-reduce wrapper
```
@@ -813,7 +836,7 @@ async all-reduce wrapper
full materialize 结果 == prefix materialize + suffix materialize 结果
```
### Step 3: 新增 MLA prefetcher
### Step 3: 新增 MLA / index prefetcher
新增:
@@ -822,7 +845,7 @@ full materialize 结果 == prefix materialize + suffix materialize 结果
职责:
```text
gate
common gate
allocate dense buffer
prefetch prefix range
record event
@@ -831,7 +854,15 @@ defer wait until consume
fallback
```
### Step 4: 接入 `nsa_backend.forward_extend`
Index K/scale prefetch 与 MLA prefetch 使用相同 slot-layout page table
```text
real_page_table.reshape(-1)[slot] -> dense page slot + 1
```
区别是 MLA KV materialize 的单位是 token rows,需要 `page_size` 展开;index K/scale materialize 的单位是 page rowsprefix/suffix range 直接对应 dense page 行。
### Step 4: 接入 `nsa_backend.forward_extend` / `nsa_indexer`
修改:
@@ -840,7 +871,7 @@ fallback
接入位置:
```text
shared KV PAGED path 的 MLA materialize 分支。
shared KV PAGED path 的 MLA materialize 分支,以及 NSA indexer 的 `_maybe_materialize_shared_index_buffer(...)`
```
逻辑:
@@ -853,6 +884,15 @@ run attention
do not wait before return; consume waits before use
```
Index K/scale 的启动点在当前层 topk/indexer 完成后:
```text
Layer L writes current layer index K/scale
Layer L materializes/uses current layer index K/scale for topk
Layer L starts Layer L+1 prefix index K/scale prefetch
Layer L+1 indexer consumes prefetched prefix and fills suffix
```
### Step 5: 单元测试
修改:
@@ -861,12 +901,13 @@ do not wait before return; consume waits before use
新增覆盖:
1. prefix/suffix range materialize 拼接结果等价于 full materialize
1. token/paged prefix/suffix range materialize 拼接结果等价于 full materialize
2. prefix page-aligned gate
3. prefix_len=0 不 prefetch
4. non-PAGED / batch_size>1 / debug enabled fallback
5. consume handle layer mismatch fallback
6. started async handle 必须 wait。
7. index materialize 优先消费 prefetched buffermiss 后回退 full materialize
### Step 6: 远端集成验证
@@ -885,7 +926,7 @@ router: g0034
3. 重复请求触发 radix hit
4. 检查输出质量;
5. 检查没有 collective hang
6. profile 确认 `cp_shared_kv.materialize.token` 同步时间下降或被后续计算 overlap,并能看到下一层 consume 前的必要 wait。
6. profile 确认 `cp_shared_kv.materialize.token` / index paged materialize 同步时间下降或被后续计算 overlap,并能看到下一层 consume 前的必要 wait。
---
@@ -972,4 +1013,3 @@ Fallback
debug enabled 时自动回到现有同步 materialize
pynccl unavailable 时自动回到现有同步 materialize
```
@@ -10,13 +10,19 @@ from sglang.srt.layers.attention.nsa.cp_shared_kv_runtime import (
_all_reduce_materialized_buffer_async,
_all_reduce_materialized_buffer_range,
build_shared_token_kv_slot_remap,
build_slot_page_inverse_optimized,
build_slot_page_remap,
cp_shared_kv_debug_enabled,
cp_shared_kv_mla_prefetch_enabled,
cp_shared_kv_mla_prefetch_log,
cp_shared_kv_mla_prefetch_should_log_layer,
filter_locs_mappable_to_physical_pool,
filter_pages_mappable_to_physical_pool,
materialize_local_paged_buffer_page_slots_into,
materialize_local_token_kv_page_slots_into,
remap_logical_pages_to_slot_dense_pages,
remap_logical_locs_to_slot_dense_locs_optimized,
slot_range_to_page_slice,
slot_range_to_token_slice,
)
from sglang.srt.layers.attention.nsa.utils import is_nsa_prefill_cp_in_seq_split
@@ -30,6 +36,14 @@ def _prefetch_log(message: str, *args) -> None:
cp_shared_kv_mla_prefetch_log(message, *args)
def _index_prefetch_fallback_log(reason: str, message: str, *args) -> None:
logger.info(
"CP shared KV index prefetch fallback (%s): " + message,
reason,
*args,
)
def _is_cuda_stream_capturing() -> bool:
if not torch.cuda.is_available():
return False
@@ -46,6 +60,13 @@ class CpSharedKVMlaPrefetchHandle:
event: torch.cuda.Event
@dataclass
class CpSharedKVIndexPrefetchHandle:
layer_id: int
dense_page_buffer: torch.Tensor
event: torch.cuda.Event
class CpSharedKVMlaPrefetcher:
"""One-layer-ahead MLA prefix materialize prefetch for CP shared KV.
@@ -416,3 +437,423 @@ class CpSharedKVMlaPrefetcher:
self.layout.cp_size,
*args,
)
class CpSharedKVIndexPrefetcher:
"""One-layer-ahead NSA index K/scale prefix materialize prefetch.
The index buffer is page-granular, unlike MLA KV which is token-granular.
It still uses the same slot-layout page table as MLA prefetch so the next
layer can consume the prefetched prefix and synchronously fill only current
suffix pages before running fp8 MQA/topk.
"""
def __init__(
self,
*,
layout: CpSharedKVLayout,
prefix_pages: int,
slot_logical_pages: torch.Tensor,
page_inverse: torch.Tensor,
dense_num_pages: int,
) -> None:
self.layout = layout
self.prefix_pages = prefix_pages
self.slot_logical_pages = slot_logical_pages
self.page_inverse = page_inverse
self.dense_num_pages = dense_num_pages
self.total_slots = int(slot_logical_pages.numel())
self.stream = torch.cuda.Stream()
self.handles: dict[int, CpSharedKVIndexPrefetchHandle] = {}
self.pending_attention_handle: Optional[CpSharedKVIndexPrefetchHandle] = None
self.disabled = False
@classmethod
def maybe_create(
cls,
*,
forward_batch: Any,
metadata: Any,
topk_transform_is_paged: bool,
) -> Optional["CpSharedKVIndexPrefetcher"]:
if not cp_shared_kv_mla_prefetch_enabled():
return None
if cp_shared_kv_debug_enabled():
_index_prefetch_fallback_log(
"debug_enabled",
"SGLANG_DEBUG_CP_SHARED_KV is enabled.",
)
return None
if not torch.cuda.is_available() or _is_cuda_stream_capturing():
_index_prefetch_fallback_log(
"cuda_unavailable_or_stream_capturing",
"CUDA is unavailable or the current stream is capturing. cuda_available=%s",
torch.cuda.is_available(),
)
return None
if not getattr(forward_batch, "uses_cp_shared_kv", False):
_index_prefetch_fallback_log(
"not_cp_shared_kv",
"forward batch is not using CP shared KV.",
)
return None
if getattr(forward_batch, "hisparse_coordinator", None) is not None:
_index_prefetch_fallback_log(
"hisparse",
"HiSparse coordinator is active.",
)
return None
forward_mode = getattr(forward_batch, "forward_mode", None)
if forward_mode is None or not forward_mode.is_context_parallel_extend():
_index_prefetch_fallback_log(
"not_context_parallel_extend",
"forward mode is not context-parallel extend. forward_mode=%s",
forward_mode,
)
return None
if not is_nsa_prefill_cp_in_seq_split():
_index_prefetch_fallback_log(
"not_in_seq_split",
"NSA prefill CP mode is not in-seq-split.",
)
return None
if not topk_transform_is_paged:
_index_prefetch_fallback_log(
"not_paged_topk",
"topk transform is not PAGED.",
)
return None
if int(getattr(forward_batch, "batch_size", 0)) != 1:
_index_prefetch_fallback_log(
"batch_size",
"batch size is not supported. batch_size=%s",
getattr(forward_batch, "batch_size", None),
)
return None
token_to_kv_pool = getattr(forward_batch, "token_to_kv_pool", None)
if token_to_kv_pool is None:
_index_prefetch_fallback_log(
"missing_token_to_kv_pool",
"forward batch has no token_to_kv_pool.",
)
return None
if getattr(token_to_kv_pool, "layer_transfer_counter", None) is not None:
_index_prefetch_fallback_log(
"layer_transfer_active",
"layer transfer is active.",
)
return None
layout = getattr(forward_batch, "cp_shared_kv_layout", None)
if layout is None:
_index_prefetch_fallback_log(
"missing_layout",
"forward batch has no CP shared KV layout.",
)
return None
extend_prefix_lens_cpu = getattr(forward_batch, "extend_prefix_lens_cpu", None)
if extend_prefix_lens_cpu is None or len(extend_prefix_lens_cpu) != 1:
_index_prefetch_fallback_log(
"bad_prefix_lens_metadata",
"extend_prefix_lens_cpu is missing or not single-batch. value=%s",
extend_prefix_lens_cpu,
)
return None
page_size = int(getattr(token_to_kv_pool, "page_size", 1))
if page_size <= 1:
_index_prefetch_fallback_log(
"bad_page_size",
"page size is not supported. page_size=%s",
page_size,
)
return None
extend_prefix_len = int(extend_prefix_lens_cpu[0])
if extend_prefix_len <= 0 or extend_prefix_len % page_size != 0:
_index_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,
)
return None
prefix_pages = extend_prefix_len // page_size
real_page_table = getattr(metadata, "real_page_table", None)
page_table_1 = getattr(metadata, "page_table_1", None)
if real_page_table is None or page_table_1 is None:
_index_prefetch_fallback_log(
"missing_page_tables",
"metadata is missing real_page_table or page_table_1.",
)
return None
if prefix_pages <= 0 or prefix_pages > int(real_page_table.numel()):
_index_prefetch_fallback_log(
"prefix_pages_out_of_range",
"prefix pages are outside real page table. prefix_pages=%s real_pages=%s",
prefix_pages,
int(real_page_table.numel()),
)
return None
cp_group = get_attention_cp_group()
if getattr(cp_group, "pynccl_comm", None) is None and layout.cp_size > 1:
_index_prefetch_fallback_log(
"missing_pynccl",
"pynccl communicator is unavailable. cp_rank=%s cp_size=%s",
layout.cp_rank,
layout.cp_size,
)
return None
try:
first_layer_id = int(getattr(token_to_kv_pool, "start_layer", 0))
page_buffer = token_to_kv_pool.get_index_k_with_scale_buffer(
layer_id=first_layer_id
)
remap_logical_pages = filter_pages_mappable_to_physical_pool(
logical_pages=real_page_table,
layout=layout,
physical_page_capacity=page_buffer.shape[0],
)
slot_logical_pages, _ = build_slot_page_remap(remap_logical_pages)
logical_page_capacity = max(int(page_buffer.shape[0]) - 1, 0) * (
layout.cp_size
) + 1
page_inverse = build_slot_page_inverse_optimized(
slot_logical_pages,
logical_page_capacity=logical_page_capacity,
)
except Exception as exc:
_index_prefetch_fallback_log(
"init_exception",
"failed to initialize index prefetcher; falling back to sync materialize. error=%s",
exc,
)
logger.exception("Failed to initialize CP shared KV index prefetcher.")
return None
_prefetch_log(
"index_create cp_rank=%s cp_size=%s prefix_pages=%s total_slots=%s dense_pages=%s page_size=%s",
layout.cp_rank,
layout.cp_size,
prefix_pages,
int(slot_logical_pages.numel()),
int(slot_logical_pages.numel()) + 1,
page_size,
)
return cls(
layout=layout,
prefix_pages=prefix_pages,
slot_logical_pages=slot_logical_pages,
page_inverse=page_inverse,
dense_num_pages=int(slot_logical_pages.numel()) + 1,
)
def _layer_in_pool(self, token_to_kv_pool: Any, layer_id: int) -> bool:
start_layer = int(getattr(token_to_kv_pool, "start_layer", 0))
kv_buffer = getattr(token_to_kv_pool, "kv_buffer", None)
if kv_buffer is None:
return layer_id >= start_layer
return start_layer <= layer_id < start_layer + len(kv_buffer)
def consume(
self,
*,
layer_id: int,
page_buffer: torch.Tensor,
logical_pages: torch.Tensor,
) -> Optional[tuple[torch.Tensor, torch.Tensor]]:
if self.disabled:
self._log_layer(
layer_id,
"index_consume_skip reason=disabled layer=%s",
layer_id,
)
return None
handle = self.handles.pop(layer_id, None)
if handle is None:
self._log_layer(layer_id, "index_consume_miss layer=%s", layer_id)
return None
if self.pending_attention_handle is handle:
self.pending_attention_handle = None
if handle.layer_id != layer_id:
self.disabled = True
self._log_layer(
layer_id,
"index_consume_skip reason=layer_mismatch expected=%s actual=%s",
layer_id,
handle.layer_id,
)
return None
torch.cuda.current_stream().wait_event(handle.event)
dense_page_buffer = handle.dense_page_buffer
suffix_slots = self.total_slots - self.prefix_pages
if self.prefix_pages < self.total_slots:
materialize_local_paged_buffer_page_slots_into(
page_buffer=page_buffer,
dense_page_buffer=dense_page_buffer,
slot_logical_pages=self.slot_logical_pages,
layout=self.layout,
start_slot=self.prefix_pages,
end_slot=self.total_slots,
)
suffix_rows = slot_range_to_page_slice(
self.prefix_pages,
self.total_slots,
)
_all_reduce_materialized_buffer_range(
dense_page_buffer,
self.layout.cp_size,
suffix_rows.start,
suffix_rows.stop,
)
self._log_layer(
layer_id,
"index_consume_hit layer=%s prefix_pages=%s suffix_slots=%s dense_pages=%s",
layer_id,
self.prefix_pages,
suffix_slots,
int(dense_page_buffer.shape[0]),
)
logical_pages = filter_pages_mappable_to_physical_pool(
logical_pages=logical_pages,
layout=self.layout,
physical_page_capacity=page_buffer.shape[0],
)
dense_pages = remap_logical_pages_to_slot_dense_pages(
logical_pages,
page_inverse=self.page_inverse,
)
return dense_page_buffer, dense_pages
def start_next_layer_prefix(
self,
*,
next_layer_id: int,
token_to_kv_pool: Any,
) -> None:
if self.disabled:
self._log_next_layer(
next_layer_id,
"index_start_skip reason=disabled next_layer=%s",
next_layer_id,
)
return
if next_layer_id in self.handles:
self._log_next_layer(
next_layer_id,
"index_start_skip reason=already_started next_layer=%s",
next_layer_id,
)
return
if not self._layer_in_pool(token_to_kv_pool, next_layer_id):
self._log_next_layer(
next_layer_id,
"index_start_skip reason=layer_out_of_pool next_layer=%s",
next_layer_id,
)
return
try:
page_buffer = token_to_kv_pool.get_index_k_with_scale_buffer(
layer_id=next_layer_id
)
except Exception:
logger.exception(
"Failed to get next-layer index buffer for CP shared KV index prefetch."
)
self.disabled = True
self._log_next_layer(
next_layer_id,
"index_start_disable reason=get_index_buffer_failed next_layer=%s",
next_layer_id,
)
return
current_stream = torch.cuda.current_stream()
self.stream.wait_stream(current_stream)
try:
with torch.cuda.stream(self.stream):
dense_page_buffer = page_buffer.new_zeros(
(self.dense_num_pages, *page_buffer.shape[1:])
)
materialize_local_paged_buffer_page_slots_into(
page_buffer=page_buffer,
dense_page_buffer=dense_page_buffer,
slot_logical_pages=self.slot_logical_pages,
layout=self.layout,
start_slot=0,
end_slot=self.prefix_pages,
)
prefix_rows = slot_range_to_page_slice(0, self.prefix_pages)
event = _all_reduce_materialized_buffer_async(
dense_page_buffer[prefix_rows],
cp_size=self.layout.cp_size,
stream=self.stream,
)
if event is None:
self.disabled = True
self._log_next_layer(
next_layer_id,
"index_start_disable reason=async_reduce_unavailable next_layer=%s",
next_layer_id,
)
return
except Exception:
logger.exception("Failed to start CP shared KV index prefix prefetch.")
self.disabled = True
self._log_next_layer(
next_layer_id,
"index_start_disable reason=start_exception next_layer=%s",
next_layer_id,
)
return
handle = CpSharedKVIndexPrefetchHandle(
layer_id=next_layer_id,
dense_page_buffer=dense_page_buffer,
event=event,
)
self.handles[next_layer_id] = handle
self.pending_attention_handle = handle
self._log_next_layer(
next_layer_id,
"index_start next_layer=%s prefix_pages=%s dense_pages=%s",
next_layer_id,
self.prefix_pages,
int(dense_page_buffer.shape[0]),
)
def wait_attention_window(self) -> None:
handle = self.pending_attention_handle
if handle is None:
return
self._log_next_layer(
handle.layer_id,
"index_attention_wait_deferred next_layer=%s",
handle.layer_id,
)
def _log_layer(self, layer_id: int, message: str, *args) -> None:
if cp_shared_kv_mla_prefetch_should_log_layer(layer_id):
self._log(message, *args)
def _log_next_layer(self, next_layer_id: int, message: str, *args) -> None:
if cp_shared_kv_mla_prefetch_should_log_layer(next_layer_id):
self._log(message, *args)
def _log(self, message: str, *args) -> None:
_prefetch_log(
"cp_rank=%s cp_size=%s " + message,
self.layout.cp_rank,
self.layout.cp_size,
*args,
)
@@ -773,6 +773,43 @@ def remap_logical_locs_to_slot_dense_locs(
return torch.where(mapped, dense_values, dense_locs)
def remap_logical_pages_to_slot_dense_pages(
logical_pages: torch.Tensor,
page_inverse: torch.Tensor,
) -> torch.Tensor:
"""Map logical page ids through a fixed-shape slot page inverse.
This is the paged-buffer equivalent of
`remap_logical_locs_to_slot_dense_locs`. Page 0 remains the dummy dense page
0; negative sentinels and pages absent from `page_inverse` remain `-1`.
"""
dense_pages_out = torch.full_like(logical_pages, -1)
if logical_pages.numel() == 0 or page_inverse.numel() == 0:
return dense_pages_out
pages_long = logical_pages.to(torch.long)
valid_pages = pages_long >= 0
safe_pages = torch.where(valid_pages, pages_long, torch.zeros_like(pages_long))
pages_in_range = safe_pages < page_inverse.numel()
clamped_pages = torch.clamp(safe_pages, max=page_inverse.numel() - 1)
dense_pages = page_inverse[clamped_pages]
mapped = valid_pages & pages_in_range & (dense_pages >= 0)
if cp_shared_kv_debug_enabled() and torch.any(
valid_pages & pages_in_range & (dense_pages < 0)
):
missing_pages = pages_long[valid_pages & pages_in_range & (dense_pages < 0)]
raise RuntimeError(
"CP shared KV slot remap got logical pages outside remap_logical_pages. "
f"missing_page_min={int(missing_pages.min().item())} "
f"missing_page_max={int(missing_pages.max().item())} "
f"logical_pages={tensor_debug_summary(logical_pages)}"
)
return torch.where(mapped, dense_pages.to(logical_pages.dtype), dense_pages_out)
def build_shared_token_kv_slot_remap(
kv_cache: torch.Tensor,
logical_locs: torch.Tensor | None,
@@ -1261,7 +1298,50 @@ def materialize_local_paged_buffer_page_slots(
if slot_logical_pages.numel() == 0:
return dense_page_buffer
logical_pages = slot_logical_pages.reshape(-1).to(torch.long)
materialize_local_paged_buffer_page_slots_into(
page_buffer=page_buffer,
dense_page_buffer=dense_page_buffer,
slot_logical_pages=slot_logical_pages,
layout=layout,
start_slot=0,
end_slot=int(slot_logical_pages.numel()),
)
return dense_page_buffer
def materialize_local_paged_buffer_page_slots_into(
page_buffer: torch.Tensor,
dense_page_buffer: torch.Tensor,
slot_logical_pages: torch.Tensor,
layout: CpSharedKVLayout,
start_slot: int,
end_slot: int | None = None,
) -> None:
"""Materialize a slot range into an existing dense paged buffer.
Dense page 0 is the dummy page; slot `i` writes dense page `i + 1`.
This is used by layer prefetch: prefix pages are written asynchronously,
then only suffix pages are filled synchronously when the layer is reached.
"""
flat_slot_logical_pages = slot_logical_pages.reshape(-1)
total_slots = int(flat_slot_logical_pages.numel())
if end_slot is None:
end_slot = total_slots
if start_slot < 0 or end_slot < start_slot or end_slot > total_slots:
raise ValueError(
"Invalid CP shared KV paged slot materialize range: "
f"start_slot={start_slot} end_slot={end_slot} total_slots={total_slots}"
)
if start_slot == end_slot:
return
if dense_page_buffer.shape[0] < total_slots + 1:
raise ValueError(
"CP shared KV dense paged buffer is too small for slot materialize: "
f"dense_pages={dense_page_buffer.shape[0]} expected_at_least={total_slots + 1}"
)
logical_pages = flat_slot_logical_pages[start_slot:end_slot].to(torch.long)
owned_mask = layout.owned_pages_mask(logical_pages)
physical_pages = layout.logical_pages_to_physical(logical_pages).to(torch.long)
safe_physical_pages = torch.where(
@@ -1272,8 +1352,21 @@ def materialize_local_paged_buffer_page_slots(
gathered = page_buffer[safe_physical_pages]
owned_view = owned_mask.view(-1, *([1] * (gathered.ndim - 1)))
zero = torch.zeros((), dtype=page_buffer.dtype, device=page_buffer.device)
dense_page_buffer[1:].copy_(torch.where(owned_view, gathered, zero))
return dense_page_buffer
dense_page_buffer[start_slot + 1 : end_slot + 1].copy_(
torch.where(owned_view, gathered, zero)
)
def slot_range_to_page_slice(
start_slot: int,
end_slot: int,
) -> slice:
if start_slot < 0 or end_slot < start_slot:
raise ValueError(
"Invalid CP shared KV slot page slice range: "
f"start_slot={start_slot} end_slot={end_slot}"
)
return slice(start_slot + 1, end_slot + 1)
def paged_copy_debug_checksum(
@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
@@ -34,6 +35,7 @@ _is_cuda = is_cuda()
_is_hip = is_hip()
_is_npu = is_npu()
_is_fp8_fnuz = is_fp8_fnuz()
logger = logging.getLogger(__name__)
if _is_cuda:
try:
import deep_gemm
@@ -53,6 +55,7 @@ from sglang.srt.layers import deep_gemm_wrapper
from sglang.srt.layers.attention.nsa.utils import (
cp_all_gather_rerange_output,
get_cp_shared_kv_local_out_cache_loc,
get_cp_shared_kv_local_physical_out_cache_loc,
is_nsa_enable_prefill_cp,
is_nsa_prefill_cp_in_seq_split,
log_cp_shared_kv_direct_write_fallback,
@@ -102,6 +105,24 @@ def _compute_contiguous_valid_cp_query_count(
return max(0, min(actual_seq_q, valid_count))
def _log_cp_shared_kv_index_prefetch_fallback(
reason: str,
message: str,
*args,
) -> None:
"""Log every index prefetch fallback event.
Warmup and real requests can hit the same reason independently; do not
dedupe here or later real fallbacks become invisible during profiling.
"""
logger.info(
"CP shared KV index prefetch fallback (%s): " + message,
reason,
*args,
)
class BaseIndexerMetadata(ABC):
@abstractmethod
def get_seqlens_int32(self) -> torch.Tensor:
@@ -282,6 +303,39 @@ class Indexer(MultiPlatformOp):
assert forward_batch.cp_shared_kv_layout is not None
layout = forward_batch.cp_shared_kv_layout
index_prefetcher = getattr(
forward_batch, "cp_shared_kv_index_prefetcher", None
)
if index_prefetcher is not None:
prefetched = index_prefetcher.consume(
layer_id=layer_id,
page_buffer=index_buffer,
logical_pages=logical_page_table,
)
if prefetched is not None:
if cp_shared_kv_debug_enabled():
cp_shared_kv_debug_log(
"index_materialize_prefetch_hit",
"NSA index materialize prefetch hit cp_rank=%s layer=%s dense_pages=%s buffer_ck=%s",
layout.cp_rank,
layer_id,
tensor_debug_summary(prefetched[1]),
tensor_debug_checksum(prefetched[0]),
)
return prefetched
start_layer = int(getattr(forward_batch.token_to_kv_pool, "start_layer", 0))
if int(layer_id) > start_layer:
_log_cp_shared_kv_index_prefetch_fallback(
"consume_miss",
"prefetcher did not provide layer buffer; falling back to "
"sync paged materialize. layer=%s start_layer=%s cp_rank=%s "
"logical_page_table_shape=%s",
layer_id,
start_layer,
layout.cp_rank,
tuple(logical_page_table.shape),
)
if cp_shared_kv_debug_enabled():
cp_shared_kv_debug_log(
"index_materialize_call",
@@ -303,9 +357,24 @@ class Indexer(MultiPlatformOp):
layer_id,
tensor_debug_summary(dense_pages),
tensor_debug_checksum(materialized),
)
)
return materialized, dense_pages
def _maybe_start_next_layer_index_prefetch(
self,
forward_batch: ForwardBatch,
layer_id: int,
) -> None:
index_prefetcher = getattr(
forward_batch, "cp_shared_kv_index_prefetcher", None
)
if index_prefetcher is None:
return
index_prefetcher.start_next_layer_prefix(
next_layer_id=layer_id + 1,
token_to_kv_pool=forward_batch.token_to_kv_pool,
)
def _filter_shared_index_write(
self,
forward_batch: ForwardBatch,
@@ -1355,12 +1424,9 @@ class Indexer(MultiPlatformOp):
if local_out_loc.numel() == 0:
return True
assert forward_batch.cp_shared_kv_layout is not None
physical_out_loc = (
forward_batch.cp_shared_kv_layout.logical_locs_to_physical(
local_out_loc
).contiguous()
)
physical_out_loc = get_cp_shared_kv_local_physical_out_cache_loc(forward_batch)
if physical_out_loc is None:
return False
self._store_index_k_cache(
forward_batch=forward_batch,
layer_id=layer_id,
@@ -1579,7 +1645,7 @@ class Indexer(MultiPlatformOp):
forward_batch.nsa_cp_metadata is not None
and is_nsa_prefill_cp_in_seq_split()
):
return self._get_topk_in_seq_cp_pair(
topk_result = self._get_topk_in_seq_cp_pair(
forward_batch,
layer_id,
q_fp8,
@@ -1604,6 +1670,7 @@ class Indexer(MultiPlatformOp):
topk=self.index_topk,
layer_id=layer_id,
)
self._maybe_start_next_layer_index_prefetch(forward_batch, layer_id)
return topk_result
def forward_npu(
+168 -12
View File
@@ -167,6 +167,8 @@ class PageAlignedInSeqSplitInfo:
@dataclass
class NSAContextParallelMetadata:
split_list: List[int] = None
split_list_tensor: torch.Tensor = None
split_prefix_tensor: torch.Tensor = None
max_rank_len: List[int] = None
zigzag_index: List[int] = None
per_rank_actual_token: List[int] = None
@@ -529,6 +531,44 @@ def get_cp_shared_kv_local_out_cache_loc(forward_batch: "ForwardBatch"):
return local_out_cache_loc
def get_cp_shared_kv_local_physical_out_cache_loc(forward_batch: "ForwardBatch"):
"""Return cached physical rows for this CP rank's shared-KV direct writes.
`get_cp_shared_kv_local_out_cache_loc` returns logical shared-KV locs because
radix/scheduler/PD metadata are expressed in the global logical address
space. Persistent writes into this rank's compact physical pool need the
same locs remapped through `CpSharedKVLayout`. The logical loc tensor is
batch-scoped, not layer-scoped, so cache the physical remap on ForwardBatch
and reuse it for both MLA KV and NSA index K/scale writes across layers.
"""
cached = getattr(forward_batch, "cp_local_physical_out_cache_loc", None)
if cached is not None:
return cached
local_out_cache_loc = get_cp_shared_kv_local_out_cache_loc(forward_batch)
if local_out_cache_loc is None:
return None
if local_out_cache_loc.numel() == 0:
forward_batch.cp_local_physical_out_cache_loc = local_out_cache_loc
return local_out_cache_loc
layout = getattr(forward_batch, "cp_shared_kv_layout", None)
if layout is None:
log_cp_shared_kv_direct_write_fallback(
"missing_layout",
"cp_shared_kv_layout is missing while building physical out_cache_loc",
)
return None
physical_out_cache_loc = layout.logical_locs_to_physical(
local_out_cache_loc
).contiguous()
forward_batch.cp_local_physical_out_cache_loc = physical_out_cache_loc
return physical_out_cache_loc
def cp_split_and_rebuild_position(forward_batch, positions: torch.Tensor):
if is_nsa_prefill_cp_round_robin_split():
cp_size = get_attention_cp_size()
@@ -655,7 +695,7 @@ def nsa_use_prefill_cp(forward_batch, nsa_enable_prefill_cp=None):
return False
def cp_attn_tp_all_gather_reorganazied_into_tensor(
def _cp_attn_tp_all_gather_padded_tensor(
input_: torch.Tensor, total_len, attn_tp_size, forward_batch, stream_op
):
"""
@@ -683,11 +723,14 @@ def cp_attn_tp_all_gather_reorganazied_into_tensor(
get_attention_cp_group().cp_all_gather_into_tensor_async(
input_tensor_all, input_, stream_op
)
# step3
return input_tensor_all
def _trim_cp_rank_padding_after_all_gather(input_tensor_all, forward_batch):
outputs_list_max = list(
torch.split(input_tensor_all, forward_batch.nsa_cp_metadata.max_rank_len, dim=0)
)
outputs = torch.cat(
return torch.cat(
[
outputs_list_max[index][:per_rank_len]
for index, per_rank_len in enumerate(
@@ -696,9 +739,114 @@ def cp_attn_tp_all_gather_reorganazied_into_tensor(
],
dim=0,
)
def cp_attn_tp_all_gather_reorganazied_into_tensor(
input_: torch.Tensor, total_len, attn_tp_size, forward_batch, stream_op
):
input_tensor_all = _cp_attn_tp_all_gather_padded_tensor(
input_, total_len, attn_tp_size, forward_batch, stream_op
)
# step3
outputs = _trim_cp_rank_padding_after_all_gather(input_tensor_all, forward_batch)
return outputs
def _log_tai_in_seq_rerange_fallback(reason: str, message: str, *args) -> None:
logger.info(
"CP NSA in-seq all-gather rerange tai-kernel fallback (%s): " + message,
reason,
*args,
)
def _try_tai_in_seq_all_gather_rerange(
input_tensor_all: torch.Tensor,
forward_batch: "ForwardBatch",
*,
hidden_size: int,
cp_size: int,
) -> torch.Tensor | None:
metadata = forward_batch.nsa_cp_metadata
split_lens = getattr(metadata, "split_list_tensor", None)
split_prefix = getattr(metadata, "split_prefix_tensor", None)
split_list = getattr(metadata, "split_list", None)
max_rank_len = getattr(metadata, "max_rank_len", None)
if split_lens is None or split_prefix is None:
_log_tai_in_seq_rerange_fallback(
"missing_metadata",
"split_list_tensor or split_prefix_tensor is missing",
)
return None
if split_list is None or max_rank_len is None:
_log_tai_in_seq_rerange_fallback(
"missing_cpu_metadata",
"split_list or max_rank_len is missing",
)
return None
if not input_tensor_all.is_cuda or input_tensor_all.dim() != 2:
return None
total_tokens = int(sum(split_list))
if total_tokens == 0:
return input_tensor_all.new_empty((0, hidden_size))
max_segment_len = int(max(split_list))
max_rank_token = int(max_rank_len[0]) if len(max_rank_len) > 0 else 0
if max_segment_len <= 0 or max_rank_token <= 0:
return None
if input_tensor_all.shape[0] < max_rank_token * cp_size:
return None
try:
from tai_kernel.nsa_prefill import in_seq_all_gather_rerange
except Exception as exc:
_log_tai_in_seq_rerange_fallback(
"import_failed",
"failed to import tai_kernel.nsa_prefill.in_seq_all_gather_rerange: %s",
exc,
)
return None
try:
return in_seq_all_gather_rerange(
input_tensor_all,
split_lens,
split_prefix,
total_tokens=total_tokens,
hidden_size=hidden_size,
max_segment_len=max_segment_len,
max_rank_token=max_rank_token,
cp_size=cp_size,
)
except Exception as exc:
_log_tai_in_seq_rerange_fallback(
"kernel_failed",
"tai-kernel in-seq rerange failed: %s",
exc,
)
return None
def _torch_in_seq_all_gather_rerange(
input_tensor_all: torch.Tensor,
forward_batch: "ForwardBatch",
hidden_size: int,
) -> torch.Tensor:
output_tensor = _trim_cp_rank_padding_after_all_gather(
input_tensor_all, forward_batch
)
outputs_list = list(
torch.split(
output_tensor, forward_batch.nsa_cp_metadata.reverse_split_len, dim=0
)
)
output_tensor = torch.cat(
[outputs_list[i] for i in forward_batch.nsa_cp_metadata.cp_reverse_index], dim=0
)
return output_tensor.view(-1, hidden_size)
def cp_all_gather_rerange_output(input_tensor, cp_size, forward_batch, stream):
"""
# for in-seq-split
@@ -746,23 +894,26 @@ def cp_all_gather_rerange_output(input_tensor, cp_size, forward_batch, stream):
return output_tensor
bs_seq_len, hidden_size = input_tensor.shape
output_tensor = cp_attn_tp_all_gather_reorganazied_into_tensor(
input_tensor_all = _cp_attn_tp_all_gather_padded_tensor(
input_tensor,
forward_batch.nsa_cp_metadata.total_seq_lens,
cp_size,
forward_batch,
stream,
)
outputs_list = list(
torch.split(
output_tensor, forward_batch.nsa_cp_metadata.reverse_split_len, dim=0
)
output_tensor = _try_tai_in_seq_all_gather_rerange(
input_tensor_all,
forward_batch,
hidden_size=hidden_size,
cp_size=cp_size,
)
output_tensor = torch.cat(
[outputs_list[i] for i in forward_batch.nsa_cp_metadata.cp_reverse_index], dim=0
if output_tensor is not None:
return output_tensor
return _torch_in_seq_all_gather_rerange(
input_tensor_all,
forward_batch,
hidden_size,
)
output_tensor = output_tensor.view(-1, hidden_size)
return output_tensor
def calculate_cp_seq_idx(cp_chunks_len, seqs_len):
@@ -908,6 +1059,7 @@ def prepare_input_dp_with_cp_dsa(
)
)
prefix_sum_list = list(accumulate(split_list))
split_prefix_list = [0] + prefix_sum_list[:-1]
# TODO Support multi-batch-cp-split, multi-batch-cp support has accuracy issues
# cp_seq_index = calculate_cp_seq_idx(split_list[:], seqs_len[:])
@@ -932,6 +1084,10 @@ def prepare_input_dp_with_cp_dsa(
nsa_cp_metadata = NSAContextParallelMetadata(
split_list=split_list,
split_list_tensor=torch.tensor(split_list, device="cuda", dtype=torch.int32),
split_prefix_tensor=torch.tensor(
split_prefix_list, device="cuda", dtype=torch.int32
),
max_rank_len=max_rank_len,
zigzag_index=zigzag_index,
per_rank_actual_token=per_rank_actual_token,
@@ -10,6 +10,7 @@ from sglang.srt.configs.model_config import get_nsa_index_topk, is_deepseek_nsa
from sglang.srt.environ import envs
from sglang.srt.layers.attention.base_attn_backend import AttentionBackend
from sglang.srt.layers.attention.nsa.cp_shared_kv_prefetch import (
CpSharedKVIndexPrefetcher,
CpSharedKVMlaPrefetcher,
)
from sglang.srt.layers.attention.nsa.cp_shared_kv_runtime import (
@@ -603,6 +604,7 @@ class NativeSparseAttnBackend(
batch_size = forward_batch.batch_size
device = forward_batch.seq_lens.device
forward_batch.cp_shared_kv_mla_prefetcher = None
forward_batch.cp_shared_kv_index_prefetcher = None
if forward_batch.forward_mode.is_target_verify():
draft_token_num = self.speculative_num_draft_tokens
@@ -884,6 +886,15 @@ class NativeSparseAttnBackend(
),
)
)
forward_batch.cp_shared_kv_index_prefetcher = (
CpSharedKVIndexPrefetcher.maybe_create(
forward_batch=forward_batch,
metadata=metadata,
topk_transform_is_paged=(
topk_transform_method == TopkTransformMethod.PAGED
),
)
)
def _cal_indexer_k_start_end(
self,
@@ -1825,6 +1836,11 @@ class NativeSparseAttnBackend(
finally:
if mla_prefetcher is not None:
mla_prefetcher.wait_attention_window()
index_prefetcher = getattr(
forward_batch, "cp_shared_kv_index_prefetcher", None
)
if index_prefetcher is not None:
index_prefetcher.wait_attention_window()
return attn_output
@@ -423,8 +423,10 @@ class ForwardBatch(ForwardBatchDeepSeekMHAMixin):
uses_cp_shared_kv: bool = False
cp_shared_kv_layout: Optional[CpSharedKVLayout] = None
cp_local_out_cache_loc: Optional[torch.Tensor] = None
cp_local_physical_out_cache_loc: Optional[torch.Tensor] = None
cp_shared_mla_direct_write_done: bool = False
cp_shared_kv_mla_prefetcher: Optional[Any] = None
cp_shared_kv_index_prefetcher: Optional[Any] = None
# For hidden states before normal
return_hidden_states_before_norm: bool = False
@@ -8,6 +8,7 @@ from sglang.srt.compilation.piecewise_context_manager import is_in_piecewise_cud
from sglang.srt.layers import deep_gemm_wrapper
from sglang.srt.layers.attention.nsa.utils import (
get_cp_shared_kv_local_out_cache_loc,
get_cp_shared_kv_local_physical_out_cache_loc,
log_cp_shared_kv_direct_write_fallback,
nsa_use_prefill_cp,
)
@@ -569,9 +570,11 @@ class DeepseekMLAForwardMixin:
):
return True
physical_out_cache_loc = (
layout.logical_locs_to_physical(local_out_cache_loc).contiguous()
physical_out_cache_loc = get_cp_shared_kv_local_physical_out_cache_loc(
forward_batch
)
if physical_out_cache_loc is None:
return False
forward_batch.token_to_kv_pool.set_mla_kv_buffer(
self.attn_mqa,
physical_out_cache_loc,
@@ -11,6 +11,7 @@ from sglang.srt.layers.attention.nsa.utils import (
can_cp_split,
cp_split_and_rebuild_1d,
get_cp_shared_kv_local_out_cache_loc,
get_cp_shared_kv_local_physical_out_cache_loc,
split_in_seq_cp_local_pair,
)
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
@@ -335,6 +336,45 @@ class TestNSAInSeqCPUtils(unittest.TestCase):
+ list(range(6 * page_size, 7 * page_size)),
)
def test_local_physical_out_cache_loc_is_cached(self):
import torch
from types import SimpleNamespace
page_size = 4
segment_pages = [1, 2, 3, 4, 8, 7, 6, 5]
out_cache_loc = torch.cat(
[
torch.arange(page * page_size, (page + 1) * page_size)
for page in segment_pages
]
)
forward_batch = SimpleNamespace(
uses_cp_shared_kv=True,
cp_shared_kv_layout=CpSharedKVLayout(
page_size=page_size,
cp_size=4,
cp_rank=1,
),
nsa_cp_metadata=NSAContextParallelMetadata(
split_list=[page_size] * 8,
zigzag_index=[1, 6],
page_aligned=True,
page_size=page_size,
extend_prefix_len=0,
),
out_cache_loc=out_cache_loc,
)
physical_locs = get_cp_shared_kv_local_physical_out_cache_loc(forward_batch)
second_read = get_cp_shared_kv_local_physical_out_cache_loc(forward_batch)
self.assertIs(physical_locs, second_read)
self.assertEqual(
physical_locs.tolist(),
list(range(1 * page_size, 2 * page_size))
+ list(range(2 * page_size, 3 * page_size)),
)
def test_local_out_cache_loc_falls_back_when_owner_mismatch(self):
import torch
from types import SimpleNamespace
@@ -1214,6 +1214,254 @@ class TestCpSharedKVTaiMaterializeIntegration(unittest.TestCase):
self.assertTrue(torch.equal(dense_page_buffer[1], page_buffer[1]))
self.assertTrue(torch.equal(dense_page_buffer[2], page_buffer[2]))
def test_materialize_local_paged_buffer_page_slots_into_matches_full_slots(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_runtime as runtime
from sglang.srt.mem_cache.cp_shared_kv_layout import CpSharedKVLayout
layout = CpSharedKVLayout(page_size=64, cp_size=2, cp_rank=0)
page_buffer = torch.arange(0, 6 * 3, dtype=torch.float32).view(6, 3)
slot_logical_pages = torch.tensor([1, 2, 3, 4, 0, -1], dtype=torch.int64)
full = runtime.materialize_local_paged_buffer_page_slots(
page_buffer=page_buffer,
slot_logical_pages=slot_logical_pages,
layout=layout,
)
split = page_buffer.new_full(full.shape, -7)
split[0].zero_()
runtime.materialize_local_paged_buffer_page_slots_into(
page_buffer=page_buffer,
dense_page_buffer=split,
slot_logical_pages=slot_logical_pages,
layout=layout,
start_slot=0,
end_slot=3,
)
runtime.materialize_local_paged_buffer_page_slots_into(
page_buffer=page_buffer,
dense_page_buffer=split,
slot_logical_pages=slot_logical_pages,
layout=layout,
start_slot=3,
end_slot=slot_logical_pages.numel(),
)
self.assertTrue(torch.equal(split, full))
def test_remap_logical_pages_to_slot_dense_pages_preserves_sentinels(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_runtime as runtime
slot_logical_pages = torch.tensor([1, 2, 0, 4, -1, 5], dtype=torch.int64)
page_inverse = runtime.build_slot_page_inverse(
slot_logical_pages,
logical_page_capacity=8,
)
logical_pages = torch.tensor([[0, 1, 5, -1, 3, 9]], dtype=torch.int32)
dense_pages = runtime.remap_logical_pages_to_slot_dense_pages(
logical_pages,
page_inverse=page_inverse,
)
self.assertEqual(dense_pages.tolist(), [[0, 1, 6, -1, -1, -1]])
def test_index_materialize_uses_prefetched_buffer_before_fallback(self):
from sglang.srt.layers.attention.nsa import nsa_indexer
class FakePool:
def __init__(self):
self.index_buffer = torch.arange(0, 12, dtype=torch.float32).view(4, 3)
def get_index_k_with_scale_buffer(self, layer_id):
self.layer_id = layer_id
return self.index_buffer
class FakePrefetcher:
def __init__(self):
self.calls = []
self.dense_buffer = torch.full((3, 3), 5.0)
self.dense_pages = torch.tensor([[1, 2]], dtype=torch.int32)
def consume(self, *, layer_id, page_buffer, logical_pages):
self.calls.append((layer_id, page_buffer, logical_pages))
return self.dense_buffer, self.dense_pages
fake_pool = FakePool()
fake_prefetcher = FakePrefetcher()
forward_batch = SimpleNamespace(
token_to_kv_pool=fake_pool,
uses_cp_shared_kv=True,
cp_shared_kv_layout=object(),
cp_shared_kv_index_prefetcher=fake_prefetcher,
)
logical_pages = torch.tensor([[1, 2]], dtype=torch.int32)
indexer = object.__new__(nsa_indexer.Indexer)
with patch.object(
nsa_indexer,
"materialize_shared_paged_buffer",
side_effect=AssertionError("prefetch hit must bypass full materialize"),
):
dense_buffer, dense_pages = indexer._maybe_materialize_shared_index_buffer(
forward_batch,
layer_id=7,
logical_page_table=logical_pages,
)
self.assertIs(dense_buffer, fake_prefetcher.dense_buffer)
self.assertIs(dense_pages, fake_prefetcher.dense_pages)
self.assertEqual(fake_prefetcher.calls[0][0], 7)
self.assertIs(fake_prefetcher.calls[0][1], fake_pool.index_buffer)
self.assertIs(fake_prefetcher.calls[0][2], logical_pages)
def test_index_prefetch_start_targets_next_layer(self):
from sglang.srt.layers.attention.nsa import nsa_indexer
class FakePrefetcher:
def __init__(self):
self.calls = []
def start_next_layer_prefix(self, *, next_layer_id, token_to_kv_pool):
self.calls.append((next_layer_id, token_to_kv_pool))
token_to_kv_pool = object()
fake_prefetcher = FakePrefetcher()
forward_batch = SimpleNamespace(
token_to_kv_pool=token_to_kv_pool,
cp_shared_kv_index_prefetcher=fake_prefetcher,
)
indexer = object.__new__(nsa_indexer.Indexer)
indexer._maybe_start_next_layer_index_prefetch(forward_batch, layer_id=11)
self.assertEqual(fake_prefetcher.calls, [(12, token_to_kv_pool)])
def test_index_prefetch_consume_miss_logs_fallback_after_first_layer(self):
from sglang.srt.layers.attention.nsa import nsa_indexer
class FakePool:
start_layer = 0
def __init__(self):
self.index_buffer = torch.arange(0, 12, dtype=torch.float32).view(4, 3)
def get_index_k_with_scale_buffer(self, layer_id):
return self.index_buffer
class FakeLayout:
cp_rank = 3
class MissingPrefetcher:
def consume(self, *, layer_id, page_buffer, logical_pages):
return None
fallback_buffer = torch.full((3, 3), 9.0)
fallback_pages = torch.tensor([[1, 2]], dtype=torch.int32)
forward_batch = SimpleNamespace(
token_to_kv_pool=FakePool(),
uses_cp_shared_kv=True,
cp_shared_kv_layout=FakeLayout(),
cp_shared_kv_index_prefetcher=MissingPrefetcher(),
)
logical_pages = torch.tensor([[1, 2]], dtype=torch.int32)
indexer = object.__new__(nsa_indexer.Indexer)
with patch.object(
nsa_indexer,
"materialize_shared_paged_buffer",
return_value=(fallback_buffer, fallback_pages),
), patch(
"sglang.srt.layers.attention.nsa.nsa_indexer.logger",
create=True,
) as logger:
dense_buffer, dense_pages = indexer._maybe_materialize_shared_index_buffer(
forward_batch,
layer_id=7,
logical_page_table=logical_pages,
)
self.assertIs(dense_buffer, fallback_buffer)
self.assertIs(dense_pages, fallback_pages)
logger.info.assert_called_once()
self.assertIn(
"CP shared KV index prefetch fallback",
logger.info.call_args.args[0],
)
self.assertIn("consume_miss", logger.info.call_args.args[1])
def test_index_prefetch_first_layer_miss_does_not_log_fallback(self):
from sglang.srt.layers.attention.nsa import nsa_indexer
class FakePool:
start_layer = 0
def __init__(self):
self.index_buffer = torch.arange(0, 12, dtype=torch.float32).view(4, 3)
def get_index_k_with_scale_buffer(self, layer_id):
return self.index_buffer
class FakeLayout:
cp_rank = 0
class MissingPrefetcher:
def consume(self, *, layer_id, page_buffer, logical_pages):
return None
fallback_buffer = torch.full((3, 3), 9.0)
fallback_pages = torch.tensor([[1, 2]], dtype=torch.int32)
forward_batch = SimpleNamespace(
token_to_kv_pool=FakePool(),
uses_cp_shared_kv=True,
cp_shared_kv_layout=FakeLayout(),
cp_shared_kv_index_prefetcher=MissingPrefetcher(),
)
logical_pages = torch.tensor([[1, 2]], dtype=torch.int32)
indexer = object.__new__(nsa_indexer.Indexer)
with patch.object(
nsa_indexer,
"materialize_shared_paged_buffer",
return_value=(fallback_buffer, fallback_pages),
), patch(
"sglang.srt.layers.attention.nsa.nsa_indexer.logger",
create=True,
) as logger:
indexer._maybe_materialize_shared_index_buffer(
forward_batch,
layer_id=0,
logical_page_table=logical_pages,
)
logger.info.assert_not_called()
def test_index_prefetch_create_skip_logs_fallback_when_enabled(self):
from sglang.srt.layers.attention.nsa import cp_shared_kv_prefetch as prefetch
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=False
), patch.object(
prefetch.logger, "info"
) as logger:
result = prefetch.CpSharedKVIndexPrefetcher.maybe_create(
forward_batch=SimpleNamespace(),
metadata=SimpleNamespace(),
topk_transform_is_paged=True,
)
self.assertIsNone(result)
logger.assert_called_once()
self.assertIn(
"CP shared KV index prefetch fallback",
logger.call_args.args[0],
)
self.assertIn("cuda_unavailable_or_stream_capturing", logger.call_args.args[1])
if __name__ == "__main__":
unittest.main()