feat(writer): cardinality-bounded global-dict buffering (ADR 0021)#285
Merged
Conversation
VortexWriter previously buffered every global-dict-candidate column's raw per-row values from its first chunk until close(), so it could build one shared dictionary spanning the whole file. Retained memory scaled with file size x column count, not with any bounded quantity: a candidate whose distinct set only grew past GLOBAL_DICT_MAX_CARDINALITY (2048) after millions of later rows had already accumulated raw duplicates for the whole file, OOM-ing on wide categorical files (NYC 311: 18.5M rows x 38 string columns). A prior stopgap added an aggregate raw-byte budget, but its eviction signal was wrong — a huge genuinely-low-cardinality column (exactly where a shared dict helps most) was demoted first for being the biggest byte contributor. Per ADR 0021, replace raw-value buffering with cardinality-bounded buffering: - Each candidate keeps a deduplicated value->code map (first-seen order, capped at GLOBAL_DICT_MAX_CARDINALITY), a per-code occurrence count, and one cheap in-memory short[] code array per chunk. Per-chunk row/null counts and min/max/sum stats are captured at ingest, before the raw array is discarded. - The cap check moves from close()-only to continuous: the moment a chunk's fresh distinct values would exceed the cap, the column demotes immediately. Ingest is atomic — a two-pass check ensures a rejected chunk never partially mutates the map. - Demotion (cardinality- or byte-budget-triggered) is lossless: buffered chunks are reconstructed exactly from their codes + the inverse map and replayed through the normal cascade path, yielding a Chunked-of-Flats layout. - Primitive close()-time build ranks distinct values by occurrence descending (dominant -> code 0, for SparseEncoding fill=0, matching Rust FloatDictScheme) via a first-seen -> frequency-rank remap over the buffered code arrays. Utf8 keeps first-seen order (its incremental map already matches); no remap. The globalDictMaxRetainedBytes budget stays as a secondary safety net but now tracks ~2 B/row code arrays (~35-45x smaller than raw strings); its default is raised from 256 MB to 1 GB so normal wide low-cardinality files keep all their dictionaries while pathological many-wide-column cases stay bounded. Adds regression tests for mid-file cardinality-triggered demotion (output is a Chunked-of-Flats layout, not a shared Dict) and the primitive frequency remap (dominant value gets code 0 regardless of first-seen order). Existing byte- budget and default-value tests updated for the smaller code-array quantity and the new 1 GB default. Full writer suite (1711) green; Java-writes-Rust-reads (217) and file-size comparison (8) integration tests confirm the wire output stays Rust-readable and size-competitive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Summary
Implements ADR 0021. Replaces
VortexWriter's raw-value buffering for global-dictionary candidates (which scaled memory with file size × column count, causing OOM on wide categorical files — see #278/#279) with cardinality-bounded buffering:value → codemap (capped atGLOBAL_DICT_MAX_CARDINALITY = 2048), a per-code occurrence count, and cheap in-memoryshort[]code arrays per chunk, instead of raw duplicated values.close()-only to continuous — the moment a chunk's fresh distinct values would exceed the cap, the column demotes immediately (atomic two-pass ingest: a rejected chunk never partially mutates the dictionary state).SparseEncodingEncoderfill=0 compression, matching Rust'sFloatDictScheme) via a close()-time remap; Utf8 columns need no remap (already first-seen order).WriteOptions#globalDictMaxRetainedBytesdefault raised 256MB → 1GB now that it guards ~2B/row code arrays instead of raw strings (~35-45× smaller retention per candidate).Test plan
./mvnw verify -pl writer -am -DskipITs— 1713 tests, 0 failures, checkstyle cleanvortex-revieweragainst the 5 highest correctness risks (round-trip fidelity, frequency remap consistency, two-pass ingest atomicity, per-chunk stats parity) — APPROVE, all gaps closed before this pushJavaWritesRustReadsIntegrationTest,FileSizeComparisonIntegrationTest) confirmed green during implementation — wire output stays Rust-readable, dict+sparse size win preserved🤖 Generated with Claude Code