Repository navigation
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 |
|
Also #24086 from @adriangb has some interesting experiments. Some other thoughts:
|
|
FWIW the DuckDB folks posted a blog that describes a very similar implementation 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 |
|
I have been thinking about this PR and how it could play into our "turn pushdown on by default" story We have put a lot of work in upstream to make applying filters during parquet decoding faster (thanks to @hhhizzz @haohuaijin and many others). However, enabling filter pushdown today also changes the I/O patterns (so that the all the needed columns are not fetched up front, but instead are fetched sequentially) I think one thing we could do with a nice I/O pre-fetching abstraction would be to start a request to fetch bytes for all columns required in a single request, so the I/O pattern is the same -- and then just enable the pushdown filter application once we already had the data 🤔 |
I think that's "tricky" because the depends on the IO backend, no?
I think for ClickBench it might not matter (no offset index?) - so perhaps we should special case this (just request the columns). |
|
Another one to consider: when the pushed down returns false for all values, we will skip the other requests (in which case it's more optimal than having wasted any IO). |
Should we make a ClickBench dataset with a page index? Or an option on the CB benchmark if possible? |
Yes for sure -- this is the key difference Where I was heading is that previously, in my mind we can't turn on predicate pushdown because it caused slowdowns. The slowdowns were caused by two reasons:
I think after the great work from @hhhizzz @haohuaijin and others upstream in arrow-rs/parquet we are close (if not already) at the point where it is computationally faster. All that remains is the different I/O. So if we can have a mode where we can turn on predicate pushdown and the I/O pattern is the same then we could (finally) turn on predicate pushdown by default.
This is a great point -- maybe that could be the trigger (if we are running with predicate pushdown but there is no page index, just fetch the column page data for all columns in one go) 🤔
We could -- though I think we should figure out what exactly we are trying to measure / accomplish with such a benchmark. |
|
I filed a ticket to track the idea |
Row group prefetch will come from upstream DataFusion (apache/datafusion#23492) rather than this reader, so stop advertising it in the doc comments. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Smith Cruise <[email protected]>
…the parquet push decoder Experimental alternative to apache#23492: instead of widening the blocking fetch, spawn a background fetch of upcoming row groups' projected ranges (same byte budget) while the current row group decodes, so I/O overlaps decode. Env-var switched (DF_FETCH_POLICY / DF_FETCH_BUDGET) for A/B benchmarking; not intended to merge in this form. Co-Authored-By: Claude Fable 5 <[email protected]>
|
FYI I tried to create a high level discussion of what we are trying to do here: |
…#27016) ## Which issue does this PR close? Part of apache#26682. ## Rationale for this change When each Parquet file has one row group, a reader can hide the next file's data request by starting it while decoding the current file. A benchmark that scans several partitions concurrently can hide the same delay even when each reader waits for its current file to finish. This gives apache#23492 and the cross-file experiment on apache#24086 a shared, single-partition workload. ## What changes are included in this PR? - Extend `sql_query_with_io` with a projected scan over 20 single-row-group files in one scan partition. The same SQL and bytes run against an in-memory store and a store with 100 ms of simulated read latency. Planning and data generation happen outside the timed loop. Each execution checks the sum of the selected column over 20 million rows. - Add a two-file object-store test. The full projection must read the selected column chunk once from each file and return all four values. With `LIMIT 1`, only one file's payload is needed. File statistics are disabled so the scan gets both files, and the assertions allow either file order. ## What is the testing strategy for this PR? `cargo test -p datafusion --test core_integration object_store_access` passed all 24 tests. The extended workspace suite passed 13,396 Rust tests and all 545 SQL logic files. Workspace formatting and Clippy with all targets and features passed. To reproduce the benchmark, run ```sh cargo bench -p datafusion --bench sql_query_with_io -- parquet_cross_file_projection ``` Local Criterion baseline on the PR branch, using a release build on a shared host, four pinned CPUs and ten samples per store. | Store | Estimated scan time | 95% confidence interval | | --- | ---: | ---: | | In memory | 24.6 ms | 24.3–24.9 ms | | 100 ms per read request | 2.059 s | 2.057–2.060 s | To check that the benchmark detects cross-file read-ahead, I also ran the same workload against the [public cross-file prototype from apache#24086](adriangb@64e67f5). Both arms used the same release binary on four pinned CPUs, with file-open depth 2, a 16 MiB read-ahead window and a shared 32 MiB budget. Only the eager first-read cap changed, from 0 to 16 MiB. I ran off/on/on/off, with ten samples per process. Each cell is the median of its two process medians. | Store on the prototype | Eager reads off | Eager reads on | Elapsed time change | | --- | ---: | ---: | ---: | | In memory | 29.22 ms | 30.73 ms | 5.2% longer | | 100 ms per read request | 2.065 s | 1.036 s | 49.8% shorter | The prototype roughly halved elapsed time on the simulated high-latency store and added about 1.5 ms on the in-memory control. Both configurations produced the same expected sum. A real object-store workload could have a different balance of I/O and decoding. ## Are there any user-facing changes? No. This adds benchmark and integration-test coverage.
…own and TopK (apache#27022) ## Which issue does this PR close? - Part of apache#26682 - Follow on to apache#26897 ## Rationale for this change We have several issues / PRs open that contemplate changing the I/O pattern such as - apache#24393 - apache#20324 - apache#26387 - apache#26418 - apache#23492 - apache#24086 - apache#25811 I want to have a test that clearly shows how those proposals effect the scan ## What changes are included in this PR? Add some new tests that show the Parquet I/O patterns. For example I think this is quite clear now the difference in I/O between filter pushdown and not ```rust /// Without filter pushdown, the scan fetches every column of every row group #[tokio::test] async fn test_filter_pushdown_io_disabled() { let test = filter_pushdown_test(false).await; assert_snapshot!(test.query("SELECT * FROM t WHERE val = 7").await, @r" --- Query: SELECT * FROM t WHERE val = 7 (1 rows) --- Request 1 Read Parquet footer (offsets 0..1402) Request 2 Read dictionary page and data page 0 for column 'id' (rg 0) (offsets 4..51) Read dictionary page and data page 0 for column 'val' (rg 0) (offsets 51..98) Request 3 Read dictionary page and data page 0 for column 'id' (rg 1) (offsets 98..145) Read dictionary page and data page 0 for column 'val' (rg 1) (offsets 145..192) Request 4 Read dictionary page and data page 0 for column 'id' (rg 2) (offsets 192..239) Read dictionary page and data page 0 for column 'val' (rg 2) (offsets 239..286) Produce batch (1 rows) "); } /// **With** filter pushdown, the scan first fetches and evaluates the filter /// column (`val`) for each row group, and then fetches the remaining columns /// (`id`) only for the row groups with matching rows (row group 1). #[tokio::test] async fn test_filter_pushdown_io_enabled() { let test = filter_pushdown_test(true).await; assert_snapshot!(test.query("SELECT * FROM t WHERE val = 7").await, @r" --- Query: SELECT * FROM t WHERE val = 7 (1 rows) --- Request 1 Read Parquet footer (offsets 0..1402) Request 2 Read dictionary page and data page 0 for column 'val' (rg 0) (offsets 51..98) Request 3 Read dictionary page and data page 0 for column 'val' (rg 1) (offsets 145..192) Request 4 Read dictionary page for column 'id' (rg 1) (offsets 98..124) Read data page 0 for column 'id' (rg 1) (offsets 124..145) Produce batch (1 rows) Request 5 Read dictionary page and data page 0 for column 'val' (rg 2) (offsets 239..286) "); } ``` ## What is the testing strategy for this PR? CI ## Are there any user-facing changes? No --------- Co-authored-by: mohitgurav20 <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
|
Marked as draft after 14 days without activity: merge conflicts. Please address these items and mark Ready for review when ready. |
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.