feat: implement take for canonical sparse Union arrays - #9246
feat: implement take for canonical sparse Union arrays#9246connortsui20 wants to merge 7 commits into
Conversation
Merging this PR will degrade performance by 0.44%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
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
c1eb897 to
fc4468d
Compare
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
7f7c392 to
40326d0
Compare
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
|
@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
Rationale for this change
DType::Union#7882takeon aUnionArrayhit atodo!(), both in the parent-reduce path and in dictionary execution.What changes are included in this PR?
Registers
TakeReduceforUnion, 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 costsO(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
indicesand 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.Structsplits its validity and its fields the same way.Also adds
UnionArray::constant, becausetakeshort-circuits an empty source into a constant null array before the union ever sees it. That unblocks two adjacent TODOs: canonicalizing a constantUnion, andUncompressedSizeInBytesover one.Notes
benches/take_union.rslives in thevortexcrate, which already depends on bothvortex-arrayandvortex-sparse, and it compares two shapes of the same union ofi64,utf8, andlist<i64>. Rows are skewed 98% integers to 1% strings and 1% lists, the distribution the sparse layout exists for.dense_childrenmaterializes every child at the union's length.compressed_childrenkeeps the dominant integer child canonical and stores each rare child as aSparseArray, which is what a compressor produces.Compressed children turn out to be slower to take, not faster. 100k rows, 128 indices:
The cost is in
Patches::take. With 1000 patches against 128 indices the ratio stays abovePREFER_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 aPatchescost 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_childrencases 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:takeon a union, canonicalizing a constant union, anduncompressed_size_in_byteson a constant union.