[FullCG] Support chunked cached-prefix prefill (#30825)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
paulzhang-tm
2026-07-28 11:20:25 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 1eee8fbdcc
commit 4e5a05148a
10 changed files with 746 additions and 37 deletions
@@ -1,19 +1,22 @@
"""Integration test for the full prefill CUDA graph backend.
"""Integration tests for the full prefill CUDA graph backend.
Spins up Qwen3-8B with --cuda-graph-backend-prefill=full and checks
mgsm_en accuracy, mirroring the breakable-CG integration test.
The Qwen3-8B test checks end-to-end accuracy with FlashInfer. The smaller
DeepSeek-Coder-V2-Lite test checks that an MLA radix-prefix hit selects the
OSS FA4 cached-prefix graph variant and matches an eager cold request.
The attention backend is pinned to flashinfer: plain EXTEND under full
CUDA graph requires the backend's init_forward_metadata_out_graph to
support extend (capture-stable plan state). flashinfer and the
FlashAttention backend (fa4; fa3 untested — needs SM90 hardware)
implement it; flashinfer is pinned here for CI-hardware portability
(fa4 requires Blackwell).
implement it; the FA4 case is restricted to Blackwell.
"""
import re
import unittest
from sglang.srt.utils import kill_process_tree
import requests
from sglang.srt.utils import get_device_sm, kill_process_tree
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
@@ -24,8 +27,8 @@ from sglang.test.test_utils import (
popen_launch_server,
)
# CI Registration — large suite to fit the integration test's server startup.
register_cuda_ci(est_time=79, stage="base-b", runner_config="1-gpu-large")
# OSS FA4 coverage requires Blackwell. Each test still uses only one GPU.
register_cuda_ci(est_time=170, stage="base-b", runner_config="4-gpu-b200")
class TestFullCudaGraphPrefill(CustomTestCase):
@@ -65,5 +68,97 @@ class TestFullCudaGraphPrefill(CustomTestCase):
self.assertGreaterEqual(score, 0.80)
@unittest.skipIf(get_device_sm() < 100, "Test requires CUDA SM 100 or higher")
class TestFullCudaGraphChunkedPrefix(unittest.TestCase):
"""A radix-cache hit replays the OSS FA4 FullCG prefix variant."""
@classmethod
def setUpClass(cls):
cls.model = "deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct"
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--trust-remote-code",
"--prefill-attention-backend=fa4",
"--decode-attention-backend=flashinfer",
"--disable-flashinfer-autotune",
"--context-length=256",
"--max-total-tokens=512",
"--max-running-requests=1",
"--chunked-prefill-size=256",
"--skip-server-warmup",
"--enable-metrics",
"--cuda-graph-config",
'{"decode":{"backend":"disabled"},'
'"prefill":{"backend":"full","bs":[32],"max_bs":32,'
'"full_prefill_max_req":1,'
'"full_prefill_prefix_chunk_tokens":64}}',
],
)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def _generate(self, input_ids):
response = requests.post(
self.base_url + "/generate",
json={
"input_ids": input_ids,
"sampling_params": {"max_new_tokens": 4, "temperature": 0},
},
timeout=120,
)
response.raise_for_status()
return response.json()
def _prefill_graph_count(self):
metrics = requests.get(self.base_url + "/metrics", timeout=30).text
match = re.search(
r'^sglang:cuda_graph_passes_total\{[^}]*mode="prefill_cuda_graph"[^}]*\}'
r"\s+([0-9.eE+-]+)$",
metrics,
re.MULTILINE,
)
return float(match.group(1)) if match else 0.0
def test_cached_prefix_replays_full_cuda_graph(self):
prefix = list(range(1000, 1048))
prompt = prefix + list(range(2000, 2032))
requests.post(self.base_url + "/flush_cache", timeout=30).raise_for_status()
cold = self._generate(prompt)
requests.post(self.base_url + "/flush_cache", timeout=30).raise_for_status()
self._generate(prefix)
graph_count = self._prefill_graph_count()
cached = self._generate(prompt)
self.assertEqual(cached["meta_info"]["cached_tokens"], len(prefix))
self.assertEqual(cached["output_ids"], cold["output_ids"])
self.assertEqual(self._prefill_graph_count(), graph_count + 1)
def test_cached_prefix_replays_two_64_token_full_cuda_graph_chunks(self):
prefix = list(range(1000, 1128))
prompt = prefix + list(range(2000, 2032))
# The 160-token cold reference fits in one scheduler prefill chunk, so
# only the 32-token cache-hit suffix is eligible for this FullCG bucket.
requests.post(self.base_url + "/flush_cache", timeout=30).raise_for_status()
cold = self._generate(prompt)
requests.post(self.base_url + "/flush_cache", timeout=30).raise_for_status()
self._generate(prefix)
graph_count = self._prefill_graph_count()
cached = self._generate(prompt)
self.assertEqual(cached["meta_info"]["cached_tokens"], len(prefix))
self.assertEqual(cached["output_ids"], cold["output_ids"])
self.assertEqual(self._prefill_graph_count(), graph_count + 1)
if __name__ == "__main__":
unittest.main()
@@ -31,6 +31,7 @@ class TestMultimodalPiecewiseCudaGraph(CustomTestCase):
runner = PrefillCudaGraphRunner.__new__(PrefillCudaGraphRunner)
runner._is_full_backend = False
runner.enable_lora = False
runner._capture_chunked_prefix = False
runner.prefill_backend_name = backend
runner.has_mha_companion_layers = backend == Backend.BREAKABLE
runner.capture_hidden_mode = CaptureHiddenMode.NULL
@@ -20,6 +20,7 @@ class TestPrefillCudaGraphPadding(CustomTestCase):
runner = PrefillCudaGraphRunner.__new__(PrefillCudaGraphRunner)
runner._is_full_backend = False
runner.enable_lora = False
runner._capture_chunked_prefix = False
runner.prefill_backend_name = Backend.TC_PIECEWISE
runner.has_mha_companion_layers = False
runner.capture_hidden_mode = CaptureHiddenMode.NULL
@@ -0,0 +1,266 @@
"""CPU coverage for chunked-prefix Full prefill CUDA-graph state."""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import torch
import sglang.srt.model_executor.runner.prefill_cuda_graph_runner as runner_module
from sglang.srt.model_executor.cuda_graph_config import Backend
from sglang.srt.model_executor.runner.prefill_cuda_graph_runner import (
PrefillCudaGraphRunner,
)
from sglang.srt.model_executor.runner.shape_key import ShapeKey
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
class _FakeAttentionBackend:
supports_full_cuda_graph_chunked_prefix = True
def __init__(self):
self.calls = []
def prepare_full_cuda_graph_chunked_prefix(self, forward_batch, *, in_capture):
self.calls.append((forward_batch, in_capture))
class _FakeKVIndexKernel:
def __getitem__(self, grid):
del grid
def run(
req_to_token,
req_pool_indices,
starts,
seq_lens,
cu_seq_lens,
output,
req_to_token_stride,
):
del cu_seq_lens, req_to_token_stride
cursor = 0
for row in range(seq_lens.numel()):
seq_len = int(seq_lens[row])
start = int(starts[row])
req = int(req_pool_indices[row])
output[cursor : cursor + seq_len].copy_(
req_to_token[req, start : start + seq_len]
)
cursor += seq_len
return run
class TestPrefillCudaGraphRunnerChunkedPrefix(CustomTestCase):
def test_prefix_chunk_capacity_is_aggregate_and_can_be_overridden(self):
model_runner = SimpleNamespace(
server_args=SimpleNamespace(
chunked_prefill_size=16,
context_length=None,
cuda_graph_config=SimpleNamespace(
prefill=SimpleNamespace(
full_prefill_prefix_chunk_tokens=None, max_bs=8
)
),
),
req_to_token_pool=SimpleNamespace(
req_to_token=torch.empty((1, 32), dtype=torch.int32)
),
)
self.assertEqual(
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4),
(4, 16),
)
model_runner.server_args.chunked_prefill_size = -1
self.assertEqual(
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4),
(2, 8),
)
model_runner.server_args.chunked_prefill_size = 16
model_runner.server_args.cuda_graph_config.prefill.full_prefill_prefix_chunk_tokens = (
24
)
self.assertEqual(
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4),
(6, 24),
)
model_runner.server_args.cuda_graph_config.prefill.full_prefill_prefix_chunk_tokens = (
256
)
self.assertEqual(
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4),
(32, 128),
)
# At least one token is reserved per request lane even if the requested
# aggregate capacity is smaller than the fixed request-slot count.
model_runner.server_args.cuda_graph_config.prefill.full_prefill_prefix_chunk_tokens = (
2
)
self.assertEqual(
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4),
(1, 4),
)
model_runner.server_args.cuda_graph_config.prefill.full_prefill_prefix_chunk_tokens = (
0
)
with self.assertRaisesRegex(ValueError, "must be positive"):
PrefillCudaGraphRunner._resolve_prefix_chunk_shape(model_runner, 4)
def test_buffers_are_shared_across_token_buckets(self):
backend = _FakeAttentionBackend()
runner = PrefillCudaGraphRunner.__new__(PrefillCudaGraphRunner)
runner._capture_req_slots = 3
runner._prefix_chunk_len = 2
runner._prefix_chunk_capacity = 6
runner._prefix_max_len = 8
runner._prefix_capture_variants = (1, 2, 4)
runner.device = torch.device("cpu")
runner._prefill_static_buffers = {
"extend_prefix_lens": torch.zeros(3, dtype=torch.int64),
"req_pool_indices": torch.tensor([2, 0, 1], dtype=torch.int64),
}
runner._prefix_capture_batches = {}
runner._prefix_capture_buffers = runner._create_chunked_prefix_buffers()
runner.model_runner = SimpleNamespace(
attn_backend=backend,
req_to_token_pool=SimpleNamespace(
req_to_token=torch.arange(24, dtype=torch.int32).view(3, 8)
),
)
first = SimpleNamespace()
second = SimpleNamespace()
first_key = ShapeKey(size=8, variant_label="chunked_prefix:4")
second_key = ShapeKey(size=16, variant_label="chunked_prefix:4")
with patch.object(
runner_module,
"create_chunked_prefix_cache_kv_indices",
_FakeKVIndexKernel(),
):
runner._prepare_chunked_prefix_capture(first, first_key, 4)
runner._prepare_chunked_prefix_capture(second, second_key, 4)
buffers = runner._prefix_capture_buffers
self.assertIsNotNone(buffers)
# Chunk starts are constant and prefilled at allocation.
self.assertEqual(
buffers.starts_cpu.tolist(),
[[0, 0, 0], [2, 2, 2], [4, 4, 4], [6, 6, 6]],
)
self.assertEqual(first.extend_prefix_lens_cpu, [8, 8, 8])
self.assertEqual(first.prefix_chunk_num_tokens, [6, 6, 6, 6])
self.assertIs(first.prefix_chunk_starts, buffers.starts)
self.assertIs(first.prefix_chunk_seq_lens, buffers.seq_lens)
self.assertIs(first.prefix_chunk_cu_seq_lens, buffers.cu_seq_lens)
self.assertIs(first.prefix_chunk_starts, second.prefix_chunk_starts)
self.assertIs(first.prefix_chunk_seq_lens, second.prefix_chunk_seq_lens)
self.assertIs(
first.prefix_chunk_cu_seq_lens,
second.prefix_chunk_cu_seq_lens,
)
# Per-chunk KV indices are views of one shared 2-D buffer; what
# capture bakes into the graph is the address, so compare pointers.
for kv_chunk_idx in (0, 3):
self.assertEqual(
first.prefix_chunk_kv_indices[kv_chunk_idx].data_ptr(),
buffers.kv_indices[kv_chunk_idx].data_ptr(),
)
self.assertEqual(
first.prefix_chunk_kv_indices[kv_chunk_idx].data_ptr(),
second.prefix_chunk_kv_indices[kv_chunk_idx].data_ptr(),
)
runner._prepare_chunked_prefix_replay(
second_key,
SimpleNamespace(batch_size=2, extend_prefix_lens_cpu=[5, 1]),
)
self.assertEqual(
second.prefix_chunk_seq_lens.tolist(),
[[2, 1, 0], [2, 0, 0], [1, 0, 0], [0, 0, 0]],
)
self.assertEqual(
second.prefix_chunk_kv_indices[0].tolist(),
[16, 17, 0, 0, 0, 0],
)
self.assertEqual(
second.prefix_chunk_kv_indices[1].tolist(),
[18, 19, 0, 0, 0, 0],
)
self.assertEqual(
second.prefix_chunk_kv_indices[2].tolist(),
[20, 0, 0, 0, 0, 0],
)
self.assertEqual(second.prefix_chunk_kv_indices[3].tolist(), [0] * 6)
self.assertEqual(
backend.calls,
[(first, True), (second, True), (second, False)],
)
def test_prefix_gate_only_applies_to_chunked_prefix_variant(self):
runner = PrefillCudaGraphRunner.__new__(PrefillCudaGraphRunner)
runner._capture_req_slots = 4
runner.enable_lora = False
runner.capture_hidden_mode = None
runner.max_num_tokens = 32
runner.capture_num_tokens = [4]
runner.backend = SimpleNamespace()
runner.prefill_backend_name = Backend.FULL
runner.has_mha_companion_layers = False
runner._prefix_chunk_len = 2
runner._prefix_capture_variants = (1, 2, 4)
forward_batch = SimpleNamespace(
batch_size=1,
input_ids=torch.zeros(4, dtype=torch.int64),
input_embeds=None,
replace_embeds=None,
forward_mode=SimpleNamespace(is_target_verify=lambda: False),
capture_hidden_mode=None,
global_num_tokens_cpu=None,
return_logprob=False,
extend_prefix_lens_cpu=[8],
)
# Prefix hits in BCG/TC-piecewise and ordinary non-MLA FullCG use the
# normal graph topology and must retain their existing eligibility.
runner._capture_chunked_prefix = False
for is_full_backend in (False, True):
with self.subTest(is_full_backend=is_full_backend):
runner._is_full_backend = is_full_backend
self.assertTrue(runner.can_run_graph(forward_batch))
# The dedicated chunked-prefix topology has a fixed captured capacity.
runner._is_full_backend = True
runner._capture_chunked_prefix = True
self.assertTrue(runner.can_run_graph(forward_batch))
self.assertEqual(
runner._shape_key(4, forward_batch).variant_label,
"chunked_prefix:4",
)
forward_batch.batch_size = 2
# Capacity is per request, not a sum: three real chunks round up to the
# four-chunk graph even though the aggregate prefix has eight tokens.
forward_batch.extend_prefix_lens_cpu = [5, 3]
self.assertTrue(runner.can_run_graph(forward_batch))
self.assertEqual(
runner._shape_key(4, forward_batch).variant_label,
"chunked_prefix:4",
)
forward_batch.extend_prefix_lens_cpu = [9, 1]
self.assertFalse(runner.can_run_graph(forward_batch))
if __name__ == "__main__":
unittest.main()