Repository navigation
Description
Is your feature request related to a problem or challenge? Please describe what you are trying to do.
The async_reader ParquetRecordBatchStreamBuilder is awesome and fast
However, it is currently quite aggressive when reading and will buffer an entire row group in memory during read. For data reorganization operations that read all columns, such merging multiple files together, we see significant memory used buffering this entire input
For example, we have some files at InfluxData that contain 1M rows each with 10 columns. Using the default WriterProperties with row group size 1M and 20k rows per page results in a parquet file with
- a single 1M row group
- 10 column chunks each with 50 pages.
- Each file is 60MB
This merging 10 such files requires 600MB of memory just to buffer the parquet data. 100 such files requires 6GB buffer, etc.
We can reduce the memory required by reducing the number of files merged concurrently (the fan-in) as well as reducing the number of rows in each row group. However, I also think there is potential improvement in the parquet reader itself to not buffer the entire file while reading
The root cause of this memory use is that all the pages of the current RowGroup are read in memory via the InMemoryRowGroup
In pictures, if we have a parquet file on disk/object store:
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┃
┃ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │ ┃
┃ │ │ Page 1 │ │ │ Page 1 │ │ │ Page 1 │ ┃
┃ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │ ┃
┃ │ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ ┃
┃ │ Page 2 │ │ │ Page 2 │ │ │ Page 2 │ │ ┃
┃ │ └──────────────┘ │ └──────────────┘ ... │ └──────────────┘ ┃
┃ ... │ ... │ ... │ ┃
┃ │ │ │ ┃
┃ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │ ┃
┃ │ │ Page N │ │ │ Page N │ │ │ Page N │ ┃
┃ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │ ┃
┃ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┃
┃ ColumnChunk ColumnChunk ColumnChunk ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Row Group 1━━┛
Reading it requires loading all pages from all columns read into InMemoryRowGroup
┏━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ┓
┃
┃ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃
┃ ┃ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┃
┃ ┃ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │ ┃ ┃
┃ │ │ Page 1 │ │ │ Page 1 │ │ │ Page 1 │ ┃ ┃
┃ ┃ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │ ┃ ┃
┃ ┃ │ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ ┃
┃ ┃ │ Page 2 │ │ │ Page 2 │ │ │ Page 2 │ │ ┃ ┃
┃ │ └──────────────┘ │ └──────────────┘ ... │ └──────────────┘ ┃ ┃
┃ ┃ ... │ ... │ ... │ ┃ ┃
┃ ┃ │ │ │ ┃
┃ ┃ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │ ┃ ┃
┃ │ │ Page N │ │ │ Page N │ │ │ Page N │ ┃ ┃
┃ ┃ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │ ┃ ┃
┃ ┃ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┃
┃ ┃ ColumnChunk ColumnChunk ColumnChunk ┃ ┃
┃ ┃ ┃
┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Row Group 1━━┛ ┃
┃
┗ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━┛
InMemoryRowGroup
Describe the solution you'd like
Basically, TLDR only read a subset of the pages into memory at any time and do additional IO to fetch others
┏━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ┓
┃ ┃
┃ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃
┃ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┃
┃ ┃ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ │ ┃ ┃
┃ ┃ │ │ Page 1 │ │ │ Page 1 │ │ │ Page 1 │ ┃ ┃
┃ ┃ └──────────────┘ │ └──────────────┘ │ └──────────────┘ │ ┃ ┃
┃ │ ┌──────────────┐ │ ┌──────────────┐ │ ┌──────────────┐ ┃
┃ ┃ │ Page 2 │ │ │ Page 2 │ │ │ Page 2 │ │ ┃ ┃
┃ ┃ │ └──────────────┘ │ └──────────────┘ ... │ └──────────────┘ ┃ ┃
┃ ┃ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┃ ┃
┃ ColumnChunk ColumnChunk ColumnChunk ┃
┃ ┃ ┃ ┃
┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━Row Group 1━━┛ ┃
┃ ┃
━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━ ━━━
InMemoryRowGroup
Describe alternatives you've considered
If course there is a tradeoff between buffer usage and number of I/O requests so there is unlikely to be one setting that works for all use cases. In our examples the two extremes are to
| Target | I/Os required | Buffer Required |
|---|---|---|
| Minimize I/O requests (behavior today) | 1 request (ObjectStore::get_ranges call) |
60MB Buffer |
| Minimize buffering | 500 requests (one for each page) | ~1MB buffer (60MB/50 pages) |
| Intermediate buffering | 10 requests (get 5 pages per column per request) | 10MB buffer |
So I would propose implementing some new optional setting like row_group_page_buffer_size that the reader would respect and try and limit its buffer usage to row_group_page_buffer_size, ideally fetching multiple pages at a time.
large pages (e.g. large string data) are likely to make it impossible to always keep to a particular buffer size
Additional context
Activity
tustvold commentedon Jan 7, 2025
I think this boils down to what the characteristics of the backing store are:
Memory
Data is already buffered in memory it doesn't matter what approach we use, tbh people should be using the sync reader for this.
Object Storage
Object stores do not support true vectorized I/O, that is you can't fetch disjoint ranges in a single request. This means that to fetch 5 pages from each column, as in the intermediate buffering approach, it will actually perform one request per column. Unless the column chunk is small in which case it may just fetch the entire column chunk, and then chuck the extra pages away, which is even worse. These requests are made in parallel, but this does have potentially significant cost implications.
I'd posit that the current approach is the right one for object stores.
Local SSD
Local SSD is the option where this gets potentially interesting, as something closer to the minimize buffering approach becomes viable. IMO this is where something like #5522 becomes more relevant, especially if wanting to use something like io_uring. The current abstraction just starts to fall apart at this point, and I'm not sure it is sensible to try to contort it to make it work.
Alternatives
A simpler option might just be to write files with smaller row groups, this effectively "materializes" the intermediate buffering approach into the file, but with the added benefit that it doesn't break IO coalescing. This is effectively the observation made by file formats like Lance when they got rid of the column chunk.
There is a slight real difference between one row group with 50 pages and 5 row groups with 10 pages each, concerning dictionary pages, but based on what I guess is motivating this request, just writing smaller row groups may be a better option
alamb commentedon Jan 7, 2025
This is a very good observation -- the usecase we have is exactly this (data is local on SSD not on remote object storage)
I also think there might be value in a hybrid approach to reduce RAM requirements: fetching data from on object store to a local SSD and then reading it more incrementally
So if we can use the same abstraction that would be very nice
Indeed, this is exactly the workaround we are trying internally and I or @hiltontj will report back here on how well it worked.
tustvold commentedon Jan 7, 2025
Tbh if using local SSD I'd be tempted to just use the sync ParquetRecordBatchReader, the runtime needs to handle blocking anyway due to the CPU bound work, and the IO latencies are likely small enough to be irrelevant in comparison.
alamb commentedon Jan 7, 2025
In the context of DataFusion all IO is done in terms of the
ObjectStoretrait -- so I think the trick is in figuring out how to wire in / call the right version.If we decide reducing buffering is important enough usecase for us at Influx we'll spend some more time trying out various options and report back
XiangpengHao commentedon Jan 7, 2025
I believe we need something like
AsyncChunkReader(https://github.com/apache/arrow-rs/blob/main/parquet/src/file/reader.rs#L63) which allows us to read the pages on demand in async, and thus avoiding buffering the entire row group in memory before creating the record batch stream.But that means we need to plague almost the entire crate to be async functions.
tustvold commentedon Jan 7, 2025
I strongly disagree that intertwining IO in that way is desirable, let alone practical. I think it is far better to expose lower-level push-based APIs for decoding, that allow users to orchestrate IO as they see fit. This is the approach we take for JSON and CSV, parquet poses some additional challenges, but none that are insurmountable I don't think.
alamb commentedon Jun 24, 2025
I think this tradeoff between buffering / reducing IO will also appear in other cases (like optimizing filter pushdown) so maybe we will revisit it (perhaps with a setting to control the max buffer size or something)
adriangb commentedon Feb 22, 2026
@alamb does your work on the push decoder help here?
alamb commentedon Feb 27, 2026
TLDR is yes -- I think so
It is now possible to
So for example if you wanted to read only 100k rows at a time from a parquet file, you could make multiple ParquetPushDecoders that each read a 100k chunk of the file, and then could run them in whatever order / amount of pipelineing you wanted
I don't think this is well explained (it would make a great example on the blog I would love to write when I have infinte time)
adriangb commentedon Jul 23, 2026
I was looking into this again on our production system. There's two problems that this is creating for us:
I started looking into a design that has a stream per column. This results in more object store GETs but allows each column to stream at whatever pace it needs to to feed decoding and minimizes how much data has to be buffered in memory to keep the CPU fully fed. I initially got mixed results, but I'm posting here to bump the issue and share the pain / solution ideas.
alamb commentedon Jul 23, 2026
FWIW I think turning on predicate pushdown also (effectively) increases the GET requests but reduces the buffering (depending on the selectivyt of the filters) and that has seen to be a very mixed bag.
@adriangb I wonder if you could be slightly more clever and compute the amount of data that would be buffered for some slices of rows, using the OffsetIndex -- you might be able to use that to determine how many rows and bytes there are for each page
https://github.com/apache/parquet-format/blob/21ecc2b0f5786070561ae0d34152230853c83e2b/src/main/thrift/parquet.thrift#L1239-L1251
alamb commentedon Sep 25, 2026
This came up in a discussion @adriangb had today. Basically the idea is to enable reads at the page granularity, while today reads are done at the row group granularity.
In my mind, one of the main use cases for the
ScanPlanAPI in #10555 is to allow incremental decoding of row groups (aka not having to fetch all the pages for all columns used for the columns in that row group up front)Right now, when the
ParquetPushDecoderstarts to read a row group, it will compute all the pages necessary for either evaluating a pushdown filter OR all the pages for the projection. @adriangb mentioned to me that he thought it was possible to pre-push the data needed into the push decoder and it would not ask for more data until it ran out of them.I want to prove/disprove this by finding or writing a test that demonstrates pushing only the pages needed for the next record batch (not the whole row group) and decoding it if possible
Here is some research about how the ranges are computed / needed today
Details
Where the ranges for all columns are computed
arrow-rs/parquet/src/arrow/in_memory_row_group.rs
Lines 59 to 134 in 2f2360d
InMemoryRowGroup::fetch_rangesiterates every leaf column and returns either a single range per column chunk or ranges for each pagearrow-rs/parquet/src/arrow/push_decoder/reader_builder/data.rs
Lines 190 to 230 in 2f2360d
DataRequestBuilder::buildbuilt from the result offetch_rangesWhere the push decoder issues the requests, per row group
arrow-rs/parquet/src/arrow/push_decoder/reader_builder/mod.rs
Lines 544 to 562 in 2f2360d
arrow-rs/parquet/src/arrow/push_decoder/reader_builder/mod.rs
Lines 720 to 731 in 2f2360d
Where decoder refuses to proceed until every range is present
arrow-rs/parquet/src/arrow/push_decoder/reader_builder/data.rs
Lines 48 to 54 in 2f2360d
arrow-rs/parquet/src/arrow/push_decoder/reader_builder/mod.rs
Lines 582 to 594 in 2f2360d
arrow-rs/parquet/src/arrow/push_decoder/reader_builder/mod.rs
Lines 762 to 773 in 2f2360d
alamb commentedon Sep 25, 2026
My test suggests that the PushDecoder still tries to request the entire row group today:
[-]Avoid buffering the entire row group in Async parquet reader[/-][+]Incremental Parquet Decoding: avoid the need to buffer pages for all used columns in a RowGroup in Async parquet reader[/+]adriangb commentedon Sep 25, 2026
Yes, you are right! Turns out I was hacking around that. Do you think we can change that (first step is your test) and go from there? I see no reason why the push decoder wouldn't be able to emit a batch as soon as it has all of the data needed to decode and materialize it.
alamb commentedon Sep 25, 2026
Yes! I think this is the first step for sure
Then we can add the ScanPlan API and I think we'll be in a pretty great position to let callers incrementally decide how to feed data to the push decoder, and give them APIs to know what to push
chore: pin arrow-rs with batch-granular push decoding and scan_plan
feat(parquet): batch-granular fetching in ParquetPushDecoder
feat(parquet): batch-granular fetching in ParquetPushDecoder
chore: pin arrow-rs with batch-granular push decoding and scan_plan
test(parquet): show push decoder needs a whole row group before decod…
feat(parquet): batch-granular fetching in ParquetPushDecoder
test(parquet): show push decoder needs a whole row group before decod…
chore: pin arrow-rs with batch-granular push decoding and scan_plan
chore: pin arrow-rs with batch-granular push decoding and scan_plan
refactor(parquet): share push decoder fetch-range and stage logic (pr…
feat(parquet): add ParquetPushDecoder::scan_plan for read-ahead plann…
perf(parquet): store a ScanPlan cursor as one heap entry (apache#11334)