Unify full→SWA index translation in init_forward_metadata; drop pool caches (#27091)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cheng Wan
2026-06-03 16:12:27 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8980eb82de
commit c9ca56da8c
29 changed files with 274 additions and 814 deletions
-1
View File
@@ -702,7 +702,6 @@ class Envs:
SGLANG_OPT_FLASHMLA_SPARSE_PREFILL = EnvBool(False) SGLANG_OPT_FLASHMLA_SPARSE_PREFILL = EnvBool(False)
# SWA radix cache # SWA radix cache
SGLANG_OPT_CACHE_SWA_TRANSLATION = EnvBool(True)
# TODO(DSV4): @ispobock this has bug on main branch when retract # TODO(DSV4): @ispobock this has bug on main branch when retract
SGLANG_OPT_SWA_RADIX_CACHE_COMPACT = EnvBool(False) SGLANG_OPT_SWA_RADIX_CACHE_COMPACT = EnvBool(False)
SGLANG_OPT_SWA_SPLIT_LEAF_ON_INSERT = EnvBool(False) SGLANG_OPT_SWA_SPLIT_LEAF_ON_INSERT = EnvBool(False)
@@ -116,6 +116,9 @@ class DSV4AttnMetadata:
swa_topk_lengths: torch.Tensor swa_topk_lengths: torch.Tensor
c4_sparse_topk: int c4_sparse_topk: int
# SWA KV-store write target (out_cache_loc translated to SWA space), computed
# once per iteration in make_core_attn_metadata and read by the store path.
swa_out_cache_loc: Optional[torch.Tensor] = None
c4_out_loc: Optional[torch.Tensor] = None c4_out_loc: Optional[torch.Tensor] = None
c4_topk_lengths_raw: Optional[torch.Tensor] = None c4_topk_lengths_raw: Optional[torch.Tensor] = None
c4_topk_lengths_clamp1: Optional[torch.Tensor] = None c4_topk_lengths_clamp1: Optional[torch.Tensor] = None
@@ -172,6 +175,9 @@ class DSV4AttnMetadata:
"c4_sparse_raw_indices", "c4_sparse_raw_indices",
], ],
assign_fields=[ assign_fields=[
# Recomputed by the recorded init_forward_metadata_in_graph op
# each forward; not copied across replays.
"swa_out_cache_loc",
"c1_flashmla_metadata", "c1_flashmla_metadata",
"c4_flashmla_metadata", "c4_flashmla_metadata",
"c128_flashmla_metadata", "c128_flashmla_metadata",
@@ -218,6 +224,7 @@ class DSV4AttnMetadata:
] ]
_CP_GLOBAL_FIELDS = [ _CP_GLOBAL_FIELDS = [
"raw_out_loc", "raw_out_loc",
"swa_out_cache_loc",
"c4_out_loc", "c4_out_loc",
"c128_out_loc", "c128_out_loc",
] ]
@@ -699,6 +706,36 @@ class DeepseekV4AttnBackend(
raw_metadata=self.forward_metadata, raw_metadata=self.forward_metadata,
) )
# Compute the SWA KV-store write target once per forward and cache it on
# the metadata for every layer's store. This is recorded inside the cuda
# graph, so replay re-reads the live out_cache_loc buffer (spec-v2 and DP
# padding rebind out_cache_loc after out-graph metadata prep). flash_mla
# kernels require int32 indices.
metadata = self.forward_metadata
if (
isinstance(metadata, DSV4Metadata)
and forward_batch.out_cache_loc is not None
):
out_cache_loc = forward_batch.out_cache_loc
if (
forward_batch.forward_mode.is_decode_or_idle()
and self.topk > 0
and self.speculative_num_steps > 1
):
# Multi-step draft decode shares one out_cache_loc buffer across
# steps; mirror the eager init's per-step slice.
out_cache_loc = per_step_draft_out_cache_loc(
out_cache_loc,
forward_batch.batch_size,
self.topk,
self.speculative_num_steps,
)[self.speculative_step_id]
metadata.core_attn_metadata.swa_out_cache_loc = (
self.token_to_kv_pool.translate_loc_from_full_to_swa(out_cache_loc).to(
torch.int32
)
)
def init_forward_metadata_out_graph( def init_forward_metadata_out_graph(
self, self,
forward_batch: ForwardBatch, forward_batch: ForwardBatch,
@@ -789,12 +826,22 @@ class DeepseekV4AttnBackend(
) )
elif bucket == _GraphBucket.DRAFT_EXTEND: elif bucket == _GraphBucket.DRAFT_EXTEND:
num_tokens_per_bs = self.draft_extend_num_tokens_per_bs num_tokens_per_bs = self.draft_extend_num_tokens_per_bs
if out_cache_loc is not None:
# Pad the real write locations to the captured token count so
# raw_out_loc reflects the actual replay out_cache_loc.
out_cache_loc = torch.nn.functional.pad(
out_cache_loc,
pad=(0, num_tokens_per_bs * bs - len(out_cache_loc)),
mode="constant",
value=0,
)
temp_metadata = self.init_forward_metadata_draft_extend( temp_metadata = self.init_forward_metadata_draft_extend(
max_seq_len=chosen_max_seq_len, max_seq_len=chosen_max_seq_len,
req_pool_indices=req_pool_indices, req_pool_indices=req_pool_indices,
seq_lens=seq_lens, seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu.tolist(), seq_lens_cpu=seq_lens_cpu.tolist(),
num_tokens_per_bs=num_tokens_per_bs, num_tokens_per_bs=num_tokens_per_bs,
out_cache_loc=out_cache_loc,
use_prefill_cuda_graph=True, use_prefill_cuda_graph=True,
) )
else: else:
@@ -934,21 +981,47 @@ class DeepseekV4AttnBackend(
if current_raw is not None: if current_raw is not None:
self.forward_metadata = current_raw self.forward_metadata = current_raw
def get_swa_out_cache_loc(self, forward_batch: ForwardBatch) -> torch.Tensor:
"""Resolve the SWA KV-store write target for the current forward.
Fast path: the per-forward value cached by init_forward_metadata_in_graph
(recorded inside cuda graphs, so replay re-reads live buffers). Fallback:
translate at store time, matching the pre-cache behavior, for paths that
never run the in-graph init — eager idle (forward_idle skips attn init),
runners that only run the out-graph prep (e.g.
EAGLEDraftExtendCudaGraphRunner) — or whose batch was re-padded after
init (shape mismatch). Idle always falls back: its metadata is absent or
left over from a previous forward, and translating the zero-padded
out_cache_loc writes to the dummy slot.
"""
out_cache_loc = forward_batch.out_cache_loc
core = getattr(self.forward_metadata, "core_attn_metadata", None)
cached = core.swa_out_cache_loc if core is not None else None
if (
cached is not None
and not forward_batch.forward_mode.is_idle()
and cached.shape[0] == out_cache_loc.shape[0]
):
return cached
return self.token_to_kv_pool.translate_loc_from_full_to_swa(out_cache_loc).to(
torch.int32
)
def store_cache( def store_cache(
self, layer_id: int, swa_k: torch.Tensor, forward_batch: ForwardBatch self, layer_id: int, swa_k: torch.Tensor, forward_batch: ForwardBatch
) -> None: ) -> None:
raw_loc = forward_batch.out_cache_loc swa_loc = self.get_swa_out_cache_loc(forward_batch)
if envs.SGLANG_OPT_USE_FUSED_STORE_CACHE.get(): if envs.SGLANG_OPT_USE_FUSED_STORE_CACHE.get():
self.token_to_kv_pool.set_swa_key_buffer_radix_fused( self.token_to_kv_pool.set_swa_key_buffer_radix_fused(
layer_id=layer_id, layer_id=layer_id,
raw_loc=raw_loc, swa_loc=swa_loc,
cache_k=swa_k, cache_k=swa_k,
) )
else: else:
swa_k_pack = quant_to_nope_fp8_rope_bf16_pack_triton(swa_k) swa_k_pack = quant_to_nope_fp8_rope_bf16_pack_triton(swa_k)
self.token_to_kv_pool.set_swa_key_buffer_radix( self.token_to_kv_pool.set_swa_key_buffer_radix(
layer_id=layer_id, layer_id=layer_id,
raw_loc=raw_loc, swa_loc=swa_loc,
cache_nope_fp8_rope_bf16_pack=swa_k_pack, cache_nope_fp8_rope_bf16_pack=swa_k_pack,
) )
@@ -1322,7 +1395,8 @@ class DeepseekV4AttnBackend(
assert raw_indices.shape == (num_qo_tokens, SWA_WINDOW) assert raw_indices.shape == (num_qo_tokens, SWA_WINDOW)
raw_indices.masked_fill_(invalid_offset_mask, -1) raw_indices.masked_fill_(invalid_offset_mask, -1)
swa_indices = self.token_to_kv_pool.translate_loc_from_full_to_swa(raw_indices) swa_indices = self.token_to_kv_pool.translate_loc_from_full_to_swa(raw_indices)
return swa_indices # flash_mla attention requires int32 page indices.
return swa_indices.to(torch.int32)
class DeepseekV4MultiStepBackend(DeepseekV4AttnBackend): class DeepseekV4MultiStepBackend(DeepseekV4AttnBackend):
@@ -106,6 +106,9 @@ class DSV4AttnMetadata:
swa_topk_lengths: torch.Tensor swa_topk_lengths: torch.Tensor
c4_sparse_topk: int c4_sparse_topk: int
# SWA KV-store write target (out_cache_loc translated to SWA space), computed
# once per iteration in make_core_attn_metadata and read by the store path.
swa_out_cache_loc: Optional[torch.Tensor] = None
c4_out_loc: Optional[torch.Tensor] = None c4_out_loc: Optional[torch.Tensor] = None
c4_topk_lengths_raw: Optional[torch.Tensor] = None c4_topk_lengths_raw: Optional[torch.Tensor] = None
c4_topk_lengths_clamp1: Optional[torch.Tensor] = None c4_topk_lengths_clamp1: Optional[torch.Tensor] = None
@@ -160,6 +163,9 @@ class DSV4AttnMetadata:
"c4_sparse_page_indices", "c4_sparse_page_indices",
], ],
assign_fields=[ assign_fields=[
# Recomputed by the recorded init_forward_metadata_in_graph op
# each forward; not copied across replays.
"swa_out_cache_loc",
"c1_flashmla_metadata", "c1_flashmla_metadata",
"c4_flashmla_metadata", "c4_flashmla_metadata",
"c128_flashmla_metadata", "c128_flashmla_metadata",
@@ -206,6 +212,7 @@ class DSV4AttnMetadata:
] ]
_CP_GLOBAL_FIELDS = [ _CP_GLOBAL_FIELDS = [
"raw_out_loc", "raw_out_loc",
"swa_out_cache_loc",
"c4_out_loc", "c4_out_loc",
"c128_out_loc", "c128_out_loc",
] ]
@@ -672,6 +679,36 @@ class DeepseekV4HipRadixBackend(
raw_metadata=self.forward_metadata, raw_metadata=self.forward_metadata,
) )
# Compute the SWA KV-store write target once per forward and cache it on
# the metadata for every layer's store. This is recorded inside the cuda
# graph, so replay re-reads the live out_cache_loc buffer (spec-v2 and DP
# padding rebind out_cache_loc after out-graph metadata prep). flash_mla
# kernels require int32 indices.
metadata = self.forward_metadata
if (
isinstance(metadata, DSV4Metadata)
and forward_batch.out_cache_loc is not None
):
out_cache_loc = forward_batch.out_cache_loc
if (
forward_batch.forward_mode.is_decode_or_idle()
and self.topk > 0
and self.speculative_num_steps > 1
):
# Multi-step draft decode shares one out_cache_loc buffer across
# steps; mirror the eager init's per-step slice.
out_cache_loc = per_step_draft_out_cache_loc(
out_cache_loc,
forward_batch.batch_size,
self.topk,
self.speculative_num_steps,
)[self.speculative_step_id]
metadata.core_attn_metadata.swa_out_cache_loc = (
self.token_to_kv_pool.translate_loc_from_full_to_swa(out_cache_loc).to(
torch.int32
)
)
def init_forward_metadata_out_graph( def init_forward_metadata_out_graph(
self, self,
forward_batch: ForwardBatch, forward_batch: ForwardBatch,
@@ -760,12 +797,22 @@ class DeepseekV4HipRadixBackend(
) )
elif bucket == _GraphBucket.DRAFT_EXTEND: elif bucket == _GraphBucket.DRAFT_EXTEND:
num_tokens_per_bs = self.draft_extend_num_tokens_per_bs num_tokens_per_bs = self.draft_extend_num_tokens_per_bs
if out_cache_loc is not None:
# Pad the real write locations to the captured token count so
# raw_out_loc reflects the actual replay out_cache_loc.
out_cache_loc = torch.nn.functional.pad(
out_cache_loc,
pad=(0, num_tokens_per_bs * bs - len(out_cache_loc)),
mode="constant",
value=0,
)
temp_metadata = self.init_forward_metadata_draft_extend( temp_metadata = self.init_forward_metadata_draft_extend(
max_seq_len=chosen_max_seq_len, max_seq_len=chosen_max_seq_len,
req_pool_indices=req_pool_indices, req_pool_indices=req_pool_indices,
seq_lens=seq_lens, seq_lens=seq_lens,
seq_lens_cpu=seq_lens_cpu.tolist(), seq_lens_cpu=seq_lens_cpu.tolist(),
num_tokens_per_bs=num_tokens_per_bs, num_tokens_per_bs=num_tokens_per_bs,
out_cache_loc=out_cache_loc,
use_prefill_cuda_graph=True, use_prefill_cuda_graph=True,
) )
else: else:
@@ -905,21 +952,47 @@ class DeepseekV4HipRadixBackend(
if current_raw is not None: if current_raw is not None:
self.forward_metadata = current_raw self.forward_metadata = current_raw
def get_swa_out_cache_loc(self, forward_batch: ForwardBatch) -> torch.Tensor:
"""Resolve the SWA KV-store write target for the current forward.
Fast path: the per-forward value cached by init_forward_metadata_in_graph
(recorded inside cuda graphs, so replay re-reads live buffers). Fallback:
translate at store time, matching the pre-cache behavior, for paths that
never run the in-graph init — eager idle (forward_idle skips attn init),
runners that only run the out-graph prep (e.g.
EAGLEDraftExtendCudaGraphRunner) — or whose batch was re-padded after
init (shape mismatch). Idle always falls back: its metadata is absent or
left over from a previous forward, and translating the zero-padded
out_cache_loc writes to the dummy slot.
"""
out_cache_loc = forward_batch.out_cache_loc
core = getattr(self.forward_metadata, "core_attn_metadata", None)
cached = core.swa_out_cache_loc if core is not None else None
if (
cached is not None
and not forward_batch.forward_mode.is_idle()
and cached.shape[0] == out_cache_loc.shape[0]
):
return cached
return self.token_to_kv_pool.translate_loc_from_full_to_swa(out_cache_loc).to(
torch.int32
)
def store_cache( def store_cache(
self, layer_id: int, swa_k: torch.Tensor, forward_batch: ForwardBatch self, layer_id: int, swa_k: torch.Tensor, forward_batch: ForwardBatch
) -> None: ) -> None:
raw_loc = forward_batch.out_cache_loc swa_loc = self.get_swa_out_cache_loc(forward_batch)
if envs.SGLANG_OPT_USE_FUSED_STORE_CACHE.get(): if envs.SGLANG_OPT_USE_FUSED_STORE_CACHE.get():
self.token_to_kv_pool.set_swa_key_buffer_radix_fused( self.token_to_kv_pool.set_swa_key_buffer_radix_fused(
layer_id=layer_id, layer_id=layer_id,
raw_loc=raw_loc, swa_loc=swa_loc,
cache_k=swa_k, cache_k=swa_k,
) )
else: else:
swa_k_pack = quant_to_nope_fp8_rope_bf16_pack_triton(swa_k) swa_k_pack = quant_to_nope_fp8_rope_bf16_pack_triton(swa_k)
self.token_to_kv_pool.set_swa_key_buffer_radix( self.token_to_kv_pool.set_swa_key_buffer_radix(
layer_id=layer_id, layer_id=layer_id,
raw_loc=raw_loc, swa_loc=swa_loc,
cache_nope_fp8_rope_bf16_pack=swa_k_pack, cache_nope_fp8_rope_bf16_pack=swa_k_pack,
) )
@@ -1165,7 +1238,8 @@ class DeepseekV4HipRadixBackend(
assert raw_indices.shape == (num_qo_tokens, SWA_WINDOW) assert raw_indices.shape == (num_qo_tokens, SWA_WINDOW)
raw_indices.masked_fill_(invalid_offset_mask, -1) raw_indices.masked_fill_(invalid_offset_mask, -1)
swa_indices = self.token_to_kv_pool.translate_loc_from_full_to_swa(raw_indices) swa_indices = self.token_to_kv_pool.translate_loc_from_full_to_swa(raw_indices)
return swa_indices # flash_mla attention requires int32 page indices.
return swa_indices.to(torch.int32)
class DeepseekV4MultiStepBackend(DeepseekV4HipRadixBackend): class DeepseekV4MultiStepBackend(DeepseekV4HipRadixBackend):
@@ -1486,9 +1486,10 @@ class DeepseekSparseAttnBackend(
# todo hisparse: to cover more backends # todo hisparse: to cover more backends
if self.hisparse_coordinator is not None: if self.hisparse_coordinator is not None:
# flash_mla_sparse_fwd / tilelang require int32 page indices.
page_table_1 = self.token_to_kv_pool.translate_loc_to_hisparse_device( page_table_1 = self.token_to_kv_pool.translate_loc_to_hisparse_device(
page_table_1 page_table_1
) ).to(torch.int32)
if dsa_impl == "tilelang": if dsa_impl == "tilelang":
if q_rope is not None: if q_rope is not None:
@@ -509,7 +509,10 @@ class CompressorBackendMixin:
if hasattr(compress_kv_pool, "translate_loc_to_hisparse_device"): if hasattr(compress_kv_pool, "translate_loc_to_hisparse_device"):
# The v2 compressor writes directly into the raw C4 KV tensor. # The v2 compressor writes directly into the raw C4 KV tensor.
# HiSparse C4 therefore needs the physical C4 location here. # HiSparse C4 therefore needs the physical C4 location here.
out_loc = compress_kv_pool.translate_loc_to_hisparse_device(out_loc) # The compress kernel requires an int32 write location.
out_loc = compress_kv_pool.translate_loc_to_hisparse_device(
out_loc
).to(torch.int32)
self._forward_compress_all_in_one( self._forward_compress_all_in_one(
kv_score_buffer=state_pool.kv_score_buffer.kv_score, kv_score_buffer=state_pool.kv_score_buffer.kv_score,
kv_score_input=kv_score_input, kv_score_input=kv_score_input,
@@ -600,10 +600,11 @@ class C4IndexerBackendMixin:
) )
) )
else: else:
# flash_mla C4 attention requires int32 page indices.
core_metadata.c4_sparse_page_indices = ( core_metadata.c4_sparse_page_indices = (
token_to_kv_pool.c4_kv_pool.translate_loc_to_hisparse_device( token_to_kv_pool.c4_kv_pool.translate_loc_to_hisparse_device(
core_metadata.c4_sparse_page_indices core_metadata.c4_sparse_page_indices
) ).to(torch.int32)
) )
if capture_enabled: if capture_enabled:
@@ -693,10 +693,11 @@ class FlashAttentionBackend(AttentionBackend):
] ]
if self.use_sliding_window_kv_pool: if self.use_sliding_window_kv_pool:
# FA3 requires an int32 page_table.
metadata.swa_page_table = ( metadata.swa_page_table = (
self.token_to_kv_pool.translate_loc_from_full_to_swa( self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table metadata.page_table
) ).to(torch.int32)
) )
# Convert the page table to a strided format which is needed by FA3 API # Convert the page table to a strided format which is needed by FA3 API
@@ -886,7 +887,7 @@ class FlashAttentionBackend(AttentionBackend):
else: else:
page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa( page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table metadata.page_table
) ).to(torch.int32)
cu_seqlens_q = metadata.cu_seqlens_q cu_seqlens_q = metadata.cu_seqlens_q
cache_seqlens = metadata.cache_seqlens_int32 cache_seqlens = metadata.cache_seqlens_int32
max_seqlen_q = metadata.max_seq_len_q max_seqlen_q = metadata.max_seq_len_q
@@ -1365,7 +1366,7 @@ class FlashAttentionBackend(AttentionBackend):
page_table = ( page_table = (
self.token_to_kv_pool.translate_loc_from_full_to_swa( self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table metadata.page_table
) ).to(torch.int32)
) )
cache_seqlens = metadata.cache_seqlens_int32 cache_seqlens = metadata.cache_seqlens_int32
max_seqlen_q = metadata.max_seq_len_q max_seqlen_q = metadata.max_seq_len_q
@@ -2424,7 +2425,7 @@ class FlashAttentionBackend(AttentionBackend):
if self.use_sliding_window_kv_pool: if self.use_sliding_window_kv_pool:
page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa( page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table metadata.page_table
) ).to(torch.int32)
else: else:
page_table = metadata.page_table page_table = metadata.page_table
if cu_seqlens_q is None or cache_seqlens_int32 is None or page_table is None: if cu_seqlens_q is None or cache_seqlens_int32 is None or page_table is None:
@@ -2551,7 +2552,7 @@ class FlashAttentionBackend(AttentionBackend):
if self.use_sliding_window_kv_pool: if self.use_sliding_window_kv_pool:
sliced_page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa( sliced_page_table = self.token_to_kv_pool.translate_loc_from_full_to_swa(
metadata.page_table[:bs, :max_seq_len] metadata.page_table[:bs, :max_seq_len]
) ).to(torch.int32)
else: else:
sliced_page_table = metadata.page_table[:bs, :max_seq_len] sliced_page_table = metadata.page_table[:bs, :max_seq_len]
@@ -2628,10 +2629,10 @@ class FlashAttentionBackend(AttentionBackend):
if self.use_sliding_window_kv_pool: if self.use_sliding_window_kv_pool:
page_table_a = self.token_to_kv_pool.translate_loc_from_full_to_swa( page_table_a = self.token_to_kv_pool.translate_loc_from_full_to_swa(
page_table_a page_table_a
) ).to(torch.int32)
page_table_b = self.token_to_kv_pool.translate_loc_from_full_to_swa( page_table_b = self.token_to_kv_pool.translate_loc_from_full_to_swa(
page_table_b page_table_b
) ).to(torch.int32)
prepare_swa_spec_page_table_triton( prepare_swa_spec_page_table_triton(
page_table, page_table,
@@ -1519,12 +1519,9 @@ def update_sliding_window_buffer(
) )
if hasattr(token_to_kv_pool, "translate_loc_from_full_to_swa"): if hasattr(token_to_kv_pool, "translate_loc_from_full_to_swa"):
kv_last_index = window_kv_indptr[-1] kv_last_index = window_kv_indptr[-1]
# Flush before+after: window_kv_indices is a different tensor than out_cache_loc.
token_to_kv_pool.invalidate_loc_cache()
window_kv_indices[:kv_last_index] = ( window_kv_indices[:kv_last_index] = (
token_to_kv_pool.translate_loc_from_full_to_swa( token_to_kv_pool.translate_loc_from_full_to_swa(
window_kv_indices[:kv_last_index] window_kv_indices[:kv_last_index]
) )
) )
token_to_kv_pool.invalidate_loc_cache()
return window_kv_indptr, window_kv_indices, window_kv_lens, window_kv_start_idx return window_kv_indptr, window_kv_indices, window_kv_lens, window_kv_start_idx
@@ -164,9 +164,12 @@ class TRTLLMHAAttnBackend(FlashInferAttnBackend):
if self._swa_kv_pool is None: if self._swa_kv_pool is None:
return None return None
shape = token_indices.shape shape = token_indices.shape
return self._swa_kv_pool.translate_loc_from_full_to_swa( # trtllm-gen SWA attention kernels require int32 page indices.
token_indices.reshape(-1) return (
).reshape(shape) self._swa_kv_pool.translate_loc_from_full_to_swa(token_indices.reshape(-1))
.reshape(shape)
.to(torch.int32)
)
def _alloc_swa_page_table( def _alloc_swa_page_table(
self, max_bs: int, max_num_pages: int self, max_bs: int, max_num_pages: int
@@ -16,9 +16,6 @@ class BaseSWAKVPool(KVCache):
swa_kv_pool: KVCache swa_kv_pool: KVCache
def invalidate_loc_cache(self) -> None:
pass
@abc.abstractmethod @abc.abstractmethod
def register_mapping(self, full_to_swa_index_mapping: torch.Tensor) -> None: def register_mapping(self, full_to_swa_index_mapping: torch.Tensor) -> None:
raise NotImplementedError() raise NotImplementedError()
@@ -513,15 +513,8 @@ class DeepSeekV4TokenToKVPool(BaseSWAKVPool):
else: else:
self._init_paged_compress_states(enable_memory_saver) self._init_paged_compress_states(enable_memory_saver)
self._should_cache_swa = envs.SGLANG_OPT_CACHE_SWA_TRANSLATION.get()
self.cached_loc = None
def register_mapping(self, full_to_swa_index_mapping: torch.Tensor): def register_mapping(self, full_to_swa_index_mapping: torch.Tensor):
self.full_to_swa_index_mapping = full_to_swa_index_mapping self.full_to_swa_index_mapping = full_to_swa_index_mapping
self.cached_loc = None # mapping replaced; discard any cached translation
def invalidate_loc_cache(self) -> None:
self.cached_loc = None
def get_ring_size(self, compress_ratio: int) -> int: def get_ring_size(self, compress_ratio: int) -> int:
server_args = get_global_server_args() server_args = get_global_server_args()
@@ -530,15 +523,7 @@ class DeepSeekV4TokenToKVPool(BaseSWAKVPool):
def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor): def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor):
assert self.full_to_swa_index_mapping is not None assert self.full_to_swa_index_mapping is not None
return self.full_to_swa_index_mapping[kv_indices]
return self.full_to_swa_index_mapping[kv_indices].to(torch.int32)
def get_cached_swa_loc(self, raw_loc: torch.Tensor, layer_id: int) -> torch.Tensor:
if self._should_cache_swa:
if layer_id == self.start_layer or self.cached_loc is None:
self.cached_loc = self.translate_loc_from_full_to_swa(raw_loc)
return self.cached_loc
return self.translate_loc_from_full_to_swa(raw_loc)
def get_contiguous_buf_infos(self) -> Tuple[List[int], List[int], List[int]]: def get_contiguous_buf_infos(self) -> Tuple[List[int], List[int], List[int]]:
data_ptrs: List[int] = [] data_ptrs: List[int] = []
@@ -768,10 +753,9 @@ class DeepSeekV4TokenToKVPool(BaseSWAKVPool):
def set_swa_key_buffer_radix( def set_swa_key_buffer_radix(
self, self,
layer_id: int, layer_id: int,
raw_loc: torch.Tensor, swa_loc: torch.Tensor,
cache_nope_fp8_rope_bf16_pack: NopeFp8RopeBf16Pack, cache_nope_fp8_rope_bf16_pack: NopeFp8RopeBf16Pack,
) -> None: ) -> None:
swa_loc = self.translate_loc_from_full_to_swa(raw_loc)
self.swa_kv_pool.set_key_buffer( self.swa_kv_pool.set_key_buffer(
self._swa_local_layer_id(layer_id), swa_loc, cache_nope_fp8_rope_bf16_pack self._swa_local_layer_id(layer_id), swa_loc, cache_nope_fp8_rope_bf16_pack
) )
@@ -783,10 +767,9 @@ class DeepSeekV4TokenToKVPool(BaseSWAKVPool):
def set_swa_key_buffer_radix_fused( def set_swa_key_buffer_radix_fused(
self, self,
layer_id: int, layer_id: int,
raw_loc: torch.Tensor, swa_loc: torch.Tensor,
cache_k: torch.Tensor, cache_k: torch.Tensor,
) -> None: ) -> None:
swa_loc = self.get_cached_swa_loc(raw_loc, layer_id)
return self.swa_kv_pool.set_key_buffer_fused( return self.swa_kv_pool.set_key_buffer_fused(
self._swa_local_layer_id(layer_id), swa_loc, cache_k self._swa_local_layer_id(layer_id), swa_loc, cache_k
) )
@@ -794,14 +777,13 @@ class DeepSeekV4TokenToKVPool(BaseSWAKVPool):
def set_swa_key_buffer_radix_fused_norm_rope( def set_swa_key_buffer_radix_fused_norm_rope(
self, self,
layer_id: int, layer_id: int,
raw_loc: torch.Tensor, swa_loc: torch.Tensor,
kv: torch.Tensor, kv: torch.Tensor,
kv_weight: torch.Tensor, kv_weight: torch.Tensor,
eps: float, eps: float,
freqs_cis: torch.Tensor, freqs_cis: torch.Tensor,
positions: torch.Tensor, positions: torch.Tensor,
) -> None: ) -> None:
swa_loc = self.get_cached_swa_loc(raw_loc, layer_id)
fused_k_norm_rope_flashmla( fused_k_norm_rope_flashmla(
kv=kv, kv=kv,
kv_weight=kv_weight, kv_weight=kv_weight,
@@ -77,9 +77,7 @@ class HiSparseDSATokenToKVPool(DSATokenToKVPool):
) )
def translate_loc_to_hisparse_device(self, compressed_indices: torch.Tensor): def translate_loc_to_hisparse_device(self, compressed_indices: torch.Tensor):
return self.full_to_hisparse_device_index_mapping[compressed_indices].to( return self.full_to_hisparse_device_index_mapping[compressed_indices]
torch.int32
)
def _translate_loc_to_hisparse_device(self, compressed_indices: torch.Tensor): def _translate_loc_to_hisparse_device(self, compressed_indices: torch.Tensor):
return self.full_to_hisparse_device_index_mapping[compressed_indices] return self.full_to_hisparse_device_index_mapping[compressed_indices]
+1 -28
View File
@@ -92,8 +92,6 @@ class SWAKVPool(BaseSWAKVPool):
for swa_layer_id, global_layer_id in enumerate(swa_attention_layer_ids): for swa_layer_id, global_layer_id in enumerate(swa_attention_layer_ids):
self.layers_mapping[global_layer_id] = (swa_layer_id, True) self.layers_mapping[global_layer_id] = (swa_layer_id, True)
self.full_to_swa_index_mapping: Optional[torch.Tensor] = None self.full_to_swa_index_mapping: Optional[torch.Tensor] = None
self._cached_swa_loc: Optional[torch.Tensor] = None
self._cached_loc_key: Optional[tuple] = None
k_size, v_size = self.get_kv_size_bytes() k_size, v_size = self.get_kv_size_bytes()
self.mem_usage = (k_size + v_size) / GB self.mem_usage = (k_size + v_size) / GB
@@ -103,11 +101,6 @@ class SWAKVPool(BaseSWAKVPool):
def register_mapping(self, full_to_swa_index_mapping: torch.Tensor): def register_mapping(self, full_to_swa_index_mapping: torch.Tensor):
self.full_to_swa_index_mapping = full_to_swa_index_mapping self.full_to_swa_index_mapping = full_to_swa_index_mapping
self.invalidate_loc_cache()
def invalidate_loc_cache(self) -> None:
self._cached_swa_loc = None
self._cached_loc_key = None
def register_layer_transfer_counter(self, layer_transfer_counter): def register_layer_transfer_counter(self, layer_transfer_counter):
# Wait happens at this wrapper. Inner pools must not wait again. # Wait happens at this wrapper. Inner pools must not wait again.
@@ -167,21 +160,8 @@ class SWAKVPool(BaseSWAKVPool):
def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor) -> torch.Tensor: def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor) -> torch.Tensor:
assert self.full_to_swa_index_mapping is not None assert self.full_to_swa_index_mapping is not None
# data_ptr() (not untyped_storage().data_ptr()) encodes the offset, so
# views at different positions within the same storage get distinct keys.
# -1 in kv_indices maps to -1 via the sentinel appended to the mapping. # -1 in kv_indices maps to -1 via the sentinel appended to the mapping.
key = (kv_indices.data_ptr(), kv_indices.numel()) return self.full_to_swa_index_mapping[kv_indices]
if key != self._cached_loc_key:
if self._cached_loc_key is not None:
logger.debug(
"translate_loc_from_full_to_swa: loc tensor changed mid-forward "
"without invalidate_loc_cache() — possible missing call site"
)
self._cached_swa_loc = self.full_to_swa_index_mapping[kv_indices].to(
torch.int32
)
self._cached_loc_key = key
return self._cached_swa_loc
def set_kv_buffer( def set_kv_buffer(
self, self,
@@ -425,7 +405,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
return self._kvcache.translate_loc_from_full_to_swa(kv_indices) return self._kvcache.translate_loc_from_full_to_swa(kv_indices)
def alloc(self, need_size: int): def alloc(self, need_size: int):
self._kvcache.invalidate_loc_cache()
assert self.page_size == 1 assert self.page_size == 1
if need_size > self.full_attn_allocator.available_size(): if need_size > self.full_attn_allocator.available_size():
return None return None
@@ -454,7 +433,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
last_loc: torch.Tensor, # last_loc for full layers last_loc: torch.Tensor, # last_loc for full layers
extend_num_tokens: int, extend_num_tokens: int,
): ):
self._kvcache.invalidate_loc_cache()
assert self.page_size > 1 assert self.page_size > 1
num_new_pages = get_num_new_pages( num_new_pages = get_num_new_pages(
@@ -507,7 +485,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
extend_num_tokens: int, extend_num_tokens: int,
swa_tail_len: int, swa_tail_len: int,
): ):
self._kvcache.invalidate_loc_cache()
"""Allocate full KV for the whole extend and SWA KV only for the tail. """Allocate full KV for the whole extend and SWA KV only for the tail.
This is used by disaggregated decode preallocation: decode receives full This is used by disaggregated decode preallocation: decode receives full
@@ -571,7 +548,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
seq_lens_cpu: torch.Tensor, seq_lens_cpu: torch.Tensor,
last_loc: torch.Tensor, # last_loc for full layers last_loc: torch.Tensor, # last_loc for full layers
): ):
self._kvcache.invalidate_loc_cache()
assert self.page_size > 1 assert self.page_size > 1
swa_last_loc = self.translate_loc_from_full_to_swa(last_loc) swa_last_loc = self.translate_loc_from_full_to_swa(last_loc)
@@ -619,7 +595,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
if full_indices.numel() == 0: if full_indices.numel() == 0:
return return
assert full_indices.numel() == swa_indices.numel() assert full_indices.numel() == swa_indices.numel()
self._kvcache.invalidate_loc_cache()
if _is_npu: if _is_npu:
self.full_to_swa_index_mapping[full_indices.to(torch.int64)] = ( self.full_to_swa_index_mapping[full_indices.to(torch.int64)] = (
swa_indices.to(torch.int64) swa_indices.to(torch.int64)
@@ -628,7 +603,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.full_to_swa_index_mapping[full_indices] = swa_indices self.full_to_swa_index_mapping[full_indices] = swa_indices
def free_swa(self, free_index: torch.Tensor): def free_swa(self, free_index: torch.Tensor):
self._kvcache.invalidate_loc_cache()
swa_indices = self.full_to_swa_index_mapping[free_index] swa_indices = self.full_to_swa_index_mapping[free_index]
swa_indices = swa_indices[swa_indices > 0] swa_indices = swa_indices[swa_indices > 0]
self.swa_attn_allocator.free(swa_indices) self.swa_attn_allocator.free(swa_indices)
@@ -646,7 +620,6 @@ class SWATokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
self.swa_attn_allocator.restore_state(state[1]) self.swa_attn_allocator.restore_state(state[1])
def clear(self): def clear(self):
self._kvcache.invalidate_loc_cache()
self.swa_attn_allocator.clear() self.swa_attn_allocator.clear()
self.full_attn_allocator.clear() self.full_attn_allocator.clear()
# Note: the last item is -1, we don't clear it, see the comment in __init__ # Note: the last item is -1, we don't clear it, see the comment in __init__
@@ -414,9 +414,6 @@ class BreakableCudaGraphRunner:
self.model_runner.attn_backend.init_forward_metadata(forward_batch) self.model_runner.attn_backend.init_forward_metadata(forward_batch)
def run_once(): def run_once():
# Invalidate SWA loc cache — same fix as in cuda_graph_runner.run_once.
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
return self._run_forward(forward_batch, num_tokens) return self._run_forward(forward_batch, num_tokens)
with forward_context( with forward_context(
@@ -1047,12 +1047,6 @@ class CudaGraphRunner:
attn_backend.init_forward_metadata_out_graph(forward_batch, in_capture=True) attn_backend.init_forward_metadata_out_graph(forward_batch, in_capture=True)
def run_once(): def run_once():
# Without this, warmup-1 caches the translation; the capture
# run hits the cache, skips the gather, and replay reuses
# stale SWA locations.
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
# Must run inside the capture block: warmup mutations here are # Must run inside the capture block: warmup mutations here are
# undone by on_after_cuda_graph_warmup so capture starts clean. # undone by on_after_cuda_graph_warmup so capture starts clean.
attn_backend.init_forward_metadata_in_graph(forward_batch) attn_backend.init_forward_metadata_in_graph(forward_batch)
@@ -3362,9 +3362,6 @@ class ModelRunner(ModelRunnerKVCacheMixin):
self.hisparse_coordinator.wait_for_pending_backup() self.hisparse_coordinator.wait_for_pending_backup()
self.hisparse_coordinator.num_real_reqs.fill_(forward_batch.batch_size) self.hisparse_coordinator.num_real_reqs.fill_(forward_batch.batch_size)
if self.is_hybrid_swa:
self.token_to_kv_pool.invalidate_loc_cache()
# Replay cuda graph if applicable # Replay cuda graph if applicable
if can_run_graph: if can_run_graph:
ret = self.graph_runner.replay( ret = self.graph_runner.replay(
@@ -623,10 +623,6 @@ class PiecewiseCudaGraphRunner:
# Run and capture # Run and capture
def run_once(): def run_once():
# Invalidate SWA loc cache — same fix as in cuda_graph_runner.run_once.
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
# Clean intermediate result cache for DP attention # Clean intermediate result cache for DP attention
forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = ( forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = (
None None
+11 -10
View File
@@ -471,6 +471,7 @@ class MQALayer(nn.Module):
x: torch.Tensor, x: torch.Tensor,
positions: torch.Tensor, positions: torch.Tensor,
forward_batch: ForwardBatch, forward_batch: ForwardBatch,
attn_backend,
qkv_a: Optional[torch.Tensor] = None, qkv_a: Optional[torch.Tensor] = None,
) -> None: ) -> None:
"""Fused: rmsnorm + RoPE + write directly to FlashMLA paged cache. """Fused: rmsnorm + RoPE + write directly to FlashMLA paged cache.
@@ -487,7 +488,7 @@ class MQALayer(nn.Module):
assert isinstance(token_to_kv_pool, DeepSeekV4TokenToKVPool) assert isinstance(token_to_kv_pool, DeepSeekV4TokenToKVPool)
token_to_kv_pool.set_swa_key_buffer_radix_fused_norm_rope( token_to_kv_pool.set_swa_key_buffer_radix_fused_norm_rope(
layer_id=self.layer_id, layer_id=self.layer_id,
raw_loc=forward_batch.out_cache_loc, swa_loc=attn_backend.get_swa_out_cache_loc(forward_batch),
kv=kv, kv=kv,
kv_weight=self.kv_norm.weight.data, kv_weight=self.kv_norm.weight.data,
eps=self.eps, eps=self.eps,
@@ -562,7 +563,9 @@ class MQALayer(nn.Module):
if qkv_a_ready is not None: if qkv_a_ready is not None:
stream_kv.wait_event(qkv_a_ready) stream_kv.wait_event(qkv_a_ready)
# Fused norm + rope + cache write -- no bf16 KV intermediate. # Fused norm + rope + cache write -- no bf16 KV intermediate.
self._compute_kv_to_cache(x_linear, positions, forward_batch, qkv_a=qkv_a) self._compute_kv_to_cache(
x_linear, positions, forward_batch, attn_backend, qkv_a=qkv_a
)
del qkv_a del qkv_a
@@ -646,9 +649,7 @@ class MQALayer(nn.Module):
) )
token_to_kv_pool = get_token_to_kv_pool() token_to_kv_pool = get_token_to_kv_pool()
swa_loc = token_to_kv_pool.get_cached_swa_loc( swa_loc = attn_backend.get_swa_out_cache_loc(forward_batch)
forward_batch.out_cache_loc, self.layer_id
)
swa_cache = token_to_kv_pool.swa_kv_pool.kv_buffer[self.layer_id] swa_cache = token_to_kv_pool.swa_kv_pool.kv_buffer[self.layer_id]
swa_page_size = token_to_kv_pool.swa_kv_pool.page_size swa_page_size = token_to_kv_pool.swa_kv_pool.page_size
@@ -672,7 +673,9 @@ class MQALayer(nn.Module):
else: else:
q_lora = self.q_norm(q_lora) q_lora = self.q_norm(q_lora)
q = self._compute_q_b(q_lora, positions, q_out) q = self._compute_q_b(q_lora, positions, q_out)
self._compute_kv_to_cache(x_linear, positions, forward_batch, qkv_a=qkv_a) self._compute_kv_to_cache(
x_linear, positions, forward_batch, attn_backend, qkv_a=qkv_a
)
del qkv_a del qkv_a
@@ -736,9 +739,7 @@ class MQALayer(nn.Module):
) )
token_to_kv_pool = get_token_to_kv_pool() token_to_kv_pool = get_token_to_kv_pool()
swa_loc = token_to_kv_pool.get_cached_swa_loc( swa_loc = attn_backend.get_swa_out_cache_loc(forward_batch)
forward_batch.out_cache_loc, self.layer_id
)
swa_cache = token_to_kv_pool.swa_kv_pool.kv_buffer[self.layer_id] swa_cache = token_to_kv_pool.swa_kv_pool.kv_buffer[self.layer_id]
swa_page_size = token_to_kv_pool.swa_kv_pool.page_size swa_page_size = token_to_kv_pool.swa_kv_pool.page_size
@@ -790,7 +791,7 @@ class MQALayer(nn.Module):
) )
else: else:
self._compute_kv_to_cache( self._compute_kv_to_cache(
x_linear, positions, forward_batch, qkv_a=qkv_a x_linear, positions, forward_batch, attn_backend, qkv_a=qkv_a
) )
kv = None kv = None
@@ -352,9 +352,6 @@ class EAGLEDraftCudaGraphRunner:
) )
def run_once(): def run_once():
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
self.draft_attn_backend.init_forward_metadata_in_graph(forward_batch) self.draft_attn_backend.init_forward_metadata_in_graph(forward_batch)
forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None
@@ -376,9 +376,6 @@ class EAGLEDraftExtendCudaGraphRunner:
) )
def run_once(): def run_once():
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
# Clean intermediate result cache for DP attention # Clean intermediate result cache for DP attention
forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None
set_dp_buffer_len( set_dp_buffer_len(
@@ -270,9 +270,6 @@ class FrozenKVMTPCudaGraphRunner:
) )
def run_once(): def run_once():
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None
set_dp_buffer_len( set_dp_buffer_len(
global_dp_buffer_len, global_dp_buffer_len,
@@ -401,9 +401,6 @@ class MultiLayerEagleDraftExtendCudaGraphRunner:
attn_backend = self.eagle_worker.draft_extend_attn_backend_list[self.step] attn_backend = self.eagle_worker.draft_extend_attn_backend_list[self.step]
def run_once(): def run_once():
if self.model_runner.is_hybrid_swa:
self.model_runner.token_to_kv_pool.invalidate_loc_cache()
# Clean intermediate result cache for DP attention # Clean intermediate result cache for DP attention
forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None forward_batch.dp_local_start_pos = forward_batch.dp_local_num_tokens = None
set_dp_buffer_len( set_dp_buffer_len(
@@ -518,9 +518,12 @@ class ProjectedDSV4Attention(nn.Module):
# `[num_tokens, 1, hidden_dim]`. # `[num_tokens, 1, hidden_dim]`.
k_flat = k.reshape(k.shape[0], -1).to(torch.bfloat16) k_flat = k.reshape(k.shape[0], -1).to(torch.bfloat16)
pack = quant_to_nope_fp8_rope_bf16_pack_triton(k_flat) pack = quant_to_nope_fp8_rope_bf16_pack_triton(k_flat)
attn_backend.token_to_kv_pool.set_swa_key_buffer_radix( pool = attn_backend.token_to_kv_pool
pool.set_swa_key_buffer_radix(
layer_id=self.attn.layer_id, layer_id=self.attn.layer_id,
raw_loc=forward_batch.out_cache_loc.to(torch.int64), swa_loc=pool.translate_loc_from_full_to_swa(
forward_batch.out_cache_loc.to(torch.int64)
),
cache_nope_fp8_rope_bf16_pack=pack, cache_nope_fp8_rope_bf16_pack=pack,
) )
out = attn_backend.forward( out = attn_backend.forward(
@@ -546,7 +549,9 @@ def _write_swa_cache(
pack = quant_to_nope_fp8_rope_bf16_pack_triton(k_bf16.to(torch.bfloat16)) pack = quant_to_nope_fp8_rope_bf16_pack_triton(k_bf16.to(torch.bfloat16))
runner.token_to_kv_pool.set_swa_key_buffer_radix( runner.token_to_kv_pool.set_swa_key_buffer_radix(
layer_id=layer_id, layer_id=layer_id,
raw_loc=loc.to(torch.int64), swa_loc=runner.token_to_kv_pool.translate_loc_from_full_to_swa(
loc.to(torch.int64)
),
cache_nope_fp8_rope_bf16_pack=pack, cache_nope_fp8_rope_bf16_pack=pack,
) )
@@ -1,180 +0,0 @@
"""Regression test for PR #25889: DeepSeekV4TokenToKVPool.register_mapping()
must clear cached_loc.
Bug scenario (pre-fix):
During a forward pass, the first SWA layer (layer_id == start_layer) computes
and caches `cached_loc` via translate_loc_from_full_to_swa(). If HiCache then
loads back SWA KV from host, it calls register_mapping() to install the new
full->swa index mapping. Before the fix, register_mapping() only stored the
new tensor but did NOT clear cached_loc. Subsequent SWA layers (layer_id >
start_layer) saw `cached_loc is not None` and returned the stale translation,
silently writing KV to wrong SWA pool slots.
Fix (PR #25889): add `self.cached_loc = None` in register_mapping().
Test structure:
- test_stale_without_fix: shows the stale-return bug using a replica
of the pre-fix logic
- test_correct_with_fix: verifies the fixed logic returns fresh values
- test_actual_pool_register_mapping: exercises the real production method
directly (bypassing the full __init__)
Run with:
python -m pytest test/manual/core/test_dsv4_cached_loc_invalidation.py -v
"""
import unittest
import torch
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.srt.utils import get_device
from sglang.test.test_utils import CustomTestCase
# ---------------------------------------------------------------------------
# Minimal stub that replicates DeepSeekV4TokenToKVPool's caching pattern.
# Used to demonstrate both sides of the bug without constructing the full pool.
# ---------------------------------------------------------------------------
class _DSV4CacheStub:
"""Stripped-down replica of the caching logic in DeepSeekV4TokenToKVPool."""
start_layer = 3
def __init__(self, device):
self.device = device
self.cached_loc = None
self.full_to_swa_index_mapping = None
# --- pre-fix version ---
def register_mapping_buggy(self, mapping: torch.Tensor) -> None:
self.full_to_swa_index_mapping = mapping
# BUG: cached_loc not cleared → stale on next mid-forward call
# --- post-fix version (PR #25889) ---
def register_mapping_fixed(self, mapping: torch.Tensor) -> None:
self.full_to_swa_index_mapping = mapping
self.cached_loc = None # THE FIX
def _translate(self, raw_loc: torch.Tensor) -> torch.Tensor:
return self.full_to_swa_index_mapping[raw_loc]
def get_swa_loc(self, layer_id: int, raw_loc: torch.Tensor) -> torch.Tensor:
"""Exact replica of set_swa_key_buffer_radix_fused caching branch."""
if layer_id == self.start_layer or self.cached_loc is None:
self.cached_loc = self._translate(raw_loc)
return self.cached_loc
def _make_mapping(indices, values, size=32, device="cpu"):
m = torch.zeros(size, dtype=torch.int64, device=device)
m[indices] = torch.tensor(values, dtype=torch.int64, device=device)
return m
class TestDSV4CachedLocBugAndFix(CustomTestCase):
"""Shows the pre-fix bug and verifies the post-fix behaviour."""
def setUp(self):
self.device = get_device()
self.raw_loc = torch.tensor([0, 1, 2, 3], device=self.device)
self.mapping_v1 = _make_mapping(
[0, 1, 2, 3], [10, 11, 12, 13], device=self.device
)
self.mapping_v2 = _make_mapping(
[0, 1, 2, 3], [20, 21, 22, 23], device=self.device
)
def test_stale_without_fix(self):
"""Without the fix, register_mapping() mid-forward leaves a stale cached_loc.
Sequence:
1. start_layer: cached_loc = translate(mapping_v1) = [10..13]
2. HiCache load-back: register_mapping(mapping_v2) ← buggy, no clear
3. start_layer+1: layer_id != start_layer, cached_loc is not None
→ returns stale [10..13], NOT the correct [20..23]
"""
stub = _DSV4CacheStub(self.device)
stub.register_mapping_buggy(self.mapping_v1)
# Forward pass — first SWA layer primes the cache.
loc = stub.get_swa_loc(stub.start_layer, self.raw_loc)
self.assertEqual(loc.tolist(), [10, 11, 12, 13])
# HiCache load-back installs new mapping (buggy path).
stub.register_mapping_buggy(self.mapping_v2)
self.assertIsNotNone(stub.cached_loc, "Bug: cached_loc not cleared")
# Next SWA layer — should use mapping_v2 but returns mapping_v1.
loc_next = stub.get_swa_loc(stub.start_layer + 1, self.raw_loc)
self.assertEqual(
loc_next.tolist(),
[10, 11, 12, 13],
"Confirms the bug: stale cached_loc [10..13] returned instead of [20..23]",
)
def test_correct_with_fix(self):
"""With the fix, register_mapping() clears cached_loc; next layer recomputes.
Same sequence as test_stale_without_fix but using the fixed register_mapping.
"""
stub = _DSV4CacheStub(self.device)
stub.register_mapping_fixed(self.mapping_v1)
# Forward pass — first SWA layer primes the cache.
loc = stub.get_swa_loc(stub.start_layer, self.raw_loc)
self.assertEqual(loc.tolist(), [10, 11, 12, 13])
# HiCache load-back installs new mapping (fixed path).
stub.register_mapping_fixed(self.mapping_v2)
self.assertIsNone(
stub.cached_loc, "Fix: cached_loc cleared by register_mapping"
)
# Next SWA layer — recomputes with mapping_v2.
loc_next = stub.get_swa_loc(stub.start_layer + 1, self.raw_loc)
self.assertEqual(
loc_next.tolist(),
[20, 21, 22, 23],
"Fix works: fresh translation [20..23] from new mapping",
)
class TestDSV4ActualPoolRegisterMapping(CustomTestCase):
"""Exercises the production DeepSeekV4TokenToKVPool.register_mapping() directly.
Uses __new__ to bypass the complex __init__ (which needs full GPU pool setup)
and tests only the register_mapping / cached_loc contract.
"""
def test_register_mapping_clears_cached_loc(self):
device = get_device()
# Bypass full __init__ — only the fields register_mapping touches matter.
pool = DeepSeekV4TokenToKVPool.__new__(DeepSeekV4TokenToKVPool)
old_loc = torch.tensor([10, 11, 12, 13], device=device)
pool.cached_loc = old_loc
pool.full_to_swa_index_mapping = None
new_mapping = torch.arange(64, dtype=torch.int64, device=device)
pool.register_mapping(new_mapping)
self.assertIsNone(pool.cached_loc, "register_mapping must clear cached_loc")
self.assertIs(pool.full_to_swa_index_mapping, new_mapping)
def test_register_mapping_clears_none_cached_loc(self):
"""Idempotent when cached_loc is already None."""
pool = DeepSeekV4TokenToKVPool.__new__(DeepSeekV4TokenToKVPool)
pool.cached_loc = None
pool.full_to_swa_index_mapping = None
mapping = torch.arange(16, dtype=torch.int64, device=get_device())
pool.register_mapping(mapping)
self.assertIsNone(pool.cached_loc)
self.assertIs(pool.full_to_swa_index_mapping, mapping)
if __name__ == "__main__":
unittest.main()
@@ -1,114 +0,0 @@
"""E2E regression for PR #25889: DSV4 cached_loc stale after HiCache load-back.
Bug (pre-fix):
DeepSeekV4TokenToKVPool.register_mapping() replaces full_to_swa_index_mapping
but does NOT clear self.cached_loc. When SGLANG_OPT_CACHE_SWA_TRANSLATION=True,
set_swa_key_buffer_radix_fused() caches the full→SWA translation across SWA
layers. After a HiCache commit/load-back that calls register_mapping() with a
new mapping, subsequent SWA layers reuse the stale cached_loc and write KV to
wrong SWA pool slots — producing divergent logprobs.
Fix (PR #25889):
register_mapping() sets self.cached_loc = None so the first SWA layer in the
next forward pass recomputes the translation from the fresh mapping.
Test strategy:
Subclass the existing DSV4 Flash HiCache KL suite and activate the SWA
translation cache via SGLANG_OPT_CACHE_SWA_TRANSLATION=1. The mixin tests
(test_multiturn_logprobs_match, test_multiturn_prefill_cache_hit_branching,
test_multiturn_decode_cache_hit_branching) compare logprobs from cold and
warm radix-cache hits. Without the fix the stale translation corrupts SWA KV
data and the KL divergence exceeds the threshold; with the fix it stays within.
"""
import unittest
from test_unified_radix_cache_kl_hicache import (
DSV4_FLASH_LAUNCH_TIMEOUT,
DSV4_FLASH_MODEL,
_assert_dsv4_decode_cached_tokens,
)
from sglang.srt.utils import kill_process_tree
from sglang.test.kits.unified_radix_cache_kit import UnifiedRadixTreeTestMixin
from sglang.test.kl_multiturn_utils import get_input_ids
from sglang.test.test_utils import (
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
class TestDSV4HiCacheSWATranslationCache(UnifiedRadixTreeTestMixin, CustomTestCase):
"""DSV4 Flash FP8 + HiCache + SWA translation cache enabled.
Identical server config to TestUnifiedDeepSeekV4FlashHiCache but with
SGLANG_OPT_CACHE_SWA_TRANSLATION=1 to activate the cached_loc path.
Without PR #25889 the KL tests fail; with the fix they pass.
"""
kl_threshold = 0.005
sampling_temperature = 0
decode_hit_request_batch_size = 3
decode_hit_inter_batch_delay_s = 0.5
decode_cache_assert = staticmethod(_assert_dsv4_decode_cached_tokens)
gsm8k_threshold = 0.90
num_gsm8k_questions = 100
@unittest.skipIf(True, "Covered by test_multiturn_prefill_cache_hit_branching.")
def test_multiturn_logprobs_match(self):
pass
@classmethod
def setUpClass(cls):
cls.model = DSV4_FLASH_MODEL
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DSV4_FLASH_LAUNCH_TIMEOUT,
other_args=[
"--trust-remote-code",
"--tp-size",
"4",
"--attention-backend",
"compressed",
"--page-size",
"256",
"--chunked-prefill-size",
"8192",
"--mem-fraction-static",
"0.9",
"--disable-shared-experts-fusion",
"--enable-hierarchical-cache",
"--hicache-ratio",
"4",
"--hicache-write-policy",
"write_through",
"--hicache-io-backend",
"direct",
"--hicache-mem-layout",
"page_first_direct",
"--swa-full-tokens-ratio",
"0.25",
"--max-total-tokens",
"20000",
"--max-running-requests",
"4",
],
env={
"SGLANG_DSV4_FP4_EXPERTS": "0",
"SGLANG_ENABLE_UNIFIED_RADIX_TREE": "1",
# Activate the SWA translation cache — the flag that exposes the bug.
"SGLANG_OPT_CACHE_SWA_TRANSLATION": "1",
},
)
cls.input_ids = get_input_ids(cls.model, num_samples=18)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
if __name__ == "__main__":
unittest.main()
@@ -1,164 +0,0 @@
"""Crash regression for PR #25889: stale cached_loc after register_mapping().
Bug:
DeepSeekV4TokenToKVPool caches the full→SWA index translation in
self.cached_loc (when SGLANG_OPT_CACHE_SWA_TRANSLATION=True).
register_mapping() was replacing full_to_swa_index_mapping without
clearing cached_loc, so a subsequent set_swa_key_buffer_radix_fused
call would use stale SWA indices and, if those indices exceed the
current pool size, raise a RuntimeError (OOB tensor access).
Crash scenario reproduced here:
Pass 1 (large SWA pool, size=8): first SWA layer primes
cached_loc = [4, 5, 6, 7].
register_mapping() called with new mapping (pre-fix: cache not cleared).
Pass 2 (smaller SWA pool, size=4): same SWA layer finds cached_loc is
not None → uses stale [4, 5, 6, 7] → OOB write on size-4 pool →
RuntimeError.
Fix: register_mapping() sets self.cached_loc = None so the next call
recomputes with the fresh mapping.
Run with:
python -m pytest test/registered/unit/mem_cache/test_dsv4_stale_loc_crash.py -v
"""
import unittest
import torch
from sglang.srt.mem_cache.deepseek_v4_memory_pool import DeepSeekV4TokenToKVPool
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=3, suite="base-a-test-cpu")
_NUM_HEADS = 2
_HEAD_DIM = 8
_SWA_LARGE = 8 # size-8 pool used during pass 1
_SWA_SMALL = 4 # size-4 pool used during pass 2 (simulates post-HiCache loadback)
class _SWAPoolMock:
"""Minimal SWA pool mock whose set_key_buffer_fused does a real tensor write.
A write with OOB swa_loc raises RuntimeError, reproducing the crash.
"""
def __init__(self, size: int):
self.buf = torch.zeros(size, _NUM_HEADS, _HEAD_DIM)
def set_key_buffer_fused(
self, local_layer_id: int, swa_loc: torch.Tensor, cache_k: torch.Tensor
) -> None:
n = swa_loc.numel()
self.buf[swa_loc.long()] = cache_k[:n].reshape(n, _NUM_HEADS, _HEAD_DIM)
def _build_pool(
mapping: torch.Tensor,
swa_pool: _SWAPoolMock,
start_layer: int = 0,
) -> DeepSeekV4TokenToKVPool:
"""Create a minimal DeepSeekV4TokenToKVPool via __new__, bypassing __init__."""
pool = DeepSeekV4TokenToKVPool.__new__(DeepSeekV4TokenToKVPool)
pool.cached_loc = None
pool._should_cache_swa = True
pool.start_layer = start_layer
pool.full_to_swa_index_mapping = mapping
pool.swa_kv_pool = swa_pool
# _swa_local_layer_id: map global SWA layer id → local index 0 for this test.
pool._swa_local_layer_id = lambda lid: 0
return pool
def _mapping(indices: list, size: int = 16) -> torch.Tensor:
m = torch.full((size,), -1, dtype=torch.int64)
for i, v in enumerate(indices):
m[i] = v
return m
class TestDSV4StaleLocCrash(CustomTestCase):
"""
Two paired tests that together constitute the crash regression for #25889.
test_crash_without_fix: reproduces the RuntimeError that occurs when
register_mapping() does NOT clear cached_loc.
test_fix_prevents_crash: same sequence with the fixed register_mapping()
→ no error, correct SWA slots written.
"""
def setUp(self):
# raw_loc: full-pool indices for 4 tokens.
self.raw_loc = torch.tensor([0, 1, 2, 3], dtype=torch.int64)
# cache_k: synthetic key data for 4 tokens.
self.cache_k = torch.ones(4, _NUM_HEADS, _HEAD_DIM)
# SWA layer id > start_layer (0), so cached_loc is only reset by the
# "is None" branch, NOT by the "layer_id == start_layer" branch.
self.swa_layer_id = 1
# mapping_v1: raw_loc [0,1,2,3] → SWA slots [4,5,6,7] (valid for size-8 pool).
self.mapping_v1 = _mapping([4, 5, 6, 7])
# mapping_v2: same raw_loc → SWA slots [0,1,2,3] (valid for size-4 pool).
self.mapping_v2 = _mapping([0, 1, 2, 3])
def test_crash_without_fix(self):
"""Without the fix, stale cached_loc [4,5,6,7] causes RuntimeError on
the size-4 pool used in pass 2."""
large_pool = _SWAPoolMock(_SWA_LARGE)
pool = _build_pool(self.mapping_v1, large_pool, start_layer=0)
# Pass 1: swa_layer_id=1, cached_loc is None → compute and cache [4,5,6,7].
pool.set_swa_key_buffer_radix_fused(
self.swa_layer_id, self.raw_loc, self.cache_k
)
self.assertEqual(pool.cached_loc.tolist(), [4, 5, 6, 7], "Pass 1: cache primed")
# PRE-FIX register_mapping: replace mapping WITHOUT clearing cached_loc.
pool.full_to_swa_index_mapping = self.mapping_v2
# Pass 2 on a smaller pool (size=4). cached_loc is still [4,5,6,7].
# OOB write → RuntimeError.
pool.swa_kv_pool = _SWAPoolMock(_SWA_SMALL)
with self.assertRaises((RuntimeError, IndexError)):
pool.set_swa_key_buffer_radix_fused(
self.swa_layer_id, self.raw_loc, self.cache_k
)
def test_fix_prevents_crash(self):
"""With the fix, register_mapping() clears cached_loc. Pass 2 recomputes
[0,1,2,3] from mapping_v2 and writes to the correct size-4 pool slots."""
large_pool = _SWAPoolMock(_SWA_LARGE)
pool = _build_pool(self.mapping_v1, large_pool, start_layer=0)
# Pass 1: prime cache → cached_loc = [4,5,6,7].
pool.set_swa_key_buffer_radix_fused(
self.swa_layer_id, self.raw_loc, self.cache_k
)
self.assertEqual(pool.cached_loc.tolist(), [4, 5, 6, 7])
# FIXED register_mapping: clears cached_loc.
pool.register_mapping(self.mapping_v2)
self.assertIsNone(
pool.cached_loc, "Fix: cached_loc cleared by register_mapping"
)
# Pass 2 on the smaller pool: cached_loc is None → recompute with mapping_v2.
small_pool = _SWAPoolMock(_SWA_SMALL)
pool.swa_kv_pool = small_pool
pool.set_swa_key_buffer_radix_fused(
self.swa_layer_id, self.raw_loc, self.cache_k
)
self.assertEqual(
pool.cached_loc.tolist(), [0, 1, 2, 3], "Fresh indices after fix"
)
# Verify data landed in the correct SWA slots [0-3], not the stale [4-7].
self.assertTrue(
small_pool.buf[0:4].abs().sum().item() > 0,
"Correct SWA slots 0-3 received data",
)
if __name__ == "__main__":
unittest.main()
@@ -1,233 +0,0 @@
"""Manual tests for SWAKVPool.translate_loc_from_full_to_swa cache behaviour.
These tests cover three properties introduced by PR #25824:
1. Cache key uses data_ptr() — correctly distinguishes views at different
offsets within the same storage (untyped_storage().data_ptr() would not).
2. Allocator mutations invalidate the cache — alloc/free/clear/
set_full_to_swa_mapping each call invalidate_loc_cache() so the next
translation sees the fresh mapping.
3. BaseSWAKVPool.invalidate_loc_cache is a no-op default — subclasses that
don't cache (e.g. DSV4) can be called safely without AttributeError.
Run with:
python -m pytest test/manual/core/test_swa_loc_translation_cache.py -v
"""
import unittest
import torch
from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool, SWATokenToKVPoolAllocator
from sglang.srt.utils import get_device
from sglang.test.test_utils import CustomTestCase
def _build_pool(
kv_size: int = 32,
kv_size_swa: int = 32,
page_size: int = 1,
):
device = get_device()
num_layers = 8
full_layer_ids = [0, 4]
swa_layer_ids = [i for i in range(num_layers) if i not in set(full_layer_ids)]
pool = SWAKVPool(
size=kv_size,
size_swa=kv_size_swa,
page_size=page_size,
dtype=torch.bfloat16,
head_num=4,
head_dim=64,
swa_attention_layer_ids=swa_layer_ids,
full_attention_layer_ids=full_layer_ids,
enable_kvcache_transpose=False,
device=device,
)
allocator = SWATokenToKVPoolAllocator(
size=kv_size,
size_swa=kv_size_swa,
page_size=page_size,
dtype=torch.bfloat16,
device=device,
kvcache=pool,
need_sort=False,
)
return pool, allocator, device
class TestCacheKeyDataPtr(CustomTestCase):
"""Cache key uses data_ptr(), which encodes the storage offset."""
def test_same_offset_view_is_cache_hit(self):
"""Two different Python objects pointing to the same base are a hit."""
pool, allocator, device = _build_pool()
loc = allocator.alloc(4)
self.assertIsNotNone(loc)
# Create two slice objects at offset 0 — same data_ptr, same numel.
view_a = loc[:4]
view_b = loc[:4]
self.assertIsNot(view_a, view_b) # different Python objects
self.assertEqual(view_a.data_ptr(), view_b.data_ptr())
result_a = pool.translate_loc_from_full_to_swa(view_a)
result_b = pool.translate_loc_from_full_to_swa(view_b)
# Both should return the identical tensor (cache hit).
self.assertIs(result_a, result_b)
def test_different_offset_view_is_cache_miss(self):
"""Views at different offsets produce different data_ptr → cache miss."""
pool, allocator, device = _build_pool(kv_size=32, kv_size_swa=32)
loc = allocator.alloc(10)
self.assertIsNotNone(loc)
self.assertGreaterEqual(loc.numel(), 10)
view_lo = loc[0:5]
view_hi = loc[5:10]
self.assertEqual(view_lo.numel(), view_hi.numel()) # same numel
# Different data_ptr (different storage offset).
self.assertNotEqual(view_lo.data_ptr(), view_hi.data_ptr())
# Prime the cache with view_lo.
result_lo = pool.translate_loc_from_full_to_swa(view_lo)
# view_hi should be a cache miss and produce a distinct translation.
result_hi = pool.translate_loc_from_full_to_swa(view_hi)
# They should NOT be the same object (different cache entries).
self.assertIsNot(result_lo, result_hi)
# And the content must differ (different full indices → different swa).
self.assertFalse(torch.equal(result_lo, result_hi))
def test_storage_base_ptr_would_collide(self):
"""Demonstrate that untyped_storage().data_ptr() WOULD collide for the
two views above — confirming data_ptr() is the right key."""
t = torch.arange(20, device=get_device())
a, b = t[0:10], t[5:15]
# Same storage base — old key would collide.
self.assertEqual(a.untyped_storage().data_ptr(), b.untyped_storage().data_ptr())
self.assertEqual(a.numel(), b.numel())
# But data_ptr differs — new key is safe.
self.assertNotEqual(a.data_ptr(), b.data_ptr())
class TestAllocatorMutationInvalidation(CustomTestCase):
"""Each allocator method that writes the mapping calls invalidate_loc_cache."""
def _prime_and_check_invalidation(self, pool, allocator, mutate_fn):
"""Helper: prime cache, mutate, assert fresh translation."""
loc = allocator.alloc(4)
self.assertIsNotNone(loc)
# Prime the cache.
first = pool.translate_loc_from_full_to_swa(loc)
self.assertIsNotNone(pool._cached_loc_key)
# Mutate — should invalidate.
mutate_fn(allocator, loc)
# Cache must be cleared after mutation.
self.assertIsNone(pool._cached_loc_key)
self.assertIsNone(pool._cached_swa_loc)
def test_alloc_invalidates(self):
pool, allocator, _ = _build_pool()
loc = allocator.alloc(4)
pool.translate_loc_from_full_to_swa(loc)
self.assertIsNotNone(pool._cached_loc_key)
# Another alloc should invalidate.
allocator.alloc(4)
self.assertIsNone(pool._cached_loc_key)
def test_free_swa_invalidates(self):
pool, allocator, _ = _build_pool()
loc = allocator.alloc(4)
pool.translate_loc_from_full_to_swa(loc)
self.assertIsNotNone(pool._cached_loc_key)
allocator.free_swa(loc)
self.assertIsNone(pool._cached_loc_key)
def test_clear_invalidates(self):
pool, allocator, _ = _build_pool()
loc = allocator.alloc(4)
pool.translate_loc_from_full_to_swa(loc)
self.assertIsNotNone(pool._cached_loc_key)
allocator.clear()
self.assertIsNone(pool._cached_loc_key)
def test_set_full_to_swa_mapping_invalidates(self):
"""HiCache load-back path: set_full_to_swa_mapping must invalidate."""
pool, allocator, device = _build_pool(kv_size=32, kv_size_swa=32)
loc = allocator.alloc(4)
pool.translate_loc_from_full_to_swa(loc)
self.assertIsNotNone(pool._cached_loc_key)
# Simulate HiCache rebuild with new swa indices.
new_swa = torch.arange(4, dtype=torch.int64, device=device)
allocator.set_full_to_swa_mapping(loc, new_swa)
self.assertIsNone(pool._cached_loc_key)
# Translation after rebuild should reflect the new mapping.
result = pool.translate_loc_from_full_to_swa(loc)
self.assertEqual(result.tolist(), new_swa.tolist())
class TestBaseClassNoOp(CustomTestCase):
"""BaseSWAKVPool.invalidate_loc_cache is a no-op default — must not raise."""
def test_noop_does_not_raise(self):
# BaseSWAKVPool is abstract; instantiate via SWAKVPool which inherits.
pool, _, _ = _build_pool()
# Calling on the concrete class uses the override — that's fine.
pool.invalidate_loc_cache() # must not raise
pool.invalidate_loc_cache() # idempotent
def test_base_class_noop_directly(self):
"""Call the base-class method directly to verify it's a true no-op."""
pool, _, _ = _build_pool()
# Prime the cache first.
loc = pool.full_to_swa_index_mapping # any tensor
pool._cached_loc_key = ("dummy", 1)
pool._cached_swa_loc = torch.zeros(1)
# Call the BASE class method directly — should not clear the cache
# (it's a no-op; the concrete override is what clears).
BaseSWAKVPool.invalidate_loc_cache(pool)
# base no-op: cache untouched
self.assertIsNotNone(pool._cached_loc_key)
class TestExplicitInvalidationCycle(CustomTestCase):
"""Simulates the per-forward-pass invalidation done by model_runner."""
def test_fresh_translation_after_explicit_invalidation(self):
"""After invalidate_loc_cache(), a new alloc produces the right mapping."""
pool, allocator, device = _build_pool(kv_size=32, kv_size_swa=32)
# First "forward pass": alloc 4 tokens, translate.
loc1 = allocator.alloc(4)
trans1 = pool.translate_loc_from_full_to_swa(loc1).clone()
# Simulate start of next forward pass: model_runner calls invalidate.
pool.invalidate_loc_cache()
self.assertIsNone(pool._cached_loc_key)
# Alloc 4 more (mapping changes), translate loc1 again.
loc2 = allocator.alloc(4)
# Alloc already invalidated; translate loc1 with fresh mapping.
trans1_after = pool.translate_loc_from_full_to_swa(loc1)
# loc1's SWA mapping hasn't changed (same full→swa assignment),
# so result should be equal — but it must have been recomputed
# (cache key was None before this call).
self.assertEqual(trans1.tolist(), trans1_after.tolist())
# loc2 should have different translation than loc1.
trans2 = pool.translate_loc_from_full_to_swa(loc2)
# They have different indices, so translation differs.
self.assertFalse(torch.equal(trans1_after, trans2))
if __name__ == "__main__":
unittest.main()
@@ -16,6 +16,7 @@ import importlib.util
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import torch import torch
@@ -337,5 +338,75 @@ class TestDSV4AttentionBackendCorrectness(CustomTestCase):
run_dsv4_eagle_draft_extend_cuda_graph_runner_case(self, case) run_dsv4_eagle_draft_extend_cuda_graph_runner_case(self, case)
class TestDSV4SwaOutCacheLocResolution(CustomTestCase):
"""`get_swa_out_cache_loc`: cached fast path vs store-time fallback.
The KV-store consumers run in paths that never invoke
`init_forward_metadata_in_graph` (eager idle, runners that only run the
out-graph prep) or whose batch is re-padded after init (DP attention).
The resolver must use the per-forward cached value only when it is
provably current and fall back to translating `out_cache_loc` otherwise.
"""
def _make_backend(self, mapping: torch.Tensor):
from sglang.srt.layers.attention.deepseek_v4_backend import (
DeepseekV4AttnBackend,
)
backend = object.__new__(DeepseekV4AttnBackend)
backend.forward_metadata = None
backend.token_to_kv_pool = SimpleNamespace(
translate_loc_from_full_to_swa=lambda loc: mapping[loc]
)
return backend
@staticmethod
def _make_fb(out_cache_loc: torch.Tensor, forward_mode: ForwardMode):
return SimpleNamespace(out_cache_loc=out_cache_loc, forward_mode=forward_mode)
@staticmethod
def _set_cached(backend, cached: torch.Tensor):
backend.forward_metadata = SimpleNamespace(
core_attn_metadata=SimpleNamespace(swa_out_cache_loc=cached)
)
def test_no_metadata_falls_back_to_translate(self):
mapping = torch.arange(10, dtype=torch.int64) * 2
backend = self._make_backend(mapping)
fb = self._make_fb(torch.tensor([3, 4]), ForwardMode.DECODE)
out = backend.get_swa_out_cache_loc(fb)
self.assertEqual(out.dtype, torch.int32)
self.assertEqual(out.tolist(), [6, 8])
def test_current_cached_value_is_used(self):
mapping = torch.arange(10, dtype=torch.int64) * 2
backend = self._make_backend(mapping)
cached = torch.tensor([6, 8], dtype=torch.int32)
self._set_cached(backend, cached)
fb = self._make_fb(torch.tensor([3, 4]), ForwardMode.DECODE)
self.assertIs(backend.get_swa_out_cache_loc(fb), cached)
def test_stale_shape_falls_back_to_translate(self):
# DP padding rebinds out_cache_loc to a longer tensor after init;
# a pre-pad cached value must not be used.
mapping = torch.arange(10, dtype=torch.int64) * 2
backend = self._make_backend(mapping)
self._set_cached(backend, torch.tensor([6, 8], dtype=torch.int32))
fb = self._make_fb(torch.tensor([3, 4, 0, 0]), ForwardMode.DRAFT_EXTEND_V2)
out = backend.get_swa_out_cache_loc(fb)
self.assertEqual(out.tolist(), [6, 8, 0, 0])
def test_idle_never_uses_cached_value(self):
# Idle forwards skip attn init, so any metadata is left over from a
# previous forward; writing dummy tokens to its locations would
# corrupt live KV. Idle must translate the zero-padded out_cache_loc.
mapping = torch.arange(10, dtype=torch.int64) * 2
backend = self._make_backend(mapping)
self._set_cached(backend, torch.tensor([6, 8], dtype=torch.int32))
fb = self._make_fb(torch.tensor([0, 0]), ForwardMode.IDLE)
out = backend.get_swa_out_cache_loc(fb)
self.assertEqual(out.tolist(), [0, 0])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -33,7 +33,6 @@ def _make_self(*, page_size: int, full_available: int, swa_available: int):
), ),
translate_loc_from_full_to_swa=lambda last_loc: last_loc, translate_loc_from_full_to_swa=lambda last_loc: last_loc,
full_to_swa_index_mapping=torch.zeros(64, dtype=torch.int64), full_to_swa_index_mapping=torch.zeros(64, dtype=torch.int64),
_kvcache=SimpleNamespace(invalidate_loc_cache=lambda: None),
) )