Repository navigation
perf(writer, core): optional Vector API candidates on the write path and in shared helpers #484
Description
Companion to the reader-side issue (fused filter-aggregate, Delta undelta). Same ground rules: opt-in, scalar default, hardwood-style link-failure fallback, no DecodeContext/WriteOptions flag, and ADR 0005's bar (C2 can't do it, >= 10% on two architectures, hot in a real profile). Nothing here is measured yet. This issue lists where to measure first.
Prior evidence we must respect
BitpackedEncodingEncoder already records that a fused scalar pass beat a separately vectorized min/max pass ("the loop is memory-bound ... measured, #475"). So any candidate that merely re-adds a standalone min/max/histogram reduction starts with negative evidence. Candidates below are ranked by compute density per byte loaded, which is what the Vector API can actually win on.
Writer candidates
W1. FastLanes bit-pack (BitpackedEncodingEncoder, the block / lane / row nest around line 210)
Per block, per lane, the loop walks typeBits rows, shifting each value into an accumulator and flushing a word when filled >= typeBits:
for (lane...) { acc = 0; shift = 0; word = 0;
for (row...) { value = values[blockStart + rowOffset[row] + lane] & widthMask;
acc |= value << shift;
if (filled >= typeBits) { words[word*lanes+lane] = acc & typeMask; ... carry ... } } }Why it fits: idx = blockStart + rowOffset[row] + lane, so adjacent lanes are contiguous. The branch filled >= typeBits depends only on row (and bitWidth), not on lane, so it is uniform across the vector and becomes a scalar branch hoisted out of the lane dimension. With lane innermost, acc/shift-by-row become per-row scalars and acc becomes a LongVector (LongVector.fromArray(values, idx), .and(widthMask), .lanewise(LSHL, shift), .or). C2 can't do this today because lane is the outer loop and the acc dependency runs along the inner row loop.
Do first (plain Java): interchange to row-outer, lane-inner with long[] acc = new long[lanes]. The inner loop is then uniform and contiguous, and C2 superword may take it. Only if it doesn't does a LongVector version earn its keep.
Caveat: the fullBlock || idx < n tail guard is the only non-uniform part. Keep the full-block fast path and leave the partial tail block scalar (the existing branch-split idiom).
Why this is the best writer target: unlike min/max, bit-packing does shifts, ORs and a store per element, so it's compute-bound rather than purely memory-bound, and it sits in the write path for every integer column the cascade tries bitpacking on (write was the 0.44 to 0.88 ops/s gap vs vortex-jni, see the cascade-write stack).
W2. ALP encode (AlpEncodingEncoder#computeF64 / computeF32, the per-value encodeF64 + verify)
Per value: multiply by two table constants, add and subtract the sweet-spot magic number, cast to long, decode again, compare raw bits for the exception check.
long encoded = (long) ((v * F10[e] * IF10[f] + SWEET) - SWEET);
if (doubleToRawLongBits(encoded * F10[f] * IF10[e]) != doubleToRawLongBits(v)) { patch... }Why it fits: straight-line FP math with a single rare exception. The exception path is the only non-uniform part: compute the mismatch mask for a vector, and if (!mask.anyTrue()) store; else scalar fallback for those lanes is the standard pattern, and patches are rare by construction (ALP is chosen only when it mostly round-trips). DoubleVector.convert(D2L, 0) and reinterpretAsLongs() cover the cast and the bit compare.
Parity risk (prime directive): results must be bit-identical to Rust's encode_single / decode_single. The two-step multiply order (* F10[f] * IF10[e], never a pre-multiplied scale) is already protected in the reader's javadoc. The vector path must do the same two multiplies, in the same order, with no FMA contraction. Java doesn't fuse implicitly, but DoubleVector.fma must not be used here. Add a differential test over seeded-random doubles plus edge cases (-0.0, NaN, +-Inf, subnormals, Double.MAX_VALUE, values at exactly the sweet-spot boundary).
The exponent search (for e..., for f... over the 1024-value sample) is ~e^2/2 passes over a tiny array and is a cheaper first target for the same kernel: it is dominated by exactly this encode-and-verify arithmetic.
W3 (low priority, measure only). FrameOfReferenceEncodingEncoder#computeRef and ZoneMapStats.ofDecimal(long[])
Standalone min scans (computeRef) and a min+max scan (ofDecimal) are textbook reduceLanes(MIN/MAX) shapes, but see the #475 evidence above. Worth a single JMH row to confirm or refute, not a commitment. The 128/256-bit decimal ofDecimal(MemorySegment, byteWidth) is a multi-word compare and is not a candidate.
Shared helpers (core.compute.PrimitiveArrays, PTypeIO)
The existing PrimitiveArraysBenchmark (widen, narrow) is the harness. Before writing any Vector API, measure what C2 already does.
| Helper | Shape | Note |
|---|---|---|
toLongs(Object, ...) |
heap array to long[], sign or zero extension, one specialized loop per ptype |
Likely already auto-vectorized (simple widening of a heap array). Measure with -prof perfasm or PrintAssembly or JFR before touching. convertShape(B2L / ZERO_EXTEND_B2L) would be the Vector API form |
toLongsInto(MemorySegment, ...) |
LE MemorySegment to long[], same shape |
Segment accessors with int loop var and long offset: check C2 vectorizes it (long-offset index math can defeat superword). If not, LongVector/IntVector.fromMemorySegment(..., LE) plus convert is a direct fit. This helper is called per FastLanes chunk by the Delta decoder, so it sits on a real read path |
fromLongs(long[], ptype, arena) (narrowing branch) |
per-element PTypeIO.set(seg, off, ptype, v) |
Not a Vector API problem. PTypeIO.set is a MethodHandle[] lookup + invokeExact per element with the ptype switch inside the loop, which is exactly what the hot-loop rule forbids (SETTERS[ptype.ordinal()] is variable-target). Fix: hoist the switch and write one specialized loop per width (as toLongsInto already does). Used by DeltaEncodingEncoder for bases and deltas. Plain-Java win, no flag, no ADR gate |
That last row is the cheapest, safest item in this whole issue and could ship independently of any Vector API decision.
Packaging note (applies to W1/W2 and helpers)
Don't scatter jdk.incubator.vector imports across encoders. Mirror hardwood's seam: one small SimdKernels-style interface per module boundary (writer kernels in writer, shared widening in core), scalar implementation always present, a vector implementation selected by a guarded static initializer that catches Throwable (NoClassDefFoundError/UnsupportedOperationException when the module is absent, or SPECIES_PREFERRED too narrow). Writer must not depend on reader, so there will be two small seams rather than one shared class, or the seam lives in core if both sides need it.
Acceptance
- JMH baselines (
-f 3,ClassName.methodName): bit-pack, ALP encode (incl. exponent search),toLongsInto/fromLongs, on M5 and one x86 machine -
fromLongsnarrowing hoisted to per-width loops (independent, plain Java) - Bit-pack loop interchange tried before any Vector API
- Vector kernel for a candidate only if >= 10% over the best scalar on both machines
- Differential tests vs the scalar path, byte-identical output, including ALP edge values
- ADR 0005 and
docs/reference.mdupdated in the same commit
Activity
dfa1 commentedon Oct 9, 2026
What the Rust reference does with SIMD (surveyed
vortex-data/vortex@30e84dc, 2026-10-09)Short version: Rust has two tiers. The encode/decode kernels are plain scalar code shaped so LLVM auto-vectorizes it. Explicit intrinsics are used only for selection and bitmap work (filter, take, mask, popcount, UTF-8). This supports our order: make the loop C2-friendly first, and use the Vector API only where Rust also had to go explicit.
Tier 1: scalar code shaped for the auto-vectorizer (no intrinsics)
core.simdfastlanescrateBitPacking::pack/unpack,Delta, FoR, RLE. The README says SIMD decoding "without intrinsics or other explicit SIMD code" (VLDB'23, "with Scalar Code")packBlock,deltaChunk,undeltaChunkwidenInto,narrowIntois_constantover primitives: chunked intoIS_CONST_LANE_WIDTH(16, or 32 with AVX2) lanes so it vectorizesfor_compress.rs,rle_decompress.rs: comments call out keeping validity and bounds checks out of the loop "which keeps their loops vectorized"So the kernels on this branch are Rust's tier 1. Parity means getting them to auto-vectorize under C2 (lane-inner interchange, branch-split tails). It doesn't mean using the Vector API.
Tier 2: explicit SIMD, with one-time CPU dispatch
Dispatch is
vortex-buffer/src/dispatch.rsCpuKernel: a function pointer is chosen once on first call, with a per-archcfgplus a runtime feature-probe chain and a portable default. The portable kernels usefearless_simd(dispatch!(Level::new(), ...)). This is the same shape as ourVectorSupport+ScalarOperations.arrays/filter/execute/simd_compress/{x86,neon,generic}.rs, rung 3 of the ladder infilter/execute/buffer.rsmaskz_compress_epi{8,16,32,64}; AVX2permutevar8x32+ maskstore; NEONvqtbl1q_u8with an index LUT; portableu8x16::swizzle_dynfor 1/2-bytearrays/fixed_width/take/avx2/take/small_table/{neon,avx2}.rsvqtbl1q_u8(+vmaxq_u8for the max-code bound check)vortex-buffer/src/bit/count_ones.rsu64x8lane-wise popcount over 64-byte chunksbit/select.rs,vortex-mask/src/intersect_by_rank.rspdep/tzcntcollect_bool)bit/pack.rsvptestmb, AVX2/SSE2pmovmskb, NEONushl+addp, SWAR fallbackarrays/bool/compute/filter.rspextbitpacking/compute/compare_fused.rstranspose_bitsper 1024 block (fastlanesruntimefeature)vortex-buffer/src/string.rs,varbinview/array.rssimdutf8crateNothing on the write path is explicit SIMD in Rust: compressor stats, ALP encode, bit-pack and FoR are all tier 1.
Consequences for this ticket and #483
LongVector/IntVector.compress(VectorMask)(JDK 19+, intrinsified on AVX-512 and SVE) +VectorMask.fromLongVectorMask.fromLong/toLong(thecollect_boolanalogue)ByteVector.selectFrom/rearrange(NEONtblanalogue)Long.bitCountis already a C2 intrinsic, so measure before adding anythingfromArray(species, arr, off, indexMap, ...)exists only for heap arrays, notMemorySegment. This is a weak fit, and Rust itself skips 1/2-byte gathersStringdecode is already intrinsified (countPositives). No actionCpuKernel+fearless_simddo. KeepSimdOperationsas the single seam incore.simd.Proposed spec rule for
core.simd: a kernel gets a Vector API implementation only if the corresponding Rust operation is tier 2 (explicit SIMD), or if C2 provably fails to vectorize a tier-1 loop that LLVM vectorizes. Everything else stays scalar, shaped for C2.perf(core,reader,writer): core.simd SimdOperations kernels, lane-inne…
dfa1 commentedon Oct 9, 2026
Progress and plan (branch
perf/simd-kernels, not pushed)All timings:
SimdKernelBenchmark, 256 chunks/blocks per op,-f 3, Apple M5.Done
PrimitiveArrays.fromLongsnarrowing: one loop per width instead of aMethodHandlecall per element. 159 → 12 µs (I8), 168 → 12 µs (I32), about 13× (9ed81ace).core.simdpackage:SimdOperations(kernel interface),ScalarOperations,VectorSupport(single selection seam).PrimitiveArrayswiden/narrow delegate to it (696b4634).deltaChunkkernel (Delta encoder), lanes-inner: 8-bit 137 → 70 µs, 64-bit 150 → 92 µs (b18ad842).packBlockkernel (BitpackedEncodingEncoder), lanes-inner with the word-flush branch hoisted out of the lane loop: 8-bit 221 → 88 µs, 64-bit 241 → 109 µs, about 2.3× (c094a16d). A partial tail block is zero-padded into scratch.Writer and Rust interop ITs pass. This answers W1 (the loop-interchange-first experiment): C2 does vectorize the interchanged form, so a Vector API pack kernel must now beat these numbers.
Next: ALP encode-and-verify (W2), scalar first
ALP stores a double
vas(long) ((v * 10^f * 10^-e + SWEET) - SWEET), then decodes it again (encoded * 10^f * 10^-e) and compares raw bits. A value that does not round-trip becomes a patch. This runs over the whole column and, inside the exponent search, over the 1024-value sample for every (e, f) pair.Plan:
SimdOperations: fill thelong[]of encoded values for the whole block and flag the mismatches; the encoder then handles the few flagged values on a scalar path. Today each element goes through a branch that appends to anArrayList<Integer>andArrayList<Double>(boxing), which blocks vectorization, so that is where I expect the first gain.-0.0,NaN,±Inf, subnormals,Double.MAX_VALUE, values at theSWEETboundary; F32 too.DoubleVector/convert(D2L)/reinterpretAsLongs) only if it beats the scalar kernel by >= 10% on two architectures.Hard constraint (prime directive): the two-step multiply order must match Rust's
encode_single/decode_singlebit for bit; no pre-multiplied scale, nofma.Remaining from the original list:
toLongsIntosegment-widening benchmark vs C2, and the FoR/ZoneMapStatsmin/max rows (measure only, given the #475 evidence).docs: refresh benchmark numbers, add real-file full scans