Skip to content

[EPIC] Improve I/O patterns for Parquet scans #26682

Description

@alamb

Is your feature request related to a problem or challenge?

I feel like there is no good writeup of the high level project / goals for the parquet reader / IO system that @adriangb, @Dandandan, @hhhizzz, @HippoBaro, @zhuqi-lucas @etseidl @XiangpengHao @Rich-T-kid @haohuaijin @neilconway and @mbutrovich and others has been working on recently, so I wanted to try and write it up

Today the DataFusion Parquet scan reads an entire row group into memory in one request, decodes it, and only then fetches the next row group. This is a hard coded trade-off between I/O latency and memory that users can not change. The user visible effects are:

  1. Time to first batch scales with row group size: on object storage the first rows of a large row group arrive only after the whole row group has been downloaded.
  2. Peak memory per partition is at least one full row group (often 100MB+), regardless of batch_size or the memory pool limit, and is not reclaimable.
  3. I/O and decode do not overlap within a partition: the fetch of row group N+1 does not start until row group N has been fully decoded.
  4. Enabling pushdown_filters changes the I/O pattern to many smaller sequential requests (filter columns first, then projection columns). That can be slower on high latency stores and is one reason it is still off by default (see [EPIC] Fix performance regressions when enabling parquet filter pushdown (late materialization) #20324, Parquet Pushdown Filters: Way to keep the I/O pattern the same when pushdown filters are enabled #24393, Filtered Parquet scans issue additional object-store requests after ParquetPushDecoder migration #24527).
  5. No way to tune for the storage system: a local SSD or in-memory cache would benefit from more, smaller reads; a remote object store from fewer, larger reads. The only knob today is metadata_size_hint.

Visually, here is how the DataFusion reader read two row groups, RG1 and RG2 today:

   time    ──────────────────────────────────────────────────────────────────────────────────────────────────────────▶

           ┌───────────────────────────┐                        ┌───────────────────────────┐
   I/O     │     fetch ALL of RG 1     │        ~ idle ~        │     fetch ALL of RG 2     │        ~ idle ~
           │  (one request, ~100 MB)   │                        │  (one request, ~100 MB)   │
           └───────────────────────────┘                        └───────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                                        ┌──────────────────────┐                             ┌───────────────────────┐
   CPU               ~ idle ~           │     decode RG 1      │          ~ idle ~           │      decode RG 2      │
                 (waiting for I/O)      │   (batch by batch)   │      (waiting for I/O)      │   (batch by batch)    │
                                        └──────────────────────┘                             └───────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                                        ┌──────────────────────┐                             ┌───────────────────────┐
  Output             no output          │   batches of RG 1    │          no output          │    batches of RG 2    │
            (until ALL of RG 1 fetched) │       emitted        │ (until ALL of RG 2 fetched) │        emitted        │
                                        └──────────────────────┘                             └───────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                       ┌───────────────────────────────────────┐            ┌────────────────────────────────────────┐
  Memory               │     RG 1 fully buffered (~100 MB)     │            │     RG 2 fully buffered (~100 MB)      │
                       │  from first byte fetched until last   │            │from first byte fetched until last batch│
                       └───────────────────────────────────────┘            └────────────────────────────────────────┘

However, depending on your systems IO speed and RAM budget/buffering and other factors you may want different patters.

For example, on a object store which has high throughput but low latency you probably want to start next fetch as early as possible

   time    ──────────────────────────────────────────────────────────────────────────────────────────────────────────▶

           ┌──────────────────────────────────────┐┌──────────────────────────────────────┐
   I/O     │fetch ALL of RG 1 (one large request) ││fetch ALL of RG 2, issued IMMEDIATELY │         ~ idle ~
           │    long latency, high throughput     ││  while RG 1 is still being decoded   │(or start reading next RG)
           └──────────────────────────────────────┘└──────────────────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                                                   ┌────────────────────────┐              ┌────────────────────────┐
   CPU                     ~ idle ~                │      decode RG 1       │  short wait  │      decode RG 2       │
            (unavoidable: first request in flight) │(overlaps fetch of RG 2)│ (I/O bound)  │ (starts as soon as it  │
                                                   └────────────────────────┘              └────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                                                   ┌────────────────────────┐              ┌────────────────────────┐
  Output                  no output                │    batches of RG 1     │              │    batches of RG 2     │
               (waiting for the first request)     │        emitted         │              │        emitted         │
                                                   └────────────────────────┘              └────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
           ┌────────────────────────────────────────────────────────────────┐
           │RG 1 buffered (~100 MB) from first byte until last batch decoded│
           │                                                                │
  Memory   └────────────────────────────────────────────────────────────────┘
                                                   ┌────────────────────────────────────────────────────────────────┐
                                                   │ RG 2 buffered (~100 MB): overlaps RG 1, so peak = 2 row groups │
                                                   │                           (~200 MB)                            │
                                                   └────────────────────────────────────────────────────────────────┘

But on a LOCAL SSD, with a tiny latency per request, interleaving small fetches with decode is probably a better strategy:

   time    ──────────────────────────────────────────────────────────────────────────────────────────────────────────▶

           ┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐
   I/O     │fetch RG 1 ││fetch RG 1 ││fetch RG 1 ││fetch RG 1 ││fetch RG 2 ││fetch RG 2 ││fetch RG 2 │      done
           │ pages 1-2 ││ pages 3-4 ││ pages 5-6 ││ pages 7-8 ││ pages 1-2 ││ pages 3-4 ││ pages 5-6 │
           └───────────┘└───────────┘└───────────┘└───────────┘└───────────┘└───────────┘└───────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                        ┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐┌───────────┐
   CPU         wait     │decode RG 1││decode RG 1││decode RG 1││decode RG 1││decode RG 2││decode RG 2││decode RG 2│
            (1st pages) │ pages 1-2 ││ pages 3-4 ││ pages 5-6 ││ pages 7-8 ││ pages 1-2 ││ pages 3-4 ││ pages 5-6 │
                        └───────────┘└───────────┘└───────────┘└───────────┘└───────────┘└───────────┘└───────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                        ┌─────────────────────────────────────────────────────────────────────────────────────────┐
  Output     no output  │        batches emitted continuously: the first batch appears after a few pages,         │
                        │                               not after a whole row group                               │
                        └─────────────────────────────────────────────────────────────────────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
           ┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
  Memory   │             only the read-ahead buffer (e.g. 8 MB): the pages of the next batch or two,              │
           │                                       never a whole row group                                        │
           └──────────────────────────────────────────────────────────────────────────────────────────────────────┘

There are many other combinations like if you have a shared cache, for example

Describe the solution you'd like

Make the Parquet scan I/O pattern configurable rather than single hard coded reader implementation. Some ideas:

Describe alternatives you've considered

The high level idea I think we are pursuing is:

  1. @adriangb / @sunchao / @zhuqi-lucas: arrow-rs lets the ParquetPushDecoder communicate what it will do so the caller can plan I/O

  2. @adriangb / @HippoBaro: arrow-rs lets the decoder decode a row group incrementally, batch by batch, as its pages arrive

  3. @alamb / @adriangb / @Dandandan: make the I/O policy in DataFusion extensible, so the scan can choose between one request per row group, prefetch with a byte budget, or batch-granular fetch depending on the storage system

Additional context

DataFusion:

arrow-rs:

Willingness to contribute

None

Activity

  1. added theissue type on Oct 23, 2026
  2. alamb commented on Oct 23, 2026

    @alamb
    ContributorAuthor

    @alamb / @adriangb / @Dandandan: make the I/O policy in DataFusion extensible, so the scan can choose between one request per row group, prefetch with a byte budget, or batch-granular fetch depending on the storage system

    I don't think there is yet an epic for that describes how this would work end to end (aka what APIs would be exposed to the user)

  3. added
    PROPOSAL EPICA proposal being discussed that is not yet fully underway
    on Oct 26, 2026
  4. xudong963 commented on Oct 26, 2026

    @xudong963
    Member

    3. @alamb / @adriangb / @Dandandan: make the I/O policy in DataFusion extensible, so the scan can choose between one request per row group, prefetch with a byte budget, or batch-granular fetch depending on the storage system

    * in progress: [WIP: Add parquet `progressive_io` option using `ParquetPushDecoder::scan_plan` (one-shot row group I/O with pushdown filters) #26387](https://github.com/apache/datafusion/pull/26387)
    * in progress: [Prefetch Parquet row groups with a bounded I/O budget #23492](https://github.com/apache/datafusion/pull/23492)
    * proof of concept: [POC: batch-granular Parquet scans with the push decoder and scan_plan read-ahead #24086](https://github.com/apache/datafusion/pull/24086)
    

    These three ongoing PRs each added a configuration option:

    #26387 Added progressive_io (bool), which ensures that only one request is made per row group when pushdown is enabled.
    #23492 Added prefetch_size (bytes), which enables prefetching at the row group granularity.
    #24086 adds read_ahead_bytes (bytes), which enables read-ahead at the batch level.

    The semantics of the latter two are nearly identical—both involve “reading ahead by a specified number of bytes”—but they differ in granularity. If merged in the order they were submitted, users would be faced with four separate toggles: pushdown, progressive, prefetch, and read_ahead. It would be impossible to test all possible combinations.

    My recommendation:

    #26387 Merge first. This is essentially a bug fix: pushdown should not alter the I/O mode. Only after merging will pushdown have the opportunity to be enabled by default.

    Merge #23492 and #24086 into a single read-ahead budget parameter. The granularity is determined by the decoder’s capabilities: before arrow-rs#11234 was merged, it was based on row groups; after the merge, it is based on batches.

  5. xudong963 commented on Oct 26, 2026

    @xudong963
    Member

    I found we didn't mention morsel in the epic issue. I believe the first question the design document for Layer 3 should address is: Should the strategy be placed in the decoder stream or in the morsel scheduler?

  6. alamb commented on Oct 26, 2026

    @alamb
    ContributorAuthor

    #26387 Merge first. This is essentially a bug fix: pushdown should not alter the I/O mode. Only after merging will pushdown have the opportunity to be enabled by default.

    Sounds good -- I agree.

    I think once arrow 60.1 is out (eta in a few days) we'll have that one ready to merge

    Merge #23492 and #24086 into a single read-ahead budget parameter. The granularity is determined by the decoder’s capabilities: before arrow-rs#11234 was merged, it was based on row groups; after the merge, it is based on batches.

    I think the ideal budget is not based on rows / row groups, but instead on "target_buffer_memory" as that is the limiting resource that prevents infinite buffering and what will run out earlier.

    Things like "num_row_groups" is just a proxy for buffer / memory usage.

    It may be that "read_ahead_bytes" in #24086 is the right measure

  7. alamb commented on Oct 26, 2026

    @alamb
    ContributorAuthor

    I found we didn't mention morsel in the epic issue. I believe the first question the design document for Layer 3 should address is: Should the strategy be placed in the decoder stream or in the morsel scheduler?

    that is an excellent question and I don't have any idea -- it might make sense to try both to see what they look like

    My intuition is that it is the morsel scheduler -- the decode stream should try and read data for target_batch size chunks as that is what it wants to decode. However we would need to find some way to coalesce multiple requests maybe

  8. adriangb commented on Oct 26, 2026

    @adriangb
    Contributor

    I think the ideal budget is not based on rows / row groups, but instead on "target_buffer_memory" as that is the limiting resource that prevents infinite buffering and what will run out earlier.

    👍🏻, yep this is why I chose read ahead bytes in #24086.

    It might be reasonable to say that we only read ahead within a row group and thus setting read_ahead_bytes = 32Gi or something large (could be 0 or -1) is equivalent to progressive_io = false

  9. alamb commented on Oct 26, 2026

    @alamb
    ContributorAuthor

    It might be reasonable to say that we only read ahead within a row group and thus setting read_ahead_bytes = 32Gi or something large (could be 0 or -1) is equivalent to progressive_io = false

    I am thinking progressive_io is sort of a policy choice rather than a resource cap (and the ideal setting is based on how rows that match the predicate are distributed over the input pages).

    In general I think the parquet reader should be reading ahead to the next row group if there is memory budget for it, rather than being restricted to just a single row group

  10. XiangpengHao commented on Oct 28, 2026

    @XiangpengHao
    Contributor

    fwiw, I'm working on a very related thing as part of my work at SpiralDB: https://github.com/XiangpengHao/t4

    t4 is not ready to use yet, will make blog posts & announcements when it's ready in a few weeks.

    The high level idea is that object_store trait already give us a lot of opportunity to do io optimizations, and t4 especially focus on super fast SSD caching (using io_uring), with good enough SSDs (which you can rent from AWS), t4 can serve Parquet/Vortex data from disk as fast as from memory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    PROPOSAL EPICA proposal being discussed that is not yet fully underwayenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions