Skip to content

Commit 332b067

Browse files
dfa1claude
andcommitted
refactor(reader): add Array.materialize(), remove the ArraySegments switch
ArraySegments.of() re-stated each encoding's decode formula (FoR/ZigZag/ALP), the chunked concat, and the dict gather in one large switch, separate from the per-element accessor each lazy array already carries. Introduce Array.materialize(SegmentAllocator) — a pure abstract method mirroring the existing Array.limited() polymorphism — and push every case onto the type that owns it: - segment-backed arrays (Materialized*, VarBin, Generic, LazyDecimal) return their existing buffer with no copy; - the Lazy FoR/ZigZag/ALP variants bulk-decode through their own getX(i) in a tight loop — the formula lives only in getX; the override exists purely to give the JIT a monomorphic, inlinable (hence vectorisable) call site that the shared megamorphic interface default cannot; - chunked arrays concatenate children, dict arrays gather, constant-decimal fills; - MaskedArray delegates to its inner data; - primitive interfaces provide a scalar getX fallback, and BoolArray packs an LSB-first bitmap (the lazy-bool path that previously had no materialisation); - families with no primary segment (struct, list, list-view, fixed-size list, variant, byte-parts decimal, null, unknown) implement it to throw explicitly. ArraySegments.of() is removed; callers (ReadRegistry, ScanIterator, DecodeContext, encoder/integration/performance code) invoke arr.materialize(arena) directly. ArraySegments retains only the non-allocating trySegment() probe used by the scan layer's dict zip-bomb guard, and is marked @deprecated(forRemoval = true) — it is deleted once the decode-limits layer owns that bound. Behaviour is preserved, including the broadcast edge where a constant column materialises to a single-element buffer. Adds ArrayMaterializeTest for direct coverage (zero-copy, scalar/bitmap fallbacks, the Lazy formulas, chunked/dict, constant-decimal, masked delegation, and all no-primary-segment throw cases). Updates ADR 0016 to document materialize() as the shipped producer of the Arrow C-Data values buffer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fe98383 commit 332b067

65 files changed

Lines changed: 1267 additions & 492 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/adr/0016-vortex-arrow-bridge.md

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ Three pieces of work beyond just handing over the existing mmap slices:
115115
2. **Lazy materialisation.** Lazy arrays (ZigZag/FoR/ALP/Dict/RLE) store the
116116
*encoded* form, which is not the Arrow values layout, so they must be materialised
117117
into a contiguous LE segment first. This is exactly the producer step that
118-
`ArraySegments.of(...)` (or a future `Array.materialize(arena)` delegation seam,
119-
see below) performs, so the internal materialise path feeds the `values` buffer
118+
`Array.materialize(arena)` performs (see below), so it feeds the `values` buffer
120119
directly. Primitive values, VarBin data+offsets, and StringView are already
121120
Arrow-shaped (zero-copy).
122121
3. **Lifetime / release contract.** Buffers are zero-copy slices of the mmap'd file
@@ -127,20 +126,28 @@ Three pieces of work beyond just handing over the existing mmap slices:
127126
consumer calls `release` is a use-after-unmap → native segfault, not a Java
128127
exception. This is the highest-risk part.
129128

