[Bugfix][CPU] Enable C++ causal_conv1d GDN path and float32 SSM cache on non-AMX AVX-512BF16 CPUs (#49688)
Signed-off-by: Dinesh Chitlangia <dineshchitlangia@gmail.com>
Dinesh Chitlangia committed
Aug 19, 2026 at 11:50 UTC
525b7bbb3a74d811cedcee108425d9bb5c215fdd
4 files changed
+290
-17
tests/kernels/mamba/cpu/test_cpu_gdn_ops.py
+270
-4
@@ -878,17 +878,18 @@ def test_causal_conv1d_update_cpu_rejects_invalid_accepted_count(
878
879
880
@pytest.mark.skipif(
881
- not torch.cpu._is_amx_tile_supported(),
882
- reason="requires AMX support",
881
+ not torch.cpu._is_avx512_bf16_supported(),
882
+ reason="causal_conv1d_fwd_cpu requires AVX-512BF16 (Intel Xeon or AMD EPYC)",
883
)
884
@pytest.mark.parametrize("total_tokens, split", TWO_CALL_SPLITS)
885
@torch.inference_mode()
886
def test_causal_conv1d_fwd_cpu_two_call_split(total_tokens: int, split: int) -> None:
887
- """AMX prefill conv op must honor ``has_initial_state`` so a two-call split
887
+ """C++ prefill conv op must honor ``has_initial_state`` so a two-call split
888
matches the single-call result.
889
890
Regression test for ``causal_conv1d_fwd_varlen_kernel_impl`` (``conv.cpp``)
891
- ignoring the carried conv state on continued chunks.
891
+ ignoring the carried conv state on continued chunks. Runs on any
892
+ AVX-512BF16 CPU since conv.cpp uses VDPBF16PS, not AMX tiles.
893
"""
894
state_len = CONV_KERNEL - 1
895
x, weight, bias = _conv_inputs(total_tokens)
@@ -996,3 +997,268 @@ def test_batch_memcpy_cpu_fallback() -> None:
997
998
for src, dst in zip(srcs, dsts):
999
torch.testing.assert_close(dst, src)
1000
+
1001
+
1002
+# ---------------------------------------------------------------------------
1003
+# C++ conv (conv.cpp) uses VDPBF16PS, not AMX tiles, so it runs on any
1004
+# AVX-512BF16 CPU; weight is VNNI-packed on this same predicate at load time.
1005
+# ---------------------------------------------------------------------------
1006
+
1007
+_HAS_AVX512_BF16 = torch.cpu._is_avx512_bf16_supported()
1008
+
1009
+_STATE_LEN = CONV_KERNEL - 1
1010
+
1011
+CONV_EQUIV_SEQ_LENS = [[1], [7], [64], [65], [1, 2, 3], [63, 64, 65], [128, 129]]
1012
+
1013
+
1014
+def _conv_fp32_oracle(x, weight, bias, seq_lens, activation="silu"):
1015
+ """High-precision conv reference: everything in float32, no bf16 rounding.
1016
+ x: [total_tokens, dim] (no initial state). Returns [total_tokens, dim]."""
1017
+ xf = x.float()
1018
+ wf = weight.float().unsqueeze(1)
1019
+ bf = bias.float()
1020
+ out = torch.empty_like(xf)
1021
+ start = 0
1022
+ for n in seq_lens:
1023
+ seg = xf[start : start + n].transpose(0, 1).unsqueeze(0) # [1, dim, n]
1024
+ conv_in = F.pad(seg, (_STATE_LEN, 0))
1025
+ seg_out = F.conv1d(conv_in, wf, bf, padding=0, groups=CONV_DIM)[..., -n:]
1026
+ if activation in ("silu", "swish"):
1027
+ seg_out = F.silu(seg_out)
1028
+ out[start : start + n] = seg_out.squeeze(0).transpose(0, 1)
1029
+ start += n
1030
+ return out
1031
+
1032
+
1033
+def _run_prefill_torch(x, weight, bias, seq_lens):
1034
+ from vllm.model_executor.layers.mamba.ops.cpu.causal_conv1d import (
1035
+ causal_conv1d_fn_cpu as causal_conv1d_torch,
1036
+ )
1037
+
1038
+ num_seqs = len(seq_lens)
1039
+ conv_states = torch.zeros(num_seqs, CONV_DIM, _STATE_LEN, dtype=x.dtype)
1040
+ qsl = torch.tensor(
1041
+ [0, *torch.tensor(seq_lens).cumsum(0).tolist()], dtype=torch.int32
1042
+ )
1043
+ out = causal_conv1d_torch(
1044
+ x=x.transpose(0, 1).contiguous(),
1045
+ weight=weight,
1046
+ bias=bias,
1047
+ conv_states=conv_states,
1048
+ query_start_loc=qsl,
1049
+ cache_indices=torch.arange(num_seqs, dtype=torch.int32),
1050
+ has_initial_state=torch.zeros(num_seqs, dtype=torch.bool),
1051
+ activation="silu",
1052
+ )
1053
+ return out.transpose(0, 1).contiguous(), conv_states
1054
+
1055
+
1056
+def _run_prefill_cpp(x, weight, bias, seq_lens, is_vnni=False):
1057
+ num_seqs = len(seq_lens)
1058
+ packed_w = ops.causal_conv1d_weight_pack(weight) if is_vnni else weight
1059
+ if is_vnni:
1060
+ # C++-branch layout: kv-cache "SD" [slots, state_len, dim] transposed to
1061
+ # [slots, dim, state_len] (a non-contiguous view).
1062
+ conv_state = torch.zeros(
1063
+ num_seqs, _STATE_LEN, CONV_DIM, dtype=x.dtype
1064
+ ).transpose(1, 2)
1065
+ else:
1066
+ conv_state = torch.zeros(num_seqs, CONV_DIM, _STATE_LEN, dtype=x.dtype)
1067
+ qsl = torch.tensor(
1068
+ [0, *torch.tensor(seq_lens).cumsum(0).tolist()], dtype=torch.int32
1069
+ )
1070
+ out = ops.causal_conv1d_fwd_cpu(
1071
+ x=x.transpose(0, 1),
1072
+ weight=packed_w,
1073
+ bias=bias,
1074
+ conv_states=conv_state,
1075
+ query_start_loc=qsl,
1076
+ cache_indices=torch.arange(num_seqs, dtype=torch.int32),
1077
+ has_initial_state=torch.zeros(num_seqs, dtype=torch.bool),
1078
+ silu_activation=True,
1079
+ is_vnni=is_vnni,
1080
+ )
1081
+ return out.transpose(0, 1).contiguous(), conv_state
1082
+
1083
+
1084
+@pytest.mark.skipif(
1085
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1086
+)
1087
+@pytest.mark.parametrize("seq_lens", CONV_EQUIV_SEQ_LENS)
1088
+@torch.inference_mode()
1089
+def test_conv_cpp_matches_torch(seq_lens):
1090
+ """C++ causal_conv1d_fwd_cpu matches the torch fallback within bf16 tol."""
1091
+ x, weight, bias = _conv_inputs(sum(seq_lens))
1092
+ out_torch, state_torch = _run_prefill_torch(x, weight, bias, seq_lens)
1093
+ out_cpp, state_cpp = _run_prefill_cpp(x, weight, bias, seq_lens)
1094
+ torch.testing.assert_close(out_cpp, out_torch, atol=1e-2, rtol=1e-2)
1095
+ torch.testing.assert_close(state_cpp, state_torch, atol=1e-2, rtol=1e-2)
1096
+
1097
+
1098
+@pytest.mark.skipif(
1099
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1100
+)
1101
+@pytest.mark.parametrize("seq_lens", CONV_EQUIV_SEQ_LENS)
1102
+@torch.inference_mode()
1103
+def test_conv_cpp_no_worse_than_torch_vs_fp32(seq_lens):
1104
+ """Swapping torch -> C++ conv must not increase error vs an fp32 oracle."""
1105
+ x, weight, bias = _conv_inputs(sum(seq_lens))
1106
+ oracle = _conv_fp32_oracle(x, weight, bias, seq_lens)
1107
+ out_torch, _ = _run_prefill_torch(x, weight, bias, seq_lens)
1108
+ out_cpp, _ = _run_prefill_cpp(x, weight, bias, seq_lens)
1109
+ err_torch = (out_torch.float() - oracle).abs().mean().item()
1110
+ err_cpp = (out_cpp.float() - oracle).abs().mean().item()
1111
+ assert err_cpp <= err_torch + 1e-3, (
1112
+ f"C++ conv less accurate than torch: "
1113
+ f"err_cpp={err_cpp:.2e} err_torch={err_torch:.2e}"
1114
+ )
1115
+
1116
+
1117
+@pytest.mark.skipif(
1118
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1119
+)
1120
+@pytest.mark.parametrize("seq_lens", CONV_EQUIV_SEQ_LENS)
1121
+@torch.inference_mode()
1122
+def test_conv_cpp_vnni_packed_matches_torch(seq_lens):
1123
+ """The exact runtime prefill sequence (VNNI-packed weight + SD-layout
1124
+ conv_state view + is_vnni=True) must match the torch fallback. Validates
1125
+ the packing + layout handoff on any AVX-512BF16 CPU."""
1126
+ x, weight, bias = _conv_inputs(sum(seq_lens))
1127
+ out_torch, _ = _run_prefill_torch(x, weight, bias, seq_lens)
1128
+ out_vnni, _ = _run_prefill_cpp(x, weight, bias, seq_lens, is_vnni=True)
1129
+ torch.testing.assert_close(out_vnni, out_torch, atol=1e-2, rtol=1e-2)
1130
+
1131
+
1132
+@pytest.mark.skipif(
1133
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1134
+)
1135
+@pytest.mark.parametrize("batch", DECODE_BATCH_SIZES)
1136
+@torch.inference_mode()
1137
+def test_conv_update_cpp_matches_torch(batch):
1138
+ """Decode conv: causal_conv1d_update_cpu matches causal_conv1d_update_torch,
1139
+ including the in-place conv_state update (the next-step handoff)."""
1140
+ from vllm.model_executor.layers.mamba.ops.cpu.causal_conv1d import (
1141
+ causal_conv1d_update_torch,
1142
+ )
1143
+
1144
+ x = tensor_cache(batch * CONV_DIM, torch.bfloat16).view(batch, CONV_DIM)
1145
+ weight = tensor_cache(CONV_DIM * CONV_KERNEL, torch.bfloat16).view(
1146
+ CONV_DIM, CONV_KERNEL
1147
+ )
1148
+ bias = tensor_cache(CONV_DIM, torch.bfloat16)
1149
+ conv_state = tensor_cache(batch * CONV_DIM * _STATE_LEN, torch.bfloat16).view(
1150
+ batch, CONV_DIM, _STATE_LEN
1151
+ )
1152
+
1153
+ cs_torch = conv_state.clone()
1154
+ out_torch = causal_conv1d_update_torch(
1155
+ x=x.unsqueeze(-1),
1156
+ conv_state=cs_torch,
1157
+ weight=weight,
1158
+ bias=bias,
1159
+ activation="silu",
1160
+ ).squeeze(-1)
1161
+
1162
+ cs_cpp = conv_state.clone()
1163
+ out_cpp = ops.causal_conv1d_update_cpu(
1164
+ x=x.contiguous(),
1165
+ conv_states=cs_cpp,
1166
+ weight=weight,
1167
+ bias=bias,
1168
+ silu_activation=True,
1169
+ conv_state_indices=torch.arange(batch, dtype=torch.int32),
1170
+ is_vnni=False,
1171
+ )
1172
+ torch.testing.assert_close(out_cpp, out_torch, atol=1e-2, rtol=1e-2)
1173
+ torch.testing.assert_close(cs_cpp, cs_torch, atol=1e-2, rtol=1e-2)
1174
+
1175
+
1176
+@pytest.mark.skipif(
1177
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1178
+)
1179
+@torch.inference_mode()
1180
+def test_conv_weight_pack_roundtrip_unpacked_matches():
1181
+ """`causal_conv1d_weight_pack` repacks the (dim, width) weight, so the C++ conv op
1182
+ with packed weight + is_vnni=True must equal unpacked weight + is_vnni=False.
1183
+ Guards the spec-decode contract: the runtime VNNI-packs `layer.conv1d.weight`
1184
+ in place and stashes the original as `_cpu_unpacked_conv_weight` for the torch
1185
+ spec-decode path, so both must produce identical math.
1186
+ """
1187
+ seq_lens = [7, 64, 65]
1188
+ x, weight, bias = _conv_inputs(sum(seq_lens))
1189
+ out_unpacked, _ = _run_prefill_cpp(x, weight, bias, seq_lens, is_vnni=False)
1190
+ out_packed, _ = _run_prefill_cpp(x, weight, bias, seq_lens, is_vnni=True)
1191
+ torch.testing.assert_close(out_packed, out_unpacked, atol=1e-2, rtol=1e-2)
1192
+
1193
+
1194
+@pytest.mark.skipif(
1195
+ not _HAS_AVX512_BF16, reason="C++ causal_conv1d requires AVX-512BF16"
1196
+)
1197
+@torch.inference_mode()
1198
+def test_spec_decode_unpacked_conv_weight_stash():
1199
+ """Spec-decode correctness: AVX-512BF16 CPUs VNNI-pack ``conv1d.weight`` in place
1200
+ at load time and stash the original ``(dim, width)`` tensor as
1201
+ ``_cpu_unpacked_conv_weight``; the spec-decode path must use that stash since
1202
+ reading the packed weight directly produces garbage. Verifies the recovered
1203
+ weight equals the original and that torch F.conv1d agrees with the C++ conv
1204
+ fed the packed weight.
1205
+ """
1206
+ from vllm.model_executor.layers.utils import dispatch_cpu_unquantized_gemm
1207
+
1208
+ torch.manual_seed(0)
1209
+ # conv1d weight is stored [dim, 1, width]; bias [dim].
1210
+ orig_2d = torch.rand(CONV_DIM, CONV_KERNEL, dtype=torch.bfloat16)
1211
+ bias = torch.rand(CONV_DIM, dtype=torch.bfloat16)
1212
+
1213
+ conv = torch.nn.Module()
1214
+ conv.weight = torch.nn.Parameter(
1215
+ orig_2d.view(CONV_DIM, 1, CONV_KERNEL).clone(), requires_grad=False
1216
+ )
1217
+
1218
+ # Load-time dispatch: packs weight in place + stashes the unpacked copy.
1219
+ dispatch_cpu_unquantized_gemm(conv, remove_weight=False)
1220
+
1221
+ # 1. The stash must exist and equal the original (dim, width) weight.
1222
+ assert hasattr(conv, "_cpu_unpacked_conv_weight"), (
1223
+ "dispatch_cpu_unquantized_gemm did not stash _cpu_unpacked_conv_weight "
1224
+ "on an AVX-512BF16 CPU"
1225
+ )
1226
+ stash = conv._cpu_unpacked_conv_weight
1227
+ torch.testing.assert_close(stash, orig_2d, atol=0, rtol=0)
1228
+
1229
+ # 2. Mirror _unpacked_conv_weight()'s lookup: stash wins over conv.weight.
1230
+ recovered = getattr(conv, "_cpu_unpacked_conv_weight", None)
1231
+ assert recovered is not None
1232
+ # conv.weight is now packed; using it directly (the bug) would differ.
1233
+ packed_weight = conv.weight # [dim, 1, width], VNNI-packed contents
1234
+
1235
+ # 3. Spec-path torch conv with the recovered unpacked weight must match the
1236
+ # nonspec C++ conv with the packed weight (is_vnni=True).
1237
+ seq_lens = [7, 65]
1238
+ total = sum(seq_lens)
1239
+ x = tensor_cache(total * CONV_DIM, torch.bfloat16).view(total, CONV_DIM)
1240
+
1241
+ out_torch, _ = _run_prefill_torch(x, recovered, bias, seq_lens)
1242
+
1243
+ num_seqs = len(seq_lens)
1244
+ cs = torch.zeros(num_seqs, _STATE_LEN, CONV_DIM, dtype=x.dtype).transpose(1, 2)
1245
+ qsl = torch.tensor(
1246
+ [0, *torch.tensor(seq_lens).cumsum(0).tolist()], dtype=torch.int32
1247
+ )
1248
+ out_cpp = (
1249
+ ops.causal_conv1d_fwd_cpu(
1250
+ x=x.transpose(0, 1),
1251
+ weight=packed_weight.view(CONV_DIM, CONV_KERNEL),
1252
+ bias=bias,
1253
+ conv_states=cs,
1254
+ query_start_loc=qsl,
1255
+ cache_indices=torch.arange(num_seqs, dtype=torch.int32),
1256
+ has_initial_state=torch.zeros(num_seqs, dtype=torch.bool),
1257
+ silu_activation=True,
1258
+ is_vnni=True,
1259
+ )
1260
+ .transpose(0, 1)
1261
+ .contiguous()
1262
+ )
1263
+
1264
+ torch.testing.assert_close(out_cpp, out_torch, atol=1e-2, rtol=1e-2)
vllm/model_executor/layers/mamba/ops/cpu/gdn_attention.py
+8
-6
@@ -103,13 +103,15 @@ def _cpu_gdn_attention_nonspec(
103
assert state_indices_tensor is not None
104
assert query_start_loc is not None
105
106
- is_amx = torch.cpu._is_amx_tile_supported()
106
+ # C++ conv (conv.cpp) uses VDPBF16PS, not AMX tiles, so it runs on any
107
+ # AVX-512BF16 CPU; weight is VNNI-packed on this same predicate at load time.
108
+ use_cpp_conv = torch.cpu._is_avx512_bf16_supported()
109
110
conv_state = layer.kv_cache[0]
109
- if is_amx:
110
- # AMX causal conv requires [num_allocated_slots, kernel - 1, conv_dim].
111
+ if use_cpp_conv:
112
+ # C++ conv requires [num_allocated_slots, kernel - 1, conv_dim] (SD).
113
if is_conv_state_dim_first():
112
- raise RuntimeError("AMX GDN attention requires `SD` conv_state layout.")
114
+ raise RuntimeError("C++ CPU GDN attention requires `SD` conv_state layout.")
115
conv_state = conv_state.transpose(1, 2)
116
else:
117
if not is_conv_state_dim_first():
@@ -138,7 +140,7 @@ def _cpu_gdn_attention_nonspec(
140
decode_b = b[:num_decode_tokens]
141
decode_a = a[:num_decode_tokens]
142
decode_state_indices = state_indices_tensor[:num_decodes]
141
- if is_amx:
143
+ if use_cpp_conv:
144
decode_mixed_qkv = ops.causal_conv1d_update_cpu(
145
x=decode_mixed_qkv,
146
conv_states=conv_state,
@@ -207,7 +209,7 @@ def _cpu_gdn_attention_nonspec(
209
num_decodes : num_decodes + num_prefills
210
]
211
210
- if is_amx:
212
+ if use_cpp_conv:
213
prefill_mixed_qkv = ops.causal_conv1d_fwd_cpu(
214
x=prefill_mixed_qkv.transpose(0, 1),
215
weight=layer.conv1d.weight,
vllm/model_executor/layers/utils.py
+5
-2
@@ -280,7 +280,10 @@ def dispatch_cpu_unquantized_gemm(
280
if layer.weight.ndim != 2:
281
# this is not a linear layer
282
# For now it should be a causal_conv1d op or MoE 3D expert weights
283
- if torch.cpu._is_amx_tile_supported() and hasattr(
283
+ # The C++ causal_conv1d kernels use VDPBF16PS (no AMX tiles), so the
284
+ # VNNI weight prepack applies to any AVX-512BF16 CPU, not just AMX
285
+ # (e.g. AMD Zen5/Turin).
286
+ if torch.cpu._is_avx512_bf16_supported() and hasattr(
287
ops, "causal_conv1d_weight_pack"
288
):
289
# prepack conv weight
@@ -293,7 +296,7 @@ def dispatch_cpu_unquantized_gemm(
296
.clone()
297
)
298
# Stash the un-packed (dim, width) weight so the speculative-decode
296
- # GDN path (which uses torch conv, not the AMX kernel) can use it.
299
+ # GDN path (which uses torch conv, not the C++ kernel) can use it.
300
layer._cpu_unpacked_conv_weight = unpacked
301
layer.weight.data = ops.causal_conv1d_weight_pack(unpacked)
302
return
vllm/platforms/cpu.py
+7
-5
@@ -181,13 +181,13 @@ class CpuPlatform(Platform):
181
"otherwise the performance is not optimized."
182
)
183
184
- # AMX GDN requires float32 state
184
+ # Accelerated GDN (AMX tiles or AVX-512BF16 VDPBF16PS) requires float32 SSM state.
185
if (
186
- torch.cpu._is_amx_tile_supported()
186
+ torch.cpu._is_avx512_bf16_supported()
187
and cache_config.mamba_ssm_cache_dtype != "float32"
188
):
189
cache_config.mamba_ssm_cache_dtype = "float32"
190
- logger.warning("Reset SSM cache type to float32 for AMX mamba attention.")
190
+ logger.warning("Reset SSM cache type to float32 for accelerated GDN mamba attention.")
191
192
# Lagecy setting
193
env_key = "VLLM_CPU_KVCACHE_SPACE"
@@ -290,8 +290,10 @@ class CpuPlatform(Platform):
290
# Avoid inductor generates num_thread() and breaks the thread binding
291
os.environ["TORCHINDUCTOR_CPP_DYNAMIC_THREADS"] = "1"
292
293
- # For efficient conv state memory access
294
- if torch.cpu._is_amx_tile_supported():
293
+ # For efficient conv state memory access. The C++ causal_conv1d
294
+ # kernels (VDPBF16PS, no AMX tiles) consume the SD layout on any
295
+ # AVX-512BF16 CPU, so apply it beyond AMX (e.g. AMD Zen5/Turin).
296
+ if torch.cpu._is_avx512_bf16_supported():
297
os.environ["VLLM_SSM_CONV_STATE_LAYOUT"] = "SD"
298
299
ld_preload_str = os.getenv("LD_PRELOAD", "")