Expand description
Late materialisation of a bounded top-N aggregate.
A GROUP BY that lists a key and the columns that key determines carries
those columns through every join and every shuffle beneath it, only to
display a handful of them at the end. This rule groups on the key alone,
takes the top N, and re-fetches the wide columns for the survivors.
§The query that motivated this, and the number that justifies it
TPC-H q10 groups by seven columns —
c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment — and
returns twenty rows. Six of the seven are functionally determined by
c_custkey; c_comment alone averages ~73 B/row.
Measured at SF100 on the three-node cluster by hand-writing the narrowed
query (same joins, same filters, same LIMIT, but GROUP BY c_custkey):
real q10 (wide) narrowed
s1 customer scan 52 task-s / 3.40 GB 16.5 / 240 MB 14x fewer bytes
s2 orders⋈customer 8,968 task-s 38.9 230x
s5 final agg+TopK 1,510 6.3 240x
wall clock 1784.57 s 120.88 s 14.8xs2 fell 230x while its input bytes fell only ~10x, so the cost is
superlinear in the wide columns — per-row string handling in the hash join,
not wire volume. That is why nothing about transport fixed it (see the
q10-dist-s2-is-the-whole-query record: neither a cross-stage runtime filter
nor a deeper shuffle prefetch moved it).
§Two rewrites that do not work, so they are not attempted again
Declaring the key alone is not enough. ParquetTableSpec::with_primary_key
(shipped separately) gives DataFusion the functional dependency, and
optimize_projections will happily shrink a GROUP BY with it — but only
(columns the parent requires) ∪ (minimal FD subset). q10 selects all
seven grouped columns, so the parent requires them and no key declaration can
prune them.
Narrowing the group key alone is not enough either, and this one was
measured rather than reasoned. Rewriting q10 as GROUP BY c_custkey plus
first_value(...) per determined column — exactly what a local
composite-key rule would emit — ran at SF100 in 1955.65 s against the
1784.57 s baseline, ~10% slower, and not one stage improved: s1 shipped
the identical 3.40 GB. first_value still takes the wide columns as
aggregate inputs, so they cross every join and shuffle exactly as before.
Narrowing the group key only saves hashing, and hashing was never the cost.
The cost is the wide columns flowing through the joins. Only a join-back removes them, which is why this rule is non-local: the non-locality is the optimization, not an inconvenience around it.
§The rewrite
Sort: revenue DESC, fetch=20
Projection: c_custkey, c_name, revenue, c_acctbal, n_name, ...
Aggregate: groupBy=[c_custkey, c_name, c_acctbal, c_phone,
n_name, c_address, c_comment]
aggr=[sum(...)]
<customer ⋈ orders ⋈ lineitem ⋈ nation>becomes
Sort: revenue DESC, fetch=20 (unchanged)
Projection: ... (unchanged)
Projection: <exactly the aggregate's old schema>
Inner Join: customer.c_nationkey = nation.n_nationkey
Inner Join: __krishiv_lm.c_custkey = customer.c_custkey
SubqueryAlias: __krishiv_lm
Sort: sum(...) DESC, fetch=20
Aggregate: groupBy=[c_custkey], aggr=[sum(...)]
<customer ⋈ orders ⋈ lineitem ⋈ nation>
TableScan: customer
TableScan: nationOnly the Aggregate node is replaced; everything above it keeps the exact
same schema, so the enclosing Projection and Sort are untouched. The
inner Sort carries the same fetch, which is what bounds the join-back to
twenty rows — and is why the rule refuses without one.
Nothing prunes the wide columns from the narrow branch directly: once the
aggregate stops referencing them, DataFusion’s own optimize_projections
does it on the next pass, and the customer scan under the aggregate drops
to [c_custkey, c_nationkey].
§Reaching a column through more than one table
n_name lives in nation, not in customer, so no direct join-back on
c_custkey can fetch it. It is still determined: c_custkey → (customer’s
key) → c_nationkey, c_nationkey = n_nationkey is an equality of the
original join, and n_nationkey is nation’s key → n_name. DataFusion’s FD
machinery does not compose dependencies across join equalities, so this rule
computes its own closure:
a table’s columns become available when every column of its declared primary key is either already available or equated — by an inner-join
ONpair in the aggregate’s input — to a column that is.
The same closure decides which group columns may be dropped and, run forwards, emits the join-back chain, so the two can never disagree about what is recoverable.
§Why it is safe
- The key really determines the columns.
Constraint::PrimaryKeyis unverified here, exactly as Spark/DatabricksRELYis. That single declaration is doing two jobs — uniqueness (so the join-back returns one row per key) and non-nullness (so the key equi-joins at all) — which is precisely what a primary key means.Constraint::Uniqueis refused: DataFusion marks itnullable, and a null key would silently fetch the wrong row or none. - Inner joins only, everywhere. Every node between the aggregate and its
base scans must preserve column values: an outer join null-pads its
non-preserved side, so a column re-fetched from the base table would come
back non-null where the original plan had a null. Any node this rule does
not understand — a
SubqueryAlias, a nested aggregate, a union — makes it decline rather than guess. - The join-back is
Inner, deliberately. ALeftjoin is the instinct, and it is wrong here for a mechanical reason:PartitionMode::CollectLeftwith a join type that emits unmatched build rows cannot be split across distributed tasks, soredistribute_unsplittable_broadcast_joinswould convert it to a hash-partitioned join and shuffle the very columns this rule exists to keep off the wire.Inneris also exactly right semantically: the surviving key came from a row that already joined. - The ordering is computable before the columns are. Every
ORDER BYexpression must resolve to a retained key column or an aggregate output; a sort on a deferred column would need the column it is deferring. Group columns the sort names are added back to the key rather than refused. - Bounded output only. Without a
fetchthe join-back re-joins every group and the rewrite is a pure loss. SeeMAX_LATE_MATERIALIZE_FETCH. - Names cannot be crossed. Columns are matched by fully-qualified name, and a projection that reuses an input’s qualified name for a different expression makes the rule decline; duplicate qualified names across the collected scans (a self-join) do too.
Set KRISHIV_LATE_MATERIALIZATION=off to disable.
Structs§
- Late
Materialize TopK Aggregate - Replace a bounded top-N aggregate’s determined grouping columns with a join-back, so they never enter the joins beneath it.
Constants§
- LATE_
MATERIALIZATION_ ENV - Environment switch for late materialisation.
Functions§
- late_
materialization_ enabled - Whether late materialisation is enabled (default: yes).