Skip to content

feat: implement take for canonical sparse Union arrays - #9246

Open
connortsui20 wants to merge 7 commits into
developfrom
claude/union-take-impl-jsg9kj
Open

feat: implement take for canonical sparse Union arrays#9246
connortsui20 wants to merge 7 commits into
developfrom
claude/union-take-impl-jsg9kj

Conversation

@connortsui20

@connortsui20 connortsui20 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Rationale for this change

take on a UnionArray hit a todo!(), both in the parent-reduce path and in dictionary execution.

What changes are included in this PR?

Registers TakeReduce for Union, the naive way: take the type IDs, take every sparse child, reassemble. Sparse children are row-aligned with the union, so a gather has to visit all of them and take costs O(variants * indices). Fixing that needs the dense encoding, which is still open on the tracking issue.

The nullability split is the part worth reviewing. The type IDs child carries the union's validity, so it is gathered with the original indices and a null index becomes an outer union null. The children are gathered with the null indices filled with zero, which keeps each child's dtype exactly as the variant schema declares it. Struct splits its validity and its fields the same way.

Also adds UnionArray::constant, because take short-circuits an empty source into a constant null array before the union ever sees it. That unblocks two adjacent TODOs: canonicalizing a constant Union, and UncompressedSizeInBytes over one.

Notes

benches/take_union.rs lives in the vortex crate, which already depends on both vortex-array and vortex-sparse, and it compares two shapes of the same union of i64, utf8, and list<i64>. Rows are skewed 98% integers to 1% strings and 1% lists, the distribution the sparse layout exists for. dense_children materializes every child at the union's length. compressed_children keeps the dominant integer child canonical and stores each rare child as a SparseArray, which is what a compressor produces.

Compressed children turn out to be slower to take, not faster. 100k rows, 128 indices:

dense children compressed children
non-nullable indices 11.01 µs 36.61 µs
nullable indices 27.21 µs 46.32 µs

The cost is in Patches::take. With 1000 patches against 128 indices the ratio stays above PREFER_MAP_WHEN_PATCHES_OVER_INDICES_LESS_THAN, so every call takes the binary-search path, which is cache-miss bound at roughly 0.68 µs per index against 0.01 µs for a canonical child. That is a Patches cost rather than a union one. Union take multiplies it by the variant count.

This does not contradict the layout decision on #7882, which is about space. It does mean the take path gets no benefit from sparse children today, so it is worth a follow-up against Patches::take.

The two compressed_children cases run over the sub-millisecond budget the other microbenchmarks hold to. The gap only appears once a child's patch indices outgrow the cache: at 10k rows and 32 indices everything fits the budget and the gap collapses from 3.3x to 1.35x, which measures nothing. Landing them over budget is deliberate.

What APIs are changed? Are there any user-facing changes?

UnionArray::constant(scalar, len) is new. Three operations that used to panic now work: take on a union, canonicalizing a constant union, and uncompressed_size_in_bytes on a constant union.

@codspeed-hq

codspeed-hq Bot commented Aug 6, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 0.44%

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 1923 untouched benchmarks
🆕 4 new benchmarks
⏩ 51 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation chunked_varbinview_opt_canonical_into[(10, 1000)] 4.4 ms 4.9 ms -10.81%
Simulation decompress[u64, (1000, 16)] 73 µs 65.7 µs +11.13%
🆕 Simulation take_union_compressed_children N/A 462.6 µs N/A
🆕 Simulation take_union_compressed_children_nullable_indices N/A 567.8 µs N/A
🆕 Simulation take_union_dense_children N/A 222.1 µs N/A
🆕 Simulation take_union_dense_children_nullable_indices N/A 339.3 µs N/A

Tip

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


Comparing claude/union-take-impl-jsg9kj (dfa124c) with develop (a1057db)

Open in CodSpeed

Footnotes

  1. 51 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.

connortsui20 and others added 2 commits August 6, 2026 19:46
Registers TakeReduce for Union and wires the union branch of the
dictionary execution path, removing both TODOs.

Take gathers the type IDs with the original indices so a null index
becomes an outer union null, and gathers every sparse child with the
null indices filled in so each child keeps its declared variant dtype.
Every child has to be visited because sparse children are row-aligned
with the union, so take costs O(variants * indices).

Also adds UnionArray::constant, which take needs for an empty source and
which lets constant Union arrays canonicalize into a sparse union whose
unselected children hold their variant's default value.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
Corrects the union module doc, which claimed mask rewrites every child.
Mask only rewrites the type IDs, because outer nulls live there.

Removes the UncompressedSizeInBytes todo for constant Union arrays. Its
comment named constant Union canonicalization as the blocker, and that
is now in place, so the arm joins the group that canonicalizes and
recurses.

Notes at the fill-null site that the lazy node is executed once per
child, and why TakeReduce cannot materialize it.

Widens the constant Union test to cover the non-nullable and
nullable-present cases alongside the outer null, and asserts the
placeholder value rather than only its dtype.

Shrinks the take benchmark to 256 indices over 2, 4, and 8 variants so
the widest case stays inside the sub-millisecond budget for
microbenchmarks.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
@connortsui20
connortsui20 force-pushed the claude/union-take-impl-jsg9kj branch from c1eb897 to fc4468d Compare August 6, 2026 19:47
@connortsui20 connortsui20 added the changelog/feature A new feature label Aug 6, 2026 — with Claude
Cuts the comments down to what the code does not already say. The take
doc no longer restates the impl header, the fill-null note drops the
contract it duplicated from that doc, and the constant-union placeholder
comment is gone because the doc above it covers the same ground.

Collapses the repeated per-row scalar assertions in the take tests
behind an `assert_rows` helper, and replaces the `rstest` cases on the
constant-union test with a table in the body so the scalars are built
with `?` instead of `vortex_expect` inside an attribute.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
@connortsui20
connortsui20 force-pushed the claude/union-take-impl-jsg9kj branch from 7f7c392 to 40326d0 Compare August 6, 2026 20:22
connortsui20 and others added 3 commits August 6, 2026 20:33
Pulls the shared bencher plumbing out of the two take benchmarks into
`bench_take`, so each one only builds its indices.

Corrects the uncompressed-size assertion comment, which listed the
`i32` rows and the `bool` placeholder bits but not the type IDs, and
reorders the expression to match. Restates the fill-null cost note in
terms of the indices rather than "per element", which read as if it
were per output row.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
The pool was `variant_count` identical `i64` children, which understated
take because every child gathered at the same cheap rate. It now runs
`i64`, `list<i32>`, and `utf8`, so widening the union adds a child that
gathers differently.

The mix costs roughly 2.5x the primitive-only version per child, so the
sweep drops from 2, 4, and 8 variants to 1, 2, and 3, and the index
count drops to 128. The single-variant case is the baseline that
isolates the type IDs gather from the child gathers.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
Replaces the variant-count sweep over a synthetic pool with one
realistic schema. The sweep measured a contrived axis, and its children
were weak: the strings were all under the 12-byte `VarBinView` inline
limit, so the gather never touched a data buffer, and the lists were
built around a `MAX_LIST_LEN` constant that read as a fixed size when
`FixedSizeList` is the type for that.

The union is now `i64`, `utf8`, and `list<i64>`. The strings mix inline
and out-of-line lengths, and the list lengths vary so the gather has to
rebuild offsets and sizes. Two benchmarks remain, one per index
nullability, down from six.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
@connortsui20
connortsui20 marked this pull request as ready for review August 6, 2026 21:14
@connortsui20

connortsui20 commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

@gatesn the benchmark here is a bit naive, I will add compressing the sparse variants and seeing how that affects the benchmarks.

I still that this is something we would want to improve, but I guess we need to see who runs into perf bottlenecks first

Edit: Yeah as I expected, because we take against patches / sparse, we have to materialize a bunch of junk. I think this can be optimized though? @robert3005 do you have any thoughts?

Moves the union take benchmark to the `vortex` crate, which already
depends on both `vortex-array` and `vortex-sparse`, and adds a
compressed shape alongside the dense one.

Rows are skewed 98% integers to 1% strings and 1% lists, which is the
distribution the sparse layout exists for. The `dense_children` cases
materialize every child at the union's length. The `compressed_children`
cases keep the dominant integer child canonical and store each rare
child as a `SparseArray`, which is what a compressor produces.

Compressed children turn out to be 3.3x slower to take, not faster. The
cost is cache-missing binary searches in `Patches::take`: with 1000
patches against 128 indices the ratio stays above
`PREFER_MAP_WHEN_PATCHES_OVER_INDICES_LESS_THAN`, so every call searches.
That is a `Patches` cost rather than a union one, and union take
multiplies it by the variant count.

The compressed cases run over the sub-millisecond budget the other
microbenchmarks hold to. The gap only appears once a child's patch
indices outgrow the cache, so a size inside the budget measures nothing.

Signed-off-by: Connor Tsui <connor@spiraldb.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VBqADUGaR81PoijKkvoUs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant