[Worker] Index the persistent per-request-slot block table under PP

Under pipeline parallelism the Mamba/GDN state-copy path indexed a block-table
row gathered for a different step. The align post-process now indexes the
persistent per-request-slot table, and fails closed when it is absent.

Co-Authored-By: Hinata <hinata@idihore.id>
Signed-off-by: MOVZX <movzx@yahoo.com>
This commit is contained in:
2026-10-09 19:43:54 +07:00
co-authored by Hinata
parent 41f6691c26
commit 62acb3dab8
4 changed files with 718 additions and 10 deletions
+4
View File
@@ -694,6 +694,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
cp_rank=self.dcp_rank,
cp_interleave=self.cp_interleave,
)
# Mamba "align" indexes a block table from the PP-deferred post-process,
# which runs pp_size steps after the gather that produced its
# idx_mapping; give the model state the persistent per-req-slot tables.
self.model_state.set_block_tables(self.block_tables)
self.pcp_manager = pcp.maybe_build_pcp_manager(
self.vllm_config,
self.device,
@@ -126,6 +126,14 @@ class ModelState(ABC):
def apply_staged_writes(self) -> None:
return None
def set_block_tables(self, block_tables: Any) -> None:
"""Register the runner's `BlockTables` once it exists.
Model states that index a block table outside the step that gathered it
(mamba "align" + the PP-deferred post-process) need the persistent
per-req-slot tables, not the batch-order gather. No-op by default."""
return None
def get_additional_cg_support(self) -> tuple[AttentionCGSupport, str | None]:
"""Cudagraph support of attention groups this ModelState builds outside
``init_attn_backend`` (e.g. encoder-only layers).
@@ -90,6 +90,9 @@ class MambaHybridModelState(DefaultModelState):
# kernel reusing the postprocess copy machinery, so the per-step src
# columns and the running state_idx are kept GPU-resident.
self._align_mode = self.cache_config.mamba_cache_mode == "align"
# Set by the runner via `set_block_tables`; the align post-process needs
# the persistent per-req-slot tables, not the per-step gather.
self._runner_block_tables: Any = None
self.recoverssm = (
RecoverSSMState() if self.cache_config.use_recoverssm else None
)
@@ -108,6 +111,9 @@ class MambaHybridModelState(DefaultModelState):
self._mamba_spec: MambaSpec | None = None
self._mamba_state_copy_funcs: MambaStateCopyFuncsByType | None = None
def set_block_tables(self, block_tables: Any) -> None:
self._runner_block_tables = block_tables
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
super().add_request(req_index, new_req_data)
# Must reset the speculative acceptance count in this idx which could be stale.
@@ -168,11 +174,24 @@ class MambaHybridModelState(DefaultModelState):
# block_tables are batch-order slices of the persistent
# input_block_tables (stable data_ptr), so the metadata is captured
# once here and reused across steps.
#
# Also hand over the per-req-slot tables the gather reads from
# (block_table.py:53-59). The align post-process can run pp_size
# steps after the gather that produced its idx_mapping, at which
# point the batch-order buffer belongs to another micro-batch; it
# indexes these by req slot instead.
persistent_block_tables = None
if self._runner_block_tables is not None:
persistent_block_tables = [
self._runner_block_tables.block_tables[gid].gpu
for gid in mamba_group_ids
]
ctx.initialize_from_forward_context(
kv_cache_config,
forward_context,
copy_funcs,
[block_tables[gid] for gid in mamba_group_ids],
persistent_block_tables=persistent_block_tables,
)
return ctx
+687 -10
View File
@@ -7,6 +7,7 @@ from typing import Any, NamedTuple
import torch
import vllm.envs as envs
from vllm.config import CacheConfig
from vllm.logger import init_logger
from vllm.model_executor.layers.mamba.mamba_utils import (
@@ -26,14 +27,299 @@ from vllm.v1.kv_cache_interface import (
UniformTypeKVCacheSpecs,
)
from vllm.v1.utils import CpuGpuBuffer
from vllm.v1.worker import guard_telemetry
from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
logger = init_logger(__name__)
# Bias so a negative row/index survives atomic_max on a zeroed counter
# -- it MUST be a tl.constexpr: a plain module-level int is not readable
# from a @triton.jit body -- an earlier build shipped one and every guarded kernel died
# with "NameError: Cannot access global variable _GUARD_BIAS" on the smoke.
_GUARD_BIAS = tl.constexpr(guard_telemetry.GUARD_BIAS)
# Production value for the align post-process's block-table row rule. True
# means "the row is the request's own state slot", which is the only correct
# rule for the PP-deferred launch (see run_fused_postprocess_align). It is a
# module-level name rather than a literal at the launch site for exactly one
# reason: the failing control for the slot-ownership check below has to be able
# to flip it. NOTHING reads it from the environment, so there is no way to turn
# it off on a serving host; tests monkeypatch it.
ALIGN_BT_ROW_IS_REQ = True
# Host-side mamba state-slot ownership check (PLE diagnostics, llama.cpp#29092 class /
# vllm#55506 failure mode). off | warn | raise, default off, so it costs
# nothing in production. It is a DIAGNOSTIC, not a device guard: it copies
# decision arrays and block-table rows to the host, which synchronizes.
#
# WHY IT EXISTS. Mamba state blocks are the one KV class with no zeroing and no
# ownership instrument:
# * single_type_kv_cache_manager.py:96-99 records newly allocated blocks for
# zeroing only when the spec `isinstance(kv_cache_spec, AttentionSpec)`.
# `MambaSpec` is not an `AttentionSpec`, so mamba state blocks are NEVER
# zeroed by `KVBlockZeroer` (gpu/model_runner.py:1213).
# * the host-side KV-zeroer ownership check (sched/scheduler.py:1508-1535) only
# inspects `new_block_ids_to_zero`, a list mamba blocks are never on.
# * a genuinely fresh request is still safe: gdn_attn.py:401 sets
# `has_initial_state = context_lens_tensor > 0`, so at context_len 0 the
# GDN kernel ignores whatever bytes the reused block holds.
# * a RESUMING request is not. With `has_initial_state` true the kernel
# consumes the block's contents, and the only thing that puts the right
# contents there is this align copy. Everything else we have bounds
# indices (the bounds guards): a bounds check catches an
# out-of-range row, never a wrong-but-in-range one, which is the whole of
# this defect class.
# Read through the envs registry, not os.getenv: the registry owns the
# normalization, so the value reported by
# `vllm.envs` and the value this check applies cannot drift apart. It is
# registered as a non-compile factor there, and an unrecognised value raises
# on first read rather than silently disarming the instrument.
_SLOT_OWNERSHIP_MODE: str = envs.VLLM_E2_MAMBA_SLOT_OWNERSHIP
# Liveness cadence: first check and every Nth are logged with a running count
# (see MambaSpecDecodeGPUContext.slot_ownership_checks_run).
_E2_CHECK_HEARTBEAT = 4096
_NULL_BLOCK_ID = 0
def mamba_slot_ownership_violations(
*,
num_reqs: int,
idx_mapping: list[int],
bt_row_is_req: bool,
block_tables: list[list[list[int]]],
num_rows: int,
num_accepted_tokens: list[int],
state_idx: list[int],
new_num_computed_tokens: list[int],
block_size: int,
registered_ptrs: list[int] | None = None,
view_ptrs: list[int] | None = None,
) -> list[str]:
"""ALIGN ROW-RULE AND SIMULTANEOUS BLOCK-COLLISION CHECK.
Read the name literally. It was narrowed from "slot ownership":
with the production row rule ``row_used == req_idx``, so the
destination comparison below reduces to ``table[req_idx][dst_col]``
against itself and is TAUTOLOGICAL on a healthy run. This function derives
what it expects and what it observes from THE SAME TABLE, and a table
cannot testify about its own staleness. What it does and does not
establish, stated so that zero findings are not over-read:
ESTABLISHES
1. **Row attribution.** The row the kernel will use is the request's own
state slot. This is the property vllm#55506 violates: under PP the
deferred launch carries an ``idx_mapping`` captured ``pp_size`` steps
earlier, so ``batch_idx`` names a different request. The
``BT_ROW_IS_REQ=False`` control exercises exactly this and nothing
wider.
2. **Simultaneous block collision.** Within ONE step, no two live request
slots reach the same state block -- counting both the source and the
destination column, and counting slots that are live but not copying,
because a writer colliding with a non-copying live owner is the same
corruption. ``NULL_BLOCK_ID`` is excluded: block id 0 is the pool's
placeholder (``core/block_pool.py:188-191``), owned by no request.
3. **Pointer-registration agreement**, when the caller supplies
``registered_ptrs``: the tables read here are the tables whose addresses
the kernel was handed. That is the one genuinely INDEPENDENT source of
truth available at this layer, and without it a wrong pointer array
would be invisible -- the checker would faithfully validate a table the
kernel never reads.
DOES NOT ESTABLISH -- do not quote zero findings as covering these:
* **Stale ownership across steps.** The block-table row could itself be
stale; nothing here compares it against the scheduler's allocation.
* **Request-generation reuse.** A slot re-issued to a different request
with the same index is indistinguishable here: there is no generation
id in any tensor this function sees.
* **Cross-step source staleness.** A source block correct for this step but
written by a previous request's state is invisible.
* **Anything about a captured replay**, which does not re-enter the caller.
Closing those needs an independent allocation/generation map from the
scheduler side, which is a plumbing change, not a diagnostic.
Pure and side-effect free; every argument is a plain Python list or int, so
this runs with no GPU and no CUDA context. The caller owns the device->host
copies and the mode.
``block_tables[g][row][col]`` is the PERSISTENT per-req-slot table of mamba
group ``g``. ``registered_ptrs`` and ``view_ptrs``, when given, are the
kernel's ``persistent_block_table_ptrs`` values and the ``data_ptr()`` of
the tables actually read, compared elementwise.
Returns one string per violation, empty when clean.
"""
violations: list[str] = []
# (group, block id) -> (req slot, which column class claimed it). Both the
# source and the destination column are entered, and live-but-not-copying
# slots are entered too, so a writer colliding with a non-copying owner is
# caught (the destination-only map missed that case).
block_owner: dict[tuple[int, int], tuple[int, str]] = {}
# (3) pointer-registration agreement -- the one independent source of truth
# available here. Without it the checker can faithfully validate a table
# the kernel never reads.
if registered_ptrs is not None and view_ptrs is not None:
if len(registered_ptrs) != len(view_ptrs):
violations.append(
f"pointer registration: {len(registered_ptrs)} registered "
f"block-table pointer(s) but {len(view_ptrs)} retained view(s)"
)
for group_idx, (registered, view) in enumerate(
zip(registered_ptrs, view_ptrs)
):
if int(registered) != int(view):
violations.append(
f"pointer registration: group {group_idx} kernel reads "
f"0x{int(registered):x} but the table checked here is at "
f"0x{int(view):x}. Every other finding in this run is "
f"about the wrong tensor."
)
def _claim(group_idx: int, block: int, req_idx: int, kind: str) -> None:
"""Record one live claim on a state block and report a collision."""
if block == _NULL_BLOCK_ID:
return # the pool's placeholder; owned by no request
key = (group_idx, block)
previous = block_owner.get(key)
if previous is not None and previous[0] != req_idx:
violations.append(
f"block collision: group {group_idx}, state block {block} is "
f"claimed in one step by request slot {previous[0]} "
f"({previous[1]}) and request slot {req_idx} ({kind}) "
f"(llama.cpp#29092 class: a prior request's recurrent state in "
f"a slot another request resumes into)."
)
elif previous is None:
block_owner[key] = (req_idx, kind)
def _cell(group_idx: int, table, row: int, col: int, what: str) -> int | None:
"""Bounded read. Returns None and reports rather than indexing wrongly.
A NEGATIVE column silently indexes a Python list from the end, so an
unbounded read here invents a block id and can report an unrelated
collision (example: accepted 1 with computed 0 yields dst_col -1,
which the device guard masks and this reader would turn into the LAST
column). Both bounds, both indices, every time.
"""
if not 0 <= row < len(table):
violations.append(
f"malformed metadata: group {group_idx}, {what} row {row} is "
f"outside the table's [0, {len(table)}) rows"
)
return None
if not 0 <= col < len(table[row]):
violations.append(
f"malformed metadata: group {group_idx}, {what} column {col} "
f"is outside row {row}'s [0, {len(table[row])}) columns. The "
f"device guard masks this access; it is reported, not read."
)
return None
return int(table[row][col])
# Pass 1: every LIVE slot's current state block, whether or not it copies.
# A slot that does not copy this step still owns its block, and a copying
# writer that lands on it is the corruption -- so the claims have to be
# entered before any writer is judged.
for batch_idx in range(min(num_reqs, len(idx_mapping))):
slot = int(idx_mapping[batch_idx])
if slot < 0 or slot >= len(state_idx):
continue
for group_idx, table in enumerate(block_tables):
block = _cell(group_idx, table, slot, int(state_idx[slot]), "live src")
if block is not None:
_claim(group_idx, block, slot, "live source")
for batch_idx in range(num_reqs):
if batch_idx >= len(idx_mapping):
violations.append(
f"idx_mapping is shorter ({len(idx_mapping)}) than num_reqs "
f"({num_reqs}); batch row {batch_idx} has no request slot"
)
continue
req_idx = int(idx_mapping[batch_idx])
if req_idx < 0:
continue # the kernel's own inactive-row sentinel
if req_idx >= min(
len(num_accepted_tokens), len(state_idx), len(new_num_computed_tokens)
):
violations.append(
f"slot bound: batch row {batch_idx} maps to request slot "
f"{req_idx}, past the per-slot decision arrays "
f"({len(num_accepted_tokens)}, {len(state_idx)}, "
f"{len(new_num_computed_tokens)} entries)"
)
continue
# Mirror of the kernel's decision block (postprocess_mamba_fused_kernel,
# the PRECOMPUTED_NEW_COMPUTED branch). Every array is read at req_idx,
# never at batch_idx -- that asymmetry is the bug's hiding place.
num_accepted = int(num_accepted_tokens[req_idx])
src_col = int(state_idx[req_idx])
new_num_computed = int(new_num_computed_tokens[req_idx])
num_tokens_running_state = new_num_computed - num_accepted + 1
aligned_new_computed = (new_num_computed // block_size) * block_size
if aligned_new_computed < num_tokens_running_state:
continue # needs_copy is False: the block table is never read
accept_token_bias = aligned_new_computed - num_tokens_running_state
dst_col = aligned_new_computed // block_size - 1
if src_col == dst_col and accept_token_bias == 0:
continue # no-op self-copy: the kernel returns before the copy
row_used = req_idx if bt_row_is_req else batch_idx
# (1) row attribution
if row_used != req_idx:
violations.append(
f"row attribution: batch row {batch_idx} is request slot "
f"{req_idx}, but the align post-process will read block-table "
f"row {row_used}. Under PP the idx_mapping was captured "
f"pp_size steps before this launch, so row {row_used} belongs "
f"to a different micro-batch's request (vllm#55506)."
)
if not 0 <= row_used < num_rows:
violations.append(
f"row bound: batch row {batch_idx} resolves to block-table row "
f"{row_used}, outside [0, {num_rows})"
)
continue
for group_idx, table in enumerate(block_tables):
owner_block = _cell(group_idx, table, req_idx, dst_col, "owner dst")
used_block = _cell(group_idx, table, row_used, dst_col, "used dst")
if owner_block is None or used_block is None:
continue
# (2) destination agreement. NOTE: with the production row rule
# row_used == req_idx, so this comparison is tautological on a
# healthy run and proves nothing on its own. It earns
# its place only in the wrong-row world, where it reports the
# defect in BLOCK IDS rather than in indices -- which is what says
# the copy really would land on another request's state.
if used_block != owner_block:
violations.append(
f"destination disagreement: group {group_idx}, request "
f"slot {req_idx} would resume into state block "
f"{used_block} (row {row_used}, col {dst_col}) but its own "
f"row holds block {owner_block} there. The copy would "
f"write this request's recurrent state over another "
f"request's block."
)
_claim(group_idx, used_block, req_idx, f"align destination col {dst_col}")
return violations
# 16 saturates HBM on H100/GB200 across the reqs=8..128 range in
# microbenchmarks
_TEMPORAL_TILES = 16
# Row bound used when no trustworthy row count is available: large enough never
# to reject a real request slot, so the guard degrades to "reject negatives"
# rather than to "reject correct metadata". See initialize_from_forward_context.
_UNBOUNDED_ROWS = 1 << 30
@triton.jit(do_not_specialize=["num_requests"])
def get_aligned_state_indices_multi_group_kernel(
@@ -187,6 +473,11 @@ def _reinterpret_u64_as_i64(value: int) -> int:
return value if value < (1 << 63) else value - (1 << 64)
def _reinterpret_i64_as_u64(value: int) -> int:
"""Inverse of ``_reinterpret_u64_as_i64``, for comparing pointers host side."""
return value + (1 << 64) if value < 0 else value
@triton.jit
def _copy_mamba_state_block(
state_idx,
@@ -196,6 +487,8 @@ def _copy_mamba_state_block(
token_bias,
block_table_ptrs_ptr,
block_table_stride_req,
block_table_num_rows,
guard_ctr,
state_base_addrs_ptr,
state_block_strides_ptr,
state_elem_sizes_ptr,
@@ -243,12 +536,39 @@ def _copy_mamba_state_block(
group_idx = tl.load(state_group_indices_ptr + state_idx).to(tl.int64)
group_base_addr = tl.load(block_table_ptrs_ptr + group_idx)
block_table_typed = group_base_addr.to(tl.pointer_type(tl.int32))
# Bounds-guard instrumentation: bound the ROW as well as the column. #50021 bounds which
# column is read within a row; this bounds which row. CAN fire on
# postprocess_mamba_fused_kernel, where bt_row_idx comes from a device
# idx_mapping value. CANNOT fire on precopy_mamba_align_fused_kernel, whose
# bt_row_idx is program_id(0) bounded by num_reqs <= rows -- that path has a
# TEMPORAL staleness problem, fixed separately, not a spatial one.
if (bt_row_idx < 0) or (bt_row_idx >= block_table_num_rows):
tl.atomic_add(guard_ctr, 1)
tl.atomic_max(guard_ctr + 1, (bt_row_idx).to(tl.int64) + _GUARD_BIAS)
tl.atomic_max(guard_ctr + 2, _GUARD_BIAS + (-1))
return
block_table_base = block_table_typed + bt_row_idx * block_table_stride_req
# Widen block ids to int64 before they reach `block_id * state_block_stride`
# below: state_block_stride can exceed 2**31 bytes for large mamba caches,
# and Triton would otherwise do the multiply in int32 and wrap.
dest_block_id = tl.load(block_table_base + dst_col).to(tl.int64)
# PR #50021 (upstream :178): these columns are derived from per-request
# accepted-token counts. A wrong count walks the read past this request's
# row; the loaded int32 is then multiplied by state_block_stride and
# dereferenced, so an out-of-range column becomes a wild (and possibly
# misaligned) address rather than merely a wrong copy. Only the
# out-of-range case is counted: `<= 0` also matches NULL_BLOCK_ID (0),
# which is the ordinary unallocated marker and not a fault.
dst_col_ok = (dst_col >= 0) & (dst_col < block_table_stride_req)
if (dst_col < 0) or (dst_col >= block_table_stride_req):
tl.atomic_add(guard_ctr, 1)
tl.atomic_max(guard_ctr + 1, (bt_row_idx).to(tl.int64) + _GUARD_BIAS)
tl.atomic_max(guard_ctr + 2, (dst_col).to(tl.int64) + _GUARD_BIAS)
dest_block_id = tl.load(
block_table_base + dst_col, mask=dst_col_ok, other=-1
).to(tl.int64)
if dest_block_id <= 0:
return
dst_addr = state_base_addr + dest_block_id * state_block_stride
is_conv_state = conv_width > 0
@@ -259,7 +579,16 @@ def _copy_mamba_state_block(
if tile_idx > 0:
return
# DS conv layout: state_len is the slide axis; copy per dim row.
src_block_id = tl.load(block_table_base + src_col).to(tl.int64)
src_col_ok = (src_col >= 0) & (src_col < block_table_stride_req)
if (src_col < 0) or (src_col >= block_table_stride_req):
tl.atomic_add(guard_ctr, 1)
tl.atomic_max(guard_ctr + 1, (bt_row_idx).to(tl.int64) + _GUARD_BIAS)
tl.atomic_max(guard_ctr + 2, (src_col).to(tl.int64) + _GUARD_BIAS)
src_block_id = tl.load(
block_table_base + src_col, mask=src_col_ok, other=-1
).to(tl.int64)
if src_block_id <= 0:
return
dim_rows = tl.load(state_dim_row_count_ptr + state_idx)
row_stride = tl.load(state_dim_row_stride_ptr + state_idx)
src_block_addr = state_base_addr + src_block_id * state_block_stride
@@ -310,7 +639,16 @@ def _copy_mamba_state_block(
# SD conv: copy
# state[bt[src_col], token_bias:] ->
# state[bt[dst_col], :conv_width - token_bias]
src_block_id = tl.load(block_table_base + src_col).to(tl.int64)
src_col_ok = (src_col >= 0) & (src_col < block_table_stride_req)
if (src_col < 0) or (src_col >= block_table_stride_req):
tl.atomic_add(guard_ctr, 1)
tl.atomic_max(guard_ctr + 1, (bt_row_idx).to(tl.int64) + _GUARD_BIAS)
tl.atomic_max(guard_ctr + 2, (src_col).to(tl.int64) + _GUARD_BIAS)
src_block_id = tl.load(
block_table_base + src_col, mask=src_col_ok, other=-1
).to(tl.int64)
if src_block_id <= 0:
return
src_block_addr = state_base_addr + src_block_id * state_block_stride
token_bytes = state_inner_size * state_elem_size
num_dst_tokens = conv_width - token_bias
@@ -349,7 +687,17 @@ def _copy_mamba_state_block(
# Temporal state: copy state[bt[src_col + token_bias]] -> state[bt[dst_col]]
# Body u64 range is partitioned across TEMPORAL_TILES CTAs to keep the
# SMs filled at small batch.
actual_src_block_id = tl.load(block_table_base + src_col + token_bias).to(tl.int64)
tmp_col = src_col + token_bias
tmp_col_ok = (tmp_col >= 0) & (tmp_col < block_table_stride_req)
if (tmp_col < 0) or (tmp_col >= block_table_stride_req):
tl.atomic_add(guard_ctr, 1)
tl.atomic_max(guard_ctr + 1, (bt_row_idx).to(tl.int64) + _GUARD_BIAS)
tl.atomic_max(guard_ctr + 2, (tmp_col).to(tl.int64) + _GUARD_BIAS)
actual_src_block_id = tl.load(
block_table_base + tmp_col, mask=tmp_col_ok, other=-1
).to(tl.int64)
if actual_src_block_id <= 0:
return
src_addr = state_base_addr + actual_src_block_id * state_block_stride
# Use natural block data size (inner_size * elem_size), NOT
# state_block_stride which is the page stride and can exceed the
@@ -378,6 +726,8 @@ def postprocess_mamba_fused_kernel(
# block table.
block_table_ptrs_ptr,
block_table_stride_req: tl.int64, # stride between requests (in elements)
block_table_num_rows: tl.int64, # bounds-guard instrumentation
guard_ctr, # bounds-guard instrumentation: persistent int64[3]
# Mamba state metadata, flattened across all layer/state tensors. Different
# Mamba groups may contribute different numbers of state tensors.
state_base_addrs_ptr, # base address of each state tensor
@@ -409,6 +759,14 @@ def postprocess_mamba_fused_kernel(
# PRECOMPUTED_NEW_COMPUTED: when True, num_computed_tokens_ptr already holds
# the post-step new_num_computed value (V2 supplies the advanced count).
PRECOMPUTED_NEW_COMPUTED: tl.constexpr = False,
# BT_ROW_IS_REQ: when True, block_table_ptrs_ptr points at the *persistent*
# per-req-slot block tables (BlockTables.block_tables[i].gpu,
# block_table.py:53-59), whose rows are request state slots, so the row
# index is req_idx rather than batch_idx. When False it points at the
# batch-order gather (BlockTables.input_block_tables, block_table.py:74-76)
# and batch_idx is the row. Required by the PP-deferred post-process, which
# runs pp_size steps after the gather that produced its idx_mapping.
BT_ROW_IS_REQ: tl.constexpr = False,
# TEMPORAL_TILES: when > 1, the temporal copy body is partitioned across
# TEMPORAL_TILES CTAs along the u64 inner range. Callers must launch a
# 3D grid (num_reqs, total_states, TEMPORAL_TILES). Default 1 preserves
@@ -476,7 +834,15 @@ def postprocess_mamba_fused_kernel(
if src_block_idx == dest_block_idx and accept_token_bias == 0:
return
bt_row_idx = batch_idx if HAS_IDX_MAPPING else req_idx
if BT_ROW_IS_REQ: # noqa: SIM108 -- a ternary would drop this comment
# Persistent per-req-slot table: the row is the request's own slot, so
# it stays correct however many steps separate the gather from this
# launch (the PP-deferred post-process is enqueued at the top of
# execute_model, before this step's gather, with an idx_mapping
# captured pp_size steps earlier).
bt_row_idx = req_idx
else:
bt_row_idx = batch_idx if HAS_IDX_MAPPING else req_idx
_copy_mamba_state_block(
state_idx,
bt_row_idx,
@@ -485,6 +851,8 @@ def postprocess_mamba_fused_kernel(
accept_token_bias,
block_table_ptrs_ptr,
block_table_stride_req,
block_table_num_rows,
guard_ctr,
state_base_addrs_ptr,
state_block_strides_ptr,
state_elem_sizes_ptr,
@@ -556,6 +924,8 @@ def precopy_mamba_align_fused_kernel(
# Same flattened state-layout metadata as postprocess_mamba_fused_kernel
block_table_ptrs_ptr,
block_table_stride_req: tl.int64,
block_table_num_rows: tl.int64, # bounds-guard instrumentation
guard_ctr, # bounds-guard instrumentation: persistent int64[3]
state_base_addrs_ptr,
state_block_strides_ptr,
state_elem_sizes_ptr,
@@ -616,6 +986,8 @@ def precopy_mamba_align_fused_kernel(
token_bias,
block_table_ptrs_ptr,
block_table_stride_req,
block_table_num_rows,
guard_ctr,
state_base_addrs_ptr,
state_block_strides_ptr,
state_elem_sizes_ptr,
@@ -810,6 +1182,31 @@ class MambaSpecDecodeGPUContext:
block_table_ptrs: torch.Tensor
block_table_stride_req: int = 0
# Per-group *persistent* per-req-slot block-table addresses: int64[num_groups].
# `block_table_ptrs` above addresses the batch-order gather
# (BlockTables.input_block_tables, block_table.py:74-76), which is rewritten
# in place every step and is only valid for the step that gathered it. These
# address BlockTables.block_tables[i].gpu (block_table.py:53-59), the
# persistent [max_num_reqs, max_num_blocks] tables the gather reads from
# (their data_ptrs are already held device-side at block_table.py:94-96).
# Rows there are request state slots, so they can be indexed by req_idx from
# any later step. Used only by the align post-process (BT_ROW_IS_REQ).
persistent_block_table_ptrs: torch.Tensor | None = None
has_persistent_block_tables: bool = False
# shape[0] of the persistent tables; see the note in set_block_tables.
# -1, not 0: this is the ROW BOUND the align post-process uses, and a 0
# default bounds EVERY row out, so a context that never registered the
# persistent tables would report guard hits on correct metadata instead of
# copying state. -1 is unusable on purpose and is asserted before use.
persistent_block_table_num_rows: int = -1
# The same tensors `persistent_block_table_ptrs` addresses, retained so the
# host-side slot-ownership check can READ the rows the kernel dereferences
# rather than model them. Holding these costs no allocation: they are the
# runner's own `BlockTables.block_tables[i].gpu`, alive for the engine's
# lifetime, and nothing here writes to them.
persistent_block_table_views: list[torch.Tensor] | None = None
# persistent output for the once-per-step, all-group aligned-index launch.
# shape: [num_groups, max_num_reqs, 1 + num_speculative_blocks].
aligned_state_indices: torch.Tensor | None = None
@@ -828,6 +1225,18 @@ class MambaSpecDecodeGPUContext:
# Flag to track if metadata has been populated
is_initialized: bool = False
# Last result of the host-side slot-ownership check (always [] unless
# VLLM_E2_MAMBA_SLOT_OWNERSHIP is set). Readable by a test so the control
# does not have to parse log records.
last_slot_ownership_violations: list[str] = dataclasses.field(
default_factory=list
)
# Liveness for the same check (diagnostics on 0.30): how many launches it has
# judged and how many had findings. Logged on the first check and every
# _E2_CHECK_HEARTBEAT after, so a zero-findings run can prove it ran.
slot_ownership_checks_run: int = 0
slot_ownership_checks_with_findings: int = 0
@classmethod
def create(
cls,
@@ -900,6 +1309,9 @@ class MambaSpecDecodeGPUContext:
block_table_ptrs=torch.zeros(
len(mamba_group_ids), dtype=torch.int64, device=device
),
persistent_block_table_ptrs=torch.zeros(
len(mamba_group_ids), dtype=torch.int64, device=device
),
aligned_state_indices=torch.empty(
(
len(mamba_group_ids),
@@ -924,6 +1336,7 @@ class MambaSpecDecodeGPUContext:
forward_context: dict[str, Any],
mamba_state_copy_funcs: MambaStateCopyFuncsByType,
block_tables: list[torch.Tensor],
persistent_block_tables: list[torch.Tensor] | None = None,
) -> None:
"""Extract and cache memory layout metadata from Mamba state tensors.
@@ -954,10 +1367,14 @@ class MambaSpecDecodeGPUContext:
have a `kv_cache` attribute containing the list of state tensors.
mamba_state_copy_funcs: Mapping from MambaAttentionBackendEnum to
copy functions.
block_tables: per-mamba-group persistent block-table tensors, in
the same order as `mamba_group_ids`. Their `data_ptr()` /
`stride(0)` are captured once for the kernel to index into.
block_tables: per-mamba-group *gathered*, batch-order block-table
tensors, in the same order as `mamba_group_ids`. Their
`data_ptr()` / `stride(0)` are captured once for the kernel to
index into by batch row.
persistent_block_tables: per-mamba-group *persistent* per-req-slot
block-table tensors (`BlockTables.block_tables[i].gpu`), same
order. Captured separately so the align post-process can index
by req slot; see `persistent_block_table_ptrs`.
"""
if self.is_initialized:
return
@@ -968,6 +1385,7 @@ class MambaSpecDecodeGPUContext:
forward_context,
mamba_state_copy_funcs,
block_tables,
persistent_block_tables,
)
def _populate_metadata(
@@ -976,6 +1394,7 @@ class MambaSpecDecodeGPUContext:
forward_context: dict[str, Any],
mamba_state_copy_funcs: MambaStateCopyFuncsByType,
block_tables: list[torch.Tensor],
persistent_block_tables: list[torch.Tensor] | None = None,
) -> None:
idx = 0
for group_local_idx, mamba_group_id in enumerate(self.mamba_group_ids):
@@ -1084,9 +1503,90 @@ class MambaSpecDecodeGPUContext:
f"all mamba block tables must share stride(0), got {strides}"
)
self.block_table_stride_req = int(next(iter(strides)))
# Bounds-guard instrumentation: row bound for the guard in _copy_mamba_state_block.
#
# NOT from `block_tables`. Every caller passes a per-batch VIEW --
# get_device_tensor(num_reqs) at :1690 and :1838, a num_reqs_padded
# slice at mamba_hybrid.py:196 -- and this function runs once, so a
# bound taken from shape[0] here freezes at the first batch's request
# count and rejects every higher row in every later batch. The guard
# would then skip real state copies and report masked faults that never
# happened. (stride(0) is unaffected: it is max_num_blocks and survives
# a row slice, which is why the existing stride assert never caught
# this.)
#
# The persistent tables ARE [max_num_reqs, ...], so use them when they
# are registered. When they are not there is no correct row count at
# this point, and a wrong bound is worse than a loose one: fall back to
# a sentinel that still rejects negative rows -- the case the poison
# guard row exercises and the only one that reads outside the tensor --
# while never rejecting a real request slot.
rows = {bt.shape[0] for bt in block_tables}
assert len(rows) == 1, (
f"all mamba block tables must share shape[0], got {rows}"
)
self.block_table_num_rows = _UNBOUNDED_ROWS
for i, bt in enumerate(block_tables):
self.block_table_ptrs[i] = _reinterpret_u64_as_i64(bt.data_ptr())
# Same capture for the persistent per-req-slot tables. `input_block_tables`
# is `torch.zeros_like` of these (block_table.py:74-76), so stride(0) is
# identical and `block_table_stride_req` covers both; assert it rather
# than assume.
if persistent_block_tables is not None:
assert len(persistent_block_tables) == self.num_groups, (
f"expected {self.num_groups} persistent block tables, "
f"got {len(persistent_block_tables)}"
)
persistent_strides = {bt.stride(0) for bt in persistent_block_tables}
assert persistent_strides == strides, (
"persistent block tables must share stride(0) with the gathered "
f"tables, got {persistent_strides} vs {strides}"
)
assert self.persistent_block_table_ptrs is not None
for i, bt in enumerate(persistent_block_tables):
self.persistent_block_table_ptrs[i] = _reinterpret_u64_as_i64(
bt.data_ptr()
)
# The align paths index the PERSISTENT tables by req_idx while
# every other geometry scalar is measured from the GATHERED ones
# (by design). Two different scalars, two different treatments:
#
# * the STRIDE is load-bearing -- it is the multiplier in
# `block_table_typed + bt_row_idx * block_table_stride_req`, so
# a mismatch reads the wrong row entirely. It is ASSERTED equal
# just above; there is no correct behaviour if it differs.
# * the ROW COUNT is not required to be equal (the fixture in
# test_pp_deferred_mamba_block_table.py builds a 2-row gathered
# table against an 8-row persistent one on purpose), so it is
# CARRIED separately. Anything bounding a row against the
# persistent tables must use this scalar; using
# block_table_num_rows would falsely reject a valid request slot
# whenever the gathered buffer is narrower, and a guard that
# fires on correct metadata is worse than no guard.
persistent_rows = {bt.shape[0] for bt in persistent_block_tables}
assert len(persistent_rows) == 1, (
f"all persistent block tables must share shape[0], got "
f"{persistent_rows}"
)
self.persistent_block_table_num_rows = int(next(iter(persistent_rows)))
# ...and this is the only trustworthy row count in this object, so
# the same-step guard takes it too.
self.block_table_num_rows = self.persistent_block_table_num_rows
self.has_persistent_block_tables = True
self.persistent_block_table_views = list(persistent_block_tables)
else:
# Legal for V1 callers, which never reach the V2 align
# post-process. If a V2 align launch does happen on such a context,
# `run_fused_postprocess_align` raises rather than falling back to
# the batch-order gather: that fallback is exactly the earlier
# wrong-row bug.
logger.warning_once(
"Mamba align context initialized without the persistent "
"per-req-slot block tables. The V1 paths are unaffected; a V2 "
"align post-process launch on this context will raise."
)
self.is_initialized = True
def compute_aligned_state_indices(
@@ -1168,6 +1668,10 @@ class MambaSpecDecodeGPUContext:
num_draft_tokens_gpu,
self.block_table_ptrs,
self.block_table_stride_req,
self.block_table_num_rows,
guard_telemetry.counter(
guard_telemetry.SITE_MAMBA_COPY, self.state_base_addrs.device
),
self.state_base_addrs,
self.state_block_strides,
self.state_elem_sizes,
@@ -1215,6 +1719,10 @@ class MambaSpecDecodeGPUContext:
token_bias_gpu,
self.block_table_ptrs,
self.block_table_stride_req,
self.block_table_num_rows,
guard_telemetry.counter(
guard_telemetry.SITE_MAMBA_COPY, self.state_base_addrs.device
),
self.state_base_addrs,
self.state_block_strides,
self.state_elem_sizes,
@@ -1252,11 +1760,49 @@ class MambaSpecDecodeGPUContext:
if num_reqs == 0 or not self.is_initialized:
return
# Fail closed. Under PP this launch is the *deferred* post-process: it
# is enqueued by `update_pp_decode_requests` at the top of
# `execute_model` (model_runner.py:1644), before that step's
# `prepare_attn` (model_runner.py:1718) and the
# `gather_block_tables` inside it (:1442 / block_table.py:149), and
# `idx_mapping` was captured pp_size steps earlier
# (pp_utils.py:57-58, 258-271). The single batch-order gather buffer
# therefore holds a different micro-batch's rows and `batch_idx`
# resolves to the wrong request. This path MUST index the persistent
# per-req-slot tables by `req_idx`; there is no safe fallback, so a
# context without them is a bug in the caller, not a degraded mode.
# (`req_idx` is also correct on the same-step last-rank / no-PP path.)
if not self.has_persistent_block_tables:
raise RuntimeError(
"MambaSpecDecodeGPUContext was initialized without the "
"persistent per-req-slot block tables, so the V2 align "
"post-process cannot index them by req slot. Falling back to "
"the batch-order gather is the known deferred-PP wrong-row "
"corruption, so this raises instead. The runner must call "
"ModelState.set_block_tables() before the first align step."
)
block_table_ptrs = self.persistent_block_table_ptrs
assert self.persistent_block_table_num_rows > 0, (
"persistent_block_table_num_rows was never set, so the row bound "
"in _copy_mamba_state_block would reject every row and report "
"guard hits on correct metadata. set_block_tables() must run "
"before the first align post-process."
)
# V2 reads non-contiguous idx_mapping positions, so snapshot the whole
# decision buffer rather than only [:num_reqs].
num_accepted_tokens_snapshot = self.num_accepted_tokens_out
num_accepted_tokens_snapshot.copy_(num_accepted_tokens_gpu)
if _SLOT_OWNERSHIP_MODE != "off":
self._check_slot_ownership(
num_reqs=num_reqs,
num_accepted_tokens=num_accepted_tokens_snapshot,
state_idx_gpu=state_idx_gpu,
new_num_computed_tokens_gpu=new_num_computed_tokens_gpu,
idx_mapping=idx_mapping,
)
total_states = self.num_states
grid = (num_reqs, total_states, _TEMPORAL_TILES)
postprocess_mamba_fused_kernel[grid](
@@ -1265,8 +1811,12 @@ class MambaSpecDecodeGPUContext:
None, # num_scheduled: unused under PRECOMPUTED_NEW_COMPUTED
new_num_computed_tokens_gpu,
None, # num_draft: unused under PRECOMPUTED_NEW_COMPUTED
self.block_table_ptrs,
block_table_ptrs,
self.block_table_stride_req,
self.persistent_block_table_num_rows,
guard_telemetry.counter(
guard_telemetry.SITE_MAMBA_COPY, self.state_base_addrs.device
),
self.state_base_addrs,
self.state_block_strides,
self.state_elem_sizes,
@@ -1283,9 +1833,136 @@ class MambaSpecDecodeGPUContext:
CONV_STATE_DIM_FIRST=is_conv_state_dim_first(),
HAS_IDX_MAPPING=True,
PRECOMPUTED_NEW_COMPUTED=True,
BT_ROW_IS_REQ=ALIGN_BT_ROW_IS_REQ,
TEMPORAL_TILES=_TEMPORAL_TILES,
)
def _check_slot_ownership(
self,
*,
num_reqs: int,
num_accepted_tokens: torch.Tensor,
state_idx_gpu: torch.Tensor,
new_num_computed_tokens_gpu: torch.Tensor,
idx_mapping: torch.Tensor,
) -> list[str]:
"""Host-side mamba state-slot ownership check, off by default.
VLLM_E2_MAMBA_SLOT_OWNERSHIP=warn|raise. Same discipline as the KV
zeroer's ownership check (VLLM_E2_ZERO_OWNERSHIP_CHECK): a diagnostic in the
scheduler-visible sense -- host side, opt-in, never a device guard.
Unlike that check it lives here, because the thing at risk is not visible to
the scheduler at all: `MambaSpec` is not an `AttentionSpec`, so mamba
state blocks never appear on `new_block_ids_to_zero` and that check never
sees them.
COST, and it is not only wall-clock. Four device->host copies plus one
block-table slice per call, each a synchronize. That SERIALIZES work
that is normally overlapped, so an instrumented boot has materially
different timing from a production one: zero findings under this check
cannot by itself exclude a timing-sensitive defect. Run the
instrumented arm for diagnosis and an uninstrumented arm for the
concurrency qualification; do not merge their conclusions.
CAPTURE CONTRACT, stated explicitly rather than assumed. These copies
are illegal inside a stream capture, so the function asserts it is not
capturing instead of relying on "this is engine-thread Python" -- Python
also runs while a graph is being captured, and the caller's own contract
is the thing being checked here. A captured REPLAY does not re-enter
this function at all, which is a real scope limit of the instrument,
not a safety argument.
Returns the violation list (also stored on the context so a test can
read it without parsing logs).
"""
if torch.cuda.is_current_stream_capturing():
# The D2H copies below would abort the capture. Report rather than
# silently skip: a diagnostic reached from a path it cannot serve
# is a contract error in the caller.
logger.warning_once(
"VLLM_E2_MAMBA_SLOT_OWNERSHIP is set but the align "
"post-process was reached during a cudagraph capture, where "
"the check's device->host copies are illegal. Skipped for "
"this launch; the caller's outside-capture contract does not "
"hold."
)
self.last_slot_ownership_violations = []
return []
tables = self.persistent_block_table_views
if not tables:
# Unreachable from run_fused_postprocess_align, which raises above
# when the persistent tables were never registered. Kept so the
# check degrades to silence rather than to an exception if some
# future caller reaches it first.
logger.warning_once(
"VLLM_E2_MAMBA_SLOT_OWNERSHIP is set but the persistent "
"per-req-slot block tables were never registered, so slot "
"ownership cannot be checked."
)
self.last_slot_ownership_violations = []
return []
num_cols = min(int(t.shape[1]) for t in tables)
violations = mamba_slot_ownership_violations(
num_reqs=int(num_reqs),
idx_mapping=idx_mapping.detach().to("cpu").reshape(-1).tolist(),
bt_row_is_req=bool(ALIGN_BT_ROW_IS_REQ),
block_tables=[
t[:, :num_cols].detach().to("cpu").tolist() for t in tables
],
num_rows=int(self.persistent_block_table_num_rows),
# The independent evidence: what the kernel was handed, against
# what is being read here. `persistent_block_table_ptrs` holds the
# addresses reinterpreted as signed, so undo that before comparing.
registered_ptrs=(
[
_reinterpret_i64_as_u64(int(v))
for v in self.persistent_block_table_ptrs.detach()
.to("cpu")
.reshape(-1)
.tolist()[: len(tables)]
]
if self.persistent_block_table_ptrs is not None
else None
),
view_ptrs=[int(t.data_ptr()) for t in tables],
num_accepted_tokens=num_accepted_tokens.detach()
.to("cpu")
.reshape(-1)
.tolist(),
state_idx=state_idx_gpu.detach().to("cpu").reshape(-1).tolist(),
new_num_computed_tokens=new_num_computed_tokens_gpu.detach()
.to("cpu")
.reshape(-1)
.tolist(),
block_size=int(self.block_size),
)
self.last_slot_ownership_violations = violations
self.slot_ownership_checks_run += 1
if violations:
self.slot_ownership_checks_with_findings += 1
runs = self.slot_ownership_checks_run
if runs == 1 or runs % _E2_CHECK_HEARTBEAT == 0:
logger.info(
"Mamba slot-ownership check (mode=%s): %d checks run, %d with "
"findings",
_SLOT_OWNERSHIP_MODE,
runs,
self.slot_ownership_checks_with_findings,
)
if not violations:
return violations
message = (
f"Mamba state-slot ownership violation on the align "
f"post-process ({len(violations)} finding(s), num_reqs="
f"{num_reqs}, BT_ROW_IS_REQ={ALIGN_BT_ROW_IS_REQ}): "
+ " | ".join(violations)
)
if _SLOT_OWNERSHIP_MODE == "raise":
raise RuntimeError(message)
logger.warning("%s", message)
return violations
@dataclasses.dataclass
class MambaBuffers: