You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Fix (2) below restores Half/BF16 speed and accuracy at N ≥ 256 on its own. Fix (1) is a local change to layer_norm_scalar. It fixes the FP32 regression and the portable kernel. Under #23153's routing it also fixes Half/BF16 accuracy at N ≥ 256 by itself. The two can land separately; (1) is more urgent.
Problem 1: one-pass float variance in layer_norm_scalar
float sum = std::accumulate(x, x + N, 0.0f);
... sq_sum += static_cast<float>(x[j]) * x[j];
float mean_value = sum / N;
float variance = sq_sum / N - mean_value * mean_value;
float std = std::sqrt(variance + eps);
E[x²] − E[x]² cancels when the mean is large relative to the std. The variance is not clamped, and the error can go either way:
Below -eps: rstd and the whole output row are NaN. For a constant FP32 row of 3000.0 at N=192, the float Σx² is 1727995392 instead of 1728000000, so the computed variance is −24.
Above -eps: rstd is finite but wrong. A constant FP32 row of 1000.1 at N=255 gives rstd 0.949 instead of 316.2.
Double inputs are also accumulated in float.
Where layer_norm_scalar is used (all four dtypes: Float, Double, Half, BFloat16):
Repro. No delegation is involved; the pybindings run the optimized kernel. The last two lines show main's separate Half/BF16 bug at N ≥ 256, which #23153 fixes.
A C++ harness covers cases the Python repro doesn't. Settings: M=64, affine, eps 1e-5, max abs error of out against a double reference. It uses a different seed from the repro, so NaN counts differ.
0 (by accident: the misread mean ≈ 300.5 rounds to 300 in BF16)
all NaN
all NaN
Related: both kernels convert eps to CTYPE (optimized #L32, portable #L29). In Half, eps=1e-12 rounds to 0, so a constant Half row (e.g. all ones) produces NaN where PyTorch returns 0.
Setup: median ms on an Apple M1 Pro, single thread (the kernel has no parallel_for), M=4096, randn input, gamma and beta present. The A/B runs were interleaved on a loaded machine, so the ratios are more reliable than the absolute times. main's Half/BF16 results at N ≥ 256 are wrong and are shown only for comparison.
Most of the cost is the scalar loop, not the Half conversion: FP32 through layer_norm_scalar takes 41.2 ms at N=4096. With (1) alone, #23153's Half/BF16 path at N ≥ 256 gets 2.0–2.6x faster (e.g. 18.72 ms FP16 and 24.55 ms BF16 at N=4096). That is still 1.7–2.7x FP32. (2) brings Half/BF16 to 0.87–1.11x FP32.
It calls Vectorized<acc_t<T>>::loadu(X_ptr + j * Vec::size()), where X_ptr is a const Half* or const BFloat16* and acc_t<T> is float. This reinterprets pairs of 16-bit values as float bit patterns.
It advances by Vectorized<float>::size() elements per load, but RowwiseMomentsImpl sizes chunks by Vectorized<T>::size(), which is twice as large (4 vs 8 on NEON). Part of each chunk is read twice and the rest is never read.
The header says ATen's BF16 specializations "are excluded" (#L11-L13), but the kernel has dispatched Half/BF16 since #7752.
A second problem shows up once the loads are fixed. The map3 lambda is generic (auto x, #L112-L121).
Where Vectorized<Half/BFloat16> is specialized (arm64 builds without C10_MOBILE, and Buck Linux builds, which define AVX2): the lambda computes in the reduced type, with scale and offset rounded to it. This alone leaves outputs up to 77x above the rounding floor.
Android/iOS (C10_MOBILE), not measured: the NEON Half/BF16 specializations are disabled there, so the map3 problem does not apply. But convert_to_float is a scalar fallback there, so the speedup from (2) needs to be measured on device.
x86: OSS CMake builds use the generic Vectorized. x86 was not measured.
The header hazard remains after #23153.moments_utils is an exported, PUBLIC target (targets.bzl#L79-L89). RowwiseMoments<Half> and RowwiseMoments<BFloat16> still compile and still return wrong moments for any other caller. For example, on {2, 3, 4, 5, 9, 10, 12, 13} (mean 7.25), Half at N=8 on arm64 gives a mean of 1.18e6.
The existing short case in moments_utils_test.cpp (#L15-L18) fails on every architecture:
arm64: it hits the same reinterpretation (mean 196610).
x86:acc_t<short> is int32_t, so the math is integer (mean 6, variance 26).
Nobody noticed because moments_utils_test_bin is a Buck cxx_binary, not a test target (test/targets.bzl#L41), and CMake doesn't build it.
Proposed fix
Make the variance in layer_norm_scalar stable. Use a corrected two-pass computation with 8 independent lane accumulators, so that clang vectorizes it:
float mean_value = sum / N;
// per lane: d = x[j] - mean_value; d_sum += d; sq_sum += d * d;float variance = std::max((sq_sum - d_sum * d_sum / N) / N, 0.0f);
mean_value += d_sum / N;
Three related changes were not prototyped: the std::max clamp, accumulating Double inputs in double, and passing eps without rounding it to CTYPE.
Port ATen's reduced-precision UpdateMomentsVec overload (ATen moments_utils.h#L81-L111). It does Vectorized<T>::loadu, then convert_to_float, then accumulates into two float vectors. RowwiseMomentsImpl already computes kVecSize and m0_add correctly for this. Then:
Go back to routing on N < kSmallNThreshold only.
Type the map3 lambda on Vectorized<float>, so that ATen's convert-through-float map3 (in functional_bfloat16.h) is selected. Alternatively, drop map3 and use the scalar normalize loop, which clang auto-vectorizes. On arm64 that changed time by −4% to +6%, with the same accuracy.
In RowwiseMoments, static_assert that T is float, double, Half, or BFloat16. Replace the short test case with Half/BF16 cases.
Prototype accuracy. The prototypes are a standalone harness, not gtest. Timings are in the Problem 2 table. The table shows max abs error of out with M=64; "floor" is the error of the correctly rounded result.
(2) alone matches (1)+(2) on those cases too, but leaves the N < 256 cases unchanged.
FP32 row: the remaining error comes from float rounding of the mean itself. Eager PyTorch shows about 1e-3 on a similar input in the repro above.
Notes:
Alternatives to (1), measured on the FP32 row above:
Scalar Welford: 6.3e-3 error, and 2.3x (FP32) to 2.7x (FP16) slower than the current one-pass loop at N=192.
Plain two-pass without the correction term: 8.0e-3 error.
The corrected two-pass without lane accumulators: as accurate as (1), but not vectorized (2.2 ms vs 0.79 ms).
Low-bit changes. (1) changes the summation order, so portable FP32 results shift in the low bits.
[CPU] Fix FP16/BF16 convolution and layer normalization #23153's exact-equality test. With (2), the BFloat16LargeRows test fails on the width-514 row: the mean is −8.67e-9 instead of 0, while out and rstd stay exact. That assertion uses EXPECT_TENSOR_EQ and needs a tolerance. (1) alone passes it.
Suggested tests
FP32/Double at N < 256 (portable and optimized):
Rows with mean ≫ std (mean 100, std 0.01, N=192).
Constant rows (3000.0, N=192).
Expect finite outputs and rstd close to a double reference.
Use EXPECT_TENSOR_CLOSE_WITH_TOL with atol at the rounding floor. The default Half/BF16 atol (1e-3 / 1e-2) is tighter than one output ulp at |out| ≈ 2–4.
Half constant row with eps=1e-12.
moments_utils_test.cpp:
Add Half and BF16 cases with N large enough to reach the vector path on every architecture (e.g. 512 and 514).
Make it a real test target and build it in CMake, so that it runs in OSS CI.
The investigation, the C++ harness and prototypes, and this issue were done with Claude Code.
Python repro: a locally built executorch 1.5.0 wheel (5b3da18), torch 2.13.0, Python 3.11. Released wheels 1.3.0+ should reproduce the FP32 N < 256 rows.
C++ harness: Apple clang 17, -O3, ATen vec headers from torch 2.13.0.
🐛 Describe the bug
Follow-up to #23153. There are two problems in
native_layer_norm, and they have independent fixes:layer_norm_scalarreturns NaN or badly wrong output on large-mean rows.nn.LayerNorm(192)on rows with mean 100 and std 0.01 returns NaN for about half of the rows.layer_norm_scalar.Fix (2) below restores Half/BF16 speed and accuracy at N ≥ 256 on its own. Fix (1) is a local change to
layer_norm_scalar. It fixes the FP32 regression and the portable kernel. Under #23153's routing it also fixes Half/BF16 accuracy at N ≥ 256 by itself. The two can land separately; (1) is more urgent.Problem 1: one-pass float variance in
layer_norm_scalarnormalization_ops_util.h#L38-L46:
E[x²] − E[x]²cancels when the mean is large relative to the std. The variance is not clamped, and the error can go either way:-eps: rstd and the whole output row are NaN. For a constant FP32 row of 3000.0 at N=192, the floatΣx²is 1727995392 instead of 1728000000, so the computed variance is −24.-eps: rstd is finite but wrong. A constant FP32 row of 1000.1 at N=255 gives rstd 0.949 instead of 316.2.Double inputs are also accumulated in float.
Where
layer_norm_scalaris used (all four dtypes: Float, Double, Half, BFloat16):layer_norm_scalarfornative_layer_norm(#L61-L70)native_layer_norm, main (#L75-L91)Repro. No delegation is involved; the pybindings run the optimized kernel. The last two lines show main's separate Half/BF16 bug at N ≥ 256, which #23153 fixes.
A C++ harness covers cases the Python repro doesn't. Settings: M=64, affine, eps 1e-5, max abs error of
outagainst a double reference. It uses a different seed from the repro, so NaN counts differ.Related: both kernels convert
epstoCTYPE(optimized #L32, portable #L29). In Half,eps=1e-12rounds to 0, so a constant Half row (e.g. all ones) produces NaN where PyTorch returns 0.Problem 2: Half/BF16 performance with #23153
Setup: median ms on an Apple M1 Pro, single thread (the kernel has no
parallel_for), M=4096, randn input, gamma and beta present. The A/B runs were interleaved on a loaded machine, so the ratios are more reliable than the absolute times. main's Half/BF16 results at N ≥ 256 are wrong and are shown only for comparison.Most of the cost is the scalar loop, not the Half conversion: FP32 through
layer_norm_scalartakes 41.2 ms at N=4096. With (1) alone, #23153's Half/BF16 path at N ≥ 256 gets 2.0–2.6x faster (e.g. 18.72 ms FP16 and 24.55 ms BF16 at N=4096). That is still 1.7–2.7x FP32. (2) brings Half/BF16 to 0.87–1.11x FP32.Why main's vectorized Half/BF16 path is wrong
The load in
UpdateMomentsVecis broken (moments_utils.h#L74-L78):Vectorized<acc_t<T>>::loadu(X_ptr + j * Vec::size()), whereX_ptris aconst Half*orconst BFloat16*andacc_t<T>isfloat. This reinterprets pairs of 16-bit values as float bit patterns.Vectorized<float>::size()elements per load, butRowwiseMomentsImplsizes chunks byVectorized<T>::size(), which is twice as large (4 vs 8 on NEON). Part of each chunk is read twice and the rest is never read.The header says ATen's BF16 specializations "are excluded" (#L11-L13), but the kernel has dispatched Half/BF16 since #7752.
A second problem shows up once the loads are fixed. The
map3lambda is generic (auto x, #L112-L121).Vectorized<Half/BFloat16>is specialized (arm64 builds withoutC10_MOBILE, and Buck Linux builds, which define AVX2): the lambda computes in the reduced type, withscaleandoffsetrounded to it. This alone leaves outputs up to 77x above the rounding floor.C10_MOBILE), not measured: the NEON Half/BF16 specializations are disabled there, so themap3problem does not apply. Butconvert_to_floatis a scalar fallback there, so the speedup from (2) needs to be measured on device.Vectorized. x86 was not measured.The header hazard remains after #23153.
moments_utilsis an exported,PUBLICtarget (targets.bzl#L79-L89).RowwiseMoments<Half>andRowwiseMoments<BFloat16>still compile and still return wrong moments for any other caller. For example, on{2, 3, 4, 5, 9, 10, 12, 13}(mean 7.25), Half at N=8 on arm64 gives a mean of 1.18e6.The existing
shortcase inmoments_utils_test.cpp(#L15-L18) fails on every architecture:acc_t<short>isint32_t, so the math is integer (mean 6, variance 26).Nobody noticed because
moments_utils_test_binis a Buckcxx_binary, not a test target (test/targets.bzl#L41), and CMake doesn't build it.Proposed fix
layer_norm_scalarstable. Use a corrected two-pass computation with 8 independent lane accumulators, so that clang vectorizes it:std::maxclamp, accumulating Double inputs in double, and passingepswithout rounding it toCTYPE.UpdateMomentsVecoverload (ATen moments_utils.h#L81-L111). It doesVectorized<T>::loadu, thenconvert_to_float, then accumulates into two float vectors.RowwiseMomentsImplalready computeskVecSizeandm0_addcorrectly for this. Then:N < kSmallNThresholdonly.map3lambda onVectorized<float>, so that ATen's convert-through-floatmap3(infunctional_bfloat16.h) is selected. Alternatively, dropmap3and use the scalar normalize loop, which clang auto-vectorizes. On arm64 that changed time by −4% to +6%, with the same accuracy.RowwiseMoments,static_assertthatTis float, double, Half, or BFloat16. Replace theshorttest case with Half/BF16 cases.Prototype accuracy. The prototypes are a standalone harness, not gtest. Timings are in the Problem 2 table. The table shows max abs error of
outwith M=64; "floor" is the error of the correctly rounded result.Notes:
BFloat16LargeRowstest fails on the width-514 row: the mean is −8.67e-9 instead of 0, whileoutandrstdstay exact. That assertion usesEXPECT_TENSOR_EQand needs a tolerance. (1) alone passes it.Suggested tests
EXPECT_TENSOR_CLOSE_WITH_TOLwith atol at the rounding floor. The default Half/BF16 atol (1e-3 / 1e-2) is tighter than one output ulp at |out| ≈ 2–4.eps=1e-12.moments_utils_test.cpp:The investigation, the C++ harness and prototypes, and this issue were done with Claude Code.
Versions
-O3, ATen vec headers from torch 2.13.0.cc @larryliu0820 @manuelcandales @JakeStevens