Skip to content

Support decimal values in Widen - #10946

Draft
connortsui20 wants to merge 2 commits into
ct/widen-serialize-canonicalfrom
ct/decimal-widen
Draft

connortsui20 wants to merge 2 commits into
ct/widen-serialize-canonicalfrom
ct/decimal-widen

Conversation

@connortsui20

@connortsui20 connortsui20 commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Tracking Issue: #10701

Stacked on #11002. #11003 builds on it, and #10994 stacks on it too. No release gate.

Summary

#10701 gives each decimal precision one canonical width, and that width can be much wider than the values need. A Decimal(38, 0) array of small counts still stores 16 bytes per value. A decimal Widen keeps such values in a lower-precision child, so compute can run on the narrow buffer.

This PR used to hold the whole decimal Widen change. It is now split into #11002, this PR, #11003, #11004, #11005 and #11006. Before #11006, no production code builds a decimal Widen (only tests and benches do), so no layer below it can change production behavior or performance.

Changes

Widen accepts a decimal child with the logical scale and nullability and a strictly narrower canonical width. The child precision must hold every valid value. Execution asks the scheduler for a Decimal child with require_child! and copies its values into the canonical width. encode_decimal picks the smallest precision that holds the valid values.

No kernel serves a decimal child yet, and the integer kernels decline one, so compare, between, fill null, cast and the aggregates execute the canonical array. That is slower, but correct. Tests cover these fallbacks, the shared conformance suites, and writing decimal Widens through serialization, IPC and the flat writer. The widen_decimal bench times Widen inputs next to canonical baselines, so CodSpeed on #11003, #11004 and #11006 measures each kernel against the fallback.

Local benchmark results

cargo bench -p vortex-array --bench widen_decimal, Apple M4, medians. The numbers were measured at the top of this stack before the split, so they cover the whole stack, not this layer alone. The baseline is the canonical input, and the candidate is the same values as a Widen (child precision 5 under precision 10, 9 under 18, and 18 under 38). CodSpeed on this PR measures the layer itself.

bench p10 canonical p10 Widen p18 canonical p18 Widen p38 canonical p38 Widen
filter 22.16 µs 5.083 µs 13.66 µs 4.916 µs 10.58 µs 24.74 µs
execute 10.59 ns 2.832 µs 10.26 ns 2.926 µs 10.34 ns 9.895 µs

execute on a Widen is the widening copy, which the canonical input does not need. The canonical filter medians at p10 and p18 were bimodal across runs, and filter at p38 is slower on the Widen.

API Changes

WidenArray::try_new accepts decimal values. Adds WidenArray::encode_decimal.

@connortsui20 connortsui20 added the changelog/feature A new feature label Oct 28, 2026
@connortsui20 connortsui20 mentioned this pull request Oct 28, 2026
11 of 32 tasks
@codspeed

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 22.43%

2 warnings

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 4 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 2 improved benchmarks
✅ 2316 untouched benchmarks
🆕 42 new benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ CPU Simulation missing_stats[16] 85.6 µs 67.3 µs +27.12%
⚡ CPU Simulation filter_powerlaw_by_random[10000] 492.8 ns 418 ns +17.92%
🆕 CPU Simulation cast_widen[p10_childp5_canonical] N/A 1 ms N/A
🆕 CPU Simulation cast_widen[p10_childp5_widen] N/A 1.2 ms N/A
🆕 CPU Simulation cast_widen[p18_childp9_canonical] N/A 1 ms N/A
🆕 CPU Simulation cast_widen[p18_childp9_widen] N/A 1.2 ms N/A
🆕 CPU Simulation cast_widen[p38_childp18_canonical] N/A 2.4 ms N/A
🆕 CPU Simulation cast_widen[p38_childp18_widen] N/A 2.9 ms N/A
🆕 CPU Simulation compare_constant_out_of_range[p10_childp5_canonical] N/A 306.1 µs N/A
🆕 CPU Simulation compare_constant_out_of_range[p10_childp5_widen] N/A 503.6 µs N/A
🆕 CPU Simulation compare_constant_out_of_range[p18_childp9_canonical] N/A 294.9 µs N/A
🆕 CPU Simulation compare_constant_out_of_range[p18_childp9_widen] N/A 503.6 µs N/A
🆕 CPU Simulation compare_constant_out_of_range[p38_childp18_canonical] N/A 686 µs N/A
🆕 CPU Simulation compare_constant_out_of_range[p38_childp18_widen] N/A 1.2 ms N/A
🆕 CPU Simulation compare_constant[p10_childp5_canonical] N/A 306.2 µs N/A
🆕 CPU Simulation compare_constant[p10_childp5_widen] N/A 503.6 µs N/A
🆕 CPU Simulation compare_constant[p18_childp9_canonical] N/A 306.2 µs N/A
🆕 CPU Simulation compare_constant[p18_childp9_widen] N/A 503.5 µs N/A
🆕 CPU Simulation compare_constant[p38_childp18_canonical] N/A 686 µs N/A
🆕 CPU Simulation compare_constant[p38_childp18_widen] N/A 1.2 ms N/A
... ... ... ... ... ...

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

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ct/decimal-widen (fdf9ac2) with ct/widen-serialize-canonical (de8a7a6)

Open in CodSpeed

@connortsui20
connortsui20 force-pushed the ct/decimal-widen branch 4 times, most recently from 31f7451 to 9577faf Compare October 28, 2026 20:46
Base automatically changed from ct/decimal-interleave to develop October 29, 2026 12:05
connortsui20 added a commit that referenced this pull request Oct 29, 2026
## Summary

- Tracking Issue: #10701
- CD4 (#10946) builds on this PR.

`Interleave` accepts decimal values but panics when it executes them,
because only the bool and primitive kernels exist. Decimal `Widen` (CD4)
falls back to canonical decimals for interleave, so it needs a decimal
kernel first.

## Changes

Most of the diff is a refactor of the primitive kernel: its gather moves
into a `fixed_width` module next to the kernels, so decimals reuse it
instead of getting a second copy. Canonical decimals are fixed-width
buffers just like primitives, so one set of selector bounds checks and
one tuned gather loop can serve both. The gather stays in `interleave`
and not in `arrays/fixed_width` because it owns the selector contract of
`Interleave` and has no other caller.

Decimal sources stored at a non-canonical width are copied to the
canonical width before the gather. That copy goes away once CD8a stores
every decimal at its canonical width.

## API Changes

`Interleave` executes decimal values and returns them at the canonical
width of their dtype, where it used to panic.

---------

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
WD1 to WD4, CD3, CD3b and the decimal Interleave kernel merged, and
CD4, CD5 and WD6 are open drafts. The plan now lists CD3b (#10939) and
the Interleave kernel (CD4p, #10940) as dependencies of CD4, and CD3b
as a dependency of CD6 and CD7. CD7 merges right after CD5 (#10905).

D26 moves to CD6, because CD4 (#10946) adds no serialization lowering:
the default writer executes a Widen, and reads of old files are the
first ordinary source of one. The CD6 and WR1 briefs now carry that
choice. D35 no longer makes WD6 wait for WD5. #10908 joins the
follow-up issues, and the plan suggests settling the mechanism of

Signed-off-by: Connor Tsui <[email protected]>
#10748 before WD5.
connortsui20 added a commit that referenced this pull request Oct 29, 2026
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits. LLVM vectorized some width pairs, such
as `i32` to `i64`, but left others scalar, such as `i64` to `i128`.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead code
when the target is at least as wide as the source, so the loop has one
exit and no panic edge. `widened_buffer` now calls `widen_values`.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

A test checks every widening pair against a per-value cast.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits. LLVM vectorized some width pairs, such
as `i32` to `i64`, but left others scalar, such as `i64` to `i128`.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead code
when the target is at least as wide as the source, so the loop has one
exit and no panic edge. `widened_buffer` now calls `widen_values`.
A runtime assert checks that the target is at least as wide as the
source. Its two sides are constants, so it folds away. A `const`
assert does not compile, because the disabled `if const` branches of
the callers still name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

A test checks every widening pair against a per-value cast, and
another checks that a narrower target panics.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
LLVM left some widening pairs scalar, such as `i64` to `i128`.
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead when
the target is at least as wide as the source, so the loop has one exit
and no panic edge. `widened_buffer` now calls `widen_values`. A runtime
assert guards the widths and folds away. A `const` assert does not
compile, because the disabled `if const` branches of the callers still
name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
LLVM left some widening pairs scalar, such as `i64` to `i128`.
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead when
the target is at least as wide as the source, so the loop has one exit
and no panic edge. `widened_buffer` now calls `widen_values`. A runtime
assert guards the widths and folds away. A `const` assert does not
compile, because the disabled `if const` branches of the callers still
name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
Tracking Issue: #10701

Bottom of stack #10991. #10989 builds on this PR.

## Summary

Every path that needs the canonical decimal array widens its values with
`copy_to_canonical_width`: the Arrow and DuckDB imports, interleave, and
(after #10946) the execution of a decimal `Widen`. No benchmark covered
that widening path. This lands before #10989 so that CodSpeed measures
the loop change there against it.

## Changes

Adds a `copy_to_canonical_width` bench that copies 16,384 values for
`i32` to `i64`, `i64` to `i128` and `i128` to `i256`, with a same-width
`i64` copy as the control. Widening never reads the validity, so the
cases have no nulls.

How the compiler vectorizes the widening loop depends on the target
features, so every case carries `#[cpu_features]` and runs on the AVX2,
AVX-512 and NEON walltime legs instead of in simulation.

## API Changes

None.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
LLVM left some widening pairs scalar, such as `i64` to `i128`.
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead when
the target is at least as wide as the source, so the loop has one exit
and no panic edge. `widened_buffer` now calls `widen_values`. A runtime
assert guards the widths and folds away. A `const` assert does not
compile, because the disabled `if const` branches of the callers still
name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

Signed-off-by: Connor Tsui <[email protected]>
Time compare, sum, min-max, filter, execute and cast on canonical
decimal arrays before Widen accepts decimal children, so that the
later commits have a baseline to compare their Widen numbers against.

Signed-off-by: Connor Tsui <[email protected]>
Decimal values often need far fewer digits than their precision
allows, yet the canonical width of that precision makes every compute
step read and write the wider type. Widen already keeps integers at a
narrower type for this reason.

Accept a decimal child with the logical scale and nullability and a
strictly narrower canonical width. Execution asks the scheduler for a
Decimal child with `require_child!` and copies its values into the
logical canonical width, and `encode_decimal` picks the smallest
precision that holds the valid values.

No kernel serves a decimal child yet. The integer kernels decline one,
so compare, between, fill null, cast and the aggregates run on the
canonical array. Tests cover the fallbacks of arithmetic, zip, chunked
execution and interleave, run the shared conformance suites, and write
decimal Widens through serialization, IPC and the flat writer. The
`widen_decimal` bench now also times Widen inputs.

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Oct 29, 2026
LLVM left some widening pairs scalar, such as `i64` to `i128`.
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead when
the target is at least as wide as the source, so the loop has one exit
and no panic edge. `widened_buffer` now calls `widen_values`. A runtime
assert guards the widths and folds away. A `const` assert does not
compile, because the disabled `if const` branches of the callers still
name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

Signed-off-by: Connor Tsui <[email protected]>
@connortsui20
connortsui20 changed the base branch from develop to ct/widen-serialize-canonical October 29, 2026 21:33
@connortsui20
connortsui20 added this pull request to stack #11007 October 29, 2026 21:33
@connortsui20 connortsui20 changed the title Accept decimal children in Widen Support decimal values in Widen Oct 29, 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/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant