perf(nvfp4): drop the guarded expf slow path from the fused SwiGLU epilogue - #194
perf(nvfp4): drop the guarded expf slow path from the fused SwiGLU epilogue#194MichaelDementii wants to merge 2 commits into
Conversation
…ilogue nvfp4_linear_swiglu_w4a4_tma_kernel evaluates silu with the accurate expf and an accurate divide, then rounds the result to bf16 on the next instruction. The accurate expf compiles to a guarded slow path (64 CALL.REL.NOINC and 64 FCHK per kernel in SASS) and the divide to a Newton refinement; together they account for 652 FFMA that the bf16 rounding then discards. Use __fdividef(x, 1.0f + __expf(-x)) in that kernel only. The decode, small-T and non-TMA w4a4 SwiGLU routes keep the accurate silu. Operator benchmark (--policy a4, 34816x5120), two independent passes, smaller of the two, against a rebuild of identical master source as the false-alarm floor (0.00-0.18%): -5.37% at T=1024, -2.53% at T=4096, -1.79% at T=8192. Against the directly measured NVFP4 MMA ceiling of 2003.9 TFLOP/s the kernel goes from 51.0% to 51.9% of the tier at T=8192, on the useful FLOPs the benchmark itself counts. This tile's own ceiling, measured by an ablation arm with no compute, no operand fetch and no epilogue, is 1089.0 TFLOP/s = 54.3% of the tier; that arm belongs to a separate harness whose stock runs 0.54% faster than the binary measured here, so the two are quoted with the offset visible rather than divided into each other. Either way 3.9-4.5% of this kernel's time is left, and it needs a different tile, not a different epilogue. Kernel resources are unchanged, by kernel name and template arguments: REG 168, STACK 0, SHARED 1024, LOCAL 0, CONSTANT[0] 1424, identical on both sides, no spills. Inside the 27B NVFP4 model the same kernel is 28.5-29.0% of prefill kernel time and gains less than it does on the operator fixture. Profiled with nsys, two passes per chunk width, the median launch moves -1.03/-1.28% at chunk 1024, -0.79/-0.90% at 4096 and -0.84/-1.01% at 8192, against a floor of +0.04/+0.12% from a rebuild of identical source. Whole-prefill kernel time moves -0.16 to -0.48%. A prefill chunk width that is not a multiple of 256 takes the other route, and there the profile finds no instance of this kernel at all and the two arms differ by 0.013% over the whole run. The change is not bit-neutral, so the evidence is numeric. On the two operator cases that take the TMA route the FP64 oracle reports rel_l2 identical to master in all 17 significant digits, at 0.365 and 0.329 of the criterion limit; a one-ULP fp32 perturbation of the accurate activation already moves the same instrument in its eighth digit, and a four-level coarsening of the sigmoid fails it. Perplexity over 95641 scored tokens moves by -0.0034 nats against a floor of -0.0026 from the one-ULP perturbation, i.e. 1.29x the floor rather than inside it. What makes that reading uninterpretable as damage is that the instrument is not monotone here: a 64-level quantiser, far coarser than this change, moves it by only +0.0009. Interpretable departure starts at the 16-level build (+0.0071). Rebuilding identical source moves it by exactly 0.0000000000. ctest 104/104 on both arms from one build directory, 2 skipped for want of a groupwise 27B artifact, the same 2 on master.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Re-measured on Operator,
24 of 24 cells faster, median −9.23 %. The −5.37 / −2.53 / −1.79 % in the description were taken The mechanism claim also checks out on this base, exactly: @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 61250e89df
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // and the result is then rounded to bf16 on the very next instruction. The | ||
| // hardware-approximate forms are ~1e-6 relative, four orders of magnitude below | ||
| // the bf16 quantum, so nothing that survives the rounding is affected. | ||
| __device__ __forceinline__ float swiglu_silu(float x) { return __fdividef(x, 1.0f + __expf(-x)); } |
There was a problem hiding this comment.
Avoid the large-divisor zeroing range of __fdividef
When the scaled gate is roughly between -87.34 and -88.72, __expf(-x) is finite but lies in [2^126, 2^128), where CUDA documents __fdividef as returning zero instead of the correctly rounded quotient. For example, x=-87.5 should produce SiLU near -8.7e-37, which is still representable in BF16, but this helper collapses it to zero; this also makes the TMA route numerically inconsistent with the other routes for valid finite inputs. Use an overflow-safe negative-side formulation such as x * __expf(x) / (1 + __expf(x)) while retaining approximate intrinsics.
AGENTS.md reference: AGENTS.md:L83-L88
Useful? React with 👍 / 👎.
…idef __fdividef returns zero once the divisor reaches 2^126, which 1 + __expf(-x) does for x below -87.34, where SiLU is still a normal bf16. Fold the exponential onto the side that cannot overflow: the divisor stays in (1, 2] for every finite x and the only subnormal the form can produce enters a multiply. Still no CALL and no FCHK, and the operator is unchanged within +0.14%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The finding is right and is fixed in Probed on the card, x from −95 to −75 in steps of 0.005, against a double reference:
So the routes did disagree, on [−88.72, −87.34], and below that the base is wrong too. What the Committed instead, keeping the divisor away from 2^126 rather than the numerator: const float e = __expf(-fabsf(x));
const float r = __fdividef(1.0f, 1.0f + e);
return (x >= 0.0f ? x : x * e) * r;
Cost: @codex review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Base
origin/master@487f8977. Rebased ontomaster487f8977; the diff applies to it cleanly, with the same diff on its own base as the control. The measurements below were taken onad0f3d38, which was master when the work was done, 83 commits back. I have not re-taken them here and I say so rather than implying they are fresh.What
nvfp4_linear_swiglu_w4a4_tma_kernelcomputes its activation with the accuratesilu(x) = x / (1 + expf(-x))and then rounds the result to bf16 on the very nextinstruction. The accurate
expfcompiles to a guarded slow path and the accurate divide toa Newton refinement, and both are paid for a value that is about to lose 16 mantissa bits.
This replaces the activation in that one kernel with the hardware-approximate forms,
__fdividef(x, 1.0f + __expf(-x)). No other SwiGLU route is touched: the decode, small-Tand non-TMA
w4a4kernels keep the accuratesilu, and they serve as the control below.SASS of
nvfp4_linear_swiglu_w4a4_tma.cu.o,nvcc 13.1,-gencode arch=compute_120a,code=sm_120a:CALL.REL.NOINCFCHKFFMAFMULMUFU.EX2MUFU.RCPF2FP.BF16.F32.PACK_ABWhy it is worth doing
The kernel is feed-bound. Nine
NINFER_ABLarms on the same base, same operator benchmark, twopasses each, from the campaign that motivated this change
(§3.2 of its write-up; its cells are two families of per-arm CSVs,
A_a*.csvand the matchingB_a*.csv, which live outside this repository and are not files ofthis package — supplied on request; that harness is a separate set of binaries from the ones
measured below, and its stock arm runs 0.54% faster than the
e0binary of this package):a7, the whole consumer inner loop still in place) takes5.73% off at T=8192: 2842.6 us -> 2679.8;
ldmatrix, 20 scale reads and 64OMMAperk-tile (arm
a6) — gives 2681.9 us, 0.08% slower thana7. All of the compute and all ofthe shared-memory operand fetch are hidden behind the feed and cost nothing.
So the epilogue is the one non-zero item that is not the global-to-shared feed, and it is worth
162.8 us of the kernel's 2842.6. Of that, the accurate
expfplus the accurate divide are53.3 us; the remaining 109 us are the shared-memory staging and the store, which this change
does not touch and cannot.
That 162.8 us is a fixture figure and is data-dependent. On near-zero accumulators the same
epilogue costs 31.8 us instead — the slow
expfpath is not taken on zeros — so the ratio ofthis change to the kernel is not a constant of the kernel. That is one of the reasons the model
gains less than the fixture, and the model numbers are in "End-to-end" below.
Measurements
ninfer_nvfp4_linear_swiglu_bench --policy a4 --t-sweep 1024,4096,8192 --warmup 3 --repeat 25,RTX 5090 (sm_120a), CUDA 13.1, driver 580.159.03, 525 W cap.
Two independent passes with the arms interleaved; the table quotes the smaller of the two.
The middle column is the instrument's own false-alarm floor: the same source rebuilt into a
different binary. It is 0.00-0.18%, i.e. below every delta reported.
Roofline, on the fixture above (the model's own numbers are in the next section). Against
ceilings measured directly on this card (the NVFP4 MMA tier at
2003.9 TFLOP/s from the product register form of
mma.sync.aligned.kind::mxf4nvf4.block_scale..., two runs agreeing to 0.63%; L2 read at7132-7461 GB/s). Useful FLOPs are
2 * 34816 * 5120 * T, which is what the benchmark's ownTFLOP/scolumn reports, and the two arms below are the two arms of the table above:The 1027.42 in the 4096 row is a genuine coincidence with a number that appears below, and it is
how an error got into an earlier revision of this description: the ablation harness's stock arm
also reads 1027.4 TFLOP/s, at T=8192. They are different cells of different binaries.
The feed is unchanged (the epilogue reads no global memory), so the share of the L2 read ceiling
stays at 53-56% — the kernel moves 3988 GB/s against an L2 read plateau of 7132-7461 GB/s, both
from the same campaign, §3.3.
The kernel's own ceiling at this tile is 1089.0 TFLOP/s = 54.3% of the tier, measured by an
arm with no compute, no shared-memory operand fetch and no epilogue. That arm is not in this
package: it comes from the ablation harness of the campaign that motivated this change (arm
a6of that campaign, which is my own work outside this repository), and that harness is a
different binary — its own unmodified-
masterarm runs T=8192 in 2842.6 us against the2857.984 us of the
e0binary measured here, a 0.54% offset, which is three times thefalse-alarm floor of the speed instrument. So the two must be quoted with that offset visible:
takes 94.3% of the kernel ceiling (1027.42 of 1089.01, i.e. 2681.856 us of 2842.624);
93.8% — but that pair crosses the 0.54% offset and is worth about that much less. Taking the
offset back out, by scaling this branch's 2806.784 us with the 2842.624/2857.984 ratio of the
two stock arms, puts it at 96.1% of the ceiling. That 96.1% is a numerical coincidence
with a figure disproved below and arrived at a different way; they are not the same claim.
So what is left in this kernel is 3.9-4.5% of its time depending on which of the two
conventions is used, and it cannot be had without changing bytes per FLOP, i.e. without
changing the tile.
Provenance: those ceilings are direct measurements of the instructions in their product
register form on this card, two independent runs agreeing to within 0.2-0.63%. That is more
honest than quoting a constant compiled into a benchmark, but it is our own measurement, not
a vendor figure.
Resources. Identical on both arms, compared by kernel name and template arguments
(
Nvfp4GemvGeometry<34816, 5120>,Nvfp4W4a4TmaSchedule<256, 3, 1>):REG:168 STACK:0 SHARED:1024 LOCAL:0 CONSTANT[0]:1424. No spills either way.Re-dumped from the submitted branch 2026-09-05 with
cuobjdump -res-usage, on the single translation unitsrc/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cubuilt with the project's ownflags (
nvcc13.1.115,-O3 -std=c++20 --generate-code=arch=compute_120a,code=[compute_120a,sm_120a],taken verbatim from
compile_commands.json). Raw:raw/fmt_res/resusage_base.txtandraw/fmt_res/resusage_cand.txt. The TU instantiates exactlyone kernel; the
Functionline and the resource line that follows it are byte-identical on thetwo sides (
md5 d7d1389002533c25da7a5e9b19afba71over both lines on either side). What doeschange is the object: 199 568 bytes on
master, 151 984 with this change, which is the removedslow path.
The
candtree the dump was taken in holds blob2be97137e7354c52398edf9447986fca470fd4aefor the changed file — the same blob as the submittedcommit, so this is the submitted code and not a rebuild of the same patch; the branch-location
note of this package records the chain.
End-to-end, on the model this kernel serves
The operator benchmark above is a fixture. The numbers below are the same kernel inside
Qwen3.6-27BNVFP4 driven throughninfer_bench(-p 16384,--kv-dtype fp8,--max-ctx 20480,-r 7 --warmup 2), on the same card.The gain is smaller in the model than on the fixture, and this is stated up front.
nsyscuda_gpu_kern_sum, two independent passes per chunk width, arms interleaved and theorder reversed in the second pass:
The false-alarm floor of this instrument is measured on the same target: a rebuild of
identical master source profiled the same way moves the median by +0.119% and +0.041%
and whole-prefill kernel time by +0.196% and +0.010%.
Two things about this table that a reviewer listing
raw/e2e/prof/would find, so they are saidhere.
There is a third 4096 triple in that directory and it is not in the table. Files
E0,E1,ENULwithout anA_/B_prefix are an earlier pilot run, taken before the witness sampler wasstarted (
raw/e2e/prof/card_nsys.samplescovers only theA_/B_profiles). It is excluded forthat reason and not for its numbers, so its numbers are given: median -1.387%, kernel sum
-0.830%, and whole-run kernel time +0.354% — the opposite sign to the two witnessed passes —
against a rebuild floor on the same triple of -0.193% / -0.229% / -0.233%, which is worse than the
witnessed floor in every column. That is what an unwitnessed window looks like on this host, and
it is the reason the table quotes the other two.
The 1024 and 8192 profiles ran under the exclusive card lock but without a per-arm witness
sample.
raw/e2e/nsys_c1024.shstarts no sampler; only the 4096 script does. The lock is thesame one that refused five wall-clock arms outright, so the arms were not sharing the card, but
the per-window verdict that backs every other table in this description is absent for those four
profiles. Re-taking them under a sampler is a cheap fix and has not been done.
Against the fixture's -5.37 / -2.53 / -1.79% at the same three values of T, the model gives
-1.0…-1.3 / -0.8…-0.9 / -0.8…-1.0%. The fixture overstates this change by 1.9x to 4.4x,
most at small T. The epilogue's cost is data-dependent — on near-zero accumulators it is
31.8 us instead of 162.8 us — so a fixture number should not be read as a model number, and
the number to plan with is the one in the table above.
Control that the change cannot touch, proven by profile rather than asserted. A prefill
chunk width that is not a multiple of 256 leaves the TMA route (
resolve_routerequirestokens >= 256 && tokens % 256 == 0). At chunk 3968 the profile contains no instance ofthis kernel at all — the work goes through
silu_and_mul_dim0_split_kernel— and the twoarms differ by +0.013% over the whole run (6186757431 vs 6187537720 ns). The decode
column of the same benchmark, which uses a different kernel, moves -0.030 / -0.029 /
-0.061% across three passes.
Wall clock, for completeness, and it does not resolve this change. Prefill throughput,
three independent passes, arm order permuted per pass:
Every reading is in the right direction, but the false-alarm floor of the wall clock spans
-0.42% to +0.31%, which is the size of the effect. Wall-clock throughput on this benchmark
cannot grade a change of this size, and it is quoted here only to show it does not
contradict the profile. The claim rests on the kernel-time table, whose floor is ten times
smaller.
All 36 timed arms ran under an exclusive card lock with a per-arm witness sampling at 1 s;
all 36 windows are clean, and each window starts after the lock is taken. Five arms that the
lock refused outright because a foreign process held the card were re-taken rather than
patched over; both affected triples were re-taken whole, so every triple sits in one
contiguous window.
Correctness evidence
The change is not bit-neutral by construction, so a bit-exact gate is not offered as
evidence. Two numeric instruments are, each with its own control.
Numeric oracle (FP64), quoted as a margin, not as a verdict.
ninfer_linear_swiglu_nvfp4_testwithNINFER_OP_REPORT_STATS=1reportsrel_l2againstthe criterion limit. Exactly two of its ten cases take the TMA route (
NVFP4_A4 T=256andT=1024); on both, this branch reports the samerel_l2as master to all 17 significantdigits (0.36488986484368158 and 0.32930753840228488). That is an aggregate, not a
byte-for-byte comparison, so the claim is exactly this: an instrument that resolves one fp32
ULP sees no difference.
The test fails on whichever of the two reported ratios crosses 1 first, and on this operator
that is
gross_ratio, notrel_l2_ratio: the four-level build below fails withgross_ratio1.1736 while itsrel_l2_ratiois still 0.9586. On the ratio that actuallydecides, this branch reports exactly master's value —
gross_ratio0.48562588625839476 atT=256 and 0.37263683208756154 at T=1024, digit for digit — so it consumes none of the
headroom master already has, which is 2.06x and 2.68x respectively.
That headroom is not a distance to failure, and it is quoted here only as headroom.
gross_ratiodoes not respond monotonically to this class of perturbation: coarsening thesigmoid to 256 levels moves it down to 0.4627, to 16 levels down to 0.4110, and 64 levels
leaves it at master's value to all digits — and then 4 levels jumps it to 1.1736. So "2.06x"
does not mean the change could be 2.06 times larger and still pass. What the column does support
is the comparison actually being made, which is between builds at one ratio: master, the rebuild
and this branch are identical on it, and the only build that fails is the one four levels coarse.
rel_l2_ratiois the finer of the two readings — it is the one that resolves a single fp32 ULP —but it is not the one that fails first here, which is why both are given.
The oracle is shown to be capable of resolving less than this change and of failing.
Builds that are never submitted, differing from master only in that same activation:
rel_l2/ limit, T=256rel_l2/ limit, T=1024A one-ULP perturbation of the accurate result already moves the instrument in its eighth
digit; this branch does not move it at all. The four-level build fails the test
(
gross_ratio1.1736 against a limit of 1).Control inside the same table. The eight cases that do not take the TMA route are
byte-identical across every build above, including the failing one. The instrument moves
where the change is and nowhere else.
End-to-end perplexity, with its own noise floor measured.
ninfer-perplexity <27B NVFP4 artifact> --text <405 KB of English prose> --context 4096 --stride 2048, 95 641 scored tokens per build.Rebuilding the identical source into a different binary moves
mean_nllby exactly0.0000000000, so the instrument is deterministic across builds and the whole spread below is
numerical, not run-to-run.
The band is built from the two controls only, and this branch sits just outside it. The
controls are the rebuild (0.0000000000) and the one-ULP fp32 perturbation of the accurate
activation, which cannot matter and moves
mean_nllby -0.0026081. That is the measuredfloor: 0.0026 nats. This branch moves it -0.0033547, which is 1.29x the floor — inside
the same order of magnitude, in the same direction, and not inside the band.
What makes that reading uninterpretable as damage is that the instrument is not monotone in the
size of the perturbation over this range: a 64-level sigmoid — vastly coarser than anything
this change does — moves it by only +0.0009, about a third of what one ULP moves it, and in
the opposite direction. An instrument that ranks one ULP above a 64-level quantiser is not
resolving perturbation size at 0.003 nats. Monotone, interpretable departure begins at the
16-level build (+0.0071) and is unmistakable at 4 levels (+0.1708).
So the honest statement is:
mean_nlldoes not separate this change from a one-ULP roundingdifference, and it rules out anything as coarse as a 16-level sigmoid. It does not, on its own,
certify the change; the FP64 oracle above does that, and this is the end-to-end corroboration
that nothing large is happening.
The bit-exact output gate is not cited at all, and neither is needle-in-a-haystack
retrieval; on a change of this kind neither instrument grades anything.
Throughput is not cited as correctness.
Tests
ctestfrom a single build directory, both arms, run serially withNINFER_QWEN3_6_27B_NVFP4_WEIGHTSandNINFER_QWEN3_6_35B_A3B_WEIGHTSset:104/104 on each (102 run and passed, 2 skipped for want of a groupwise 27B artifact).
The real-model tests need to be run one at a time: two of them co-scheduled do not fit in
32 GB of device memory, and that is true of
masteras well.clang-format --dry-runon the changed file reports 2 diagnostics on this branch and thesame 2 on the base commit; the change adds none. Re-run 2026-09-05 in two separate worktrees
of the same repository, both sides real files on disk — reading a blob through a pipe does not
find the repository
.clang-formatand invents hundreds of diagnostics. Raw:raw/fmt_res/cf_base.logandraw/fmt_res/cf_cand.log,clang-format23.1.0, the same.clang-format(md5 2b1cd100967456f321f6dd5ef76b8244) present in both trees.Both diagnostics are the same two lines of alignment in an untouched block, and they simply
move by the 9 lines this change inserts above them:
const int row_mod32 = scale_pair_row & 31;/const int row_quartile = scale_pair_row >> 5;By the added-lines rule this is a zero: the lines this change adds are 15-23 and 248-251, and
neither diagnostic falls on any of them. Nothing here is reformatted.
🤖 Generated with Claude Code