From 9b2ee2ec41b08b86c7cba4f68513f01c499a96da Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:26:21 +0000 Subject: [PATCH 1/7] bench: add nullable dictionary hash density benchmarks --- datafusion/common/benches/with_hashes.rs | 60 +++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index 7fc4a29fc55ec..3a22325e58781 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -178,8 +178,13 @@ fn create_null_mask(len: usize) -> NullBuffer where StandardUniform: Distribution, { + create_null_mask_with_density(len, 0.03).expect("should be nulls in buffer") +} + +/// Returns a random null mask where each slot is null with probability +/// `null_density`, or `None` if no slot ended up null +fn create_null_mask_with_density(len: usize, null_density: f32) -> Option { let mut rng = make_rng(); - let null_density = 0.03; let mut builder = NullBufferBuilder::new(len); for _ in 0..len { if rng.random::() < null_density { @@ -188,7 +193,21 @@ where builder.append_non_null(); } } - builder.finish().expect("should be nulls in buffer") + builder.finish() +} + +/// Returns `dict` with a new random null mask on its keys, where each key is null +/// with probability `null_density`. Any existing key nulls are replaced, and the +/// key and value buffers are shared rather than copied +fn with_null_keys( + dict: &DictionaryArray, + null_density: f32, +) -> ArrayRef { + let keys = PrimitiveArray::::new( + dict.keys().values().clone(), + create_null_mask_with_density(dict.len(), null_density), + ); + Arc::new(DictionaryArray::new(keys, Arc::clone(dict.values()))) } // Returns a new array that is the same as array, but with nulls @@ -290,9 +309,15 @@ impl StringPool { &self, array_length: usize, ) -> ArrayRef { - Arc::new(DictionaryArray::::from_iter( - self.iter_strings(array_length), - )) + Arc::new(self.typed_dictionary_array::(array_length)) + } + + /// Like [`Self::dictionary_array`], but returns the concrete `DictionaryArray` + fn typed_dictionary_array( + &self, + array_length: usize, + ) -> DictionaryArray { + DictionaryArray::::from_iter(self.iter_strings(array_length)) } } @@ -645,10 +670,33 @@ fn mixed_columns_benchmark(c: &mut Criterion) { }); } +/// Int32 dictionaries with 50% and 97% null keys. 0% and 3% are covered by +/// the existing `dictionary_utf8_int32` "no nulls" and "nulls" cases in +/// `criterion_benchmark` +fn dictionary_benchmark(c: &mut Criterion) { + let pool = StringPool::new(100, 64); + let dict = pool.typed_dictionary_array::(BATCH_SIZE); + let name = "dictionary_utf8_int32"; + for null_percent in [50, 97] { + let array = with_null_keys(&dict, null_percent as f32 / 100.0); + c.bench_function(&format!("{name}: single, {null_percent}% null keys"), |b| { + do_hash_test(b, std::slice::from_ref(&array)); + }); + c.bench_function( + &format!("{name}: multiple, {null_percent}% null keys"), + |b| { + let arrays = vec![array.clone(), array.clone(), array.clone()]; + do_hash_test(b, &arrays); + }, + ); + } +} + criterion_group!( benches, criterion_benchmark, sliced_array_benchmark, - mixed_columns_benchmark + mixed_columns_benchmark, + dictionary_benchmark ); criterion_main!(benches); From 5cb5ed5e39fa8de3d55c8de77b92883453d8b1f9 Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:02:48 +0000 Subject: [PATCH 2/7] refactor: integrate null-density cases into existing benchmarks --- datafusion/common/benches/with_hashes.rs | 64 ++++++++++++------------ 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index 3a22325e58781..2f8e844fca075 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -160,6 +160,33 @@ fn criterion_benchmark(c: &mut Criterion) { do_hash_test(b, &arrays); }); } + + if name == "dictionary_utf8_int32" { + let dict = array + .as_any() + .downcast_ref::>() + .expect("Expected Int32 dictionary"); + + for null_percent in [50, 97] { + let nullable_array = with_null_keys(dict, null_percent as f32 / 100.0); + + c.bench_function( + &format!("{name}: single, {null_percent}% null keys"), + |b| do_hash_test(b, std::slice::from_ref(&nullable_array)), + ); + c.bench_function( + &format!("{name}: multiple, {null_percent}% null keys"), + |b| { + let arrays = vec![ + nullable_array.clone(), + nullable_array.clone(), + nullable_array.clone(), + ]; + do_hash_test(b, &arrays); + }, + ); + } + } } } @@ -309,15 +336,9 @@ impl StringPool { &self, array_length: usize, ) -> ArrayRef { - Arc::new(self.typed_dictionary_array::(array_length)) - } - - /// Like [`Self::dictionary_array`], but returns the concrete `DictionaryArray` - fn typed_dictionary_array( - &self, - array_length: usize, - ) -> DictionaryArray { - DictionaryArray::::from_iter(self.iter_strings(array_length)) + Arc::new(DictionaryArray::::from_iter( + self.iter_strings(array_length), + )) } } @@ -670,33 +691,10 @@ fn mixed_columns_benchmark(c: &mut Criterion) { }); } -/// Int32 dictionaries with 50% and 97% null keys. 0% and 3% are covered by -/// the existing `dictionary_utf8_int32` "no nulls" and "nulls" cases in -/// `criterion_benchmark` -fn dictionary_benchmark(c: &mut Criterion) { - let pool = StringPool::new(100, 64); - let dict = pool.typed_dictionary_array::(BATCH_SIZE); - let name = "dictionary_utf8_int32"; - for null_percent in [50, 97] { - let array = with_null_keys(&dict, null_percent as f32 / 100.0); - c.bench_function(&format!("{name}: single, {null_percent}% null keys"), |b| { - do_hash_test(b, std::slice::from_ref(&array)); - }); - c.bench_function( - &format!("{name}: multiple, {null_percent}% null keys"), - |b| { - let arrays = vec![array.clone(), array.clone(), array.clone()]; - do_hash_test(b, &arrays); - }, - ); - } -} - criterion_group!( benches, criterion_benchmark, sliced_array_benchmark, - mixed_columns_benchmark, - dictionary_benchmark + mixed_columns_benchmark ); criterion_main!(benches); From 190f145a9b1c502448fc7d03c97861fe133833de Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:52:12 +0530 Subject: [PATCH 3/7] Update datafusion/common/benches/with_hashes.rs Co-authored-by: RIchard Baah <137434454+Rich-T-kid@users.noreply.github.com> --- datafusion/common/benches/with_hashes.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index 2f8e844fca075..470205197a50b 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -161,18 +161,19 @@ fn criterion_benchmark(c: &mut Criterion) { }); } - if name == "dictionary_utf8_int32" { - let dict = array - .as_any() - .downcast_ref::>() - .expect("Expected Int32 dictionary"); - + if let Some(dict) = array + .as_any() + .downcast_ref::>() + { for null_percent in [50, 97] { - let nullable_array = with_null_keys(dict, null_percent as f32 / 100.0); + let nullable_array = + with_null_keys(dict, null_percent as f32 / 100.0); c.bench_function( &format!("{name}: single, {null_percent}% null keys"), - |b| do_hash_test(b, std::slice::from_ref(&nullable_array)), + |b| { + do_hash_test(b, std::slice::from_ref(&nullable_array)); + }, ); c.bench_function( &format!("{name}: multiple, {null_percent}% null keys"), From 0792ce820145f7d9424fd3726460611d461fd549 Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:56:26 +0530 Subject: [PATCH 4/7] Enhance comments in with_hashes.rs for clarity Add comments for future null-density cases in benchmarks. --- datafusion/common/benches/with_hashes.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index 470205197a50b..f7a03c931b274 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -160,7 +160,8 @@ fn criterion_benchmark(c: &mut Criterion) { do_hash_test(b, &arrays); }); } - + // Apply additional null-density cases to all Int32 dictionary + // benchmarks, including any added in the future. if let Some(dict) = array .as_any() .downcast_ref::>() From d4f90cdd4596904a8062bd70880044a458f102b9 Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:02:38 +0000 Subject: [PATCH 5/7] style: format dictionary benchmark changes --- datafusion/common/benches/with_hashes.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index f7a03c931b274..c4fda45e3e204 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -162,13 +162,9 @@ fn criterion_benchmark(c: &mut Criterion) { } // Apply additional null-density cases to all Int32 dictionary // benchmarks, including any added in the future. - if let Some(dict) = array - .as_any() - .downcast_ref::>() - { + if let Some(dict) = array.as_any().downcast_ref::>() { for null_percent in [50, 97] { - let nullable_array = - with_null_keys(dict, null_percent as f32 / 100.0); + let nullable_array = with_null_keys(dict, null_percent as f32 / 100.0); c.bench_function( &format!("{name}: single, {null_percent}% null keys"), From 32b0f3bc6366a9f45c647f7dfd87ebe39e4672f7 Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:49:14 +0000 Subject: [PATCH 6/7] bench: address nullable dictionary benchmark review --- datafusion/common/benches/with_hashes.rs | 86 ++++++++++++++---------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index c4fda45e3e204..dd17f17beb41c 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -30,6 +30,9 @@ struct BenchData { array: ArrayRef, /// Union arrays can't have null bitmasks added supports_nulls: bool, + /// Extra null percentages to benchmark, in addition to the default 3% + /// ("nulls") case + additional_null_densities: &'static [u8], } fn criterion_benchmark(c: &mut Criterion) { @@ -41,46 +44,55 @@ fn criterion_benchmark(c: &mut Criterion) { name: "int64", array: primitive_array::(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "utf8", array: pool.string_array::(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "large_utf8", array: pool.string_array::(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "utf8_view", array: pool.string_view_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "utf8_view (small)", array: small_pool.string_view_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "dictionary_utf8_int32", array: pool.dictionary_array::(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[50, 97], }, BenchData { name: "list_array", array: list_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "map_array", array: map_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "sparse_union", array: sparse_union_array(BATCH_SIZE), supports_nulls: false, + additional_null_densities: &[], }, BenchData { name: "sparse_union (2 types)", @@ -90,6 +102,7 @@ fn criterion_benchmark(c: &mut Criterion) { .collect(), ), supports_nulls: false, + additional_null_densities: &[], }, BenchData { name: "sparse_union (utf8)", @@ -99,36 +112,43 @@ fn criterion_benchmark(c: &mut Criterion) { .collect(), ), supports_nulls: false, + additional_null_densities: &[], }, BenchData { name: "dense_union", array: dense_union_array(BATCH_SIZE), supports_nulls: false, + additional_null_densities: &[], }, BenchData { name: "struct_array", array: create_struct_array(&pool, BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "run_array_int32", array: create_run_array::(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "null", array: Arc::new(NullArray::new(BATCH_SIZE)), supports_nulls: false, + additional_null_densities: &[], }, BenchData { name: "list_view_array", array: list_view_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, BenchData { name: "fixed_size_list_array", array: fixed_size_list_array(BATCH_SIZE), supports_nulls: true, + additional_null_densities: &[], }, ]; @@ -136,6 +156,7 @@ fn criterion_benchmark(c: &mut Criterion) { name, array, supports_nulls, + additional_null_densities, } in cases { c.bench_function(&format!("{name}: single, no nulls"), |b| { @@ -147,7 +168,7 @@ fn criterion_benchmark(c: &mut Criterion) { }); // Union arrays can't have null bitmasks if supports_nulls { - let nullable_array = add_nulls(&array); + let nullable_array = add_nulls(&array, DEFAULT_NULL_DENSITY); c.bench_function(&format!("{name}: single, nulls"), |b| { do_hash_test(b, std::slice::from_ref(&nullable_array)); }); @@ -159,21 +180,16 @@ fn criterion_benchmark(c: &mut Criterion) { ]; do_hash_test(b, &arrays); }); - } - // Apply additional null-density cases to all Int32 dictionary - // benchmarks, including any added in the future. - if let Some(dict) = array.as_any().downcast_ref::>() { - for null_percent in [50, 97] { - let nullable_array = with_null_keys(dict, null_percent as f32 / 100.0); - + for &null_percent in additional_null_densities { + let nullable_array = add_nulls(&array, null_percent as f32 / 100.0); c.bench_function( - &format!("{name}: single, {null_percent}% null keys"), + &format!("{name}: single, {null_percent}% nulls"), |b| { do_hash_test(b, std::slice::from_ref(&nullable_array)); }, ); c.bench_function( - &format!("{name}: multiple, {null_percent}% null keys"), + &format!("{name}: multiple, {null_percent}% nulls"), |b| { let arrays = vec![ nullable_array.clone(), @@ -199,17 +215,19 @@ fn do_hash_test(b: &mut Bencher, arrays: &[ArrayRef]) { }); } -fn create_null_mask(len: usize) -> NullBuffer -where - StandardUniform: Distribution, -{ - create_null_mask_with_density(len, 0.03).expect("should be nulls in buffer") -} +/// Density of the default "nulls" benchmark cases +const DEFAULT_NULL_DENSITY: f32 = 0.03; /// Returns a random null mask where each slot is null with probability -/// `null_density`, or `None` if no slot ended up null -fn create_null_mask_with_density(len: usize, null_density: f32) -> Option { - let mut rng = make_rng(); +/// `null_density`, or `None` if no slot ended up null. +/// +/// The mask is drawn from its own RNG stream (`make_null_rng`) so null +/// positions are independent of the random draws that generated the data. +/// Every density restarts that stream, so a lower-density mask is a subset +/// of a higher-density one: the cases in a density sweep differ only in how +/// many slots are null. +fn create_null_mask(len: usize, null_density: f32) -> Option { + let mut rng = make_null_rng(); let mut builder = NullBufferBuilder::new(len); for _ in 0..len { if rng.random::() < null_density { @@ -221,23 +239,10 @@ fn create_null_mask_with_density(len: usize, null_density: f32) -> Option( - dict: &DictionaryArray, - null_density: f32, -) -> ArrayRef { - let keys = PrimitiveArray::::new( - dict.keys().values().clone(), - create_null_mask_with_density(dict.len(), null_density), - ); - Arc::new(DictionaryArray::new(keys, Arc::clone(dict.values()))) -} - -// Returns a new array that is the same as array, but with nulls +// Returns a new array that is the same as array, but with each slot null with +// probability `null_density`. For a dictionary these are null keys. // Handles the special case of RunArray where nulls must be in the values array -fn add_nulls(array: &ArrayRef) -> ArrayRef { +fn add_nulls(array: &ArrayRef, null_density: f32) -> ArrayRef { use arrow::datatypes::DataType; match array.data_type() { @@ -258,7 +263,7 @@ fn add_nulls(array: &ArrayRef) -> ArrayRef { .clone() .into_data() .into_builder() - .nulls(Some(create_null_mask(values.len()))) + .nulls(create_null_mask(values.len(), null_density)) .build() .unwrap(); make_array(array_data) @@ -274,7 +279,7 @@ fn add_nulls(array: &ArrayRef) -> ArrayRef { .clone() .into_data() .into_builder() - .nulls(Some(create_null_mask(array.len()))) + .nulls(create_null_mask(array.len(), null_density)) .build() .unwrap(); make_array(array_data) @@ -286,6 +291,13 @@ pub fn make_rng() -> StdRng { StdRng::seed_from_u64(42) } +/// RNG for null masks. Deliberately not `make_rng()`: sharing its stream makes +/// null positions a function of the generated values (e.g. which pool string +/// was picked for a row). +fn make_null_rng() -> StdRng { + StdRng::seed_from_u64(43) +} + /// String pool for generating low cardinality data (for dictionaries and string views) struct StringPool { strings: Vec, From df89d8327ce6348292d93ea512a79fd2a8f66060 Mon Sep 17 00:00:00 2001 From: Lakshya Sharma <229351168+lakshaydevoo-arch@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:51:10 +0000 Subject: [PATCH 7/7] bench: preserve baseline inputs and improve null-density coverage --- datafusion/common/benches/with_hashes.rs | 297 ++++++++++------------- 1 file changed, 125 insertions(+), 172 deletions(-) diff --git a/datafusion/common/benches/with_hashes.rs b/datafusion/common/benches/with_hashes.rs index dd17f17beb41c..85ff380b8c48f 100644 --- a/datafusion/common/benches/with_hashes.rs +++ b/datafusion/common/benches/with_hashes.rs @@ -28,11 +28,29 @@ const BATCH_SIZE: usize = 8192; struct BenchData { name: &'static str, array: ArrayRef, - /// Union arrays can't have null bitmasks added - supports_nulls: bool, - /// Extra null percentages to benchmark, in addition to the default 3% - /// ("nulls") case - additional_null_densities: &'static [u8], + /// Null percentages to benchmark, besides the no-null case. Empty for + /// arrays that can't have null bitmasks (e.g. unions) + null_densities: &'static [u8], +} + +impl BenchData { + /// An array benchmarked with no nulls and with the default null density + fn new(name: &'static str, array: ArrayRef) -> Self { + Self { + name, + array, + null_densities: &[DEFAULT_NULL_PERCENT], + } + } + + /// An array that can't have null bitmasks added, such as a union + fn no_nulls(name: &'static str, array: ArrayRef) -> Self { + Self { + name, + array, + null_densities: &[], + } + } } fn criterion_benchmark(c: &mut Criterion) { @@ -40,170 +58,79 @@ fn criterion_benchmark(c: &mut Criterion) { // poll with small strings for string view tests (<=12 bytes are inlined) let small_pool = StringPool::new(100, 5); let cases = [ + BenchData::new("int64", primitive_array::(BATCH_SIZE)), + BenchData::new("utf8", pool.string_array::(BATCH_SIZE)), + BenchData::new("large_utf8", pool.string_array::(BATCH_SIZE)), + BenchData::new("utf8_view", pool.string_view_array(BATCH_SIZE)), + BenchData::new( + "utf8_view (small)", + small_pool.string_view_array(BATCH_SIZE), + ), BenchData { - name: "int64", - array: primitive_array::(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "utf8", - array: pool.string_array::(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "large_utf8", - array: pool.string_array::(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "utf8_view", - array: pool.string_view_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "utf8_view (small)", - array: small_pool.string_view_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "dictionary_utf8_int32", - array: pool.dictionary_array::(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[50, 97], - }, - BenchData { - name: "list_array", - array: list_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "map_array", - array: map_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "sparse_union", - array: sparse_union_array(BATCH_SIZE), - supports_nulls: false, - additional_null_densities: &[], + null_densities: &[DEFAULT_NULL_PERCENT, 50, 97], + ..BenchData::new( + "dictionary_utf8_int32", + pool.dictionary_array::(BATCH_SIZE), + ) }, - BenchData { - name: "sparse_union (2 types)", - array: sparse_union_of( + BenchData::new("list_array", list_array(BATCH_SIZE)), + BenchData::new("map_array", map_array(BATCH_SIZE)), + BenchData::no_nulls("sparse_union", sparse_union_array(BATCH_SIZE)), + BenchData::no_nulls( + "sparse_union (2 types)", + sparse_union_of( (0..2) .map(|_| primitive_array::(BATCH_SIZE)) .collect(), ), - supports_nulls: false, - additional_null_densities: &[], - }, - BenchData { - name: "sparse_union (utf8)", - array: sparse_union_of( + ), + BenchData::no_nulls( + "sparse_union (utf8)", + sparse_union_of( (0..5) .map(|_| pool.string_array::(BATCH_SIZE)) .collect(), ), - supports_nulls: false, - additional_null_densities: &[], - }, - BenchData { - name: "dense_union", - array: dense_union_array(BATCH_SIZE), - supports_nulls: false, - additional_null_densities: &[], - }, - BenchData { - name: "struct_array", - array: create_struct_array(&pool, BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "run_array_int32", - array: create_run_array::(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "null", - array: Arc::new(NullArray::new(BATCH_SIZE)), - supports_nulls: false, - additional_null_densities: &[], - }, - BenchData { - name: "list_view_array", - array: list_view_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, - BenchData { - name: "fixed_size_list_array", - array: fixed_size_list_array(BATCH_SIZE), - supports_nulls: true, - additional_null_densities: &[], - }, + ), + BenchData::no_nulls("dense_union", dense_union_array(BATCH_SIZE)), + BenchData::new("struct_array", create_struct_array(&pool, BATCH_SIZE)), + BenchData::new("run_array_int32", create_run_array::(BATCH_SIZE)), + BenchData::no_nulls("null", Arc::new(NullArray::new(BATCH_SIZE))), + BenchData::new("list_view_array", list_view_array(BATCH_SIZE)), + BenchData::new("fixed_size_list_array", fixed_size_list_array(BATCH_SIZE)), ]; for BenchData { name, array, - supports_nulls, - additional_null_densities, + null_densities, } in cases { - c.bench_function(&format!("{name}: single, no nulls"), |b| { - do_hash_test(b, std::slice::from_ref(&array)); - }); - c.bench_function(&format!("{name}: multiple, no nulls"), |b| { - let arrays = vec![array.clone(), array.clone(), array.clone()]; - do_hash_test(b, &arrays); - }); - // Union arrays can't have null bitmasks - if supports_nulls { - let nullable_array = add_nulls(&array, DEFAULT_NULL_DENSITY); - c.bench_function(&format!("{name}: single, nulls"), |b| { - do_hash_test(b, std::slice::from_ref(&nullable_array)); - }); - c.bench_function(&format!("{name}: multiple, nulls"), |b| { - let arrays = vec![ - nullable_array.clone(), - nullable_array.clone(), - nullable_array.clone(), - ]; - do_hash_test(b, &arrays); - }); - for &null_percent in additional_null_densities { - let nullable_array = add_nulls(&array, null_percent as f32 / 100.0); - c.bench_function( - &format!("{name}: single, {null_percent}% nulls"), - |b| { - do_hash_test(b, std::slice::from_ref(&nullable_array)); - }, - ); - c.bench_function( - &format!("{name}: multiple, {null_percent}% nulls"), - |b| { - let arrays = vec![ - nullable_array.clone(), - nullable_array.clone(), - nullable_array.clone(), - ]; - do_hash_test(b, &arrays); - }, - ); - } + let columns = vec![array.clone(), array.clone(), array.clone()]; + bench_single_and_multiple(c, name, "no nulls", &columns); + for &null_percent in null_densities { + let columns = null_columns(&array, null_percent); + bench_single_and_multiple(c, name, &null_label(null_percent), &columns); } } } +/// Registers hashing of the first of `columns` ("single") and of all of them +/// ("multiple") +fn bench_single_and_multiple( + c: &mut Criterion, + name: &str, + label: &str, + columns: &[ArrayRef], +) { + c.bench_function(&format!("{name}: single, {label}"), |b| { + do_hash_test(b, &columns[..1]); + }); + c.bench_function(&format!("{name}: multiple, {label}"), |b| { + do_hash_test(b, columns); + }); +} + fn do_hash_test(b: &mut Bencher, arrays: &[ArrayRef]) { let state = RandomState::default(); b.iter(|| { @@ -215,19 +142,52 @@ fn do_hash_test(b: &mut Bencher, arrays: &[ArrayRef]) { }); } -/// Density of the default "nulls" benchmark cases -const DEFAULT_NULL_DENSITY: f32 = 0.03; +/// Percentage of nulls in the default "nulls" benchmark cases +const DEFAULT_NULL_PERCENT: u8 = 3; + +/// Seed of the default "nulls" masks, the same stream as `make_rng()`. It must +/// not change: it keeps those inputs identical to earlier benchmark runs. +const DEFAULT_NULL_SEED: u64 = 42; + +/// Seed of the first column of the other "N% nulls" masks. Not `make_rng()`'s +/// stream, which would tie null positions to the random draws that generated +/// the data (e.g. which pool string a dictionary key picked). +const NULL_SEED: u64 = 43; + +/// Number of columns in the "multiple" cases +const MULTIPLE_COLUMNS: usize = 3; + +/// Benchmark label for arrays with `null_percent` nulls +fn null_label(null_percent: u8) -> String { + if null_percent == DEFAULT_NULL_PERCENT { + "nulls".to_string() + } else { + format!("{null_percent}% nulls") + } +} + +/// Returns the columns of a "multiple" case, each `array` with `null_percent` +/// nulls. The default case has the same nulls in every column. In the others +/// the column masks come from different seeds, so rows have nulls in only some +/// columns. For one seed, a mask is a subset of the mask for any higher percentage. +fn null_columns(array: &ArrayRef, null_percent: u8) -> Vec { + let null_density = null_percent as f32 / 100.0; + (0..MULTIPLE_COLUMNS as u64) + .map(|column| { + let seed = if null_percent == DEFAULT_NULL_PERCENT { + DEFAULT_NULL_SEED + } else { + NULL_SEED + column + }; + add_nulls(array, null_density, seed) + }) + .collect() +} /// Returns a random null mask where each slot is null with probability -/// `null_density`, or `None` if no slot ended up null. -/// -/// The mask is drawn from its own RNG stream (`make_null_rng`) so null -/// positions are independent of the random draws that generated the data. -/// Every density restarts that stream, so a lower-density mask is a subset -/// of a higher-density one: the cases in a density sweep differ only in how -/// many slots are null. -fn create_null_mask(len: usize, null_density: f32) -> Option { - let mut rng = make_null_rng(); +/// `null_density`, drawn from the stream of `seed` +fn create_null_mask(len: usize, null_density: f32, seed: u64) -> NullBuffer { + let mut rng = StdRng::seed_from_u64(seed); let mut builder = NullBufferBuilder::new(len); for _ in 0..len { if rng.random::() < null_density { @@ -236,13 +196,13 @@ fn create_null_mask(len: usize, null_density: f32) -> Option { builder.append_non_null(); } } - builder.finish() + builder.finish().expect("should be nulls in buffer") } -// Returns a new array that is the same as array, but with each slot null with -// probability `null_density`. For a dictionary these are null keys. +// Returns a new array that is the same as array, but with nulls: each slot is +// null with probability `null_density`, using the mask stream of `seed` // Handles the special case of RunArray where nulls must be in the values array -fn add_nulls(array: &ArrayRef, null_density: f32) -> ArrayRef { +fn add_nulls(array: &ArrayRef, null_density: f32, seed: u64) -> ArrayRef { use arrow::datatypes::DataType; match array.data_type() { @@ -263,7 +223,7 @@ fn add_nulls(array: &ArrayRef, null_density: f32) -> ArrayRef { .clone() .into_data() .into_builder() - .nulls(create_null_mask(values.len(), null_density)) + .nulls(Some(create_null_mask(values.len(), null_density, seed))) .build() .unwrap(); make_array(array_data) @@ -279,7 +239,7 @@ fn add_nulls(array: &ArrayRef, null_density: f32) -> ArrayRef { .clone() .into_data() .into_builder() - .nulls(create_null_mask(array.len(), null_density)) + .nulls(Some(create_null_mask(array.len(), null_density, seed))) .build() .unwrap(); make_array(array_data) @@ -291,13 +251,6 @@ pub fn make_rng() -> StdRng { StdRng::seed_from_u64(42) } -/// RNG for null masks. Deliberately not `make_rng()`: sharing its stream makes -/// null positions a function of the generated values (e.g. which pool string -/// was picked for a row). -fn make_null_rng() -> StdRng { - StdRng::seed_from_u64(43) -} - /// String pool for generating low cardinality data (for dictionaries and string views) struct StringPool { strings: Vec,