Skip to content

Decimal Add/Sub kernels#8724

Merged
mhk197 merged 8 commits into
developfrom
mk/decimal-add-sub
Jul 23, 2026
Merged

Decimal Add/Sub kernels#8724
mhk197 merged 8 commits into
developfrom
mk/decimal-add-sub

Conversation

@mhk197

@mhk197 mhk197 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Adds shared infrastructure for decimal arithmetic and native Add/Sub kernels.

Operand alignment

The execution kernel requires both operands to have the same precision and scale (nullability may differ). Addition and subtraction can then operate directly and exactly on the unscaled integers because both values use the same power-of-ten factor.

Vortex's optional coercion pass can align operands with different decimal dtypes before execution. Query-engine integrations may instead insert their own planner casts. If neither layer aligns the operands, the kernel rejects them rather than performing an implicit per-kernel rescale.

Result precision

Adding or subtracting two p-digit integers can require one additional carry digit. This PR follows the Arrow/Hive result-type rule for already aligned operands, capped at Vortex's maximum decimal precision:

decimal(p, s) +/- decimal(p, s) -> decimal(min(p + 1, 76), s)

The scale remains unchanged because the operation is performed on integers at the same scale. For example:

decimal(2, 0): 60 + 60 = 120 -> decimal(3, 0)
decimal(5, 2): 123.45 + 0.55 = 124.00 -> decimal(6, 2)

Widening the logical precision can also widen the native storage used for the result. The kernel executes at the smallest native width capable of representing the result dtype, including the important decimal(18, s) -> decimal(19, s) (i64 -> i128) and decimal(38, s) -> decimal(39, s) (i128 -> i256) transitions. Inputs may already use a wider physical representation; they are converted into the selected working width before arithmetic.

At precision 76 the result dtype cannot widen to precision 77, so it stays at precision 76 and the kernel explicitly checks the mathematical result against the precision-76 bound.

Nulls, constants, and errors

  • Output nullability is the union of the input nullabilities, while a lane is valid only when both input lanes are valid.
  • Validity is combined and materialized once. Arithmetic runs only on valid lanes, so arbitrary values underneath null lanes cannot trigger an overflow error.
  • Any native-width overflow or result-precision overflow on a valid lane returns an error for the operation.
  • Constant and all-null operands are handled directly instead of first materializing full decimal buffers. Constant folding is preserved, and empty Add/Sub inputs produce a correctly widened empty dtype without evaluating a constant value.
  • Decimal Mul/Div remain unsupported because they require separate result-type and rescaling rules.

Implementation

  • Splits numeric execution into numeric/{mod,primitive,decimal,tests}.rs, mirroring the compare implementation.
  • Canonicalizes encoded decimal operands through execute::<DecimalArray>, while extracting constant and null operands directly.
  • Uses a checked lane loop at the result's working width, followed by an explicit precision-bound check.
  • Shares widened_buffer from arrays/decimal between comparison and numeric execution.

Comparative performance

Measured over arrays with ~60k elements, using same result width in Vortex and Arrow.

Case Vortex Add Arrow Add Vortex Sub Arrow Sub Vortex speedup
i32, non-null 59.87 us 100.4 us 59.74 us 100.4 us 1.68x
i64, non-null 61.66 us 101.1 us 62.29 us 101.2 us 1.62-1.64x
i128, non-null 68.91 us 145.4 us 69.54 us 145.6 us 2.09-2.11x
i256, nullable 175.4 us 564.7 us 163.2 us 561.5 us 3.22-3.44x

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 20.42%

❌ 2 regressed benchmarks
✅ 1845 untouched benchmarks
🆕 2 new benchmarks
⏩ 46 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation copy_nullable[65536] 1 ms 1.4 ms -24.15%
Simulation copy_non_nullable[65536] 913.7 µs 1,094.3 µs -16.5%
🆕 Simulation add_decimal_i128_nullable N/A 3.9 ms N/A
🆕 Simulation add_decimal_i64_nonnull N/A 1.9 ms N/A

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing mk/decimal-add-sub (6ef8891) with develop (57962f4)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@mhk197 mhk197 added changelog/feature A new feature changelog/performance A performance improvement labels Jul 14, 2026
@mhk197
mhk197 marked this pull request as ready for review July 15, 2026 16:53
@mhk197 mhk197 changed the title Add native decimal Add/Sub kernels Decimal Add/Sub kernels Jul 15, 2026
@mhk197 mhk197 removed the changelog/performance A performance improvement label Jul 15, 2026
Comment thread vortex-array/src/scalar_fn/fns/binary/mod.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Verdict: Likely regression (high confidence)
Attributed Vortex impact: +17.5%
Engines: DataFusion Likely regression (+27.6%, high confidence) · DuckDB No clear signal (+8.2%, medium confidence)
Vortex (geomean): 1.088x ➖
Parquet (geomean): 1.004x ➖
Shifts: Parquet (control) +0.4% · Median polish +6.0%

