Add token-id verification to the KV-canary (#26818)

This commit is contained in:
fzyzcjy
2026-05-31 09:58:51 +08:00
committed by GitHub
parent 0ca610a6df
commit 6be4b32d8d
18 changed files with 983 additions and 13 deletions
@@ -14,6 +14,7 @@ register_cuda_ci(est_time=60, stage="extra-a", runner_config="1-gpu-small")
_SPEC_EAGLE_TOKEN_ORACLE_ENV = {
"SGLANG_KV_CANARY_ENABLE_WRITE_INPUT_ASSERT": "0",
"SGLANG_KV_CANARY_ENABLE_TOKEN_ORACLE": "1",
"SGLANG_KV_CANARY_ENABLE_VERIFY_TOKEN_ASSERT": "0",
}
_SPEC_EAGLE_REVERT_PR_ENV = {
**_SPEC_EAGLE_TOKEN_ORACLE_ENV,
@@ -0,0 +1,84 @@
"""Regression for PR #26329 EAGLE chunked-prefill rotation."""
from __future__ import annotations
import random
import string
import unittest
from typing import ClassVar
from sglang.srt.kv_canary.config import CanaryMode
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kv_canary.e2e_base import CanaryE2EBase
register_cuda_ci(est_time=60, stage="extra-a", runner_config="1-gpu-small")
_CHUNKED_PREFILL_SIZE = 2048
_EAGLE_CHUNKED_SERVER_ARGS = (
"--speculative-algorithm",
"EAGLE",
"--chunked-prefill-size",
str(_CHUNKED_PREFILL_SIZE),
"--cuda-graph-max-bs",
"1",
"--max-running-requests",
"4",
)
class _EagleChunkedRotationBase(CanaryE2EBase):
model_mode = "mha"
kv_canary_mode = CanaryMode.LOG
extra_server_args = _EAGLE_CHUNKED_SERVER_ARGS
revert_pr: ClassVar[bool]
@classmethod
def setUpClass(cls) -> None:
if cls is _EagleChunkedRotationBase:
raise unittest.SkipTest("abstract base; concrete subclasses set revert_pr")
cls.extra_env = {"SGLANG_DEBUG_REVERT_PR": "26329"} if cls.revert_pr else {}
super().setUpClass()
def make_prompts(self, n: int) -> list[str]:
# Seeded random ASCII so the model can't predict the next prompt token
# — otherwise target's bonus token can accidentally match prompt[K1]
# at the chunk boundary and the validator never fires.
rng = random.Random(0)
# ~3K tokens after BPE — spans 2+ chunks at chunked_prefill_size=2048.
body = "".join(rng.choices(string.ascii_letters + string.digits + " ", k=8000))
return [body] * n
def test_chunked_rotation_token_id_mismatch(self) -> None:
self.send_parallel_requests(
n=1,
assert_all_success=not self.revert_pr,
max_new_tokens=8,
timeout=60.0,
)
if self.revert_pr:
self.assert_violation_logged_any(
launch_tag_patterns=("*",),
fail_reason="verify_token",
flush_wait_seconds=3.0,
)
else:
self.assert_no_violation(wait_seconds=2.0)
class TestEagleChunkedRotationRegression(_EagleChunkedRotationBase):
"""Revert PR #26329 fix; expect canary to fire a verify_token violation."""
revert_pr = True
class TestEagleChunkedRotationClean(_EagleChunkedRotationBase):
"""With the PR #26329 fix in place, the same request runs clean."""
revert_pr = False
if __name__ == "__main__":
unittest.main()
@@ -25,6 +25,7 @@ def _config(mode: RealKvHashMode) -> CanaryConfig:
sweep_interval=0,
real_kv_hash_mode=mode,
enable_write_input_assert=False,
enable_verify_token_assert=False,
)
@@ -108,6 +108,28 @@ class TestSelfUnitPlanInput(CustomTestCase):
self.assertEqual(plan.prefix_lens[:3].tolist(), [3, 6, 0])
self.assertEqual(plan.extend_seq_lens[:3].tolist(), [1, 1, 1])
def test_plan_input_mirrors_req_all_ids_lens(self):
"""req_to_verify_expected_tokens_valid_lens copies forward_batch.req_all_ids_lens for active rows."""
fb = make_forward_batch(
self.device,
req_pool_indices=torch.tensor(
[1, 2], dtype=torch.int64, device=self.device
),
seq_lens=torch.tensor([10, 12], dtype=torch.int32, device=self.device),
is_extend=False,
)
fb.req_all_ids_lens = torch.tensor([7, 9], dtype=torch.int64, pin_memory=True)
plan = _make_static_plan_input(bs_capacity=4, device=self.device)
plan.fill_from_forward_batch(forward_batch=fb)
torch.cuda.synchronize()
self.assertEqual(
plan.req_to_verify_expected_tokens_valid_lens[:2].tolist(), [7, 9]
)
# Padding tail stays at zero so the plan kernel reads "no in-range positions" for it.
self.assertEqual(
plan.req_to_verify_expected_tokens_valid_lens[2:].tolist(), [0, 0]
)
def test_plan_input_padding_dummy_sentinel(self):
"""Verify padding sentinel rows remain valid plan input entries."""
fb = make_forward_batch(
@@ -0,0 +1,170 @@
from __future__ import annotations
import unittest
from array import array
from types import SimpleNamespace
import torch
from sglang.srt.kv_canary.req_to_expected_token_ids_manager import (
compute_req_all_ids_info,
populate_req_to_expected_token_ids,
)
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kv_canary.fixtures import DEFAULT_DEVICE, make_forward_batch
from sglang.test.test_utils import CustomTestCase
register_cuda_ci(est_time=15, stage="extra-a", runner_config="1-gpu-small")
def _make_req(*, origin: list[int], output: list[int]) -> SimpleNamespace:
return SimpleNamespace(
origin_input_ids=array("q", origin),
output_ids=array("q", output),
)
class TestComputeReqAllIdsInfo(CustomTestCase):
def test_single_req_concatenates_origin_then_output(self) -> None:
"""One req's flat is origin_input_ids followed by output_ids in that order."""
req = _make_req(origin=[10, 20, 30], output=[40, 50])
flat, lens = compute_req_all_ids_info([req])
self.assertEqual(flat.tolist(), [10, 20, 30, 40, 50])
self.assertEqual(lens.tolist(), [5])
def test_multi_req_flat_is_concat_across_reqs(self) -> None:
"""Multi-req flat is per-req (origin+output) concatenated in req order."""
reqs = [
_make_req(origin=[1, 2], output=[3]),
_make_req(origin=[100], output=[]),
_make_req(origin=[7, 8, 9], output=[10, 11]),
]
flat, lens = compute_req_all_ids_info(reqs)
self.assertEqual(flat.tolist(), [1, 2, 3, 100, 7, 8, 9, 10, 11])
self.assertEqual(lens.tolist(), [3, 1, 5])
def test_returned_cpu_tensors_are_pinned(self) -> None:
"""Snapshot tensors live on pinned CPU memory so the manager's async H2D actually overlaps."""
req = _make_req(origin=[1, 2, 3], output=[4])
flat, lens = compute_req_all_ids_info([req])
self.assertTrue(flat.is_pinned())
self.assertTrue(lens.is_pinned())
self.assertEqual(flat.device, torch.device("cpu"))
self.assertEqual(lens.device, torch.device("cpu"))
class TestPopulateReqToExpectedTokenIds(CustomTestCase):
def setUp(self) -> None:
self.device = DEFAULT_DEVICE
def _make_pool(self, *, max_reqs: int, max_context_len: int) -> torch.Tensor:
return torch.full(
(max_reqs, max_context_len),
-999,
dtype=torch.int32,
device=self.device,
)
def _fb_with_snapshot(
self,
*,
req_pool_indices: list[int],
lens: list[int],
flat: list[int],
) -> SimpleNamespace:
fb = make_forward_batch(
self.device,
bs=len(req_pool_indices),
req_pool_indices=torch.tensor(
req_pool_indices, dtype=torch.int64, device=self.device
),
)
fb.req_all_ids_flat = torch.tensor(flat, dtype=torch.int64, pin_memory=True)
fb.req_all_ids_lens = torch.tensor(lens, dtype=torch.int64, pin_memory=True)
return fb
def test_no_op_when_snapshot_is_none(self) -> None:
"""Cuda-graph capture's dry-run leaves snapshot fields as None; manager must early-return."""
fb = make_forward_batch(self.device, bs=2)
pool = self._make_pool(max_reqs=4, max_context_len=8)
original = pool.clone()
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=pool
)
torch.cuda.synchronize()
self.assertTrue(torch.equal(pool, original))
def test_no_op_when_pool_is_none(self) -> None:
"""When the validator is off the device pool is None; manager must early-return without touching anything."""
fb = self._fb_with_snapshot(req_pool_indices=[1], lens=[3], flat=[10, 20, 30])
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=None
)
def test_no_op_when_bs_zero(self) -> None:
"""Empty batch (bs == 0) must early-return; no kernel launch."""
fb = make_forward_batch(
self.device,
bs=0,
req_pool_indices=torch.zeros(0, dtype=torch.int64, device=self.device),
seq_lens=torch.zeros(0, dtype=torch.int32, device=self.device),
)
fb.req_all_ids_flat = torch.zeros(0, dtype=torch.int64, pin_memory=True)
fb.req_all_ids_lens = torch.zeros(0, dtype=torch.int64, pin_memory=True)
pool = self._make_pool(max_reqs=4, max_context_len=8)
original = pool.clone()
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=pool
)
torch.cuda.synchronize()
self.assertTrue(torch.equal(pool, original))
def test_raises_when_lens_length_mismatches_batch_size(self) -> None:
"""req_all_ids_lens length must equal forward_batch batch size; mismatch indicates a corrupted snapshot."""
fb = self._fb_with_snapshot(
req_pool_indices=[1, 2], lens=[3], flat=[10, 20, 30]
)
pool = self._make_pool(max_reqs=4, max_context_len=8)
with self.assertRaisesRegex(RuntimeError, "req_all_ids_lens length"):
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=pool
)
def test_raises_when_cumsum_does_not_match_flat_numel(self) -> None:
"""cumsum(lens) must equal flat.numel(); inconsistent snapshot raises."""
fb = self._fb_with_snapshot(
req_pool_indices=[1, 2], lens=[3, 4], flat=[10, 20, 30, 40]
)
pool = self._make_pool(max_reqs=4, max_context_len=8)
with self.assertRaisesRegex(RuntimeError, "snapshot inconsistent"):
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=pool
)
def test_happy_path_scatters_each_req_into_its_pool_row(self) -> None:
"""Scatter populates pool[rp, :len_r] = req_r's flattened tokens; other rows untouched."""
fb = self._fb_with_snapshot(
req_pool_indices=[1, 3],
lens=[3, 2],
flat=[10, 20, 30, 40, 50],
)
pool = self._make_pool(max_reqs=5, max_context_len=8)
original = pool.clone()
populate_req_to_expected_token_ids(
forward_batch=fb, req_to_verify_expected_tokens=pool
)
torch.cuda.synchronize()
pool_cpu = pool.cpu()
self.assertEqual(pool_cpu[1, :3].tolist(), [10, 20, 30])
self.assertEqual(pool_cpu[3, :2].tolist(), [40, 50])
# Other rows must be left at their pre-scatter sentinel.
for untouched in (0, 2, 4):
self.assertTrue(
torch.equal(pool_cpu[untouched], original[untouched].cpu()),
f"row {untouched} was unexpectedly modified",
)
if __name__ == "__main__":
unittest.main()