Repository navigation
Description
There is some great work going on in arrow-rs (apache/arrow-rs#9722) by @HippoBaro to make wide schemas (schemas with many columns) more performant.
We have similarly wide data and spend a lot of time in our queries loading parquet metadata. I spent some time investigating this today and came to the conclusion that not only is it expensive to load wide schemas, they also introduce overhead in the rest of the query. Below is a summary of the analysis (AI generated). This makes me think we need to tackle the problem from the DataFusion side as well, with the first step being adding a benchmark for wide schema queries.
TL;DR
A simple selective query over 339 Parquet files runs ~11× slower when the
files carry hundreds of columns the query never touches, even though every
unused column is correctly projected away and every unused row group is
correctly pruned.
metadata_load_time is the cleanest signal but only ~10% of the wall-clock
overhead. The rest sits in downstream per-file setup that scales with schema
width — most of it not explicitly bucketed by EXPLAIN ANALYZE.
Setup
-
Two copies of the same 339 Parquet files, same rows, same row groups.
-
wide: full production schema (681 unique schema paths, 163,926 column
chunks across the dataset). -
narrow: only the 4 columns the query references (4 schema paths, 1,356
column chunks). -
Engine:
datafusion-cli 53.0.0, local disk, hot OS cache, 12-way scan
parallelism. -
Query:
SELECT start_timestamp, trace_id FROM '<path>/*.parquet' WHERE service_name = 'api' AND span_name = 'event';
Returns 1,287 rows out of ~12.1 M scanned. Pruning behaviour is identical
on both datasets: 350→350 files matched on stats, 339→339 row groups
matched on stats, 339→320 row groups after bloom-filter pruning.
Wall time (3 hot runs each)
| Dataset | run 1 | run 2 | run 3 | median |
|---|---|---|---|---|
| narrow | 0.053 s | 0.046 s | 0.049 s | 0.049 s |
| wide | 0.548 s | 0.556 s | 0.557 s | 0.557 s |
EXPLAIN ANALYZE — DataSourceExec metrics
Sums across 12 scan tasks. Same output_bytes=1266.2 MB, same final row
count, identical pruning.
| Phase | narrow | wide | Δ |
|---|---|---|---|
| metadata_load_time | 8.4 ms | 580 ms | +572 ms |
| time_elapsed_opening | 27 ms | 161 ms | +134 ms |
| time_elapsed_scanning_until_data | 308 ms | 526 ms | +218 ms |
| time_elapsed_processing | 307 ms | 737 ms | +430 ms |
| time_elapsed_scanning_total | 391 ms | 755 ms | +364 ms |
| bloom_filter_eval_time | 349 ms | 284 ms | -65 ms |
| statistics_eval_time | 4.6 ms | 7.0 ms | +2.4 ms |
| page_index_eval_time | 13 µs | 14 µs | ~0 |
| FilterExec elapsed_compute | 17.7 ms | 17.3 ms | ~0 |
Where the cost lands
Wall-clock delta is +508 ms going narrow → wide. With ~12-way parallelism,
dividing the cumulative metrics by 12 gives a rough wall-clock estimate per
phase:
| Phase | Cumulative Δ | ~Wall Δ |
|---|---|---|
| metadata_load_time | +572 ms | ~48 ms |
| time_elapsed_opening | +134 ms | ~11 ms |
| time_elapsed_processing (excl. metadata + opening) | ~+296 ms | ~25 ms |
| Unattributed (serial setup, expression binding against wide schema, scan-task dispatch) | — | ~420 ms |
Observations
-
metadata_load_timescales linearly with column-chunk count. ~3.5 µs
per chunk in both datasets (8 ms / 1,356 ≈ 580 ms / 163,926). The
per-chunk cost is layout-independent; what changes is how many chunks the
footer describes. -
The wide schema keeps costing after metadata is loaded.
time_elapsed_processingmore than doubles andtime_elapsed_scanning_until_data
grows by 218 ms even though the projected columns are identical. That is
per-column reader-state construction, schema-descriptor traversal, and
predicate binding against a 681-element schema running per file before
the first useful byte flows. -
Predicate evaluation phases are flat.
bloom_filter_eval_time,
statistics_eval_time,page_index_eval_time,FilterExec.elapsed_compute
— none of them respond to schema width. Once row groups are selected, the
unused columns do not participate. -
Most of the wall-clock gap is unattributed by
EXPLAIN ANALYZE. The
bucketed phases sum to ~85 ms wall; the observed delta is 508 ms wall.
The remaining ~420 ms is serial coordinator/planner work that scales with
schema width and is not broken out in current metrics. On object storage
this is likely amplified further because every file open also pays a
network round-trip proportional to footer size.
Conclusion
The narrow result (~49 ms) is roughly what this query should cost. Anything
above that is the price of carrying schema descriptions for columns the query
never touched through the planner and scanner.
Activity
adriangb commentedon May 1, 2026
I was able to reproduce this using data derived from TPCH data. Essentially I copied the data row wise and column wise to produce 128 files with 1024 columns. Then ran a simple query against them. I was able to reproduce a 7x slowdown with criterion warming up (so metadata is cached) and a 30x slowdown on cold
datafusin-cliruns.I'm hoping this will be a good target for profiling + systematically fixing the places we are no efficient on wide schemas.
adriangb commentedon May 1, 2026
PR is ready for review #21970
HippoBaro commentedon May 1, 2026
Thank you @adriangb. I also have a similar setup, which I’ve used locally to identify potential improvement opportunities. I’m currently working my way up the stack, from Parquet to Arrow, and eventually DataFusion.
I do have some local DataFusion patches that make a big difference locally, but they’re untested and very much not ready for upstream yet 😕
adriangb commentedon May 1, 2026
Very happy to hear you're working your way up the stack!
I'm curious if these benchmarks align with your local setup / patches? It'd be useful if we can merge some high signal benchmarks as a first step. Then if you'd be open to at least put draft PRs up we can start looking into how to get them in shape to upstream 😄
adriangb commentedon May 2, 2026
Wide-schema parquet read perf — investigation
Setup
I generated the wide-schema benchmark suite (now committed on the
draft branch as
benchmarks/sql_benchmarks/wide_schema/): two copiesof the same 256 parquet files / 50 k rows each — one with 8
columns, one with 1024 columns. Identical row counts, identical
file counts, identical bytes scanned. Q04 is the most stressful:
Profiled with samply, all hotspots resolved with
--unstable-presymbolicate.Worst offenders identified
Default 50 MB metadata cache thrashes badly. Per-file
ParquetMetaDatais ~1.5 MB (page index dominates), dataset total~400 MB. The default fits ~30 of 256 files — enough to make
threads contend on the cache mutex, not enough to stop
re-parsing. Cache-size sweep showed 50 MB is the worst regime:
disabling the cache (0) is faster (~100 ms hot vs ~136 ms hot at
50 MB) because the lock contention disappears. Sizing at 400 MB+
(everything fits) doubles hot performance to ~55 ms.
statistics_from_parquet_metadatawas O(N²) per file. Ititerated every logical field and called
StatisticsConverter::try_new,which itself did an O(N) scan in
parquet_column(the commentsaid "this could be made more efficient (#TBD)"). 1024² × 256 ≈
268 M ops/query just to set up stats.
ArrowReaderMetadata::try_newruns per file open. Walks everyparquet leaf to build the arrow
Schema+ dremelParquetField.~190 µs/file at 1024 cols. With 256 files / 12 threads = ~4 ms
wall per query.
Coercion forces a second arrow-schema rebuild. With
schema_force_view_types=true(default),apply_file_schema_type_coercionsreturns a new schema ⇒
try_newruns again per file. Another~190 µs/file.
apply_file_schema_type_coercionsalways built a 1024-entryHashMap even when the early-return condition would discard it.
DefaultFilesMetadataCache::evict_entriescalled fullstructural
memory_size()walks on every put / remove / eviction.collect_statistics=true(default) is the cold-path tax.Eagerly fetches metadata + computes per-column stats for every
file at planning time, even when the query touches only 4 of 1024
columns. For Q04 the inferred stats are then unused
(
files_ranges_pruned_statistics: 267 → 267).Fixes shipped (on the draft branches)
arrow-rs (#9882)
SchemaDescriptor::root_to_first_leaf: precomputed at construction⇒
parquet_columnis now O(1).StatisticsConverter::from_arrow_field: low-overhead constructortaking a resolved
(field, leaf_idx)pair.ArrowReaderMetadata::from_field_levels: package precomputed partsso callers can skip the per-leaf walk.
parquet_to_arrow_schema_and_field_levels: produces(Schema, FieldLevels)in one walk.AsyncFileReader::get_arrow_reader_metadata: new trait method (defaultdelegates to
try_new) so cache-aware readers can short-circuit.load_asyncnow goes through it.ArrowReaderOptionsso callers can decidewhether their cached arrow view applies.
DataFusion (#21987)
statistics_from_parquet_metadata: precompute logical → parquetleaf indices once (O(N)) and use
from_arrow_fieldin the loop.O(N²)/file → O(N)/file.
CachedParquetMetaDatacarries aOnceLock<ArrowReaderMetadata>(cache hit ⇒
Arc::clone, ~4 ns) plus a single-slotMutex<Option<(supplied_schema_ptr, ArrowReaderMetadata)>>for thepost-coercion build.
CachedParquetFileReader::get_arrow_reader_metadataoverrides thenew trait method and serves both base and post-coercion ARMs from
cache.
prepare_filtersmade async so the post-coercion rebuild alsoroutes through the cache-aware reader.
apply_file_schema_type_coercions: cheap "any view/string?" firstpass (only build the lookup HashMap when needed); return
Nonewhennothing actually changed.
DefaultFilesMetadataCachestoresmemory_sizenext to each entry —no more re-walks on put / evict / remove.
wide_schema_microbenchcoveringtry_newvsclone_cached,apply_file_schema_type_coercionsno-op,PruningPredicate::try_new,StatisticsConverter::try_newvsfrom_arrow_field.Results (Q04, 1024 cols × 256 files, profiling build)
collect_statistics=false, coldFor reference (warm-fitting cache):
Microbench numbers (from the new
wide_schema_microbench)ArrowReaderMetadata::try_newclone_cachedtry_newis O(N) at ~190 ns/col; the cached clone path is O(1).PruningPredicate::try_newagainst a 1-column predicate is already~2-3 µs and does not scale with schema width — predicate-driven,
not schema-driven, so that part is already where we want it.
Open / next
collect_statistics=truedefault eats most of the cold-pathbudget. The structural fix is lazy / projection-aware
Statistics::column_statisticsso the optimizer only pays forthe columns it actually inspects. That'd let the default stay safe
without paying for unused columns.
warm because
load_page_indexdoesn't update the cache. The fixis to also persist page-index-loaded metadata back into the cache.
Schema::index_of/Fields::findare O(N) linear scans in arrow.Profile shows them at <1% on Q04 — small now, but they're in
enough hot loops that adding a lazy name-index hashmap to
Fieldswould future-proof anything that does name lookups in inner loops.
Full investigation log lives at
report.mdon the DataFusion branch(direct link).
Add wide-schema benchmark suite for measuring per-file metadata overh…
adriangb commentedon Jun 8, 2026
Wide-schema perf — PR breakdown
The investigation work is now split into focused PRs:
arrow-rs (primitives):
parquet_column,parquet_to_arrow_schema_and_field_levels+ArrowReaderMetadata::from_field_levels,StatisticsConverter::from_arrow_field,AsyncFileReader::get_arrow_reader_metadata. Ready for review.DataFusion:
memory_sizeinDefaultFilesMetadataCache, andapply_file_schema_type_coercionsearly-return. Mergeable now.ArrowReaderMetadataacross opens. Depends on arrow-rs#9882; blocked on a build until those primitives ship in a release DataFusion bumps to (DataFusionmainis on arrow58.3.0, the primitives are on arrow-rsmain/59.0.0).The original
statistics_from_parquet_metadataO(N²)→O(N) idea is not included — upstream #22462 already refactored that path, and combined with arrow-rs#9882's O(1)parquet_columnit is already O(N).The earlier all-in-one draft #21987 is superseded by the split above.
bench: make wide_schema honor DATA_DIR like the other sql_benchmarks (a…
bench: make wide_schema honor DATA_DIR like the other sql_benchmarks (a…
perf: avoid building a DFSchema for every pruning comparison (apache#…
perf: resolve parquet leaf columns in O(1) during statistics collecti…
perf: avoid building a DFSchema for every pruning comparison (apache#…
perf: resolve parquet leaf columns in O(1) during statistics collecti…
perf: decode Parquet footers for statistics in parallel during planni…