Register BFloat16 LayerNorm/RMSNorm kernels on the CPU EP - #31974
Register BFloat16 LayerNorm/RMSNorm kernels on the CPU EP#31974Justin Chu (justinchuby) merged 15 commits into
Conversation
Internal review pass — findings addressedRan an adversarial internal review before asking for your time. No blocking findings; one substantive item, now fixed.
|
MLFloat16 regression check (follow-up to the
|
Retracting my test claim, and fixing a real numerical bugAn internal review rejected this PR. Two findings are mine to own publicly. I claimed "45 MLAS kernel tests". That was wrong.
They were real tests, but they exercised nothing this PR touches. Deleted. The honest number is 17 CPU EP operator tests, covering all five registered families: core A real bug: stats were degraded to bf16 despite U being float
That is a silent cross-EP numerical inconsistency, and it also contradicts this PR's own argument: the point of widen -> f32-accumulate -> narrow is to preserve precision, so quantising the statistics back down undoes it. Both overloads now call Tolerances now split by output type
That split is what makes the stat tests meaningful: the old behaviour was ~780x the tolerance, so these tests fail against the pre-fix code. Also addressed
Scope decision on the MLFloat16 U changeKeeping it. The contrib schema constrains Build clean with warnings-as-errors; 17 BFloat16 tests and 96 across the LayerNorm suite pass. Staying draft pending another internal review. |
e582388 to
881246c
Compare
The
|
CI status: the remaining red checks are not from this PRMarking ready for review. Three checks are red; I looked into each rather than assuming.
macOS jobs — network failures, not compile failures:
In the last of those the build itself succeeded and 340 of 341 tests passed — the single failure is DNS resolution. The same jobs are green on #31969–#31972. Happy to rebase or re-run if a maintainer would rather see a clean board first. |
5755a8a to
71bc68a
Compare
BFloat16 is permitted by the schemas and implemented on CUDA, but was never registered on the CPU EP, so a bf16 model that runs these ops on GPU cannot run them on CPU at all. contrib_defs.cc:3323,3331 schema T allows tensor(bfloat16) cuda_contrib_kernels.cc:178 CUDA registers BFloat16 SkipSimplifiedLayerNorm cpu_contrib_kernels.cc:159 CPU had float, double, MLFloat16 only cpu_execution_provider.cc:1080 same for ONNX LayerNormalization This registers BFloat16 for LayerNormalization (opset 17 and contrib 1-16), SimplifiedLayerNormalization, SkipLayerNormalization and SkipSimplifiedLayerNormalization on CPU. To be precise about what this is: the compute widens bf16 to f32, accumulates in f32, and narrows back. That is f32 arithmetic on bf16-stored data, NOT native bf16 arithmetic. No bf16 hardware instructions are used and none are claimed. AVX2 has no bf16 support - widening is a 16-bit shift of the bit pattern - and no AVX512-BF16 path is added here, so nothing depends on an ISA this change cannot test. Rather than adding a parallel code path, BFloat16 reuses the existing MLFloat16 widen/narrow structure through an is_narrow_float_v trait, so the diff is registration plus a type policy rather than a new kernel. Narrowing uses round-to-nearest-even via the existing BFloat16Impl::ToUint16Impl, so conversion matches the rest of ORT rather than introducing a second rounding rule. Welford's online algorithm is retained for LayerNorm variance and sum-of-squares for RMSNorm; no two-pass formulation is introduced. Numerics were measured rather than assumed. The bf16 representation-error floor is ~3.9e-3 (0.5 bf16 ULP), and widen/accumulate/narrow adds at most 1 further bf16 ULP even at N=65536, so tolerances are set at 2 bf16 ULP (~0.016 at unit scale) rather than copied from the f32 tests, where a value like 1e-4 would be below bf16's own representable precision and meaningless. Tests: 45 MLAS kernel tests covering the representation floor, rounding rule, high dynamic range, near-zero variance, denormals and large N; plus 10 operator tests that pin execution to the CPU EP so an unregistered type cannot be satisfied by an inserted Cast or another provider. Verified on AMD EPYC 9V74 (AVX2/FMA/F16C, no AVX-512): onnxruntime_provider_test --gtest_filter=LayerNormBFloat16* -> 10 passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review feedback. The contrib REGISTER_CONTRIB_KERNELS macro registered U=T for every type, but the contrib schema constrains U - the optional Mean and InvStdDev outputs - to tensor(float). So the new BFloat16 registration, and the pre-existing MLFloat16 one, both declared a U the schema does not permit. There is no runtime correctness problem: the contrib LayerNorm constructor does not set contrib_op=true, so SrcDispatcher always dispatches to ComputeImpl<T, float> and Mean/InvStdDev are emitted as float regardless of what the registration declared. The mismatch was declaration-only. The macro now takes (T, U) and registers narrow float types with U=float. That also corrects the pre-existing MLFloat16 declaration rather than adding a second, differently-wrong registration beside it, and it matches the CUDA contrib kernels, which already register U=float for narrow types. Widening scope slightly here seemed better than leaving two adjacent registrations inconsistent with each other. Not done: deduplicating NarrowToFloat/FloatToNarrow, which are currently copied between layer_norm_impl.cc and skip_layer_norm.cc. That needs a shared header and is scope creep for a registration change; the duplicated code is short and obviously correct. Worth a follow-up. 10 operator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes 46 failing CI jobs. They shared a single cause: test_layernorm_bf16.cpp:78:14: error: 'float BF16Ulp(float)' defined but not used [-Werror=unused-function] Confirmed identical in the arm64 Debug and Minimal (Exceptions Disabled) jobs, so this was one dead static function failing essentially every build job rather than a per-platform problem. BF16Ulp computed the float-valued ULP magnitude of a bf16 value, but every tolerance in these tests is expressed as an integer ULP distance via BF16UlpDistance, which is used. It was development scaffolding that never got wired in. Also removed ReportErrors, an uncalled private method intended for error decomposition that no test invokes yet; it can come back with the code that needs it. Swept the other seven files this PR touches for warnings-as-errors problems - unused variables and parameters, sign-compare, shadowing, and code that is only unused under minimal-build feature flags - and found none. Root cause of it reaching CI at all was the verification, not the code: local builds had been run with --compile_no_warning_as_error, which is exactly the condition that hides a -Werror failure. Both the fix and the test run were verified with warnings-as-errors enabled instead. 45 MLAS bf16 tests and 10 CPU-EP operator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…othing Fixes a real numerical bug and removes a large test file that did not test this change. Mean and InvStdDev are declared as float (U), but both narrow-float ComputeJob overloads were round-tripping the statistics through the narrow type before writing them, losing roughly 0.4% for BFloat16 and diverging from what the generic path produces for identical input. That is a silent numerical inconsistency between execution providers, and it also contradicts this PR's own argument: the whole point of widen -> f32 accumulate -> narrow is to keep precision, so quantising the statistics back down undoes it. Both overloads now call WriteStat<U> directly with U=float. The dead MLFloat16 and BFloat16 branches of WriteStat are removed, and SrcDispatcher uses `if constexpr` so ComputeImpl<NarrowType, NarrowType> is never instantiated at all. Deleted onnxruntime/test/mlas/unittest/test_layernorm_bf16.cpp (1037 lines). It called no MLAS function: all 17 "Mlas" tokens in it were harness base classes, and its own header referred to a "kernel hook (MlasLayerNormBF16)" that does not exist and is not part of this PR. It tested standalone BFloat16 rounding arithmetic while the change here is CPU EP registration, with compute going through layer_norm_impl.cc. Those tests were real C++ tests, but they exercised nothing this PR touches, so describing them as coverage for it was wrong. Coverage now matches what is actually registered: 17 CPU EP operator tests spanning core LayerNormalization opset 17, contrib LayerNormalization opset 1-16, SimplifiedLayerNormalization, SkipLayerNormalization and SkipSimplifiedLayerNormalization. Tolerances are split by output type rather than applying one blanket value: BFloat16 Y is checked at 2 bf16 ULP, while the float Mean and InvStdDev are checked at 1e-5. That distinction is what makes the stat tests meaningful - the previous behaviour was off by about 780x that tolerance, so these tests fail against the pre-fix code. Regenerating docs/OperatorKernels.md requires built Python bindings, which was not practical here, so the five affected rows were hand-edited to match the existing format. Flagging that explicitly rather than passing it off as generated. Also removed internal working notes that had been committed by mistake: comments naming internal reviewers, replaced with the substance they were citing. Build is clean with warnings as errors. 17 BFloat16 operator tests pass, and 96 across the whole LayerNorm suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two narrow-float conversion helpers were duplicated across layer_norm_impl.cc and skip_layer_norm.cc. Move them into a new header core/util/narrow_float_utils.h and include it from both sites. No logic change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
71bc68a to
7cf4ac9
Compare
Coverage:
- LayerNorm17_PrePack_ScaleBiasInitializers: scale/bias as constant
initializers, exercising the PrePack bf16→f32 conversion at session init.
- SkipLayerNorm_PrePack_GammaBetaInitializers: gamma/beta as initializers
for the skip variant.
- LayerNorm17_GenericBroadcast: X={2,2,2} with scale/bias={2,2} triggers
use_generic_broadcast=true, exercising ComputeJobGeneric / BFloat16Math.
Hygiene:
- Remove internal 'B5' labels from test comments (2 occurrences).
- Fix SrcDispatcher comment to accurately describe the if-constexpr
behaviour that prevents ComputeImpl<NarrowType, NarrowType> instantiation.
- Align tolerance comments with what the checker actually applies:
tolerance = absolute + relative * |expected| (numpy.isclose semantics),
where the relative component is the framework default.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…entical results
Each PrePack test now loops over is_initializer={false, true} against the
same reference output, directly proving that the prepacked code path does not
change results. SCOPED_TRACE labels failures by configuration.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Note on the two red checksBoth fail before any of this PR's code is compiled.
This PR touches no Objective-C and no build configuration. Related Apple/arm64 lanes on sibling PRs have failed on the same class of dependency-download error ( For contrast, a one-line documentation PR I opened from the same fork at the same time reached 86/86 green, so this is not a systematic problem with the fork or the branch. Everything that actually exercises this change is green: the CPU EP tests were built and run locally with warnings-as-errors from a clean rebuild — 20 BFloat16 tests and 106 across the LayerNorm suite, plus upstream #31676's prepacked-length validation at 7/7. Happy to rebase or re-push to retrigger if a maintainer would rather see a fully green board first — |
…lise is_narrow_float_v - Thread number_of_pre_packed_weights_counter through RunBF16CpuOnly and assert it is 0 for graph-input legs and 2 for initializer legs. This turns the PrePack A/B tests from 'outputs agree' into 'outputs agree AND the paths genuinely differed'. - Add LayerNorm17_MLFloat16_MeanInvStdDev_FloatPrecision test covering the float-precision stat write path for MLFloat16 (previously only tested for BFloat16). - Move is_narrow_float_v from layer_norm_impl.cc into narrow_float_utils.h to prevent header/cc drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ePack The previous commit incorrectly changed the initialisation of is_packed from false to true. ConvertMLFloat16ToFloatIfNeeded only sets is_packed inside the MLFloat16/BFloat16 branches; for float inputs it is a no-op, so is_packed retained the (now wrong) true value. This caused the float dispatch path to skip reading the Scale input, expecting prepacked data. Restore is_packed = false so the float path correctly falls through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Corrections and a compatibility noteThree things updated in the description, plus one regression caught and fixed while doing so. A regression I introduced and then caught. While adding the PrePack counter assertions, a change flipped It is fixed (one line, restoring the
The stat round-trip figure was imprecise. I quoted ~0.4% generally; that is the bfloat16 number (7-bit stored mantissa). float16 is about 0.049% (10-bit stored mantissa). Both are now stated separately. The tolerance margin was overstated. I wrote "roughly 780x the stat tolerance", which compared against the bare 1e-5 absolute and ignored the checker's relative term. The checker applies Compatibility disclosure. A model that declared Also added: the A/B PrePack tests now assert |
There was a problem hiding this comment.
Pull request overview
Adds CPU EP BFloat16 support for LayerNorm/RMSNorm operator families.
Changes:
- Registers BFloat16 kernels for five normalization operators.
- Adds shared narrow-float conversion utilities and float-precision statistics.
- Adds CPU tests and updates kernel documentation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
onnxruntime/test/contrib_ops/layer_norm_bf16_cpu_test.cc |
Adds BFloat16 CPU tests. |
onnxruntime/core/util/narrow_float_utils.h |
Adds narrow-float conversion helpers. |
onnxruntime/core/providers/cpu/nn/layer_norm.cc |
Registers core BFloat16 LayerNorm. |
onnxruntime/core/providers/cpu/nn/layer_norm_impl.h |
Updates narrow-type dispatch. |
onnxruntime/core/providers/cpu/nn/layer_norm_impl.cc |
Implements BFloat16 normalization and precise statistics. |
onnxruntime/core/providers/cpu/cpu_execution_provider.cc |
Exposes the core kernel registration. |
onnxruntime/contrib_ops/cpu/skip_layer_norm.cc |
Supports BFloat16 skip normalization. |
onnxruntime/contrib_ops/cpu/layer_norm.cc |
Registers contrib narrow-float kernels with float statistics. |
onnxruntime/contrib_ops/cpu/cpu_contrib_kernels.cc |
Exposes contrib BFloat16 registrations. |
docs/OperatorKernels.md |
Documents expanded kernel support. |
Suppressed comments (1)
onnxruntime/contrib_ops/cpu/layer_norm.cc:30
- This changes the existing contrib MLFloat16 kernel from
U=MLFloat16toU=float, but the new tests exercise MLFloat16 statistics only for the core opset-17 operator. Add a contrib-op MLFloat16 case with floatMean/InvStdDevoutputs so a registration/dispatch regression here cannot pass unnoticed.
REGISTER_CONTRIB_KERNELS(MLFloat16, float)
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Add missing float-stat tests and clarify contrib type constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Thanks for the careful BF16 widening, prepack coverage, and generic-broadcast tests. I found two output-contract issues that need correction before the registrations are complete: the contrib double-stat dispatch remains unreachable, and the BF16 skip kernels do not materialize the optional float statistics they advertise. I also left one hot-path allocation suggestion and a documentation nit inline.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Verdict: Request changes
The core BF16 registration work is sound — the schema already permits bfloat16, arithmetic widens to f32/f64 with no native bf16 math, both the fast path and the generic broadcast path have coverage, and the PrePack A/B tests asserting pre_packed_counter == 2 vs 0 against a shared reference are a strong property test. Thanks also for fixing all four items from the previous round.
Three things to resolve before merge.
1. docs/OperatorKernels.md will fail the Doc Gen check. gen_opkernel_doc.py builds the type map from KernelDef::type_constraints() (tools/python/gen_opkernel_doc.py:183), not from the schema. After REGISTER_CONTRIB_KERNELS(double, float), all four kOnnxDomain 1-16 registrations declare U = tensor(float). The hand-edit removed tensor(float16) from the two U lists but left tensor(double), so both rows will disagree with the generated output. Details inline.
2. The contrib U narrowing is broader and harder than the description says. REGISTER_CONTRIB_KERNELS(double) -> (double, float) is not mentioned in the PR body at all (only MLFloat16 is). And the contrib schemas expose a stash_type attribute with U in {tensor(float), tensor(double)} — after this PR no CPU registration supports stash_type = DOUBLE, so such a model fails at session initialization with "Could not find an implementation for LayerNormalization(1)". That is a kernel-match failure, not "will now see different values". Both changes are arguably improvements over the previous latent MutableData<U>() throw, but please state them accurately.
3. Materializing SkipLayerNorm mean / inv_std_var is undisclosed scope. This affects float, double, MLFloat16 and BFloat16, not just the new BF16 path, and CUDA/WebGPU still ignore outputs 1 and 2 — so the same model now behaves differently across EPs. Details inline. Consider splitting it out.
Suggestions (non-blocking)
- The
contrib_opplumbing is now dead code — with theif constexpr (std::is_same_v<T, float>)guard both arms resolve toComputeImpl<float, float>. - The BF16
ComputeJobis a ~60-line near-verbatim copy of theMLFloat16overload. ConvertMLFloat16ToFloatIfNeededis misnamed now and still exists in two copies that have drifted (only theskip_layer_norm.cccopy guardstensor_size > 0).- Stale comment above the
static_assertinComputeImpl: still says "Currently only instantiated for T in {float, double, MLFloat16}"; only the assertion message was updated. WriteStat<U>(..., static_cast<double>(mean))widens afloattodoubleonly forgsl::narrow_cast<float>to narrow it straight back.- Test file naming:
layer_norm_bf16_cpu_test.ccalso covers core opset-17LayerNormalizationand the MLFloat16/double stat regressions, andtest/contrib_ops/already haslayer_norm_op_test.ccandlayer_norm_test.cc. Consider folding in or renaming.
Verified clean
- No new allocation in a hot path beyond the pre-existing FP16 pattern; the conditional
skip_input_bias_add_output_fp32allocation from the last round is in. - Direct includes are correct in
narrow_float_utils.h(core/common/float16.hforBFloat16ToFloat/FloatToBFloat16,mlas.hfor the MLAS converters) — no transitive-include reliance. scale_float_ptris unconditionally dereferenced in the BF16 fast path but is guaranteed non-null for narrow-floatTby theif constexpr (is_narrow_float_v<T>)block inComputeWithoutContext.- Input rank is validated (2 or 3) before
dims.back()in the newstat_shapelambda. - No C API surface touched; offsets use
SafeInt<ptrdiff_t>.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3278b8e1-b3ec-4be5-a92b-0a61e0a00ffa
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Reviewed the current head after the follow-up fixes. The CPU registration constraints, shared narrow-float and prepack handling, optional statistics, Welford update, and focused coverage are consistent; I found no remaining actionable issues.
Local validation: rebuilt onnxruntime_provider_test and ran 21 tests from 2 suites (LayerNormBFloat16CpuTest.* plus the two float SkipLayerNorm statistic tests); all passed.
a6545f9
into
microsoft:main
What this adds
BFloat16 registration on the CPU EP for five LayerNorm/RMSNorm op families already permitted by their schemas:
LayerNormalization(core, opset 17)LayerNormalization(contrib, opsets 1–16)SimplifiedLayerNormalizationSkipLayerNormalizationSkipSimplifiedLayerNormalizationThe CPU implementations widen narrow-float inputs to float for arithmetic and narrow only output tensors. No native BF16 instruction or new ISA-specific path is added.
Statistics and type contracts
The contrib
LayerNormalizationandSimplifiedLayerNormalizationCPU registrations now consistently declareU = tensor(float)for everyT, includingdouble; runtime dispatch uniformly usesComputeImpl<T, float>. This corrects the generated operator documentation and matches the kernel registrations.This is a compatibility change for contrib models that set
stash_type = DOUBLE: shape inference may produce double statistic outputs, but no CPU kernel now matches that signature. Such models fail kernel matching during session initialization (for example, “Could not find an implementation for LayerNormalization(1)”), rather than producing different statistic values at runtime.SkipLayerNormalizationnow materializes its optional CPUmeanandinv_std_varoutputs for all registered input types. It uses Welford accumulation for stable variance.SkipSimplifiedLayerNormalizationmaterializes a zero centering mean, the inverse RMS used by its simplified normalization, and the optional residual output. This follows the existing CUDA forward/gradient contract: simplified gradients use inverse RMS and have no centering term.CUDA and WebGPU SkipLayerNorm kernels are unchanged by this PR and do not currently materialize optional statistic outputs 1/2. This PR scopes the correction to the CPU kernels and covers the CPU behavior directly.
Implementation cleanup
core/util/narrow_float_utils.h, including identical empty-tensor behavior.OperatorKernels.mdrows.Validation
onnxruntime_provider_testbuild.LayerNormBFloat16CpuTest.*andSkipLayerNormTest.*tests passed, including float large-mean/small-variance Welford coverage, BF16 statistics, all optional Skip outputs, prepack paths, and generic broadcasting.onnxruntime_providersbuild.git diff --check.The Python-enabled documentation build initially hit vendored Abseil C4702 warnings promoted to errors; rerunning it with the repository-supported
--compile_no_warning_as_erroroption succeeded for documentation generation. No source warning from this PR was involved.