Skip to content

Commit 8ab484c

Browse files
dfa1claude
andcommitted
feat(writer): cascade Utf8 dict codes so they bitpack (#303)
encodeUtf8Cascade exposed the dict values pool as an open child (#299 Finding 2b) but left the codes as a raw vortex.primitive leaf — paying the full U8/U16/U32 code width per row. The Rust reference bitpacks dict codes (fastlanes.bitpacked), and the primitive dict path here already exposes its codes the same way; only the Utf8 path was missing it. Now both children are left open: codes (index 0) as a typed primitive array the cascade bitpacks, and the values pool (index 1) for the FSST/VarBin competition. No wire/reader change — the reader already decodes bitpacked dict codes (it reads Rust files that use them). nyc-311 re-encode: 1934.23 MB -> 1862.23 MB (1.10x -> 1.06x vortex-jni); the win spans every per-chunk Utf8 dict column, not only the street columns that surfaced it. Composes with #302 (they compress different children). Round-trip + 217 Java-writes-Rust-reads interop tests pass; full verify green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 147ee0e commit 8ab484c

2 files changed

Lines changed: 50 additions & 19 deletions

File tree

writer/src/main/java/io/github/dfa1/vortex/writer/encode/DictEncodingEncoder.java

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,11 @@ private static CascadeStep encodeUtf8Cascade(String[] strings, EncodeContext ctx
118118
}
119119
int dictSize = valueMap.size();
120120
PType codePType = codePType(dictSize);
121-
int codeBytes = codePType.byteSize();
122121

123-
MemorySegment codesBuf = ctx.arena().allocate((long) n * codeBytes);
124-
for (int i = 0; i < n; i++) {
125-
writeCodeToSeg(codesBuf, codePType, i, valueMap.get(strings[i]));
126-
}
122+
// Codes as a typed array (U8->byte[], U16->short[], U32->int[]) so the cascade can bitpack
123+
// them, rather than emitting a raw vortex.primitive leaf that pays the full U8/U16/U32 width
124+
// per row (#303). The primitive dict path already exposes its codes this way.
125+
Object codesArr = buildCodesArray(strings, valueMap, codePType);
127126

128127
byte[] metaBytes = new ProtoDictMetadata(
129128
dictSize,
@@ -132,23 +131,51 @@ private static CascadeStep encodeUtf8Cascade(String[] strings, EncodeContext ctx
132131
null
133132
).encode();
134133

135-
// Child order matches the terminal encodeUtf8: [codes, values]. Codes is a raw primitive
136-
// leaf at buffer 0; values (index 1) is left null for the cascade to fill.
137-
EncodeNode codesNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 0);
134+
// Child order matches the terminal encodeUtf8: [codes, values]. Both children are left open
135+
// so the cascade bitpacks the codes and FSST/VarBin-competes the values pool.
138136
EncodeNode partialRoot = new EncodeNode(
139137
EncodingId.VORTEX_DICT, MemorySegment.ofArray(metaBytes),
140-
new EncodeNode[]{codesNode, null},
138+
new EncodeNode[]{null, null},
141139
new int[0]);
142140

143141
String[] distinct = valueMap.keySet().toArray(new String[0]);
144-
ChildSlot valuesSlot = new ChildSlot(DType.UTF8, distinct, 1);
145142

146143
String minStr = valueMap.keySet().stream().min(String::compareTo).orElse(null);
147144
String maxStr = valueMap.keySet().stream().max(String::compareTo).orElse(null);
148145
byte[] statsMin = minStr != null ? ProtoScalarValue.ofStringValue(minStr).encode() : null;
149146
byte[] statsMax = maxStr != null ? ProtoScalarValue.ofStringValue(maxStr).encode() : null;
150147

151-
return new CascadeStep(partialRoot, List.of(codesBuf), List.of(valuesSlot), statsMin, statsMax, true);
148+
return new CascadeStep(partialRoot, List.of(),
149+
List.of(new ChildSlot(new DType.Primitive(codePType, false), codesArr, 0),
150+
new ChildSlot(DType.UTF8, distinct, 1)),
151+
statsMin, statsMax, true);
152+
}
153+
154+
private static Object buildCodesArray(String[] strings, java.util.Map<String, Integer> valueMap, PType codePType) {
155+
int n = strings.length;
156+
return switch (codePType) {
157+
case U8 -> {
158+
byte[] a = new byte[n];
159+
for (int i = 0; i < n; i++) {
160+
a[i] = (byte) (int) valueMap.get(strings[i]);
161+
}
162+
yield a;
163+
}
164+
case U16 -> {
165+
short[] a = new short[n];
166+
for (int i = 0; i < n; i++) {
167+
a[i] = (short) (int) valueMap.get(strings[i]);
168+
}
169+
yield a;
170+
}
171+
default -> {
172+
int[] a = new int[n];
173+
for (int i = 0; i < n; i++) {
174+
a[i] = valueMap.get(strings[i]);
175+
}
176+
yield a;
177+
}
178+
};
152179
}
153180

154181
private static EncodeResult encodeUtf8(String[] strings, EncodeContext ctx) {

writer/src/test/java/io/github/dfa1/vortex/writer/encode/DictEncodingEncoderTest.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.github.dfa1.vortex.reader.array.Array;
44
import io.github.dfa1.vortex.reader.array.VarBinArray;
5+
import io.github.dfa1.vortex.core.model.DType;
56
import io.github.dfa1.vortex.core.testing.DTypes;
67
import io.github.dfa1.vortex.reader.decode.DecodeContext;
78

@@ -180,20 +181,23 @@ void encode_utf8_metadata_valuesLen_matchesUniqueCount() throws Exception {
180181
}
181182

182183
@Test
183-
void encodeCascade_utf8_exposesValuesPoolAsOpenChild() {
184+
void encodeCascade_utf8_exposesCodesAndValuesAsOpenChildren() {
184185
// Given — a low-cardinality Utf8 column.
185186
String[] data = repeat(new String[]{"apple", "banana", "cherry"}, 100);
186187

187188
// When
188189
CascadeStep result = ENCODER.encodeCascade(DTypes.UTF8, data, EncodeTestHelper.testCtx());
189190

190-
// Then — the distinct-values pool (child index 1) is left open for the cascade to compress
191-
// (FSST/VarBin compete on it, #299), not hardcoded to a terminal raw-varbin node.
191+
// Then — both children are left open for the cascade: codes (index 0) so they bitpack
192+
// (#303), and the distinct-values pool (index 1) so FSST/VarBin compete on it (#299).
192193
assertThat(result.isTerminal()).isFalse();
193-
assertThat(result.openChildren()).singleElement().satisfies(slot -> {
194-
assertThat(slot.parentChildIdx()).isEqualTo(1);
195-
assertThat(slot.childDtype()).isEqualTo(DTypes.UTF8);
196-
assertThat((String[]) slot.childData()).containsExactly("apple", "banana", "cherry");
197-
});
194+
assertThat(result.openChildren()).extracting(ChildSlot::parentChildIdx).containsExactly(0, 1);
195+
// Codes child: one primitive code per row.
196+
ChildSlot codes = result.openChildren().get(0);
197+
assertThat(codes.childDtype()).isInstanceOf(DType.Primitive.class);
198+
// Values child: the distinct pool as Utf8.
199+
ChildSlot values = result.openChildren().get(1);
200+
assertThat(values.childDtype()).isEqualTo(DTypes.UTF8);
201+
assertThat((String[]) values.childData()).containsExactly("apple", "banana", "cherry");
198202
}
199203
}

0 commit comments

Comments
 (0)