This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 59eeb442a6 perf: optimize `rank` for byte views via caching prefix
keys (#10605)
59eeb442a6 is described below
commit 59eeb442a6a8f88823e3cc498623ff1a4eefe928
Author: Thefool <[email protected]>
AuthorDate: Tue Sep 1 15:01:38 2026 +0800
perf: optimize `rank` for byte views via caching prefix keys (#10605)
# Which issue does this PR close?
- Closes #10604.
# Rationale for this change
`byte_view_rank` currently materializes full byte slices and compares
them throughout the sort. For view values longer than the 12-byte inline
capacity, this can repeatedly dereference backing buffers in the O(n log
n) comparison loop.
The benchmark coverage for this optimization is split into #10772 so it
can be present on `main` and be measured by `@adriangbot` without
including benchmark changes in this implementation PR.
# What changes are included in this PR?
- Sort fully inline views using their precomputed `u128` inline keys.
- Cache a big-endian 16-byte key once for long and mixed views,
resolving full values only when keys collide.
- Sample up to eight valid values across two local windows and fall back
to the existing slice path when the sampled key-collision rate is high.
- Return early from sampling when four equal keys already guarantee the
fallback threshold.
- Add `StringView` and `BinaryView` correctness tests for key
collisions, zero bytes, prefix-length relationships, duplicates, nulls,
and all sort option combinations.
# Are these changes tested?
- `cargo test -p arrow-ord rank` passed (10 tests).
- `cargo fmt --all -- --check` passed.
- `git diff --check` passed.
The benchmark cases and the baseline-vs-optimized benchmark run are
tracked in #10772 and will be rerun after that PR is merged.
# Are there any user-facing changes?
No API or behavior changes. This only changes the internal rank
implementation and adds correctness coverage.
---
arrow-ord/src/rank.rs | 384 +++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 380 insertions(+), 4 deletions(-)
diff --git a/arrow-ord/src/rank.rs b/arrow-ord/src/rank.rs
index a4808b93fb..034a002b5d 100644
--- a/arrow-ord/src/rank.rs
+++ b/arrow-ord/src/rank.rs
@@ -106,16 +106,257 @@ fn byte_view_rank<T: ByteViewType>(
array: &GenericByteViewArray<T>,
options: SortOptions,
) -> Vec<u32> {
- let to_sort: Vec<(&[u8], u32)> = match array.nulls().filter(|n|
n.null_count() > 0) {
+ // An inline view already contains the complete value. Convert it once to
+ // a key whose integer ordering matches the byte ordering, as is done by
+ // `sort_byte_view`.
+ if array.data_buffers().is_empty() {
+ let to_sort: Vec<(u128, u32)> = match array.nulls().filter(|n|
n.null_count() > 0) {
+ Some(n) => n
+ .valid_indices()
+ .map(|idx| {
+ // SAFETY: `valid_indices` only yields indices in the
array.
+ let raw = unsafe { *array.views().get_unchecked(idx) };
+ (GenericByteViewArray::<T>::inline_key_fast(raw), idx as
u32)
+ })
+ .collect(),
+ None => array
+ .views()
+ .iter()
+ .enumerate()
+ .map(|(idx, raw)|
(GenericByteViewArray::<T>::inline_key_fast(*raw), idx as u32))
+ .collect(),
+ };
+ return rank_impl(
+ array.len(),
+ to_sort,
+ options,
+ |a, b| a.cmp(&b),
+ |a, b| a == b,
+ );
+ }
+
+ if has_high_byte_view_key_collision_rate(array) {
+ let to_sort: Vec<(&[u8], u32)> = match array.nulls().filter(|n|
n.null_count() > 0) {
+ Some(n) => n
+ .valid_indices()
+ .map(|idx| (array.value(idx).as_ref(), idx as u32))
+ .collect(),
+ None => (0..array.len())
+ .map(|idx| (array.value(idx).as_ref(), idx as u32))
+ .collect(),
+ };
+ return rank_impl(array.len(), to_sort, options, Ord::cmp,
PartialEq::eq);
+ }
+
+ // Cache a wider prefix than the 4 bytes stored in a non-inline view. This
+ // pays for the backing-buffer access once per value instead of once per
+ // comparison, and only resolves the complete value when two keys collide.
+ let to_sort: Vec<(u128, u32)> = match array.nulls().filter(|n|
n.null_count() > 0) {
Some(n) => n
.valid_indices()
- .map(|idx| (array.value(idx).as_ref(), idx as u32))
+ .map(|idx| {
+ // SAFETY: `valid_indices` only yields indices in the array.
+ let value: &[u8] = unsafe {
array.value_unchecked(idx).as_ref() };
+ (byte_view_key(value), idx as u32)
+ })
.collect(),
None => (0..array.len())
- .map(|idx| (array.value(idx).as_ref(), idx as u32))
+ .map(|idx| {
+ // SAFETY: `idx` is in `0..array.len()`.
+ let value: &[u8] = unsafe {
array.value_unchecked(idx).as_ref() };
+ (byte_view_key(value), idx as u32)
+ })
.collect(),
};
- rank_impl(array.len(), to_sort, options, Ord::cmp, PartialEq::eq)
+ rank_impl_by(
+ array.len(),
+ to_sort,
+ options,
+ |a, b| compare_view_key(array, a, b),
+ |a, b| equal_view_key(array, a, b),
+ )
+}
+
+// A 16-byte prefix fits in one `u128` and is wider than the 4-byte view
prefix.
+// Shorter keys collide more often; longer keys need another representation.
+const BYTE_VIEW_KEY_LEN: usize = 16;
+
+// Four valid values per window keeps sampling cheap while allowing an early
+// all-collision decision. More samples improve confidence but add buffer
reads.
+const BYTE_VIEW_KEY_SAMPLES_PER_WINDOW: usize = 4;
+
+// Capacity for the two windows; it must be at least
+// `2 * BYTE_VIEW_KEY_SAMPLES_PER_WINDOW`. Increasing it alone has no effect;
+// decreasing it without changing the sampling count can overflow the array.
+const BYTE_VIEW_KEY_SAMPLE_SIZE: usize = 8;
+
+// Bound entries inspected when nulls are present. A higher limit finds valid
+// samples more reliably but costs reads; a lower limit is cheaper but less
+// informative for null-heavy arrays.
+const BYTE_VIEW_KEY_MAX_PROBES_PER_WINDOW: usize = 32;
+
+// `colliding_keys * 3 >= sample_len` means roughly one third of sampled keys
+// are duplicates. Lower values fall back earlier; higher values risk keeping
+// the key path for collision-heavy inputs.
+const BYTE_VIEW_KEY_FALLBACK_COLLISION_RATIO: usize = 3;
+
+/// Estimates whether cached byte-view keys collide often enough to make the
+/// key-based ranking path unattractive.
+/// Caching a wider key usually avoids repeated backing-buffer reads for long
+/// views. If sampled keys collide frequently, resolving full values plus the
+/// extra key comparison can be slower than comparing slices directly, so the
+/// caller falls back to that path. The bounded two-window sample keeps this
+/// check inexpensive.
+fn has_high_byte_view_key_collision_rate<T: ByteViewType>(array:
&GenericByteViewArray<T>) -> bool {
+ if array.len() < 2 {
+ return false;
+ }
+
+ let mut keys = [0_u128; BYTE_VIEW_KEY_SAMPLE_SIZE];
+ let mut sample_len = 0;
+ let midpoint = array.len() / 2;
+
+ // Probe small local windows in both halves. Keeping each probe local
avoids
+ // turning collision detection itself into scattered backing-buffer reads.
+ for (start, end) in [(0, midpoint), (midpoint, array.len())] {
+ let probe_end =
end.min(start.saturating_add(BYTE_VIEW_KEY_MAX_PROBES_PER_WINDOW));
+ let window_start = sample_len;
+ let mut window_samples = 0;
+
+ for idx in start..probe_end {
+ if array.is_null(idx) {
+ continue;
+ }
+
+ // SAFETY: `idx` is within a window bounded by `array.len()`.
+ let value: &[u8] = unsafe { array.value_unchecked(idx).as_ref() };
+ keys[sample_len] = byte_view_key(value);
+ sample_len += 1;
+ window_samples += 1;
+ if window_samples == BYTE_VIEW_KEY_SAMPLES_PER_WINDOW {
+ break;
+ }
+ }
+
+ // Four equal keys already contribute three collisions. Even if every
+ // sample in the other window is distinct, that satisfies the final
+ // one-third threshold, so avoid touching the second backing-buffer
+ // window in the common all-collision case.
+ if window_samples == BYTE_VIEW_KEY_SAMPLES_PER_WINDOW
+ && keys[window_start..sample_len]
+ .windows(2)
+ .all(|w| w[0] == w[1])
+ {
+ return true;
+ }
+ }
+
+ if sample_len < 2 {
+ return false;
+ }
+
+ let keys = &mut keys[..sample_len];
+ keys.sort_unstable();
+ let unique_keys = 1 + keys.windows(2).filter(|w| w[0] != w[1]).count();
+
+ // If at least roughly one third of sampled keys collide, comparing the
+ // wider key before every full-value comparison is likely more expensive
+ // than sorting slices directly.
+ let colliding_keys = sample_len - unique_keys;
+ colliding_keys * BYTE_VIEW_KEY_FALLBACK_COLLISION_RATIO >= sample_len
+}
+
+#[inline(always)]
+fn byte_view_key(value: &[u8]) -> u128 {
+ let mut key = [0_u8; BYTE_VIEW_KEY_LEN];
+ let key_len = value.len().min(key.len());
+ key[..key_len].copy_from_slice(&value[..key_len]);
+
+ // Big-endian conversion makes integer comparison equivalent to comparing
+ // these bytes lexicographically. Equal keys fall back to the full values,
+ // covering values that differ after 16 bytes and prefixes containing zero.
+ u128::from_be_bytes(key)
+}
+
+#[inline(always)]
+fn compare_view_key<T: ByteViewType>(
+ array: &GenericByteViewArray<T>,
+ a: &(u128, u32),
+ b: &(u128, u32),
+) -> Ordering {
+ match a.0.cmp(&b.0) {
+ Ordering::Equal => {
+ // SAFETY: both indices were produced from this array above.
+ let full_a: &[u8] = unsafe { array.value_unchecked(a.1 as
usize).as_ref() };
+ let full_b: &[u8] = unsafe { array.value_unchecked(b.1 as
usize).as_ref() };
+ full_a.cmp(full_b)
+ }
+ ordering => ordering,
+ }
+}
+
+#[inline(always)]
+fn equal_view_key<T: ByteViewType>(
+ array: &GenericByteViewArray<T>,
+ a: &(u128, u32),
+ b: &(u128, u32),
+) -> bool {
+ if a.0 != b.0 {
+ return false;
+ }
+
+ // SAFETY: both indices were produced from this array above.
+ let full_a: &[u8] = unsafe { array.value_unchecked(a.1 as usize).as_ref()
};
+ let full_b: &[u8] = unsafe { array.value_unchecked(b.1 as usize).as_ref()
};
+ full_a == full_b
+}
+
+fn rank_impl_by<T, C, E>(
+ len: usize,
+ mut valid: Vec<(T, u32)>,
+ options: SortOptions,
+ compare: C,
+ eq: E,
+) -> Vec<u32>
+where
+ C: Fn(&(T, u32), &(T, u32)) -> Ordering,
+ E: Fn(&(T, u32), &(T, u32)) -> bool,
+{
+ // Same ranking and null handling as `rank_impl`, but callbacks receive
+ // tuple references because key collisions use the index to read the full
+ // value. `rank_impl` passes copied values directly through `Fn(T, T)`.
+ // We can use an unstable sort as we combine equal values later
+ valid.sort_unstable_by(compare);
+ if options.descending {
+ valid.reverse();
+ }
+
+ let (mut valid_rank, null_rank) = match options.nulls_first {
+ true => (len as u32, (len - valid.len()) as u32),
+ false => (valid.len() as u32, len as u32),
+ };
+
+ let mut out: Vec<_> = vec![null_rank; len];
+ if let Some(v) = valid.last() {
+ out[v.1 as usize] = valid_rank;
+ }
+
+ let mut count = 1; // Number of values in rank
+ for w in valid.windows(2).rev() {
+ match eq(&w[0], &w[1]) {
+ true => {
+ count += 1;
+ out[w[0].1 as usize] = valid_rank;
+ }
+ false => {
+ valid_rank -= count;
+ count = 1;
+ out[w[0].1 as usize] = valid_rank
+ }
+ }
+ }
+
+ out
}
fn rank_impl<T, C, E>(
@@ -247,6 +488,21 @@ mod tests {
use super::*;
use arrow_array::*;
+ fn assert_same_rank(left: &dyn Array, right: &dyn Array) {
+ for descending in [false, true] {
+ for nulls_first in [false, true] {
+ let options = SortOptions {
+ descending,
+ nulls_first,
+ };
+ assert_eq!(
+ rank(left, Some(options)).unwrap(),
+ rank(right, Some(options)).unwrap()
+ );
+ }
+ }
+ }
+
#[test]
fn test_primitive() {
let descending = SortOptions {
@@ -389,6 +645,35 @@ mod tests {
assert_eq!(res, &[3, 1, 4, 3]);
}
+ #[test]
+ fn test_inline_byte_views() {
+ let string_values = vec![
+ Some(""),
+ Some("short"),
+ None,
+ Some("0123456789qa"), // exactly the 12-byte inline limit
+ Some("short"),
+ ];
+ let string_view = StringViewArray::from(string_values.clone());
+ let string = StringArray::from(string_values);
+
+ assert!(string_view.data_buffers().is_empty());
+ assert_same_rank(&string_view, &string);
+
+ let binary_values: Vec<Option<&[u8]>> = vec![
+ Some(b""),
+ Some(b"short"),
+ None,
+ Some(b"0123456789qa"),
+ Some(b"short"),
+ ];
+ let binary_view = BinaryViewArray::from_iter(binary_values.clone());
+ let binary = BinaryArray::from_opt_vec(binary_values);
+
+ assert!(binary_view.data_buffers().is_empty());
+ assert_same_rank(&binary_view, &binary);
+ }
+
#[test]
fn test_string_view_with_nulls() {
let values = StringViewArray::from(vec![
@@ -413,4 +698,95 @@ mod tests {
let res = rank(&values, None).unwrap();
assert_eq!(res, &[3, 4, 1, 3]);
}
+
+ #[test]
+ fn test_string_view_key_collisions() {
+ let values = vec![
+ Some("abcdefghijklmnop"),
+ Some("abcdefghijklmnopA"),
+ Some("abcdefghijklmnopB"),
+ Some("abcdefghijklmnopA"),
+ Some("abcdefghijklmno"),
+ Some("short"),
+ None,
+ ];
+ let expected = StringArray::from(values.clone());
+ let actual = StringViewArray::from(values);
+
+ assert_same_rank(&actual, &expected);
+ }
+
+ #[test]
+ fn test_binary_view_key_collisions() {
+ let zeroes_16 = [0_u8; 16];
+ let zeroes_17 = [0_u8; 17];
+ let mut zeroes_then_one = [0_u8; 17];
+ zeroes_then_one[16] = 1;
+ let mut zeroes_then_two = [0_u8; 17];
+ zeroes_then_two[16] = 2;
+
+ let values: Vec<Option<&[u8]>> = vec![
+ Some(b""),
+ Some(b"\0"),
+ Some(&zeroes_16),
+ Some(&zeroes_17),
+ Some(&zeroes_then_one),
+ Some(&zeroes_then_two),
+ Some(&zeroes_then_one),
+ None,
+ ];
+ let expected = BinaryArray::from_opt_vec(values.clone());
+ let actual = BinaryViewArray::from_iter(values);
+
+ assert_same_rank(&actual, &expected);
+ }
+
+ #[test]
+ fn test_byte_view_high_key_collision_detection() {
+ const SIZE: u32 = 64;
+
+ let same_key: StringViewArray = (0..SIZE)
+ .map(|i| {
+ let suffix = i.wrapping_mul(2_654_435_761);
+ Some(format!("abcdefghijklmnop{suffix:08x}"))
+ })
+ .collect();
+
+ assert_eq!(
+ byte_view_key(same_key.value(0).as_bytes()),
+ byte_view_key(same_key.value(1).as_bytes())
+ );
+ assert!(has_high_byte_view_key_collision_rate(&same_key));
+
+ let clustered: StringViewArray = (0..SIZE)
+ .map(|i| {
+ let suffix = i.wrapping_mul(2_654_435_761);
+ let value = if i < SIZE / 2 {
+ format!("{suffix:016x}abcdefgh")
+ } else {
+ format!("abcdefghijklmnop{suffix:08x}")
+ };
+ Some(value)
+ })
+ .collect();
+ assert!(has_high_byte_view_key_collision_rate(&clustered));
+
+ let with_nulls: StringViewArray = (0..SIZE)
+ .map(|i| {
+ (i % 2 == 0).then(|| {
+ let suffix = i.wrapping_mul(2_654_435_761);
+ format!("abcdefghijklmnop{suffix:08x}")
+ })
+ })
+ .collect();
+ assert!(has_high_byte_view_key_collision_rate(&with_nulls));
+
+ let distinct: StringViewArray = (0..SIZE)
+ .map(|i| {
+ let suffix = i.wrapping_mul(2_654_435_761);
+ Some(format!("{suffix:016x}abcdefgh"))
+ })
+ .collect();
+ assert!(!has_high_byte_view_key_collision_rate(&distinct));
+ }
}