bench(reader): add ArrowReader benchmark harness#2558
Conversation
e1e4060 to
d2ac37e
Compare
mbutrovich
left a comment
There was a problem hiding this comment.
The harness fills a real gap: there was no in-repo reader benchmark, and the deterministic data plus per-group mapping to epic items is a good setup. Three notes on what it measures, none blocking:
-
Coverage. It runs on
FileIO::new_with_fs(), so the object-store per-task costs the epic targets (operator creation,stat(), TLS, credential init) are absent. Operator caching (#2177) will look near-flat locally, for the same reason the latency items (#2173, #2181) are not measurable here. Add a "what this cannot measure" line to the module doc so a flat local run is not read as "the optimization does not help" (part of why #2100 was closed). -
Correctness unverified. No group checks the rows returned. A bug that reads less data, such as the #2614 sub-row-group-split duplication that lives in the
same_file_splitspath, times faster and looks like a win. Assert the expected total row count per group. -
Warm vs cold. criterion plus the OS page cache means the time-based groups measure warm-cache CPU, not cold first-touch reads (closest to per-file object-store GETs). State the regime in the doc.
same_file_splits/bytes_readis a deterministic count and is unaffected.
| /// tasks carry a predicate). | ||
| async fn read_all(tasks: Vec<FileScanTask>, concurrency: usize, filtering: bool) { | ||
| let file_io = FileIO::new_with_fs(); | ||
| let reader = ArrowReaderBuilder::new(file_io, Runtime::current()) |
There was a problem hiding this comment.
The doc lists "operator construction" as measured, but on the Fs backend that has no credential-provider init, signer, or TLS, which is the expensive part operator caching (#2177) targets, so it looks near-flat here. The comment should say explicitly that operator creation, stat(), TLS, credential init, and the latency items (#2173, #2181) are not measurable on local FS, and that the time-based groups measure warm-cache CPU (files sit in the OS page cache after the first sample), not cold first-touch reads. What this measures is the CPU / decode / schema-resolution path.
| .await | ||
| .expect("read all batches"); | ||
|
|
||
| criterion::black_box(batches); |
There was a problem hiding this comment.
read_all never checks how many rows came back, so a config that reads 0 rows or duplicates rows (the #2614 sub-row-group-split bug is in the same_file_splits path) would not error and would report inflated throughput. Assert the total rows read equals the expected total before black_box; that also makes same_file_splits a regression guard for #2614.
| // total bytes fetched grows with the split count even though the file | ||
| // contents are identical — i.e. the redundant per-split metadata I/O. | ||
| let bytes_read = tokio.block_on(read_all_bytes( | ||
| same_file_split_tasks(&path, &iceberg_schema, num_splits), |
There was a problem hiding this comment.
Using ScanMetrics::bytes_read as the throughput basis makes the redundant-metadata-fetch cost visible without a latency backend and is deterministic across machines. This is the measurement #2100 lacked.
| /// `(id: long, name: string)` with embedded Parquet field IDs, so the reader | ||
| /// takes the "file has field IDs" fast path. | ||
| fn schemas() -> (SchemaRef, arrow_schema::SchemaRef) { | ||
| let iceberg_schema = Arc::new( |
There was a problem hiding this comment.
Fine for per-task overhead, but timestamp / decimal / nested / INT96 decode paths are not exercised, so column-decode regressions will not show. Worth one line in the doc marking the covered space.
There were no in-repo benchmarks for the ArrowReader, so every performance claim in the perf epic (apache#2172) had to be validated against external workloads. This adds a criterion harness that writes Parquet files to a local temp dir and reads them back through the normal FileIO path, measuring the per-FileScanTask overhead that dominates scans of tables with many small files. Benchmark groups: - many_small_files: scans 16/64/256 small files, reporting files/sec so per-file overhead is directly visible. - concurrency: a fixed corpus read at concurrency 1/4/16, exercising both the single-concurrency fast path and the buffered/flattened multi-task path. - migrated_table: files without embedded field IDs read via name mapping, isolating the migrated-table schema-resolution cost. - same_file_splits: one multi-row-group file read as 1/8/32 byte-range tasks, surfacing the redundant per-split metadata fetch that metadata caching (item apache#5) targets. - with_predicate: scans carrying a bound predicate with row-group filtering and row selection enabled, exercising the per-task row-filter setup. This gives a reproducible baseline for evaluating reader optimizations such as operator caching (apache#2177) and metadata reuse.
Wall-clock time on the local FS cannot show the benefit of round-trip-saving optimizations, but it also undersells the same-file-split problem, whose cost is redundant *bytes fetched* rather than latency. Report ScanMetrics::bytes_read as the same_file_splits throughput basis (a deterministic count, not a sample), and print the read amplification per split count. Reading one ~1.2MB file as 32 byte-range splits fetches ~15.7x the file size, because each split re-fetches the Parquet metadata independently. This is the machine-independent evidence for the cost that same-file metadata caching (proposed in apache#2172, attempted in apache#2100) targets; a caching implementation would drive the amplification back toward 1x.
Address review feedback from mbutrovich: - Every group now asserts the expected total row count, so a change that reads fewer or duplicated rows fails loudly instead of reporting a bogus win. The same_file_splits assertion doubles as a live regression guard for the apache#2614 sub-row-group-split duplication. - The module doc now states the measurement regime explicitly: warm-cache CPU/decode path on local FS; operator caching (apache#2177) and the latency items (apache#2173, apache#2181) are not measurable here and a flat local result must not be read as "no benefit"; only Int64/Utf8 decode is exercised.
0c43c7e to
44b6354
Compare
|
Thanks — all three are now in (a3fe113, after a rebase onto current main):
|
|
Thanks for taking a look @blackmwk. I'd like to make the case for landing it before the file format API rather than after, and see what you think:
That said, if you'd still prefer to hold it, I'm happy to park this as a draft until the file format API settles — I'd just rather not lose the pre-refactor baseline window. WDYT? |
|
Hi, @viirya My main concern comes from the maintaining effort. Since we are going to do a large refactor, adding such things will introduce extra maintain efforts for both reviewers and contributors. My suggestion is that we keep this pr open, and anyone interested in it could run it locally. |
|
That makes sense — I was focused on the CI/build-time cost, but you're right that's not really the concern. The real cost is whoever touches reader internals during the refactor having to understand and fix a benchmark they didn't write, and that shouldn't be pushed onto them. Let's keep this PR open rather than merge it. I'll treat it as ours to maintain — watching for reader-internal changes and updating the benchmark to match — and revisit merging once the file format API work settles. Thanks for pushing on this. |
Which issue does this PR close?
What changes are included in this PR?
Adds a criterion-based benchmark harness for the
ArrowReaderatcrates/iceberg/benches/arrow_reader.rs. Until now there were no in-repo reader benchmarks, so every performance claim in the perf epic (#2172) had to be validated against external workloads. This harness writes Parquet files to a local temp dir and reads them back through the normalFileIOpath, measuring the per-FileScanTaskoverhead that dominates scans of tables with many small files. Because it runs on the local FS, it isolates CPU / per-task work rather than network latency.Benchmark groups, chosen to map onto the epic's code paths:
create_parquet_record_batch_stream_builder()call for migrated tables #2176 path).This adds
criterion(with theasync_tokiofeature) as a workspace dev-dependency and a[[bench]]entry to theicebergcrate.Measuring
bytes_read, not just timeWall-clock time on the local FS cannot show the benefit of optimizations that save round-trips (e.g. the metadata size hint #2173 or range coalescing #2181) — those need a latency-bearing backend, which is left as follow-up. But the same-file-split problem is different: its cost is redundant bytes fetched, not latency, so it is measurable even locally.
The
same_file_splitsgroup therefore reportsScanMetrics::bytes_readas its throughput basis (a deterministic count, not a sampled measurement) and prints the read amplification per split count. On a ~1.2 MiB file:Reading one file as 32 byte-range splits fetches ~15.7x the file size, because each split re-fetches the Parquet metadata independently. This is machine-independent evidence for the cost that same-file metadata caching targets — #2100 was closed partly because its author could not demonstrate a benefit on their workload (a ~1:1 task-to-file table, where same-file caching has nothing to hit); with this benchmark a caching implementation would show the amplification dropping back toward 1x.
Run with:
Are these changes tested?
This is a benchmark, not a code change. The harness compiles and runs (
cargo bench -p iceberg --bench arrow_reader), and reuses the same Parquet-writing and reader-driving patterns as the existing reader unit tests.cargo fmtandcargo clippy -p iceberg --benchesare clean.