Skip to content

feat: add FP8 blockwise dequantize/GEMM operators and FP8(E4M3) KV cache support in paged attention - #1568

Open
shsaihdsaiudh wants to merge 11 commits into
InfiniTensor:mainfrom
shsaihdsaiudh:feat/fp8-blockwise-dequantize
Open

shsaihdsaiudh wants to merge 11 commits into
InfiniTensor:mainfrom
shsaihdsaiudh:feat/fp8-blockwise-dequantize

Conversation

@shsaihdsaiudh

@shsaihdsaiudh shsaihdsaiudh commented Sep 20, 2026

Copy link
Copy Markdown

Course submission: LLAISYS 2026 spring camp, project #4 (quantization and low-precision inference).
Downstream integration PR (InfiniLM): InfiniTensor/InfiniLM#586
Hardware used for all measurements: NVIDIA RTX 5090 (sm_120, 32 GB), driver 610.43.02; full builds verified with CUDA 12.8 and 13.3 toolchains.
Raw development artifacts referenced below (dev_fp8/...: weekly reports, bench logs, repro scripts) are kept on the downstream fork branch shsaihdsaiudh/InfiniLM@backup/fp8-lm-full-history, intentionally not part of the downstream PR.

Summary

  • New operator fp8_blockwise_dequantize: FP8(E4M3) weight [M,N] + F32 block scales [ceil(M/128), ceil(N/128)] -> F16/BF16/F32. Elementwise, no workspace. Backends: NVIDIA CUDA kernel + CPU reference. Files: include/infiniop/ops/fp8_blockwise_dequantize.h, src/infiniop/ops/fp8_blockwise_dequantize/, include/infinicore/ops/fp8_blockwise_dequantize.hpp, src/infinicore/ops/fp8_blockwise_dequantize/.
  • New operator fp8_blockwise_gemm: fused FP8 blockwise GEMM, out[m,n] = sum_k a[m,k] * e4m3_decode(q[n,k]) * scales[n/128, k/128], without materializing a BF16 weight matrix. Backends: NVIDIA + CPU reference. Three NVIDIA dispatch paths:
    • SIMT warp-per-row kernel, M_TILE in {1,2,4,8} template, for M <= 8 (decode/GEMV-shaped; FP32 accumulate, per-128-K-block scale application).
    • Tensor-core mma path (mma.m16n8k16.row.col, F16/BF16) for 9 <= M <= 32: 4 warps x n8 = N_TILE 32, M_BLOCKS in {1,2}, K pipelined in 128-chunks through double-buffered smem, per-chunk mma partials promoted with the block scale in FP32. E4M3 code bits are placed directly into BF16/F16 bit patterns (decode = true value x 2^-120 / 2^-8, folded into the scale), so mma inputs are bit-exact with no per-element multiply. INFINIOP_FP8_GEMM_MMA=0 forces SIMT.
    • SIMT M_TILE=16 fallback otherwise (F32, or M > 32).
    • Shape constraints (K % 128 == 0, block_k % 128 == 0, block_n % 16 == 0) are enforced in Fp8BlockwiseGemmInfo::create, so a bad shape fails at descriptor creation instead of silently misrouting.
  • FP8(E4M3) KV cache support in the three paged attention operators (paged_caching, paged_attention, paged_attention_prefill):
    • C API extended with trailing optional k_scale/v_scale descriptors and data pointers (NULL = absent; existing callers unchanged). info.h enforces: cache dtype F8 => scales required, F32, shape [num_blocks, num_kv_heads, block_size]; cache dtype != F8 => scales must be NULL. BF16/F16 paths are bit-identical to before.
    • Quantization semantics: per-token-per-kv-head dynamic scale, scale = amax(|x[0:head_dim]|) / 448 (amax == 0 => scale = 1); K stored post-RoPE; decode/encode reuse the existing portable infiniopFp8E4m3Encode/Decode.
    • paged_caching: new quantization write kernel (grid over (token, kv_head), per-block amax reduction -> scale write -> encode).
    • paged_attention (decode): new clean-room FP8 kernel family (cuda/kernel_fp8.cuh, nvidia/paged_attention_fp8.cu), CTA per (seq, q_head), 32 warps, dequant-on-load, log2-domain online softmax, hd 64/128. Includes split-kv flash-decoding: token dimension sharded across CTAs (grid z-dim, up to 8 splits), partials combined by a log2-domain combine kernel; default auto FA2-style waves heuristic (no split when the grid is already large). Env overrides: INFINIOP_FLASH_DECODE_SPLITKV=0/1/auto, INFINIOP_FLASH_NUM_SPLITS=1..8/auto, INFINIOP_FLASH_DEBUG_SPLITS=1.
    • paged_attention_prefill: gather-dequant of referenced pages to BF16 scratch (identity block_tables, scratch via workspace), reusing the existing BF16 prefill kernel unchanged.
  • Non-NVIDIA paged attention backends (ascend/bang/metax/moore): operator signatures updated to pass the KV scale parameters through; an F8 cache explicitly returns NOT_IMPLEMENTED on these backends.
  • Fixes:
    • Restore the int M_TILE template parameter on launch_mtile (build break when extracted to a function).
    • Pass CUDA gencode via cugencodes (not raw cuflags) so the device-link step inherits the arch — nvcc >= 13 otherwise device-links for sm_75 by default and produces binaries without SASS for the actual GPU (xmake/nvidia.lua).
    • Define paged-attention decode output for zero-length sequences (cuda/kernel_v2.cuh).
    • Route F8 caches to the infiniop fallback in paged_caching dispatch (paged_caching_infiniops.cc).
  • Build: allow NVIDIA builds with cudnn=n (exclude cuDNN-dependent avg_pool3d sources); add sm_120 to --cuda_arch.
  • Tests: new test/infiniop/fp8_blockwise_dequantize.py, test/infiniop/fp8_blockwise_gemm.py; FP8 cases added to test/infiniop/paged_attention.py, paged_attention_prefill.py, paged_caching.py, paged_caching_prefill.py; ctypes signatures registered in test/infiniop/libinfiniop/op_register.py.

Motivation

  • The library had no 128x128 block-scale FP8 semantics on the weight side: quant/dequant are INT8-only, scaled_mm is INT8 (per-row x per-col F32 scale), and the Marlin port's FP8 support (kFE4M3fn) is per-tensor/per-channel scale — and the Marlin kernel itself is broken on sm_120 for all bit widths (deadlock under CUDA 12.8, wrong output under CUDA 13.2; documented with minimal repro in the downstream repo's dev_fp8/marlin_sm120_issue.md).
  • FP8(E4M3, 128x128 block) is the release format of DeepSeek-V3 main weights and the Qwen3 FP8 series (weight_block_size: [128,128]), and is the target format of the downstream InfiniLM integration (feat: add FP8 blockwise weight quantization and FP8(E4M3) KV cache support InfiniLM#586).
  • KV cache had INT8 static quantization only; FP8 KV halves both cache capacity and decode attention read bandwidth vs BF16.

Tests and reproduction

Operator tests (all run on RTX 5090, sm_120, --nvidia; torch CPU/numpy references):

Test Cases Result
fp8_blockwise_dequantize.py 5 shapes x {F16, BF16, F32} = 15 (incl. block 64x128 / 128x64) PASS
fp8_blockwise_gemm.py 7 shapes x {F16, BF16, F32} = 21 (incl. M=13/32, K tail block) PASS, run twice with INFINIOP_FP8_GEMM_MMA=1/0 (mma path and SIMT A/B both green)
paged_caching.py (FP8) 6 shapes x {F16, BF16} sources (incl. hd 576/512) PASS — bitwise equal to torch CPU RNE reference, scale maxdiff = 0
paged_attention.py 28 cases total; FP8 decode: 4 shapes x {F16, BF16} (incl. alibi, GQA 40/64 heads); added (1, 8, 8, 128, 16, 4096, alibi=True) triggers num_splits=8 on GPU, large-grid cases correctly stay at num_splits=1 PASS
paged_attention_prefill.py (FP8) 4 shapes x {F16, BF16} x {I32, I64} indices, multi-round incremental PASS
paged_caching_prefill.py regression (signature pass-through) PASS

Reproduce:

# Build (scripts referenced below live in the downstream repo's dev_fp8/results/:
# fp8_build_core.sh, fp8_env.sh)
xmake f -c --nv-gpu=y --cpu=y --cuda_arch=sm_120 -k shared
xmake -y && xmake install
export INFINI_ROOT=<install prefix> LD_LIBRARY_PATH=<install prefix>/lib

cd test/infiniop
python3 fp8_blockwise_dequantize.py --nvidia
python3 fp8_blockwise_gemm.py --nvidia
INFINIOP_FP8_GEMM_MMA=0 python3 fp8_blockwise_gemm.py --nvidia   # SIMT A/B round
python3 paged_caching.py --nvidia
python3 paged_attention.py --nvidia                              # FP8 + split-kv cases
INFINIOP_FLASH_DEBUG_SPLITS=1 python3 paged_attention.py --nvidia # prints split decisions
python3 paged_attention_prefill.py --nvidia
python3 paged_caching_prefill.py --nvidia

Additional pre-GPU verification artifacts (in downstream repo dev_fp8/results/): fp8_mma_sim.py (bit-trick exact over all 256 non-NaN E4M3 codes, BF16/F16; 10 simulated GEMM cases vs float64 reference, ALL PASS) and fp8_splitkv_sim.py (split/combine vs direct softmax, rel err <= 1e-6 over 300 random cases incl. empty shards). Both were later confirmed on GPU by the operator tests above.

Results

Operator-level performance (NVIDIA, RTX 5090):

  • fp8_blockwise_gemm SIMT kernel: 1317 GB/s at 4096x4096 — 88% of the 1505 GB/s pure-stream ceiling measured on the same card (HBM-resident rotation benchmark; earlier L2-contaminated microbenchmarks were discarded, see downstream dev_fp8/w4_fused_gemm_report.md).

End-to-end results via the downstream InfiniLM integration (InfiniTensor/InfiniLM#586), Qwen3-8B-FP8, examples/bench.py, paged attention:

Metric Before After (this PR's ops)
decode bs=1 (in 128/out 256) 28.49 tok/s (naive dequant + cuBLAS) 142.96 tok/s = 5.02x naive, 1.55x BF16 (92.34); 91% of vLLM FP8 (157.02)
decode bs=16 427.0 tok/s (naive) 1794.6 tok/s (mma) = 4.20x naive, 4.73x SIMT fused (379.7)
decode bs=32 817.6 tok/s (naive) 2702.0 tok/s (mma) = 3.30x naive
FP8 KV decode ITL, bs=1 in=4k / 16k 1.14x / 1.27x vs BF16 KV (no split-kv) 0.77x / 0.58x vs BF16 KV with split-kv auto (11.93 ms vs 20.58 ms at 16k) — faster than BF16
KV cache capacity BF16: 147456 B/token (Qwen3-8B) FP8: 73728 B + 2304 B scale/token — ~1.94x compression

E2E correctness (downstream): fused-vs-naive greedy generation token-identical (3 prompts x 64 tokens); FP8 KV vs BF16 KV greedy token-identical (4020-token prompt + 48 tokens, split-kv active).

Platform support status

Platform Status
NVIDIA (sm_120, RTX 5090) Fully implemented and verified; all operator tests pass; E2E numbers above. Builds verified with CUDA 12.8 and 13.3. Older NVIDIA archs: kernels are portable CUDA but only sm_120 was tested.
CPU Reference implementations for fp8_blockwise_dequantize and fp8_blockwise_gemm included; a standalone --cpu backend run was not performed (CPU code serves as the reference/fallback path).
ascend / bang / metax / moore Paged operator signatures updated to pass k_scale/v_scale through; F8 cache explicitly returns NOT_IMPLEMENTED. Compile-level change only — no hardware access, untested. FP8 weight/KV features remain NVIDIA-only.
Other vendors Untouched.

Notes

  • The C API extension is additive: trailing optional parameters, existing callers compile and run unchanged; BF16/F16 attention paths are bit-identical.
  • Branch state: linear, 11 commits, rebased onto main@9855c0c6 (2026-08-25, the tip at development time). Per course instruction the branch is intentionally not rebased across the recent operator-layer refactor on main; happy to rebase if maintainers prefer.
  • Known limits (FP8 KV): no cross-backend support beyond NVIDIA; KV connector (PD disaggregation) does not transfer scales, so that combination is unavailable; prefill gather-dequant adds a one-time scratch bandwidth cost (+0.5%~+3.0% TTFT downstream).
  • The bs=8/in=1k split-kv cell over-splits by ~3.8% (shared makespan heuristic assumes 1 CTA/SM; the 1024-thread FP8 CTA co-resides 2/SM). Kept as-is to avoid forking the heuristic shared with the BF16 family; INFINIOP_FLASH_DECODE_SPLITKV=0 avoids it.
  • CI: runs on this PR per repo configuration; all operator tests above pass locally on RTX 5090.

shsaihdsaiudh and others added 11 commits September 20, 2026 15:21
Elementwise dequantization for block-quantized FP8 (E4M3) weights:
out[i,j] = fp8_e4m3_decode(q[i,j]) * scales[i/BM, j/BN], with the
block shape (BM, BN) inferred from tensor shapes (e.g. 128x128 for
DeepSeek/Qwen3-FP8 checkpoints). q is INFINI_DTYPE_F8, scales F32,
output F16/BF16/F32; no workspace.

Backends: cpu (portable bit-manipulation decode) and nvidia (reuses
infiniopFp8E4m3Decode). infinicore C++ op layer included for InfiniLM
consumption. infiniop tests cover 5 block shapes x 3 output dtypes,
passing on cpu and nvidia (sm_89, sm_120).
- xmake/nvidia.lua: exclude avg_pool3d nvidia .cu when cudnn=n (its
  implementation requires cuDNN; mixing PyTorch's bundled cuDNN with the
  system CUDA runtime breaks the FA-enabled build)
- avg_pool3d/operator.cc: gate NVIDIA/QY branches on ENABLE_CUDNN_API
- xmake.lua: add sm_120 to cuda_arch values (RTX 5060 Ti)
out[m,n] = sum_k a[m,k] * fp8_e4m3_decode(q[n,k]) * scales[n/BN, k/BK],
computed without materializing the dequantized weight (decode-oriented;
InfiniLM routes M<=8 here). CPU reference backend + full infiniop /
infinicore / ctypes surface.

nvidia kernel: warp-per-row GEMV (4 warps/block, grid N/4), each lane
holds 16B of FP8 with next-group register prefetch, per-128-chunk scale
application, FP32 accumulate with warp shuffle reduce; M_TILE in
{1,2,4,8,16} templates. test/infiniop/fp8_blockwise_gemm.py passes on
sm_120 for 7 shapes x F16/BF16/F32 (incl. block 64x128 / 128x256 and
K-tail cases).

Measured on RTX 5090 with Qwen3-8B-FP8 in InfiniLM: decode bs=1 goes
142.96 tok/s vs 92.34 for the BF16 baseline (1.55x) and 28.49 for the
naive dequantize+GEMM path (5.02x); op-level streaming bandwidth
1338-1731 GB/s on the model shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ends

Extend the ascend/bang/metax/moore descriptor create/calculate
signatures of paged_caching, paged_attention and
paged_attention_prefill with the k_scale/v_scale parameters added
for FP8(E4M3) KV caches, forwarding the descriptors to the shared
info validation. These backends do not implement the FP8 path, so
create explicitly returns INFINI_STATUS_NOT_IMPLEMENTED for F8
caches instead of silently misinterpreting the data.
The FP8 KV decode kernel assigns one CTA per (sequence, q_head), so at
bs=1 only 32 CTAs (Qwen3-8B) occupy ~19% of the SMs and each CTA scans
the whole token range serially; the FP8/BF16 overhead grows with
context length (1.05x @ 1k -> 1.27x @ 16k). Shard the token dimension
across up to 8 CTAs per (seq, head), each emitting a partial
(max, sum_exp, weighted sum) triple, and merge across CTAs in a second
online-softmax reduction kernel (log2-domain merge, standard
flash-decoding).

- kernel_fp8.cuh: partial_acc/m/l outputs + num_splits, token shard
  windows, -inf guard for empty shards, split-kv combine kernel
- paged_attention_fp8.cu: two-stage launch, auto heuristic,
  INFINIOP_FLASH_DECODE_SPLITKV/NUM_SPLITS/DEBUG_SPLITS env overrides
- paged_attention_nvidia.cu: FP8 workspace 0 -> splits*seqs*heads*
  (value_size+2)*4B
- test: (1,8,8,128,16,4096,True) alibi+split case

Validated with an exact Python simulator only (dev_fp8/results/
fp8_splitkv_sim.py, ALL PASS); GPU build/bench pending (server down).
See dev_fp8/w7_kv_fp8_splitkv_report.md in the InfiniLM repo.
The SIMT warp-per-row kernel re-reads each activation row per weight
element and goes instruction-throughput bound once M grows (W4: fused
loses to dequant+cuBLAS from M >= 16). Add an mma.m16n8k16.row.col
kernel for the decode range 9 <= M <= 32 with F16/BF16 activations:

- CTA = 4 warps x n8 (N_TILE 32), M_BLOCKS in {1, 2} covering M <= 32
- K streamed in 128-wide chunks (exactly one scale sub-chunk; the shape
  constraints are already enforced by Fp8BlockwiseGemmInfo), global ->
  register staging -> double-buffered smem, one __syncthreads per chunk
- per-chunk mma partials promoted with the block scale into an FP32
  accumulator (c_fin = fmaf(s, c_part, c_fin)), same numeric structure
  as the SIMT path
- FP8 e4m3 decoded in registers with a bit-placement trick giving the
  true value times 2^-120 (BF16) / 2^-8 (F16); the power-of-two factor
  folds into the promote scale, so mma inputs are bit-exact with no
  per-element multiply
- dispatch: F16/BF16 and 9 <= M <= 32 -> mma; INFINIOP_FP8_GEMM_MMA=0
  forces the SIMT kernels (A/B); F32 stays on SIMT M_TILE

Validated with an exact Python simulator (10 cases: M tails, N tails,
block_n/block_k variants, M_BLOCKS 1/2; ALL PASS) plus static review of
fragment layouts against the PTX ISA; GPU build/bench pending (server
down). See dev_fp8/w8_mma_report.md in the InfiniLM repo.
The W8 mma commit extracted the SIMT dispatch into launch_mtile but
dropped its int M_TILE template parameter (all call sites already pass
two arguments), breaking the GPU build with 'identifier M_TILE is
undefined'. Found during the first server build of the mma path.
The device-link step of -rdc=true targets did not see the -gencode flags
that were only added to cuflags; nvcc >= 13 device-links for sm_75 by
default in that case, producing binaries without SASS for the actual GPU
(every kernel launch fails with cudaErrorNoKernelImageForDevice).

- apply_cuda_arch_flags() now registers cugencodes (compile + devlink)
- fix the nvidia-smi auto-detect: os.iorunv returns (stdout, stderr);
  the version match ran against stderr and always fell back to native.
  Auto-detect now builds for the detected GPU's own arch (sm_90 -> 90a)
- infinirt-nvidia gains the same on_load arch handling
- drop the unused boost add_requires (nothing in src/ references it)
The decode kernels returned early for seq_len == 0 without writing
anything: the direct path left the caller's output buffer untouched, and
the split-kv path never published partials, so the combine kernel merged
stale workspace from previous calls (reproduced on sm_120: the len-0 row
came back bit-identical to the previous call's row 0).

- direct kernels (v1, v2 warp/CTA/pipelined/GQA, MLA, FP8) now write
  zeros for len-0 rows
- split kernels (v2 warp/CTA, MLA, FP8) publish the neutral element
  (m=-inf, l=0, acc=0) so combine emits a zero row
- v2/MLA combine kernels guard exp2f(ms - m_total) against ms=-inf so an
  all-empty row cannot produce NaN
The flash path only fell back when scale tensors were present; an F8
cache without scales slipped into a path that cannot quantize on write
and now fails loudly (Bad Parameter) instead of silently corrupting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant