samvallad33 opened a new pull request, #10912:
URL: https://github.com/apache/arrow-rs/pull/10912

   Draft, part of #7973. Single byte array columns read correctly now. Multi 
column needs a structural decision that I want from you before I build it, and 
separately, the regression tests already on main do not test anything.
   
   ## What overflows
   
   `OffsetBuffer::try_push` fails at 
`parquet/src/arrow/buffer/offset_buffer.rs:77-78` once the values written for 
one batch pass `i32::MAX`. At the default batch size of 8192 that caps the 
average value at 256KB. The file in the issue holds 5MB rows, so about 409 of 
them fit.
   
   `DELTA_LENGTH_BYTE_ARRAY` does not even get an error there. It panics, at 
`parquet/src/arrow/array_reader/byte_array.rs:514`, because that path builds 
offsets with `expect` instead of `try_push`.
   
   ## Why the three earlier attempts could not have worked
   
   #9362, #9369 and #9504 all stop the byte array decoder early and return a 
short count. That cannot work as a local change, for four separate reasons, all 
on main today:
   
   1. `parquet/src/column/reader.rs:292-298` turns a short values read into a 
hard error, `insufficient values read from column`.
   2. The definition levels for those values were already consumed at 
`parquet/src/column/reader.rs:276`, before the values decoder runs at line 292. 
By the time the decoder discovers the next value does not fit, the level stream 
has moved past it and there is nothing to unwind it with.
   3. `parquet/src/arrow/array_reader/mod.rs:195-227` treats any short read 
from the record reader as an exhausted column chunk and calls `pages.next()`. A 
short read that is not end of chunk therefore drops the rest of the chunk 
silently.
   4. `parquet/src/arrow/record_reader/mod.rs:147-162` loops while 
`has_next()`. A decoder that returns 0 while the page still holds data spins 
forever.
   
   So the stop has to happen before the levels are read, not inside the values 
decoder.
   
   ## What this PR does
   
   Cap the batch before any levels are consumed.
   
   - New `ColumnValueDecoder::values_capacity`, default `None`. It returns an 
upper bound on the values that can still be decoded into the output buffer from 
the current page, or `None` when the whole remainder is guaranteed to fit.
   - `GenericColumnReader::read_records_with_reservation` calls it once per 
data page, before decoding levels, and lowers the record budget. For a non 
repeated column a record contributes at most one value, so capping records by a 
value budget is always safe.
   - A `stopped_for_capacity` flag distinguishes a capacity stop from an 
exhausted chunk. `GenericRecordReader::read_records` stops instead of spinning, 
and the free `read_records` in `array_reader/mod.rs` stops instead of advancing 
the page iterator.
   - `ByteArrayColumnValueDecoder` implements `values_capacity` for all four 
byte array encodings.
   
   Hot path cost. `values_capacity` is a defaulted method reached through a 
generic type parameter, not a trait object, so for every decoder that does not 
override it the call and the branch fold away at monomorphisation. For the byte 
array decoder the common case is one comparison against a bound the decoder 
already holds:
   
   - PLAIN, bytes left in the page. Those include the 4 byte length prefixes, 
so it is a strict over estimate.
   - DELTA_LENGTH_BYTE_ARRAY, value bytes left in the page.
   - DELTA_BYTE_ARRAY, values left times the longest value in the page. A delta 
byte array value can be longer than the bytes it occupies because of the shared 
prefix, so the page length is not a bound. The longest value is computed once 
from the length arrays the decoder already materialises.
   - RLE_DICTIONARY, keys left times the longest dictionary value, computed 
once per dictionary page.
   
   Only when that comparison says the remainder might not fit does anything 
scan, and by then we are already in the multi gigabyte regime.
   
   `LargeUtf8` and `LargeBinary` are untouched. `OffsetSizeTrait::IS_LARGE` is 
a constant, so `values_capacity` reduces to `None` when monomorphised for `i64`.
   
   ## The regression tests on main do not reach the reader
   
   `parquet/tests/arrow_reader/large_string_overflow.rs` from #9361 builds its 
input with `BinaryBuilder`, 1024 values of 3MB each. That is 3GB, so 
`append_value` panics on row 683 inside the test helper, at 
`arrow-array/src/builder/generic_bytes_builder.rs:87`. The panic message is the 
exact string the tests assert on, so all four pass, and they pass without a 
parquet file ever being written.
   
   ```
   $ RUST_BACKTRACE=1 cargo test -p parquet --features 
arrow,async,test_common,experimental \
       --test arrow_reader large_binary_plain_encoding_overflow -- --nocapture 
--test-threads=1
   
   running 1 test
   test large_string_overflow::large_binary_plain_encoding_overflow - should 
panic ...
   thread '...' panicked at 
arrow-array/src/builder/generic_bytes_builder.rs:87:57:
   byte array offset overflow
   stack backtrace:
      5: <...GenericByteBuilder<GenericBinaryType<i32>>>::next_offset
                at arrow-array/src/builder/generic_bytes_builder.rs:87:57
      6: 
<...GenericByteBuilder<GenericBinaryType<i32>>>::append_value::<&Vec<u8>>
                at arrow-array/src/builder/generic_bytes_builder.rs:110:40
      7: arrow_reader::large_string_overflow::make_large_binary_array
                at ./tests/arrow_reader/large_string_overflow.rs:43:17
      8: 
arrow_reader::large_string_overflow::large_binary_plain_encoding_overflow
                at ./tests/arrow_reader/large_string_overflow.rs:80:17
   ok
   
   test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 124 filtered 
out; finished in 0.33s
   ```
   
   0.33s, and a stack that never reaches parquet. They would keep passing after 