130-
### Relationship to the internal materialise seam
131-
132-
`ArraySegments.of(Array, SegmentAllocator)` already centralises "turn any array
133-
(lazy or eager) into a contiguous LE primitive segment", and currently re-states each
134-
encoding's decode formula (ZigZag/FoR/ALP) in a large switch separate from the
135-
per-element accessor on the lazy array. A standalone refactor — moving that bulk
136-
materialisation onto the array types as an `Array.materialize(SegmentAllocator)`
137-
delegation (mirroring the existing `Array.limited(...)` pattern, kept on a
138-
package-private seam to avoid widening the public API) — stands on its own as a
139-
locality cleanup. It is **not** an Arrow feature, but it is the natural producer of
140-
the Arrow `values` buffer, so Option B should build on it rather than duplicate it.
141-
The contiguous LE segment it yields already matches Arrow's primitive values-buffer
142-
layout; the gap to a full Arrow array is validity + offsets + children, per the table
143-
above.
129+
### Relationship to the `Array.materialize` seam (shipped)
130+
131+
The bulk-materialisation seam Option B builds on now exists:
132+
`Array.materialize(SegmentAllocator)` — a pure abstract method (mirroring the existing
133+
`Array.limited(...)` polymorphism) that turns any array, lazy or eager, into a contiguous
134+
LE primitive segment. Each type owns its path: segment-backed arrays return their buffer
135+
zero-copy, the `Lazy*` variants apply their inlined decode formula (ZigZag/FoR/ALP) in a
136+
vectorisable loop next to their per-element accessor, chunked/dict arrays concat/gather,
137+
and the families with no primary segment (struct, list, variant, byte-parts decimal, null,
138+
unknown) throw.
139+
140+
This is **not** an Arrow feature — but it is the natural producer of the Arrow `values`
141+
buffer, so Option B builds on it. The contiguous LE segment it yields already matches
142+
Arrow's primitive values-buffer layout. Two gaps remain to a full Arrow array, both per
143+
the table above: validity + offsets + children; and the broadcast edge — a constant column
144+
materialises to a single-element buffer (`length != elementCount`), which `materialize()`
145+
returns as-is, so the Arrow producer must expand it to `length` values.
146+
147+
`materialize` is intentionally part of the public `Array` contract (not a package-private
148+
seam): it is the documented way to obtain a column's contiguous primitive buffer, and a
149+
future `vortex-arrow` module in a separate package consumes it without further API
150+
widening.
144151

