Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/simd-benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: SIMD benchmark

# Informational, never a gate: numbers from shared runners are noisy, so the table says which rows are within
# JMH's confidence interval of a tie instead of pretending to precision. One fork keeps the job short; run it
# by hand with more forks for numbers worth recording. It exists to put the
# Vector API kernels next to the auto-vectorized ones on more than one architecture (ADR 0005 asks for
# two), as a table in the job summary. Runs on demand and when the SIMD code or this workflow changes.
on:
workflow_dispatch:
inputs:
forks:
description: 'JMH forks per benchmark (3 or more for numbers worth recording)'
required: false
default: '1'
pull_request:
branches: [ main ]
paths:
- 'core/src/main/java/io/github/dfa1/vortex/core/simd/**'
- 'performance/src/main/java/io/github/dfa1/vortex/core/simd/**'
- 'scripts/simd-benchmark.sh'
- '.github/workflows/simd-benchmark.yml'

permissions:
contents: read

jobs:
benchmark:
runs-on: ${{ matrix.os }}
timeout-minutes: 60

strategy:
fail-fast: false
matrix:
# x86-64 Linux, 64-bit ARM Linux and Apple silicon. The ubuntu-latest label does not pin the CPU: runners
# have come back with 256-bit (AVX2) and 512-bit (AVX-512) vectors, so the x86 job runs twice, once as
# allocated and once capped at 32 bytes (AVX2), which makes runs comparable and shows how the kernels
# scale with the vector width.
include:
- { name: x86-64-default, os: ubuntu-latest }
- { name: x86-64-avx2, os: ubuntu-latest, max-vector-bytes: '32' }
- { name: arm64-linux, os: ubuntu-24.04-arm }
- { name: apple-silicon, os: macos-latest }

steps:
- uses: actions/checkout@v7

- name: Set up Azul Zulu JDK 25
uses: actions/setup-java@v6.0.1
with:
distribution: zulu
java-version: '25'

