Skip to content

Decode DecimalByteParts into the canonical array or a decimal Widen - #10905

Draft
connortsui20 wants to merge 4 commits into
ct/decimal-widen-storedfrom
ct/dbp-canonical-decode
Draft

connortsui20 wants to merge 4 commits into
ct/decimal-widen-storedfrom
ct/dbp-canonical-decode

Conversation

@connortsui20

@connortsui20 connortsui20 commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Tracking Issue: #10701

Stacked on #11005, the layer of the decimal Widen stack that adds from_stored_decimal. It does not need #11006. #10992 (CD7) is stacked on this PR.

Summary

CD8a will require every DecimalArray to be at the canonical width of its dtype. DecimalByteParts decoding breaks that today, because it reuses a narrow MSP as a DecimalArray at the MSP width. Decoding now returns either the canonical array or a decimal Widen over a narrower child.

The first version of this PR widened every narrow MSP into the canonical width, and CodSpeed showed decode up to 11x slower. Every default-written decimal column whose values are small for its precision has a narrow MSP, so this is the common read path. A Widen over the narrow MSP reuses its buffer and keeps compute narrow, and only the paths that need the canonical array (such as exports) pay the copy.

Merge gates:

  • Do not merge before 0.91.0 is published with the CD2 compatibility fixtures. Then run uv run vortex-test/compat-gen/scripts/compat.py check --mode last on this branch.
  • Merge Build DecimalByteParts from the canonical decimal array #10992 (CD7) right after this PR, to replace the old scheme's storage-based splitting.
  • The CUDA part was not built locally and needs a CUDA reviewer.

Changes

assemble_decimal passes the assembled values to WidenArray::from_stored_decimal from #11005, which picks the result. For a lone MSP that is not at the canonical width, the range of the valid values comes from min_max on the MSP, which reads exact cached statistics or makes one vectorized pass (the decimal valid_range scan is not vectorized). The range is passed as exact, so the child records it as its extrema and a later min_max on the result needs no scan.

Lower parts must now be u64, and the constructor bounds their count by the precision. The parts then never assemble wider than the canonical width, so only a lone MSP can be wider. A wider MSP gets a checked narrowing copy.

The decode and filter benches and the encoding-tree decompress bench now stop at a Widen, which matches what they measure on develop. A new canonicalize bench executes to the canonical DecimalArray, so CodSpeed still tracks the widening that exports pay.

CUDA has no Widen kernel, so the CUDA decoder still returns the canonical width. It widens a narrow MSP on the device with the decimal_cast kernels, which gain an i16 output. It rejects an MSP wider than the canonical width, because narrowing on the device needs an overflow flag that synchronizes with the host. The CUDA Arrow export already rejects such storage, and the two now share one crate-private widening entry point.

Accepted costs:

  • A narrow MSP without exact statistics, such as a FoR MSP or a freshly built array, pays one integer min_max pass per decode. The nullable primitive min_max is about 5x slower than the non-nullable one, so it dominates a nullable decode. Follow-up: speed up the nullable primitive min_max.
  • The child trusts exact MSP statistics from the file, as reads already trust the precision. Stale statistics would give wrong compare shortcuts.
  • Sum without skip_nans, and SumV2, still execute the Widen.

Local benches against the decimal Widen stack before it was split: decoding a narrow MSP now costs about 1 to 2 µs for 8,192 non-nullable rows and about 5 µs for nullable ones, all of it the min_max pass. The baseline decode did no work, because it reused the MSP in the form that CD8a forbids. min_max on the result gets faster, because that pass leaves exact extrema on the child.

Local benchmark results (Apple M4, medians)

cargo bench -p vortex-decimal-byte-parts --bench dbp_compute, 8,192 rows. Before is the tip of #10946 before it was split into #11002 to #11006, which decodes DecimalByteParts as develop does. After is this branch. The results isolate this PR. msp_i64_p18_canonical is the control: its MSP is already at the canonical width, so this PR does not change its decode.

bench case before after
decode gap_msp_i16_p10 82.75 ns 1.29 µs
decode msp_i32_p18 73.97 ns 1.03 µs
decode msp_i32_p18_nullable 80.49 ns 5.332 µs
decode msp_i64_p18_canonical 72.02 ns 89.28 ns
canonicalize (new) gap_msp_i16_p10 2.519 µs
canonicalize (new) msp_i32_p18 2.041 µs
canonicalize (new) msp_i32_p18_nullable 6.957 µs
filter gap_msp_i16_p10 749.7 ns 1.791 µs
filter msp_i32_p18 1.041 µs 2.04 µs
filter msp_i32_p18_nullable 2.582 µs 6.291 µs
filter msp_i64_p18_canonical 1.999 µs 2.249 µs
min_max gap_msp_i16_p10 5.124 µs 1.958 µs
min_max msp_i32_p18 4.083 µs 1.739 µs
min_max msp_i32_p18_nullable 6.165 µs 5.957 µs

Benches that this PR does not change, such as compare_constant and the control, ran about 20% slower in the after run than in the before run, so part of each after number is run-to-run noise. canonicalize shows the full widening that the first version of this PR paid on every decode.

API Changes

DecimalByteParts decoding returns the canonical DecimalArray of its dtype or a decimal Widen, never a DecimalArray at another width. Constructors and the v2 reader reject lower parts that are not u64, and more lower parts than the precision allows. On CUDA, decoding returns the canonical width and rejects an MSP wider than it.

@connortsui20 connortsui20 added the changelog/break A breaking API change label Oct 27, 2026
@connortsui20 connortsui20 mentioned this pull request Oct 27, 2026
11 of 32 tasks
@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 46.62%

1 warning

⚠️ 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 improved benchmarks
❌ 14 regressed benchmarks
✅ 2342 untouched benchmarks
🆕 5 new benchmarks
🗄️ 47 archived benchmarks run1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ CPU Simulation decode[msp_i32_p18_nullable] 19.2 µs 219.1 µs -91.23%
❌ CPU Simulation decode[msp_i64_p38] 17.9 µs 128.6 µs -86.07%
❌ CPU Simulation decode[gap_msp_i16_p10] 17.9 µs 117.5 µs -84.8%
❌ CPU Simulation decode[msp_i32_p18] 18 µs 95.5 µs -81.13%
❌ CPU Simulation sum[gap_msp_i16_p10] 79.6 µs 168 µs -52.63%
❌ CPU Simulation sum[msp_i32_p18_nullable] 155.3 µs 319.1 µs -51.33%
❌ CPU Simulation filter[msp_i32_p18_nullable] 142.1 µs 257.4 µs -44.79%
❌ CPU Simulation filter[gap_msp_i16_p10] 88.5 µs 155.3 µs -43.02%
❌ CPU Simulation sum[msp_i32_p18] 94.2 µs 144.9 µs -34.95%
❌ CPU Simulation filter[msp_i32_p18] 110 µs 159.5 µs -31%
❌ CPU Simulation sum[msp_i64_p38] 141.9 µs 202.4 µs -29.91%
❌ CPU Simulation min_max[gap_msp_i16_p10] 105.2 µs 147.8 µs -28.85%
❌ CPU Simulation filter[msp_i64_p38] 152.7 µs 208.4 µs -26.71%
❌ CPU Simulation decompress[dbp_bp_i32] 55.6 µs 72.8 µs -23.68%
⚡ Walltime filter_neon[64] 2.9 µs 1.9 µs +57.4%
⚡ Walltime threshold_neon[64] 3.7 µs 2.5 µs +45.1%
⚡ Walltime filter_neon[80] 3.7 µs 2.7 µs +40.98%
⚡ Walltime threshold_neon[80] 4.3 µs 3.5 µs +20.78%
🆕 CPU Simulation canonicalize[gap_msp_i16_p10] N/A 194 µs N/A
🆕 CPU Simulation canonicalize[msp_i32_p18_nullable] N/A 325.1 µs N/A
... ... ... ... ... ...

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

Tip

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


Comparing ct/dbp-canonical-decode (8d7e3ee) with ct/decimal-widen-stored (eb6bfc5)

Open in CodSpeed

Footnotes

  1. 47 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩

@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from 59eca0e to 881fe73 Compare October 27, 2026 22:08
@connortsui20
connortsui20 changed the base branch from ct/decimal-width-owner to ct/decimal-scalar-factory-width October 27, 2026 22:08
@connortsui20
connortsui20 added this pull request to stack #10904 October 27, 2026 22:08
@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from 881fe73 to fca52f8 Compare October 27, 2026 22:18
@connortsui20

connortsui20 commented Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

So the regressions are definitely real, but without narrow decimal encoding there's not much we can do.

I will note though that I think it is good we regress these benchmarks because now it actually shows how expensive a very large decimal is in its fully decoded representation.

@connortsui20
connortsui20 marked this pull request as ready for review October 28, 2026 13:49
@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from fca52f8 to e0640ce Compare October 28, 2026 15:39
@robert3005

Copy link
Copy Markdown
Contributor

But we have Widen now so shouldn't this decode to Decimal(Widen) to preserve behaviour?

@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from e0640ce to 5b77c67 Compare October 28, 2026 15:54
@connortsui20
connortsui20 removed this pull request from stack #10904 October 28, 2026 15:59
Base automatically changed from ct/decimal-scalar-factory-width to develop October 28, 2026 16:19
@github-actions
github-actions Bot temporarily deployed to docs-preview/pr-10905 October 28, 2026 16:29 Inactive
@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from 5b77c67 to ed4f730 Compare October 28, 2026 18:07
@connortsui20
connortsui20 marked this pull request as draft October 29, 2026 14:40
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
The first version of #10905 widened every narrow DecimalByteParts MSP
with a copy, and CodSpeed showed regressions of up to 11x. The
maintainer approved a plan on 2026-10-29: decoding returns the
canonical array or a decimal Widen, LT2 becomes part of CD5, and CD5
depends on CD4.

D9 now has one rule for every stored width, so a wider old node can
also become a Widen. D16, D21 and the new D40 make
WidenArray::from_stored_decimal the single owner of the choice between
the canonical array and a Widen. D26 is settled: CD4 lowers a Widen in
the serializer. D31 names 0.91.0, and D36 records the decimal Sum and
MinMax kernels. D41 records the order and the stacks, with two new
PRs, and D42 the decode benchmarks that stop at a Widen.

The implementation plan follows: the PR table, the graph, the waves,
the later work, the rules for every PR and the risks.

Signed-off-by: Connor Tsui <[email protected]>
@connortsui20
connortsui20 force-pushed the ct/dbp-canonical-decode branch from ed4f730 to 86ce007 Compare October 29, 2026 19:35
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
connortsui20 changed the base branch from develop to ct/decimal-widen October 29, 2026 19:40
@connortsui20
connortsui20 added this pull request to stack #10993 October 29, 2026 19:40
@connortsui20 connortsui20 changed the title Decode DecimalByteParts at the canonical decimal width Decode DecimalByteParts into the canonical array or a decimal Widen Oct 29, 2026
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
connortsui20 force-pushed the ct/dbp-canonical-decode branch from 86ce007 to 4a623c2 Compare October 29, 2026 20:21
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]>
The compressor never narrows a lower part, and no file has lower parts
yet, so accepting narrower unsigned lower parts only cost a cast during
assembly. Bounding the lower-part count by the canonical width of the
dtype also means that decoding never has to narrow assembled values.

Every lower part now has the dtype `u64`, and only the MSP narrows. The
constructor, `assemble_decimal` and the v2 reader reject other lower
part dtypes. Precision up to 18 allows no lower parts, 19 to 38 at most
one, and 39 to 76 up to three.

`DecimalByteParts::encode` now uses the checked constructor, because
storage wider than the canonical width splits into more lower parts
than its dtype allows. That was the only caller of the unchecked
constructor, so it is removed.

The crate doc moves to `//!`, where rustdoc reads it.

Signed-off-by: Connor Tsui <[email protected]>
Decoding DecimalByteParts with a narrow MSP will return a decimal
`Widen` over the MSP instead of widening it into the canonical array.
The `decode` and `filter` benches and the `decompress` bench of the
encoding trees now stop at a `Widen` with `execute_until`, so they
measure what decoding produces and not the widening that only exports
need.

A new `canonicalize` bench keeps the old `decode`, which executes to the
canonical `DecimalArray`, so CodSpeed still tracks the full widening
that exports pay.

`execute_until` stops at canonical arrays as well, so these benches run
on the current decoder too. The other encoding trees never yield a
`Widen`, so their `decompress` cases do the same work as before.

Signed-off-by: Connor Tsui <[email protected]>
Widening every narrow MSP into a new canonical buffer made decoding up
to 11x slower in CodSpeed. A decimal `Widen` can reuse the narrow MSP
instead, so `assemble_decimal` now hands the assembled values to
`WidenArray::from_stored_decimal`, which returns the canonical array or
a `Widen` over a narrower child.

The parts assemble at their natural width: the MSP width with no lower
parts, `i128` with one, and `i256` with two or three. A lone MSP at the
canonical width is reused with no scan. Any other lone MSP gets its
range from the integer `min_max`, which reads exact cached extrema or
makes one vectorized pass, unlike the decimal `valid_range` scan.
`from_stored_decimal` scans values assembled from lower parts only when
they are narrower than the canonical width, which happens for one lower
part at a precision above 38.

Signed-off-by: Connor Tsui <[email protected]>
The CPU decoder now returns the canonical array or a `Widen`, so every
decimal array that it returns has the canonical width of its own dtype.
The CUDA decoder still returned the MSP at its own width. CUDA has no
`Widen` kernel, so the GPU decoder widens on the device instead.

An MSP at the canonical width is reused with no copy, and a narrower MSP
is widened with the decimal_cast kernels. An MSP wider than the
canonical width returns an error: narrowing it must check the precision,
and that check on the device needs an overflow flag that synchronizes
with the host. Older writers can store such an MSP, but the CUDA Arrow
export already rejected those columns. Lower parts still return an
error on the GPU.

The device widening moves out of the Arrow export into a crate-private
kernel module, so the export and the decoder share one entry point that
rejects narrowing. The decimal_cast kernels gain an i16 output, because
an i8 MSP at precision 3 or 4 widens to i16.

Signed-off-by: Connor Tsui <[email protected]>
@connortsui20
connortsui20 removed this pull request from stack #10993 October 29, 2026 21:29
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 force-pushed the ct/dbp-canonical-decode branch from 4a623c2 to 8d7e3ee Compare October 29, 2026 21:33
@connortsui20
connortsui20 changed the base branch from ct/decimal-widen to ct/decimal-widen-stored October 29, 2026 21:33
@connortsui20
connortsui20 added this pull request to stack #11008 October 29, 2026 21:33

This branch was previously deployed

1 inactive (outdated) deployment
docs-preview/pr-10905 — 5b77c67b Deployed Oct 28, 2026 by github-actions[bot]
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