[CPU][Zen] Add DA8W4 (W4A8) int4 support for dense and MoE layers (#54024)

Signed-off-by: R <Ganesh.R@amd.com> Signed-off-by: Ganesh R <Ganesh.R@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Ganesh R committed Oct 1, 2026 at 12:48 UTC e12291d7332db897885fdc0e6ed61b28c969d743
9 files changed +1184 -15
.buildkite/hardware_tests/cpu.yaml
+4
@@ -24,6 +24,9 @@ steps:
24 - tests/kernels/test_onednn.py
25 - tests/kernels/test_awq_int4_to_int8.py
26 - tests/kernels/quantization/test_cpu_fp8_scaled_mm.py
27 + - tests/kernels/quantization/test_zen_da8w4.py
28 + - vllm/model_executor/kernels/linear/mixed_precision/zentorch.py
29 + - vllm/model_executor/layers/fused_moe/experts/zentorch_moe.py
30 - tests/kernels/mamba/cpu/test_cpu_gdn_ops.py
31 - tests/kernels/mamba/test_cpu_short_conv.py
32 - tests/kernels/mamba/test_causal_conv1d.py
@@ -46,6 +49,7 @@ steps:
49 tests/kernels/test_onednn.py \
50 tests/kernels/test_awq_int4_to_int8.py \
51 tests/kernels/quantization/test_cpu_fp8_scaled_mm.py \
52 + tests/kernels/quantization/test_zen_da8w4.py \
53 tests/kernels/mamba/cpu/test_cpu_gdn_ops.py \
54 tests/kernels/mamba/test_causal_conv1d.py \
55 tests/kernels/mamba/test_mamba_ssm.py \
tests/kernels/quantization/test_zen_da8w4.py new
+661
@@ -0,0 +1,661 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the zentorch DA8W4 (W4A8) int4 linear and MoE paths on Zen CPUs.
4 +
5 +The zentorch ops are mocked with reference implementations when zentorch is not
6 +installed, so the layout and dispatch contracts are covered in CI.
7 +"""
8 +
9 +import dataclasses
10 +
11 +import pytest
12 +import torch
13 +from compressed_tensors.compressors.pack_quantized.helpers import pack_to_int32
14 +
15 +from tests.kernels.quant_utils import ref_dynamic_per_token_quant
16 +from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import (
17 + MPLinearLayerConfig,
18 +)
19 +from vllm.model_executor.kernels.linear.mixed_precision.zentorch import (
20 + ZentorchWNA16LinearKernel,
21 + _import_unpack_from_int32,
22 +)
23 +from vllm.model_executor.layers.fused_moe.oracle import int_wna16
24 +from vllm.model_executor.layers.fused_moe.oracle.int_wna16 import WNA16MoEBackend
25 +from vllm.platforms import current_platform
26 +from vllm.scalar_type import scalar_types
27 +
28 +if not current_platform.is_cpu():
29 + pytest.skip("skipping CPU-only tests", allow_module_level=True)
30 +
31 +GROUP_SIZE = 128
32 +IN_FEATURES = 512
33 +OUT_FEATURES = 128
34 +
35 +
36 +def _unpack_s4(packed: torch.Tensor, in_features: int) -> torch.Tensor:
37 + """int8 [N, K/2] (or int32 [N, K/8]) packed s4 -> float32 [N, K]."""
38 + words = packed.view(torch.int32)
39 + out = torch.zeros(words.shape[0], in_features, dtype=torch.float32)
40 + for i in range(8):
41 + nibble = (words >> (4 * i)) & 0xF
42 + out[:, i::8] = torch.where(nibble > 7, nibble - 16, nibble).float()
43 + return out
44 +
45 +
46 +def _repack_s4(unpacked: torch.Tensor) -> torch.Tensor:
47 + """Reference zentorch_woq_repack_weight: int8 [N, K] -> int32 [N, K/8]."""
48 + n, k = unpacked.shape
49 + values = unpacked.to(torch.int32).reshape(n, k // 8, 8)
50 + out = torch.zeros(n, k // 8, dtype=torch.int32)
51 + for i in range(8):
52 + out |= (values[:, :, i] & 0xF) << (4 * i)
53 + return out
54 +
55 +
56 +def _quantize_per_group(
57 + weight: torch.Tensor, group_size: int
58 +) -> tuple[torch.Tensor, torch.Tensor]:
59 + """Symmetric int4 per-group quantization -> (int8 in [-8, 7], scale [N, G])."""
60 + out_features, in_features = weight.shape
61 + grouped = weight.float().reshape(
62 + out_features, in_features // group_size, group_size
63 + )
64 + scale = grouped.abs().amax(dim=-1) / 8.0
65 + quantized = (grouped / scale.unsqueeze(-1)).round().clamp(-8, 7).to(torch.int8)
66 + return quantized.reshape(out_features, in_features), scale.to(torch.bfloat16)
67 +
68 +
69 +def _dequantize_per_group(
70 + quantized: torch.Tensor, scale: torch.Tensor, group_size: int
71 +) -> torch.Tensor:
72 + """Dequantize with [N, G] scales into a float32 [N, K] weight."""
73 + return quantized.float() * scale.float().repeat_interleave(group_size, dim=1)
74 +
75 +
76 +def _dynamic_quant_matmul(
77 + x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None
78 +) -> torch.Tensor:
79 + """DA8W4 reference: per-token int8 activation quant against a bf16 weight."""
80 + x_q, scale = ref_dynamic_per_token_quant(x.float(), torch.int8)
81 + out = (x_q.float() @ weight.t()) * scale
82 + if bias is not None:
83 + out = out + bias.float()
84 + return out
85 +
86 +
87 +@pytest.fixture
88 +def mock_zentorch_ops():
89 + """Register reference zentorch ops when zentorch is not installed."""
90 + if hasattr(torch.ops.zentorch, "zentorch_dynamic_qlinear"):
91 + yield None
92 + return
93 +
94 + calls: dict[str, tuple] = {}
95 +
96 + def _dynamic_qlinear(input, weight, weight_scales, bias=None, zentorch_op_name=""):
97 + calls["dynamic_qlinear"] = (input, weight, weight_scales, bias)
98 + in_features = weight.view(torch.int32).shape[1] * 8
99 + group_size = in_features // weight_scales.shape[0]
100 + # weight_scales is [G, N]; expand to a [N, K] dequantized weight.
101 + scales = weight_scales.float().t().repeat_interleave(group_size, dim=1)
102 + weight_deq = _unpack_s4(weight, in_features) * scales
103 + out = _dynamic_quant_matmul(input, weight_deq, bias)
104 + return out.to(input.dtype)
105 +
106 + def _fused_moe(
107 + output,
108 + input,
109 + w13,
110 + w2,
111 + w13_bias,
112 + w2_bias,
113 + topk_weights,
114 + topk_id,
115 + skip_weighted,
116 + act,
117 + w13_scales=None,
118 + w2_scales=None,
119 + zentorch_op_name="",
120 + ):
121 + calls["fused_moe"] = (
122 + input,
123 + w13,
124 + w2,
125 + topk_weights,
126 + topk_id,
127 + skip_weighted,
128 + act,
129 + w13_scales,
130 + w2_scales,
131 + )
132 + output.zero_()
133 +
134 + lib_def = torch.library.Library("zentorch", "DEF")
135 + lib_def.define("zentorch_woq_repack_weight(Tensor unpacked_weight) -> Tensor")
136 + lib_def.define(
137 + "zentorch_dynamic_qlinear(Tensor input, Tensor weight, "
138 + "Tensor weight_scales, Tensor? bias=None, *, "
139 + "str zentorch_op_name='zentorch::zentorch_dynamic_qlinear') -> Tensor"
140 + )
141 + lib_def.define(
142 + "zentorch_fused_moe(Tensor(a!) output, Tensor input, Tensor w13, "
143 + "Tensor w2, Tensor? w13_bias, Tensor? w2_bias, Tensor topk_weights, "
144 + "Tensor topk_id, bool skip_weighted, str act, "
145 + "Tensor? w13_scales=None, Tensor? w2_scales=None, *, "
146 + "str zentorch_op_name='zentorch::zentorch_fused_moe') -> ()"
147 + )
148 +
149 + lib_impl = torch.library.Library("zentorch", "IMPL", "CPU")
150 + lib_impl.impl("zentorch_woq_repack_weight", _repack_s4)
151 + lib_impl.impl("zentorch_dynamic_qlinear", _dynamic_qlinear)
152 + lib_impl.impl("zentorch_fused_moe", _fused_moe)
153 +
154 + yield calls
155 +
156 + lib_impl._destroy()
157 + lib_def._destroy()
158 +
159 +
160 +def _make_config(
161 + act_type: torch.dtype = torch.bfloat16,
162 + weight_type=scalar_types.uint4b8,
163 + zero_points: bool = False,
164 + group_size: int = GROUP_SIZE,
165 +) -> MPLinearLayerConfig:
166 + return MPLinearLayerConfig(
167 + full_weight_shape=(IN_FEATURES, OUT_FEATURES),
168 + partition_weight_shape=(IN_FEATURES, OUT_FEATURES),
169 + weight_type=weight_type,
170 + act_type=act_type,
171 + group_size=group_size,
172 + zero_points=zero_points,
173 + )
174 +
175 +
176 +def _make_layer(group_size: int = GROUP_SIZE) -> tuple[torch.nn.Module, torch.Tensor]:
177 + """Build a layer holding a CT-packed int4 weight, plus its dequantized form."""
178 + torch.manual_seed(0)
179 + weight = torch.randn(OUT_FEATURES, IN_FEATURES, dtype=torch.bfloat16)
180 + quantized, scale = _quantize_per_group(weight, group_size)
181 +
182 + # compressed-tensors stores [N, K//8] int32, packed along the input dim.
183 + packed = pack_to_int32(quantized, 4, packed_dim=1)
184 +
185 + layer = torch.nn.Module()
186 + layer.weight_packed = torch.nn.Parameter(packed, requires_grad=False)
187 + layer.weight_packed.input_dim = 1
188 + layer.weight_packed.packed_dim = 1
189 + layer.weight_scale = torch.nn.Parameter(scale, requires_grad=False)
190 + layer.weight_zero_point = None
191 + return layer, _dequantize_per_group(quantized, scale, group_size)
192 +
193 +
194 +def _make_kernel(config: MPLinearLayerConfig) -> ZentorchWNA16LinearKernel:
195 + return ZentorchWNA16LinearKernel(
196 + config, "weight_packed", "weight_scale", "weight_zero_point"
197 + )
198 +
199 +
200 +# ---------------------------------------------------------------------------
201 +# Dense DA8W4
202 +# ---------------------------------------------------------------------------
203 +
204 +
205 +def test_da8w4_eligible_for_symmetric_bf16_layer(mock_zentorch_ops, monkeypatch):
206 + monkeypatch.setattr(
207 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
208 + "current_platform.is_zen_cpu",
209 + lambda: True,
210 + )
211 + layer, _ = _make_layer()
212 + kernel = _make_kernel(_make_config())
213 + assert kernel._maybe_process_da8w4_weights(layer)
214 +
215 +
216 +@pytest.mark.parametrize(
217 + "config_kwargs,reason",
218 + [
219 + ({"act_type": torch.float32}, "f32 activations are rejected"),
220 + (
221 + {"weight_type": scalar_types.uint4, "zero_points": True},
222 + "asymmetric int4 is unsupported",
223 + ),
224 + ],
225 +)
226 +def test_da8w4_not_eligible(mock_zentorch_ops, monkeypatch, config_kwargs, reason):
227 + monkeypatch.setattr(
228 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
229 + "current_platform.is_zen_cpu",
230 + lambda: True,
231 + )
232 + layer, _ = _make_layer()
233 + kernel = _make_kernel(_make_config(**config_kwargs))
234 + assert not kernel._maybe_process_da8w4_weights(layer), reason
235 +
236 +
237 +def test_da8w4_not_eligible_when_env_disabled(mock_zentorch_ops, monkeypatch):
238 + monkeypatch.setattr(
239 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
240 + "current_platform.is_zen_cpu",
241 + lambda: True,
242 + )
243 + monkeypatch.setattr(
244 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
245 + "envs.VLLM_CPU_INT4_W4A8",
246 + False,
247 + )
248 + layer, _ = _make_layer()
249 + kernel = _make_kernel(_make_config())
250 + assert not kernel._maybe_process_da8w4_weights(layer)
251 +
252 +
253 +def test_da8w4_process_weights_layout(mock_zentorch_ops, monkeypatch):
254 + monkeypatch.setattr(
255 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
256 + "current_platform.is_zen_cpu",
257 + lambda: True,
258 + )
259 + layer, _ = _make_layer()
260 + kernel = _make_kernel(_make_config())
261 + kernel.process_weights_after_loading(layer)
262 +
263 + assert layer._zentorch_da8w4
264 + assert layer._zentorch_kind == "compressed_tensors_w4a8_da8w4"
265 + # Packed s4 holds two nibbles per byte, and scales transpose to {G, N}.
266 + assert layer._zentorch_da8w4_packed.dtype == torch.int8
267 + assert layer._zentorch_da8w4_packed.shape == (OUT_FEATURES, IN_FEATURES // 2)
268 + assert layer._zentorch_da8w4_scale.dtype == torch.bfloat16
269 + assert layer._zentorch_da8w4_scale.shape == (
270 + IN_FEATURES // GROUP_SIZE,
271 + OUT_FEATURES,
272 + )
273 + assert layer._zentorch_da8w4_packed.is_contiguous()
274 + assert layer._zentorch_da8w4_scale.is_contiguous()
275 + # The checkpoint parameters are released once repacked.
276 + assert layer.weight_packed.numel() == 0
277 + assert layer.weight_scale.numel() == 0
278 +
279 +
280 +def test_da8w4_apply_weights_matches_dequantized_reference(
281 + mock_zentorch_ops, monkeypatch
282 +):
283 + monkeypatch.setattr(
284 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
285 + "current_platform.is_zen_cpu",
286 + lambda: True,
287 + )
288 + layer, weight_deq = _make_layer()
289 + kernel = _make_kernel(_make_config())
290 + kernel.process_weights_after_loading(layer)
291 +
292 + x = torch.randn(4, IN_FEATURES, dtype=torch.bfloat16)
293 + bias = torch.randn(OUT_FEATURES, dtype=torch.bfloat16)
294 + out = kernel.apply_weights(layer, x, bias)
295 +
296 + expected = _dynamic_quant_matmul(x, weight_deq, bias)
297 + assert out.dtype == torch.bfloat16
298 + torch.testing.assert_close(out.float(), expected, rtol=2e-2, atol=2e-2)
299 +
300 +
301 +def test_da8w4_falls_back_to_w4a16_when_op_missing(monkeypatch):
302 + """Without zentorch_dynamic_qlinear the layer must not take the DA8W4 path."""
303 + monkeypatch.setattr(
304 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch.has_zentorch_op",
305 + lambda ops: "zentorch_dynamic_qlinear" not in ops,
306 + )
307 + layer, _ = _make_layer()
308 + kernel = _make_kernel(_make_config())
309 + assert not kernel._maybe_process_da8w4_weights(layer)
310 +
311 +
312 +def test_can_implement_requires_zen_cpu(monkeypatch):
313 + monkeypatch.setattr(
314 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
315 + "current_platform.is_zen_cpu",
316 + lambda: False,
317 + )
318 + ok, reason = ZentorchWNA16LinearKernel.can_implement(_make_config())
319 + assert not ok
320 + assert reason is not None
321 +
322 +
323 +def test_can_implement_accepts_shapes_cpuwna16_rejects(monkeypatch):
324 + """CPUWNA16's N/K % 32 rule is a oneDNN packing constraint zentorch lacks."""
325 + from vllm.model_executor.kernels.linear.mixed_precision.cpu import (
326 + CPUWNA16LinearKernel,
327 + )
328 +
329 + monkeypatch.setattr(
330 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
331 + "current_platform.is_zen_cpu",
332 + lambda: True,
333 + )
334 + monkeypatch.setattr(
335 + "vllm.model_executor.kernels.linear.mixed_precision.cpu."
336 + "current_platform.is_cpu",
337 + lambda: True,
338 + )
339 + monkeypatch.setattr(
340 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch.has_zentorch_op",
341 + lambda ops: True,
342 + )
343 +
344 + unaligned = dataclasses.replace(
345 + _make_config(), partition_weight_shape=(IN_FEATURES, OUT_FEATURES - 16)
346 + )
347 + assert unaligned.partition_weight_shape[1] % 32 != 0
348 +
349 + ok, _ = CPUWNA16LinearKernel.can_implement(unaligned)
350 + assert not ok, "the parent is expected to turn this shape away"
351 +
352 + ok, reason = ZentorchWNA16LinearKernel.can_implement(unaligned)
353 + assert ok, reason
354 +
355 +
356 +@pytest.mark.parametrize(
357 + "group_size,expected_ok",
358 + [
359 + (-1, True), # per-channel, one group spanning K
360 + (GROUP_SIZE, True),
361 + (IN_FEATURES, True),
362 + (6, False), # even, so the parent's multiple-of-2 rule would allow it
363 + (0, False),
364 + ],
365 +)
366 +def test_can_implement_group_size_must_divide_k(monkeypatch, group_size, expected_ok):
367 + """ZenDNN WOQ groups along K, so a group that straddles the end is unusable."""
368 + monkeypatch.setattr(
369 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch."
370 + "current_platform.is_zen_cpu",
371 + lambda: True,
372 + )
373 + monkeypatch.setattr(
374 + "vllm.model_executor.kernels.linear.mixed_precision.zentorch.has_zentorch_op",
375 + lambda ops: True,
376 + )
377 +
378 + ok, reason = ZentorchWNA16LinearKernel.can_implement(
379 + _make_config(group_size=group_size)
380 + )
381 + assert ok is expected_ok, reason
382 + if not expected_ok:
383 + assert reason is not None
384 + assert "must divide input size" in reason
385 +
386 +
387 +# ---------------------------------------------------------------------------
388 +# MoE DA8W4
389 +# ---------------------------------------------------------------------------
390 +
391 +NUM_EXPERTS = 4
392 +HIDDEN = 256
393 +INTERMEDIATE = 128
394 +
395 +
396 +def _make_moe_weights(group_size: int = 32):
397 + """Build CT-layout MoE weights: w13 [E, 2I, H//8], w2 [E, H, I//8]."""
398 + torch.manual_seed(0)
399 + w13_q, w13_s, w2_q, w2_s = [], [], [], []
400 + for _ in range(NUM_EXPERTS):
401 + w13 = torch.randn(2 * INTERMEDIATE, HIDDEN, dtype=torch.bfloat16)
402 + q13, s13 = _quantize_per_group(w13, group_size)
403 + # CT is N-first and packs along the input dim: [N, K//8] per expert.
404 + w13_q.append(pack_to_int32(q13, 4, packed_dim=1))
405 + w13_s.append(s13)
406 +
407 + w2 = torch.randn(HIDDEN, INTERMEDIATE, dtype=torch.bfloat16)
408 + q2, s2 = _quantize_per_group(w2, group_size)
409 + w2_q.append(pack_to_int32(q2, 4, packed_dim=1))
410 + w2_s.append(s2)
411 + return (
412 + torch.stack(w13_q),
413 + torch.stack(w2_q),
414 + torch.stack(w13_s),
415 + torch.stack(w2_s),
416 + )
417 +
418 +
419 +def test_zen_cpu_first_in_cpu_backend_priority(monkeypatch):
420 + monkeypatch.setattr(int_wna16.current_platform, "is_cpu", lambda: True)
421 + backends = int_wna16._get_priority_backends()
422 + assert backends[0] == WNA16MoEBackend.ZEN_CPU
423 + # The generic CPU backend stays available as a fallback.
424 + assert WNA16MoEBackend.CPU in backends
425 +
426 +
427 +def test_zen_cpu_process_weights_layout(mock_zentorch_ops):
428 + group_size = 32
429 + w13, w2, w13_scale, w2_scale = _make_moe_weights(group_size)
430 + assert w13.shape == (NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN // 8)
431 + assert w2.shape == (NUM_EXPERTS, HIDDEN, INTERMEDIATE // 8)
432 +
433 + converted = int_wna16._process_weights_zen_cpu(w13, w2, w13_scale, w2_scale)
434 + w13_out, w2_out, w13_s_out, w2_s_out = converted[:4]
435 +
436 + # zentorch consumes [E, N, K/8] packed s4 with per-group [E, G, N] scales.
437 + assert w13_out.shape == (NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN // 8)
438 + assert w2_out.shape == (NUM_EXPERTS, HIDDEN, INTERMEDIATE // 8)
439 + assert w13_out.dtype == w2_out.dtype == torch.int32
440 + assert w13_s_out.shape == (NUM_EXPERTS, HIDDEN // group_size, 2 * INTERMEDIATE)
441 + assert w2_s_out.shape == (NUM_EXPERTS, INTERMEDIATE // group_size, HIDDEN)
442 + assert w13_s_out.dtype == w2_s_out.dtype == torch.bfloat16
443 + # Symmetric checkpoints carry no zero points into the kernel.
444 + assert converted[8] is None and converted[9] is None
445 +
446 +
447 +def test_zen_cpu_repack_is_value_exact(mock_zentorch_ops):
448 + """The repacked weight must dequantize back to the checkpoint values."""
449 + group_size = 32
450 + w13, w2, w13_scale, w2_scale = _make_moe_weights(group_size)
451 + w13_out = int_wna16._process_weights_zen_cpu(w13, w2, w13_scale, w2_scale)[0]
452 +
453 + expected = _import_unpack_from_int32()(
454 + w13,
455 + 4,
456 + torch.Size([NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN]),
457 + packed_dim=1,
458 + )
459 + for expert in range(NUM_EXPERTS):
460 + torch.testing.assert_close(
461 + _unpack_s4(w13_out[expert], HIDDEN),
462 + expected[expert].float(),
463 + )
464 +
465 +
466 +@pytest.mark.parametrize(
467 + "group_size,expected_ok",
468 + [(32, True), (128, True), (-1, False), (2, False)],
469 +)
470 +def test_zen_cpu_group_size_gating(group_size, expected_ok):
471 + quant_config = type("Args", (), {"group_size": group_size})()
472 + reason = int_wna16._backend_incompatibility_reason(
473 + WNA16MoEBackend.ZEN_CPU,
474 + moe_config=None,
475 + quant_config=quant_config,
476 + may_have_zp=False,
477 + may_have_bias=False,
478 + allow_tile_padding=True,
479 + )
480 + assert (reason is None) == expected_ok
481 +
482 +
483 +@pytest.mark.parametrize(
484 + "may_have_zp,may_have_bias,expected_ok",
485 + [(True, False, False), (False, True, True), (True, True, False)],
486 +)
487 +def test_zen_cpu_rejects_zero_points_but_takes_bias(
488 + may_have_zp, may_have_bias, expected_ok
489 +):
490 + quant_config = type("Args", (), {"group_size": 128})()
491 + reason = int_wna16._backend_incompatibility_reason(
492 + WNA16MoEBackend.ZEN_CPU,
493 + moe_config=None,
494 + quant_config=quant_config,
495 + may_have_zp=may_have_zp,
496 + may_have_bias=may_have_bias,
497 + allow_tile_padding=True,
498 + )
499 + assert (reason is None) == expected_ok
500 +
501 +
502 +def test_zen_cpu_rejects_moe_wna16_layout():
503 + from vllm.model_executor.layers.quantization.moe_wna16 import MoeWNA16Config
504 +
505 + quant_config = MoeWNA16Config(
506 + linear_quant_method="gptq",
507 + weight_bits=4,
508 + group_size=128,
509 + has_zp=False,
510 + lm_head_quantized=False,
511 + modules_to_not_convert=None,
512 + full_config={},
513 + )
514 + reason = int_wna16._backend_incompatibility_reason(
515 + WNA16MoEBackend.ZEN_CPU,
516 + moe_config=None,
517 + quant_config=quant_config,
518 + may_have_zp=False,
519 + may_have_bias=False,
520 + allow_tile_padding=True,
521 + )
522 + assert reason is not None
523 +
524 +
525 +def test_zen_cpu_disabled_by_env(monkeypatch):
526 + monkeypatch.setattr(int_wna16.envs, "VLLM_CPU_INT4_W4A8", False)
527 + quant_config = type("Args", (), {"group_size": 128})()
528 + reason = int_wna16._backend_incompatibility_reason(
529 + WNA16MoEBackend.ZEN_CPU,
530 + moe_config=None,
531 + quant_config=quant_config,
532 + may_have_zp=False,
533 + may_have_bias=False,
534 + allow_tile_padding=True,
535 + )
536 + assert reason is not None
537 +
538 +
539 +def test_zen_cpu_backend_maps_to_experts_class():
540 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
541 + ZentorchExpertsInt4,
542 + )
543 +
544 + assert int_wna16.backend_to_kernel_cls(WNA16MoEBackend.ZEN_CPU) == [
545 + ZentorchExpertsInt4
546 + ]
547 +
548 +
549 +def test_zen_experts_support_predicates():
550 + from vllm.model_executor.kernels.linear.zentorch_utils import (
551 + _ZENTORCH_MOE_ACTIVATIONS,
552 + )
553 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
554 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
555 + ZentorchExpertsInt4,
556 + )
557 + from vllm.model_executor.layers.quantization.utils.quant_utils import (
558 + kInt4Static,
559 + kInt4Static32,
560 + kInt8StaticChannelSym,
561 + )
562 +
563 + assert ZentorchExpertsInt4._supports_quant_scheme(kInt4Static, None)
564 + assert ZentorchExpertsInt4._supports_quant_scheme(kInt4Static32, None)
565 + assert not ZentorchExpertsInt4._supports_quant_scheme(kInt8StaticChannelSym, None)
566 + for act in MoEActivation:
567 + expected = act.value in _ZENTORCH_MOE_ACTIVATIONS
568 + assert ZentorchExpertsInt4._supports_activation(act) == expected
569 + assert ZentorchExpertsInt4.requires_interleaved_w13
570 + assert not ZentorchExpertsInt4._supports_no_act_and_mul()
571 + assert ZentorchExpertsInt4._supports_parallel_config(
572 + type("Par", (), {"use_ep": False})()
573 + )
574 + assert not ZentorchExpertsInt4._supports_parallel_config(
575 + type("Par", (), {"use_ep": True})()
576 + )
577 +
578 +
579 +def test_zen_experts_take_custom_routing():
580 + """Models with their own router (gemma-4) reach select_experts through the
581 + config captured off the layer, since apply() cannot be handed the callable."""
582 + from types import SimpleNamespace
583 +
584 + from tests.kernels.moe.test_cpu_fused_moe import _StubMoELayer
585 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
586 + from vllm.model_executor.layers.fused_moe.config import RoutingMethodType
587 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
588 + ZentorchExpertsInt4,
589 + )
590 +
591 + assert ZentorchExpertsInt4._supports_routing_method(
592 + RoutingMethodType.Custom, None, None
593 + )
594 +
595 + def routing_fn(**kwargs):
596 + raise AssertionError("not called")
597 +
598 + layer = _StubMoELayer(
599 + torch.zeros(NUM_EXPERTS, 2 * INTERMEDIATE, HIDDEN),
600 + torch.zeros(NUM_EXPERTS, HIDDEN, INTERMEDIATE),
601 + MoEActivation.SILU,
602 + )
603 + layer.renormalize = True
604 + layer.custom_routing_function = routing_fn
605 +
606 + experts = SimpleNamespace(renormalize=False, custom_routing_function=None)
607 + ZentorchExpertsInt4.process_weights_after_loading(experts, layer)
608 +
609 + assert experts.custom_routing_function is routing_fn
610 + assert experts.renormalize is True
611 +
612 +
613 +def _swigluoai_perm(activation, experts_cls=None):
614 + from vllm.model_executor.layers.fused_moe.experts.cpu_moe import (
615 + swigluoai_w13_interleave_perm,
616 + )
617 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
618 + ZentorchExpertsInt4,
619 + )
620 +
621 + return swigluoai_w13_interleave_perm(
622 + experts_cls or ZentorchExpertsInt4,
623 + activation,
624 + 2 * INTERMEDIATE,
625 + torch.device("cpu"),
626 + )
627 +
628 +
629 +def test_swigluoai_perm_only_for_zen_swigluoai():
630 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
631 +
632 + assert _swigluoai_perm(MoEActivation.SWIGLUOAI) is not None
633 + # Other activations keep the half-split layout the loader leaves.
634 + assert _swigluoai_perm(MoEActivation.SILU) is None
635 + # object stands in for any kernel that never set requires_interleaved_w13.
636 + assert _swigluoai_perm(MoEActivation.SWIGLUOAI, experts_cls=object) is None
637 +
638 +
639 +def test_swigluoai_perm_interleaves_weights_scales_and_bias(mock_zentorch_ops):
640 + """The permutation is a gather on w13's output-channel axis, which is dim 1
641 + for the N-first packed weights and their group scales."""
642 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
643 +
644 + group_size = 32
645 + w13, w2, w13_scale, w2_scale = _make_moe_weights(group_size)
646 + bias = torch.randn(NUM_EXPERTS, 2 * INTERMEDIATE, dtype=torch.bfloat16)
647 + perm = _swigluoai_perm(MoEActivation.SWIGLUOAI)
648 +
649 + baseline = int_wna16._process_weights_zen_cpu(w13, w2, w13_scale, w2_scale)[0]
650 + permuted = int_wna16._process_weights_zen_cpu(
651 + w13[:, perm].contiguous(), w2, w13_scale[:, perm].contiguous(), w2_scale
652 + )[0]
653 +
654 + for expert in range(NUM_EXPERTS):
655 + before = _unpack_s4(baseline[expert], HIDDEN)
656 + after = _unpack_s4(permuted[expert], HIDDEN)
657 + torch.testing.assert_close(after[0::2], before[:INTERMEDIATE])
658 + torch.testing.assert_close(after[1::2], before[INTERMEDIATE:])
659 +
660 + torch.testing.assert_close(bias[:, perm][:, 0::2], bias[:, :INTERMEDIATE])
661 + torch.testing.assert_close(bias[:, perm][:, 1::2], bias[:, INTERMEDIATE:])
vllm/envs.py
+2 -1
@@ -912,7 +912,8 @@ environment_variables: dict[str, Callable[[], Any]] = {
912 "VLLM_ZENTORCH_WEIGHT_PREPACK": lambda: bool(
913 int(os.getenv("VLLM_ZENTORCH_WEIGHT_PREPACK", "1"))
914 ),
915 - # (CPU backend only) whether to use SGLang INT4 W4A8 kernels for AWQ.
915 + # (CPU backend only) whether to use SGLang INT4 W4A8 kernels for AWQ, and
916 + # on Zen CPUs whether to serve int4 checkpoints as DA8W4 rather than W4A16.
917 "VLLM_CPU_INT4_W4A8": lambda: bool(int(os.getenv("VLLM_CPU_INT4_W4A8", "1"))),
918 # If the env var is set, Ray Compiled Graph uses the specified
919 # channel type to communicate between workers belonging to
vllm/model_executor/kernels/linear/mixed_precision/zentorch.py
+113 -8
@@ -1,6 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Zentorch W4A16 GPTQ weight-only-quantized linear kernel for AMD Zen CPUs.
3 +"""Zentorch int4 weight-quantized linear kernels for AMD Zen CPUs.
4
5 Selected by ``choose_mp_linear_kernel`` ahead of the generic oneDNN-backed
6 ``CPUWNA16LinearKernel``. When ``can_implement`` rejects a layer, the selector
@@ -9,12 +9,13 @@ falls through to the next kernel in ``_POSSIBLE_KERNELS[PlatformEnum.CPU]``.
9
10 import torch
11
12 +from vllm import envs
13 from vllm.logger import init_logger
14 from vllm.model_executor.kernels.linear.zentorch_utils import has_zentorch_op
15 from vllm.platforms import current_platform
16 from vllm.scalar_type import scalar_types
17
17 -from .cpu import CPUWNA16LinearKernel
18 +from .cpu import _CPUWNA16_SUPPORTED_QUANT_TYPES, CPUWNA16LinearKernel
19 from .MPLinearKernel import MPLinearLayerConfig
20
21 logger = init_logger(__name__)
@@ -34,17 +35,35 @@ def _import_unpack_from_int32():
35
36
37 class ZentorchWNA16LinearKernel(CPUWNA16LinearKernel):
37 - """W4A16 GPTQ kernel backed by ``torch.ops.zentorch.zentorch_woq_linear``."""
38 + """Int4 kernel backed by ``zentorch_dynamic_qlinear`` (DA8W4) or
39 + ``zentorch_woq_linear`` (W4A16)."""
40
41 @classmethod
42 def can_implement(cls, c: MPLinearLayerConfig) -> tuple[bool, str | None]:
41 - ok, reason = super().can_implement(c)
42 - if not ok:
43 - return ok, reason
44 -
43 if not current_platform.is_zen_cpu():
44 return False, "ZentorchWNA16 requires an AMD Zen CPU."
45
46 + # Not super(): its N/K multiple-of-32 rule is a oneDNN packing
47 + # constraint the zentorch repack does not share.
48 + if c.weight_type not in _CPUWNA16_SUPPORTED_QUANT_TYPES:
49 + return (
50 + False,
51 + f"Quant type ({c.weight_type}) not supported by "
52 + "CPUWNA16, supported types are: "
53 + f"{_CPUWNA16_SUPPORTED_QUANT_TYPES}",
54 + )
55 +
56 + # -1 is per-channel, one group spanning K. Anything else has to divide
57 + # K, which is stricter than the parent's multiple-of-2 rule.
58 + if c.group_size != -1:
59 + in_features = c.partition_weight_shape[0]
60 + if c.group_size <= 0 or in_features % c.group_size != 0:
61 + return (
62 + False,
63 + f"Group size ({c.group_size}) must divide input size "
64 + f"({in_features})",
65 + )
66 +
67 if not has_zentorch_op(["zentorch_woq_repack_weight", "zentorch_woq_linear"]):
68 return (
69 False,
@@ -89,8 +108,81 @@ class ZentorchWNA16LinearKernel(CPUWNA16LinearKernel):
108 num_groups = weight_scale.shape[1]
109 return num_groups > 0 and in_features % num_groups == 0
110
111 + def _maybe_process_da8w4_weights(self, layer: torch.nn.Module) -> bool:
112 + """Repack ``layer`` into the DA8W4 layout, or leave it untouched.
113 +
114 + Returns False without mutating the layer when it is not eligible, so
115 + the caller falls through to the W4A16 paths.
116 + """
117 + if not envs.VLLM_CPU_INT4_W4A8:
118 + return False
119 +
120 + if not has_zentorch_op(["zentorch_dynamic_qlinear"]):
121 + return False
122 +
123 + # DA8W4 is symmetric-only, and the kernel rejects f32 activations.
124 + if self.config.zero_points or self.config.weight_type == scalar_types.uint4:
125 + return False
126 + if self.config.act_type != torch.bfloat16:
127 + return False
128 +
129 + if not self._zentorch_woq_eligible(layer):
130 + return False
131 +
132 + weight_q = getattr(layer, self.w_q_name)
133 + weight_s = getattr(layer, self.w_s_name)
134 + weight_packed = weight_q.data if hasattr(weight_q, "data") else weight_q
135 + weight_scale = weight_s.data if hasattr(weight_s, "data") else weight_s
136 +
137 + bits = self.config.weight_type.mantissa
138 + pack_factor = torch.iinfo(weight_packed.dtype).bits // bits
139 + out_features, num_groups = weight_scale.shape[0], weight_scale.shape[1]
140 + in_features = weight_packed.shape[1] * pack_factor
141 +
142 + # AOCL sym_quant requires K/G to be a multiple of 4; K must be even to
143 + # pack two s4 values per byte.
144 + if (in_features // num_groups) % 4 != 0 or in_features % 2 != 0:
145 + return False
146 +
147 + if self.w_zp_name is not None:
148 + setattr(layer, self.w_zp_name, None)
149 +
150 + unpack_from_int32 = _import_unpack_from_int32()
151 +
152 + weight_unpacked = unpack_from_int32(
153 + weight_packed,
154 + bits,
155 + torch.Size([out_features, in_features]),
156 + packed_dim=weight_q.packed_dim,
157 + )
158 + # The WOQ repack packs 8 int4 per int32, which on little-endian is the
159 + # same byte stream as s4 [N, K/2]; the kernel takes either view.
160 + layer._zentorch_da8w4_packed = (
161 + torch.ops.zentorch.zentorch_woq_repack_weight.default(
162 + weight_unpacked.to(torch.int8).contiguous()
163 + ).view(torch.int8)
164 + )
165 + # CT stores scales as [N, G]; DA8W4 wants per-group {G, N}.
166 + layer._zentorch_da8w4_scale = weight_scale.t().to(torch.bfloat16).contiguous()
167 +
168 + for param_name in (self.w_q_name, self.w_s_name):
169 + param = getattr(layer, param_name, None)
170 + if param is not None and hasattr(param, "data"):
171 + param.data = torch.empty(0)
172 +
173 + layer._zentorch_kind = "compressed_tensors_w4a8_da8w4"
174 + layer._zentorch_da8w4 = True
175 + layer._zentorch_processed_weights = True
176 + logger.info_once(
177 + "[zen_cpu] Using zentorch_dynamic_qlinear for DA8W4 (W4A8) "
178 + "(weight_type=%s, group_size=%d)",
179 + self.config.weight_type,
180 + in_features // num_groups,
181 + )
182 + return True
183 +
184 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
93 - """Repack CT GPTQ weights into the zentorch WOQ layout.
185 + """Repack CT GPTQ weights into the zentorch DA8W4 or WOQ layout.
186
187 Falls back to ``CPUWNA16LinearKernel.process_weights_after_loading``
188 via ``super()`` when the layer doesn't satisfy
@@ -101,6 +193,9 @@ class ZentorchWNA16LinearKernel(CPUWNA16LinearKernel):
193 if getattr(layer, "_zentorch_processed_weights", False):
194 return
195
196 + if self._maybe_process_da8w4_weights(layer):
197 + return
198 +
199 if not self._zentorch_woq_eligible(layer):
200 logger.info_once(
201 "[zen_cpu] ZentorchWNA16 fast path not eligible for this "
@@ -186,6 +281,16 @@ class ZentorchWNA16LinearKernel(CPUWNA16LinearKernel):
281 x: torch.Tensor,
282 bias: torch.Tensor | None = None,
283 ) -> torch.Tensor:
284 + if getattr(layer, "_zentorch_da8w4", False):
285 + # The kernel reads bias through a raw pointer, so it must be
286 + # contiguous; the weight and scales already are.
287 + return torch.ops.zentorch.zentorch_dynamic_qlinear.default(
288 + x if x.dtype == torch.bfloat16 else x.to(torch.bfloat16),
289 + layer._zentorch_da8w4_packed,
290 + layer._zentorch_da8w4_scale,
291 + bias.contiguous() if bias is not None else None,
292 + )
293 +
294 if getattr(layer, "_zentorch_processed_weights", False):
295 return torch.ops.zentorch.zentorch_woq_linear.default(
296 x,
vllm/model_executor/layers/fused_moe/experts/cpu_moe.py
+26
@@ -51,6 +51,32 @@ from vllm.platforms import CpuArchEnum, current_platform
51 from vllm.utils.math_utils import round_up
52
53 logger = init_logger(__name__)
54 +# ===========================================================================
55 +# Weight layout
56 +# ===========================================================================
57 +
58 +
59 +def swigluoai_w13_interleave_perm(
60 + experts_cls: type[mk.FusedMoEExperts],
61 + activation: MoEActivation,
62 + two_i: int,
63 + device: torch.device,
64 +) -> torch.Tensor | None:
65 + """Index taking a half-split w13 ``[gate | up]`` to the interleaved order
66 + ZenDNN's ``swiglu_oai_mul`` expects, or None when no reorder is needed.
67 + Callers apply it along whichever axis holds their output channels."""
68 + if not getattr(experts_cls, "requires_interleaved_w13", False):
69 + return None
70 + if activation != MoEActivation.SWIGLUOAI:
71 + return None
72 +
73 + i = two_i // 2
74 + return torch.stack(
75 + [torch.arange(0, i, device=device), torch.arange(i, two_i, device=device)],
76 + dim=1,
77 + ).flatten()
78 +
79 +
80 # ===========================================================================
81 # Unquantized (BF16/FP16/FP32) MoE
82 # ===========================================================================
vllm/model_executor/layers/fused_moe/experts/zentorch_moe.py new
+204
@@ -0,0 +1,204 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Zentorch fused MoE experts for AMD Zen CPUs."""
4 +
5 +from collections.abc import Callable
6 +
7 +import torch
8 +
9 +import vllm.model_executor.layers.fused_moe.modular_kernel as mk
10 +from vllm.model_executor.kernels.linear.zentorch_utils import (
11 + _ZENTORCH_MOE_ACTIVATIONS,
12 + _moe_activation_to_str,
13 + has_zentorch_op,
14 +)
15 +from vllm.model_executor.layers.fused_moe.activation import MoEActivation
16 +from vllm.model_executor.layers.fused_moe.config import (
17 + FusedMoEConfig,
18 + FusedMoEParallelConfig,
19 + FusedMoEQuantConfig,
20 + RoutingMethodType,
21 +)
22 +from vllm.model_executor.layers.fused_moe.router.cpu_router import select_experts
23 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
24 + QuantKey,
25 + kInt4Static,
26 + kInt4Static32,
27 +)
28 +
29 +
30 +class ZentorchExpertsInt4(mk.FusedMoEExpertsMonolithic):
31 + """DA8W4 (W4A8) group-quantized monolithic MoE experts."""
32 +
33 + # swiglu_oai_mul reads gate/up interleaved, not in the half-split order the
34 + # weight loader leaves behind.
35 + requires_interleaved_w13 = True
36 +
37 + def __init__(
38 + self,
39 + moe_config: FusedMoEConfig,
40 + quant_config: FusedMoEQuantConfig,
41 + ):
42 + super().__init__(moe_config, quant_config)
43 + # Defaults hold until process_weights_after_loading sees the layer.
44 + self.renormalize = moe_config.routing_method in (
45 + RoutingMethodType.Renormalize,
46 + RoutingMethodType.RenormalizeNaive,
47 + )
48 + self.custom_routing_function: Callable | None = None
49 +
50 + def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
51 + """Capture the router config that the monolithic apply() signature
52 + cannot carry; Custom routing has no other source for it."""
53 + self.renormalize = layer.renormalize
54 + self.custom_routing_function = layer.custom_routing_function
55 +
56 + @property
57 + def expects_unquantized_inputs(self) -> bool:
58 + return True
59 +
60 + @staticmethod
61 + def activation_format() -> mk.FusedMoEActivationFormat:
62 + return mk.FusedMoEActivationFormat.Standard
63 +
64 + @staticmethod
65 + def _supports_current_device() -> bool:
66 + return has_zentorch_op(["zentorch_fused_moe", "zentorch_woq_repack_weight"])
67 +
68 + @staticmethod
69 + def is_supported_config(
70 + cls: type[mk.FusedMoEExperts],
71 + moe_config: FusedMoEConfig,
72 + weight_key: QuantKey | None,
73 + activation_key: QuantKey | None,
74 + activation_format: mk.FusedMoEActivationFormat,
75 + ) -> tuple[bool, str | None]:
76 + supported, reason = mk.FusedMoEExperts.is_supported_config(
77 + cls, moe_config, weight_key, activation_key, activation_format
78 + )
79 + if not supported:
80 + return supported, reason
81 + if moe_config.in_dtype != torch.bfloat16:
82 + return False, "kernel requires bfloat16 activations"
83 + # The grouped GEMM needs tokens spread over at least two experts, which
84 + # top_k=1 cannot satisfy once decode drops to a single token.
85 + if moe_config.experts_per_token < 2:
86 + return False, "kernel requires experts_per_token >= 2"
87 + return True, None
88 +
89 + @staticmethod
90 + def _supports_no_act_and_mul() -> bool:
91 + return False
92 +
93 + @staticmethod
94 + def _supports_activation(activation: MoEActivation) -> bool:
95 + return _moe_activation_to_str(activation) in _ZENTORCH_MOE_ACTIVATIONS
96 +
97 + @staticmethod
98 + def _supports_parallel_config(
99 + moe_parallel_config: FusedMoEParallelConfig,
100 + ) -> bool:
101 + # apply() passes select_experts' global ids straight to the kernel, so
102 + # every expert has to be resident on the rank.
103 + return not moe_parallel_config.use_ep
104 +
105 + @staticmethod
106 + def _supports_quant_scheme(
107 + weight_key: QuantKey | None,
108 + activation_key: QuantKey | None,
109 + ) -> bool:
110 + return (weight_key, activation_key) in [
111 + (kInt4Static, None),
112 + (kInt4Static32, None),
113 + ]
114 +
115 + @staticmethod
116 + def _supports_routing_method(
117 + routing_method: RoutingMethodType,
118 + weight_key: QuantKey | None,
119 + activation_key: QuantKey | None,
120 + ) -> bool:
121 + return routing_method in [
122 + RoutingMethodType.Default,
123 + RoutingMethodType.Renormalize,
124 + RoutingMethodType.RenormalizeNaive,
125 + RoutingMethodType.Custom,
126 + ]
127 +
128 + @staticmethod
129 + def _supports_router_logits_dtype(
130 + router_logits_dtype: torch.dtype | None,
131 + routing_method: RoutingMethodType,
132 + ) -> bool:
133 + return True
134 +
135 + def supports_expert_map(self) -> bool:
136 + return False
137 +
138 + def apply(
139 + self,
140 + hidden_states: torch.Tensor,
141 + w1: torch.Tensor,
142 + w2: torch.Tensor,
143 + router_logits: torch.Tensor,
144 + activation: MoEActivation,
145 + global_num_experts: int,
146 + expert_map: torch.Tensor | None,
147 + a1q_scale: torch.Tensor | None,
148 + apply_router_weight_on_input: bool,
149 + # grouped topk + fused topk bias parameters
150 + num_expert_group: int | None = None,
151 + e_score_correction_bias: torch.Tensor | None = None,
152 + routed_scaling_factor: float | None = None,
153 + topk_group: int | None = None,
154 + ) -> torch.Tensor:
155 + if apply_router_weight_on_input:
156 + raise NotImplementedError(
157 + "ZentorchExpertsInt4 does not support "
158 + "apply_router_weight_on_input=True."
159 + )
160 +
161 + if (
162 + self.moe_config.routing_method == RoutingMethodType.Custom
163 + and self.custom_routing_function is None
164 + ):
165 + raise RuntimeError(
166 + "ZentorchExpertsInt4 needs the model's custom_routing_function "
167 + "for RoutingMethodType.Custom."
168 + )
169 +
170 + topk_weights, topk_ids = select_experts(
171 + hidden_states=hidden_states,
172 + router_logits=router_logits,
173 + use_grouped_topk=num_expert_group is not None,
174 + top_k=self.moe_config.experts_per_token,
175 + renormalize=self.renormalize,
176 + topk_group=topk_group,
177 + num_expert_group=num_expert_group,
178 + custom_routing_function=self.custom_routing_function,
179 + scoring_func="softmax",
180 + routed_scaling_factor=(
181 + routed_scaling_factor if routed_scaling_factor is not None else 1.0
182 + ),
183 + e_score_correction_bias=e_score_correction_bias,
184 + )
185 +
186 + output = torch.empty_like(hidden_states)
187 + torch.ops.zentorch.zentorch_fused_moe(
188 + output,
189 + hidden_states,
190 + w1,
191 + w2,
192 + self.w1_bias,
193 + self.w2_bias,
194 + topk_weights,
195 + topk_ids,
196 + False, # skip_weighted
197 + _moe_activation_to_str(activation),
198 + self.w1_scale,
199 + self.w2_scale,
200 + )
201 + return output
202 +
203 +
204 +__all__ = ["ZentorchExpertsInt4"]
vllm/model_executor/layers/fused_moe/oracle/int_wna16.py
+104 -3
@@ -12,6 +12,7 @@ from compressed_tensors.quantization import (
12
13 import vllm._custom_ops as ops
14 import vllm.model_executor.layers.fused_moe.modular_kernel as mk
15 +from vllm import envs
16 from vllm.config.kernel import MoEBackend
17 from vllm.logger import init_logger
18 from vllm.model_executor.layers.fused_moe.config import (
@@ -55,6 +56,7 @@ class WNA16MoEBackend(Enum):
56 BATCHED_MARLIN = "BATCHED_MARLIN"
57 HUMMING = "HUMMING"
58 CPU = "CPU"
59 + ZEN_CPU = "ZEN_CPU"
60 FLASHINFER_TRTLLM = "FLASHINFER_TRTLLM"
61 TRITON = "TRITON"
62 XPU = "XPU"
@@ -98,6 +100,12 @@ def backend_to_kernel_cls(
100 )
101
102 return [CPUExpertsInt4]
103 + elif backend == WNA16MoEBackend.ZEN_CPU:
104 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
105 + ZentorchExpertsInt4,
106 + )
107 +
108 + return [ZentorchExpertsInt4]
109 elif backend == WNA16MoEBackend.EMULATION:
110 from vllm.model_executor.layers.fused_moe.experts.int4_emulation_moe import (
111 Int4EmulationTritonExperts,
@@ -117,7 +125,7 @@ def backend_to_kernel_cls(
125 def _get_priority_backends() -> list[WNA16MoEBackend]:
126 """Get available backends in priority order based on platform and config."""
127 if current_platform.is_cpu():
120 - return [WNA16MoEBackend.CPU]
128 + return [WNA16MoEBackend.ZEN_CPU, WNA16MoEBackend.CPU]
129 if current_platform.is_xpu():
130 return [WNA16MoEBackend.XPU]
131
@@ -160,6 +168,27 @@ def _backend_incompatibility_reason(
168 from vllm.model_executor.layers.quantization.auto_gptq import AutoGPTQConfig
169 from vllm.model_executor.layers.quantization.moe_wna16 import MoeWNA16Config
170
171 + if backend == WNA16MoEBackend.ZEN_CPU:
172 + if not envs.VLLM_CPU_INT4_W4A8:
173 + return "VLLM_CPU_INT4_W4A8=0 disables the DA8W4 path"
174 + if may_have_zp:
175 + return "zero points are not supported"
176 + if isinstance(quant_config, MoeWNA16Config):
177 + return "the MoeWNA16 weight layout is not supported"
178 + if isinstance(quant_config, AutoGPTQConfig) and quant_config.desc_act:
179 + return "GPTQ activation ordering is not supported"
180 + if (
181 + isinstance(quant_config, QuantizationArgs)
182 + and quant_config.actorder == "group"
183 + ):
184 + return "group activation ordering is not supported"
185 + group_size = getattr(quant_config, "group_size", None)
186 + if group_size is None or group_size <= 0:
187 + return "DA8W4 requires group-quantized weights"
188 + # AOCL sym_quant requires the group size to be a multiple of 4.
189 + if group_size % 4 != 0:
190 + return f"group size {group_size} is not a multiple of 4"
191 +
192 if backend == WNA16MoEBackend.TRITON:
193 if may_have_bias:
194 return "expert bias is not supported"
@@ -403,10 +432,14 @@ def make_wna16_moe_kernel(
432 from vllm.model_executor.layers.fused_moe.experts.xpu_moe import (
433 XPUExpertsWNA16,
434 )
435 + from vllm.model_executor.layers.fused_moe.experts.zentorch_moe import (
436 + ZentorchExpertsInt4,
437 + )
438
439 # Currently, we only support TrtLlmMxint4ExpertsMonolithic, MarlinExperts,
408 - # BatchedMarlinExperts, XPUExpertsWNA16, CPUExpertsInt4, the Humming
409 - # grouped/indexed experts, and Int4EmulationTritonExperts
440 + # BatchedMarlinExperts, XPUExpertsWNA16, CPUExpertsInt4,
441 + # ZentorchExpertsInt4, the Humming grouped/indexed experts, and
442 + # Int4EmulationTritonExperts
443 allowed_experts: tuple[type[mk.FusedMoEExperts], ...] = (
444 MarlinExperts,
445 BatchedMarlinExperts,
@@ -414,6 +447,7 @@ def make_wna16_moe_kernel(
447 TrtLlmMxint4ExpertsMonolithic,
448 XPUExpertsWNA16,
449 CPUExpertsInt4,
450 + ZentorchExpertsInt4,
451 Int4EmulationTritonExperts,
452 Rdna3WNA16Experts,
453 )
@@ -1034,6 +1068,64 @@ def _process_weights_cpu(
1068 )
1069
1070
1071 +def _zen_repack_s4(packed: torch.Tensor, in_features: int) -> torch.Tensor:
1072 + """Repack CT int4 ``[E, N, K//8]`` into zentorch s4 ``[E, N, K//8]``."""
1073 + from vllm.model_executor.kernels.linear.mixed_precision.zentorch import (
1074 + _import_unpack_from_int32,
1075 + )
1076 +
1077 + num_experts, out_features, _ = packed.shape
1078 + # packed_dim indexes the 2D expert slice, which is N-first, so K is dim 1.
1079 + unpacked = _import_unpack_from_int32()(
1080 + packed,
1081 + 4,
1082 + torch.Size([num_experts, out_features, in_features]),
1083 + packed_dim=1,
1084 + )
1085 + # CT is already N-first, which is what the repack consumes.
1086 + repack_op = torch.ops.zentorch.zentorch_woq_repack_weight.default
1087 + return torch.stack([repack_op(w) for w in unpacked])
1088 +
1089 +
1090 +def _process_weights_zen_cpu(
1091 + w13: torch.Tensor,
1092 + w2: torch.Tensor,
1093 + w13_scale: torch.Tensor,
1094 + w2_scale: torch.Tensor,
1095 + w13_bias: torch.Tensor | None = None,
1096 + w2_bias: torch.Tensor | None = None,
1097 +) -> tuple[
1098 + torch.Tensor, # w13_qweight
1099 + torch.Tensor, # w2_qweight
1100 + torch.Tensor, # w13_scales
1101 + torch.Tensor, # w2_scales
1102 + torch.Tensor | None, # w13_qzeros
1103 + torch.Tensor | None, # w2_qzeros
1104 + torch.Tensor | None, # w13_input_global_scale
1105 + torch.Tensor | None, # w2_input_global_scale
1106 + torch.Tensor | None, # w13_bias
1107 + torch.Tensor | None, # w2_bias
1108 +]:
1109 + """Zen CPU INT4 DA8W4 (W4A8) weight post-processing."""
1110 + # CT WNA16 buffers are N-first, so the scales are [E, N, G].
1111 + hidden_size = w2_scale.shape[1]
1112 + intermediate_size = w13_scale.shape[1] // 2
1113 +
1114 + # The kernel reads per-group scales as [E, G, N], so N-first needs a swap.
1115 + return (
1116 + _zen_repack_s4(w13.data, hidden_size),
1117 + _zen_repack_s4(w2.data, intermediate_size),
1118 + w13_scale.data.transpose(1, 2).to(torch.bfloat16).contiguous(),
1119 + w2_scale.data.transpose(1, 2).to(torch.bfloat16).contiguous(),
1120 + None, # w13_qzeros (symmetric)
1121 + None, # w2_qzeros (symmetric)
1122 + None, # w13_input_global_scale
1123 + None, # w2_input_global_scale
1124 + w13_bias,
1125 + w2_bias,
1126 + )
1127 +
1128 +
1129 def _process_weights_xpu(
1130 layer: torch.nn.Module,
1131 quant_config: QuantizationConfig,
@@ -1584,6 +1676,15 @@ def convert_to_wna16_moe_kernel_format(
1676 w13_bias,
1677 w2_bias,
1678 )
1679 + elif backend == WNA16MoEBackend.ZEN_CPU:
1680 + return _process_weights_zen_cpu(
1681 + w13,
1682 + w2,
1683 + w13_scale,
1684 + w2_scale,
1685 + w13_bias,
1686 + w2_bias,
1687 + )
1688 elif backend == WNA16MoEBackend.FLASHINFER_TRTLLM:
1689 return _process_weights_flashinfer(
1690 w13,
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_wna16.py
+66 -2
@@ -210,6 +210,27 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
210 layer.register_parameter("w2_weight_packed", w2_weight)
211 set_weight_attrs(w2_weight, extra_weight_attrs)
212
213 + # Per-expert biases, as gpt-oss carries.
214 + if self.moe.has_bias:
215 + w13_num_shards = 2 if self.moe.is_act_and_mul else 1
216 + w13_bias = torch.nn.Parameter(
217 + torch.zeros(
218 + num_experts,
219 + w13_num_shards * intermediate_size_per_partition,
220 + dtype=params_dtype,
221 + ),
222 + requires_grad=False,
223 + )
224 + layer.register_parameter("w13_bias", w13_bias)
225 + set_weight_attrs(w13_bias, extra_weight_attrs)
226 +
227 + w2_bias = torch.nn.Parameter(
228 + torch.zeros(num_experts, hidden_size, dtype=params_dtype),
229 + requires_grad=False,
230 + )
231 + layer.register_parameter("w2_bias", w2_bias)
232 + set_weight_attrs(w2_bias, extra_weight_attrs)
233 +
234 if self.strategy == "channel":
235 num_groups_w2 = num_groups_w13 = 1
236 self.group_size = -1
@@ -350,7 +371,41 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
371 routing_tables=layer._expert_routing_tables(),
372 )
373
374 + if self.wna16_backend == WNA16MoEBackend.ZEN_CPU:
375 + # Monolithic experts route inside apply(), so like the unquantized
376 + # CPU path they need the router config that only the layer carries.
377 + self.moe_kernel.fused_experts.process_weights_after_loading(layer)
378 +
379 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
380 + from vllm.model_executor.layers.fused_moe.experts.cpu_moe import (
381 + swigluoai_w13_interleave_perm,
382 + )
383 +
384 + # Reorder before the conversion below, which repacks w13 for the kernel.
385 + # The CT WNA16 buffers are N-first, so 2I is dim 1 of the packed weight
386 + # [E, 2I, K/pack] and of its group scales [E, 2I, G], as for the bias.
387 + perm = swigluoai_w13_interleave_perm(
388 + self.experts_cls,
389 + self.moe.activation,
390 + layer.w13_weight_packed.size(1),
391 + layer.w13_weight_packed.device,
392 + )
393 + if perm is not None:
394 + replace_parameter(
395 + layer,
396 + "w13_weight_packed",
397 + layer.w13_weight_packed.data[:, perm].contiguous(),
398 + )
399 + replace_parameter(
400 + layer,
401 + "w13_weight_scale",
402 + layer.w13_weight_scale.data[:, perm].contiguous(),
403 + )
404 + if self.moe.has_bias:
405 + replace_parameter(
406 + layer, "w13_bias", layer.w13_bias.data[:, perm].contiguous()
407 + )
408 +
409 # Process weights using the shared oracle infrastructure
410 converted = convert_to_wna16_moe_kernel_format(
411 backend=self.wna16_backend,
@@ -363,6 +418,8 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
418 w2_scale=layer.w2_weight_scale,
419 w13_qzeros=getattr(layer, "w13_weight_zero_point", None),
420 w2_qzeros=getattr(layer, "w2_weight_zero_point", None),
421 + w13_bias=getattr(layer, "w13_bias", None),
422 + w2_bias=getattr(layer, "w2_bias", None),
423 )
424
425 if converted is None:
@@ -378,8 +435,8 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
435 w2_qzeros,
436 w13_input_global_scale,
437 w2_input_global_scale,
381 - _, # w13_bias
382 - _, # w2_bias
438 + w13_bias,
439 + w2_bias,
440 ) = converted
441
442 # Replace common parameters
@@ -388,6 +445,11 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
445 replace_parameter(layer, "w13_weight_scale", w13_scales)
446 replace_parameter(layer, "w2_weight_scale", w2_scales)
447
448 + if w13_bias is not None:
449 + replace_parameter(layer, "w13_bias", w13_bias)
450 + if w2_bias is not None:
451 + replace_parameter(layer, "w2_bias", w2_bias)
452 +
453 # CPU fused_experts_cpu requires zero points even for symmetric quant.
454 # EMULATION bakes ZP into the dequantized bf16 weights — ZP is None.
455 if (
@@ -437,6 +499,8 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
499 num_bits=self.num_bits,
500 w1_zp=getattr(layer, "w13_weight_zero_point", None),
501 w2_zp=getattr(layer, "w2_weight_zero_point", None),
502 + w1_bias=getattr(layer, "w13_bias", None),
503 + w2_bias=getattr(layer, "w2_bias", None),
504 gemm1_clamp_limit=getattr(layer, "swiglu_limit", None),
505 gemm1_alpha=getattr(layer, "swiglu_alpha", None),
506 gemm1_beta=getattr(layer, "swiglu_beta", None),
vllm/model_executor/models/gpt_oss.py
+4 -1
@@ -76,6 +76,8 @@ _GPT_OSS_STREAMED_EXPERT_SUFFIX_TO_SHARD = {
76 "w2_bias": "gpt_oss_w2",
77 "w13_weight_scale": "gpt_oss_w13",
78 "w2_weight_scale": "gpt_oss_w2",
79 + "w13_weight_packed": "gpt_oss_w13",
80 + "w2_weight_packed": "gpt_oss_w2",
81 }
82
83
@@ -263,7 +265,7 @@ class GptOssRoutedExperts(RoutedExperts):
265 ) -> None:
266 tp_rank = self.moe_config.moe_parallel_config.tp_rank
267 is_w13 = shard_id == "gpt_oss_w13"
266 - is_weight = weight_name.endswith("_weight")
268 + is_weight = weight_name.endswith(("_weight", "_weight_packed"))
269 is_partitioned_scale = weight_name.endswith("_weight_scale")
270
271 if is_weight:
@@ -347,6 +349,7 @@ class GptOssRoutedExperts(RoutedExperts):
349 ) or quant_method_name in (
350 "CompressedTensorsW4A4Nvfp4MoEMethod",
351 "Nvfp4OnlineMoEMethod",
352 + "CompressedTensorsWNA16MoEMethod",
353 ):
354 self._load_packed_expert(expert_data, loaded_weight, weight_name, shard_id)
355 else: