feat(VT-QUANT-FP8-GROUP): add vt::QuantFp8Group, the dynamic per-token per-group fp8 activation quant, CPU and CUDA (#1189) - #1212
Merged
Conversation
…wice, and only a byte comparison can see it (#1189) Block-wise FP8 needs an activation quantizer before it needs anything else, and #1189 splits it out as milestone M1. This commit is that milestone's spec, committed before its implementation. The design pass that matters is which upstream kernel to mirror. `per_token_group_quant_fp8` looks like a Triton kernel with a C++ fast path, and it is the other way round: `vllm/model_executor/layers/quantization/utils/fp8_utils.py:635-650` calls `torch.ops._C.per_token_group_fp8_quant` and returns whenever the platform is CUDA-alike and the input is contiguous, which is every case this row cares about. The Triton kernel below it never runs there. The two arms are not interchangeable. The C++ kernel divides, at `csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu:68` for the scale and `:85` for the value. The Triton kernel multiplies by `(1.0 / fp8_max)` at `fp8_utils.py:145`, under a comment that names the 1-ULP difference this produces. One f32 ULP before an e4m3 round changes the emitted byte near a tie, and upstream's own test cannot see the difference: it compares values at `rtol=0.15` (`tests/kernels/quantization/test_block_fp8.py:112-114`). So the spec declares a bitwise gate against an independently written reference on top of the ported test, because the ported test alone would pass either arm. The spec also records what M1 does not do. It lands unreached, M4 owns the wiring, and the CUDA arm compiles without running because the row takes no GPU lease. Both are listed under `## Owed`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…n per-group fp8 activation quant, CPU and CUDA (#1189) Block-wise FP8 needs an activation quantizer, and this is it: milestone M1 of #1189. `vt::QuantFp8Group` takes x [M,K] f32 or bf16 and emits the fp8 bytes plus an f32 [M, K/group_size] scale, one scale per contiguous run of `group_size` elements inside a row. WHICH UPSTREAM ARM THIS MIRRORS, because there are two and they disagree. `per_token_group_quant_fp8` reads like a Triton kernel with a C++ fast path and it is the other way round: on a CUDA-alike platform with a contiguous input it calls `torch.ops._C.per_token_group_fp8_quant` and returns (fp8_utils.py:635-650), so the Triton kernel never executes there. The executing kernel is csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu, and it divides twice: `local_absmax / max_8bit` at :68 and `static_cast<float>(src) / y_s` at :85. The Triton fallback instead forms `_absmax * (1.0 / fp8_max)` at fp8_utils.py:145, under an upstream comment that names the 1-ULP difference this opens. This is the opposite polarity from vt::QuantFp8Static, which multiplies by a hoisted reciprocal because that IS upstream's shipped form for the static per-tensor path. Both kernels carry the reason beside the code, because each looks like a defect from the other's point of view. `eps` seeds the reduction rather than clamping it afterwards (per_token_group_quant.cu:47). The two are numerically identical and upstream's form makes it visible that an all-zero group yields 1e-10/448 instead of dividing by zero. WHY THE PORTED TEST IS NOT ENOUGH, measured rather than argued. G2 ports upstream's case with its full grid and upstream's tolerances: values at rtol=0.15, the scale at torch.allclose's rtol=1e-5 (test_block_fp8.py:112-115). Mutating this kernel to the Triton arm's scale form makes G2 fail 6 of its 48 shape checks -- every one of them at num_tokens=2050, none at num_tokens=7, and never the scale check, since a 1-ULP scale difference is about 6e-8 relative. Mutating it to the Triton arm's value form makes G2 pass 50 of 50. So upstream's tolerances catch one of the two forms, on the large shapes, by luck of which element lands on an e4m3 boundary, and miss the other entirely. G1 catches both on every shape because it compares BYTES, against a reference derived from the e4m3fn format by exhaustive nearest-value scan -- a different algorithm from this tree's F32ToFp8 rather than a restatement of it. The CUDA arm gives one thread the whole group instead of upstream's 16-lane shuffle reduction. That cannot change the result: fmaxf is exact and order-independent over finite inputs, unlike a floating-point sum. A lane-parallel rewrite is a performance question for M5, which needs a GPU to measure. `scripts/check-cuda-op-arch-gate.py` now pins kQuantFp8Group as well. The kernel is a max, two divides and a hardware convert, with no cutlass dependency, so a CUDA queue must never fall through to the host reference tier and dereference device pointers -- the defect #960 and #844 record. Its miniature fixture gains the matching registration, because the fixture describes the real TU and the real TU now has two. NOTHING REACHES THIS OP YET, and that is deliberate. No production entry point dispatches vt::QuantFp8Group at this commit: include/vllm.h does not expose it, no loader builds an Fp8BlockWeight, and ModelRegistry::Forward has no block-FP8 linear method to call it from. The wiring is owned by #1189 milestone M4, which needs M2's GEMM and M3's loader rung first. This is the staged-slice exception of .agents/reachability.md and the row's spec lists it under `## Owed`, together with the CUDA arm's on-hardware leg. That kernel compiles in the cuda-fat-build CI job, which builds the vllm target with VLLM_CPP_CUDA=ON for ten architectures on every pull request, and it executes nowhere: no lane runs a CUDA kernel and this host has neither a toolkit nor a device. G6 prints a PENDING banner naming what was not measured instead of skipping silently. The run belongs to M5, which needs a GPU regardless. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
origin/main advanced while this row was in flight. Merging before the push makes both diff-scoped preflight range blocks execute against the base the branch will actually land on, and `git merge --no-edit` writes a subject with no body and no trailers, so this commit is amended to carry them. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
FOLLOWING_AGENTS_PROTOCOL Brings the branch up to `origin/main` so the committed-range and trailer gates examine this tree rather than skipping. A skipped gate reports nothing about the tree, and the exit status alone cannot carry that. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Block-wise FP8 needs an activation quantizer before it needs anything else, and
this is it: milestone M1 of #1189, the only one of the six that is gateable
without a GPU.
vt::QuantFp8Grouptakesx [M,K]f32 or bf16 and emits thefp8-e4m3fn bytes plus an f32
[M, K/group_size]scale, one scale per contiguousrun of
group_sizeelements inside a row. It refusesK % group_size != 0byname.
Spec:
.agents/specs/vt-quant-fp8-group.md,committed before the implementation. Pinned oracle: vLLM
5559679229bc961848b121ccdeaa8fa5d79bec98, verified as the local checkout's HEADbefore any anchor below was read.
Which upstream arm this mirrors
per_token_group_quant_fp8reads like a Triton kernel with a C++ fast path andit is the other way round. On a CUDA-alike platform with a contiguous input it
calls
torch.ops._C.per_token_group_fp8_quantand returns(
fp8_utils.py:635-650), so the Triton kernel below it never executes there. Theexecuting kernel is
csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu:Two divides. The Triton fallback instead forms
_absmax * (1.0 / fp8_max)(
fp8_utils.py:145) under an upstream comment that names the 1-ULP difference itopens. This is the opposite polarity from
vt::QuantFp8Static, which multipliesby a hoisted reciprocal because that is upstream's shipped form for the static
per-tensor path (
common.cuh:62, with the inverse formed by the caller atcommon.cu:31). Both kernels now carry the reason beside the code, because eachlooks like a defect from the other's point of view.
epsseeds the reduction rather than clamping it afterwards. The two arenumerically identical, and upstream's form makes it visible that an all-zero
group yields
1e-10/448instead of dividing by zero.Why the ported test is not enough, measured rather than argued
G2 ports
tests/kernels/quantization/test_block_fp8.py:82-118with its grid(
num_tokensin {7, 2050},din {512, 4096, 5120, 13824},group_sizein{64, 128, 512}, seed 0) and upstream's tolerances: values at
rtol=0.15, thescale at
torch.allclose'srtol=1e-5. Mutating the CPU kernel to each of theTriton arm's two forms, both built with
compile_rc=0:y_s = amax * (1/448)num_tokens=2050, all the value check, never the scale checkx * (1/y_s)So upstream's tolerances catch one of the two forms, on the large shapes only,
by luck of which element lands on an e4m3 boundary, and miss the other entirely.
G1 catches both on every shape because it compares bytes, against a reference
derived from the e4m3fn format by exhaustive nearest-value scan. That is a
different algorithm from this tree's
F32ToFp8rather than a restatement of it,so agreement is evidence.
Evidence
RED, before the implementation existed: the focused build failed with
'QuantFp8Group' is not a member of 'vt'and'kQuantFp8Group' is not a member of 'vt::OpId'at 12 sites.GREEN:
test_ops_quant_fp8_group_cpureports 6 cases, 476 assertions, 0failed.
ctest -R "test_ops_quant_fp8_group_cpu|test_op_provider|test_ops_fp8_cpu"reports 3/3 passed.
Full preflight (
scripts/agent-preflight.sh --fail-on-skip) at the pushedhead: 27 record gates
ok, 45 mutation suitesokand 1FAIL, 3committed-range gates
ok, 2 commit-trailer gatesok, noSKIP— 78okin total. Read the verdict line, not the exit status: this script exits 0 on a
SKIP, which is why--fail-on-skipis on.The one
FAIListest_cpu_x86_llamacpp_floor, the known load flake of #618,and it failed with that issue's exact signature rather than a novel one:
NO_QUIET_WINDOW after 30s (busy=111% builders=0 load=102.07 81.60 61.68),exit 4 instead of 0. The box was carrying a load average of 94 from other
sessions at that moment. Re-run serially once the load fell to 32: 10 tests,
OK, 200.7s. Nothing in this diff is read by that harness — it touches no
llama.cpp path, no benchmark record and no published figure.
Mutation evidence, ten runs. Every run prints
git diff --statandcompile_rc, because a mutation that never applied and a mutation that failed tobuild both read as a passing test. Two of the ten did exactly that and are
reported rather than dropped: deleting the eps seed orphans
kEpsand deletingthe CPU registration orphans the kernel, so both die on
-Werrorand provenothing until re-run in a form that keeps the symbol live. Three results are
worth keeping beyond the table above:
the dispatcher's validation and fire before dispatch, so a refusal test cannot
stand in for a registration test. Only G1's
REQUIRE(OpRegistered(...))caughtit;
which is why the all-zero row is its own case;
The full table is in the spec under
## Evidence.What is owed, named rather than implied
Nothing reaches this op yet, and that is deliberate. No production entry point
dispatches
vt::QuantFp8Groupat this merge commit:include/vllm.hdoes notexpose it, no loader builds an
Fp8BlockWeight, andModelRegistry::Forwardhasno block-FP8 linear method to call it from. The wiring is owned by #1189
milestone M4 (
Fp8BlockLinearMethodand the Qwen3.5 dense forward), which needsM2's GEMM and M3's loader rung first. This is the staged-slice exception of
.agents/reachability.md; the row's spec lists itunder
## Owed.The CUDA arm's on-hardware leg is owed, and it is worth saying exactly where
the line falls. The implementing host has no CUDA toolkit, so this row did not
compile the kernel locally; the
cuda-fat-buildjob(
.github/workflows/ci.yml:669-710) does, building thevllmtarget with-DVLLM_CPP_CUDA=ONfor ten architectures on every pull request, andcuda_quant_fp8.cuis in the unconditional CUDA source list. So the compile legis gated — by this pull request's own CI, not by a measurement taken before it
opened. Nothing runs the kernel: that job states it uses no GPU and no other
lane executes a CUDA kernel. G6 prints a
PENDINGbanner naming what was notmeasured instead of skipping silently, and the run belongs to M5.
Also owed and listed in the spec: the column-major and TMA-aligned scale layouts
(
fp8_utils.py:610-628), owed to M5, which is their first consumer; anduse_ue8m0scale rounding, which the target architecture never selects becauseupstream excludes
qwen3_5_textfrom DeepGEMM on family 120(
vllm/utils/deep_gemm.py:27-46).Also in this change
scripts/check-cuda-op-arch-gate.pynow pinskQuantFp8GroupbesidekQuantFp8Static. The new kernel has the same property and the same consequenceif it moves: it is a max, two divides and a hardware convert, with no cutlass
dependency, so a CUDA queue must never fall through to the host reference tier
and dereference device pointers — the defect #960 and #844 record. Its miniature
fixture gains the matching registration, because the fixture describes the real
translation unit and the real one now has two. That change was red first: adding
the
REQUIREDentry alone madetest_baseline_miniature_is_greenfail withexpected exactly ONE live RegisterOp(OpId::kQuantFp8Group, ...) found 0, whichis the checker proving the new entry is live.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5-1m [claude-code]