main
py 2,772 lines 100 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from dataclasses import dataclass
4 from typing import Any
5 from unittest.mock import MagicMock, patch
6
7 import numpy as np
8 import pytest
9 import torch
10
11 from vllm.model_executor.layers.mamba.mamba_utils import (
12 MambaStateCopyFunc,
13 MambaStateCopyFuncsByType,
14 get_conv_copy_spec,
15 get_temporal_copy_spec,
16 )
17 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
18 from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
19 from vllm.v1.kv_cache_interface import (
20 KVCacheConfig,
21 KVCacheGroupSpec,
22 MambaSpec,
23 UniformTypeKVCacheSpecs,
24 )
25 from vllm.v1.worker.gpu.model_states.mamba_hybrid import MambaHybridModelState
26 from vllm.v1.worker.mamba_utils import (
27 MambaCopyBuffers,
28 MambaSpecDecodeGPUContext,
29 _reinterpret_u64_as_i64,
30 batch_memcpy,
31 collect_mamba_copy_meta,
32 do_mamba_copy_block,
33 get_mamba_groups,
34 preprocess_mamba,
35 stage_postprocess_inputs_to_gpu,
36 )
37
38 # Conv + temporal copy specs, in the order the tests' MambaSpec shapes expect.
39 _DEFAULT_COPY_FUNCS: tuple[MambaStateCopyFunc, ...] = (
40 get_conv_copy_spec,
41 get_temporal_copy_spec,
42 )
43 _COPY_FUNCS: MambaStateCopyFuncsByType = {
44 MambaAttentionBackendEnum.MAMBA2: _DEFAULT_COPY_FUNCS,
45 MambaAttentionBackendEnum.GDN_ATTN: _DEFAULT_COPY_FUNCS,
46 MambaAttentionBackendEnum.SHORT_CONV: (get_conv_copy_spec,),
47 }
48
49
50 def postprocess_mamba(
51 scheduler_output: "SchedulerOutput",
52 kv_cache_config: "KVCacheConfig",
53 input_batch: Any,
54 requests: dict[str, Any],
55 forward_context: dict[str, Any],
56 mamba_state_copy_funcs: MambaStateCopyFuncsByType,
57 copy_bufs: "MambaCopyBuffers",
58 ):
59 """CPU reference for the align-mode postprocess.
60
61 Used as a golden against the GPU fused kernel (``postprocess_mamba_align_gpu``).
62 Mirrors what the production code did before the fused kernel replaced it;
63 kept here because production no longer has a CPU implementation.
64 """
65 assert input_batch.mamba_state_idx_cpu is not None
66 num_scheduled_tokens_dict = scheduler_output.num_scheduled_tokens
67 scheduled_spec_decode_tokens_dict = scheduler_output.scheduled_spec_decode_tokens
68 num_accepted_tokens_cpu = input_batch.num_accepted_tokens_cpu
69 mamba_state_idx_cpu = input_batch.mamba_state_idx_cpu
70 mamba_group_ids = copy_bufs.mamba_group_ids
71 mamba_spec = copy_bufs.mamba_spec
72 copy_bufs.offset = 0
73 for i, req_id in enumerate(input_batch.req_ids):
74 req_state = requests[req_id]
75 num_computed_tokens = req_state.num_computed_tokens
76 num_draft_tokens = len(scheduled_spec_decode_tokens_dict.get(req_id, []))
77 num_scheduled_tokens = num_scheduled_tokens_dict[req_id]
78 num_accepted_tokens = num_accepted_tokens_cpu[i]
79 num_tokens_running_state = (
80 num_computed_tokens + num_scheduled_tokens - num_draft_tokens
81 )
82 new_num_computed_tokens = num_tokens_running_state + num_accepted_tokens - 1
83 aligned_new_computed_tokens = (
84 new_num_computed_tokens // mamba_spec.block_size * mamba_spec.block_size
85 )
86 if aligned_new_computed_tokens >= num_tokens_running_state:
87 accept_token_bias = aligned_new_computed_tokens - num_tokens_running_state
88 src_block_idx = mamba_state_idx_cpu[i]
89 dest_block_idx = aligned_new_computed_tokens // mamba_spec.block_size - 1
90 collect_mamba_copy_meta(
91 copy_bufs,
92 kv_cache_config,
93 mamba_state_copy_funcs,
94 mamba_group_ids,
95 src_block_idx,
96 dest_block_idx,
97 accept_token_bias,
98 req_state,
99 forward_context,
100 )
101 if src_block_idx == dest_block_idx:
102 num_accepted_tokens_cpu[i] = 1
103 do_mamba_copy_block(copy_bufs)
104
105
106 def _make_scheduler_output(
107 finished_req_ids: set[str],
108 preempted_req_ids: set[str] | None,
109 resumed_req_ids: set[str],
110 ) -> SchedulerOutput:
111 cached = CachedRequestData.make_empty()
112 cached.resumed_req_ids = resumed_req_ids
113 return SchedulerOutput(
114 scheduled_new_reqs=[],
115 scheduled_cached_reqs=cached,
116 num_scheduled_tokens={},
117 total_num_scheduled_tokens=0,
118 scheduled_spec_decode_tokens={},
119 scheduled_encoder_inputs={},
120 num_common_prefix_blocks=[],
121 finished_req_ids=finished_req_ids,
122 free_encoder_mm_hashes=[],
123 preempted_req_ids=preempted_req_ids,
124 )
125
126
127 def test_resumed_req_ids_cleared_from_mamba_state_idx():
128 """When a request is force-preempted (e.g. reset_prefix_cache),
129 it appears in resumed_req_ids but NOT in preempted_req_ids.
130 preprocess_mamba must still clear its mamba_state_idx entry,
131 otherwise stale indices can point beyond the new block allocation.
132 """
133 spec = MagicMock(block_size=64, num_speculative_blocks=0)
134 cache_config = MagicMock(enable_prefix_caching=True)
135 input_batch = MagicMock(req_ids=[])
136 copy_bufs = MagicMock(mamba_group_ids=[0], mamba_spec=spec)
137
138 mamba_state_idx: dict[str, int] = {
139 "finished": 1,
140 "preempted": 2,
141 "resumed": 3, # only in resumed_req_ids, NOT in preempted
142 "keep": 99,
143 }
144 sched = _make_scheduler_output(
145 finished_req_ids={"finished"},
146 preempted_req_ids={"preempted"},
147 resumed_req_ids={"resumed"},
148 )
149
150 with patch(
151 "vllm.v1.worker.mamba_utils.get_mamba_groups",
152 return_value=([0], spec),
153 ):
154 preprocess_mamba(
155 sched,
156 MagicMock(), # kv_cache_config
157 cache_config,
158 mamba_state_idx,
159 input_batch,
160 {}, # requests
161 {}, # forward_context
162 {}, # mamba_state_copy_funcs
163 copy_bufs,
164 )
165
166 assert mamba_state_idx == {"keep": 99}
167
168
169 # -----------------------------------------------------------------------------
170 # Golden tests for postprocess_mamba_fused_kernel
171 # -----------------------------------------------------------------------------
172
173
174 @dataclass
175 class _TestConfig:
176 """Common test configuration for fused kernel tests."""
177
178 block_size: int = 16
179 num_blocks: int = 32
180 num_layers: int = 2
181 num_reqs: int = 4
182 max_num_reqs: int = 8
183 # Conv state shape: [num_blocks, conv_width, inner_dim]
184 conv_width: int = 4
185 conv_inner_dim: int = 64
186 # Temporal state shape: [num_blocks, state_dim]
187 temporal_state_dim: int = 128
188 dtype: torch.dtype = torch.float16
189
190
191 class _MockCpuGpuBuffer:
192 """Mock CpuGpuBuffer for testing without pinned memory."""
193
194 def __init__(self, size: int, dtype: torch.dtype, device: torch.device):
195 self.cpu = torch.zeros(size, dtype=dtype, device="cpu")
196 self.gpu = torch.zeros(size, dtype=dtype, device=device)
197 self.np = self.cpu.numpy()
198
199 def copy_to_gpu(self, n: int | None = None) -> torch.Tensor:
200 if n is None:
201 return self.gpu.copy_(self.cpu, non_blocking=True)
202 return self.gpu[:n].copy_(self.cpu[:n], non_blocking=True)
203
204
205 class _FakeDataPtrTensor:
206 """Tensor wrapper that exposes a controlled data_ptr for metadata tests."""
207
208 def __init__(self, tensor: torch.Tensor, data_ptr: int):
209 self._tensor = tensor
210 self._data_ptr = data_ptr
211 self.shape = tensor.shape
212
213 def data_ptr(self) -> int:
214 return self._data_ptr
215
216 def dim(self) -> int:
217 return self._tensor.dim()
218
219 def stride(self, *args):
220 return self._tensor.stride(*args)
221
222 def numel(self) -> int:
223 return self._tensor.numel()
224
225 def element_size(self) -> int:
226 return self._tensor.element_size()
227
228 def size(self, *args):
229 return self._tensor.size(*args)
230
231 def __getitem__(self, item):
232 return self._tensor[item]
233
234
235 def test_reinterpret_u64_as_i64_preserves_pointer_bits():
236 ptrs = [
237 0,
238 1,
239 (1 << 63) - 1,
240 1 << 63,
241 (1 << 63) + 1234,
242 (1 << 64) - 1,
243 ]
244 ptr_tensor = torch.zeros(len(ptrs), dtype=torch.int64)
245
246 for idx, ptr in enumerate(ptrs):
247 ptr_tensor[idx] = _reinterpret_u64_as_i64(ptr)
248
249 assert ptr_tensor.numpy().view(np.uint64).tolist() == ptrs
250
251
252 def test_gpu_context_reinterprets_high_data_ptrs_for_int64_metadata():
253 cfg = _TestConfig(num_layers=1)
254 device = torch.device("cpu")
255 kv_cache_config = _make_kv_cache_config(cfg, ["layer_0"])
256 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
257 conv_ptr = 1 << 63
258 temporal_ptr = (1 << 64) - 8
259 block_table_ptr = (1 << 63) + 42
260
261 conv_state = _FakeDataPtrTensor(
262 torch.empty(
263 cfg.num_blocks,
264 cfg.conv_width,
265 cfg.conv_inner_dim,
266 dtype=cfg.dtype,
267 ),
268 conv_ptr,
269 )
270 temporal_state = _FakeDataPtrTensor(
271 torch.empty(cfg.num_blocks, cfg.temporal_state_dim, dtype=cfg.dtype),
272 temporal_ptr,
273 )
274 block_table = _FakeDataPtrTensor(
275 torch.empty(1, 4, dtype=torch.int32),
276 block_table_ptr,
277 )
278 forward_context = {"layer_0": _make_mock_attention(conv_state, temporal_state)}
279
280 gpu_ctx.initialize_from_forward_context(
281 kv_cache_config, forward_context, _COPY_FUNCS, [block_table]
282 )
283
284 assert gpu_ctx.state_base_addrs.tolist() == [
285 _reinterpret_u64_as_i64(conv_ptr),
286 _reinterpret_u64_as_i64(temporal_ptr),
287 ]
288 assert gpu_ctx.block_table_ptrs.tolist() == [
289 _reinterpret_u64_as_i64(block_table_ptr)
290 ]
291
292
293 def _make_postprocess_scheduler_output(
294 req_ids: list[str],
295 num_scheduled_tokens: dict[str, int],
296 scheduled_spec_decode_tokens: dict[str, list] | None = None,
297 ) -> SchedulerOutput:
298 """Create a minimal SchedulerOutput for postprocess testing."""
299 cached = CachedRequestData.make_empty()
300 return SchedulerOutput(
301 scheduled_new_reqs=[],
302 scheduled_cached_reqs=cached,
303 num_scheduled_tokens=num_scheduled_tokens,
304 total_num_scheduled_tokens=sum(num_scheduled_tokens.values()),
305 scheduled_spec_decode_tokens=scheduled_spec_decode_tokens or {},
306 scheduled_encoder_inputs={},
307 num_common_prefix_blocks=[],
308 finished_req_ids=set(),
309 free_encoder_mm_hashes=[],
310 preempted_req_ids=set(),
311 )
312
313
314 def _make_mock_attention(
315 conv_state: torch.Tensor, temporal_state: torch.Tensor
316 ) -> MagicMock:
317 """Create a mock attention object with kv_cache."""
318 attention = MagicMock()
319 attention.kv_cache = [conv_state, temporal_state]
320 return attention
321
322
323 def _make_dual_states(
324 cfg: "_TestConfig",
325 layer_names: list[str],
326 device: torch.device,
327 *,
328 num_blocks: int | None = None,
329 ) -> tuple[
330 list[torch.Tensor],
331 list[torch.Tensor],
332 list[torch.Tensor],
333 list[torch.Tensor],
334 dict[str, MagicMock],
335 dict[str, MagicMock],
336 ]:
337 """Allocate conv+temporal state tensors for the Python path, clone them for
338 the GPU path, and build matching ``forward_context`` dicts for both.
339
340 Returns ``(conv_py, temporal_py, conv_gpu, temporal_gpu, fwd_py, fwd_gpu)``
341 where the four state lists are parallel to ``layer_names``.
342 """
343 n_blocks = num_blocks if num_blocks is not None else cfg.num_blocks
344 conv_py = [
345 torch.randn(
346 n_blocks,
347 cfg.conv_width,
348 cfg.conv_inner_dim,
349 dtype=cfg.dtype,
350 device=device,
351 )
352 for _ in layer_names
353 ]
354 temporal_py = [
355 torch.randn(n_blocks, cfg.temporal_state_dim, dtype=cfg.dtype, device=device)
356 for _ in layer_names
357 ]
358 conv_gpu = [s.clone() for s in conv_py]
359 temporal_gpu = [s.clone() for s in temporal_py]
360 fwd_py = {
361 name: _make_mock_attention(c, t)
362 for name, c, t in zip(layer_names, conv_py, temporal_py)
363 }
364 fwd_gpu = {
365 name: _make_mock_attention(c, t)
366 for name, c, t in zip(layer_names, conv_gpu, temporal_gpu)
367 }
368 return conv_py, temporal_py, conv_gpu, temporal_gpu, fwd_py, fwd_gpu
369
370
371 def _make_dual_layer_state(
372 cfg: "_TestConfig",
373 device: torch.device,
374 *,
375 num_blocks: int | None = None,
376 layer_name: str = "layer_0",
377 ) -> tuple[
378 torch.Tensor,
379 torch.Tensor,
380 torch.Tensor,
381 torch.Tensor,
382 dict[str, MagicMock],
383 dict[str, MagicMock],
384 ]:
385 """Single-layer convenience form of ``_make_dual_states``."""
386 conv_py, temporal_py, conv_gpu, temporal_gpu, fwd_py, fwd_gpu = _make_dual_states(
387 cfg, [layer_name], device, num_blocks=num_blocks
388 )
389 return conv_py[0], temporal_py[0], conv_gpu[0], temporal_gpu[0], fwd_py, fwd_gpu
390
391
392 def _make_kv_cache_config(cfg: _TestConfig, layer_names: list[str]) -> KVCacheConfig:
393 """Create a KVCacheConfig with mamba groups."""
394 mamba_spec = MambaSpec(
395 block_size=cfg.block_size,
396 shapes=(
397 (cfg.conv_width, cfg.conv_inner_dim),
398 (cfg.temporal_state_dim,),
399 ),
400 dtypes=(cfg.dtype, cfg.dtype),
401 mamba_cache_mode="align",
402 )
403 group = KVCacheGroupSpec(
404 layer_names=layer_names,
405 kv_cache_spec=mamba_spec,
406 )
407 return KVCacheConfig(
408 num_blocks=cfg.num_blocks,
409 kv_cache_tensors=[],
410 kv_cache_groups=[group],
411 )
412
413
414 def _make_input_batch(
415 req_ids: list[str],
416 num_accepted_tokens: list[int],
417 mamba_state_idx: list[int],
418 ) -> MagicMock:
419 """Create a mock GPUInputBatch."""
420 batch = MagicMock()
421 batch.req_ids = req_ids
422 batch.req_id_to_index = {rid: i for i, rid in enumerate(req_ids)}
423 # Use numpy arrays so modifications persist
424 batch.num_accepted_tokens_cpu = np.array(num_accepted_tokens, dtype=np.int32)
425 batch.mamba_state_idx_cpu = np.array(mamba_state_idx, dtype=np.int32)
426 return batch
427
428
429 def _make_requests(
430 req_ids: list[str],
431 num_computed_tokens: list[int],
432 block_ids_per_req: list[list[int]],
433 ) -> dict[str, MagicMock]:
434 """Create mock CachedRequestState objects."""
435 requests = {}
436 for i, req_id in enumerate(req_ids):
437 req = MagicMock()
438 req.num_computed_tokens = num_computed_tokens[i]
439 req.block_ids = {0: block_ids_per_req[i]} # group_id=0
440 requests[req_id] = req
441 return requests
442
443
444 def _make_copy_bufs(
445 cfg: _TestConfig,
446 kv_cache_config: KVCacheConfig,
447 device: torch.device,
448 copy_funcs: MambaStateCopyFuncsByType = _COPY_FUNCS,
449 ) -> MambaCopyBuffers:
450 """Create MambaCopyBuffers for the Python path."""
451
452 def make_buffer(n, dtype):
453 return _MockCpuGpuBuffer(n, dtype, device)
454
455 return MambaCopyBuffers.create(
456 max_num_reqs=cfg.max_num_reqs,
457 kv_cache_config=kv_cache_config,
458 copy_funcs=copy_funcs,
459 make_buffer=make_buffer,
460 )
461
462
463 def _make_gpu_ctx(
464 cfg: _TestConfig,
465 kv_cache_config: KVCacheConfig,
466 device: torch.device,
467 copy_funcs: MambaStateCopyFuncsByType = _COPY_FUNCS,
468 ) -> MambaSpecDecodeGPUContext:
469 """Create MambaSpecDecodeGPUContext for the GPU path."""
470
471 def make_buffer(n, dtype):
472 return _MockCpuGpuBuffer(n, dtype, device)
473
474 return MambaSpecDecodeGPUContext.create(
475 max_num_reqs=cfg.max_num_reqs,
476 kv_cache_config=kv_cache_config,
477 copy_funcs=copy_funcs,
478 device=device,
479 make_buffer=make_buffer,
480 )
481
482
483 def test_mamba_groups_support_different_state_specs():
484 gdn_spec = MambaSpec(
485 block_size=16,
486 shapes=((4, 4), (2, 4, 4)),
487 dtypes=(torch.float16, torch.float32),
488 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
489 mamba_cache_mode="align",
490 num_speculative_blocks=3,
491 )
492 short_conv_spec = MambaSpec(
493 block_size=16,
494 shapes=((12, 8),),
495 dtypes=(torch.float16,),
496 mamba_type=MambaAttentionBackendEnum.SHORT_CONV,
497 mamba_cache_mode="align",
498 num_speculative_blocks=3,
499 )
500 kv_cache_config = KVCacheConfig(
501 num_blocks=8,
502 kv_cache_tensors=[],
503 kv_cache_groups=[
504 KVCacheGroupSpec(["gdn.0", "gdn.1"], gdn_spec),
505 KVCacheGroupSpec(["ple.0"], short_conv_spec),
506 ],
507 )
508
509 mamba_groups = get_mamba_groups(kv_cache_config)
510 assert mamba_groups == {gdn_spec: [0], short_conv_spec: [1]}
511
512 model_state = object.__new__(MambaHybridModelState)
513 model_state._mamba_group_ids = []
514 model_state._mamba_spec = None
515 group_ids, representative_spec = model_state._get_mamba_group_info(kv_cache_config)
516 assert group_ids == [0, 1]
517 assert representative_spec is gdn_spec
518
519 cfg = _TestConfig()
520 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, torch.device("cpu"))
521 assert copy_bufs.src_ptrs.cpu.numel() == cfg.max_num_reqs * 5
522 assert copy_bufs.dst_ptrs.cpu.numel() == cfg.max_num_reqs * 5
523 assert copy_bufs.sizes.cpu.numel() == cfg.max_num_reqs * 5
524
525 ctx = _make_gpu_ctx(cfg, kv_cache_config, torch.device("cpu"))
526 assert ctx.num_states == 5
527
528 forward_context = {}
529 gdn_spec_layer_names = ["gdn.0", "gdn.1"]
530 for layer_name in gdn_spec_layer_names:
531 attention = MagicMock()
532 attention.kv_cache = (
533 torch.empty(8, 4, 4, dtype=torch.float16),
534 torch.empty(8, 2, 4, 4, dtype=torch.float32),
535 )
536 forward_context[layer_name] = attention
537 ple_attention = MagicMock()
538 ple_attention.kv_cache = (torch.empty(8, 12, 8, dtype=torch.float16),)
539 forward_context["ple.0"] = ple_attention
540 assert gdn_spec_layer_names == kv_cache_config.kv_cache_groups[0].layer_names
541
542 block_tables = [
543 torch.zeros(4, 8, dtype=torch.int32),
544 torch.zeros(4, 8, dtype=torch.int32),
545 ]
546 ctx.initialize_from_forward_context(
547 kv_cache_config,
548 forward_context,
549 _COPY_FUNCS,
550 block_tables,
551 )
552 assert ctx.is_initialized
553 assert ctx.state_group_indices.tolist() == [0, 0, 0, 0, 1]
554 assert ctx.state_conv_widths.tolist() == [4, 0, 4, 0, 12]
555
556
557 def test_mamba_groups_support_mixed_specs_in_uniform_group():
558 gdn_spec = MambaSpec(
559 block_size=16,
560 shapes=((4, 4), (2, 4, 4)),
561 dtypes=(torch.float16, torch.float32),
562 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
563 mamba_cache_mode="align",
564 num_speculative_blocks=3,
565 )
566 short_conv_spec = MambaSpec(
567 block_size=16,
568 shapes=((12, 8),),
569 dtypes=(torch.float16,),
570 mamba_type=MambaAttentionBackendEnum.SHORT_CONV,
571 mamba_cache_mode="align",
572 num_speculative_blocks=3,
573 )
574 layer_specs = {
575 "gdn.0": gdn_spec,
576 "gdn.1": gdn_spec,
577 "ple.0": short_conv_spec,
578 }
579 kv_cache_config = KVCacheConfig(
580 num_blocks=8,
581 kv_cache_tensors=[],
582 kv_cache_groups=[
583 KVCacheGroupSpec(
584 list(layer_specs),
585 UniformTypeKVCacheSpecs(
586 block_size=16,
587 kv_cache_specs=layer_specs,
588 ),
589 )
590 ],
591 )
592
593 assert kv_cache_config.has_mamba_layers
594 assert get_mamba_groups(kv_cache_config) == {
595 gdn_spec: [0],
596 short_conv_spec: [0],
597 }
598
599 # The group is listed once per spec, so its id must not be repeated.
600 model_state = object.__new__(MambaHybridModelState)
601 model_state._mamba_group_ids = []
602 model_state._mamba_spec = None
603 group_ids, representative_spec = model_state._get_mamba_group_info(kv_cache_config)
604 assert group_ids == [0]
605 assert representative_spec is gdn_spec
606
607 cfg = _TestConfig()
608 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, torch.device("cpu"))
609 assert copy_bufs.mamba_group_ids == [0]
610 assert copy_bufs.src_ptrs.cpu.numel() == cfg.max_num_reqs * 5
611
612 ctx = _make_gpu_ctx(cfg, kv_cache_config, torch.device("cpu"))
613 assert ctx.mamba_group_ids == [0]
614 assert ctx.num_states == 5
615
616 forward_context = {
617 "gdn.0": MagicMock(
618 kv_cache=(
619 torch.empty(8, 4, 4, dtype=torch.float16),
620 torch.empty(8, 2, 4, 4, dtype=torch.float32),
621 )
622 ),
623 "gdn.1": MagicMock(
624 kv_cache=(
625 torch.empty(8, 4, 4, dtype=torch.float16),
626 torch.empty(8, 2, 4, 4, dtype=torch.float32),
627 )
628 ),
629 "ple.0": MagicMock(kv_cache=(torch.empty(8, 12, 8, dtype=torch.float16),)),
630 }
631 ctx.initialize_from_forward_context(
632 kv_cache_config,
633 forward_context,
634 _COPY_FUNCS,
635 [torch.zeros(4, 8, dtype=torch.int32)],
636 )
637 assert ctx.state_group_indices.tolist() == [0, 0, 0, 0, 0]
638 assert ctx.state_conv_widths.tolist() == [4, 0, 4, 0, 12]
639
640
641 # -----------------------------------------------------------------------------
642 # stage_postprocess_inputs_to_gpu: single-pass staging into pinned views
643 # -----------------------------------------------------------------------------
644
645
646 def _make_staging_ctx(max_num_reqs: int, device: torch.device) -> MagicMock:
647 """Build a MambaSpecDecodeGPUContext stand-in exposing only the four
648 per-request staging buffers touched by stage_postprocess_inputs_to_gpu."""
649 ctx = MagicMock()
650 ctx.mamba_state_idx_buf = _MockCpuGpuBuffer(max_num_reqs, torch.int32, device)
651 ctx.num_scheduled_tokens_buf = _MockCpuGpuBuffer(max_num_reqs, torch.int32, device)
652 ctx.num_computed_tokens_buf = _MockCpuGpuBuffer(max_num_reqs, torch.int32, device)
653 ctx.num_draft_tokens_buf = _MockCpuGpuBuffer(max_num_reqs, torch.int32, device)
654 return ctx
655
656
657 def test_stage_postprocess_inputs_to_gpu_fills_pinned_views():
658 """stage_postprocess_inputs_to_gpu writes each request's state_idx and
659 scheduled/computed/draft counts into slot `i` for `req_ids[i]`, leaves
660 slots past `num_reqs` untouched, and mirrors the values to the GPU buffers."""
661 device = torch.device("cpu")
662 max_num_reqs = 8
663 ctx = _make_staging_ctx(max_num_reqs, device)
664
665 # Any negative int32 works as a sentinel: all staged values (state_idx,
666 # scheduled/computed/draft token counts) are non-negative, so a negative
667 # entry surviving in slots [num_reqs:] proves the loop didn't overrun.
668 sentinel = np.int32(-1)
669 bufs = (
670 ctx.mamba_state_idx_buf,
671 ctx.num_scheduled_tokens_buf,
672 ctx.num_computed_tokens_buf,
673 ctx.num_draft_tokens_buf,
674 )
675 for buf in bufs:
676 buf.np[:] = sentinel
677
678 req_ids = ["req_a", "req_b", "req_c"]
679 num_reqs = len(req_ids)
680 scheduler_output = _make_postprocess_scheduler_output(
681 req_ids=req_ids,
682 num_scheduled_tokens={"req_a": 5, "req_b": 12, "req_c": 3},
683 # req_b intentionally absent -> defaults to 0 drafts.
684 scheduled_spec_decode_tokens={
685 "req_a": [1, 2],
686 "req_c": [1, 2, 3, 4],
687 },
688 )
689 requests = _make_requests(
690 req_ids=req_ids,
691 num_computed_tokens=[10, 20, 30],
692 block_ids_per_req=[[0], [0], [0]],
693 )
694 mamba_state_idx = {"req_a": 100, "req_b": 200, "req_c": 300}
695 # A trailing entry past num_reqs must not be read.
696 req_ids_padded = req_ids + ["not_scheduled"]
697
698 stage_postprocess_inputs_to_gpu(
699 ctx,
700 scheduler_output,
701 req_ids_padded,
702 num_reqs,
703 requests,
704 mamba_state_idx,
705 )
706
707 np.testing.assert_array_equal(
708 ctx.mamba_state_idx_buf.np[:num_reqs], [100, 200, 300]
709 )
710 np.testing.assert_array_equal(
711 ctx.num_scheduled_tokens_buf.np[:num_reqs], [5, 12, 3]
712 )
713 np.testing.assert_array_equal(
714 ctx.num_computed_tokens_buf.np[:num_reqs], [10, 20, 30]
715 )
716 np.testing.assert_array_equal(ctx.num_draft_tokens_buf.np[:num_reqs], [2, 0, 4])
717 for buf in bufs:
718 assert (buf.np[num_reqs:] == sentinel).all()
719
720 assert torch.equal(
721 ctx.mamba_state_idx_buf.gpu[:num_reqs],
722 torch.tensor([100, 200, 300], dtype=torch.int32),
723 )
724 assert torch.equal(
725 ctx.num_draft_tokens_buf.gpu[:num_reqs],
726 torch.tensor([2, 0, 4], dtype=torch.int32),
727 )
728
729
730 def test_stage_postprocess_inputs_to_gpu_asserts_on_missing_state_idx():
731 """If preprocess_mamba didn't populate mamba_state_idx for a req in the
732 batch, staging must fail loudly rather than silently writing a stale index."""
733 device = torch.device("cpu")
734 ctx = _make_staging_ctx(max_num_reqs=4, device=device)
735 scheduler_output = _make_postprocess_scheduler_output(
736 req_ids=["req_a"],
737 num_scheduled_tokens={"req_a": 1},
738 )
739 requests = _make_requests(["req_a"], [0], [[0]])
740
741 # mamba_state_idx has an entry for a *different* request but not for
742 # req_a (the one being staged). This is the realistic failure mode:
743 # preprocess_mamba ran, populated some reqs, but missed this one.
744 # The safety assert in stage_postprocess_inputs_to_gpu must fire on
745 # req_a's missing entry rather than silently writing None.
746 mamba_state_idx: dict[str, int] = {"other_req": 42}
747 with pytest.raises(AssertionError, match="mamba_state_idx missing entry"):
748 stage_postprocess_inputs_to_gpu(
749 ctx,
750 scheduler_output,
751 ["req_a"],
752 1,
753 requests,
754 mamba_state_idx,
755 )
756
757
758 def test_gpu_context_ignores_auxiliary_cache_tensors() -> None:
759 device = torch.device("cpu")
760 config = _TestConfig(num_layers=1)
761 layer_names = ["layer_0"]
762 kv_cache_config = _make_kv_cache_config(config, layer_names)
763 conv_state = torch.empty(
764 config.num_blocks,
765 config.conv_width,
766 config.conv_inner_dim,
767 dtype=config.dtype,
768 )
769 temporal_state = torch.empty(
770 config.num_blocks, config.temporal_state_dim, dtype=config.dtype
771 )
772 attention = MagicMock()
773 attention.kv_cache = [
774 conv_state,
775 temporal_state,
776 *(torch.empty(config.num_blocks, 1) for _ in range(4)),
777 ]
778 context = _make_gpu_ctx(config, kv_cache_config, device)
779
780 context.initialize_from_forward_context(
781 kv_cache_config,
782 {"layer_0": attention},
783 _COPY_FUNCS,
784 [torch.zeros(1, 1, dtype=torch.int32)],
785 )
786
787 assert context.is_initialized
788 assert context.state_base_addrs.tolist() == [
789 conv_state.data_ptr(),
790 temporal_state.data_ptr(),
791 ]
792
793
794 def _run_gpu_postprocess(
795 gpu_ctx: MambaSpecDecodeGPUContext,
796 *,
797 kv_cache_config: KVCacheConfig,
798 forward_context: dict[str, Any],
799 copy_funcs: MambaStateCopyFuncsByType,
800 block_table: torch.Tensor,
801 req_ids: list[str],
802 num_accepted_tokens: list[int],
803 mamba_state_idx: list[int],
804 num_scheduled_tokens: dict[str, int],
805 num_computed_tokens: list[int],
806 num_draft_tokens: dict[str, int],
807 device: torch.device,
808 ) -> None:
809 """Initialize the GPU context against `block_table`, run the fused
810 postprocess kernel for `req_ids`, and synchronize."""
811
812 def t(values):
813 return torch.tensor(values, dtype=torch.int32, device=device)
814
815 gpu_ctx.initialize_from_forward_context(
816 kv_cache_config, forward_context, copy_funcs, [block_table]
817 )
818 gpu_ctx.run_fused_postprocess(
819 num_reqs=len(req_ids),
820 num_accepted_tokens_gpu=t(num_accepted_tokens),
821 mamba_state_idx_gpu=t(mamba_state_idx),
822 num_scheduled_tokens_gpu=t([num_scheduled_tokens[r] for r in req_ids]),
823 num_computed_tokens_gpu=t(num_computed_tokens),
824 num_draft_tokens_gpu=t([num_draft_tokens.get(r, 0) for r in req_ids]),
825 )
826 torch.accelerator.synchronize()
827
828
829 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
830 class TestPostprocessMambaFusedKernel:
831 """Tests for postprocess_mamba_fused_kernel comparing GPU vs CPU paths."""
832
833 @pytest.fixture
834 def device(self):
835 return torch.device("cuda:0")
836
837 @pytest.fixture
838 def test_config(self):
839 return _TestConfig()
840
841 def test_batch_memcpy_left_overlap_has_memmove_semantics(self, device):
842 batch = 128
843 row_bytes = 32 * 1024
844 shift = 16
845 copy_size = row_bytes - shift
846
847 pattern = (torch.arange(row_bytes, dtype=torch.int32, device=device) % 251).to(
848 torch.uint8
849 )
850 state = pattern.expand(batch, -1).clone()
851 snapshot = state.clone()
852
853 row_stride_bytes = state.stride(0) * state.element_size()
854 row_offsets = (
855 torch.arange(batch, dtype=torch.int64, device=device) * row_stride_bytes
856 )
857 dst_ptrs = (row_offsets + state.data_ptr()).to(torch.uint64)
858 src_ptrs = (row_offsets + state.data_ptr() + shift).to(torch.uint64)
859 sizes = torch.full((batch,), copy_size, dtype=torch.int32, device=device)
860
861 expected = snapshot.clone()
862 expected[:, :copy_size].copy_(snapshot[:, shift:])
863 for _ in range(10):
864 state.copy_(snapshot)
865 batch_memcpy(src_ptrs, dst_ptrs, sizes)
866 torch.accelerator.synchronize()
867 torch.testing.assert_close(state, expected, rtol=0, atol=0)
868
869 def test_matches_python_postprocess_mamba(self, device, test_config):
870 """Golden test: GPU kernel produces identical results to Python impl.
871
872 This test:
873 1. Sets up identical initial state for both paths
874 2. Runs Python postprocess_mamba (modifies states via batch_memcpy)
875 3. Runs GPU fused kernel (modifies states directly)
876 4. Compares resulting state tensors and num_accepted_tokens
877 """
878 cfg = test_config
879 torch.manual_seed(42)
880
881 # Test scenario: 4 requests with different copy conditions
882 # Copy needed when: aligned_new_computed >= num_tokens_running_state
883 # where: num_tokens_running_state = num_computed + num_scheduled - num_draft
884 # new_num_computed = num_tokens_running_state + num_accepted - 1
885 # aligned_new_computed = (new_num_computed // block_size) * block_size
886 req_ids = ["req_0", "req_1", "req_2", "req_3"]
887
888 # Configure requests so some need copies, some don't
889 # block_size = 16
890 # req_0: running=60+5-2=63, new=63+3-1=65, aligned=64 >= 63 -> COPY
891 # req_1: running=30+3-0=33, new=33+2-1=34, aligned=32 < 33 -> NO COPY
892 # req_2: running=45+8-3=50, new=50+4-1=53, aligned=48 < 50 -> NO COPY
893 # req_3: running=10+6-0=16, new=16+2-1=17, aligned=16 >= 16 -> COPY
894 num_computed_tokens = [60, 30, 45, 10]
895 num_scheduled_tokens = {"req_0": 5, "req_1": 3, "req_2": 8, "req_3": 6}
896 num_draft_tokens = {"req_0": 2, "req_1": 0, "req_2": 3, "req_3": 0}
897 num_accepted_tokens = [3, 2, 4, 2]
898 mamba_state_idx = [3, 1, 2, 0] # source block indices
899
900 # Block IDs for each request (simulate block table)
901 block_ids_per_req = [
902 list(range(8)), # req_0: blocks 0-7
903 list(range(8, 16)), # req_1: blocks 8-15
904 list(range(16, 24)), # req_2: blocks 16-23
905 list(range(24, 32)), # req_3: blocks 24-31
906 ]
907
908 layer_names = [f"layer_{i}" for i in range(cfg.num_layers)]
909 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
910
911 (
912 conv_states_py,
913 temporal_states_py,
914 conv_states_gpu,
915 temporal_states_gpu,
916 forward_context_py,
917 forward_context_gpu,
918 ) = _make_dual_states(cfg, layer_names, device)
919
920 # --- Run Python path ---
921 scheduler_output = _make_postprocess_scheduler_output(
922 req_ids,
923 num_scheduled_tokens,
924 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
925 )
926 input_batch_py = _make_input_batch(
927 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
928 )
929 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
930 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
931
932 postprocess_mamba(
933 scheduler_output,
934 kv_cache_config,
935 input_batch_py,
936 requests,
937 forward_context_py,
938 _COPY_FUNCS,
939 copy_bufs,
940 )
941 torch.accelerator.synchronize()
942
943 # --- Run GPU path ---
944 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
945
946 num_reqs = len(req_ids)
947 max_blocks = max(len(b) for b in block_ids_per_req)
948 block_table_gpu = torch.zeros(
949 num_reqs, max_blocks, dtype=torch.int32, device=device
950 )
951 for i, block_ids in enumerate(block_ids_per_req):
952 block_table_gpu[i, : len(block_ids)] = torch.tensor(
953 block_ids, dtype=torch.int32
954 )
955 _run_gpu_postprocess(
956 gpu_ctx,
957 kv_cache_config=kv_cache_config,
958 forward_context=forward_context_gpu,
959 copy_funcs=_COPY_FUNCS,
960 block_table=block_table_gpu,
961 req_ids=req_ids,
962 num_accepted_tokens=num_accepted_tokens,
963 mamba_state_idx=mamba_state_idx,
964 num_scheduled_tokens=num_scheduled_tokens,
965 num_computed_tokens=num_computed_tokens,
966 num_draft_tokens=num_draft_tokens,
967 device=device,
968 )
969
970 # --- Compare results ---
971 # 1. Compare state tensors
972 for i in range(cfg.num_layers):
973 torch.testing.assert_close(
974 conv_states_gpu[i],
975 conv_states_py[i],
976 msg=f"Conv state mismatch at layer {i}",
977 )
978 torch.testing.assert_close(
979 temporal_states_gpu[i],
980 temporal_states_py[i],
981 msg=f"Temporal state mismatch at layer {i}",
982 )
983
984 # 2. Compare num_accepted_tokens updates
985 expected_accepted = torch.tensor(
986 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
987 dtype=torch.int32,
988 device=device,
989 )
990 torch.testing.assert_close(
991 gpu_ctx.num_accepted_tokens_out[:num_reqs],
992 expected_accepted,
993 msg="num_accepted_tokens mismatch",
994 )
995
996 def test_no_copy_when_not_needed(self, device, test_config):
997 """Kernel should not modify state when no copy is needed."""
998 cfg = test_config
999 torch.manual_seed(123)
1000
1001 # Single request where no copy is needed:
1002 # running = 30 + 3 = 33, new = 33 + 1 - 1 = 33, aligned = 32 < 33
1003 req_ids = ["req_0"]
1004 num_computed_tokens = [30]
1005 num_scheduled_tokens = {"req_0": 3}
1006 num_draft_tokens: dict[str, int] = {}
1007 num_accepted_tokens = [1]
1008 mamba_state_idx = [1]
1009 block_ids_per_req = [list(range(8))]
1010
1011 layer_names = ["layer_0"]
1012 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1013
1014 # Create state tensor
1015 conv_state = torch.randn(
1016 cfg.num_blocks,
1017 cfg.conv_width,
1018 cfg.conv_inner_dim,
1019 dtype=cfg.dtype,
1020 device=device,
1021 )
1022 temporal_state = torch.randn(
1023 cfg.num_blocks, cfg.temporal_state_dim, dtype=cfg.dtype, device=device
1024 )
1025
1026 # Clone to verify no modification
1027 conv_state_orig = conv_state.clone()
1028 temporal_state_orig = temporal_state.clone()
1029
1030 forward_context = {"layer_0": _make_mock_attention(conv_state, temporal_state)}
1031
1032 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1033
1034 num_reqs = len(req_ids)
1035 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
1036 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
1037
1038 _run_gpu_postprocess(
1039 gpu_ctx,
1040 kv_cache_config=kv_cache_config,
1041 forward_context=forward_context,
1042 copy_funcs=_COPY_FUNCS,
1043 block_table=block_table_gpu,
1044 req_ids=req_ids,
1045 num_accepted_tokens=num_accepted_tokens,
1046 mamba_state_idx=mamba_state_idx,
1047 num_scheduled_tokens=num_scheduled_tokens,
1048 num_computed_tokens=num_computed_tokens,
1049 num_draft_tokens=num_draft_tokens,
1050 device=device,
1051 )
1052
1053 # State should be unchanged
1054 torch.testing.assert_close(conv_state, conv_state_orig)
1055 torch.testing.assert_close(temporal_state, temporal_state_orig)
1056
1057 @pytest.mark.parametrize("num_reqs", [1, 2, 8, 16])
1058 def test_various_batch_sizes(self, device, test_config, num_reqs):
1059 """Verify kernel works correctly with different batch sizes."""
1060 cfg = _TestConfig(max_num_reqs=max(16, num_reqs))
1061 torch.manual_seed(456)
1062
1063 req_ids = [f"req_{i}" for i in range(num_reqs)]
1064 # All requests will trigger a copy
1065 num_computed_tokens = [60] * num_reqs
1066 num_scheduled_tokens = {r: 5 for r in req_ids}
1067 num_draft_tokens = {r: 0 for r in req_ids}
1068 num_accepted_tokens = [3] * num_reqs
1069 mamba_state_idx = [3] * num_reqs
1070 # Each request gets unique blocks
1071 block_ids_per_req = [list(range(i * 8, (i + 1) * 8)) for i in range(num_reqs)]
1072
1073 # Ensure we have enough blocks
1074 total_blocks = num_reqs * 8
1075 cfg = _TestConfig(num_blocks=total_blocks, max_num_reqs=max(16, num_reqs))
1076
1077 layer_names = ["layer_0"]
1078 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1079
1080 (
1081 conv_state_py,
1082 temporal_state_py,
1083 conv_state_gpu,
1084 temporal_state_gpu,
1085 forward_context_py,
1086 forward_context_gpu,
1087 ) = _make_dual_layer_state(cfg, device)
1088
1089 # Run Python path
1090 scheduler_output = _make_postprocess_scheduler_output(
1091 req_ids,
1092 num_scheduled_tokens,
1093 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1094 )
1095 input_batch_py = _make_input_batch(
1096 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1097 )
1098 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1099 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1100
1101 postprocess_mamba(
1102 scheduler_output,
1103 kv_cache_config,
1104 input_batch_py,
1105 requests,
1106 forward_context_py,
1107 _COPY_FUNCS,
1108 copy_bufs,
1109 )
1110 torch.accelerator.synchronize()
1111
1112 # Run GPU path
1113 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1114
1115 max_blocks_per_req = 8
1116 block_table_gpu = torch.zeros(
1117 num_reqs, max_blocks_per_req, dtype=torch.int32, device=device
1118 )
1119 for i, block_ids in enumerate(block_ids_per_req):
1120 block_table_gpu[i, : len(block_ids)] = torch.tensor(
1121 block_ids, dtype=torch.int32
1122 )
1123
1124 _run_gpu_postprocess(
1125 gpu_ctx,
1126 kv_cache_config=kv_cache_config,
1127 forward_context=forward_context_gpu,
1128 copy_funcs=_COPY_FUNCS,
1129 block_table=block_table_gpu,
1130 req_ids=req_ids,
1131 num_accepted_tokens=num_accepted_tokens,
1132 mamba_state_idx=mamba_state_idx,
1133 num_scheduled_tokens=num_scheduled_tokens,
1134 num_computed_tokens=num_computed_tokens,
1135 num_draft_tokens=num_draft_tokens,
1136 device=device,
1137 )
1138
1139 # Compare results
1140 torch.testing.assert_close(
1141 conv_state_gpu, conv_state_py, msg="Conv state mismatch"
1142 )
1143 torch.testing.assert_close(
1144 temporal_state_gpu, temporal_state_py, msg="Temporal state mismatch"
1145 )
1146
1147 def test_block_table_with_realistic_stride(self, device, test_config):
1148 """Test kernel with realistic block table strides.
1149
1150 In real usage, the block table is pre-allocated with shape
1151 [max_num_reqs, max_num_blocks_per_req] and then sliced to
1152 [:num_reqs]. This means stride(0) = max_num_blocks_per_req,
1153 which is typically much larger than the actual blocks used.
1154
1155 This test verifies the kernel handles non-tight strides correctly,
1156 catching bugs where stride is incorrectly treated as bytes vs elements.
1157 """
1158 cfg = test_config
1159 torch.manual_seed(789)
1160
1161 # Use multiple requests to exercise stride-based indexing
1162 num_reqs = 4
1163 req_ids = [f"req_{i}" for i in range(num_reqs)]
1164
1165 # All requests trigger copies (same setup as test_various_batch_sizes)
1166 num_computed_tokens = [60] * num_reqs
1167 num_scheduled_tokens = {r: 5 for r in req_ids}
1168 num_draft_tokens: dict[str, int] = {}
1169 num_accepted_tokens = [3] * num_reqs
1170 mamba_state_idx = [3] * num_reqs
1171
1172 # Each request uses only 8 blocks, but we allocate much more
1173 blocks_used_per_req = 8
1174 block_ids_per_req = [
1175 list(range(i * blocks_used_per_req, (i + 1) * blocks_used_per_req))
1176 for i in range(num_reqs)
1177 ]
1178
1179 total_blocks = num_reqs * blocks_used_per_req
1180 cfg = _TestConfig(num_blocks=total_blocks, max_num_reqs=max(16, num_reqs))
1181
1182 layer_names = ["layer_0"]
1183 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1184
1185 (
1186 conv_state_py,
1187 temporal_state_py,
1188 conv_state_gpu,
1189 temporal_state_gpu,
1190 forward_context_py,
1191 forward_context_gpu,
1192 ) = _make_dual_layer_state(cfg, device)
1193
1194 # Run Python path
1195 scheduler_output = _make_postprocess_scheduler_output(
1196 req_ids,
1197 num_scheduled_tokens,
1198 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1199 )
1200 input_batch_py = _make_input_batch(
1201 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1202 )
1203 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1204 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1205
1206 postprocess_mamba(
1207 scheduler_output,
1208 kv_cache_config,
1209 input_batch_py,
1210 requests,
1211 forward_context_py,
1212 _COPY_FUNCS,
1213 copy_bufs,
1214 )
1215 torch.accelerator.synchronize()
1216
1217 # Run GPU path with REALISTIC block table stride
1218 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1219
1220 # KEY DIFFERENCE: Create a large block table like real code does
1221 # Real system has max_num_blocks_per_req >> blocks actually used
1222 max_num_reqs_full = 16
1223 max_blocks_per_req_full = 512 # Much larger than blocks_used_per_req=8
1224
1225 # Allocate full-size table (simulates pre-allocated CpuGpuBuffer)
1226 block_table_full = torch.zeros(
1227 max_num_reqs_full, max_blocks_per_req_full, dtype=torch.int32, device=device
1228 )
1229
1230 # Fill in actual block IDs (only first few columns used)
1231 for i, block_ids in enumerate(block_ids_per_req):
1232 block_table_full[i, : len(block_ids)] = torch.tensor(
1233 block_ids, dtype=torch.int32
1234 )
1235
1236 # Slice like real code: block_table.gpu[:num_reqs]
1237 # This preserves stride(0) = 512, not 8!
1238 block_table_gpu = block_table_full[:num_reqs]
1239
1240 # Verify stride is large (the key property we're testing)
1241 assert block_table_gpu.stride(0) == max_blocks_per_req_full, (
1242 f"Expected stride {max_blocks_per_req_full}, "
1243 f"got {block_table_gpu.stride(0)}"
1244 )
1245
1246 _run_gpu_postprocess(
1247 gpu_ctx,
1248 kv_cache_config=kv_cache_config,
1249 forward_context=forward_context_gpu,
1250 copy_funcs=_COPY_FUNCS,
1251 block_table=block_table_gpu,
1252 req_ids=req_ids,
1253 num_accepted_tokens=num_accepted_tokens,
1254 mamba_state_idx=mamba_state_idx,
1255 num_scheduled_tokens=num_scheduled_tokens,
1256 num_computed_tokens=num_computed_tokens,
1257 num_draft_tokens=num_draft_tokens,
1258 device=device,
1259 )
1260
1261 # Compare results - this will fail if stride handling is incorrect
1262 torch.testing.assert_close(
1263 conv_state_gpu,
1264 conv_state_py,
1265 msg="Conv state mismatch - possible stride bug in kernel",
1266 )
1267 torch.testing.assert_close(
1268 temporal_state_gpu,
1269 temporal_state_py,
1270 msg="Temporal state mismatch - possible stride bug in kernel",
1271 )
1272
1273 def test_src_addr_equals_dst_addr_skips_copy_and_sets_accepted_to_1(
1274 self, device, test_config
1275 ):
1276 """Test the ``src_addr == dst_addr`` early-return path in
1277 postprocess_mamba_fused_kernel matches Python behavior.
1278
1279 When src_addr == dst_addr (source and destination memory addresses are
1280 identical), both implementations should:
1281 1. Skip the copy (state unchanged)
1282 2. Set num_accepted_tokens to 1
1283
1284 This condition occurs when:
1285 - src_block_idx == dest_block_idx (same logical block)
1286 - accept_token_bias == 0 (no offset within the block)
1287
1288 Python reference (collect_mamba_copy_meta):
1289 if src_block_idx == dest_block_idx and accept_token_bias == 0:
1290 return # No copy added
1291
1292 Python reference (postprocess_mamba):
1293 if src_block_idx == dest_block_idx:
1294 num_accepted_tokens_cpu[i] = 1
1295
1296 Test setup (block_size=16):
1297 - num_tokens_running_state = 30 + 2 - 0 = 32
1298 - new_num_computed = 32 + 1 - 1 = 32
1299 - aligned_new_computed = 32
1300 - accept_token_bias = 32 - 32 = 0
1301 - dest_block_idx = 32 // 16 - 1 = 1
1302 - src_block_idx = 1 (set explicitly)
1303 """
1304 cfg = test_config
1305 torch.manual_seed(1001)
1306
1307 req_ids = ["req_0"]
1308 num_computed_tokens = [30]
1309 num_scheduled_tokens = {"req_0": 2}
1310 num_draft_tokens: dict[str, int] = {}
1311 num_accepted_tokens = [5] # Initial value, should become 1
1312 mamba_state_idx = [1] # src_block_idx = 1 = dest_block_idx
1313 block_ids_per_req = [list(range(8))]
1314
1315 layer_names = ["layer_0"]
1316 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1317
1318 (
1319 conv_state_py,
1320 temporal_state_py,
1321 conv_state_gpu,
1322 temporal_state_gpu,
1323 forward_context_py,
1324 forward_context_gpu,
1325 ) = _make_dual_layer_state(cfg, device)
1326
1327 # Also clone to verify no modification
1328 conv_state_orig = conv_state_py.clone()
1329 temporal_state_orig = temporal_state_py.clone()
1330
1331 # --- Run Python path ---
1332 scheduler_output = _make_postprocess_scheduler_output(
1333 req_ids,
1334 num_scheduled_tokens,
1335 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1336 )
1337 input_batch_py = _make_input_batch(
1338 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1339 )
1340 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1341 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1342
1343 postprocess_mamba(
1344 scheduler_output,
1345 kv_cache_config,
1346 input_batch_py,
1347 requests,
1348 forward_context_py,
1349 _COPY_FUNCS,
1350 copy_bufs,
1351 )
1352 torch.accelerator.synchronize()
1353
1354 # --- Run GPU path ---
1355 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1356
1357 num_reqs = len(req_ids)
1358 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
1359 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
1360
1361 _run_gpu_postprocess(
1362 gpu_ctx,
1363 kv_cache_config=kv_cache_config,
1364 forward_context=forward_context_gpu,
1365 copy_funcs=_COPY_FUNCS,
1366 block_table=block_table_gpu,
1367 req_ids=req_ids,
1368 num_accepted_tokens=num_accepted_tokens,
1369 mamba_state_idx=mamba_state_idx,
1370 num_scheduled_tokens=num_scheduled_tokens,
1371 num_computed_tokens=num_computed_tokens,
1372 num_draft_tokens=num_draft_tokens,
1373 device=device,
1374 )
1375
1376 # --- Verify Python behavior (ground truth) ---
1377 # State should be unchanged (no copy when src_addr == dst_addr)
1378 torch.testing.assert_close(
1379 conv_state_py,
1380 conv_state_orig,
1381 msg="Python: Conv state should be unchanged when src==dst",
1382 )
1383 torch.testing.assert_close(
1384 temporal_state_py,
1385 temporal_state_orig,
1386 msg="Python: Temporal state should be unchanged when src==dst",
1387 )
1388 # num_accepted_tokens should be 1
1389 assert input_batch_py.num_accepted_tokens_cpu[0] == 1, (
1390 f"Python: num_accepted_tokens should be 1, "
1391 f"got {input_batch_py.num_accepted_tokens_cpu[0]}"
1392 )
1393
1394 # --- Verify GPU matches Python ---
1395 torch.testing.assert_close(
1396 conv_state_gpu,
1397 conv_state_py,
1398 msg="GPU conv state should match Python",
1399 )
1400 torch.testing.assert_close(
1401 temporal_state_gpu,
1402 temporal_state_py,
1403 msg="GPU temporal state should match Python",
1404 )
1405 expected_accepted = torch.tensor(
1406 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
1407 dtype=torch.int32,
1408 device=device,
1409 )
1410 torch.testing.assert_close(
1411 gpu_ctx.num_accepted_tokens_out[:num_reqs],
1412 expected_accepted,
1413 msg="GPU num_accepted_tokens should match Python",
1414 )
1415
1416 def test_same_block_idx_with_offset_copies_then_sets_accepted_to_1(
1417 self, device, test_config
1418 ):
1419 """Test the ``src_block_idx == dest_block_idx`` post-copy update in
1420 postprocess_mamba_fused_kernel matches Python behavior.
1421
1422 When src_block_idx == dest_block_idx but accept_token_bias > 0, both
1423 implementations should:
1424 1. Perform the copy (src_addr != dst_addr due to offset)
1425 2. Set num_accepted_tokens to 1 AFTER the copy
1426
1427 Python reference (postprocess_mamba):
1428 if src_block_idx == dest_block_idx:
1429 num_accepted_tokens_cpu[i] = 1
1430
1431 For conv states: copies state[block, offset:] to
1432 state[block, :] (shifted window)
1433 For temporal states: copies state[block_ids[src_idx + offset]] to
1434 state[block_ids[dest_idx]]
1435
1436 Test setup (block_size=16):
1437 - num_tokens_running_state = 30 + 1 - 0 = 31
1438 - new_num_computed = 31 + 2 - 1 = 32
1439 - aligned_new_computed = 32
1440 - accept_token_bias = 32 - 31 = 1 (> 0, so copy happens)
1441 - dest_block_idx = 32 // 16 - 1 = 1
1442 - src_block_idx = 1 (set explicitly, == dest_block_idx)
1443 """
1444 cfg = test_config
1445 torch.manual_seed(1002)
1446
1447 req_ids = ["req_0"]
1448 num_computed_tokens = [30]
1449 num_scheduled_tokens = {"req_0": 1}
1450 num_draft_tokens: dict[str, int] = {}
1451 num_accepted_tokens = [2] # Results in accept_token_bias = 1
1452 mamba_state_idx = [1] # src_block_idx = 1 = dest_block_idx
1453 block_ids_per_req = [list(range(8))]
1454
1455 layer_names = ["layer_0"]
1456 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1457
1458 (
1459 conv_state_py,
1460 temporal_state_py,
1461 conv_state_gpu,
1462 temporal_state_gpu,
1463 forward_context_py,
1464 forward_context_gpu,
1465 ) = _make_dual_layer_state(cfg, device)
1466
1467 # Clone to verify modification
1468 conv_state_orig = conv_state_py.clone()
1469 temporal_state_orig = temporal_state_py.clone()
1470
1471 # --- Run Python path ---
1472 scheduler_output = _make_postprocess_scheduler_output(
1473 req_ids,
1474 num_scheduled_tokens,
1475 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1476 )
1477 input_batch_py = _make_input_batch(
1478 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1479 )
1480 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1481 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1482
1483 postprocess_mamba(
1484 scheduler_output,
1485 kv_cache_config,
1486 input_batch_py,
1487 requests,
1488 forward_context_py,
1489 _COPY_FUNCS,
1490 copy_bufs,
1491 )
1492 torch.accelerator.synchronize()
1493
1494 # --- Run GPU path ---
1495 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1496
1497 num_reqs = len(req_ids)
1498 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
1499 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
1500
1501 _run_gpu_postprocess(
1502 gpu_ctx,
1503 kv_cache_config=kv_cache_config,
1504 forward_context=forward_context_gpu,
1505 copy_funcs=_COPY_FUNCS,
1506 block_table=block_table_gpu,
1507 req_ids=req_ids,
1508 num_accepted_tokens=num_accepted_tokens,
1509 mamba_state_idx=mamba_state_idx,
1510 num_scheduled_tokens=num_scheduled_tokens,
1511 num_computed_tokens=num_computed_tokens,
1512 num_draft_tokens=num_draft_tokens,
1513 device=device,
1514 )
1515
1516 # --- Verify Python behavior (ground truth) ---
1517 dest_block_id = block_ids_per_req[0][1] # dest_block_idx = 1
1518
1519 # This is an overlapping in-place left shift, so comparing only the
1520 # Python and fused paths can hide the same memcpy race in both. Build
1521 # the memmove result from the untouched snapshot and check each path
1522 # independently.
1523 expected_conv_state = conv_state_orig.clone()
1524 expected_conv_state[dest_block_id, :-1].copy_(
1525 conv_state_orig[dest_block_id, 1:]
1526 )
1527 torch.testing.assert_close(
1528 conv_state_py,
1529 expected_conv_state,
1530 rtol=0,
1531 atol=0,
1532 msg="Python: overlapping conv copy should have memmove semantics",
1533 )
1534 torch.testing.assert_close(
1535 conv_state_gpu,
1536 expected_conv_state,
1537 rtol=0,
1538 atol=0,
1539 msg="GPU: overlapping conv copy should have memmove semantics",
1540 )
1541
1542 # Temporal state should be modified (copy from different block)
1543 src_block_id_temporal = block_ids_per_req[0][2] # actual_src_block_idx = 2
1544 dest_block_id_temporal = block_ids_per_req[0][1] # dest_block_idx = 1
1545 torch.testing.assert_close(
1546 temporal_state_py[dest_block_id_temporal],
1547 temporal_state_orig[src_block_id_temporal],
1548 msg="Python: Temporal state copy should have happened",
1549 )
1550
1551 # num_accepted_tokens should be 1
1552 assert input_batch_py.num_accepted_tokens_cpu[0] == 1, (
1553 f"Python: num_accepted_tokens should be 1, "
1554 f"got {input_batch_py.num_accepted_tokens_cpu[0]}"
1555 )
1556
1557 # --- Verify GPU matches Python ---
1558 torch.testing.assert_close(
1559 conv_state_gpu,
1560 conv_state_py,
1561 msg="GPU conv state should match Python",
1562 )
1563 torch.testing.assert_close(
1564 temporal_state_gpu,
1565 temporal_state_py,
1566 msg="GPU temporal state should match Python",
1567 )
1568 expected_accepted = torch.tensor(
1569 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
1570 dtype=torch.int32,
1571 device=device,
1572 )
1573 torch.testing.assert_close(
1574 gpu_ctx.num_accepted_tokens_out[:num_reqs],
1575 expected_accepted,
1576 msg="GPU num_accepted_tokens should match Python",
1577 )
1578
1579 def test_different_block_idx_copies_without_setting_accepted_to_1(
1580 self, device, test_config
1581 ):
1582 """Test that neither special-case path triggers when
1583 src_block_idx != dest_block_idx, and GPU matches Python behavior.
1584
1585 When copying between different blocks:
1586 1. src_addr != dst_addr (different blocks = different addresses)
1587 2. src_block_idx != dest_block_idx
1588
1589 Therefore:
1590 - The ``src_addr == dst_addr`` early-return does NOT trigger
1591 - The ``src_block_idx == dest_block_idx`` post-copy update does NOT trigger
1592 - Copy happens normally
1593 - num_accepted_tokens remains UNCHANGED
1594
1595 Test setup (block_size=16):
1596 - num_tokens_running_state = 60 + 3 - 0 = 63
1597 - new_num_computed = 63 + 3 - 1 = 65
1598 - aligned_new_computed = 64
1599 - accept_token_bias = 64 - 63 = 1
1600 - dest_block_idx = 64 // 16 - 1 = 3
1601 - src_block_idx = 2 (set explicitly, != dest_block_idx)
1602 """
1603 cfg = test_config
1604 torch.manual_seed(1003)
1605
1606 req_ids = ["req_0"]
1607 num_computed_tokens = [60]
1608 num_scheduled_tokens = {"req_0": 3}
1609 num_draft_tokens: dict[str, int] = {}
1610 num_accepted_tokens = [3] # Should remain 3, NOT set to 1
1611 mamba_state_idx = [2] # src_block_idx = 2, dest_block_idx will be 3
1612 block_ids_per_req = [list(range(8))]
1613
1614 layer_names = ["layer_0"]
1615 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1616
1617 (
1618 conv_state_py,
1619 temporal_state_py,
1620 conv_state_gpu,
1621 temporal_state_gpu,
1622 forward_context_py,
1623 forward_context_gpu,
1624 ) = _make_dual_layer_state(cfg, device)
1625
1626 # Clone to verify modification
1627 conv_state_orig = conv_state_py.clone()
1628
1629 # --- Run Python path ---
1630 scheduler_output = _make_postprocess_scheduler_output(
1631 req_ids,
1632 num_scheduled_tokens,
1633 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1634 )
1635 input_batch_py = _make_input_batch(
1636 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1637 )
1638 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1639 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1640
1641 postprocess_mamba(
1642 scheduler_output,
1643 kv_cache_config,
1644 input_batch_py,
1645 requests,
1646 forward_context_py,
1647 _COPY_FUNCS,
1648 copy_bufs,
1649 )
1650 torch.accelerator.synchronize()
1651
1652 # --- Run GPU path ---
1653 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1654
1655 num_reqs = len(req_ids)
1656 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
1657 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
1658
1659 _run_gpu_postprocess(
1660 gpu_ctx,
1661 kv_cache_config=kv_cache_config,
1662 forward_context=forward_context_gpu,
1663 copy_funcs=_COPY_FUNCS,
1664 block_table=block_table_gpu,
1665 req_ids=req_ids,
1666 num_accepted_tokens=num_accepted_tokens,
1667 mamba_state_idx=mamba_state_idx,
1668 num_scheduled_tokens=num_scheduled_tokens,
1669 num_computed_tokens=num_computed_tokens,
1670 num_draft_tokens=num_draft_tokens,
1671 device=device,
1672 )
1673
1674 # --- Verify Python behavior (ground truth) ---
1675 dest_block_id = block_ids_per_req[0][3] # dest_block_idx = 3
1676
1677 # Copy DID happen (dest block should be modified)
1678 conv_changed = not torch.allclose(
1679 conv_state_py[dest_block_id], conv_state_orig[dest_block_id]
1680 )
1681 assert conv_changed, "Python: Conv state copy should have happened"
1682
1683 # num_accepted_tokens should NOT be changed to 1
1684 assert input_batch_py.num_accepted_tokens_cpu[0] == num_accepted_tokens[0], (
1685 f"Python: num_accepted_tokens should remain {num_accepted_tokens[0]}, "
1686 f"got {input_batch_py.num_accepted_tokens_cpu[0]}"
1687 )
1688
1689 # --- Verify GPU matches Python ---
1690 torch.testing.assert_close(
1691 conv_state_gpu,
1692 conv_state_py,
1693 msg="GPU conv state should match Python",
1694 )
1695 torch.testing.assert_close(
1696 temporal_state_gpu,
1697 temporal_state_py,
1698 msg="GPU temporal state should match Python",
1699 )
1700 expected_accepted = torch.tensor(
1701 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
1702 dtype=torch.int32,
1703 device=device,
1704 )
1705 torch.testing.assert_close(
1706 gpu_ctx.num_accepted_tokens_out[:num_reqs],
1707 expected_accepted,
1708 msg="GPU num_accepted_tokens should match Python",
1709 )
1710
1711 def test_prefix_caching_shared_block_does_not_set_accepted_to_1(
1712 self, device, test_config
1713 ):
1714 """Regression test: with prefix caching, different logical block indices
1715 can map to the same physical block. The kernel must NOT set
1716 num_accepted_tokens to 1 in that case.
1717
1718 When src_block_idx != dest_block_idx but block_table maps both to the
1719 same physical block ID, src_addr == dst_addr. The copy is correctly
1720 skipped (self-copy is a no-op), but num_accepted_tokens must be
1721 preserved — only logical-index equality justifies setting it to 1.
1722
1723 Test setup (block_size=16):
1724 - num_tokens_running_state = 30 + 2 - 0 = 32
1725 - new_num_computed = 32 + 3 - 1 = 34
1726 - aligned_new_computed = 32
1727 - accept_token_bias = 32 - 32 = 0
1728 - dest_block_idx = 32 // 16 - 1 = 1
1729 - src_block_idx = 0 (set explicitly, != dest_block_idx)
1730 - block_ids = [5, 5, ...] — prefix caching: both logical indices
1731 map to the same physical block 5
1732 """
1733 cfg = test_config
1734 torch.manual_seed(2001)
1735
1736 req_ids = ["req_0"]
1737 num_computed_tokens = [30]
1738 num_scheduled_tokens = {"req_0": 2}
1739 num_draft_tokens: dict[str, int] = {}
1740 num_accepted_tokens = [3] # Must stay 3, NOT become 1
1741 mamba_state_idx = [0] # src_block_idx = 0, dest_block_idx will be 1
1742
1743 # Prefix caching: logical blocks 0 and 1 share physical block 5
1744 block_ids_per_req = [[5, 5, 2, 3, 4, 6, 7, 8]]
1745
1746 layer_names = ["layer_0"]
1747 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
1748
1749 (
1750 conv_state_py,
1751 temporal_state_py,
1752 conv_state_gpu,
1753 temporal_state_gpu,
1754 forward_context_py,
1755 forward_context_gpu,
1756 ) = _make_dual_layer_state(cfg, device)
1757
1758 conv_state_orig = conv_state_py.clone()
1759 temporal_state_orig = temporal_state_py.clone()
1760
1761 # --- Run Python path ---
1762 scheduler_output = _make_postprocess_scheduler_output(
1763 req_ids,
1764 num_scheduled_tokens,
1765 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1766 )
1767 input_batch_py = _make_input_batch(
1768 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1769 )
1770 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1771 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
1772
1773 postprocess_mamba(
1774 scheduler_output,
1775 kv_cache_config,
1776 input_batch_py,
1777 requests,
1778 forward_context_py,
1779 _COPY_FUNCS,
1780 copy_bufs,
1781 )
1782 torch.accelerator.synchronize()
1783
1784 # --- Run GPU path ---
1785 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
1786 num_reqs = len(req_ids)
1787 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
1788 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
1789
1790 _run_gpu_postprocess(
1791 gpu_ctx,
1792 kv_cache_config=kv_cache_config,
1793 forward_context=forward_context_gpu,
1794 copy_funcs=_COPY_FUNCS,
1795 block_table=block_table_gpu,
1796 req_ids=req_ids,
1797 num_accepted_tokens=num_accepted_tokens,
1798 mamba_state_idx=mamba_state_idx,
1799 num_scheduled_tokens=num_scheduled_tokens,
1800 num_computed_tokens=num_computed_tokens,
1801 num_draft_tokens=num_draft_tokens,
1802 device=device,
1803 )
1804
1805 # --- Verify Python behavior (ground truth) ---
1806 # Copy is self-to-self (same physical block), state unchanged
1807 torch.testing.assert_close(
1808 conv_state_py,
1809 conv_state_orig,
1810 msg="Python: Conv state should be unchanged (self-copy)",
1811 )
1812 torch.testing.assert_close(
1813 temporal_state_py,
1814 temporal_state_orig,
1815 msg="Python: Temporal state should be unchanged (self-copy)",
1816 )
1817 # num_accepted_tokens must NOT be set to 1 (src_block_idx != dest_block_idx)
1818 assert input_batch_py.num_accepted_tokens_cpu[0] == num_accepted_tokens[0], (
1819 f"Python: num_accepted_tokens should remain {num_accepted_tokens[0]}, "
1820 f"got {input_batch_py.num_accepted_tokens_cpu[0]}"
1821 )
1822
1823 # --- Verify GPU matches Python ---
1824 torch.testing.assert_close(
1825 conv_state_gpu,
1826 conv_state_py,
1827 msg="GPU conv state should match Python",
1828 )
1829 torch.testing.assert_close(
1830 temporal_state_gpu,
1831 temporal_state_py,
1832 msg="GPU temporal state should match Python",
1833 )
1834 expected_accepted = torch.tensor(
1835 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
1836 dtype=torch.int32,
1837 device=device,
1838 )
1839 torch.testing.assert_close(
1840 gpu_ctx.num_accepted_tokens_out[:num_reqs],
1841 expected_accepted,
1842 msg="GPU num_accepted_tokens should match Python (must NOT be 1)",
1843 )
1844
1845 def test_prefix_caching_nonsequential_block_ids_boundary(self, device, test_config):
1846 """Regression test: non-sequential physical block IDs under prefix caching
1847 with the needs_copy boundary at exact equality.
1848
1849 Under PC, the block allocator assigns physical block IDs in arbitrary
1850 order (e.g., [17, 3, 42, 9] instead of [0, 1, 2, 3]). The needs_copy
1851 condition is purely token-count based and must evaluate identically
1852 regardless of the physical block IDs assigned. This test verifies that
1853 the kernel's address arithmetic (block_table lookup, stride computation)
1854 produces correct copies when physical IDs are non-sequential.
1855
1856 Two requests exercise different boundary behaviors:
1857 - req_0: aligned_new_computed == num_tokens_running_state (exact boundary)
1858 This is the tightest edge: one fewer accepted token and no copy needed.
1859 - req_1: aligned_new_computed == num_tokens_running_state (exact boundary)
1860 Different block layout, src!=dest, real copy happens.
1861
1862 Both use non-sequential block IDs typical of PC reuse patterns.
1863
1864 Test setup (block_size=16):
1865 req_0:
1866 - num_tokens_running_state = 48 + 0 - 0 = 48
1867 - new_num_computed = 48 + 1 - 1 = 48
1868 - aligned_new_computed = 48
1869 - needs_copy = (48 >= 48) = True (exact boundary!)
1870 - accept_token_bias = 48 - 48 = 0
1871 - dest_block_idx = 48 // 16 - 1 = 2
1872 - src_block_idx = 2 (same as dest -> num_accepted = 1)
1873
1874 req_1:
1875 - num_tokens_running_state = 31 + 1 - 0 = 32
1876 - new_num_computed = 32 + 3 - 1 = 34
1877 - aligned_new_computed = 32
1878 - needs_copy = (32 >= 32) = True (exact boundary!)
1879 - accept_token_bias = 32 - 32 = 0
1880 - dest_block_idx = 32 // 16 - 1 = 1
1881 - src_block_idx = 0 (diff from dest -> num_accepted unchanged)
1882 """
1883 cfg = test_config
1884 torch.manual_seed(4001)
1885
1886 req_ids = ["req_0", "req_1"]
1887 num_computed_tokens = [48, 31]
1888 num_scheduled_tokens = {"req_0": 0, "req_1": 1}
1889 num_draft_tokens: dict[str, int] = {}
1890 num_accepted_tokens = [1, 3]
1891 mamba_state_idx = [2, 0]
1892
1893 # Non-sequential block IDs typical of prefix caching allocation
1894 block_ids_per_req = [
1895 [17, 3, 42, 9, 25, 11, 30, 2], # req_0: scattered physical blocks
1896 [41, 7, 22, 15, 38, 19, 4, 28], # req_1: different scattered blocks
1897 ]
1898
1899 layer_names = [f"layer_{i}" for i in range(cfg.num_layers)]
1900 # Need enough physical blocks for the scattered IDs
1901 num_blocks = 50
1902 local_cfg = _TestConfig(num_blocks=num_blocks, max_num_reqs=cfg.max_num_reqs)
1903 kv_cache_config = _make_kv_cache_config(local_cfg, layer_names)
1904
1905 (
1906 conv_states_py,
1907 temporal_states_py,
1908 conv_states_gpu,
1909 temporal_states_gpu,
1910 forward_context_py,
1911 forward_context_gpu,
1912 ) = _make_dual_states(local_cfg, layer_names, device)
1913
1914 # --- Run Python path ---
1915 scheduler_output = _make_postprocess_scheduler_output(
1916 req_ids,
1917 num_scheduled_tokens,
1918 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
1919 )
1920 input_batch_py = _make_input_batch(
1921 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
1922 )
1923 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
1924 copy_bufs = _make_copy_bufs(local_cfg, kv_cache_config, device)
1925
1926 postprocess_mamba(
1927 scheduler_output,
1928 kv_cache_config,
1929 input_batch_py,
1930 requests,
1931 forward_context_py,
1932 _COPY_FUNCS,
1933 copy_bufs,
1934 )
1935 torch.accelerator.synchronize()
1936
1937 # --- Run GPU path ---
1938 gpu_ctx = _make_gpu_ctx(local_cfg, kv_cache_config, device)
1939 num_reqs = len(req_ids)
1940 max_blocks = max(len(b) for b in block_ids_per_req)
1941 block_table_gpu = torch.zeros(
1942 num_reqs, max_blocks, dtype=torch.int32, device=device
1943 )
1944 for i, block_ids in enumerate(block_ids_per_req):
1945 block_table_gpu[i, : len(block_ids)] = torch.tensor(
1946 block_ids, dtype=torch.int32
1947 )
1948
1949 _run_gpu_postprocess(
1950 gpu_ctx,
1951 kv_cache_config=kv_cache_config,
1952 forward_context=forward_context_gpu,
1953 copy_funcs=_COPY_FUNCS,
1954 block_table=block_table_gpu,
1955 req_ids=req_ids,
1956 num_accepted_tokens=num_accepted_tokens,
1957 mamba_state_idx=mamba_state_idx,
1958 num_scheduled_tokens=num_scheduled_tokens,
1959 num_computed_tokens=num_computed_tokens,
1960 num_draft_tokens=num_draft_tokens,
1961 device=device,
1962 )
1963
1964 # --- Compare results ---
1965 for i in range(cfg.num_layers):
1966 torch.testing.assert_close(
1967 conv_states_gpu[i],
1968 conv_states_py[i],
1969 msg=f"Conv state mismatch at layer {i} with non-sequential block IDs",
1970 )
1971 torch.testing.assert_close(
1972 temporal_states_gpu[i],
1973 temporal_states_py[i],
1974 msg=(
1975 f"Temporal state mismatch at layer {i} "
1976 f"with non-sequential block IDs"
1977 ),
1978 )
1979
1980 expected_accepted = torch.tensor(
1981 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
1982 dtype=torch.int32,
1983 device=device,
1984 )
1985 torch.testing.assert_close(
1986 gpu_ctx.num_accepted_tokens_out[:num_reqs],
1987 expected_accepted,
1988 msg="num_accepted_tokens mismatch with non-sequential block IDs",
1989 )
1990
1991 # Verify req_0 had num_accepted set to 1 (src==dest) and req_1 unchanged
1992 assert input_batch_py.num_accepted_tokens_cpu[0] == 1
1993 assert input_batch_py.num_accepted_tokens_cpu[1] == num_accepted_tokens[1]
1994
1995 def test_prefix_caching_mixed_shared_and_distinct_blocks(self, device, test_config):
1996 """Regression test: mixed batch under prefix caching where some requests
1997 have shared physical blocks (aliased) and others have distinct blocks,
1998 with the needs_copy boundary at various positions.
1999
2000 This tests the interaction between:
2001 1. PC block aliasing (src and dest map to same physical block)
2002 2. The needs_copy boundary (exact equality vs well-past vs no-copy)
2003 3. Non-sequential physical block IDs
2004
2005 Batch of 4 requests:
2006 - req_0: needs_copy=True, src!=dest, shared physical block (PC aliased)
2007 -> copy skipped (src_addr==dst_addr), num_accepted PRESERVED
2008 - req_1: needs_copy=True, src!=dest, distinct blocks, non-sequential IDs
2009 -> real copy happens, num_accepted PRESERVED
2010 - req_2: needs_copy=False (below boundary)
2011 -> no action at all
2012 - req_3: needs_copy=True, src==dest (exact boundary, zero bias)
2013 -> copy skipped (self-copy), num_accepted SET TO 1
2014
2015 Test setup (block_size=16):
2016 req_0: running=30+2-0=32, new=32+3-1=34, aligned=32, 32>=32 -> COPY
2017 bias=0, dest=32//16-1=1, src=0 (!=dest)
2018 block_ids=[5,5,...] -> same physical -> skip, keep accepted=3
2019
2020 req_1: running=60+5-2=63, new=63+3-1=65, aligned=64, 64>=63 -> COPY
2021 bias=1, dest=64//16-1=3, src=2 (!=dest)
2022 block_ids=[41,7,22,15,...] -> distinct -> real copy, keep accepted=3
2023
2024 req_2: running=30+3-0=33, new=33+1-1=33, aligned=32, 32<33 -> NO COPY
2025
2026 req_3: running=48+0-0=48, new=48+1-1=48, aligned=48, 48>=48 -> COPY
2027 bias=0, dest=48//16-1=2, src=2 (==dest)
2028 block_ids=[10,20,30,...] -> distinct IDs, same logical idx
2029 -> self-copy (src_addr==dst_addr), set accepted=1
2030 """
2031 cfg = test_config
2032 torch.manual_seed(5001)
2033
2034 req_ids = ["req_0", "req_1", "req_2", "req_3"]
2035 num_computed_tokens = [30, 60, 30, 48]
2036 num_scheduled_tokens = {"req_0": 2, "req_1": 5, "req_2": 3, "req_3": 0}
2037 num_draft_tokens = {"req_1": 2}
2038 num_accepted_tokens = [3, 3, 1, 1]
2039 mamba_state_idx = [0, 2, 1, 2]
2040
2041 # Block IDs with various PC patterns:
2042 # req_0: shared blocks (PC alias: logical 0 and 1 -> physical 5)
2043 # req_1: distinct non-sequential blocks
2044 # req_2: doesn't matter (no copy)
2045 # req_3: distinct sequential blocks (no aliasing)
2046 block_ids_per_req = [
2047 [5, 5, 12, 18, 23, 31, 44, 2], # req_0: blocks 0,1 share phys 5
2048 [41, 7, 22, 15, 38, 19, 4, 28], # req_1: all distinct
2049 [10, 20, 30, 40, 1, 6, 8, 14], # req_2: irrelevant
2050 [10, 20, 30, 40, 1, 6, 8, 14], # req_3: distinct, dest=src=idx 2
2051 ]
2052
2053 layer_names = [f"layer_{i}" for i in range(cfg.num_layers)]
2054 num_blocks = 50
2055 local_cfg = _TestConfig(num_blocks=num_blocks, max_num_reqs=cfg.max_num_reqs)
2056 kv_cache_config = _make_kv_cache_config(local_cfg, layer_names)
2057
2058 (
2059 conv_states_py,
2060 temporal_states_py,
2061 conv_states_gpu,
2062 temporal_states_gpu,
2063 forward_context_py,
2064 forward_context_gpu,
2065 ) = _make_dual_states(local_cfg, layer_names, device)
2066
2067 # --- Run Python path ---
2068 scheduler_output = _make_postprocess_scheduler_output(
2069 req_ids,
2070 num_scheduled_tokens,
2071 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
2072 )
2073 input_batch_py = _make_input_batch(
2074 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
2075 )
2076 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
2077 copy_bufs = _make_copy_bufs(local_cfg, kv_cache_config, device)
2078
2079 postprocess_mamba(
2080 scheduler_output,
2081 kv_cache_config,
2082 input_batch_py,
2083 requests,
2084 forward_context_py,
2085 _COPY_FUNCS,
2086 copy_bufs,
2087 )
2088 torch.accelerator.synchronize()
2089
2090 # --- Run GPU path ---
2091 gpu_ctx = _make_gpu_ctx(local_cfg, kv_cache_config, device)
2092 num_reqs = len(req_ids)
2093 max_blocks = max(len(b) for b in block_ids_per_req)
2094 block_table_gpu = torch.zeros(
2095 num_reqs, max_blocks, dtype=torch.int32, device=device
2096 )
2097 for i, block_ids in enumerate(block_ids_per_req):
2098 block_table_gpu[i, : len(block_ids)] = torch.tensor(
2099 block_ids, dtype=torch.int32
2100 )
2101
2102 _run_gpu_postprocess(
2103 gpu_ctx,
2104 kv_cache_config=kv_cache_config,
2105 forward_context=forward_context_gpu,
2106 copy_funcs=_COPY_FUNCS,
2107 block_table=block_table_gpu,
2108 req_ids=req_ids,
2109 num_accepted_tokens=num_accepted_tokens,
2110 mamba_state_idx=mamba_state_idx,
2111 num_scheduled_tokens=num_scheduled_tokens,
2112 num_computed_tokens=num_computed_tokens,
2113 num_draft_tokens=num_draft_tokens,
2114 device=device,
2115 )
2116
2117 # --- Compare all state tensors ---
2118 for i in range(cfg.num_layers):
2119 torch.testing.assert_close(
2120 conv_states_gpu[i],
2121 conv_states_py[i],
2122 msg=(
2123 f"Conv state mismatch at layer {i} — "
2124 f"mixed PC batch with shared/distinct blocks"
2125 ),
2126 )
2127 torch.testing.assert_close(
2128 temporal_states_gpu[i],
2129 temporal_states_py[i],
2130 msg=(
2131 f"Temporal state mismatch at layer {i} — "
2132 f"mixed PC batch with shared/distinct blocks"
2133 ),
2134 )
2135
2136 # --- Compare num_accepted_tokens ---
2137 expected_accepted = torch.tensor(
2138 input_batch_py.num_accepted_tokens_cpu[:num_reqs],
2139 dtype=torch.int32,
2140 device=device,
2141 )
2142 torch.testing.assert_close(
2143 gpu_ctx.num_accepted_tokens_out[:num_reqs],
2144 expected_accepted,
2145 msg="num_accepted_tokens mismatch in mixed PC batch",
2146 )
2147
2148 # Verify per-request expectations:
2149 # req_0: src!=dest, shared block -> preserved (3)
2150 assert input_batch_py.num_accepted_tokens_cpu[0] == 3
2151 # req_1: src!=dest, distinct blocks -> preserved (3)
2152 assert input_batch_py.num_accepted_tokens_cpu[1] == 3
2153 # req_2: no copy -> preserved (1)
2154 assert input_batch_py.num_accepted_tokens_cpu[2] == 1
2155 # req_3: src==dest -> set to 1
2156 assert input_batch_py.num_accepted_tokens_cpu[3] == 1
2157
2158 def test_pc_aliased_blocks_skip_must_use_logical_idx_not_addr(
2159 self, device, test_config
2160 ):
2161 """Regression test for 6466ce0d vs 959ca0fd: the kernel's early-return
2162 guard must compare logical block indices, not physical addresses.
2163
2164 Under prefix caching, different logical blocks (src_block_idx=0,
2165 dest_block_idx=1) can map to the same physical block. When
2166 accept_token_bias=0, this makes src_addr == dst_addr for BOTH conv
2167 and temporal states. A buggy guard `if src_addr == dst_addr` would
2168 incorrectly set num_accepted_tokens=1; the correct guard is
2169 `if src_block_idx == dest_block_idx and accept_token_bias == 0`.
2170
2171 The Python reference only sets num_accepted_tokens=1 when
2172 src_block_idx == dest_block_idx (line 79 of postprocess_mamba).
2173 With src_block_idx=0, dest_block_idx=1, num_accepted_tokens must
2174 be preserved even though the physical addresses match.
2175
2176 Test setup (block_size=16):
2177 - num_tokens_running_state = 30 + 2 - 0 = 32
2178 - new_num_computed = 32 + 3 - 1 = 34
2179 - aligned_new_computed = 32
2180 - needs_copy = (32 >= 32) = True
2181 - accept_token_bias = 32 - 32 = 0
2182 - dest_block_idx = 32 // 16 - 1 = 1
2183 - src_block_idx = 0 (explicitly, != dest_block_idx)
2184 - block_ids = [7, 7, ...] -> physical aliasing via prefix caching
2185
2186 Expected: num_accepted_tokens stays 3 (not set to 1).
2187 Bug (959ca0fd): kernel saw src_addr == dst_addr, set it to 1.
2188 """
2189 cfg = test_config
2190 torch.manual_seed(6001)
2191
2192 req_ids = ["req_0"]
2193 num_computed_tokens = [30]
2194 num_scheduled_tokens = {"req_0": 2}
2195 num_draft_tokens: dict[str, int] = {}
2196 num_accepted_tokens = [3]
2197 mamba_state_idx = [0] # src_block_idx = 0
2198
2199 # Prefix caching: logical blocks 0 and 1 both map to physical block 7.
2200 block_ids_per_req = [[7, 7, 10, 11, 12, 13, 14, 15]]
2201
2202 layer_names = ["layer_0"]
2203 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
2204
2205 (
2206 conv_state_py,
2207 temporal_state_py,
2208 conv_state_gpu,
2209 temporal_state_gpu,
2210 forward_context_py,
2211 forward_context_gpu,
2212 ) = _make_dual_layer_state(cfg, device)
2213
2214 # --- Run Python path ---
2215 scheduler_output = _make_postprocess_scheduler_output(
2216 req_ids,
2217 num_scheduled_tokens,
2218 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
2219 )
2220 input_batch_py = _make_input_batch(
2221 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
2222 )
2223 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
2224 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
2225
2226 postprocess_mamba(
2227 scheduler_output,
2228 kv_cache_config,
2229 input_batch_py,
2230 requests,
2231 forward_context_py,
2232 _COPY_FUNCS,
2233 copy_bufs,
2234 )
2235 torch.accelerator.synchronize()
2236
2237 # Python reference: src_block_idx(0) != dest_block_idx(1) -> no change
2238 assert input_batch_py.num_accepted_tokens_cpu[0] == 3, (
2239 f"Python: num_accepted_tokens should remain 3, "
2240 f"got {input_batch_py.num_accepted_tokens_cpu[0]}"
2241 )
2242
2243 # --- Run GPU path ---
2244 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
2245 num_reqs = len(req_ids)
2246 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
2247 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
2248
2249 _run_gpu_postprocess(
2250 gpu_ctx,
2251 kv_cache_config=kv_cache_config,
2252 forward_context=forward_context_gpu,
2253 copy_funcs=_COPY_FUNCS,
2254 block_table=block_table_gpu,
2255 req_ids=req_ids,
2256 num_accepted_tokens=num_accepted_tokens,
2257 mamba_state_idx=mamba_state_idx,
2258 num_scheduled_tokens=num_scheduled_tokens,
2259 num_computed_tokens=num_computed_tokens,
2260 num_draft_tokens=num_draft_tokens,
2261 device=device,
2262 )
2263
2264 # The critical assertion: kernel must NOT set num_accepted_tokens to 1
2265 # when src_block_idx != dest_block_idx, even though src_addr == dst_addr
2266 # due to prefix caching aliasing.
2267 #
2268 # Old kernel (959ca0fd): `if src_addr == dst_addr` -> FAILS here (sets 1)
2269 # Fixed kernel (6466ce0d): `if src_block_idx == dest_block_idx and
2270 # accept_token_bias == 0` -> PASSES (preserves 3)
2271 kernel_accepted = gpu_ctx.num_accepted_tokens_out[0].item()
2272 assert kernel_accepted == 3, (
2273 f"Kernel set num_accepted_tokens to {kernel_accepted} but expected 3. "
2274 f"The early-return guard likely compared physical addresses "
2275 f"(src_addr == dst_addr) instead of logical block indices "
2276 f"(src_block_idx == dest_block_idx). Under prefix caching, "
2277 f"different logical blocks can share the same physical block."
2278 )
2279
2280 # Also verify state tensors match Python
2281 torch.testing.assert_close(
2282 conv_state_gpu,
2283 conv_state_py,
2284 msg="GPU conv state should match Python",
2285 )
2286 torch.testing.assert_close(
2287 temporal_state_gpu,
2288 temporal_state_py,
2289 msg="GPU temporal state should match Python",
2290 )
2291
2292 def test_as_strided_temporal_copy_size(self, device, test_config):
2293 """Regression test for 240723d46: temporal copy_size must be
2294 inner_size * elem_size, not state_block_stride.
2295
2296 In production (gpu_model_runner.py), conv and temporal states share
2297 a raw buffer via torch.as_strided where stride(0) equals
2298 page_size_bytes / elem_size — larger than either state's natural
2299 element count. Using stride(0) as copy_size for temporal states
2300 overwrites into the next block's conv region.
2301
2302 Layout per page (384 float16 elements = 768 bytes):
2303 [conv: 256 elems | temporal: 128 elems]
2304
2305 The test triggers a temporal copy from block 4 to block 3. With the
2306 bug the kernel copies 768 bytes (page stride) instead of 256 bytes
2307 (128 * 2), overwriting conv_state[4] with conv_state[5]'s data.
2308
2309 Test setup (block_size=16):
2310 - running = 60 + 5 - 2 = 63
2311 - new = 63 + 3 - 1 = 65
2312 - aligned = 64 >= 63 -> COPY needed
2313 - accept_token_bias = 64 - 63 = 1
2314 - dest_block_idx = 64 // 16 - 1 = 3
2315 - temporal: actual_src_block_idx = 3 + 1 = 4 (block_ids[4] = 4)
2316 """
2317 cfg = test_config
2318 torch.manual_seed(7001)
2319
2320 req_ids = ["req_0"]
2321 num_computed_tokens = [60]
2322 num_scheduled_tokens = {"req_0": 5}
2323 num_draft_tokens = {"req_0": 2}
2324 num_accepted_tokens = [3]
2325 mamba_state_idx = [3]
2326 block_ids_per_req = [list(range(8))]
2327
2328 layer_names = ["layer_0"]
2329 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
2330
2331 # --- Production-like packed layout (mirrors gpu_model_runner.py) ---
2332 conv_shape = (cfg.conv_width, cfg.conv_inner_dim)
2333 temporal_shape = (cfg.temporal_state_dim,)
2334 dtype = cfg.dtype
2335 elem_size = torch.tensor([], dtype=dtype).element_size()
2336
2337 conv_natural_elems = cfg.conv_width * cfg.conv_inner_dim
2338 temporal_natural_elems = cfg.temporal_state_dim
2339 page_size_bytes = (conv_natural_elems + temporal_natural_elems) * elem_size
2340 num_element_per_page = page_size_bytes // elem_size
2341
2342 assert num_element_per_page > temporal_natural_elems, (
2343 "Test requires padded stride; page must be larger than one state"
2344 )
2345
2346 raw_py = torch.randn(
2347 cfg.num_blocks * num_element_per_page, dtype=dtype, device=device
2348 )
2349 raw_gpu = raw_py.clone()
2350
2351 def make_views(raw):
2352 conv_tgt = (cfg.num_blocks, *conv_shape)
2353 conv_nat_stride = torch.empty(conv_tgt).stride()
2354 conv = torch.as_strided(
2355 raw,
2356 size=conv_tgt,
2357 stride=(num_element_per_page, *conv_nat_stride[1:]),
2358 storage_offset=0,
2359 )
2360
2361 temp_tgt = (cfg.num_blocks, *temporal_shape)
2362 temp_nat_stride = torch.empty(temp_tgt).stride()
2363 temp = torch.as_strided(
2364 raw,
2365 size=temp_tgt,
2366 stride=(num_element_per_page, *temp_nat_stride[1:]),
2367 storage_offset=conv_natural_elems,
2368 )
2369 return conv, temp
2370
2371 conv_py, temp_py = make_views(raw_py)
2372 conv_gpu, temp_gpu = make_views(raw_gpu)
2373
2374 fwd_py = {"layer_0": _make_mock_attention(conv_py, temp_py)}
2375 fwd_gpu = {"layer_0": _make_mock_attention(conv_gpu, temp_gpu)}
2376
2377 # --- Python reference ---
2378 sched = _make_postprocess_scheduler_output(
2379 req_ids,
2380 num_scheduled_tokens,
2381 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
2382 )
2383 batch_py = _make_input_batch(
2384 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
2385 )
2386 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
2387 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
2388
2389 postprocess_mamba(
2390 sched,
2391 kv_cache_config,
2392 batch_py,
2393 requests,
2394 fwd_py,
2395 _COPY_FUNCS,
2396 copy_bufs,
2397 )
2398 torch.accelerator.synchronize()
2399
2400 # --- GPU fused kernel ---
2401 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
2402 num_reqs = 1
2403 block_table = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
2404 block_table[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
2405
2406 _run_gpu_postprocess(
2407 gpu_ctx,
2408 kv_cache_config=kv_cache_config,
2409 forward_context=fwd_gpu,
2410 copy_funcs=_COPY_FUNCS,
2411 block_table=block_table,
2412 req_ids=req_ids,
2413 num_accepted_tokens=num_accepted_tokens,
2414 mamba_state_idx=mamba_state_idx,
2415 num_scheduled_tokens=num_scheduled_tokens,
2416 num_computed_tokens=num_computed_tokens,
2417 num_draft_tokens=num_draft_tokens,
2418 device=device,
2419 )
2420
2421 # --- Assertions ---
2422 # With the bug (pre-240723d46), the kernel copies page_size_bytes
2423 # (768) for temporal state instead of 256 bytes, overwriting
2424 # conv_state[4] with conv_state[5]'s data.
2425 torch.testing.assert_close(
2426 conv_gpu,
2427 conv_py,
2428 msg=(
2429 "Conv state corrupted: temporal copy_size was likely "
2430 "state_block_stride instead of inner_size * elem_size"
2431 ),
2432 )
2433 torch.testing.assert_close(
2434 temp_gpu,
2435 temp_py,
2436 msg="Temporal state mismatch",
2437 )
2438
2439 expected_accepted = torch.tensor(
2440 batch_py.num_accepted_tokens_cpu[:num_reqs],
2441 dtype=torch.int32,
2442 device=device,
2443 )
2444 torch.testing.assert_close(
2445 gpu_ctx.num_accepted_tokens_out[:num_reqs],
2446 expected_accepted,
2447 msg="num_accepted_tokens mismatch",
2448 )
2449
2450 def test_temporal_copy_with_bias_ge_2(self, device, test_config):
2451 """Coverage test for the temporal-state block-table stride arithmetic
2452 when ``accept_token_bias >= 2``.
2453
2454 The kernel computes, for temporal (non-conv) states::
2455
2456 actual_src_block_idx = src_block_idx + accept_token_bias
2457 actual_src_block_id = block_table[req, actual_src_block_idx]
2458
2459 A ``bias == 1`` case only reads one slot ahead of ``src_block_idx``
2460 in the block table. This test isolates the larger-stride case, where
2461 an off-by-one would read the wrong physical block after multiple
2462 tokens are accepted across a block boundary.
2463
2464 Setup (block_size=16):
2465 - running = 28 + 2 - 0 = 30
2466 - new = 30 + 3 - 1 = 32
2467 - aligned = 32 >= 30 -> COPY needed
2468 - bias = 32 - 30 = 2 (key: >= 2)
2469 - dest_idx = 32 // 16 - 1 = 1
2470 - src_idx = 1 (same as dest -> exercises post-copy accepted=1 write)
2471 - temporal actual_src_block_idx = 1 + 2 = 3 (reads block_table[0, 3])
2472
2473 With identity block_ids = [0,1,2,3,...], an off-by-one that used
2474 bias=1 would copy from block_ids[2]=2 instead of block_ids[3]=3,
2475 producing a clear mismatch against the untouched snapshot.
2476 """
2477 cfg = test_config
2478 torch.manual_seed(7002)
2479
2480 req_ids = ["req_0"]
2481 num_computed_tokens = [28]
2482 num_scheduled_tokens = {"req_0": 2}
2483 num_draft_tokens: dict[str, int] = {}
2484 num_accepted_tokens = [3] # -> accept_token_bias = 2
2485 mamba_state_idx = [1] # src_block_idx = 1 = dest_block_idx
2486 block_ids_per_req = [list(range(8))]
2487
2488 layer_names = ["layer_0"]
2489 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
2490
2491 (
2492 conv_state_py,
2493 temporal_state_py,
2494 conv_state_gpu,
2495 temporal_state_gpu,
2496 fwd_py,
2497 fwd_gpu,
2498 ) = _make_dual_layer_state(cfg, device)
2499 conv_state_orig = conv_state_py.clone()
2500 temporal_state_orig = temporal_state_py.clone()
2501
2502 # --- Python reference ---
2503 sched = _make_postprocess_scheduler_output(
2504 req_ids,
2505 num_scheduled_tokens,
2506 {k: [None] * v for k, v in num_draft_tokens.items() if v > 0},
2507 )
2508 batch_py = _make_input_batch(
2509 req_ids, num_accepted_tokens.copy(), mamba_state_idx.copy()
2510 )
2511 requests = _make_requests(req_ids, num_computed_tokens, block_ids_per_req)
2512 copy_bufs = _make_copy_bufs(cfg, kv_cache_config, device)
2513
2514 postprocess_mamba(
2515 sched,
2516 kv_cache_config,
2517 batch_py,
2518 requests,
2519 fwd_py,
2520 _COPY_FUNCS,
2521 copy_bufs,
2522 )
2523 torch.accelerator.synchronize()
2524
2525 # --- GPU fused kernel ---
2526 gpu_ctx = _make_gpu_ctx(cfg, kv_cache_config, device)
2527 num_reqs = 1
2528 block_table = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
2529 block_table[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
2530
2531 _run_gpu_postprocess(
2532 gpu_ctx,
2533 kv_cache_config=kv_cache_config,
2534 forward_context=fwd_gpu,
2535 copy_funcs=_COPY_FUNCS,
2536 block_table=block_table,
2537 req_ids=req_ids,
2538 num_accepted_tokens=num_accepted_tokens,
2539 mamba_state_idx=mamba_state_idx,
2540 num_scheduled_tokens=num_scheduled_tokens,
2541 num_computed_tokens=num_computed_tokens,
2542 num_draft_tokens=num_draft_tokens,
2543 device=device,
2544 )
2545
2546 # --- Ground truth from untouched snapshots ---
2547 actual_src_block_id = block_ids_per_req[0][3] # == 3
2548 dest_block_id = block_ids_per_req[0][1] # == 1
2549 expected_conv_state = conv_state_orig.clone()
2550 expected_conv_state[dest_block_id, :-2].copy_(
2551 conv_state_orig[dest_block_id, 2:]
2552 )
2553 torch.testing.assert_close(conv_state_py, expected_conv_state, rtol=0, atol=0)
2554 torch.testing.assert_close(conv_state_gpu, expected_conv_state, rtol=0, atol=0)
2555
2556 # Python must have sourced temporal from block 3.
2557 torch.testing.assert_close(
2558 temporal_state_py[dest_block_id],
2559 temporal_state_orig[actual_src_block_id],
2560 rtol=0,
2561 atol=0,
2562 msg=(
2563 "Python reference did not copy from block_ids[src+bias]=3; "
2564 "test preconditions are wrong"
2565 ),
2566 )
2567
2568 # --- GPU kernel must match Python byte-for-byte ---
2569 torch.testing.assert_close(
2570 conv_state_gpu,
2571 conv_state_py,
2572 msg="Conv state mismatch at accept_token_bias=2",
2573 )
2574 torch.testing.assert_close(
2575 temporal_state_gpu,
2576 temporal_state_py,
2577 msg=(
2578 "Temporal state mismatch at accept_token_bias=2: the kernel "
2579 "likely read the wrong slot of the block table "
2580 "(actual_src_block_idx stride arithmetic)"
2581 ),
2582 )
2583
2584 expected_accepted = torch.tensor(
2585 batch_py.num_accepted_tokens_cpu[:num_reqs],
2586 dtype=torch.int32,
2587 device=device,
2588 )
2589 torch.testing.assert_close(
2590 gpu_ctx.num_accepted_tokens_out[:num_reqs],
2591 expected_accepted,
2592 msg="num_accepted_tokens mismatch at accept_token_bias=2",
2593 )
2594
2595 @pytest.mark.parametrize(
2596 "same_physical_block", [True, False], ids=["same", "distinct"]
2597 )
2598 @pytest.mark.parametrize("accept_token_bias", [1, 2, 3])
2599 @pytest.mark.parametrize(
2600 "dtype",
2601 [torch.float16, torch.float32, torch.float64],
2602 ids=["fp16", "fp32", "fp64"],
2603 )
2604 def test_sd_and_ds_conv_layouts_match_snapshot(
2605 self,
2606 device,
2607 test_config,
2608 monkeypatch,
2609 accept_token_bias,
2610 same_physical_block,
2611 dtype,
2612 ):
2613 """SD and DS copies should independently match memmove semantics."""
2614 from vllm.model_executor.layers.mamba import mamba_utils as model_mamba_utils
2615
2616 cfg = test_config
2617 cfg.dtype = dtype
2618 torch.manual_seed(38898)
2619
2620 req_ids = ["req_0"]
2621 # Keep new_num_computed on an aligned boundary while varying how far
2622 # below it the running state starts. This makes the copy bias exactly
2623 # ``accept_token_bias`` for each case. The 32 boundary keeps source and
2624 # destination in logical block 1; the 64 boundary copies block 2 -> 3.
2625 aligned_boundary = 32 if same_physical_block else 64
2626 num_computed_tokens = [aligned_boundary - 2 * accept_token_bias]
2627 num_scheduled_tokens = {"req_0": accept_token_bias}
2628 num_draft_tokens: dict[str, int] = {}
2629 num_accepted_tokens = [accept_token_bias + 1]
2630 dest_block_idx = aligned_boundary // cfg.block_size - 1
2631 src_block_idx = dest_block_idx if same_physical_block else dest_block_idx - 1
2632 mamba_state_idx = [src_block_idx]
2633 block_ids_per_req = [list(range(8))]
2634
2635 layer_names = ["layer_0"]
2636 kv_cache_config = _make_kv_cache_config(cfg, layer_names)
2637
2638 num_reqs = len(req_ids)
2639 block_table_gpu = torch.zeros(num_reqs, 8, dtype=torch.int32, device=device)
2640 block_table_gpu[0, :8] = torch.tensor(block_ids_per_req[0], dtype=torch.int32)
2641
2642 # Same logical conv state in SD and DS layouts.
2643 sd_source_conv = torch.randn(
2644 cfg.num_blocks,
2645 cfg.conv_width,
2646 cfg.conv_inner_dim,
2647 dtype=cfg.dtype,
2648 device=device,
2649 )
2650 ds_source_conv = sd_source_conv.permute(0, 2, 1).contiguous()
2651 sd_source_temporal = torch.randn(
2652 cfg.num_blocks, cfg.temporal_state_dim, dtype=cfg.dtype, device=device
2653 )
2654
2655 # SD GPU path.
2656 monkeypatch.delenv("VLLM_SSM_CONV_STATE_LAYOUT", raising=False)
2657 model_mamba_utils.get_conv_state_layout.cache_clear()
2658 sd_conv = sd_source_conv.clone()
2659 sd_temporal = sd_source_temporal.clone()
2660 forward_context_sd = {
2661 "layer_0": _make_mock_attention(sd_conv, sd_temporal),
2662 }
2663 gpu_ctx_sd = _make_gpu_ctx(cfg, kv_cache_config, device)
2664 _run_gpu_postprocess(
2665 gpu_ctx_sd,
2666 kv_cache_config=kv_cache_config,
2667 forward_context=forward_context_sd,
2668 copy_funcs=_COPY_FUNCS,
2669 block_table=block_table_gpu,
2670 req_ids=req_ids,
2671 num_accepted_tokens=num_accepted_tokens,
2672 mamba_state_idx=mamba_state_idx,
2673 num_scheduled_tokens=num_scheduled_tokens,
2674 num_computed_tokens=num_computed_tokens,
2675 num_draft_tokens=num_draft_tokens,
2676 device=device,
2677 )
2678 torch.accelerator.synchronize()
2679
2680 src_block_id = block_ids_per_req[0][src_block_idx]
2681 dest_block_id = block_ids_per_req[0][dest_block_idx]
2682 expected_conv = sd_source_conv.clone()
2683 expected_conv[dest_block_id, :-accept_token_bias].copy_(
2684 sd_source_conv[src_block_id, accept_token_bias:]
2685 )
2686 torch.testing.assert_close(
2687 sd_conv,
2688 expected_conv,
2689 rtol=0,
2690 atol=0,
2691 msg="SD conv copy did not match the untouched source snapshot",
2692 )
2693
2694 actual_temporal_src_idx = src_block_idx + accept_token_bias
2695 actual_temporal_src_id = block_ids_per_req[0][actual_temporal_src_idx]
2696 expected_temporal = sd_source_temporal.clone()
2697 expected_temporal[dest_block_id].copy_(
2698 sd_source_temporal[actual_temporal_src_id]
2699 )
2700 torch.testing.assert_close(
2701 sd_temporal,
2702 expected_temporal,
2703 rtol=0,
2704 atol=0,
2705 msg="SD temporal copy did not match the untouched source snapshot",
2706 )
2707
2708 # DS GPU path on the DS twin.
2709 monkeypatch.setenv("VLLM_SSM_CONV_STATE_LAYOUT", "DS")
2710 model_mamba_utils.get_conv_state_layout.cache_clear()
2711 try:
2712 ds_conv = ds_source_conv.clone()
2713 ds_temporal = sd_source_temporal.clone()
2714 forward_context_ds = {
2715 "layer_0": _make_mock_attention(ds_conv, ds_temporal),
2716 }
2717 gpu_ctx_ds = _make_gpu_ctx(cfg, kv_cache_config, device)
2718 _run_gpu_postprocess(
2719 gpu_ctx_ds,
2720 kv_cache_config=kv_cache_config,
2721 forward_context=forward_context_ds,
2722 copy_funcs=_COPY_FUNCS,
2723 block_table=block_table_gpu,
2724 req_ids=req_ids,
2725 num_accepted_tokens=num_accepted_tokens,
2726 mamba_state_idx=mamba_state_idx,
2727 num_scheduled_tokens=num_scheduled_tokens,
2728 num_computed_tokens=num_computed_tokens,
2729 num_draft_tokens=num_draft_tokens,
2730 device=device,
2731 )
2732 torch.accelerator.synchronize()
2733 finally:
2734 # Reset the lru cache so other tests see the default layout again.
2735 model_mamba_utils.get_conv_state_layout.cache_clear()
2736
2737 # Validate DS independently against the snapshot; otherwise a shared
2738 # SD/DS bug would remain invisible.
2739 ds_conv_sd_layout = ds_conv.permute(0, 2, 1).contiguous()
2740 torch.testing.assert_close(
2741 ds_conv_sd_layout,
2742 expected_conv,
2743 rtol=0,
2744 atol=0,
2745 msg="DS conv copy did not match the untouched source snapshot",
2746 )
2747 torch.testing.assert_close(
2748 ds_temporal,
2749 expected_temporal,
2750 rtol=0,
2751 atol=0,
2752 msg="DS temporal copy did not match the untouched source snapshot",
2753 )
2754
2755 expected_accepted = 1 if same_physical_block else accept_token_bias + 1
2756 expected_accepted_tensor = torch.tensor(
2757 [expected_accepted], dtype=torch.int32, device=device
2758 )
2759 torch.testing.assert_close(
2760 gpu_ctx_sd.num_accepted_tokens_out[:num_reqs],
2761 expected_accepted_tensor,
2762 rtol=0,
2763 atol=0,
2764 msg="SD num_accepted_tokens result is wrong",
2765 )
2766 torch.testing.assert_close(
2767 gpu_ctx_ds.num_accepted_tokens_out[:num_reqs],
2768 expected_accepted_tensor,
2769 rtol=0,
2770 atol=0,
2771 msg="DS num_accepted_tokens result is wrong",
2772 )