Skip to content

fix(writer): Rust's global-dict shape for numeric columns (null entry, U16 codes) - #467

Merged
dfa1 merged 1 commit into
mainfrom
fix/primitive-dict-null-entry
Oct 9, 2026
Merged

dfa1 merged 1 commit into
mainfrom
fix/primitive-dict-null-entry

Conversation

@dfa1

@dfa1 dfa1 commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #466 (← #465 ← #464).

Fixes the Airport_fee regression 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.

Commit Taxi total Airport_fee (jni 415,648) ops/s alloc/op
base (#466) 42,323,238 496,240 (1.19×) 0.763 ± 0.023 2.49 GB
53c78827 null as a dict entry, non-nullable codes 42,286,742 495,482 (1.19×) 0.756 ± 0.017 2.49 GB
5a227dc0 U16 codes (Rust's default) 42,342,166 411,043 (0.99×) 0.769 ± 0.026 2.50 GB

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

  1. Null as a pool entry. Rust's dict layout writer always writes non-nullable codes. Null is one more pool entry, invalid in the values' validity, and DictLayoutMetadata.is_nullable_codes is 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.
  2. U16 codes. Airport_fee stayed 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 00 is codes_ptype = U16 for a 4-entry dictionary. DictLayoutConstraints pick U8 only when the configured max_len <= 255; the default is u16::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_type 0.95× → 1.04×, store_and_fwd_flag 1.33× → 1.40×, mta_tax 1.16× → 1.20×, extra 0.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 (5 codePTypeForSize cases deleted with the method), checkstyle, javadoc
  • ./mvnw verify -pl integration -am: 365 tests, 0 failures
  • New: JavaWritesRustReadsIntegrationTest#javaWriter_rustReader_globalDict_nullableF64_nullAsDictEntry (vortex-jni full + filtered scan), DictColumnStateTest emit/null-slot cases

🤖 Generated with Claude Code

…, 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
dfa1 force-pushed the fix/primitive-dict-null-entry branch from 5a227dc to 594c170 Compare October 9, 2026 07:52
@dfa1
dfa1 merged commit 7ad1ba9 into main Oct 9, 2026
6 checks passed
@dfa1
dfa1 deleted the fix/primitive-dict-null-entry branch October 9, 2026 07:57
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant