Prefetch Parquet row groups with a bounded I/O budget - #23492
Conversation
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
|
run benchmarks |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/parquet-pushdecoder-prefetch (4d02e1e) to 2880e10 (merge-base) diff using: tpch File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/parquet-pushdecoder-prefetch (4d02e1e) to 2880e10 (merge-base) diff using: tpcds File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagetpch — base (merge-base)
tpch — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usageclickbench_partitioned — base (merge-base)
clickbench_partitioned — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagetpcds — base (merge-base)
tpcds — branch
File an issue against this benchmark runner |
|
run benchmarks |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/parquet-pushdecoder-prefetch (4d02e1e) to 2880e10 (merge-base) diff using: clickbench_partitioned File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/parquet-pushdecoder-prefetch (4d02e1e) to 2880e10 (merge-base) diff using: tpcds File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/parquet-pushdecoder-prefetch (4d02e1e) to 2880e10 (merge-base) diff using: tpch File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagetpch — base (merge-base)
tpch — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usageclickbench_partitioned — base (merge-base)
clickbench_partitioned — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagetpcds — base (merge-base)
tpcds — branch
File an issue against this benchmark runner |
4d02e1e to
2d0806b
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #23492 +/- ##
==========================================
+ Coverage 80.88% 80.89% +0.01%
==========================================
Files 1102 1102
Lines 375813 376300 +487
Branches 375813 376300 +487
==========================================
+ Hits 303966 304409 +443
- Misses 53745 53781 +36
- Partials 18102 18110 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
You might be interested in such a change :) Around 1.5-2x (or more) performance improvements on S3. I did some benchmarking on S3 in #23492 (comment) which shows it basically improves performance overall without regressing anything. |
|
What can we expect for memory consumption? Will we use more memory because we prefetch? |
Yes. The extra memory consumption is pretty low (and can be configured) - it is roughly max the IO budget x partitions (probably slightly higher because of overhead / Tokio tasks running, but generally much lower). Thus 16 (partitions/cores) * 8 MiB = ~128 MiB |
Switch `prefetched_row_groups` to the `datafusion_common` HashSet re-export so the opener no longer needs an inline `std::collections` path, and drop a redundant assertion in the prefetch budget test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I will review this PR later this week -- sorry for the dealy |
|
Also #24086 from @adriangb has some interesting experiments. Some other thoughts:
|
|
FWIW the DuckDB folks posted a blog that describes a very similar implementation https://duckdb.org/2026/07/31/asynchronous-io The configuration is interesting -- you can bound by number of outstanding I/O or memory: I think we can do something similar, but the trick is how much memory to use for prefetching / buffering (compared to hash tables, etc) (BTW I am still reading this PR) |
A classic way to model this would be with another "number of outstanding requests" limit -- which would limit how many outstanding requests could be going (which is a reasonable proxy for bandwidth). For users with large bandwidth / trying to maxxx out the execution, they could crank it up to 10 or 20 or something.
If we really wanted to get fancy, what I think we would do is
Such a strategy seems somewhat complicated and on one end of the performance spectrum (maximum performance, at the cost of more requests and memory..) |
alamb
left a comment
There was a problem hiding this comment.
This is very cool @Dandandan -- I think we definitely want some way to be able to implement this kind of prefetching.
I need to think about this API tonight (a good night sleep etc) -- and think how useful a more general purpose trait might be.
Also I feel like maybe we should advertise this feature more (maybe in the tuning guide 🤔 )https://datafusion.apache.org/user-guide/configs.html#tuning-guide
| /// this many bytes. The required ranges for the current row group are | ||
| /// always read, even when they exceed this value. None disables data | ||
| /// prefetching. | ||
| pub prefetch_size: Option<usize>, default = None |
There was a problem hiding this comment.
I agree this is an obvious and common strategy for reading from object store and we should add it
One thing I am wondering is if we can make this IO strategy more generic (for example, I can imagine a user wanting to support racing reads like the craziness described in #23492 (comment)
Where I am heading is "can we add an API / trait" that lets user customize the I/O behavior more?
For example, maybe the trait kind of like madvise that could have methods like
/// The reader will need the bytes in `range` at some point in the future
/// it will request the bytes in the same order as called by this function
/// though it may not read all of them
fn advise_bytes_needed(&self, range: Range<usize>)And then we could include a default implementation that implemented memory limited prefetch 🤔 but users could provide their own implementations that did other things (like racing reads, etc)
| continue; | ||
| } | ||
|
|
||
| let row_group = metadata.row_group(entry.rg_index); |
There was a problem hiding this comment.
this would be a nice API to add upstream in the parquet crate probably
|
Sorry I haven’t replied here. I hope to have time tomorrow to look in more detail. I’m not sure what the right architecture is yet, but my intuition is that:
My intuition is to have an IO pipeline that uses memory, row counts and request concurrency to tune work, and feeds into CPU decoding. But it’s the decoding that determines what data to fetch, either known or speculative (the row filter case) and that owns the data in flight (so it can do things like discard speculative data that proves non useful). The IO pipeline asks the decoder “hey i’m going to make my next 1-4MB request, what do you want in there?” and the decoder + some IO policy decide what bytes they want. |
|
I agree, it would be good to spend some cycles iterating on the best design forward that fits DataFusion well and we can keep for longer (and enable by default). I think it would be interesting as well to test on block storage (EBS) as well if we see any (smaller) benefit there (this would benefit official ClickBench benchmark AFAIK), having latency profile of around ~1-10ms. SSDs - I expect minimal or negative gains from prefetching as the latency is extremely low (perhaps it would help for very fragmented/small reads but would need some stress test I think). More gains on IO-level there I would expect from different IO runtimes / reducing system calls, etc. |
Some other potentially related work is Lance's Here is their trait for the IO interface: https://github.com/lance-format/lance/blob/71c4aa2174971e98acb7e256fde1e1589024f5bc/rust/lance-encoding/src/lib.rs#L41 |
Which issue does this PR close?
Rationale for this change
Sequential Parquet row-group reads are expensive when object-store requests have meaningful latency. Batching projected ranges can hide much of that latency without eagerly decoding data.
What changes are included in this PR?
datafusion.execution.parquet.prefetch_sizebyte budget, disabled by default.Results
In the picture you can see it gives a 1.5-2x or more (up to 6x) on several TPC-H / Clickbench queries without really regressing anything (the regressions that show up are s3 variance - I only ran the suite x3 to average it somewhat but tail S3 latency is very high.
This is tested on a 16 core AWS CPU (5-15 Gbps) node with data in S3.
(Image rendered / generated using Claude - I checked/verified the numbers).
On a 20 MiB budget, simulated object-store latency is 2-3x (see results brlow).
With latency disabled, performance remained effectively neutral: 1.02x for TPC-H and 1.01x for ClickBench. Raising the budget to 100 MiB provided no further aggregate benefit.
Are these changes tested?
Are there any user-facing changes?
Users can opt in by setting
datafusion.execution.parquet.prefetch_size; the default remains disabled.