Repository navigation
Conversation
Rust's choose_and_compress (vortex-compressor compressor/cascade.rs) compresses the winning scheme on the full array and keeps the result only when its byte size is below the array's exact uncompressed size; otherwise the array stays canonical. Java ran the winner and kept whatever came out. The two disagree whenever the sample favors a scheme the full array does not. Bit-packing pads to 1024-value blocks, so a 1024-row sample of 1500 15-bit u16 values packs to 1920 of 2048 bytes and wins, while the full array pads to 2048 values: 3840 bytes over its 3000. Rust stores those as vortex.primitive. Found while comparing nullable columns against vortex-jni (#604), where it decides a zone table's four null counts (FoR wins their estimate, its bit-packed residuals pad past the 32 raw bytes) and a mostly-null column's sparse patch indices. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust measures a sampled scheme by compressing the sample with it, children included (compress_child runs the full choose_and_compress on each child), so a child's cost is its winner's real output, and the child stays canonical when that is not smaller. Java picked the child's winner the same way, but when the winner won on a stats estimate it took the estimate as the cost. The estimate can be far off: a FoR ratio says nothing of the 1024-value block its bit-packing pads a short child to. On a 1024-row sample of runs of 20 wide values with null runs, FoR estimated 81 bytes for run-end's 65 ends, which encode to 1283 and are kept as 130 raw bytes. Run-end came out 49 bytes too cheap and beat RLE, which Rust picks (found comparing nullable columns against vortex-jni, #604). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust's v1 FoRScheme declares no children: it subtracts the minimum and bit-packs the
residuals at once with BITPACKING_V1 ("if any other scheme was preferable, it would be
chosen instead of bitpacking", vortex-btrblocks schemes/integer/for_.rs), keeping them a
primitive when the best width is the native one (compress_global returns None).
Java opened the residuals as a cascade child, so any scheme could win them. On a zone
table's null counts [4681, 4681, 4681, 4682] the child became a vortex.dict, giving
for(dict) where vortex-jni writes a vortex.primitive (FoR wins the estimate, its
bit-packing pads past the 32 raw bytes, and the array stays canonical). Found comparing
nullable columns against vortex-jni (#604).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
…does Rust narrows these position arrays before compressing them (PrimitiveArray::narrow: the smallest unsigned type that holds the largest value): runend_encode narrows its u64 ends, and rle_compress its u16 indices and u64 offsets. Java always wrote u32 ends, u16 indices and u64 offsets. The width decides what the cascade does with them. A 32768-row column's ends fit u16, and bit-packing 15 of 16 bits pads past their raw size, so Rust keeps them a vortex.primitive where Java's u32 ends went through FoR. RLE indices within a chunk rarely pass 255, so Rust's u8 indices cost half of Java's u16, which tipped the choice between RLE and run-end (found comparing nullable columns against vortex-jni, #604). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust's file strategy builds two compressors from one builder: the data compressor excludes IntDictScheme (DictStrategy already dictionary-encodes suitable columns as a layout), while the stats compressor, which also compresses dict values, keeps every scheme (vortex-file strategy.rs). Java compressed stats tables with its data cascade codecs, so an integer dict never competed there. A zone table column with a repeated value differs: the per-zone null counts [1, 0, 0, 0] of a column with one null are a vortex.dict in vortex-jni's file (16 bytes of values and 4 of codes against 32 raw). The candidates are now the probe's codecs, which are the data cascade with the integer dict back in (found comparing nullable columns, #604). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
) Java wrote a nullable integer column as vortex.masked(values, validity) and ran the cascade over the values alone. Rust has no such wrapper: every array carries its own validity, so the leaf that stores the values holds it as a plain vortex.bool child (vortex.primitive, fastlanes.bitpacked after any patches), and the encodings above pass it down: FoR and ZigZag to their output, run-end to its run values (a run of nulls is one invalid run), RLE to its indices padded to a whole chunk, dict to its codes, Pco by compressing only the valid values. Rust never compresses that bitmap. The wrapper changed more than the tree. Rust's sampled schemes are measured with the bitmap, its estimated ones are not, so the two compressors ranked schemes differently on nullable data, and the dict layout could not take Rust's probe. Now: - NullableData flows through CascadingCompressor for integer dtypes. Stats skip the nulls as Rust's do (computed over the valid values: same min, max, distinct count and runs, since a null continues the run before it), the baseline adds the packed bitmap, and only encoders that keep validity compete (acceptsNullable). - The compressor's own checks come first, as in choose_and_compress: all null is a null constant, valid rows holding one value a constant masked by a plain bitmap (Rust's compress_constant). A column without nulls stores no bitmap, as Rust imports an all-valid Arrow array. - Sparse follows Sparse::encode: past 90% null the fill is null and the valid rows are the patches (with Rust's all-valid bitmap on their values); otherwise nulls are patches. - Bit-packing counts a null at width 0 and patches only valid values (bit_width_histogram, gather_patches); FoR takes its reference from the valid values and zeroes null residuals. - The dict layout probe now runs for nullable integers too, as Rust's DictStrategy does. Floats keep vortex.masked until ALP and ALP-RD carry a validity (keepsValidity). NullableIntegerInteropIntegrationTest writes 17 column shapes through vortex-jni and vortex-java and asserts the same encoding tree, data and zone table, then reads Java's file (default and compact) through vortex-jni, full, filtered and row-range. Not covered: patched bit-packing, whose chunk offsets Rust stores and Java does not yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust's ALPScheme compresses the encoded integers through compress_child for either float width (vortex-btrblocks schemes/float/alp.rs). Java's encodeCascade only opened that child for F64 and fell back to the terminal encode for F32, which writes the i32s as a raw vortex.primitive, so an F32 column ALP won never got them bit-packed. vortex-jni writes an F32 column of halves as alp(bitpacked), and one with one decimal as alp(bitpacked) with patches; Java wrote alp(primitive) for both. The F32 path now mirrors the F64 one: computeF32 shared by both entry points, the patches owned by the node and the encoded ints left open (found comparing nullable floats against vortex-jni, #604). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust's encode_generic (encodings/alp/src/alp_rd/mod.rs) packs the dictionary-coded left parts at the encoder's left_bit_width (the width of the largest code, at least 1 bit: bit_width(dict_len - 1) in the alp crate) and the right parts at right_bit_width, both with bitpack_encode_unchecked. Java ran them through the bit-packing encoder, whose histogram finds narrower widths when the values allow: on quarters the right parts are all zero and packed in 0 bits. That made ALP-RD measure smaller in Java than in Rust, so it won columns Rust gives to a dictionary: a float column with five distinct values, or taxi's Airport_fee shape, which vortex-jni writes with a dict layout (found comparing nullable floats, #604). The parts now go through BitpackedEncodingEncoder.encodeAtWidth, Rust's unchecked pack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
The integer change extended to every primitive: a nullable float column no longer goes
through vortex.masked. As in Rust:
- ALP encodes every slot, keeps the validity on the encoded ints and drops the exceptions
at null rows from its patches ("we remove null exceptions in order to keep the Patches
small", alp_encode_components_typed). Its terminal encode, used without a cascade, keeps
the encoded ints a primitive with the validity.
- ALP-RD splits every slot and keeps the validity on its bit-packed left parts
(encode_generic).
- Sparse takes a nullable float only when it is more than 90% null (Rust's
NullDominatedSparseScheme), and compresses only its indices: the patch values stay as
they are, with the all-valid bitmap Rust's filter leaves on them.
- The dict layout probe now runs for every primitive.
NullableFloatInteropIntegrationTest writes 11 F32/F64 shapes through vortex-jni and
vortex-java and asserts the same encoding tree, then reads Java's file (default and
compact) through vortex-jni, full, filtered and row-range. EncodingTrees is the tree walk
both nullable cross-checks share.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
Rust breaks a tie between two equal scores by registration order within each pass (choose_best_scheme), and registers integers as FoR, ZigZag, BitPacking, Sparse, IntDict, RunEnd, Sequence, RLE, Delta and floats as ALP, ALP-RD, FloatDict, sparse, RLE (vortex-btrblocks session.rs). Java's list had RLE and run-end ahead of dict and bit-packing last, so a tie went the other way. Ties are rare but real: on a float column of runs with null runs, RLE and dict both measure 644 bytes on the dict layout probe's sample. Rust takes the dict and writes a dict layout; Java took RLE and wrote none (found comparing nullable floats, #604). One list serves both families, so Dict sits before Sparse as floats have them; for integers the two only compete in the probe and zone tables, where a tie of their two estimates is the one case still broken the other way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #604. A nullable primitive column no longer goes through
vortex.masked: as in Rust, its validity is a plainvortex.boolchild of the encoding that stores the values, and encodings above that pass it down to their children. The cascade measures the bitmap the way Rust does, so it picks the same scheme as Rust, and the dict layout runs Rust's probe on nullable columns too.Comparing nullable columns against vortex-jni turned up eight more parity bugs. Each one has its own commit:
choose_and_compressacceptance).narrowdoes.Most of these also change non-null columns.
Tests
NullableIntegerInteropIntegrationTest: 17 shapes. Each asserts the same encoding tree as vortex-jni (data and zone table), then that vortex-jni reads Java's file back (default and compact; full, filtered, row-range).NullableFloatInteropIntegrationTest: 12 F32/F64 shapes, checked the same way.DictAdmissionInteropIntegrationTestgains nullable cases../mvnw verifyis green locally.Known gaps, not in this PR
chunk_offsetsfor bit-packing patches and Java does not. This predates the PR, so the cross-checks leave out patched columns.🤖 Generated with Claude Code
https://claude.ai/code/session_01SwhK4W5cgj7y7s4ZPCif5x