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>
197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Which weight format and which kernel, per class of device.
|
|
|
|
A laptop, an edge gateway and a phone do not want the same build of the same
|
|
model, and the reason is not preference -- it is that the fast kernel on each
|
|
one needs a different weight layout, and the layouts are mutually exclusive:
|
|
|
|
* an x86 core with AMX tiles has a 4-bit GEMM (`_C::int4_scaled_mm_cpu`) that
|
|
wants GPTQ-packed weights and quantizes activations to int8 -- the same
|
|
arithmetic llama.cpp's Q4_K_M does, and the fastest thing measured here;
|
|
* an x86 core without AMX has no such kernel, so 4-bit has to go through
|
|
PyTorch's tinygemm path, which wants its own packing and keeps activations
|
|
in bf16 -- slower, but more faithful, and the only option;
|
|
* a phone NPU does not run vLLM at all. The deliverable there is an exported
|
|
graph, and its quantization is decided by the export toolchain, not by us.
|
|
|
|
So the choice is a property of the device, and picking it at deploy time from
|
|
a probe is the difference between "4-bit is 22% faster" and "4-bit is broken
|
|
here". Measured on a Xeon 8480C (24 threads, Spark-X2.5-1.7B, best of three
|
|
interleaved passes, tok/s decode / prefill):
|
|
|
|
bf16 41.0 / 4796 no 4-bit kernel used
|
|
w4a16 tinygemm g64 75.1 / 3207
|
|
w4a16 tinygemm g128 78.7 / 3346 more faithful of the two
|
|
w4a8 AMX g128 84.5 / 3347 fastest; least faithful
|
|
llama.cpp Q4_K_M 90.0 / 1121 the bar
|
|
|
|
Fidelity is the reason both 4-bit rows stay: against each build's own bf16
|
|
output over 12 greedy prompts, llama.cpp's Q4_K_M reproduced 4/12 exactly,
|
|
w4a16 2/12 and w4a8 1/12. W4A8 pins the zero point at 8 (so the grid must be
|
|
symmetric) *and* quantizes activations to int8; W4A16 does neither.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
from vllm_omni.edge.hardware_probe import (
|
|
HW_CLASS_ARM64_CPU,
|
|
HW_CLASS_CUDA_DISCRETE,
|
|
HW_CLASS_JETSON,
|
|
HW_CLASS_NPU_PHONE,
|
|
HW_CLASS_X86_CPU,
|
|
HardwareProfile,
|
|
hardware_class,
|
|
)
|
|
|
|
Priority = Literal["speed", "fidelity"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WeightPath:
|
|
"""How to build and run the weights on one class of device."""
|
|
|
|
name: str
|
|
quantization: str | None
|
|
"""vLLM ``--quantization`` name, or None to keep the model dtype."""
|
|
group_size: int | None
|
|
bits_per_weight: float | None
|
|
fused_cpu_norms: bool
|
|
runs_in_vllm: bool
|
|
"""False means vLLM cannot execute here and an exported artifact is the
|
|
deliverable -- the phone case."""
|
|
verified: bool
|
|
"""True only where this combination was actually measured on hardware."""
|
|
rationale: str
|
|
build_with: str | None = None
|
|
"""Script that produces a checkpoint in this format."""
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
def as_engine_kwargs(self) -> dict[str, object]:
|
|
"""The subset that goes straight into ``LLM(...)`` / deploy overrides."""
|
|
if not self.runs_in_vllm:
|
|
return {}
|
|
kwargs: dict[str, object] = {}
|
|
if self.quantization:
|
|
kwargs["quantization"] = self.quantization
|
|
return kwargs
|
|
|
|
|
|
_AMX_FLAGS = ("amx_tile", "amx_bf16")
|
|
|
|
|
|
def has_amx(profile: HardwareProfile) -> bool:
|
|
"""AMX tiles, which is what gates vLLM's CPU 4-bit GEMM."""
|
|
return profile.arch == "x86_64" and any(f in profile.cpu_flags for f in _AMX_FLAGS)
|
|
|
|
|
|
def select_weight_path(
|
|
profile: HardwareProfile, priority: Priority = "speed"
|
|
) -> WeightPath:
|
|
"""Pick the weight format for this device.
|
|
|
|
``priority='fidelity'`` keeps bf16 activations wherever a choice exists,
|
|
which costs ~7% of decode on an AMX machine and buys back the int8-activation
|
|
error.
|
|
"""
|
|
cls = hardware_class(profile)
|
|
|
|
if cls == HW_CLASS_NPU_PHONE:
|
|
return WeightPath(
|
|
name="export-qnn-w8a16",
|
|
quantization=None,
|
|
group_size=None,
|
|
bits_per_weight=8.0,
|
|
fused_cpu_norms=False,
|
|
runs_in_vllm=False,
|
|
verified=True,
|
|
rationale=(
|
|
"vLLM has no NPU backend; the artifact is an exported graph. "
|
|
"Calibrated w8a16 is the only scheme measured correct on device "
|
|
"(36.9 dB); int8 activations break it (5.1 dB) and 4-bit weights "
|
|
"fail the converter on the calibrated path."
|
|
),
|
|
build_with="vllm_omni/edge/spark_export.py",
|
|
notes=[
|
|
"Calibration must use real activations: uncalibrated w4a16 "
|
|
"scores -1.6 dB on device, i.e. uncorrelated with the reference.",
|
|
"4-bit saves memory, not time, on this NPU: 0.917 vs 0.912 ms "
|
|
"per layer against 8-bit.",
|
|
],
|
|
)
|
|
|
|
if cls in (HW_CLASS_CUDA_DISCRETE, HW_CLASS_JETSON):
|
|
return WeightPath(
|
|
name="bf16" if profile.supports_bf16 else "fp16",
|
|
quantization=None,
|
|
group_size=None,
|
|
bits_per_weight=16.0,
|
|
fused_cpu_norms=False,
|
|
runs_in_vllm=True,
|
|
verified=cls == HW_CLASS_CUDA_DISCRETE,
|
|
rationale=(
|
|
"The CPU 4-bit kernels are x86 CPU kernels. On a GPU the weights "
|
|
"stay in the model dtype and the GPU quantization paths "
|
|
"(marlin/AWQ) are a separate question this selector does not make."
|
|
),
|
|
)
|
|
|
|
if cls == HW_CLASS_ARM64_CPU:
|
|
return WeightPath(
|
|
name="w4a16-tinygemm",
|
|
quantization="cpu_int4",
|
|
group_size=128,
|
|
bits_per_weight=4.16,
|
|
fused_cpu_norms=True,
|
|
runs_in_vllm=True,
|
|
verified=False,
|
|
rationale=(
|
|
"No AMX, so no int4_scaled_mm_cpu. PyTorch's 4-bit CPU GEMM has "
|
|
"an ARM path, but it dispatches to a different kernel "
|
|
"(KleidiAI-style packing) than the AVX-512 one measured here."
|
|
),
|
|
build_with="analysis/experiments/spark_edge/quantize_int4.py",
|
|
notes=["Unverified on this host: x86 only. Measure before trusting."],
|
|
)
|
|
|
|
# x86 CPU: the one case with a real choice to make.
|
|
if has_amx(profile) and priority == "speed":
|
|
return WeightPath(
|
|
name="w4a8-amx",
|
|
quantization="gptq",
|
|
group_size=128,
|
|
bits_per_weight=4.16,
|
|
fused_cpu_norms=True,
|
|
runs_in_vllm=True,
|
|
verified=True,
|
|
rationale=(
|
|
"AMX tiles present, so vLLM's own int4_scaled_mm_cpu applies: "
|
|
"84.5 tok/s decode against 78.7 for tinygemm and 41.0 for bf16, "
|
|
"and 3.0x llama.cpp on prefill."
|
|
),
|
|
build_with="analysis/experiments/spark_edge/quantize_gptq_int4.py",
|
|
notes=[
|
|
"Least faithful of the three: 1/12 greedy prompts reproduced "
|
|
"exactly against its own bf16, vs 2/12 for w4a16.",
|
|
"Activations are quantized to int8 by the kernel; that is what "
|
|
"llama.cpp's Q4_K_M also does.",
|
|
],
|
|
)
|
|
|
|
return WeightPath(
|
|
name="w4a16-tinygemm",
|
|
quantization="cpu_int4",
|
|
group_size=128,
|
|
bits_per_weight=4.16,
|
|
fused_cpu_norms=True,
|
|
runs_in_vllm=True,
|
|
verified=True,
|
|
rationale=(
|
|
"No AMX tiles, or fidelity was asked for: keep activations in bf16 "
|
|
"and take the tinygemm 4-bit GEMM. 78.7 tok/s decode, and the more "
|
|
"faithful of the two 4-bit paths."
|
|
),
|
|
build_with="analysis/experiments/spark_edge/quantize_int4.py",
|
|
)
|