[unified-memory] Enable prefill cuda-graph capture (#37418)

This commit is contained in:
Cheng Wan
2026-09-13 19:17:19 -07:00
committed by GitHub
parent 2ec4bbcbd4
commit 6410800af9
5 changed files with 154 additions and 60 deletions
+26 -17
View File
@@ -262,25 +262,34 @@ def handle_unified_memory_pool(server_args: Any) -> None:
) )
if cfg.dcp_size > 1: if cfg.dcp_size > 1:
_validate_unified_memory_dcp(server_args) _validate_unified_memory_dcp(server_args)
# Only monolithic decode cuda-graph capture is wired; piecewise prefill # Prefill cuda-graph capture IS wired for the unified pool: the captured
# capture is not. Guard when the user opts into it. # batch reads `out_cache_loc` out of the registry slot, which
# `populate_from_forward_batch` refills from the already-rebound (kernel-
# facing) loc before every replay, and the read tables are refilled
# out-of-graph from the live v2p.
#
# The FULL backend is the one exception, and not for a unified reason: its
# metadata path (`_init_full_cg_prefill_metadata`) exists only on the
# fa3/fa4 family. Any other backend lands in the decode-shaped
# `_apply_cuda_graph_metadata`, which has no EXTEND branch at all. Inkling
# declares FULL as a MODEL default, indistinguishable here from a flag the
# user typed, so warn and fall back rather than refuse to boot.
_cg_cfg = cfg.cuda_graph_config _cg_cfg = cfg.cuda_graph_config
if _cg_cfg is not None and _cg_cfg.prefill.backend != Backend.DISABLED: if _cg_cfg is not None and _cg_cfg.prefill.backend == Backend.FULL:
if cfg.cuda_graph_backend_prefill is not None: full_cg_backends = {"fa3", "fa4"}
raise ValueError( backends = set(attention_backends_of(resolved_view(server_args)))
"--enable-unified-memory supports decode cuda-graph " backends.discard(None)
"capture only; prefill capture is not wired (the prefill " if not backends <= full_cg_backends:
"graph runner bypasses the unified virtual->physical loc " _cg_cfg.prefill.backend = Backend.DISABLED
"rebind). Got --cuda-graph-backend-prefill=" logger.warning(
f"{cfg.cuda_graph_backend_prefill!r}; pass " "--enable-unified-memory: disabling the FULL prefill "
"--cuda-graph-backend-prefill=disabled." "cuda-graph backend. It builds its block table in "
"_init_full_cg_prefill_metadata, which only %s implement; the "
"resolved attention backends are %s. Decode capture and the "
"other prefill backends are unaffected.",
sorted(full_cg_backends),
sorted(backends),
) )
_cg_cfg.prefill.backend = Backend.DISABLED
logger.warning(
"--enable-unified-memory: disabling prefill cuda-graph "
"capture (not wired for the unified pool's loc rebind); "
"decode capture is unaffected."
)
def _validate_unified_memory_dcp(server_args: Any) -> None: def _validate_unified_memory_dcp(server_args: Any) -> None:
@@ -26,6 +26,7 @@ from sglang.srt.layers.attention.verify_mask import VerifyMask, maybe_create_ver
from sglang.srt.layers.cp.base import CPAttentionBackendKind, get_cp_strategy from sglang.srt.layers.cp.base import CPAttentionBackendKind, get_cp_strategy
from sglang.srt.layers.cp.utils import is_cp_active from sglang.srt.layers.cp.utils import is_cp_active
from sglang.srt.layers.radix_attention import AttentionType from sglang.srt.layers.radix_attention import AttentionType
from sglang.srt.mem_cache.kv_index_translator import KVReadTables
from sglang.srt.mem_cache.memory_pool import KVWriteLoc from sglang.srt.mem_cache.memory_pool import KVWriteLoc
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
@@ -673,8 +674,26 @@ class FlashAttentionBackend(AttentionBackend):
m.cu_seqlens_q[1:].copy_( m.cu_seqlens_q[1:].copy_(
torch.cumsum(forward_batch.extend_seq_lens[:bs], dim=0) torch.cumsum(forward_batch.extend_seq_lens[:bs], dim=0)
) )
translating = self.kv_index_translator.is_translating
max_seq_len_k = int(forward_batch.seq_lens_cpu[:bs].max().item()) max_seq_len_k = int(forward_batch.seq_lens_cpu[:bs].max().item())
if max_seq_len_k > 0: if translating:
# Unified pool: the block table is a TRANSLATED page table, built
# straight into these capture-stable buffers from the LIVE v2p, so
# a page relocated by compaction since capture is picked up. Same
# substitution the eager extend branch makes in its `_unified_read`
# fixup; `build_index_table` emits page-granular kernel-facing ids
# directly, so there is no `// page_size` to undo.
self.kv_index_translator.build_index_table(
req_pool_indices=forward_batch.req_pool_indices[:bs],
seq_lens=forward_batch.seq_lens[:bs],
into=KVReadTables(
full=m.page_table,
sliding_window=(
m.swa_page_table if self.use_sliding_window_kv_pool else None
),
),
)
elif max_seq_len_k > 0:
# Build the block table like the eager extend branch: take every # Build the block table like the eager extend branch: take every
# page_size-th token slot from req_to_token and divide by page_size. # page_size-th token slot from req_to_token and divide by page_size.
# Identity for page_size == 1 (strided is 0..max_seq_len_k-1, //1). # Identity for page_size == 1 (strided is 0..max_seq_len_k-1, //1).
@@ -700,11 +719,21 @@ class FlashAttentionBackend(AttentionBackend):
self.full_cg_prefill_swa_out_cache_loc.shape[0], self.full_cg_prefill_swa_out_cache_loc.shape[0],
"full-CG prefill SWA write-location buffer", "full-CG prefill SWA write-location buffer",
) )
self.full_cg_prefill_swa_out_cache_loc[:num_out].copy_( # Under the unified pool `out_cache_loc` was rebound to FULL-side
self.token_to_kv_pool.translate_loc_from_full_to_swa( # KERNEL-FACING ids at ForwardBatch construction, so the full->swa
# map cannot be re-run on it -- those values index far past the swa
# v2p table (a device-side "index out of bounds" assert). Phase 2 of
# the write contract derives the swa loc from them instead.
swa_write_loc = (
self.kv_index_translator.sliding_window_write_loc_for(
forward_batch.out_cache_loc
)
if translating
else self.token_to_kv_pool.translate_loc_from_full_to_swa(
forward_batch.out_cache_loc forward_batch.out_cache_loc
) )
) )
self.full_cg_prefill_swa_out_cache_loc[:num_out].copy_(swa_write_loc)
# Captured kernels read the full bucket. Route its inactive tail to # Captured kernels read the full bucket. Route its inactive tail to
# SWA's zero dummy slot to prevent stale writes into live slots. # SWA's zero dummy slot to prevent stale writes into live slots.
self.full_cg_prefill_swa_out_cache_loc[num_out:].zero_() self.full_cg_prefill_swa_out_cache_loc[num_out:].zero_()
@@ -51,7 +51,7 @@ _MODEL_PATH = os.environ.get("INKLING_TEST_MODEL_PATH", "thinkingmachines/Inklin
_MODEL_REVISION = os.environ.get("INKLING_TEST_MODEL_REVISION", "test") _MODEL_REVISION = os.environ.get("INKLING_TEST_MODEL_REVISION", "test")
def _unified_args(): def _unified_args(*, attention_backend="triton", prefill_cuda_graph=False):
"""Server args for the tri-pool boot. Mirrors test_inkling.py's fixture """Server args for the tri-pool boot. Mirrors test_inkling.py's fixture
minus the multimodal/parser surface (KV-path focus), plus the unified minus the multimodal/parser surface (KV-path focus), plus the unified
flags. The ratios still feed boot sizing until the byte configurator flags. The ratios still feed boot sizing until the byte configurator
@@ -59,17 +59,12 @@ def _unified_args():
args = [ args = [
"--trust-remote-code", "--trust-remote-code",
"--enable-unified-memory", "--enable-unified-memory",
# Unified requires the Triton strided page-major read/write paths.
"--attention-backend", "--attention-backend",
"triton", attention_backend,
"--page-size", "--page-size",
"128", "128",
"--mamba-radix-cache-strategy", "--mamba-radix-cache-strategy",
"extra_buffer", "extra_buffer",
# Inkling defaults to a FULL prefill graph, which unified rejects at
# boot: the prefill graph runner bypasses the virtual->physical rebind.
"--cuda-graph-backend-prefill",
"disabled",
"--swa-full-tokens-ratio", "--swa-full-tokens-ratio",
"0.1", "0.1",
"--mamba-full-memory-ratio", "--mamba-full-memory-ratio",
@@ -77,6 +72,11 @@ def _unified_args():
"--mem-fraction-static", "--mem-fraction-static",
"0.5", "0.5",
] ]
if not prefill_cuda_graph:
# Inkling declares a FULL prefill graph as a model default; the Triton
# cells cannot serve it (the cuda-graph metadata path has no EXTEND
# branch), so pin it off rather than lean on the auto-fallback.
args += ["--cuda-graph-backend-prefill", "disabled"]
if _MODEL_REVISION: if _MODEL_REVISION:
args += ["--revision", _MODEL_REVISION] args += ["--revision", _MODEL_REVISION]
return args return args
@@ -91,8 +91,8 @@ def _static_args():
"128", "128",
"--mamba-radix-cache-strategy", "--mamba-radix-cache-strategy",
"extra_buffer", "extra_buffer",
# Inkling defaults to a FULL prefill graph, which unified rejects at # Match the unified cell's Triton pin, which cannot serve Inkling's
# boot: the prefill graph runner bypasses the virtual->physical rebind. # default FULL prefill graph.
"--cuda-graph-backend-prefill", "--cuda-graph-backend-prefill",
"disabled", "disabled",
"--swa-full-tokens-ratio", "--swa-full-tokens-ratio",
@@ -128,6 +128,10 @@ def _greedy_generate(base_url, text, max_new_tokens=32, logprobs=False):
class TestInklingUnifiedTriPool(CustomTestCase): class TestInklingUnifiedTriPool(CustomTestCase):
@classmethod
def server_args(cls):
return _unified_args()
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.model = _MODEL_PATH cls.model = _MODEL_PATH
@@ -136,7 +140,7 @@ class TestInklingUnifiedTriPool(CustomTestCase):
cls.model, cls.model,
cls.base_url, cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=_unified_args(), other_args=cls.server_args(),
env={**os.environ, "SGLANG_ENABLE_UNIFIED_RADIX_TREE": "1"}, env={**os.environ, "SGLANG_ENABLE_UNIFIED_RADIX_TREE": "1"},
) )
@@ -194,6 +198,28 @@ class TestInklingUnifiedTriPool(CustomTestCase):
self.assertGreater(len(data["text"].strip()), 0, data) self.assertGreater(len(data["text"].strip()), 0, data)
class TestInklingUnifiedFullPrefillGraph(TestInklingUnifiedTriPool):
"""The same tri-pool guards with Inkling's OWN default FULL prefill cuda
graph left on, over fa4 -- the backend family whose
`_init_full_cg_prefill_metadata` implements that path.
The pairing used to be a hard boot failure: unified disabled prefill
capture outright. With capture on, both the captured block table and the
SWA write loc have to come from the translator. Re-running the full->swa
map on `out_cache_loc` does not work here -- it is already FULL-side
kernel-facing by then, and indexes far past the swa v2p table (a
device-side "index out of bounds" assert).
`test_input_output_logprobs_match` is the sharp guard: a wrong-slot SWA
write moves logprobs at once, and
`test_long_decode_slides_past_swa_window` keeps compaction running
underneath a replaying graph.
"""
@classmethod
def server_args(cls):
return _unified_args(attention_backend="fa4", prefill_cuda_graph=True)
@unittest.skipUnless( @unittest.skipUnless(
os.environ.get("INKLING_UNIFIED_PARITY") == "1", os.environ.get("INKLING_UNIFIED_PARITY") == "1",
"eval-host lane: set INKLING_UNIFIED_PARITY=1 (two sequential server boots)", "eval-host lane: set INKLING_UNIFIED_PARITY=1 (two sequential server boots)",
@@ -24,6 +24,17 @@ class TestFlashAttentionGraphMetadata(CustomTestCase):
backend.token_to_kv_pool = SimpleNamespace( backend.token_to_kv_pool = SimpleNamespace(
translate_loc_from_full_to_swa=lambda locations: locations translate_loc_from_full_to_swa=lambda locations: locations
) )
# The metadata builder reads `is_translating` to choose between the
# translated block table and the strided one this test covers, so the
# source has to be real; the stub pools disable translation, which is
# the static-pool view the assertions below are written against.
backend.kv_index_translator = KVIndexTranslator(
req_to_token=backend.req_to_token,
token_to_kv_pool_allocator=SimpleNamespace(),
token_to_kv_pool=SimpleNamespace(),
page_size=backend.page_size,
device="cpu",
)
forward_batch = SimpleNamespace( forward_batch = SimpleNamespace(
batch_size=1, batch_size=1,
seq_lens=torch.zeros(1, dtype=torch.int64), seq_lens=torch.zeros(1, dtype=torch.int64),
@@ -11,26 +11,31 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ============================================================================== # ==============================================================================
"""`--enable-unified-memory` disables PREFILL cuda-graph capture. """`--enable-unified-memory` and PREFILL cuda-graph capture.
BUG REGRESSION. Only decode capture is wired: the prefill graph runner builds Capture is wired: the captured batch reads `out_cache_loc` out of the registry
its ForwardBatch directly, so it never runs the unified pool's write-loc slot, refilled before each replay from the already-rebound kernel-facing loc,
rebind (rebind_write_loc) and the captured batch holds VIRTUAL ids -- the and the read tables are refilled out-of-graph from the live v2p. So BREAKABLE
captured store would silently write wrong slots. (the CUDA default) and TC_PIECEWISE must be left alone -- an earlier gate
disabled every prefill backend outright, which cost every unified run its
prefill graph.
The old gate only rejected `TC_PIECEWISE`, but the generic prefill default is The FULL backend is the exception, and for a reason that is not about unified
`BREAKABLE` -- so the DEFAULT unified invocation was broken; it only ever memory: its metadata path (`_init_full_cg_prefill_metadata`) is implemented
worked when `--cuda-graph-backend-prefill=disabled` happened to be passed. only by the fa3/fa4 family. Anything else lands in the decode-shaped
`_apply_cuda_graph_metadata`, which has no EXTEND branch.
Pinned: the default is auto-disabled with a warning (unified boots out of the Pinned here: FULL survives on fa3/fa4, FULL is disabled with a warning on any
box), an EXPLICIT prefill backend still raises (never silently override a other backend (Inkling declares FULL as a MODEL default, so refusing to boot
user's stated intent), and decode capture is untouched either way. would fail on a flag the user never typed), and decode capture is never
touched.
python -m pytest test/registered/unit/server_args/test_unified_prefill_cuda_graph_gate.py -v python -m pytest test/registered/unit/server_args/test_unified_prefill_cuda_graph_gate.py -v
""" """
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
import msgspec import msgspec
@@ -42,7 +47,7 @@ from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=8, suite="base-a-test-cpu") register_cpu_ci(est_time=8, suite="base-a-test-cpu")
def _run_handler(*, prefill_backend, explicit): def _run_handler(*, prefill_backend, attention_backends):
"""Run just `handle_unified_memory_pool` over a minimal stand-in.""" """Run just `handle_unified_memory_pool` over a minimal stand-in."""
sa = ServerArgs(model_path="dummy") sa = ServerArgs(model_path="dummy")
cg = SimpleNamespace( cg = SimpleNamespace(
@@ -59,33 +64,47 @@ def _run_handler(*, prefill_backend, explicit):
"enable_two_batch_overlap": False, "enable_two_batch_overlap": False,
"dcp_size": 1, "dcp_size": 1,
"cuda_graph_config": cg, "cuda_graph_config": cg,
"cuda_graph_backend_prefill": prefill_backend if explicit else None, "cuda_graph_backend_prefill": prefill_backend,
}.items(): }.items():
msgspec.Struct.__setattr__(sa, name, value) msgspec.Struct.__setattr__(sa, name, value)
handle_unified_memory_pool(sa) with patch(
"sglang.srt.arg_groups.kv_cache_hook.attention_backends_of",
return_value=attention_backends,
):
handle_unified_memory_pool(sa)
return cg return cg
class TestUnifiedPrefillCudaGraphGate(unittest.TestCase): class TestUnifiedPrefillCudaGraphGate(unittest.TestCase):
def test_default_prefill_capture_is_auto_disabled(self): def test_non_full_prefill_backends_are_left_enabled(self):
"""The generic default (BREAKABLE) must be turned off, not crash the """BUG REGRESSION. Unified used to disable prefill capture outright, so
server 30 seconds later inside graph capture.""" the default BREAKABLE graph silently never ran."""
for backend in (Backend.BREAKABLE, Backend.FULL, Backend.TC_PIECEWISE): for backend in (Backend.BREAKABLE, Backend.TC_PIECEWISE):
cg = _run_handler(prefill_backend=backend, explicit=False) for attn in (("fa4", "fa4"), ("triton", "triton")):
self.assertEqual(cg.prefill.backend, Backend.DISABLED) with self.subTest(prefill=backend, attn=attn):
# Decode capture is the wired path and must survive untouched. cg = _run_handler(prefill_backend=backend, attention_backends=attn)
self.assertEqual(cg.decode.backend, Backend.FULL) self.assertEqual(cg.prefill.backend, backend)
self.assertEqual(cg.decode.backend, Backend.FULL)
def test_explicit_prefill_backend_is_refused(self): def test_full_prefill_survives_on_the_fa_family(self):
"""A user who explicitly asked for prefill graphs gets a clear error, for attn in (("fa3", "fa3"), ("fa4", "fa4")):
not a silent override of their stated intent.""" with self.subTest(attn=attn):
for backend in (Backend.BREAKABLE, Backend.FULL, Backend.TC_PIECEWISE): cg = _run_handler(prefill_backend=Backend.FULL, attention_backends=attn)
with self.assertRaises(ValueError) as ctx: self.assertEqual(cg.prefill.backend, Backend.FULL)
_run_handler(prefill_backend=backend, explicit=True)
self.assertIn("prefill capture is not wired", str(ctx.exception)) def test_full_prefill_is_disabled_on_other_backends(self):
"""Warn and fall back rather than raise: Inkling declares FULL as a
model default, indistinguishable at this point from a user flag."""
for attn in (("triton", "triton"), ("flashinfer", "flashinfer")):
with self.subTest(attn=attn):
cg = _run_handler(prefill_backend=Backend.FULL, attention_backends=attn)
self.assertEqual(cg.prefill.backend, Backend.DISABLED)
self.assertEqual(cg.decode.backend, Backend.FULL)
def test_already_disabled_is_a_no_op(self): def test_already_disabled_is_a_no_op(self):
cg = _run_handler(prefill_backend=Backend.DISABLED, explicit=True) cg = _run_handler(
prefill_backend=Backend.DISABLED, attention_backends=("triton", "triton")
)
self.assertEqual(cg.prefill.backend, Backend.DISABLED) self.assertEqual(cg.prefill.backend, Backend.DISABLED)
self.assertEqual(cg.decode.backend, Backend.FULL) self.assertEqual(cg.decode.backend, Backend.FULL)