Repository navigation
GH-54885: [C++][Compute] Use 64-bit offsets for basic row encoding - #55170
zanmato1984 wants to merge 4 commits into
Conversation
Signed-off-by: Rossi Sun <[email protected]>
|
|
There was a problem hiding this comment.
🟢 Approval recommended
Static review found no blocking issues in the consistent widening or regression coverage; the opt-in large-memory tests were not executed.
No open issues 🎉
What changed in this PR
Fixes the 2 GiB encoded-buffer overflow behind GH-54885 by widening internal byte offsets and lengths to 64 bits in basic row encoding and grouping. Row IDs, group IDs, and per-column encoding formats remain unchanged.
Changes:
- Widens RowEncoder offsets and shared KeyEncoder length counters.
- Updates basic Grouper temporary and persistent buffer offsets consistently.
- Adds six opt-in large-memory regressions covering encoders, grouping, and join keys and payloads.
| File | Description |
|---|---|
| cpp/src/arrow/compute/row/row_encoder_internal.h | Widens encoder interfaces, row lengths, and stored offsets. |
| cpp/src/arrow/compute/row/row_encoder_internal.cc | Uses 64-bit length calculations and cumulative append sizes. |
| cpp/src/arrow/compute/row/row_encoder_internal_test.cc | Tests large value lengths and cumulative appends beyond 2 GiB. |
| cpp/src/arrow/compute/row/grouper.cc | Widens batch and persistent unique-key offsets. |
| cpp/src/arrow/compute/row/grouper_test.cc | Tests batch and unique-key buffers beyond 2 GiB. |
| cpp/src/arrow/acero/hash_join_node_test.cc | Tests oversized build-side key and payload buffers. |
Review effort: Balanced
Signed-off-by: Rossi Sun <[email protected]>
dmitry-chirkov-dremio
left a comment
There was a problem hiding this comment.
LGTM but I'd love to see benchmarks for short-row workloads
Avoid redundant offset initialization and read variable payload lengths directly from array metadata while retaining 64-bit counters. Signed-off-by: Rossi Sun <[email protected]>
| ASSERT_OK_AND_ASSIGN(auto expected, MakeArrayFromScalar(UInt32Scalar{0}, kBatchLength)); | ||
|
|
||
| // All rows must receive group 0, despite the large temporary batch encoding. | ||
| ASSERT_OK_AND_ASSIGN(auto groups, grouper->Consume(ExecSpan(batch))); |
There was a problem hiding this comment.
Added ValidateFull() before checking the group IDs.
| // Re-inserting the same key must not create more groups; Lookup must find it | ||
| // for every row, and GetUniques must decode just the one original value. | ||
| ASSERT_OK(grouper->Populate(ExecSpan(batch))); | ||
| ASSERT_OK_AND_ASSIGN(auto found, grouper->Lookup(ExecSpan(batch))); |
There was a problem hiding this comment.
Added ValidateFull() before comparing the lookup result with the expected IDs.
| ASSERT_OK(grouper->Populate(ExecSpan(batch))); | ||
| } else { | ||
| ASSERT_OK_AND_ASSIGN(auto groups, grouper->Consume(ExecSpan(batch))); | ||
| auto group_array = groups.make_array(); |
There was a problem hiding this comment.
Added ValidateFull(), length and null-count checks before accessing the individual group IDs. The final lookup result is now validated as well.
| } | ||
|
|
||
| Status RowEncoder::EncodeAndAppend(const ExecSpan& batch) { | ||
| const int64_t batch_length = batch.length; |
There was a problem hiding this comment.
Is there a point in this? Casting to int64_t perhaps?
There was a problem hiding this comment.
batch.length is already int64_t; this caches the loop bound rather than casting it. With Apple Clang on arm64, using the member directly caused the prefix-sum loop to reload it on each iteration. A local A/B benchmark showed about a 1% benefit from caching it for million-row fixed-width encoding batches. I kept the local variable for that small benefit; it is not required for correctness.
| LargeBinaryScalar scalar(bytes); | ||
| VarLengthKeyEncoder<LargeBinaryType> encoder(large_binary()); | ||
| int64_t length = 0; | ||
| encoder.AddLength(ExecValue(&scalar), /*batch_length=*/1, &length); |
There was a problem hiding this comment.
Why not use a non-trivial batch length?
There was a problem hiding this comment.
Changed the scalar case to broadcast over three rows and check every length counter, rather than only exercising a single row.
| auto offsets = Buffer::FromVector<int64_t>({0, kValueLength}); | ||
| auto array = ArrayData::Make(large_binary(), /*length=*/1, {nullptr, offsets, bytes}, | ||
| /*null_count=*/0); | ||
| encoder.AddLength(ExecValue(*array), /*batch_length=*/1, &length); |
There was a problem hiding this comment.
Changed the array case to three rows: a value larger than 2 GiB, an empty value, and a null. It reuses the same value buffer and checks all three counters, including accumulation on top of the scalar lengths.
| // the special all-null row, without decoding the entire large buffer. | ||
| const int32_t row_ids[] = {0, kBatchLength - 1, kNumRows - 2, kNumRows - 1, | ||
| RowEncoder::kRowIdForNulls()}; | ||
| ASSERT_OK_AND_ASSIGN(auto decoded, encoder.Decode(kNumDecodedValues + 1, row_ids)); |
There was a problem hiding this comment.
Added ValidateFull() on the complete decoded array before slicing it and comparing the values.
| constexpr int64_t kValueLength = 64 * 1024; | ||
| constexpr int32_t kBatchLength = 1024; | ||
| constexpr int32_t kNumBatches = 17; | ||
| // Keep join keys small so that it is the build-side payload encoder, not the |
There was a problem hiding this comment.
"Payload encoder" means the join result, or something else?
There was a problem hiding this comment.
It is the build-side encoder storing r_payload1 and r_payload2, not the join result. I clarified the comment to distinguish that buffer from the key buffer.
| const auto zero_id = ArrayFromJSON(int32(), "[0]"); | ||
| const auto zero_key = ArrayFromJSON(binary(), R"(["0"])"); | ||
|
|
||
| for (const auto& type : {large_utf8(), large_binary()}) { |
There was a problem hiding this comment.
Do we actually need to test both types?
There was a problem hiding this comment.
Agreed, both types use VarLengthKeyEncoder<LargeBinaryType> here. I kept only large_binary() for the payload regression, while retaining both small-key encodings and serial/parallel execution.
| // cover eight INNER join combinations without growing the expected output. | ||
| for (bool binary_keys : {false, true}) { | ||
| for (bool parallel : {false, true}) { | ||
| CheckRunOutput(JoinType::INNER, left, right, {binary_keys ? "l_key" : "l_id"}, |
There was a problem hiding this comment.
With the default true filter, LEFT_ANTI has no right-side output, so the large payload columns are projected away. Adding it to this test would therefore not exercise the payload buffer exceeding 2 GiB. A meaningful anti-join payload case would need a residual filter using those payload columns. I kept this test focused on INNER; the separate large-key regression still covers both INNER and LEFT_ANTI.
|
@zanmato1984 AI-generated PR description is very verbose, can you trim it for human consumption? |
Signed-off-by: Rossi Sun <[email protected]>
zanmato1984
left a comment
There was a problem hiding this comment.
Shortened the description to the problem, main changes, test coverage and memory overhead, while retaining the required disclosures.
| ASSERT_OK_AND_ASSIGN(auto expected, MakeArrayFromScalar(UInt32Scalar{0}, kBatchLength)); | ||
|
|
||
| // All rows must receive group 0, despite the large temporary batch encoding. | ||
| ASSERT_OK_AND_ASSIGN(auto groups, grouper->Consume(ExecSpan(batch))); |
There was a problem hiding this comment.
Added ValidateFull() before checking the group IDs.
| // Re-inserting the same key must not create more groups; Lookup must find it | ||
| // for every row, and GetUniques must decode just the one original value. | ||
| ASSERT_OK(grouper->Populate(ExecSpan(batch))); | ||
| ASSERT_OK_AND_ASSIGN(auto found, grouper->Lookup(ExecSpan(batch))); |
There was a problem hiding this comment.
Added ValidateFull() before comparing the lookup result with the expected IDs.
| ASSERT_OK(grouper->Populate(ExecSpan(batch))); | ||
| } else { | ||
| ASSERT_OK_AND_ASSIGN(auto groups, grouper->Consume(ExecSpan(batch))); | ||
| auto group_array = groups.make_array(); |
There was a problem hiding this comment.
Added ValidateFull(), length and null-count checks before accessing the individual group IDs. The final lookup result is now validated as well.
| } | ||
|
|
||
| Status RowEncoder::EncodeAndAppend(const ExecSpan& batch) { | ||
| const int64_t batch_length = batch.length; |
There was a problem hiding this comment.
batch.length is already int64_t; this caches the loop bound rather than casting it. With Apple Clang on arm64, using the member directly caused the prefix-sum loop to reload it on each iteration. A local A/B benchmark showed about a 1% benefit from caching it for million-row fixed-width encoding batches. I kept the local variable for that small benefit; it is not required for correctness.
| LargeBinaryScalar scalar(bytes); | ||
| VarLengthKeyEncoder<LargeBinaryType> encoder(large_binary()); | ||
| int64_t length = 0; | ||
| encoder.AddLength(ExecValue(&scalar), /*batch_length=*/1, &length); |
There was a problem hiding this comment.
Changed the scalar case to broadcast over three rows and check every length counter, rather than only exercising a single row.
| auto offsets = Buffer::FromVector<int64_t>({0, kValueLength}); | ||
| auto array = ArrayData::Make(large_binary(), /*length=*/1, {nullptr, offsets, bytes}, | ||
| /*null_count=*/0); | ||
| encoder.AddLength(ExecValue(*array), /*batch_length=*/1, &length); |
There was a problem hiding this comment.
Changed the array case to three rows: a value larger than 2 GiB, an empty value, and a null. It reuses the same value buffer and checks all three counters, including accumulation on top of the scalar lengths.
| // the special all-null row, without decoding the entire large buffer. | ||
| const int32_t row_ids[] = {0, kBatchLength - 1, kNumRows - 2, kNumRows - 1, | ||
| RowEncoder::kRowIdForNulls()}; | ||
| ASSERT_OK_AND_ASSIGN(auto decoded, encoder.Decode(kNumDecodedValues + 1, row_ids)); |
There was a problem hiding this comment.
Added ValidateFull() on the complete decoded array before slicing it and comparing the values.
| constexpr int64_t kValueLength = 64 * 1024; | ||
| constexpr int32_t kBatchLength = 1024; | ||
| constexpr int32_t kNumBatches = 17; | ||
| // Keep join keys small so that it is the build-side payload encoder, not the |
There was a problem hiding this comment.
It is the build-side encoder storing r_payload1 and r_payload2, not the join result. I clarified the comment to distinguish that buffer from the key buffer.
| const auto zero_id = ArrayFromJSON(int32(), "[0]"); | ||
| const auto zero_key = ArrayFromJSON(binary(), R"(["0"])"); | ||
|
|
||
| for (const auto& type : {large_utf8(), large_binary()}) { |
There was a problem hiding this comment.
Agreed, both types use VarLengthKeyEncoder<LargeBinaryType> here. I kept only large_binary() for the payload regression, while retaining both small-key encodings and serial/parallel execution.
| // cover eight INNER join combinations without growing the expected output. | ||
| for (bool binary_keys : {false, true}) { | ||
| for (bool parallel : {false, true}) { | ||
| CheckRunOutput(JoinType::INNER, left, right, {binary_keys ? "l_key" : "l_id"}, |
There was a problem hiding this comment.
With the default true filter, LEFT_ANTI has no right-side output, so the large payload columns are projected away. Adding it to this test would therefore not exercise the payload buffer exceeding 2 GiB. A meaningful anti-join payload case would need a residual filter using those payload columns. I kept this test focused on INNER; the separate large-key regression still covers both INNER and LEFT_ANTI.
Rationale for this change
Basic hash joins can crash when encoded build-side keys or payloads exceed 2 GiB because their cumulative byte offsets overflow int32_t.
What changes are included in this PR?
Use 64-bit byte offsets and length calculations in RowEncoder, shared KeyEncoder counters, and basic Grouper. Row/group IDs and per-column encoded length formats are unchanged.
Are these changes tested?
Added large-memory regressions for length calculation, appends, fixed-width decoding, grouping, and hash-join keys/payloads, plus tests for sliced inputs and encoder reuse.
Are there any user-facing changes?
Basic hash joins can handle more than 2 GiB of encoded large_string/large_binary build-side keys or payloads.
Offsets grow from 4 to 8 bytes. Extra element storage is 4 × (N + 1) bytes per RowEncoder storing N rows: approximately 3.8 MiB per million rows, separately for key and payload encoders. Basic Grouper also uses four additional bytes per unique-key offset and temporary batch offset. These figures exclude spare capacity and allocator overhead; total join memory does not double.
No public API or row/group-count limit changes.
This PR contains a "Critical Fix". It prevents a crash on valid input.
Was AI used for this PR?
The author conducted a thorough review with AI assistance.
PR code and description written by:
Reviewed before submission by:
large_stringkeys near 2gb #54885