Files
lightseekorg--tokenspeed/python/tokenspeed/runtime/execution/cuda_graph_wrapper.py
T
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

1171 lines
48 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations
import bisect
import gc
import queue
from collections.abc import Callable
from contextlib import contextmanager
from typing import TYPE_CHECKING
import torch
import torch.distributed as dist
import tqdm
from tokenspeed.runtime.configs.paged_cache_spec import (
compute_max_logical_pages_for_capture,
)
from tokenspeed.runtime.execution.context import ForwardContext
from tokenspeed.runtime.execution.forward_batch_info import (
CaptureHiddenMode,
ForwardMode,
)
from tokenspeed.runtime.layers.attention.backends.base import (
init_backend_cuda_graph_state,
)
from tokenspeed.runtime.sampling.backends.base import CUDA_GRAPH_VARIANT_DEFAULT
from tokenspeed.runtime.sampling.sampling_batch_info import SamplingBatchInfo
from tokenspeed.runtime.utils import (
get_available_gpu_memory,
get_colorful_logger,
)
from tokenspeed.runtime.utils.nvtx import nvtx_range
if TYPE_CHECKING:
from tokenspeed.runtime.execution.drafter.base import BaseDrafter
from tokenspeed.runtime.execution.input_buffer import InputBuffers
from tokenspeed.runtime.execution.model_executor import ModelExecutorConfig
from tokenspeed.runtime.execution.runtime_states import RuntimeStates
from tokenspeed.runtime.layers.attention.backends.base import AttentionBackend
from tokenspeed.runtime.layers.attention.kv_cache.base import BaseTokenToKVPool
from tokenspeed.runtime.sampling.backends.base import SamplingBackend
logger = get_colorful_logger(__name__)
_is_capture_mode = False
def get_is_capture_mode() -> bool:
return _is_capture_mode
def _should_update_mamba_state_after_mtp_verify(
drafter, attn_backend, forward_mode: ForwardMode
) -> bool:
return (
drafter is not None
and forward_mode.is_decode()
and hasattr(attn_backend, "update_mamba_state_after_mtp_verify")
)
@contextmanager
def freeze_gc(enable_cudagraph_gc: bool):
"""
Optimize garbage collection during CUDA graph capture.
Clean up, then freeze all remaining objects from being included
in future collections if GC is disabled during capture.
"""
gc.collect()
should_freeze = not enable_cudagraph_gc
if should_freeze:
gc.freeze()
try:
yield
finally:
if should_freeze:
gc.unfreeze()
gc.collect()
def get_batch_sizes_to_capture(config: ModelExecutorConfig):
capture_bs = config.cudagraph_capture_sizes
max_bs = config.max_num_seqs // max(config.data_parallel_size, 1)
if capture_bs is None:
if config.disable_cuda_graph_padding:
capture_bs = list(range(1, 33)) + [64, 96, 128, 160]
else:
capture_bs = [1, 2, 4] + [i * 8 for i in range(1, 21)]
if max(capture_bs) > max_bs:
capture_bs = list(sorted(set(capture_bs + [max_bs - 1] + [max_bs])))
effective_max = min(config.max_cudagraph_capture_size, max_bs)
capture_bs = [bs for bs in capture_bs if 0 < bs <= effective_max]
return capture_bs
global_graph_memory_pool = None
class DeepEPCudaGraphRunnerAdapter:
"""Manages DeepEP dispatch mode consistency across CUDA graph capture/replay.
During capture the forward pass (including DeepEP low-latency RDMA
dispatch/combine) is recorded. On replay the Python wrapper code
that normally sets dispatch mode and manages the RDMA workspace
never re-executes. This adapter restores both before each replay.
Follows the same CUDA graph replay contract as the upstream DeepEP runner.
"""
def __init__(self):
self._active = False
@staticmethod
def _get_buffer_cls():
try:
from tokenspeed_kernel.ops.communication.deep_ep import (
DeepEPBuffer,
)
return DeepEPBuffer
except ImportError:
return None
def capture(self):
"""Call before ``torch.cuda.graph()`` capture."""
cls = self._get_buffer_cls()
if cls is None or cls._buffer is None:
return
self._active = True
cls.set_dispatch_mode_as_low_latency()
def replay(self):
"""Call before every ``graph.replay()``; restores dispatch mode
and resets RDMA workspace so stale sync state doesn't corrupt
the combine kernel across replays."""
if not self._active:
return
cls = self._get_buffer_cls()
if cls is None or cls._buffer is None:
return
cls.set_dispatch_mode_as_low_latency()
cls.clean_buffer()
class CudaGraphWrapper:
"""
Wraps a forward_func and transparently dispatches to either a captured
CUDA graph (decode, supported batch size) or the eager path (prefill /
unsupported batch size).
Callers always use the same interface::
output_tokens, output_lengths, output_logprobs = runner(
bs, ctx, sampling_info, req_to_page,
extend_with_prefix=..., extend_prefix_lens=...,
)
Internally the wrapper owns both paths and calls init_forward_metadata
with use_cuda_graph=True/False to select the appropriate backend buffers.
"""
def __init__(
self,
forward_func: Callable,
attn_backend: AttentionBackend,
token_to_kv_pool: BaseTokenToKVPool,
input_buffers: InputBuffers,
config: ModelExecutorConfig,
draft_attn_backend: AttentionBackend | None = None,
draft_token_to_kv_pool: BaseTokenToKVPool | None = None,
drafter: BaseDrafter | None = None,
capturable_grammar=None,
eager_grammar_buffers=None,
sampling_backend: SamplingBackend | None = None,
runtime_states: RuntimeStates | None = None,
):
self.config = config
self.attn_backend = attn_backend
self.draft_attn_backend = draft_attn_backend
self.draft_token_to_kv_pool = draft_token_to_kv_pool
self.token_to_kv_pool = token_to_kv_pool
self.drafter = drafter
self.sampling_backend = sampling_backend
self.input_buffers = input_buffers
self.capturable_grammar = capturable_grammar
self.eager_grammar_buffers = eager_grammar_buffers
self.runtime_states = runtime_states
self.enable_torch_compile = getattr(config, "enable_torch_compile", False)
self.disable_padding = config.disable_cuda_graph_padding
self.enable_cudagraph_gc = getattr(config, "enable_cudagraph_gc", True)
self.device = config.device
self.gpu_id = config.gpu_id
self.global_rank = config.global_rank
self.context_len = config.context_len
self.vocab_size = config.vocab_size
self.grammar_backend = config.grammar_backend
self.capture_bs = get_batch_sizes_to_capture(config)
self.max_bs = max(self.capture_bs)
self.max_tokens_per_req = (
config.spec_num_tokens if config.spec_algo is not None else 1
)
self.overlap_schedule_depth = config.overlap_schedule_depth
self.use_v4_mtp_paged_metadata = config.use_v4_mtp_paged_metadata
self.dp_size = config.data_parallel_size
self.world_size = config.world_size
# Backends alias their cache_seqlens buffer. Draft backend aliases
# the drafter-owned draft_seq_lens to keep InputBuffers read-only.
init_backend_cuda_graph_state(
attn_backend,
self.max_bs,
self.input_buffers.seq_lens_buf,
paged_cache_group_specs=tuple(token_to_kv_pool.paged_cache_group_specs),
max_tokens_per_req=self.max_tokens_per_req,
overlap_schedule_depth=self.overlap_schedule_depth,
)
if draft_attn_backend is not None:
init_backend_cuda_graph_state(
draft_attn_backend,
self.max_bs,
self.drafter.draft_seq_lens_buf,
paged_cache_group_specs=tuple(
draft_token_to_kv_pool.paged_cache_group_specs
),
max_tokens_per_req=self.max_tokens_per_req,
overlap_schedule_depth=self.overlap_schedule_depth,
)
# Drafter (Eagle) is constructed with the target's req_to_page
# (ModelExecutor passes the same self.req_to_page to both), and the
# replay path hands both backends the same req_pool_indices. The
# block-table gather is req_to_page[req_pool_indices] (see
# _create_block_kv_indices; it does not depend on seq_lens), so both
# backends would compute identical block_kv_indices. When the backing
# buffer shapes/dtypes also line up, point the draft backend at the
# target's buffer and skip its gather+copy in the replay path: the
# target's metadata prep runs first and populates the shared buffer
# (see init_forward_metadata_replay_cuda_graph).
target_kv = getattr(attn_backend, "decode_cuda_graph_kv_indices", None)
draft_kv = getattr(draft_attn_backend, "decode_cuda_graph_kv_indices", None)
if (
target_kv is not None
and draft_kv is not None
and target_kv.shape == draft_kv.shape
and target_kv.dtype == draft_kv.dtype
):
draft_attn_backend.decode_cuda_graph_kv_indices = target_kv
draft_attn_backend._block_table_aliased = True
self.graph_variants = (
sampling_backend.cuda_graph_capture_variants(self.max_tokens_per_req)
if sampling_backend is not None
else (CUDA_GRAPH_VARIANT_DEFAULT,)
)
self.graphs: dict[tuple[str, int], torch.cuda.CUDAGraph] = {}
self.output_buffers: dict[tuple[str, int], tuple] = {}
self._forward_func: Callable | None = forward_func
self.disable = config.enforce_eager
self.deepep_adapter = DeepEPCudaGraphRunnerAdapter()
if not self.disable:
self.capture()
# ------------------------------------------------------------------
# Graph capture
# ------------------------------------------------------------------
def capture(self):
"""
Capture CUDA graphs for all configured batch sizes.
Args:
forward_func: ModelExecutor.forward_step(bs, ctx, sampling_info).
"""
rank = self.global_rank
with freeze_gc(self.enable_cudagraph_gc):
self.stream = torch.cuda.Stream()
# Capture backend-declared sampler variants explicitly.
capture_items = [
(variant, bs)
for variant in self._cuda_graph_capture_variants()
for bs in self.capture_bs
]
capture_range = tqdm.tqdm(capture_items) if rank == 0 else capture_items
if rank == 0:
logger.info("Capturing batches: %s", self.capture_bs)
for variant, bs in capture_range:
if rank == 0:
avail_mem = get_available_gpu_memory(
self.device, self.gpu_id, empty_cache=False
)
variant_desc = (
""
if variant == CUDA_GRAPH_VARIANT_DEFAULT
else f" variant={variant}"
)
capture_range.set_description(
f"Capturing batches ({bs=}{variant_desc} {avail_mem=:.2f} GB)"
)
graph, output_buffers = self._capture_one(bs, variant=variant)
self.graphs[(variant, bs)] = graph
self.output_buffers[(variant, bs)] = output_buffers
def _cuda_graph_capture_variants(self) -> tuple[str, ...]:
if self.sampling_backend is None:
return (CUDA_GRAPH_VARIANT_DEFAULT,)
variants = self.sampling_backend.cuda_graph_capture_variants(
self.max_tokens_per_req
)
if not variants:
return (CUDA_GRAPH_VARIANT_DEFAULT,)
deduped = tuple(dict.fromkeys((CUDA_GRAPH_VARIANT_DEFAULT, *variants)))
return deduped
def _prepare_sampling_capture(self, bs: int, variant: str) -> None:
if self.sampling_backend is None:
return
self.sampling_backend.prepare_capture_variant(
bs=bs,
num_tokens_per_req=self.max_tokens_per_req,
variant=variant,
)
def _cuda_graph_replay_variant(self) -> str:
if self.sampling_backend is None:
return CUDA_GRAPH_VARIANT_DEFAULT
return self.sampling_backend.cuda_graph_replay_variant(self.max_tokens_per_req)
def _cuda_graph_key(self, bs: int) -> tuple[str, int]:
variant = self._cuda_graph_replay_variant()
key = (variant, bs)
if key in self.graphs:
return key
if variant != CUDA_GRAPH_VARIANT_DEFAULT:
captured_variants = sorted(
graph_variant
for graph_variant, graph_bs in self.graphs
if graph_bs == bs
)
raise RuntimeError(
"Sampling backend requested CUDA graph variant "
f"{variant!r} for batch size {bs}, but it was not captured. "
f"Captured variants for this batch size: {captured_variants}."
)
return (CUDA_GRAPH_VARIANT_DEFAULT, bs)
def _has_cuda_graph_for_bs(self, bs: int) -> bool:
return (CUDA_GRAPH_VARIANT_DEFAULT, bs) in self.graphs
def _capture_one(self, bs: int, variant: str = CUDA_GRAPH_VARIANT_DEFAULT):
graph = torch.cuda.CUDAGraph()
capture_forward_mode = ForwardMode.DECODE
ctx = ForwardContext(
attn_backend=self.attn_backend,
token_to_kv_pool=self.token_to_kv_pool,
bs=bs,
num_extends=0,
input_num_tokens=bs * self.max_tokens_per_req,
forward_mode=capture_forward_mode,
capture_hidden_mode=(
CaptureHiddenMode.FULL
if self.drafter is not None
else CaptureHiddenMode.NULL
),
)
# For DP mode, global_num_tokens must be set so that the MoE
# all-gather comm layers know token counts for all DP ranks.
# During capture, use uniform dummy counts across ranks.
if self.dp_size > 1:
ctx.global_num_tokens = [bs * self.max_tokens_per_req] * self.world_size
# global_bs must ALSO be set at capture. The draft first step's
# collective sizing (reported via report_collective_sizing) reads
# global_bs; if left None at capture it records a single-rank
# layout (fallback branch in comm_manager), but at replay global_bs
# is the live per-rank batch list -> multi-rank layout. The mismatch
# makes the captured (frozen-offset) gather read uninitialized
# symm-mem -> NaN draft logits -> accept_rate 0. Set the matching
# uniform dummy.
ctx.global_bs = [bs] * self.world_size
# Capture with is_all_greedy=False so the graph records the full
# top_k_top_p_sampling path (greedy-only requests are served by the
# same path with top_k=1 in the buffer, which effectively argmaxes).
# is_all_greedy=True at capture would freeze the graph into
# argmax and bypass per-request seeding at replay.
ibd = self.input_buffers
sampling_info = SamplingBatchInfo(
req_pool_indices=ibd.req_pool_indices_buf[:bs],
valid_cache_lengths=(
self.runtime_states.valid_cache_lengths
if self.runtime_states is not None
else None
),
is_all_greedy=False,
vocab_size=self.vocab_size,
device=self.device,
)
from tokenspeed.runtime.grammar.capturable_grammar import (
bind_grammar_mask_buf,
)
# Bind whichever grammar buffer is active so the captured sampler
# records the apply_vocab_mask call. At replay, runtime fills the
# bound buffer in place (hostfunc for capturable, sync H2D for
# eager) — the captured graph reads from the same memory.
bind_grammar_mask_buf(
sampling_info,
self.eager_grammar_buffers,
bs,
spec=self.drafter is not None,
capturable=self.capturable_grammar,
grammar_backend=self.grammar_backend,
)
def run_once():
# Dummy add_batch keeps the grammar queue 1:1 with replays —
# fetch_batch pops once per forward, so warmup + capture
# would otherwise raise queue.Empty.
if self.capturable_grammar is not None:
self.capturable_grammar.add_batch(
grammars=[None] * bs, bs=bs, has_candidates=False
)
return self._forward_func(bs=bs, ctx=ctx, sampling_info=sampling_info)
# Warm up before capture.
for _ in range(4):
torch.cuda.synchronize()
dist.barrier()
self._prepare_sampling_capture(bs=bs, variant=variant)
# Keep warmup seq_lens >= q_len_per_req so no query row gets an
# empty causal span; a stale seq_len of 1 overflows to non-finite KV.
self.input_buffers.seq_lens_buf[:bs].fill_(self.max_tokens_per_req)
self._init_capture_metadata(bs)
run_once()
# Clear any per-pool state that warm-up dirtied at pool row 0,
# so the graph captures reads against a clean baseline.
if self.sampling_backend is not None:
self.sampling_backend.reset_capture_state()
torch.cuda.synchronize()
dist.barrier()
# Warmups can switch a backend back to eager metadata objects. Restore
# the graph-backed metadata immediately before capture so replay-time
# metadata refreshes update the same tensors recorded by the graph.
self._init_capture_metadata(bs)
# Fill sampler buffers OUTSIDE the capture so RNG ops aren't recorded.
self._prepare_sampling_capture(bs=bs, variant=variant)
# Warmup forwards can mutate aliased metadata buffers, so refresh
# them again immediately before graph capture records the final views.
self._init_capture_metadata(bs)
self.deepep_adapter.capture()
global _is_capture_mode
_is_capture_mode = True
global global_graph_memory_pool
with torch.cuda.graph(graph, pool=global_graph_memory_pool, stream=self.stream):
out = run_once()
torch.cuda.synchronize()
dist.barrier()
_is_capture_mode = False
# Graph capture records the hostfunc launches without invoking
# them, so the dummy run_once pushed stays queued — drain it, and
# reset prev_batch/current_batch so the first real replay's build
# doesn't advance the matcher from a stale warmup entry.
if self.capturable_grammar is not None:
while True:
try:
self.capturable_grammar.queue.get_nowait()
except queue.Empty:
break
self.capturable_grammar.reset_state()
global_graph_memory_pool = graph.pool()
return graph, out
def _capture_paged_cache_block_tables(self, bs: int, pool) -> dict | None:
specs = tuple(pool.paged_cache_group_specs)
if not specs:
return None
out = {}
for spec in specs:
max_pages = compute_max_logical_pages_for_capture(
spec,
max_context_len=(
self.max_tokens_per_req * self.max_bs
if self.context_len <= 0
else self.context_len
),
max_tokens_per_req=self.max_tokens_per_req,
overlap_schedule_depth=self.overlap_schedule_depth,
)
out[str(spec.group_id)] = torch.zeros(
(bs, max_pages),
dtype=torch.int32,
device=self.device,
)
return out
def _flat_cache_group_ids(self, pool) -> tuple[str, ...]:
"""Group ids for flat per-group CUDA-graph capture: real tables only
arrive at replay, so capture needs just the ids to allocate its
persistent per-group buffers."""
if not getattr(self.attn_backend, "uses_flat_cache_groups", False):
return ()
return tuple(str(spec.group_id) for spec in pool.paged_cache_group_specs)
def _draft_flat_group_ids(self) -> tuple[str, ...]:
"""The draft head shares the target full-attention group's page ids
(EAGLE writes its own pool tensors at the same indices), so the
drafter consumes exactly that group's table on the flat path."""
if self.draft_attn_backend is None or not getattr(
self.draft_attn_backend, "uses_flat_cache_groups", False
):
return ()
return tuple(
str(spec.group_id)
for spec in self.token_to_kv_pool.paged_cache_group_specs
if spec.family != "state" and spec.retention == "full_history"
)
def _draft_flat_tables(self, flat_block_tables):
"""Subset of the target's per-group tables the drafter consumes."""
gids = self._draft_flat_group_ids()
if not gids or not flat_block_tables:
return None
subset = {
gid: flat_block_tables[gid] for gid in gids if gid in flat_block_tables
}
return subset or None
def _init_capture_metadata(self, bs: int):
capture_kwargs = {}
if self.input_buffers.has_mamba:
capture_kwargs["mamba_pool_indices"] = (
self.input_buffers.mamba_pool_indices_buf[:bs]
)
if self.attn_backend.uses_paged_cache_groups:
paged_cache_block_tables = self._capture_paged_cache_block_tables(
bs,
self.token_to_kv_pool,
)
if paged_cache_block_tables is not None:
capture_kwargs["paged_cache_block_tables"] = paged_cache_block_tables
if self.drafter is not None:
capture_kwargs["num_tokens"] = bs * self.max_tokens_per_req
flat_cache_group_ids = self._flat_cache_group_ids(self.token_to_kv_pool)
if flat_cache_group_ids:
capture_kwargs["flat_cache_group_ids"] = flat_cache_group_ids
self.attn_backend.init_forward_metadata_capture_cuda_graph(
bs,
self.input_buffers.req_pool_indices_buf[:bs],
self.input_buffers.seq_lens_buf[:bs],
ForwardMode.DECODE,
**capture_kwargs,
)
if self.draft_attn_backend is not None:
draft_kwargs = {}
if (
self.draft_token_to_kv_pool is not None
and self.draft_attn_backend.uses_paged_cache_groups
):
draft_paged_cache_block_tables = self._capture_paged_cache_block_tables(
bs,
self.draft_token_to_kv_pool,
)
if draft_paged_cache_block_tables is not None:
draft_kwargs["paged_cache_block_tables"] = (
draft_paged_cache_block_tables
)
draft_kwargs["num_tokens"] = bs * self.max_tokens_per_req
draft_flat_ids = self._draft_flat_group_ids()
if draft_flat_ids:
draft_kwargs["flat_cache_group_ids"] = draft_flat_ids
# Drafter mutates seq_lens_buf in place per step; backends alias.
self.draft_attn_backend.init_forward_metadata_capture_cuda_graph(
bs,
self.input_buffers.req_pool_indices_buf[:bs],
self.input_buffers.seq_lens_buf[:bs],
ForwardMode.DECODE,
**draft_kwargs,
)
def _idle_flat_block_tables(self, padded_bs: int) -> dict | None:
"""Minimal per-group tables for the bs==0 idle replay: all rows are
dummy rows, so one column of page-0 entries per group is valid.
None when the pool publishes no groups."""
specs = tuple(self.token_to_kv_pool.paged_cache_group_specs)
if not specs:
return None
table = torch.zeros((padded_bs, 1), dtype=torch.int32, device=self.device)
return {str(spec.group_id): table for spec in specs}
@staticmethod
def _pad_block_tables_to_padded_bs(
block_tables: dict,
*,
actual_bs: int,
padded_bs: int,
pad_value: int = -1,
) -> dict:
"""Pad each table with dummy ROWS up to padded_bs. Flat passes
pad_value=0, radix/V4 keeps -1 — see the padding contract at the MHA
backend's replay guard (backends/mha.py).
"""
if padded_bs <= actual_bs:
return block_tables
out = {}
for key, table in block_tables.items():
if not isinstance(table, torch.Tensor):
out[key] = table
continue
rows = int(table.shape[0])
if rows == padded_bs:
out[key] = table
continue
out[key] = torch.nn.functional.pad(
table,
(0, 0, 0, padded_bs - rows),
value=pad_value,
)
return out
@staticmethod
def _pad_offsets_to_padded_bs(
base_offsets: dict,
*,
actual_bs: int,
padded_bs: int,
) -> dict:
if padded_bs <= actual_bs:
return base_offsets
out = {}
for key, off in base_offsets.items():
if not isinstance(off, torch.Tensor):
out[key] = off
continue
rows = int(off.shape[0])
if rows == padded_bs:
out[key] = off
continue
# Base 0: padded rows have no real request; the paired padded
# table row is invalid (-1).
out[key] = torch.nn.functional.pad(
off,
(0, padded_bs - rows),
value=0,
)
return out
def _init_replay_metadata(
self,
padded_bs: int,
actual_bs: int,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
req_to_page: torch.Tensor,
forward_mode: ForwardMode,
**kwargs,
):
"""Graph-replay path — update persistent cuda-graph buffers in place."""
paged_cache_block_tables = kwargs.pop("paged_cache_block_tables", None)
paged_cache_block_table_base_offsets = kwargs.pop(
"paged_cache_block_table_base_offsets", None
)
flat_block_tables = kwargs.pop("flat_block_tables", None)
target_uses_paged_groups = getattr(
self.attn_backend,
"uses_paged_cache_groups",
False,
)
draft_uses_paged_groups = self.draft_attn_backend is not None and getattr(
self.draft_attn_backend, "uses_paged_cache_groups", False
)
if paged_cache_block_tables is not None and (
target_uses_paged_groups or draft_uses_paged_groups
):
table_bs = next(
(
int(table.shape[0])
for table in paged_cache_block_tables.values()
if isinstance(table, torch.Tensor)
),
int(req_pool_indices.shape[0]),
)
paged_cache_block_tables = self._pad_block_tables_to_padded_bs(
paged_cache_block_tables,
actual_bs=table_bs,
padded_bs=padded_bs,
)
if paged_cache_block_table_base_offsets is not None:
paged_cache_block_table_base_offsets = self._pad_offsets_to_padded_bs(
paged_cache_block_table_base_offsets,
actual_bs=actual_bs,
padded_bs=padded_bs,
)
if target_uses_paged_groups:
kwargs["paged_cache_block_tables"] = paged_cache_block_tables
if paged_cache_block_table_base_offsets is not None:
kwargs["paged_cache_block_table_base_offsets"] = (
paged_cache_block_table_base_offsets
)
if flat_block_tables is not None and getattr(
self.attn_backend, "uses_flat_cache_groups", False
):
flat_table_bs = next(
(
int(table.shape[0])
for table in flat_block_tables.values()
if isinstance(table, torch.Tensor)
),
int(req_pool_indices.shape[0]),
)
kwargs["flat_block_tables"] = self._pad_block_tables_to_padded_bs(
flat_block_tables,
actual_bs=flat_table_bs,
padded_bs=padded_bs,
pad_value=0,
)
if self.attn_backend.uses_padded_decode_token_mask:
kwargs["actual_bs"] = actual_bs
if target_uses_paged_groups and getattr(self, "drafter", None) is not None:
kwargs["num_tokens"] = padded_bs * self.max_tokens_per_req
self.attn_backend.init_forward_metadata_replay_cuda_graph(
padded_bs,
req_pool_indices,
seq_lens,
req_to_page=req_to_page,
forward_mode=forward_mode,
**kwargs,
)
if self.draft_attn_backend is not None:
draft_attn_kwargs = {}
if draft_uses_paged_groups and paged_cache_block_tables is not None:
draft_attn_kwargs["paged_cache_block_tables"] = paged_cache_block_tables
if paged_cache_block_table_base_offsets is not None:
draft_attn_kwargs["paged_cache_block_table_base_offsets"] = (
paged_cache_block_table_base_offsets
)
if getattr(self.draft_attn_backend, "uses_padded_decode_token_mask", False):
draft_attn_kwargs["actual_bs"] = actual_bs
draft_flat = self._draft_flat_tables(kwargs.get("flat_block_tables"))
if draft_flat is not None:
draft_attn_kwargs["flat_block_tables"] = draft_flat
draft_forward_mode = ForwardMode.DECODE
if draft_uses_paged_groups:
draft_attn_kwargs["num_tokens"] = padded_bs * self.max_tokens_per_req
draft_seq_lens = self.drafter.draft_seq_lens_buf[:padded_bs]
draft_seq_lens.copy_(seq_lens[:padded_bs])
self.draft_attn_backend.init_forward_metadata_replay_cuda_graph(
padded_bs,
req_pool_indices,
draft_seq_lens,
req_to_page=self.drafter.req_to_page,
forward_mode=draft_forward_mode,
**draft_attn_kwargs,
)
@nvtx_range("attn_meta_prep", color="orange")
def _init_forward_metadata(
self,
padded_bs: int,
num_extends: int,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
req_to_page: torch.Tensor,
forward_mode: ForwardMode,
**kwargs,
):
"""Eager path — allocate/refresh metadata for the upcoming forward."""
if (
getattr(self.attn_backend, "uses_paged_cache_groups", False)
and self.drafter is not None
and forward_mode.is_decode()
):
kwargs.setdefault("num_tokens", padded_bs * self.max_tokens_per_req)
self.attn_backend.init_forward_metadata(
bs=padded_bs,
num_extends=num_extends,
req_pool_indices=req_pool_indices,
seq_lens=seq_lens,
req_to_page=req_to_page,
forward_mode=forward_mode,
**kwargs,
)
if self.draft_attn_backend is not None:
draft_kwargs = {}
if getattr(self.draft_attn_backend, "uses_paged_cache_groups", False):
for key in (
"paged_cache_block_tables",
"paged_cache_block_table_base_offsets",
):
value = kwargs.get(key)
if value is not None:
draft_kwargs[key] = value
draft_flat = self._draft_flat_tables(kwargs.get("flat_block_tables"))
if draft_flat is not None:
draft_kwargs["flat_block_tables"] = draft_flat
# The drafter mutates draft_seq_lens_buf between MTP draft steps;
# decode metadata must alias that buffer.
draft_seq_lens = self.drafter.draft_seq_lens_buf[:padded_bs]
draft_seq_lens.copy_(seq_lens[:padded_bs])
if forward_mode.is_extend_or_mixed():
# Non-V4 draft backends follow the legacy contract: a single
# EXTEND/MIXED metadata init fills both first-step prefill
# metadata and step 1+ decode metadata, with seq_lens aliased
# to the drafter-owned mutable buffer. V4 additionally needs
# the accepted-prefix view for first-step grouped-cache
# metadata, then a separate decode init to prepare the draft
# decode metadata from that first-step state.
draft_prefill_seq_lens = (
seq_lens if self.use_v4_mtp_paged_metadata else draft_seq_lens
)
# Drafter consumes only the full group's table (see _draft_flat_tables).
draft_extend_kwargs = (
{**kwargs, "flat_block_tables": draft_flat}
if kwargs.get("flat_block_tables") is not None
else kwargs
)
self.draft_attn_backend.init_forward_metadata(
bs=padded_bs,
num_extends=num_extends,
req_pool_indices=req_pool_indices,
seq_lens=draft_prefill_seq_lens,
req_to_page=self.drafter.req_to_page,
forward_mode=forward_mode,
**draft_extend_kwargs,
)
if self.use_v4_mtp_paged_metadata:
self.draft_attn_backend.init_forward_metadata(
bs=padded_bs,
num_extends=0,
req_pool_indices=req_pool_indices,
seq_lens=draft_seq_lens,
req_to_page=self.drafter.req_to_page,
forward_mode=ForwardMode.DECODE,
**draft_kwargs,
)
else:
draft_metadata_seq_lens = (
seq_lens if self.use_v4_mtp_paged_metadata else draft_seq_lens
)
draft_forward_mode = ForwardMode.DECODE
if getattr(self.draft_attn_backend, "uses_paged_cache_groups", False):
draft_kwargs["num_tokens"] = padded_bs * self.max_tokens_per_req
self.draft_attn_backend.init_forward_metadata(
bs=padded_bs,
num_extends=0,
req_pool_indices=req_pool_indices,
seq_lens=draft_metadata_seq_lens,
req_to_page=self.drafter.req_to_page,
forward_mode=draft_forward_mode,
**draft_kwargs,
)
def _global_graph_bs(self, ctx: ForwardContext) -> int | None:
if self.dp_size <= 1 or ctx.global_num_tokens is None:
return None
max_num_tokens = max(ctx.global_num_tokens)
return (max_num_tokens + self.max_tokens_per_req - 1) // self.max_tokens_per_req
def _can_use_graph(self, bs: int, ctx: ForwardContext) -> bool:
if self.disable:
return False
if not ctx.forward_mode.is_decode():
return False
if self.dp_size > 1:
if not ctx.all_decode_or_idle:
return False
global_bs = self._global_graph_bs(ctx)
if global_bs is None or global_bs == 0:
return False
if self.disable_padding:
return self._has_cuda_graph_for_bs(global_bs)
return global_bs <= self.max_bs
if self.disable_padding:
return self._has_cuda_graph_for_bs(bs)
return bs <= self.max_bs
def can_run(self, bs: int, ctx: ForwardContext) -> bool:
return self._can_use_graph(bs, ctx)
def padded_bs(self, bs: int, ctx: ForwardContext) -> int:
return self._padded_bs(bs, ctx)
def _padded_bs(self, bs: int, ctx: ForwardContext) -> int:
graph_bs = self._global_graph_bs(ctx)
target_bs = graph_bs if graph_bs is not None else bs
index = bisect.bisect_left(self.capture_bs, target_bs)
return self.capture_bs[index]
def _pad_graph_req_pool_indices(
self, active_req_pool_indices: torch.Tensor, padded_bs: int
) -> torch.Tensor:
pad = padded_bs - active_req_pool_indices.shape[0]
if pad <= 0:
return active_req_pool_indices
if self.config.spec_algo == "DFLASH":
# Route padding rows to the sentinel req-pool slot
# (max_req_pool_size), not slot 0. The DFLASH draft derives each
# row's block seq_len from valid_cache_lengths[req_pool], so
# padding rows pointing at slot 0 would grow unbounded with
# request 0's context and hang the draft block-decode kernel.
# The sentinel row stays zero-init (length 0, dummy page 0).
sentinel = int(self.config.max_req_pool_size)
return torch.cat(
[
active_req_pool_indices,
active_req_pool_indices.new_full((pad,), sentinel),
]
)
return torch.cat(
[active_req_pool_indices, active_req_pool_indices.new_zeros(pad)]
)
def _set_graph_state_write_indices(
self, active_req_pool_indices: torch.Tensor, padded_bs: int
) -> None:
state_indices = self.input_buffers.state_write_req_pool_indices_buf[:padded_bs]
active_bs = active_req_pool_indices.shape[0]
if active_bs > 0:
state_indices[:active_bs].copy_(active_req_pool_indices)
if active_bs < padded_bs:
state_indices[active_bs:padded_bs].fill_(int(self.config.max_req_pool_size))
def __call__(
self,
bs: int,
ctx: ForwardContext,
sampling_info: SamplingBatchInfo,
req_to_page: torch.Tensor,
extend_with_prefix: bool = False,
extend_prefix_lens: torch.Tensor | None = None,
extend_prefix_lens_cpu: torch.Tensor | None = None,
extend_seq_lens: torch.Tensor | None = None,
extend_seq_lens_cpu: torch.Tensor | None = None,
positions: torch.Tensor | None = None,
out_cache_loc: torch.Tensor | None = None,
mamba_pool_indices: torch.Tensor | None = None,
mamba_cow_src_indices: torch.Tensor | None = None,
mamba_branching_seqlens: torch.Tensor | None = None,
mamba_track_pool_indices: torch.Tensor | None = None,
spec_info=None,
paged_cache_block_tables: dict | None = None,
paged_cache_block_table_base_offsets: dict | None = None,
flat_block_tables: dict | None = None,
):
"""
Unified forward entry point.
Dispatches to the captured CUDA graph when possible; falls back to the
eager forward_func otherwise. The caller does not need to know which
path was taken.
"""
use_graph = self._can_use_graph(bs, ctx)
padded_bs = self._padded_bs(bs, ctx) if use_graph else bs
active_req_pool_indices = self.input_buffers.req_pool_indices_buf[:bs]
if use_graph and padded_bs != bs:
ctx.bs = padded_bs
pad = padded_bs - bs
seq_lens = torch.nn.functional.pad(
self.input_buffers.seq_lens_buf[:bs], (0, pad), value=1
)
req_pool_indices = self._pad_graph_req_pool_indices(
active_req_pool_indices, padded_bs
)
self.input_buffers.seq_lens_buf[:padded_bs].copy_(seq_lens)
self.input_buffers.req_pool_indices_buf[:padded_bs].copy_(req_pool_indices)
if mamba_pool_indices is not None:
# Pad with -1 (PAD_SLOT_ID), NOT 0. Mamba slot 0 is a real
# allocatable slot, so padding with 0 aliases a live request's
# mamba state and corrupts it. -1 is the kernel-skipped pad id.
mamba_pool_indices = torch.nn.functional.pad(
mamba_pool_indices, (0, pad), value=-1
)
if mamba_cow_src_indices is not None:
mamba_cow_src_indices = torch.nn.functional.pad(
mamba_cow_src_indices, (0, pad), value=-1
)
if mamba_branching_seqlens is not None:
mamba_branching_seqlens = torch.nn.functional.pad(
mamba_branching_seqlens, (0, pad), value=-1
)
if mamba_track_pool_indices is not None:
mamba_track_pool_indices = torch.nn.functional.pad(
mamba_track_pool_indices, (0, pad), value=-1
)
else:
seq_lens = self.input_buffers.seq_lens_buf[:padded_bs]
req_pool_indices = self.input_buffers.req_pool_indices_buf[:padded_bs]
if use_graph:
self._set_graph_state_write_indices(active_req_pool_indices, padded_bs)
mamba_kwargs = {}
if mamba_pool_indices is not None:
mamba_kwargs["mamba_pool_indices"] = mamba_pool_indices
if mamba_cow_src_indices is not None:
mamba_kwargs["mamba_cow_src_indices"] = mamba_cow_src_indices
if mamba_branching_seqlens is not None:
mamba_kwargs["mamba_branching_seqlens"] = mamba_branching_seqlens
if mamba_track_pool_indices is not None:
mamba_kwargs["mamba_track_pool_indices"] = mamba_track_pool_indices
if use_graph:
if (
bs == 0
and paged_cache_block_tables is None
and self.attn_backend.uses_paged_cache_groups
):
paged_cache_block_tables = self._capture_paged_cache_block_tables(
padded_bs,
self.token_to_kv_pool,
)
# The backend's stale-table guard also covers the bs==0 idle
# replay: synthesize minimal valid tables for it.
if (
bs == 0
and not flat_block_tables
and getattr(self.attn_backend, "uses_flat_cache_groups", False)
):
flat_block_tables = self._idle_flat_block_tables(padded_bs)
self._init_replay_metadata(
padded_bs,
bs,
req_pool_indices,
seq_lens,
req_to_page=req_to_page,
forward_mode=ctx.forward_mode,
num_padding=padded_bs - bs if padded_bs != bs else 0,
paged_cache_block_tables=paged_cache_block_tables,
paged_cache_block_table_base_offsets=(
paged_cache_block_table_base_offsets
),
flat_block_tables=flat_block_tables,
**mamba_kwargs,
)
# Runtime prepare() is called by ModelExecutor with per-request rids
# BEFORE self.forward_step — we don't refill here to avoid clobbering
# the per-request generators with the capture-stub generator.
self.deepep_adapter.replay()
graph_key = self._cuda_graph_key(padded_bs)
with nvtx_range("graph_replay", color="red"):
self.graphs[graph_key].replay()
(
output_tokens,
output_lengths,
output_logprobs,
) = self.output_buffers[graph_key]
result = (
output_tokens[: bs * self.max_tokens_per_req],
output_lengths[:bs],
(
output_logprobs[: bs * self.max_tokens_per_req]
if output_logprobs is not None
else None
),
)
else:
# Eager parity with the replay stale-table guard: with >1 group
# the single-table fallback would serve first-group pages to
# every layer. Idle/bs==0 forwards carry no requests (exempt);
# a single published group falls back to the single table.
if (
bs > 0
and not ctx.forward_mode.is_idle()
and not flat_block_tables
and getattr(self.attn_backend, "uses_flat_cache_groups", False)
and len(self.token_to_kv_pool.paged_cache_group_specs) > 1
):
raise RuntimeError(
"CudaGraphWrapper eager forward: pool publishes "
f"{len(self.token_to_kv_pool.paged_cache_group_specs)} "
"flat cache groups and the backend consumes flat tables, "
f"but flat_block_tables is missing/empty at bs={bs} "
f"({ctx.forward_mode.name}); the single-table fallback "
"would use one group's pages for all layers."
)
metadata_num_tokens = (
{"num_tokens": ctx.input_num_tokens}
if self.attn_backend.uses_paged_cache_groups
else {}
)
self._init_forward_metadata(
padded_bs,
ctx.num_extends,
req_pool_indices,
seq_lens,
req_to_page=req_to_page,
forward_mode=ctx.forward_mode,
extend_with_prefix=extend_with_prefix,
extend_prefix_lens=extend_prefix_lens,
extend_prefix_lens_cpu=extend_prefix_lens_cpu,
extend_seq_lens=extend_seq_lens,
extend_seq_lens_cpu=extend_seq_lens_cpu,
positions=positions,
out_cache_loc=out_cache_loc,
global_num_tokens=ctx.global_num_tokens,
all_decode_or_idle=ctx.all_decode_or_idle,
capture_hidden_mode=ctx.capture_hidden_mode,
spec_info=spec_info,
**metadata_num_tokens,
paged_cache_block_tables=(
paged_cache_block_tables
if self.attn_backend.uses_paged_cache_groups
else None
),
paged_cache_block_table_base_offsets=(
paged_cache_block_table_base_offsets
if self.attn_backend.uses_paged_cache_groups
else None
),
flat_block_tables=(
flat_block_tables
if self.attn_backend.uses_flat_cache_groups
else None
),
**mamba_kwargs,
)
result = self._forward_func(bs=bs, ctx=ctx, sampling_info=sampling_info)
if use_graph and padded_bs != bs:
ctx.bs = bs
# Update mamba/GDN state after speculative verify
if _should_update_mamba_state_after_mtp_verify(
self.drafter, self.attn_backend, ctx.forward_mode
):
accept_lengths = result[1]
self.attn_backend.update_mamba_state_after_mtp_verify(accept_lengths, None)
return result