Skip to content

Support out of order grouped aggregate accumulation - #8379

Draft
gatesn wants to merge 1 commit into
developfrom
ngates/grouped-aggregate
Draft

gatesn wants to merge 1 commit into
developfrom
ngates/grouped-aggregate

Conversation

@gatesn

@gatesn gatesn commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Our original API for grouped aggregate functions only supported groups that we pre-sorted into complete lists. That meant large groups had to be globally sorted before we could start accumulating their internal state, unlike the grouped-aggregate APIs used by query engines.

This PR updates the grouped accumulator API to accept caller-assigned dense GroupIds directly.

Summary

  • Accept GroupIds { ids: ArrayRef, num_groups }, including repeated, out-of-order, and absent IDs.
  • Dispatch grouped kernels on both the value encoding and group-ID encoding.
  • Cache materialized and validated group IDs across clones and aggregate functions.
  • Keep a list-group compatibility adapter for existing list_sum callers.
  • Let each aggregate own its dense grouped-state representation, with the existing generic partial-state implementation as the fallback.
  • Store grouped count state as one u64 per group.
  • Store grouped sum state in monomorphic u64, i64, f64, or decimal vectors, with overflow and empty state kept separately.
  • Preserve SQL null-on-empty sum semantics, including all-null groups and groups absent from a batch.
  • Select exact decimal state width (i8 through i256) once when the accumulator is created.
  • Merge count, primitive-sum, float-sum, and decimal-sum partial arrays without execute_scalar per row.
  • Adapt between direct typed scatter for unclustered IDs and per-run reduction for clustered IDs.
  • Use one stable counting-sort gather per morsel in the universal fallback instead of one take per group.

Spiral impact

Spiral already interns group keys into dense u32 state slots, so it can pass those IDs directly without rearranging values into ordered list groups. This covers the common count/sum machinery while allowing Spiral to retain its stronger integer/decimal mean states and other specialized paths.

Local performance

Medians below use 65,536 non-null i32 rows and include canonical result materialization. Shuffled inputs contain the same multiset of IDs as their clustered counterpart.

Groups Count clustered Count shuffled Sum clustered Sum shuffled
128 16.6 μs 16.5 μs 22.0 μs 36.3 μs
4,096 18.5 μs 16.1 μs 30.2 μs 26.9 μs
65,536 18.3 μs 37.2 μs 60.4 μs 90.2 μs

The original 65,536-row / 4,096-group shuffled sum benchmark was approximately 215 μs with per-group SumPartial state. Typed storage plus adaptive scatter reduces it to approximately 26.9 μs while also materializing the result and tracking SQL empty-state semantics, an approximately 8× improvement.

On the existing roughly 1K-row cases, representative medians are 1.9 μs for clustered-null count, 3.7 μs for nullable all-valid i32 sum, 3.9 μs for all-valid f64 sum, and 6.2 μs for clustered-null i32 sum.

Validation

  • cargo +nightly fmt --all
  • cargo test -p vortex-array --lib (3,376 passed, 1 ignored)
  • cargo bench -p vortex-array --bench aggregate_grouped
  • cargo clippy -p vortex-array --all-targets --all-features
  • cargo clippy --all-targets --all-features
  • RUSTDOCFLAGS="-D warnings" cargo doc --profile ci --no-deps -p vortex-array

@gatesn
gatesn requested a review from onursatici June 11, 2026 21:41
@gatesn gatesn added the changelog/break A breaking API change label Jun 11, 2026
@gatesn gatesn changed the title Support dense grouped aggregate accumulation Support out of order grouped aggregate accumulation Jun 11, 2026
@gatesn
gatesn marked this pull request as ready for review June 11, 2026 21:43
@gatesn
gatesn requested a review from a team June 11, 2026 21:43
@gatesn
gatesn enabled auto-merge (squash) June 11, 2026 21:43
@codspeed

