Java Vector API (JEP 537): SIMD, Auto-Vectorization, Masks, and Real Benchmarks
A beginner-to-advanced guide to Java’s Vector API (JEP 537, twelfth incubator in JDK 27). Start from what a CPU vector and SIMD actually are, see how HotSpot’s C2 auto-vectorizes simple loops for free (with an experiment that proves it), learn when auto-vectorization silently gives up, then write real vector loops with species, the loopBound tail pattern, and masks for branchless control flow – including a JMH-measured 5.0x speedup on one masked-filter workload and 5.7x on a branchy brightness kernel, the exact machine those numbers came from, and a runnable companion repository.
Almost every Java program spends some of its time in a loop that does the same arithmetic to every element of an array — scaling audio samples, normalizing a feature vector, summing a column, comparing pixels. You write that loop one element at a time because that is how a for loop reads. Your CPU, however, has been able to do eight, sixteen, or even sixty-four of those operations in a single instruction for over two decades. The gap between the loop you wrote and the hardware you own is the subject of this post.
There are two ways to close that gap in Java. The first is free and invisible: the JIT compiler sometimes rewrites your scalar loop into a vector loop behind your back. The second is explicit: the Vector API lets you express vector intent directly, which gives you far more predictable access to SIMD than relying on C2 to infer it — though “explicit” here means stating the intent, not bypassing the compiler: it is still the JIT that lowers each call to real hardware instructions, as the assembly section later in this post shows. This guide starts from what a CPU vector even is — assuming you have never thought about it — and ends at the details that decide whether vectorizing was worth it: masks, the tail problem, floating-point reduction hazards, and the API's road out of incubation. Every listing was compiled and run on a live JDK 21, and every number was measured on the machine documented below; the complete source is in the companion repository, so you can reproduce all of it on your own hardware.
Status (July 2026): The Vector API is still an incubating feature. JEP 537 proposes its twelfth incubation and is targeted to JDK 27 (due September 2026). It has incubated continuously since JDK 16 (JEP 338) because it is deliberately waiting for Project Valhalla: the plan described in JEP 537 is that once value classes arrive as a preview feature, the API would be re-based on them and promoted from incubator to preview — though, like any in-progress JEP, the exact timing and mechanics could still change. Everything below uses the module jdk.incubator.vector and must be compiled and run with --add-modules jdk.incubator.vector. The shape of the API is stable; the package name is expected to change once it eventually leaves incubation, though OpenJDK has not committed to a final name.
The problem: your CPU is idling one lane at a time
Start with the most ordinary loop imaginable. Given three arrays, compute c[i] = -(a[i]² + b[i]²) for every element:
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = -(a[i] * a[i] + b[i] * b[i]);
}
}
Read literally, this does one multiply, then another, then an add, then a negate, then a store — and only then moves to the next index. Each iteration touches exactly one float. That is the “scalar” world: one instruction, one datum. It is easy to read and easy for the compiler to reason about, but it leaves most of the CPU's arithmetic hardware switched off. To understand what is being wasted, you have to look one level down, at the registers the CPU actually computes with.
What a CPU vector actually is
In everyday math a vector is just a list of numbers. Inside a CPU it is the same idea, made physical: a single wide register that holds several numbers side by side. A modern x86 core has 256-bit registers (the AVX2 feature) and often 512-bit ones (AVX-512); Arm cores have 128-bit NEON registers and scalable SVE ones. Because a float is 32 bits, a 256-bit register holds exactly eight of them:
A 256-bit register packs eight 32-bit floats into eight independent lanes. Original diagram; lane terminology per the Vector API (JEP 537).
Each number in the register sits in its own lane. The register in the diagram has eight lanes; a 512-bit register would have sixteen. Nothing about the loop's logic requires processing one lane at a time — it was only ever a limitation of how we wrote it. The hardware can process those lanes in parallel; the question is whether the compiler or your code exposes enough independent work for it to do so. The instruction that does exactly that has a name.
SIMD: one instruction, many data
The feature is called SIMD — Single Instruction, Multiple Data. A SIMD add takes two vector registers, adds lane 0 to lane 0, lane 1 to lane 1, and so on across all lanes, and writes all the sums in one shot. Conceptually:
A single SIMD instruction adds every lane pair at once. Original diagram.
If the eight-lane version of our loop runs in roughly the same time as one scalar iteration, then processing eight elements per step could, in the ideal case, be about eight times faster. Real speedups are smaller — memory bandwidth, tail elements, and instruction latency all take a cut — but the principle is why SIMD is the cheapest parallelism available: it happens inside a single core, with no threads, no locks, and no coordination. The natural next question is whether Java gives you any of this automatically. It does — sometimes.
Auto-vectorization: the free lunch you may already be eating
HotSpot's optimizing JIT compiler, C2, contains an optimization called SuperWord (auto-vectorization) that tries to fuse consecutive scalar iterations into one vector iteration. When it fires, you get SIMD speed without touching your code. Our scalarComputation loop above is close to the ideal case for it: a simple counted loop, contiguous a[i]/b[i] accesses, straight-line arithmetic, no branches. So does it actually fire? Rather than guess, measure.
Benchmark methodology. These first measurements are exploratory timing experiments intended to demonstrate compiler behaviour, not statistically rigorous performance claims: 50,000 warmup calls to force C2 compilation, then the median of 15 rounds of 5,000 calls. The JMH section below re-measures the headline speedups with a harness that handles warmup, dead-code elimination and statistics properly, and those are the numbers worth quoting. Everything ran on one machine:
JDK : OpenJDK 21.0.2+13-58 (HotSpot 64-Bit Server VM, mixed mode)
OS : Ubuntu 22.04.5 LTS, Linux 6.8.0-124-generic, x86_64
CPU : AMD Ryzen 5 5600U (Zen 3), 2 vCPUs exposed, Hyper-V guest
ISA : AVX2 + FMA, no AVX-512 -> SPECIES_PREFERRED = 8 float lanes (256-bit)
Caches : L1d 32 KiB, L2 512 KiB, L3 16 MiB (as reported to the guest)
JVM flags : --add-modules jdk.incubator.vector (plus -XX:-UseSuperWord where noted)
Threads : single-threaded throughout
Data : 8192 floats = 32 KiB per array, cache-resident
Frequency : not controllable inside the guest (no cpufreq governor exposed)
That last line deserves a word. On a virtualized, frequency-scaled machine, absolute nanoseconds are soft. Ratios measured back to back inside one JVM hold up much better than the absolute figures, which is why everything below is stated as a multiple. Here is a warmed-up run comparing the plain scalar loop against a hand-written Vector API version (which we will dissect in a moment):
--- kernel 1: c[i] = -(a[i]^2 + b[i]^2), 8192 floats ---
scalar for-loop median 832.0 ns/call
Vector API median 1279.9 ns/call
vector advantage 0.65x
The hand-written vector loop is not faster — it is slower. That is not a failure; it is the punchline. C2 already auto-vectorized the scalar loop, so both versions are running SIMD instructions and the explicit one just carries more ceremony. (Under JMH the two turn out to be indistinguishable — 838.6 ± 106.7 ns against 814.5 ± 10.8 ns — so most of that 0.65× is overhead in this simple harness, not in the kernel. One more reason not to read a stopwatch too closely.) You can check whether the scalar loop was genuinely vectorized by asking the JVM to switch SuperWord off with the diagnostic flag -XX:-UseSuperWord and running the identical experiment:
=== Same experiment with C2 auto-vectorization DISABLED (-XX:-UseSuperWord) ===
--- kernel 1: c[i] = -(a[i]^2 + b[i]^2), 8192 floats ---
scalar for-loop median 3427.0 ns/call
Vector API median 1230.1 ns/call
vector advantage 2.79x
With auto-vectorization disabled the scalar loop collapses to roughly 4.1× slower, while the Vector API version barely moves — it was doing SIMD either way, on purpose. Two lessons fall out of this pair of runs. First, a lot of Java numeric code is already vectorized and you never knew. Second, and more importantly: the scalar loop's speed is at the compiler's discretion, while the Vector API's vector intent is yours to express explicitly. That distinction is the whole reason the API exists, so it is worth understanding exactly when the compiler's discretion runs out.
Why auto-vectorization is not enough
SuperWord only transforms a loop when C2 can prove the transformation is both safe and profitable. That proof needs a lot to line up: the loop must be a simple counted loop with a predictable trip count; memory accesses must be regular and non-overlapping (the compiler has to rule out aliasing, where a and c might be the same array); the body must use supported primitive operations; and the estimated payoff must beat the overhead given alignment and trip count. Violate any one of these and C2 quietly keeps the scalar code.
Real loops violate them constantly. They contain data-dependent branches (“only process elements above a threshold”), indirect indexing (a[index[i]]), reductions with awkward ordering constraints, mixed types, or calls the compiler cannot see through. In most of these cases auto-vectorization backs off and keeps the scalar version — and because that decision is silent, you often have no signal that you left a 4× on the table. This is the gap the Vector API was built to close. Time to write one.
Feature
SuperWord (auto-vectorization)
Vector API
Automatic
Yes — free, no code
No — you write the loop
Explicit SIMD intent
No — best effort, can silently bail
Yes — stated in code, subject to what the CPU supports
Handles data-dependent branches
Usually no
Yes, via masks
Programmer control
None
Full (species, masks, reductions)
Readability
Excellent — it is just a for loop
Moderate — explicit API calls
The two are complementary, not rivals: lean on SuperWord by default, and drop to the Vector API exactly where SuperWord gives up. Everything below is about writing that explicit loop.
Hello, Vector API
The smallest useful program introduces the two types you cannot avoid: FloatVector (a vector of floats) and VectorSpecies (the description of a vector's shape). Here it is, run for real:
import jdk.incubator.vector.*;
public class VectorIntro {
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
public static void main(String[] args) {
System.out.println("Preferred species: " + SPECIES);
System.out.println("Lane count (length): " + SPECIES.length());
float[] a = {1, 2, 3, 4, 5, 6, 7, 8};
float[] b = {10, 20, 30, 40, 50, 60, 70, 80};
FloatVector va = FloatVector.fromArray(SPECIES, a, 0);
FloatVector vb = FloatVector.fromArray(SPECIES, b, 0);
// One expression, all lanes: c = -(a*a + b*b)
FloatVector vc = va.mul(va).add(vb.mul(vb)).neg();
System.out.println("result = " + vc);
}
}
Compiled with javac --add-modules jdk.incubator.vector and run the same way, on an AVX2 machine it prints:
Preferred species: Species[float, 8, S_256_BIT]
Lane count (length): 8
result = [-101.0, -404.0, -909.0, -1616.0, -2525.0, -3636.0, -4949.0, -6464.0]
Three things to notice, because the rest of the API builds on them. SPECIES_PREFERRED asks the runtime for the species this platform considers preferred for float, and got back an eight-lane, 256-bit species — on a machine where the JVM resolves a 512-bit preferred shape, the same code would typically report sixteen lanes instead (the JVM does not always default to a CPU's widest register width; it can pick a narrower preferred shape, for example to avoid the downclocking some AVX-512 parts exhibit under sustained wide-vector use). fromArray loaded eight consecutive floats into a vector in one go. And the chained mul/add/neg computed all eight results with lane-wise operations, exactly the arithmetic our scalar loop did one element at a time — though it is still C2, not the API itself, that turns those calls into real SIMD instructions; see the assembly walkthrough later in this post for what that lowering looks like in practice. What this toy leaves out is how to handle an array whose length is not a tidy multiple of eight — which is every real array. Solving that cleanly requires understanding species properly.
Species, shapes, and lanes — the vocabulary
A VectorSpecies bundles two facts: the element type (here, float) and the shape (the register width in bits). From those it derives the lane count, which is simply shape divided by element size. Pinning down the four terms makes the rest of the API read easily:
Term
Meaning
Example (AVX2)
Element type
the primitive in each lane
float (32-bit)
Shape
register width in bits
256-bit (S_256_BIT)
Lane count / length
shape ÷ element size
256 ÷ 32 = 8
Species
element type + shape together
FloatVector.SPECIES_256
The same source, then, produces different lane counts on different hardware — which is precisely the point of asking for the preferred species instead of hard-coding a width:
CPU / instruction set
Vector width
Preferred float lanes
x86 SSE / AVX (128-bit)
128-bit
4
x86 AVX2 (256-bit)
256-bit
8 — the machine these benchmarks ran on
x86 AVX-512 (512-bit)
512-bit
16
Arm NEON (128-bit)
128-bit
4
Arm SVE (scalable)
runtime-dependent
resolved at run time
Write the loop against SPECIES_PREFERRED and species.length() rather than hard-coding SPECIES_256, and the same bytecode can run 4-wide on NEON, 8-wide on AVX2 and 16-wide on AVX-512 without a code change — the exact lane count is whatever the JVM resolves as preferred on that machine, which is usually, but not guaranteed to be, the widest register the hardware supports. Never assume a lane count; read it from the species. With that habit in hand, the real loop — the one that handles any array length — is straightforward.
Your first real vector loop: the loopBound + tail pattern
The idiom every Vector API loop uses is: stride through the array in lane-count-sized chunks for as long as a full vector fits, then finish the leftover elements with a plain scalar loop. The method species.loopBound(length) returns the largest multiple of the lane count that is ≤ length, giving you a safe upper bound for the vector part:
import jdk.incubator.vector.*;
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length); // e.g. 8192 -> 8192, 8190 -> 8184
// Main loop: process SPECIES.length() elements per iteration
for (; i < upperBound; i += SPECIES.length()) {
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
va.mul(va).add(vb.mul(vb)).neg().intoArray(c, i);
}
// Scalar tail: the last (a.length % laneCount) elements
for (; i < a.length; i++) {
c[i] = -(a[i] * a[i] + b[i] * b[i]);
}
}
Walk it once and the pattern sticks. The main loop advances i by SPECIES.length() each time; on each pass it loads a lane-width slice of a and b at offset i, does the lane-wise math, and stores the whole result vector back into c with intoArray. When fewer than a full vector's worth of elements remain, the main loop stops and the scalar tail mops up the rest — at most seven elements on an eight-lane machine. This is the exact code that produced the “Vector API” timings above — it is SquareAdd.java in the companion repository — which is the honest place to pause and ask what those timings really told us.
The benchmark, honestly read
Collecting the two runs into one table makes the lesson unambiguous:
Loop (8192 floats)
C2 auto-vec ON
C2 auto-vec OFF
Scalar for
~832 ns
~3427 ns
Vector API
~1280 ns
~1230 ns
Vector API advantage
0.65× (slower)
2.79× faster
The Vector API is not a switch that makes every loop faster — on a loop C2 already vectorizes, the explicit version can lose. Its value is predictability: it delivers roughly the same ~1,250 ns whether or not the compiler cooperates, while the scalar loop swings by 4.1× on a decision you do not control and cannot see. For this tidy square-and-add loop that predictability is not worth the extra code. The branchy, filtering, reducing loops from the earlier list are a different story, and they need one more tool: masks.
What machine code did the JVM actually generate?
It is worth proving the abstraction is real rather than taking it on faith. Ask HotSpot to print the assembly it JIT-compiled for vectorComputation — -XX:+PrintAssembly with an hsdis disassembler plugin — and the inner loop that computes c = -(a² + b²) comes out as a short run of AVX2 instructions, annotated here line by line. Reproducing this on your own machine needs an hsdis plugin installed next to your JDK — without one the JVM prints Loading hsdis library failed and falls back to a hex dump; the exact command is in the repository's RESULTS.md. Treat what follows as one concrete, reproducible example rather than a fixed specification — exact instruction selection can shift with JDK version, CPU microarchitecture, and JIT tiering, so do not assume a given Vector API call always lowers to the same opcode:
Each ymm register is 256 bits wide, so a single vmulps multiplies eight pairs of floats at once. On this build and this run, that one instruction is the hardware realization of the FloatVector.mul() call; vaddps maps to .add(), and the two vmovdqus map to fromArray and intoArray. One detail is more honest than the tidy version of the story: .neg() did not compile to a subtract here. C2 negated the float by flipping its sign bit with vxorps against a mask of 0x80000000 values — typically cheaper than subtracting from zero, and a reminder that the API maps to whatever the compiler judges fastest on the day you ran it, not to one fixed instruction guaranteed across JDK versions or CPUs. (C2 also unrolls the loop, emitting several of these ymm blocks per iteration; the excerpt shows one.) This is the bridge the whole post is about, made literal: in this run, the vector expression became a sequence of AVX2 operations working on eight lanes at a time.
Masks: branchless control flow across lanes
A data-dependent branch inside a loop is often difficult for automatic SIMD vectorization: the lanes of a vector all execute the same instruction, so “do this for some elements but not others” cannot be expressed as an if. The Vector API's answer is a mask — a per-lane boolean, produced by a comparison, that says which lanes an operation should affect. Consider summing only the elements of an array that exceed a threshold. The scalar version has exactly the data-dependent branch that defeats auto-vectorization:
static final float THRESH = 0.5f;
// Scalar: a data-dependent branch -> C2 generally cannot auto-vectorize this form
static float scalar(float[] a) {
float sum = 0;
for (int i = 0; i < a.length; i++)
if (a[i] > THRESH) sum += a[i];
return sum;
}
The vector version replaces the branch with a mask. v.compare(GT, THRESH) yields a VectorMask that is true in the lanes above the threshold; passing that mask to add accumulates only those lanes and leaves the rest untouched. No branch ever executes:
static final VectorSpecies<Float> SP = FloatVector.SPECIES_PREFERRED;
static float vector(float[] a) {
FloatVector acc = FloatVector.zero(SP);
int i = 0, bound = SP.loopBound(a.length);
for (; i < bound; i += SP.length()) {
var v = FloatVector.fromArray(SP, a, i);
VectorMask<Float> m = v.compare(VectorOperators.GT, THRESH);
acc = acc.add(v, m); // add only where mask is true
}
float sum = acc.reduceLanes(VectorOperators.ADD); // horizontal sum of the 8 lanes
for (; i < a.length; i++) if (a[i] > THRESH) sum += a[i]; // masked-off tail
return sum;
}
Run head-to-head with the same warmup, on 8192 floats:
--- kernel 2: masked filter-and-sum, 8192 floats ---
scalar sum = 3084.1404
vector sum = 3084.1448 (same value, different rounding)
scalar (branch) median 5021.9 ns/call
Vector API (masked) median 1029.9 ns/call
vector advantage 4.88x
Nearly 4.9× faster here, and 5.0× under JMH later in this post. The reason is that on this JDK and CPU the scalar loop was never vectorized in the first place — and that is testable rather than assumed: rerun it with -XX:-UseSuperWord and the scalar time moves by less than half a percent (5022 ns to 5040 ns). A loop C2 was vectorizing gets dramatically slower when you take SuperWord away, as kernel 1 did; this one does not notice. Notice too the new step reduceLanes(ADD): after the loop, the accumulator vector holds eight partial sums (one per lane), and a reduction collapses them into the single scalar total. But look closely at the two sums. They are not identical: 3084.1404 versus 3084.1448. That tiny discrepancy is not a bug — it is a genuinely important property of vectorized floating-point, and glossing over it is how people ship subtly wrong numbers.
The floating-point reduction gotcha
Floating-point addition is not associative: (x + y) + z can differ from x + (y + z) in the last bits, because each intermediate result is rounded. The scalar loop adds the qualifying elements strictly left to right. The vector loop instead keeps eight running sums in the eight lanes and only combines them at the end, so the additions happen in a different order — and a different order means different rounding. Hence 3084.1404 versus 3084.1448: same mathematical answer, different accumulated rounding error. Seeing the two orders side by side makes the reason obvious:
Both compute the same sum, but the additions happen in a different order — and because floating-point rounding depends on order, the last bits differ. Original diagram.
For most workloads — graphics, machine learning, aggregate statistics — this difference is far below anything that matters. But if you require bit-for-bit reproducibility, or you are summing values of wildly different magnitudes where order genuinely changes the result, you must decide the ordering deliberately (for example with a compensated-summation algorithm) rather than letting lane count silently pick it for you. Integer operations have no such hazard: integer add is associative, so an integer reduction is exact regardless of lane order. Keeping this in view is what separates “it's faster” from “it's faster and still correct,” which brings us to the rest of the sharp edges.
Where you would actually reach for this: image brightness
Synthetic square-and-add loops make the mechanics clear, but the natural question is where this earns its keep. Image processing is the canonical fit, because every pixel can be transformed independently — exactly the shape SIMD wants. Take a brightness adjustment: scale each pixel by a gain, add a bias, and clamp the result back into the valid [0, 255] range.
// Scalar: the clamp is two data-dependent branches -> C2 generally cannot auto-vectorize this
static void brightScalar(float[] in, float[] out, float gain, float bias) {
for (int i = 0; i < in.length; i++) {
float v = in[i] * gain + bias;
if (v < 0f) v = 0f; else if (v > 255f) v = 255f;
out[i] = v;
}
}
The clamp is the crux: those two data-dependent branches are the kind of control flow that often prevents C2 from auto-vectorizing this loop. In the Vector API version the branches vanish, because clamping is just a lane-wise max followed by min, and the multiply-add is expressed as a plain mul().add() chain. (The API also has a fused fma() method; it is tempting to reach for it here, but fma(b, c) is not simply a fused version of mul(b).add(c) — it computes the product at full intermediate precision and rounds only once, so it can return a different last bit than the separate mul/add pair, the same kind of ordering-sensitive rounding difference discussed above for reductions. Reach for fma() deliberately, not as a drop-in swap.)
static void brightVector(float[] in, float[] out, float gain, float bias) {
var vg = FloatVector.broadcast(SP, gain);
var vb = FloatVector.broadcast(SP, bias);
var lo = FloatVector.zero(SP);
var hi = FloatVector.broadcast(SP, 255f);
int i = 0, bound = SP.loopBound(in.length);
for (; i < bound; i += SP.length()) {
FloatVector.fromArray(SP, in, i).mul(vg).add(vb).max(lo).min(hi).intoArray(out, i);
}
for (; i < in.length; i++) { // scalar tail
float v = in[i] * gain + bias;
if (v < 0f) v = 0f; else if (v > 255f) v = 255f;
out[i] = v;
}
}
Benchmarked the same way as everything else here, on 8192 pixels:
--- kernel 3: image brightness + clamp, 8192 pixels ---
scalar (branchy clamp) median 6881.6 ns/call
Vector API (max/min) median 1243.8 ns/call
vector advantage 5.53x
About 5.5× in this run (5.7× under JMH; the exploratory harness put it anywhere from 5.1× to 6.3× across repeats, which is exactly why the JMH figure is the one quoted). The reason is the same as the masked sum: the clamp's branches keep C2 from vectorizing, and here too -XX:-UseSuperWord leaves the scalar time untouched (6882 ns to 6884 ns), so the explicit vector version is the only one running SIMD. The same shape covers the other workloads people reach for — pixel normalization, audio gain and mixing, cosine similarity over embedding vectors, small matrix kernels: contiguous data, independent elements, and often a clamp or threshold a plain loop cannot cross.
Performance pitfalls, and how the abstractions map to hardware
The Vector API is a thin, honest layer over real instructions, and every abstraction has a hardware cost you should be able to picture. Masks, comparisons, and simple lane-wise arithmetic are cheap — they are single instructions on any SIMD-capable core. Two operations are not cheap and deserve respect. Shuffles (VectorShuffle), which permute lanes, map to hardware permute instructions that are often several times slower than arithmetic; if you find yourself shuffling to rearrange data, it is almost always faster to store the data in the right layout to begin with — Structure-of-Arrays rather than Array-of-Structures — so loads are contiguous. Gather/scatter (indexed a[index[i]] access) is slower still and only vectorizes well on hardware that has dedicated instructions for it.
Beyond individual operations, three systemic pitfalls catch people:
You are memory-bound, not compute-bound. SIMD accelerates arithmetic per byte loaded. If your loop is limited by how fast data streams from memory — which large, once-touched arrays usually are — vectorizing the math buys little. Our benchmarks used an 8192-float (32 KB) array precisely so it stays in cache and the compute actually dominates.
Letting vectors escape. Vector objects are meant to be short-lived and optimized away into registers. Store one in a collection or a field, or otherwise let a reference outlive the loop, and you usually defeat that optimization; the JIT will typically fall back to materializing a real heap object instead of keeping the value in a register, since it can no longer prove the vector never escapes.
An operation your CPU lacks. The API degrades gracefully — if a shape or operation is not supported in hardware, it still runs correctly via a scalar fallback — but “correct” is not “fast.” A loop that looks vectorized can quietly run scalar on the wrong machine.
The last question is where the tool itself is headed.
Common mistakes
Most Vector API disappointments trace back to a short list of recurring errors. Keep it nearby:
Vectorizing loops that are not hot. If a profiler has not flagged the loop, rewriting it in SIMD adds complexity and risk for speed no user will ever notice. Optimize the few loops that dominate; leave the rest readable.
Benchmarking without JMH. Hand-rolled timing loops fall prey to dead-code elimination, missing warmup, and on-stack replacement. (While writing this post, a naive cosine-similarity timing loop reported single-digit nanoseconds — because the JIT deleted the entire computation as unused.) Use JMH for any number you intend to defend.
The JMH numbers
Because that mistake is the most common, here is the harness the headline figures in this post actually came from. It compiles against jmh-core 1.37 on JDK 21:
import jdk.incubator.vector.*;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class VectorBench {
static final VectorSpecies<Float> SP = FloatVector.SPECIES_PREFERRED;
float[] a, b, c;
@Setup
public void setup() {
int n = 8192;
a = new float[n]; b = new float[n]; c = new float[n];
Random r = new Random(42);
for (int i = 0; i < n; i++) { a[i] = r.nextFloat(); b[i] = r.nextFloat(); }
}
@Benchmark
public void scalar(Blackhole bh) {
for (int i = 0; i < a.length; i++) c[i] = -(a[i]*a[i] + b[i]*b[i]);
bh.consume(c);
}
@Benchmark
public void vector(Blackhole bh) {
int i = 0, bound = SP.loopBound(a.length);
for (; i < bound; i += SP.length()) {
var va = FloatVector.fromArray(SP, a, i);
var vb = FloatVector.fromArray(SP, b, i);
va.mul(va).add(vb.mul(vb)).neg().intoArray(c, i);
}
for (; i < a.length; i++) c[i] = -(a[i]*a[i] + b[i]*b[i]);
bh.consume(c);
}
}
The benchmark writes into a persistent output array rather than computing an unused local, and then hands that array to JMH's Blackhole. Use the framework's result-consumption mechanisms rather than reasoning about what the JIT can prove — returned values are consumed automatically, and bh.consume(...) covers the void case. The tempting alternative, returning a checksum of the output, does prevent dead-code elimination but folds the checksum loop into the measurement: the same kernel with a trailing sum over c[] reports 7410 ns/op instead of 815, measuring mostly the checksum. If a number looks surprising, inspect the generated code before believing it.
Run it with the incubator module on the command line — java --add-modules jdk.incubator.vector -jar target/benchmarks.jar — and on the machine described earlier all three kernels come out like this (1 fork, 3 warmup and 5 measurement iterations of 1 s, single-threaded):
Read those three pairs and the argument of this whole post is in the table. Square-and-add: the error bars overlap, so scalar and vector are indistinguishable — C2 was already doing the work, and the 0.65× from the stopwatch harness was overhead, not the kernel. Masked filter: 5.0×. Brightness clamp: 5.7×. Every one of these benchmarks, plus the timing experiments and the exact environment, is in the companion repository — clone it and the first thing it prints is the machine it is running on.
Where JEP 537 is going: incubation and Valhalla
The Vector API has been incubating longer than almost any feature in Java's history — twelve rounds, from JDK 16 through the JDK 27 proposal in JEP 537, with no substantial changes in the most recent rounds. That is not drift; it is a deliberate wait. The API models each vector as an object, and its performance depends on the JIT reliably flattening those objects into registers. Project Valhalla's value classes — which are intended to give the JVM exactly that guarantee, with no identity and no heap header — are widely seen as the missing foundation. The stated plan, per JEP 537, is to incubate until Valhalla's value objects are available as a preview feature, then re-base the Vector API on them and promote it from incubator to preview — but a JEP describes intent, not a commitment, and the timeline or exact mechanics could still shift as Valhalla itself evolves. (If value classes are new to you, they are the subject of a companion guide on this site, linked in the references — the two features are two halves of the same performance story.)
There is real engineering happening under the surface even without an API change. The JDK 27 proposal, for instance, updates the bundled SLEEF library — used to implement vector math functions like sin and exp on Arm and RISC-V — from version 3.6.1 to 3.9.0, quietly improving transcendental performance on those architectures. And on the auto-vectorization side, C2's SuperWord continues to improve release over release, so the “free lunch” keeps getting bigger too. Both paths — implicit and explicit — are advancing together, which is the right frame for deciding which one to reach for.
A decision guide: which path should you use?
Your loop…
Reach for…
Why
is a simple counted loop over contiguous arrays with straight-line math
Benchmark before rewriting
SuperWord often already vectorizes loops like this; confirm with JMH or -XX:-UseSuperWord before adding Vector API code that may just add ceremony for no gain
has data-dependent branches, filtering, or thresholding
Vector API + masks
C2 generally will not cross the branch; masks make it branchless (our ~5× cases)
needs predictable SIMD across machines and JDK versions
Vector API
the intent is in your code, not at C2's discretion
lane reductions reorder additions and change rounding
The same logic reads even faster as a flowchart — a quick decision path for any hot loop:
A practical decision path: most hot loops exit early to “leave it scalar,” and the Vector API is reserved for branchy or filtering loops that C2 will usually not cross. Original diagram.
Quick reference
// Setup: compile and run with the incubator module
// javac --add-modules jdk.incubator.vector Foo.java
// java --add-modules jdk.incubator.vector Foo
import jdk.incubator.vector.*;
// The species: element type + shape. Prefer PREFERRED for portability.
static final VectorSpecies<Float> SP = FloatVector.SPECIES_PREFERRED;
SP.length(); // lanes on THIS cpu (8 on AVX2, 16 on AVX-512)
SP.loopBound(n); // largest multiple of length() that is <= n
// Load / compute / store
var v = FloatVector.fromArray(SP, arr, i); // load a lane-width slice
var r = v.mul(v).add(other).neg(); // lane-wise arithmetic
r.intoArray(out, i); // store the whole vector
// Masks: branchless conditionals
VectorMask<Float> m = v.compare(VectorOperators.GT, threshold);
acc = acc.add(v, m); // apply only where m is true
v.blend(other, m); // pick lanes from v or other
// Reductions: many lanes -> one scalar (watch FP ordering!)
float sum = acc.reduceLanes(VectorOperators.ADD);
// The universal loop shape
int i = 0, bound = SP.loopBound(arr.length);
for (; i < bound; i += SP.length()) { /* vector body */ }
for (; i < arr.length; i++) { /* scalar tail */ }
Closing thought
The distance between the loop you write and the CPU you own has always been real, and for most of Java's life the only bridge was hoping the JIT would notice. That free bridge, C2's auto-vectorization, is better than ever and should remain your default. The Vector API is the bridge you build yourself for the loops the compiler usually will not cross: the ones with masks, filters and reductions, where the measured difference here was not 10% but roughly 5×. Start by learning to tell whether your hot loop is already vectorized — -XX:-UseSuperWord is a one-line experiment, and if the scalar loop does not get slower, C2 was probably never vectorizing it. Then, for the few loops that deserve it, express the intent explicitly — with an eye on memory bandwidth and floating-point ordering — and let the JIT turn that explicit intent into the one instruction that does the work of eight. Every kernel, harness and raw result from this post is in the companion repository: run it on your own CPU before trusting any number here, mine included.
References and further reading
vector-api-jep-537-demo — the companion repository: every kernel in this post, the JMH module, and RESULTS.md with the full environment and raw output
No Comments yet!