How to read Verdict and Engines
  • Verdict: Overall PR-level signal after subtracting baseline drift estimated from Parquet control rows. It can be Likely improvement, Likely regression, or No clear signal.
  • Engines: Per-engine attribution. DataFusion is compared against DataFusion/Parquet controls; DuckDB is compared against DuckDB/Parquet controls. This answers whether each engine improved or regressed independently.
  • Confidence: Based on directional consistency, share of rows above the noise floor, and control-run noise.

datafusion / vortex-file-compressed (1.262x ❌, 0↑ 2↓)
name PR 6ef8891 (ns) base 57962f4 (ns) ratio (PR/base)
vortex_q00/datafusion:vortex-file-compressed 🚨 9780004 8197567 1.19
vortex_q01/datafusion:vortex-file-compressed 🚨 6523417 4885078 1.34
datafusion / parquet (0.989x ➖, 0↑ 0↓)
name PR 6ef8891 (ns) base 57962f4 (ns) ratio (PR/base)
vortex_q00/datafusion:parquet 20189605 20638806 0.98
vortex_q01/datafusion:parquet 4583734 4580085 1.00
duckdb / vortex-file-compressed (1.102x ❌, 0↑ 1↓)
name PR 6ef8891 (ns) base 57962f4 (ns) ratio (PR/base)
vortex_q00/duckdb:vortex-file-compressed 10786521 10061599 1.07
vortex_q01/duckdb:vortex-file-compressed 🚨 6987191 6167184 1.13
duckdb / parquet (1.019x ➖, 0↑ 0↓)
name PR 6ef8891 (ns) base 57962f4 (ns) ratio (PR/base)
vortex_q00/duckdb:parquet 23575510 23195009 1.02
vortex_q01/duckdb:parquet 9538057 9340266 1.02

No file size changes detected.

mhk197 added 7 commits July 20, 2026 07:13
Mechanical move mirroring the compare split: dtype-generic lane machinery
stays in numeric/mod.rs (bounds relaxed to T: Default so wider decimal types
can reuse it), primitive-specific code moves to numeric/primitive.rs, tests
to numeric/tests.rs.

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Add and Sub over decimal arrays sharing a decimal dtype, applied to the
unscaled stored integers (exact at a shared scale) in a working width wide
enough that in-precision inputs cannot spuriously overflow. Precision
violations error only on valid lanes, matching primitive semantics. Mul and
Div need rescaling and are gated off until follow-up PRs. widened_buffer
moves to arrays/decimal so compare and numeric share it.

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
The conformance harness now accepts decimal arrays, checking Add/Sub results
element-wise against DecimalScalar::checked_binary_numeric for representative
constants. Wire it into the decimal, chunked, and decimal-byte-parts compute
tests, and add decimal Add cases to the binary_ops benchmark.

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
@mhk197
mhk197 force-pushed the mk/decimal-add-sub branch from 6ed525c to df4f7b6 Compare July 20, 2026 14:13
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Polar Signals Profiling Results

Latest Run

Status Commit Job Attempt Link
🟢 Done 6ef8891 1 Explore Profiling Data
Previous Runs (1)
Status Commit Job Attempt Link
🟢 Done df4f7b6 1 Explore Profiling Data

Powered by Polar Signals Cloud

@mhk197
mhk197 requested a review from joseph-isaacs July 20, 2026 14:41
connortsui20 added a commit that referenced this pull request Jul 20, 2026
## Rationale for this change

- Closes: #8807

Three benchmarks stayed flaky after #8742, flipping between the same two
values on PRs that can't affect them. Same root causes and fixes as
#8742:

| Benchmark | Seen flaky on | Why | Fix |
| --- | --- | --- | --- |
| `true_count_vortex_buffer[128]` | ±11.17% on 9 unrelated PRs (#8805,
#8811, #8812, #8820, #8843, #8803, …) | a 128-bit popcount measures
harness overhead and code layout, not the count | drop the 128 size |
| runend `compress[(100000, 4)]` | ±11.9% on #8805, #8750, #8856 |
allocates in the timed region; glibc malloc differs across runner images
| mimalloc as global allocator |
| `cast_decimal` `copy_*[65536]` | identical flags on #8838 and #8724 |
same glibc-malloc cause (512 KB alloc per iteration) | mimalloc as
global allocator |

Left alone: `compact_sliced[(4096, 90)]` (single sighting) and the CUDA
walltime benches (hosted-runner walltime noise, a runner config issue).

The allocator swap shifts every benchmark in the two touched binaries
once — see the comment below. Needs a one-time CodSpeed acknowledgment,
like #8742.

## What changes are included in this PR?

One commit per benchmark; bench files only. Ran `cargo check` + `clippy`
on the three bench targets, smoke-ran the binaries, `cargo +nightly
fmt`.

---------

Signed-off-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
@mhk197
mhk197 requested review from gatesn and robert3005 July 21, 2026 18:27
Comment thread vortex-array/src/compute/conformance/binary_numeric.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/mod.rs
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/mod.rs
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
#[inline(always)]
fn apply<W>(lhs: W, rhs: W, plan: &DecimalValueBounds<W>) -> Option<W>
where
W: NativeDecimalType + CheckedAdd + CheckedSub,

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.

Maybe we should add CheckedAdd + CheckedSub to the trait bounds of NativeDecimalType now?

Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/mod.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/mod.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Comment thread vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs Outdated
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
@mhk197
mhk197 requested a review from connortsui20 July 22, 2026 19:26
Comment on lines +84 to +111
pub(super) fn checked_valid_lanes<T, F>(
len: usize,
valid_bits: &BitBuffer,
mut checked_at: F,
) -> CheckedValues<T>
where
T: Default,
F: FnMut(usize) -> Option<T>,
{
let mut values = BufferMut::<T>::zeroed(len);
let mut failed = false;
{
let values = values.as_mut_slice();
for_each_valid_idx(len, valid_bits, |idx| {
let Some(value) = checked_at(idx) else {
failed = true;
return false;
};
values[idx] = value;
true
});
}

CheckedValues {
values: values.freeze(),
failed,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This (

fn try_map_masked_into<R, F>(
) might help here. I spent a little bit of time trying to ensure that LLVM will SIMD this even with failures

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If what you have here is faster we would unify these.

@connortsui20

Copy link
Copy Markdown
Member

This is just what my codex review said:

Details

• The arithmetic implementation works for ordinary valid arrays, but it breaks legitimate DataFusion filter pushdown at decimal storage-width boundaries and can silently accept out-of-precision input lanes. Both are observable correctness failures.

Full review comments:

  • [P1] Keep pushed-down decimal results aligned with DataFusion — /Users/connor/spiral/vortex-data/vortex4/vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs:54-56
    When a DataFusion filter performs arithmetic at a physical precision ceiling, such as Decimal128(38, 0) + Decimal128(38, 0) = literal, DataFusion caps the addition result at Decimal128(38, 0) while this code promotes it to Vortex decimal(39, 0). The existing filter converter
    accepts this predicate but preserves the literal as precision 38, so Vortex rejects the resulting comparison with Cannot compare different DTypes before scanning; Decimal32 precision 9 and Decimal64 precision 18 have the same problem. Preserve the planner's capped result type
    in converted expressions or exclude these predicates from pushdown.

  • [P2] Reject input values outside the declared precision — /Users/connor/spiral/vortex-data/vortex4/vortex-array/src/scalar_fn/fns/binary/numeric/decimal.rs:361-365
    For a DecimalArray whose valid lane exceeds its declared input precision but still fits the widened result type, this conversion accepts the lane and only validates the arithmetic result. For example, the currently constructible decimal(2, 0) value 100 plus -1 returns 99 as
    decimal(3, 0) instead of rejecting the invalid input; only values too large for the working width are caught. This matters for arrays accepted by the public constructor and legacy files, so each valid operand lane needs an input-precision check before applying the operation.

Maybe this is an existing issue?

@connortsui20 connortsui20 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.

seems fine to me, I dont think that codex review is real

@mhk197
mhk197 merged commit 46b0b2b into develop Jul 23, 2026
103 of 104 checks passed
@mhk197
mhk197 deleted the mk/decimal-add-sub branch July 23, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants