Skip to content

Commit d83d1b9

Browse files
dfa1claude
andcommitted
feat(writer): compress the global-dict Utf8 values pool (#299)
The global-dict flush wrote a low-cardinality Utf8 column's distinct-values pool as raw vortex.varbin, so it never captured substring redundancy across dictionary entries (shared "STREET"/"AVENUE"/borough tokens) — a large part of the nyc-311 file-size gap vs the Rust reference, which dict-encodes *and* FSST-compresses the same columns. writeGlobalDictUtf8Column now runs the values pool through the normal Utf8 competition (FSST/VarBin/Zstd) with vortex.dict excluded, so the cascade never wraps the dictionary in a second dict the reader cannot unwrap. Threaded via a new excludedFromCascade argument on writeSegment. At cascade depth 0 (no competition) it keeps forcing flat VarBin as before. Verified: values pool is FSST-encoded and round-trips (new integration test); 80 writer dict tests and 217 Java-writes-Rust-reads interop tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f11716c commit d83d1b9

2 files changed

Lines changed: 139 additions & 9 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package io.github.dfa1.vortex.integration;
2+
3+
import io.github.dfa1.vortex.core.model.ColumnName;
4+
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.inspect.InspectorTree;
6+
import io.github.dfa1.vortex.reader.ReadRegistry;
7+
import io.github.dfa1.vortex.reader.ScanOptions;
8+
import io.github.dfa1.vortex.reader.VortexReader;
9+
import io.github.dfa1.vortex.reader.array.Array;
10+
import io.github.dfa1.vortex.reader.array.VarBinArray;
11+
import io.github.dfa1.vortex.writer.VortexWriter;
12+
import io.github.dfa1.vortex.writer.WriteOptions;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.io.TempDir;
15+
16+
import java.io.IOException;
17+
import java.nio.channels.FileChannel;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
import java.nio.file.StandardOpenOption;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
import java.util.Map;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
27+
/// Global-dict Utf8 columns compress their distinct-values pool through the normal Utf8
28+
/// competition (FSST/VarBin/Zstd) instead of hardcoded raw varbin (#299). Rust dict-encodes
29+
/// *and* FSST-compresses the same columns; before this change Java left the pool uncompressed,
30+
/// which was a large part of the nyc-311 file-size gap.
31+
class DictValuesPoolCompressionIntegrationTest {
32+
33+
private static final ColumnName COL = ColumnName.of("desc");
34+
private static final DType.Struct SCHEMA = new DType.Struct(List.of(COL), List.of(DType.UTF8), false);
35+
36+
/// Builds a low-cardinality column whose distinct values share a long common prefix, so FSST
37+
/// beats raw varbin on the values pool decisively. `distinct * 2 < rows` keeps it inside the
38+
/// global-dict gate; `distinct < 2048` keeps it inside the current cardinality cap so this test
39+
/// exercises Finding 2 alone (independent of the cap raise).
40+
private static String[] redundantChunk(int rows, int distinct, int chunkSeed) {
41+
String prefix = "New York City Department of Housing Preservation and Development complaint at ";
42+
String[] out = new String[rows];
43+
for (int i = 0; i < rows; i++) {
44+
int id = (chunkSeed + i) % distinct;
45+
out[i] = prefix + "site #" + id + " STREET";
46+
}
47+
return out;
48+
}
49+
50+
@Test
51+
void globalDictUtf8_valuesPool_isFsstCompressed_andRoundTrips(@TempDir Path tmp) throws IOException {
52+
// Given — 800 distinct long strings sharing a 78-char prefix, repeated across 5 chunks.
53+
Path file = tmp.resolve("dict_fsst.vortex");
54+
int rowsPerChunk = 4_000;
55+
int chunkCount = 5;
56+
int distinct = 800;
57+
58+
List<String> expected = new ArrayList<>();
59+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
60+
var sut = VortexWriter.create(ch, SCHEMA, WriteOptions.cascading(3))) {
61+
// When
62+
for (int c = 0; c < chunkCount; c++) {
63+
String[] data = redundantChunk(rowsPerChunk, distinct, c * 37);
64+
expected.addAll(List.of(data));
65+
sut.writeChunk(Map.of(COL, data));
66+
}
67+
}
68+
69+
// Then — the values pool is FSST-compressed. The global dictionary itself is a *layout*
70+
// (LAYOUT_DICT), so it is not listed among the segment encodings; FSST winning the
71+
// values-pool competition is the observable proof the pool is no longer raw varbin.
72+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll())) {
73+
InspectorTree tree = InspectorTree.build(vf);
74+
assertThat(tree.usedEncodings())
75+
.as("FSST compresses the dictionary values pool")
76+
.contains("vortex.fsst");
77+
}
78+
79+
// And global-dict dedup + FSST keeps the file far below the ~1.8 MB of raw repeated
80+
// strings (20 000 rows × ~90 B) — dedup to 800 distinct, values pool FSST-compressed.
81+
assertThat(Files.size(file))
82+
.as("dict dedup + FSST values pool")
83+
.isLessThan(300_000L);
84+
85+
// And every value round-trips exactly.
86+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll())) {
87+
List<String> got = readAllStrings(vf);
88+
assertThat(got).isEqualTo(expected);
89+
}
90+
}
91+
92+
private static List<String> readAllStrings(VortexReader vf) {
93+
List<String> out = new ArrayList<>();
94+
try (var iter = vf.scan(ScanOptions.columns("desc"))) {
95+
while (iter.hasNext()) {
96+
try (var chunk = iter.next()) {
97+
Array col = chunk.column("desc");
98+
VarBinArray strings = (VarBinArray) col;
99+
long n = strings.length();
100+
for (long i = 0; i < n; i++) {
101+
out.add(strings.getString(i));
102+
}
103+
}
104+
}
105+
}
106+
return out;
107+
}
108+
}

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -588,12 +588,27 @@ private int writeSegment(DType dtype, Object data) throws IOException {
588588
return writeSegment(dtype, data, null);
589589
}
590590

591-
/// Writes a segment, optionally forcing a specific `encodingOverride` instead of
592-
/// the configured cascade. Used by the global Utf8 dictionary path where the values
593-
/// segment must be flat varbin — the cascade would otherwise re-pick
594-
/// [DictEncodingEncoder] and wrap the dictionary in another dict (which the reader
595-
/// cannot unwrap).
596591
private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverride) throws IOException {
592+
return writeSegment(dtype, data, encodingOverride, Set.of());
593+
}
594+
595+
/// Writes a segment, optionally forcing a specific `encodingOverride` instead of the
596+
/// configured cascade, and optionally excluding encodings from the cascade competition.
597+
///
598+
/// `excludedFromCascade` lets the global Utf8 dictionary path compress its values pool
599+
/// through the normal Utf8 competition (FSST/VarBin/Zstd) while excluding
600+
/// [DictEncodingEncoder] — the cascade would otherwise re-pick it and wrap the dictionary
601+
/// in another dict the reader cannot unwrap. Only consulted on the cascade branch
602+
/// (`encodingOverride == null && allowedCascading > 0`).
603+
///
604+
/// @param dtype logical type of the segment
605+
/// @param data the values to encode
606+
/// @param encodingOverride a specific encoder to force, or `null` to use the cascade
607+
/// @param excludedFromCascade encoding ids to exclude from the cascade competition
608+
/// @return the index of the written segment
609+
/// @throws IOException if writing to the channel fails
610+
private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverride,
611+
Set<EncodingId> excludedFromCascade) throws IOException {
597612
// Non-extension nullable columns (Primitive, Utf8) wrap with MaskedEncodingEncoder here.
598613
// FbsExtension columns route through ExtEncodingEncoder.encode which itself delegates to
599614
// MaskedEncodingEncoder when its storage data is NullableData — handled inside ExtEncoding.
@@ -624,6 +639,9 @@ private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverr
624639
result = encodingOverride.encode(dtype, data, encodeCtx);
625640
} else if (options.allowedCascading() > 0) {
626641
EncodeContext encodeCtx = EncodeContext.ofDepth(options.allowedCascading(), arena, cascadeRegistry);
642+
for (EncodingId excluded : excludedFromCascade) {
643+
encodeCtx = encodeCtx.withExcluded(excluded);
644+
}
627645
CascadingCompressor compressor = new CascadingCompressor(cascadeCodecs);
628646
result = compressor.encode(dtype, data, encodeCtx);
629647
} else {
@@ -1510,11 +1528,15 @@ private void writeGlobalDictUtf8Column(ColumnName colName, DictColumnState state
15101528
PType codePType = codePTypeForSize(dictSize);
15111529

15121530
// Utf8 assigns codes in first-seen order with no frequency sort, so the incremental map's
1513-
// order already matches — no remap pass (ADR 0021). Write unique strings as a flat VarBin
1514-
// segment, bypassing cascade so DictEncoding doesn't wrap the (all-unique-by-construction)
1515-
// dictionary in another dict the reader cannot unwrap.
1531+
// order already matches — no remap pass (ADR 0021). Compress the distinct-values pool
1532+
// through the normal Utf8 competition (FSST/VarBin/Zstd) so it captures substring
1533+
// redundancy across dictionary entries (#299), but exclude Dict so the cascade never wraps
1534+
// the (all-unique-by-construction) dictionary in another dict the reader cannot unwrap. At
1535+
// cascade depth 0 there is no competition to run, so force flat VarBin as before.
15161536
String[] uniques = state.valueToCode.keySet().toArray(new String[0]);
1517-
int valuesSegIdx = writeSegment(state.dtype, uniques, new VarBinEncodingEncoder());
1537+
int valuesSegIdx = options.allowedCascading() > 0
1538+
? writeSegment(state.dtype, uniques, null, Set.of(EncodingId.VORTEX_DICT))
1539+
: writeSegment(state.dtype, uniques, new VarBinEncodingEncoder());
15181540

15191541
DType codesDtype = new DType.Primitive(codePType, state.nullable);
15201542
List<Integer> codesSegIdxes = new ArrayList<>();

0 commit comments

Comments
 (0)