[Runtime] Add decode CUDA graph hooks for eager logits processing (#40222)

Co-authored-by: mxz <mxz@fb.com>
This commit is contained in:
Xiaozhu Meng
2026-09-18 23:27:03 -07:00
committed by GitHub
co-authored by mxz
parent 6e1338dd1e
commit 111aeedd37
8 changed files with 81 additions and 7 deletions
@@ -760,6 +760,7 @@ class TboForwardBatchPreparer:
"is_prefill_only", "is_prefill_only",
"spec_algorithm", "spec_algorithm",
"capture_hidden_mode", "capture_hidden_mode",
"defer_logits_to_eager", # forward-level flag, inherited by both child batches
"split_index", # for split prefill "split_index", # for split prefill
"orig_seq_lens", # only used by qwen-1m, thus not care "orig_seq_lens", # only used by qwen-1m, thus not care
"return_pooled_hidden_states", "return_pooled_hidden_states",
@@ -576,6 +576,9 @@ class ForwardBatch(ForwardBatchDeepSeekMHAMixin):
# === Per-forward overrides passed explicitly to init_new === # === Per-forward overrides passed explicitly to init_new ===
capture_hidden_mode: CaptureHiddenMode = None capture_hidden_mode: CaptureHiddenMode = None
# CUDA-graph runners may capture only the transformer body and execute the
# logits processor eagerly after replay.
defer_logits_to_eager: bool = False
# For hidden states before normal # For hidden states before normal
return_hidden_states_before_norm: bool = False return_hidden_states_before_norm: bool = False
@@ -14,7 +14,11 @@ if TYPE_CHECKING:
class GraphSharedOutput: class GraphSharedOutput:
"""``(max_rows, vocab)`` logits buffer, shared by every cuda-graph runner.""" """Persistent ``(max_rows, vocab)`` output shared by graph runners.
The producer need not be captured in a CUDA graph. A runner may capture only
the transformer body and reuse this buffer for an eager logits tail.
"""
_process_shared: Optional[GraphSharedOutput] = None _process_shared: Optional[GraphSharedOutput] = None
@@ -39,7 +43,7 @@ class GraphSharedOutput:
max_rows = 0 max_rows = 0
decode = cuda_graph_config.decode decode = cuda_graph_config.decode
if decode.backend != Backend.DISABLED and decode.bs: if decode.backend != Backend.DISABLED and decode.bs:
max_rows = max(max_rows, model_runner.max_decode_logits_rows()) max_rows = max(max_rows, model_runner.max_shared_logits_buffer_rows())
if max_rows <= 0: if max_rows <= 0:
return None return None
@@ -892,6 +892,14 @@ class ModelRunner:
device=self.device, device=self.device,
) )
def max_shared_logits_buffer_rows(self) -> int:
"""Maximum rows in the persistent logits buffer used by graph runners.
This includes outputs produced inside a graph as well as eager logits
tails that reuse the runner-owned buffer after graph replay.
"""
return self.max_decode_logits_rows()
def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = None): def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = None):
"""Allocate KV cache memory pools only (no backends or cuda graphs).""" """Allocate KV cache memory pools only (no backends or cuda graphs)."""
if memory_pool_config is not None: if memory_pool_config is not None:
@@ -20,7 +20,7 @@ import gc
import logging import logging
from abc import abstractmethod from abc import abstractmethod
from contextlib import contextmanager from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, List, Sequence, Tuple from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple
from sglang.srt.model_executor.runner.base_runner import BaseRunner from sglang.srt.model_executor.runner.base_runner import BaseRunner
from sglang.srt.runtime_context import ( from sglang.srt.runtime_context import (
@@ -132,6 +132,27 @@ class BaseCudaGraphRunner(BaseRunner):
buffers: ForwardInputBuffers buffers: ForwardInputBuffers
backend: BaseCudaGraphBackend backend: BaseCudaGraphBackend
def cuda_graph_output_rows(self, output: Any) -> Optional[int]:
"""Rows of graph output that must be preserved for post-replay work.
The default graph key is a request count, which is also the output row
count for ordinary decode. A graph that returns per-token hidden states
for an eager tail can instead produce ``requests * tokens_per_request``
rows. Such a runner must return that actual row count here. ``None``
keeps the backend's default request-count behavior.
"""
return None
def cuda_graph_output_capacity_rows(self, output: Any) -> Optional[int]:
"""Capacity required by the output buffer shared across graph keys.
The breakable backend allocates this buffer once, while capturing its
first shape. A runner whose output uses token rows rather than request
rows must return the largest possible output here so later graph shapes
fit. ``None`` uses the current graph key as the capacity.
"""
return None
@staticmethod @staticmethod
def _pad_to_bucket(raw_size: int, buckets: Sequence[int]) -> int: def _pad_to_bucket(raw_size: int, buckets: Sequence[int]) -> int:
"""Return the smallest buckets[i] >= raw_size. """Return the smallest buckets[i] >= raw_size.
@@ -26,6 +26,7 @@ Backend selection comes from cuda_graph_config.decode:
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import dataclasses
import inspect import inspect
import logging import logging
import os import os
@@ -384,13 +385,16 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
assert self.require_mlp_tp_gather or self.require_attn_tp_gather assert self.require_mlp_tp_gather or self.require_attn_tp_gather
# --- buffers --------------------------------------------------- # --- buffers ---------------------------------------------------
logits_buffer_rows = self._next_token_logits_buffer_capacity_rows(
self.max_num_token
)
self.buffers: DecodeInputBuffers = DecodeInputBuffers.create( self.buffers: DecodeInputBuffers = DecodeInputBuffers.create(
device=self.device, device=self.device,
max_bs=self.max_bs, max_bs=self.max_bs,
max_num_token=self.max_num_token, max_num_token=self.max_num_token,
hidden_size=self.model_runner.model_config.hidden_size, hidden_size=self.model_runner.model_config.hidden_size,
next_token_logits_buffer=self.model_runner.graph_shared_output.get_logits_buffer( next_token_logits_buffer=self.model_runner.graph_shared_output.get_logits_buffer(
self.model_runner.model_config.vocab_size, rows=self.max_num_token self.model_runner.model_config.vocab_size, rows=logits_buffer_rows
), ),
dtype=self.model_runner.model_config.dtype, dtype=self.model_runner.model_config.dtype,
dp_size=self.dp_size, dp_size=self.dp_size,
@@ -468,6 +472,10 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
f"Capture cuda graph failed: {e}\n{CUDA_GRAPH_CAPTURE_FAILED_MSG}" f"Capture cuda graph failed: {e}\n{CUDA_GRAPH_CAPTURE_FAILED_MSG}"
) )
def _next_token_logits_buffer_capacity_rows(self, max_num_tokens: int) -> int:
"""Rows reserved for the largest shared logits output."""
return max_num_tokens
def _record_in_graph_metadata_prep_done(self): def _record_in_graph_metadata_prep_done(self):
# Purely a marker at this point in the graph; where the shared reads # Purely a marker at this point in the graph; where the shared reads
# actually end is the attn backend's call. # actually end is the attn backend's call.
@@ -1457,6 +1465,8 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
if shared_read_ends is SharedReadEnds.POST_REPLAY: if shared_read_ends is SharedReadEnds.POST_REPLAY:
self._publish_read_done(in_graph=False) self._publish_read_done(in_graph=False)
output = self._process_output_after_replay(output, forward_batch)
if isinstance(output, LogitsProcessorOutput): if isinstance(output, LogitsProcessorOutput):
if self.is_dllm: if self.is_dllm:
next_token_logits = None next_token_logits = None
@@ -1473,7 +1483,9 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
else None else None
) )
return LogitsProcessorOutput( # Preserve extension fields produced by the eager output processor.
return dataclasses.replace(
output,
next_token_logits=next_token_logits, next_token_logits=next_token_logits,
full_logits=full_logits, full_logits=full_logits,
hidden_states=( hidden_states=(
@@ -1481,7 +1493,6 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
if output.hidden_states is not None if output.hidden_states is not None
else None else None
), ),
customized_info=output.customized_info,
) )
else: else:
assert isinstance(output, PPProxyTensors) assert isinstance(output, PPProxyTensors)
@@ -1495,6 +1506,10 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
} }
) )
def _process_output_after_replay(self, output, forward_batch: ForwardBatch):
"""Optional eager tail executed after graph replay and inside its timer."""
return output
def get_spec_info(self, num_tokens: int): def get_spec_info(self, num_tokens: int):
spec_info = None spec_info = None
if ( if (
@@ -68,6 +68,7 @@ class BreakableCudaGraphBackend(DedupedCudaGraphMixin, BaseCudaGraphBackend):
enable_memory_saver: bool = False, enable_memory_saver: bool = False,
debug_eager: bool = False, debug_eager: bool = False,
) -> None: ) -> None:
self._cuda_graph_runner = cuda_graph_runner
self._model_runner = cuda_graph_runner.model_runner self._model_runner = cuda_graph_runner.model_runner
self._graphs: Dict[Any, BreakableCUDAGraph] = {} self._graphs: Dict[Any, BreakableCUDAGraph] = {}
self._outputs: Dict[Any, Any] = {} self._outputs: Dict[Any, Any] = {}
@@ -130,7 +131,14 @@ class BreakableCudaGraphBackend(DedupedCudaGraphMixin, BaseCudaGraphBackend):
) )
size = shape_key.size size = shape_key.size
if self._shared_output_buffer is None: if self._shared_output_buffer is None:
self._shared_output_buffer = self._alloc_full_buffer(warmup_out, size) capacity_rows = self._cuda_graph_runner.cuda_graph_output_capacity_rows(
warmup_out
)
if capacity_rows is None:
capacity_rows = size
self._shared_output_buffer = self._alloc_full_buffer(
warmup_out, capacity_rows
)
with ( with (
graph_pool_capture_scope(), graph_pool_capture_scope(),
BreakableCUDAGraphCapture( BreakableCUDAGraphCapture(
@@ -157,6 +165,9 @@ class BreakableCudaGraphBackend(DedupedCudaGraphMixin, BaseCudaGraphBackend):
A body that shards or prunes its output along dim 0 returns fewer than A body that shards or prunes its output along dim 0 returns fewer than
``cap`` rows; everything else returns exactly ``cap``. ``cap`` rows; everything else returns exactly ``cap``.
""" """
runner_rows = self._cuda_graph_runner.cuda_graph_output_rows(output)
if runner_rows is not None:
return runner_rows
if torch.is_tensor(output): if torch.is_tensor(output):
return min(cap, output.shape[0]) return min(cap, output.shape[0])
if isinstance(output, PPProxyTensors): if isinstance(output, PPProxyTensors):
@@ -80,6 +80,17 @@ class TestTboFilterBatchMarker(CustomTestCase):
self.assertFalse(child.forward_metadata_ready) self.assertFalse(child.forward_metadata_ready)
self.assertFalse(child.forward_metadata_replan_equivalent) self.assertFalse(child.forward_metadata_replan_equivalent)
def test_filter_batch_children_inherit_deferred_logits(self):
"""A graph that defers logits to an eager tail defers them for both
TBO halves; a child that reset the flag would run the logits processor
inside the captured body."""
for deferred in (False, True):
with self.subTest(deferred=deferred):
parent = _make_target_verify_batch(8)
parent.defer_logits_to_eager = deferred
child = _filter(parent, lo=0, hi=4)
self.assertEqual(child.defer_logits_to_eager, deferred)
def _make_valued_batch(bs: int) -> ForwardBatch: def _make_valued_batch(bs: int) -> ForwardBatch:
# Distinct per-position values so a filtered slice is unambiguous. # Distinct per-position values so a filtered slice is unambiguous.