fix: GenericByteViewArray::gc() drops inline views on the multi-buffer slow path#10287
fix: GenericByteViewArray::gc() drops inline views on the multi-buffer slow path#10287adriangb wants to merge 4 commits into
GenericByteViewArray::gc() drops inline views on the multi-buffer slow path#10287Conversation
`gc()` takes a multi-buffer "slow path" once a `Utf8View`/`BinaryView` column references more than `i32::MAX` (~2.1 GiB) of non-inline data (a single Arrow buffer is addressed by a `u32` offset, so the output must be split across buffers). That path built its copy groups by counting only *non-inline* views and then copied a contiguous index range of that size, so every inline (short) view was dropped and the row count shrank. The index-range / byte-budget mismatch could also push a single output buffer past `i32::MAX`. `gc()` is a pure size optimisation and must never change the row count, so callers that rebuild a `RecordBatch` from the result tripped the row-count invariant and panicked. Replace the grouping logic with a single pass over *all* views: inline views are copied unchanged, non-inline views are packed into the current buffer until the next would exceed the max buffer size, then a new buffer is sealed. Extract the body into a private `gc_with_max_buffer_size` so the split path can be tested with a tiny threshold instead of allocating 2+ GiB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQ5ToRQyLENghZVTbA36xt
|
@XiangpengHao or @rluvaton would one of you mind taking a look at this fix? |
| // progress even for a single value larger than `max_buffer_size`. | ||
| if view_len > MAX_INLINE_VIEW_LEN | ||
| && !data_buf.is_empty() | ||
| && data_buf.len() as u64 + view_len as u64 > max_buffer_size as u64 |
There was a problem hiding this comment.
why do we operate on u64's here instead of i32/u32?
There was a problem hiding this comment.
Left a comment in the code, copying here for visibility: data_buf.len() itself can exceed max_buffer_size, so the sum is computed in u64 to avoid overflowing the u32 offset space on the addition.
There was a problem hiding this comment.
data_buf.len() cannot exceed max_buffer_len here, i think you mean data_buf.len() + view_len can exceed it?
- Remove the redundant current_buffer_idx; use data_blocks.len() as the index of the buffer currently being filled (per review). - Size each buffer reservation to the data still to be copied (remaining_large.min(max_buffer_size)) instead of always reserving a full max_buffer_size, so the final buffer is no longer padded out to a full reservation when little data is left. - Clarify the comments: the reservation cap is about total_large (the multi-GiB sum that forced the slow path), not a single value; and note why the buffer-overflow check is done in u64 (a single value larger than max_buffer_size can push data_buf.len() past max_buffer_size). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xx1J3hcCvDUxCMnizMn8cz
|
@Jefffrey thank you for the review. I reviewed the changes Claude made to address it and they make sense to me, let me know if you agree. |
| total_len, | ||
| } | ||
| // slow path: the non-inline data does not fit in a single buffer | ||
| // (a buffer offset is a `u32`, so no buffer may exceed `i32::MAX`), |
There was a problem hiding this comment.
technically i think buffers can exceed i32::MAX safely; if we fill i32::MAX - 1 of the buffer, then the last string is also a massive string (e.g. i32::MAX) then its offset is still within i32 range 🤔
that is, things are fine so long as the starting offset is within i32 range, even if its length causes the buffer to exceed i32::MAX in length
There was a problem hiding this comment.
though i do wonder if having such strings could cause problems downstream in other functions anyway 🤔
| let mut remaining_large = total_large; | ||
| let mut views_buf = Vec::with_capacity(len); | ||
| let mut data_blocks: Vec<Buffer> = Vec::new(); | ||
| let mut data_buf: Vec<u8> = Vec::with_capacity(remaining_large.min(max_buffer_size)); |
There was a problem hiding this comment.
the way of reserving this much capacity up front can lead to fragmentation with unused capacity at the end; i guess this is why the initial approach calculated groups to get exact sizes 🤔
There was a problem hiding this comment.
Yes, visiting one pass is lightweight, so I uses group here
| // next one would overflow the current buffer's offset space, | ||
| // seal it and start a new one. The `!data_buf.is_empty()` guard | ||
| // avoids emitting a leading empty buffer and guarantees forward | ||
| // progress even for a single value larger than `max_buffer_size` |
There was a problem hiding this comment.
can we clean up these comments; it should not be possible for a single value to be larger than max_buffer_size (unless we're doing testing where max_buffer_size < i32::MAX)
| // progress even for a single value larger than `max_buffer_size`. | ||
| if view_len > MAX_INLINE_VIEW_LEN | ||
| && !data_buf.is_empty() | ||
| && data_buf.len() as u64 + view_len as u64 > max_buffer_size as u64 |
There was a problem hiding this comment.
data_buf.len() cannot exceed max_buffer_len here, i think you mean data_buf.len() + view_len can exceed it?
| let new_view = unsafe { self.copy_view_to_buffer(i, buffer_idx, &mut data_buf) }; | ||
| views_buf.push(new_view); | ||
| if is_large { | ||
| remaining_large -= view_len as usize; |
There was a problem hiding this comment.
we can probably push this remaining_large adjustment inside the same if block above where we allocate a new data_buf; this way we just subtract based on previous data_buf instead of on each iteration
| current_elements = 0; | ||
| } | ||
| current_length += len; | ||
| current_elements += 1; |
There was a problem hiding this comment.
So extract current_elements += 1; fix the bug that the small buffer being eliminate?
|
I wonder whether this is ok, since the wrong problem is diff --git a/arrow-array/src/array/byte_view_array.rs b/arrow-array/src/array/byte_view_array.rs
index be6e799221..cf137b4faf 100644
--- a/arrow-array/src/array/byte_view_array.rs
+++ b/arrow-array/src/array/byte_view_array.rs
@@ -469,6 +469,13 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
}
}
+ pub fn gc(&self) -> Self {
+ // A single Arrow data buffer is addressed by a `u32` offset, so no
+ // output buffer may exceed `i32::MAX` bytes. Above that the data is
+ // split across multiple buffers (the "slow path").
+ self.gc_with_max_buffer_size(i32::MAX as usize)
+ }
+
/// Returns a "compacted" version of this array
///
/// The original array will *not* be modified
@@ -512,7 +519,7 @@ 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 {
+ pub fn gc_with_max_buffer_size(&self, max_buffer_size: usize) -> Self {
// 1) Read basic properties once
let len = self.len(); // number of elements
let nulls = self.nulls().cloned(); // reuse & clone existing null bitmap
@@ -543,7 +550,7 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
};
}
- let (views_buf, data_blocks) = if total_large < i32::MAX as usize {
+ let (views_buf, data_blocks) = if total_large < max_buffer_size as usize {
// fast path, the entire data fits in a single buffer
// 3) Allocate exactly capacity for all non-inline data
let mut data_buf = Vec::with_capacity(total_large);
@@ -579,20 +586,20 @@ impl<T: ByteViewType + ?Sized> GenericByteViewArray<T> {
for view in self.views() {
let len = *view as u32;
if len > MAX_INLINE_VIEW_LEN {
- if current_length + len > i32::MAX as u32 {
+ if current_length + len > max_buffer_size as u32 {
// Start a new group
groups.push(GcCopyGroup::new(current_length, current_elements));
current_length = 0;
current_elements = 0;
}
current_length += len;
- current_elements += 1;
}
+ current_elements += 1;
}
if current_elements != 0 {
groups.push(GcCopyGroup::new(current_length, current_elements));
}
- debug_assert!(groups.len() <= i32::MAX as usize);
+ debug_assert!(groups.len() <= max_buffer_size);
// 3) Copy the buffers group by group
let mut views_buf = Vec::with_capacity(len); |
Address mapleFU's review: instead of a single reserve-as-you-go pass, partition views into contiguous groups (first pass) and copy each into an exactly-sized buffer (second pass). This restores precise buffer sizing with no over-reservation or trailing waste, while keeping the actual bug fix — counting *all* views per group, not just non-inline ones, so inline views are preserved and the row count is unchanged. Keeps the `gc_with_max_buffer_size` extraction and the `test_gc_slow_path_preserves_inline_views` regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPEM1WJDpZKTGsoHDkJwLD
A byte-view length is a u32 and a single value may exceed max_buffer_size, so the per-group running sum is computed in u64 to stay within the addressable buffer-offset space without wrapping. An oversized value that cannot be split occupies a buffer of its own. Add test_gc_slow_path_value_larger_than_buffer covering that path: row count preserved, values unchanged, and validate_full passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPEM1WJDpZKTGsoHDkJwLD
Which issue does this PR close?
N/A — filing alongside; happy to open a tracking issue if preferred.
Rationale for this change
GenericByteViewArray::gc()takes a multi-buffer slow path once aUtf8View/BinaryViewcolumn references more thani32::MAX(~2.1 GiB) of non-inline data. Because a single Arrow data buffer is addressed by au32offset, the compacted output has to be split across multiple buffers there.That slow path built its copy groups by counting only non-inline views (
current_elements), then copied a contiguous index range of that size. As a result:i32::MAX.gc()is documented as a pure size optimisation and must never change the row count. Downstream code that rebuilds aRecordBatchfrom the GC'd columns therefore panicked on the "all columns must have the same row count" invariant whenever a view column crossed the ~2.1 GiB threshold.The existing
test_gc_huge_arraymissed this because all of its views are non-inline, so the miscount happened to equallen.What changes are included in this PR?
gc_with_max_buffer_size(max_buffer_size);gc()calls it withi32::MAX. This lets the split path be tested with a tiny threshold instead of allocating 2+ GiB.test_gc_slow_path_preserves_inline_views, which interleaves inline, large, and null views and drives the split via a smallmax_buffer_size, asserting the row count, per-value equality, buffer-size cap, and that the split actually occurred.Are there any user-facing changes?
No. Public API is unchanged; this is a correctness fix to
gc().