Repository navigation
Description
This is a tracking issue for adding a Widen encoding for integers and decimals, which stores logical values that can fit in a smaller bit width or precision in a smaller logical dtype.
- Adapted from Epic: Implement Narrow integer encoding #10020
Status
Integer Widen, its compute benchmarks, aggregates and arithmetic/cast kernels have merged (WD1 to WD4). Structural lifting (WD5) has no PR yet, and the internal-child migration experiment (#10943) closed without merging and is parked for later. The decimal writer plugin and decimal Widen merged on 2026-11-03 and 2026-11-04, and the rest of the decimal stack is open in #10701. No edition permits vortex.widen yet.
Motivation
We have the concept of "narrowing" data in a few places.
- In the compressor, if we have a
Primitiveinteger array that does not contain very large values, we will call anarrow()method on the primitive array to shrink the logical dtype to fit the values. For example, if my values are all in[-128, 127], but the dtype isI64, then I can narrow this toI8. We use this with list offsets, for example. Note that this changes the dtype of the array. - Narrowing can also happen with explicit casts (which are fallible), but this also changes the dtype.
- In the canonical decimal encoding, there is currently implicit narrowing of the buffer when possible. This is arguably very wrong, see Tracking Issue: Fix Canonical Decimal #10701 for more details.
Ideally, we want a way to keep the logical type of these values while having a better physical encoding that narrows data internally. This is why we want a Widen encoding.
Design
The encoding is named after what decoding does: like FoR is an addition, Widen is a widening cast of its child (see #9156). The compressor scheme that builds it narrows the data, so it keeps the name Narrow.
The Widen encoding will have a single array child that has a narrower logical dtype. For integers, the child has the same signedness and a strictly smaller power-of-two bit width. For decimals, the child has a lower precision whose canonical width is strictly smaller, with the same scale. A positive scale sets a lower bound on that precision. The child always has the same nullability as the parent, and supplies its validity.
For example, a Widen array of I64 can hold an I8 child (if it fits). Or, a Widen array of Decimal(18, 2) (with a 64-bit buffer) can hold a child of Decimal(9, 2) (with a 32-bit buffer).
We can constrain the child of the Widen array such that it must actually shrink the amount of data we need to store. This is only relevant for Decimal: having a Widen(Decimal(17, 2)) with dtype Decimal(18, 2) is completely pointless because it doesn't actually save any space. This is something we might be able to relax in the future, but it seems like a reasonable constraint for now.
Note that the compressor narrowing an internal child does not need to change. This is because list offsets, dictionary codes, run-end ends, patch indices, and the parts of DecimalByteParts have dtypes that their parent encoding chooses, so a narrow integer dtype there is "correct" by default, and these children technically do not need a Widen.
Compute
A consequence of making narrowing a physical concern rather than a logical one is that we will need to forward lots of compute into the Widen array's child.
Kernels run on the child at the narrow width and widen only when the result needs it. Any operation without a kernel falls back to canonicalization, which widens the child to the logical dtype.
Compute behavior and remaining work
| Operation | Behavior on Widen |
|---|---|
slice, filter, take, mask, fill_null, zip |
Run on the child. |
compare, between |
Run on the child. A constant outside the range of the child dtype gives the result without reading the values. |
| Arithmetic | Runs at the smallest width that holds both inputs and the result interval, taken from the non-null bounds. Otherwise it runs at the logical width with checked kernels. |
min, max, sum and other aggregates |
Must preserve the canonical array's results and overflow behavior, including when combining running partials. Ordered aggregates can widen child partials. Decimal Sum pushdown in #11004 uses the logical sum dtype only for the legacy Stat::Sum path. SumV2 and sums without skip_nans keep the fallback. |
cast |
Passes to the child with the same target dtype, since a Widen holds exactly the values of its child. |
| Generic structural encodings | WD5 moves Dict, RunEnd, Sparse, Constant, Masked, Chunked and Interleave above Widen. The earlier plan to merge chunked and interleaved inputs under a top-level Widen is dropped (D25). |
The compare shortcut is the reason a decimal child dtype must always come from the values. A buffer width alone does not give a precision (an i32 buffer can hold 2,000,000,000, which needs precision 10), so a child labeled Decimal(9, 2) without a check could hold values above its precision, and the shortcut would return wrong results for them.
Serialization
Widen is currently an in-memory encoding. The decimal writer in #10946 (merged on 2026-11-03) uses the split plugin read and write IDs from #11057 (merged on 2026-11-02). It includes the work folded from #11002, which closed without merging, and registers a writer-only WidenPlugin whose only write ID is vortex.decimal.
This replaces D26's serializer-only lowering. The writer adapts a concrete decimal leaf to the existing vortex.decimal wire form under the parent logical dtype, retaining the child width without executing values or validity. Integer Widen, non-decimal leaves, and nonserializable validity require explicit caller preparation. #10995, which is still open, reads non-canonical decimal nodes into the canonical array or a decimal Widen. Such reads need a range scan because stored statistics reach the array only after the plugin returns, including for ordinary new IPC and writes without a compressor.
The later wire change gives integers and decimals one ID, vortex.widen, behind an edition. Its proposed prost metadata has a typed oneof: the child PType for an integer, or the child precision for a decimal. It stores neither scale nor nullability, since both come from the parent. WR1 must adapt the writer plugin and add a working reader.
Relation to DecimalByteParts v2
Ideally, vortex.widen enters a preview edition together with DecimalByteParts v2, and later core together with it.
For decimal columns, we want the final compressor output to be a Widen over DecimalByteParts. Right now, decimals whose values need more than 64 bits get no compression beyond implicit narrowing, because DBPv1 can only store values up to 64 bits. For example, a Decimal(50, 2) column whose values fit in 128 bits is stored as a plain i128 buffer instead of an i256 one. If we remove the implicit narrowing (again, see #10701), those values are stored at the full canonical width.
A Widen over an i128 child brings back today's size, but only DBPv2 actually compresses these values, since it splits them into a 64-bit most significant part and u64 lower parts. So once we add Widen to replace Decimal's implicit narrowing, we will also want DecimalByteParts v2.
Steps
- Integer
Widenin memory, with compute forwarding (rewrite of feat(array): add Narrow integer encoding #10223 and feat(array): push arithmetic and selection through Narrow #10332) - Replace
PrimitiveArrayExt::narrowwithWidenArray::encode_valuesfor internal children such as list offsets and dictionary codes (first part of feat(compress): retain Narrow integer children through compression #10333) - Benchmarks for compute over integer
Widen(Add compute benchmarks forWiden#10888) - Benchmarks for compute over decimal
Widen(Support decimal values in Widen #10946) - Decimal
Widenin memory (needed by Tracking Issue: Fix Canonical Decimal #10701) -
vortex.widenwire format for integers and decimals in a preview edition together withDecimalBytePartsv2, with the compressor wrapping top-level integer and decimal columns inWiden(second part of feat(compress): retain Narrow integer children through compression #10333) -
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.
- FX3 Dictionary
fill_nullno longer truncates the fill code (brief). Needs: none. Avoid truncating dictionary fill codes #10745, merged on 2026-10-26, which closed Dict fill_null can truncate indices of unreferenced dictionary values #10623. Widen dictionary codes in Dict::fill_null #10806 then widens the codes instead of declining, merged on 2026-10-26 (Widen dict codes inDict::fill_null#10804) - WD1 In-memory integer
Widen, withencode, deprecatedencode_values, and a flatten rule. Add an in-memoryWidenencoding for integers #10864, merged on 2026-10-28. Support decimal values in Widen #10946 (merged on 2026-11-03) includes the writer work folded from Write Widen arrays through an array plugin #11002: the default session registers a writer-only plugin for decimalWidenleaves usingvortex.decimal, without executing arrays. IntegerWidenserialization still requires explicit caller preparation until upstream normalization or its own wire form is available. - WD2 Benchmarks for compute on integer
Widen. Needs: WD1. Add compute benchmarks forWiden#10888, merged on 2026-10-28 - WD3 Aggregates run on the child. Needs: WD2. Compute
Widenaggregates on the values #10890, merged on 2026-10-28 - WD4 Arithmetic,
between,zipand casts run on the child. Needs: WD3. RunWidenarithmetic, cast, fill null, zip and between on the values #10891, merged on 2026-10-28. Follow-up:ZipExecuteAdaptordispatches only on theif_truebranch #10908, so that aWidenin theif_falsebranch ofzipalso runs on the child (fixed by Dispatch zip kernels on either value branch #11138) - WD5 Structural encodings move above
Widen, and dictionary codes use the child. Needs: WD4, FX3. No PR yet - WD6 The existing narrowing call sites move to
encode_values. Needs: WD1, not WD5. [EXPERIMENT] Narrow internal children withencode_values#10943, a draft experiment, closed without merging on 2026-11-03 and is parked for later. It preserved the signedness of internal children and removedPrimitiveArrayExt::narrow. The PR records the old-reader comparison and the remaining performance and CUDA validation - Decimal
Widenin memory (CD4) is in Tracking Issue: Fix Canonical Decimal #10701. Support decimal values in Widen #10946, Push decimal comparisons, between, and fills through Widen #11003, Push decimal aggregates through Widen #11004, and Export decimal Widen arrays to Arrow with one copy #10994 merged on 2026-11-03. Add shared conversion to canonical decimal arrays or Widen #11005 and Keep same-scale decimal widening casts lazy #11006 merged on 2026-11-04. Write Widen arrays through an array plugin #11002 closed and its writer work moved into Support decimal values in Widen #10946. The plugin prerequisite Separate the read and write IDs of array plugins #11057 merged on 2026-11-02 - WR1
vortex.widenwire format for integers and decimals (brief). Needs: WD1, CD3, CD4, CD8a (Tracking Issue: Fix Canonical Decimal #10701). It adds a working reader and adapts the earlierWidenPluginwriter to the new format - WR2 One preview edition with
vortex.widenand DecimalByteParts v2. Needs: WR1, CD5, CD7 (Tracking Issue: Fix Canonical Decimal #10701) - WR3 A
Narrowscheme in the compressor that producesWidenarrays. Needs a design pass first. Needs: WR1, WR2, WD5, CD3, CD4, CD7. A scheme that compresses its child through another scheme declares and refines that scheme, as the FoR scheme does sinceFoRScheme: declare and refine its bit-packed child #10757 - LT1
vortex.widenand DecimalByteParts v2 in acoreedition, and a decision on retaining the older serialization path for restricted contexts
Follow-up issues found while planning:
- Keep generic structural encodings above other encodings #10748 Keep generic structural encodings above other encodings. WD5 does this for
Widenonly - FoR scheme bit-packs its child outside the allowed schemes #10751 The FoR scheme bit-packed its child outside the allowed schemes. Fixed by
FoRScheme: declare and refine its bit-packed child #10757 -
ZipExecuteAdaptordispatches only on theif_truebranch #10908 The zip adaptors dispatch only onif_true, so an encodedif_falsebranch misses its kernel. Fixed by Dispatch zip kernels on either value branch #11138, merged on 2026-11-03
Unresolved questions
- Should we store the entire
DecimalDTypein theWidenmetadata, which includesscale? It is kind of pointless because the scale is always the same, but it also doesn't really hurt.- No, the metadata stores only the child precision. The scale and nullability of the child always equal those of the parent, so storing them only lets a file hold an invalid child. The format is in no edition yet, so this can still change.
- Should
ListandListViewoffsets always have anI64dtype, withWidenproviding the narrow physical width? (From the discussion on Epic: Implement Narrow integer encoding #10020.)
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 - feat(array): add Narrow integer encoding #10223
- feat(array): push arithmetic and selection through Narrow #10332
- feat(compress): retain Narrow integer children through compression #10333
- Avoid truncating dictionary fill codes #10745: dictionary
fill_nullno longer truncates the fill code (FX3) - Widen dictionary codes in Dict::fill_null #10806: dictionary
fill_nullwidens the codes instead of declining (follow-up of FX3) - Renamed from
NarrowtoWidenon 2026-10-28. - Add an in-memory
Widenencoding for integers #10864: in-memory integerWiden(WD1) - Add compute benchmarks for
Widen#10888: compute benchmarks forWiden(WD2) - Compute
Widenaggregates on the values #10890:Widenaggregates run on the child (WD3) - Run
Widenarithmetic, cast, fill null, zip and between on the values #10891:Widenarithmetic, cast, fill null, zip and between run on the child (WD4) - Separate the read and write IDs of array plugins #11057 (2026-11-02): separate plugin read and write IDs, so a
Widenwriter can emit existing wire forms without claiming their readers - Support decimal values in Widen #10946 (2026-11-03): decimal values in
Widen, with a writer-only plugin for decimal leaves (CD4 in Tracking Issue: Fix Canonical Decimal #10701) - Dispatch zip kernels on either value branch #11138 (2026-11-03):
zipkernels dispatch on either value branch, which fixesZipExecuteAdaptordispatches only on theif_truebranch #10908 - Push decimal comparisons, between, and fills through Widen #11003, Export decimal Widen arrays to Arrow with one copy #10994, and Push decimal aggregates through Widen #11004 (2026-11-03): decimal compare,
between, fills, Arrow export, and aggregates onWiden(CD4 and CD4b in Tracking Issue: Fix Canonical Decimal #10701) - Add shared conversion to canonical decimal arrays or Widen #11005 (2026-11-04):
WidenArray::from_stored_decimalconverts stored decimal values into the canonical array or a decimalWiden(CD4 in Tracking Issue: Fix Canonical Decimal #10701) - Keep same-scale decimal widening casts lazy #11006 (2026-11-04): same-scale decimal widening casts return a
Widen(CD4 in Tracking Issue: Fix Canonical Decimal #10701)
Activity
Avoid truncating dictionary fill codes (#10745)
Update the Narrow and decimal research for the first PRs
Update the Narrow and decimal research for the first PRs
[-]Tracking Issue: Add `Narrow` encoding for integers and decimals[/-][+]Tracking Issue: Add `Widen` encoding for integers and decimals[/+]