Repository navigation
Tracking Issue: Fix Canonical Decimal #10701
Description
This is a tracking issue for fixing Vortex's canonical Decimal encoding.
Fixes: #5820
Status
The canonical width mapping, Arrow and DuckDB imports, compatibility fixtures, integer Widen, and faster widening copy have merged. Decimal Widen and the reader changes remain open. Release 0.91.0 is still a draft as of 2026-11-02, so CD5 and CD6 remain gated on publishing its fixtures.
Motivation
Our canonical Decimal encoding has been incorrect for too long.
The main (but not only) issue is that the canonicalization abstraction in Vortex has been broken by Decimal, where we can have multiple (up to 6) different possible canonical encodings for the same data. This breaks the abstraction because we want a SINGLE canonical (normal form) of logical data.
For example, right now if I have a Decimal(9, 2) (which means at most 9 integer digits, max integer 999_999_999, max decimal 9_999_999.99), but my decimal array looks like [1.00, 2.00, 1.27] where all of the integers fit in an i8 integer, then the canonical encoding allows us to implicitly narrow the buffer to 8 bits.
This is incredibly wrong because it means for the same array, we can have 6 different canonical representations (i8, i16, i32, i64, i128, i256).
The logical/physical overloading and mismatches have caused a lot of problems (see issue #5820). We seemingly have stamped out most of the bugs, but that doesn't mean the current implementation is not hacky.
There are 2 things that need to be done in order to fix this:
- The canonical decimal encoding for a given decimal dtype must have exactly 1 canonical encoding / normal form.
- Narrowing the data buffer must be a physical encoding concern, done via a new
Widenencoding.
Design
For 1), we simply need to choose what physical encoding an array of Decimal(p, s) canonicalizes to. The simplest and most elegant solution is to pick the smallest power-of-2 bit-width buffer whose integer representation can fit every possible value in the domain of Decimal(p, s).
As an example, Decimal(9, 2) has a max integer value of 999,999,999, which means the smallest bit width it can fit into is 32 bits (i32::MAX == 2,147,483,647), so we can use 32-bit width buffers as the canonical encoding. On the other hand, while some values of Decimal(10, 2) can fit in 32 bits, the largest values must be stored in 64 bits, so we use 64-bit width buffers.
Of course, the issue is that this can potentially blow up the amount of memory and storage we use for representing potentially very small decimal values. But given that Vortex deals with compression quite well (via physical encodings), we can easily solve this by introducing a Widen array.
Backcompat conversion
The planned reader converts a vortex.decimal node whose stored width differs from its dtype's canonical width through WidenArray::from_stored_decimal. The reader scans valid values for their minimum and maximum, then picks the smallest child precision that covers the values and the scale. The result is a Widen(Decimal) when the child has a narrower canonical width, or a canonical decimal array otherwise.
The stored metadata width identifies non-canonical nodes. Nodes already at the canonical width reuse their buffer without a scan or copy. The current writer plan also emits narrow vortex.decimal nodes for decimal Widen arrays, so this conversion covers ordinary IPC and writes without a compressor as well as older files.
The conversion can reuse a buffer only when the selected child precision has that canonical width. Decimal precision does not exactly match two's-complement widths, so some values fit the stored width but need a precision whose canonical width is larger.
| Width | Largest precision for the width | Largest value of that precision | Largest value of the width |
|---|---|---|---|
i8 |
2 | 99 | 127 |
i16 |
4 | 9,999 | 32,767 |
i32 |
9 | 999,999,999 | 2,147,483,647 |
i64 |
18 | 10^18 - 1 | about 9.2 × 10^18 |
i128 |
38 | 10^38 - 1 | about 1.7 × 10^38 |
For example, if an old array has type Decimal(9, s) but only contains integers up to 127, we currently store it as i8. The values 100 to 127 have 3 digits, so in the canonical decimal encoding world we ideally want to narrow it to Decimal(3, s). If we naively say "the largest decimal precision fitting i8 is 2, so we should use Decimal(2, s)", that would not cover the values 100-127 that we need. And since the canonical width of Decimal(3, s) is i16, the conversion copies the i8 values into a wider buffer.
To choose a valid child precision, the reader needs both extrema of the valid values, including negative values, and the scale. This does mean that there will be cases where we cannot narrow some old decimal arrays into a buffer that "fits" in this new world. When the needed precision has the same canonical width as the dtype, there is no Widen at all: an old Decimal(4, s) array with values up to 127 needs precision 3, and precisions 3 and 4 both use i16, so it becomes a plain Decimal(4, s) array at i16. See unresolved questions below.
Nodes stored wider than their dtype's canonical width, such as older Arrow Decimal128 imports at a low precision, use the same child selection and get a narrowing copy. Every narrowing copy writes zeros into null slots and fails on a valid value outside the target precision.
Serialization before vortex.widen
The current stack supersedes D26's serializer-only lowering with a non-executing array writer plugin. #11057 separates plugin read and write IDs (merged on 2026-11-02). #11002 closed without merging and its writer work moved into #10946, now based on develop. That PR registers a writer-only WidenPlugin whose only write ID is vortex.decimal.
The writer adapts a concrete decimal leaf to a narrow vortex.decimal node under the parent logical dtype, without executing values or validity. The node stores the child width, preserving narrow storage without introducing a new wire ID. Integer Widen, non-decimal leaves, and nonserializable validity require explicit caller preparation.
#10995 reads non-canonical decimal nodes through the shared conversion. Because the read plugin receives stored statistics only after it returns, each non-canonical read needs a range scan. This is an ordinary path for new IPC and writes without a compressor as well as older files. The future vortex.widen format and its edition remain separate work in #10733.
Steps
The work lands as an in-memory change and then a wire change, so that the edition change stays small. A step lands as its own PR only if it needs no temporary code. Strict width decoding comes first, because the detection of old arrays reads the width field, and an unknown width currently decodes as I8 without an error.
- Decode the decimal width strictly, so that an unknown value returns an error
- Baseline decimal compute benchmarks (Add decimal benchmarks for DecimalByteParts, casts and exports #10752) and backcompat fixtures for old widths, gap values and high scales (Add compat fixtures for old decimal forms #10753)
- Decimal
Widencompute benchmarks (in the CD4 stack) - One owner for the precision to width mapping, used by every producer and import
- Decimal
Widenin memory (Tracking Issue: AddWidenencoding for integers and decimals #10733) -
DecimalBytePartsdecodes to the canonical array or a decimalWiden(Decode DecimalByteParts into the canonical array or a decimal Widen #10905) - Convert non-canonical
vortex.decimalnodes on read, including narrow nodes from the writer plugin (Read old vortex.decimal nodes into the canonical array or a Widen #10995) - The compressor builds
DecimalBytePartsfrom the canonical array, andnarrowed_decimalgoes away -
DecimalArray::try_newaccepts only the canonical width and checks every valid value -
vortex.widenandDecimalBytePartsv2 in a preview edition, and the compressor narrows the decimal type first (Tracking Issue: AddWidenencoding for integers and decimals #10733) -
vortex.widenandDecimalBytePartsv2 in acoreedition
PR plan (agents keep this list up to date)
Each line is one PR of the implementation plan. The briefs describe each PR. When a PR opens, add its link. When it merges, tick its box. When its scope changes, add a short note here. Record a change of plan in the research branch only when it changes a brief or a decision.
- FX1 Strict decoding of stored
DecimalTypeandPTypevalues (brief). Needs: none. Decode stored DecimalType and PType values strictly #10755, merged on 2026-10-26 FX2 DecimalByteParts nullability cast guard. Dropped: Guard the DecimalByteParts nullability cast rule #10746 closed, and Document the nullability contract of cast rules #10798 documents the cast-rule contract instead- CD1 Decimal benchmarks. Needs: none. Add decimal benchmarks for DecimalByteParts, casts and exports #10752, merged on 2026-10-26
- CD2 Compat fixtures for old decimal forms. Needs: none. Add compat fixtures for old decimal forms #10753, merged on 2026-10-26. Release 0.91.0 publishes the fixtures and is still a draft as of 2026-11-02
- CD3 One canonical width function, imports that check and copy, and the shared helpers. Needs: none. Add a canonical decimal width and import decimals at it #10897, merged on 2026-10-28
- CD3b One scan for the range of valid decimal values. Needs: CD3. Add a scan for the range of valid decimal values #10939, merged on 2026-10-28. Shared by CD4, CD6 and CD7. Reuses the existing decimal MinMax scanner. DBP decoding (CD5) takes the MSP range from the integer
min_maxinstead, because this scan is not vectorized - CD4 prerequisite Decimal Interleave execution. Needs: none. Execute interleaved decimal arrays #10940, merged on 2026-10-29. Fixes the existing unsupported-decimal panic so CD4 can test Interleave fallbacks.
- CD3c A faster decimal widening copy (
copy_to_canonical_width). Needs: none. The benchmark Benchmark the copy of decimal values into the canonical width #10990 merged on 2026-10-29 and the loop change Speed up the decimal widening copy #10989 merged on 2026-10-30, satisfying the order rule that the fast copy lands before CD4 - CD4 prerequisite Separate array plugin read and write IDs. Separate the read and write IDs of array plugins #11057, merged on 2026-11-02. A writer can emit an existing encoding without taking over its reader
- CD4 Decimal
Widenin memory, with one stored-value conversion shared by CD5 and CD6. Needs: WD1, WD3 and WD4 (Tracking Issue: AddWidenencoding for integers and decimals #10733), CD1, CD3, CD3b and Execute interleaved decimal arrays #10940. Support decimal values in Widen #10946 is now based ondevelopand includes the non-executing decimal writer folded from closed Write Widen arrays through an array plugin #11002. IntegerWiden, non-decimal leaves, and nonserializable validity require explicit caller preparation. The remaining layers are Push decimal comparisons, between and fills through Widen #11003 (compare,betweenand fills), Push decimal aggregates through Widen #11004 (aggregates), Read stored decimal values into the canonical array or a Widen #11005 (WidenArray::from_stored_decimal) and Keep same-scale decimal widening casts lazy #11006 (lazy same-scale widening casts). All five PRs remain open. CD5 and CD6 branch from Read stored decimal values into the canonical array or a Widen #11005 and do not need Keep same-scale decimal widening casts lazy #11006. No release gate for CD4 - CD4b Export a decimal
Widento Arrow with one copy. Needs: CD4. Export decimal Widen arrays to Arrow with one copy #10994, open and stacked on Support decimal values in Widen #10946. Can merge before the remaining CD4 layers. No release gate - CD5 DecimalByteParts decodes to the canonical array or a decimal
Widen(LT2 folded in). Needs: CD1, CD3, CD4, the CD2 fixtures in 0.91.0. Decode DecimalByteParts into the canonical array or a decimal Widen #10905, open and stacked on Read stored decimal values into the canonical array or a Widen #11005, with CD7 (Build DecimalByteParts from the canonical decimal array #10992) on top. A lone MSP uses exact cached extrema or one integermin_maxpass, and the child caches the extrema. Decode benchmarks stop at the decoded form, with a separate canonicalization benchmark. CUDA still decodes to the canonical width and needs a CUDA review. After 0.91.0 publishes, run the latest-release compatibility check before merging, then merge CD7 immediately afterward - CD6 The
vortex.decimalread plugin converts non-canonical nodes into the canonical array or aWiden, with one rule for every stored width. Needs: FX1, CD3, CD3b, CD4, the CD2 fixtures in 0.91.0. Read old vortex.decimal nodes into the canonical array or a Widen #10995, open and stacked on Read stored decimal values into the canonical array or a Widen #11005. Covers older nodes and the narrow nodes written byWidenPlugin. Non-canonical reads scan on the host, including CUDA inputs. After 0.91.0 publishes, run the latest-release compatibility check before merging. Aim to ship CD5, CD7 and CD6 in one release - CD7 One DecimalByteParts scheme on the canonical array, and
narrowed_decimalgoes away. Needs: WD1 (Tracking Issue: AddWidenencoding for integers and decimals #10733), CD1, CD3b, CD5. Build DecimalByteParts from the canonical decimal array #10992, stacked on Decode DecimalByteParts into the canonical array or a decimal Widen #10905. Merges right after CD5 - CD8a Every decimal array has the canonical width. Needs: CD3, CD5, CD6, CD7
- CD8b
DecimalArray::try_newchecks every valid value, with anExecutionCtx. Needs: CD8a - CD8c Remove the code for mixed decimal widths. Needs: CD8a
- The wire change (WR1 to WR3) and the
coreedition (LT1) are in Tracking Issue: AddWidenencoding for integers and decimals #10733 LT2 A max pass in DecimalByteParts decoding that returns a. Folded into CD5 (Decode DecimalByteParts into the canonical array or a decimal Widen #10905), because the benchmarks showed the needWiden- LT3 Sum, min and max pushdown into the DecimalByteParts MSP. Only if benchmarks show the need
Follow-up issues found while planning:
- Decimal scalars panic on a stored value outside the precision #10749 Decimal scalars panic on a stored value outside the precision. Return an error for decimal scalars outside the precision #10767 remains open and fixes
scalar_atand themin_maxkernel. Decimal stats read from a file are not checked against the precision #10807 separately tracks cached decimal statistics that bypass that kernel. OtherScalar::decimalcallers stay for later PRs - compat-gen skips old fixture versions in CI and stops at the first failure #10754 compat-gen skips old fixture versions in CI and stops at the first failure
- Corrupt ALP-RD, RLE and RunEnd metadata can panic a file read #10756 Corrupt ALP-RD, RLE and RunEnd metadata can panic a file read. fix(alp): bounds-check ALP-RD stored metadata and left-parts codes #10760 fixes ALP-RD, merged on 2026-10-29. Return Serde errors for corrupt RLE and RunEnd metadata #10852 fixes RLE and RunEnd, open
- Seven cast rules push nullability casts without trivially_cast_nullability #10750 Push-down cast rules without the nullability guard. Closed as not planned, because Document the nullability contract of cast rules #10798 documents them as valid
Related PRs outside this plan:
- Bound decimal storage by precision #10305 and Add bounded decimal dispatch macros and migrate kernels #10314 bound decimal storage by precision and add bounded dispatch macros. Open. The plan does not build on them for now
- perf(decimal-byte-parts): compare wide decimals through their parts #10146 compares wide decimals through their DecimalByteParts parts. Open
- Skip codegen of unreachable int to decimal carrier arms #10735 removes unreachable decimal carrier arms from the integer to decimal cast. Merged on 2026-10-26
- Establish widening cast benchmark baselines #11052 (widening cast benchmark baselines) and [EXPERIMENT] Keep ARM integer widening loops compact #11050 (ARM loop experiment) closed without merging. Neither is a dependency
Unresolved questions
- Is it okay that we are not able to narrow every decimal array exactly how it was before?
Note that this only affects canonical decimal arrays, not encodings likeYes, see explanations above.DecimalByteParts. - Should
DecimalBytePartsdecoding find the max of a narrow most significant part and return aWidenover its buffer instead of widening it?This only matters for files written before the compressor narrows the decimal type first, so the decision waits for benchmarks that show the cost of widening.Yes. Widening every narrow MSP made decoding up to 11x slower in CodSpeed (Decode DecimalByteParts into the canonical array or a decimal Widen #10905), and every default-written decimal column whose values are small for its precision has a narrow MSP. CD5 takes the range frommin_maxon the MSP and returns aWidenthat reuses the MSP buffer (LT2, folded into CD5).
Implementation history
- Research: [DO NOT MERGE] Research notes on extension types, Decimal and casts #10633
- Describe scheme variants in the versioning docs #10747 (2026-10-23): the versioning docs describe the v1 and v2 variants of the decimal scheme
- Add compat fixtures for old decimal forms #10753 (2026-10-26): compat fixtures for old decimal forms (CD2)
- Document the nullability contract of cast rules #10798 (2026-10-26): documents the cast-rule contract on
CastReduce, in place of FX2 - Add decimal benchmarks for DecimalByteParts, casts and exports #10752 (2026-10-26): decimal benchmarks for DecimalByteParts, casts and exports (CD1)
- Skip codegen of unreachable int to decimal carrier arms #10735 (2026-10-26): skips codegen of unreachable integer to decimal carrier arms
- Decode stored DecimalType and PType values strictly #10755 (2026-10-26): strict decoding of stored
DecimalTypeandPTypevalues (FX1) - Add a canonical decimal width and import decimals at it #10897 (2026-10-28): one canonical decimal width, and Arrow and DuckDB imports at that width (CD3)
- Build precision 19 decimal scalars at
i128#10903 (2026-10-28): precision 19 decimal scalars from Python and JNI are built ati128 - Add a scan for the range of valid decimal values #10939 (2026-10-28): one scan for the range of valid decimal values (CD3b)
- Execute interleaved decimal arrays #10940 (2026-10-29): decimal Interleave execution (CD4 prerequisite)
- Benchmark the copy of decimal values into the canonical width #10990 (2026-10-29): a benchmark of the decimal copy into the canonical width (CD3c)
- Speed up the decimal widening copy #10989 (2026-10-30): faster decimal widening copy (CD3c)
- Separate the read and write IDs of array plugins #11057 (2026-11-02): separate array plugin read and write IDs, enabling
WidenPluginto write existing wire forms without registering avortex.widenreader
Activity
DecimalArrayLogical and Physical type mismatch #5820Add compat fixtures for old decimal forms (#10753)
Update the Narrow and decimal research for the first PRs
Update the Narrow and decimal research for the first PRs
Add decimal benchmarks for DecimalByteParts, casts and exports (#10752)
Decode stored DecimalType and PType values strictly (#10755)
Add a canonical decimal width and import decimals at it (#10897)
Add a scan for the range of valid decimal values (#10939)
Note that the non-canonical decimal Interleave copy goes away
Execute interleaved decimal arrays (#10940)
Benchmark the copy of decimal values into the canonical width (#10990)
Speed up the decimal widening copy (#10989)
Separate the read and write IDs of array plugins (#11057)