134 lines
4.8 KiB
Python
134 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,
|
||
|
|
)
|