CERIN AMROTH · ML systems

How do I run paged decode attention over an FP8 KV cache for a quantized MoE serving path, with sliding windows, attention sinks and a custom scale?

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 (the composition was measured on the RTX 5090, sm_120); NVIDIA CUDA GPU, sm_80 or newer; this is the path sm_80-sm_88 take by default. Its reference tests were run on an RTX 5090 (sm_120) with the mode forced; no registered cell exists on sm_80-sm_88; NVIDIA CUDA GPU, sm_89 or newer (the fp8 tensor-core dot); measured on the RTX 5090 (sm_120) only -- sm_89 and sm_90 meet the requirement but no registered cell was run there · Requires: shape constraints, all from fp8_paged_attn.fp8_compute_unsupported (the single predicate the default selector and the path's own asserts share): v_groups == 1; k_groups in (1, 2, 4, 8, 16); head_dim // k_groups >= 32; q in bf16 or fp16; split branch: ktile >= 32 when the caller supplies it; packed branch (pack_heads=True): block_tokens * n_kv_heads >= 32. A call that fails any of these with GNF4_ATTN_COMPUTE unset takes the f32 path (its own entry); the open item is on torch 2.8.0+cu128 / triton 3.4.0 -- the package floor -- so no torch/Triton pair inside the supported range is registered as passing this path's reference tests; torch>=2.8 (pre-releases accepted); triton>=3.4 (Linux-only distribution)

Use fp8_paged_attn.fp8_paged_decode_attention from grouped-nf4-gemm: a Triton flash-decode kernel over paged E4M3 K/V blocks that dequantizes in registers, takes window, sinks, sm_scale and per-layer row strides, and is checked against the pure-torch fp8_paged_attn.paged_attn_ref. The KV quantize/pack primitives live in fp8_kv; the decode glue kernels (fused RMSNorm, residual fold, rotary, router epilogue) live in int4_b32.

Symptoms

Why it happens

At batch-1 decode nothing is compute-bound; every kernel is a launch plus a read. A per-query-head grid re-reads shared K/V heads under GQA, an unsplit grid leaves most SMs idle, and a materialized bf16 dequant of an fp8 cache adds the round trip the fp8 storage was meant to remove. The kernel's docstring records each decision as a measured fact: grid over (sequence, KV head, split), E4M3 decode as bit assembly, scales loaded at their natural shape.

Which project solves it

grouped-nf4-gemm owns the paged attention kernel and its reference, the FP8 KV primitives, the writable KV row tier (row_pool.RowPool) and the decode glue. experts4bit-qlora (PyPI) owns KV allocation, block tables, batching, model wiring and the served-path parity gates.

Install

Kernel package (the minimum route):

pip install grouped-nf4-gemm

Linux, NVIDIA GPU sm_80 or newer, triton>=3.4 (Linux-only distribution), torch>=2.8 (pre-releases accepted); CI tests Python 3.11. The fp8 compute path needs sm_89 or newer; sm_80–sm_88 take the f32 path, which is open (below). fp8_kv and paged_attn_ref are pure torch. Through the model consumer:

pip install "experts4bit-qlora[fast]"

Two compute paths, two support states

One kernel, two numerical paths, and capabilities.json carries them as two entries because a single status cannot be true of both.

pathentry in capabilities.jsonwho takes itrequirements (the predicate is fp8_paged_attn.fp8_compute_unsupported; the default selector and the path's own asserts share it)measured onstatus
fp8 compute (compute="fp8"; the default where it can run)fp8-paged-attention-fp8-computesm_89+ with GNF4_ATTN_COMPUTE unset and every constraint passing; any explicit fp8 request (never downgraded: the asserts refuse instead)sm_89 or newer; v_groups == 1; k_groups in (1, 2, 4, 8, 16); head_dim // k_groups >= 32; q bf16/fp16; split branch: ktile >= 32 when supplied; packed branch (pack_heads=True): block_tokens * n_kv_heads >= 32RTX 5090 (sm_120) only — sm_89 and sm_90 meet the requirement, no registered cell was run there (claims gnf4.serve.fp8-paged-attn-windows-sinks-scale, gnf4.serve.m3-defaults-on, both measured)supported
f32 compute (compute="f32", split or packed kernel)fp8-paged-attention-f32-computesm_80–sm_88 by default; sm_89+ whenever a constraint above fails with the env unset; every explicit GNF4_ATTN_COMPUTE=f32 / compute="f32" on any cardsm_80 or newerits reference tests were run on an RTX 5090 with the mode forced; no cell on sm_80–sm_88open#319: on torch 2.8.0+cu128 / triton 3.4.0 the split and packed f32 modes miss their reference by up to 0.074 against a 0.02 tolerance on 10 of 35 tests, on unmodified main (claim gnf4.open.f32-compute-modes-triton34, status open, so it backs no capability); the capability entry is unsupported until the issue closes

A kernel that imports and launches is not thereby numerically supported: the status of each path follows the claim and issue register, not the import. compute_counts() records which path actually ran, because an environment variable is a request, not an event.

Smallest correct example

CPU-only: quantize and pack a paged KV pool, run the reference, and check it against plain attention over the dequantized values.

# CPU-only (pure torch; no GPU, no triton)
import torch
from fp8_kv import quantize_kv_fp8, dequant_kv_fp8_ref, pack_kv_block, kv_block_bytes
from fp8_paged_attn import paged_attn_ref

BT, H_KV, H_Q, D, T = 16, 2, 4, 64, 40            # 16-token blocks, GQA 2:1, 40 live tokens
n_blk = (T + BT - 1) // BT
row = kv_block_bytes(BT, H_KV, D)                  # fp8 payload + fp32 scales per block
k_pool = torch.zeros(n_blk * row, dtype=torch.uint8)
v_pool = torch.zeros(n_blk * row, dtype=torch.uint8)
kt, vt = torch.randn(n_blk * BT, H_KV, D), torch.randn(n_blk * BT, H_KV, D)
table = torch.tensor([[2, 0, 1]], dtype=torch.int32)   # block table, permuted on purpose
kd, vd = [], []
for i in range(n_blk):
    r = int(table[0, i])
    qk, sk = quantize_kv_fp8(kt[i * BT:(i + 1) * BT]); pack_kv_block(qk, sk, k_pool[r * row:(r + 1) * row])
    qv, sv = quantize_kv_fp8(vt[i * BT:(i + 1) * BT]); pack_kv_block(qv, sv, v_pool[r * row:(r + 1) * row])
    kd.append(dequant_kv_fp8_ref(qk, sk, dtype=torch.float32))
    vd.append(dequant_kv_fp8_ref(qv, sv, dtype=torch.float32))
q = torch.randn(1, H_Q, D).to(torch.bfloat16)      # one decode token for one sequence
lens = torch.tensor([T], dtype=torch.int32)
out = paged_attn_ref(q, k_pool, v_pool, table, lens, n_kv_heads=H_KV, head_dim=D)  # [1, H_Q, D]

k = torch.cat(kd)[:T].permute(1, 0, 2); v = torch.cat(vd)[:T].permute(1, 0, 2)      # [H_KV, T, D]
qq = q[0].float().view(H_KV, H_Q // H_KV, D)
w = torch.softmax(torch.einsum("hgd,htd->hgt", qq, k) * D ** -0.5, dim=-1)
want = torch.einsum("hgt,htd->hgd", w, v).reshape(1, H_Q, D)
torch.testing.assert_close(out.float(), want, rtol=1e-2, atol=1e-2)

GPU: the kernel against the reference, recording which compute mode actually ran.

# GPU (sm_89+ for the fp8 compute default) + triton; sm_80-sm_88 take the f32 path,
# open under #319 on triton 3.4 (claim gnf4.open.f32-compute-modes-triton34)
import torch
from fp8_paged_attn import (fp8_paged_decode_attention, paged_attn_ref,
                            paged_attn_available, compute_counts)
# build q, k_pool, v_pool, table, lens, H_KV, D exactly as in the CPU block
assert paged_attn_available()
before = compute_counts()
got = fp8_paged_decode_attention(q.cuda(), k_pool.cuda(), v_pool.cuda(),
                                 table.cuda(), lens.cuda(),
                                 n_kv_heads=H_KV, head_dim=D, window=0, sinks=None)
mode = "fp8" if compute_counts()["fp8"] > before["fp8"] else "f32"   # what ran, not what was asked
want = paged_attn_ref(q, k_pool, v_pool, table, lens, n_kv_heads=H_KV, head_dim=D)
tol = 1.5e-1 if mode == "fp8" else 2e-2          # the suite's per-mode bounds
torch.testing.assert_close(got.cpu().float(), want.float(), rtol=tol, atol=tol)
print("compute mode:", mode)

Expected result

The CPU block passes: the packed pool, read back through the reference, reproduces attention over the dequantized K/V to bf16 output precision. The GPU block prints the compute mode that ran and passes its bound. window=W restricts each query to the last W keys; sinks (an [H_q] fp32 tensor) adds one logit per query head to the softmax denominator with no value, the gpt-oss s_aux convention; sm_scale replaces D ** -0.5. paged_attn_ref accepts the same three.

Supported scope

Limitations

STATUS.md · claims.json · context-budgets.md (KB/token, rung one only) · int4-decode-gemv.md · nf4-grouped-gemm-without-bf16-materialization.md · kernel/test_fp8_paged_attn.py · kernel/test_fp8_kv.py

Evidence

Register: claims.json. Measured, fp8 compute path: claim gnf4.serve.fp8-paged-attn-windows-sinks-scale (windows, sinks, scale and stride overrides; fp8-mode GPU suite on an RTX 5090), claim gnf4.serve.m3-defaults-on (both decode knobs on by default, capability-conditional, with a paired perplexity check). Open, f32 compute path: claim gnf4.open.f32-compute-modes-triton34 (backs nothing). Measured-private, labelled: claim gnf4.serve.decode-glue-kernels (its own capability entry, decode-glue-kernels). The single-stream decode anchor, claim gnf4.serve.decode-anchor-5090, is a consumer-measured anchor of the serving class on a knob-off basis, not a measurement of this kernel, and is not cited as its evidence. Receipts: kernel/RESULTS-m3-default-on.md, kernel/RESULTS-k8-fp8-compute-attn.md, kernel/RESULTS-m2-anchor-recert.md.

Common wrong approaches

Source and freshness

This page is a rendering of docs/solutions/fp8-paged-attention-for-moe-serving.md at commit e2af4cfb91b2 (sha256 375b44c1e48a65fc…). 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