Skip to content

Expose accumulator state to allow prefix scanning - #24035

Open
avantgardnerio wants to merge 9 commits into
apache:mainfrom
avantgardnerio:brent/bwag-finalized-state-observer
Open

Expose accumulator state to allow prefix scanning#24035
avantgardnerio wants to merge 9 commits into
apache:mainfrom
avantgardnerio:brent/bwag-finalized-state-observer

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Expose state of aggregate streams within BWAG so downstream prefix scanning can take place.

API

// physical-plan/src/windows/bounded_window_agg_exec.rs
pub type FinalizedWindowStateObserver = Arc<
    dyn Fn(usize, &PartitionKey, &[Option<Vec<ScalarValue>>]) -> Result<()>
        + Send + Sync,
>;

impl BoundedWindowAggExec {
    pub fn with_finalized_state_observer(mut self, obs: FinalizedWindowStateObserver) -> Self {}
}

// physical-expr/src/window/window_expr.rs
impl WindowState {
    /// `Accumulator::state()` if this is an aggregate window function, `None` otherwise.
    pub fn aggregate_state(&mut self) -> Result<Option<Vec<ScalarValue>>> {}
}

@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate labels Jul 31, 2026
@avantgardnerio
avantgardnerio marked this pull request as draft July 31, 2026 18:55
@avantgardnerio avantgardnerio changed the title feat(physical-plan): FinalizedWindowStateObserver on BoundedWindowAggExec Expose accumulator state to allow prefix scanning Jul 31, 2026
@avantgardnerio
avantgardnerio force-pushed the brent/bwag-finalized-state-observer branch from ae37c0b to 65014d5 Compare July 31, 2026 18:58
@avantgardnerio
avantgardnerio requested a review from Dandandan July 31, 2026 18:58
@codecov-commenter

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.98980% with 51 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.05%. Comparing base (3e3a92d) to head (b980ba4).
⚠️ Report is 32 commits behind head on main.

Files with missing lines Patch % Lines
...ysical-plan/src/windows/bounded_window_agg_exec.rs 87.08% 16 Missing and 34 partials ⚠️
datafusion/physical-expr/src/window/window_expr.rs 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24035      +/-   ##
==========================================
+ Coverage   80.91%   81.05%   +0.13%     
==========================================
  Files        1103     1106       +3     
  Lines      377219   382677    +5458     
  Branches   377219   382677    +5458     
==========================================
+ Hits       305244   310182    +4938     
- Misses      53775    54142     +367     
- Partials    18200    18353     +153     

☔ 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.

@avantgardnerio
avantgardnerio marked this pull request as ready for review August 1, 2026 16:24
@neilconway

Copy link
Copy Markdown
Contributor

Thanks for this contribution @avantgardnerio ! I'd find it helpful if you'd elaborate a little bit more about the motivation for this change in the PR description. For example, some intended use-cases, what kind of performance improvement this unlocks, etc.

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@neilconway I'm trying to speed up window functions using parallel prefix scans. I am presently incubating this in Ballista, and this is the minimum API exposure that I need to do it for non-decomposable operations like approx_distinct() (vs others like AVG, STDDEV via Welford / Chan, etc). Though, the work is certainly not limited to Ballista, it has shown improvement in DataFusion as well.

The jury is still out about re-partition cost vs performance benefit, but the signs are hopeful:

image

And at least from a big-O time perspective (table 1) it should be optimal for some queries (select my_agg() over unbounded preceding...)

docs

� Conflicts:
�	datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs
@avantgardnerio
avantgardnerio force-pushed the brent/bwag-finalized-state-observer branch from 0767f73 to 13023b2 Compare August 5, 2026 19:01
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@gene-bordegaray and @JSOD11 you guys might be interested as well.

@alamb alamb 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.

Thanks @avantgardnerio and @neilconway -- I left some comments

Comment thread datafusion/physical-expr/src/window/window_expr.rs
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated

@alamb alamb 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.

I may not fully understand prefix scanning, but it seems to me like this API will only give you access to the window state for the single last row in each partition.

Don't you potentially need access to the window state for the last N rows in a partition (e.g the HALO rows) 🤔

@alamb

alamb commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

FWIW claude claims this doesn't get run with windows like

UNBOUNDED PRECEDING → CURRENT ROW

@alamb

alamb commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I think it would also be super useful to add some sort of example / test that shows how you intend to use this API (for exmple some simple example for computing a window function in parallel or something 🤔

that way we could see the API in action

@avantgardnerio

Copy link
Copy Markdown
Contributor Author

this doesn't get run with windows like

Thanks @alamb ! That was a critical bug that would have defeated the whole point. It is now fixed and asserted in test_finalized_state_observer_fires_on_causal_frame()

@avantgardnerio

avantgardnerio commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

access to the window state for the single last row

Yes, this is exactly what is required.

need access to the window state for the last N rows

No, not for prefix scanning. (answer below)

e.g the HALO rows

The HPC "halo" term is a good fit for bounded preceding/following (surrounding cells, in 1D) but doesn't extend cleanly to "last row of every other partition." Regardless of the name, this PR doesn't take that approach - because although it works for SUM, and decomposes for AVG (sum+count), it fails by the time you get to arbitrary accumulators like approx_distinct.

add some sort of example / test

Which is exactly where (the newly added) test_prefix_scan_across_tasks_matches_single_bwag() comes in. It shows that with only the accumulator state of the very last row of the entire (DF) partition, parallel partitioned prefix-scanned sums produce exactly the same results as a single (DF) partition BWAG. Given the observer exposes Accumulator::state() directly, any function that supports Accumulator::merge_batch - including approx_distinct - can be prefix-scanned the same way, with the downstream consumer merging sketches instead of adding scalars.

Edit: added the qualifier (DF) partition to distinguish between the ambiguously named (SQL window) partition.

@avantgardnerio
avantgardnerio requested a review from alamb August 6, 2026 17:05
@avantgardnerio

avantgardnerio commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Pseudo code, stripped directly from the test_prefix_scan_across_tasks_matches_single_bwag test, for those who value brevity:

        // Two tasks under range partition on sn:
        let (task1_out, task1_total) = run_running_sum_task(&[1, 1, 2, 2, 3, 3, 4, 4]);
        let (task2_out, task2_total) = run_running_sum_task(&[5, 5, 6, 6, 7, 7, 8, 8]);

        // Local (uncorrected) outputs and totals — first pass.
        assert_eq!(task1_out, vec![1, 2, 4, 6, 9, 12, 16, 20]);
        assert_eq!(task2_out, vec![5, 10, 16, 22, 29, 36, 44, 52]);

        // Prefix scan over per-task totals → carry-in for each task. Task 0's
        // carry-in is 0; task N's carry-in is the sum of tasks [0, N).
        let carry_ins = [0u64, task1_total];

        // Second pass: shift each task's local values by its carry-in.
        let task1_final: Vec<u64> = task1_out.iter().map(|v| v + carry_ins[0]).collect();
        let task2_final: Vec<u64> = task2_out.iter().map(|v| v + carry_ins[1]).collect();
        let parallel_result: Vec<u64> = task1_final.iter().chain(task2_final.iter());

        // Oracle: single BWAG over the full concatenated input.
        let (single_result, single_total) = run_running_sum_task(
            &[1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8]);

        assert_eq!(parallel_result, single_result);
        assert_eq!(single_result,
            vec![1, 2, 4, 6, 9, 12, 16, 20, 25, 30, 36, 42, 49, 56, 64, 72]
        );

Trait is `pub` but its containing module `bounded_window_agg_exec` is
private, so there was no public path to it — Ballista couldn't name it,
and rustdoc rejected the intra-doc link on `with_state_observer` as
pointing to a private item (breaking `cargo doc -D warnings`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@alamb alamb 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.

Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs Outdated
Comment thread datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs
Match the field's type so with_new_children collapses to a single chained
call and the setter can also clear a previously-installed observer.

Addresses apache#24035 review comment 3738803158.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
avantgardnerio and others added 4 commits August 8, 2026 16:04
The method now always mutates when called and takes the observer as a
required argument; the "is observer installed?" check moves to the caller
in `compute_aggregates`. Removes the "&mut self that only mutates when
observer is set" shape.

Addresses apache#24035 review comment 3738822701.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename the trait method to `finalize_window_aggregate` and split its
signature so the callback fires once per aggregate window expression per
closing PARTITION BY group, receiving that expression's Arc and its own
`Accumulator::state` directly. Non-aggregate window functions no longer
fire the callback at all.

Removes the per-partition-key `Vec<Option<Vec<ScalarValue>>>` wrapper
allocation, and gives the observer the window-expression context needed
to disambiguate calls when the exec carries multiple window expressions.

Leaves room to add a peer `finalize_window_function` later for built-in
(non-aggregate) window functions.

Addresses apache#24035 review comments 3738790091 and 3738816488.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…close tests

The two `test_finalized_state_observer_*` tests were structurally
identical apart from the window frame. Fold their common setup and
assertions into a single async helper that takes the frame, so each test
body is now just the frame construction + a one-line comment explaining
which causality regime it exercises.

Addresses apache#24035 review comment 3738845164.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ox_distinct)

Adds `test_prefix_merge_across_tasks_approx_distinct` which runs two
BWAG(approx_distinct(sn)) tasks over disjoint-but-overlapping slices,
takes each task's observed EOS state, feeds both into a fresh accumulator
via `Accumulator::merge_batch`, and asserts the resulting distinct count
matches a single-BWAG oracle over the concatenated input.

This is the load-bearing contract for the parallel-window use case that
motivated exposing accumulator state: non-decomposable aggregates like
approx_distinct must survive round-tripping through the observer and
merge_batch to be usable in a prefix-merge pipeline. The count/sum tests
already in this file exercise decomposable aggregates only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@avantgardnerio
avantgardnerio requested a review from alamb August 8, 2026 22:40
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

Hi @alamb , I appreciate your feedback, and I think I addressed all of it. I also threw in a defensive test for merging HLLs just to be sure. If there's anything I missed, please let me know and I'll address that too. I really appreciate you sticking with this through multiple rounds of reviews 🙂

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

Labels

physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants