main
py 248 lines 9.75 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Prefill == decode equivalence for the Mamba2 ReplaySSM kernels.
4
5 The SSM recurrence is path-independent: vLLM's chunked prefill (the SSD kernel
6 ``mamba_chunk_scan_combined_varlen``) and step-by-step decode must produce the
7 same per-position outputs and final state over a sequence. This file feeds one
8 set of inputs through the production dt flow (raw dt + softplus + a per-head
9 dt_bias, applied inside each kernel) to:
10
11 * the exact fp32 step recurrence (``selective_state_update_ref``) -- the
12 ground truth,
13 * the chunked prefill kernel,
14 * the baseline decode kernel,
15 * the ReplaySSM output_only decode kernel,
16
17 and checks all of them agree. Prefill (a chunked scan) and decode (a step
18 recurrence) are different code paths, so they differ numerically: the chunked
19 scan carries ~2e-2 (fp32) / ~4e-2 (bf16) vs the exact recurrence, far above the
20 near-exact decode. We therefore anchor every path on the exact recurrence at
21 SSD-level tolerances (the same regime as ``test_mamba_ssm_ssd.py``), which the
22 chunked scan sets, keyed off the activation dtype.
23
24 State and activation/buffer precision are swept independently, including the
25 fp32-state + bf16-activation production config.
26 """
27
28 import pytest
29 import torch
30
31 from tests.kernels.mamba.utils import selective_state_update_ref
32 from vllm.model_executor.layers.mamba.ops.mamba_ssm import selective_state_update
33 from vllm.model_executor.layers.mamba.ops.selective_state_update_replayssm_output_only import ( # noqa: E501
34 selective_state_update_replayssm_output_only,
35 )
36 from vllm.model_executor.layers.mamba.ops.ssd_combined import (
37 mamba_chunk_scan_combined_varlen,
38 )
39 from vllm.platforms import current_platform
40 from vllm.utils.torch_utils import set_random_seed
41 from vllm.v1.attention.backends.mamba2_attn import compute_varlen_chunk_metadata
42
43
44 def _prefill_tolerances(act_dtype: torch.dtype) -> tuple[float, float]:
45 # The chunked prefill scan, not the decode, sets these: it carries ~2e-2
46 # (fp32) / ~6e-2 (bf16) vs the exact recurrence, while ReplaySSM decode is
47 # near-exact (~2e-6 fp32). Keyed off the activation dtype (the outputs are
48 # in act_dtype). Same regime as test_mamba_ssm_ssd.py.
49 if act_dtype == torch.float32:
50 return 1e-2, 3e-2
51 return 6e-2, 1e-1
52
53
54 def _run_prefill_decode_equivalence(
55 *,
56 state_dtype: torch.dtype,
57 act_dtype: torch.dtype,
58 nheads: int,
59 headdim: int,
60 ngroups: int,
61 dstate: int,
62 seqlen: int,
63 chunk_size: int,
64 max_cache_len: int,
65 seed: int = 0,
66 ) -> None:
67 """Prefill the whole sequence and decode it step by step; check both match
68 the exact fp32 recurrence (and each other). All paths use the production dt
69 flow (raw dt + softplus + a per-head dt_bias), so this also checks prefill
70 and decode apply the softplus/bias preprocessing consistently. ``state_dtype``
71 is the recurrent-state precision; ``act_dtype`` the activation/buffer one."""
72 device = current_platform.device_type
73 rtol, atol = _prefill_tolerances(act_dtype)
74 set_random_seed(seed)
75
76 # Production dt flow: raw dt + a per-head dt_bias (~-4 keeps softplus(dt +
77 # bias) small and well-conditioned). dt_bias is (nheads,) for prefill and
78 # (nheads, headdim) for the decode kernels/reference.
79 A = -torch.exp(torch.rand(nheads, device=device, dtype=act_dtype))
80 dt = torch.randn(seqlen, nheads, device=device, dtype=act_dtype)
81 dt_bias = torch.rand(nheads, device=device, dtype=act_dtype) - 4
82 dt_bias_hd = dt_bias.view(nheads, 1).expand(nheads, headdim)
83 X = torch.randn(seqlen, nheads, headdim, device=device, dtype=act_dtype)
84 B = torch.randn(seqlen, ngroups, dstate, device=device, dtype=act_dtype)
85 C = torch.randn(seqlen, ngroups, dstate, device=device, dtype=act_dtype)
86 A_bcast = A.view(nheads, 1, 1).expand(nheads, headdim, dstate)
87
88 # Chunked prefill over the whole sequence (implicit batch=1, varlen). The
89 # kernel always returns the final state in fp32, so no state_dtype plumbing.
90 cu_seqlens = torch.tensor((0, seqlen), device=device).cumsum(0).to(torch.int32)
91 cu_chunk_seqlens, last_chunk_indices, seq_idx = compute_varlen_chunk_metadata(
92 cu_seqlens, chunk_size
93 )
94 y_prefill = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
95 final_state_prefill = mamba_chunk_scan_combined_varlen(
96 X,
97 dt,
98 A,
99 B,
100 C,
101 chunk_size,
102 cu_seqlens=cu_seqlens,
103 cu_chunk_seqlens=cu_chunk_seqlens,
104 last_chunk_indices=last_chunk_indices,
105 seq_idx=seq_idx,
106 out=y_prefill,
107 D=None,
108 dt_bias=dt_bias,
109 dt_softplus=True,
110 )
111
112 # Step paths: exact fp32 recurrence (ground truth), baseline, ReplaySSM.
113 # State follows state_dtype; caches follow act_dtype (dt_cache is fp32).
114 state_ref = torch.zeros(
115 1, nheads, headdim, dstate, device=device, dtype=torch.float32
116 )
117 state_base = torch.zeros(
118 1, nheads, headdim, dstate, device=device, dtype=state_dtype
119 )
120 state_dec = torch.zeros(
121 1, nheads, headdim, dstate, device=device, dtype=state_dtype
122 )
123 x_cache = torch.zeros(
124 1, nheads, max_cache_len, headdim, device=device, dtype=act_dtype
125 )
126 dt_cache = torch.zeros(1, nheads, max_cache_len, device=device, dtype=torch.float32)
127 B_cache = torch.zeros(
128 1, ngroups, max_cache_len, dstate, device=device, dtype=act_dtype
129 )
130 bc_pre = torch.empty(1, ngroups, max_cache_len, device=device, dtype=torch.float32)
131 write_pos = torch.zeros(1, dtype=torch.int32, device=device)
132 # No skip connection (D=0) on any path here; the D!=0 path is covered by the
133 # standard-decode suite. The baseline kernel needs a D tensor, not None.
134 D_zero = torch.zeros(nheads, headdim, device=device)
135
136 y_ref = torch.empty(seqlen, nheads, headdim, device=device, dtype=torch.float32)
137 y_base = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
138 y_dec = torch.empty(seqlen, nheads, headdim, device=device, dtype=act_dtype)
139 for t in range(seqlen):
140 dt_t = dt[t].view(1, nheads, 1).expand(1, nheads, headdim)
141 is_flush = write_pos == max_cache_len - 1
142
143 y_ref[t] = selective_state_update_ref(
144 state_ref,
145 X[t : t + 1].float(),
146 dt_t.float(),
147 A_bcast.float(),
148 B[t : t + 1].float(),
149 C[t : t + 1].float(),
150 dt_bias=dt_bias_hd.float(),
151 dt_softplus=True,
152 )[0]
153
154 out_b = torch.empty(1, nheads, headdim, device=device, dtype=act_dtype)
155 selective_state_update(
156 state_base,
157 X[t : t + 1],
158 dt_t,
159 A_bcast,
160 B[t : t + 1],
161 C[t : t + 1],
162 D=D_zero,
163 dt_bias=dt_bias_hd,
164 dt_softplus=True,
165 out=out_b,
166 )
167 y_base[t] = out_b[0]
168
169 out_d = torch.empty(1, nheads, headdim, device=device, dtype=act_dtype)
170 common = dict(
171 dt_bias=dt_bias_hd,
172 dt_softplus=True,
173 x_cache=x_cache,
174 dt_cache=dt_cache,
175 B_cache=B_cache,
176 write_pos=write_pos,
177 is_flush=is_flush,
178 max_cache_len=max_cache_len,
179 out=out_d,
180 )
181 selective_state_update_replayssm_output_only(
182 state_dec,
183 X[t : t + 1],
184 dt_t,
185 A_bcast,
186 B[t : t + 1],
187 C[t : t + 1],
188 bc_pre=bc_pre,
189 **common,
190 )
191 y_dec[t] = out_d[0]
192
193 write_pos = torch.where(is_flush, torch.zeros_like(write_pos), write_pos + 1)
194
195 # Every path computes the same recurrence; anchor each on the fp32 truth.
196 torch.testing.assert_close(y_prefill.float(), y_ref, rtol=rtol, atol=atol)
197 torch.testing.assert_close(y_base.float(), y_ref, rtol=rtol, atol=atol)
198 torch.testing.assert_close(y_dec.float(), y_ref, rtol=rtol, atol=atol)
199 # Headline: ReplaySSM decode matches the chunked prefill directly.
200 torch.testing.assert_close(y_dec.float(), y_prefill.float(), rtol=rtol, atol=atol)
201 # Final state too (the recurrence ends in state_ref after the loop).
202 torch.testing.assert_close(
203 final_state_prefill[0].float(), state_ref[0], rtol=rtol, atol=atol
204 )
205
206
207 @pytest.mark.skipif(
208 not (current_platform.is_cuda_alike() or current_platform.is_xpu()),
209 reason="Mamba2 ReplaySSM kernels require a CUDA-alike or XPU device.",
210 )
211 @pytest.mark.parametrize(
212 "precision",
213 # fp32 state is the default; bf16/fp16 are reduced-footprint configs. fp16
214 # appears as an activation dtype (s32_afp16, sfp16_afp16) and as a finer-
215 # mantissa state under bf16 activations (sfp16_a16).
216 [
217 pytest.param((torch.float32, torch.float32), id="s32_a32"),
218 pytest.param((torch.float32, torch.bfloat16), id="s32_a16"),
219 pytest.param((torch.bfloat16, torch.bfloat16), id="s16_a16"),
220 pytest.param((torch.float32, torch.float16), id="s32_afp16"),
221 pytest.param((torch.float16, torch.float16), id="sfp16_afp16"),
222 pytest.param((torch.float16, torch.bfloat16), id="sfp16_a16"),
223 ],
224 )
225 @pytest.mark.parametrize(
226 "geometry", # (nheads, headdim, dstate, ngroups)
227 [
228 pytest.param((8, 64, 64, 2), id="small"),
229 pytest.param((96, 80, 128, 8), id="nano4b"),
230 ],
231 )
232 def test_replayssm_prefill_decode_equivalence(
233 precision: tuple[torch.dtype, torch.dtype],
234 geometry: tuple[int, int, int, int],
235 ):
236 state_dtype, act_dtype = precision
237 nheads, headdim, dstate, ngroups = geometry
238 _run_prefill_decode_equivalence(
239 state_dtype=state_dtype,
240 act_dtype=act_dtype,
241 nheads=nheads,
242 headdim=headdim,
243 ngroups=ngroups,
244 dstate=dstate,
245 seqlen=16,
246 chunk_size=8,
247 max_cache_len=4,
248 )