Files
vllm-omni/vllm_omni/model_executor/layers/cpu_ir_ops.py
T
tzhouamandClaude Opus 5 3dac97e0bd [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>
2026-09-12 13:39:19 +08:00

143 lines
5.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Give vLLM's CPU backend the fused RMSNorm kernels it already ships.
vLLM's CPU build registers `_C::rms_norm` and `_C::fused_add_rms_norm`, and
`vllm/kernels/vllm_c.py` already wraps both as IR-op implementations -- but it
gates them on ``GPGPU_DEVICE = is_cuda_alike() or is_xpu()``, so on CPU the IR
layer marks them unsupported and every norm falls through to the PyTorch
implementation. Nothing then fuses it: inductor emits an elementwise loop and
spreads a 4 KB reduction across every core, which at one token is almost
entirely thread barrier.
Measured on a Xeon 8480C at Spark-X2.5's decode shape (1 token, hidden 2048),
24 threads:
rms_norm native 20.99 us fused 5.76 us 3.6x
fused_add_rms_norm native 41.14 us fused 9.24 us 4.5x
Two norms per layer over 28 layers is ~1.8 ms of an ~12 ms decode step.
The same file's `gelu_and_mul` is deliberately *not* re-registered here: the
fused CPU kernel measured 64.11 us against the native path's 23.36 us at
Spark's GEGLU shape ([1, 13312] -> [1, 6656]), i.e. 2.7x slower, so the native
form is the right choice there and the asymmetry is the point -- "use the
fused kernel" is not a rule, it is a per-op measurement.
Registering under our own provider name rather than editing `vllm_c.py` keeps
this working across vLLM wheel upgrades; it is the mechanism vLLM documents for
out-of-tree kernels (see `vllm/kernels/oink_ops.py`). The equivalent upstream
change is two lines: add `is_cpu()` to `GPGPU_DEVICE` and give `CpuPlatform` a
`get_default_ir_op_priority`.
"""
import os
import torch
from torch import Tensor
from vllm import ir
from vllm.logger import init_logger
from vllm.platforms import current_platform
logger = init_logger(__name__)
PROVIDER = "vllm_c_cpu"
_FUSED_NORM_OPS = ("rms_norm", "fused_add_rms_norm")
def _cpu_kernels_available() -> bool:
if not current_platform.is_cpu():
return False
try:
current_platform.import_kernels()
return all(hasattr(torch.ops._C, name) for name in _FUSED_NORM_OPS)
except Exception as exc: # pragma: no cover - build without the extension
logger.debug("CPU fused norm kernels unavailable: %s", exc)
return False
CPU_FUSED_NORMS = _cpu_kernels_available()
def _supports(x: Tensor, weight, epsilon, variance_size=None) -> bool:
# The kernel normalizes over the last dim of a contiguous 2-D buffer and
# has no variance_size override; anything else stays on the native path.
return (
variance_size is None
and (weight is None or weight.dtype == x.dtype)
and x.is_contiguous()
)
def _supports_add(x: Tensor, x_residual: Tensor, weight, epsilon, variance_size=None):
return _supports(x, weight, epsilon, variance_size) and x_residual.is_contiguous()
@ir.ops.rms_norm.register_impl(
PROVIDER, supports_args=_supports, supported=CPU_FUSED_NORMS
)
def rms_norm(
x: Tensor, weight: Tensor | None, epsilon: float, variance_size: int | None = None
) -> Tensor:
assert variance_size is None
shape = x.shape
x2d = x if x.dim() == 2 else x.reshape(-1, shape[-1])
out = torch.empty_like(x2d)
torch.ops._C.rms_norm(out, x2d, weight, epsilon)
return out if x.dim() == 2 else out.reshape(shape)
@ir.ops.fused_add_rms_norm.register_impl(
PROVIDER, supports_args=_supports_add, supported=CPU_FUSED_NORMS, inplace=True
)
def fused_add_rms_norm(
x: Tensor,
x_residual: Tensor,
weight: Tensor | None,
epsilon: float,
variance_size: int | None = None,
) -> tuple[Tensor, Tensor]:
assert variance_size is None
if x.dim() == 2:
torch.ops._C.fused_add_rms_norm(x, x_residual, weight, epsilon)
return x, x_residual
shape = x.shape
x2d, r2d = x.reshape(-1, shape[-1]), x_residual.reshape(-1, shape[-1])
torch.ops._C.fused_add_rms_norm(x2d, r2d, weight, epsilon)
return x2d.reshape(shape), r2d.reshape(shape)
def prefer_cpu_fused_norms() -> None:
"""Make the CPU platform rank these kernels ahead of the native path.
The platform hook only fills in ops the user left unset, so an explicit
``kernel_config.ir_op_priority`` still wins.
"""
if not CPU_FUSED_NORMS:
return
if os.environ.get("VLLM_OMNI_CPU_FUSED_NORMS", "1") != "1":
logger.info("CPU fused RMSNorm kernels disabled by environment.")
return
from vllm.platforms.cpu import CpuPlatform
if getattr(CpuPlatform, "_omni_fused_norm_priority", False):
return
base = CpuPlatform.get_default_ir_op_priority.__func__
def get_default_ir_op_priority(cls, vllm_config):
priority = base(cls, vllm_config)
for op_name in _FUSED_NORM_OPS:
current = list(getattr(priority, op_name))
if PROVIDER not in current:
setattr(priority, op_name, [PROVIDER] + current)
return priority
CpuPlatform.get_default_ir_op_priority = classmethod(get_default_ir_op_priority)
CpuPlatform._omni_fused_norm_priority = True
logger.info(
"CPU fused RMSNorm kernels enabled (provider %s) ahead of the native path.",
PROVIDER,
)