Skip to content

perf(writer, core): optional Vector API candidates on the write path and in shared helpers #484

Open

Description

@dfa1

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
  • fromLongs narrowing 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.md updated in the same commit

Activity

  1. dfa1 commented on Oct 9, 2026

    @dfa1
    OwnerAuthor

    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)

    Rust Java counterpart in core.simd
    fastlanes crate BitPacking::pack/unpack, Delta, FoR, RLE. The README says SIMD decoding "without intrinsics or other explicit SIMD code" (VLDB'23, "with Scalar Code") packBlock, deltaChunk, undeltaChunk
    PType casts, widen/narrow: plain loops widenInto, narrowInto
    is_constant over primitives: chunked into IS_CONST_LANE_WIDTH (16, or 32 with AVX2) lanes so it vectorizes none yet
    for_compress.rs, rle_decompress.rs: comments call out keeping validity and bounds checks out of the loop "which keeps their loops vectorized" same as our hot-loop rule

    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.rs CpuKernel: a function pointer is chosen once on first call, with a per-arch cfg plus a runtime feature-probe chain and a portable default. The portable kernels use fearless_simd (dispatch!(Level::new(), ...)). This is the same shape as our VectorSupport + ScalarOperations.

    Operation Where Instructions
    Filter compress (mask-select fixed-width values) arrays/filter/execute/simd_compress/{x86,neon,generic}.rs, rung 3 of the ladder in filter/execute/buffer.rs AVX-512 maskz_compress_epi{8,16,32,64}; AVX2 permutevar8x32 + maskstore; NEON vqtbl1q_u8 with an index LUT; portable u8x16::swizzle_dyn for 1/2-byte
    Take / gather arrays/fixed_width/take/avx2/ AVX2 gather (4/8-byte lanes only; 1/2-byte and >8-byte fall back to scalar)
    Small-table take (u8 codes, ≤16 one-byte values, i.e. a tiny dict) take/small_table/{neon,avx2}.rs NEON vqtbl1q_u8 (+ vmaxq_u8 for the max-code bound check)
    Bitmap popcount vortex-buffer/src/bit/count_ones.rs u64x8 lane-wise popcount over 64-byte chunks
    Bitmap select (nth set bit) bit/select.rs, vortex-mask/src/intersect_by_rank.rs SIMD popcount scan + BMI2 pdep/tzcnt
    bool → bitmap pack (collect_bool) bit/pack.rs AVX-512BW vptestmb, AVX2/SSE2 pmovmskb, NEON ushl + addp, SWAR fallback
    Bool filter arrays/bool/compute/filter.rs BMI2 pext
    FastLanes fused compare bitpacking/compute/compare_fused.rs compare happens during unpack (tier 1), then one SIMD transpose_bits per 1024 block (fastlanes runtime feature)
    UTF-8 validation vortex-buffer/src/string.rs, varbinview/array.rs simdutf8 crate

    Nothing 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

    1. Writer candidates W1–W3 have no Rust explicit-SIMD analogue. For parity, they should stay "scalar shaped for C2". A Vector API kernel there would only be a speed win, which the prime directive treats as information, not a reason to diverge.
    2. The Vector API targets that actually mirror Rust are on the read side, and perf(reader): SIMD kernels mirroring Rust: C2-shaped scalar loops, opt-in Vector API for filter compress/bitmap/small-table take #483's candidates (fused filter-aggregate, undelta) don't cover them. Java equivalents, in rough order of fit:
      • filter compress → LongVector/IntVector.compress(VectorMask) (JDK 19+, intrinsified on AVX-512 and SVE) + VectorMask.fromLong
      • bitmap → mask and mask → bitmap → VectorMask.fromLong / toLong (the collect_bool analogue)
      • small-table dict take → ByteVector.selectFrom / rearrange (NEON tbl analogue)
      • popcount/select: Long.bitCount is already a C2 intrinsic, so measure before adding anything
      • take gather: fromArray(species, arr, off, indexMap, ...) exists only for heap arrays, not MemorySegment. This is a weak fit, and Rust itself skips 1/2-byte gathers
      • UTF-8: the JDK's String decode is already intrinsified (countPositives). No action
    3. Seam design matches Rust. One-time selection, a scalar reference kept for differential tests, and a portable default are what CpuKernel + fearless_simd do. Keep SimdOperations as the single seam in core.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.

  2. dfa1 commented on Oct 9, 2026

    @dfa1
    OwnerAuthor

    Progress and plan (branch perf/simd-kernels, not pushed)

    All timings: SimdKernelBenchmark, 256 chunks/blocks per op, -f 3, Apple M5.

    Done

    • PrimitiveArrays.fromLongs narrowing: one loop per width instead of a MethodHandle call per element. 159 → 12 µs (I8), 168 → 12 µs (I32), about 13× (9ed81ace).
    • core.simd package: SimdOperations (kernel interface), ScalarOperations, VectorSupport (single selection seam). PrimitiveArrays widen/narrow delegate to it (696b4634).
    • deltaChunk kernel (Delta encoder), lanes-inner: 8-bit 137 → 70 µs, 64-bit 150 → 92 µs (b18ad842).
    • packBlock kernel (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 v as (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:

    1. Scalar kernel in SimdOperations: fill the long[] 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 an ArrayList<Integer> and ArrayList<Double> (boxing), which blocks vectorization, so that is where I expect the first gain.
    2. Differential test against the current loop: -0.0, NaN, ±Inf, subnormals, Double.MAX_VALUE, values at the SWEET boundary; F32 too.
    3. Vector API version (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_single bit for bit; no pre-multiplied scale, no fma.

    Remaining from the original list: toLongsInto segment-widening benchmark vs C2, and the FoR/ZoneMapStats min/max rows (measure only, given the #475 evidence).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions