@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 4bf699d310
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Kernel] Support DS Mamba tail copy for MTP align mode (#45473)

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com> Co-authored-by: Thomas Parnell <tom.parnell@gmail.com>

sungsoo ha committed Jun 16, 2026 at 15:50 UTC 4bf699d310300edf1df2df2713e3d6516438a798
3 files changed +182 -34
tests/v1/worker/test_mamba_utils.py
+115
@@ -2132,3 +2132,118 @@ class TestPostprocessMambaFusedKernel:
2132 expected_accepted,
2133 msg="num_accepted_tokens mismatch at accept_token_bias=2",
2134 )
2135 +
2136 + def test_ds_conv_layout_bias_gt_0_byte_equal_to_sd(
2137 + self, device, test_config, monkeypatch
2138 + ):
2139 + """DS conv postprocess should match SD when accept_token_bias > 0."""
2140 + from vllm.model_executor.layers.mamba import mamba_utils as model_mamba_utils
2141 +
2142 + cfg = test_config
2143 + torch.manual_seed(38898)
2144 +
2145 + req_ids = ["req_0"]
2146 + num_computed_tokens = [30]
2147 + num_scheduled_tokens = {"req_0": 1}
2148 + num_draft_tokens: dict[str, int] = {}
2149 + num_accepted_tokens = [2] # Results in accept_token_bias = 1
2150 + mamba_state_idx = [1] # src_block_idx = 1 = dest_block_idx
2151 + block_ids_per_req = [list(range(8))]
2152 +
2153 + layer_names = ["layer_0"]
2154 + kv_cache_config = _make_kv_cache_config(cfg, layer_names)
2155 +
2156 + num_reqs = len(req_ids)
2157 + block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
2158 + block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
2159 +
2160 + # Same logical conv state in SD and DS layouts.
2161 + sd_source_conv = torch.randn(
2162 + cfg.num_blocks,
2163 + cfg.conv_width,
2164 + cfg.conv_inner_dim,
2165 + dtype=cfg.dtype,
2166 + device=device,
2167 + )
2168 + ds_source_conv = sd_source_conv.permute(0, 2, 1).contiguous()
2169 + sd_source_temporal = torch.randn(
2170 + cfg.num_blocks, cfg.temporal_state_dim, dtype=cfg.dtype, device=device
2171 + )
2172 +
2173 + # SD GPU path. Default layout is SD.
2174 + model_mamba_utils.get_conv_state_layout.cache_clear()
2175 + sd_conv = sd_source_conv.clone()
2176 + sd_temporal = sd_source_temporal.clone()
2177 + forward_context_sd = {
2178 + "layer_0": _make_mock_attention(sd_conv, sd_temporal),
2179 + }
2180 + gpu_ctx_sd = _make_gpu_ctx(cfg, kv_cache_config, device)
2181 + _run_gpu_postprocess(
2182 + gpu_ctx_sd,
2183 + kv_cache_config=kv_cache_config,
2184 + forward_context=forward_context_sd,
2185 + copy_funcs=_COPY_FUNCS,
2186 + block_table=block_table_gpu,
2187 + req_ids=req_ids,
2188 + num_accepted_tokens=num_accepted_tokens,
2189 + mamba_state_idx=mamba_state_idx,
2190 + num_scheduled_tokens=num_scheduled_tokens,
2191 + num_computed_tokens=num_computed_tokens,
2192 + num_draft_tokens=num_draft_tokens,
2193 + device=device,
2194 + )
2195 + torch.accelerator.synchronize()
2196 +
2197 + # Sanity: SD path actually modified the state (copy was performed).
2198 + assert not torch.equal(sd_conv, sd_source_conv), (
2199 + "SD baseline did not modify conv state; test setup is wrong"
2200 + )
2201 +
2202 + # DS GPU path on the DS twin.
2203 + monkeypatch.setenv("VLLM_SSM_CONV_STATE_LAYOUT", "DS")
2204 + model_mamba_utils.get_conv_state_layout.cache_clear()
2205 + try:
2206 + ds_conv = ds_source_conv.clone()
2207 + ds_temporal = sd_source_temporal.clone()
2208 + forward_context_ds = {
2209 + "layer_0": _make_mock_attention(ds_conv, ds_temporal),
2210 + }
2211 + gpu_ctx_ds = _make_gpu_ctx(cfg, kv_cache_config, device)
2212 + _run_gpu_postprocess(
2213 + gpu_ctx_ds,
2214 + kv_cache_config=kv_cache_config,
2215 + forward_context=forward_context_ds,
2216 + copy_funcs=_COPY_FUNCS,
2217 + block_table=block_table_gpu,
2218 + req_ids=req_ids,
2219 + num_accepted_tokens=num_accepted_tokens,
2220 + mamba_state_idx=mamba_state_idx,
2221 + num_scheduled_tokens=num_scheduled_tokens,
2222 + num_computed_tokens=num_computed_tokens,
2223 + num_draft_tokens=num_draft_tokens,
2224 + device=device,
2225 + )
2226 + torch.accelerator.synchronize()
2227 + finally:
2228 + # Reset the lru cache so other tests see the default layout again.
2229 + model_mamba_utils.get_conv_state_layout.cache_clear()
2230 +
2231 + # DS bytes, un-permuted, should match the SD result.
2232 + torch.testing.assert_close(
2233 + ds_conv.permute(0, 2, 1).contiguous(),
2234 + sd_conv,
2235 + msg=(
2236 + "DS conv post-kernel does not match SD baseline; the DS "
2237 + "row-loop in postprocess_mamba_fused_kernel is wrong."
2238 + ),
2239 + )
2240 + torch.testing.assert_close(
2241 + ds_temporal,
2242 + sd_temporal,
2243 + msg="DS temporal state diverged from SD",
2244 + )
2245 + torch.testing.assert_close(
2246 + gpu_ctx_ds.num_accepted_tokens_out[:num_reqs],
2247 + gpu_ctx_sd.num_accepted_tokens_out[:num_reqs],
2248 + msg="DS num_accepted_tokens diverged from SD",
2249 + )
vllm/model_executor/layers/mamba/mamba_utils.py
+6 -10
@@ -303,18 +303,14 @@ def get_conv_copy_spec(
303 src_block_id = block_ids[cur_block_idx]
304 offset = num_accepted_tokens - 1
305 if is_conv_state_dim_first():
306 - # DS layout: (num_blocks, dim, state_len) — state_len is last.
307 - if offset > 0:
308 - # Slicing along the last dim yields a non-contiguous view
309 - # because features (dim) are strided by state_len.
310 - raise NotImplementedError(
311 - "DS conv state layout does not yet support speculative "
312 - "decoding with mamba_cache_mode='align' "
313 - "(num_accepted_tokens > 1)."
314 - )
306 + # DS offset > 0 is handled by the fused postprocess kernel.
307 + assert offset == 0, (
308 + "DS conv state with num_accepted_tokens > 1 must be handled by "
309 + "the fused postprocess kernel, not get_conv_copy_spec"
310 + )
311 src_state = state[src_block_id]
312 else:
317 - # SD layout: (num_blocks, state_len, dim) — dim contiguous.
313 + # SD layout: (num_blocks, state_len, dim), with dim contiguous.
314 src_state = state[src_block_id, offset:]
315 return MambaCopySpec(
316 start_addr=src_state.data_ptr(), num_elements=src_state.numel()
vllm/v1/worker/mamba_utils.py
+61 -24
@@ -12,6 +12,7 @@ from vllm.model_executor.layers.mamba.mamba_utils import (
12 MambaStateCopyFunc,
13 get_conv_copy_spec,
14 get_temporal_copy_spec,
15 + is_conv_state_dim_first,
16 )
17 from vllm.triton_utils import tl, triton
18 from vllm.utils.math_utils import cdiv
@@ -43,6 +44,9 @@ def postprocess_mamba_fused_kernel(
44 state_inner_sizes_ptr, # number of elements in inner dimensions
45 state_conv_widths_ptr, # conv width for conv states (0 for temporal)
46 state_group_indices_ptr, # maps state_idx to group index in block table
47 + # DS conv row metadata. Zero keeps the single-region copy path.
48 + state_dim_row_count_ptr, # int32: per-block dim row count for DS conv
49 + state_dim_row_stride_ptr, # int64: bytes between rows for DS conv
50 # Output: num_accepted_tokens update (for src==dst case)
51 num_accepted_tokens_out_ptr,
52 # Runtime parameter (varies per batch - NOT constexpr to avoid recompilation)
@@ -52,6 +56,7 @@ def postprocess_mamba_fused_kernel(
56 block_size: tl.constexpr,
57 # COPY_BLOCK_SIZE: fixed tuning parameter for memory copy loop
58 COPY_BLOCK_SIZE: tl.constexpr,
59 + CONV_STATE_DIM_FIRST: tl.constexpr,
60 ):
61 """
62 Fused GPU kernel for postprocess_mamba that computes decisions AND performs
@@ -121,8 +126,35 @@ def postprocess_mamba_fused_kernel(
126 # conv_width == 0 means this is a temporal state (get_temporal_copy_spec logic)
127 is_conv_state = conv_width > 0
128
129 + # Update accepted-token count before early exits.
130 + if src_block_idx == dest_block_idx and state_idx == 0:
131 + tl.store(num_accepted_tokens_out_ptr + req_idx, 1)
132 +
133 + # Skip no-op self-copy.
134 + if src_block_idx == dest_block_idx and accept_token_bias == 0:
135 + return
136 +
137 + if CONV_STATE_DIM_FIRST and is_conv_state:
138 + dim_rows = tl.load(state_dim_row_count_ptr + state_idx)
139 + row_stride = tl.load(state_dim_row_stride_ptr + state_idx)
140 + per_row_bytes = (conv_width - accept_token_bias).to(tl.int64) * state_elem_size
141 + bias_bytes = accept_token_bias.to(tl.int64) * state_elem_size
142 + src_block_addr = state_base_addr + src_block_id * state_block_stride
143 + dst_block_addr = state_base_addr + dest_block_id * state_block_stride
144 + offsets = tl.arange(0, COPY_BLOCK_SIZE)
145 + for d in range(0, dim_rows):
146 + row_src = src_block_addr + d * row_stride + bias_bytes
147 + row_dst = dst_block_addr + d * row_stride
148 + for i in range(0, per_row_bytes, COPY_BLOCK_SIZE):
149 + mask = (i + offsets) < per_row_bytes
150 + curr_src = (row_src + i + offsets).to(tl.pointer_type(tl.uint8))
151 + curr_dst = (row_dst + i + offsets).to(tl.pointer_type(tl.uint8))
152 + data = tl.load(curr_src, mask=mask)
153 + tl.store(curr_dst, data, mask=mask)
154 + return
155 +
156 if is_conv_state:
125 - # Conv state: copy
157 + # SD conv: copy
158 # state[block_table[req_idx, src_block_idx], accept_token_bias:]
159 # to
160 # state[block_table[req_idx, dest_block_idx], :conv_width - accept_token_bias]
@@ -151,19 +183,6 @@ def postprocess_mamba_fused_kernel(
183 # actual data when the state tensor uses as_strided page padding.
184 copy_size = state_inner_size * state_elem_size
185
154 - # Mirror postprocess_mamba's trailing
155 - # if src_block_idx == dest_block_idx: num_accepted_tokens_cpu[i] = 1
156 - # This runs whether or not the copy below is skipped (it's per-request, so
157 - # only state_idx == 0 writes).
158 - if src_block_idx == dest_block_idx and state_idx == 0:
159 - tl.store(num_accepted_tokens_out_ptr + req_idx, 1)
160 -
161 - # Mirror collect_mamba_copy_meta's early return: src==dst with no token
162 - # bias means source and destination ranges coincide, so the copy is a
163 - # no-op.
164 - if src_block_idx == dest_block_idx and accept_token_bias == 0:
165 - return
166 -
186 offsets = tl.arange(0, COPY_BLOCK_SIZE)
187 for i in range(0, copy_size, COPY_BLOCK_SIZE):
188 mask = (i + offsets) < copy_size
@@ -271,6 +290,9 @@ class MambaSpecDecodeGPUContext:
290 state_inner_sizes: torch.Tensor # int64: elements in inner dimensions
291 state_conv_widths: torch.Tensor # int32: conv width (0 for temporal states)
292 state_group_indices: torch.Tensor # int32: maps state_idx to group index
293 + # DS conv row metadata. Zero keeps the single-region copy path.
294 + state_dim_row_count: torch.Tensor # int32: per-block dim row count
295 + state_dim_row_stride: torch.Tensor # int64: bytes between rows
296
297 # Configuration
298 block_size: int
@@ -338,6 +360,12 @@ class MambaSpecDecodeGPUContext:
360 state_group_indices=torch.zeros(
361 total_states, dtype=torch.int32, device=device
362 ),
363 + state_dim_row_count=torch.zeros(
364 + total_states, dtype=torch.int32, device=device
365 + ),
366 + state_dim_row_stride=torch.zeros(
367 + total_states, dtype=torch.int64, device=device
368 + ),
369 block_size=mamba_spec.block_size,
370 num_layers=num_layers,
371 num_state_types=num_state_types,
@@ -430,17 +458,23 @@ class MambaSpecDecodeGPUContext:
458 or copy_func is get_temporal_copy_spec
459 ), f"unexpected copy func: {copy_func}"
460 if copy_func is get_conv_copy_spec:
433 - # Conv state: conv_width is state.size(1)
434 - # inner_size is stride(1) = elements per conv position,
435 - # used to compute byte offset for state[block, offset:]
436 - conv_w = state.size(1) if state.dim() > 1 else 0
437 - self.state_conv_widths[idx] = conv_w
438 - if state.dim() > 2:
439 - # stride(1) = product of dims[2:] for contiguous tensor
440 - self.state_inner_sizes[idx] = state.stride(1)
441 - else:
442 - # 2D tensor: [num_blocks, conv_dim], no inner dims
461 + if state.dim() != 3:
462 + raise ValueError(
463 + "Expected 3D conv state cache, got "
464 + f"shape {tuple(state.shape)}"
465 + )
466 + if is_conv_state_dim_first():
467 + # DS layout: state_len is the slide axis.
468 + self.state_conv_widths[idx] = state.size(2)
469 self.state_inner_sizes[idx] = 1
470 + self.state_dim_row_count[idx] = state.size(1)
471 + self.state_dim_row_stride[idx] = (
472 + state.stride(1) * state.element_size()
473 + )
474 + else:
475 + # SD layout: dim is contiguous.
476 + self.state_conv_widths[idx] = state.size(1)
477 + self.state_inner_sizes[idx] = state.stride(1)
478 else:
479 # Temporal state: inner_size = natural elements per
480 # block (prod of inner dims). The kernel uses this
@@ -521,10 +555,13 @@ class MambaSpecDecodeGPUContext:
555 self.state_inner_sizes,
556 self.state_conv_widths,
557 self.state_group_indices,
558 + self.state_dim_row_count,
559 + self.state_dim_row_stride,
560 self.num_accepted_tokens_out,
561 num_reqs,
562 block_size=self.block_size,
563 COPY_BLOCK_SIZE=1024,
564 + CONV_STATE_DIM_FIRST=is_conv_state_dim_first(),
565 )
566
567