Repository navigation
fix(writer): Rust's global-dict shape for numeric columns (null entry, U16 codes) - #467
Merged
Merged
Conversation
This was referenced Oct 9, 2026
Closed
dfa1
force-pushed
the
perf/port-rust-verdicts
branch
from
October 9, 2026 07:47
7cc6cc3 to
31df7d9
Compare
…, U16 codes) ## fix(writer): store null as a global-dict entry for numeric columns, as Rust does A nullable numeric column written as a global dict layout had masked codes: a row validity bitmap over codes whose null slots held a real code. Rust's dict layout writer instead makes null one more pool entry, invalid in the values' validity, and always writes non-nullable codes (vortex-layout dict/writer.rs), setting DictLayoutMetadata is_nullable_codes. Java now writes the same shape and the same metadata (field 2 = false; vortex-jni reads either, so this is wire parity, not a read fix). The placeholder codes also skewed codes-side estimates: Sparse saw taxi Airport_fee's null placeholder as part of a 90% value. Taxi 2024-01: 42,323,238 -> 42,286,742 bytes (vortex-jni 44,463,892); 12 dict columns shrink by the dropped mask (e.g. VendorID 383,806 -> 379,698, mta_tax 213,724 -> 210,288). Airport_fee stays 1.19x jni: its codes are U8 where Rust's are U16 (next commit). OHLC 10M rows: 58,230,650 -> 58,230,658 bytes (metadata field). JavaVsJniWriteBenchmark.javaWriteCascading, 5 forks: 0.763 +- 0.023 -> 0.756 +- 0.017 ops/s (per fork 0.764 0.766 0.728 0.734 0.787) gc.alloc.rate.norm 2.49 -> 2.49 GB/op New interop test: vortex-jni reads a nullable F64 global dict back, full scan and filtered. ## fix(writer): write global-dict codes as U16, Rust's default Rust's DictLayoutConstraints derive the codes ptype from the configured maximum dictionary length, not the actual one: U8 only when max_len <= 255, and the default max_len is u16::MAX, so a default Rust global dict always has U16 codes (vortex-jni writes codes_ptype = U16 even for a 4-entry dictionary). Java sized them to the dictionary. The width matters beyond bytes on disk: the cascade scores candidates against the codes' raw width, so on U8 codes Sparse's estimate beat bit-packing where on U16 it does not. codePTypeForSize and the U8/U32 branches of emitCodes are now dead and deleted. Taxi 2024-01: 42,286,742 -> 42,342,166 bytes (vortex-jni 44,463,892) Airport_fee 495,482 -> 411,043 (jni 415,648: 1.19x -> 0.99x) payment_type 698,449 -> 763,892 (jni 731,660: 0.95x -> 1.04x) store_and_fwd_flag 63,370 -> 66,460 (jni 47,536) mta_tax 210,288 -> 217,678 (jni 181,664) extra 1,339,137 -> 1,399,146 (jni 1,791,864) Several columns move away from vortex-jni; U16 is Rust's rule, so the remaining gap on those is another divergence (chunk sizing is the known candidate), not a reason to keep U8. OHLC 10M rows: unchanged at 58,230,658 bytes. JavaVsJniWriteBenchmark.javaWriteCascading, 5 forks: 0.756 +- 0.017 -> 0.769 +- 0.026 ops/s (per fork 0.795 0.726 0.747 0.819 0.759) gc.alloc.rate.norm 2.49 -> 2.50 GB/op Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015R8cf8YM8XFQqrkyhSAtYr
dfa1
force-pushed
the
fix/primitive-dict-null-entry
branch
from
October 9, 2026 07:52
5a227dc to
594c170
Compare
dfa1
added a commit
that referenced
this pull request
Oct 9, 2026
…gh the mask vortexRead/vortexReadMultiColumn cast c.column(...) straight to DoubleArray / IntArray and crashed with ClassCastException once nullable global-dict columns began decoding to a MaskedArray (null as an invalid dictionary entry, as Rust writes it, #467/#468). Fold the values under the mask, as the Parquet batch path folds its arrays. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015R8cf8YM8XFQqrkyhSAtYr
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.
Stacked on #466 (← #465 ← #464).
Fixes the
Airport_feeregression surfaced in #466 by matching Rust's global dict layout shape. That uncovered two divergences, each its own commit.Commits and numbers
Taxi = NYC taxi 2024-01 (2.96M rows), bytes per column via
InspectorTree, vortex-jni writing the same parquet. Throughput =JavaVsJniWriteBenchmark.javaWriteCascading, 5 forks.Airport_fee(jni 415,648)53c78827null as a dict entry, non-nullable codes5a227dc0U16 codes (Rust's default)vortex-jni taxi total: 44,463,892. OHLC 10M: 58,230,650 → 58,230,658 (+8 B, metadata field).
What Rust does, and how we found it
DictLayoutMetadata.is_nullable_codesis set. Java used masked codes whose null slots held a real code. Now the same shape and metadata (vortex-jni reads either form; verified by flipping the field, so this is wire parity, not a read fix). 12 dict columns shrink by the dropped mask.Airport_feestayed at 1.19×. A probe against vortex-jni showed Rust does choose Sparse at ≥ 90% on a plain U8 column, so the fix(writer): port Rust's scheme verdicts (taxi 60 → 42 MB, tip_amount fixed; +27% write) #466 rule was right. vortex-jni's layout metadata held the answer:08 01 10 00 18 00iscodes_ptype = U16for a 4-entry dictionary.DictLayoutConstraintspick U8 only when the configuredmax_len <= 255; the default isu16::MAX. The cascade scores candidates against the codes' raw width, so on U8 bit-packing scored half and lost to Sparse's estimate.Honest side effects of U16
Several dict columns move away from vortex-jni:
payment_type0.95× → 1.04×,store_and_fwd_flag1.33× → 1.40×,mta_tax1.16× → 1.20×,extra0.75× → 0.78×. Total +0.13%. U16 is Rust's rule (prime directive: size is not a reason to diverge), so what remains on those columns is a different divergence. Chunk sizing (Rust repartitions; Java writes one chunk per batch, parked by decision) is the known candidate.Testing
./mvnw verify -pl writer -am: 1995 tests (5codePTypeForSizecases deleted with the method), checkstyle, javadoc./mvnw verify -pl integration -am: 365 tests, 0 failuresJavaWritesRustReadsIntegrationTest#javaWriter_rustReader_globalDict_nullableF64_nullAsDictEntry(vortex-jni full + filtered scan),DictColumnStateTestemit/null-slot cases🤖 Generated with Claude Code