codspeed Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 16 benchmarks

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 14 improved benchmarks
❌ 16 regressed benchmarks
✅ 2105 untouched benchmarks
🆕 18 new benchmarks
⏩ 110 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Simulation fsl_sum_large 167.8 µs 231.8 µs -27.59%
❌ Simulation listview_sum_large 191.8 µs 254.7 µs -24.69%
❌ WallTime mul_u64_nonnull_neon 15.2 µs 19.6 µs -22.51%
❌ Simulation fsl_sum_medium 144.6 µs 183.2 µs -21.1%
❌ Simulation listview_sum_medium 159.5 µs 198.4 µs -19.58%
❌ Simulation fsl_sum_small 131.7 µs 163.1 µs -19.28%
❌ Simulation list_sum_nullable_elements_large 414.4 µs 512.2 µs -19.09%
❌ Simulation listview_sum_small 140.2 µs 172.4 µs -18.67%
❌ Simulation list_sum_large 272.7 µs 335.3 µs -18.67%
❌ Simulation sum_v2_i32_nullable_all_valid 110.9 µs 133.4 µs -16.86%
❌ WallTime mul_i64_nonnull_neon 17 µs 20.4 µs -16.69%
❌ Simulation list_sum_nullable_elements_medium 308.3 µs 368 µs -16.22%
❌ WallTime multiply_shapes_neon[(16384, PerRowPerRow)] 17.2 µs 20.5 µs -15.98%
❌ Simulation list_sum_medium 235.8 µs 278 µs -15.16%
❌ Simulation list_sum_small 213.2 µs 244.9 µs -12.91%
❌ Simulation sum_v2_f64_all_valid 117.6 µs 131.5 µs -10.57%
⚡ Simulation count_varbinview 159 µs 35 µs ×4.5
⚡ Simulation sum_i32_clustered_nulls 124 µs 57.6 µs ×2.2
⚡ Simulation sum_f64_clustered_nulls 131.1 µs 61.8 µs ×2.1
⚡ Simulation count_i32_clustered_nulls 89.8 µs 43.8 µs ×2.1
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ngates/grouped-aggregate (3c4d65f) with develop (f880575)

Open in CodSpeed

Footnotes

  1. 110 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@gatesn
gatesn requested a review from joseph-isaacs June 12, 2026 02:49
@joseph-isaacs

Copy link
Copy Markdown
Contributor

We wait that vortex was a linear scan engine. This breaks it correct?

@onursatici onursatici 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 prefer this API, not only that it supports out of order groups but also it can keep the memory footprint lower by not having us to materialise each group before calling aggregate.

I think you are conflicting with #8314
Sum and count kernels you added make sense to me but we went with having a encoding agnostic kernel support on the registry on the merged PR instead of having the kernel in the aggregate function's vtable

@gatesn

gatesn commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

We wait that vortex was a linear scan engine. This breaks it correct?

No, the idea is that I can take an array, compute the group indices (i.e. using a scalar function), then update the aggregate state per group. This still doesn't require a shuffle / sort. But it does mean aggregate state may grow large. So in the future this is where we would want the ability to spill partials / reconstruct later.

Comment thread vortex-array/benches/aggregate_grouped.rs Outdated
Comment thread vortex-array/src/aggregate_fn/fns/count/grouped.rs Outdated

@onursatici onursatici 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 think we should choose either to have encoding agnostic grouped kernels that we register in the registry, like develop does with AggregateFnSession::register_grouped_kernel, or have the fast path as methods on the aggregate function vtable, like this PR does with AggregateFnVTable::try_accumulate_grouped

If we merge this as is, we will have two ways that do very similar things

@gatesn

gatesn commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Agreed, in general I want to move everything towards session kernels, including both aggregate functions and scalar functions.

Comment thread vortex-array/src/aggregate_fn/fns/count/grouped.rs Outdated
Comment thread vortex-array/src/aggregate_fn/fns/sum/grouped.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/accumulator_grouped.rs Outdated
Comment thread vortex-array/src/aggregate_fn/accumulator_grouped.rs Outdated
Comment thread vortex-array/src/aggregate_fn/accumulator_grouped.rs
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been marked as stale because it has been open for 14 days with no activity. Please comment or remove the stale label if you wish to keep it active, otherwise it will be closed in 7 days

@github-actions github-actions Bot added the stale This PR is stale and will be auto-closed soon label Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR was closed because it has been inactive for 7 days since being marked as stale.

@github-actions github-actions Bot closed this Jul 22, 2026
auto-merge was automatically disabled July 22, 2026 04:40

Pull request was closed

@gatesn gatesn reopened this Aug 7, 2026
@gatesn
gatesn marked this pull request as draft August 8, 2026 02:05
@github-actions github-actions Bot removed the stale This PR is stale and will be auto-closed soon label Aug 9, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been marked as stale because it has been open for 14 days with no activity. Please comment or remove the stale label if you wish to keep it active, otherwise it will be closed in 7 days

@github-actions github-actions Bot added the stale This PR is stale and will be auto-closed soon label Aug 25, 2026
robert3005 pushed a commit that referenced this pull request Aug 27, 2026
Rebases #8379 onto develop and addresses the outstanding review
comments.

The grouped accumulator now takes caller-assigned dense `GroupIds`
(`{ids: ArrayRef, num_groups}`) directly, instead of requiring values to
be pre-sorted into complete list groups. Ids may repeat, arrive out of
order, or be absent from a batch.

## Changes

- Dispatch grouped kernels on both the values encoding and the group-id
  encoding, with wildcard fallbacks in each dimension.
- Cache materialized and validated group ids across clones, so several
  aggregates over the same ids validate once.
- Let each aggregate own its dense grouped state (`GroupedState`), with
  the generic one-partial-per-group implementation as the fallback.
- Store grouped count state as one `u64` per group and grouped sum state
  in monomorphic `u64`/`i64`/`f64`/decimal vectors, choosing the decimal
  width once from the partial dtype.
- Merge count and sum partial arrays without `execute_scalar` per row.
- Adapt between typed scatter for unclustered ids and per-run reduction
  for clustered ids.
- Use one stable counting-sort gather per batch in the universal
  fallback instead of one `take` per group.
- Keep `GroupedArray::dense_input` as the list-group adapter for
  `list_sum`, which restores list semantics (null, empty, and all-null
  lists sum to null) on top of the dense result.

## Rebase notes

`develop` reverted SQL null-on-empty semantics for `Sum` (#9324), so the
grouped sum state follows the current contract: a group starts at zero
and a null partial means overflow. `SumAggregateOpts` is gone, and
`Sum::Options` is `NumericalAggregateOpts` again.

## Review comments

- Group ids are an `ArrayRef` so kernels can dispatch on their encoding
  (Constant, RLE, ...), and `GroupIds` is the "Group struct" the
  accumulator API talks in.
- `Mask::for_each_valid_index` replaces the hand-rolled
  `for_each_valid_idx` helpers, and avoids materializing the mask's
  index vector when it is not already cached.
- The run-detection helpers shared by the count and sum kernels moved to
  `aggregate_fn::kernels` instead of being duplicated per aggregate.
- The accumulator tracks `num_groups` itself, so `flush_partials` and
  `finish` no longer take it; `ensure_groups` is exposed for callers
  that need trailing groups no batch mentioned.
- `merge_group` names its parameters `into_group` / `from_group`.
- The benchmark input builder is now `DenseGroupedInput::clustered`.

## list_sum performance

CodSpeed flagged large `list_sum` regressions on the original PR. Routing
`list_sum` through dense ids costs a group id per element, so the adapter
now avoids the two things that actually hurt: it reuses the element array
as the values array whenever the groups already cover it in order (null
lists keep their own ids, and their meaningless sums are masked out
afterwards anyway), and it shares one set of group ranges and validity
between the adaptation and the mask.

Medians from `cargo bench -p vortex-array --bench list_sum`, develop vs
this branch:

| benchmark | develop | this branch |
|---|---:|---:|
| `fsl_sum_large` | 7.3 us | 8.8 us |
| `fsl_sum_medium` | 5.4 us | 5.7 us |
| `fsl_sum_small` | 4.3 us | 3.6 us |
| `list_sum_large` | 15.5 us | 15.8 us |
| `list_sum_medium` | 12.8 us | 12.0 us |
| `list_sum_small` | 11.0 us | 9.5 us |
| `list_sum_nullable_elements_large` | 64.5 us | 20.9 us |
| `list_sum_nullable_elements_medium` | 36.7 us | 14.6 us |
| `listview_sum_large` | 10.2 us | 10.2 us |
| `listview_sum_medium` | 11.9 us | 6.6 us |
| `listview_sum_small` | 6.4 us | 6.4 us |

## Validation

- `cargo +nightly fmt --all`
- `cargo build -p vortex-array --all-targets`
- `cargo test -p vortex-array --lib` (3,438 passed, 1 ignored)
- `cargo test --doc -p vortex-array`
- `cargo test -p vortex-mask` (122 passed)
- `cargo bench -p vortex-array --bench list_sum`
- `cargo bench -p vortex-array --bench aggregate_grouped -- --test`
- `cargo clippy -p vortex-array -p vortex-mask --all-targets --all-features`
- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p vortex-array -p vortex-mask`

A workspace-wide build ran out of disk in this environment; the change
is confined to `vortex-array` and `vortex-mask`, and no other crate
references the grouped accumulator API.

Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8VcUXWmDmNH2JjJY4aDQ8
robert3005 pushed a commit that referenced this pull request Aug 27, 2026
Rebases #8379 onto develop and addresses the outstanding review
comments.

The grouped accumulator now takes caller-assigned dense `GroupIds`
(`{ids: ArrayRef, num_groups}`) directly, instead of requiring values to
be pre-sorted into complete list groups. Ids may repeat, arrive out of
order, or be absent from a batch.

## Changes

- Dispatch grouped kernels on both the values encoding and the group-id
  encoding, with wildcard fallbacks in each dimension.
- Cache materialized and validated group ids across clones, so several
  aggregates over the same ids validate once.
- Let each aggregate own its dense grouped state (`GroupedState`), with
  the generic one-partial-per-group implementation as the fallback.
- Store grouped count state as one `u64` per group and grouped sum state
  in monomorphic `u64`/`i64`/`f64`/decimal vectors, choosing the decimal
  width once from the partial dtype.
- Merge count and sum partial arrays without `execute_scalar` per row.
- Adapt between typed scatter for unclustered ids and per-run reduction
  for clustered ids.
- Use one stable counting-sort gather per batch in the universal
  fallback instead of one `take` per group.
- Keep `GroupedArray::dense_input` as the list-group adapter for
  `list_sum`, which restores list semantics (null, empty, and all-null
  lists sum to null) on top of the dense result.

## Rebase notes

`develop` reverted SQL null-on-empty semantics for `Sum` (#9324), so the
grouped sum state follows the current contract: a group starts at zero
and a null partial means overflow. `SumAggregateOpts` is gone, and
`Sum::Options` is `NumericalAggregateOpts` again.

## Review comments

- Group ids are an `ArrayRef` so kernels can dispatch on their encoding
  (Constant, RLE, ...), and `GroupIds` is the "Group struct" the
  accumulator API talks in.
- The hand-rolled `for_each_valid_idx` helpers are gone: the kernels
  take the mask's bit buffer, handle the all-true and all-false cases
  directly, and let `BitBuffer::for_each_set_index` walk the rest
  instead of materializing the mask's index vector.
- The run-detection helpers shared by the count and sum kernels moved to
  `aggregate_fn::kernels` instead of being duplicated per aggregate.
- The accumulator tracks `num_groups` itself, so `flush_partials` and
  `finish` no longer take it; `ensure_groups` is exposed for callers
  that need trailing groups no batch mentioned.
- `merge_group` names its parameters `into_group` / `from_group`.
- The benchmark input builder is now `DenseGroupedInput::clustered`.

## list_sum performance

CodSpeed flagged large `list_sum` regressions on the original PR. Routing
`list_sum` through dense ids costs a group id per element, so the adapter
now avoids the two things that actually hurt: it reuses the element array
as the values array whenever the groups already cover it in order (null
lists keep their own ids, and their meaningless sums are masked out
afterwards anyway), and it shares one set of group ranges and validity
between the adaptation and the mask.

Medians from `cargo bench -p vortex-array --bench list_sum`, develop vs
this branch:

| benchmark | develop | this branch |
|---|---:|---:|
| `fsl_sum_large` | 7.3 us | 7.3 us |
| `fsl_sum_medium` | 5.4 us | 5.0 us |
| `fsl_sum_small` | 4.3 us | 3.6 us |
| `list_sum_large` | 15.5 us | 15.7 us |
| `list_sum_medium` | 12.8 us | 11.9 us |
| `list_sum_small` | 11.0 us | 9.5 us |
| `list_sum_nullable_elements_large` | 64.5 us | 21.3 us |
| `list_sum_nullable_elements_medium` | 36.7 us | 14.8 us |
| `listview_sum_large` | 10.2 us | 9.8 us |
| `listview_sum_medium` | 11.9 us | 7.1 us |
| `listview_sum_small` | 6.4 us | 7.6 us |

## Validation

- `cargo +nightly fmt --all`
- `cargo build -p vortex-array --all-targets`
- `cargo test -p vortex-array --lib` (3,438 passed, 1 ignored)
- `cargo test --doc -p vortex-array`
- `cargo bench -p vortex-array --bench list_sum`
- `cargo bench -p vortex-array --bench aggregate_grouped -- --test`
- `cargo clippy -p vortex-array --all-targets --all-features`
- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p vortex-array`

A workspace-wide build ran out of disk in this environment; the change
is confined to `vortex-array`, and no other crate references the grouped
accumulator API.

Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8VcUXWmDmNH2JjJY4aDQ8
@robert3005
robert3005 force-pushed the ngates/grouped-aggregate branch from b27ec6a to 0d691c9 Compare August 27, 2026 14:03
robert3005 pushed a commit that referenced this pull request Aug 27, 2026
Rebases #8379 onto develop and addresses the outstanding review
comments.

The grouped accumulator now takes caller-assigned dense `GroupIds`
(`{ids: ArrayRef, num_groups}`) directly, instead of requiring values to
be pre-sorted into complete list groups. Ids may repeat, arrive out of
order, or be absent from a batch.

## Changes

- Dispatch grouped kernels on both the values encoding and the group-id
  encoding, with wildcard fallbacks in each dimension.
- Cache materialized and validated group ids across clones, so several
  aggregates over the same ids validate once.
- Let each aggregate own its dense grouped state (`GroupedState`), with
  the generic one-partial-per-group implementation as the fallback.
- Store grouped count state as one `u64` per group and grouped sum state
  in monomorphic `u64`/`i64`/`f64`/decimal vectors, choosing the decimal
  width once from the sum dtype.
- Port `SumV2` onto the same dense sums, adding the per-group empty flag
  its SQL semantics need, so both sums share one accumulation kernel.
- Merge count and sum partial arrays without `execute_scalar` per row.
- Adapt between typed scatter for unclustered ids and per-run reduction
  for clustered ids.
- Use one stable counting-sort gather per batch in the universal
  fallback instead of one `take` per group.
- Keep `GroupedArray::dense_input` as the list-group adapter for
  `list_sum`, which restores list semantics (null, empty, and all-null
  lists sum to null) on top of the dense result.

## Rebase notes

`develop` reverted SQL null-on-empty semantics for `Sum` (#9324) and
re-landed them as a separate `SumV2` (#9590), so this branch follows
both contracts: `Sum` starts a group at zero and treats a null partial
as overflow, while `SumV2` keeps `{sum, is_overflow, is_empty}` and
finalizes an untouched group to null. `SumAggregateOpts` is gone, and
both aggregates take `NumericalAggregateOpts`.

`SumV2`'s list-shaped `PrimitiveGroupedSumV2EncodingKernel` is replaced
by a dense kernel that reuses `Sum`'s accumulation and then clears the
empty flag of every group that saw a valid value, so `sum_v2` keeps a
fast grouped path rather than falling back to per-group accumulators.

The single-partial `flush` shortcut from #9597 no longer applies: dense
state produces one partial array per flush, so there is no chunk to
collapse, and its three tests went with it.

## Review comments

- Group ids are an `ArrayRef` so kernels can dispatch on their encoding
  (Constant, RLE, ...), and `GroupIds` is the "Group struct" the
  accumulator API talks in.
- The hand-rolled `for_each_valid_idx` helpers are gone: the kernels
  take the mask's bit buffer, handle the all-true and all-false cases
  directly, and let `BitBuffer::for_each_set_index` walk the rest
  instead of materializing the mask's index vector.
- The run-detection helpers shared by the count and sum kernels moved to
  `aggregate_fn::kernels` instead of being duplicated per aggregate.
- The accumulator tracks `num_groups` itself, so `flush_partials` and
  `finish` no longer take it; `ensure_groups` is exposed for callers
  that need trailing groups no batch mentioned.
- `merge_group` names its parameters `into_group` / `from_group`.
- The benchmark input builder is now `DenseGroupedInput::clustered`.

## list_sum performance

`list_sum` is the one caller that has to build dense ids for input that
is already grouped, so it pays for the new API. The adapter avoids the
two costs that dominated: it reuses the element array as the values
array whenever the groups already cover it in order (null lists keep
their own ids, and their meaningless sums are masked out afterwards),
and it shares one set of group ranges and validity between the
adaptation and the mask.

What remains is the id array itself, roughly half a nanosecond per
element. Medians from `cargo bench -p vortex-array --bench list_sum`,
develop (d0a59fe) vs this branch on the same machine:

| benchmark | develop | this branch |
|---|---:|---:|
| `fsl_sum_large` | 5.1 us | 7.3 us |
| `fsl_sum_medium` | 3.4 us | 4.8 us |
| `fsl_sum_small` | 2.4 us | 3.2 us |
| `list_sum_large` | 11.2 us | 13.7 us |
| `list_sum_medium` | 8.9 us | 9.9 us |
| `list_sum_small` | 7.6 us | 7.5 us |
| `list_sum_nullable_elements_large` | 57.6 us | 18.1 us |
| `list_sum_nullable_elements_medium` | 31.2 us | 12.3 us |
| `listview_sum_large` | 6.9 us | 9.6 us |
| `listview_sum_medium` | 4.9 us | 6.0 us |
| `listview_sum_small` | 3.8 us | 3.9 us |

Lists whose elements are all valid are 20-40% slower; lists with
nullable elements are 2.5-3x faster, since the old path sliced the
element mask per group. Closing the remaining gap wants a grouped
kernel registered for run-encoded group ids, which the new two-encoding
kernel lookup already supports - a follow-up rather than part of this
change.

## Validation

- `cargo +nightly fmt --all`
- `cargo build -p vortex-array --all-targets`
- `cargo test -p vortex-array --lib` (3,509 passed, 1 ignored)
- `cargo test --doc -p vortex-array`
- `cargo bench -p vortex-array --bench list_sum`
- `cargo bench -p vortex-array --bench aggregate_grouped -- --test`
- `cargo clippy -p vortex-array --all-targets --all-features`
- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p vortex-array`

A workspace-wide build ran out of disk in this environment; the change
is confined to `vortex-array`, and no other crate references the grouped
accumulator API.

Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8VcUXWmDmNH2JjJY4aDQ8
@robert3005
robert3005 force-pushed the ngates/grouped-aggregate branch from 0d691c9 to 6051e06 Compare August 27, 2026 14:44
@robert3005 robert3005 removed the stale This PR is stale and will be auto-closed soon label Aug 27, 2026
robert3005 pushed a commit that referenced this pull request Aug 27, 2026
Rebases #8379 onto develop and addresses the outstanding review
comments.

The grouped accumulator now takes caller-assigned dense `GroupIds`
(`{ids: ArrayRef, num_groups}`) directly, instead of requiring values to
be pre-sorted into complete list groups. Ids may repeat, arrive out of
order, or be absent from a batch.

## Changes

- Dispatch grouped kernels on both the values encoding and the group-id
  encoding, with wildcard fallbacks in each dimension.
- Cache materialized and validated group ids across clones, so several
  aggregates over the same ids validate once.
- Let each aggregate own its dense grouped state (`GroupedState`), with
  the generic one-partial-per-group implementation as the fallback.
- Store grouped count state as one `u64` per group and grouped sum state
  in monomorphic `u64`/`i64`/`f64`/decimal vectors, choosing the decimal
  width once from the sum dtype.
- Port `SumV2` onto the same dense sums, adding the per-group empty flag
  its SQL semantics need, so both sums share one accumulation kernel.
- Merge count and sum partial arrays without `execute_scalar` per row.
- Adapt between typed scatter for unclustered ids and per-run reduction
  for clustered ids.
- Use one stable counting-sort gather per batch in the universal
  fallback instead of one `take` per group.
- Keep `GroupedArray::dense_input` as the list-group adapter for
  `list_sum`, which restores list semantics (null, empty, and all-null
  lists sum to null) on top of the dense result.

## Rebase notes

`develop` reverted SQL null-on-empty semantics for `Sum` (#9324) and
re-landed them as a separate `SumV2` (#9590), so this branch follows
both contracts: `Sum` starts a group at zero and treats a null partial
as overflow, while `SumV2` keeps `{sum, is_overflow, is_empty}` and
finalizes an untouched group to null. `SumAggregateOpts` is gone, and
both aggregates take `NumericalAggregateOpts`.

`SumV2`'s list-shaped `PrimitiveGroupedSumV2EncodingKernel` is replaced
by a dense kernel that reuses `Sum`'s accumulation and then clears the
empty flag of every group that saw a valid value, so `sum_v2` keeps a
fast grouped path rather than falling back to per-group accumulators.

`list_sum` now sums with `SumV2` (#9621), so its empty and all-null list
masking is gone; it only has to restore the one case dense ids cannot
express, a null list.

The single-partial `flush` shortcut from #9597 no longer applies: dense
state produces one partial array per flush, so there is no chunk to
collapse, and its three tests went with it.

## Review comments

- Group ids are an `ArrayRef` so kernels can dispatch on their encoding
  (Constant, RLE, ...), and `GroupIds` is the "Group struct" the
  accumulator API talks in.
- The hand-rolled `for_each_valid_idx` helpers are gone: the kernels
  take the mask's bit buffer, handle the all-true and all-false cases
  directly, and let `BitBuffer::for_each_set_index` walk the rest
  instead of materializing the mask's index vector.
- The run-detection helpers shared by the count and sum kernels moved to
  `aggregate_fn::kernels` instead of being duplicated per aggregate.
- The accumulator tracks `num_groups` itself, so `flush_partials` and
  `finish` no longer take it; `ensure_groups` is exposed for callers
  that need trailing groups no batch mentioned.
- `merge_group` names its parameters `into_group` / `from_group`.
- The benchmark input builder is now `DenseGroupedInput::clustered`.

## list_sum performance

`list_sum` is the one caller that has to build dense ids for input that
is already grouped, so it pays for the new API: one `u32` per element,
read once to sum and once to clear empty flags, plus a mask that nulls
the lists dense ids cannot represent. The adapter already avoids the
larger costs - it reuses the element array as the values array whenever
the groups cover it in order, and shares one set of group ranges and
validity with the mask - but the id array itself remains.

Fastest-of-100 from `cargo bench -p vortex-array --bench list_sum`,
develop (1fe8dda) vs this branch on the same machine. Medians are noisy
on this host, so these are the fastest samples:

| benchmark | develop | this branch |
|---|---:|---:|
| `fsl_sum_large` | 7.2 us | 11.1 us |
| `fsl_sum_medium` | 5.8 us | 8.2 us |
| `fsl_sum_small` | 4.8 us | 6.6 us |
| `list_sum_large` | 11.5 us | 18.5 us |
| `list_sum_medium` | 9.9 us | 13.8 us |
| `list_sum_small` | 9.0 us | 10.8 us |
| `list_sum_nullable_elements_large` | 21.9 us | 20.9 us |
| `list_sum_nullable_elements_medium` | 14.9 us | 15.3 us |
| `listview_sum_large` | 7.3 us | 14.1 us |
| `listview_sum_medium` | 6.1 us | 9.7 us |
| `listview_sum_small` | 5.2 us | 7.2 us |

Lists that are already grouped are 20-90% slower; only the
nullable-element cases come out level. `SumV2`'s list kernel reduces one
range at a time and never touches a per-element id, which is what dense
ids cost here.

Closing this wants a grouped kernel registered for run-encoded group
ids, so a list adapter can hand over `(offsets, group ids)` without
materializing one id per element and the kernel can reduce a run at a
time. The two-encoding kernel lookup added here already supports
registering it; the kernel itself is follow-up work, not part of this
rebase.

## Validation

- `cargo +nightly fmt --all`
- `cargo build -p vortex-array --all-targets`
- `cargo test -p vortex-array --lib` (3,511 passed, 1 ignored)
- `cargo test --doc -p vortex-array`
- `cargo bench -p vortex-array --bench list_sum`
- `cargo bench -p vortex-array --bench aggregate_grouped -- --test`
- `cargo clippy -p vortex-array --all-targets --all-features`
- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p vortex-array`

A workspace-wide build ran out of disk in this environment; the change
is confined to `vortex-array`, and no other crate references the grouped
accumulator API.

Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8VcUXWmDmNH2JjJY4aDQ8
@robert3005
robert3005 force-pushed the ngates/grouped-aggregate branch from 6051e06 to 201d03b Compare August 27, 2026 16:53
Rebases #8379 onto develop and addresses the outstanding review
comments.

The grouped accumulator now takes caller-assigned dense `GroupIds`
(`{ids: ArrayRef, num_groups}`) directly, instead of requiring values to
be pre-sorted into complete list groups. Ids may repeat, arrive out of
order, or be absent from a batch.

## Changes

- Dispatch grouped kernels on both the values encoding and the group-id
  encoding, with wildcard fallbacks in each dimension.
- Carry run-encoded group ids as `PiecewiseSequence` runs, and register
  `Count`, `Sum`, and `SumV2` kernels for that encoding so already-grouped
  input reduces a run per group instead of reading an id per row.
- Cache materialized and validated group ids across clones, so several
  aggregates over the same ids validate once.
- Let each aggregate own its dense grouped state (`GroupedState`), with
  the generic one-partial-per-group implementation as the fallback.
- Store grouped count state as one `u64` per group and grouped sum state
  in monomorphic `u64`/`i64`/`f64`/decimal vectors, choosing the decimal
  width once from the sum dtype.
- Port `SumV2` onto the same dense sums, adding the per-group empty flag
  its SQL semantics need, so both sums share one accumulation kernel.
- Merge count and sum partial arrays without `execute_scalar` per row.
- Adapt between typed scatter for unclustered ids and per-run reduction
  for clustered ids.
- Use one stable counting-sort gather per batch in the universal
  fallback instead of one `take` per group.
- Keep `GroupedArray::dense_input` as the list-group adapter for
  `list_sum`, which restores list semantics (null, empty, and all-null
  lists sum to null) on top of the dense result.

## Rebase notes

`develop` reverted SQL null-on-empty semantics for `Sum` (#9324) and
re-landed them as a separate `SumV2` (#9590), so this branch follows
both contracts: `Sum` starts a group at zero and treats a null partial
as overflow, while `SumV2` keeps `{sum, is_overflow, is_empty}` and
finalizes an untouched group to null. `SumAggregateOpts` is gone, and
both aggregates take `NumericalAggregateOpts`.

`SumV2`'s list-shaped `PrimitiveGroupedSumV2EncodingKernel` is replaced
by a dense kernel that reuses `Sum`'s accumulation and then clears the
empty flag of every group that saw a valid value, so `sum_v2` keeps a
fast grouped path rather than falling back to per-group accumulators.

`list_sum` now sums with `SumV2` (#9621), so its empty and all-null list
masking is gone; it only has to restore the one case dense ids cannot
express, a null list.

The single-partial `flush` shortcut from #9597 no longer applies: dense
state produces one partial array per flush, so there is no chunk to
collapse, and its three tests went with it.

## Review comments

- Group ids are an `ArrayRef` so kernels can dispatch on their encoding
  (Constant, RLE, ...), and `GroupIds` is the "Group struct" the
  accumulator API talks in.
- The hand-rolled `for_each_valid_idx` helpers are gone: the kernels
  take the mask's bit buffer, handle the all-true and all-false cases
  directly, and let `BitBuffer::for_each_set_index` walk the rest
  instead of materializing the mask's index vector.
- The run-detection helpers shared by the count and sum kernels moved to
  `aggregate_fn::kernels` instead of being duplicated per aggregate.
- The accumulator tracks `num_groups` itself, so `flush_partials` and
  `finish` no longer take it; `ensure_groups` is exposed for callers
  that need trailing groups no batch mentioned.
- `merge_group` names its parameters `into_group` / `from_group`.
- The benchmark input builder is now `DenseGroupedInput::clustered`.

## Run-encoded group ids

An input that is already grouped - a list array adapted by
`GroupedArray::dense_input` - would otherwise pay a `u32` per element
just to say "these rows belong to that group". When the groups cover the
element array in order, the adapter now hands over
`GroupIds::from_runs`: the ids are `PiecewiseSequence` runs (each group
id repeated for its run length, via a zero multiplier), and `Count`,
`Sum`, and `SumV2` each register a kernel for that group-id encoding
which reduces one run at a time. `GroupIds::runs` caches the decoded
runs the same way `validated_ids` caches materialized ids, so several
aggregates over the same ids decode once.

Each run kernel falls back to per-row accumulation when the ids turn out
not to be runs, so registering for the encoding never loses the generic
path. Tests assert run ids and materialized ids agree for every aggregate
across empty runs, all-null groups, overflow, and NaN options.

## list_sum performance

`list_sum` is the caller that pays for the dense-ids API on input that is
already grouped. Best-of-three alternating runs of the two benchmark
binaries, develop (1fe8dda) vs this branch on the same machine:

| benchmark | develop | this branch |
|---|---:|---:|
| `fsl_sum_large` | 7.0 us | 10.9 us |
| `fsl_sum_medium` | 5.8 us | 8.5 us |
| `fsl_sum_small` | 4.8 us | 6.6 us |
| `list_sum_large` | 11.4 us | 15.6 us |
| `list_sum_medium` | 9.6 us | 12.7 us |
| `list_sum_small` | 8.5 us | 10.8 us |
| `list_sum_nullable_elements_large` | 21.8 us | 27.8 us |
| `list_sum_nullable_elements_medium` | 14.2 us | 19.0 us |
| `listview_sum_large` | 7.2 us | 11.4 us |
| `listview_sum_medium` | 5.8 us | 8.7 us |
| `listview_sum_small` | 5.0 us | 7.3 us |

Still 27-59% slower. The run kernels removed the per-element id work,
and what is left scales with groups rather than elements: these
benchmarks run 100-500 groups over 800-4000 elements, where the dense
pipeline's fixed costs - building the run ids, dense state, flushing it
to arrays, and the extra mask that restores a null list - are the whole
measurement. `SumV2`'s list kernel instead writes its three output
buffers in one pass over the ranges.

Whether `list_sum` should keep a list-shaped kernel for that case, or
accept the overhead in exchange for one grouped API, is a call for this
PR's reviewers. The dense path is the one that wins at engine scale: the
`aggregate_grouped` benchmarks, which run 65k rows, are what this API
exists for.

## Validation

- `cargo +nightly fmt --all`
- `cargo build -p vortex-array --all-targets`
- `cargo test -p vortex-array --lib` (3,515 passed, 1 ignored)
- `cargo test --doc -p vortex-array`
- `cargo bench -p vortex-array --bench list_sum`
- `cargo bench -p vortex-array --bench aggregate_grouped -- --test`
- `cargo clippy -p vortex-array --all-targets --all-features`
- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p vortex-array`

A workspace-wide build ran out of disk in this environment; the change
is confined to `vortex-array`, and no other crate references the grouped
accumulator API.

Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8VcUXWmDmNH2JjJY4aDQ8
@robert3005
robert3005 force-pushed the ngates/grouped-aggregate branch from 201d03b to 3c4d65f Compare August 27, 2026 18:11
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been marked as stale because it has been open for 14 days with no activity. Please comment or remove the stale label if you wish to keep it active, otherwise it will be closed in 7 days

@github-actions github-actions Bot added the stale This PR is stale and will be auto-closed soon label Sep 20, 2026

This branch has not been deployed

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

Labels

changelog/break A breaking API change stale This PR is stale and will be auto-closed soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants