Skip to content

perf: Extend WindowTopN to dense_rank - #23869

Open
SubhamSinghal wants to merge 7 commits into
apache:mainfrom
SubhamSinghal:window-topn-dense-rank
Open

perf: Extend WindowTopN to dense_rank#23869
SubhamSinghal wants to merge 7 commits into
apache:mainfrom
SubhamSinghal:window-topn-dense-rank

Conversation

@SubhamSinghal

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Naive plan for Filter(dr <= K) → BoundedWindowAggExec(DENSE_RANK) → SortExec(pk, ob) → input sorts the full input even though only rows at the K distinct-smallest ob values per partition are needed. DENSE_RANK is stable under tail-pruning, so the rewrite preserves ranks for every retained row.

What changes are included in this PR?

Are these changes tested?

Yes

h2o bench — Q24–Q29 added; top-2 on 10M-row large, 3-iter avg vs enable_window_topn=false:

# Cardinality OFF (ms) ON (ms) Δ
Q24 ~100 parts 308.19 114.45 2.20× faster
Q25 ~1K, low ties 261.24 113.85 2.13× faster
Q26 ~1K, heavy ties 296.54 124.42 2.09× faster
Q27 ~10K, low ties 250.83 131.85 1.70× faster
Q28 ~10K, heavy ties 281.00 149.00 1.76× faster
Q29 ~100K parts 264.32 304.37 1.40× slower

Are there any user-facing changes?

No breaking changes. Rule fires on DENSE_RANK when datafusion.optimizer.enable_window_topn = true; flag stays
false by default

@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Jul 24, 2026
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.1.0 (current)
       Built [ 108.294s] (current)
     Parsing datafusion v54.1.0 (current)
      Parsed [   0.036s] (current)
    Building datafusion v54.1.0 (baseline)
       Built [ 108.447s] (baseline)
     Parsing datafusion v54.1.0 (baseline)
      Parsed [   0.035s] (baseline)
    Checking datafusion v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.639s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 219.015s] datafusion
    Building datafusion-physical-optimizer v54.1.0 (current)
       Built [  43.482s] (current)
     Parsing datafusion-physical-optimizer v54.1.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-physical-optimizer v54.1.0 (baseline)
       Built [  43.884s] (baseline)
     Parsing datafusion-physical-optimizer v54.1.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-physical-optimizer v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.126s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  88.578s] datafusion-physical-optimizer
    Building datafusion-physical-plan v54.1.0 (current)
       Built [  40.762s] (current)
     Parsing datafusion-physical-plan v54.1.0 (current)
      Parsed [   0.147s] (current)
    Building datafusion-physical-plan v54.1.0 (baseline)
       Built [  39.763s] (baseline)
     Parsing datafusion-physical-plan v54.1.0 (baseline)
      Parsed [   0.149s] (baseline)
    Checking datafusion-physical-plan v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.658s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.49.0/src/lints/enum_variant_added.ron

Failed in:
  variant WindowFnKind:DenseRank in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/sorts/partitioned_topk.rs:76

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  82.666s] datafusion-physical-plan
    Building datafusion-sqllogictest v54.1.0 (current)
       Built [ 188.209s] (current)
     Parsing datafusion-sqllogictest v54.1.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-sqllogictest v54.1.0 (baseline)
       Built [ 186.114s] (baseline)
     Parsing datafusion-sqllogictest v54.1.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-sqllogictest v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.086s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 376.950s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jul 24, 2026
@codecov-commenter

codecov-commenter commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.60563% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.87%. Comparing base (582453b) to head (c7e67c9).
⚠️ Report is 82 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/topk/mod.rs 87.35% 17 Missing and 69 partials ⚠️
...fusion/physical-plan/src/sorts/partitioned_topk.rs 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23869      +/-   ##
==========================================
+ Coverage   80.72%   80.87%   +0.15%     
==========================================
  Files        1090     1099       +9     
  Lines      370384   375428    +5044     
  Branches   370384   375428    +5044     
==========================================
+ Hits       298975   303628    +4653     
- Misses      53590    53637      +47     
- Partials    17819    18163     +344     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@comphead comphead changed the title perf: Extend WindowTopN to dense rank perf: Extend WindowTopN to dense_rank Jul 24, 2026
@SubhamSinghal

Copy link
Copy Markdown
Contributor Author

@kumarUjjawal @kosiew can you please review this PR?

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @SubhamSinghal I have left some comments please let me know what you think.

match udf.fun().name() {
"row_number" => Some(WindowFnKind::RowNumber),
"rank" => Some(WindowFnKind::Rank),
"dense_rank" => Some(WindowFnKind::DenseRank),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we skip this rewrite when another expression in the same window operator needs following rows? The input is pruned before all window expressions run, so a sibling lead() returns the wrong value at the retained boundary. We can add a dense_rank with lead regression and only rewrite when every sibling expression is safe under tail pruning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4530bba. The rewrite now bails unless every window expr is ROW_NUMBER/RANK/DENSE_RANK over the same PARTITION BY/ORDER BY. LEAD and aggregates are excluded. Added EXPLAIN + correctness SLT regressions for SUM and LEAD siblings.

// For RANK / DENSE_RANK: an empty ORDER BY makes every row tie
// at rank 1 — the optimization is degenerate (we'd retain the
// entire input) and tie storage would be unbounded.
if matches!(fn_kind, WindowFnKind::Rank | WindowFnKind::DenseRank)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard misses orderings that become empty after partition keys are removed. DENSE_RANK() OVER (PARTITION BY pk ORDER BY pk) reaches PartitionedTopKExec without order expressions and panics. Please validate the effective order keys and add a regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4530bba. Replaced order_by().is_empty() with a structural guard: sort_exec.expr().len() <= partition_prefix_len → bail. Catches PARTITION BY pk ORDER BY pk (and no-ORDER-BY ROW_NUMBER). Added SLT regression.

entries.push(TieEntry {
batch: batch.clone(),
row_indices: run_indices,
batch_bytes: input_batch_bytes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

records the full source-batch size for every retained group even though the batch clones share Arrow buffers. With many partitions, memory reservation is heavily inflated and can fail with ResourcesExhausted. This expands #23326 to the normal dense-rank path; please account each source batch once or compact the retained rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the DENSE_RANK instance of #23326 (same TieEntry/size() shared-buffer over-count as RANK). Keeping storage consistent so one fix in #23326 covers both operators.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that will leaves the normal dense-rank path counting one shared batch once per retained group across every partition. This can greatly inflate the reservation and cause false ResourcesExhausted errors. Could we fix the shared-batch accounting here, or keep this rewrite disabled until #23326 is fixed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8e97623.

.groups
.keys()
.map(|key| key.as_slice())
.max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding the maximum scans all K keys for every new distinct value. On mostly distinct input this makes the rewrite O(N × K) and can be much slower for large K than the sort it replaces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had kept it unoptimised thinking K will be small. Fixed in 4530bba. Cache the admission boundary (max_key); the O(K) scan runs only when stale (after eviction/fill), so the common "worse than boundary → drop" path is O(1).

if is_smaller {
// Evict the entire max-key group and drop the cached
// max (recomputed on the next Case C).
let evicted_key = state.max_key.take().expect("max key present");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache only makes rejected candidates O(1). Every accepted candidate invalidates it, so input arriving from worst to best still scans all K keys for each new value and remains O(N × K). The benchmarks use K=2 and do not exercise this case. Could we use an ordered structure or add a large-K improving-order benchmark?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a74ed66. Replaced with binaryHeap so that worst case will be 0(Nxlog(K)) instead 0(NxK)

entries.push(TieEntry {
batch: batch.clone(),
row_indices: run_indices,
batch_bytes: input_batch_bytes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that will leaves the normal dense-rank path counting one shared batch once per retained group across every partition. This can greatly inflate the reservation and cause false ResourcesExhausted errors. Could we fix the shared-batch accounting here, or keep this rewrite disabled until #23326 is fixed?

@kumarUjjawal

Copy link
Copy Markdown
Contributor

@2010YOUY01 Can you take a look at this whenever you get the chance.

@kosiew kosiew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SubhamSinghal
Thanks for the dense-rank implementation. I think the overall approach looks good, but I found one memory accounting issue that can lead to very large overestimates and trigger unnecessary OOMs. I also have one small naming suggestion that could make the ownership and accounting model a bit clearer.

+ entries
.iter()
.map(|e| {
e.row_indices.capacity() * size_of::<u32>() + e.batch_bytes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a memory accounting issue here. DenseRankPartitionState::size() adds e.batch_bytes for every TieEntry, but the new dense-rank path creates one TieEntry per retained ORDER BY group while each entry only holds an Arc clone of the same source RecordBatch (see the insertions at lines 2056, 2068, and 2104).

That means a single input batch with K retained distinct values is charged roughly K * get_record_batch_memory_size(batch), even though the Arrow buffers are only retained once. The existing rank tie path has some precedent for conservative duplicate charging, but for dense-rank this becomes the common case because one batch can contribute many retained groups.

I reproduced this with datafusion-cli using the script below. With enable_window_topn=true, target_partitions=1, --batch-size 2000, and --memory-limit 50M, the query fails trying to allocate about 2001.6 MiB for PartitionedTopKDenseRank[0]. Running the same query with enable_window_topn=false succeeds and returns 200 rows.

python3 - <<'PY'
with open('/tmp/df_dr.csv','w') as f:
    f.write('pk,val,payload\n')
    payload='x'*10000
    for g in range(1,1001):
        f.write(f'{g%2},{g},{payload}\n')
PY

cargo run -q -p datafusion-cli -- \
  --batch-size 2000 --memory-limit 50M \
  --command "
SET datafusion.optimizer.enable_window_topn = true;
SET datafusion.execution.target_partitions = 1;
CREATE EXTERNAL TABLE dr(pk INT, val INT, payload VARCHAR)
STORED AS CSV LOCATION '/tmp/df_dr.csv'
OPTIONS (format.has_header true);

SELECT count(pk), count(val), count(payload)
FROM (
  SELECT pk, val, payload,
         DENSE_RANK() OVER (PARTITION BY pk ORDER BY val) AS drnk
  FROM dr
)
WHERE drnk <= 100;
"

Could we account for retained batches uniquely instead? For example, by reusing or extending the existing RecordBatchStore style accounting, or by storing batch IDs with ref-counted byte accounting. It would also be great to add a regression test covering the case where one batch contributes multiple retained dense-rank groups.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8e97623. Using RecordBatchStore as suggested.


/// Per-partition state for `DENSE_RANK()` semantics.
///
/// A `HashMap<Vec<u8>, Vec<TieEntry>>` keyed by the row-encoded ORDER BY

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small naming suggestion. The dense-rank implementation currently describes the retained groups as HashMap<Vec<u8>, Vec<TieEntry>>. After fixing the memory accounting, it might be worth introducing a type name that reflects retained batch slices instead of reusing TieEntry, since these entries represent more than just boundary ties. I think that would make the ownership and accounting invariant a bit clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed it to GroupEntry

@kosiew kosiew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SubhamSinghal
Thanks for the follow-up. The heap-based boundary tracking looks promising, but I think there are still two memory-accounting issues to address before this is ready. The dense-rank state continues to charge shared RecordBatch bytes multiple times, and the new eager heap allocation can consume substantial memory when both k and partition cardinality are large.

/// The same keys as `groups`, in a max-heap: the admission boundary
/// (the largest tracked ob value) is an O(1) `peek()` check, and
/// admission / removal are O(log K).
///

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this still over-counts retained memory. Each TieEntry only holds an Arc clone of a RecordBatch, but size() adds e.batch_bytes for every entry. In dense-rank processing, a single input batch can contribute several retained ORDER BY groups, so the same underlying batch bytes may be charged multiple times.

The insertion sites around lines 2062, 2075, and 2100 also continue to assign the full input_batch_bytes value to each entry. This can inflate the reservation and cause a false ResourcesExhausted error when enable_window_topn=true.

Could we account for each shared batch allocation only once, for example by tracking unique batch allocations or moving ownership of the batch-level charge outside individual tie entries?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kosiew are you referring to RANK code? at line 2100 I can see:

 state.groups.insert(
                        ob_key,
                        vec![GroupEntry {
                            row_indices: run_indices,
                            batch_id,
                        }],
                    );

which is referring to batch_id. Looks like stale comment but please confirm.

groups.clear();
{
let pk_rows = &self.partition_scratch_rows;
for i in 0..num_rows {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we avoid allocating capacity for all k heap entries as soon as a partition is first seen? With a large k and many partitions, BinaryHeap::with_capacity(k) front-loads roughly O(partitions * k) heap-slot allocation even when most partitions retain only a few distinct keys.

Using BinaryHeap::new() or a smaller initial capacity would let the heap grow with actual usage and reduce reservation pressure for high-cardinality partition workloads.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in afc931c using BinaryHeap::new()

@kosiew kosiew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SubhamSinghal

Thanks for the iteraion.

Looks 👍 to me

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good, just one issue I found.

Comment thread datafusion/physical-plan/src/topk/mod.rs Outdated

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @SubhamSinghal

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

Labels

auto detected api change Auto detected API change core Core DataFusion crate optimizer Optimizer rules physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants