Reduce the cost of hull and intersect_interval - #772
Conversation
Benchmark Results (Julia v1)Time benchmarks
Memory benchmarks
|
EDIT: Oh nevermind, the performance gap is for |
|
Yes, the main idea here is to never produce NaNs so you never have to check for them (this is why #771 had to come first). That check is expensive because it introduces branching, and branches break things like vectorization (https://en.wikipedia.org/wiki/Automatic_vectorization, see sections on control flow). Opting into the check gives you a way to easily check the validity of your code; but to be fast, the decision about whether to omit the safety check must be made at compile time. The trick: if it's a package-wide setting rather than a type parameter, it has to depend on a setting that makes it into Julia's "is this precompiled cache valid given the settings for this session?" query. Otherwise Julia might end up picking, e.g., an "unsafe" precompiled cache in a project where you've deliberately enabled the check. There is one alternative to Julia's own settings: Preferences.jl, using a compile-time constant. If you agree with the overall idea of setting this choice at compile time, which of these options seems best to you? Any of these could be activated as a special build step on CI, if you're prepared to wait for that one job (it will be a little slower, but not slower than it is now). |
Then why not modifying EDIT: to be clear; the idea would be to make |
Really? I mean it's just |
|
I did not try it, but I now convinced myself that we can just drop the What am I missing? |
|
It's completely optional. I added it as an escape hatch to catch regressions; if any future change in the package does start producing NaNs, turning that check on will catch them directly at the source rather than letting them cascade through a bigger computation. That's the only advantage it provides, and that may not be much of one since you can also just try to trace back once you observe NaNs. To use this to isolate the source of the NaNs would require starting a fresh Julia session with the option turned on.
It's not the evaluation of the Bool that's expensive; it's the change it causes in the compiled code. If you've never seen the "train switch station analogy," you might want to read the first answer to this question; it's entertaining and where I first learned about these issues. (Claude helped me find it again, and informed me that it's still the most upvoted stackoverflow question of all time!) I'm happy to ditch the check altogether if that's what you prefer. |
|
Thx for the link. I thought that our ifelse as much as possible.That's also the case for normalisezero, which here seems "free".
My vote is to remove the mechanism with For now there is just my two minor comments on the PR. |
|
The branching is in Here's a tally of the LLVM:
|
|
Ah of course, for some reason I got fixated on the normalizations involved in |
|
Glad it helps. Let me know what you decide about
and then I'll make any further changes. (I addressed your two review points in the most recent force-push.) |
|
@Kolaru is going to run some checks for IntervalRootFinding.jl just to be sure. I think the mechanism |
|
I ran the tests of IntervalRootFinding and TaylorModels with this PR and everything seems fine. So it looks like this PR doesn't break much downstream. Regarding However, the idea could be added to the mechanism proposed in #637, together with other checks at construction (e.g. loss of guarantee). |
|
Fantastic thanks for running these checks. @timholy once you removed the |
Empty floating-point bare intervals were stored as `(NaN, NaN)`. Every call to `inf`, `sup`, or `bounds` then translated those fields into the bounds required by IEEE 1788. Store `(typemax(T), typemin(T))` directly, as was already done for rational bounds. This makes `sup` and `bounds` plain field accesses; `inf` only needs to restore a negative zero lower bound. Bounds that are not `NaN` become a requirement of the internal constructor `_unsafe_bareinterval`; the checked constructors already reject `NaN`. This speeds up addition and subtraction by about 1.4x, multiplication by 1.2x, and `mid` by 2.3x. Refs JuliaIntervals#585. Assisted-by: Claude Opus 5 <noreply@anthropic.com>
The bounds of an empty interval are neutral under `min` and `max`; for
intersection, an empty operand makes the computed lower bound exceed the
upper bound. The bare operations can therefore read stored bounds
directly without a separate emptiness check.
For decorated intervals, the minimum decoration is `ill` exactly when an
operand is NaI, and the same value supplies `dec = :auto`. After
excluding NaI, `dec = :default` is always `trv` and needs no further
adjustment.
The variadic methods now compute the decoration minimum and
`isguaranteed` conjunction once, then reduce only the bare intervals.
Dedicated variadic `BareInterval` methods also fix the previous
`MethodError` caused by forwarding an unsupported `dec` keyword.
On Julia 1.12.6, pairwise `hull` for `Interval{Float64}` improves from
6.2 ns to 2.2 ns, four-argument `hull` from 17.0 ns to 3.8 ns, and
pairwise `intersect_interval` from 6.3 ns to 2.0 ns.
Refs JuliaIntervals#585.
Assisted-by: Claude Opus 5 <noreply@anthropic.com>
|
Done! I'm happy with it once it passes CI, merge at will. I also added some notes to #637 and linked back here. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #772 +/- ##
==========================================
+ Coverage 73.77% 74.27% +0.49%
==========================================
Files 32 32
Lines 3047 3090 +43
==========================================
+ Hits 2248 2295 +47
+ Misses 799 795 -4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This follows the discussion in #585: can empty intervals be handled more cheaply, and can variadic set operations inspect their arguments only once? Both are possible after changing the internal representation of an empty interval, as @OlivierHnt suggested.
The prerequisite
mincefix is already merged in #771.Empty-interval representation
An empty
BareInterval{<:AbstractFloat}was stored as(NaN, NaN). Every call toinf,sup, orboundsthen had to translate those fields into the bounds required by IEEE 1788. Empty intervals are now stored as(typemax(T), typemin(T)), as they already were forRational:The only remaining normalization restores the standard's
-0lower bound.The implementation now relies on stored bounds never being
NaN.is_valid_intervalrejectsNaNin checked constructors, and--check-bounds=yeschecks the invariant in the internal constructor. The check compiles away otherwise becausecheck_boundsis part of Julia's precompilation cache key.The package's exported callables were swept over empty, entire, thin-zero, half-bounded, and ordinary intervals at the supported bound types. The only operation that produced a stored
NaNwasminceon empty or unbounded intervals, which #771 fixed.Set operations
The new representation removes the special empty case from both operations. The empty bounds are neutral under
minandmax, while an empty operand ofintersect_intervalmakes the computed lower bound exceed the upper bound:For decorated intervals, the minimum decoration is
illexactly when an operand is NaI. The same minimum supplies the result fordec = :auto. After excluding NaI,dec = :defaultis alwaystrv, so it needs no further decoration adjustment.The variadic methods now reduce the bare intervals and compute the decoration minimum and
isguaranteedconjunction once. Dedicated variadic methods forBareIntervalalso fix aMethodError: the previous generic fallback passed adeckeyword to a method that does not accept one.Benchmarks
Measured on an i7-14700KF with Julia 1.12.6:
hull(bare, bare)hull(Interval, Interval)hull(w, x, y, z)intersect_interval(Interval, Interval)In loops over 1000-element vectors, consuming both bounds of every result:
reduce(hull, v)out[i] = hull(x[i], y[i])reduce(intersect_interval, v)out[i] = intersect_interval(x[i], y[i])Cheaper bound access also improves other operations: addition and subtraction by about 1.4×, multiplication by 1.24×, and
midby 1.3× onIntervaland 2.3× onBareInterval. There is no allocation change forBigFloatorRational.Testing
New tests cover the representation invariants, empty and NaI operands, decoration semantics, variadic and pairwise agreement,
isguaranteedpropagation, and bound-type promotion.