Skip to content

Commit 4cbc12d

Browse files
dfa1claude
andcommitted
feat: make writer's global-dict retained-memory budget configurable
The OOM fix that bounded VortexWriter's global-dictionary retained memory hardcoded the aggregate budget at 256 MB (GLOBAL_DICT_MAX_RETAINED_BYTES). Expose it via WriteOptions so callers can tune it per host/workload: - WriteOptions gains a `globalDictMaxRetainedBytes` record component with a 256 MB default in defaults()/cascading() and a withGlobalDictMaxRetainedBytes(long) copy-method matching the existing withXxx convention. - VortexWriter reads the budget from its WriteOptions instead of the constant; the constant is deleted and its rationale moved onto the WriteOptions field. - Removed the setDictRetainedBudgetForTest(long) test seam; the demotion test now configures the budget through the real public API (WriteOptions.cascading(3).withGlobalDictMaxRetainedBytes(120_000L)). - Updated positional new WriteOptions(...) call sites for the new component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fb4b6be commit 4cbc12d

16 files changed

Lines changed: 143 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `WriteOptions.withGlobalDictMaxRetainedBytes(long)` makes the writer's global-dictionary retained-memory budget (default 256 MB) configurable. ([58ba2986](https://github.com/dfa1/vortex-java/commit/58ba2986))
13+
1014
### Fixed
1115

1216
- `VortexWriter` no longer buffers a global-dictionary candidate column's raw data for the whole file, so importing a huge Parquet file (e.g. an 18.5M-row dataset) no longer exhausts the heap; a column whose retained bytes exceed a fixed budget is demoted to per-chunk encoding. ([a3b921b5](https://github.com/dfa1/vortex-java/commit/a3b921b5))

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateSumNullTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ private SchemaPlus tableOf(long[] values, boolean[] valid) throws IOException {
4949
List.of(ColumnName.of("v")), List.of(new DType.Primitive(PType.I64, true)), false);
5050
Path file = tmp.resolve("sum-nulls.vortex");
5151
// Large chunk so the whole column is one chunk; zone maps on so the SUM stat is emitted.
52-
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false);
52+
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false, 256L * 1024 * 1024);
5353
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
5454
var writer = VortexWriter.create(ch, schema, opts)) {
5555
writer.writeChunk(Map.of(ColumnName.of("v"), new NullableData(values, valid)));

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateWhereBoundaryTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ static void write() throws Exception {
5757
.build();
5858
// enableZoneMaps=true emits the per-chunk min/max/sum/null-count the tier-1 fold reads and the
5959
// classify() step uses to find the boundary zones.
60-
WriteOptions opts = new WriteOptions(CHUNK, true, 0.90, 0, true, false);
60+
WriteOptions opts = new WriteOptions(CHUNK, true, 0.90, 0, true, false, 256L * 1024 * 1024);
6161
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
6262
VortexWriter writer = VortexWriter.create(ch, schema, opts)) {
6363
for (int c = 0; c < CHUNKS; c++) {
@@ -456,7 +456,7 @@ private static Ground reduce(java.util.function.LongPredicate predicate) {
456456
private static void writeChunks(Path file, DType.Struct schema, Map<ColumnName, Object> chunk0,
457457
Map<ColumnName, Object> chunk1) throws Exception {
458458
// chunkSize large so each writeChunk is exactly one chunk (one zone).
459-
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false);
459+
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false, 256L * 1024 * 1024);
460460
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
461461
VortexWriter writer = VortexWriter.create(ch, schema, opts)) {
462462
writer.writeChunk(chunk0);

calcite/src/test/java/io/github/dfa1/vortex/calcite/AggregateWhereCleanPartitionTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ static void write() throws Exception {
5656
.field("val", DType.I64)
5757
.build();
5858
// enableZoneMaps=true emits the per-chunk min/max/sum/null-count the fold reads.
59-
WriteOptions opts = new WriteOptions(CHUNK, true, 0.90, 0, true, false);
59+
WriteOptions opts = new WriteOptions(CHUNK, true, 0.90, 0, true, false, 256L * 1024 * 1024);
6060
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
6161
VortexWriter writer = VortexWriter.create(ch, schema, opts)) {
6262
for (int c = 0; c < CHUNKS; c++) {
@@ -429,7 +429,7 @@ private static Path nullPartitionedFile(String name) throws Exception {
429429
private static void writeChunks(Path file, DType.Struct schema, Map<ColumnName, Object> chunk0,
430430
Map<ColumnName, Object> chunk1) throws Exception {
431431
// chunkSize large so each writeChunk is exactly one chunk (one zone).
432-
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false);
432+
WriteOptions opts = new WriteOptions(1024, true, 0.90, 0, false, false, 256L * 1024 * 1024);
433433
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
434434
VortexWriter writer = VortexWriter.create(ch, schema, opts)) {
435435
writer.writeChunk(chunk0);

calcite/src/test/java/io/github/dfa1/vortex/calcite/OhlcGenerator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ private OhlcGenerator() {
2020
}
2121

2222
static void write(Path file, int totalRows, int chunkSize) throws IOException {
23-
WriteOptions opts = new WriteOptions(chunkSize, true, 0.90, 0, true, false);
23+
WriteOptions opts = new WriteOptions(chunkSize, true, 0.90, 0, true, false, 256L * 1024 * 1024);
2424
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
2525
var writer = VortexWriter.create(ch, OhlcData.SCHEMA, opts)) {
2626
for (OhlcData.Batch batch : OhlcData.generate(totalRows, chunkSize)) {

calcite/src/test/java/io/github/dfa1/vortex/calcite/UnsignedColumnTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ private Path write(String name, WriteOptions opts, int u32, long u64) throws Exc
110110
private static WriteOptions noZoneMaps() {
111111
// Same shape as the adapter coverage test's zone-maps-off options: the second flag disables
112112
// zone maps so no per-zone SUM exists and VortexAggregates falls back to scanSum.
113-
return new WriteOptions(65_536, false, 0.90, 0, true, false);
113+
return new WriteOptions(65_536, false, 0.90, 0, true, false, 256L * 1024 * 1024);
114114
}
115115

116116
private static ReadRegistry registry() {

calcite/src/test/java/io/github/dfa1/vortex/calcite/VortexAdapterCoverageTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ void nonNumericColumn_throws() throws Exception {
278278
void noZoneMap_sumFallsBackToFullScan(@TempDir Path noStats) throws Exception {
279279
// Given — a file written with zone maps off, so no per-zone SUM exists to fold
280280
Path bare = noStats.resolve("nostats.vortex");
281-
WriteOptions noZoneMaps = new WriteOptions(65_536, false, 0.90, 0, true, false);
281+
WriteOptions noZoneMaps = new WriteOptions(65_536, false, 0.90, 0, true, false, 256L * 1024 * 1024);
282282
try (var ch = FileChannel.open(bare, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
283283
var w = VortexWriter.create(ch, SCHEMA, noZoneMaps)) {
284284
w.writeChunk(Map.ofEntries(

integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ void javaWriter_jniReader_zoneMapped_multipleZones(@TempDir Path tmp) throws IOE
509509
// zone-map with one zone per chunk. The Rust reader must parse that layout and still
510510
// return every value (zones are a transparent pruning aux).
511511
Path file = tmp.resolve("java_zoned.vtx");
512-
WriteOptions zoneMapped = new WriteOptions(4, true, 0.90, 0, true, false);
512+
WriteOptions zoneMapped = new WriteOptions(4, true, 0.90, 0, true, false, 256L * 1024 * 1024);
513513
long[] ids = new long[20];
514514
double[] vals = new double[20];
515515
for (int i = 0; i < 20; i++) {

performance/src/main/java/io/github/dfa1/vortex/performance/CalciteBoundaryAggregateBenchmark.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ private static void writeFixture(Path file) throws IOException {
194194
.field("val", DType.I64)
195195
.build();
196196
// enableZoneMaps=true emits the per-chunk min/max/sum/null-count the interior-zone fold reads.
197-
WriteOptions opts = new WriteOptions(CHUNK_SIZE, true, 0.90, 0, true, false);
197+
WriteOptions opts = new WriteOptions(CHUNK_SIZE, true, 0.90, 0, true, false, 256L * 1024 * 1024);
198198
java.util.Random rng = new java.util.Random(SEED);
199199
try (FileChannel ch = FileChannel.open(file,
200200
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@
8080
/// writer.writeChunk(Map.of(ColumnName.of("id"), idArray, ColumnName.of("value"), valueArray));
8181
/// }
8282
/// ```
83+
///
84+
/// With global dictionary encoding enabled (the default), candidate columns are buffered in the heap
85+
/// until `close()` so a shared dictionary can span every chunk. The aggregate memory this buffering may
86+
/// retain is bounded by [WriteOptions#globalDictMaxRetainedBytes()] (default 256 MB); once the budget is
87+
/// crossed the largest columns are demoted to per-chunk encoding, keeping writer memory bounded
88+
/// regardless of file size or column count.
8389
public final class VortexWriter implements Closeable {
8490

8591
// Indices into layout_specs list in the FbsFooter
@@ -99,18 +105,6 @@ public final class VortexWriter implements Closeable {
99105
// Kept low: global dict hurts high-cardinality F64 columns (ALP codes beat U16 dict codes).
100106
static final int GLOBAL_DICT_MAX_CARDINALITY = 2_048;
101107

102-
// Aggregate memory budget (bytes) for the raw data all columns may retain while buffering for a
103-
// shared global dictionary. A global dict must see every chunk before it can be built, so a
104-
// dict-candidate column's raw arrays are held from the first chunk until close(). On a huge,
105-
// wide file (e.g. the 18.5M-row / 38-string-column NYC-311 Parquet import) any column
106-
// mis-detected as low-cardinality on its first chunk — one whose distinct count grows only after
107-
// millions of later rows — would otherwise pin its entire column in the heap; with dozens of such
108-
// columns the total is several GB and the import OOMs. This budget bounds the SUM across all
109-
// buffering columns: once the total is exceeded, the largest-retained columns are demoted (their
110-
// buffered chunks flushed per-chunk, per-chunk encoding thereafter) until back under budget,
111-
// keeping writer memory bounded by the budget rather than by total file size × column count.
112-
static final long GLOBAL_DICT_MAX_RETAINED_BYTES = 256L * 1024 * 1024;
113-
114108
private static final List<EncodingEncoder> DEFAULT_CODECS = List.of(
115109
new AlpEncodingEncoder(), new PrimitiveEncodingEncoder(), new BoolEncodingEncoder(),
116110
new DictEncodingEncoder(), new VarBinEncodingEncoder(), new ExtEncodingEncoder(),
@@ -141,9 +135,9 @@ public final class VortexWriter implements Closeable {
141135
// pin the heap. When the sum crosses the budget, the largest columns are demoted until under it.
142136
private final Map<ColumnName, Long> dictRetainedBytes = new LinkedHashMap<>();
143137
private long dictRetainedTotal = 0;
144-
// Effective aggregate global-dict retention budget; the constant by default, lowered by tests
145-
// to exercise the demotion path without allocating the full budget.
146-
private long dictRetainedBudget = GLOBAL_DICT_MAX_RETAINED_BYTES;
138+
// Effective aggregate global-dict retention budget, configured via
139+
// WriteOptions.globalDictMaxRetainedBytes(); see that field's javadoc for the rationale.
140+
private final long dictRetainedBudget;
147141
private boolean firstChunkSeen = false;
148142

149143
// Per-column zone-maps, populated by flushZoneMaps() in close() when enableZoneMaps is set.
@@ -173,6 +167,7 @@ private VortexWriter(
173167
this.channel = channel;
174168
this.schema = schema;
175169
this.options = options;
170+
this.dictRetainedBudget = options.globalDictMaxRetainedBytes();
176171
this.encodings = encodings;
177172
this.defaultRegistry = buildRegistry(encodings);
178173
this.cascadeCodecs = buildCascadeCodecs(options);
@@ -182,12 +177,6 @@ private VortexWriter(
182177
}
183178
}
184179

185-
// Test seam: lower the aggregate global-dict retention budget so the demotion path (see
186-
// writeChunk) can be exercised without allocating GLOBAL_DICT_MAX_RETAINED_BYTES of column data.
187-
void setDictRetainedBudgetForTest(long budgetBytes) {
188-
this.dictRetainedBudget = budgetBytes;
189-
}
190-
191180
/// Builds a [WriteRegistry] from the given encoder list plus all built-in extension encoders.
192181
private static WriteRegistry buildRegistry(List<EncodingEncoder> encoders) {
193182
WriteRegistry.Builder b = WriteRegistry.builder();

0 commit comments

Comments
 (0)