///|
/// Cap on how many hash partitions one aggregation may create. The join caps
/// at 256 and splits an over-budget partition again; aggregation cannot
/// recurse (a group's state must stay in one partition), so it stays lower.
const AGG_PARTITION_CAP : Int32 = 64
// Grace hash aggregation: when a GROUP BY's input
// exceeds the memory budget, the input is partitioned by the hash of
// its group keys into per-partition spill runs (the same SpillStore the
// sort and the join use), and the existing in-memory aggregate() runs
// one partition at a time. Equal group keys always land in the same
// partition, so each partition's group rows are FINAL — median,
// string_agg and count-distinct state never spans partitions, and no
// combine phase exists. The engine still never touches the filesystem.
///|
/// Dispatch a GROUP BY aggregation under the memory budget: within
/// budget (or budget-less, or a global aggregate with no GROUP BY) the
/// historical in-memory aggregate() runs; over budget the aggregation
/// spills (grace hash) when a store is provided, and is a clean error
/// without one.
fn aggregate_within_budget(
joined : Array[@types.DataBatch],
group_exprs : Array[PhysExpr],
group_types : Array[@types.DataType],
aggs : Array[AggSpec],
opts : ExecOptions,
stats : Array[(String, Int64)],
) -> @types.DataBatch raise @types.SqlError {
if opts.budget <= 0 || group_exprs.length() == 0 {
return aggregate(joined, group_exprs, group_types, aggs)
}
if batches_bytes(joined) <= opts.budget {
return aggregate(joined, group_exprs, group_types, aggs)
}
match opts.spill {
Some(store) =>
grace_agg(
joined,
group_exprs,
group_types,
aggs,
opts.budget,
store,
stats,
)
None =>
raise @types.SqlError::Execute(
"aggregate input exceeds the memory budget (\{batches_bytes(joined)} bytes > \{opts.budget}); provide a spill store (--spill-dir) or raise the budget (--memory)",
)
}
}
///|
/// Grace hash aggregation: partition the input by group-key hash into
/// spill runs, aggregate each partition in memory (its group rows are
/// final), and concatenate. P is derived from the estimated input bytes
/// so a partition's input fits the budget; a partition that still
/// overflows (few distinct keys — every row of a group shares its
/// partition) aggregates in memory, exceeding the budget for that
/// partition only — group state cannot be split further without a
/// combine phase (future work).
fn grace_agg(
joined : Array[@types.DataBatch],
group_exprs : Array[PhysExpr],
group_types : Array[@types.DataType],
aggs : Array[AggSpec],
budget : Int32,
store : SpillStore,
stats : Array[(String, Int64)],
) -> @types.DataBatch raise @types.SqlError {
let p = partition_count(batches_bytes(joined), budget, AGG_PARTITION_CAP)
let writers : Array[RunWriter] = []
for _ in 0.. 0 {
stats.push(("spill partitions", p.to_int64()))
}
grace_finish(store, writers, group_exprs, group_types, aggs)
}
///|
/// Aggregate every partition the writers hold, one run at a time, remove their
/// files and concatenate the groups. Shared by the collected and the pulled
/// grace aggregation: both end with every partition's runs on the store.
fn grace_finish(
store : SpillStore,
writers : Array[RunWriter],
group_exprs : Array[PhysExpr],
group_types : Array[@types.DataType],
aggs : Array[AggSpec],
) -> @types.DataBatch raise @types.SqlError {
let out : Array[@types.DataBatch] = []
let dtypes : Array[@types.DataType] = []
for k in 0.. Int32 {
let mut p = budget / 4 / AGG_MIN_RUN
if p < 2 {
p = 2
}
if p > AGG_PULL_PARTITION_CAP {
p = AGG_PULL_PARTITION_CAP
}
p
}
///|
/// The partition writers of one pulled aggregation and the single buffer they
/// share. Each writer used to hold its own `budget / 4`, so up to 64 of them
/// could keep 16 times the budget; here the buffers add up to `limit`, and when
/// they do not fit the largest one is written out as a run.
priv struct PartitionPool {
writers : Array[RunWriter]
limit : Int32
mut pooled : Int32
// the most the shared buffers held at once, before the flush that brought
// them back under `limit`
mut peak : Int32
// estimated in-memory bytes each partition has written out as runs
spilled : Array[Int64]
}
///|
/// Route each row of `batch` into its key-hash partition buffer, then write out
/// the largest buffers until the pool fits again.
fn PartitionPool::add(
self : PartitionPool,
batch : @types.DataBatch,
key_exprs : Array[PhysExpr],
) -> Unit raise @types.SqlError {
let p = self.writers.length()
let key_vecs : Array[@types.ColumnVector] = []
for e in key_exprs {
key_vecs.push(eval_vec(e, batch))
}
let idx : Array[Array[Int32]] = []
for _ in 0.. 0 {
let part = take_rows(batch, idx[k])
let bytes = estimate_batch_bytes(part)
self.writers[k].stage(part, bytes)
self.pooled += bytes
}
}
if self.pooled > self.peak {
self.peak = self.pooled
}
while self.pooled > self.limit {
self.flush_largest()
}
}
///|
/// Write the largest buffer as one run and release what it held.
fn PartitionPool::flush_largest(
self : PartitionPool,
) -> Unit raise @types.SqlError {
let mut best = 0
for k in 1.. self.writers[best].acc_bytes {
best = k
}
}
let w = self.writers[best]
let held = w.acc_bytes
if held == 0 {
self.pooled = 0
return
}
w.flush()
self.pooled -= held
self.spilled[best] += held.to_int64()
}
///|
/// What a pulled aggregation hands back besides its groups: how many partitions
/// it spilled into (0 when it fit its budget), the most input bytes it held at
/// once, and the most one partition wrote out. Both byte counts are the
/// engine's size estimate (`estimate_batch_bytes`), not file sizes.
priv struct PulledAgg {
batch : @types.DataBatch
partitions : Int32
peak_buffered : Int64
largest_spilled : Int64
}
///|
/// A GROUP BY under `--memory`, fed from a pull instead of a collected input.
/// Batches are buffered while their estimated bytes (summed in 64 bits) stay
/// within the budget; if the pull ends first, the buffered batches are aggregated
/// exactly as the within-budget path does and no spill store is needed. The first
/// overflow needs one: the buffer is then routed into a fixed number of hash
/// partitions that share one `budget / 4` pool, the rest of the pull follows, and
/// each partition is aggregated run by run. Retained input is the budget plus one
/// scan batch, whatever the total row count; group state and the grouped result
/// are not bounded by this. The result carries the counters `PulledAgg` names.
fn aggregate_pulled(
next : () -> @types.DataBatch? raise @types.SqlError,
group_exprs : Array[PhysExpr],
group_types : Array[@types.DataType],
aggs : Array[AggSpec],
opts : ExecOptions,
) -> PulledAgg raise @types.SqlError {
let limit = opts.budget.to_int64()
let buffered : Array[@types.DataBatch] = []
let mut bytes = 0L
let mut exhausted = false
while !exhausted && bytes <= limit {
match next() {
Some(batch) => {
buffered.push(batch)
bytes += estimate_batch_bytes(batch).to_int64()
}
None => exhausted = true
}
}
if exhausted {
return {
batch: aggregate(buffered, group_exprs, group_types, aggs),
partitions: 0,
peak_buffered: bytes,
largest_spilled: 0L,
}
}
let store = match opts.spill {
Some(store) => store
None =>
raise @types.SqlError::Execute(
"aggregate input exceeds the memory budget (\{bytes} bytes > \{opts.budget}); provide a spill store (--spill-dir) or raise the budget (--memory)",
)
}
let p = agg_pull_partitions(opts.budget)
let writers : Array[RunWriter] = []
for _ in 0.. largest {
largest = s
}
}
let peak = pool.peak.to_int64()
{
batch: grace_finish(store, writers, group_exprs, group_types, aggs),
partitions: p,
// the buffer drains into the pool, so the most held at once is whichever
// of the two phases was larger
peak_buffered: if peak > bytes {
peak
} else {
bytes
},
largest_spilled: largest,
}
}