-
Notifications
You must be signed in to change notification settings - Fork 1.2k
perf: speed up StringViewArray gc 1.4 ~5.x faster #7873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 26 commits
e0728ed
02f2870
5815519
f5488cc
4c3c7ee
1601ab6
a809c98
2bb5b93
5b5a05c
3cb9431
de6a199
55fb826
d4b7243
a774986
75eb0d0
fbd47d6
b214ab7
adc2b1f
5310dd1
1a01926
39cf32c
4786066
3350638
7a08ce0
32f27cb
adb8605
219aabf
c16d236
6e6387d
135cbeb
625c421
2097747
c1a1065
797b63e
5e30749
0110ef9
214f844
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -473,13 +473,92 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> { | |
| /// Note: this function does not attempt to canonicalize / deduplicate values. For this | ||
| /// feature see [`GenericByteViewBuilder::with_deduplicate_strings`]. | ||
| pub fn gc(&self) -> Self { | ||
| let mut builder = GenericByteViewBuilder::<T>::with_capacity(self.len()); | ||
| // 1) Read basic properties once | ||
| let len = self.len(); // number of elements | ||
| let views = self.views(); // raw u128 "view" values per slot | ||
| let nulls = self.nulls().cloned(); // reuse & clone existing null bitmap | ||
|
|
||
| // 2) Calculate total size of all non-inline data and detect if any exists | ||
| let mut total_large = 0; | ||
| if let Some(nbm) = &nulls { | ||
| for i in nbm.valid_indices() { | ||
| let bv = ByteView::from(unsafe { *views.get_unchecked(i) }); | ||
| if bv.length > MAX_INLINE_VIEW_LEN { | ||
| total_large += bv.length as usize; | ||
| } | ||
| } | ||
| } else { | ||
| for &raw in views.iter() { | ||
| let bv = ByteView::from(raw); | ||
| if bv.length > MAX_INLINE_VIEW_LEN { | ||
| total_large += bv.length as usize; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| for v in self.iter() { | ||
| builder.append_option(v); | ||
| // 2.5) Fast path: if there is no non-inline data, avoid buffer allocation & processing | ||
| if total_large == 0 { | ||
| // Views are inline-only or all null; just reuse original views and no data blocks | ||
| let views_scalar = ScalarBuffer::from(views.to_vec()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you @Dandandan for good suggestion! Addressed in latest PR! |
||
| return unsafe { | ||
| GenericByteViewArray::new_unchecked( | ||
| views_scalar, | ||
| vec![], // empty data blocks | ||
| nulls, | ||
| ) | ||
| }; | ||
| } | ||
|
|
||
| builder.finish() | ||
| // 3) Allocate exactly capacity for all non-inline data | ||
| let mut data_buf = Vec::with_capacity(total_large); | ||
|
|
||
| // 4) Iterate over views and process each inline/non-inline view | ||
| let views_buf: Vec<u128> = match &nulls { | ||
| Some(nbm) => { | ||
| let mut buf = vec![0u128; len]; | ||
| for i in nbm.valid_indices() { | ||
| buf[i] = self.process_view(i, views, &mut data_buf); | ||
| } | ||
| buf | ||
| } | ||
| None => (0..len) | ||
| .map(|i| self.process_view(i, views, &mut data_buf)) | ||
| .collect(), | ||
| }; | ||
|
|
||
| // 5) Wrap up buffers | ||
| let data_block = Buffer::from_vec(data_buf); | ||
| let views_scalar = ScalarBuffer::from(views_buf); | ||
| let data_blocks = vec![data_block]; | ||
|
|
||
| // SAFETY: views_scalar, data_blocks, and nulls are correctly aligned and sized | ||
| unsafe { GenericByteViewArray::new_unchecked(views_scalar, data_blocks, nulls) } | ||
| } | ||
|
|
||
| // This is the helper function that processes a view at index `i`, | ||
| // extracting the data from the buffers if necessary. | ||
| // It used by `gc` function to process each view. | ||
| #[inline(always)] | ||
| fn process_view(&self, i: usize, views: &[u128], data_buf: &mut Vec<u8>) -> u128 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should mark this method as unsafe as it makes assumptions that views always point to a valid view in self.buffers. It would be good to document assumptions too:
I think we might be able to make it safer by NOT passing in views but instead using
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you @alamb for review and good suggestion, addressed in latest PR.
zhuqi-lucas marked this conversation as resolved.
Outdated
|
||
| let raw_view = unsafe { *views.get_unchecked(i) }; | ||
| let mut bv = ByteView::from(raw_view); | ||
|
|
||
| if bv.length <= MAX_INLINE_VIEW_LEN { | ||
| raw_view | ||
| } else { | ||
| let buffer = unsafe { self.buffers.get_unchecked(bv.buffer_index as usize) }; | ||
| let start = bv.offset as usize; | ||
| let end = start + bv.length as usize; | ||
| let slice = unsafe { buffer.get_unchecked(start..end) }; | ||
|
|
||
| let new_offset = data_buf.len() as u32; | ||
| data_buf.extend_from_slice(slice); | ||
|
|
||
| bv.buffer_index = 0; | ||
| bv.offset = new_offset; | ||
|
|
||
| bv.into() | ||
| } | ||
| } | ||
|
|
||
| /// Returns the total number of bytes used by all non inlined views in all | ||
|
|
@@ -998,7 +1077,11 @@ mod tests { | |
| Array, BinaryViewArray, GenericBinaryArray, GenericByteViewArray, StringViewArray, | ||
| }; | ||
| use arrow_buffer::{Buffer, ScalarBuffer}; | ||
| use arrow_data::ByteView; | ||
| use arrow_data::{ByteView, MAX_INLINE_VIEW_LEN}; | ||
| use rand::prelude::StdRng; | ||
| use rand::{Rng, SeedableRng}; | ||
|
|
||
| const BLOCK_SIZE: u32 = 8; | ||
|
|
||
| #[test] | ||
| fn try_new_string() { | ||
|
|
@@ -1188,6 +1271,130 @@ mod tests { | |
| check_gc(&array.slice(3, 1)); | ||
| } | ||
|
|
||
| /// 1) Empty array: no elements, expect gc to return empty with no data buffers | ||
| #[test] | ||
| fn test_gc_empty_array() { | ||
| let array = StringViewBuilder::new() | ||
| .with_fixed_block_size(BLOCK_SIZE) | ||
| .finish(); | ||
| let gced = array.gc(); | ||
| // length and null count remain zero | ||
| assert_eq!(gced.len(), 0); | ||
| assert_eq!(gced.null_count(), 0); | ||
| // no underlying data buffers should be allocated | ||
| assert!( | ||
| gced.data_buffers().is_empty(), | ||
| "Expected no data buffers for empty array" | ||
| ); | ||
| } | ||
|
|
||
| /// 2) All inline values (<= INLINE_LEN): capacity-only data buffer, same values | ||
| #[test] | ||
| fn test_gc_all_inline() { | ||
| let mut builder = StringViewBuilder::new().with_fixed_block_size(BLOCK_SIZE); | ||
| // append many short strings, each exactly INLINE_LEN long | ||
| for _ in 0..100 { | ||
| let s = "A".repeat(MAX_INLINE_VIEW_LEN as usize); | ||
| builder.append_option(Some(&s)); | ||
| } | ||
| let array = builder.finish(); | ||
| let gced = array.gc(); | ||
| // Since all views fit inline, data buffer is empty | ||
| assert_eq!( | ||
| gced.data_buffers().len(), | ||
| 0, | ||
| "Should have no data buffers for inline values" | ||
| ); | ||
| assert_eq!(gced.len(), 100); | ||
| // verify element-wise equality | ||
| array.iter().zip(gced.iter()).for_each(|(orig, got)| { | ||
| assert_eq!(orig, got, "Inline value mismatch after gc"); | ||
| }); | ||
| } | ||
|
|
||
| /// 3) All large values (> INLINE_LEN): each must be copied into the new data buffer | ||
| #[test] | ||
| fn test_gc_all_large() { | ||
| let mut builder = StringViewBuilder::new().with_fixed_block_size(BLOCK_SIZE); | ||
| let large_str = "X".repeat(MAX_INLINE_VIEW_LEN as usize + 5); | ||
| // append multiple large strings | ||
| for _ in 0..50 { | ||
| builder.append_option(Some(&large_str)); | ||
| } | ||
| let array = builder.finish(); | ||
| let gced = array.gc(); | ||
| // New data buffers should be populated (one or more blocks) | ||
| assert!( | ||
| !gced.data_buffers().is_empty(), | ||
| "Expected data buffers for large values" | ||
| ); | ||
| assert_eq!(gced.len(), 50); | ||
| // verify that every large string emerges unchanged | ||
| array.iter().zip(gced.iter()).for_each(|(orig, got)| { | ||
| assert_eq!(orig, got, "Large view mismatch after gc"); | ||
| }); | ||
| } | ||
|
|
||
| /// 4) All null elements: ensure null bitmap handling path is correct | ||
| #[test] | ||
| fn test_gc_all_nulls() { | ||
| let mut builder = StringViewBuilder::new().with_fixed_block_size(BLOCK_SIZE); | ||
| for _ in 0..20 { | ||
| builder.append_null(); | ||
| } | ||
| let array = builder.finish(); | ||
| let gced = array.gc(); | ||
| // length and null count match | ||
| assert_eq!(gced.len(), 20); | ||
| assert_eq!(gced.null_count(), 20); | ||
| // data buffers remain empty for null-only array | ||
| assert!( | ||
| gced.data_buffers().is_empty(), | ||
| "No data should be stored for nulls" | ||
| ); | ||
| } | ||
|
|
||
| /// 5) Random mix of inline, large, and null values with slicing tests | ||
| #[test] | ||
| fn test_gc_random_mixed_and_slices() { | ||
| let mut rng = StdRng::seed_from_u64(42); | ||
| let mut builder = StringViewBuilder::new().with_fixed_block_size(BLOCK_SIZE); | ||
| // Keep a Vec of original Option<String> for later comparison | ||
| let mut original: Vec<Option<String>> = Vec::new(); | ||
|
|
||
| for _ in 0..200 { | ||
| if rng.random_bool(0.1) { | ||
| // 10% nulls | ||
| builder.append_null(); | ||
| original.push(None); | ||
| } else { | ||
| // random length between 0 and twice the inline limit | ||
| let len = rng.random_range(0..(MAX_INLINE_VIEW_LEN * 2)); | ||
| let s: String = "A".repeat(len as usize); | ||
| builder.append_option(Some(&s)); | ||
| original.push(Some(s)); | ||
| } | ||
| } | ||
|
|
||
| let array = builder.finish(); | ||
| // Test multiple slice ranges to ensure offset logic is correct | ||
| for (offset, slice_len) in &[(0, 50), (10, 100), (150, 30)] { | ||
| let sliced = array.slice(*offset, *slice_len); | ||
| let gced = sliced.gc(); | ||
| // Build expected slice of Option<&str> | ||
| let expected: Vec<Option<&str>> = original[*offset..(*offset + *slice_len)] | ||
| .iter() | ||
| .map(|opt| opt.as_deref()) | ||
| .collect(); | ||
|
|
||
| assert_eq!(gced.len(), *slice_len, "Slice length mismatch"); | ||
| // Compare element-wise | ||
| gced.iter().zip(expected.iter()).for_each(|(got, expect)| { | ||
| assert_eq!(got, *expect, "Value mismatch in mixed slice after gc"); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_eq() { | ||
| let test_data = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are some good tricks here that maybe we can apply to
coalese-- inarrow-rs/arrow-select/src/coalesce/byte_view.rs
Line 114 in 38a7a1a
The only difference is the ability to extend exsiting views/buffers rather than allocate entirely new buffers 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point @alamb , add the sub-task, i will start the investigation after this PR, thanks!
Improve the performance for coalese with StringView
In epic:
#7802