[Edge][CPU] W4A8 via vLLM's own AMX kernel, a per-device weight path, and stop recompiling on every start
Four changes, each measured on a Xeon 8480C with Spark-X2.5-1.7B at 24 threads. Numbers are best-of-interleaved-passes; this host is shared and a single sequential grid swung the llama.cpp control 30%, so anything under ~8% is not treated as a result. **vLLM's CPU backend does have a 4-bit kernel.** An earlier note in this tree said it did not, on the strength of `woq_int4_linear`, `int4_scaled_mm_with_quant` and `da8w4_linear` all being absent. Those are simply not the names it uses: `CPUWNA16LinearKernel` dispatches GPTQ/AWQ checkpoints to `int4_scaled_mm_cpu`, an AMX W4A8 GEMM, whenever activations are bf16 and the checkpoint carries no act-order. Four-bit weights times eight-bit activations is the arithmetic llama.cpp's Q4_K_M already does. `gptq_cpu_export` builds checkpoints for it; the kernel pins the zero point at 8, so the grid must be symmetric, and a per-group scale search recovers most of what that costs. **A per-device weight path** (`edge/weight_path.py`). The fast kernel on each device needs a different, mutually exclusive weight layout, so the choice has to come from a probe rather than a flag: AMX x86 -> W4A8/GPTQ; x86 without AMX, or priority="fidelity" -> W4A16/tinygemm (bf16 activations, the more faithful of the two); ARM -> W4A16 but flagged unverified, because PyTorch's 4-bit CPU GEMM dispatches a different kernel there than the AVX-512 one measured here; phone -> runs_in_vllm=False, the deliverable is an exported graph with calibrated w8a16. **The CPU compile cache never loaded.** `_try_load_aot_compiled_fn` finishes with `compiled_fn.finalize_loading(cfg)`, which the CUDA wrapper object has and the inductor CPU backend's plain function does not. The AttributeError was swallowed as a cache miss, so every engine start recompiled the model: warm start 49.8 s against 14.9 s with the missing no-op attached. Self- disabling if upstream gives the CPU path a real one. **The head gate stops being a GEMM.** A 2048x8 projection cannot amortize a thread fan-out: dispatched as `aten::mm` it costs 1.155 ms of a decode step, nearly all barrier. As an eager reduction behind an opaque op it costs 0.444 ms. It has to be opaque -- written inline the reduction is traced, and inductor's loop made the whole step 1.8x slower (78.8 -> 44.5 tok/s). Also included and *off by default*: `cpu_ir_ops` registers the fused `_C::rms_norm` / `_C::fused_add_rms_norm` kernels that vLLM's CPU build ships but gates on `GPGPU_DEVICE`. In isolation they are 3.6x and 4.5x faster than the native path; end to end they are slightly slower (72.7 vs 74.0 tok/s), because inductor was already fusing the native norm into its neighbours and an opaque call breaks that fusion. Kept with the measurement recorded so the next person does not repeat it. `gelu_and_mul` is deliberately not registered: its fused CPU kernel is 2.7x slower at Spark's GEGLU shape. Decode, W4A16 g64 -> W4A8 g128 with the gate fix: 69.1 -> 84.5 tok/s (+22%), against llama.cpp Q4_K_M's 90.0; prefill 3347 against 1121 (3.0x). The two 4-bit paths are not equally faithful: against each build's own bf16 over 12 greedy prompts, Q4_K_M reproduces 4/12, W4A16 2/12 and W4A8 1/12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7869a8dc0d
commit
3dac97e0bd
10 files changed
+888
-5
No files matched your search
@@ -0,0 +1,94 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Build GPTQ-format 4-bit tensors for vLLM's CPU W4A8 kernel.
|
||||
|
||||
`CPUWNA16LinearKernel` routes a GPTQ checkpoint to `int4_scaled_mm_cpu`, an
|
||||
AMX W4A8 GEMM, when the activations are bf16 and the checkpoint carries no
|
||||
activation reordering. That kernel builds its own zero point -- a constant 8
|
||||
-- and ignores the checkpoint's `qzeros`, so the grid written here has to be
|
||||
symmetric about 8:
|
||||
|
||||
w = (code - 8) * scale, code in [0, 15]
|
||||
|
||||
Keeping that assumption in the package, with a test that checks it against the
|
||||
kernel itself, is the point of this module: it is the one thing that can go
|
||||
silently wrong -- a checkpoint quantized to a different convention still loads,
|
||||
still generates, and is simply wrong.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
PACK_FACTOR = 8
|
||||
# Scale candidates as a fraction of amax/8. 0.875 puts +amax exactly on code
|
||||
# 15; larger values trade clipping the extreme for a finer step, and which
|
||||
# wins depends on the group, so it is searched rather than assumed.
|
||||
DEFAULT_SHRINKS = (1.06, 1.0, 0.95, 0.9, 0.875, 0.85, 0.8, 0.75)
|
||||
|
||||
|
||||
def pack_int32(codes: torch.Tensor, dim: int) -> torch.Tensor:
|
||||
"""Pack 4-bit codes into int32 words, eight per word, along ``dim``."""
|
||||
codes = codes.to(torch.int32)
|
||||
if dim == 0:
|
||||
out = torch.zeros(
|
||||
codes.shape[0] // PACK_FACTOR, codes.shape[1], dtype=torch.int32
|
||||
)
|
||||
for i in range(PACK_FACTOR):
|
||||
out |= (codes[i::PACK_FACTOR] & 0xF) << (4 * i)
|
||||
else:
|
||||
out = torch.zeros(
|
||||
codes.shape[0], codes.shape[1] // PACK_FACTOR, dtype=torch.int32
|
||||
)
|
||||
for i in range(PACK_FACTOR):
|
||||
out |= (codes[:, i::PACK_FACTOR] & 0xF) << (4 * i)
|
||||
return out
|
||||
|
||||
|
||||
def quantize_symmetric(w: torch.Tensor, group: int, search: bool = True):
|
||||
"""[N, K] float -> (codes [N, K] in [0, 15], scales [N, K // group])."""
|
||||
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)
|
||||
amax = wf.abs().amax(-1).clamp(min=1e-9)
|
||||
|
||||
best_err = best = None
|
||||
for f in (DEFAULT_SHRINKS if search else (0.875,)):
|
||||
scale = amax * f / 8.0
|
||||
codes = (wf / scale.unsqueeze(-1)).round().add_(8).clamp_(0, 15)
|
||||
err = ((codes - 8.0) * scale.unsqueeze(-1) - wf).pow_(2).sum(-1)
|
||||
if best_err is None:
|
||||
best_err, best = err, (codes, scale)
|
||||
else:
|
||||
take = err < best_err
|
||||
best_err = torch.where(take, err, best_err)
|
||||
best = (
|
||||
torch.where(take.unsqueeze(-1), codes, best[0]),
|
||||
torch.where(take, scale, best[1]),
|
||||
)
|
||||
codes, scale = best
|
||||
return codes.reshape(n, k).to(torch.int32), scale
|
||||
|
||||
|
||||
def dequantize_symmetric(codes: torch.Tensor, scale: torch.Tensor, group: int):
|
||||
n, k = codes.shape
|
||||
c = codes.float().reshape(n, k // group, group)
|
||||
return ((c - 8.0) * scale.unsqueeze(-1)).reshape(n, k)
|
||||
|
||||
|
||||
def gptq_tensors(w: torch.Tensor, group: int, search: bool = True):
|
||||
"""[N, K] float -> the four tensors AutoGPTQLinearMethod expects.
|
||||
|
||||
Returns ``(qweight [K//8, N], qzeros [K//g, N//8], scales [K//g, N],
|
||||
g_idx [K], snr_db)``.
|
||||
"""
|
||||
n, k = w.shape
|
||||
codes, scale = quantize_symmetric(w, group, search)
|
||||
deq = dequantize_symmetric(codes, scale, group)
|
||||
wf = w.float()
|
||||
snr = float(20 * torch.log10(wf.norm() / (wf - deq).norm().clamp(min=1e-12)))
|
||||
|
||||
qweight = pack_int32(codes.t().contiguous(), dim=0)
|
||||
scales = scale.t().contiguous().to(torch.bfloat16)
|
||||
qzeros = pack_int32(torch.full((k // group, n), 8, dtype=torch.int32), dim=1)
|
||||
g_idx = torch.arange(k, dtype=torch.int32) // group
|
||||
return qweight, qzeros, scales, g_idx, snr
|
||||
Reference in new issue
Block a user