Skip to content

Commit f10afbc

Browse files
Dandandanclaude
andcommitted
feat: Add BatchedVec for chunked accumulator storage in hash aggregation
Introduce BatchedVec<T>, a Vec-like container that stores elements in fixed-size batches (default 8192). This enables O(1) per-batch emission via mem::take on individual inner Vecs, avoiding both the O(n) cost of materializing all groups into one large batch and the O(n²/batch_size) cost of repeated EmitTo::First(batch_size) shifting. Update PrimitiveGroupsAccumulator to use BatchedVec as proof of concept. The flat usize group index is decomposed into (batch, offset) internally via simple division/modulo. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 9c3c01a commit f10afbc

3 files changed

Lines changed: 271 additions & 8 deletions

File tree

‎datafusion/functions-aggregate-common/src/aggregate/groups_accumulator.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
//! Adapter that makes [`GroupsAccumulator`] out of [`Accumulator`]
2020
2121
pub mod accumulate;
22+
pub mod batched_vec;
2223
pub mod bool_op;
2324
pub mod nulls;
2425
pub mod prim_op;
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! [`BatchedVec`] — a Vec-like structure that stores elements in
19+
//! fixed-size batches for efficient batch-at-a-time emission.
20+
21+
use std::fmt::Debug;
22+
use std::mem::size_of;
23+
24+
/// A Vec-like container that stores elements in fixed-size batches.
25+
///
26+
/// Indexed by a flat `usize` which is decomposed into
27+
/// `(batch_index, offset)` via simple division/modulo on `batch_size`.
28+
///
29+
/// Each inner batch is always exactly `batch_size` elements (except
30+
/// possibly the last one during growth). Emission of a single batch
31+
/// is O(1) via [`Self::take_batch`].
32+
#[derive(Debug)]
33+
pub struct BatchedVec<T> {
34+
batches: Vec<Vec<T>>,
35+
/// log2(batch_size) — used for bit-shift decomposition
36+
batch_shift: u32,
37+
/// batch_size - 1 — used as bitmask for offset extraction
38+
batch_mask: usize,
39+
len: usize,
40+
}
41+
42+
impl<T: Clone> BatchedVec<T> {
43+
pub fn new(batch_size: usize) -> Self {
44+
assert!(batch_size > 0 && batch_size.is_power_of_two(),
45+
"batch_size must be a power of two, got {batch_size}");
46+
Self {
47+
batches: Vec::new(),
48+
batch_shift: batch_size.trailing_zeros(),
49+
batch_mask: batch_size - 1,
50+
len: 0,
51+
}
52+
}
53+
54+
#[inline]
55+
fn batch_size(&self) -> usize {
56+
1 << self.batch_shift
57+
}
58+
59+
#[inline]
60+
fn decompose(&self, index: usize) -> (usize, usize) {
61+
(index >> self.batch_shift, index & self.batch_mask)
62+
}
63+
64+
pub fn len(&self) -> usize {
65+
self.len
66+
}
67+
68+
pub fn is_empty(&self) -> bool {
69+
self.len == 0
70+
}
71+
72+
/// Ensure capacity for at least `total` elements, filling new
73+
/// slots with `default_value`.
74+
pub fn ensure_capacity(&mut self, total: usize, default_value: T) {
75+
if total <= self.len {
76+
return;
77+
}
78+
79+
let batch_size = self.batch_size();
80+
81+
// Fill current last batch up to batch_size before adding new ones
82+
if let Some(last) = self.batches.last_mut() {
83+
let can_add = batch_size - last.len();
84+
let need = total - self.len;
85+
let add = can_add.min(need);
86+
if add > 0 {
87+
last.resize(last.len() + add, default_value.clone());
88+
self.len += add;
89+
}
90+
}
91+
92+
// Add new batches as needed
93+
while self.len < total {
94+
let batch_len = (total - self.len).min(batch_size);
95+
let mut batch = Vec::with_capacity(batch_len);
96+
batch.resize(batch_len, default_value.clone());
97+
self.batches.push(batch);
98+
self.len += batch_len;
99+
}
100+
}
101+
102+
/// # Safety
103+
///
104+
/// `index` must be in bounds (< self.len).
105+
#[inline]
106+
pub unsafe fn get_unchecked_mut(&mut self, index: usize) -> &mut T {
107+
let (batch, offset) = self.decompose(index);
108+
self.batches
109+
.get_unchecked_mut(batch)
110+
.get_unchecked_mut(offset)
111+
}
112+
113+
/// Number of batches (including a possible partial last batch).
114+
pub fn num_batches(&self) -> usize {
115+
self.batches.len()
116+
}
117+
118+
/// Take one batch by index, leaving an empty Vec in its place.
119+
/// This is O(1).
120+
pub fn take_batch(&mut self, batch_idx: usize) -> Option<Vec<T>> {
121+
self.batches.get_mut(batch_idx).map(|batch| {
122+
self.len = self.len.saturating_sub(batch.len());
123+
std::mem::take(batch)
124+
})
125+
}
126+
127+
/// Take all elements as a flat Vec (for `EmitTo::All`).
128+
pub fn take_all(&mut self) -> Vec<T> {
129+
self.len = 0;
130+
std::mem::take(&mut self.batches)
131+
.into_iter()
132+
.flatten()
133+
.collect()
134+
}
135+
136+
/// Take the first `n` elements (for `EmitTo::First(n)`).
137+
///
138+
/// When `n` equals `batch_size`, this is zero-copy (just `mem::take`
139+
/// on the first batch). Otherwise, full batches are drained via
140+
/// `mem::take` (O(1) each) and only the split-point batch is copied.
141+
pub fn take_first(&mut self, n: usize) -> Vec<T> {
142+
assert!(n <= self.len);
143+
let full_batches = n >> self.batch_shift;
144+
let remainder = n & self.batch_mask;
145+
146+
// Fast path: exactly one full batch, no remainder — zero copy
147+
if full_batches == 1 && remainder == 0 {
148+
self.len -= n;
149+
return self.batches.drain(..1).next().unwrap();
150+
}
151+
152+
let mut result = Vec::with_capacity(n);
153+
154+
// Take full batches — O(1) each
155+
for batch in self.batches.drain(..full_batches) {
156+
result.extend(batch);
157+
}
158+
159+
// Split the next batch if there's a remainder
160+
if remainder > 0 && !self.batches.is_empty() {
161+
let batch = &mut self.batches[0];
162+
let mut tail = batch.split_off(remainder);
163+
std::mem::swap(batch, &mut tail);
164+
result.extend(tail); // tail has [0..remainder]
165+
}
166+
167+
self.len -= n;
168+
result
169+
}
170+
171+
/// Total heap memory used by this structure.
172+
pub fn size(&self) -> usize {
173+
self.batches
174+
.iter()
175+
.map(|b| b.capacity() * size_of::<T>())
176+
.sum::<usize>()
177+
+ self.batches.capacity() * size_of::<Vec<T>>()
178+
}
179+
}
180+
181+
#[cfg(test)]
182+
mod tests {
183+
use super::*;
184+
185+
#[test]
186+
fn test_basic_operations() {
187+
let mut v = BatchedVec::new(4);
188+
assert_eq!(v.len(), 0);
189+
190+
v.ensure_capacity(10, 0i32);
191+
assert_eq!(v.len(), 10);
192+
assert_eq!(v.num_batches(), 3); // 4 + 4 + 2
193+
194+
// Write via composite index
195+
for i in 0..10 {
196+
unsafe {
197+
*v.get_unchecked_mut(i) = i as i32;
198+
}
199+
}
200+
201+
// take_all flattens
202+
let all = v.take_all();
203+
assert_eq!(&all[..10], &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
204+
assert_eq!(v.len(), 0);
205+
}
206+
207+
#[test]
208+
fn test_take_batch() {
209+
let mut v = BatchedVec::new(4);
210+
v.ensure_capacity(10, 0i32);
211+
for i in 0..10 {
212+
unsafe {
213+
*v.get_unchecked_mut(i) = i as i32;
214+
}
215+
}
216+
217+
let b0 = v.take_batch(0).unwrap();
218+
assert_eq!(b0, vec![0, 1, 2, 3]);
219+
220+
let b1 = v.take_batch(1).unwrap();
221+
assert_eq!(b1, vec![4, 5, 6, 7]);
222+
223+
// Last batch has only the 2 remaining elements
224+
let b2 = v.take_batch(2).unwrap();
225+
assert_eq!(b2, vec![8, 9]);
226+
}
227+
228+
#[test]
229+
fn test_take_first() {
230+
let mut v = BatchedVec::new(4);
231+
v.ensure_capacity(10, 0i32);
232+
for i in 0..10 {
233+
unsafe {
234+
*v.get_unchecked_mut(i) = i as i32;
235+
}
236+
}
237+
238+
let first = v.take_first(6);
239+
assert_eq!(first, vec![0, 1, 2, 3, 4, 5]);
240+
241+
let rest = v.take_all();
242+
assert_eq!(&rest[..4], &[6, 7, 8, 9]);
243+
}
244+
245+
#[test]
246+
fn test_ensure_capacity_grows() {
247+
let mut v = BatchedVec::new(4);
248+
v.ensure_capacity(3, 42i32);
249+
assert_eq!(v.len(), 3);
250+
assert_eq!(v.num_batches(), 1);
251+
// Batch has exactly the elements needed
252+
assert_eq!(v.take_batch(0).unwrap().len(), 3);
253+
}
254+
}

‎datafusion/functions-aggregate-common/src/aggregate/groups_accumulator/prim_op.rs‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::mem::size_of;
1918
use std::sync::Arc;
2019

2120
use arrow::array::{ArrayRef, AsArray, BooleanArray, PrimitiveArray};
@@ -27,6 +26,11 @@ use datafusion_common::{DataFusionError, Result, internal_datafusion_err};
2726
use datafusion_expr_common::groups_accumulator::{EmitTo, GroupsAccumulator};
2827

2928
use super::accumulate::NullState;
29+
use super::batched_vec::BatchedVec;
30+
31+
/// Default batch size for internal batched storage.
32+
/// Matches the default DataFusion batch size.
33+
const DEFAULT_BATCH_SIZE: usize = 8192;
3034

3135
/// An accumulator that implements a single operation over
3236
/// [`ArrowPrimitiveType`] where the accumulated state is the same as
@@ -43,8 +47,8 @@ where
4347
T: ArrowPrimitiveType + Send,
4448
F: Fn(&mut T::Native, T::Native) + Send + Sync,
4549
{
46-
/// values per group, stored as the native type
47-
values: Vec<T::Native>,
50+
/// values per group, stored in batch_size chunks
51+
values: BatchedVec<T::Native>,
4852

4953
/// The output type (needed for Decimal precision and scale)
5054
data_type: DataType,
@@ -66,7 +70,7 @@ where
6670
{
6771
pub fn new(data_type: &DataType, prim_fn: F) -> Self {
6872
Self {
69-
values: vec![],
73+
values: BatchedVec::new(DEFAULT_BATCH_SIZE),
7074
data_type: data_type.clone(),
7175
null_state: NullState::new(),
7276
starting_value: T::default_value(),
@@ -96,8 +100,9 @@ where
96100
assert_eq!(values.len(), 1, "single argument to update_batch");
97101
let values = values[0].as_primitive::<T>();
98102

99-
// update values
100-
self.values.resize(total_num_groups, self.starting_value);
103+
// ensure storage covers all group indices
104+
self.values
105+
.ensure_capacity(total_num_groups, self.starting_value);
101106

102107
// NullState dispatches / handles tracking nulls and groups that saw no values
103108
self.null_state.accumulate(
@@ -116,7 +121,10 @@ where
116121
}
117122

118123
fn evaluate(&mut self, emit_to: EmitTo) -> Result<ArrayRef> {
119-
let values = emit_to.take_needed(&mut self.values);
124+
let values = match emit_to {
125+
EmitTo::All => self.values.take_all(),
126+
EmitTo::First(n) => self.values.take_first(n),
127+
};
120128
let nulls = self.null_state.build(emit_to);
121129
let values = PrimitiveArray::<T>::new(values.into(), nulls) // no copy
122130
.with_data_type(self.data_type.clone());
@@ -196,6 +204,6 @@ where
196204
}
197205

198206
fn size(&self) -> usize {
199-
self.values.capacity() * size_of::<T::Native>() + self.null_state.size()
207+
self.values.size() + self.null_state.size()
200208
}
201209
}

0 commit comments

Comments
 (0)