[DSV4] Fix silent KV corruption when speculative draft tokens > 4 (#34189)

Co-authored-by: Deleter-D <867909454@qq.com>
This commit is contained in:
Liangsheng Yin
2026-08-09 15:54:41 -07:00
committed by GitHub
co-authored by Deleter-D
parent 57f2105118
commit 4a5d7d3c67
6 changed files with 314 additions and 7 deletions
@@ -47,6 +47,8 @@ struct Prefill0Params {
uint32_t num_q_tokens;
int32_t compress_ratio;
int32_t swa_page_size;
/// \brief Trailing tokens the write plan keeps resident in the compress state ring.
/// Derived from the ring in `plan_compress_prefill`; see the bound there.
int32_t mtp_pad;
};
@@ -509,13 +511,19 @@ inline PrefillPlan plan_compress_prefill(
RuntimeCheck(batch_size <= num_q_tokens && num_q_tokens <= kMaxTokens);
// `swa_page_size` >= `ring_size` >= `compress_ratio`
RuntimeCheck(swa_page_size % ring_size == 0 && ring_size % compress_ratio == 0);
// Write pad: trailing tokens kept resident so a verify batch's committed tail survives
// any accept length. Zero without speculation -- nothing rolls back, and the ring is
// then exactly one window wide. Otherwise the ring bounds it: a write at `w` aliases
// onto `w - ring_size`, and the earliest position a future compression still needs is
// `prefix_len - window_size + 2` (the next batch commits >= 1 token, and `run_prefill`
// launches the compress kernel before the write kernel, so a batch's own compressions
// read the pre-write ring). Padding past the extend range is harmless: the loops only
// span `[prefix_len, seq_len)`.
const auto mtp_pad = ring_size > window_size ? ring_size - window_size + 2 : 0;
const auto device = device_.unwrap();
const auto stream = LaunchKernel::resolve_device(device);
constexpr int32_t kMaxMTPDraftTokens = 4;
const auto mtp_pad = std::min(ring_size - compress_ratio, kMaxMTPDraftTokens);
if (cpu_or_gpu.unwrap().device_type == kDLGPU) {
// GPU input path: kernel0 builds the (CPU-loop-equivalent) plan metadata directly
// on device, padding to num_q_tokens with invalid; kernel_1 then finalizes the
@@ -575,7 +583,7 @@ inline PrefillPlan plan_compress_prefill(
const int32_t extend_len = ext_ptr[i];
const int32_t prefix_len = seq_len - extend_len;
const int32_t last_c_pos = seq_len / compress_ratio * compress_ratio;
const int32_t first_w_pos = last_c_pos - (is_overlap ? compress_ratio : 0);
const int32_t first_w_pos = std::min(last_c_pos - (is_overlap ? compress_ratio : 0), seq_len - mtp_pad);
RuntimeCheck(0 < extend_len && extend_len <= seq_len);
const auto should_write = [=](int32_t position) {
if (position >= first_w_pos) return true;
@@ -48,6 +48,14 @@ def get_compress_state_ring_size(
return 8 if compress_ratio == 4 else 128
def get_compress_state_write_pad(compress_ratio: int, ring_size: int) -> int:
"""Largest draft-token count this ring can serve; mirrors `mtp_pad` in `c_plan.cuh`
(the bound is derived there). Zero for a non-speculative ring, which is exactly one
window wide."""
window_size = compress_ratio * (2 if compress_ratio == 4 else 1)
return ring_size - window_size + 2 if ring_size > window_size else 0
class DeepSeekV4SingleKVPool(KVCache):
def __init__(
self,
@@ -31,7 +31,10 @@ from sglang.srt.configs.model_config import (
)
from sglang.srt.environ import envs
from sglang.srt.mem_cache.allocation_sizing import get_alloc_len_per_decode
from sglang.srt.mem_cache.deepseek_v4_memory_pool import get_compress_state_ring_size
from sglang.srt.mem_cache.deepseek_v4_memory_pool import (
get_compress_state_ring_size,
get_compress_state_write_pad,
)
from sglang.srt.mem_cache.memory_pool import DSATokenToKVPool
from sglang.srt.runtime_context import get_parallel
from sglang.srt.utils.common import (
@@ -667,6 +670,12 @@ class DSV4PoolConfigurator(MemoryPoolConfigurator):
self.num_layers_ca4 = sum(1 for r in self.compression_ratios if r == 4)
self.num_layers_ca128 = sum(1 for r in self.compression_ratios if r == 128)
if self.is_speculative:
# Ring is sized once here, so it must serve the largest adaptive tier.
self._assert_ring_serves_draft_tokens(
kvc.server_args.max_speculative_num_draft_tokens or 0
)
self.bytes_per_full_token = self._get_bytes_per_full_token()
if self.is_speculative:
# Reserve memory for the speculative draft worker by inflating
@@ -707,6 +716,26 @@ class DSV4PoolConfigurator(MemoryPoolConfigurator):
"DSV4 compressed attention: online c128 enabled (ring_size=1)"
)
def _assert_ring_serves_draft_tokens(self, num_draft_tokens: int) -> None:
"""A verify batch writes its whole optimistic tail into the ring, so ring
capacity bounds the draft count."""
for compress_ratio, ring_size, num_layers in (
(4, self.c4_ring_size, self.num_layers_ca4),
(128, self.c128_ring_size, self.num_layers_ca128),
):
if num_layers == 0:
continue
if compress_ratio == 128 and envs.SGLANG_OPT_USE_ONLINE_COMPRESS.get():
# Online c128 keeps per-draft state instead of a ring; sized separately.
continue
max_draft_tokens = get_compress_state_write_pad(compress_ratio, ring_size)
assert num_draft_tokens <= max_draft_tokens, (
f"speculative_num_draft_tokens={num_draft_tokens} exceeds what the c{compress_ratio} "
f"compress state ring can keep resident (ring_size={ring_size} serves at most "
f"{max_draft_tokens} draft tokens). Lower the draft count, or grow the ring in "
f"get_compress_state_ring_size()."
)
def _get_bytes_per_full_token(self) -> float:
kv_bytes = self.qk_nope_head_dim + self.qk_rope_head_dim * 2 + 8
@@ -371,7 +371,7 @@ class MockDSV4ModelRunner:
max_running_requests=None,
pp_size=1,
revision=None,
speculative_algorithm=None,
speculative_algorithm=("EAGLE" if speculative_num_draft_tokens else None),
speculative_eagle_topk=speculative_eagle_topk,
speculative_num_draft_tokens=speculative_num_draft_tokens,
speculative_num_steps=max(0, speculative_num_draft_tokens - 1),
@@ -400,7 +400,7 @@ class MockDSV4ModelRunner:
c4_state_pool_size=pool_batch_size,
c128_state_pool_size=pool_batch_size,
page_size=case.page_size,
swa_page_size=DSV4_SWA_WINDOW,
swa_page_size=case.page_size,
dtype=torch.float8_e4m3fn,
c4_state_dtype=dtype,
c128_state_dtype=dtype,