145152
### Option C — No bridge; document manual conversion
146153

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import io.github.dfa1.vortex.core.DType;
1313
import io.github.dfa1.vortex.core.PType;
1414
import io.github.dfa1.vortex.reader.array.Array;
15-
import io.github.dfa1.vortex.reader.array.ArraySegments;
1615
import io.github.dfa1.vortex.reader.array.DoubleArray;
1716
import io.github.dfa1.vortex.reader.array.LongArray;
1817
import io.github.dfa1.vortex.reader.ReadRegistry;
@@ -130,7 +129,7 @@ private static List<JavaChunk> scanAll(VortexReader vf,
130129
/// into a heap primitive array — long[]/int[]/double[]/float[]/short[]/byte[].
131130
private static Object snapshotArray(Array arr) {
132131
var ptype = ((DType.Primitive) arr.dtype()).ptype();
133-
var seg = ArraySegments.of(arr, Arena.ofAuto());
132+
var seg = arr.materialize(Arena.ofAuto());
134133
return switch (ptype) {
135134
case I64, U64 -> seg.toArray(ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN));
136135
case I32, U32 -> seg.toArray(ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN));

performance/src/main/java/io/github/dfa1/vortex/performance/RustWritesJavaReadsBigFileBenchmark.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import dev.vortex.arrow.ArrowAllocation;
1010
import dev.vortex.jni.NativeLoader;
1111
import io.github.dfa1.vortex.reader.array.Array;
12-
import io.github.dfa1.vortex.reader.array.ArraySegments;
1312
import io.github.dfa1.vortex.reader.ReadRegistry;
1413
import io.github.dfa1.vortex.reader.VortexReader;
1514
import io.github.dfa1.vortex.reader.Chunk;
@@ -181,7 +180,7 @@ private long scanJava() throws IOException {
181180
while (iter.hasNext()) {
182181
try (Chunk c = iter.next()) {
183182
Array arr = c.columns().get("c0");
184-
MemorySegment buf = ArraySegments.of(arr, Arena.ofAuto());
183+
MemorySegment buf = arr.materialize(Arena.ofAuto());
185184
long count = buf.byteSize() / Long.BYTES;
186185
for (long i = 0; i < count; i++) {
187186
sum += buf.getAtIndex(LE_LONG, i);

reader/src/main/java/io/github/dfa1/vortex/reader/ReadRegistry.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import io.github.dfa1.vortex.core.VortexException;
44
import io.github.dfa1.vortex.reader.array.Array;
5-
import io.github.dfa1.vortex.reader.array.ArraySegments;
65
import io.github.dfa1.vortex.reader.array.UnknownArray;
76
import io.github.dfa1.vortex.encoding.EncodingId;
87
import io.github.dfa1.vortex.reader.decode.ArrayNode;
@@ -101,7 +100,7 @@ public MemorySegment decodeAsSegment(DecodeContext ctx) {
101100
case UnknownArrayNode _ -> null;
102101
};
103102
if (decoder != null) {
104-
return ArraySegments.of(decoder.decode(ctx), ctx.arena());
103+
return decoder.decode(ctx).materialize(ctx.arena());
105104
}
106105
String id = switch (node) {
107106
case KnownArrayNode k -> k.encodingId().id();

reader/src/main/java/io/github/dfa1/vortex/reader/ScanIterator.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ private Array decodeDictLayout(Layout dictLayout, DType dtype, SegmentAllocator
603603
// than the claimed rowCount. Full-decode encodings (e.g. bitpacked) already
604604
// wrote n * elemBytes to the arena during decodeLayout above, so their buffer
605605
// matches n.
606-
MemorySegment codesSeg = ArraySegments.of(codes, arena);
606+
MemorySegment codesSeg = codes.materialize(arena);
607607
long bufferCodes = codesSeg.byteSize() / (long) codesPType.byteSize();
608608
if (bufferCodes < n) {
609609
throw new VortexException(EncodingId.VORTEX_DICT,
@@ -624,7 +624,7 @@ private Array decodeDictLayout(Layout dictLayout, DType dtype, SegmentAllocator
624624
}
625625
// Non-Utf8, non-Primitive dict — e.g. extension types backed by VarBin. Fall through
626626
// to the existing string expansion for compatibility.
627-
MemorySegment codesSegFallback = ArraySegments.of(codes, arena);
627+
MemorySegment codesSegFallback = codes.materialize(arena);
628628
long bufferCodesFallback = codesSegFallback.byteSize() / (long) codesPType.byteSize();
629629
if (bufferCodesFallback < n) {
630630
throw new VortexException(EncodingId.VORTEX_DICT,
@@ -641,6 +641,9 @@ private Array decodeDictLayout(Layout dictLayout, DType dtype, SegmentAllocator
641641
/// @param codes the decoded codes array
642642
/// @param codesPType code ptype reported by the dict layout metadata
643643
/// @param n claimed dict row count
644+
// ArraySegments is deprecated-for-removal; this guard is its only caller and moves to
645+
// the decode-limits layer with it.
646+
@SuppressWarnings("removal")
644647
private static void validateDictCodesCapacity(Array codes, PType codesPType, long n) {
645648
Optional<MemorySegment> maybeSeg = ArraySegments.trySegment(codes);
646649
if (maybeSeg.isEmpty()) {

reader/src/main/java/io/github/dfa1/vortex/reader/array/Array.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import io.github.dfa1.vortex.core.DType;
44

5+
import java.lang.foreign.MemorySegment;
6+
import java.lang.foreign.SegmentAllocator;
7+
58
/// Decoded columnar data. Concrete subtypes specialise element access for the JIT;
69
/// each covers a specific dtype family.
710
///
@@ -41,6 +44,24 @@ public sealed interface Array
4144
/// @return an array of length `rows`
4245
Array limited(long rows);
4346

47+
/// Materialises this array into its primary backing [MemorySegment],
48+
/// allocating from `arena` for lazy variants.
49+
///
50+
/// Segment-backed arrays (the `Materialized*` records, `VarBinArray`,
51+
/// `GenericArray`, `LazyDecimalArray`) return their existing buffer with no
52+
/// copy. Lazy primitive arrays decode element-by-element, the `Lazy*`
53+
/// frame-of-reference / zigzag / ALP variants apply their inlined formula in a
54+
/// vectorisable loop, and composite arrays (chunked, dict) concatenate or gather
55+
/// their children. This is the single materialisation contract behind
56+
/// [io.github.dfa1.vortex.reader.decode.DecodeContext#materialize(Array)].
57+
///
58+
/// Array families with no row-addressable primary segment (struct, list, variant,
59+
/// the byte-parts decimal layout) throw [io.github.dfa1.vortex.core.VortexException].
60+
///
61+
/// @param arena allocator used to materialise lazy variants
62+
/// @return the primary [MemorySegment]
63+
MemorySegment materialize(SegmentAllocator arena);
64+
4465
/// Limits `arr` to its first `rows` elements (semantically `min(length, rows)`),
4566
/// returning it unchanged when it already fits. Single guard shared by the scan
4667
/// layer and the composite subtypes that recurse into children, so the

0 commit comments

Comments
 (0)