Add AVX2 LayerNorm/RMSNorm kernel for x86-64 (MLAS) - #31973
Add AVX2 LayerNorm/RMSNorm kernel for x86-64 (MLAS)#31973Justin Chu (justinchuby) wants to merge 7 commits into
Conversation
Correction to the numerics claim, and benchmark resultsCorrecting something in the commit message before it misleads a reviewer. The numerics claim was wrongThe commit says the kernel "keeps the same two-pass mean/variance formulation as the scalar path". That is not accurate. So this PR does change the numerical formulation for full LayerNorm on x86: Welford's one-pass → two-pass mean/variance. I should have said so up front. RMSNorm is unaffected — both sides use sum-of-squares. This is a real trade-off and reviewers should decide it, not me. Two-pass avoids Welford's per-element BenchmarksNow measured against the true baseline (the Host: AMD EPYC 9V74 (AVX2, FMA, F16C; no AVX-512). p50/p95 over 1000 iterations after warmup. RMSNorm — pure SIMD effect, same algorithm both sides:
Full LayerNorm — SIMD plus the algorithmic change above, so read with that caveat:
Much of that is removing Welford's per-element division, not vectorization. I would not want the 20× figure quoted without the caveat. Known regressionFor NormSize ≤ 15, RMSNorm is 0.7–0.8× — AVX2 setup overhead exceeds the gain on very short rows. Disclosing rather than hiding it; happy to add a size threshold that falls back to scalar below the vector width if you would prefer. Scope of the measurementThis is a single-row kernel microbenchmark. End-to-end model impact is unmeasured. LayerNorm is typically a small fraction of total inference time, so please do not read these as model-level numbers. |
GPU validation is an external hardware blocker, now tracked in #768. Confirmed zero self-hosted runners on this repository (GET /repos/justinchuby/onnx-genai/actions/runners -> total_count: 0) and a User-account owner, so there is no org-level GPU pool either. Hosted runners have no NVIDIA GPU, so CUDA cannot be validated by CI as configured. #762 stays draft until #768 returns exit 0 with evidence. Upstream CPU pilot (PR #763's plan) started in a separate clone of justinchuby/onnxruntime, outside this repo. Two corrections to the gap analysis, both found by inspecting upstream main rather than trusting the earlier survey: - GatherBlockQuantized CPU is NOT a gap. contrib_ops/cpu/quantization/ gather_block_quantized.cc already exists upstream; the original search covered core/providers/cpu/ and missed contrib_ops/. - x86 f16/bf16 GEMM is not viable. AVX2 has only F16C conversion, so an AVX2 half-GEMM would convert to fp32, multiply, and convert back - which is exactly what Eigen already does today via math::MatMul<MLFloat16>. Native fp16 arithmetic needs AVX512-FP16, which this host lacks. Pivoted to a verified gap: MLAS had no x86 LayerNorm kernel at all (layernorm.cpp is 41 lines of dispatch; only a RISC-V RVV kernel existed), so LayerNormalization and SimplifiedLayerNormalization ran scalar on AVX2. Shipped as draft microsoft/onnxruntime#31973: an 8-wide AVX2+FMA3 kernel plus 36 MLAS unit tests, all passing on a real binary I ran myself. Also recorded a numerics correction made publicly on that PR. The commit message claimed the kernel matched a two-pass scalar path; in fact the real x86 baseline is Welford's online algorithm in layer_norm_impl.cc, so the PR does change the formulation for full LayerNorm. That is disclosed upstream along with the resulting caveat that the large full-LayerNorm speedups come substantially from dropping Welford's per-element division rather than from vectorization, and a small-size regression at NormSize <= 15. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update: Welford preserved, tiny rows excluded — both driven by measurementFollowing up on my own correction above. Two changes; the PR is materially different and, I think, materially better. 1. The two-pass variance was not just less precise — it was wrongI said the Welford → two-pass change was a trade-off for reviewers to weigh. Adversarial testing settled it against us:
With mean ≈1e6, both terms of The reduction now uses Welford's with 8 parallel AVX2 accumulators combined by the standard pairwise merge, so the formulation matches the scalar baseline. Against an fp64 reference it is more accurate than scalar Welford at every size, since the parallel accumulators shorten each dependent chain:
RMSNorm keeps sum-of-squares — no mean subtraction, so no cancellation to avoid. Cost: Welford's per-element division runs ~2.5–3× slower than two-pass, so full-LayerNorm drops from ~20× to 5–7×. That is the honest number. The earlier 20× was mostly the removed division, not vectorization, which is why I did not want it quoted. 2. Tiny rows now fall back instead of regressingThe ≤15 regression I disclosed is gone by construction. Measured crossover on AMD EPYC 9V74 (AVX2/FMA, no AVX-512): 3–22% regression for NormSize 1–7, parity at 8 — where the first 256-bit iteration executes. Tests assert the contract on both sides: ≥8 the kernel must run, <8 it must decline. Neither a silent fallback nor an accidental re-enable for tiny rows can pass unnoticed. StatusIncludes the catastrophic-cancellation cases, asserting finiteness and exact parity with scalar Welford. Formatting fixed ( Still unmeasured: end-to-end model impact. This is a single-row kernel microbenchmark and I am not going to imply otherwise. Keeping it draft — happy to take feedback on the Welford SIMD merge or the threshold value. |
Track B (CUDA upstream contribution): audited in a separate worktree against microsoft/onnxruntime main @ 16b486a2. Both ranked candidates are dead, and no upstream PR was opened. - MatMulNBits int4 block-128 GEMV: upstream already covers block-128. matmul_4bits_m1_impl.cuh:152 has an explicit block_size == 128 template, matmul_nbits.cc:76 accepts it via CheckFpAIntBEligibility, and contrib_ops/cuda/llm/fpA_intB_gemv/ is a full groupwise int4 GEMV dispatcher. ORT issue #23004 turns out to be about CPU int4 performance, not CUDA. - QMoE parallel routing: Microsoft already merged PR #28980 optimizing the QMoE SoftmaxTopK router for small-batch decode, qmoe_kernels.cu already uses warp-cooperative reductions, and issue #28987 lists 8+ active PRs on these kernels. Contributing here would duplicate in-flight work. That is four of four planned upstream candidates eliminated by inspection across both tracks. The pattern is consistent and worth stating plainly: our CUDA advantages are runtime-architectural - graph capture, VMM weight paging, tiered KV - and are not portable as self-contained upstream kernels. Prior +36-60% and +30% figures measured our Rust runtime, not any upstream port, and must not be cited as evidence for upstream code. Track A (CPU LayerNorm, microsoft/onnxruntime#31973) hardened on two fronts, both driven by measurement rather than argument: - Adversarial numerics showed the two-pass variance was not merely less precise but wrong: NaN at base=1e6/spread=1e-3 and 100% relative error at base=1e7, where E[x^2] - mean^2 loses every fp32 digit. The kernel now preserves Welford semantics using 8 parallel AVX2 accumulators, which measures more accurate than scalar Welford at every size tested. The honest speedup drops from ~20x to 5-7x, because most of the original figure was the removed per-element division rather than vectorization. - A NormSize < 8 dispatch threshold removes the previously disclosed 3-22% small-row regression by construction, with tests asserting the contract on both sides so neither a silent fallback nor an accidental re-enable can pass unnoticed. 40 tests pass. GPU validation remains blocked by #768; no self-hosted runners exist, so CUDA cannot be validated in CI as configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Internal review pass — findings addressedRan an adversarial internal review before asking for your time. No blocking findings; two substantive items, both now fixed. Wasted work in the RMSNorm path. In Simplified mode the running sum feeds only the optional Worth being precise: the reviewer estimated ~15% from reading the code. Measured, it is 5–9% for NormSize 8–64 and under 1% for NormSize ≥ 256 — so at LLM-typical hidden sizes it is in the noise. The change stands on making the dead-code intent explicit, not on a perf claim. fp64 reference uses two-pass. Reasonable thing to flag, given this PR removed two-pass from the kernel for causing NaN. It is deliberate and now documented in-code: at these magnitudes the cancellation cannot bite in fp64, and keeping a different algorithm in the reference is what makes it an independent oracle — if reference and kernel both used Welford, a shared conceptual error could produce matching wrong answers. Also removed a dead statement in the tests. Independently verified during review
Welford reduction, the |
Correction: the AVX2 reduction was less accurate than scalar. Replaced.An internal review found a blocker that invalidates an accuracy claim I made earlier in this PR. Correcting it directly. B1 — the lane-parallel Welford was ~1000× worse, not betterI previously claimed the AVX2 Welford was more accurate than scalar Welford. That was wrong on large-base/small-spread inputs. Reproduced against an fp64 oracle: Each of the 8 lanes accumulates its own mean over ~N/8 elements in fp32, so the rounding is already baked in before the pairwise merge runs. Merging in double cannot recover it. My earlier measurements simply did not sample this region — which is a fair criticism of the tests as much as the kernel. B2 — replaced with centered two-pass (double first-pass sum)Worth distinguishing from earlier in this PR's history: the formulation that produced NaN was the uncentered Cross-platform bugs I introduced, now fixed
Tests strengthenedThe old precision tests were too weak to catch a 1000× regression — that is how B1 reached review. Added an fp64 parity sweep over base 1e3–1e6, spread 1–1e-3, eps 1e-5/1e-6/1e-12, NormSize 9–4096, plus an explicit B1 regression guard calibrated so the removed Welford (2.49e-01) fails and the current kernel (3.30e-02) passes. Also fixed the sweep's own metric: per-element relative error returns exactly 1.0 whenever a near-zero normalized output rounds to zero, which is routine for LayerNorm. It now uses vector-normalized max error. Staying in draft pending another internal review pass. Apologies for the churn — I would rather correct my own numbers here than have you find them. |
541ea05 to
53a554c
Compare
Note on the
|
The
|
CI status: the remaining red checks are not from this PRMarking ready for review.
Recap of what this PR is nowThe AVX2 kernel uses a centered two-pass formulation — That replaced a lane-parallel Welford version I had originally proposed. Review found it was not merely imperfect but worse than the scalar baseline for large-base/small-spread inputs (base 1e5, spread 1e-2, N=1024: scalar relative error 2.54e-4 against AVX2 at 0.249), because per-lane means round in fp32 before the merge, and merging in double cannot recover what the lanes already lost. The current form measured both more accurate and ~4.7x faster than the Welford version. Worth stating plainly since it is easy to conflate: this is centered two-pass, not the uncentered Also in response to review: the small- |
6ef1f61 to
2989cf5
Compare
MLAS dispatches LayerNormF32Kernel to a RISC-V RVV kernel where available and otherwise falls back to the scalar implementation in layernorm.cpp. There is no x86-64 kernel, so LayerNormalization and SimplifiedLayerNormalization run scalar on AVX2 hardware. This adds an 8-wide AVX2 + FMA3 two-pass kernel and wires it into the existing AVX2 CPUID dispatch block, alongside the other AVX2 kernels selected there. Numerics are unchanged in shape: the kernel keeps the same two-pass mean/variance formulation as the scalar path rather than switching to a one-pass sum-of-squares form, so accumulation behaviour matches the existing reference. Tail elements beyond the vector width use the scalar path, and Simplified (RMSNorm) mode skips the mean subtraction exactly as the scalar kernel does. Dispatch stays fail-closed: the kernel is only installed inside the existing AVX2 feature check, so hardware without AVX2/FMA3 continues to use the scalar implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Covers MlasLayerNormF32 across NormSize 1, 7, 8, 15, 16, 127, 128 and 1024, both Simplified (RMSNorm) and full LayerNorm, with and without bias, plus the Mean and InvStdDev outputs. The sizes deliberately span non-multiples of the 8-wide vector so the scalar tail is exercised. Parity is checked against an fp64-accumulated scalar reference. Tolerance is 0.5% relative with a 1e-4 absolute floor, matching the existing CloseEnough convention in test_util.h; the zero-variance case uses a 2e-4 floor because 1/sqrt(var+eps) amplifies rounding there. Worst observed divergence is 0.02% relative at NormSize=1, from FMA contraction. Edge cases: zero variance (all-equal input, which is the division risk in the inverse-stddev computation), denormals, large magnitudes, and NaN/Inf behaviour consistent with the scalar path. The tests also assert reachability. MlasLayerNormF32 reports whether a kernel was installed, so if the AVX2 kernel is not registered in platform.cpp the tests fail rather than silently exercising the scalar fallback. On AVX2 hardware there is no skip path, so a dispatch regression cannot pass unnoticed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Python format / Suggest fixes checks flagged
onnxruntime/test/mlas/unittest/test_layernorm.cpp:336.
Only the test file is reformatted. onnxruntime/core/mlas/** is listed in
.lintrunner.toml's clang-format exclude_patterns ("Contains assembly code"),
so the kernel, mlasi.h and platform.cpp are deliberately left as-is rather
than reformatted against the project's own exclusion.
Rebuilt and re-ran after formatting: 36 tests still pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… rows Two corrections to the original proposal, both driven by measurement. 1. Numerics: keep Welford's, do not replace it with two-pass. The first version computed variance as E[x^2] - mean^2. That silently changed the formulation used on x86 today: ComputeJob in layer_norm_impl.cc uses Welford's online algorithm, commented there as numerically stable. Adversarial testing showed the replacement was not merely less precise but wrong: base=1e6, spread=1e-3, N=256 two-pass: NaN Welford: finite base=1e6, spread=1e-3, N=1024 two-pass: NaN Welford: finite base=1e7, spread=1e-2, N=256 two-pass: 100% error Welford: finite base=1e7, spread=1e-2, N=1024 two-pass: 100% error Welford: finite When mean is ~1e6 both terms of E[x^2] - mean^2 are ~1e12 and the subtraction consumes every significant fp32 digit. The reduction now uses Welford's with 8 parallel AVX2 accumulators combined by the standard pairwise merge, so the formulation matches the scalar baseline. Measured against an fp64 reference it is in fact more accurate than scalar Welford at every size tested (e.g. N=4096: 5.51e-07 vs 2.30e-05), because the parallel accumulators shorten each dependent chain. RMSNorm keeps sum-of-squares: with no mean subtraction there is no cancellation to avoid. Welford's per-element division costs roughly 2.5-3x against the two-pass form, so the full-LayerNorm speedup drops from ~20x to 5-7x. That is the right trade: the earlier figure was mostly the removed division, not vectorization. 2. Dispatch: fall back to scalar below NormSize 8. Measured on AMD EPYC 9V74 (AVX2/FMA, no AVX-512), the kernel regressed 3-22% for NormSize 1-7 and reached parity at 8, where a single 256-bit iteration first executes. Below that the kernel is scalar tail plus setup overhead, so MlasLayerNormF32 now declines and the caller keeps its existing path. Tests assert the dispatch contract on both sides: for NormSize >= 8 the kernel must run, and for NormSize < 8 it must decline, so neither a silent fallback nor an accidental re-enable for tiny rows can pass unnoticed. 40 tests pass, including the catastrophic-cancellation cases above, which assert finiteness and exact parity with scalar Welford. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review feedback. In Simplified (RMSNorm) mode the running sum feeds only the optional Mean output - the normalization pass never subtracts the mean - so when MeanOut is null the accumulation and its horizontal reduction are dead work. The check is per row, outside the inner vector loop: a branch inside the loop could cost more than the single vaddps it saves, and a template split seemed too invasive for this. Measured on AMD EPYC 9V74 over 500k iterations: 5-9% for NormSize 8-64, and under 1% for NormSize >= 256. The initial estimate of ~15% was overstated, and at LLM-typical hidden sizes the saving is in the noise, so this change stands on making the dead-code intent explicit rather than on a performance claim. The Welford reduction, its pairwise merge, the NormSize < 8 decline contract and the full LayerNorm path are all untouched. Also documents why the fp64 test reference deliberately keeps the two-pass formulation that was removed from the fp32 kernel: at these magnitudes the cancellation cannot bite in fp64, and keeping a different algorithm in the reference is what makes it an independent oracle. If reference and kernel both used Welford, a shared conceptual error could produce matching wrong answers. 40 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…orm test gating
Review found the AVX2 reduction was substantially LESS accurate than scalar,
not more. Correcting a claim made earlier in this PR.
B1. The lane-parallel Welford merge loses accuracy on large-base/small-spread
inputs. Reproduced against an fp64 oracle:
base=1e5, spread=1e-2, N=4096
scalar Welford rel err 3.35e-05
AVX2 Welford rel err 2.71e-01 ~8000x worse
Each of the 8 lanes accumulates its own mean over ~N/8 elements in fp32, so
rounding is already baked in before the pairwise merge runs; merging in double
cannot recover it.
B2. Replaced with a centered two-pass reduction - mean = sum/n, then
sum((x - mean)^2) - with the first-pass sum accumulated in double. Measured
against the fp64 oracle:
scalar Welford 5.03e-02 1.0x
AVX2 Welford (removed) 2.82e-01 8.1x
centered two-pass, fp32 sum 1.00e+00 ~15x
centered two-pass, double sum 5.95e-03 14.3x
Worth distinguishing from the earlier revision of this PR: the formulation
that produced NaN was the *uncentered* Var = E[x^2] - mean^2, which cancels
catastrophically. Subtracting the mean before squaring removes that, so
centered two-pass is both more accurate than scalar Welford here and faster,
since it avoids the per-element division in Welford's inner loop. The fp32-sum
variant is not viable; double accumulation on the first pass is required.
N2. The NormSize < 8 gate had been added to shared dispatch, which also
disabled the pre-existing RISC-V RVV kernel for short rows. It is now scoped
to x86 only, so RVV behaves exactly as it did before this PR.
N4. Added MSVC /arch:AVX2 for the kernel source, which sits outside the
globbed AVX2 source list.
Test fixes:
B3. The tests asserted AVX2 dispatch unconditionally, which would fail on
every non-AVX2 platform in CI. Dispatch is now capability-gated with a skip,
while the reachability assertion is retained where a kernel exists, so a
silent fallback still fails.
B4. Zero-variance expectations assumed Welford semantics and conflicted with
the RVV kernel's E[x^2] - mean^2 formulation. The assertions now accept both
while still checking finiteness.
N5/N6. Added an fp64 parity sweep over base 1e3-1e6, spread 1-1e-3, eps
1e-5/1e-6/1e-12 and NormSize 9-4096, plus an explicit B1 regression guard. The
previous precision tests were too weak to catch a 1000x regression, which is
how B1 reached review. The guard is calibrated so the removed Welford
(2.49e-01) fails and the current kernel (3.30e-02) passes.
Also fixed the sweep's own error metric: per-element relative error returns
exactly 1.0 whenever a near-zero normalized output rounds to zero, which is
routine for LayerNorm. It now uses a vector-normalized max error.
41 tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to an independent re-review. The fp64 parity sweep threshold was 2.5e-2 against a worst observed error of 2.23e-2 - only 12% headroom, thin enough that a different CPU, compiler version or FMA contraction decision could turn CI red for a kernel that is actually fine. Widened to 3e-2, giving 35% headroom. The B1 guard still bites at that tolerance: the removed lane-parallel Welford measured 2.49e-1, which is 8x above the threshold. The adversarial precision report was marked DISABLED and failed if enabled, which is a trap for whoever enables it later and assumes a real regression. The cause was a scenario using values near FLT_MAX, where sum(x^2) overflows in fp32 regardless of the algorithm - an unreasonable input rather than a kernel defect - so that scenario is now excluded and the reason documented. Catastrophic-cancellation scenarios are tracked separately with a 10% gate (measured 8.4e-2) while ordinary scenarios keep 0.5%, and the test is enabled. Also corrected stale "Welford" labels in test names; the reduction is centered two-pass now, and given this PR's history a misleading label costs a reviewer real time. 42 tests pass, and 43 with --gtest_also_run_disabled_tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2989cf5 to
d847340
Compare
Draft — opened early for feedback on approach. Performance numbers are still being measured against the correct baseline; see Benchmarks below.
Motivation
MlasLayerNormF32dispatchesLayerNormF32Kernelto a RISC-V RVV kernel where available and otherwise uses the scalar implementation inlayernorm.cpp. There is no x86-64 kernel, soLayerNormalizationandSimplifiedLayerNormalizationrun scalar on AVX2 hardware.Verified against
main(16b486a2):onnxruntime/core/mlas/lib/contains onlylayernorm.cppandriscv64/layernorm_kernel_rvv.cpp, andplatform.cppsetsLayerNormF32Kernelonly for RVV.Change
Adds an 8-wide AVX2 + FMA3 two-pass kernel and registers it inside the existing AVX2 CPUID dispatch block, alongside the other AVX2 kernels selected there.
onnxruntime/core/mlas/lib/layernorm_kernel_avx2.cpponnxruntime/core/mlas/lib/mlasi.honnxruntime/core/mlas/lib/platform.cppcmake/onnxruntime_mlas.cmakeonnxruntime/test/mlas/unittest/test_layernorm.cppNumerics
The kernel keeps the same two-pass mean/variance formulation as the scalar path rather than switching to a one-pass sum-of-squares form, so accumulation behaviour matches the existing reference. Tail elements beyond the vector width use the scalar path, and Simplified (RMSNorm) mode skips mean subtraction exactly as the scalar kernel does.
Dispatch is fail-closed: the kernel is installed only inside the existing AVX2 feature check, so hardware without AVX2/FMA3 keeps using the scalar implementation.
Tests
1/sqrt(var+eps)), denormals, large magnitudes, NaN/Inf.CloseEnoughintest_util.h; zero-variance uses 2e-4. Worst observed divergence 0.02% relative at NormSize=1, from FMA contraction.Run on AMD EPYC 9V74 (AVX2, F16C; no AVX-512).
Benchmarks
Deliberately omitted for now. An initial measurement compared the kernel against an fp64 reference rather than the fp32 scalar path this actually replaces, which inflates the result, so those numbers are being redone against the correct same-binary baseline. I would rather post nothing than post a number I cannot defend — I will update this section with p50/p95 and variance once measured properly.
Happy to hear early feedback on the approach, dispatch placement, or whether a one-pass formulation would be preferred despite the numerics change.