Skip to content

bench: add nullable dictionary hash density benchmarks - #27008

Open
lakshaydevoo-arch wants to merge 13 commits into
apache:mainfrom
lakshaydevoo-arch:bench/26848-dictionary-null-density
Open

lakshaydevoo-arch wants to merge 13 commits into
apache:mainfrom
lakshaydevoo-arch:bench/26848-dictionary-null-density

Conversation

@lakshaydevoo-arch

Copy link
Copy Markdown

Important

Fork CI setup: For pull requests from a fork to main, enable workflows in your fork's Actions tab before you push.
See the fork CI setup guide.

Which issue does this PR close?

Part of #26848.

Related to #26966.

Rationale for this change

This PR separates the additional dictionary hashing benchmarks from the optimization in #26966, as requested during review.

Having the benchmarks in main first will allow the benchmark bot to compare the same workloads before and after the optimization.

What changes are included in this PR?

  • Add Int32 dictionary hashing benchmarks with 50% and 97% null keys.
  • Cover both single-column and multi-column hashing.
  • Keep the existing 0% and 3% null-key benchmarks unchanged.
  • Reuse deterministic null-key generation for reproducible workloads.

The benchmarks use 8,192 rows and the existing dictionary value pool.

No hashing implementation changes are included.

What is the testing strategy for this PR?

The new benchmarks are registered in the existing with_hashes Criterion benchmark suite.

The benchmark target was previously compiled successfully using:

cargo bench -p datafusion-common --bench with_hashes --no-run

The additional cases were also executed locally and by the benchmark bot as part of #26966.

This PR adds benchmark coverage only. Performance comparisons will be made against these benchmarks once they are available in the baseline.

Are there any user-facing changes?

No. This PR only adds benchmarks.

@Rich-T-kid Rich-T-kid left a comment

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.

this looks good, we should simplify it to make it look like the other benchmarks in the file

Comment on lines +676 to +693
fn dictionary_benchmark(c: &mut Criterion) {
let pool = StringPool::new(100, 64);
let dict = pool.typed_dictionary_array::<Int32Type>(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);
},
);
}
}

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 think we should add this to existing benchmarks

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense. i will move these cases into the existing dictionary benchmarks to keep everything together.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. I've moved the 50% and 97% null-key cases into the existing criterion_benchmark() loop and removed the separate benchmark function, the existing benchmark cases remain unchanged.

lakshaydevoo-arch and others added 3 commits November 3, 2026 19:52
Co-authored-by: RIchard Baah <137434454+Rich-T-kid@users.noreply.github.com>
Add comments for future null-density cases in benchmarks.

@Rich-T-kid Rich-T-kid left a comment

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.

thank you @lakshaydevoo-arch

@lakshaydevoo-arch

lakshaydevoo-arch commented Nov 3, 2026 •

Copy link
Copy Markdown
Author

i have found another issue also , it's a sorting bug that when adding another column it distorts the global sorting order, should i start investigation on it in another branch
or wait for my these remaining optimization pr's to merge?

@Rich-T-kid

Copy link
Copy Markdown
Contributor

I think its best to do that in a separate branch

@lakshaydevoo-arch

Copy link
Copy Markdown
Author

I was mainly hesitant about opening another PR while my current optimization PR is still under review, as I don't want to add unnecessary review overhead for the maintainers, so will be
opening another focused PR for that issue and investigate more to that until these gets merged.

@lakshaydevoo-arch

Copy link
Copy Markdown
Author

@Rich-T-kid when will it merge?

@Rich-T-kid

Copy link
Copy Markdown
Contributor

@Rich-T-kid when will it merge?

@lakshaydevoo-arch 😅 i'm not a committer. i think @kumarUjjawal would have the most context to review these changes, if he has time

@lakshaydevoo-arch

Copy link
Copy Markdown
Author

@kumarUjjawal could you please review it

@kumarUjjawal kumarUjjawal left a comment

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.

Thank you @lakshaydevoo-arch

Left few comments please take a look

@lakshaydevoo-arch

Copy link
Copy Markdown
Author

@kumarUjjawal i just wanted to ask that when will it be merged because my another main performance optimization PR #26966 depends on these benchmarks

@kumarUjjawal kumarUjjawal left a comment

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.

@lakshaydevoo-arch Please don't resolve the open threads. I will do it once I am satisfied with the code changes.

let null_density = 0.03;
let mut builder = NullBufferBuilder::new(len);
for _ in 0..len {
if rng.random::<f32>() < null_density {

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.

Every density uses the same seed (42) and the same rng.random::<f32>() < null_density test. So the null positions are nested: the 3% mask from add_nulls is inside the 50% mask, which is inside the 97% mask. raised this in threads/r4463265560.md; the thread is resolved but the code is not changed.

/// 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<NullBuffer> {
let mut rng = make_rng();

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.

create_null_mask_with_density calls make_rng(), which uses seed 42. iter_strings uses the same seed to pick each dictionary key, so the draws that decide which keys become null come from the same random stream that chose the strings. kumarUjjawal raised this in threads/r4463265558.md; it is resolved but not changed.

Failure scenario: At 97% nulls only about 250 keys stay non-null, and which ones stay depends on the same random draws that chose the strings. The values that are left can be skewed toward a few dictionary entries instead of a uniform sample. That changes how much value-hash work is done and makes the high-density numbers unrepresentative.

do_hash_test(b, &arrays);
});
}
// Apply additional null-density cases to all Int32 dictionary

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.

The comment says the density cases apply to dictionary benchmarks 'including any added in the future', but the downcast only matches DictionaryArray<Int32Type>.**

Failure scenario: Someone adds a dictionary_utf8_int8 or int64 key case to cases and expects the 50%/97% density cases from this comment. The downcast returns None and those cases are silently never registered, and nothing warns about it.

let nullable_array = with_null_keys(dict, null_percent as f32 / 100.0);

c.bench_function(
&format!("{name}: single, {null_percent}% null keys"),

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.

The new cases are named '{name}: single, 50% null keys' while the existing 3% case for the same array is still '{name}: single, nulls', so one density sweep is split across two naming schemes. raised this in threads/r4463265565.md; it is resolved but not changed.

Failure scenario: When you filter or compare the dictionary null-density results with critcmp or the benchmark bot (for example --bench with_hashes '% null keys'), the 3% data point is left out, and the results don't show that 'nulls' means 3%.

}
// 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::<DictionaryArray<Int32Type>>() {

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.

The extra density cases are chosen by downcasting inside the shared loop, not declared on the BenchData entry (for example a null_densities: &'static [u8] field like the existing supports_nulls). kumarUjjawal raised this in threads/r4463265572.md; it is resolved but not changed.

Failure scenario: The type check hides which cases get extra benchmarks. Adding density cases to another array type means adding another downcast branch to the shared loop, not setting a field on its entry. This also leads directly to the misleading 'future' comment above.

/// 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<K: ArrowDictionaryKeyType>(

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.

with_null_keys repeats what add_nulls already does. For a dictionary, into_data().into_builder().nulls(...) sets the key nulls in the same way. Giving add_nulls (or create_null_mask) a density parameter would remove the new helper and the Option/expect split.

Failure scenario: Now two code paths build nullable dictionaries: add_nulls for 3% and with_null_keys for 50%/97%. They can drift apart in how they handle buffers, and they are a second place to update if null generation changes, for example to fix the seed problem above.

/// 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<K: ArrowDictionaryKeyType>(

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.

with_null_keys is generic over the key type K: ArrowDictionaryKeyType, but its only caller passes DictionaryArray<Int32Type>. kumarUjjawal raised this in threads/r4463265575.md; it is resolved but not changed.

Failure scenario: A generic that is never used with more than one type adds complexity. Because the caller is limited to Int32, the generic also suggests coverage of other key types that does not exist.

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.

fn create_null_mask(len: usize) -> NullBuffer

create_null_mask keeps the bound where StandardUniform: Distribution<bool>. It is always true and has no purpose, now that the function is a one-line wrapper around create_null_mask_with_density.

Failure scenario: The leftover bound is noise in a function the diff touches, and it makes readers wonder why the wrapper needs it when the real implementation does not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common Related to common crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants