You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[RFC/Discussion] Partition-at-a-Time Streaming Evaluation in WindowAggExec
#26425
In Apache DataFusion, window functions that require full partition awareness (such as ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, LEAD, LAG, and RANK) are executed by WindowAggExec.
Currently, WindowAggStream acts as a pipeline-breaking operator:
It buffers every incoming RecordBatch into Vec<RecordBatch> until the upstream stream yields None (End-of-Stream / EOS).
It blocks downstream operators from consuming even a single output row until the entire upstream pipeline terminates.
At stream exhaustion, it invokes concat_batches across all accumulated batches, materializing a monolithic batch of $N$ rows before evaluating window expressions.
$O(N)$ Memory Retention: Working memory scales linearly with total stream length ($N$), even when partition cardinality is high and individual partitions are small ($|P_i| \ll N$). In local benchmarks on a 2.5-million-row dataset containing ~16.6k partitions, baseline DataFusion retained all 2.5M rows in memory, reaching a peak resident set size (Peak RSS) of ~724 MB.
Pipeline Latency: Downstream operators that could stream data incrementally (such as LimitExec, streaming network sinks, or interactive displays) are starved of rows until the full upstream input is scanned.
DataFusion's physical planner requires inputs to WindowAggExec to be pre-sorted by PARTITION BY keys (enforced via required_input_ordering()). Consequently, all rows belonging to any partition $P_k$ arrive contiguously in the stream.
Once the first row with partition key $P_{k+1}$ is observed, partition $P_k$ is guaranteed to be complete and will receive no further rows.
We propose updating WindowAggStream to evaluate and emit completed partitions incrementally:
Active Partition Buffer: Maintain active_partition_batches: Vec<RecordBatch> and active_partition_key: Option<Vec<ScalarValue>> for the currently open partition.
Boundary Detection (process_batch): Inspect partition keys in incoming batches using evaluate_partition_ranges:
Intra-batch partitions: Evaluate partitions that start and end within the batch directly on batch.slice(start, len) without cross-batch concatenation.
Cross-batch partitions: Accumulate slices until a boundary key transition occurs, concatenate only that partition's slices, evaluate, and emit.
Output FIFO & Backpressure: Enqueue evaluated partitions into a pending_batches: VecDeque<RecordBatch>. The stream drains pending_batches before polling upstream input, ensuring upstream ingestion pauses when downstream consumption is slow.
Unpartitioned Fallback: Queries without a PARTITION BY clause (OVER (ORDER BY ...)) take an explicit code branch (self.is_unpartitioned()) that falls back to the existing whole-stream buffering path, preserving current behavior. (In contrast, partitioned queries where all rows happen to share a single partition key run through the streaming state machine, buffering slices in active_partition_batches until EOF).
Rather than claiming constant $O(1)$ memory, the physical memory bound for partitioned streams accounts for Arrow buffer sharing and asynchronous buffering:
$O(|P_{\max}|)$: The raw row data of the largest single partition in the stream.
$O(B_{\text{input}})$ (Buffer Pinning Slack): Arrow zero-copy slicing (RecordBatch::slice) retains Arc references to parent memory buffers. If an open partition spans across multiple batches, the head slice pins the first batch (holding unneeded prefix rows) and the tail slice pins the last batch (holding unneeded suffix rows). This unneeded pinned data can add up to approximately $2 \times B_{\text{input}}$ of buffer slack.
$O(B_{\text{pending}})$: Output batches awaiting downstream polling, bounded by $B_{\text{input}} \times \text{output_width}$ due to backpressure.
$O(\text{eval_temp})$: During partition finalization, finalize_active_partition() simultaneously holds the input slices, the concatenated partition batch, the newly computed window columns, and the output batch. We estimate this creates an unverified transient memory peak of roughly $3\times\text{ to }4\times$ the active partition's physical row data before intermediate buffers are freed.
3.2 The Single-Large-Partition Limitation
Partition-at-a-time evaluation does not solve memory accumulation when an individual partition spans the entire input ($|P| = N$) or exceeds available physical RAM. In that case, active_partition_batches accumulates all batches, degenerating to $O(N)$ memory with measured near-parity (observed $0.99\times$ speedup, $<0.1%$ peak RSS difference). Mitigating single-large-partition memory exhaustion requires out-of-core disk spilling or intra-partition sliding window operators (BoundedWindowAggExec).
4. Current Prototype Limitations: MemoryPool & OOM Risk
A critical architectural limitation of the current prototype is that it does not integrate with DataFusion's MemoryPool:
WindowAggStream currently allocates active partition buffers, output queues, and evaluation temporaries directly via the system allocator without calling MemoryReservation::try_grow().
Operational Risk: DataFusion's query engine currently has no visibility into the operator's heap usage. If an individual partition is excessively large (or data is unpartitioned), memory will expand unthrottled, risking an unmanaged process termination (OS OOM kill) rather than returning a clean DataFusionError::ResourcesExhausted error or triggering spills.
We propose scoping pure in-memory streaming as Phase 1, with dynamic MemoryReservation integration as Phase 2.
To evaluate the mechanical impact of partition streaming, controlled benchmarks were run under isolated process invocations on Linux x86_64 (rustc 1.99.0 release build, 5 runs per workload):
Query:SELECT c1, sn, v, COUNT(sn) OVER (PARTITION BY c1 ORDER BY sn) FROM t;
Sample Size & Variability: On the 1M-row small-partition workload, baseline query timings exhibited run-to-run variability between $98.01\text{ ms}$ and $133.04\text{ ms}$ (29.2% spread, mean $117.47 \pm 12.61\text{ ms}$), whereas prototype timings ranged from $30.05\text{ ms}$ to $31.25\text{ ms}$ (mean $30.41 \pm 0.49\text{ ms}$). Pairwise speedup across the observed extremes ranged from $3.14\times$ to $4.43\times$. Because sample sizes are small ($N=5$), we report observed ranges rather than parametric confidence intervals.
Causal Caveat: Baseline variability correlated with higher minor page faults (~70k vs. ~32k) and higher system CPU time ($0.16\text{ s}$ vs. $0.05\text{ s}$). We hypothesize that virtual memory reallocations during monolithic batch concatenation contributed to this jitter, though direct kernel-level tracing (e.g. clear_page) was not instrumented.
No Universal Speedup Guarantee: These speedups reflect synthetic workloads where avoiding monolithic batch reallocations saved significant CPU cycles. They are not universal guarantees for narrower payloads or compute-heavy expressions.
Single Partition Near-Parity: On single-partition streams (2.0M rows), the prototype exhibited performance and memory near-parity ($0.99\times$ speedup, $543.6\text{ MB}$ vs. $543.7\text{ MB}$ peak RSS—a 148 KB difference, well within typical OS page allocator variance).
6. Correctness Status & Test Gaps
What Has Been Formally Tested
Automated unit tests in the prototype verify:
Cumulative frames: COUNT(sn) with ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Forward-looking frames: SUM(sn) with ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING.
Ranking functions: RANK() and ROW_NUMBER() computing partition-local sequences on distinct inputs.
Boundary edge conditions: Partitions spanning multiple batches, multiple partitions per batch, empty inputs, NULL partition keys across batch boundaries, and early stream cancellation.
Outstanding Test Gaps (Prior to PR)
Unverified LEAD/LAG Boundary Assertions: While test_partitioned_lead_lag_rank_following_frame executes queries with LEAD and LAG without panicking, the test asserts only on the emitted rank and sum columns. It lacks explicit assert_eq! assertions verifying that LEAD and LAG emit correct boundary values and NULLs. Proving navigation semantics remains an outstanding test gap.
Ties in RANK: Duplicate sort values within a streaming partition have not yet been explicitly tested.
Complex Frames:GROUPS units, sliding offset frames (ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING), and RANGE intervals.
Key Types: Multi-column composite keys (PARTITION BY col_a, col_b), DictionaryArray, and StringViewArray keys.
7. Phased Implementation Proposal
To keep review manageable, we suggest splitting this work into phases:
Phase 1 (This RFC): Pure in-memory partition-at-a-time streaming in WindowAggStream, direct batch emission via pending_batches, unpartitioned fallback, and comprehensive edge-case unit tests. Zero public API changes.
Phase 2: Dynamic MemoryReservation integration with DataFusion's MemoryPool to prevent unmanaged OOMs, plus vectorized intra-batch evaluation for micro-partitions.
Phase 3 (Future Idea for Design Review): Explore potential out-of-core spilling mechanisms if an individual partition $|P_i|$ exceeds available memory allocation (e.g. leveraging DataFusion's disk management infrastructure), subject to future architectural design review.
8. Specific Feedback Requested from Maintainers
We would appreciate guidance on the following questions:
Output Batch Granularity vs. Coalescing: When partitions are small (e.g. 100 rows), emitting completed partitions directly yields a higher frequency of small batches. Should WindowAggExec rely on the physical optimizer inserting CoalesceBatchesExec downstream, or should WindowAggStream support an internal row threshold before emitting?
Planner Routing with BoundedWindowAggExec: DataFusion currently uses BoundedWindowAggExec for certain sliding frames. What architectural criteria or planner heuristics should guide the selection between BoundedWindowAggExec (which slides within a partition) and partition-streaming WindowAggExec (which streams across partitions) when both might apply?
MemoryPool Reservation Granularity (Phase 2): For dynamic memory tracking in Phase 2, should MemoryReservation be updated per incoming batch, or dynamically per partition transition?
Sorted Stream Debug Assertions: Should WindowAggExec include an optional runtime debug assertion verifying that input batches strictly adhere to declared partition sort order?
Disclaimer
This is an early architectural design proposal to align on direction with DataFusion maintainers. The prototype code is an experimental proof-of-concept and is not yet a production-ready or merge-ready implementation.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
[RFC/Discussion] Partition-at-a-Time Streaming Evaluation in WindowAggExec
datafusion-physical-plan(WindowAggExec,WindowAggStream)1. Problem Statement
In Apache DataFusion, window functions that require full partition awareness (such as
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,LEAD,LAG, andRANK) are executed byWindowAggExec.Currently,
WindowAggStreamacts as a pipeline-breaking operator:RecordBatchintoVec<RecordBatch>until the upstream stream yieldsNone(End-of-Stream / EOS).concat_batchesacross all accumulated batches, materializing a monolithic batch ofOperational Drawbacks
LimitExec, streaming network sinks, or interactive displays) are starved of rows until the full upstream input is scanned.2. Proposed Approach: Partition-at-a-Time Streaming
DataFusion's physical planner requires inputs to$P_k$ arrive contiguously in the stream.
WindowAggExecto be pre-sorted byPARTITION BYkeys (enforced viarequired_input_ordering()). Consequently, all rows belonging to any partitionOnce the first row with partition key$P_{k+1}$ is observed, partition $P_k$ is guaranteed to be complete and will receive no further rows.
We propose updating
WindowAggStreamto evaluate and emit completed partitions incrementally:active_partition_batches: Vec<RecordBatch>andactive_partition_key: Option<Vec<ScalarValue>>for the currently open partition.process_batch): Inspect partition keys in incoming batches usingevaluate_partition_ranges:batch.slice(start, len)without cross-batch concatenation.pending_batches: VecDeque<RecordBatch>. The stream drainspending_batchesbefore polling upstream input, ensuring upstream ingestion pauses when downstream consumption is slow.PARTITION BYclause (OVER (ORDER BY ...)) take an explicit code branch (self.is_unpartitioned()) that falls back to the existing whole-stream buffering path, preserving current behavior. (In contrast, partitioned queries where all rows happen to share a single partition key run through the streaming state machine, buffering slices inactive_partition_batchesuntil EOF).3. Working Memory Model & Inherent Limitations
3.1 Qualified Memory Bound
Rather than claiming constant$O(1)$ memory, the physical memory bound for partitioned streams accounts for Arrow buffer sharing and asynchronous buffering:
Where:
RecordBatch::slice) retainsArcreferences to parent memory buffers. If an open partition spans across multiple batches, the head slice pins the first batch (holding unneeded prefix rows) and the tail slice pins the last batch (holding unneeded suffix rows). This unneeded pinned data can add up to approximatelyfinalize_active_partition()simultaneously holds the input slices, the concatenated partition batch, the newly computed window columns, and the output batch. We estimate this creates an unverified transient memory peak of roughly3.2 The Single-Large-Partition Limitation
Partition-at-a-time evaluation does not solve memory accumulation when an individual partition spans the entire input ($|P| = N$ ) or exceeds available physical RAM. In that case, $O(N)$ memory with measured near-parity (observed $0.99\times$ speedup, $<0.1%$ peak RSS difference). Mitigating single-large-partition memory exhaustion requires out-of-core disk spilling or intra-partition sliding window operators (
active_partition_batchesaccumulates all batches, degenerating toBoundedWindowAggExec).4. Current Prototype Limitations: MemoryPool & OOM Risk
A critical architectural limitation of the current prototype is that it does not integrate with DataFusion's
MemoryPool:WindowAggStreamcurrently allocates active partition buffers, output queues, and evaluation temporaries directly via the system allocator without callingMemoryReservation::try_grow().DataFusionError::ResourcesExhaustederror or triggering spills.MemoryReservationintegration as Phase 2.5. Controlled Benchmark Observations (and Caveats)
To evaluate the mechanical impact of partition streaming, controlled benchmarks were run under isolated process invocations on Linux x86_64 (
rustc 1.99.0release build, 5 runs per workload):SELECT c1, sn, v, COUNT(sn) OVER (PARTITION BY c1 ORDER BY sn) FROM t;c1: Utf8(100-byte string partition keys),sn: Int64(ordering sequence),v: Float64.Essential Measurement Caveats
clear_page) was not instrumented.6. Correctness Status & Test Gaps
What Has Been Formally Tested
Automated unit tests in the prototype verify:
COUNT(sn)withROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.SUM(sn)withROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING.RANK()andROW_NUMBER()computing partition-local sequences on distinct inputs.Outstanding Test Gaps (Prior to PR)
LEAD/LAGBoundary Assertions: Whiletest_partitioned_lead_lag_rank_following_frameexecutes queries withLEADandLAGwithout panicking, the test asserts only on the emittedrankandsumcolumns. It lacks explicitassert_eq!assertions verifying thatLEADandLAGemit correct boundary values andNULLs. Proving navigation semantics remains an outstanding test gap.RANK: Duplicate sort values within a streaming partition have not yet been explicitly tested.GROUPSunits, sliding offset frames (ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING), andRANGEintervals.PARTITION BY col_a, col_b), DictionaryArray, and StringViewArray keys.7. Phased Implementation Proposal
To keep review manageable, we suggest splitting this work into phases:
WindowAggStream, direct batch emission viapending_batches, unpartitioned fallback, and comprehensive edge-case unit tests. Zero public API changes.MemoryReservationintegration with DataFusion'sMemoryPoolto prevent unmanaged OOMs, plus vectorized intra-batch evaluation for micro-partitions.8. Specific Feedback Requested from Maintainers
We would appreciate guidance on the following questions:
WindowAggExecrely on the physical optimizer insertingCoalesceBatchesExecdownstream, or shouldWindowAggStreamsupport an internal row threshold before emitting?BoundedWindowAggExec: DataFusion currently usesBoundedWindowAggExecfor certain sliding frames. What architectural criteria or planner heuristics should guide the selection betweenBoundedWindowAggExec(which slides within a partition) and partition-streamingWindowAggExec(which streams across partitions) when both might apply?MemoryReservationbe updated per incoming batch, or dynamically per partition transition?WindowAggExecinclude an optional runtime debug assertion verifying that input batches strictly adhere to declared partition sort order?Disclaimer
This is an early architectural design proposal to align on direction with DataFusion maintainers. The prototype code is an experimental proof-of-concept and is not yet a production-ready or merge-ready implementation.
All reactions