Skip to content

Commit 7b65132

Browse files
committed
build arrow dictionaries for primitive arrays with one fxhash pass
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
1 parent 6d03fa8 commit 7b65132

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

‎Cargo.lock‎

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

‎vortex-arrow/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ arrow-data = { workspace = true }
2626
arrow-schema = { workspace = true, features = ["canonical_extension_types"] }
2727
itertools = { workspace = true }
2828
num-traits = { workspace = true }
29+
rustc-hash = { workspace = true }
2930
simdutf8 = { workspace = true }
3031
tracing = { workspace = true }
3132
vortex-array = { workspace = true }
3233
vortex-buffer = { workspace = true, features = ["arrow"] }
3334
vortex-error = { workspace = true }
3435
vortex-mask = { workspace = true }
3536
vortex-session = { workspace = true }
37+
vortex-utils = { workspace = true }
3638

3739
[dev-dependencies]
3840
divan = { workspace = true }

‎vortex-arrow/src/executor/dictionary.rs‎

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

4+
use std::hash::Hash;
45
use std::sync::Arc;
56

67
use arrow_array::ArrayRef as ArrowArrayRef;
@@ -9,21 +10,37 @@ use arrow_array::PrimitiveArray;
910
use arrow_array::cast::AsArray;
1011
use arrow_array::new_null_array;
1112
use arrow_array::types::*;
13+
use arrow_schema::ArrowError;
1214
use arrow_schema::DataType;
15+
use rustc_hash::FxBuildHasher;
1316
use vortex_array::ArrayRef;
1417
use vortex_array::ExecutionCtx;
1518
use vortex_array::IntoArray;
1619
use vortex_array::arrays::Constant;
1720
use vortex_array::arrays::ConstantArray;
1821
use vortex_array::arrays::Dict;
1922
use vortex_array::arrays::DictArray;
23+
use vortex_array::arrays::PrimitiveArray as VortexPrimitiveArray;
2024
use vortex_array::arrays::dict::DictArraySlotsExt;
25+
use vortex_array::arrays::primitive::NativeValue;
26+
use vortex_array::dtype::DType;
27+
use vortex_array::dtype::NativePType;
28+
use vortex_array::dtype::UnsignedPType;
29+
use vortex_array::match_each_native_ptype;
30+
use vortex_array::match_each_unsigned_integer_ptype;
2131
use vortex_array::matcher::Matcher;
32+
use vortex_array::validity::Validity;
33+
use vortex_buffer::BufferMut;
2234
use vortex_error::VortexError;
35+
use vortex_error::VortexExpect;
2336
use vortex_error::VortexResult;
2437
use vortex_error::vortex_bail;
38+
use vortex_mask::Mask;
39+
use vortex_utils::aliases::hash_map::Entry;
40+
use vortex_utils::aliases::hash_map::HashMap;
2541

2642
use crate::ArrowExporter;
43+
use crate::dtype::ptype_from_arrow;
2744
use crate::executor::execute_arrow_naive;
2845

2946
/// Matches the encodings [`to_arrow_dictionary`] requires for export.
@@ -55,6 +72,13 @@ pub(super) fn to_arrow_dictionary(
5572
Err(array) => array,
5673
};
5774

75+
if let DType::Primitive(ptype, _) = array.dtype()
76+
&& ptype_from_arrow(values_type).ok() == Some(*ptype)
77+
{
78+
let array = array.execute::<VortexPrimitiveArray>(ctx)?;
79+
return primitive_to_dict(array, codes_type, values_type, exporter, ctx);
80+
}
81+
5882
// Otherwise, we should try and build a dictionary.
5983
// Arrow hides this functionality inside the cast module!
6084
let array = execute_arrow_naive(array, Some(values_type), exporter, ctx)?;
@@ -65,6 +89,74 @@ pub(super) fn to_arrow_dictionary(
6589
.map_err(VortexError::from)
6690
}
6791

