Skip to content

Rework AggregateVTable - #9816

Merged
robert3005 merged 13 commits into
developfrom
claude/aggregate-fn-vtable-refactor-ny5l4s
Sep 16, 2026
Merged

robert3005 merged 13 commits into
developfrom
claude/aggregate-fn-vtable-refactor-ny5l4s

Conversation

@robert3005

Copy link
Copy Markdown
Contributor

Most methods now also take options thus partial state doesn't need to persist options necessary on each invocation. We separate out parse/merge operation on partial state such that we can perform operations in Partial type and also can easily validate serialisation of Partial into scalar

@robert3005 robert3005 added the changelog/break A breaking API change label Sep 9, 2026
@codspeed

codspeed Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 11%

⚠️ 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

⚡ 2 improved benchmarks
❌ 5 regressed benchmarks
✅ 2198 untouched benchmarks
⏩ 218 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Simulation decode_primitives[f32, (1000, 512)] 42.4 µs 64.9 µs -34.77%
❌ Simulation sum_v2 65.6 µs 80 µs -18.08%
❌ Simulation bloom[1024] 653.9 µs 781.8 µs -16.35%
❌ WallTime filtered_sink_i64_neon[NineNullsInTen] 17.4 µs 20.4 µs -14.58%
❌ WallTime dict_canonicalize_gt_u8_avx512[16000000] 6.8 ms 7.5 ms -10.14%
⚡ WallTime dict_canonicalize_gt_u8_neon[1000000] 560.7 µs 489.5 µs +14.54%
⚡ WallTime dict_canonicalize_gt_u8_neon[16000000] 9.3 ms 8.3 ms +12.59%

Tip

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


Comparing claude/aggregate-fn-vtable-refactor-ny5l4s (cc29c1e) with develop (669428f)

Open in CodSpeed

Footnotes

  1. 218 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. ↩

@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch from de73390 to 0a82483 Compare September 9, 2026 17:12
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/accumulator.rs Outdated
Comment thread vortex-array/src/aggregate_fn/accumulator.rs
@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch 2 times, most recently from 798246e to 6ed6e20 Compare September 11, 2026 16:36
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs Outdated
@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch 2 times, most recently from ae7c362 to f006525 Compare September 15, 2026 17:46
Comment thread encodings/runend/src/compute/sum/tests.rs Outdated
Comment thread vortex-array/src/aggregate_fn/vtable.rs
@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch from f006525 to 77982e0 Compare September 16, 2026 15:40
.as_binary()
.value()
.ok_or_else(|| vortex_err!("non-null bloom partial has no bytes"))?;
partial.merge(bytes)?;

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.

this is funny.

We should likely move the deser into here

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.

shall we remove this going forwards?

@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch from 77982e0 to 9c407cf Compare September 16, 2026 16:26
@robert3005

Copy link
Copy Markdown
Contributor Author

I am doing partials benchmarks and will merge them first to see the exact impact first

connortsui20 pushed a commit that referenced this pull request Sep 16, 2026
This is missing in our benchmark coverage and is useful to check things
like #9816

---------

Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
robert3005 and others added 2 commits September 16, 2026 20:59
…duce_partials

The vtable's `empty_partial`, `combine_partials` (typed partial plus untyped
scalar) and `reset` are replaced by two primitives so each aggregate either
parses scalars or operates on typed state:

- `partial_from_scalar(options, input_dtype, scalar) -> Partial` parses a
  partial scalar (kernel results, cached statistics, other accumulators'
  `to_scalar`) into the typed state, and is its inverse.
- `reduce_partials(options, input_dtype, impl IntoIterator<Item = Partial>)`
  reduces owned partials in iteration order; the empty sequence is the
  identity (the state of a group with no values).

Accumulator changes:

- `Accumulator.partial` is an `Option`, with `None` as the cheap empty state,
  so folds and empty accumulators never construct an identity partial.
- `DynAccumulator::combine_partials` is removed. `merge_from` downcasts the
  other accumulator (`DynAccumulator::downcast_mut`), checks that the
  aggregate, options and input dtype match, and folds its typed partial
  directly, so merging never round-trips through scalars.
- `Combined` holds typed child accumulators and merges children with
  `merge_from`; its `reduce_partials` seeds from the first pair instead of
  building fresh children per fold.

Partial states:

- Min, Max, MinMax, BoundedMin, BoundedMax, IsSorted, IsConstant, Sum and
  SumV2 build their identity from one `empty` constructor, and reduce bodies
  move values instead of cloning them.
- IsSorted and IsConstant no longer serialize a false verdict without a
  first value as the null (empty) struct, which previously lost the verdict
  when merged into a materialized empty state or persisted as a zone stat.
  IsSorted's reduce honors an unsorted partial before the emptiness check.
- BloomPartial gains a typed `union`, so reducing bloom partials ORs blocks
  without serializing.

`can_satisfy` documents that satisfaction is a claim about stored state: a
partial for the requested aggregate must be creatable from this aggregate's
partial. DuckDB's `finalize_scan` merges thread-local accumulators with
`merge_from`.

