Support wide decimals in DecimalBytePartsArray and kernels - #9809
Conversation
Merging this PR will degrade performance by 3.08%
|
f7dfebe to
a566a28
Compare
DecimalBytePartsArray and kernels
Represent wide decimals with a signed high part and up to three unsigned low parts. Add validation, execution, kernel support, property tests, and assembly benchmarks while keeping serialization on the frozen format. Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Move array helpers onto a crate-private extension trait, preserve decimal precision and scale when replacing the MSP, and group slicing with the other compute operations. Inline canonical execution and select scalar storage directly from the lower-part count. Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
7283d4f to
a20094e
Compare
| .cast(array.msp().dtype().with_nullability(*target_nullability))?; | ||
|
|
||
| Ok(Some( | ||
| DecimalByteParts::try_new(new_msp, *target_decimal)?.into_array(), |
There was a problem hiding this comment.
I think this was redundant to begin with because we check above that target dtype is same as current modulo nullability
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
| divan::main(); | ||
| } | ||
|
|
||
| #[vortex_bench_support::cpu_features] |
There was a problem hiding this comment.
can you do this over the kernel working with [T] not vortex arrays. This is more noisy so it would be nice to only run if over small loops with allocs
|
|
||
| #[test] | ||
| fn test_cast_decimal_byte_parts_nullability() { | ||
| let mut ctx = array_session().create_execution_ctx(); | ||
| let decimal_dtype = DecimalDType::new(10, 2); | ||
| let array = | ||
| DecimalByteParts::try_new(buffer![100i32, 200, 300, 400].into_array(), decimal_dtype) | ||
| .unwrap(); | ||
|
|
||
| // Cast to nullable decimal | ||
| let casted = array | ||
| .into_array() | ||
| .cast(DType::Decimal(decimal_dtype, Nullability::Nullable)) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| casted.dtype(), | ||
| &DType::Decimal(decimal_dtype, Nullability::Nullable) | ||
| ); | ||
|
|
||
| // Verify the values are preserved | ||
| let decoded = casted.execute::<DecimalArray>(&mut ctx).unwrap(); | ||
| assert_eq!(decoded.len(), 4); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_cast_decimal_byte_parts_nullable_to_non_nullable() { | ||
| let mut ctx = array_session().create_execution_ctx(); | ||
| let decimal_dtype = DecimalDType::new(10, 2); | ||
| let array = DecimalByteParts::try_new( | ||
| PrimitiveArray::from_option_iter([Some(100i32), None, Some(300)]).into_array(), | ||
| decimal_dtype, | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| // Cast to non-nullable should fail due to nulls - force evaluation via execute::<Canonical> | ||
| let result = array | ||
| .into_array() | ||
| .cast(DType::Decimal(decimal_dtype, Nullability::NonNullable)) | ||
| .and_then(|a| a.execute::<Canonical>(&mut ctx).map(|c| c.into_array())); | ||
| assert!(result.is_err()); | ||
| } | ||
|
|
||
| #[rstest] | ||
| #[case::i32(DecimalByteParts::try_new( | ||
| buffer![100i32, 200, 300, 400, 500].into_array(), | ||
| DecimalDType::new(10, 2), | ||
| ).unwrap())] | ||
| #[case::i64(DecimalByteParts::try_new( | ||
| buffer![1000i64, 2000, 3000, 4000].into_array(), | ||
| DecimalDType::new(19, 4), | ||
| ).unwrap())] | ||
| #[case::nullable(DecimalByteParts::try_new( | ||
| PrimitiveArray::from_option_iter([Some(100i32), None, Some(300), Some(400), None]) | ||
| .into_array(), | ||
| DecimalDType::new(10, 2), | ||
| ).unwrap())] | ||
| #[case::single(DecimalByteParts::try_new( | ||
| buffer![42i32].into_array(), | ||
| DecimalDType::new(5, 1), | ||
| ).unwrap())] | ||
| #[case::negative(DecimalByteParts::try_new( | ||
| buffer![-100i32, -200, 300, -400, 500].into_array(), | ||
| DecimalDType::new(10, 2), | ||
| ).unwrap())] | ||
| #[case::one_lower_part(i128_parts( | ||
| vec![1i128 << 70, -(1i128 << 70), 5, (1i128 << 64) - 1, 0], | ||
| Validity::NonNullable, | ||
| ))] | ||
| #[case::three_lower_parts(i256_parts( | ||
| vec![i256_of(1, 0), i256_of(-1, 5), i256_of(0, u128::MAX)], | ||
| Validity::NonNullable, | ||
| ))] | ||
| fn test_cast_decimal_byte_parts_conformance(#[case] array: DecimalBytePartsArray) { |
There was a problem hiding this comment.
we don't need this many new tests
| // The MSP alone only determines the ordering when it holds the whole value. With | ||
| // lower parts present, fall back to comparing the canonical decimal. | ||
| if !lhs.lower_parts().is_empty() { | ||
| return Ok(None); | ||
| } |
There was a problem hiding this comment.
add a todo saying we could be smarter here
| impl TakeReduce for DecimalByteParts { | ||
| fn take(array: ArrayView<'_, Self>, indices: &ArrayRef) -> VortexResult<Option<ArrayRef>> { | ||
| // Taking with nullable indices makes every taken part nullable, but lower parts must | ||
| // stay non-nullable `u64` — validity belongs to the MSP alone. Fall back to the | ||
| // canonical path rather than rebuilding parts we would have to strip nullability from. | ||
| if indices.dtype().is_nullable() && !array.lower_parts().is_empty() { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| array | ||
| .map_parts(|part| part.take(indices.clone())) | ||
| .map(|a| Some(a.into_array())) | ||
| } | ||
| } |
There was a problem hiding this comment.
add a todo saying we could impl this using fill null or smthing else
| let mut value = T::from(msp).vortex_expect("MSP fits in the output type"); | ||
| for part in lower { | ||
| value = (value << LOWER_PART_BITS) | ||
| | T::from(part).vortex_expect("lower word fits in the output type"); |
There was a problem hiding this comment.
Can we remove this failure it will likely break simd. Can this ever fail. It should be checked outside the loop once and not checked here?
There was a problem hiding this comment.
Can add infallible upcast from u64/i64 to i256 instead
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
`DecimalBytePartsArray` previously stored the entire unscaled decimal value in one signed integer child, limiting it to values that fit in 64 bits. It now supports wide decimals by representing each value as integer parts that can be compressed independently, while preserving the decimal's logical precision, scale, and nullability. The array has a signed most significant part (MSP) and up to three unsigned 64-bit lower parts, ordered most significant first. Splitting canonical decimal storage produces: | Decimal storage | Children | | --- | --- | | `i8` / `i16` / `i32` / `i64` | Signed MSP only; shares the original value buffer | | `i128` | `i64` MSP + one `u64` lower part | | `i256` | `i64` MSP + three `u64` lower parts | Only the MSP carries validity. Every lower part must be a non-nullable `u64` array with the same length, and splitting wide decimals zeroes the parts at null positions. All children remain `ArrayRef`s, so their individual encodings are independent of the decimal representation. `execute::<DecimalArray>` reassembles a `DecimalArray` from the MSP and lower parts children. `take` with nullable indices is not yet supported by the DecimalByteParts kernel for arrays with lower parts; it falls back to canonical execution. Taking each part directly would make the lower parts nullable, violating the representation's invariant. The frozen serializer also continues to reject arrays with lower parts. This PR also refines the splitting and assembly modules. * Assembly takes `ArrayRef`s instead of `PrimitiveArray`s so that in the future, we can add special fast paths for constant arrays * Assembly casts lower parts to `u64`s, allowing for assembly of narrowed lower parts. * Assembly loop is optimized such that it vectorizes for `i256` assembly on local runs. --------- Signed-off-by: Matt Katz <mhkatz97@gmail.com>
`DecimalBytePartsArray` previously stored the entire unscaled decimal value in one signed integer child, limiting it to values that fit in 64 bits. It now supports wide decimals by representing each value as integer parts that can be compressed independently, while preserving the decimal's logical precision, scale, and nullability. The array has a signed most significant part (MSP) and up to three unsigned 64-bit lower parts, ordered most significant first. Splitting canonical decimal storage produces: | Decimal storage | Children | | --- | --- | | `i8` / `i16` / `i32` / `i64` | Signed MSP only; shares the original value buffer | | `i128` | `i64` MSP + one `u64` lower part | | `i256` | `i64` MSP + three `u64` lower parts | Only the MSP carries validity. Every lower part must be a non-nullable `u64` array with the same length, and splitting wide decimals zeroes the parts at null positions. All children remain `ArrayRef`s, so their individual encodings are independent of the decimal representation. `execute::<DecimalArray>` reassembles a `DecimalArray` from the MSP and lower parts children. `take` with nullable indices is not yet supported by the DecimalByteParts kernel for arrays with lower parts; it falls back to canonical execution. Taking each part directly would make the lower parts nullable, violating the representation's invariant. The frozen serializer also continues to reject arrays with lower parts. This PR also refines the splitting and assembly modules. * Assembly takes `ArrayRef`s instead of `PrimitiveArray`s so that in the future, we can add special fast paths for constant arrays * Assembly casts lower parts to `u64`s, allowing for assembly of narrowed lower parts. * Assembly loop is optimized such that it vectorizes for `i256` assembly on local runs. --------- Signed-off-by: Matt Katz <mhkatz97@gmail.com>
`DecimalBytePartsArray` previously stored the entire unscaled decimal value in one signed integer child, limiting it to values that fit in 64 bits. It now supports wide decimals by representing each value as integer parts that can be compressed independently, while preserving the decimal's logical precision, scale, and nullability. The array has a signed most significant part (MSP) and up to three unsigned 64-bit lower parts, ordered most significant first. Splitting canonical decimal storage produces: | Decimal storage | Children | | --- | --- | | `i8` / `i16` / `i32` / `i64` | Signed MSP only; shares the original value buffer | | `i128` | `i64` MSP + one `u64` lower part | | `i256` | `i64` MSP + three `u64` lower parts | Only the MSP carries validity. Every lower part must be a non-nullable `u64` array with the same length, and splitting wide decimals zeroes the parts at null positions. All children remain `ArrayRef`s, so their individual encodings are independent of the decimal representation. `execute::<DecimalArray>` reassembles a `DecimalArray` from the MSP and lower parts children. `take` with nullable indices is not yet supported by the DecimalByteParts kernel for arrays with lower parts; it falls back to canonical execution. Taking each part directly would make the lower parts nullable, violating the representation's invariant. The frozen serializer also continues to reject arrays with lower parts. This PR also refines the splitting and assembly modules. * Assembly takes `ArrayRef`s instead of `PrimitiveArray`s so that in the future, we can add special fast paths for constant arrays * Assembly casts lower parts to `u64`s, allowing for assembly of narrowed lower parts. * Assembly loop is optimized such that it vectorizes for `i256` assembly on local runs. --------- Signed-off-by: Matt Katz <mhkatz97@gmail.com>
`DecimalBytePartsArray` previously stored the entire unscaled decimal value in one signed integer child, limiting it to values that fit in 64 bits. It now supports wide decimals by representing each value as integer parts that can be compressed independently, while preserving the decimal's logical precision, scale, and nullability. The array has a signed most significant part (MSP) and up to three unsigned 64-bit lower parts, ordered most significant first. Splitting canonical decimal storage produces: | Decimal storage | Children | | --- | --- | | `i8` / `i16` / `i32` / `i64` | Signed MSP only; shares the original value buffer | | `i128` | `i64` MSP + one `u64` lower part | | `i256` | `i64` MSP + three `u64` lower parts | Only the MSP carries validity. Every lower part must be a non-nullable `u64` array with the same length, and splitting wide decimals zeroes the parts at null positions. All children remain `ArrayRef`s, so their individual encodings are independent of the decimal representation. `execute::<DecimalArray>` reassembles a `DecimalArray` from the MSP and lower parts children. `take` with nullable indices is not yet supported by the DecimalByteParts kernel for arrays with lower parts; it falls back to canonical execution. Taking each part directly would make the lower parts nullable, violating the representation's invariant. The frozen serializer also continues to reject arrays with lower parts. This PR also refines the splitting and assembly modules. * Assembly takes `ArrayRef`s instead of `PrimitiveArray`s so that in the future, we can add special fast paths for constant arrays * Assembly casts lower parts to `u64`s, allowing for assembly of narrowed lower parts. * Assembly loop is optimized such that it vectorizes for `i256` assembly on local runs. --------- Signed-off-by: Matt Katz <mhkatz97@gmail.com>
) ## Summary `DecimalBytePartsArray` stored the whole unscaled decimal value in one signed integer child. That capped it at values that fit in 64 bits. This PR adds support for `i128` and `i256` decimals. Each value is now split into a signed most significant part (MSP) plus up to three unsigned 64-bit lower parts. Every part is an independent child array, so each one compresses on its own. The frozen `vortex.decimal_byte_parts` file format is untouched. Arrays with lower parts serialize under a new `vortex.decimal_byte_parts.v2` format owned by a plugin. This is the integration branch for three reviewed sub-PRs: #9808 (splitting and assembly), #9809 (array and kernels), and #9810 (serde plugin). ## Representation | Decimal storage | Children | | --- | --- | | `i8`, `i16`, `i32`, `i64` | Signed MSP only. Shares the original value buffer. | | `i128` | `i64` MSP holding the high 64 bits, plus one `u64` lower part. | | `i256` | `i64` MSP holding the high 64 bits, plus three `u64` lower parts. | Parts are ordered most significant first. The MSP carries the sign and the null mask. Lower parts are non-nullable unsigned integers. A lower part may use a narrower dtype such as `u8`, `u16`, or `u32` when its values fit. Its position still counts as a full 64-bit window. Splitting writes zeroes at null positions so stray bytes in null slots do not hurt compression of the lower parts. ## Splitting and assembly `DecimalByteParts::encode` splits a `DecimalArray` into parts. `split_decimal` exposes the raw parts for callers that want to build the array themselves. Assembly picks a path from the number of lower parts: - **None.** Reuse the MSP buffer as decimal storage without copying. - **One.** Combine the MSP and the lower part into an `i128`. - **Two.** The lower parts form the low 128 bits of an `i256`. The MSP is sign-extended into the high 128 bits. - **Three.** The MSP and the first lower part form the high 128 bits. The remaining two form the low 128 bits. Assembly casts narrowed lower parts back to `u64` first. The `i256` assembly loop vectorizes on local ARM64 builds. Marking `i256`'s shifts `#[inline]` removed three out-of-line calls per row. | Rows | With `#[inline]` | Without | | --- | --- | --- | | 1,024 | 0.917 µs | 6.207 µs | | 8,192 | 6.332 µs | 48.540 µs | Medians of five alternating release runs. `From<i64>` and `From<u64>` for `i256` are added in `vortex-array`. ## Compute `execute::<DecimalArray>` reassembles the canonical array from all parts. The compare, filter, is-constant, and take kernels understand lower parts. Slice and mask apply per child. Two limits are documented in code. Take with nullable indices on an array with lower parts falls back to canonical execution, because taking each part would make the lower parts nullable. The CUDA kernel rejects arrays with lower parts, because GPU reassembly is not implemented yet. ## Serialization `DecimalBytePartsPlugin` owns both wire formats and picks one from the array layout. | Array layout | Serialized ID | | --- | --- | | MSP only | `vortex.decimal_byte_parts` | | MSP plus one to three lower parts | `vortex.decimal_byte_parts.v2` | The v1 format is frozen. Its metadata and decoder live in `plugin/v1.rs` and are byte-identical to what shipped. The v1 decoder rejects any payload that claims lower parts. The v2 format records the MSP's integer type and one integer type per lower part. The lower part count is the length of that list. The decoder validates every type and restores each child with its recorded dtype. The v2 format itself accepts zero lower parts. The plugin only chooses it when lower parts are present, so files stay readable by older readers whenever possible. The in-memory encoding ID is now `vortex.decimal_byte_parts.v2`. The registry maps both wire IDs to the plugin, so existing v1 files read through it with no migration. No edition declares the v2 format yet. Writing an array with lower parts under an edition that does not permit v2 fails with an explicit error rather than silently falling back. ## Compression The BtrBlocks decimal scheme still narrows decimals that fit in `i64` and wraps them in a single-part array. Wide decimals stay canonical. Nothing in this PR writes the v2 format through the compressor. The scheme now declares `vortex.decimal_byte_parts` as its produced encoding rather than the in-memory ID. Since #9914 that list holds the serialized IDs a scheme writes, and this scheme only ever writes the frozen format. Without that change every writer that filters schemes by edition would drop the decimal scheme, because no edition permits the in-memory v2 name. ## API Changes **Breaking.** Registering `DecimalByteParts` directly no longer supports serde for either format. Replace `session.arrays().register(DecimalByteParts)` with `session.arrays().register(DecimalBytePartsPlugin)`. `vortex_decimal_byte_parts::initialize` already does this. **Breaking.** `dbp_encode` is replaced by `DecimalByteParts::encode`. **Breaking.** `DecimalBytesPartsMetadata` is no longer public. `DecimalBytePartsV2Metadata` is exposed instead. The in-memory encoding ID string changed from `vortex.decimal_byte_parts` to `vortex.decimal_byte_parts.v2`. This affects display and trace output, not files. New public items: `DecimalByteParts::try_new_with_lower_parts`, `DecimalByteParts::encode`, `split_decimal`, `DecimalParts`, `DecimalBytePartsPlugin`, `decimal_byte_parts_v1_id`, and `decimal_byte_parts_v2_id`. --------- Signed-off-by: "Matt Katz" <mhkatz97@gmail.com> Signed-off-by: Matt Katz <mhkatz97@gmail.com>
DecimalBytePartsArraypreviously stored the entire unscaled decimal value in one signed integer child, limiting it to values that fit in 64 bits. It now supports wide decimals by representing each value as integer parts that can be compressed independently, while preserving the decimal's logical precision, scale, and nullability.The array has a signed most significant part (MSP) and up to three unsigned 64-bit lower parts, ordered most significant first. Splitting canonical decimal storage produces:
i8/i16/i32/i64i128i64MSP + oneu64lower parti256i64MSP + threeu64lower partsOnly the MSP carries validity. Every lower part must be a non-nullable
u64array with the same length, and splitting wide decimals zeroes the parts at null positions. All children remainArrayRefs, so their individual encodings are independent of the decimal representation.execute::<DecimalArray>reassembles aDecimalArrayfrom the MSP and lower parts children.takewith nullable indices is not yet supported by the DecimalByteParts kernel for arrays with lower parts; it falls back to canonical execution. Taking each part directly would make the lower parts nullable, violating the representation's invariant. The frozen serializer also continues to reject arrays with lower parts.This PR also refines the splitting and assembly modules.
ArrayRefs instead ofPrimitiveArrays so that in the future, we can add special fast paths for constant arraysu64s, allowing for assembly of narrowed lower parts.i256assembly on local runs.