Skip to content

perf: track decimal overflow without rescanning results - #5044

Open
peterxcli wants to merge 7 commits into
apache:mainfrom
peterxcli:perf/wide-decimal-one-pass-with-overflow-check
Open

perf: track decimal overflow without rescanning results#5044
peterxcli wants to merge 7 commits into
apache:mainfrom
peterxcli:perf/wide-decimal-one-pass-with-overflow-check

Conversation

@peterxcli

@peterxcli peterxcli commented Jul 26, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #4943.

Rationale for this change

WideDecimalBinaryExpr previously allocated a null-masked result for every non-ANSI batch, even when nothing overflowed. DecimalRescaleCheckOverflow avoided 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?

  • Track overflow during WideDecimalBinaryExpr binary arithmetic using a per-evaluation Cell<bool>.
  • Apply the same approach to DecimalRescaleCheckOverflow unary evaluation.
  • Run the allocating null-masking pass only when overflow actually occurs.
  • Add Scalar × Scalar legacy-overflow regression coverage.
  • Add Criterion cases covering no overflow, nulls, overflow density, overflow at the end of a batch, and ANSI mode.

How are these changes tested?

  • cargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr wide_decimal_binary_expr
  • cargo test --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr decimal_rescale_check
  • cargo clippy --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --all-targets -- -D warnings
  • cargo check --manifest-path native/Cargo.toml -p datafusion-comet-spark-expr --bench wide_decimal
  • cargo fmt --manifest-path native/Cargo.toml --all -- --check
  • git diff --check

Paired Criterion runs used two independent comparisons on Apple Silicon.

WideDecimalBinaryExpr

Compared with 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

DecimalRescaleCheckOverflow

Compared with 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

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 WideDecimalBinaryExpr to eliminate its sentinel, Cell<bool>, and masking pass. The corresponding DecimalRescaleCheckOverflow cleanup is tracked in #5094.

@peterxcli
peterxcli marked this pull request as ready for review July 26, 2026 21:00

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@peterxcli
peterxcli requested a review from andygrove July 28, 2026 16:22
@peterxcli

Copy link
Copy Markdown
Member Author

@andygrove genteelly ping, I've added the comment as your review, do you think this is ok to merge now? Thanks!

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread native/spark-expr/benches/wide_decimal.rs
Comment thread native/spark-expr/src/math_funcs/wide_decimal_binary_expr.rs Outdated
Comment thread native/spark-expr/src/math_funcs/wide_decimal_binary_expr.rs Outdated
Comment thread native/spark-expr/src/math_funcs/wide_decimal_binary_expr.rs
@peterxcli
peterxcli requested a review from andygrove July 31, 2026 16:07
@mbutrovich
mbutrovich self-requested a review July 31, 2026 16:27
Comment thread native/spark-expr/src/math_funcs/internal/decimal_rescale_check.rs Outdated
@peterxcli peterxcli changed the title perf: skip wide decimal null masking when nothing overflows perf: track decimal overflow without rescanning results Jul 31, 2026
@peterxcli
peterxcli requested a review from mbutrovich August 1, 2026 02:58

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread native/spark-expr/src/math_funcs/wide_decimal_binary_expr.rs
return Err(ArrowError::ComputeError("Arithmetic overflow".to_string()));
}
// Sentinel value — will be nullified by null_if_overflow_precision
overflowed.set(true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filed #5309. also update PR description to include it

@peterxcli peterxcli left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andygrove thanks for another round of review, refine the comments you pointed out and file #5309 as followup per your inline comment.

Comment on lines -200 to -206
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.

@peterxcli peterxcli Aug 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5044 (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.rs are gone, and neither guard has a replacement, so both call sites are now a bare if 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 set null_if_overflow_precision leaves untouched, so the pass can only ever null sentinels. It is also worth saying that ANSI mode returns Err before 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 at decimal_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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filed #5309. also update PR description to include it

@peterxcli
peterxcli requested a review from andygrove August 8, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize WideDecimalBinaryExpr: skip the null-masking pass in non-ANSI mode when nothing overflows

3 participants