Tests construct partials directly where parsing is not the subject, and
cover merging into a materialized empty state, boundary-less false
verdicts, type-erased Combined merges, and SumV2 identity/overflow merges.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Every AggregateFnVTable execution method now receives the bound options
and the resolved input/partial/result dtypes, so partial states only
hold accumulated values instead of copies of the options and dtypes.

- Add `AggregateDTypes<'a>` (borrowed) and `OwnedAggregateDTypes`,
  resolved once by `Accumulator`/`GroupedAccumulator` and lent to every
  vtable call.
- Split reduction into typed primitives: `empty_partial` (the identity)
  and binary `merge_partials`; `reduce_partials` becomes a provided fold.
- Add `DynAccumulator::combine_partial_scalar`, which parses a partial
  scalar and merges it through the typed vtable in one monomorphized
  call; `Accumulator` uses the same path for kernel results and cached
  statistics.
- Strip options/dtype fields from every partial (Sum, SumV2, Count,
  Min/Max/MinMax, BoundedMin/Max, IsSorted, IsConstant, First/Last,
  BloomPartial's hash_fn, ...). `Count`'s partial is now a plain `u64`.
- `BinaryCombined::return_dtype`/`finalize`/`finalize_scalar` take the
  combined options and dtypes; `Mean` derives its target dtype from them.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
robert3005 and others added 8 commits September 16, 2026 20:59
Renames the resolved-dtype pair so the owned type carries the plain name
and the borrowed view is suffixed:

- `OwnedAggregateDTypes` -> `AggregateDTypes`
- `AggregateDTypes<'a>`  -> `AggregateDTypesRef<'a>`

Its fields and accessors keep the names they had on the accumulators
they were lifted from: `dtype`, `return_dtype`, and `partial_dtype`.

Also removes `AggregateFnVTable::reduce_partials`. It was a provided
fold over `empty_partial` and `merge_partials` with no non-test callers,
so the tests now merge directly, which exercises the binary operation
rather than the fold wrapper. Merging with the empty partial is the
identity, so a two-element reduction is exactly one `merge_partials`.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
…eDTypes fields

`DynAccumulator::combine_partial_scalar` goes back to the `combine_partials`
name it had before this branch. The sibling `merge_from` already distinguishes
combining another accumulator from combining one partial scalar, so the suffix
earned nothing, and keeping the original name spares every caller outside this
crate a rename.

`AggregateDTypes` now exposes `dtype`, `return_dtype` and `partial_dtype`
as public fields, matching `AggregateDTypesRef`, and drops the three
accessors that only returned them. `try_new` and `borrow` stay, since
they resolve the dtypes through the vtable and lend them out rather than
just reading a field.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Tests that needed an empty partial were spelling out the identity state
by hand, restating in the test what each vtable already defines. They
now call `empty_partial`, so a change to an aggregate's identity state
cannot leave a test asserting against a stale hand-written one.

Covers First, Last, BoundedMin, BoundedMax, GeometryAabb and BloomFilter.
First and Last no longer touch their partial structs at all: the
non-empty cases go through `partial_from_scalar`, which for both is
exactly a wrapped non-null scalar.

`Accumulator::empty_partial` becomes `pub(crate)`, matching the existing
visibility of `fold_partial`, so the bounded min/max tests can fold an
empty partial without rebuilding the dtypes.

Left alone: the is_sorted and is_constant partials, whose tests assert a
non-empty verdict is distinguishable from the empty state and so must
build it directly; the non-identity partials in sum, min_max and the
decimal sum tests; and the BloomPartial unit tests, which exercise the
partial type itself rather than the aggregate.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
`From<Vec<[u32; 8]>>` existed only so one test could hand-build a
saturated filter, which also let that partial disagree with its own
options: it carried four blocks while the options said 256.

The test now builds the filter through `partial_from_scalar` from an
all-ones buffer sized to the options' block count, so the partial is
consistent with the options it is checked against and nothing outside
the module can bypass `BloomPartial`'s invariants.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Drops comments that restate the line below them, narrate tests, or pad a
doc with detail the signature already carries, keeping the ones that
record a contract or a non-obvious decision.

Two doc comments claimed that parsing and merging "inline into a single
monomorphized call". That was never measured, so it is gone rather than
left as an unbacked performance claim.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Drops the trait-level paragraph on options and dtype lifetime, the
`can_satisfy` paragraph on what satisfaction claims about stored state,
and the `partial_from_scalar` note on where combining belongs. Trims the
`empty_partial` identity note to one line.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Every `AggregateFnVTable` execution method took an options reference and
an `AggregateDTypesRef` side by side, and every implementor and call site
threaded both. They are now one `AggregateArgs<'a, O>` carrying the
options alongside the input, return and partial dtypes.

`AggregateDTypesRef` is gone; `AggregateDTypes::borrow` becomes
`AggregateDTypes::args(&options)`, which binds the options an accumulator
already stores to the dtypes it already resolved.

`AggregateArgs` implements `Clone` and `Copy` by hand: the derives would
bound them on `O`, which the borrowed fields do not need.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
`partial_from_scalar` built an empty `BloomPartial` and OR-ed the scalar's
bytes into it, so parsing a filter went through a merge. `BloomPartial`
already has a `deserialize`, and `scalar_fn` already parses filters with it,
so call it here too.

`merge` existed only for that call and duplicated `deserialize`'s block
decoding, so it goes. The block-count check it performed moves to
`partial_from_scalar`: `deserialize` validates the byte length but knows
nothing of the options, which is the same guard, and the same comment,
`scalar_fn` carries.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
@robert3005
robert3005 force-pushed the claude/aggregate-fn-vtable-refactor-ny5l4s branch from 9c407cf to 7fa139e Compare September 16, 2026 21:08
@robert3005

Copy link
Copy Markdown
Contributor Author
Screenshot 2026-09-16 at 17 17 53

hmmmm

robert3005 and others added 3 commits September 16, 2026 21:29
`deserialize` checked that splitting a block left no remainder, but
`BLOCK_SIZE` is `SPLITS_PER_BLOCK * BYTES_PER_SPLIT`, so a block is a whole
number of splits by construction and the check could never fire.

It was not free: a fallible closure forces `collect::<VortexResult<Vec<_>>>`,
which collects through an adapter whose `size_hint` lower bound is zero, so
the vector reserved nothing and grew by reallocating. At the default 256
blocks that is 8KiB of filter rebuilt a few times per parse, and the zoned
read path parses one filter per zone.

Decoding a block cannot fail, so the map returns the block itself and the
collect takes the exact count from the iterator.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Merging a stored bloom filter into an accumulator went through
`partial_from_scalar`, which decoded the scalar's bytes into a fresh
`Vec<[u32; 8]>`, and then `merge_partials`, which read that vector back to
OR it in: an allocation and two passes per zone, where develop's fused
`combine_partials` made one pass with no allocation.

The bytes a zone stores are already the filter's splits as little-endian
`u32`s, which is how Vortex lays out every primitive buffer, so nothing
needs decoding. `BloomPartial` now keeps its splits in a `Buffer<u32>`,
`Frozen` when it is the scalar's own buffer and only ever read, or `Thawed`
into a `BufferMut` for the partial an accumulator writes to. That makes
`partial_from_scalar` O(1): it reinterprets the scalar's bytes when they are
aligned for `u32` and copies them once otherwise. `merge_partials` ORs
straight out of the scalar's buffer, `to_scalar` hands a frozen partial's
buffer back without a copy, and `BloomContains` slices the view's buffer
instead of copying it.

Insert and lookup keep their shape, indexing the same `[u32; 8]` blocks
through `as_chunks`; the accumulating partial thaws on its first write and
stays thawed, so an insert is still a slice write.

Measured with `aggregate_partial_merge`, medians on one machine within one
window, develop / before / after: bloom[256] 11.1 / 24.1 / 4.8 us,
bloom[1024] 48.0 / 108.8 / 19.2 us. The `bloom_filter` insert and contains
benchmarks are unchanged within noise. Two tests pin the behaviour: a
parsed partial shares an aligned scalar's pointer, and unaligned bytes are
copied rather than rejected.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
Sharing a stored filter's bytes moved the accumulator's blocks into a
`Buffer<u32>` behind an enum, and the insert path paid for it on every value:
`len` matched the enum, `blocks_mut` branched and matched again, and
`as_chunks_mut` divided the length and re-split the slice before indexing.
CodSpeed put `insert` 15-28% over develop.

Blocks are now stored as `[u32; 8]`, so indexing yields a block directly and
the block count needs no division, and an insert fetches the blocks once, so
it checks whether the partial is thawed exactly once. `add_hash`, `find_hash`
and `make_mask` are `#[inline]`: `insert` already was, so callers in other
crates inlined it and then paid a call into `add_hash` for every value, on
develop too. `make_mask`, `block_index` and `lower_hash_bits` never used
`self` and are associated functions, so the borrow of the blocks can span
the index computation.

`union` and `is_saturated` flatten the blocks back to one `u32` slice: the
flat OR compiles to wide loads and stores, where a loop per block spends
about as many instructions on the loop as on the OR.

Instructions inside the benchmark frame under callgrind, develop / previous
commit / now: insert 828,558 / 935,141 / 763,108; contains_present 926,400 /
1,008,320 / 778,950; the bloom merge group 349,006 / 213,637 / 213,181.

`deserialize` is `#[inline]` as well; `partial_from_scalar` and
`BloomContains` both parse stored filters through it.

Signed-off-by: Robert Kruszewski <github@robertk.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAC54whRD3iHLBZfM4TCvd
@robert3005

Copy link
Copy Markdown
Contributor Author

Ok, there's a marginal slowdown in codspeed but actual machine benchmarks show an improvement but mostly because I optimised partial creation. @joseph-isaacs I will merge this but if you think we should go back on the BloomPartial changes let me know

@robert3005

Copy link
Copy Markdown
Contributor Author

removing my disapproval 👍

@robert3005
robert3005 merged commit ff2e112 into develop Sep 16, 2026
83 of 84 checks passed
@robert3005
robert3005 deleted the claude/aggregate-fn-vtable-refactor-ny5l4s branch September 16, 2026 23:42
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants