CERIN AMROTH · ML systems

How do I run a grouped GEMM directly on NF4 packed MoE expert weights, without dequantizing to bf16 first?

Solved by grouped-nf4-gemm · this page in the repository (pinned e2af4cfb91b2, the source of this rendering; latest on main, unpinned; not the source of any fact rendered here)

Install routes

From docs/capabilities.json at the pinned commit.

pip install grouped-nf4-gemm

Primary route: kernel package.

Alternatives:

Environment (from the capability register): OS: Linux · Python: >=3.11 tested in CI (pyproject says >=3.9; 3.9/3.10 are not tested) · Accelerator: NVIDIA CUDA GPU, sm_80 or newer (sm_120 is the primary serving target) · Requires: torch>=2.8 (pre-releases accepted); triton>=3.4 (Linux-only distribution)

Use nf4_grouped.gemm_4bit_grouped from grouped-nf4-gemm: one Triton launch computes the routed expert GEMM on the bitsandbytes gemm_4bit NF4 layout, decoding nibbles to fp32 in registers and accumulating in fp32, so no bf16 copy of any expert is written to memory. The pure-torch nf4_grouped.dequant_ref is the oracle the kernel is asserted against, and it runs on CPU.

Measured boundaries

Every figure here is quoted from claims.json under the named ID with its tier, and each row carries the cells that lose beside the cells that win.

what was measuredresultclaim IDtier
Batch-1 decode on sm_86, census MoE shapes (OLMoE, Qwen3-30B, Gemma-4, gpt-oss-120b) vs the registered dequant comparator1.16–2.73× at median; loses: top_k=1 cells (instance-unstable) and tiny shapes under about 5 M weight elementsgnf4.kernel.decode-speed-censusconfirmed
A real OLMoE QLoRA finetune on prose, fused vs the per-expert loop4.50× (RTX 4090), 4.75× (H100)gnf4.kernel.e2e-training-real-proseconfirmed
Head-to-head against Unsloth's own kernel, 4-bit-storage regime, decode1.70× (H100), 2.79× (RTX 4090); loses: Unsloth is 2.6–5.3× faster at prefill in its own bf16-resident H100 regimegnf4.kernel.h2h-unslothconfirmed
Fidelity vs the dequantize-to-bf16-then-GEMM comparatorhas not measured less accurate in any registered confirmatory cell (fp32 accumulation, bf16 epilogue); a CUDA tensor-core statement, not a universal proofgnf4.kernel.fused-more-accurate-than-dequant-bf16confirmed

The other registered loser, a CUDA-graphed per-expert baseline at the decode band, is carried by ID under Limitations (gnf4.kernel.graphed-baseline-decode-loses). The comparator in the first and last rows is this repository's own per-expert dequantize-then-GEMM loop as each receipt ran it; the dated note under "Why it happens" says what that does and does not say about current bitsandbytes.

Symptoms

Why it happens

bitsandbytes stores NF4 as a 16-entry codebook index per weight plus a blockwise fp32 absmax. What its forward does with those bytes depends on the release, the workload and the shape:

grouped-nf4-gemm keeps the expert-major NF4 stack packed, groups the routed work, decodes nibbles inside the kernel, accumulates in fp32, and writes no bf16 expert tensor; dgrad_4bit_grouped does the same for dX. KERNEL_CONTRACT.md is the op contract.

PathWorkloadPacked forwardGrouped routingTraining dgrad
bitsandbytes ≥ 0.50 supported CUDA path (torch.ops.bitsandbytes.gemm_4bit)ordinary 2-D inference matrix, on supported shapes/devices/configs, inference (no-grad)yesnobackward dequantizes
naive fused-MoE loopper-expert operations, one per active expertpath-dependent (release and cell)nopath-dependent
grouped-nf4-gemm (gemm_4bit_grouped)expert-major stack + routed groupsyesyesone-launch path available (dgrad_4bit_grouped)

Dataflow, per layer per projection:

per-expert materializing loop                grouped packed path (this package)
-----------------------------                ----------------------------------
for each active expert e:                    packed B [E, N, K/2] u8 + absmax [E, N, K/64] f32
    W_e = dequant(B[e]) -> bf16 [N, K]       routed groups: a_cat [T, K] sorted by expert,
    out_e = a_e @ W_e.T                      sizes + expert_ids
    (write W_e, read W_e, discard)                   |
                                                     v
                                             one grouped kernel dispatch:
                                               nibble -> LUT decode in registers,
                                               absmax scale, fp32 accumulate,
                                               bf16 epilogue
                                                     |
                                                     v
                                             output [T, N] in group order

Sorting tokens by expert and scattering the output back remain outside the kernel, as for every grouped GEMM.

Dated note (2026-09-04). Every comparator called "the dequant path" or "dequantize-to-bf16-then-GEMM" in this repository's receipts is the per-expert loop as each receipt ran it: bitsandbytes dequantize_4bit per active expert followed by a bf16 matmul (bench/phase1/harness.py, bk_dequant_grouped; examples/dequant_tax.py labels which decode arm it ran). Those numbers stay what they measured; they are not a statement that current bitsandbytes dequantizes on every Linear4bit forward, and no registered cell times bitsandbytes' own packed 2-D inference forward.

Which project solves it

grouped-nf4-gemm owns the kernel, its CUDA-graph-capturable variant, the one-launch backward, the packers and reference decode, and the repack from bitsandbytes state. It does not load models or route tokens. experts4bit-qlora (PyPI) owns model loading, quantization orchestration, adapters, QLoRA training, residency integration and serving, and drives these kernels through enable_fast() / enable_fast_train(). A model-level symptom ("bitsandbytes MoE still OOMs after load_in_4bit") starts there.

Install

Kernel package (the minimum route):

pip install grouped-nf4-gemm

Linux, NVIDIA GPU of compute capability sm_80 or newer (sm_120 is the primary serving target), triton>=3.4 (Linux-only distribution), torch>=2.8 (pre-releases accepted); CI tests Python 3.11. nf4gemm, gnf4 and grouped-mxfp4-gemm are lookup aliases, not separate packages. Through the model consumer:

pip install "experts4bit-qlora[fast]"

Smallest correct example

CPU-only: pack one expert and decode it with the reference.

# CPU-only (pure torch; no GPU, no triton launch)
import torch
from nf4_pack_ref import quantize_pack_nf4
from nf4_grouped import dequant_ref

N, K = 256, 512                                  # K % 64 == 0
w = torch.randn(N, K)
packed, absmax = quantize_pack_nf4(w)            # [N, K//2] uint8, [N, K//64] fp32
wq = dequant_ref(packed, absmax, N, K)           # [N, K] fp32
assert wq.shape == (N, K)
assert torch.equal(quantize_pack_nf4(wq)[0], packed)   # re-pack is idempotent

GPU: the grouped kernel, asserted against the reference decode group by group.

# GPU (sm_80+) + triton
import torch
from nf4_pack_ref import make_stack
from nf4_grouped import gemm_4bit_grouped, dequant_ref

E, N, K = 8, 256, 512
B, absmax = make_stack(E, N, K, device="cuda")   # [E, N, K//2] u8, [E, N, K//64] f32
sizes, expert_ids = [3, 1, 4], [5, 0, 2]         # group-sorted tokens per active expert
a_cat = torch.randn(sum(sizes), K, device="cuda", dtype=torch.bfloat16)

out = gemm_4bit_grouped(a_cat, B, absmax, sizes, expert_ids)     # [T, N] bf16

row, num, den = 0, 0.0, 0.0
for m, e in zip(sizes, expert_ids):
    ref = a_cat[row:row + m].double() @ dequant_ref(B[e], absmax[e], N, K).double().t()
    num += (out[row:row + m].double() - ref).norm().item() ** 2
    den += ref.norm().item() ** 2
    row += m
assert (num ** 0.5) / (den ** 0.5) <= 1e-2       # B-abs bound, docs/TOLERANCE_CONTRACT.md

Calling gemm_4bit_grouped on CPU tensors raises and names dequant_ref; nothing falls back silently.

Expected result

Both blocks finish without an assertion. The GPU block returns [T, N] bf16 in the group order of a_cat, with relative Frobenius error against the fp64 product of the reference decode inside the registered bound. Output is not bit-identical to dequantize-then-matmul: the per-element decode is identical, the fp32 reduction order is not (TOLERANCE_CONTRACT.md).

Supported scope

Limitations

KERNEL_CONTRACT.md · TOLERANCE_CONTRACT.md · STATUS.md · claims.json · REPRO.md · native-mxfp4-moe-inference.md · int4-decode-gemv.md · stream-moe-experts-from-host-or-nvme.md

Evidence

Register: claims.json. Confirmed: claim gnf4.kernel.fused-more-accurate-than-dequant-bf16 (has not measured less accurate than the dequantize-to-bf16-then-GEMM comparator in any registered confirmatory cell), claim gnf4.kernel.decode-speed-census, claim gnf4.kernel.energy-104-of-112, claim gnf4.kernel.e2e-training-real-prose, claim gnf4.kernel.h2h-unsloth (4-bit-storage regime; Unsloth wins its own bf16-resident regime), claim gnf4.kernel.graphed-baseline-decode-loses. Measured: claim gnf4.kernel.sm120-census-vs-grouped-mm, claim gnf4.kernel.dgrad, claim gnf4.kernel.expert-offset-boundary.5090.2026-09-05 (the 2^31 offset boundary, every carrier). Receipts under kernel/RESULTS-*.md with their prereg_*.json; property suite kernel/test_nf4_grouped.py.

Common wrong approaches

Source and freshness

This page is a rendering of docs/solutions/nf4-grouped-gemm-without-bf16-materialization.md at commit e2af4cfb91b2 (sha256 5aabf224aeb1713a…). Numbers are never copied here: every measured statement cites a claim ID in docs/claims.json at that commit. Repository-relative links resolve to this site's pages where the document is published and to the pinned commit otherwise.

CURRENT · source: pjordanandrsn/grouped-nf4-gemm@e2af4cfb91b2 · rendered package: 0.30.2 · latest published package: 0.30.2