Skip to content

Commit 7b74345

Browse files
committed
wip
Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
1 parent 01fbdb6 commit 7b74345

7 files changed

Lines changed: 1837 additions & 52 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vortex-array/benches/cast_primitive.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ fn main() {
2020

2121
const N: usize = 100_000;
2222

23+
// Sizes used for the fallible-path benches below. Kept small enough to fit in L2 so
24+
// the kernel cost shows up clearly rather than being hidden by DRAM bandwidth.
25+
const SIZES: &[usize] = &[65_536];
26+
2327
#[divan::bench]
2428
fn cast_u16_to_u32(bencher: Bencher) {
2529
let mut rng = StdRng::seed_from_u64(42);
@@ -46,3 +50,46 @@ fn cast_u16_to_u32(bencher: Bencher) {
4650
.execute::<Canonical>(&mut LEGACY_SESSION.create_execution_ctx())
4751
});
4852
}
53+
54+
/// Narrowing fallible cast that goes through `try_map_with_mask`. Inputs are bounded
55+
/// so every value fits, isolating the kernel's per-lane checked-cast overhead.
56+
#[divan::bench(args = SIZES)]
57+
fn cast_u32_to_u8(bencher: Bencher, n: usize) {
58+
let mut rng = StdRng::seed_from_u64(42);
59+
#[expect(clippy::cast_possible_truncation)]
60+
let arr = PrimitiveArray::from_option_iter((0..n).map(|_| {
61+
if rng.random_bool(0.7) {
62+
Some(rng.random_range(0..u8::MAX) as u32)
63+
} else {
64+
None
65+
}
66+
}))
67+
.into_array();
68+
bencher.with_inputs(|| arr.clone()).bench_refs(|a| {
69+
#[expect(clippy::unwrap_used)]
70+
a.cast(DType::Primitive(PType::U8, Nullability::Nullable))
71+
.unwrap()
72+
.execute::<Canonical>(&mut LEGACY_SESSION.create_execution_ctx())
73+
});
74+
}
75+
76+
/// Sign-change cast i32 → u32. Values are non-negative so the kernel succeeds
77+
/// but still pays the per-lane `try_from` check.
78+
#[divan::bench(args = SIZES)]
79+
fn cast_i32_to_u32(bencher: Bencher, n: usize) {
80+
let mut rng = StdRng::seed_from_u64(42);
81+
let arr = PrimitiveArray::from_option_iter((0..n).map(|_| {
82+
if rng.random_bool(0.7) {
83+
Some(rng.random_range(0..i32::MAX))
84+
} else {
85+
None
86+
}
87+
}))
88+
.into_array();
89+
bencher.with_inputs(|| arr.clone()).bench_refs(|a| {
90+
#[expect(clippy::unwrap_used)]
91+
a.cast(DType::Primitive(PType::U32, Nullability::Nullable))
92+
.unwrap()
93+
.execute::<Canonical>(&mut LEGACY_SESSION.create_execution_ctx())
94+
});
95+
}

vortex-array/src/arrays/primitive/compute/cast.rs

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
use num_traits::AsPrimitive;
54
use num_traits::NumCast;
65
use vortex_buffer::Buffer;
76
use vortex_buffer::BufferMut;
8-
use vortex_buffer::try_map_with_mask;
7+
use vortex_buffer::lane_ops_indexed::try_map_no_validity;
8+
use vortex_buffer::lane_ops_indexed::try_map_with_mask;
99
use vortex_error::VortexResult;
1010
use vortex_error::vortex_bail;
1111
use vortex_error::vortex_err;
@@ -103,56 +103,65 @@ impl CastKernel for Primitive {
103103
}
104104
}
105105

