build(cuda): enable marlin-nvfp4 for sm_110 (Jetson Thor) - 8.0x-29.0x faster NVFP4 W4A16 decode - #326
build(cuda): enable marlin-nvfp4 for sm_110 (Jetson Thor) - 8.0x-29.0x faster NVFP4 W4A16 decode#326filipsajdak wants to merge 3 commits into
Conversation
18ade56 to
32569d2
Compare
|
Force-pushed to Two process points I'd rather raise than quietly get wrong:
Also, for the record on something I asserted earlier in this PR: I referenced a |
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
@filipsajdak The on-device evidence is strong, but this cannot merge with the canonical record knowingly stale. Please update BACKEND-CUDA-SM110 in .agents/backend-matrix.md to record the kernel-level runtime/numerical evidence without claiming model-level parity, and fix the sm_12xa-only CMake status string you called out. The AGENTS protocol requires the owning matrix to move in the same change as capability support.
The vendored dense Marlin NVFP4 W4A16 GEMM is already in the tree and fully host-wired behind VT_MARLIN_NVFP4, but the marlin-nvfp4 FEATURE TABLE cell lists only 12.0a,12.1a, so it compiles out for sm_110. Every decode GEMM then takes the m < kTileMinRows early return into MatmulNvfp4KernelNaive, a CUDA-core kernel whose cost is strictly linear in the batch dimension - which is the root cause of the flat-with- concurrency throughput reported in mudler#168. No kernel source is changed. All 18 __CUDA_ARCH__ guards under src/vt/cuda/marlin/ were enumerated: for 1100 each selects the same side an already-supported architecture selects. In particular sm_110 does NOT satisfy __CUDA_ARCH__ < 890 (it takes the >= 890 side, as sm_90/sm_100/ sm_12x do); those two sites are bare fp8-activation bail-outs with no #else, and this path pins a_type = kBFloat16 so the predicate is false at compile time on every arch. Measured on real sm_110 silicon, M=1, Qwen3-32B shapes: q_proj 0.9461 ms -> 0.0829 ms 11.4x kv_proj 0.5009 ms -> 0.0173 ms 29.0x o_proj 0.8093 ms -> 0.0794 ms 10.2x gate_proj 2.3675 ms -> 0.2943 ms 8.0x down_proj 2.6945 ms -> 0.2870 ms 9.4x Numerics vs the existing path over 20 configurations / 631,424 elements with random weights, random per-group fp8 scales and random activations: 99.84%-100% of elements bit-for-bit equal, remainder within one to a few bf16 ULP, correlation 1.00000000, zero elements above 5% relative error. Not bitwise identical, and not claimed as such. A negative control that feeds Marlin unpermuted scales is detected on every element (corr 0.00000000), so the passing comparison is meaningful. Scope: sm_110 only. The 8.0+PTX leg stays unclaimed - sm_8x has not been built or run. No dispatch policy changes; kTileMinRows is untouched, this only makes the kernel available. Refs mudler#325, mudler#168 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
Per review: the owning matrix must move in the same change as the capability, and the CMake status string named one architecture when the arch set is data. backend-matrix.md, BACKEND-CUDA-SM110: - "all five fast-path cells resolve EMPTY" -> four EMPTY, marlin-nvfp4 ENABLED for [110]; the summary column no longer says fast paths are unported without qualification. - Adds the evidence: the 18-guard enumeration (including that sm_110 does NOT satisfy __CUDA_ARCH__ < 890 and takes the >= 890 side), the build, the on-silicon run, and the numerics against the incumbent path - 631,424 elements, 99.84%-100% bit-for-bit equal, 0 elements above 5% relative error, plus the negative control that detects an unpermuted scale layout on every element. - Labels it KERNEL-RUNTIME-VERIFIED (numerics-gated) and states in both the SIGNAL and the evidence block that this is explicitly NOT model-level: no vLLM-oracle token-exact gate was run against the Marlin build, so CLAIM-CUDA-SM110-RUNTIME is unchanged and still met only by the portable bf16 path. - Records what is not claimed: no 32B end-to-end profile, 8.0+PTX still unclaimed, no dispatch policy change. The end-to-end serving numbers (16.61 -> 81.63 tok/s at c=1, 33.30 -> 477.07 at c=8 on Qwen3-1.7B-NVFP4A16, two binaries differing only in the CMake cell) are recorded as THROUGHPUT evidence and explicitly not as a correctness gate. CMakeLists.txt: the VLLM_CPP_MARLIN option help and both STATUS strings said "sm_12xa" while the arch set comes from the marlin-nvfp4 cell. They now report the resolved archs, so the wording cannot go stale again when the cell changes. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
32569d2 to
b0dbe6e
Compare
|
Both addressed in
CMake status string: Rebased onto One defect found while writing the row, which I'll file separately rather than smuggle in here: the default |
Self-correction. The five measured per-GEMM speedups at M=1 are 11.41x (q_proj), 28.95x (kv_proj), 10.19x (o_proj), 8.04x (gate_proj) and 9.39x (down_proj). The maximum is 28.95x, so "8-31x" overstated the top of the range by about 7%. No measurement changed - the ratios were always recoverable from the ms/call table in this row and in mudler#325; the summary figure was simply wrong. Corrected here, in the PR title and in mudler#325. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
|
Landed on main (as one commit authored to you — the branch's three commits were squashed because The evidence in this PR is the reason it went in without a board here: the guard enumeration, the random-weights-and-random-scales numerics comparison, and especially the negative control feeding raw unpermuted scales and catching it on every element ( Three things the cell change also owed, added on landing:
None of that is a criticism of the measurement — it is the paperwork a capability change drags behind it, and it is easy to miss because the failing gates live nowhere near Reviewed and landed with Claude Code. |
|
Thanks for landing this, and for the three things you added on the way in — the One flag, and I may simply be looking too early: I cannot see the commit on I understand the landing style from #283/#168 — squashed onto
So either it is staged and not yet pushed, or it went in and came back out, or I am racing you. Any of those is fine — I am flagging it only because of the direction of the inconsistency if it stalls here: Happy to help with whatever is easiest: re-push the branch rebased onto current (Reported from the repository state, not from the PR UI — I have not re-run the build or the tests against current |
The `marlin-nvfp4` FEATURE-TABLE cell gains `11.0`. No kernel source changes:
all 18 __CUDA_ARCH__ guards under src/vt/cuda/marlin/ select, for 1100, the
same side an already-supported arch selects, and both `< 890` sites are
fp8-activation bail-outs unreachable on this bf16-activation path.
Validated on real sm_110 silicon (Thor): 403/403 TUs build, ran across 5
decode shapes x M in {1,2,4,8} with no faults, and numerics vs the incumbent
vt::MatmulNvfp4 over 20 configurations / 631,424 elements are 99.84%-100%
bit-equal with the remainder inside a bf16 ULP, corr 1.00000000. The negative
control (raw unpermuted scales) is caught on every element, so the comparison
is not vacuous. 8.0x-29.0x per GEMM at M=1; e2e 16.61 -> 81.63 tok/s at c=1 on
Qwen3-1.7B-NVFP4A16.
NOT a token-exact model-level gate: no vLLM-oracle golden ran against the
Marlin build, so CLAIM-CUDA-SM110-RUNTIME is unchanged. `8.0+PTX` stays
unclaimed — sm_8x has never been built or run.
The branch's three commits land as one, with the two gates and the two public
documents the change owes, because check-doc-checkpoint is a per-commit
obligation and the record commits moved CMakeLists.txt away from their doc:
- cmake/CudaArchFeaturesTest.cmake asserted every non-FA2 feature EMPTY for
`110` in the cross-family fan-out, so `cmake -P` (the cuda-arch-features CI
job, ci.yml:330) went RED. The loop now excludes marlin-nvfp4 for 110 only,
and the enablement gets explicit expectations: 110 -> [110], the sm_12x
production resolutions proven byte-unchanged (121a/120a/fat 120a;121a), and
103a/100a/80 pinned EMPTY so `11.0` cannot be read as a major-11 family
claim.
- the release-manifest golden lists compiled SMs per feature; marlin-nvfp4
gains "110" there. The manifest is a projection of the feature table, so
this is a regeneration, not a widening.
- docs/FEATURES.md said sm_110 "is a correctness venue only: CUTLASS has no
FP4 tensor-core kernels for it". The CUTLASS half stays true and `fp4-mma`
is still off, but Marlin is not CUTLASS, and the row now carries the
measured result plus the explicit note that it is kernel-level, not a
token-exact model gate.
- docs/USAGE.md gains the general point: which fast paths an architecture
compiles is decided by the CUDA feature table, not the arch string, and
`cmake -P cmake/CudaArchFeaturesTest.cmake` prints that resolution with no
GPU and no CUDA toolkit.
Mutation-checked: reverting the cell to `12.0a,12.1a` fails the cmake suite
(1 mismatch, exit 1).
cmake -P cmake/CudaArchFeaturesTest.cmake ALL PASS (RED before)
python3 tests/scripts/test_release_manifest.py 21/21 (1 FAIL before)
Refs #326, #325.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [ClaudeCode]
|
Landed on main as Closing manually rather than by merge because the branch's commits were squashed into one, so the head SHA here never becomes reachable from main and GitHub cannot auto-close it. The squash was forced by The commit message records what changed on landing and why. Nothing was dropped. |
The change
One cell in the CUDA FEATURE TABLE. No kernel source is modified.
Configure then reports, on
-DVLLM_CPP_CUDA_ARCHITECTURES=110:(The trailing
sm_12xain that STATUS string is now stale and should probably be reworded;happy to fold that in.)
Why no kernel change is needed — the arch guards, stated precisely
I enumerated all 18
__CUDA_ARCH__guards undersrc/vt/cuda/marlin/. For sm_110,__CUDA_ARCH__ == 1100. Which side each guard selects:< 750< 800cp_async*_ca_predfallback (synchronous copies emulatingcp.async)cp.async)>= 750lop3LUT-based fast 4-bit dequant>= 800num2float,num2num2fornv_bfloat16)== 750use_fp16_accum< 890>= 890sideThe two blocks sm_110 is excluded from (
< 750,< 800) are pre-Turing / pre-Amperefallbacks — exclusion is exactly what should happen. The two it is included in (
>= 750,>= 800) are the fastlop3dequant and the bf16 conversion helpers, both of which this pathneeds.
To be explicit, because it is the one that matters: sm_110 does NOT satisfy
< 890(1100 < 890 is false). It takes the other side.
What the two
< 890blocks contain —quantization/marlin/marlin_template.h:283andmoe/marlin_moe_wna16/marlin_template.h:296, identical:Three points:
#else. Both are bare early-return bail-outs, so there is no alternativebody for sm_110 to fall into. Being excluded from the block simply means "do not bail".
a_type = vllm::kBFloat16(
cuda_marlin_dense.cu:100), soa_type_id == kFE4M3fn.id()is false at compile time onevery architecture — the block is a no-op for the W4A16 bf16 instantiation regardless of
arch.
exercised in production.
The only guard in the set with a real
#elseis the== 750/use_fp16_accumpair(
marlin_template.h:288); sm_110 takes the#else(use_fp16_accum = false), the same sideas sm_80 / sm_90 / sm_120.
So: every branch sm_110 selects is a branch some already-supported architecture also selects.
No new code path is entered. That reasoning is the argument; the build + numerics below are
the evidence.
Evidence
1. Builds
Clean for sm_110, 403/403, rc=0, including
marlin/libtorch_stable/quantization/marlin/sm80_kernel_bfloat16_fe2m1f_bfloat16.cu,marlin_mm_dense.cu,cuda_marlin_dense.cu, and the MoE equivalents.2. Runs
No illegal-instruction or misaligned-address faults across 5 shapes x M in {1,2,4,8}.
3. Numerics — NOT bitwise identical, and here is exactly how close it is
vt::MarlinDenseGemmvs the existingvt::MatmulNvfp4on the same operands, withrandom fp4 weights, random per-group fp8-e4m3 scales, and random bf16 activations.
All three must be random: a constant activation vector makes any permutation of the K axis
produce the identical dot product, and a constant scale vector hides a scale-permutation bug.
20 configurations, 631,424 output elements total. Worst case per shape across M in {1,2,4,8}:
Read plainly: 99.84 % to 100 % of elements are bit-for-bit equal; the remainder differ by
one to a few bf16 ULP.
kv_projat M=1 and M=2 is 100 % bitwise identical. The largestabsolute deviation anywhere is 4.0 on
down_proj, where |ref|max is 1336 and the bf16 ULP atthat magnitude is 8 — i.e. half an ULP. The larger relative errors (up to 2.9 %) occur on
small-magnitude outputs, where a K=25600 reduction of signed random terms cancels heavily.
This is exactly what
include/vt/ops.halready documents for these two paths — they "differonly in K-reduction order (matmul tolerance), not in the per-element product". It is not
consistent with a layout error, which would decorrelate the outputs.
4. The numerics harness fails when it should
A passing check proves nothing until it is shown to fail on a known-bad input. Feeding Marlin
the raw unpermuted
[N, K/16]scales instead of the processed layout — the exactsilent-repack-mismatch failure mode — gives:
Detected loudly, on every element.
5. Faster
M=1, Qwen3-32B shapes (hidden 5120, intermediate 25600), best of 20 reps:
Bandwidth caveat, stated because the numbers brush the device nominal (273 GB/s): these are
derived from byte accounting (
n*k/2packed +n*k/16scale bytes / measured time), notfrom DRAM counters —
dram__bytes.sum,dram__bytes_read.sum.per_secondandgpu__dram_throughput.*all return(!) n/aon Tegra. Thor's L2 is 32 MB, so q/kv/o (23.6 MBworking set) are L2-flattered; gate/down are 73.7 MB (2.3x L2) and still reach ~250 GB/s, and
the L2-fitting shapes are only ~14 % faster than the L2-overflowing ones, which suggests L2 is
not carrying the result. Treat ~250 GB/s as the representative rate.
What this does and does not claim
equivalent to the existing path on sm_110, and is 8-31x faster at decode shapes.
8.0+PTX. I have not built or run sm_80/86/89. That cell stays unclaimed.kTileMinRowsis untouched; this PR only makes thekernel available on sm_110. Wiring the W4A16 dense launcher to prefer Marlin at small M is
a follow-up worth doing (Marlin beat both existing kernels at every M in 1..8 that I measured)
but is deliberately out of scope here.
Reproduction
The measurements come from a small standalone benchmark that calls
vt::MatmulNvfp4andvt::MarlinDenseGemmat fixed shapes with random operands — no checkpoint needed. I caninclude it as
examples/nvfp4_decode_bench/in this PR if that is wanted, or keep the PR tothe single CMake line and attach the harness separately. Say which you prefer.
One thing I did not touch, deliberately
.agents/backend-matrix.md'sBACKEND-CUDA-SM110row currently reads, in its 3-stateSIGNAL: "fp8/fp4/CUTLASS/Marlin/FA2 FAST paths = DERIVED/NOT-YET (all EMPTY for sm_110)",
and the summary column says "Fast paths NOT ported". This PR makes
marlin-nvfp4resolve
ENABLED for [110], so that row becomes stale on merge.I have not edited it, because your evidence taxonomy is precise and I would rather not
guess which label this earns. What actually happened here is: built on sm_110, ran on
sm_110 silicon, numerically validated against the existing path with a negative control —
but at kernel level only, not a token-exact model run, so it is neither plain
DERIVED+BUILD-VERIFIED (testing-welcome)nor theRUNTIME-VERIFIEDbar thatCLAIM-CUDA-SM110-RUNTIMEsets for the portable bf16 path. Tell me the label you want andI will write the row, or take it yourself if that is cleaner.
Gates run locally against
--base <upstream/main tip>(the argument CI passes, which isnot the commit I branched from):
scripts/check-agent-record.py->exit=0,agent record OK: ENGINE=147 MODEL=361 QUANT=82 KERNEL=51 BACKEND=80scripts/check-pr-size.py --base ... --head ...->exit=0,OK: every explicit path class is within its review budget.Refs #325, #168.