any fix, which is why they cannot serve as acceptance criteria.
   
   This PR replaces them. The input is written in 100 row chunks so no single 
input array is oversized, the row group is 700 values of 3MB for 2.1GB, and the 
test reads the file back and checks every row survives in order across however 
many batches the reader chooses. All four encodings run in one test function on 
purpose: each holds a little over 2GB while decoding, and four separate tests 
let the harness run them in parallel, which needs four times the memory.
   
   Against main, with only the test file changed:
   
   ```
   running 4 tests
   test large_string_overflow::large_binary_delta_byte_array_encoding_overflow 
... FAILED
   test large_string_overflow::large_binary_delta_length_encoding_overflow ... 
FAILED
   test large_string_overflow::large_binary_plain_encoding_overflow ... FAILED
   test large_string_overflow::large_binary_rle_dictionary_encoding_overflow 
... FAILED
   
   ---- large_binary_plain_encoding_overflow stdout ----
   called `Result::unwrap()` on an `Err` value:
   ParquetError("Parquet error: index overflow decoding byte array")
   
   ---- large_binary_delta_length_encoding_overflow stdout ----
   thread '...' panicked at parquet/src/arrow/array_reader/byte_array.rs:514:36:
   index overflow decoding byte array
   
   test result: FAILED. 0 passed; 4 failed; 0 ignored; 0 measured; 121 filtered 
out; finished in 130.53s
   ```
   
   With the source changes:
   
   ```
   running 4 tests
   test large_string_overflow::large_binary_delta_byte_array_encoding_overflow 
... ok
   test large_string_overflow::large_binary_delta_length_encoding_overflow ... 
ok
   test large_string_overflow::large_binary_plain_encoding_overflow ... ok
   test large_string_overflow::large_binary_rle_dictionary_encoding_overflow 
... ok
   
   test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 121 filtered 
out; finished in 132.38s
   ```
   
   That was with the four tests still separate. They are one function now, 
which is what is in the branch.
   
   ## What works and what does not
   
   Works:
   
   - A byte array column read on its own, which is the `select length(html) 
from evil.parquet` case in the issue. The reader emits shorter batches and 
every row comes back in order.
   - All four byte array encodings.
   
   Not covered yet, and I made each of these fail with a message that names the 
limit rather than let it return wrong data:
   
   - More than one column in the projection. `StructArrayReader::read_records` 
at `parquet/src/arrow/array_reader/struct_array.rs:72-90` requires every child 
to return the same count. The stop point is only discovered part way through 
the batch, after the other columns have already decoded past it, and a leaf 
cannot be rewound. So `read_row_group` on the 8 column HuggingFace file still 
fails.
   - The predicate cache. `CachedArrayReader` maps row offsets to batches at 
fixed multiples of `batch_size`, 
`parquet/src/arrow/array_reader/cached_array_reader.rs:141`, so a short read 
that is not end of column would make every later batch id point at the wrong 
rows.
   - Row filters that use the mask cursor, for the same reason: the mask is 
built for the chunk before the read.
   - Repeated columns, a list or map of strings. A record can hold many values 
there, so a value budget does not convert into a record budget. The cap is 
disabled when a repetition level decoder is present.
   - `byte_array_dictionary.rs`, which produces a `DictionaryArray`, has its 
own value decoder and is unchanged.
   
   ## The design question
   
   Multi column is the part I want your call on, because it needs one of two 
structural changes and I do not want to guess wrong a fourth time:
   
   - Option A, the record reader learns to carry over. 
`GenericRecordReader::consume_record_data` currently hands out the whole 
buffer, `parquet/src/arrow/record_reader/mod.rs:205-209`. It would gain a split 
so a leaf can emit N records and keep the rest, which means `ValuesBuffer` 
gains a split too, and the definition and repetition level buffers with it.
   - Option B, the struct reader carries over at the array level. Children read 
as they do now, `StructArrayReader` emits the minimum length across children 
and holds the tails as sliced `ArrayRef`s. A child that is holding a tail does 
not read again until that tail drains, which keeps it to slicing only, never 
`concat`, so a carried tail plus a fresh batch cannot overflow on its own.
   
   B is the smaller change and touches no hot loop. A is more invasive but 
keeps the accounting in one place and would also cover the predicate cache. 
Tell me which you want and I will finish it here.
   
   ## About the benchmark result on #9369
   
   The bot run on #9369 reported its largest regressions on 