106-
/// Cast values from `F` to `T`. For infallible casts this is a pure pass; for fallible casts
107-
/// each valid value goes through a checked `NumCast::from` and the kernel bails if any of them
108-
/// overflow `T`. Invalid positions use the wrapping `as` cast since their values are masked out.
106+
/// Cast values from `F` to `T`. Always routes through the fallible lane-op kernels with
107+
/// `NumCast::from`. The kernel branches once on the mask shape:
108+
///
109+
/// - `Mask::AllTrue` → [`try_map_no_validity`] — no per-lane validity work.
110+
/// - `Mask::AllFalse` → bulk zero — the closure is never invoked.
111+
/// - `Mask::Values` → [`try_map_with_mask`] — the closure neutralizes null lanes
112+
/// via the `* valid as F` multiply trick so out-of-range null-lane values don't
113+
/// trigger spurious errors.
114+
///
115+
/// For statically-infallible casts (e.g. widening) LLVM proves `NumCast::from` always
116+
/// returns `Some` and strips the fail-tracking machinery, generating the same bare
117+
/// `ushll` widen loop the old hand-written `as_()` fast path produced.
109118
fn cast_values<F, T>(
110119
array: ArrayView<'_, Primitive>,
111120
new_validity: Validity,
112121
ctx: &mut ExecutionCtx,
113122
) -> VortexResult<ArrayRef>
114123
where
115-
F: NativePType + AsPrimitive<T>,
124+
F: NativePType,
116125
T: NativePType,
117126
{
118127
let values = array.as_slice::<F>();
119-
120-
// Fast path: statically infallible, or cached min/max prove every valid value fits in `T`.
121-
// The cached check never triggers a stats computation — if the bounds aren't already known
122-
// we fall through to the per-lane loop below.
123-
if values_always_fit(F::PTYPE, T::PTYPE) || values_fit_in(array, T::PTYPE, ctx, false) {
124-
return Ok(PrimitiveArray::new(cast::<F, T>(values), new_validity).into_array());
125-
}
126-
127-
// TODO(joe): if the values source and target have the same bit-width we can
128-
// mutate in place.
129-
130-
// Fallible: invalid lanes are pre-multiplied to zero so the checked cast always succeeds for
131-
// them; valid lanes go through `NumCast::from` and the whole cast bails on the first overflow.
132128
let mask = array.validity()?.execute_mask(array.len(), ctx)?;
133129
let overflow = || {
134130
vortex_err!(
135131
Compute: "Cannot cast {} to {} — value exceeds target range",
136132
F::PTYPE, T::PTYPE,
137133
)
138134
};
135+
139136
let buffer: Buffer<T> = match &mask {
140-
Mask::AllTrue(_) => BufferMut::try_from_trusted_len_iter(
141-
values
142-
.iter()
143-
.map(|&v| <T as NumCast>::from(v).ok_or_else(overflow)),
144-
)?
145-
.freeze(),
137+
Mask::AllTrue(_) => {
138+
let mut buffer = BufferMut::<T>::with_capacity(values.len());
139+
try_map_no_validity(
140+
values,
141+
&mut buffer.spare_capacity_mut()[..values.len()],
142+
|v| <T as NumCast>::from(v),
143+
)
144+
.map_err(|_| overflow())?;
145+
// SAFETY: try_map_no_validity returned Ok, so it initialized every lane.
146+
unsafe { buffer.set_len(values.len()) };
147+
buffer.freeze()
148+
}
146149
Mask::AllFalse(_) => BufferMut::<T>::zeroed(values.len()).freeze(),
147150
Mask::Values(m) => {
148151
let mut buffer = BufferMut::<T>::with_capacity(values.len());
149152
try_map_with_mask(
150153
values,
151154
m.bit_buffer(),
152155
&mut buffer.spare_capacity_mut()[..values.len()],
156+
// Lazy validity: only consult `valid` on the failure branch. For
157+
// widening / statically-infallible casts, `NumCast::from` is always
158+
// `Some` so the `or_else` is provably dead — LLVM DCEs the validity
159+
// path entirely, giving the same codegen as the maskless kernel.
160+
// For narrowing, `valid` is only read at lanes that actually
161+
// overflowed (a cold check on top of the cast).
153162
|v, valid| {
154-
let factor = if valid { F::one() } else { F::zero() };
155-
<T as NumCast>::from(v * factor)
163+
<T as NumCast>::from(v)
164+
.or_else(|| (!valid).then(T::zero))
156165
},
157166
)
158167
.map_err(|_| overflow())?;
@@ -165,12 +174,6 @@ where
165174
Ok(PrimitiveArray::new(buffer, new_validity).into_array())
166175
}
167176

168-
/// Out-of-range values at invalid positions are truncated/wrapped by `as`, which is fine because
169-
/// they are masked out by validity.
170-
fn cast<F: NativePType + AsPrimitive<T>, T: NativePType>(array: &[F]) -> Buffer<T> {
171-
BufferMut::from_trusted_len_iter(array.iter().map(|&src| src.as_())).freeze()
172-
}
173-
174177
fn reinterpret(
175178
array: ArrayView<'_, Primitive>,
176179
new_ptype: PType,
@@ -188,23 +191,6 @@ fn reinterpret(
188191
.into_array()
189192
}
190193

191-
/// Returns `true` if every value of `src` is guaranteed representable in `target` without
192-
/// overflow. Precision may be lost (e.g. large integers cast to `f32`), but the cast can never
193-
/// produce an out-of-range result.
194-
fn values_always_fit(src: PType, target: PType) -> bool {
195-
if src == target {
196-
return true;
197-
}
198-
if src.is_int() && target.is_int() {
199-
return target.byte_width() > src.byte_width()
200-
&& (src.is_unsigned_int() || target.is_signed_int());
201-
}
202-
if src.is_float() && target.is_float() {
203-
return target.byte_width() > src.byte_width();
204-
}
205-
src.is_int() && matches!(target, PType::F32 | PType::F64)
206-
}
207-
208194
/// Returns `true` if all valid values in `array` are representable as `target_ptype`.
209195
///
210196
/// Cached min/max statistics are consulted first. If either bound is missing, the function either

vortex-buffer/Cargo.toml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ vortex-error = { workspace = true }
3737
workspace = true
3838

3939
[dev-dependencies]
40-
# TEMP: arrow-{array,cast,schema} are only used by the cast_to bench for cross-impl
41-
# performance comparisons. Drop them when the bench is removed.
40+
# TEMP: arrow-* are only used by the cast_to / add_checked benches for cross-impl
41+
# performance comparisons. Drop them when the benches are removed.
42+
arrow-arith = { workspace = true }
4243
arrow-array = { workspace = true }
4344
arrow-cast = { workspace = true }
4445
arrow-schema = { workspace = true }
@@ -58,3 +59,19 @@ harness = false
5859
[[bench]]
5960
name = "cast_to"
6061
harness = false
62+
63+
[[bench]]
64+
name = "cast_to_indexed"
65+
harness = false
66+
67+
[[bench]]
68+
name = "cast_iter_all"
69+
harness = false
70+
71+
[[bench]]
72+
name = "cast_in_place"
73+
harness = false
74+
75+
[[bench]]
76+
name = "add_checked"
77+
harness = false

0 commit comments

Comments
 (0)