sanjay • engineering

Reading Parquet with cuDF

October 17, 2026

Sanjay Yepuri

At Chalk, I’ve been building a distributed query engine whose executor can target both CPUs and GPUs. For an early GPU demo, we compiled queries into cuDF operations and loaded their inputs from Parquet files. This made me wonder: how does cuDF read Parquet, and what controls does it provide for managing GPU memory? After all, the name of the game is keeping the working set as close to compute as possible.

I found a paper discussing exactly my questions. I spent a morning diving deeper and getting my hands dirty.

A Brief Tour of Parquet

Before we talk about cuDF, it's important to understand at a high level how data is laid out in a parquet file. This will enable us to reason about how the encoded/compressed data is loaded from disk and placed in memory, and how the reader decodes the data into units that can be combined to create a desired batch/chunk size.

Aside: confusingly, different libraries use chunk, batch, group, etc. to mean different things. I will try to disambiguate this by treating parquet's terminology as proper nouns.

Parquet files have two sections. They have a metadata footer that is written after all the data. This section contains the information needed to index into the first level of the data hierarchy called Row Groups and Column Chunks. It also could contain statistics and other information to select and prune Row Groups.

The section preceding the footer in the file contains all the data. The logical table represented by a parquet file is divided by rows into a Row Group. This is a horizontal partitioning of the data. Within a Row Group there are Column Chunks for each column in the data. Within a Column Chunk are Pages. These contain the bytes that should be decoded. Once a Column Chunk is loaded into memory, we can start decoding it Page by Page.

Parquet Data Structure
Row Group 1 contains a horizontal slice of the table: the same rows across every column.
Figure 1. Select a level, then a Row Group or Column Chunk.

I highly suggest reading Parquet File Format for a more detailed look.

cuDF's Parquet Reader

For our discussion, we will focus on how the reader manages compressed and decompressed data buffers for fixed-width tables, leaving nulls, nested data structures, and lists for another time.

The core algorithm for decoding parquet is fairly straightforward. The chunked reader groups selected row groups into input passes and loads their encoded column chunks. It then uses page metadata to plan subpasses for decompression and decoding. pass_read_limit influences this batching, but is a soft hint rather than a cap on total GPU memory. A separate control, chunk_read_limit, governs the size of returned output tables.

The GPU parallelism occurs in two main spots:

  • Header kernels walk page headers within each column chunk, collecting information used to plan processing and allocate buffers. In this experiment, they assigned one warp per column chunk.
  • Value-decoding kernels materialize the selected pages into output buffers. In the measured fixed-width decoding path, the launch assigned one block per data page.

Experiments

Now that we know how the parquet reader works at a high level, we can see that the number of Pages and the Page size play a large part in determining performance. We ran two experiments. The first explored the results in Luo's paper regarding Page size. The second experiment held the Page size fixed to see how cuDF's parquet reader configuration led to different numbers of passes and sub-passes.

The experiments were run on an NVIDIA RTX 3090 with 24 GiB of VRAM, using a C++ reader built against libcudf 26.10 on Ubuntu 20.04 under WSL2. Compatibility I/O was enabled; GPUDirect Storage was not used. Read timings included metadata processing, allocation, file access, transfers, and GPU decoding, ending with GPU synchronization. GPU aggregation was timed separately, and every measured sum was checked against a CPU reference. The full experiment report includes reproduction instructions and raw results. Note the repository was generated with GPT-6.1 Sol.

Sweep on Page Sizes

The first experiment was inspired by the paper; we generated a parquet file with the same amount of table data. The parquet was laid out with 4 Row Groups, each with a single Column Chunk. Each Column Chunk had a different number of Pages based on the selected Page size.

Exact page-size measurements
Page targetActual total pagesMedian readObserved min–max
16 KiB4,09624.681 ms23.988–27.926 ms
64 KiB1,02418.541 ms17.627–20.346 ms
256 KiB25617.231 ms15.995–19.096 ms
1 MiB6417.412 ms16.098–19.383 ms
16 MiB426.378 ms25.264–27.823 ms

We can see that our results empirically agree with Luo's insight that each Column Chunk should have more pages (around 100). In my experiments, we saw that between 16 and 64 pages per Column Chunk provided the best performance. The paper mentions there is no one value that is optimal.

At this point, I was wondering where time was being spent in the reader. Why did having more pages seem to substantially increase the runtime?

Select a Page size below to compare the header and value-decoding costs, then follow the corresponding recorded read:

Page Size Results
Select a measured layout. The timings and trace below follow your selection.
Page targetUnprofiled read · ms
01632 ms
Kernel Runtime
Page headers0.419 ms
Value decoding0.202 ms
Both bars share a 0–10 ms scale. Separate profiled medians.
64Pages / Column Chunk
256data-decoder blocks
With 64 Pages per Column Chunk, header work is short and the decoder still launches 256 blocks. This layout has the lowest median read time in the sweep.
Kernel Launch Timeline256 KiB · first measured profiled iteration
Figure 3. The same 8,388,608 INT64 values, four Row Groups, warm cache. Read bars show unprofiled medians; whiskers show observed min–max. Traces are separate profiled reads, not SM utilization. Subpixel events are widened for visibility. All layouts launched 33 kernels. No interpolated benchmark results.
Measurements and trace methodology ↗
Exact profiled kernel measurements
Page targetData-decoder gridHeader kernelsValue decoderKernel busy timeMemcpy busy time
16 KiB4,096 blocks7.119 ms0.189 ms7.513 ms5.807 ms
64 KiB1,024 blocks1.721 ms0.178 ms2.017 ms5.461 ms
256 KiB256 blocks0.419 ms0.202 ms0.716 ms5.504 ms
1 MiB64 blocks0.113 ms0.611 ms0.800 ms5.495 ms
16 MiB4 blocks0.012 ms9.775 ms9.862 ms5.519 ms

My initial guess was that there was more overhead on the decode side since each page couldn't be scheduled in the same sub-pass. This was wrong, however. We can see that when the Page size was small, more time was spent in the header kernels. This aligns with our understanding that these kernels are parallelized by Column Chunk, but scan the Page headers serially; more headers mean the runtime contribution grows with the number of pages.

In a similar manner, if the Page size is large, we lose parallelism opportunities on the decode side. That's why the 16 MiB page took longer. There were only 4 launched blocks decoding larger Pages of data.

In each run, the read launched 33 kernels; page size changed the grid and kernel runtime, not the number of launches.

This core takeaway is the same as in Luo's paper: the page size used in parquet plays a large part in the performance of GPU decoding.

Memory Control

In the first experiment, I noticed that my parquet file was being decoded in a single pass and sub-pass. I wanted to see how the number of passes and sub-passes impacted performance. I understood that the additional overhead would increase the runtime, but I wanted to verify that the buffer memory used for reading the parquet was controlled.

This experiment used eight row groups and three integer columns, compressed with ZSTD and written with a 1 MiB page target. The file occupied 251.6 MiB on disk and contained 640 MiB of numeric output. I varied pass_read_limit with the output limit disabled, consuming and releasing each returned table individually.

For memory measurements, we ran three additional rounds per configuration after a warm-up. An RMM statistics adaptor tracked live allocations through the current device memory resource, including reader temporaries, output tables, and reduction allocations. The default CUDA allocator was preserved; no memory pool was added.

Memory-Runtime Tradeoff
Pass-memory hint output limit disabled
Pass hintPeak live RMM0–2,000 MiBBaseline read0–7,000 ms
Unlimited
1,861.60 MiB
153.10 ms
16 MiB
44.05 MiB
6,423.41 ms
64 MiB
94.36 MiB
998.97 ms
256 MiB
259.16 MiB
462.27 ms
512 MiB
510.77 MiB
295.13 ms
8input passes
32subpasses
32output tables
At a 64 MiB hint, peak live allocations are 94.36 MiB and reads take 6.5× as long as the unlimited baseline. The 16 and 64 MiB settings both use eight input passes; their subpass counts differ sharply.
Figure 4. Measured configurations, on shared linear scales. Memory was measured separately from timing. Each returned table is consumed and released; the hint is not a cap on total memory. Data & method ↗
Exact pass-hint measurements
Pass hintInput passes / subpassesPeak live RMM allocationsBaseline median read
Unlimited1 / 11,861.60 MiB153.10 ms
16 MiB8 / 24844.05 MiB6,423.41 ms
64 MiB8 / 3294.36 MiB998.97 ms
256 MiB4 / 12259.16 MiB462.27 ms
512 MiB2 / 6510.77 MiB295.13 ms

The 16 MiB and 64 MiB hints both required eight input passes, but the smaller hint increased subpasses from 32 to 248. It reduced peak live allocations from 94.36 MiB to 44.05 MiB while making reads about 6.4× slower. The hint therefore exposes a substantial memory–latency tradeoff, even when the input-pass count stays unchanged.

Peak live RMM allocations count requests through the tracked allocator, including temporaries and output tables. They do not measure process-exclusive physical VRAM residency under WSL. These measurements also assume each returned table is released before the next is retained.

Closing Out

cuDF provides controls for trading reader memory against throughput, but those controls are soft hints rather than strict memory caps. In these experiments, we learned page layout affected the balance between header processing and value decoding, while the pass-memory hint affected batching and peak live allocations.

There are still many more details to be explored and a whole experimental parquet reader in the cuDF codebase. That's the next thing to look into. Until next time!