`FIXED_LEN_BYTE_ARRAY/Float16Array`, 1.57x and 1.53x on the plain encoded 
cases. That number cannot be work. #9369 touched `byte_array.rs`, 
`arrow_reader/mod.rs`, `offset_buffer.rs` and one test. The Float16 benchmark 
builds its reader with `make_fixed_len_byte_array_reader` at 
`parquet/benches/arrow_reader.rs:1037`, and `fixed_len_byte_array.rs` contains 
no reference to any of that:
   
   ```
   $ grep -n "offset_buffer\|OffsetBuffer\|byte_array::" 
parquet/src/arrow/array_reader/fixed_len_byte_array.rs
   NONE
   ```
   
   A change that cannot execute on a code path cannot slow that path by 57 
percent, so what the bot measured there was either run to run noise or code 
layout and inlining drift.
   
   ## A noise floor, which nobody has published for this harness
   
   I ran `--bench arrow_reader` filtered to `Float16Array|BinaryArray/plain`, 
six times, serially, on an idle M1 Max. `main2` and `main3` are two runs of the 
identical binary from `cbbb56b`, back to back, no rebuild in between. `branch2` 
and `branch3` are two runs of this branch. `main1` and `branch1` were each the 
first run after their build and I show `main1` separately below because of what 
it does. Criterion defaults, times in microseconds.
   
   ```
   benchmark                                                           main2   
main3  branch2 branch3  main/main  branch/main
   BinaryArray/plain encoded, mandatory, no NULLs                      862.1   
881.1   874.6   880.3       2.2%      1.007x
   BinaryArray/plain encoded, optional, half NULLs                     748.4   
769.2   809.5   786.5       2.8%      1.052x
   BinaryArray/plain encoded, optional, no NULLs                       903.4   
852.8   885.2   884.3       5.9%      1.008x
   Float16Array/byte_stream_split, mandatory, no NULLs                 119.8   
119.8   120.0   120.2       0.0%      1.002x
   Float16Array/byte_stream_split, optional, half NULLs                214.0   
203.3   203.9   203.5       5.2%      0.976x
   Float16Array/byte_stream_split, optional, no NULLs                  134.9   
122.5   122.4   122.4      10.1%      0.951x
   Float16Array/plain, mandatory, no NULLs                              59.5    
59.4    59.7    59.7       0.1%      1.004x
   Float16Array/plain, optional, half NULLs                            172.3   
172.6   172.3   176.4       0.2%      1.011x
   Float16Array/plain, optional, no NULLs                               62.3    
62.0    62.3    62.8       0.6%      1.006x
   ```
   
   Two things fall out.
   
   The same binary against itself reaches 10.1 percent apart on 
`Float16Array/byte_stream_split encoded, optional, no NULLs`, and `main1` is 
worse still: 212.3 microseconds on `Float16Array/byte_stream_split encoded, 
mandatory, no NULLs` against 119.8 for `main2` and 119.8 for `main3`. That is 
1.77x between two runs of bytes that are identical, on the same machine, 
minutes apart. A single paired run cannot tell that apart from a real 
regression.
   
   But it does not let #9369 off, and I want to be straight about that. The two 
benchmarks it was charged 1.57x and 1.53x for are `Float16Array/plain encoded, 
mandatory, no NULLs` and the optional no NULLs variant, and on this hardware 
those two are the steadiest in the whole set, 0.1 percent and 0.6 percent main 
against main. So what the bot saw there was probably not sampling noise either. 
Given that `fixed_len_byte_array.rs` cannot reach a single line #9369 touched, 
the remaining explanation is code layout and inlining drift, and that is worth 
knowing before a fourth PR gets closed over it.
   
   For this branch on those same two benchmarks: 1.004x and 1.006x. On the 
three `BinaryArray/plain` benchmarks, which are the path this branch actually 
adds code to, 1.007x, 1.052x and 1.008x against a main to main spread of 2.2, 
2.8 and 5.9 percent on the same three.
   
   This is my hardware, one filtered subset, criterion defaults. It is not your 
CI box and I am not claiming it replaces a run there. Say the word and I will 
widen the filter or the repetitions. What I would suggest either way is that 
the bot get a mode that runs a commit against itself and posts the spread, 
because right now every performance objection in this area is being made 
against an unmeasured baseline.
   
   ## Gates
   
   On aarch64-apple-darwin, rust 1.97.1, with `parquet-testing` and `testing` 
checked out.
   
   ```
   $ cargo fmt --all -- --check          # clean
   $ cargo clippy -p parquet --features arrow,async,test_common,experimental \
       --all-targets -- -D warnings      # clean
   $ cargo test -p parquet --features arrow,async,test_common,experimental
   test result: ok. 1337 passed; 0 failed; 0 ignored; 0 measured; 0 filtered 
out; finished in 12.74s
   test result: ok. 121 passed; 0 failed; 1 ignored; 0 measured; 0 filtered 
out; finished in 142.97s
   test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 4.04s
   test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 0.01s
   test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 0.00s
   test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 0.01s
   test result: ok. 83 passed; 0 failed; 7 ignored; 0 measured; 0 filtered out; 
finished in 23.73s
   ```
   
   The 142.97s target is `arrow_reader`, which includes the 2.1GB overflow test.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to