Repository navigation
simd: mul_add rounds once on every backend (wasm emulation, exact scalar FMA, no libm) - #374
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
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. |
simd128 has no FMA, and relaxed-simd's relaxed_madd may or may not fuse depending on the engine. wasm mul_add was mul-then-add (two roundings), or relaxed_madd under relaxed-simd, so it differed from every other backend. - F32x16: each lane in f64, where a * b is exact; the f64 sum is rounded to odd (two-sum error, then one ulp toward it when inexact and even), so the single rounding to f32 is correct, subnormals included (Boldo & Melquiond, IEEE TC 2008). - F64x8: Dekker exact product, two-sum with c, rounded-to-odd sum of the error terms, one final rounding (the same paper's emulated FMA). A v128 pair with a lane outside 2^-460 <= |a|, |b| <= 2^460, |c| <= 2^960 (or NaN, inf, zero) is computed lane by lane with f64::mul_add. The relaxed-simd branches are gone. Parity group 0x11xx compares both against the element mul_add bit for bit (NaN matches NaN): random bit patterns over every exponent, near-cancelling c, ties that double rounding breaks the wrong way, and lanes that take the f64 fallback; anti-vacuity requires the unfused result to differ on at least 256 lanes per type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
The mul_add parity group calls scalar f32/f64::mul_add as its reference, which links compiler-builtins' libm fma/fmaf. Those carry fma_with_fma / fma_with_fma4 variants picked at runtime by CPUID (the Nehalem qemu run takes fma_fallback and passes), and their vfmadd* lines tripped the "no AVX instruction" scan. The scan now skips exactly those four compiler_builtins functions; any other AVX op still fails it (checked against libc.so.6: 9620 hits remain). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
The Sandy Bridge scan hit the same four compiler_builtins fma/fmaf CPUID-dispatched variants as the v2 scan. Locally: 4 hits before, 0 after on the avx-qemu parity binary; the filtered scan still reports 4481 AVX2/FMA/F16C lines in libc.so.6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
…A scan" This reverts commit d3dbea2.
…o-AVX scan" This reverts commit b0fd6a6.
…truction On targets without a compile-time FMA instruction, f32/f64::mul_add is a libm call that picks an FMA3, FMA4 or software path by CPUID at run time. The polyfill selects its realization at compile time, so the per-lane mul_add of the scalar, AVX-without-FMA and x86-64-v2 backends, and the wasm F64x8 lanes outside the vector range, now use simd_fma::ExactFma: - with target_feature = "fma" (x86) or on aarch64: mul_add, which lowers to the instruction; - otherwise f32 through f64 with the sum rounded to odd, and f64 in integer arithmetic (exact sum in a 128-bit window with a sticky bit, one rounding). The 0x11xx parity group now compares against its own exact reference (a 4608-bit integer sum rounded to nearest-even) instead of the element mul_add, so the parity binary links no libm fma at all. Both the emulation and the reference are checked against hardware FMA in unit tests (400k + 300k cases plus all special-value triples). This replaces the CI-scan exclusions of libm's CPUID-dispatched fma, which are reverted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
…imd_ops - simd_avx2 F32x16/F64x8::mul_add were a per-lane scalar loop even on AVX2 + FMA. They now call the halves' F32x8/F64x4::mul_add: _mm256_fmadd_ps/_pd with target_feature = "fma", the exact scalar FMA per lane without it. F64x4 gains mul_add; F32x8's non-FMA branch uses the exact scalar FMA instead of f32::mul_add. - simd_ops gains mul_add_f32 / mul_add_f64 (re-exported from ndarray::simd): the polyfill's single-rounding scalar FMA, so tails and consumers (wasm included) do not hand-roll one or fall back to f32::mul_add. The add_mul and gemm tails use it; the gemm doc no longer claims wasm is unfused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
The no-std builds (tests/1.99.0 without std, nostd/thumbv6m, blas-msrv) compiled simd_fma while every caller (simd, simd_ops, simd_wasm, simd_avx512) is std-gated, so -D warnings failed on dead code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
Nine scalar f32/f64::mul_add calls in hpc (randomized_signature, signature_pde, renderer) and the two lane loops of BF16x16/F16x16::fma called the std method, which on a target without the fma feature is a call into compiler_builtins' run-time-dispatched fma. They now go through the polyfill's exact scalar FMA. Results are unchanged: both paths are the correctly rounded a*b+c. The BF16/F16 double rounding (f32 FMA, then narrowing) is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
The f64 tie rounds always added a tiny c that broke the tie, so the final rounding never saw an exact midpoint: a ties-away final rounding in simd_fma::fma_f64 passed the whole group on the v2 arm (it was caught only by simd_fma's own unit test). Round 4 now cancels the product's middle terms so a*b + c is exactly halfway between two f64 neighbours, with the even neighbour below on some lanes and above on others, in both signs, over 2^-900..2^900. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
fdaf898 to
b41760d
Compare
… item closed The 2026-10-11 open item and coresearch row X11 predate #374, which made wasm mul_add round once and was never recorded here. New entry: fast domains, the measured margin of the F64 guard, the edge fixtures and their disable runs, V8 timings before and after the exact-product repair, and what is not claimed. X11 regraded CLOSED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAqKMPh2Seh2WLccCksAdY
Summary
mul_addon the float wrappers now rounds once on every backend. This makes it IEEE 754fusedMultiplyAdd: one rounding of the exacta * b + c, bit-identical across all eight realizations. Backend selection stays compile-time only (simd.rs→simd_{arch}.rs). No dependency is added.Before this PR:
F32x16/F64x8computedmulthenadd, which rounds twice. Underrelaxed-simdthey usedrelaxed_madd, which fuses on some engines and not others.f32::mul_add/f64::mul_add. There these lower to a call into the toolchain'scompiler_builtinsfma/fmaf, which picks its implementation at run time by CPUID (fma_with_fma/fma_with_fma4/fma_fallback). That is run-time dispatch inside the polyfill, which the polyfill exists to rule out. It also put VEX FMA instructions into the v2 and AVX binaries, whichsimd-matrix's instruction scans correctly flagged.F32x16/F64x8ranmul_addas a scalar loop even when FMA3 was available.The
mul_addcontract (reference for further SIMD work)Result.
a.mul_add(b, c)is the correctly roundeda * b + c, with round-to-nearest-even and one rounding. Subnormals are included, and NaN in gives NaN out. A backend that has the instruction must use it (FMA is mandatory where the CPU has it). A backend that lacks it must emulate it exactly, never asmul+add, and never through libm.simd.rs)F32x16/F32x8F64x8/F64x4mul_add_f32/f64simd_avx512.rs)_mm512_fmadd_ps_mm512_fmadd_pdmul_add→vfmaddsimd_avx2.rs,F32x8/F64x4insimd_avx512.rs)_mm256_fmadd_pson each half_mm256_fmadd_pdon each halfmul_add→vfmaddsimd_scalar.rs)simd_fma::fma_f32)simd_fma::fma_f64)simd_neon.rs)vfmaq_f32vfmaq_f64mul_add→fmaddsimd_wasm.rs)simd_fma::fma_f64simd_fmacore::simd(simd_nightly/)StdFloat::mul_addStdFloat::mul_addWhere it lives.
src/simd_fma.rs(new,pub(crate)) holds the oneExactFmatrait. Withtarget_feature = "fma"on x86, and on aarch64, it ismul_add, i.e. the hardware instruction. Otherwise it is the exact integer emulation:fma_f32: the product is exact in f64. TwoSum gives the error ofp + c, the sum is rounded to odd (one ulp toward the error when inexact and even), and the single rounding to f32 is then correct (Boldo & Melquiond, Emulation of FMA and correctly rounded sums: proved algorithms using rounding to odd, IEEE Trans. Computers 57(4), 2008).fma_f64: the significands multiply exactly in au128,cis aligned into the same window with a sticky bit, and the sum is rounded to nearest-even once, including into the subnormal range and overflow.crate::simd::mul_add_f32/mul_add_f64(insimd_ops.rs, re-exported fromsimd.rs) are the scalar entry points. Scalar tails such asadd_mul_f32/f64and thegemm_f64_tiled_fmatail now use them instead of hand-rollingf32::mul_add.simd_wasm.rs) keeps the vector path from the first commit:F32x16: promote, exact f64 product,add_round_to_odd, demote.F64x8: Dekker split (2^27 + 1), TwoSum, round-to-odd sum of the error terms, one final rounding.F64x8path is exact only while the split cannot overflow or underflow (2^-460 <= |a|, |b| <= 2^460,|c| <= 2^960, finite, non-zero). A v128 pair with any lane outside that range goes lane by lane throughsimd_fma::fma_f64, no longer throughf64::mul_add.Reverted. An intermediate commit whitelisted the libm
fmasymbols in thesimd-matrixinstruction scans (a03450e, ebefc2f). That hid the run-time dispatch instead of removing it, and is reverted (b21226b, c6b285d). The workflow is identical tomaster.Verification
Parity group
0x11xxincrates/simd-masking-parity:mul_addbit-exactly against an independent reference,oracle_fma_f32/oracle_fma_f64. The reference is a 4608-bit integer place-value sum and contains nomul_add.c, exact f32 and f64 ties that a tinycmust break, exact f64 midpoints that must round to the even neighbour, and f64 lanes outside the wasm fast range.oracle_testschecks the reference itself against hardware FMA.All 8 arms on
b41760d, 18 groups bit-identical: native v4, native v3, avx-qemu (Sandy Bridge), v2-qemu (Nehalem), neon-qemu, wasm (simd128), wasm-scalar, nightlycore::simd. Each arm prints its own CPU flags, and those were read.The unmodified
simd-matrixscans on the parity binaries: v2 (%ymm|%zmm|%k[0-7]|VEX xmm) gives 0 hits. avx (AVX2 ymm integer ops, FMA3, F16C) gives 0 hits.objdump -tfinds nofma_with_fma*symbol in either binary.simd_fmaunit tests (on v4 against hardwaremul_add; also run on v2): 200,000 f32 and 200,000 f64 random cases; more than 1000 of them must differ from unfused, so the test has teeth. Special values are checked too: every triple drawn from {±0, ±1, 1.5, 3,MIN_POSITIVE, ±smallest subnormal, ±MAX, ±inf, NaN}, which covers signed-zero sums, overflow and subnormal results.Disable runs, each red:
0x112e0x11500x1110simd_fma::fma_f32round-to-odd, on the v2 arm0x112e, plus thesimd_fmaunit testsimd_fma::fma_f64ties-to-even → ties-away, on the v2 arm0x1160, plus bothsimd_fmaunit testsThe last row first came back green in the parity group (the unit test alone caught it). Every f64 tie round added a tiny
cthat broke the tie, so the final rounding never saw an exact midpoint. Round 4 (b41760d) now cancels the product's middle terms, soa*b + cis exactly halfway between two f64 neighbours. The even neighbour is below on some lanes and above on others, in both signs, over2^±900. The D2 row was re-run after that change and still fails at0x1150. The two f32 wasm rows were run before b41760d, which does not touch the f32 rounds. All disable runs were done before the rebase onto simd: saturating narrows down the 512-bit ladder (16x32 -> 32x16 -> 64x8) #373. The rebase only merged the narrow ladder next to this code; themul_addpaths are unchanged.Clippy
--all-targets -D warningson v4, v3 and v2: clean. No-std (--no-default-features, also withapprox,serde) under-D warnings: clean.simd_fmais gated onstdlike every module that uses it (a119298). The CI no-std jobs had failed on b27d401 because of this.Codegen on
b41760d(probe crate,objdump): on v4mul_add_f32/f64is onevfmadd213ss/sd, andF32x16/F64x8::mul_addis onevfmaddon zmm. On v3 the scalar entry points are onevfmadd213ss/sd, and the vectors are twovfmadd213ps/pdon ymm (the halves). On v2 there is no FMA instruction and no libm call: the f32 emulation is inlined, andF64x8callssimd_fma::fma_f64per lane. With constant arguments, v4/v3 and the v2 f32 path fold to the constant; the v2 f64 path does not (see below).Scalar call sites: the 9 scalar
f32/f64::mul_addcalls insrc/hpc(randomized_signature,signature_pde,renderer) and the two lane loops ofBF16x16/F16x16::fmanow callsimd::mul_add_f32/f64(efdc7d3). Their module tests pass on v2, where the emulation is the active path (68 tests).Not covered
simd_fma::fma_f64(u128integer path) does not constant-fold. A call with constant arguments stays a runtime call there, andF64x8::mul_addmakes 8 out-of-line calls per vector.F16/BF16fmainsimd_halfstill rounds twice. That is a separate issue.numeric/impl_numeric.rs(var/std) calls the genericnum_traits::Float::mul_add. On a no-FMA build that still reachescompiler_builtins. It is generic upstream ndarray code and is not changed here.StdFloat::mul_addwas only verified with FMA (v4). It was not checked on a build without FMA.masterafter simd: saturating narrows down the 512-bit ladder (16x32 -> 32x16 -> 64x8) #373 (the narrow ladder) merged; the parity group list now carries bothcheck_narrow_ladderandcheck_mul_add_single_rounding.🤖 Generated with Claude Code
https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg