perf: don't manufacture an identity projection in ParquetSource - #24441
perf: don't manufacture an identity projection in ParquetSource#24441Braedon-Wooding-Displayr wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24441 +/- ##
==========================================
- Coverage 81.63% 81.61% -0.03%
==========================================
Files 1123 1123
Lines 409869 411411 +1542
Branches 409869 411411 +1542
==========================================
+ Hits 334599 335765 +1166
- Misses 55596 55897 +301
- Partials 19674 19749 +75 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
44b00e6 to
db5437b
Compare
|
Pushed some improvements to cover a missing line that we should cover the rest of the lines are just |
|
Note: my optimisations to The idea is that in lots of cases we don't need to expand the projection of * until physical planning, this is because it doesn't effect anything. This means that But conceptually an expression like; SELECT x from (
SELECT * from foo
)Still doesn't need to expand SELECT x from (
SELECT x, * from foo
)(I'm saying "something like" because obviously this would duplicate the This results in massive performance improvements for wide queries because a lot of the cost is in planning and quadratic costs (well that and parquet metadata is terrible for wide formats, but that's something ultimately unfixable without caching / having a custom footer through something like delta/iceberg/... though they also don't handle it great). In my case I ended up with sublinear scaling which is nice, and physical plan is very cheap. Now the risk here is that there might be some optimisations that rely on |
`FileSource::projection()` returns `Option<&ProjectionExprs>`, but every
built-in source returned `Some(...)` unconditionally, and `ParquetSource::new`
manufactured an identity projection over the full table schema. Consumers in
`FileScanConfig` therefore always took the `Some` branch and did work
proportional to the schema width (projected schema, projection mapping,
statistics projection) even when the projection selected every column, in
order, under its own name.
`ParquetSource` now stores `Option<ProjectionExprs>` and leaves it `None`
until a projection is genuinely pushed down. `None` travels through the
morselizer and the per-file prepare stages to `DecoderProjection`, which
installs `ProjectionMask::all()` and no per-batch transform when the decoder's
own output already is the scan's output schema.
A concrete projection is still materialized where one is genuinely needed, and
per file rather than per partition: when partition or constant columns have to
be substituted as literals, when the file's schema does not match the table's
and casts or null fills have to land somewhere, and when the decoder's output
schema does not match the scan's for any other reason.
A pushdown that reproduces a source's own output is not a pushdown, and
detecting it belongs with the caller rather than with every source. Across the
sqllogictest corpus 871 of them were being performed, 663 of those into
ParquetSource. `projection_is_no_op` tests the incoming projection against the
source's current output, whether that output is the table schema or an existing
projection's aliases, and the callers of `FileSource::try_pushdown_projection`
skip the push when it holds. csv, arrow, avro and json are not converted here:
they still build an identity `SplitProjection` up front and still return
`Some(...)`, so they gain only the skipped pushdown, not the consumer-side
saving. Converting them is left for a follow-up.
`FileScanConfig::try_swapping_with_projection` reports success with the scan
unchanged rather than `Ok(None)`, so the caller still drops the redundant
`ProjectionExec`.
`FileScanConfig::partition_statistics` was not equivalent between its two
branches: `ProjectionExprs::project_statistics` recomputes `total_byte_size`
from the output schema, and the unprojected branch did not. Both branches now
recompute it, so a scan reports the same statistics whether or not a
projection was pushed.
The new `parquet_wide_scan` bench measures building the physical scan for an
unprojected parquet table, which is the work proportional to the table's width:
scan_construction/unprojected_1000_columns 456 us -> 7.0 us (-98.5%)
scan_construction/unprojected_10000_columns 4.61 ms -> 77.3 us (-97.9%)
scan_construction/unprojected_100000_columns 59.0 ms -> 1.26 ms (-97.8%)
End-to-end there is no measurable change, and the bench's `planning` and
`execution` groups are controls that show this rather than claim otherwise.
Physical planning of `SELECT *` is dominated by expanding the wildcard and
running the optimizer over one expression per column, and a full scan is
dominated by decoding, so the removed per-file `project_schema`, per-leaf mask
vector and per-batch `Projector` do not surface above the noise floor, measured
at roughly +/-10% by comparing the baseline binary against its own results.
db5437b to
cc47000
Compare
FileSource::projection()returnsOption<&ProjectionExprs>, but every built-in source returnedSome(...)unconditionally, andParquetSource::newmanufactured an identity projection over the full table schema. Consumers inFileScanConfigtherefore always took theSomebranch and did work proportional to the schema width (projected schema, projection mapping, statistics projection) even when the projection selected every column, in order, under its own name.ParquetSourcenow storesOption<ProjectionExprs>and leaves itNoneuntil a projection is genuinely pushed down.Nonetravels through the morselizer and the per-file prepare stages toDecoderProjection, which installsProjectionMask::all()and no per-batch transform when the decoder's own output already is the scan's output schema.A concrete projection is still materialized where one is genuinely needed, and per file rather than per partition: when partition or constant columns have to be substituted as literals, when the file's schema does not match the table's and casts or null fills have to land somewhere, and when the decoder's output schema does not match the scan's for any other reason.
A pushdown that reproduces a source's own output is not a pushdown, and detecting it belongs with the caller rather than with every source. Across the sqllogictest corpus 871 of them were being performed, 663 of those into ParquetSource.
projection_is_no_optests the incoming projection against the source's current output, whether that output is the table schema or an existing projection's aliases, and the callers ofFileSource::try_pushdown_projectionskip the push when it holds, so csv, arrow, avro and json get the same benefit without being converted.FileScanConfig::try_swapping_with_projectionreports success with the scan unchanged rather thanOk(None), so the caller still drops the redundantProjectionExec.FileScanConfig::partition_statisticswas not equivalent between its two branches:ProjectionExprs::project_statisticsrecomputestotal_byte_sizefrom the output schema, and the unprojected branch did not. Both branches now recompute it, so a scan reports the same statistics whether or not a projection was pushed.Which issue does this PR close?
Happy to raise a bug for this, up to you? Just let me know / if it's a bug or a feature.
Rationale for this change
The new
parquet_wide_scanbench measures building the physical scan for an unprojected parquet table, which is the work proportional to the table's width:End-to-end there is no measurable change, and the bench's
planningandexecutiongroups are controls that show this rather than claim otherwise. Physical planning ofSELECT *is dominated by expanding the wildcard and running the optimizer over one expression per column, and a full scan is dominated by decoding, so the removed per-fileproject_schema, per-leaf mask vector and per-batchProjectordo not surface above the noise floor, measured at roughly +/-10% by comparing the baseline binary against its own results.The reason though is that I have a custom SQL command/node that handles a
SELECT *without having to expand the wildcard but this still results in relatively slow queries due to this physical overhead (as you can see 60ms is heavy! And we have upwards of 250k columns).What changes are included in this PR?
New benchmark + new tests + propagating None through scans.
Are these changes tested?
Yes!
Are there any user-facing changes?
We actually use None as a valid result from a projection from scans, this means that any consumer code of this (like analyzers and such) have to accept this as meaning an identity scan. This functionally doesn't seem like an issue.