Skip to content

Tracking Issue: Fix Canonical Decimal #10701

Open
0 of 2 issues completed
Open

Description

@connortsui20

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:

  1. The canonical decimal encoding for a given decimal dtype must have exactly 1 canonical encoding / normal form.
  2. Narrowing the data buffer must be a physical encoding concern, done via a new Narrow encoding.

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 DecimalByteParts and Narrow, 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 Narrow in memory (Tracking Issue: Add Narrow encoding for integers and decimals #10733)
  • DecimalByteParts decodes to the canonical width of its dtype
  • Convert old vortex.decimal arrays on read
  • The compressor builds DecimalByteParts from the canonical array, and narrowed_decimal goes away
  • DecimalArray::try_new accepts only the canonical width and checks every valid value
  • vortex.narrow and DecimalByteParts v2 in a preview edition, and the compressor narrows the decimal type first (Tracking Issue: Add Narrow encoding for integers and decimals #10733)
  • vortex.narrow and DecimalByteParts v2 in a core edition

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 like DecimalByteParts. Yes, see explanations above.
  • Should DecimalByteParts decoding find the max of a narrow most significant part and return a Narrow over 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

Metadata

Metadata

Assignees

Labels

tracking-issueShared implementation context for work likely to span multiple PRs.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions