GitHub user Subodh-17 created a discussion: [RFC/Discussion] 
Partition-at-a-Time Streaming Evaluation in WindowAggExec

# [RFC/Discussion] Partition-at-a-Time Streaming Evaluation in WindowAggExec

- **Status:** Early Design Proposal — Seeking Maintainer & Community Feedback
- **Target Area:** `datafusion-physical-plan` (`WindowAggExec`, 
`WindowAggStream`)
- **Author:** Community Contributor

---

## 1. Problem Statement

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:
1. It buffers every incoming `RecordBatch` into `Vec<RecordBatch>` until the 
upstream stream yields `None` (End-of-Stream / EOS).
2. It blocks downstream operators from consuming even a single output row until 
the entire upstream pipeline terminates.
3. At stream exhaustion, it invokes `concat_batches` across all accumulated 
batches, materializing a monolithic batch of $N$ rows before evaluating window 
expressions.

```text
CURRENT BEHAVIOR (Whole-Stream Buffering Stall):
Input Batches:   [B0] ---> [B1] ---> [B2] ---> [B3] ---> ... ---> [Bn] ---> EOS
Buffering:       |----------------- FULL BUFFER (All N rows) ------------------|
Evaluation:                                                                    
|--> concat_batches(N rows)
Emission:                                                                      
|--> [Single Monolithic Batch]
Pipeline State:  |<--------------------- BLOCKED ----------------------------->|
```

### Operational Drawbacks
- **$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.

---

## 2. Proposed Approach: Partition-at-a-Time Streaming

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:
1. **Active Partition Buffer:** Maintain `active_partition_batches: 
Vec<RecordBatch>` and `active_partition_key: Option<Vec<ScalarValue>>` for the 
currently open partition.
2. **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.
3. **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.
4. **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).

```text
PROPOSED PARTITION-STREAMING PIPELINE:
Input Batch B0:  [ P0 (tail) ][ P1 (complete) ][ P2 (head) ]
                  |           |                 |
Finalize P0:      |---------> |-> Eval & Enqueue -> [pending_batches] -> 
Emitted Downstream!
Evaluate P1:                  |-> Eval & Enqueue -> [pending_batches] -> 
Emitted Downstream!
Buffer P2:                                      |-> [active_partition_batches]
Working Memory:   Bounded by max(|P_i|) + BatchSize
```

---

## 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:

$$\text{Peak Working Memory} \le O(|P_{\max}|) + O(B_{\text{input}}) + 
O(B_{\text{pending}}) + O(\text{eval\_temp})$$

Where:
- **$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.

---

## 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.0` 
release build, 5 runs per workload):
- **Query:** `SELECT c1, sn, v, COUNT(sn) OVER (PARTITION BY c1 ORDER BY sn) 
FROM t;`
- **Payload Schema:** `c1: Utf8` (100-byte string partition keys), `sn: Int64` 
(ordering sequence), `v: Float64`.

| Workload | Rows / Partitions | Baseline Median Query Time | Prototype Median 
Query Time | Observed Speedup | Baseline Peak RSS | Prototype Peak RSS | 
Physical RAM Saved |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **Small Partitions (1.0M rows)** | 1.0M / 6,666 (~150 rows/part) | 119.98 ms 
| 30.19 ms | **3.97×** | 321.9 MB | 173.3 MB | 148.6 MB (46.2%) |
| **Small Partitions (2.5M rows)** | 2.5M / 16,666 (~150 rows/part) | 246.19 ms 
| 77.76 ms | **3.17×** | 724.0 MB | 352.7 MB | 371.2 MB (51.3%) |
| **Large Partitions (2.0M rows)** | 2.0M / 4 (500k rows/part) | 157.85 ms | 
134.44 ms | **1.17×** | 574.3 MB | 349.1 MB | 225.2 MB (39.2%) |
| **Single Partition (2.0M rows)** | 2.0M / 1 (2M rows/part) | 136.59 ms | 
137.86 ms | **0.99×** | 543.7 MB | 543.6 MB | +0.1 MB (0.0%) |
| **Micro-Partitions (1.5M rows)** | 1.5M / 15,000 (100 rows/part) | 147.91 ms 
| 56.93 ms | **2.60×** | 454.2 MB | 227.2 MB | 227.0 MB (50.0%) |

### Essential Measurement Caveats
1. **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.
2. **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.
3. **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.
4. **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 `NULL`s. 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:

1. **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?
2. **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?
3. **MemoryPool Reservation Granularity (Phase 2):** For dynamic memory 
tracking in Phase 2, should `MemoryReservation` be updated per incoming batch, 
or dynamically per partition transition?
4. **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.


GitHub link: https://github.com/apache/datafusion/discussions/26425

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: 
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to