Skip to content

DeltaEncodingDecoder routes decode through four row-scaled heap long[] arrays #338

Description

@dfa1

Problem

DeltaEncodingDecoder.decode (reader/decode/DeltaEncodingDecoder.java:66-94) routes the entire column through four row-scaled heap long[] arrays before finally writing one arena segment:

long[] basesAll  = readLongs(ctx.decodeChildSegment(0, dtype, basesLen),  (int) basesLen,  ptype);
long[] deltasAll = readLongs(ctx.decodeChildSegment(1, dtype, deltasLen), (int) deltasLen, ptype);
...
long[] decoded = new long[(int) deltasLen];
...
long[] result = new long[(int) rowCount];
System.arraycopy(decoded, offset, result, 0, (int) rowCount);

MemorySegment seg = PrimitiveArrays.fromLongs(result, ptype, ctx.arena());

This is CLAUDE.md's allocation rule ("never new byte[] + heap arrays for decode output — always ctx.arena().allocate(...)") violated four times at row scale. Every value is widened to 8 bytes regardless of ptype, so an I8 delta column allocates 8× its natural width on the GC heap, three times over, plus the final off-heap segment.

decodedresult is additionally a pure duplicate: the only thing the second array adds is dropping offset leading elements, which System.arraycopy then pays a second full traversal to do.

Fix

Two independent steps, either useful alone:

  1. Write into the arena directly. The per-chunk scratch (untransposedChunk, chunkBases, chunkDeltas, chunkUndelta — all FastLanes.CHUNK/lanes sized) is fine as-is: fixed-size, cache-resident, genuinely scratch. The row-scaled ones are not. decoded and result should collapse into a single ctx.arena().allocate(rowCount * ptype.byteSize()), with the untransposed chunk written straight to its final position at -offset and at the ptype's real width, dropping both the widening and the arraycopy. basesAll/deltasAll can be read from their segments in place rather than copied out.

  2. Fix readLongs' hot loop (:116-134), which carries both hot-loop anti-patterns at once:

for (int i = 0; i < count; i++) {
    long off = (i % cap) * elemSize;          // modulo per element
    out[i] = switch (ptype) {                 // non-uniform body per element
        case I8 -> buf.get(ValueLayout.JAVA_BYTE, off);
        ...
    };
}

Per CLAUDE.md, both must be hoisted: branch-split on cap == count to get a modulo-free fast path (the modulo only exists for the ConstantEncoding broadcast case), and hoist the ptype switch outside the loop into specialized uniform bodies. DictEncodingDecoder.expandU8 (:294-311) is the in-repo template for exactly this shape.

Context

Found in a sweep for remaining eager materializations after #329 / 7e0d6e7, alongside the vortex.runend string expansion, vortex.sequence, the vortex.dict primitive path, and vortex.patched. Unlike those, this one is not about adding a lazy carrier — delta genuinely has to reconstruct values — it is about not doing that reconstruction on the heap at 8 bytes per element.

Worth a JavaVsJniReadBenchmark-style before/after, since the same i % cap + per-element-switch pattern is what caused the 5–10× regressions recorded in CLAUDE.md (ed658b7051a794442021f).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions