Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/codspeed_bench_counts.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"varbinview_try_new": 6,
"varbinview_zip": 2,
"widen": 17,
"widen_decimal": 6
"widen_decimal": 9
},
"vortex-buffer": {
"allocation": 35,
Expand Down
44 changes: 44 additions & 0 deletions vortex-array/benches/widen_decimal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
//! Canonical execution is omitted because it only returns the existing array. Each iteration shares
//! value buffers and builds fresh arrays so cached statistics cannot remove the operation's work.
//! [`WidenArray::encode_decimal`] selects the child buffers outside the timed region.
//!
//! Casts from precision 38 to 76 measure lazy construction for canonical and `Widen` inputs.
//! A canonical-input cast also measures execution through the copy into `i256` values.

use std::fmt;
use std::sync::LazyLock;
Expand All @@ -28,6 +31,7 @@ use vortex_array::arrays::DecimalArray;
use vortex_array::arrays::Widen;
use vortex_array::arrays::WidenArray;
use vortex_array::arrays::widen::WidenArraySlotsExt;
use vortex_array::builtins::ArrayBuiltins;
use vortex_array::dtype::DType;
use vortex_array::dtype::DecimalDType;
use vortex_array::dtype::DecimalType;
Expand Down Expand Up @@ -124,6 +128,13 @@ fn widen_cases() -> Vec<Case> {
.collect()
}

fn cast_cases() -> Vec<Case> {
cases()
.into_iter()
.filter(|case| case.precision == 38)
.collect()
}

impl fmt::Display for Case {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
Expand Down Expand Up @@ -260,3 +271,36 @@ fn execute(bencher: Bencher, case: Case) {
|array, ctx| execute_canonical(Ok(array), ctx),
);
}

#[divan::bench(args = cast_cases())]
fn cast_lazy(bencher: Bencher, case: Case) {
let values = case.values();
let target = DType::Decimal(DecimalDType::new(76, SCALE), Nullability::NonNullable);

bench_case(
bencher,
|| values.array(case),
|array, _ctx| {
array
.cast(target.clone())
.vortex_expect("a same-scale precision increase is valid")
},
);
}

#[divan::bench]
fn cast_execute(bencher: Bencher) {
let case = Case {
precision: 38,
child_precision: 18,
input: Input::Canonical,
};
let values = case.values();
let target = DType::Decimal(DecimalDType::new(76, SCALE), Nullability::NonNullable);

bench_case(
bencher,
|| values.array(case),
|array, ctx| execute_canonical(array.cast(target.clone()), ctx),
);
}
106 changes: 41 additions & 65 deletions vortex-array/src/arrays/decimal/compute/cast.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Decimal casts with checked precision, scale, and nullability.
//!
//! Same-scale casts that increase the canonical width defer the copy through a [`WidenArray`].
//! Scale changes and precision reductions run the conversion kernel to check or rescale valid values.

use num_traits::CheckedMul;
use vortex_buffer::Buffer;
use vortex_buffer::BufferMut;
Expand All @@ -21,6 +26,7 @@ use crate::arrays::ConstantArray;
use crate::arrays::Decimal;
use crate::arrays::DecimalArray;
use crate::arrays::PrimitiveArray;
use crate::arrays::WidenArray;
use crate::arrays::decimal::DecimalArrayExt;
use crate::dtype::BigCast;
use crate::dtype::DType;
Expand All @@ -44,8 +50,6 @@ use crate::validity::Validity;

impl CastReduce for Decimal {
fn cast(array: ArrayView<'_, Decimal>, dtype: &DType) -> VortexResult<Option<ArrayRef>> {
// Only nullability changes within the same decimal dtype are reducible without execution.
// Precision/scale changes need the kernel.
let DType::Decimal(to_decimal_dtype, to_nullability) = dtype else {
return Ok(None);
};
Expand All @@ -56,7 +60,8 @@ impl CastReduce for Decimal {
);
};

if from_decimal_dtype != to_decimal_dtype {
let grows_width = grows_canonical_width(*from_decimal_dtype, *to_decimal_dtype);
if from_decimal_dtype != to_decimal_dtype && !grows_width {
return Ok(None);
}

Expand All @@ -67,6 +72,10 @@ impl CastReduce for Decimal {
return Ok(None);
};

if grows_width {
return widen_decimal_values(array, dtype, new_validity).map(Some);
}

// SAFETY: validity has the same length, only its nullability tag changes.
unsafe {
Ok(Some(
Expand Down Expand Up @@ -121,6 +130,10 @@ impl CastKernel for Decimal {
.clone()
.cast_nullability(*to_nullability, array.len(), ctx)?;

if grows_canonical_width(*from_decimal_dtype, *to_decimal_dtype) {
return widen_decimal_values(array, dtype, new_validity).map(Some);
}

// Reuse the values buffer untouched when no rescale is required, the target precision
// only widens (so every value still fits), and the current physical type is already wide
// enough to hold the target precision. This keeps the common precision-widening cast
Expand Down Expand Up @@ -169,6 +182,31 @@ impl CastKernel for Decimal {
}
}

/// Returns whether a same-scale cast increases the canonical width.
fn grows_canonical_width(from: DecimalDType, to: DecimalDType) -> bool {
from.scale() == to.scale() && from.canonical_values_type() < to.canonical_values_type()
}

/// Wraps the source values in a [`WidenArray`] with the target dtype.
///
/// The caller must cast `validity` to the target nullability without changing its length or valid
/// rows. The child keeps the source decimal dtype and statistics because its values are unchanged.
fn widen_decimal_values(
array: ArrayView<'_, Decimal>,
dtype: &DType,
validity: Validity,
) -> VortexResult<ArrayRef> {
let values = DecimalArray::new_handle(
array.buffer_handle().clone(),
array.values_type(),
array.decimal_dtype(),
validity,
);
values.statistics().inherit_from(array.statistics());

Ok(WidenArray::try_new(values.into_array(), dtype.clone())?.into_array())
}

fn cast_to_integer<T: IntegerPType + BigCast>(
array: ArrayView<'_, Decimal>,
nullability: Nullability,
Expand Down Expand Up @@ -966,68 +1004,6 @@ mod tests {
assert_eq!(casted.buffer::<i16>().as_ref()[0], 1);
}

#[test]
fn cast_upcast_precision_succeeds() {
let mut ctx = SESSION.create_execution_ctx();
let array = DecimalArray::new(
buffer![100i32, 200, 300],
DecimalDType::new(10, 2),
Validity::NonNullable,
);

// Cast to higher precision with same scale - should succeed
let wider_dtype = DType::Decimal(DecimalDType::new(38, 2), Nullability::NonNullable);
let casted = array
.into_array()
.cast(wider_dtype)
.unwrap()
.execute::<DecimalArray>(&mut ctx)
.unwrap();

assert_eq!(casted.precision(), 38);
assert_eq!(casted.scale(), 2);
assert_eq!(casted.len(), 3);
// Should be stored in i128 now (precision 38 requires i128)
assert_eq!(casted.values_type(), DecimalType::I128);
assert_arrays_eq!(
casted,
DecimalArray::from_iter([100i128, 200, 300], DecimalDType::new(38, 2)),
&mut ctx
);
}

#[test]
fn cast_widening_same_physical_type_is_zero_copy() {
let mut ctx = SESSION.create_execution_ctx();
// Decimal(10,2) and Decimal(18,2) are both physically i64 with the same scale, so widening
// the precision must reuse the values buffer rather than allocate and re-scan it.
let array = DecimalArray::new(
buffer![100i64, 200, 300],
DecimalDType::new(10, 2),
Validity::NonNullable,
);
let src_ptr = array.buffer::<i64>().as_ptr();

let wider_dtype = DType::Decimal(DecimalDType::new(18, 2), Nullability::NonNullable);
let casted = array
.into_array()
.cast(wider_dtype)
.unwrap()
.execute::<DecimalArray>(&mut ctx)
.unwrap();

assert_eq!(casted.precision(), 18);
assert_eq!(casted.scale(), 2);
assert_eq!(casted.values_type(), DecimalType::I64);
assert_eq!(casted.buffer::<i64>().as_ref(), &[100, 200, 300]);
// The values buffer must be shared with the source (zero-copy), not reallocated.
assert_eq!(
casted.buffer::<i64>().as_ptr(),
src_ptr,
"precision-widening cast must reuse the source values buffer"
);
}

#[test]
fn cast_to_non_decimal_returns_err() {
let mut ctx = SESSION.create_execution_ctx();
Expand Down
79 changes: 61 additions & 18 deletions vortex-array/src/arrays/widen/compute/cast.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Cast reductions for decimal and primitive [`Widen`] arrays.
//!
//! Same-scale decimal casts retarget the `Widen` when the child already fits the target. Primitive
//! casts can retain a `Widen` for wider integer targets, after matching the child's signedness to the
//! target. Both paths leave required conversions and nullability checks to the child's cast.

use vortex_error::VortexResult;

use crate::ArrayRef;
Expand All @@ -10,33 +16,70 @@ use crate::arrays::widen::WidenArraySlotsExt;
use crate::arrays::widen::compute::wrap_values;
use crate::builtins::ArrayBuiltins;
use crate::dtype::DType;
use crate::dtype::DecimalDType;
use crate::dtype::PType;
use crate::scalar_fn::fns::cast::CastReduce;

impl CastReduce for Widen {
fn cast(array: ArrayView<'_, Self>, dtype: &DType) -> VortexResult<Option<ArrayRef>> {
let values = array.values();
let (DType::Primitive(target_ptype, nullability), DType::Primitive(values_ptype, _)) =
(dtype, values.dtype())
else {
return Ok(None);
};

let cast_ptype = match values_ptype_for_cast(*values_ptype, *target_ptype) {
Some(cast_ptype) if target_ptype.byte_width() > cast_ptype.byte_width() => cast_ptype,
// A float target, or an integer target no wider than `cast_ptype`, goes to the values,
// whose cast checks every value and every null.
_ => return values.cast(dtype.clone()).map(Some),
};

// A wider integer target holds every value of `cast_ptype`, so the values stay narrow.
// They take the target nullability, so that a cast to a non-nullable target still fails on
// a null.
let values = values.cast(DType::Primitive(cast_ptype, *nullability))?;
wrap_values(values, dtype.clone()).map(Some)

match values.dtype() {
DType::Decimal(values_dtype, _) => cast_decimal(values, *values_dtype, dtype),
DType::Primitive(values_ptype, _) => cast_primitive(values, *values_ptype, dtype),
_ => Ok(None),
}
}
}

fn cast_decimal(
values: &ArrayRef,
values_dtype: DecimalDType,
dtype: &DType,
) -> VortexResult<Option<ArrayRef>> {
if let DType::Decimal(target_dtype, _) = dtype
&& values_dtype.scale() == target_dtype.scale()
&& values_dtype.canonical_values_type() < target_dtype.canonical_values_type()
{
// The child's declared precision proves that every value fits the target. Only nullability
// may still need a check, so leave the width conversion to Widen execution.
let values = values.cast(values.dtype().with_nullability(dtype.nullability()))?;

return wrap_values(values, dtype.clone()).map(Some);
}

// Rescaling, narrower targets, and primitive conversions still need the child's cast. The child
// has the parent's values, scale, and nulls, so these conversions preserve the logical result.
match dtype {
DType::Decimal(..) | DType::Primitive(..) => values.cast(dtype.clone()).map(Some),
_ => Ok(None),
}
}

fn cast_primitive(
values: &ArrayRef,
values_ptype: PType,
dtype: &DType,
) -> VortexResult<Option<ArrayRef>> {
let DType::Primitive(target_ptype, nullability) = dtype else {
return Ok(None);
};

let cast_ptype = match values_ptype_for_cast(values_ptype, *target_ptype) {
Some(cast_ptype) if target_ptype.byte_width() > cast_ptype.byte_width() => cast_ptype,
// A float needs value conversion, and an integer target no wider than the converted child
// cannot retain a Widen. Casting directly also checks overflow and nullability.
_ => return values.cast(dtype.clone()).map(Some),
};

// Match the child's signedness and nullability to the target. If both already match, the cast
// returns the original child. Otherwise, it converts the child and checks negative values and
// nulls as needed. Widen handles the remaining width increase because every child value fits.
let values = values.cast(DType::Primitive(cast_ptype, *nullability))?;

wrap_values(values, dtype.clone()).map(Some)
}

/// Returns the type that the values take when the `Widen` is cast to the integer type `target`, or
/// `None` for a float target.
///
Expand Down
7 changes: 4 additions & 3 deletions vortex-array/src/arrays/widen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
//! [`WidenArray::encode`] picks the narrowest such type for a [`PrimitiveArray`].
//!
//! A decimal child has the logical scale and a lower precision, and **must** be valid for its dtype.
//! Comparisons, bounds checks and fills use the child dtype to bound its valid values.
//! Comparisons, bounds checks, and fills use the child dtype to bound its valid values.
//! [`WidenArray::encode_decimal`] and [`WidenArray::from_stored_decimal`] choose child precision
//! from valid values or supplied bounds, at the logical scale. Decimal execution copies the child
//! values into the logical canonical width.
//! from valid values or supplied bounds, at the logical scale. A widening decimal cast keeps the
//! source dtype on its child. Decimal execution copies child values directly into the logical
//! canonical width, since yielding a cast would produce another `Widen` instead of canonical values.
//!
//! Narrowing is a physical choice, so it **must not** change a result. Execution casts the values
//! to the logical dtype for integers, and an operation runs on the values only where it gives the
Expand Down
Loading
Loading