Repository navigation
Description
This is a tracking issue for fixing Vortex's canonical Decimal encoding.
Fixes: #5820
Status
Decimal Widen and legacy vortex.decimal reads have merged, and DecimalByteParts is being redesigned so that a narrow MSP needs no scan.
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 reader (#10995) converts a legacy vortex.decimal node (one 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 legacy nodes. Nodes already at the canonical width reuse their buffer without a scan or copy. The writer from #10946 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
A non-executing array writer plugin replaces D26's serializer-only lowering. #11057 separates plugin read and write IDs (merged on 2026-11-02). #11002 closed without merging, and its writer work moved into #10946, which merged on 2026-11-03. 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 (merged on 2026-11-05) reads legacy decimal nodes through the shared conversion, in a DecimalPlugin that owns all vortex.decimal serde. Because the read plugin receives stored statistics only after it returns, each legacy read needs a range scan. This is an ordinary path for new IPC and writes without a compressor as well as older files. The scan runs on the host, so a legacy node in device memory returns an error, and CUDA reads of legacy nodes fail until the CUDA executor reads them itself. The future vortex.widen format and its edition remain separate work in #10733, and vortex.widen would also stop WidenPlugin from writing legacy nodes.
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 (Support decimal values in Widen #10946) - 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) -
DecimalBytePartsbecomes the decimal over an integer child, so that decoding needs no scan - Convert legacy
vortex.decimalnodes on read, including narrow nodes from the writer plugin (Read legacy 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 published the fixtures on 2026-11-04
- 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. It lands in 5 layers. Support decimal values in Widen #10946 (with the non-executing decimal writer folded from closed Write Widen arrays through an array plugin #11002), Push decimal comparisons, between, and fills through Widen #11003 (compare,betweenand fills), and Push decimal aggregates through Widen #11004 (aggregates) merged on 2026-11-03. IntegerWiden, non-decimal leaves, and nonserializable validity require explicit caller preparation. Add shared conversion to canonical decimal arrays or Widen #11005 (WidenArray::from_stored_decimal) merged on 2026-11-04. Keep same-scale decimal widening casts lazy #11006 (lazy same-scale widening casts) merged on 2026-11-04. 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, merged on 2026-11-03, after Support decimal values in Widen #10946 and before the remaining CD4 layers - CD5 DecimalByteParts is the decimal over an integer child. The MSP keeps the canonical width, capped at
i64, and a narrower MSP is an integerWiden, so decoding needs no scan and never returns a decimalWiden. Needs: WD1, LT3, CD1, CD3, CD3b, ExecuteWidenand legacy decimal reads on the GPU #11284, the CD2 fixtures in 0.91.0. Decode DecimalByteParts into the canonical array or a decimal Widen #10905 closed on 2026-11-05 for this redesign. One new PR holds CD5 with CD7 folded in, stacks on LT3, and merges only after ExecuteWidenand legacy decimal reads on the GPU #11284. It can split later if review needs it. v1 keeps its wire form through the plugin, and only a legacy MSP wider than its logical dtype scans on read. ExecuteWidenand legacy decimal reads on the GPU #11284 adds a deviceWidenkernel, so CD5 needs no GPU stopgap. The scan design does not land in between, even if CD5 misses the release of CD6 - CD6 The
vortex.decimalread plugin converts legacy 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 legacy vortex.decimal nodes into the canonical array or a Widen #10995, merged on 2026-11-05. Covers older nodes and the narrow nodes written byWidenPlugin.DecimalPluginowns allvortex.decimalserde, so theDecimalvtable no longer serializes or deserializes. A legacy node in device memory returns an error instead of a host copy, so CUDA reads of legacy nodes regress until ExecuteWidenand legacy decimal reads on the GPU #11284 reads them on the GPU. A legacy node can read as aWiden, which has no Python subclass (follow-up, no issue yet). The latest-release compatibility check passed against the 0.91.0 fixtures on 2026-11-04 (018c22e496). Aim to ship CD5 and CD7 in the same release as CD6 - CD7 One DecimalByteParts scheme on the canonical array, and
narrowed_decimalgoes away. A narrower MSP becomes an integerWiden, also next to lower parts. Build DecimalByteParts from the canonical decimal array #10992 closed on 2026-11-05 with Decode DecimalByteParts into the canonical array or a decimal Widen #10905. Folded into the CD5 PR, because the new MSP rule, the reader, and the writer must change together - 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. Since 2026-11-05, theNarrowscheme of WR3 narrows only integers, and DecimalByteParts is the only scheme that narrows decimals, the same way ALP turns floats into integers for the integer schemes LT2 A max pass in DecimalByteParts decoding that returns a. Dropped on 2026-11-05, because an integerWidenWidenMSP needs no range- LT3 Sum, min and max pushdown into the DecimalByteParts MSP. Required, and lands on
developbefore CD5. The DecimalByteParts benchmarks keep theirdevelopform, which already runssumandmin_maxon the DecimalByteParts array. With no lower parts, each aggregate runs on the MSP and relabels its result as a decimal. Sum uses the decimal sum on the decoded array when the integer sum overflows. Predicting the overflow from the dtypes and the batch length is a possible later refinement
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_atand themin_maxkernel, merged on 2026-11-04. 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. Merged on 2026-11-03
- 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 fromNo. The MSP keeps the canonical width as its logical dtype, and a narrower MSP is an integermin_maxon the MSP and returns aWidenthat reuses the MSP buffer (LT2, folded into CD5).Widen. The canonical width of a precision is the smallest width that holds10^p - 1, so every value of a narrower integer is a validDecimal(p)value, and the MSP needs no scan and no precision label. A decimalWidenchild needs a precision label, which only a scan gives, and that scan made decoding 4.7 to 8.4 times as slow in CodSpeed (Decode DecimalByteParts into the canonical array or a decimal Widen #10905). DecimalByteParts is then the narrow form itself, and it decodes to the canonical array only when a consumer needs it.
Implementation history
- Research: [DO NOT MERGE] Research notes on extension types and casts #10633 and [DO NOT MERGE] Research notes on Decimal,
Widen, and the type roadmap #11237 - 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 - Support decimal values in Widen #10946 (2026-11-03): decimal values in
Widen, with a writer-only plugin that writes narrowvortex.decimalnodes (CD4) - perf(decimal-byte-parts): compare wide decimals through their parts #10146 (2026-11-03): wide decimals compare through their DecimalByteParts parts
- Push decimal comparisons, between, and fills through Widen #11003 (2026-11-03): decimal comparisons,
between, and fills run on theWidenchild (CD4) - Export decimal Widen arrays to Arrow with one copy #10994 (2026-11-03): decimal
Widenarrays export to Arrow with one copy (CD4b) - Push decimal aggregates through Widen #11004 (2026-11-03): decimal aggregates run on the
Widenchild (CD4) - Return an error for decimal scalars outside the precision #10767 (2026-11-04): decimal scalars outside the precision return an error (part of Decimal scalars panic on a stored value outside the precision #10749)
- Add shared conversion to canonical decimal arrays or Widen #11005 (2026-11-04): one conversion of stored decimal values into the canonical array or a decimal
Widen(CD4) - Keep same-scale decimal widening casts lazy #11006 (2026-11-04): same-scale decimal widening casts return a
Widen(CD4) - Read legacy vortex.decimal nodes into the canonical array or a Widen #10995 (2026-11-05): legacy
vortex.decimalnodes read into the canonical array or a decimalWiden(CD6) - Decode DecimalByteParts into the canonical array or a decimal Widen #10905 and Build DecimalByteParts from the canonical decimal array #10992 (closed on 2026-11-05): DecimalByteParts decoding through a
min_maxscan into a decimalWiden(CD5), and the scheme on the canonical array (CD7). Closed without merging, because DecimalByteParts became the decimal over an integer child, which needs no scan
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)
Support decimal values in Widen (#10946)
Push decimal comparisons, between, and fills through Widen (#11003)
Export decimal Widen arrays to Arrow with one copy (#10994)
Push decimal aggregates through Widen (#11004)
Keep same-scale decimal widening casts lazy (#11006)
Read legacy vortex.decimal nodes into the canonical array or a Widen (#…
Push decimal byte parts aggregates into the integer MSP (#11302)
feat(cuda): Execute Widen arrays on CUDA (#11303)
Move the DecimalByteParts tests into their own files (#11456)