- name: Cache Maven repository
uses: actions/cache@v6
with:
path: ~/.m2/repository
key: ${{ runner.os }}-${{ runner.arch }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-maven-

- name: Build the benchmarks jar
run: ./mvnw --batch-mode -q package -DskipTests -DskipShade=false -pl performance -am

- name: Report the vector unit
run: |
{
echo "### ${{ matrix.name }} (${{ matrix.os }}, ${{ runner.arch }})"
echo
echo -n "Preferred int vector: "
echo 'System.out.println(jdk.incubator.vector.IntVector.SPECIES_PREFERRED)' \
| jshell --add-modules jdk.incubator.vector ${{ matrix.max-vector-bytes && format('-R-XX:MaxVectorSize={0}', matrix.max-vector-bytes) || '' }} -s - 2>/dev/null | tail -1
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Run the benchmarks
env:
FORKS: ${{ inputs.forks || '1' }}
MAX_VECTOR_BYTES: ${{ matrix.max-vector-bytes }}
run: |
scripts/simd-benchmark.sh performance/target/simd-benchmark > table.md
{ echo; cat table.md; } >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v4
if: always()
with:
name: simd-benchmark-${{ matrix.name }}
path: |
performance/target/simd-benchmark/*.json
table.md
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Opt-in Vector API kernels for min/max, sums, run counting, widening and bit-packing, enabled by `--add-modules jdk.incubator.vector` (over 10× faster on byte and short columns); float sums and 64-bit overflow checks differ slightly from Rust's order, see `docs/compatibility.md`. ([#516](https://github.com/dfa1/vortex-java/issues/516))

### Changed
- Float min/max statistics order `-0.0` before `0.0` and skip `NaN`, and `U64` min/max compare unsigned, as Rust does. ([#516](https://github.com/dfa1/vortex-java/issues/516))

### Fixed
- Nulls written by Rust come back as null, not as zeros or empty strings, in fixed-size list, FSST, decimal, bytebool, varbinview and delta columns, and a fixed-size list can hold nullable elements or a list, struct or fixed-size list as its element. ([0c910d2f](https://github.com/dfa1/vortex-java/commit/0c910d2f), [4404effe](https://github.com/dfa1/vortex-java/commit/4404effe), [6018bd57](https://github.com/dfa1/vortex-java/commit/6018bd57), [581bf894](https://github.com/dfa1/vortex-java/commit/581bf894), [692c991f](https://github.com/dfa1/vortex-java/commit/692c991f), [7079aed0](https://github.com/dfa1/vortex-java/commit/7079aed0), [c74e7432](https://github.com/dfa1/vortex-java/commit/c74e7432), [07171ffa](https://github.com/dfa1/vortex-java/commit/07171ffa))

Expand Down
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ core — everything lives under `io.github.dfa1.vortex.core.*`:
core.io IoBounds, PTypeIO, VortexFormat
core.error VortexException
core.compute FastLanes, PrimitiveArrays, Utf8Order
core.simd SimdOperations (kernels shared by reader+writer), VectorSupport (picks the impl);
scalar only today, Vector API impl planned (ADR 0005, #483, #484)
core.simd SimdOperations (kernels shared by reader+writer, all eleven ptypes),
AutoVectorizedSimdOperations (C2-shaped loops, the Rust-parity default),
VectorApiSimdOperations (opt-in via --add-modules jdk.incubator.vector; a few documented
differences from Rust, see docs/compatibility.md), SimdOperationsSupport#preferred() (picks one)
core.fbs / core.proto — generated wire codecs + their runtimes
reader — VortexReader, VortexHttpReader, VortexHandle, ReadRegistry, Chunk, ArrayStats, Zone,
ScanOptions, RowFilter; file internals (Footer, Trailer, PostscriptParser, …)
Expand Down Expand Up @@ -79,6 +81,7 @@ and merge commits are disabled): squash a PR's commits locally into one, force-p
./mvnw verify -pl integration -am # integration (failsafe, NOT surefire)
./mvnw verify -pl integration -am -Dit.test="RustWritesJavaReadsIntegrationTest#method"
./bench JavaVsJniReadBenchmark.javaReadVolume # benchmark — always ClassName.methodName filter
scripts/simd-benchmark.sh # auto-vectorized vs Vector API kernels, table of speedups (also a CI job)
scripts/hydrate-raincloud-corpus.sh --max-mb 200 # hydrate real-world conformance corpus (#205), then:
./mvnw verify -pl integration -am -Dvortex.it.excludedGroups= -Dit.test="RaincloudConformanceIntegrationTest"
./mvnw test -pl fuzz -am -Dvortex.fuzz.excludedGroups= # Jazzer regression mode (ADR 0020)
Expand Down Expand Up @@ -272,6 +275,10 @@ When stuck on encode/decode behavior, consult **in this order**:
starting point and `withXxx` methods return copies. Never a record and never a public
constructor — they hold callbacks or executors with no meaningful `equals`, and a constructor
freezes the field list as API.
- **The Vector API implementation may differ from Rust in a few reductions** (decision recorded in
`docs/compatibility.md` and `VectorApiSimdOperations`): lane-wise float sums and per-lane `I64`/`U64`
overflow detection, instead of forcing scalar loops to keep Rust's exact order. It is opt-in
(`--add-modules jdk.incubator.vector`); the default `AutoVectorizedSimdOperations` stays Rust-parity.
- **Small public APIs.** Don't expose internals — when in doubt, leave it out or make it private.
- **POM deps** grouped with comments: `<!-- production -->` then `<!-- testing -->`, each with
project-internal (`io.github.dfa1.vortex:*`) deps first, then external. Omit empty sections.
Expand Down
66 changes: 66 additions & 0 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@

<build>
<plugins>
<!-- VectorApiSimdOperations compiles against the incubating Vector API. Nothing needs the
flag at runtime: without it SimdOperationsSupport keeps the auto-vectorized kernels. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- JDK 26 reports the incubating module as a warning, and the build fails on warnings; using
it here is the point, so silence that one lint category and keep the rest -->
<compilerArgs combine.children="append">
<arg>--add-modules</arg>
<arg>jdk.incubator.vector</arg>
<arg>-Xlint:-incubating</arg>
</compilerArgs>
</configuration>
</plugin>
<!-- Ratchet: core's javadoc is warning-free today, so keep it that way. The
parent leaves failOnWarnings off because 313 warnings predate the check in
other modules; nothing stops the already-clean ones from holding the line. -->
Expand All @@ -45,6 +60,32 @@
<failOnWarnings>true</failOnWarnings>
</configuration>
</plugin>
<!-- The test JVMs run without the jdk.incubator.vector module for now (add the add-modules option for
jdk.incubator.vector to the root and integration argLine to exercise the Vector API
implementation again; its tests are skipped without it). This second pass then only repeats the
SIMD tests under the same conditions, and stays so that re-enabling the module keeps proving
that SimdOperationsSupport picks the auto-vectorized implementation without it. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<executions>
<execution>
<id>without-vector-module</id>
<phase>test</phase>
<goals>
<goal>test</goal>
</goals>
<configuration>
<argLine>@{argLine} --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED</argLine>
<includes>
<include>**/SimdOperationsSupportTest.java</include>
<include>**/SimdOperationsTest.java</include>
</includes>
<reportsDirectory>${project.build.directory}/surefire-reports-without-vector-module</reportsDirectory>
</configuration>
</execution>
</executions>
</plugin>
<!-- Publish a test-jar so reader/ and writer/ tests can reuse core test
helpers (DTypes, EncodeTestHelper) without duplication. -->
<plugin>
Expand Down Expand Up @@ -72,6 +113,31 @@
Then commit the updated files.
-->
<profiles>
<!-- JDK 26's javadoc rejects VectorApiSimdOperations's import of the incubating module ("package ... is not
visible") unless the module is added, and then prints the "using incubating module(s)" warning, which
javadoc has no -Xlint switch to silence. On JDK 26+ add the module and tolerate that one warning; the
warning-free ratchet stays on for JDK 25, where javadoc does not complain. -->
<profile>
<id>javadoc-sees-incubating-module</id>
<activation>
<jdk>[26,)</jdk>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<configuration>
<failOnWarnings>false</failOnWarnings>
<additionalOptions>
<additionalOption>--add-modules</additionalOption>
<additionalOption>jdk.incubator.vector</additionalOption>
</additionalOptions>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>regenerate-sources</id>
<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import io.github.dfa1.vortex.core.model.EncodingId;
import io.github.dfa1.vortex.core.io.VortexFormat;
import io.github.dfa1.vortex.core.error.VortexException;
import io.github.dfa1.vortex.core.simd.VectorSupport;
import io.github.dfa1.vortex.core.simd.SimdOperations;
import io.github.dfa1.vortex.core.simd.SimdOperationsSupport;

import java.lang.foreign.MemorySegment;
import java.lang.foreign.SegmentAllocator;
Expand Down Expand Up @@ -34,58 +35,15 @@ private PrimitiveArrays() {
/// @return a `long[]` holding every element of `data` widened to 64 bits
/// @throws VortexException if `ptype` is not an integer ptype
public static long[] toLongs(Object data, PType ptype, EncodingId encoding) {
return switch (ptype) {
case I8 -> {
byte[] arr = (byte[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = arr[i];
}
yield r;
}
case U8 -> {
byte[] arr = (byte[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = Byte.toUnsignedLong(arr[i]);
}
yield r;
}
case I16 -> {
short[] arr = (short[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = arr[i];
}
yield r;
}
case U16 -> {
short[] arr = (short[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = Short.toUnsignedLong(arr[i]);
}
yield r;
}
case I32 -> {
int[] arr = (int[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = arr[i];
}
yield r;
}
case U32 -> {
int[] arr = (int[]) data;
long[] r = new long[arr.length];
for (int i = 0; i < arr.length; i++) {
r[i] = Integer.toUnsignedLong(arr[i]);
}
yield r;
}
case I64, U64 -> (long[]) data;
default -> throw new VortexException(encoding, "unsupported ptype: " + ptype);
};
if (ptype.isFloating()) {
throw new VortexException(encoding, "unsupported ptype: " + ptype);
}
if (ptype == PType.I64 || ptype == PType.U64) {
return (long[]) data;
}
long[] result = new long[Array.getLength(data)];
SimdOperationsSupport.preferred().widenArrayInto(data, 0, result.length, ptype, result);
return result;
}

/// Throws unless `ptype` is one of the four unsigned integer types. Several wire fields (run
Expand Down Expand Up @@ -159,7 +117,7 @@ public static void toLongsInto(MemorySegment seg, long fromElement, int count, P
if (ptype.isFloating()) {
throw new VortexException(encoding, "unsupported ptype: " + ptype);
}
VectorSupport.operations().widenInto(seg, fromElement, count, ptype, out);
SimdOperationsSupport.preferred().widenInto(seg, fromElement, count, ptype, out);
}

/// Writes a `long[]` to a freshly allocated little-endian off-heap segment whose element width
Expand All @@ -179,7 +137,7 @@ public static MemorySegment fromLongs(long[] longs, PType ptype, SegmentAllocato
}
int n = longs.length;
MemorySegment seg = arena.allocate((long) n * ptype.byteSize());
VectorSupport.operations().narrowInto(longs, ptype, seg);
SimdOperationsSupport.preferred().narrowInto(longs, ptype, seg);
return seg;
}

Expand Down Expand Up @@ -220,26 +178,21 @@ public static MemorySegment toSegment(Object data, PType ptype, SegmentAllocator
/// @throws VortexException for floating-point or other non-integer types
public static Object fromLongsArray(long[] longs, PType ptype, EncodingId encoding) {
int n = longs.length;
SimdOperations ops = SimdOperationsSupport.preferred();
return switch (ptype) {
case I8, U8 -> {
byte[] r = new byte[n];
for (int i = 0; i < n; i++) {
r[i] = (byte) longs[i];
}
ops.narrowArrayInto(longs, ptype, r);
yield r;
}
case I16, U16 -> {
short[] r = new short[n];
for (int i = 0; i < n; i++) {
r[i] = (short) longs[i];
}
ops.narrowArrayInto(longs, ptype, r);
yield r;
}
case I32, U32 -> {
int[] r = new int[n];
for (int i = 0; i < n; i++) {
r[i] = (int) longs[i];
}
ops.narrowArrayInto(longs, ptype, r);
yield r;
}
case I64, U64 -> longs;
Expand All @@ -259,20 +212,21 @@ public static Object fromLongsArray(long[] longs, PType ptype, EncodingId encodi
/// @throws VortexException for a type with no primitive carrier
public static Object fromBitsArray(long[] bits, PType ptype, EncodingId encoding) {
int n = bits.length;
SimdOperations ops = SimdOperationsSupport.preferred();
return switch (ptype) {
case F16 -> fromLongsArray(bits, PType.U16, encoding);
case F16 -> {
short[] r = new short[n];
ops.narrowArrayInto(bits, ptype, r);
yield r;
}
case F32 -> {
float[] r = new float[n];
for (int i = 0; i < n; i++) {
r[i] = Float.intBitsToFloat((int) bits[i]);
}
ops.narrowArrayInto(bits, ptype, r);
yield r;
}
case F64 -> {
double[] r = new double[n];
for (int i = 0; i < n; i++) {
r[i] = Double.longBitsToDouble(bits[i]);
}
ops.narrowArrayInto(bits, ptype, r);
yield r;
}
default -> fromLongsArray(bits, ptype, encoding);
Expand Down
Loading
Loading