Files
vllm-omni/vllm_omni/model_executor/layers/quantization/cpu_int4.py
T

525 lines
19 KiB
Python
Raw Normal View History

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""W4A16 group-wise weights for vLLM's CPU backend.
Single-stream decode on a CPU is a bandwidth problem: the whole weight matrix
is read to produce one token, so the bits per weight, not the FLOPs, set the
token rate. Four bits is how llama.cpp's Q4_K_M builds win single-stream
decode against an engine that beats them everywhere else.
vLLM 0.28 does reach four bits on CPU, through the GPTQ/AWQ kernel selector
(`int4_scaled_mm_cpu`, AMX W4A8 -- see `gptq_cpu_export`). That path pins the
zero point at 8, so its grid is symmetric and its activations are int8. This
one is the other trade: an asymmetric min/max grid and bf16 activations, which
costs some speed and buys back fidelity.
Weights are quantized to 4 bits on groups of ``group_size`` columns with a
bf16 scale and offset per group; at the default group of 64 that is 4.5 bits
per weight, the same budget Q4_K_M spends. The GEMM is PyTorch's ``_weight_int4pack_mm_for_cpu`` tinygemm
kernel, which has AVX-512 paths and, unlike the fp32 entry point, keeps
bf16 activations in bf16 end to end.
Checkpoints carry, per quantized linear:
<name>.weight_packed uint8 [N, K // 2] two 4-bit codes per byte,
byte j = w[2j] | w[2j+1] << 4
<name>.weight_scale bf16 [N, K // group]
<name>.weight_zero bf16 [N, K // group]
and dequantize as ``w = (q - 8) * scale + zero``. The row-major nibble layout
is the portable one; the tinygemm layout the kernel wants blends rows within a
block of 16 and is built in ``process_weights_after_loading``.
The tinygemm kernel dequantizes once per row of activations, so its cost is
linear in the batch and it loses to AMX bf16 above about eight rows -- fine
for decode, ruinous for prefill. Large batches therefore take a second path:
the weight is unpacked to bf16 once per call and handed to the ordinary
oneDNN/AMX GEMM, which costs one extra pass over the weights and wins back
prefill. That path reads a row-major copy, so with ``prefill_dequant`` on
(the default) a layer holds both layouts -- 8 bits per weight resident, but
still 4.5 bits per weight *read* in a decode step, which is what sets the
token rate. Set ``prefill_dequant: false`` to trade prefill for the smaller
footprint.
``dequant_threshold`` defaults to 2, i.e. only a single-row step takes the
tinygemm path. That is not just a speed choice. On a Sapphire Rapids host,
running the tinygemm kernel on a multi-row batch and then vLLM's
``cpu_attention_with_kv_cache`` in the same forward kills the process with
SIGILL inside the attention kernel -- the signature of an AMX instruction
issued with no valid tile configuration loaded. The same prompt is fine in
bf16, and fine at any batch size once every multi-row GEMM goes through the
dequantize path, so the two kernels disagree about who owns the tile state.
Single-row steps never trip it, which is why the crossover sits at 2 rather
than at the ~8 rows where the dequantize path starts winning on speed.
Batched decode therefore pays the dequantize path; single-stream decode, the
case 4-bit weights exist for, does not.
"""
from typing import Any
import torch
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm.model_executor.layers.quantization import register_quantization_config
from vllm.model_executor.layers.quantization.base_config import (
QuantizationConfig,
QuantizeMethodBase,
)
from vllm.model_executor.parameter import (
GroupQuantScaleParameter,
PackedvLLMParameter,
)
from vllm.model_executor.utils import set_weight_attrs
from vllm.utils.torch_utils import direct_register_custom_op
# The tinygemm packer refuses anything else.
PACK_N_ALIGN = 16
INNER_K_TILES = 1
def pack_nibbles(q: torch.Tensor) -> torch.Tensor:
"""[N, K] codes in [0, 15] -> [N, K // 2] uint8, low nibble first."""
q = q.to(torch.uint8)
lo, hi = q[:, 0::2], q[:, 1::2]
return (lo | (hi << 4)).contiguous()
def unpack_nibbles(packed: torch.Tensor) -> torch.Tensor:
"""[N, K // 2] uint8 -> [N, K] int32 codes in [0, 15]."""
n, half = packed.shape
out = torch.empty(n, half * 2, dtype=torch.int32)
out[:, 0::2] = (packed & 0x0F).to(torch.int32)
out[:, 1::2] = (packed >> 4).to(torch.int32)
return out
def quantize_groupwise(w: torch.Tensor, group: int):
"""Min/max 4-bit quantization of [N, K] -> (packed, scale, zero).
``scale = (max - min) / 15`` and ``zero = min + 8 * scale`` make the
kernel's ``(q - 8) * scale + zero`` reproduce the group's extremes
exactly, which is the same asymmetric grid llama.cpp's K-quants use.
"""
n, k = w.shape
if k % group:
raise ValueError(f"input dim {k} is not a multiple of group size {group}")
wf = w.float().reshape(n, k // group, group)
wmin, wmax = wf.amin(dim=-1), wf.amax(dim=-1)
scale = (wmax - wmin).clamp(min=1e-9) / 15.0
zero = wmin + 8.0 * scale
q = ((wf - wmin.unsqueeze(-1)) / scale.unsqueeze(-1)).round_().clamp_(0, 15)
return pack_nibbles(q.reshape(n, k)), scale, zero
def _to_tinygemm(packed: torch.Tensor, scale: torch.Tensor, zero: torch.Tensor):
"""Row-major nibbles + [N, G] scales -> kernel layout + [G, N, 2] scales."""
codes = unpack_nibbles(packed)
inner = torch.ops.aten._convert_weight_to_int4pack_for_cpu(codes, INNER_K_TILES)
sz = torch.stack([scale.float(), zero.float()], dim=-1) # [N, G, 2]
sz = sz.transpose(0, 1).contiguous().to(torch.bfloat16) # [G, N, 2]
return inner, sz
def to_split_halves(packed: torch.Tensor) -> torch.Tensor:
"""Interleaved nibbles -> byte j holding w[j] and w[j + K/2].
The on-disk layout interleaves so that a tensor-parallel split along K
stays a plain slice. Once the shard is local, splitting the halves makes
both nibble planes contiguous runs, and the dequantize loop drops its
lane shuffle: measured 3.1 ms per layer instead of 4.5.
"""
codes = unpack_nibbles(packed)
n, k = codes.shape
lo, hi = codes[:, : k // 2], codes[:, k // 2 :]
return (lo.to(torch.uint8) | (hi.to(torch.uint8) << 4)).contiguous()
def _dequantize_halves(
halves: torch.Tensor,
scale: torch.Tensor,
zero: torch.Tensor,
group: int,
) -> torch.Tensor:
n, half = halves.shape
g2 = half // group
lo = (halves & 0x0F).to(torch.bfloat16).reshape(n, g2, group)
hi = (halves >> 4).to(torch.bfloat16).reshape(n, g2, group)
a = (lo - 8.0) * scale[:, :g2].unsqueeze(-1) + zero[:, :g2].unsqueeze(-1)
b = (hi - 8.0) * scale[:, g2:].unsqueeze(-1) + zero[:, g2:].unsqueeze(-1)
return torch.cat([a.reshape(n, half), b.reshape(n, half)], dim=1)
_compiled_dequant = None
def dequantize(halves, scale, zero, group):
"""bf16 weight from the row-major halves; compiled, so it is one pass."""
global _compiled_dequant
if _compiled_dequant is None:
# Compiled outside any vLLM graph: this runs inside an opaque custom
# op, so dynamo never traces it and never guards on the batch size.
_compiled_dequant = torch.compile(_dequantize_halves, dynamic=False)
return _compiled_dequant(halves, scale, zero, group)
def _cpu_int4_linear(
x: torch.Tensor,
int4pack: torch.Tensor,
scales_and_zeros: torch.Tensor,
halves: torch.Tensor | None,
scale: torch.Tensor | None,
zero: torch.Tensor | None,
group: int,
threshold: int,
) -> torch.Tensor:
if x.dtype != torch.bfloat16:
x = x.to(torch.bfloat16)
if halves is not None and x.shape[0] >= threshold:
weight = dequantize(halves, scale, zero, group)
return torch.nn.functional.linear(x, weight)
return torch.ops.aten._weight_int4pack_mm_for_cpu(
x, int4pack, group, scales_and_zeros
)
def _cpu_int4_linear_fake(
x: torch.Tensor,
int4pack: torch.Tensor,
scales_and_zeros: torch.Tensor,
halves: torch.Tensor | None,
scale: torch.Tensor | None,
zero: torch.Tensor | None,
group: int,
threshold: int,
) -> torch.Tensor:
return x.new_empty((x.shape[0], int4pack.shape[0]), dtype=torch.bfloat16)
direct_register_custom_op(
op_name="cpu_int4_linear",
op_func=_cpu_int4_linear,
mutates_args=[],
fake_impl=_cpu_int4_linear_fake,
dispatch_key="CPU",
)
@register_quantization_config("cpu_int4")
class CPUInt4Config(QuantizationConfig):
"""Group-wise 4-bit weight-only quantization for the CPU backend."""
def __init__(
self,
group_size: int = 64,
ignore: list[str] | None = None,
quantize_embedding: bool = True,
prefill_dequant: bool = True,
dequant_threshold: int = 2,
use_custom_op: bool = True,
) -> None:
super().__init__()
self.group_size = group_size
self.ignore = list(ignore or [])
self.quantize_embedding = quantize_embedding
self.prefill_dequant = prefill_dequant
self.dequant_threshold = dequant_threshold
self.use_custom_op = use_custom_op
def __repr__(self) -> str:
return (
f"CPUInt4Config(group_size={self.group_size}, "
f"ignore={self.ignore}, quantize_embedding={self.quantize_embedding}, "
f"prefill_dequant={self.prefill_dequant}, "
f"dequant_threshold={self.dequant_threshold}, "
f"use_custom_op={self.use_custom_op})"
)
def get_name(self) -> str:
return "cpu_int4"
def get_supported_act_dtypes(self) -> list[torch.dtype]:
# The fp32 entry point of the tinygemm kernel has no vectorized path
# and runs ~30x slower, so bf16 is the only activation type offered.
return [torch.bfloat16]
@classmethod
def get_min_capability(cls) -> int:
return 0
@staticmethod
def get_config_filenames() -> list[str]:
return []
@classmethod
def from_config(cls, config: dict[str, Any]) -> "CPUInt4Config":
return cls(
group_size=cls.get_from_keys_or(config, ["group_size"], 64),
ignore=cls.get_from_keys_or(config, ["ignore"], []),
quantize_embedding=cls.get_from_keys_or(
config, ["quantize_embedding"], True
),
prefill_dequant=cls.get_from_keys_or(config, ["prefill_dequant"], True),
dequant_threshold=cls.get_from_keys_or(config, ["dequant_threshold"], 2),
use_custom_op=cls.get_from_keys_or(config, ["use_custom_op"], True),
)
def _is_ignored(self, prefix: str) -> bool:
return any(prefix == p or prefix.endswith("." + p) for p in self.ignore)
def get_quant_method(
self, layer: torch.nn.Module, prefix: str
) -> QuantizeMethodBase | None:
from vllm.model_executor.layers.vocab_parallel_embedding import (
VocabParallelEmbedding,
)
if isinstance(layer, VocabParallelEmbedding):
if not self.quantize_embedding or self._is_ignored(prefix):
return None
return CPUInt4EmbeddingMethod(self)
if isinstance(layer, LinearBase):
if self._is_ignored(prefix):
return UnquantizedLinearMethod()
return CPUInt4LinearMethod(self)
return None
def _create_int4_weights(
method: "CPUInt4LinearMethod",
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes: list[int],
params_dtype: torch.dtype,
extra_weight_attrs: dict,
) -> None:
group = method.quant_config.group_size
out_size = sum(output_partition_sizes)
if input_size_per_partition % group:
raise ValueError(
f"input dim {input_size_per_partition} is not a multiple of "
f"group size {group}"
)
if out_size % PACK_N_ALIGN:
raise ValueError(
f"output dim {out_size} is not a multiple of {PACK_N_ALIGN}, which "
"the tinygemm packer requires"
)
weight_loader = extra_weight_attrs.pop("weight_loader")
packed = PackedvLLMParameter(
data=torch.empty(out_size, input_size_per_partition // 2, dtype=torch.uint8),
input_dim=1,
output_dim=0,
packed_dim=1,
packed_factor=2,
weight_loader=weight_loader,
)
layer.register_parameter("weight_packed", packed)
for name in ("weight_scale", "weight_zero"):
p = GroupQuantScaleParameter(
data=torch.empty(
out_size, input_size_per_partition // group, dtype=torch.bfloat16
),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
)
layer.register_parameter(name, p)
layer.input_size_per_partition = input_size_per_partition
layer.output_size_per_partition = out_size
set_weight_attrs(packed, extra_weight_attrs)
def _finalize_int4(
layer: torch.nn.Module, keep_row_major: bool, prefill_dequant: bool
) -> None:
"""Repack into the layouts the two GEMM paths want; once per layer."""
if getattr(layer, "_cpu_int4_ready", False):
return
packed = layer.weight_packed.data
scale = layer.weight_scale.data
zero = layer.weight_zero.data
inner, sz = _to_tinygemm(packed, scale, zero)
layer.weight_int4pack = torch.nn.Parameter(inner, requires_grad=False)
layer.weight_scales_and_zeros = torch.nn.Parameter(sz, requires_grad=False)
if prefill_dequant:
layer.weight_halves = torch.nn.Parameter(
to_split_halves(packed), requires_grad=False
)
layer.weight_group_scale = torch.nn.Parameter(scale.clone(), requires_grad=False)
layer.weight_group_zero = torch.nn.Parameter(zero.clone(), requires_grad=False)
else:
layer.weight_halves = None
layer.weight_group_scale = None
layer.weight_group_zero = None
if keep_row_major:
# Only the vocab table needs this: the tinygemm layout interleaves
# rows inside blocks of 16, so a single-token embedding lookup cannot
# address it. One row is a few hundred bytes, so keeping the portable
# copy costs no bandwidth at decode.
layer.weight_packed = torch.nn.Parameter(packed, requires_grad=False)
layer.weight_scale = torch.nn.Parameter(scale, requires_grad=False)
layer.weight_zero = torch.nn.Parameter(zero, requires_grad=False)
else:
del layer.weight_packed
del layer.weight_scale
del layer.weight_zero
layer._cpu_int4_ready = True
def _int4_mm(
layer: torch.nn.Module,
x: torch.Tensor,
group: int,
threshold: int,
custom_op: bool = True,
) -> torch.Tensor:
orig_shape = x.shape
x2d = x.reshape(-1, orig_shape[-1])
if x2d.dtype != torch.bfloat16:
# Static at trace time: the config advertises bf16 activations only,
# and the fp32 entry point of the tinygemm kernel has no vectorized
# path (~30x slower), so never fall into it by accident.
x2d = x2d.to(torch.bfloat16)
if custom_op:
# Opaque to dynamo, so the batch-size branch never becomes a guard on
# vLLM's single traced graph. Costs ~5 us a call in dispatch.
out = torch.ops.vllm.cpu_int4_linear(
x2d,
layer.weight_int4pack,
layer.weight_scales_and_zeros,
layer.weight_halves,
layer.weight_group_scale,
layer.weight_group_zero,
group,
threshold,
)
elif layer.weight_halves is not None and x2d.shape[0] >= threshold:
out = torch.nn.functional.linear(
x2d,
_dequantize_halves(
layer.weight_halves,
layer.weight_group_scale,
layer.weight_group_zero,
group,
),
)
else:
out = torch.ops.aten._weight_int4pack_mm_for_cpu(
x2d, layer.weight_int4pack, group, layer.weight_scales_and_zeros
)
return out.reshape(*orig_shape[:-1], out.shape[-1])
class CPUInt4LinearMethod(QuantizeMethodBase):
"""Group-wise 4-bit linear."""
def __init__(self, quant_config: CPUInt4Config) -> None:
self.quant_config = quant_config
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes: list[int],
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
) -> None:
_create_int4_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
params_dtype,
extra_weight_attrs,
)
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
_finalize_int4(
layer,
keep_row_major=False,
prefill_dequant=self.quant_config.prefill_dequant,
)
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None = None,
) -> torch.Tensor:
out = _int4_mm(
layer,
x,
self.quant_config.group_size,
self.quant_config.dequant_threshold,
self.quant_config.use_custom_op,
)
if bias is not None:
out = out + bias
return out
class CPUInt4EmbeddingMethod(CPUInt4LinearMethod):
"""The same weights, used as a vocab table and as the output head.
With tied embeddings these are one tensor, so quantizing it pays twice:
the lm_head GEMM is the single largest read in a decode step, and the
table itself is 16% of the model's bytes.
"""
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes: list[int],
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
) -> None:
_create_int4_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
params_dtype,
extra_weight_attrs,
)
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# No dequantize path for the vocab table: the head only ever sees the
# tokens being sampled (one row per sequence), and unpacking a
# 131072 x 2048 matrix to bf16 would cost more than the GEMM saves.
_finalize_int4(layer, keep_row_major=True, prefill_dequant=False)
def tie_weights(self, layer: torch.nn.Module, embed_tokens: torch.nn.Module):
"""Share every quantized tensor, not just ``weight``."""
for name in (
"weight_packed",
"weight_scale",
"weight_zero",
"weight_int4pack",
"weight_scales_and_zeros",
"weight_halves",
"weight_group_scale",
"weight_group_zero",
):
src = getattr(embed_tokens, name, None)
if src is not None:
setattr(layer, name, src)
layer._cpu_int4_tied_to = embed_tokens
return layer
def embedding(self, layer: torch.nn.Module, input_: torch.Tensor) -> torch.Tensor:
group = self.quant_config.group_size
rows = layer.weight_packed.data.index_select(0, input_.reshape(-1))
codes = unpack_nibbles(rows).float()
n, k = codes.shape
scale = layer.weight_scale.data.index_select(0, input_.reshape(-1)).float()
zero = layer.weight_zero.data.index_select(0, input_.reshape(-1)).float()
deq = (codes.reshape(n, k // group, group) - 8.0) * scale.unsqueeze(
-1
) + zero.unsqueeze(-1)
out = deq.reshape(n, k).to(torch.bfloat16)
return out.reshape(*input_.shape, k)