Decimal Add/Sub kernels#8724
Conversation
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>
Merging this PR will not alter performance
Performance Changes
Comparing Footnotes
|
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
| pub struct Binary; | ||
|
|
||
| /// Derive the result type for Add/Sub over operands that already share a decimal dtype. | ||
| pub(crate) fn decimal_add_sub_result_dtype(input: DecimalDType) -> DecimalDType { |
There was a problem hiding this comment.
please can you explain why this is large enough?
There was a problem hiding this comment.
This follows arrow rules. The sum of two positive/negative operands will have max 1 extra digit from a carry. Either that or we've crossed max precision, in which case we error
| let valid_rows = validity.execute_mask(len, ctx)?; | ||
|
|
||
| match DecimalType::smallest_decimal_value_type(&result_decimal_dtype) { | ||
| DecimalType::I8 => execute_decimal_at_widths::<i8, i8>( |
There was a problem hiding this comment.
use the for each type macro?
| enum DecimalOperand { | ||
| Array { | ||
| values: DecimalArray, | ||
| validity: Validity, | ||
| }, | ||
| Constant { | ||
| value: DecimalValue, | ||
| len: usize, | ||
| validity: Validity, | ||
| }, | ||
| Null(usize), | ||
| } | ||
|
|
||
| impl DecimalOperand { | ||
| fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Self> { | ||
| if let Some(constant) = array.as_opt::<Constant>() { | ||
| return Ok(match constant.scalar().as_decimal().decimal_value() { | ||
| Some(value) => Self::Constant { | ||
| value, | ||
| len: array.len(), | ||
| validity: if constant.scalar().dtype().is_nullable() { | ||
| Validity::AllValid | ||
| } else { | ||
| Validity::NonNullable | ||
| }, | ||
| }, | ||
| None => Self::Null(array.len()), | ||
| }); | ||
| } | ||
|
|
||
| let values = array.clone().execute::<DecimalArray>(ctx)?; | ||
| let validity = values.validity()?; |
There was a problem hiding this comment.
why add this type, do you think we could instead have a type columnar type elsewhere?
There was a problem hiding this comment.
like a general TypedColumnar<CanonicalVTable>?
There was a problem hiding this comment.
The Operand pattern is in numeric/primitive.rs as well so was just matching that
| /// Per-execution constants for checked decimal lane operations at working width `W`. | ||
| struct DecimalOpPlan<W> { | ||
| /// Inclusive stored-value bounds implied by the result precision. | ||
| prec_min: W, | ||
| prec_max: W, | ||
| } | ||
|
|
||
| impl<W: NativeDecimalType> DecimalOpPlan<W> { | ||
| fn new(dtype: DecimalDType) -> Self { | ||
| let precision = dtype.precision() as usize; | ||
| Self { | ||
| prec_min: W::MIN_BY_PRECISION[precision], | ||
| prec_max: W::MAX_BY_PRECISION[precision], | ||
| } | ||
| } | ||
|
|
||
| /// Bounds-check a candidate result against the result precision. | ||
| #[inline(always)] | ||
| fn in_precision(&self, value: W) -> Option<W> { | ||
| (self.prec_min <= value && value <= self.prec_max).then_some(value) | ||
| } | ||
| } | ||
|
|
||
| /// A checked fixed-point decimal operation on unscaled values at working width `W`. | ||
| trait CheckedDecimalOp { | ||
| const ERROR: &'static str; | ||
|
|
||
| fn checked<W>(lhs: W, rhs: W, plan: &DecimalOpPlan<W>) -> Option<W> | ||
| where | ||
| W: NativeDecimalType + CheckedAdd + CheckedSub; | ||
| } | ||
|
|
||
| struct DecimalAdd; | ||
|
|
||
| struct DecimalSub; | ||
|
|
||
| impl CheckedDecimalOp for DecimalAdd { | ||
| const ERROR: &'static str = "decimal overflow in checked add"; | ||
|
|
||
| #[inline(always)] | ||
| fn checked<W>(lhs: W, rhs: W, plan: &DecimalOpPlan<W>) -> Option<W> | ||
| where | ||
| W: NativeDecimalType + CheckedAdd + CheckedSub, | ||
| { | ||
| plan.in_precision(lhs.checked_add(&rhs)?) | ||
| } | ||
| } | ||
|
|
||
| impl CheckedDecimalOp for DecimalSub { | ||
| const ERROR: &'static str = "decimal overflow in checked sub"; | ||
|
|
||
| #[inline(always)] | ||
| fn checked<W>(lhs: W, rhs: W, plan: &DecimalOpPlan<W>) -> Option<W> | ||
| where |
There was a problem hiding this comment.
checked_add/sub may not overflow, but may exceed max/min precision
| match op { | ||
| NumericOperator::Add => execute_decimal_typed::<W, O, DecimalAdd>( | ||
| lhs, | ||
| rhs, | ||
| result_decimal_dtype, | ||
| result_dtype, | ||
| validity, | ||
| valid_rows, | ||
| ), | ||
| NumericOperator::Sub => execute_decimal_typed::<W, O, DecimalSub>( | ||
| lhs, | ||
| rhs, | ||
| result_decimal_dtype, | ||
| result_dtype, | ||
| validity, | ||
| valid_rows, | ||
| ), | ||
| NumericOperator::Mul | NumericOperator::Div => vortex_bail!( | ||
| "numeric operator {:?} is not yet supported for decimal arrays", | ||
| op | ||
| ), |
| len: usize, | ||
| validity: Validity, | ||
| }, | ||
| Null(usize), |
There was a problem hiding this comment.
you can bail early here?
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:The scale remains unchanged because the operation is performed on integers at the same scale. For example:
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) anddecimal(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. Let
M = 10^76 - 1:Nulls, constants, and errors
Implementation
numeric/{mod,primitive,decimal,tests}.rs, mirroring the compare implementation.execute::<DecimalArray>, while extracting constant and null operands directly.widened_bufferfromarrays/decimalbetween comparison and numeric execution.Coverage
The conformance suite exercises Decimal, Chunked, and DecimalByteParts arrays, including:
binary_opsalso includes non-nulli64and nullablei128decimal Add benchmarks.