Skip to content

Commit 3f0efa6

Browse files
dfa1claude
andcommitted
feat(writer): type-aware global-dict cardinality cap for Utf8 (#299)
The global-dict admission cap was a single 2048 constant. It was kept low because a global dict pessimizes high-cardinality F64/I64 columns (ALP/bitpacked beat U16 dict codes) — but that ceiling also excluded text columns with thousands of repeated distinct values (nyc-311 street/place names, 9k-28k distinct), pushing them into costlier per-chunk fallbacks. Split the cap: numeric stays 2048; Utf8 rises to 32768, the positive ceiling of the per-chunk short[] code buffer (codePTypeForSize already emits U16 codes above 256, so no wire change). Threaded through isUtf8DictCandidate and the mid-file ingest gate via the column's type. Note: on very wide, high-cardinality text files the aggregate retained-code budget (WriteOptions.globalDictMaxRetainedBytes, default 1 GB) may evict/demote columns; the ADR-0021 eviction keeps this safe (no OOM) but may cap the realized gain until the budget is tuned — deferred to measurement on the full corpus. Verified: an 8000-distinct Utf8 column now produces a global DICT layout and round-trips (integration test); dict-decision unit tests updated for the raised cap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d83d1b9 commit 3f0efa6

3 files changed

Lines changed: 69 additions & 7 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,52 @@ void globalDictUtf8_valuesPool_isFsstCompressed_andRoundTrips(@TempDir Path tmp)
8989
}
9090
}
9191

92+
@Test
93+
void utf8_cardinalityAbovePriorCap_admittedToGlobalDict(@TempDir Path tmp) throws IOException {
94+
// Given — 8 000 distinct values: above the old 2048 numeric-era cap (so previously demoted
95+
// to per-chunk), below the 32768 Utf8 cap, with >50% dedup so the ratio gate admits it.
96+
Path file = tmp.resolve("high_card.vortex");
97+
int rowsPerChunk = 20_000;
98+
int chunkCount = 4;
99+
int distinct = 8_000;
100+
101+
List<String> expected = new ArrayList<>();
102+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
103+
var sut = VortexWriter.create(ch, SCHEMA, WriteOptions.cascading(3))) {
104+
// When
105+
for (int c = 0; c < chunkCount; c++) {
106+
String[] data = redundantChunk(rowsPerChunk, distinct, c * 101);
107+
expected.addAll(List.of(data));
108+
sut.writeChunk(Map.of(COL, data));
109+
}
110+
}
111+
112+
// Then — the column is stored as one global dictionary (a DICT layout node), not a per-chunk
113+
// fallback. Before the type-aware cap a 8 000-distinct column produced no DICT layout.
114+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll())) {
115+
assertThat(hasDictLayout(InspectorTree.build(vf).root()))
116+
.as("8 000-distinct Utf8 column admitted to the global dictionary")
117+
.isTrue();
118+
}
119+
120+
// And every value round-trips exactly.
121+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll())) {
122+
assertThat(readAllStrings(vf)).isEqualTo(expected);
123+
}
124+
}
125+
126+
private static boolean hasDictLayout(InspectorTree.Node node) {
127+
if (node.layout().isDict()) {
128+
return true;
129+
}
130+
for (InspectorTree.Node child : node.children()) {
131+
if (hasDictLayout(child)) {
132+
return true;
133+
}
134+
}
135+
return false;
136+
}
137+
92138
private static List<String> readAllStrings(VortexReader vf) {
93139
List<String> out = new ArrayList<>();
94140
try (var iter = vf.scan(ScanOptions.columns("desc"))) {

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,18 @@ public final class VortexWriter implements Closeable {
104104
private static final int STAT_NULL_COUNT = 6;
105105

106106
// Columns with global cardinality below this threshold are dict-encoded across all chunks.
107-
// Kept low: global dict hurts high-cardinality F64 columns (ALP codes beat U16 dict codes).
107+
// The cap is type-aware. Numeric stays low: a global dict hurts high-cardinality F64/I64
108+
// columns (ALP/bitpacked codes beat U16 dict codes). Utf8 is raised far higher — text columns
109+
// with thousands of repeated distinct values (street/place names) dictionary-compress well
110+
// (#299), and the per-chunk short[] code buffer holds codes 0..32767 (up to 32768 distinct)
111+
// with no wider buffer; codePTypeForSize already emits U16 codes above 256.
108112
static final int GLOBAL_DICT_MAX_CARDINALITY = 2_048;
113+
static final int GLOBAL_DICT_MAX_CARDINALITY_UTF8 = 32_768;
114+
115+
// The global-dict cardinality cap for a column, by whether it is Utf8 (see the constants above).
116+
private static int dictMaxCardinality(boolean utf8) {
117+
return utf8 ? GLOBAL_DICT_MAX_CARDINALITY_UTF8 : GLOBAL_DICT_MAX_CARDINALITY;
118+
}
109119

110120
private static final List<EncodingEncoder> DEFAULT_CODECS = List.of(
111121
new AlpEncodingEncoder(), new PrimitiveEncodingEncoder(), new BoolEncodingEncoder(),
@@ -1297,11 +1307,12 @@ private boolean ingestDictChunk(DictColumnState state, Object data) {
12971307
Object values = nullable ? ((NullableData) data).values() : data;
12981308
boolean[] validity = nullable ? ((NullableData) data).validity() : null;
12991309
int len = state.utf8 ? ((String[]) values).length : primitiveArrayLen(values, state.ptype);
1310+
int cap = dictMaxCardinality(state.utf8);
13001311

13011312
// First pass: would this chunk's fresh distinct values push the map past the cap? Count them
13021313
// without mutating so the whole chunk is either ingested or rejected atomically — a partial
13031314
// ingest would corrupt the dictionary when the caller demotes on rejection.
1304-
var pendingNew = new HashSet<>(GLOBAL_DICT_MAX_CARDINALITY + 1);
1315+
var pendingNew = new HashSet<>(Math.min(cap, len) + 1);
13051316
for (int i = 0; i < len; i++) {
13061317
if (validity != null && !validity[i]) {
13071318
continue;
@@ -1314,7 +1325,7 @@ private boolean ingestDictChunk(DictColumnState state, Object data) {
13141325
continue;
13151326
}
13161327
if (!state.valueToCode.containsKey(v) && pendingNew.add(v)
1317-
&& state.valueToCode.size() + pendingNew.size() > GLOBAL_DICT_MAX_CARDINALITY) {
1328+
&& state.valueToCode.size() + pendingNew.size() > cap) {
13181329
return false;
13191330
}
13201331
}
@@ -1667,13 +1678,13 @@ static boolean isUtf8DictCandidate(String[] data, boolean[] validity) {
16671678
if (data.length == 0) {
16681679
return false;
16691680
}
1670-
var seen = new java.util.HashSet<String>(GLOBAL_DICT_MAX_CARDINALITY + 1);
1681+
var seen = new java.util.HashSet<String>(Math.min(GLOBAL_DICT_MAX_CARDINALITY_UTF8, data.length) + 1);
16711682
for (int i = 0; i < data.length; i++) {
16721683
if ((validity != null && !validity[i]) || data[i] == null) {
16731684
continue;
16741685
}
16751686
seen.add(data[i]);
1676-
if (seen.size() > GLOBAL_DICT_MAX_CARDINALITY) {
1687+
if (seen.size() > GLOBAL_DICT_MAX_CARDINALITY_UTF8) {
16771688
return false;
16781689
}
16791690
}

writer/src/test/java/io/github/dfa1/vortex/writer/VortexWriterDictDecisionTest.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.stream.Stream;
99

1010
import static io.github.dfa1.vortex.writer.VortexWriter.GLOBAL_DICT_MAX_CARDINALITY;
11+
import static io.github.dfa1.vortex.writer.VortexWriter.GLOBAL_DICT_MAX_CARDINALITY_UTF8;
1112
import static org.assertj.core.api.Assertions.assertThat;
1213
import static org.junit.jupiter.params.provider.Arguments.arguments;
1314

@@ -96,8 +97,12 @@ static Stream<Arguments> utf8DictCandidateCases() {
9697
arguments("empty", new String[0], false),
9798
arguments("ratio exactly 50%", new String[]{"a", "b", "a", "b"}, false),
9899
arguments("ratio under 50%", new String[]{"a", "b", "a", "b", "a"}, true),
99-
arguments("cardinality at MAX", distinctStrings(GLOBAL_DICT_MAX_CARDINALITY), true),
100-
arguments("cardinality over MAX", distinctStrings(GLOBAL_DICT_MAX_CARDINALITY + 1), false));
100+
// Utf8 uses the raised type-aware cap (#299), not the numeric 2048.
101+
arguments("Utf8 admitted above numeric cap",
102+
distinctStrings(GLOBAL_DICT_MAX_CARDINALITY + 1), true),
103+
arguments("cardinality at Utf8 MAX", distinctStrings(GLOBAL_DICT_MAX_CARDINALITY_UTF8), true),
104+
arguments("cardinality over Utf8 MAX",
105+
distinctStrings(GLOBAL_DICT_MAX_CARDINALITY_UTF8 + 1), false));
101106
}
102107

103108
@ParameterizedTest(name = "{0}")

0 commit comments

Comments
 (0)