92+
/// Dictionary-encode a primitive array into the dictionary `arrow_cast` builds: values in order of
93+
/// first occurrence and null rows as null keys. Arrow's builder hashes with SipHash and looks up
94+
/// every new value twice, which dominated exporting columns that are stored without a dictionary.
95+
fn primitive_to_dict(
96+
array: VortexPrimitiveArray,
97+
codes_type: &DataType,
98+
values_type: &DataType,
99+
exporter: &ArrowExporter<'_>,
100+
ctx: &mut ExecutionCtx,
101+
) -> VortexResult<ArrowArrayRef> {
102+
let codes_ptype = ptype_from_arrow(codes_type)?;
103+
let max_code = codes_ptype.max_value_as_u64();
104+
let dict = match_each_unsigned_integer_ptype!(codes_ptype.to_unsigned(), |C| {
105+
match_each_native_ptype!(array.ptype(), |T| {
106+
encode_primitive::<T, C>(&array, max_code, ctx)?
107+
})
108+
});
109+
dict_to_dict(dict, codes_type, values_type, exporter, ctx)
110+
}
111+
112+
fn encode_primitive<T: NativePType, C: UnsignedPType>(
113+
array: &VortexPrimitiveArray,
114+
max_code: u64,
115+
ctx: &mut ExecutionCtx,
116+
) -> VortexResult<DictArray>
117+
where
118+
NativeValue<T>: Hash + Eq,
119+
{
120+
let len = array.len();
121+
let validity = array.validity()?;
122+
// Sized for all-distinct input like Arrow's builder: growing the table rehashes every entry.
123+
let mut lookup = HashMap::<NativeValue<T>, C, _>::with_capacity_and_hasher(len, FxBuildHasher);
124+
let mut values = BufferMut::<T>::with_capacity(len);
125+
let mut code = |value: T| -> VortexResult<C> {
126+
match lookup.entry(NativeValue(value)) {
127+
Entry::Occupied(entry) => Ok(*entry.get()),
128+
Entry::Vacant(entry) => {
129+
if values.len() as u64 > max_code {
130+
return Err(ArrowError::DictionaryKeyOverflowError.into());
131+
}
132+
let code = C::from_usize(values.len()).vortex_expect("code fits the key type");
133+
values.push(value);
134+
Ok(*entry.insert(code))
135+
}
136+
}
137+
};
138+
139+
let mut codes = BufferMut::<C>::with_capacity(len);
140+
match validity.execute_mask(len, ctx)? {
141+
Mask::AllTrue(_) => {
142+
for &value in array.as_slice::<T>() {
143+
codes.push(code(value)?);
144+
}
145+
}
146+
Mask::AllFalse(_) => codes.push_n(C::default(), len),
147+
Mask::Values(mask) => {
148+
for (&value, valid) in array.as_slice::<T>().iter().zip(mask.bit_buffer().iter()) {
149+
codes.push(if valid { code(value)? } else { C::default() });
150+
}
151+
}
152+
}
153+
154+
let codes = VortexPrimitiveArray::new(codes.freeze(), validity);
155+
let values = VortexPrimitiveArray::new(values.freeze(), Validity::NonNullable);
156+
// SAFETY: every code indexes a value pushed above.
157+
Ok(unsafe { DictArray::new_unchecked(codes.into_array(), values.into_array()) })
158+
}
159+
68160
/// Convert a constant array to a dictionary with a single entry.
69161
fn constant_to_dict(
70162
array: ConstantArray,
@@ -249,6 +341,42 @@ mod tests {
249341
Ok(())
250342
}
251343

344+
/// Primitive arrays stored without a dictionary build exactly the dictionary `arrow_cast` does.
345+
#[rstest]
346+
#[case::u64_with_nulls(
347+
PrimitiveArray::from_option_iter([Some(7u64), None, Some(9), Some(7), None, Some(1)]).into_array(),
348+
DataType::UInt32,
349+
DataType::UInt64,
350+
)]
351+
#[case::signed_keys(buffer![-1i32, 5, -1, 5, 5, 2].into_array(), DataType::Int8, DataType::Int32)]
352+
#[case::float_bits(
353+
buffer![0.0f64, -0.0, f64::NAN, 0.0, f64::NAN].into_array(),
354+
DataType::UInt16,
355+
DataType::Float64,
356+
)]
357+
#[case::all_null(
358+
PrimitiveArray::from_option_iter([None::<u32>, None]).into_array(),
359+
DataType::UInt32,
360+
DataType::UInt32,
361+
)]
362+
fn primitive_matches_arrow_cast(
363+
#[case] array: vortex_array::ArrayRef,
364+
#[case] codes_type: DataType,
365+
#[case] values_type: DataType,
366+
) -> VortexResult<()> {
367+
let target_type = dict_type(codes_type, values_type.clone());
368+
let expected = arrow_cast::cast(&execute(array.clone(), &values_type)?, &target_type)?;
369+
let actual = execute(array, &target_type)?;
370+
assert_eq!(actual.as_ref(), expected.as_ref());
371+
Ok(())
372+
}
373+
374+
#[test]
375+
fn primitive_overflowing_key_type_errors() {
376+
let array = PrimitiveArray::from_iter(0u32..300).into_array();
377+
assert!(execute(array, &dict_type(DataType::UInt8, DataType::UInt32)).is_err());
378+
}
379+
252380
#[test]
253381
fn mask_wrapped_dict_exports() -> VortexResult<()> {
254382
// Dictionary behind a lazy `mask` scalar-fn — the shape a scan produces when a row

0 commit comments

Comments
 (0)