[XPU][INC] Add int4 w4a8 (dynamic int8 activation) backend for INC linear layers (#50501)
Signed-off-by: Thanaji Rao Thakkalapelli <thanaji.rao.thakkalapelli@intel.com> Co-authored-by: Kunshang Ji <kunshang.ji@intel.com>
Thanaji Rao Thakkalapelli committed
Aug 21, 2026 at 01:38 UTC
9fd750f00de5ab8fa172c34e219b3345411e9a91
4 files changed
+787
tests/quantization/test_auto_round.py
+618
@@ -33,6 +33,9 @@ from vllm.model_executor.layers.quantization.inc.schemes.inc_mxfp8_linear import
33
from vllm.model_executor.layers.quantization.inc.schemes.inc_scheme import (
34
INCLinearScheme,
35
)
36
+from vllm.model_executor.layers.quantization.inc.schemes.inc_w4a8_linear import (
37
+ INCXPUW4A8LinearMethod,
38
+)
39
from vllm.model_executor.layers.quantization.inc.schemes.inc_wna16_linear import (
40
INCARKLinearMethod,
41
INCWNA16LinearScheme,
@@ -1482,3 +1485,618 @@ class TestGetLayerConfigFusedQKV:
1485
DummyLayer(), "model.layers.0.self_attn.qkv_proj"
1486
)
1487
assert bits == 8
1488
+
1489
+
1490
+# ---------------------------------------------------------------------------
1491
+# INC XPU w4a8: int4 weights with dynamic per-token int8 activations
1492
+#
1493
+# The w4a8 path keeps int4 weights but dynamically quantizes activations to
1494
+# per-token symmetric int8, so the GEMM stays on the int8 datapath instead of
1495
+# upconverting weights to the activation dtype. It is opt-in through
1496
+# VLLM_XPU_INC_WNA16_BACKEND because which kernel is fastest depends on the
1497
+# device: ARK can use XMX int8 on some XPUs, and the default ("auto")
1498
+# preference order is deliberately left unchanged.
1499
+#
1500
+# The backend-selection tests stub out ARK and the platform; the weight-creation
1501
+# tests only need the TP-rank stubs. Both run anywhere. The apply_weights tests
1502
+# stub out int4_gemm_w4a8 — what needs guarding there is the calling convention
1503
+# (scale dtypes, argument order, weight reuse, batch-size gate), which is where
1504
+# this path is easy to get silently wrong, since the kernel accepts bf16 scales
1505
+# and returns plausible-looking garbage rather than raising. The ones that cross
1506
+# the token threshold reach ``vllm._xpu_ops`` for the activation quantizer, so
1507
+# they are XPU-image only; the small-batch fallback test returns before that
1508
+# import and must stay unmarked to keep covering the gate off-XPU. The
1509
+# end-to-end tests load a real int4 checkpoint and additionally need the op.
1510
+# ---------------------------------------------------------------------------
1511
+
1512
+_BACKEND_ENV = "VLLM_XPU_INC_WNA16_BACKEND"
1513
+_ARK_STATE = (
1514
+ "vllm.model_executor.layers.quantization.inc.schemes.inc_ark_ops.get_ark_state"
1515
+)
1516
+_QUANT_REF = "vllm._xpu_ops.xpu_ops.dynamic_per_token_int8_quant_ref"
1517
+
1518
+IN_FEATURES = 128
1519
+OUT_FEATURES = 64
1520
+
1521
+# auto-round int4/sym/g128 checkpoints, which is what this backend serves. The
1522
+# GPTQ- and AWQ-packed variants take different repack branches in
1523
+# ``process_weights_after_loading``, so both are worth loading.
1524
+E2E_MODELS = [
1525
+ pytest.param("OPEA/Qwen2.5-0.5B-Instruct-int4-sym-inc", id="auto_round:auto_gptq"),
1526
+ pytest.param(
1527
+ "Intel/Qwen2-0.5B-Instruct-int4-sym-AutoRound", id="auto_round:auto_awq"
1528
+ ),
1529
+]
1530
+
1531
+
1532
+def _has_xpu_ops() -> bool:
1533
+ """True when ``vllm._xpu_ops`` is importable.
1534
+
1535
+ ``apply_weights`` imports it for the activation quantizer, and the stubs
1536
+ below patch through it, so tests that exercise that path cannot even be set
1537
+ up on a build without vllm-xpu-kernels installed.
1538
+ """
1539
+ if not current_platform.is_xpu():
1540
+ return False
1541
+ try:
1542
+ import vllm._xpu_ops # noqa: F401
1543
+ except ImportError:
1544
+ return False
1545
+ return True
1546
+
1547
+
1548
+def _has_w4a8_kernel() -> bool:
1549
+ """True when this build can actually run the w4a8 GEMM.
1550
+
1551
+ The op is registered as a side effect of importing ``vllm._xpu_ops``, so it
1552
+ is not visible on ``torch.ops._xpu_C`` until that import happens.
1553
+ """
1554
+ return _has_xpu_ops() and hasattr(torch.ops._xpu_C, "int4_gemm_w4a8")
1555
+
1556
+
1557
+requires_xpu_ops = pytest.mark.skipif(
1558
+ not _has_xpu_ops(),
1559
+ reason="needs an XPU image with vllm._xpu_ops available",
1560
+)
1561
+
1562
+requires_w4a8_kernel = pytest.mark.skipif(
1563
+ not _has_w4a8_kernel(),
1564
+ reason="needs an XPU with the int4_gemm_w4a8 op",
1565
+)
1566
+
1567
+
1568
+def _fake_xpu(monkeypatch, ark_available: bool = True) -> None:
1569
+ class DummyQuantLinear:
1570
+ pass
1571
+
1572
+ monkeypatch.setattr(current_platform, "is_xpu", lambda: True)
1573
+ monkeypatch.setattr(current_platform, "is_cpu", lambda: False)
1574
+ monkeypatch.setattr(
1575
+ _ARK_STATE,
1576
+ lambda: (
1577
+ (True, None, object(), DummyQuantLinear)
1578
+ if ark_available
1579
+ else (False, "missing", None, None)
1580
+ ),
1581
+ )
1582
+
1583
+
1584
+def _with_w4a8_kernel(monkeypatch) -> None:
1585
+ """Present the op so the w4a8 support check passes off-XPU."""
1586
+
1587
+ class FakeOps:
1588
+ int4_gemm_w4a8 = object()
1589
+
1590
+ monkeypatch.setattr(torch.ops, "_xpu_C", FakeOps, raising=False)
1591
+
1592
+
1593
+def _dispatch(layer_config=None):
1594
+ return INCWna16Scheme().get_linear_method(
1595
+ object(), object(), "layer", layer_config or make_layer_config()
1596
+ )
1597
+
1598
+
1599
+@pytest.fixture
1600
+def single_tp_rank(monkeypatch):
1601
+ """Parameter creation reads the TP rank/size; there is no distributed init."""
1602
+ monkeypatch.setattr(
1603
+ "vllm.model_executor.parameter.get_tensor_model_parallel_rank",
1604
+ lambda: 0,
1605
+ )
1606
+ monkeypatch.setattr(
1607
+ "vllm.model_executor.parameter.get_tensor_model_parallel_world_size",
1608
+ lambda: 1,
1609
+ )
1610
+
1611
+
1612
+def _create_weights(
1613
+ method,
1614
+ input_size_per_partition: int = IN_FEATURES,
1615
+ output_partition_sizes: list[int] | None = None,
1616
+) -> torch.nn.Module:
1617
+ class DummyLayer(torch.nn.Module):
1618
+ pass
1619
+
1620
+ layer = DummyLayer()
1621
+ output_partition_sizes = (
1622
+ [OUT_FEATURES] if output_partition_sizes is None else output_partition_sizes
1623
+ )
1624
+ method.create_weights(
1625
+ layer=layer,
1626
+ input_size_per_partition=input_size_per_partition,
1627
+ output_partition_sizes=output_partition_sizes,
1628
+ input_size=input_size_per_partition,
1629
+ output_size=sum(output_partition_sizes),
1630
+ params_dtype=torch.bfloat16,
1631
+ weight_loader=lambda *args, **kwargs: None,
1632
+ )
1633
+ return layer
1634
+
1635
+
1636
+@pytest.fixture
1637
+def w4a8_layer(single_tp_rank):
1638
+ """A w4a8 method plus a layer whose weights are created but not processed."""
1639
+ method = INCXPUW4A8LinearMethod(make_layer_config())
1640
+ return method, _create_weights(method)
1641
+
1642
+
1643
+def _stub_quant(monkeypatch, captured=None):
1644
+ def fake_quant(x, use_sym, bits):
1645
+ if captured is not None:
1646
+ captured["quant_args"] = (tuple(x.shape), use_sym, bits)
1647
+ m, k = x.shape
1648
+ return (
1649
+ torch.zeros(m, k, dtype=torch.int8),
1650
+ torch.ones(m, 1, dtype=x.dtype),
1651
+ torch.zeros(m, 1, dtype=torch.int32),
1652
+ )
1653
+
1654
+ monkeypatch.setattr(_QUANT_REF, fake_quant, raising=False)
1655
+
1656
+
1657
+def _stub_gemm(monkeypatch, captured=None):
1658
+ def fake_gemm(*args):
1659
+ if captured is not None:
1660
+ captured["gemm_args"] = args
1661
+ # The real kernel always emits float16, whatever the model dtype.
1662
+ return torch.zeros(args[0].shape[0], OUT_FEATURES, dtype=torch.float16)
1663
+
1664
+ monkeypatch.setattr(torch.ops._xpu_C, "int4_gemm_w4a8", fake_gemm, raising=False)
1665
+
1666
+
1667
+# ---------------------------------------------------------------------------
1668
+# Backend selection
1669
+# ---------------------------------------------------------------------------
1670
+
1671
+
1672
+def test_default_backend_keeps_ark_preference(monkeypatch) -> None:
1673
+ """The "auto" default must not change upstream behaviour.
1674
+
1675
+ ARK still wins whenever it is importable.
1676
+ """
1677
+ monkeypatch.delenv(_BACKEND_ENV, raising=False)
1678
+ _fake_xpu(monkeypatch, ark_available=True)
1679
+ _with_w4a8_kernel(monkeypatch)
1680
+
1681
+ method = _dispatch()
1682
+
1683
+ assert isinstance(method, INCLinearMethod)
1684
+ assert isinstance(method.scheme, INCARKLinearMethod)
1685
+
1686
+
1687
+def test_w4a8_backend_overrides_available_ark(monkeypatch) -> None:
1688
+ """An explicit request wins even though ARK would otherwise be chosen."""
1689
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
1690
+ _fake_xpu(monkeypatch, ark_available=True)
1691
+ _with_w4a8_kernel(monkeypatch)
1692
+
1693
+ method = _dispatch()
1694
+
1695
+ assert isinstance(method.scheme, INCXPUW4A8LinearMethod)
1696
+ assert not isinstance(method.scheme, INCARKLinearMethod)
1697
+ # w4a8 subclasses w4a16 so it can fall back for small batches.
1698
+ assert isinstance(method.scheme, INCXPULinearMethod)
1699
+
1700
+
1701
+def test_w4a16_backend_overrides_available_ark(monkeypatch) -> None:
1702
+ monkeypatch.setenv(_BACKEND_ENV, "w4a16")
1703
+ _fake_xpu(monkeypatch, ark_available=True)
1704
+
1705
+ method = _dispatch()
1706
+
1707
+ assert isinstance(method.scheme, INCXPULinearMethod)
1708
+ assert not isinstance(method.scheme, INCXPUW4A8LinearMethod)
1709
+ assert not isinstance(method.scheme, INCARKLinearMethod)
1710
+
1711
+
1712
+def test_ark_backend_requested_but_unavailable_raises(monkeypatch) -> None:
1713
+ """Explicitly asking for ARK must fail loudly, not fall back silently."""
1714
+ monkeypatch.setenv(_BACKEND_ENV, "ark")
1715
+ _fake_xpu(monkeypatch, ark_available=False)
1716
+
1717
+ with pytest.raises(NotImplementedError, match="auto_round_kernel is unavailable"):
1718
+ _dispatch()
1719
+
1720
+
1721
+@pytest.mark.parametrize("backend", ["w4a16", "w4a8"])
1722
+def test_onednn_backends_reject_int2(monkeypatch, backend) -> None:
1723
+ """The oneDNN int4 GEMMs cannot serve int2; ARK is the only int2 path."""
1724
+ monkeypatch.setenv(_BACKEND_ENV, backend)
1725
+ _fake_xpu(monkeypatch, ark_available=True)
1726
+ _with_w4a8_kernel(monkeypatch)
1727
+
1728
+ with pytest.raises(NotImplementedError, match="only supports int4"):
1729
+ _dispatch(make_layer_config(bits=2))
1730
+
1731
+
1732
+@pytest.mark.parametrize("group_size", [48, -1, 0])
1733
+def test_w4a8_rejects_unaligned_group_size(monkeypatch, group_size) -> None:
1734
+ """The kernel needs a group size that is a positive multiple of 32."""
1735
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
1736
+ _fake_xpu(monkeypatch, ark_available=True)
1737
+ _with_w4a8_kernel(monkeypatch)
1738
+
1739
+ with pytest.raises(NotImplementedError, match="multiple of 32"):
1740
+ _dispatch(make_layer_config(group_size=group_size))
1741
+
1742
+
1743
+@pytest.mark.parametrize("group_size", [128, 64, 32])
1744
+def test_w4a8_accepts_aligned_group_size(monkeypatch, group_size) -> None:
1745
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
1746
+ _fake_xpu(monkeypatch, ark_available=True)
1747
+ _with_w4a8_kernel(monkeypatch)
1748
+
1749
+ method = _dispatch(make_layer_config(group_size=group_size))
1750
+
1751
+ assert isinstance(method.scheme, INCXPUW4A8LinearMethod)
1752
+
1753
+
1754
+def test_w4a8_requires_the_kernel(monkeypatch) -> None:
1755
+ """Older vllm-xpu-kernels builds lack the op entirely."""
1756
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
1757
+ _fake_xpu(monkeypatch, ark_available=True)
1758
+
1759
+ class FakeOpsNoW4A8:
1760
+ pass
1761
+
1762
+ monkeypatch.setattr(torch.ops, "_xpu_C", FakeOpsNoW4A8, raising=False)
1763
+
1764
+ with pytest.raises(NotImplementedError, match="int4_gemm_w4a8"):
1765
+ _dispatch()
1766
+
1767
+
1768
+def test_invalid_backend_value_raises(monkeypatch) -> None:
1769
+ import vllm.envs as envs
1770
+
1771
+ monkeypatch.setenv(_BACKEND_ENV, "onednn")
1772
+ with pytest.raises(ValueError, match=_BACKEND_ENV):
1773
+ _ = envs.VLLM_XPU_INC_WNA16_BACKEND
1774
+
1775
+
1776
+# ---------------------------------------------------------------------------
1777
+# Partition shape
1778
+# ---------------------------------------------------------------------------
1779
+#
1780
+# ``XPUW4A8IntLinearKernel.can_implement`` rejects partition shapes whose in/out
1781
+# dims are not multiples of 8, but that check is unreachable from the INC path:
1782
+# the shapes only exist once ``create_weights`` is called, well after backend
1783
+# selection. Unaligned dims must not reach the kernel — the int4 packing divides
1784
+# both dims by 8, so an unaligned size is silently truncated into a weight tensor
1785
+# that is too small rather than rejected.
1786
+
1787
+
1788
+def test_rejects_unaligned_input_partition(single_tp_rank) -> None:
1789
+ method = INCXPUW4A8LinearMethod(make_layer_config(group_size=32))
1790
+
1791
+ with pytest.raises(NotImplementedError, match=r"multiples of 8.*input=132"):
1792
+ _create_weights(method, input_size_per_partition=132)
1793
+
1794
+
1795
+def test_rejects_unaligned_output_partition(single_tp_rank) -> None:
1796
+ method = INCXPUW4A8LinearMethod(make_layer_config())
1797
+
1798
+ with pytest.raises(NotImplementedError, match=r"multiples of 8.*output=60"):
1799
+ _create_weights(method, output_partition_sizes=[60])
1800
+
1801
+
1802
+def test_rejects_unaligned_sum_of_output_partitions(single_tp_rank) -> None:
1803
+ """A fused QKV/MLP layer is only as aligned as the sum of its shards."""
1804
+ method = INCXPUW4A8LinearMethod(make_layer_config())
1805
+
1806
+ with pytest.raises(NotImplementedError, match=r"multiples of 8.*output=76"):
1807
+ _create_weights(method, output_partition_sizes=[OUT_FEATURES, 12])
1808
+
1809
+
1810
+def test_reports_both_unaligned_dims(single_tp_rank) -> None:
1811
+ """The message names every offending dim, not just the first."""
1812
+ method = INCXPUW4A8LinearMethod(make_layer_config(group_size=32))
1813
+
1814
+ with pytest.raises(NotImplementedError) as excinfo:
1815
+ _create_weights(
1816
+ method, input_size_per_partition=132, output_partition_sizes=[60]
1817
+ )
1818
+
1819
+ assert "input=132" in str(excinfo.value)
1820
+ assert "output=60" in str(excinfo.value)
1821
+
1822
+
1823
+def test_accepts_aligned_partition_shape(single_tp_rank) -> None:
1824
+ """Multiples of 8 that are not powers of two are still fine."""
1825
+ method = INCXPUW4A8LinearMethod(make_layer_config(group_size=32))
1826
+
1827
+ layer = _create_weights(
1828
+ method, input_size_per_partition=96, output_partition_sizes=[24, 48]
1829
+ )
1830
+
1831
+ assert layer.qweight.shape == (96 // 8, 72)
1832
+
1833
+
1834
+def test_w4a16_accepts_unaligned_partition_shape(single_tp_rank) -> None:
1835
+ """The constraint is a w4a8 kernel requirement; w4a16 must stay unaffected."""
1836
+ method = INCXPULinearMethod(make_layer_config(group_size=32))
1837
+
1838
+ layer = _create_weights(method, output_partition_sizes=[60])
1839
+
1840
+ assert layer.scales.shape[1] == 60
1841
+
1842
+
1843
+# ---------------------------------------------------------------------------
1844
+# Weight processing
1845
+# ---------------------------------------------------------------------------
1846
+
1847
+
1848
+def test_keeps_fp16_scales(w4a8_layer) -> None:
1849
+ """The kernel reads scales as fp16 regardless of activation dtype.
1850
+
1851
+ Passing bf16 scales does not raise — it silently returns wrong results — so
1852
+ an fp16 copy must exist, while the bf16 ``scales`` survive for the
1853
+ small-batch w4a16 fallback.
1854
+ """
1855
+ method, layer = w4a8_layer
1856
+ layer.scales.data.fill_(0.125) # exactly representable in both dtypes
1857
+
1858
+ method.process_weights_after_loading(layer)
1859
+
1860
+ assert layer.scales_fp16.dtype is torch.float16
1861
+ assert layer.scales_fp16.shape == layer.scales.shape
1862
+ assert torch.equal(layer.scales_fp16.data.float(), layer.scales.data.float())
1863
+ # The w4a16 fallback still needs the original activation-dtype scales.
1864
+ assert layer.scales.dtype is torch.bfloat16
1865
+
1866
+
1867
+def test_reuses_w4a16_weight_layout(w4a8_layer) -> None:
1868
+ """w4a8 and w4a16 accept bit-identical weights, so there is no repacking."""
1869
+ method, layer = w4a8_layer
1870
+ qweight_before = layer.qweight.data.clone()
1871
+
1872
+ method.process_weights_after_loading(layer)
1873
+
1874
+ assert layer.qweight.dtype is torch.int32
1875
+ assert layer.qweight.shape == (IN_FEATURES // 8, OUT_FEATURES)
1876
+ assert torch.equal(layer.qweight.data, qweight_before)
1877
+ # Symmetric int4: the zero point is the constant 8, as for w4a16.
1878
+ assert layer.qzeros.dtype is torch.int8
1879
+ assert layer.qzeros.tolist() == [8]
1880
+
1881
+
1882
+# ---------------------------------------------------------------------------
1883
+# apply_weights
1884
+# ---------------------------------------------------------------------------
1885
+
1886
+
1887
+def test_falls_back_to_w4a16_for_small_batches(monkeypatch, w4a8_layer) -> None:
1888
+ """Below the token threshold, per-token quant overhead dominates."""
1889
+ method, layer = w4a8_layer
1890
+ method.process_weights_after_loading(layer)
1891
+
1892
+ calls = []
1893
+
1894
+ def record_fallback(self, layer, x, bias=None):
1895
+ calls.append(tuple(x.shape))
1896
+ return torch.zeros(x.shape[:-1] + (OUT_FEATURES,), dtype=x.dtype)
1897
+
1898
+ monkeypatch.setattr(INCXPULinearMethod, "apply_weights", record_fallback)
1899
+ monkeypatch.setattr(
1900
+ torch.ops._xpu_C,
1901
+ "int4_gemm_w4a8",
1902
+ lambda *args: pytest.fail("small batches must not call the w4a8 kernel"),
1903
+ raising=False,
1904
+ )
1905
+
1906
+ tokens = method._MIN_TOKENS_FOR_INT8 - 1
1907
+ x = torch.zeros(tokens, IN_FEATURES, dtype=torch.bfloat16)
1908
+ out = method.apply_weights(layer, x)
1909
+
1910
+ assert calls == [(tokens, IN_FEATURES)]
1911
+ assert out.shape == (tokens, OUT_FEATURES)
1912
+
1913
+
1914
+@requires_xpu_ops
1915
+def test_calls_kernel_at_threshold(monkeypatch, w4a8_layer) -> None:
1916
+ """At/above the threshold, activations are int8-quantized per token."""
1917
+ method, layer = w4a8_layer
1918
+ method.process_weights_after_loading(layer)
1919
+
1920
+ monkeypatch.setattr(
1921
+ INCXPULinearMethod,
1922
+ "apply_weights",
1923
+ lambda self, layer, x, bias=None: pytest.fail(
1924
+ "large batches must not take the w4a16 fallback"
1925
+ ),
1926
+ )
1927
+ captured: dict[str, tuple] = {}
1928
+ _stub_quant(monkeypatch, captured)
1929
+ _stub_gemm(monkeypatch, captured)
1930
+
1931
+ tokens = method._MIN_TOKENS_FOR_INT8
1932
+ x = torch.zeros(tokens, IN_FEATURES, dtype=torch.bfloat16)
1933
+ out = method.apply_weights(layer, x)
1934
+
1935
+ # Symmetric 8-bit per-token quantization over the flattened activations.
1936
+ assert captured["quant_args"] == ((tokens, IN_FEATURES), True, 8)
1937
+
1938
+ (
1939
+ quant_x,
1940
+ x_scale,
1941
+ x_zero,
1942
+ qweight,
1943
+ w_scale,
1944
+ w_zp,
1945
+ group_size,
1946
+ g_idx,
1947
+ bias,
1948
+ ) = captured["gemm_args"]
1949
+ assert quant_x.dtype is torch.int8
1950
+ # Both scales must reach the kernel as fp16, whatever the model dtype.
1951
+ assert x_scale.dtype is torch.float16
1952
+ assert w_scale.dtype is torch.float16
1953
+ assert w_scale is layer.scales_fp16
1954
+ assert x_zero.dtype is torch.int32
1955
+ assert qweight is layer.qweight
1956
+ assert w_zp is layer.qzeros
1957
+ assert group_size == 128
1958
+ assert g_idx is None
1959
+ assert bias is None
1960
+
1961
+ # The kernel emits fp16; the result is cast back to the activation dtype.
1962
+ assert out.dtype is torch.bfloat16
1963
+ assert out.shape == (tokens, OUT_FEATURES)
1964
+
1965
+
1966
+@requires_xpu_ops
1967
+@pytest.mark.parametrize("act_dtype", [torch.bfloat16, torch.float16])
1968
+def test_scales_are_fp16_for_any_activation_dtype(
1969
+ monkeypatch, w4a8_layer, act_dtype
1970
+) -> None:
1971
+ """fp16 scales are required for bf16 *and* fp16 activations alike."""
1972
+ method, layer = w4a8_layer
1973
+ layer.scales.data = layer.scales.data.to(act_dtype)
1974
+ method.process_weights_after_loading(layer)
1975
+
1976
+ captured: dict[str, tuple] = {}
1977
+ _stub_quant(monkeypatch, captured)
1978
+ _stub_gemm(monkeypatch, captured)
1979
+
1980
+ x = torch.zeros(method._MIN_TOKENS_FOR_INT8, IN_FEATURES, dtype=act_dtype)
1981
+ out = method.apply_weights(layer, x)
1982
+
1983
+ assert captured["gemm_args"][1].dtype is torch.float16
1984
+ assert captured["gemm_args"][4].dtype is torch.float16
1985
+ assert out.dtype is act_dtype
1986
+
1987
+
1988
+@requires_xpu_ops
1989
+def test_forwards_bias_to_kernel(monkeypatch, w4a8_layer) -> None:
1990
+ """Bias is applied by the kernel, not added afterwards."""
1991
+ method, layer = w4a8_layer
1992
+ method.process_weights_after_loading(layer)
1993
+
1994
+ captured: dict[str, tuple] = {}
1995
+ _stub_quant(monkeypatch)
1996
+ _stub_gemm(monkeypatch, captured)
1997
+
1998
+ bias = torch.zeros(OUT_FEATURES, dtype=torch.bfloat16)
1999
+ x = torch.zeros(method._MIN_TOKENS_FOR_INT8, IN_FEATURES, dtype=torch.bfloat16)
2000
+ method.apply_weights(layer, x, bias)
2001
+
2002
+ assert captured["gemm_args"][8] is bias
2003
+
2004
+
2005
+@requires_xpu_ops
2006
+def test_preserves_leading_dims(monkeypatch, w4a8_layer) -> None:
2007
+ """3D activations must round-trip through the flatten/reshape."""
2008
+ method, layer = w4a8_layer
2009
+ method.process_weights_after_loading(layer)
2010
+
2011
+ _stub_quant(monkeypatch)
2012
+ _stub_gemm(monkeypatch)
2013
+
2014
+ tokens = method._MIN_TOKENS_FOR_INT8
2015
+ x = torch.zeros(4, tokens, IN_FEATURES, dtype=torch.bfloat16)
2016
+ out = method.apply_weights(layer, x)
2017
+
2018
+ assert out.shape == (4, tokens, OUT_FEATURES)
2019
+ assert out.dtype is torch.bfloat16
2020
+
2021
+
2022
+@requires_xpu_ops
2023
+def test_batch_gate_uses_flattened_token_count(monkeypatch, w4a8_layer) -> None:
2024
+ """The gate counts total tokens, not the leading dimension."""
2025
+ method, layer = w4a8_layer
2026
+ method.process_weights_after_loading(layer)
2027
+
2028
+ monkeypatch.setattr(
2029
+ INCXPULinearMethod,
2030
+ "apply_weights",
2031
+ lambda self, layer, x, bias=None: pytest.fail(
2032
+ "flattened token count is at the threshold; must use w4a8"
2033
+ ),
2034
+ )
2035
+ _stub_quant(monkeypatch)
2036
+ _stub_gemm(monkeypatch)
2037
+
2038
+ tokens = method._MIN_TOKENS_FOR_INT8
2039
+ # Each leading slice is below the threshold, but 4 * (tokens // 4) is not.
2040
+ x = torch.zeros(4, tokens // 4, IN_FEATURES, dtype=torch.bfloat16)
2041
+ out = method.apply_weights(layer, x)
2042
+
2043
+ assert out.shape == (4, tokens // 4, OUT_FEATURES)
2044
+
2045
+
2046
+# ---------------------------------------------------------------------------
2047
+# End to end on real weights
2048
+# ---------------------------------------------------------------------------
2049
+
2050
+
2051
+@requires_w4a8_kernel
2052
+@pytest.mark.parametrize("model", E2E_MODELS)
2053
+def test_e2e_generates_with_real_weights(vllm_runner, monkeypatch, model) -> None:
2054
+ """The whole path — real int4 checkpoint, real kernel, real output.
2055
+
2056
+ The stubbed tests above cannot catch a wrong-but-plausible calling
2057
+ convention, because the fake kernel accepts anything. Greedy decoding of a
2058
+ factual prompt does: wrongly set scales or a bad weight layout turn the
2059
+ answer into noise rather than raising.
2060
+ """
2061
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
2062
+
2063
+ with vllm_runner(
2064
+ model, max_model_len=512, enforce_eager=True, gpu_memory_utilization=0.55
2065
+ ) as vllm_model:
2066
+ out = vllm_model.generate_greedy(["The capital of France is"], max_tokens=8)
2067
+
2068
+ assert "Paris" in out[0][1]
2069
+
2070
+
2071
+@requires_w4a8_kernel
2072
+def test_e2e_selects_w4a8_for_every_linear(vllm_runner, monkeypatch) -> None:
2073
+ """Guard against the validation silently becoming dead code.
2074
+
2075
+ If backend selection stopped routing here, the generation test above would
2076
+ still pass on the w4a16 path, so assert the method is actually in use and
2077
+ that every partition shape it accepted honours the alignment rule.
2078
+ """
2079
+ monkeypatch.setenv(_BACKEND_ENV, "w4a8")
2080
+ # apply_model pickles the closure below.
2081
+ monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
2082
+
2083
+ with vllm_runner(
2084
+ E2E_MODELS[0].values[0],
2085
+ max_model_len=512,
2086
+ enforce_eager=True,
2087
+ gpu_memory_utilization=0.55,
2088
+ ) as vllm_model:
2089
+
2090
+ def check_model(model):
2091
+ shapes = []
2092
+ for module in model.modules():
2093
+ scheme = getattr(getattr(module, "quant_method", None), "scheme", None)
2094
+ if isinstance(scheme, INCXPUW4A8LinearMethod):
2095
+ # qweight is the packed [in // 8, out] layout.
2096
+ packed_in, out = module.qweight.shape
2097
+ shapes.append((packed_in * 8, out))
2098
+
2099
+ assert shapes, "no linear layer used the w4a8 method"
2100
+ assert all(i % 8 == 0 and o % 8 == 0 for i, o in shapes), shapes
2101
+
2102
+ vllm_model.apply_model(check_model)
vllm/envs.py
+11
@@ -313,6 +313,7 @@ if TYPE_CHECKING:
313
VLLM_NIXL_EP_MAX_NUM_RANKS: int = 32
314
VLLM_XPU_ENABLE_XPU_GRAPH: bool = False
315
VLLM_XPU_USE_SAMPLER_KERNEL: bool = True
316
+ VLLM_XPU_INC_WNA16_BACKEND: Literal["auto", "ark", "w4a16", "w4a8"] = "auto"
317
VLLM_LORA_ENABLE_DUAL_STREAM: bool = False
318
VLLM_GPU_NIC_PCIE_MAPPING: str = ""
319
VLLM_NIC_SELECTION_VARS: str = ""
@@ -2111,6 +2112,16 @@ environment_variables: dict[str, Callable[[], Any]] = {
2112
"VLLM_XPU_USE_SAMPLER_KERNEL": lambda: bool(
2113
int(os.getenv("VLLM_XPU_USE_SAMPLER_KERNEL", "1"))
2114
),
2115
+ # Kernel backend for INC weight-only intN (WNA16) linear layers on XPU.
2116
+ # "auto" keeps the default preference order (ARK when importable, else the
2117
+ # oneDNN w4a16 path). "ark" forces the auto_round_kernel backend, "w4a16"
2118
+ # forces oneDNN int4_gemm_w4a16, and "w4a8" additionally quantizes
2119
+ # activations to per-token int8 (int4_gemm_w4a8) for large token counts.
2120
+ # The two oneDNN backends are int4-only; ARK also serves int2.
2121
+ # Which one is fastest is device-dependent, so this is left as an opt-in.
2122
+ "VLLM_XPU_INC_WNA16_BACKEND": env_with_choices(
2123
+ "VLLM_XPU_INC_WNA16_BACKEND", "auto", ["auto", "ark", "w4a16", "w4a8"]
2124
+ ),
2125
# Enable simple KV offload.
2126
"VLLM_USE_SIMPLE_KV_OFFLOAD": lambda: bool(
2127
int(os.getenv("VLLM_USE_SIMPLE_KV_OFFLOAD", "0"))
vllm/model_executor/layers/quantization/inc/schemes/inc_w4a8_linear.py
new
+112
@@ -0,0 +1,112 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch
5
+from torch.nn.parameter import Parameter
6
+
7
+from .inc_wna16_linear import INCXPULinearMethod
8
+
9
+
10
+class INCXPUW4A8LinearMethod(INCXPULinearMethod):
11
+ """XPU linear method for INC int4 weights with dynamic int8 activations.
12
+
13
+ Uses the same GPTQ-packed "NT" qweight layout as ``INCXPULinearMethod`` —
14
+ ``int4_gemm_w4a8`` and ``int4_gemm_w4a16`` accept bit-identical weights, so
15
+ no extra repacking is needed. Activations are dynamically quantized
16
+ per-token to symmetric int8, which keeps the GEMM on the int8 datapath
17
+ instead of upconverting the weights to the activation dtype. That is a win
18
+ for compute-bound shapes (large token counts, e.g. diffusion), where w4a16
19
+ is dominated by dequantization rather than by weight bandwidth.
20
+
21
+ Both scale tensors must be float16: the kernel reads them as fp16
22
+ regardless of the activation dtype, so bf16 scales are silently
23
+ misinterpreted. The activations themselves may be bf16 or fp16.
24
+
25
+ Opt in with ``VLLM_XPU_INC_WNA16_BACKEND=w4a8``. Whether this beats ARK is
26
+ device-dependent, so it is not the default: it was measured faster on B70
27
+ (Xe2), where ARK cannot use its XMX int8 path and falls back to fp16.
28
+ """
29
+
30
+ # int8 activations only pay off once the GEMM is compute-bound. Below this
31
+ # token count the per-token quantization overhead dominates and w4a16 is
32
+ # faster, so fall back to it per-call.
33
+ _MIN_TOKENS_FOR_INT8 = 512
34
+
35
+ # int4_gemm_w4a8 requires both GEMM dims to be multiples of 8. Tensor
36
+ # parallelism shards these, so an aligned model dimension can still yield an
37
+ # unaligned partition.
38
+ _DIM_ALIGNMENT = 8
39
+
40
+ def create_weights(
41
+ self,
42
+ layer: torch.nn.Module,
43
+ input_size_per_partition: int,
44
+ output_partition_sizes: list[int],
45
+ input_size: int,
46
+ output_size: int,
47
+ params_dtype: torch.dtype,
48
+ **extra_weight_attrs,
49
+ ) -> None:
50
+ output_size_per_partition = sum(output_partition_sizes)
51
+ unaligned = [
52
+ (name, size)
53
+ for name, size in (
54
+ ("input", input_size_per_partition),
55
+ ("output", output_size_per_partition),
56
+ )
57
+ if size % self._DIM_ALIGNMENT != 0
58
+ ]
59
+ if unaligned:
60
+ raise NotImplementedError(
61
+ "VLLM_XPU_INC_WNA16_BACKEND=w4a8 requires partitioned in/out "
62
+ f"sizes that are multiples of {self._DIM_ALIGNMENT}, got "
63
+ + ", ".join(f"{name}={size}" for name, size in unaligned)
64
+ + f". Partition shape: ({input_size_per_partition}, "
65
+ f"{output_size_per_partition})."
66
+ )
67
+ super().create_weights(
68
+ layer=layer,
69
+ input_size_per_partition=input_size_per_partition,
70
+ output_partition_sizes=output_partition_sizes,
71
+ input_size=input_size,
72
+ output_size=output_size,
73
+ params_dtype=params_dtype,
74
+ **extra_weight_attrs,
75
+ )
76
+
77
+ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
78
+ super().process_weights_after_loading(layer)
79
+ # The kernel reads both scale tensors as fp16; keep an fp16 copy for the
80
+ # w4a8 path and leave ``scales`` untouched for the w4a16 fallback.
81
+ layer.scales_fp16 = Parameter(
82
+ layer.scales.data.to(torch.float16), requires_grad=False
83
+ )
84
+
85
+ def apply_weights(
86
+ self,
87
+ layer: torch.nn.Module,
88
+ x: torch.Tensor,
89
+ bias: torch.Tensor | None = None,
90
+ ) -> torch.Tensor:
91
+ reshaped_x = x.reshape(-1, x.shape[-1])
92
+ if reshaped_x.shape[0] < self._MIN_TOKENS_FOR_INT8:
93
+ return super().apply_weights(layer, x, bias)
94
+
95
+ from vllm._xpu_ops import xpu_ops as ops
96
+
97
+ out_shape = x.shape[:-1] + (layer.qweight.shape[1],)
98
+ quant_x, x_scale, x_zero = ops.dynamic_per_token_int8_quant_ref(
99
+ reshaped_x, True, 8
100
+ )
101
+ out = torch.ops._xpu_C.int4_gemm_w4a8(
102
+ quant_x,
103
+ x_scale.to(torch.float16),
104
+ x_zero,
105
+ layer.qweight,
106
+ layer.scales_fp16,
107
+ layer.qzeros,
108
+ self.group_size,
109
+ None,
110
+ bias,
111
+ )
112
+ return out.to(x.dtype).reshape(out_shape)
vllm/model_executor/layers/quantization/inc/schemes/inc_wna16_scheme.py
+46
@@ -3,6 +3,7 @@
3
4
from typing import TYPE_CHECKING
5
6
+from vllm import envs
7
from vllm.logger import init_logger
8
from vllm.model_executor.layers.quantization.auto_awq import AutoAWQConfig
9
from vllm.model_executor.layers.quantization.auto_gptq import AutoGPTQConfig
@@ -21,6 +22,32 @@ logger = init_logger(__name__)
22
23
XPU_WNA16_SUPPORTED_BITS = {2, 4}
24
25
+# Backends selectable through VLLM_XPU_INC_WNA16_BACKEND that are served by the
26
+# oneDNN int4 GEMMs rather than by ARK. These only cover int4.
27
+XPU_ONEDNN_BACKENDS = ("w4a16", "w4a8")
28
+
29
+
30
+def _check_xpu_w4a8_supported(layer_config: "INCLayerConfig", prefix: str) -> None:
31
+ """Raise unless ``int4_gemm_w4a8`` can serve this layer.
32
+
33
+ The backend is requested explicitly, so an unusable configuration is an
34
+ error rather than something to silently fall back from.
35
+ """
36
+ import torch
37
+
38
+ if not hasattr(torch.ops._xpu_C, "int4_gemm_w4a8"):
39
+ raise NotImplementedError(
40
+ "VLLM_XPU_INC_WNA16_BACKEND=w4a8 requires the int4_gemm_w4a8 op, "
41
+ "which this build of vllm-xpu-kernels does not provide. "
42
+ f"Layer: {prefix}."
43
+ )
44
+ if layer_config.group_size <= 0 or layer_config.group_size % 32 != 0:
45
+ raise NotImplementedError(
46
+ "VLLM_XPU_INC_WNA16_BACKEND=w4a8 requires a group size that is a "
47
+ f"positive multiple of 32, got {layer_config.group_size}. "
48
+ f"Layer: {prefix}."
49
+ )
50
+
51
52
class INCWna16Scheme(INCScheme):
53
@staticmethod
@@ -38,12 +65,31 @@ class INCWna16Scheme(INCScheme):
65
if current_platform.is_xpu():
66
if layer_config.bits in XPU_WNA16_SUPPORTED_BITS and layer_config.sym:
67
from .inc_ark_ops import get_ark_state
68
+ from .inc_w4a8_linear import INCXPUW4A8LinearMethod
69
from .inc_wna16_linear import (
70
INCARKLinearMethod,
71
INCXPULinearMethod,
72
)
73
74
+ backend = envs.VLLM_XPU_INC_WNA16_BACKEND
75
+ if backend in XPU_ONEDNN_BACKENDS:
76
+ if layer_config.bits != 4:
77
+ raise NotImplementedError(
78
+ f"VLLM_XPU_INC_WNA16_BACKEND={backend} only supports "
79
+ f"int4, got int{layer_config.bits}. Layer: {prefix}."
80
+ )
81
+ if backend == "w4a8":
82
+ _check_xpu_w4a8_supported(layer_config, prefix)
83
+ return INCLinearMethod(INCXPUW4A8LinearMethod(layer_config))
84
+ return INCLinearMethod(INCXPULinearMethod(layer_config))
85
+
86
is_ark_available, ark_error, _, _ = get_ark_state()
87
+ if backend == "ark" and not is_ark_available:
88
+ raise NotImplementedError(
89
+ "VLLM_XPU_INC_WNA16_BACKEND=ark was requested but "
90
+ f"auto_round_kernel is unavailable: "
91
+ f"{ark_error or 'unknown error'}. Layer: {prefix}."
92
+ )
93
if is_ark_available:
94
return INCLinearMethod(INCARKLinearMethod(layer_config))
95
elif layer_config.bits == 2: