Repository navigation
Reduce the cost of NaN-safe mode by checking the scalar factor once - #859
KristofferC wants to merge 1 commit into
Conversation
In NaN-safe mode every partial was masked with `iszero(partial) ? zero(y) : y`. The mask only changes the result when the scalar factor is non-finite (for multiplication) or zero/NaN (for division), so check the factor once per tuple operation and only take the masked path in that case. `_isfinite_all` recurses into nested `Dual`s so Hessians also take the fast path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #859 +/- ##
==========================================
- Coverage 91.23% 91.15% -0.08%
==========================================
Files 11 11
Lines 1072 1085 +13
==========================================
+ Hits 978 989 +11
- Misses 94 96 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I looked into this PR (and a few alternatives) in some detail. The speedups are real for several benchmarks, but I'm hesitant to merge it in its current form: Conceptually, I think it checks the wrong thing, and its performance depends on what LLVM does with the branch, which leads to large regressions in some cases. CorrectnessFor However, I think checking the factor is conceptually wrong. The math underlying NaN-safe mode is about the partials: A dual number with a zero partial is merely unperturbed in that direction, i.e., a regular value, and hence its contribution has to be exactly zero, regardless of the factor. Master implements exactly this, for every partial. The PR replaces it with a shortcut (" Additionally, it adds a new requirement for number types: On master, NaN-safe mode only uses I'd recommend to stick with checking for zero partials (see below). But if the factor check is pursued further, I'd at least restrict it to _mul_needs_mask(x::Base.IEEEFloat) = !isfinite(x)
_mul_needs_mask(x::Dual) = _mul_needs_mask(value(x)) | any(_mul_needs_mask, partials(x).values)
_mul_needs_mask(::Real) = true # always use the per-partial maskFor division, PerformanceThe benchmarks in the PR description are minimum times, and I saw quite some variation between runs. So I tried to get more robust numbers (Apple M-series, Julia 1.13.1; every configuration in a fresh process, 10 interleaved rounds, medians with bootstrap 95% CIs; all ratios relative to NaN-safe mode disabled on master). Whole programs (the benchmarks from #858):
For the Rosenbrock benchmarks the PR removes most of the overhead. Some ratios are even below 1×, which suggests that part of the differences is caused by code generation and layout effects rather than the cost of the mask itself. For the same reason I'd take the Single operations in a loop over 1024
The 3.8× regressions for using ForwardDiff, Chairmarks
using ForwardDiff: Dual
f(a, x) = a * x
function g!(out, as, xs)
@inbounds for i in eachindex(out, as, xs)
out[i] = f(as[i], xs[i])
end
return out
end
xs = [Dual(rand(), ntuple(_ -> rand(), 8)...) for _ in 1:1024]
as = rand(1024)
out = similar(xs)
@code_typed g!(out, as, xs) # this PR: `invoke Main.f(...)` in the loop body
@b g!($out, $as, $xs) # NaN-safe mode: master 1.18 μs, this PR 5.18 μsThe other differences are caused by what LLVM does with the branch, which seems hard to control from Julia:
AlternativesI tried a few variants to check whether the approach can be made robust. None of them was faster than master across the board:
RecommendationHence I'd recommend to stick with checking for zero partials, as on master:
All measurements are from a single machine (aarch64), I haven't checked x86. |
Related to #857.
In NaN-safe mode every partial is currently masked with
iszero(partial) ? zero(y) : y. The mask only changes the result when the scalar factor is non-finite (0 * Inf,0 * NaN) for multiplication, or zero/NaN (0 / 0,0 / NaN) for division. This PR checks the factor once per tuple operation (*,/,_mul_partials) and only takes the masked path in that case; otherwise plain*//is used over the tuple._isfinite_allrecurses into nestedDuals (value and partials) so Hessians also hit the fast path. Division by a nestedDualalways uses the masked path. With NaN-safe mode disabled nothing changes (the checks are constantfalse).Benchmarks
Minimum time, Apple Silicon, Julia 1.13-DEV:
(A*x).^2Jacobian, n=30The remaining overhead is one
fsub+fcmp+ well-predicted branch per operation.Notes
Dual{3}^Dual{3}is no longer SLP-vectorized in NaN-safe mode on aarch64 (three scalarfmuls), which adds two failures toSIMDTest.jlwith NaN-safe mode enabled locally (that file already has failures on master on this machine).🤖 Generated with Claude Code