Repository navigation
Rework AggregateVTable - #9816
Conversation
Merging this PR will degrade performance by 11%
|
| 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)
Footnotes
-
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. ↩
de73390 to
0a82483
Compare
798246e to
6ed6e20
Compare
ae7c362 to
f006525
Compare
f006525 to
77982e0
Compare
| .as_binary() | ||
| .value() | ||
| .ok_or_else(|| vortex_err!("non-null bloom partial has no bytes"))?; | ||
| partial.merge(bytes)?; |
There was a problem hiding this comment.
this is funny.
We should likely move the deser into here
There was a problem hiding this comment.
shall we remove this going forwards?
77982e0 to
9c407cf
Compare
|
I am doing partials benchmarks and will merge them first to see the exact impact first |
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>
…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
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
9c407cf to
7fa139e
Compare
`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
|
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 |
|
removing my disapproval 👍 |

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