Repository navigation
Decode DecimalByteParts into the canonical array or a decimal Widen - #10905
connortsui20 wants to merge 4 commits into
Conversation
Merging this PR will degrade performance by 46.62%1 warning
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
59eca0e to
881fe73
Compare
881fe73 to
fca52f8
Compare
|
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. |
fca52f8 to
e0640ce
Compare
|
But we have Widen now so shouldn't this decode to Decimal(Widen) to preserve behaviour? |
e0640ce to
5b77c67
Compare
5b77c67 to
ed4f730
Compare
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.
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]>
ed4f730 to
86ce007
Compare
`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]>
`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]>
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]>
86ce007 to
4a623c2
Compare
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]>
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]>
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]>
4a623c2 to
8d7e3ee
Compare
Tracking Issue: #10701
Stacked on #11005, the layer of the decimal
Widenstack that addsfrom_stored_decimal. It does not need #11006. #10992 (CD7) is stacked on this PR.Summary
CD8a will require every
DecimalArrayto be at the canonical width of its dtype. DecimalByteParts decoding breaks that today, because it reuses a narrow MSP as aDecimalArrayat the MSP width. Decoding now returns either the canonical array or a decimalWidenover 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
Widenover 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:
uv run vortex-test/compat-gen/scripts/compat.py check --mode laston this branch.Changes
assemble_decimalpasses the assembled values toWidenArray::from_stored_decimalfrom #11005, which picks the result. For a lone MSP that is not at the canonical width, the range of the valid values comes frommin_maxon the MSP, which reads exact cached statistics or makes one vectorized pass (the decimalvalid_rangescan is not vectorized). The range is passed as exact, so the child records it as its extrema and a latermin_maxon 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
decodeandfilterbenches and the encoding-treedecompressbench now stop at aWiden, which matches what they measure ondevelop. A newcanonicalizebench executes to the canonicalDecimalArray, so CodSpeed still tracks the widening that exports pay.CUDA has no
Widenkernel, so the CUDA decoder still returns the canonical width. It widens a narrow MSP on the device with thedecimal_castkernels, which gain ani16output. 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:
min_maxpass per decode. The nullable primitivemin_maxis about 5x slower than the non-nullable one, so it dominates a nullable decode. Follow-up: speed up the nullable primitivemin_max.Sumwithoutskip_nans, andSumV2, still execute theWiden.Local benches against the decimal
Widenstack 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 themin_maxpass. The baseline decode did no work, because it reused the MSP in the form that CD8a forbids.min_maxon 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 asdevelopdoes. After is this branch. The results isolate this PR.msp_i64_p18_canonicalis the control: its MSP is already at the canonical width, so this PR does not change its decode.decodegap_msp_i16_p10decodemsp_i32_p18decodemsp_i32_p18_nullabledecodemsp_i64_p18_canonicalcanonicalize(new)gap_msp_i16_p10canonicalize(new)msp_i32_p18canonicalize(new)msp_i32_p18_nullablefiltergap_msp_i16_p10filtermsp_i32_p18filtermsp_i32_p18_nullablefiltermsp_i64_p18_canonicalmin_maxgap_msp_i16_p10min_maxmsp_i32_p18min_maxmsp_i32_p18_nullableBenches that this PR does not change, such as
compare_constantand 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.canonicalizeshows the full widening that the first version of this PR paid on every decode.API Changes
DecimalByteParts decoding returns the canonical
DecimalArrayof its dtype or a decimalWiden, never aDecimalArrayat another width. Constructors and the v2 reader reject lower parts that are notu64, and more lower parts than the precision allows. On CUDA, decoding returns the canonical width and rejects an MSP wider than it.