Skip to content

Late materialization when LIMIT prunes heavily. #23263

Description

@RatulDawar

Currently ClickBench Q23 is a major outliner due to the fact that datafusion doesn't do late materialization of projected columns.

SELECT * FROM hits WHERE "URL" LIKE '%google%' ORDER BY "EventTime" LIMIT 10;

A query like this needs to scan all the columns only after all the redundant rows are prunned from output.

Some numbers that I was able to get locally showing this regression in play.

Engine Query time
DataFusion 54 (SELECT *) ~4.0s avg
DataFusion 54 (SELECT "EventTime", "URL") ~0.9s
SortPreservingMergeExec (limit 10)
└─ SortExec TopK (EventTime), DynamicFilter [ empty ]
   └─ ProjectionExec (105 columns)
      └─ FilterExec (URL LIKE '%google%')
         └─ DataSourceExec (projection=[105 cols], 111 files)
              predicate: URL LIKE %google% AND DynamicFilter [ empty ]

EXPLAIN ANALYZE metrics (SELECT *)

DataSourceExec

Metric Value
projection 105 columns
output_rows 97.17 M
output_bytes 137.8 GB
row_groups_pruned_statistics 325 → 325 matched
row_groups_pruned_bloom_filter 325 → 325 matched
page_index_pages_pruned 0

What duckdb does in this scenario is that it materialises the projection rows that are not needed for any computation or maybe needed in later steps as late as possilble.

But this late materialization comes at the cost of requiring a self join later on to get the other columns back, and got self join's we need row tagging.

In datafusion we can tag a row uniquely with file_row_index(), input_file_name().
But then if rows are not dynamically filtered on the basis of above two columns it will again lead to a full table scan for probe side of the join.

Activity

  1. RatulDawar commented on Jun 30, 2026

    @RatulDawar
    ContributorAuthor

    Also this optimization a strictly based on the fact that the number of output rows are small, I want to understand that would it be beneficial to take over this complexity as this seems to be a very common query pattern.

  2. saadtajwar commented on Jun 30, 2026

    @saadtajwar
    Contributor

    Hi @RatulDawar , happy to help with this if needed!

  3. RatulDawar commented on Jul 1, 2026

    @RatulDawar
    ContributorAuthor

    Hi @RatulDawar , happy to help with this if needed!

    Sure !!

  4. RatulDawar commented on Jul 1, 2026

    @RatulDawar
    ContributorAuthor

    Since this largely depends on statistics here is what I propose.
    ymbols (uniform per-cell cost):

    Symbol Meaning
    R rows before filtering
    r rows after filtering
    C columns projected (SELECT *)
    c columns required in phase 1 (filter cols + sort key + row ids)

    Scan cost

    Before optimization — filter after wide decode:

    scan_before = R × C
    

    After optimization — narrow scan, then wide fetch for survivors:

    scan_after = R × c + r × C
    

    When optimization wins

    scan_before > scan_after
    
    R × C  >  R × c + r × C
    
    R × (C - c)  >  r × C
    

    Same condition, rearranged:

    r / R  <  (C - c) / C          (selectivity < column savings fraction)
    
    R / r  >  C / (C - c)
    

    Break-even (costs equal):

    r* = R × (C - c) / C
    
    Optimization wins when  r < r*
    

    Here it's possible to determine R, C and c. But r seems a bit hard, maybe we can start with just LIMIT queries as there we have r avaliable.

    @saadtajwar what do you think ?

  5. saadtajwar commented on Jul 1, 2026

    @saadtajwar
    Contributor

    @RatulDawar thank you! Sorry for the delayed response, I will try to look at this in the afternoon when I get home from work in a few hours and let you know my thoughts!

  6. saadtajwar commented on Jul 1, 2026

    @saadtajwar
    Contributor

    Hmm okay, @RatulDawar looking at this now - going to have to ask a few newbie questions haha:

    • Am I correct in my understanding in that the problem is, for this query, DataFusion at the very start of physical execution (in the DataSourceExec node) immediately does projection of all 105 columns in the table - however projection can be deferred until much later, because only really need the URL column for the filter & EventTime for the sort & limit, and we want DataFusion to after all of those expensive computations perform the projection of all columns?
    • If I'm understanding the above correctly, your proposal is to start by completing a first phase (filter/sort/limit on URL + EventTime + row IDs in this case) and a second phase that fetches the remaining columns only for the final rows, starting with LIMIT queries where r is bounded?

    Please let me know if there's anything I'm missing - happy to start helping you out with implementation/continue digging if needed!

  7. RatulDawar commented on Jul 6, 2026

    @RatulDawar
    ContributorAuthor

    If I'm understanding the above correctly, your proposal is to start by completing a first phase (filter/sort/limit on URL + EventTime + row IDs in this case) and a second phase that fetches the remaining columns only for the final rows, starting with LIMIT queries where r is bounded?

    Here I meant that since it's difficult to detect the final surviving rows after filter we can begin with queries that have limits.
    @alamb can you guide us here if this would be a correct direrction or not ?

  8. alamb commented on Jul 6, 2026

    @alamb
    Contributor

    I think @zhuqi-lucas has been working in this area recently -- perhaps he has some time to review

  9. zhuqi-lucas commented on Jul 7, 2026

    @zhuqi-lucas
    Contributor

    I think @zhuqi-lucas has been working in this area recently -- perhaps he has some time to review

    Thanks @alamb , this is really a good direction.

    Thanks for the deep-dive @RatulDawar — the framing is right. The 0.9s narrow-column baseline is effectively the theoretical lower bound: the filter/sort/limit phase only needs the filter and sort keys to decide which rows survive, so its cost should be independent of the SELECT list width. The 4s vs 0.9s gap is exactly the wide-decode work being paid for rows that never make it into the final 10.

    Before jumping straight to a full late-materialization rewrite, a couple of orthogonal knobs to layer in first — the remaining gap tells us how much LM actually buys:

    1. datafusion.execution.parquet.pushdown_filters = true (default is false). This enables Parquet RowFilter, which decodes the filter column (URL) first, applies LIKE '%google%', and only decodes the other 104 columns for surviving rows within each row group. For ~1% match rate this eliminates the wide-decode on ~99% of rows.

    2. TopK dynamic row-group pruning (#22450, merged) — the TopK threshold on EventTime is pushed back to the scan as a DynamicFilter, so row groups whose EventTime range is fully below the current top-10 threshold get skipped. Applies automatically for SortExec + fetch over Parquet.

    Splitting the 3.1s gap (4.0s − 0.9s):

    Wasted work Rows Recovered by
    Wide decode of filter-rejected rows ~99M pushdown_filters=true
    Wide decode of filter-survived but LIMIT-rejected rows ~1M Late materialization

    So the sequence would be: turn on (1) and (2), re-measure Q23. If we land at ~1.5s, LM is worth ~0.6s. If we land at ~1.0s, LM buys ~0.1s — probably not worth the architectural weight. Concrete numbers before committing to the design.

    If we do proceed with LM, your direction is exactly what DuckDB does — src/optimizer/late_materialization.cpp (PR #15692) rewrites SELECT * FROM t ORDER BY x LIMIT N into SELECT * FROM t WHERE rowid IN (SELECT rowid FROM t ORDER BY x LIMIT N), with rowid for Parquet being (file_index, file_row_number). Once rewritten, the semi-join predicate becomes a TableFilter on file_row_number and inherits all existing row-group / page-index / RowFilter pushdown for free.

    DataFusion doesn't yet expose file_row_number as a projectable virtual column, so that plumbing is the first prerequisite. I've been working across all the moving parts here — Parquet reader / ParquetOpener, DataSourceExec, the physical optimizer, and the dynamic-filter path from #22450 — so I'm happy to drive the whole thing end-to-end: file_row_number virtual column exposure, the optimizer rewrite rule, plumbing the file_row_number IN (...) filter into the Parquet access plan, and the cost heuristic. Would be great to have you and @saadtajwar as reviewers and on integration testing / cost-model validation — let's confirm the pushdown numbers first, then I'll start opening PRs.

    Given the scope (Parquet reader plumbing → virtual column plumbing → physical optimizer rule → cost heuristic → benchmarks), I'd suggest promoting this issue to an EPIC with sub-issues for each work item — similar to #23036 (Sort Pushdown) and #22715 (GroupValuesColumn coverage). Happy to help draft the sub-issue breakdown once we agree on the direction. cc @alamb for visibility.

  10. zhuqi-lucas commented on Jul 7, 2026

    @zhuqi-lucas
    Contributor

    Ran Q23 locally with pushdown_filters=true (built from latest main):

    default:      4173 ms avg  (3 iters)
    --pushdown:    318 ms avg  (3 iters)
    

    ~13x speedup, and SELECT * with pushdown is actually faster than the narrow SELECT "EventTime", "URL" baseline you reported (~900ms). Parquet RowFilter already does within-row-group late materialization (filter column decoded first, wide columns only for surviving rows), so most of the gap is recovered by just enabling it.

  11. ratuldawar11 commented on Jul 7, 2026

    @ratuldawar11

    datafusion.execution.parquet.pushdown_filters = true (default is false). This enables Parquet RowFilter, which decodes the filter column (URL) first, applies LIKE '%google%', and only decodes the other 104 columns for surviving rows within each row group. For ~1% match rate this eliminates the wide-decode on ~99% of rows.

    The local run on this looks very promising, but this will benefit for filter only cases right ?
    Suppose a query

    SELECT * FROM hits  ORDER BY "EventTime" LIMIT 10;
    

    This won't help with the filter pushdown but yes the TopK dynamic row group pruning should be really helpful here and I think since you tested with master branch that also was used here.
    Then we should set this property as default right ? Any reason not to ? This should be helpful in many other cases too not just wide selections as this completely skips rows, right ?

  12. zhuqi-lucas commented on Jul 7, 2026

    @zhuqi-lucas
    Contributor

    @ratuldawar11 Ran the no-WHERE variant too:

    SELECT * FROM hits ORDER BY "EventTime" LIMIT 10;
    default:      409 ms avg  (569 / 342 / 316)
    --pushdown:   279 ms avg  (305 / 270 / 261)
    

    You were right — #22450 does most of the heavy lifting here (default is already 409ms). pushdown_filters=true adds a modest ~30% on top, because the runtime EventTime > threshold from TopK also gets applied as a RowFilter inside surviving row groups.

    On the "why not just default it to true" — there's an active EPIC tracking exactly this: #20324 "Fix performance regressions when enabling parquet filter pushdown (late materialization)". Q23 gets ~9x faster (alamb's Feb 2025 numbers), but ~18 other ClickBench queries regress by 5-55% (Q2, Q6, Q7, Q10, Q11, Q12, Q13, Q14, Q19, Q24, Q25, Q26, Q27, Q28, Q37, Q40, Q41, Q42). Root cause is typically low-selectivity filters where the RowFilter bookkeeping outweighs the wide-decode savings. #20325 tracks Q10 specifically. Also see #22883 "[EPIC] Adaptive predicate evaluation" for a broader direction.

    So the trade-off is real — we'd want to close the regressions first, or make the pushdown decision adaptive per-filter (based on estimated selectivity).

    And updated, i reproduced the benchmark result here:
    #23369 (comment)

  13. alamb commented on Jul 7, 2026

    @alamb
    Contributor

    I don't understand what this ticket is proposing

    As @zhuqi-lucas points out, we know that enabling filter_pushdown for Q23 makes it go a lot faster (due to the late materialization you are pointing out). I think this epic is tracking

    While the flag is called "filter_pushdown" in my mind it is really a form of late materialization (within the parquet reader -- @hhhizzz wrote a blog about this: https://arrow.apache.org/blog/2025/12/11/parquet-late-materialization-deep-dive/

    So if you want to help figure out ways to get filter_pushdown on for real, that sounds like a great idea

  14. ratuldawar11 commented on Jul 7, 2026

    @ratuldawar11

    @alamb I wasn't aware about the blog + the EPIC, bad on my end should have look through properly and will keep in mind next time, apologies. I wanted to propose late materialisation but since it's already being tracked I think this ticket can be closed.
    There was a bit of a difference on the LM that I was proposing, instead of filtering I was thinking it to be like LM when the ratio of result/scan is very high. Which can be in the cases like TopK / heaving filtering, which is alsready been taken care of.

    As for turning on filter_pushdown by default, would definitely want to help in that. But this ticket can be closed since its already being tracked.

  15. RatulDawar commented on Jul 7, 2026

    @RatulDawar
    ContributorAuthor

    @alamb @zhuqi-lucas closing this, apologies again.

  16. alamb commented on Jul 8, 2026

    @alamb
    Contributor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions