skip to content

the vector api

SIMD from java: one instruction, many lanes.

chapter
chapter 15.09
reading time
8 min read
preview

modern CPUs have SIMD instructions: one instruction that adds, multiplies or compares several numbers at once, packed side by side in a wide register (lanes). the Vector API (jdk.incubator.vector) lets java code ask for that explicitly, portably: write it once with FloatVector, and the JIT emits NEON on ARM or AVX on x86.

it's been incubating since java 16 and is still incubating in java 27 (the twelfth round, JEP 537): it's waiting for valhalla's value classes before it can be finalized.

the picture

a checkout with one cashier scanning one item at a time, versus a cashier with a scanner wide enough to read four items in one beep. the work per item is the same; the number of beeps drops by four. a 128-bit register holds four 32-bit floats, so one instruction does four multiply-adds. a 512-bit one (AVX-512 on some x86 servers) does sixteen.

register =
a
01234567891011121314151617
b
222222222222222222
c
111111111111111111
lanes per instruction 4instructions for 18 elements 6 (4 vector + 2 scalar tail)

one fused multiply-add for elements 0 to 3: 4 lanes at once.

step 1/7
step through c = a × b + c on 18 floats, then change the register width. amber lanes are one instruction; purple is the scalar tail left over when the array doesn't divide evenly. this mac's preferred shape is 128 bits.

the code

the element-wise loop, both ways. the vector version processes SPECIES.length() elements per iteration, then mops up the tail:

Main.java
import jdk.incubator.vector.*;
 
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
 
static void scalar(float[] a, float[] b, float[] c) {
    for (int i = 0; i < a.length; i++) c[i] = a[i] * b[i] + c[i];
}
 
static void vector(float[] a, float[] b, float[] c) {
    int i = 0;
    int upper = SPECIES.loopBound(a.length);
    for (; i < upper; i += SPECIES.length()) {
        FloatVector va = FloatVector.fromArray(SPECIES, a, i);
        FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
        FloatVector vc = FloatVector.fromArray(SPECIES, c, i);
        va.fma(vb, vc).intoArray(c, i);              // c = a * b + c, one lane per element
    }
    for (; i < a.length; i++) c[i] = a[i] * b[i] + c[i];   // the tail that doesn't fill a vector
}
 
void main() {
    IO.println(SPECIES + ": " + SPECIES.length() + " lanes of float, " + SPECIES.vectorBitSize() + " bits");
    float[] a = new float[10], b = new float[10], c1 = new float[10], c2 = new float[10];
    for (int i = 0; i < 10; i++) { a[i] = i; b[i] = 2; c1[i] = c2[i] = 1; }
    scalar(a, b, c1);
    vector(a, b, c2);
    IO.println(java.util.Arrays.toString(c1));
    IO.println(java.util.Arrays.toString(c2));
}

an incubator module isn't resolved by default, so it needs a flag at compile time and at run time (which is also why there's no run button here):

terminal
$ java --add-modules jdk.incubator.vector Main.java
WARNING: Using incubator modules: jdk.incubator.vector
Species[float, 4, S_128_BIT]: 4 lanes of float, 128 bits
[1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0, 19.0]
[1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0, 19.0]
  • ├── a fixed-size sequence of lanes, value-based
  • │ ├── ByteVector ShortVector IntVector LongVector
  • │ └── FloatVector DoubleVectorfromArray, add, mul, fma, compare, reduceLanes...
  • ├── VectorSpecies<E>element type x shape: SPECIES_128, SPECIES_PREFERRED...
  • ├── VectorShapeS_64_BIT .. S_512_BIT, S_Max_BIT
  • ├── VectorMask<E>one boolean per lane: results of compare, masked ops and tails
  • ├── VectorShuffle<E>lane rearrangements
  • └── VectorOperatorsADD, MUL, MAX, ABS, SQRT... as constants for lanewise and reduceLanes
the main types in jdk.incubator.vector. the purple ones describe or transform vectors rather than hold data.

the pieces: a species is element type plus shape (float × 128 bits = 4 lanes). SPECIES_PREFERRED asks for the widest shape this CPU handles well, so the same code uses 4 lanes here and 8 or 16 on an AVX2 or AVX-512 machine. fromArray/intoArray load and store whole registers, fma is a fused multiply-add per lane, and loopBound rounds the length down to a multiple of the lane count. there are lane-wise operations for everything arithmetic, comparisons that produce masks, masked loads for tails, shuffles, and reductions that combine all lanes into one value.

does it actually help?

measured with JMH on the M4 Pro, 1024 floats, scalar vs Vector API:

c = a*b + c, plain loop100.6 ±1.1 ns/op
c = a*b + c, Vector API122.1 ±2.9 ns/op
dot product, plain loop723.7 ±58.2 ns/op
dot product, Vector API171.7 ±2.9 ns/op
ns per call (the whole 1024-element loop). element-wise, the plain loop was already faster: C2 vectorized it by itself. for the dot product, the plain loop can't be vectorized automatically, and the Vector API is 4.2× faster.

that's the honest story of the Vector API. C2 has had an auto-vectorizer (SuperWord) for years: simple element-wise loops over arrays, like the first one, already turn into SIMD instructions with no help, and here the hand-written version was slightly slower.

but the dot product (sum += a[i] * b[i]) is a reduction, and floating-point addition isn't associative: (x + y) + z can differ from x + (y + z) in the last bits (floating point). adding four partial sums in parallel changes the order of the additions, so the JIT isn't allowed to do it for you; it has to add one element at a time, each addition waiting on the last. reduceLanes(ADD) is you saying "i accept a different rounding order", and then four lanes run in parallel. same for code the auto-vectorizer just doesn't recognize: branches turned into masks, lookups through shuffles, mixed types.

quick check
why can't the JIT auto-vectorize sum += a[i] * b[i] over floats, when it vectorized c[i] = a[i] * b[i] + c[i]?

why it's still incubating

the API was ready years ago. what it's waiting for is valhalla. a FloatVector is a value: it has no meaningful identity, you never want to compare two with == or lock on one, and the JIT tries hard to keep it in a register and never allocate it. today it's an ordinary class marked value-based, and when the JIT can't keep a vector in a register (it escapes, or the code isn't compiled yet), it really does allocate an object per vector, and performance collapses. the plan in the JEP is to turn Vector and its subclasses into value classes once valhalla's are available as a preview, move the API from incubator to preview, and then finalize it.

incubator status has practical consequences: the package is jdk.incubator.vector, it needs --add-modules everywhere, it prints that warning at startup, and the API can still change between releases (it's had small changes along the way). libraries that want SIMD today mostly use it behind a runtime check (Lucene does this for vector search), or rely on the auto-vectorizer.

keylines
  • SIMD: one instruction, many lanes. 128-bit registers hold 4 floats, 512-bit hold 16.
  • the Vector API asks for it portably: species, fromArray, lane-wise ops, masks, reduceLanes.
  • C2 already auto-vectorizes simple loops. the API wins where it can't, like float reductions.
  • still incubating in java 27: --add-modules jdk.incubator.vector, waiting for valhalla.
  • fast only in hot, compiled code with vectors in registers. benchmark the steady state.