Skip to content
Merged
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
86 changes: 72 additions & 14 deletions vortex-array/src/arrays/decimal/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,7 @@ pub(crate) fn widened_buffer<W: NativeDecimalType>(array: &DecimalArray) -> Buff
// Only storage types narrower than `W` reach here. The const condition lets the compiler
// skip codegen of the other arms.
if const { (T::DECIMAL_TYPE as u8) < (W::DECIMAL_TYPE as u8) } {
array
.buffer::<T>()
.iter()
.map(|v| W::from(*v).vortex_expect("widening decimal cast must succeed"))
.collect()
widen_values::<T, W>(&array.buffer::<T>())
} else {
unreachable!(
"{} is not narrower than {}",
Expand Down Expand Up @@ -156,16 +152,30 @@ pub fn copy_to_canonical_width<F: NativeDecimalType>(
Ok(BufferHandle::new_host(copy))
}

/// Widens every value to `W`, which **must** be at least as wide as `F`. Null slots keep their
/// values.
/// Widens every value to `W`, which **must** be at least as wide as `F`, or this panics. Null slots
/// keep their values.
fn widen_values<F: NativeDecimalType, W: NativeDecimalType>(values: &[F]) -> Buffer<W> {
values
.iter()
.map(|&value| {
<W as BigCast>::from(value)
.vortex_expect("W is at least as wide as F, so every value of F fits W")
})
.collect()
// This cannot be a `const` assert: the disabled `if const` branches of `widened_buffer` and
// `copy_to_canonical_width` still name narrower pairs, and rustc evaluates the inline constants
// of every function they name. Both sides are constants, so the check folds away.
assert!(
(F::DECIMAL_TYPE as u8) <= (W::DECIMAL_TYPE as u8),
"widen_values requires a target at least as wide as the source, got {} into {}",
F::DECIMAL_TYPE,
W::DECIMAL_TYPE
);

// NB: Keep the `unwrap_or_default` fallback and `from_trusted_len_iter`. The fallback is dead
// because `W` is at least as wide as `F`, so the write loop has one exit and no panic edge. A
// `collect` writes through `BufferMut::extend_iter`, whose loop also exits when the reserved
// capacity runs out, and the LLVM loop vectorizer leaves some pairs scalar there, such as `i64`
// to `i128`. The accepted trade-off is that this loop is slower than `collect` when `W` is
// `i256`, for an unknown reason. The `copy_to_canonical_width` benchmark covers these pairs.
Buffer::from_trusted_len_iter(
values
.iter()
.map(|&value| <W as BigCast>::from(value).unwrap_or_default()),
)
Comment on lines +174 to +178

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.

I would check the lanezip (and related) these are written so they simd better. I think from_trusted_len_iter cannot quite avoid bounds check

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.

initial test shows that the lanezip is slower for i32->i64 on M4, am spinning up some EC2 instances to double check (and also ask why)

@connortsui20 connortsui20 Oct 30, 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.

Keeping the current loop in this PR: the bounds-check concern does not reproduce, and map_into introduces large regressions on ARM and AVX-512. The follow-up is to improve map_into's tiling separately, since a diagnostic larger-tile run removes the ARM regression.

Benchmark results and diagnostic experiment

Baseline is this PR at d09193ef532c. The candidate replaces the trusted-iterator construction in widen_values with the related unary map_into helper, keeping the cast and output allocation. Rust 1.99.1, LLVM 23.1.1, bench profile, 16 CGUs, no LTO, MiMalloc, 16,384 non-null values. Each result is the median paired candidate/baseline ratio from seven alternating pairs, with 300 samples and a 0.3-second minimum per case. Higher is slower. EC2 runs were pinned to CPU 2, with its SMT sibling disabled on C7i. The M4 runs were unpinned.

Case M4 Max Graviton3, NEON C7i, AVX2 C7i, AVX-512
i32 -> i64 1.82x 1.80x 1.01x 1.00x
i64 -> i128 0.95x 0.47x 1.03x 1.03x
i128 -> i256 1.00x 0.83x 1.00x 1.55x
Same-width copy control 1.00x 1.00x 1.00x 1.00x

The C7i runs use the AVX2 and full AVX-512 feature sets from CI on the same Xeon Platinum 8488C. Graviton3 uses +neon without SVE.

For the i32 -> i64 diagnostic, separate interleaved three-way runs gave:

Machine Current loop map_into, 64-row tiles map_into, 1024-row tiles
M4 Max 1.166 us 2.124 us 1.165 us
Graviton3 1.788 us 3.251 us 1.786 us

The 64-row body uses narrower loads and individual stores. A 128-row experiment restored wider paired operations but recovered only part of the slowdown. The 1024-row version restores a compact rolled vector loop with the same inner instruction sequence as the current implementation. Allocation and conversion work are unchanged. This isolates a lane-tiling/code-generation sensitivity, while the exact hardware bottleneck remains unresolved. A separate prototype keeps map_into unchanged by default and adds an explicit widening opt-in: retain the tiled loop up to 128 values, then use one untiled loop. In six alternating pairs at 16,384 values, this reduces time against stock map_into by about 48% on M4 and 44% on Graviton3 for i32 -> i64, and 35% on C7i AVX-512 for i128 -> i256. AVX2 is roughly unchanged. Some short integer cases still cost about 1 ns more, floating-point results are mixed, and unchanged controls show placement sensitivity. That supports a selective follow-up, rather than replacing every map loop.

}

/// Narrows values that [`check_precision`] accepted to `W`, and writes zeros into the null slots.
Expand Down Expand Up @@ -320,6 +330,7 @@ mod tests {
use super::converted_buffer;
use super::copy_to_canonical_width;
use super::valid_range;
use super::widen_values;
use crate::arrays::DecimalArray;
use crate::dtype::BigCast;
use crate::dtype::DecimalDType;
Expand Down Expand Up @@ -504,6 +515,53 @@ mod tests {
Ok(())
}

/// Each case widens 130 values, so a vectorized loop runs its vector body and a scalar tail.
#[rstest]
#[case::i8_to_i16(DecimalType::I8, DecimalType::I16)]
#[case::i8_to_i32(DecimalType::I8, DecimalType::I32)]
#[case::i8_to_i64(DecimalType::I8, DecimalType::I64)]
#[case::i8_to_i128(DecimalType::I8, DecimalType::I128)]
#[case::i8_to_i256(DecimalType::I8, DecimalType::I256)]
#[case::i16_to_i32(DecimalType::I16, DecimalType::I32)]
#[case::i16_to_i64(DecimalType::I16, DecimalType::I64)]
#[case::i16_to_i128(DecimalType::I16, DecimalType::I128)]
#[case::i16_to_i256(DecimalType::I16, DecimalType::I256)]
#[case::i32_to_i64(DecimalType::I32, DecimalType::I64)]
#[case::i32_to_i128(DecimalType::I32, DecimalType::I128)]
#[case::i32_to_i256(DecimalType::I32, DecimalType::I256)]
#[case::i64_to_i128(DecimalType::I64, DecimalType::I128)]
#[case::i64_to_i256(DecimalType::I64, DecimalType::I256)]
#[case::i128_to_i256(DecimalType::I128, DecimalType::I256)]
fn test_widen_values_sign_extends(#[case] source: DecimalType, #[case] target: DecimalType) {
match_each_decimal_value_type!(source, |F| {
let small = |value: i8| {
<F as BigCast>::from(value).vortex_expect("small values fit every decimal type")
};
let pattern = [F::MIN, small(-1), small(0), small(1), F::MAX];
let values: Vec<F> = pattern.into_iter().cycle().take(130).collect();

match_each_decimal_value_type!(target, |W| {
let expected: Vec<W> = values
.iter()
.map(|&value| {
<W as BigCast>::from(value)
.vortex_expect("W is at least as wide as F, so every value fits")
})
.collect();
assert_eq!(
widen_values::<F, W>(&values).as_slice(),
expected.as_slice()
);
});
});
}

#[test]
#[should_panic(expected = "widen_values requires a target at least as wide as the source")]
fn test_widen_values_rejects_narrower_target() {
widen_values::<i64, i32>(&[1, 2, 3]);
}

/// Each case puts `10^p` inside the target width, so only the precision check can reject it.
#[rstest]
#[case::i16_to_i8(DecimalType::I16, 2)]
Expand Down
Loading