Repository navigation
Tracking Issue: Fix Canonical Decimal #10701
Description
This is a tracking issue for fixing Vortex's canonical Decimal encoding.
Fixes: #5820
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
Narrowencoding.
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 Narrow array.
Backcompat conversion
Old decimal arrays that have narrower buffers than their type can be converted (relatively cheaply) to an array tree Narrow(Decimal) by picking a decimal precision that fits the buffer values.
Note that we only do this if the array was written "incorrectly" the old way. Luckily, we are able to detect an old decimal array by simply checking the metadata width field, and asserting if the width matches the "correct" canonical width. If it is wrong, we know it is an old decimal array.
There is a subtle reason why we cannot convert with zero-copy: because decimal precision does not exactly match twos-complement widths, there are a "gaps" of values where the buffer width might be able to store more than the decimal dtype.
| 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 fix this, we need to compute the max of the integer buffer to determine the actual smallest decimal precision we can use. 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 Narrow 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.
Old arrays with a buffer wider than the canonical width, such as Arrow Decimal128 data at a low precision that was written without compression, get a narrowing copy instead. The copy writes zeros into null slots and fails on a value outside the precision, since the copy already reads every value. Old narrow vortex.decimal arrays are rare, since the compressor has put every decimal whose values fit in 64 bits into DecimalByteParts since 2025-05, so the speed of this conversion matters little.
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
- Benchmarks for decimal compute on
DecimalBytePartsandNarrow, and backcompat fixtures for old widths, gap values and high scales - One owner for the precision to width mapping, used by every producer and import
- Decimal
Narrowin memory (Tracking Issue: AddNarrowencoding for integers and decimals #10733) -
DecimalBytePartsdecodes to the canonical width of its dtype - Convert old
vortex.decimalarrays on read - The compressor builds
DecimalBytePartsfrom the canonical array, andnarrowed_decimalgoes away -
DecimalArray::try_newaccepts only the canonical width and checks every valid value -
vortex.narrowandDecimalBytePartsv2 in a preview edition, and the compressor narrows the decimal type first (Tracking Issue: AddNarrowencoding for integers and decimals #10733) -
vortex.narrowandDecimalBytePartsv2 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, draft, rebased ontodevelopwithout the error categories that Simplify error types, remove kind from struct #10080 removed 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, in review
- CD2 Compat fixtures for old decimal forms. Needs: none. Add compat fixtures for old decimal forms #10753, merged. The next regular release publishes the fixtures
- CD3 One canonical width function, imports that check and copy, and the shared helpers. Needs: none
- CD4 Decimal
Narrowin memory. Needs: NR1, NR3 and NR4 (Tracking Issue: AddNarrowencoding for integers and decimals #10733), CD1, CD3 - CD5 DecimalByteParts decodes to the canonical width. Needs: CD1, CD3, a release with the CD2 fixtures
- CD6 The
vortex.decimalread plugin converts old arrays. Needs: FX1, CD3, CD4, a release with the CD2 fixtures - CD7 One DecimalByteParts scheme on the canonical array. Needs: NR1 (Tracking Issue: Add
Narrowencoding for integers and decimals #10733), CD1 - 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: AddNarrowencoding for integers and decimals #10733 - LT2 A max pass in DecimalByteParts decoding that returns a
Narrow. Only if benchmarks show the need - 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 fixes
scalar_atandmin_max, in review. 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
- 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. Open
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 aNarrowover 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.
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
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)