[feature]Add int4 quantization support for emulation moe backend (#48451)
Signed-off-by: Qiang Li <qiang.li2@amd.com>
qli88 committed
Jul 15, 2026 at 21:00 UTC
5de1add806ce524ddd8c13286500ab8089e9654e
9 files changed
+1653
-10
tests/kernels/quantization/test_int4_emulation_moe.py
new
+1189
@@ -0,0 +1,1189 @@
1
+#!/usr/bin/env python3
2
+# SPDX-License-Identifier: Apache-2.0
3
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
+"""Correctness tests for Int4EmulationTritonExperts MoE backend.
5
+
6
+Tests the weight dequantization helpers (_unpack_and_dequant_int4_gptq,
7
+_unpack_and_dequant_int4_awq) and full MoE forward pass
8
+(_process_weights_emulation_gptq, _process_weights_emulation_awq)
9
+for both symmetric and asymmetric zero-point cases.
10
+
11
+Run `pytest tests/kernels/quantization/test_int4_emulation_moe.py`.
12
+"""
13
+
14
+import numpy
15
+import pytest
16
+import torch
17
+import torch.nn.functional as F
18
+
19
+from vllm.model_executor.layers.fused_moe.activation import MoEActivation
20
+from vllm.model_executor.layers.fused_moe.config import (
21
+ FusedMoEConfig,
22
+ FusedMoEParallelConfig,
23
+ RoutingMethodType,
24
+ int4_w4a16_moe_quant_config,
25
+)
26
+from vllm.model_executor.layers.fused_moe.experts.int4_emulation_moe import (
27
+ Int4EmulationTritonExperts,
28
+)
29
+from vllm.model_executor.layers.fused_moe.oracle.int_wna16 import (
30
+ _process_weights_emulation_awq,
31
+ _process_weights_emulation_gptq,
32
+ _unpack_and_dequant_int4_awq,
33
+ _unpack_and_dequant_int4_gptq,
34
+)
35
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
36
+ awq_pack,
37
+ gptq_pack,
38
+)
39
+from vllm.platforms import current_platform
40
+
41
+pytestmark = pytest.mark.skipif(
42
+ not current_platform.is_cuda_alike(),
43
+ reason="Int4EmulationTritonExperts requires CUDA.",
44
+)
45
+
46
+device = "cuda"
47
+
48
+# (E, K, N, group_size)
49
+SHAPES = [
50
+ pytest.param(2, 64, 32, 32, id="tiny-gs32"),
51
+ pytest.param(4, 128, 64, 64, id="small-gs64"),
52
+ pytest.param(4, 256, 128, 128, id="medium-gs128"),
53
+]
54
+
55
+# (E, K, N, top_k, group_size, num_tokens)
56
+E2E_CONFIGS = [
57
+ pytest.param(4, 64, 32, 2, 32, 8, id="tiny"),
58
+ pytest.param(8, 128, 64, 2, 64, 16, id="small"),
59
+]
60
+
61
+
62
+# ---------------------------------------------------------------------------
63
+# Helpers
64
+# ---------------------------------------------------------------------------
65
+
66
+
67
+def _quantize_sym(w_fp: torch.Tensor, group_size: int):
68
+ """Quantize [K, N] float to int4 symmetric (uint4b8), return q and scale."""
69
+ K, N = w_fp.shape
70
+ assert K % group_size == 0
71
+ n_groups = K // group_size
72
+ w_grouped = w_fp.reshape(n_groups, group_size, N)
73
+ scale = w_grouped.abs().amax(dim=1) / 7.0
74
+ scale = scale.clamp(min=1e-6)
75
+ w_quant = (w_grouped / scale.unsqueeze(1)).round().clamp(-8, 7)
76
+ q = (w_quant + 8).to(torch.int32).reshape(K, N)
77
+ return q, scale
78
+
79
+
80
+def _quantize_asym(w_fp: torch.Tensor, group_size: int):
81
+ """Quantize [K, N] float to uint4 asymmetric, return q, scale, zero."""
82
+ K, N = w_fp.shape
83
+ assert K % group_size == 0
84
+ n_groups = K // group_size
85
+ w_grouped = w_fp.reshape(n_groups, group_size, N)
86
+ wmin = w_grouped.amin(dim=1)
87
+ wmax = w_grouped.amax(dim=1)
88
+ scale = (wmax - wmin) / 15.0
89
+ scale = scale.clamp(min=1e-6)
90
+ zero = (-wmin / scale).round().clamp(0, 15).to(torch.int32)
91
+ w_quant = ((w_grouped - wmin.unsqueeze(1)) / scale.unsqueeze(1)).round()
92
+ q = w_quant.clamp(0, 15).to(torch.int32).reshape(K, N)
93
+ return q, scale, zero
94
+
95
+
96
+def _dequantize_ref(
97
+ w_uint: torch.Tensor,
98
+ scale: torch.Tensor,
99
+ zero=None,
100
+ output_dtype: torch.dtype = torch.bfloat16,
101
+):
102
+ """Reference dequant for a single [K, N] slice."""
103
+ K, N = w_uint.shape
104
+ n_groups = scale.shape[0]
105
+ group_size = K // n_groups
106
+ w = w_uint.reshape(n_groups, group_size, N).to(output_dtype)
107
+ s = scale.unsqueeze(1).to(output_dtype)
108
+ if zero is None:
109
+ return ((w - 8) * s).reshape(K, N)
110
+ z = zero.unsqueeze(1).to(output_dtype)
111
+ return ((w - z) * s).reshape(K, N)
112
+
113
+
114
+def _pack_gptq_zeros(zero: torch.Tensor, N: int) -> torch.Tensor:
115
+ """Pack [n_groups, N] zeros into GPTQ format [n_groups, N//8] int32."""
116
+ n_groups, _ = zero.shape
117
+ z = zero.to(torch.int32).cpu().numpy().astype(numpy.uint32)
118
+ packed = numpy.zeros((n_groups, N // 8), dtype=numpy.uint32)
119
+ for i in range(8):
120
+ packed |= z[:, i::8] << (i * 4)
121
+ return torch.from_numpy(packed.astype(numpy.int32)).to(device)
122
+
123
+
124
+def _pack_awq_zeros(zero: torch.Tensor, N: int) -> torch.Tensor:
125
+ """Pack [n_groups, N] zeros into AWQ column format [n_groups, N//8] int32."""
126
+ n_groups, _ = zero.shape
127
+ interleave = numpy.array([0, 2, 4, 6, 1, 3, 5, 7])
128
+ z = zero.to(torch.int32).cpu().numpy().astype(numpy.uint32)
129
+ z_interleaved = z.reshape(-1, 8)[:, interleave].reshape(n_groups, N)
130
+ packed = numpy.zeros((n_groups, N // 8), dtype=numpy.uint32)
131
+ for i in range(8):
132
+ packed |= z_interleaved[:, i::8] << (i * 4)
133
+ return torch.from_numpy(packed.astype(numpy.int32)).to(device)
134
+
135
+
136
+def _make_moe_config(E, K, N):
137
+ return FusedMoEConfig(
138
+ num_experts=E,
139
+ experts_per_token=2,
140
+ hidden_dim=K,
141
+ intermediate_size=N,
142
+ num_local_experts=E,
143
+ num_logical_experts=E,
144
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
145
+ activation=MoEActivation.SILU,
146
+ in_dtype=torch.bfloat16,
147
+ device=device,
148
+ routing_method=RoutingMethodType.TopK,
149
+ max_num_tokens=512,
150
+ )
151
+
152
+
153
+def _make_gptq_moe_weights(E, K, N, group_size, asym=False):
154
+ """Build GPTQ MoE weight tensors and per-expert float references."""
155
+ torch.manual_seed(7)
156
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device)
157
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device)
158
+
159
+ w13_list, w13s_list, w13z_list, w13_ref_list = [], [], [], []
160
+ w2_list, w2s_list, w2z_list, w2_ref_list = [], [], [], []
161
+
162
+ for e in range(E):
163
+ if asym:
164
+ q13, s13, z13 = _quantize_asym(w13_fp[e], group_size)
165
+ w13_list.append(gptq_pack(q13, 4, K, 2 * N))
166
+ w13s_list.append(s13)
167
+ w13z_list.append(_pack_gptq_zeros(z13, 2 * N))
168
+ w13_ref_list.append(_dequantize_ref(q13, s13, z13))
169
+ q2, s2, z2 = _quantize_asym(w2_fp[e], group_size)
170
+ w2_list.append(gptq_pack(q2, 4, N, K))
171
+ w2s_list.append(s2)
172
+ w2z_list.append(_pack_gptq_zeros(z2, K))
173
+ w2_ref_list.append(_dequantize_ref(q2, s2, z2))
174
+ else:
175
+ q13, s13 = _quantize_sym(w13_fp[e], group_size)
176
+ w13_list.append(gptq_pack(q13, 4, K, 2 * N))
177
+ w13s_list.append(s13)
178
+ w13z_list.append(None)
179
+ w13_ref_list.append(_dequantize_ref(q13, s13))
180
+ q2, s2 = _quantize_sym(w2_fp[e], group_size)
181
+ w2_list.append(gptq_pack(q2, 4, N, K))
182
+ w2s_list.append(s2)
183
+ w2z_list.append(None)
184
+ w2_ref_list.append(_dequantize_ref(q2, s2))
185
+
186
+ return (
187
+ torch.stack(w13_list),
188
+ torch.stack(w13s_list),
189
+ torch.stack(w13z_list) if asym else None,
190
+ torch.stack(w13_ref_list), # [E, K, 2N]
191
+ torch.stack(w2_list),
192
+ torch.stack(w2s_list),
193
+ torch.stack(w2z_list) if asym else None,
194
+ torch.stack(w2_ref_list), # [E, N, K]
195
+ )
196
+
197
+
198
+def _make_awq_moe_weights(E, K, N, group_size, asym=False):
199
+ """Build AWQ MoE weight tensors and per-expert float references."""
200
+ torch.manual_seed(8)
201
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device)
202
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device)
203
+
204
+ w13_list, w13s_list, w13z_list, w13_ref_list = [], [], [], []
205
+ w2_list, w2s_list, w2z_list, w2_ref_list = [], [], [], []
206
+
207
+ for e in range(E):
208
+ if asym:
209
+ q13, s13, z13 = _quantize_asym(w13_fp[e], group_size)
210
+ w13_list.append(awq_pack(q13, 4, K, 2 * N))
211
+ w13s_list.append(s13)
212
+ w13z_list.append(_pack_awq_zeros(z13, 2 * N))
213
+ w13_ref_list.append(_dequantize_ref(q13, s13, z13))
214
+ q2, s2, z2 = _quantize_asym(w2_fp[e], group_size)
215
+ w2_list.append(awq_pack(q2, 4, N, K))
216
+ w2s_list.append(s2)
217
+ w2z_list.append(_pack_awq_zeros(z2, K))
218
+ w2_ref_list.append(_dequantize_ref(q2, s2, z2))
219
+ else:
220
+ q13, s13 = _quantize_sym(w13_fp[e], group_size)
221
+ w13_list.append(awq_pack(q13, 4, K, 2 * N))
222
+ w13s_list.append(s13)
223
+ w13z_list.append(None)
224
+ w13_ref_list.append(_dequantize_ref(q13, s13))
225
+ q2, s2 = _quantize_sym(w2_fp[e], group_size)
226
+ w2_list.append(awq_pack(q2, 4, N, K))
227
+ w2s_list.append(s2)
228
+ w2z_list.append(None)
229
+ w2_ref_list.append(_dequantize_ref(q2, s2))
230
+
231
+ return (
232
+ torch.stack(w13_list),
233
+ torch.stack(w13s_list),
234
+ torch.stack(w13z_list) if asym else None,
235
+ torch.stack(w13_ref_list), # [E, K, 2N]
236
+ torch.stack(w2_list),
237
+ torch.stack(w2s_list),
238
+ torch.stack(w2z_list) if asym else None,
239
+ torch.stack(w2_ref_list), # [E, N, K]
240
+ )
241
+
242
+
243
+def _run_emulation_forward(
244
+ experts, w13_bf16, w2_bf16, hidden_states, topk_weights, topk_ids, E, K, N
245
+):
246
+ ws13_size = hidden_states.shape[0] * topk_ids.shape[1] * max(N, K)
247
+ ws2_size = hidden_states.shape[0] * topk_ids.shape[1] * max(2 * N, K)
248
+ workspace13 = torch.zeros(ws13_size, dtype=hidden_states.dtype, device=device)
249
+ workspace2 = torch.zeros(ws2_size, dtype=hidden_states.dtype, device=device)
250
+ output = torch.zeros(
251
+ hidden_states.shape[0], K, dtype=hidden_states.dtype, device=device
252
+ )
253
+ experts.apply(
254
+ output=output,
255
+ hidden_states=hidden_states,
256
+ w1=w13_bf16,
257
+ w2=w2_bf16,
258
+ topk_weights=topk_weights,
259
+ topk_ids=topk_ids,
260
+ activation=MoEActivation.SILU,
261
+ global_num_experts=E,
262
+ expert_map=None,
263
+ a1q_scale=None,
264
+ a2_scale=None,
265
+ workspace13=workspace13,
266
+ workspace2=workspace2,
267
+ expert_tokens_meta=None,
268
+ apply_router_weight_on_input=False,
269
+ )
270
+ return output
271
+
272
+
273
+# ---------------------------------------------------------------------------
274
+# Tests: _unpack_and_dequant_int4_gptq
275
+# ---------------------------------------------------------------------------
276
+
277
+
278
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
279
+def test_gptq_unpack_symmetric(E, K, N, group_size):
280
+ """GPTQ symmetric unpacker matches reference."""
281
+ torch.manual_seed(0)
282
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
283
+
284
+ packed_list, scale_list, ref_list = [], [], []
285
+ for e in range(E):
286
+ q, s = _quantize_sym(w_fp[e], group_size)
287
+ packed_list.append(gptq_pack(q, 4, K, N))
288
+ scale_list.append(s)
289
+ ref_list.append(_dequantize_ref(q, s, output_dtype=torch.float32))
290
+
291
+ w_packed = torch.stack(packed_list).to(device)
292
+ scale = torch.stack(scale_list).to(device)
293
+ ref = torch.stack(ref_list).to(device)
294
+
295
+ out = _unpack_and_dequant_int4_gptq(
296
+ w_packed, scale, None, transpose_output=False, output_dtype=torch.float32
297
+ )
298
+
299
+ assert out.shape == (E, K, N)
300
+ assert torch.allclose(out, ref, atol=0), (
301
+ f"max diff: {(out - ref).abs().max().item()}"
302
+ )
303
+
304
+
305
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
306
+def test_gptq_unpack_asymmetric(E, K, N, group_size):
307
+ """GPTQ asymmetric unpacker matches reference."""
308
+ torch.manual_seed(1)
309
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
310
+
311
+ packed_list, scale_list, zero_list, ref_list = [], [], [], []
312
+ for e in range(E):
313
+ q, s, z = _quantize_asym(w_fp[e], group_size)
314
+ packed_list.append(gptq_pack(q, 4, K, N))
315
+ scale_list.append(s)
316
+ zero_list.append(_pack_gptq_zeros(z, N))
317
+ ref_list.append(_dequantize_ref(q, s, z, output_dtype=torch.float32))
318
+
319
+ w_packed = torch.stack(packed_list).to(device)
320
+ scale = torch.stack(scale_list).to(device)
321
+ qzeros = torch.stack(zero_list).to(device)
322
+ ref = torch.stack(ref_list).to(device)
323
+
324
+ out = _unpack_and_dequant_int4_gptq(
325
+ w_packed, scale, qzeros, transpose_output=False, output_dtype=torch.float32
326
+ )
327
+
328
+ assert out.shape == (E, K, N)
329
+ assert torch.allclose(out, ref, atol=0), (
330
+ f"max diff: {(out - ref).abs().max().item()}"
331
+ )
332
+
333
+
334
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
335
+def test_gptq_unpack_transpose(E, K, N, group_size):
336
+ """GPTQ transpose_output=True gives [E, N, K]."""
337
+ torch.manual_seed(2)
338
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
339
+
340
+ packed_list, scale_list = [], []
341
+ for e in range(E):
342
+ q, s = _quantize_sym(w_fp[e], group_size)
343
+ packed_list.append(gptq_pack(q, 4, K, N))
344
+ scale_list.append(s)
345
+
346
+ w_packed = torch.stack(packed_list).to(device)
347
+ scale = torch.stack(scale_list).to(device)
348
+
349
+ out_normal = _unpack_and_dequant_int4_gptq(w_packed, scale, None, False)
350
+ out_transposed = _unpack_and_dequant_int4_gptq(w_packed, scale, None, True)
351
+
352
+ assert out_transposed.shape == (E, N, K)
353
+ assert torch.allclose(
354
+ out_transposed, out_normal.permute(0, 2, 1).contiguous(), atol=0
355
+ )
356
+
357
+
358
+# ---------------------------------------------------------------------------
359
+# Tests: _unpack_and_dequant_int4_awq
360
+# ---------------------------------------------------------------------------
361
+
362
+
363
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
364
+def test_awq_unpack_symmetric(E, K, N, group_size):
365
+ """AWQ symmetric unpacker matches reference."""
366
+ torch.manual_seed(3)
367
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
368
+
369
+ packed_list, scale_list, ref_list = [], [], []
370
+ for e in range(E):
371
+ q, s = _quantize_sym(w_fp[e], group_size)
372
+ packed_list.append(awq_pack(q, 4, K, N))
373
+ scale_list.append(s)
374
+ ref_list.append(_dequantize_ref(q, s, output_dtype=torch.float32))
375
+
376
+ w_packed = torch.stack(packed_list).to(device)
377
+ scale = torch.stack(scale_list).to(device)
378
+ ref = torch.stack(ref_list).to(device)
379
+
380
+ out = _unpack_and_dequant_int4_awq(
381
+ w_packed, scale, None, transpose_output=False, output_dtype=torch.float32
382
+ )
383
+
384
+ assert out.shape == (E, K, N)
385
+ assert torch.allclose(out, ref, atol=0), (
386
+ f"max diff: {(out - ref).abs().max().item()}"
387
+ )
388
+
389
+
390
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
391
+def test_awq_unpack_asymmetric(E, K, N, group_size):
392
+ """AWQ asymmetric unpacker matches reference."""
393
+ torch.manual_seed(4)
394
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
395
+
396
+ packed_list, scale_list, zero_list, ref_list = [], [], [], []
397
+ for e in range(E):
398
+ q, s, z = _quantize_asym(w_fp[e], group_size)
399
+ packed_list.append(awq_pack(q, 4, K, N))
400
+ scale_list.append(s)
401
+ zero_list.append(_pack_awq_zeros(z, N))
402
+ ref_list.append(_dequantize_ref(q, s, z, output_dtype=torch.float32))
403
+
404
+ w_packed = torch.stack(packed_list).to(device)
405
+ scale = torch.stack(scale_list).to(device)
406
+ qzeros = torch.stack(zero_list).to(device)
407
+ ref = torch.stack(ref_list).to(device)
408
+
409
+ out = _unpack_and_dequant_int4_awq(
410
+ w_packed, scale, qzeros, transpose_output=False, output_dtype=torch.float32
411
+ )
412
+
413
+ assert out.shape == (E, K, N)
414
+ assert torch.allclose(out, ref, atol=0), (
415
+ f"max diff: {(out - ref).abs().max().item()}"
416
+ )
417
+
418
+
419
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
420
+def test_awq_unpack_transpose(E, K, N, group_size):
421
+ """AWQ transpose_output=True gives [E, N, K]."""
422
+ torch.manual_seed(5)
423
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
424
+
425
+ packed_list, scale_list = [], []
426
+ for e in range(E):
427
+ q, s = _quantize_sym(w_fp[e], group_size)
428
+ packed_list.append(awq_pack(q, 4, K, N))
429
+ scale_list.append(s)
430
+
431
+ w_packed = torch.stack(packed_list).to(device)
432
+ scale = torch.stack(scale_list).to(device)
433
+
434
+ out_normal = _unpack_and_dequant_int4_awq(w_packed, scale, None, False)
435
+ out_transposed = _unpack_and_dequant_int4_awq(w_packed, scale, None, True)
436
+
437
+ assert out_transposed.shape == (E, N, K)
438
+ assert torch.allclose(
439
+ out_transposed, out_normal.permute(0, 2, 1).contiguous(), atol=0
440
+ )
441
+
442
+
443
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
444
+def test_awq_gptq_unpack_agree(E, K, N, group_size):
445
+ """AWQ and GPTQ unpackers produce identical values for the same weights."""
446
+ torch.manual_seed(6)
447
+ w_fp = torch.randn(E, K, N, dtype=torch.float16, device=device)
448
+
449
+ gptq_list, awq_list, scale_list = [], [], []
450
+ for e in range(E):
451
+ q, s = _quantize_sym(w_fp[e], group_size)
452
+ gptq_list.append(gptq_pack(q, 4, K, N))
453
+ awq_list.append(awq_pack(q, 4, K, N))
454
+ scale_list.append(s)
455
+
456
+ scale = torch.stack(scale_list).to(device)
457
+ out_gptq = _unpack_and_dequant_int4_gptq(
458
+ torch.stack(gptq_list).to(device), scale, None, False, torch.float32
459
+ )
460
+ out_awq = _unpack_and_dequant_int4_awq(
461
+ torch.stack(awq_list).to(device), scale, None, False, torch.float32
462
+ )
463
+
464
+ assert torch.allclose(out_gptq, out_awq, atol=0), (
465
+ f"max diff: {(out_gptq - out_awq).abs().max().item()}"
466
+ )
467
+
468
+
469
+# ---------------------------------------------------------------------------
470
+# Tests: _process_weights_emulation_{gptq,awq}
471
+# ---------------------------------------------------------------------------
472
+
473
+
474
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
475
+@pytest.mark.parametrize("asym", [False, True], ids=["sym", "asym"])
476
+def test_gptq_process_weights_shapes_and_values(E, K, N, group_size, asym):
477
+ """_process_weights_emulation_gptq shapes and values match reference."""
478
+ w13, w13s, w13z, w13_ref, w2, w2s, w2z, w2_ref = _make_gptq_moe_weights(
479
+ E, K, N, group_size, asym
480
+ )
481
+ result = _process_weights_emulation_gptq(w13, w2, w13s, w2s, w13z, w2z)
482
+ w13_out, w2_out = result[0], result[1]
483
+
484
+ assert w13_out.shape == (E, 2 * N, K)
485
+ assert w2_out.shape == (E, K, N)
486
+ assert w13_out.dtype == torch.bfloat16
487
+ assert w2_out.dtype == torch.bfloat16
488
+
489
+ expected_w13 = w13_ref.permute(0, 2, 1)
490
+ expected_w2 = w2_ref.permute(0, 2, 1)
491
+
492
+ assert torch.allclose(w13_out.float(), expected_w13.float(), atol=0), (
493
+ f"w13 max diff: {(w13_out.float() - expected_w13.float()).abs().max().item()}"
494
+ )
495
+ assert torch.allclose(w2_out.float(), expected_w2.float(), atol=0), (
496
+ f"w2 max diff: {(w2_out.float() - expected_w2.float()).abs().max().item()}"
497
+ )
498
+
499
+
500
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
501
+@pytest.mark.parametrize("asym", [False, True], ids=["sym", "asym"])
502
+def test_awq_process_weights_shapes_and_values(E, K, N, group_size, asym):
503
+ """_process_weights_emulation_awq shapes and values match reference."""
504
+ w13, w13s, w13z, w13_ref, w2, w2s, w2z, w2_ref = _make_awq_moe_weights(
505
+ E, K, N, group_size, asym
506
+ )
507
+ result = _process_weights_emulation_awq(w13, w2, w13s, w2s, w13z, w2z)
508
+ w13_out, w2_out = result[0], result[1]
509
+
510
+ assert w13_out.shape == (E, 2 * N, K)
511
+ assert w2_out.shape == (E, K, N)
512
+ assert w13_out.dtype == torch.bfloat16
513
+ assert w2_out.dtype == torch.bfloat16
514
+
515
+ expected_w13 = w13_ref.permute(0, 2, 1)
516
+ expected_w2 = w2_ref.permute(0, 2, 1)
517
+
518
+ assert torch.allclose(w13_out.float(), expected_w13.float(), atol=0), (
519
+ f"w13 max diff: {(w13_out.float() - expected_w13.float()).abs().max().item()}"
520
+ )
521
+ assert torch.allclose(w2_out.float(), expected_w2.float(), atol=0), (
522
+ f"w2 max diff: {(w2_out.float() - expected_w2.float()).abs().max().item()}"
523
+ )
524
+
525
+
526
+@pytest.mark.parametrize("E, K, N, group_size", SHAPES)
527
+@pytest.mark.parametrize("asym", [False, True], ids=["sym", "asym"])
528
+def test_gptq_awq_process_weights_agree(E, K, N, group_size, asym):
529
+ """AWQ and GPTQ process_weights produce identical dequantized tensors."""
530
+ torch.manual_seed(9)
531
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device)
532
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device)
533
+
534
+ g13_list, g13s_list, g13z_list = [], [], []
535
+ a13_list, a13s_list, a13z_list = [], [], []
536
+ g2_list, g2s_list, g2z_list = [], [], []
537
+ a2_list, a2s_list, a2z_list = [], [], []
538
+
539
+ for e in range(E):
540
+ if asym:
541
+ q13, s13, z13 = _quantize_asym(w13_fp[e], group_size)
542
+ q2, s2, z2 = _quantize_asym(w2_fp[e], group_size)
543
+ g13z_list.append(_pack_gptq_zeros(z13, 2 * N))
544
+ a13z_list.append(_pack_awq_zeros(z13, 2 * N))
545
+ g2z_list.append(_pack_gptq_zeros(z2, K))
546
+ a2z_list.append(_pack_awq_zeros(z2, K))
547
+ else:
548
+ q13, s13 = _quantize_sym(w13_fp[e], group_size)
549
+ q2, s2 = _quantize_sym(w2_fp[e], group_size)
550
+ g13z_list.append(None)
551
+ a13z_list.append(None)
552
+ g2z_list.append(None)
553
+ a2z_list.append(None)
554
+
555
+ g13_list.append(gptq_pack(q13, 4, K, 2 * N))
556
+ a13_list.append(awq_pack(q13, 4, K, 2 * N))
557
+ g13s_list.append(s13)
558
+ a13s_list.append(s13)
559
+ g2_list.append(gptq_pack(q2, 4, N, K))
560
+ a2_list.append(awq_pack(q2, 4, N, K))
561
+ g2s_list.append(s2)
562
+ a2s_list.append(s2)
563
+
564
+ gptq_res = _process_weights_emulation_gptq(
565
+ torch.stack(g13_list),
566
+ torch.stack(g2_list),
567
+ torch.stack(g13s_list),
568
+ torch.stack(g2s_list),
569
+ torch.stack(g13z_list) if asym else None,
570
+ torch.stack(g2z_list) if asym else None,
571
+ )
572
+ awq_res = _process_weights_emulation_awq(
573
+ torch.stack(a13_list),
574
+ torch.stack(a2_list),
575
+ torch.stack(a13s_list),
576
+ torch.stack(a2s_list),
577
+ torch.stack(a13z_list) if asym else None,
578
+ torch.stack(a2z_list) if asym else None,
579
+ )
580
+
581
+ assert torch.allclose(gptq_res[0].float(), awq_res[0].float(), atol=1e-3), (
582
+ f"w13 max diff: {(gptq_res[0] - awq_res[0]).float().abs().max().item()}"
583
+ )
584
+ assert torch.allclose(gptq_res[1].float(), awq_res[1].float(), atol=1e-3), (
585
+ f"w2 max diff: {(gptq_res[1] - awq_res[1]).float().abs().max().item()}"
586
+ )
587
+
588
+
589
+# ---------------------------------------------------------------------------
590
+# End-to-end MoE forward pass tests
591
+# ---------------------------------------------------------------------------
592
+
593
+
594
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens", E2E_CONFIGS)
595
+def test_gptq_vs_awq_forward_agree(E, K, N, top_k, group_size, num_tokens):
596
+ """GPTQ and AWQ emulation backends produce bit-identical forward outputs."""
597
+ torch.manual_seed(42)
598
+ moe_config = _make_moe_config(E, K, N)
599
+
600
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device)
601
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device)
602
+
603
+ g13_list, g13s_list, a13_list, a13s_list = [], [], [], []
604
+ g2_list, g2s_list, a2_list, a2s_list = [], [], [], []
605
+
606
+ for e in range(E):
607
+ q13, s13 = _quantize_sym(w13_fp[e], group_size)
608
+ q2, s2 = _quantize_sym(w2_fp[e], group_size)
609
+ g13_list.append(gptq_pack(q13, 4, K, 2 * N))
610
+ a13_list.append(awq_pack(q13, 4, K, 2 * N))
611
+ g13s_list.append(s13)
612
+ a13s_list.append(s13.clone())
613
+ g2_list.append(gptq_pack(q2, 4, N, K))
614
+ a2_list.append(awq_pack(q2, 4, N, K))
615
+ g2s_list.append(s2)
616
+ a2s_list.append(s2.clone())
617
+
618
+ gptq_res = _process_weights_emulation_gptq(
619
+ torch.stack(g13_list),
620
+ torch.stack(g2_list),
621
+ torch.stack(g13s_list),
622
+ torch.stack(g2s_list),
623
+ None,
624
+ None,
625
+ )
626
+ awq_res = _process_weights_emulation_awq(
627
+ torch.stack(a13_list),
628
+ torch.stack(a2_list),
629
+ torch.stack(a13s_list),
630
+ torch.stack(a2s_list),
631
+ None,
632
+ None,
633
+ )
634
+ w13_gptq, w2_gptq = gptq_res[0], gptq_res[1]
635
+ w13_awq, w2_awq = awq_res[0], awq_res[1]
636
+
637
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
638
+ experts_gptq = Int4EmulationTritonExperts(
639
+ moe_config, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
640
+ )
641
+ experts_awq = Int4EmulationTritonExperts(
642
+ moe_config, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
643
+ )
644
+
645
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
646
+ topk_weights = torch.softmax(
647
+ torch.randn(num_tokens, top_k, dtype=torch.float32, device=device), dim=-1
648
+ )
649
+ topk_ids = torch.stack(
650
+ [torch.randperm(E, device=device)[:top_k] for _ in range(num_tokens)]
651
+ ).to(torch.int32)
652
+
653
+ out_gptq = _run_emulation_forward(
654
+ experts_gptq, w13_gptq, w2_gptq, hidden_states, topk_weights, topk_ids, E, K, N
655
+ )
656
+ out_awq = _run_emulation_forward(
657
+ experts_awq, w13_awq, w2_awq, hidden_states, topk_weights, topk_ids, E, K, N
658
+ )
659
+
660
+ assert torch.allclose(out_gptq, out_awq, atol=0), (
661
+ f"max diff: {(out_gptq - out_awq).abs().max().item()}"
662
+ )
663
+
664
+
665
+# ---------------------------------------------------------------------------
666
+# EP (Expert Parallelism) tests
667
+# ---------------------------------------------------------------------------
668
+
669
+# (E, K, N, top_k, group_size, num_tokens, ep_size)
670
+EP_CONFIGS = [
671
+ pytest.param(4, 64, 32, 2, 32, 8, 2, id="E4-ep2"),
672
+ pytest.param(8, 64, 32, 2, 32, 16, 4, id="E8-ep4"),
673
+ pytest.param(8, 128, 64, 2, 64, 16, 2, id="E8-ep2"),
674
+]
675
+
676
+
677
+def _make_expert_map(global_num_experts: int, start: int, end: int) -> torch.Tensor:
678
+ """Build expert_map for a rank that owns experts [start, end)."""
679
+ expert_map = torch.full((global_num_experts,), -1, dtype=torch.int32, device=device)
680
+ expert_map[start:end] = torch.arange(end - start, dtype=torch.int32, device=device)
681
+ return expert_map
682
+
683
+
684
+def _run_emulation_forward_ep(
685
+ experts,
686
+ w13_bf16,
687
+ w2_bf16,
688
+ hidden_states,
689
+ topk_weights,
690
+ topk_ids,
691
+ global_num_experts,
692
+ expert_map,
693
+):
694
+ """Run forward with EP expert_map; returns output tensor."""
695
+ T, K = hidden_states.shape
696
+ N = w2_bf16.shape[2]
697
+ ws13_size = T * topk_ids.shape[1] * max(N, K)
698
+ ws2_size = T * topk_ids.shape[1] * max(2 * N, K)
699
+ workspace13 = torch.zeros(ws13_size, dtype=hidden_states.dtype, device=device)
700
+ workspace2 = torch.zeros(ws2_size, dtype=hidden_states.dtype, device=device)
701
+ output = torch.zeros(T, K, dtype=hidden_states.dtype, device=device)
702
+ experts.apply(
703
+ output=output,
704
+ hidden_states=hidden_states,
705
+ w1=w13_bf16,
706
+ w2=w2_bf16,
707
+ topk_weights=topk_weights,
708
+ topk_ids=topk_ids,
709
+ activation=MoEActivation.SILU,
710
+ global_num_experts=global_num_experts,
711
+ expert_map=expert_map,
712
+ a1q_scale=None,
713
+ a2_scale=None,
714
+ workspace13=workspace13,
715
+ workspace2=workspace2,
716
+ expert_tokens_meta=None,
717
+ apply_router_weight_on_input=False,
718
+ )
719
+ return output
720
+
721
+
722
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens, ep_size", EP_CONFIGS)
723
+@pytest.mark.parametrize("fmt", ["gptq", "awq"])
724
+def test_ep_output_matches_no_ep(E, K, N, top_k, group_size, num_tokens, ep_size, fmt):
725
+ """EP simulation: sum of per-rank outputs equals the no-EP forward pass."""
726
+ assert E % ep_size == 0
727
+ num_local = E // ep_size
728
+
729
+ torch.manual_seed(20)
730
+
731
+ # Build all expert weights in BF16 (no-EP reference)
732
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device) * 0.02
733
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device) * 0.02
734
+
735
+ packed13_list, scales13_list, packed2_list, scales2_list = [], [], [], []
736
+ for e in range(E):
737
+ q13, s13 = _quantize_sym(w13_fp[e].float(), group_size)
738
+ q2, s2 = _quantize_sym(w2_fp[e].float(), group_size)
739
+ if fmt == "gptq":
740
+ packed13_list.append(gptq_pack(q13, 4, K, 2 * N))
741
+ packed2_list.append(gptq_pack(q2, 4, N, K))
742
+ else:
743
+ packed13_list.append(awq_pack(q13, 4, K, 2 * N))
744
+ packed2_list.append(awq_pack(q2, 4, N, K))
745
+ scales13_list.append(s13)
746
+ scales2_list.append(s2)
747
+
748
+ process_fn = (
749
+ _process_weights_emulation_gptq
750
+ if fmt == "gptq"
751
+ else _process_weights_emulation_awq
752
+ )
753
+ res = process_fn(
754
+ torch.stack(packed13_list),
755
+ torch.stack(packed2_list),
756
+ torch.stack(scales13_list),
757
+ torch.stack(scales2_list),
758
+ None,
759
+ None,
760
+ )
761
+ w13_all, w2_all = res[0], res[1] # [E, 2N, K], [E, K, N]
762
+
763
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
764
+ topk_weights = torch.softmax(
765
+ torch.randn(num_tokens, top_k, dtype=torch.float32, device=device), dim=-1
766
+ )
767
+ topk_ids = torch.stack(
768
+ [torch.randperm(E, device=device)[:top_k] for _ in range(num_tokens)]
769
+ ).to(torch.int32)
770
+
771
+ # No-EP reference
772
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
773
+ moe_config_full = FusedMoEConfig(
774
+ num_experts=E,
775
+ experts_per_token=top_k,
776
+ hidden_dim=K,
777
+ intermediate_size=N,
778
+ num_local_experts=E,
779
+ num_logical_experts=E,
780
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
781
+ activation=MoEActivation.SILU,
782
+ in_dtype=torch.bfloat16,
783
+ device=device,
784
+ routing_method=RoutingMethodType.TopK,
785
+ max_num_tokens=512,
786
+ )
787
+ experts_ref = Int4EmulationTritonExperts(
788
+ moe_config_full, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
789
+ )
790
+ out_no_ep = _run_emulation_forward(
791
+ experts_ref, w13_all, w2_all, hidden_states, topk_weights, topk_ids, E, K, N
792
+ )
793
+
794
+ # EP simulation: sum contributions from each rank
795
+ out_ep_sum = torch.zeros(num_tokens, K, dtype=torch.bfloat16, device=device)
796
+ for rank in range(ep_size):
797
+ start = rank * num_local
798
+ end = start + num_local
799
+ expert_map = _make_expert_map(E, start, end)
800
+ moe_config_ep = FusedMoEConfig(
801
+ num_experts=E,
802
+ experts_per_token=top_k,
803
+ hidden_dim=K,
804
+ intermediate_size=N,
805
+ num_local_experts=num_local,
806
+ num_logical_experts=E,
807
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
808
+ activation=MoEActivation.SILU,
809
+ in_dtype=torch.bfloat16,
810
+ device=device,
811
+ routing_method=RoutingMethodType.TopK,
812
+ max_num_tokens=512,
813
+ )
814
+ experts_ep = Int4EmulationTritonExperts(
815
+ moe_config_ep, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
816
+ )
817
+ out_rank = _run_emulation_forward_ep(
818
+ experts_ep,
819
+ w13_all[start:end],
820
+ w2_all[start:end],
821
+ hidden_states,
822
+ topk_weights,
823
+ topk_ids,
824
+ global_num_experts=E,
825
+ expert_map=expert_map,
826
+ )
827
+ out_ep_sum = out_ep_sum + out_rank
828
+
829
+ assert torch.allclose(out_ep_sum, out_no_ep, atol=1e-3), (
830
+ f"[{fmt}] EP sum max diff: {(out_ep_sum - out_no_ep).abs().max().item():.6f}"
831
+ )
832
+
833
+
834
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens, ep_size", EP_CONFIGS)
835
+def test_ep_gptq_awq_agree(E, K, N, top_k, group_size, num_tokens, ep_size):
836
+ """With EP, GPTQ and AWQ emulation produce the same outputs per rank."""
837
+ assert E % ep_size == 0
838
+ num_local = E // ep_size
839
+
840
+ torch.manual_seed(21)
841
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device) * 0.02
842
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device) * 0.02
843
+
844
+ g13_list, g13s_list, g2_list, g2s_list = [], [], [], []
845
+ a13_list, a13s_list, a2_list, a2s_list = [], [], [], []
846
+ for e in range(E):
847
+ q13, s13 = _quantize_sym(w13_fp[e].float(), group_size)
848
+ q2, s2 = _quantize_sym(w2_fp[e].float(), group_size)
849
+ g13_list.append(gptq_pack(q13, 4, K, 2 * N))
850
+ a13_list.append(awq_pack(q13, 4, K, 2 * N))
851
+ g13s_list.append(s13)
852
+ a13s_list.append(s13.clone())
853
+ g2_list.append(gptq_pack(q2, 4, N, K))
854
+ a2_list.append(awq_pack(q2, 4, N, K))
855
+ g2s_list.append(s2)
856
+ a2s_list.append(s2.clone())
857
+
858
+ gptq_res = _process_weights_emulation_gptq(
859
+ torch.stack(g13_list),
860
+ torch.stack(g2_list),
861
+ torch.stack(g13s_list),
862
+ torch.stack(g2s_list),
863
+ None,
864
+ None,
865
+ )
866
+ awq_res = _process_weights_emulation_awq(
867
+ torch.stack(a13_list),
868
+ torch.stack(a2_list),
869
+ torch.stack(a13s_list),
870
+ torch.stack(a2s_list),
871
+ None,
872
+ None,
873
+ )
874
+ w13_gptq, w2_gptq = gptq_res[0], gptq_res[1]
875
+ w13_awq, w2_awq = awq_res[0], awq_res[1]
876
+
877
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
878
+ topk_weights = torch.softmax(
879
+ torch.randn(num_tokens, top_k, dtype=torch.float32, device=device), dim=-1
880
+ )
881
+ topk_ids = torch.stack(
882
+ [torch.randperm(E, device=device)[:top_k] for _ in range(num_tokens)]
883
+ ).to(torch.int32)
884
+
885
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
886
+
887
+ for rank in range(ep_size):
888
+ start = rank * num_local
889
+ end = start + num_local
890
+ expert_map = _make_expert_map(E, start, end)
891
+ moe_config_ep = FusedMoEConfig(
892
+ num_experts=E,
893
+ experts_per_token=top_k,
894
+ hidden_dim=K,
895
+ intermediate_size=N,
896
+ num_local_experts=num_local,
897
+ num_logical_experts=E,
898
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
899
+ activation=MoEActivation.SILU,
900
+ in_dtype=torch.bfloat16,
901
+ device=device,
902
+ routing_method=RoutingMethodType.TopK,
903
+ max_num_tokens=512,
904
+ )
905
+ experts_gptq = Int4EmulationTritonExperts(
906
+ moe_config_ep, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
907
+ )
908
+ experts_awq = Int4EmulationTritonExperts(
909
+ moe_config_ep, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
910
+ )
911
+ out_gptq = _run_emulation_forward_ep(
912
+ experts_gptq,
913
+ w13_gptq[start:end],
914
+ w2_gptq[start:end],
915
+ hidden_states,
916
+ topk_weights,
917
+ topk_ids,
918
+ E,
919
+ expert_map,
920
+ )
921
+ out_awq = _run_emulation_forward_ep(
922
+ experts_awq,
923
+ w13_awq[start:end],
924
+ w2_awq[start:end],
925
+ hidden_states,
926
+ topk_weights,
927
+ topk_ids,
928
+ E,
929
+ expert_map,
930
+ )
931
+ assert torch.allclose(out_gptq, out_awq, atol=0), (
932
+ f"rank={rank} max diff: {(out_gptq - out_awq).abs().max().item()}"
933
+ )
934
+
935
+
936
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens, ep_size", EP_CONFIGS)
937
+def test_ep_partial_rank_no_active_experts(
938
+ E, K, N, top_k, group_size, num_tokens, ep_size
939
+):
940
+ """A rank that owns no token-selected experts produces an all-zero output."""
941
+ assert E % ep_size == 0
942
+ num_local = E // ep_size
943
+
944
+ torch.manual_seed(22)
945
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device) * 0.02
946
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device) * 0.02
947
+
948
+ packed13_list, scales13_list, packed2_list, scales2_list = [], [], [], []
949
+ for e in range(E):
950
+ q13, s13 = _quantize_sym(w13_fp[e].float(), group_size)
951
+ q2, s2 = _quantize_sym(w2_fp[e].float(), group_size)
952
+ packed13_list.append(gptq_pack(q13, 4, K, 2 * N))
953
+ packed2_list.append(gptq_pack(q2, 4, N, K))
954
+ scales13_list.append(s13)
955
+ scales2_list.append(s2)
956
+
957
+ res = _process_weights_emulation_gptq(
958
+ torch.stack(packed13_list),
959
+ torch.stack(packed2_list),
960
+ torch.stack(scales13_list),
961
+ torch.stack(scales2_list),
962
+ None,
963
+ None,
964
+ )
965
+ w13_all, w2_all = res[0], res[1]
966
+
967
+ # Force topk_ids to only use experts in [0, num_local) — rank 0's slice
968
+ topk_ids = torch.zeros(num_tokens, top_k, dtype=torch.int32, device=device)
969
+ topk_weights = torch.softmax(
970
+ torch.randn(num_tokens, top_k, dtype=torch.float32, device=device), dim=-1
971
+ )
972
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
973
+
974
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
975
+
976
+ # Last rank owns experts [E-num_local, E), tokens only route to [0, num_local)
977
+ last_rank = ep_size - 1
978
+ start = last_rank * num_local
979
+ end = E
980
+ expert_map = _make_expert_map(E, start, end)
981
+ moe_config_ep = FusedMoEConfig(
982
+ num_experts=E,
983
+ experts_per_token=top_k,
984
+ hidden_dim=K,
985
+ intermediate_size=N,
986
+ num_local_experts=num_local,
987
+ num_logical_experts=E,
988
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
989
+ activation=MoEActivation.SILU,
990
+ in_dtype=torch.bfloat16,
991
+ device=device,
992
+ routing_method=RoutingMethodType.TopK,
993
+ max_num_tokens=512,
994
+ )
995
+ experts_ep = Int4EmulationTritonExperts(
996
+ moe_config_ep, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
997
+ )
998
+ out = _run_emulation_forward_ep(
999
+ experts_ep,
1000
+ w13_all[start:end],
1001
+ w2_all[start:end],
1002
+ hidden_states,
1003
+ topk_weights,
1004
+ topk_ids,
1005
+ E,
1006
+ expert_map,
1007
+ )
1008
+ assert torch.all(out == 0), (
1009
+ f"Expected zeros for inactive rank, got max={out.abs().max().item()}"
1010
+ )
1011
+
1012
+
1013
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens, ep_size", EP_CONFIGS)
1014
+def test_ep_sum_equals_full_forward(E, K, N, top_k, group_size, num_tokens, ep_size):
1015
+ """With fixed routing, EP rank outputs sum to the single-rank full forward."""
1016
+ assert E % ep_size == 0
1017
+ num_local = E // ep_size
1018
+
1019
+ torch.manual_seed(23)
1020
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.float16, device=device) * 0.02
1021
+ w2_fp = torch.randn(E, N, K, dtype=torch.float16, device=device) * 0.02
1022
+
1023
+ packed13_list, scales13_list, packed2_list, scales2_list = [], [], [], []
1024
+ for e in range(E):
1025
+ q13, s13 = _quantize_sym(w13_fp[e].float(), group_size)
1026
+ q2, s2 = _quantize_sym(w2_fp[e].float(), group_size)
1027
+ packed13_list.append(gptq_pack(q13, 4, K, 2 * N))
1028
+ packed2_list.append(gptq_pack(q2, 4, N, K))
1029
+ scales13_list.append(s13)
1030
+ scales2_list.append(s2)
1031
+
1032
+ res = _process_weights_emulation_gptq(
1033
+ torch.stack(packed13_list),
1034
+ torch.stack(packed2_list),
1035
+ torch.stack(scales13_list),
1036
+ torch.stack(scales2_list),
1037
+ None,
1038
+ None,
1039
+ )
1040
+ w13_all, w2_all = res[0], res[1]
1041
+
1042
+ # Fix routing so every token uses exactly 2 consecutive experts (round-robin)
1043
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
1044
+ topk_ids = torch.stack(
1045
+ [
1046
+ torch.tensor([(t * top_k + k) % E for k in range(top_k)], dtype=torch.int32)
1047
+ for t in range(num_tokens)
1048
+ ]
1049
+ ).to(device)
1050
+ topk_weights = torch.full((num_tokens, top_k), 1.0 / top_k, device=device)
1051
+
1052
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
1053
+
1054
+ # Full (no-EP) reference
1055
+ moe_config_full = FusedMoEConfig(
1056
+ num_experts=E,
1057
+ experts_per_token=top_k,
1058
+ hidden_dim=K,
1059
+ intermediate_size=N,
1060
+ num_local_experts=E,
1061
+ num_logical_experts=E,
1062
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
1063
+ activation=MoEActivation.SILU,
1064
+ in_dtype=torch.bfloat16,
1065
+ device=device,
1066
+ routing_method=RoutingMethodType.TopK,
1067
+ max_num_tokens=512,
1068
+ )
1069
+ experts_full = Int4EmulationTritonExperts(
1070
+ moe_config_full, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
1071
+ )
1072
+ out_full = _run_emulation_forward(
1073
+ experts_full, w13_all, w2_all, hidden_states, topk_weights, topk_ids, E, K, N
1074
+ )
1075
+
1076
+ # EP sum
1077
+ out_ep_sum = torch.zeros(num_tokens, K, dtype=torch.bfloat16, device=device)
1078
+ for rank in range(ep_size):
1079
+ start = rank * num_local
1080
+ end = start + num_local
1081
+ expert_map = _make_expert_map(E, start, end)
1082
+ moe_config_ep = FusedMoEConfig(
1083
+ num_experts=E,
1084
+ experts_per_token=top_k,
1085
+ hidden_dim=K,
1086
+ intermediate_size=N,
1087
+ num_local_experts=num_local,
1088
+ num_logical_experts=E,
1089
+ moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(),
1090
+ activation=MoEActivation.SILU,
1091
+ in_dtype=torch.bfloat16,
1092
+ device=device,
1093
+ routing_method=RoutingMethodType.TopK,
1094
+ max_num_tokens=512,
1095
+ )
1096
+ experts_ep = Int4EmulationTritonExperts(
1097
+ moe_config_ep, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
1098
+ )
1099
+ out_rank = _run_emulation_forward_ep(
1100
+ experts_ep,
1101
+ w13_all[start:end],
1102
+ w2_all[start:end],
1103
+ hidden_states,
1104
+ topk_weights,
1105
+ topk_ids,
1106
+ E,
1107
+ expert_map,
1108
+ )
1109
+ out_ep_sum = out_ep_sum + out_rank
1110
+
1111
+ assert torch.allclose(out_ep_sum, out_full, atol=1e-3), (
1112
+ f"EP sum max diff: {(out_ep_sum - out_full).abs().max().item():.6f}"
1113
+ )
1114
+
1115
+
1116
+@pytest.mark.parametrize("E, K, N, top_k, group_size, num_tokens", E2E_CONFIGS)
1117
+@pytest.mark.parametrize("fmt", ["gptq", "awq"])
1118
+def test_emulation_output_close_to_bf16_reference(
1119
+ E, K, N, top_k, group_size, num_tokens, fmt
1120
+):
1121
+ """Emulation output is close to a direct BF16 MoE forward."""
1122
+ torch.manual_seed(11)
1123
+ moe_config = _make_moe_config(E, K, N)
1124
+
1125
+ w13_fp = torch.randn(E, K, 2 * N, dtype=torch.bfloat16, device=device) * 0.02
1126
+ w2_fp = torch.randn(E, N, K, dtype=torch.bfloat16, device=device) * 0.02
1127
+
1128
+ packed13_list, scales13_list, packed2_list, scales2_list = [], [], [], []
1129
+ for e in range(E):
1130
+ q13, s13 = _quantize_sym(w13_fp[e].float(), group_size)
1131
+ q2, s2 = _quantize_sym(w2_fp[e].float(), group_size)
1132
+ if fmt == "gptq":
1133
+ packed13_list.append(gptq_pack(q13, 4, K, 2 * N))
1134
+ packed2_list.append(gptq_pack(q2, 4, N, K))
1135
+ else:
1136
+ packed13_list.append(awq_pack(q13, 4, K, 2 * N))
1137
+ packed2_list.append(awq_pack(q2, 4, N, K))
1138
+ scales13_list.append(s13)
1139
+ scales2_list.append(s2)
1140
+
1141
+ process_fn = (
1142
+ _process_weights_emulation_gptq
1143
+ if fmt == "gptq"
1144
+ else _process_weights_emulation_awq
1145
+ )
1146
+ res = process_fn(
1147
+ torch.stack(packed13_list),
1148
+ torch.stack(packed2_list),
1149
+ torch.stack(scales13_list),
1150
+ torch.stack(scales2_list),
1151
+ None,
1152
+ None,
1153
+ )
1154
+ w13_bf16, w2_bf16 = res[0], res[1]
1155
+
1156
+ dummy_scale = torch.ones(1, dtype=torch.float16, device=device)
1157
+ experts = Int4EmulationTritonExperts(
1158
+ moe_config, int4_w4a16_moe_quant_config(dummy_scale, dummy_scale)
1159
+ )
1160
+
1161
+ hidden_states = torch.randn(num_tokens, K, dtype=torch.bfloat16, device=device)
1162
+ topk_weights = torch.softmax(
1163
+ torch.randn(num_tokens, top_k, dtype=torch.float32, device=device), dim=-1
1164
+ )
1165
+ topk_ids = torch.stack(
1166
+ [torch.randperm(E, device=device)[:top_k] for _ in range(num_tokens)]
1167
+ ).to(torch.int32)
1168
+
1169
+ out_emulation = _run_emulation_forward(
1170
+ experts, w13_bf16, w2_bf16, hidden_states, topk_weights, topk_ids, E, K, N
1171
+ )
1172
+
1173
+ ref = torch.zeros(num_tokens, K, dtype=torch.bfloat16, device=device)
1174
+ for m in range(num_tokens):
1175
+ acc = torch.zeros(K, dtype=torch.float32, device=device)
1176
+ for k in range(top_k):
1177
+ e = topk_ids[m, k].item()
1178
+ w = topk_weights[m, k].item()
1179
+ gate_up = hidden_states[m] @ w13_bf16[e].T
1180
+ gate, up = gate_up.chunk(2)
1181
+ act = F.silu(gate) * up
1182
+ acc += w * (act @ w2_bf16[e].T).float()
1183
+ ref[m] = acc.bfloat16()
1184
+
1185
+ rel_l2 = (
1186
+ torch.norm(out_emulation.float() - ref.float())
1187
+ / torch.norm(ref.float()).clamp(min=1e-6)
1188
+ ).item()
1189
+ assert rel_l2 < 0.15, f"[{fmt}] relative L2 = {rel_l2:.4f} (threshold 0.15)"
vllm/model_executor/layers/fused_moe/config.py
+12
@@ -887,6 +887,9 @@ def int4_w4a16_moe_quant_config(
887
block_shape: list[int] | None = None,
888
a1_gscale: torch.Tensor | None = None,
889
a2_gscale: torch.Tensor | None = None,
890
+ gemm1_clamp_limit: float | None = None,
891
+ gemm1_alpha: float | None = None,
892
+ gemm1_beta: float | None = None,
893
) -> FusedMoEQuantConfig:
894
"""
895
Construct a quant config for 16-bit float activations and int4 weights.
@@ -897,6 +900,9 @@ def int4_w4a16_moe_quant_config(
900
_a2=FusedMoEQuantDesc(shape=group_shape, alpha_or_gscale=a2_gscale),
901
_w1=FusedMoEQuantDesc("int4", group_shape, w1_scale, None, w1_zp, w1_bias),
902
_w2=FusedMoEQuantDesc("int4", group_shape, w2_scale, None, w2_zp, w2_bias),
903
+ gemm1_clamp_limit=gemm1_clamp_limit,
904
+ gemm1_alpha=gemm1_alpha,
905
+ gemm1_beta=gemm1_beta,
906
)
907
908
@@ -950,6 +956,9 @@ def int8_w8a16_moe_quant_config(
956
block_shape: list[int] | None = None,
957
a1_gscale: torch.Tensor | None = None,
958
a2_gscale: torch.Tensor | None = None,
959
+ gemm1_clamp_limit: float | None = None,
960
+ gemm1_alpha: float | None = None,
961
+ gemm1_beta: float | None = None,
962
) -> FusedMoEQuantConfig:
963
"""
964
Construct a quant config for 16-bit float activations and int8 weights.
@@ -960,6 +969,9 @@ def int8_w8a16_moe_quant_config(
969
_a2=FusedMoEQuantDesc(shape=group_shape, alpha_or_gscale=a2_gscale),
970
_w1=FusedMoEQuantDesc(torch.int8, group_shape, w1_scale, None, w1_zp, w1_bias),
971
_w2=FusedMoEQuantDesc(torch.int8, group_shape, w2_scale, None, w2_zp, w2_bias),
972
+ gemm1_clamp_limit=gemm1_clamp_limit,
973
+ gemm1_alpha=gemm1_alpha,
974
+ gemm1_beta=gemm1_beta,
975
)
976
977
vllm/model_executor/layers/fused_moe/experts/int4_emulation_moe.py
new
+129
@@ -0,0 +1,129 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Int4 weight-only quantization emulation for MoE.
4
+
5
+Weights are dequantized from packed int4 to BF16 once at load time;
6
+the forward pass then runs plain TritonExperts in BF16.
7
+"""
8
+
9
+import torch
10
+
11
+import vllm.model_executor.layers.fused_moe.modular_kernel as mk
12
+from vllm.logger import init_logger
13
+from vllm.model_executor.layers.fused_moe.activation import MoEActivation
14
+from vllm.model_executor.layers.fused_moe.config import (
15
+ FusedMoEConfig,
16
+ FusedMoEQuantConfig,
17
+)
18
+from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts
19
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
20
+ QuantKey,
21
+ kInt4Static,
22
+ kInt4Static32,
23
+ kInt4Static32Asym,
24
+ kInt4StaticAsym,
25
+)
26
+from vllm.platforms import current_platform
27
+
28
+logger = init_logger(__name__)
29
+
30
+
31
+class Int4EmulationTritonExperts(TritonExperts):
32
+ """Int4 W-only MoE that dequantizes weights to BF16 at load time.
33
+
34
+ Weights arrive already dequantized (convert_to_wna16_moe_kernel_format
35
+ does the unpacking); apply() simply forwards to TritonExperts.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ moe_config: FusedMoEConfig,
41
+ quant_config: FusedMoEQuantConfig,
42
+ ):
43
+ super().__init__(moe_config, quant_config)
44
+ logger.warning_once(
45
+ "Using Int4EmulationTritonExperts MoE backend. Int4 weights are "
46
+ "dequantized to BF16 at load time "
47
+ )
48
+ # Weights are dequantized to BF16 before apply() is called, so
49
+ # TritonExperts must see them as plain float — clear the int4 dtype
50
+ # and scales so the hidden-size assertion and kernel dispatch treat
51
+ # them as unquantized.
52
+ self.quant_config._w1.dtype = None
53
+ self.quant_config._w2.dtype = None
54
+ self.quant_config._w1.scale = None
55
+ self.quant_config._w2.scale = None
56
+
57
+ @staticmethod
58
+ def _supports_current_device() -> bool:
59
+ return current_platform.is_cuda_alike()
60
+
61
+ @staticmethod
62
+ def _supports_quant_scheme(
63
+ weight_key: QuantKey | None,
64
+ activation_key: QuantKey | None,
65
+ ) -> bool:
66
+ return (
67
+ weight_key
68
+ in (
69
+ kInt4Static,
70
+ kInt4Static32,
71
+ kInt4StaticAsym,
72
+ kInt4Static32Asym,
73
+ )
74
+ and activation_key is None
75
+ )
76
+
77
+ @property
78
+ def quant_dtype(self) -> torch.dtype | str | None:
79
+ return None
80
+
81
+ @property
82
+ def block_shape(self) -> list[int] | None:
83
+ return None
84
+
85
+ @property
86
+ def expects_unquantized_inputs(self) -> bool:
87
+ return True
88
+
89
+ def apply(
90
+ self,
91
+ output: torch.Tensor,
92
+ hidden_states: torch.Tensor,
93
+ w1: torch.Tensor,
94
+ w2: torch.Tensor,
95
+ topk_weights: torch.Tensor,
96
+ topk_ids: torch.Tensor,
97
+ activation: MoEActivation,
98
+ global_num_experts: int,
99
+ expert_map: torch.Tensor | None,
100
+ a1q_scale: torch.Tensor | None,
101
+ a2_scale: torch.Tensor | None,
102
+ workspace13: torch.Tensor,
103
+ workspace2: torch.Tensor,
104
+ expert_tokens_meta: mk.ExpertTokensMetadata | None,
105
+ apply_router_weight_on_input: bool,
106
+ ):
107
+ if w1.element_size() < 2:
108
+ raise RuntimeError(
109
+ "Int4EmulationTritonExperts.apply() received packed int4 weights "
110
+ "(element_size < 2). Weights must be dequantized to BF16 before "
111
+ "the forward pass via convert_to_wna16_moe_kernel_format."
112
+ )
113
+ return super().apply(
114
+ output=output,
115
+ hidden_states=hidden_states,
116
+ w1=w1,
117
+ w2=w2,
118
+ topk_weights=topk_weights,
119
+ topk_ids=topk_ids,
120
+ activation=activation,
121
+ global_num_experts=global_num_experts,
122
+ expert_map=expert_map,
123
+ a1q_scale=None,
124
+ a2_scale=None,
125
+ workspace13=workspace13,
126
+ workspace2=workspace2,
127
+ expert_tokens_meta=expert_tokens_meta,
128
+ apply_router_weight_on_input=apply_router_weight_on_input,
129
+ )
vllm/model_executor/layers/fused_moe/oracle/int_wna16.py
+281
-2
@@ -51,6 +51,7 @@ class WNA16MoEBackend(Enum):
51
CPU = "CPU"
52
FLASHINFER_TRTLLM = "FLASHINFER_TRTLLM"
53
XPU = "XPU"
54
+ EMULATION = "EMULATION"
55
56
57
def backend_to_kernel_cls(
@@ -87,6 +88,12 @@ def backend_to_kernel_cls(
88
)
89
90
return [CPUExpertsInt4]
91
+ elif backend == WNA16MoEBackend.EMULATION:
92
+ from vllm.model_executor.layers.fused_moe.experts.int4_emulation_moe import (
93
+ Int4EmulationTritonExperts,
94
+ )
95
+
96
+ return [Int4EmulationTritonExperts]
97
else:
98
raise ValueError(f"Unknown WNA16 MoE backend: {backend.value}")
99
@@ -105,6 +112,7 @@ def _get_priority_backends() -> list[WNA16MoEBackend]:
112
WNA16MoEBackend.MARLIN,
113
WNA16MoEBackend.BATCHED_MARLIN,
114
WNA16MoEBackend.HUMMING,
115
+ WNA16MoEBackend.EMULATION,
116
]
117
return _AVAILABLE_BACKENDS
118
@@ -115,6 +123,7 @@ def map_wna16_backend(runner_backend: MoEBackend) -> WNA16MoEBackend:
123
"marlin": WNA16MoEBackend.MARLIN,
124
"humming": WNA16MoEBackend.HUMMING,
125
"flashinfer_trtllm": WNA16MoEBackend.FLASHINFER_TRTLLM,
126
+ "emulation": WNA16MoEBackend.EMULATION,
127
}
128
if backend := mapping.get(runner_backend):
129
return backend
@@ -215,6 +224,9 @@ def make_wna16_moe_quant_config(
224
w2_bias: torch.Tensor | None = None,
225
a1_gscale: torch.Tensor | None = None,
226
a2_gscale: torch.Tensor | None = None,
227
+ gemm1_clamp_limit: float | None = None,
228
+ gemm1_alpha: float | None = None,
229
+ gemm1_beta: float | None = None,
230
) -> FusedMoEQuantConfig:
231
"""Create the FusedMoEQuantConfig for 4 or 8-bit WNA16 MoE."""
232
if num_bits == 4:
@@ -228,6 +240,9 @@ def make_wna16_moe_quant_config(
240
block_shape=[0, group_size],
241
a1_gscale=a1_gscale,
242
a2_gscale=a2_gscale,
243
+ gemm1_clamp_limit=gemm1_clamp_limit,
244
+ gemm1_alpha=gemm1_alpha,
245
+ gemm1_beta=gemm1_beta,
246
)
247
else:
248
assert num_bits == 8
@@ -241,6 +256,9 @@ def make_wna16_moe_quant_config(
256
block_shape=[0, group_size],
257
a1_gscale=a1_gscale,
258
a2_gscale=a2_gscale,
259
+ gemm1_clamp_limit=gemm1_clamp_limit,
260
+ gemm1_alpha=gemm1_alpha,
261
+ gemm1_beta=gemm1_beta,
262
)
263
264
@@ -263,19 +281,23 @@ def make_wna16_moe_kernel(
281
from vllm.model_executor.layers.fused_moe.experts.cpu_moe import (
282
CPUExpertsInt4,
283
)
284
+ from vllm.model_executor.layers.fused_moe.experts.int4_emulation_moe import (
285
+ Int4EmulationTritonExperts,
286
+ )
287
from vllm.model_executor.layers.fused_moe.experts.xpu_moe import (
288
XPUExpertsWNA16,
289
)
290
291
# Currently, we only support TrtLlmMxint4ExpertsMonolithic, MarlinExperts,
271
- # BatchedMarlinExperts, XPUExpertsWNA16, CPUExpertsInt4, and the Humming
272
- # grouped/indexed experts.
292
+ # BatchedMarlinExperts, XPUExpertsWNA16, CPUExpertsInt4, the Humming
293
+ # grouped/indexed experts, and Int4EmulationTritonExperts
294
allowed_experts: tuple[type[mk.FusedMoEExperts], ...] = (
295
MarlinExperts,
296
BatchedMarlinExperts,
297
TrtLlmMxint4ExpertsMonolithic,
298
XPUExpertsWNA16,
299
CPUExpertsInt4,
300
+ Int4EmulationTritonExperts,
301
)
302
if backend == WNA16MoEBackend.HUMMING:
303
allowed_experts += tuple(backend_to_kernel_cls(WNA16MoEBackend.HUMMING))
@@ -1017,6 +1039,243 @@ def _humming_wna16_weight_schema(
1039
)
1040
1041
1042
+def _unpack_and_dequant_int4_gptq(
1043
+ w_int32: torch.Tensor,
1044
+ scale: torch.Tensor,
1045
+ qzeros: torch.Tensor | None,
1046
+ transpose_output: bool,
1047
+ output_dtype: torch.dtype = torch.bfloat16,
1048
+) -> torch.Tensor:
1049
+ """Unpack GPTQ-packed int4 weights and dequantize to output_dtype.
1050
+
1051
+ Args:
1052
+ w_int32: packed weights, shape [E, K_packed, N] where K_packed = K//8
1053
+ (8 nibbles per int32, LSB-first in the K dimension).
1054
+ scale: per-group scales, shape [E, K//group_size, N], float16.
1055
+ qzeros: optional asymmetric zero-points, shape [E, K//gs, N//8], int32.
1056
+ None for symmetric (uint4b8 with implicit bias 8).
1057
+ transpose_output: if True return [E, N, K]; if False return [E, K, N].
1058
+ output_dtype: target floating-point dtype (bfloat16 or float16).
1059
+
1060
+ Returns:
1061
+ Dequantized weight tensor in the requested layout.
1062
+ """
1063
+ E, K_packed, N = w_int32.shape
1064
+ K = K_packed * 8
1065
+
1066
+ # Unpack: [E, K_packed, N] -> [E, K_packed, N, 8] via bit-shifts.
1067
+ # The nibble index (last dim) enumerates K rows within each packed column,
1068
+ # so we must fuse K_packed and the nibble dim, not N and the nibble dim.
1069
+ # Permute to [E, K_packed, 8, N] before reshaping to [E, K, N].
1070
+ shifts = torch.arange(8, device=w_int32.device, dtype=torch.int32) * 4
1071
+ nibbles = (w_int32.unsqueeze(-1) >> shifts) & 0xF # [E, K_packed, N, 8]
1072
+
1073
+ # Reshape to [E, K, N]: fuse K_packed and nibble index (dim 1 and 3)
1074
+ w = nibbles.permute(0, 1, 3, 2).reshape(E, K, N).to(torch.int16)
1075
+
1076
+ if qzeros is None:
1077
+ # Symmetric uint4b8: subtract bias so the range is [-8, 7]
1078
+ w = w - 8
1079
+ else:
1080
+ # Asymmetric: unpack zero-points (same 8-nibble packing) and subtract
1081
+ # qzeros shape: [E, K//gs, N//8] int32
1082
+ gs = K // scale.shape[1]
1083
+ n_gs = scale.shape[1]
1084
+ zp_shifts = torch.arange(8, device=qzeros.device, dtype=torch.int32) * 4
1085
+ zp_nibbles = (qzeros.unsqueeze(-1) >> zp_shifts) & 0xF # [E, n_gs, N//8, 8]
1086
+ zp = zp_nibbles.reshape(E, n_gs, N).to(torch.int16) # [E, n_gs, N]
1087
+ zp = zp.repeat_interleave(gs, dim=1) # [E, K, N]
1088
+ w = w - zp
1089
+
1090
+ # Broadcast scale [E, K//gs, N] -> [E, K, N]
1091
+ gs = K // scale.shape[1]
1092
+ scale_broadcast = scale.repeat_interleave(gs, dim=1).to(output_dtype)
1093
+
1094
+ w_dequant = w.to(output_dtype) * scale_broadcast # [E, K, N]
1095
+
1096
+ if transpose_output:
1097
+ return w_dequant.permute(0, 2, 1).contiguous() # [E, N, K]
1098
+ return w_dequant.contiguous() # [E, K, N]
1099
+
1100
+
1101
+def _unpack_and_dequant_int4_awq(
1102
+ w_int32: torch.Tensor,
1103
+ scale: torch.Tensor,
1104
+ qzeros: torch.Tensor | None,
1105
+ transpose_output: bool,
1106
+ output_dtype: torch.dtype = torch.bfloat16,
1107
+) -> torch.Tensor:
1108
+ """Unpack AWQ-packed int4 weights and dequantize to output_dtype.
1109
+
1110
+ AWQ packs along the N (column) dimension with an interleave permutation
1111
+ [0,2,4,6,1,3,5,7] applied before packing, so unpacking must undo that.
1112
+
1113
+ Args:
1114
+ w_int32: packed weights, shape [E, K, N_packed] where N_packed = N//8
1115
+ (8 nibbles per int32, packed along N with AWQ interleaving).
1116
+ scale: per-group scales, shape [E, K//group_size, N], float16.
1117
+ qzeros: asymmetric zero-points, shape [E, K//gs, N_packed], int32.
1118
+ None for symmetric (uint4b8 with implicit bias 8).
1119
+ transpose_output: if True return [E, N, K]; if False return [E, K, N].
1120
+ output_dtype: target floating-point dtype (bfloat16 or float16).
1121
+
1122
+ Returns:
1123
+ Dequantized weight tensor in the requested layout.
1124
+ """
1125
+ E, K, N_packed = w_int32.shape
1126
+ N = N_packed * 8
1127
+
1128
+ # Unpack 8 nibbles per int32 along the N dimension (LSB-first)
1129
+ shifts = torch.arange(8, device=w_int32.device, dtype=torch.int32) * 4
1130
+ # [E, K, N_packed, 8] -> [E, K, N_packed*8] = [E, K, N_interleaved]
1131
+ nibbles = (w_int32.unsqueeze(-1) >> shifts) & 0xF
1132
+ w_interleaved = nibbles.reshape(E, K, N) # [E, K, N] but column-interleaved
1133
+
1134
+ # Undo AWQ interleave: packed order is [0,2,4,6,1,3,5,7] within each group
1135
+ # of 8. Inverse: position i in packed -> original column interleave[i].
1136
+ # To reverse: we need the inverse permutation so that
1137
+ # w[:, :, inv_interleave] = w_interleaved gives the natural column order.
1138
+ interleave = torch.tensor([0, 2, 4, 6, 1, 3, 5, 7], device=w_int32.device)
1139
+ inv_interleave = torch.empty_like(interleave)
1140
+ inv_interleave[interleave] = torch.arange(8, device=w_int32.device)
1141
+
1142
+ # Apply inverse interleave within each group of 8 columns
1143
+ w_reshaped = w_interleaved.reshape(E, K, N // 8, 8) # [E, K, groups, 8]
1144
+ w_reordered = w_reshaped[:, :, :, inv_interleave] # undo interleave
1145
+ w = w_reordered.reshape(E, K, N).to(torch.int16) # [E, K, N]
1146
+
1147
+ if qzeros is None:
1148
+ w = w - 8
1149
+ else:
1150
+ # qzeros: [E, K//gs, N_packed] int32, same AWQ column packing
1151
+ gs = K // scale.shape[1]
1152
+ n_gs = scale.shape[1]
1153
+ zp_nibbles = (qzeros.unsqueeze(-1) >> shifts) & 0xF # [E, n_gs, N_packed, 8]
1154
+ zp_interleaved = zp_nibbles.reshape(E, n_gs, N)
1155
+ zp_reshaped = zp_interleaved.reshape(E, n_gs, N // 8, 8)
1156
+ zp_reordered = zp_reshaped[:, :, :, inv_interleave]
1157
+ zp = zp_reordered.reshape(E, n_gs, N).to(torch.int16) # [E, n_gs, N]
1158
+ zp = zp.repeat_interleave(gs, dim=1) # [E, K, N]
1159
+ w = w - zp
1160
+
1161
+ gs = K // scale.shape[1]
1162
+ scale_broadcast = scale.repeat_interleave(gs, dim=1).to(output_dtype) # [E, K, N]
1163
+
1164
+ w_dequant = w.to(output_dtype) * scale_broadcast # [E, K, N]
1165
+
1166
+ if transpose_output:
1167
+ return w_dequant.permute(0, 2, 1).contiguous() # [E, N, K]
1168
+ return w_dequant.contiguous() # [E, K, N]
1169
+
1170
+
1171
+def _process_weights_emulation_gptq(
1172
+ w13: torch.Tensor,
1173
+ w2: torch.Tensor,
1174
+ w13_scale: torch.Tensor,
1175
+ w2_scale: torch.Tensor,
1176
+ w13_qzeros: torch.Tensor | None,
1177
+ w2_qzeros: torch.Tensor | None,
1178
+) -> tuple:
1179
+ """Dequantize int4 weights to BF16 for the emulation backend.
1180
+
1181
+ Inputs are in GPTQ packed format:
1182
+ w13: [E, K//8, 2*N] int32 (gate+up proj stacked on dim 2)
1183
+ w2: [E, N//8, K] int32
1184
+ w13_scale: [E, K//gs, 2*N] float16
1185
+ w2_scale: [E, N//gs, K] float16
1186
+
1187
+ Outputs (what TritonExperts expects):
1188
+ w13_out: [E, 2*N, K] bfloat16
1189
+ w2_out: [E, K, N] bfloat16
1190
+ """
1191
+ # w13: packed along K (dim 1), output cols are 2*N (dim 2)
1192
+ # transpose_output=True yields [E, 2*N, K]
1193
+ w13_bf16 = _unpack_and_dequant_int4_gptq(
1194
+ w13, w13_scale, w13_qzeros, transpose_output=True
1195
+ )
1196
+
1197
+ # w2: packed along N (dim 1 is N//8), output cols are K (dim 2)
1198
+ # After unpacking we get [E, N, K]; we want [E, K, N] for TritonExperts
1199
+ # transpose_output=False gives [E, N, K], then we permute once more
1200
+ w2_unpacked = _unpack_and_dequant_int4_gptq(
1201
+ w2, w2_scale, w2_qzeros, transpose_output=False
1202
+ ) # [E, N, K]
1203
+ w2_bf16 = w2_unpacked.permute(0, 2, 1).contiguous() # [E, K, N]
1204
+
1205
+ dummy = torch.ones(1, dtype=torch.float16, device=w13.device)
1206
+ return (
1207
+ w13_bf16, # w13_qweight (now bf16, not int32)
1208
+ w2_bf16, # w2_qweight (now bf16, not int32)
1209
+ dummy, # w13_scales (unused; nulled out in Int4EmulationTritonExperts)
1210
+ dummy, # w2_scales (unused)
1211
+ None, # w13_g_idx
1212
+ None, # w2_g_idx
1213
+ None, # w13_g_idx_sort_indices
1214
+ None, # w2_g_idx_sort_indices
1215
+ None, # w13_qzeros
1216
+ None, # w2_qzeros
1217
+ None, # w13_input_global_scale
1218
+ None, # w2_input_global_scale
1219
+ None, # w13_bias
1220
+ None, # w2_bias
1221
+ )
1222
+
1223
+
1224
+def _process_weights_emulation_awq(
1225
+ w13: torch.Tensor,
1226
+ w2: torch.Tensor,
1227
+ w13_scale: torch.Tensor,
1228
+ w2_scale: torch.Tensor,
1229
+ w13_qzeros: torch.Tensor | None,
1230
+ w2_qzeros: torch.Tensor | None,
1231
+) -> tuple:
1232
+ """Dequantize AWQ int4 weights to BF16 for the emulation backend.
1233
+
1234
+ AWQ inputs:
1235
+ w13: [E, K, 2*N//8] int32 (packed along N, gate+up on dim 2)
1236
+ w2: [E, N, K//8] int32 (packed along K)
1237
+ w13_scale: [E, K//gs, 2*N] float16
1238
+ w2_scale: [E, N//gs, K] float16
1239
+
1240
+ Outputs (what TritonExperts expects):
1241
+ w13_out: [E, 2*N, K] bfloat16
1242
+ w2_out: [E, K, N] bfloat16
1243
+ """
1244
+ # w13: AWQ-packed along N (dim 2), K is unpacked in dim 1
1245
+ # _unpack_and_dequant_int4_awq with transpose_output=True yields [E, 2*N, K]
1246
+ w13_bf16 = _unpack_and_dequant_int4_awq(
1247
+ w13, w13_scale, w13_qzeros, transpose_output=True
1248
+ )
1249
+
1250
+ # w2: AWQ packs along K (dim 2 is K//8), N is unpacked in dim 1.
1251
+ # AWQ w2 is [E, N, K//8] — same column-pack format applied to the K dim.
1252
+ # _unpack_and_dequant_int4_awq expects [E, rows, N_packed] where the
1253
+ # packed dim is columns. Treat dim 1 as rows and dim 2 as N_packed:
1254
+ # unpacking gives [E, N, K]. Then permute to [E, K, N].
1255
+ w2_unpacked = _unpack_and_dequant_int4_awq(
1256
+ w2, w2_scale, w2_qzeros, transpose_output=False
1257
+ ) # [E, N, K]
1258
+ w2_bf16 = w2_unpacked.permute(0, 2, 1).contiguous() # [E, K, N]
1259
+
1260
+ dummy = torch.ones(1, dtype=torch.float16, device=w13.device)
1261
+ return (
1262
+ w13_bf16,
1263
+ w2_bf16,
1264
+ dummy,
1265
+ dummy,
1266
+ None,
1267
+ None,
1268
+ None,
1269
+ None,
1270
+ None,
1271
+ None,
1272
+ None,
1273
+ None,
1274
+ None,
1275
+ None,
1276
+ )
1277
+
1278
+
1279
def convert_to_wna16_moe_kernel_format(
1280
backend: WNA16MoEBackend,
1281
layer: torch.nn.Module,
@@ -1203,5 +1462,25 @@ def convert_to_wna16_moe_kernel_format(
1462
w13_bias_out,
1463
w2_bias_out,
1464
)
1465
+ elif backend == WNA16MoEBackend.EMULATION:
1466
+ from vllm.model_executor.layers.quantization.auto_awq import AutoAWQConfig
1467
+
1468
+ if isinstance(quant_config, AutoAWQConfig):
1469
+ return _process_weights_emulation_awq(
1470
+ w13,
1471
+ w2,
1472
+ w13_scale,
1473
+ w2_scale,
1474
+ w13_qzeros,
1475
+ w2_qzeros,
1476
+ )
1477
+ return _process_weights_emulation_gptq(
1478
+ w13,
1479
+ w2,
1480
+ w13_scale,
1481
+ w2_scale,
1482
+ w13_qzeros,
1483
+ w2_qzeros,
1484
+ )
1485
else:
1486
raise ValueError(f"Unsupported wna16 MoE backend: {backend.value}")
vllm/model_executor/layers/quantization/auto_awq.py
+3
@@ -774,6 +774,9 @@ class AutoAWQMoEMethod(FusedMoEMethodBase):
774
w2_bias=getattr(layer, "w2_bias", None),
775
a1_gscale=getattr(layer, "w13_input_global_scale", None),
776
a2_gscale=getattr(layer, "w2_input_global_scale", None),
777
+ gemm1_clamp_limit=getattr(layer, "swiglu_limit", None),
778
+ gemm1_alpha=getattr(layer, "swiglu_alpha", None),
779
+ gemm1_beta=getattr(layer, "swiglu_beta", None),
780
)
781
782
def select_gemm_impl(
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe.py
+17
@@ -142,6 +142,23 @@ class CompressedTensorsMoEMethod(FusedMoEMethodBase):
142
return CompressedTensorsW4A16FlydslMoEMethod(
143
weight_quant, input_quant, layer.moe_config
144
)
145
+ elif moe_backend == "emulation":
146
+ # Although this is called 'Marlin', actually it selects
147
+ # emulation backend by calling select_wna16_moe_backend.
148
+ # TODO: we need to update CompressedTensorsWNA16MoeMethod
149
+ # to honor "--moe-backend" option
150
+ from .compressed_tensors_moe_wna16_marlin import (
151
+ CompressedTensorsWNA16MarlinMoEMethod,
152
+ )
153
+
154
+ logger.info_once(
155
+ "Using CompressedTensorsWNA16MarlinMoEMethod "
156
+ "(emulation backend requested)"
157
+ )
158
+ return CompressedTensorsWNA16MarlinMoEMethod(
159
+ weight_quant, input_quant, layer.moe_config, layer_name
160
+ )
161
+
162
from .compressed_tensors_moe_wna16 import (
163
CompressedTensorsWNA16MoEMethod,
164
)
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_w4a16_flydsl.py
+2
-1
@@ -108,7 +108,8 @@ class CompressedTensorsW4A16FlydslMoEMethod(CompressedTensorsMoEMethod):
108
# grouped actorder isn't supported by this kernel
109
assert weight_quant.actorder != "group"
110
assert weight_quant.symmetric, (
111
- "Only symmetric quantization is supported for MoE"
111
+ "Only symmetric quantization is supported for MoE. "
112
+ "Try --moe-backend emulation."
113
)
114
115
def create_weights(
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_wna16.py
+2
-1
@@ -48,7 +48,8 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
48
# grouped actorder isn't supported by this kernel
49
assert weight_quant.actorder != "group"
50
assert weight_quant.symmetric, (
51
- "Only symmetric quantization is supported for MoE"
51
+ "Only symmetric quantization is supported for MoE. "
52
+ "Try --moe-backend emulation."
53
)
54
55
def create_weights(
vllm/model_executor/layers/quantization/compressed_tensors/compressed_tensors_moe/compressed_tensors_moe_wna16_marlin.py
+18
-6
@@ -467,10 +467,16 @@ class CompressedTensorsWNA16MarlinMoEMethod(CompressedTensorsMoEMethod):
467
468
# Marlin-specific parameters (not needed for Flashinfer)
469
if not is_flashinfer:
470
- replace_parameter(layer, "w13_weight_g_idx", w13_g_idx_processed)
471
- replace_parameter(layer, "w2_weight_g_idx", w2_g_idx_processed)
472
- replace_parameter(layer, "w13_g_idx_sort_indices", w13_g_idx_sort_indices)
473
- replace_parameter(layer, "w2_g_idx_sort_indices", w2_g_idx_sort_indices)
470
+ if w13_g_idx_processed is not None:
471
+ replace_parameter(layer, "w13_weight_g_idx", w13_g_idx_processed)
472
+ if w2_g_idx_processed is not None:
473
+ replace_parameter(layer, "w2_weight_g_idx", w2_g_idx_processed)
474
+ if w13_g_idx_sort_indices is not None:
475
+ replace_parameter(
476
+ layer, "w13_g_idx_sort_indices", w13_g_idx_sort_indices
477
+ )
478
+ if w2_g_idx_sort_indices is not None:
479
+ replace_parameter(layer, "w2_g_idx_sort_indices", w2_g_idx_sort_indices)
480
481
# Register input global scales if present
482
if w13_input_global_scale is not None:
@@ -484,8 +490,11 @@ class CompressedTensorsWNA16MarlinMoEMethod(CompressedTensorsMoEMethod):
490
torch.nn.Parameter(w2_input_global_scale, requires_grad=False),
491
)
492
487
- if self.experts_cls is not None and issubclass(
488
- self.experts_cls, FusedMoEExpertsModular
493
+ # Marlin workspace — only needed for Marlin-family backends, not emulation.
494
+ if (
495
+ self.experts_cls is not None
496
+ and issubclass(self.experts_cls, FusedMoEExpertsModular)
497
+ and self.wna16_backend != WNA16MoEBackend.EMULATION
498
):
499
layer.workspace = marlin_make_workspace_new(
500
layer.w13_weight_g_idx.device, 4
@@ -528,6 +537,9 @@ class CompressedTensorsWNA16MarlinMoEMethod(CompressedTensorsMoEMethod):
537
num_bits=self.num_bits,
538
w1_zp=getattr(layer, "w13_weight_zero_point", None),
539
w2_zp=getattr(layer, "w2_weight_zero_point", None),
540
+ gemm1_clamp_limit=getattr(layer, "swiglu_limit", None),
541
+ gemm1_alpha=getattr(layer, "swiglu_alpha", None),
542
+ gemm1_beta=getattr(layer, "swiglu_beta", None),
543
)
544
545
def apply_monolithic(