perf: track decimal overflow without rescanning results - #5044
Conversation
andygrove
left a comment
There was a problem hiding this comment.
Nice targeted perf fix, and the new bench shapes make the win legible. One small ask: the sibling optimization in decimal_rescale_check.rs:200 carries a comment explaining that i128::MAX is the overflow sentinel and that contains short-circuits so no-overflow batches skip the extra allocation. Could we add the same comment on the new guard in wide_decimal_binary_expr.rs:284 so both call sites read the same way? Without it, a reader landing on this line has to jump to the doc comment on check_overflow_and_convert to figure out why i128::MAX is the value being probed.
|
@andygrove genteelly ping, I've added the comment as your review, do you think this is ok to merge now? Thanks! |
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the update, the comment reads much better alongside the sibling in decimal_rescale_check.rs now.
I went through the equivalence argument carefully and I agree the guard is sound. try_binary zero-fills null slots rather than leaving stale values, so the scan cannot false-positive on a null. A non-overflowing result is clamped to ±(10^p_out - 1), and 10^38 - 1 is about 9.99e37 against i128::MAX at roughly 1.70e38, so a legitimate value can never collide with the sentinel. And null_if_overflow_precision nulls exactly the values outside ±(10^p - 1), which is exactly the sentinel set. So when no sentinel is present the pass really is a no-op and the output stays bit-identical.
CI is fully green.
I have a few things I would like to resolve before this merges. The main one is that neither new overflow benchmark actually measures the shape that could regress, so we do not yet have evidence that the overflow path is unaffected. Details inline.
andygrove
left a comment
There was a problem hiding this comment.
I measured both benches before and after on this branch to fill in the shapes that are not in the description. Two independent comparisons each, on Apple Silicon.
benches/wide_decimal.rs, against 1104c78e^:
| Shape | Before | After | Change |
|---|---|---|---|
| no overflow | 60.63 µs | 54.52 µs | 10.1% faster |
| no overflow, nulls | 67.70 µs | 58.69 µs | 13.3% faster |
| sparse overflow | 58.18 µs | 59.11 µs | 1.6% slower |
| dense overflow | 58.47 µs | 61.39 µs | 5.0% slower |
| overflow at end of batch | 58.78 µs | 60.95 µs | 3.7% slower |
| ansi no overflow | 54.32 µs | 54.53 µs | unchanged |
benches/decimal_rescale.rs, against 07944c8a^:
| Shape | Before | After | Change |
|---|---|---|---|
| scale up, no overflow | 10.57 µs | 7.52 µs | 28.8% faster |
| scale up, no overflow, nulls | 16.33 µs | 13.61 µs | 16.7% faster |
| scale down, no overflow | 34.09 µs | 30.99 µs | 9.1% faster |
| sparse overflow | 13.99 µs | 14.12 µs | 0.9% slower |
| dense overflow | 16.27 µs | 17.15 µs | 5.4% slower |
| ansi scale up, no overflow | 7.33 µs | 7.33 µs | unchanged |
Two things come out of this.
The decimal_rescale_check.rs half is the bigger win of the two, at 28.8% on scale up, no overflow. benches/decimal_rescale.rs already existed with matching shapes, so those numbers come for free. Could we get them into the description? The table as it stands understates what this PR does.
The other one I would like to correct before this merges. I could not reproduce "No stable change across reruns" for the overflow shapes. dense overflow and overflow at end of batch are consistently 3% to 5% slower at p = 0.00, in both expressions. Note that overflow at end of batch has exactly one overflowing row, so a single Cell store cannot account for 3.7%. The cost looks like passing &Cell<bool> into the closure making the write an observable side effect, which inhibits optimization of the whole kernel loop. That would also explain why the no-overflow win is 10% rather than the roughly 14% the skipped allocation alone should buy.
I think the trade is clearly the right one. Overflow-bearing decimal batches are the rare case, and the common path gets 10% to 29% faster. I would just rather the description say that plainly than assert the overflow path is unchanged. Does that match what you see locally?
For the record, I also went through the equivalence argument for the new decimal_rescale_check.rs guard and it holds. Both overflow branches in rescale_and_check return Err before overflowed.set(true), so dropping the !fail_on_error condition is safe, and try_unary only invokes the closure on valid indices so nulls cannot set the flag.
CI is green.
| return Err(ArrowError::ComputeError("Arithmetic overflow".to_string())); | ||
| } | ||
| // Sentinel value — will be nullified by null_if_overflow_precision | ||
| overflowed.set(true); |
There was a problem hiding this comment.
Now that the flag records the overflow, the i128::MAX sentinel and the masking pass it feeds are both redundant. Would you be up for filing a follow-up issue to drop them and write the null bit directly at the point the overflow is detected? That would remove the extra pass and the Cell side effect, which is what is costing the 3% to 5% on the overflow shapes.
Clearly out of scope here. I would just like it tracked rather than left implicit, and a link to the issue in this thread would be enough.
There was a problem hiding this comment.
filed #5309. also update PR description to include it
peterxcli
left a comment
There was a problem hiding this comment.
@andygrove thanks for another round of review, refine the comments you pointed out and file #5309 as followup per your inline comment.
| let result = if !fail_on_error && result.values().contains(&i128::MAX) { | ||
| // The rescale pass writes i128::MAX as an overflow sentinel for values that | ||
| // do not fit the output precision. Only when a sentinel is present do we need | ||
| // the extra null-masking pass (which allocates a new array); `contains` | ||
| // short-circuits at the first sentinel, so the common no-overflow case skips | ||
| // that allocation entirely. ANSI mode raises on overflow and never produces a | ||
| // sentinel, so it also skips this pass. |
There was a problem hiding this comment.
This dropped the only comment in the codebase explaining why skipping the masking pass is safe. The six lines removed from
decimal_rescale_check.rsare gone, and neither guard has a replacement, so both call sites are now a bareif overflowed.get().
The invariant is not obvious from reading the guard. It rests on the fact that every non-sentinel value the closure returns is already clamped inside±(10^p_out - 1), which is exactly the setnull_if_overflow_precisionleaves untouched, so the pass can only ever null sentinels. It is also worth saying that ANSI mode returnsErrbefore setting the flag, so ANSI still skips the pass for the same reason it did before.
Could we add that back at both sites, here and atdecimal_rescale_check.rs:212? We worked the argument out in this thread, and it would be good for it to live in the code rather than only in the PR conversation.
I guess this comment should be here.
| return Err(ArrowError::ComputeError("Arithmetic overflow".to_string())); | ||
| } | ||
| // Sentinel value — will be nullified by null_if_overflow_precision | ||
| overflowed.set(true); |
There was a problem hiding this comment.
filed #5309. also update PR description to include it
Which issue does this PR close?
Closes #4943.
Rationale for this change
WideDecimalBinaryExprpreviously allocated a null-masked result for every non-ANSI batch, even when nothing overflowed.DecimalRescaleCheckOverflowavoided that allocation but still scanned the completed output buffer for an overflow sentinel.Both expressions already know when overflow occurs while evaluating each value. Recording that state during evaluation avoids rescanning the result and skips null masking entirely for no-overflow batches.
What changes are included in this PR?
WideDecimalBinaryExprbinary arithmetic using a per-evaluationCell<bool>.DecimalRescaleCheckOverflowunary evaluation.How are these changes tested?
cargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr wide_decimal_binary_exprcargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr decimal_rescale_checkcargo clippy --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --all-targets -- -D warningscargo check --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --bench wide_decimalcargo fmt --manifest-path native/Cargo.toml --all -- --checkgit diff --checkPaired Criterion runs used two independent comparisons on Apple Silicon.
WideDecimalBinaryExprCompared with
1104c78e^:DecimalRescaleCheckOverflowCompared with
07944c8a^:The common no-overflow path improves by 9.1% to 28.8%. Overflow-bearing batches regress by up to 5.4%; this is an accepted trade-off for the common-path improvement. Follow-up #5309 tracks writing nulls directly in
WideDecimalBinaryExprto eliminate its sentinel,Cell<bool>, and masking pass. The correspondingDecimalRescaleCheckOverflowcleanup is tracked in #5094.