fix(vortex-row): decimal sort keys are not memcmp-comparable across chunks#8937
Conversation
Decimal chunks of one column compress to different physical value widths, but the row encoder sized and encoded keys at the chunk's values_type(). Keys from differently compressed chunks therefore had different lengths, and memcmp between them no longer matched value order, silently corrupting any ORDER BY / top-k over a multi-chunk decimal column. Derive the key width from the declared decimal dtype instead (the same rule row_width_for_dtype already uses), and bring each chunk's physical values to that width via a new converted_buffer helper in vortex-array: zero-copy when the widths already match, lossless widening otherwise, and an error for any value that violates its declared precision (previously encoded silently). Signed-off-by: Nemo Yu <zyu379@wisc.edu>
a10y
left a comment
There was a problem hiding this comment.
thank you for finding this! Let's please add a regression test in vortex-row, just the case from the PR description is a good starting point
|
We probably should have a fuzz target too for vortex-row :/ Doesn't need to be in this PR but probably a good follow up Additionally, had claude take a look, it notes |
Null slots hold unspecified backing bytes, so converted_buffer could report a spurious overflow for a value the encoder never reads. Take the validity mask and validate-then-convert: only valid values must fit the target width (checked before the mask, so the happy path never touches it), then an infallible vectorized conversion. Pure widening routes through widened_buffer and needs no validation at all. Also route row_width_for_dtype's decimal arm through decimal_key_type so the size plan and the encoder share one width rule. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
- keys from chunks with different physical widths compare like their values, ascending and descending (fails on the pre-fix encoder) - garbage backing a null slot encodes without a spurious overflow - a valid value that does not fit the dtype-derived key width fails loudly instead of encoding a corrupt key Signed-off-by: Nemo Yu <zyu379@wisc.edu>
…ey-width Signed-off-by: Nemo Yu <zyu379@wisc.edu> # Conflicts: # vortex-row/src/tests.rs
Merging this PR will not alter performance
Comparing Footnotes
|
| // Pass 1: only *valid* values must fit `W`; null-slot garbage is exempt. Checking | ||
| // overflow before the mask keeps mask lookups off the happy path. |
There was a problem hiding this comment.
is this true? this still forces a branch in the inner loop
There was a problem hiding this comment.
i think we should handle when validity is alltrue or nonnullable without a branch, and then if the validity is real Mask then we do the branch
The narrowing validation scanned with a short-circuiting find, which branches per element and consults the mask on every overflow. Detect overflow with a non-short-circuiting fold instead, so the scan vectorizes; the mask-aware rescan only runs once an overflow exists. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
Signed-off-by: Nemo Yu <zyu379@wisc.edu>
Follow-up to #8937, where @a10y suggested a fuzz target for vortex-row. ## What it does Adds a `row_encode` fuzz target that generates 1–3 same-length columns of arbitrary dtypes (via the existing `ArbitraryArray` machinery) with independent per-column `RowSortField` configs (descending × nulls-first), and checks three properties: 1. **Sizes**: `compute_row_sizes`' per-row `{fixed, var}` totals equal the encoded key lengths. 2. **Order**: byte comparison of any two keys equals a logical tuple comparison of the row values, asserted pairwise. The oracle is independent of the encoder: it lifts each row into a `RowKey` (recursive over structs/FSLs) and compares with the byte format's exact semantics, nulls positioned by `nulls_first` regardless of direction, and `descending` inverting leaf values only. 3. **Cross-chunk**: keys from *separate* encode calls over different slices of the same columns, still compare correctly against each other. This is the contract sort/top-k operators rely on when comparing keys across batches. Unsupported dtypes (List, Variant, Union, Extension, Decimal256) assert that the encoder rejects them. The target is wired into the scheduled fuzz workflow like the existing targets. --------- Signed-off-by: Nemo Yu <zyu379@wisc.edu>
The bug
ORDER BY/ top-k on a decimal column silently returns wrongly ordered rows whenever the column spans chunks whose physical value widths differ. No error is raised; the output looks plausible.Example
Take a
DECIMAL(7, 5)tip column andORDER BY tip DESC. Two rows land in different chunks, and compression picks a different physical width for each chunk:tip = 0.00484484tip = 0.0009191A descending sort key is built per chunk: write the value big-endian, flip the sign bit, invert the value bytes (for DESC), and prefix the non-null sentinel
0xFE. Before this fix, the number of value bytes came from the chunk's type:The sorter compares keys with
memcmp:But byte 1 means different things in the two keys: in the 3-byte key it is the high-order byte of a two-byte number, while in the 2-byte key it is the only byte of a one-byte number. The comparison is meaningless — and DESC encoding promises bigger value ⇒ smaller key, so the smaller key wins: the sorter ranks
0.00091as a larger tip than0.00484. Ascending breaks symmetrically.With this fix, both chunks encode at the width the declared dtype implies (
DECIMAL(7,5)→ i32 → 4 value bytes), whatever their physical storage:Equal-length keys, every byte position aligned —
0.00484correctly sorts first in the descending order.The fix
Derive the key width from the declared dtype, so every chunk of a column encodes identical-length keys:
decimal_key_type(vortex-row): the dtype-derived width, used by both the sizing pass and the encode dispatch.converted_buffer<W>(vortex-array, next towidened_buffer): returns a chunk's values at exactly widthW. Zero-copy when the chunk is already stored atW(the common case), lossless widening otherwise, and an error for any value that violates its declared precision, such values previously encoded a garbage key silently.The encode loop itself is unchanged; only where it reads its values from changed.