Single-stream CPU decode reads every weight to emit one token, so bits per weight set the token rate. This registers a W4A16 group-wise method out-of-tree through register_quantization_config -- vLLM itself is untouched -- using PyTorch's _weight_int4pack_mm_for_cpu tinygemm kernel. Two paths over one set of weights, because that kernel dequantizes once per row of activations and so is linear in the batch (12.9x slower than bf16 at 512 rows). Single-row steps take tinygemm; wider batches unpack the weight to bf16 once and use the ordinary oneDNN/AMX GEMM. Shipping only the first measured pp512 = 557 tok/s against bf16's 4455; with both, 3589. The batch threshold is 2 rather than the ~8 rows where the unpack path starts winning on speed, because running tinygemm on a multi-row batch and then vLLM's cpu_attention_with_kv_cache in the same forward dies with SIGILL inside the attention kernel -- an AMX instruction with no valid tile config loaded. The same prompt is fine in bf16 and fine once every multi-row GEMM takes the unpack path. Positioning, measured on a Xeon 8480C with 32 pinned cores and a warm cache (analysis/spark_cpu_int4.md): vLLM's own GPTQ W4A8 AMX path is faster -- 88.7 tok/s decode and 4355 prefill against 85.5 and 3589 here -- but its config rejects sym=False at 4 bits, so it can only be fed a symmetric grid. This one is asymmetric and costs +12.5% perplexity against that path's +27.2%, and needs no AMX. llama.cpp Q4_K_M, for reference, is 98.0 tok/s decode and 1356 prefill at +7.6% and 5.15 bits per weight. Expanding a quantized checkpoint back to bf16 offline and scoring it reproduces the quantized run to within 0.2% perplexity, so both GEMM paths, the nibble packing and the quantized tied embedding are faithful. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXhbAfkSNRaeqZrus5Z2fx
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Unit tests for the cpu_int4 W4A16 method."""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm_omni.model_executor.layers.quantization.cpu_int4 import (
|
|
CPUInt4Config,
|
|
_to_tinygemm,
|
|
dequantize,
|
|
pack_nibbles,
|
|
quantize_groupwise,
|
|
to_split_halves,
|
|
unpack_nibbles,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
|
|
def _weights(n=64, k=256, seed=0):
|
|
torch.manual_seed(seed)
|
|
return torch.randn(n, k)
|
|
|
|
|
|
def test_nibble_pack_round_trips():
|
|
q = torch.randint(0, 16, (32, 128), dtype=torch.int32)
|
|
assert torch.equal(unpack_nibbles(pack_nibbles(q)), q)
|
|
|
|
|
|
def test_quantized_weights_stay_on_the_four_bit_grid():
|
|
w = _weights()
|
|
packed, scale, zero = quantize_groupwise(w, 64)
|
|
codes = unpack_nibbles(packed)
|
|
assert codes.min() >= 0 and codes.max() <= 15
|
|
assert packed.dtype == torch.uint8
|
|
assert packed.shape == (64, 128)
|
|
assert scale.shape == zero.shape == (64, 4)
|
|
|
|
|
|
def test_group_extremes_are_reproduced_exactly():
|
|
"""min/max grid: the two ends of each group must dequantize back to
|
|
themselves, which is what makes the offset form worth its extra bytes."""
|
|
w = _weights(n=16, k=128)
|
|
group = 64
|
|
packed, scale, zero = quantize_groupwise(w, group)
|
|
codes = unpack_nibbles(packed).float().reshape(16, 128 // group, group)
|
|
deq = (codes - 8.0) * scale.unsqueeze(-1) + zero.unsqueeze(-1)
|
|
wg = w.reshape(16, 128 // group, group)
|
|
torch.testing.assert_close(deq.amax(-1), wg.amax(-1), rtol=1e-5, atol=1e-5)
|
|
torch.testing.assert_close(deq.amin(-1), wg.amin(-1), rtol=1e-5, atol=1e-5)
|
|
|
|
|
|
def test_split_halves_is_a_relabelling_of_the_same_codes():
|
|
w = _weights()
|
|
packed, _, _ = quantize_groupwise(w, 64)
|
|
halves = to_split_halves(packed)
|
|
codes = unpack_nibbles(packed)
|
|
k = codes.shape[1]
|
|
assert torch.equal((halves & 0x0F).to(torch.int32), codes[:, : k // 2])
|
|
assert torch.equal((halves >> 4).to(torch.int32), codes[:, k // 2 :])
|
|
|
|
|
|
@pytest.mark.parametrize("group", [32, 64, 128])
|
|
def test_dequantize_path_agrees_with_the_tinygemm_kernel(group):
|
|
"""The two GEMM paths must be the same function of the weights.
|
|
|
|
Prefill takes the dequantize-then-AMX path and decode the tinygemm one;
|
|
a mismatch here would show up as a model that generates fine but answers
|
|
differently depending on prompt length, which no throughput benchmark
|
|
would catch.
|
|
"""
|
|
w = _weights(n=64, k=256)
|
|
packed, scale, zero = quantize_groupwise(w, group)
|
|
inner, sz = _to_tinygemm(packed, scale, zero)
|
|
halves = to_split_halves(packed)
|
|
sc, ze = scale.to(torch.bfloat16), zero.to(torch.bfloat16)
|
|
|
|
x = torch.randn(8, 256, dtype=torch.bfloat16)
|
|
via_kernel = torch.ops.aten._weight_int4pack_mm_for_cpu(x, inner, group, sz)
|
|
via_dequant = torch.nn.functional.linear(x, dequantize(halves, sc, ze, group))
|
|
|
|
# Scale-free: both sides accumulate in bf16, so the gap is bf16 rounding
|
|
# on outputs whose magnitude grows with K, not a layout disagreement.
|
|
a, b = via_kernel.float(), via_dequant.float()
|
|
snr = 20 * torch.log10(a.norm() / (a - b).norm())
|
|
assert snr > 40.0, f'paths disagree beyond bf16 rounding: {snr:.1f} dB'
|
|
|
|
|
|
|
|
def test_four_bit_error_is_small_enough_to_be_useful():
|
|
w = _weights(n=256, k=1024)
|
|
packed, scale, zero = quantize_groupwise(w, 64)
|
|
codes = unpack_nibbles(packed).float().reshape(256, 1024 // 64, 64)
|
|
deq = ((codes - 8.0) * scale.unsqueeze(-1) + zero.unsqueeze(-1)).reshape(256, 1024)
|
|
snr = 20 * torch.log10(w.norm() / (w - deq).norm())
|
|
assert snr > 19.0, snr
|
|
|
|
|
|
def test_config_round_trips_and_reports_bf16_only():
|
|
cfg = CPUInt4Config.from_config(
|
|
{"quant_method": "cpu_int4", "group_size": 128, "prefill_dequant": False}
|
|
)
|
|
assert cfg.group_size == 128
|
|
assert cfg.prefill_dequant is False
|
|
assert cfg.get_supported_act_dtypes() == [torch.bfloat16]
|
|
assert cfg.get_name() == "cpu_int4"
|
|
|
|
|
|
def test_registered_into_vllms_quantization_registry():
|
|
from vllm.model_executor.layers.quantization import (
|
|
QUANTIZATION_METHODS,
|
|
get_quantization_config,
|
|
)
|
|
|
|
import vllm_omni.model_executor.layers.quantization # noqa: F401
|
|
|
|
assert "cpu_int4" in QUANTIZATION_METHODS
|
|
assert get_quantization_config("cpu_int4") is CPUInt4Config
|
|
|
|
|
|
def test_ignore_list_falls_back_to_full_precision():
|
|
cfg = CPUInt4Config(group_size=64, ignore=["lm_head"])
|
|
from vllm.model_executor.layers.linear import (
|
|
ReplicatedLinear,
|
|
UnquantizedLinearMethod,
|
|
)
|
|
|
|
layer = ReplicatedLinear.__new__(ReplicatedLinear)
|
|
assert isinstance(cfg.get_quant_method(layer, "lm_head"), UnquantizedLinearMethod)
|
|
assert not isinstance(
|
|
cfg.get_quant_method(layer, "model.layers.0.mlp.down_proj"),
|
|
UnquantizedLinearMethod,
|
|
)
|