171 lines
8.0 KiB
Python

"""Block-quantized GEMMs through the vllm-neuron#71 NKI kernels (issue #10, #25).
DeepInfer keeps fp8 weights as ``[n, k]`` e4m3 with per-row, per-32-column packed ue8m0 scales (expanded from the
checkpoint's 32x32 blocks) and fp4 experts as ``[e, n, k/2]`` e2m1 pairs with per-row scales. The kernels take the
checkpoint form: e4m3 bytes with a ``[ceil(n/32), ceil(k/32)]`` e8m0 scale, e2m1 bytes with ``[n, k/32]``. The
kernel scales are prepared once after loading (``prepare_model``) and kept on the weight as ``_nki_scale``.
Activations arrive fp8-quantized (1x32 blocks); they are dequantized to bf16 exactly (an e4m3 value times a power of
two) and fed to the kernel. ``fp4_linear`` re-quantizes them through the same 1x32 fp8 round trip, which is
idempotent on already-quantized values.
"""
from __future__ import annotations
import functools
import os
import torch
ENABLED = os.environ.get("DEEPINFER_NEURON_NKI_GEMM", "1") == "1"
def _bytes(sf: torch.Tensor) -> torch.Tensor:
"""Packed ue8m0 scales (int16 pairs, uint8 or float8_e8m0fnu) -> uint8 exponent bytes, row-major."""
return sf.contiguous().view(torch.uint8)
def _kernel_scale(weight: torch.Tensor, sf: torch.Tensor) -> torch.Tensor | None:
"""The kernel's scale for one weight, or None if its scales do not fit the kernel's layout."""
k_blocks = -(-(weight.shape[-1] * (2 if weight.dtype == torch.int8 else 1)) // 32)
b = _bytes(sf)[..., :k_blocks]
if weight.dtype == torch.int8: # fp4 experts: per-row scales, as the kernel takes them
return b.contiguous()
n = weight.shape[-2]
blocks = b[..., ::32, :]
if not torch.equal(blocks.repeat_interleave(32, dim=-2)[..., :n, :], b): # rows of a block must share a scale
return None
return blocks.contiguous()
def prepare_model(model: torch.nn.Module) -> int:
"""Attach ``_nki_scale`` to every fp8 / fp4 weight the kernels can take. Returns the count."""
if not ENABLED:
return 0
count = 0
skipped = []
for name, p in model.named_parameters():
sf = getattr(p, "sf", None)
if sf is None or p.dtype not in (torch.float8_e4m3fn, torch.int8) or p.dim() not in (2, 3):
continue
# On the host: the scale tensors are small, and eager device ops would compile once per weight shape.
sf_host = (sf.data if isinstance(sf, torch.nn.Parameter) else sf).cpu()
scale = _kernel_scale(p.data, sf_host)
if scale is not None:
scale = scale.to(p.device)
p._nki_scale = scale
# The weight bytes as their own uint8 tensor (same storage): a dtype bitcast of a graph input feeding the
# kernel crashes neuronx-cc (InferNonlocalTensors), a uint8 input does not.
p._nki_weight = p.data.view(torch.uint8)
count += 1
else:
skipped.append(f"{name}{tuple(p.shape)}")
if skipped:
# These stay on the deep_gemm shims: each call dequantizes the whole weight inside the graph.
from deepinfer.logger import init_logger
init_logger(__name__).warning(f"Neuron: {len(skipped)} block-quant weights without the NKI kernels: {skipped[:12]}")
return count
#: At LNC=2 a logical NeuronCore is two physical cores, and a kernel launched at grid 1 has code for only one of them:
#: half the machine idles. The ``_2core`` kernels divide the output columns by ``nl.program_id(0)`` and run at grid 2
#: (vllm-neuron#71): 1.7-1.9x on every decode GEMM, bitwise equal (issue #27). DEEPINFER_NEURON_BQ_2CORE=0 turns it off.
TWO_CORE = os.environ.get("DEEPINFER_NEURON_BQ_2CORE", "1") == "1"
@functools.cache
def _kernel(name: str, grid: int = 0): # noqa: ANN202
from torch_neuronx.nki_hop import wrap_nki
from deepinfer.neuron_ops.nki import blockquant
kernel = wrap_nki(getattr(blockquant, name))
return kernel[grid] if grid else kernel
def _linear_kernel(name: str, n: int): # noqa: ANN202
"""``name`` at grid 2 when its output columns split into two whole 128-column halves, else at grid 1."""
if TWO_CORE and n % 256 == 0:
return _kernel(f"{name}_2core", 2)
return _kernel(name)
def _activation_bf16(a: torch.Tensor, sfa: torch.Tensor, k: int) -> torch.Tensor:
from deep_gemm._quant import decode_data, sf_to_scales
return (decode_data(a) * sf_to_scales(sfa, k)).to(torch.bfloat16)
def supported(a: torch.Tensor, b: torch.Tensor, k: int, n: int) -> bool:
return (
ENABLED
and a.device.type == "neuron"
and getattr(b, "_nki_scale", None) is not None
and k % 128 == 0
and n % 32 == 0
)
def gemm(a: torch.Tensor, sfa: torch.Tensor, b: torch.Tensor, d: torch.Tensor, group: int | None = None) -> None:
"""``d = dequant(a) @ dequant(b[group])^T`` through the NKI kernels."""
m = a.shape[0]
x = _activation_bf16(a, sfa, a.shape[-1])
scale = b._nki_scale if group is None else b._nki_scale[group]
w = b._nki_weight if group is None else b._nki_weight[group]
name = "fp4_linear" if b.dtype == torch.int8 else "fp8_linear_raw"
out = _linear_kernel(name, w.shape[0])(x, w, scale)
d.copy_(out[:m].to(d.dtype))
def linear_qdq(x: torch.Tensor, w: torch.Tensor, group: int | None = None) -> torch.Tensor:
"""bf16 ``x [m, k]`` against a prepared fp8 / fp4 weight, the kernel quantizing ``x`` to fp8 (1x32, power-of-two
scales, max 224) on the way in -- the same quantization the shims apply, so callers can skip the fp8 activation
and its int16 scales entirely. Returns bf16 ``[m, n]``."""
scale = w._nki_scale if group is None else w._nki_scale[group]
weight = w._nki_weight if group is None else w._nki_weight[group]
name = "fp4_linear" if w.dtype == torch.int8 else "fp8_linear"
return _linear_kernel(name, weight.shape[0])(x, weight, scale)[: x.shape[0]]
def linear_qdq_raw(x: torch.Tensor, weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
"""``linear_qdq`` for a bare fp8 weight (uint8 bytes ``[n, k]`` + kernel scale), e.g. a rank's shared-expert
slice (static_moe.prepare_shared_slices)."""
return _linear_kernel("fp8_linear", weight.shape[0])(x, weight, scale)[: x.shape[0]]
def linear_qdq_gated(x: torch.Tensor, w: torch.Tensor, group: int, flag: torch.Tensor) -> torch.Tensor | None:
"""``linear_qdq`` for fp4 expert ``group`` that the kernel skips (zeros) unless ``flag`` (int32 ``[1, 1]``) is 1.
None when the gated kernel does not apply (not fp4, or columns that do not split across the two cores)."""
scale = w._nki_scale[group]
weight = w._nki_weight[group]
if w.dtype != torch.int8 or not TWO_CORE or weight.shape[0] % 256 != 0:
return None
return _kernel("fp4_linear_2core_gated", 2)(x, weight, scale, flag)[: x.shape[0]]
def has_kernel(w: torch.Tensor | None) -> bool:
return ENABLED and w is not None and getattr(w, "_nki_scale", None) is not None and w.device.type == "neuron"
def bf16_norms(model: torch.nn.Module) -> int:
"""Stop the attention / ffn norms casting to fp8 where every consumer is an NKI kernel (prepare_model first).
The kernels quantize the bf16 hidden themselves, so the norm's fp8 copy and its scales were dead -- but they
crossed the graph boundary after the mHC pre block as outputs, and their encode (floor / select / power over the
whole hidden, mostly on Vector and GpSimd) was about half of that block (3.6 ms of a 512-row prefill chunk,
twice a layer). Returns the count of norms switched to bf16.
"""
count = 0
for layer in model.modules():
attn, mlp = getattr(layer, "self_attn", None), getattr(layer, "mlp", None)
pairs = (
(getattr(layer, "attn_layernorm", None), has_kernel(getattr(getattr(attn, "q_a_kv_swa_proj", None), "weight", None))),
(getattr(layer, "ffn_layernorm", None), has_kernel(getattr(mlp, "gate_up_proj_weight", None)) and not getattr(mlp, "use_mega_moe", False)),
)
for norm, nki_only in pairs:
if nki_only and getattr(norm, "use_fp8", False):
norm.use_fp8 = False
count += 1
return count