Skip to content

Commit 9edb6d3

Browse files
dfa1claude
andcommitted
merge: ALP F32 encode + DictEncoding for Utf8; resolves conflict with cascading work
- DictEncoding.encode/encodeCascade: dispatch Utf8 → encodeUtf8 (terminal) - VortexWriter DEFAULT_CODECS: include DictEncoding for first-match path File size: CSV=4.6MB → Vortex=3.3MB (1.40× ratio) vs 1.08× before Utf8 dict Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 032f371 + b96eeb8 commit 9edb6d3

6 files changed

Lines changed: 339 additions & 10 deletions

File tree

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
## Encodings
44

5-
- [ ] in one case F32 and F64 are accepted but only F64 is really implemented
65
- [ ] the classes are very long of complex, most likely we should group the impl detail in private static inner classes Encoder/Decoder
76

87
## Performance

core/src/main/java/io/github/dfa1/vortex/encoding/AlpEncoding.java

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,11 @@ public boolean accepts(DType dtype) {
9999
@Override
100100
public EncodeResult encode(DType dtype, Object data) {
101101
PType ptype = ((DType.Primitive) dtype).ptype();
102-
if (ptype == PType.F64) {
103-
return encodeF64((double[]) data, dtype);
104-
}
105-
throw new UnsupportedOperationException("ALP encode not yet supported for " + ptype);
102+
return switch (ptype) {
103+
case F64 -> encodeF64((double[]) data, dtype);
104+
case F32 -> encodeF32((float[]) data, dtype);
105+
default -> throw new UnsupportedOperationException("ALP encode not supported for " + ptype);
106+
};
106107
}
107108

108109
@Override
@@ -274,6 +275,115 @@ private static byte[] scalarF64(double v) {
274275
return ScalarProtos.ScalarValue.newBuilder().setF64Value(v).build().toByteArray();
275276
}
276277

278+
// ── F32 encode ────────────────────────────────────────────────────────────
279+
280+
private static int[] findExponentsF32(float[] values) {
281+
int sampleLen = Math.min(SAMPLE_SIZE, values.length);
282+
int bestExpE = 0, bestExpF = 0, bestExceptions = sampleLen + 1;
283+
284+
outer:
285+
for (int expE = 0; expE < F10_F32.length; expE++) {
286+
for (int expF = 0; expF < F10_F32.length; expF++) {
287+
float encFactor = F10_F32[expE] * IF10_F32[expF];
288+
float decFactor = F10_F32[expF] * IF10_F32[expE];
289+
int exceptions = 0;
290+
for (int i = 0; i < sampleLen; i++) {
291+
float enc = values[i] * encFactor;
292+
if (!Float.isFinite(enc) || (float) Math.round(enc) * decFactor != values[i]) {
293+
exceptions++;
294+
}
295+
}
296+
if (exceptions < bestExceptions) {
297+
bestExceptions = exceptions;
298+
bestExpE = expE;
299+
bestExpF = expF;
300+
if (bestExceptions == 0) {
301+
break outer;
302+
}
303+
}
304+
}
305+
}
306+
return new int[]{bestExpE, bestExpF};
307+
}
308+
309+
private static EncodeResult encodeF32(float[] values, DType dtype) {
310+
int n = values.length;
311+
int[] exps = findExponentsF32(values);
312+
int expE = exps[0], expF = exps[1];
313+
float encFactor = F10_F32[expE] * IF10_F32[expF];
314+
float decFactor = F10_F32[expF] * IF10_F32[expE];
315+
316+
int[] encodedArr = new int[n];
317+
var patchIndices = new ArrayList<Integer>();
318+
var patchValues = new ArrayList<Float>();
319+
320+
float min = Float.MAX_VALUE, max = -Float.MAX_VALUE;
321+
for (int i = 0; i < n; i++) {
322+
float v = values[i];
323+
float enc = v * encFactor;
324+
int encoded;
325+
if (Float.isFinite(enc) && (float) (encoded = Math.round(enc)) * decFactor == v) {
326+
encodedArr[i] = encoded;
327+
} else {
328+
encodedArr[i] = 0;
329+
patchIndices.add(i);
330+
patchValues.add(v);
331+
}
332+
if (v < min) {
333+
min = v;
334+
}
335+
if (v > max) {
336+
max = v;
337+
}
338+
}
339+
340+
byte[] statsMin = n > 0 ? scalarF32(min) : null;
341+
byte[] statsMax = n > 0 ? scalarF32(max) : null;
342+
343+
MemorySegment encodedBuf = Arena.ofAuto().allocate((long) n * 4, 4);
344+
for (int i = 0; i < n; i++) {
345+
encodedBuf.setAtIndex(PTypeIO.LE_INT, i, encodedArr[i]);
346+
}
347+
348+
EncodeNode encodedNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 0);
349+
350+
if (patchIndices.isEmpty()) {
351+
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
352+
.setExpE(expE).setExpF(expF).build().toByteArray();
353+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ALP,
354+
ByteBuffer.wrap(metaBytes), new EncodeNode[]{encodedNode}, new int[0]);
355+
return new EncodeResult(root, List.of(encodedBuf), statsMin, statsMax);
356+
}
357+
358+
int numPatches = patchIndices.size();
359+
MemorySegment idxBuf = Arena.ofAuto().allocate((long) numPatches * 4, 4);
360+
MemorySegment valBuf = Arena.ofAuto().allocate((long) numPatches * 4, 4);
361+
for (int i = 0; i < numPatches; i++) {
362+
idxBuf.setAtIndex(PTypeIO.LE_INT, i, patchIndices.get(i));
363+
valBuf.setAtIndex(PTypeIO.LE_FLOAT, i, patchValues.get(i));
364+
}
365+
366+
EncodingProtos.PatchesMetadata patches = EncodingProtos.PatchesMetadata.newBuilder()
367+
.setLen(numPatches)
368+
.setOffset(0)
369+
.setIndicesPtype(DTypeProtos.PType.forNumber(PType.U32.ordinal()))
370+
.build();
371+
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
372+
.setExpE(expE).setExpF(expF).setPatches(patches).build().toByteArray();
373+
374+
EncodeNode idxNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 1);
375+
EncodeNode valNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 2);
376+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ALP,
377+
ByteBuffer.wrap(metaBytes),
378+
new EncodeNode[]{encodedNode, idxNode, valNode},
379+
new int[0]);
380+
return new EncodeResult(root, List.of(encodedBuf, idxBuf, valBuf), statsMin, statsMax);
381+
}
382+
383+
private static byte[] scalarF32(float v) {
384+
return ScalarProtos.ScalarValue.newBuilder().setF32Value(v).build().toByteArray();
385+
}
386+
277387
@Override
278388
public Array decode(DecodeContext ctx) {
279389
ByteBuffer rawMeta = ctx.metadata();

core/src/main/java/io/github/dfa1/vortex/encoding/DictEncoding.java

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,25 @@
1212
import io.github.dfa1.vortex.core.array.IntArray;
1313
import io.github.dfa1.vortex.core.array.LongArray;
1414
import io.github.dfa1.vortex.core.array.ShortArray;
15+
import io.github.dfa1.vortex.core.array.VarBinArray;
1516
import io.github.dfa1.vortex.core.VortexException;
1617

18+
import java.lang.foreign.Arena;
1719
import java.lang.foreign.MemorySegment;
1820
import java.lang.foreign.ValueLayout;
1921
import java.nio.ByteBuffer;
22+
import java.nio.charset.StandardCharsets;
2023
import java.util.LinkedHashMap;
2124
import java.util.List;
2225

2326
/// Encoding for `vortex.dict` — dictionary encoding for low-cardinality columns.
2427
///
25-
/// Segment layout: [valuesbuffer(uniquevalues,primitive)] [codesbuffer(per-rowindices)].
26-
/// Metadata (1 byte): code PType ordinal (0=U8, 1=U16, 2=U32).
28+
/// Primitive: [valuesbuffer(uniquevalues)] [codesbuffer]. Metadata (1 byte): code PType ordinal.
2729
/// Node tree: DictNode{ children=[ValuesNode{buf=0},CodesNode{buf=1}] }.
30+
///
31+
/// Utf8: buf 0 = dict bytes, buf 1 = dict offsets (I64 LE, n_unique+1), buf 2 = codes.
32+
/// Metadata (2 bytes): [codePType ordinal, I64 ordinal]. Flat node (no children), bufferIndices=[0,1,2].
33+
/// Decoded as a {@link VarBinArray} in dict mode — no string materialization at decode time.
2834
public final class DictEncoding implements Encoding {
2935

3036
private static Array decodeChildAs(DecodeContext parent, int childIdx, DType dtype, long rowCount) {
@@ -198,7 +204,7 @@ public EncodingId encodingId() {
198204

199205
@Override
200206
public boolean accepts(DType dtype) {
201-
return dtype instanceof DType.Primitive;
207+
return dtype instanceof DType.Primitive || dtype instanceof DType.Utf8;
202208
}
203209

204210
private record DictData(MemorySegment valuesBuf, Object codesArr, PType codePType, int len) {}
@@ -255,6 +261,9 @@ private static DictData buildDictData(DType dtype, Object data) {
255261

256262
@Override
257263
public EncodeResult encode(DType dtype, Object data) {
264+
if (dtype instanceof DType.Utf8) {
265+
return encodeUtf8((String[]) data, dtype);
266+
}
258267
DictData d = buildDictData(dtype, data);
259268
PType codePType = d.codePType();
260269
int codeBytes = codePType.byteSize();
@@ -277,6 +286,9 @@ public EncodeResult encode(DType dtype, Object data) {
277286

278287
@Override
279288
public CascadeStep encodeCascade(DType dtype, Object data, CompressorContext ctx) {
289+
if (dtype instanceof DType.Utf8) {
290+
return CascadeStep.terminal(encodeUtf8((String[]) data, dtype));
291+
}
280292
DictData d = buildDictData(dtype, data);
281293
PType codePType = d.codePType();
282294

@@ -301,6 +313,75 @@ private static int readCodeFromArr(Object arr, PType codePType, int i) {
301313
};
302314
}
303315

316+
// ── Utf8 encode ───────────────────────────────────────────────────────────
317+
318+
private EncodeResult encodeUtf8(String[] strings, DType dtype) {
319+
int n = strings.length;
320+
321+
var valueMap = new LinkedHashMap<String, Integer>();
322+
for (String s : strings) {
323+
valueMap.computeIfAbsent(s, k -> valueMap.size());
324+
}
325+
326+
int dictSize = valueMap.size();
327+
PType codePType = codePType(dictSize);
328+
int codeBytes = codePType.byteSize();
329+
330+
byte[][] dictByteArrays = new byte[dictSize][];
331+
int j = 0;
332+
long totalDictBytes = 0;
333+
for (String s : valueMap.keySet()) {
334+
dictByteArrays[j] = s.getBytes(StandardCharsets.UTF_8);
335+
totalDictBytes += dictByteArrays[j].length;
336+
j++;
337+
}
338+
339+
Arena arena = Arena.ofAuto();
340+
MemorySegment dictBytesBuf = arena.allocate(totalDictBytes > 0 ? totalDictBytes : 1);
341+
MemorySegment dictOffsetsBuf = arena.allocate((long) (dictSize + 1) * Long.BYTES, Long.BYTES);
342+
343+
long pos = 0;
344+
dictOffsetsBuf.setAtIndex(PTypeIO.LE_LONG, 0, 0L);
345+
for (int i = 0; i < dictSize; i++) {
346+
MemorySegment.copy(MemorySegment.ofArray(dictByteArrays[i]), 0, dictBytesBuf, pos, dictByteArrays[i].length);
347+
pos += dictByteArrays[i].length;
348+
dictOffsetsBuf.setAtIndex(PTypeIO.LE_LONG, i + 1, pos);
349+
}
350+
351+
MemorySegment codesBuf = Arena.ofAuto().allocate((long) n * codeBytes);
352+
for (int i = 0; i < n; i++) {
353+
writeCodeToSeg(codesBuf, codePType, i, valueMap.get(strings[i]));
354+
}
355+
356+
// 2 bytes: [codePType ordinal, I64 ordinal] — distinguishes from 1-byte primitive format
357+
ByteBuffer meta = ByteBuffer.allocate(2)
358+
.put(0, (byte) codePType.ordinal())
359+
.put(1, (byte) PType.I64.ordinal());
360+
361+
EncodeNode root = new EncodeNode(
362+
EncodingId.VORTEX_DICT, meta,
363+
new EncodeNode[0],
364+
new int[]{0, 1, 2});
365+
366+
return new EncodeResult(root, List.of(dictBytesBuf, dictOffsetsBuf, codesBuf), null, null);
367+
}
368+
369+
// ── Utf8 decode ───────────────────────────────────────────────────────────
370+
371+
private static Array decodeUtf8Dict(DecodeContext ctx, ByteBuffer meta) {
372+
PType codePType = PType.values()[Byte.toUnsignedInt(meta.get(0))];
373+
long n = ctx.rowCount();
374+
375+
MemorySegment dictBytes = ctx.buffer(0);
376+
MemorySegment dictOffsets = ctx.buffer(1);
377+
MemorySegment codes = ctx.buffer(2);
378+
379+
return VarBinArray.ofDict(ctx.dtype(), n,
380+
dictBytes, dictOffsets, PType.I64,
381+
codes, codePType,
382+
ArrayStats.empty());
383+
}
384+
304385
@Override
305386
public Array decode(DecodeContext ctx) {
306387
if (ctx.metadata() == null || !ctx.metadata().hasRemaining()) {
@@ -309,6 +390,10 @@ public Array decode(DecodeContext ctx) {
309390

310391
ByteBuffer meta = ctx.metadata();
311392

393+
if (ctx.dtype() instanceof DType.Utf8) {
394+
return decodeUtf8Dict(ctx, meta);
395+
}
396+
312397
// 1-byte = legacy Java format; multi-byte = Rust proto format
313398
if (meta.remaining() == 1) {
314399
return decodeLegacyJava(ctx, meta.get(0));

core/src/test/java/io/github/dfa1/vortex/encoding/AlpEncodingTest.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,74 @@ void decode_f32_noPatches() {
220220
.as("index %d", i).isCloseTo(expected[i], within(1e-6f));
221221
}
222222
}
223+
224+
@Test
225+
void encode_f32_roundTrip_noPatches() {
226+
// Given
227+
float[] values = {1.0f, 2.5f, 3.75f, 10.0f, 0.1f};
228+
AlpEncoding sut = new AlpEncoding();
229+
230+
EncodingRegistry registry = EncodingRegistry.empty();
231+
registry.register(sut);
232+
registry.register(new PrimitiveEncoding());
233+
234+
// When
235+
EncodeResult encoded = sut.encode(F32_DTYPE, values);
236+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, values.length, F32_DTYPE, registry);
237+
Array result = sut.decode(ctx);
238+
239+
// Then
240+
var layout = ValueLayout.JAVA_FLOAT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
241+
for (int i = 0; i < values.length; i++) {
242+
assertThat(result.buffer(0).get(layout, (long) i * 4))
243+
.as("index %d", i).isCloseTo(values[i], within(1e-6f));
244+
}
245+
}
246+
247+
@Test
248+
void encode_f32_roundTrip_withPatches() {
249+
// Given — Float.NaN and Float.POSITIVE_INFINITY can't be ALP-encoded; must become patches
250+
float[] values = {1.0f, Float.NaN, 2.5f, Float.POSITIVE_INFINITY, 3.0f};
251+
AlpEncoding sut = new AlpEncoding();
252+
253+
EncodingRegistry registry = EncodingRegistry.empty();
254+
registry.register(sut);
255+
registry.register(new PrimitiveEncoding());
256+
257+
// When
258+
EncodeResult encoded = sut.encode(F32_DTYPE, values);
259+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, values.length, F32_DTYPE, registry);
260+
Array result = sut.decode(ctx);
261+
262+
// Then
263+
var layout = ValueLayout.JAVA_FLOAT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
264+
assertThat(result.buffer(0).get(layout, 0L)).isCloseTo(1.0f, within(1e-6f));
265+
assertThat(result.buffer(0).get(layout, 4L)).isNaN();
266+
assertThat(result.buffer(0).get(layout, 8L)).isCloseTo(2.5f, within(1e-6f));
267+
assertThat(result.buffer(0).get(layout, 12L)).isInfinite();
268+
assertThat(result.buffer(0).get(layout, 16L)).isCloseTo(3.0f, within(1e-6f));
269+
}
270+
271+
@Test
272+
void encode_f64_roundTrip_noPatches() {
273+
// Given
274+
double[] values = {1.23, 4.56, 7.89, 0.001, 100.0};
275+
AlpEncoding sut = new AlpEncoding();
276+
277+
EncodingRegistry registry = EncodingRegistry.empty();
278+
registry.register(sut);
279+
registry.register(new PrimitiveEncoding());
280+
281+
// When
282+
EncodeResult encoded = sut.encode(F64_DTYPE, values);
283+
DecodeContext ctx = EncodeTestHelper.toDecodeContext(encoded, values.length, F64_DTYPE, registry);
284+
Array result = sut.decode(ctx);
285+
286+
// Then
287+
var layout = ValueLayout.JAVA_DOUBLE_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN);
288+
for (int i = 0; i < values.length; i++) {
289+
assertThat(result.buffer(0).get(layout, (long) i * 8))
290+
.as("index %d", i).isCloseTo(values[i], within(1e-9));
291+
}
292+
}
223293
}

0 commit comments

Comments
 (0)