perf(conditional_join): compensated prefix sum for float join_agg ranges - #1675
Open
samukweku wants to merge 4 commits into
Open
perf(conditional_join): compensated prefix sum for float join_agg ranges#1675samukweku wants to merge 4 commits into
samukweku wants to merge 4 commits into
Conversation
Replace the forward float32/float64 sum kernels behind join_agg (suffix, prefix, and arbitrary-interval ranges) with a Neumaier-compensated prefix sum built once per column, answering each range query in O(1) instead of Rust re-scanning every overlapping range from scratch. Gated by a work-estimate heuristic (only worthwhile once total queried width clears ~150x the array size - otherwise Rust's native per-range scan is cheaper) and two correctness guards that fall back to Rust whenever single-level compensation can't be trusted: a real +/-inf or overflowing partial sum, and extreme within-array dynamic range (found during testing: a big excursion followed by another huge opposite-signed excursion can silently lose 100% of a later small value's contribution, well before anything overflows). Restricted to float dtypes; the integer counterpart is tracked separately in #1648. Also fixes a pre-existing duplicate _sum_starts_ends definition. Issue #1671
Contributor
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
float32/float64sumrange kernels behindjoin_agg(suffix_sum_starts, prefix_sum_ends, andarbitrary-interval
_sum_starts_ends) with a Neumaier-compensatedprefix sum built once per column, so each range query becomes O(1)
instead of Rust re-scanning every overlapping range from scratch.
separately in [PERF] Use prefix sums for conditional_join range aggregations (integer dtypes) #1648 (a plain
np.cumsumis enough there — nocompensation needed).
_prefix_sum_is_worthwhile): theone-time O(n) Python build only pays off once the total width summed
across all queried ranges clears roughly 150x the array size —
otherwise Rust's native per-range scan is cheaper overall, even though
it re-scans overlaps. Below that threshold this defers straight to
Rust.
single-level compensation can't be trusted for the whole array: a real
+/-infor a genuinely overflowing partial sum, and extremewithin-array dynamic range (see ELI5).
_sum_starts_endsdefinition foundwhile touching this function (the second copy silently shadowed the
first; both were identical, so no behavior change).
ELI5
Summing a bunch of overlapping ranges the old way means re-adding the
same numbers over and over — once per range. A prefix sum writes down
the running total once, so any range's sum is just two lookups and a
subtraction. That's the win in #1648 for integers.
Floats are trickier because the existing Rust kernels already use
compensated (Kahan) summation to stay accurate — they track a
"leftover" alongside the running total to avoid losing precision when a
tiny number gets added to a much bigger one. So this PR builds a
compensated prefix instead of a plain one, using the same idea
(Neumaier's variant) so range subtraction stays close to summing that
slice directly.
Two things can break that, both found via randomized testing rather than
by inspection:
+/-inf(that'snormal float behavior). But once the prefix's running total hits
+/-inf, subtracting two+/-infprefix entries for a later rangethat never touched the huge numbers gives
inf - inf = NaN— wrong.Rust's kernels don't have this problem because they recompute each
range from scratch.
just an ordinary float. If it's already absorbed a moderate correction
(say, size
~1e12), it can no longer represent a much later, muchtinier correction (say
~1e-6) — that increment is too small toregister against the leftover's own precision, the same way
1e12 + 1doesn't change a value that's already around
1e20. In testing, a bigexcursion followed by another huge, opposite-signed excursion followed
by a small value made a single-element window silently return
0.0instead of the true (tiny) value — 100% relative error, with nothing
ever overflowing to catch.
Both are handled by falling back to Rust for the whole array when either
condition is detected, rather than risking either failure mode.
Kahan and Neumaier can still round differently on cancellation-heavy data even
when both are accurate. The numerical contract therefore uses
math.fsumasthe high-precision oracle and bounds forward error by
8 * float64_epsilon * sum(abs(inputs)). A result-relative-only tolerance isnot meaningful here because cancellation can make the result arbitrarily close
to zero. The tests apply this same scale-aware contract to both the new prefix
path and the current Rust baseline.
Benchmark
Suffix sum (
_sum_starts, single<join), median of 3 runs,float64, no nulls:The "many overlapping ranges" case is what #1648/#1671 target — Rust's
cost there grows with the sum of all range widths, not the array size,
so it degrades badly as overlap increases (note it's already ~1000x
slower than the O(n+m) approach going from n=1,000 to n=20,000 with full
overlap). The "single query" rows are the case an earlier review pass on
this PR caught as a regression before the work-estimate heuristic was
added: a small number of queries against a large array used to pay the
full O(n) prefix-build cost for almost no reuse (~100x slower than
Rust in that case); the heuristic now correctly defers to Rust instead.
These are development-machine measurements, not a committed benchmark
suite (per this repo's convention, the one-off script used to gather
them isn't included in the diff).
Test plan
tests/functions/test_conditional_join_prefix_sum_float_agg.py(28 tests): scale-aware accuracy-contract tests against
math.fsumforboth the prefix path and the existing Rust baseline across
float32/float64and all three kernels; bitwise parity forwell-scaled data; null/all-null/empty-range handling; regressions for
overflow, extreme dynamic range, and cancellation-heavy duplicate
queries that cross the dispatch threshold; heuristic coverage; and
end-to-end
join_aggcoverage of both dispatch paths.test_conditional_join.pysuite (241 tests,including all hypothesis-based
*_aggproperty tests) passesunchanged.
math.fsum(base scales from 1e-80 to 1e80, within-array spreads up to 1e15,
random null placement and ranges): every accepted prefix stayed
within the
8 * eps * sum(abs(inputs))contract; worst observederror was ~0.995 epsilon-scaled units.
-W error::RuntimeWarning(no leakedoverflow/invalid-value warnings from the expected-and-handled
overflow/dynamic-range paths).
pixi run lintclean on touched files.opened, which caught the single-query-against-large-array
regression addressed by the work-estimate heuristic.
Related: #1648 (integer counterpart), #1653/#1654 (min/max/prod range
algorithms), pyjanitor-devs/janitor-rs#23 (reverse aggregation kernels,
out of scope here).