The :sirix-query:nativeCompile task builds a Graal native image for
io.sirix.query.bench.ScaleBenchMain → build/native/nativeCompile/sirix-bench.
./gradlew :sirix-query:nativeCompiledefaultquickBuild = true(good enough for iterative dev)../gradlew :sirix-query:nativeCompile -Pquick-build=falsefull-O3 -march=nativebuild. On this codebase/host (20 cores, GraalVM 25.0.3) both modes finish in ~2 min — the compile phase is only ~70-80 s because the reachable method count is modest; peak builder RSS ~6 GB.
The builder heap defaults to -XX:MaxRAMPercentage=65 (coexists with a live
Gradle daemon on a 31 GB host). Pass -Pnative.builderXmx=10g to hard-cap it
when other agents/suites share the box.
Override the main class/image name to reuse this recipe for the write smoke:
-Pnative.mainClass=io.sirix.query.bench.NativeWriteSmokeMain -Pnative.imageName=sirix-write-smoke.
./gradlew :sirix-query:nativeCompile -Ppgo-instrument
./build/native/nativeCompile/sirix-bench 1000000 true 5 # produces default.iprof
./gradlew :sirix-query:nativeCompile -Ppgo=default.iprofThe write path is now native-clean. MMStorage (and the ProjectionIndexHOTStorage
scratch) used to map each generation into an Arena.ofShared() and close() it on
remap/teardown; closing a shared arena in a native image requires
-H:+SharedArenaSupport, which GraalVM 25 cannot combine with the Vector API.
SharedArenas routes all shared-access arena creation through a pluggable strategy:
Arena.ofShared() + explicit close on HotSpot (unchanged, deterministic unmap),
Arena.ofAuto() in a native image (same cross-thread access semantics, GC-reclaimed,
close() is a no-op). The full create/shred/commit/reopen/append-remap/time-travel
lifecycle passes in a native image — see NativeWriteSmokeMain (:sirix-query:writeSmoke
on the JVM, or build natively with -Pnative.mainClass=io.sirix.query.bench.NativeWriteSmokeMain).
| Build/run config (GraalVM 25.0.3, Vector API reachable) | Outcome |
|---|---|
-H:+SharedArenaSupport and -H:+VectorAPISupport |
build rejected up front: Error: Support for Arena.ofShared is not available with Vector API support. Either disable Vector API support ... or replace usages of Arena.ofShared with Arena.ofAuto |
-H:+SharedArenaSupport, no -H:+VectorAPISupport, vector classes reachable |
build crashes during [6/8] Compiling: GraalError: ... AbstractLayout.varHandleInternal was not inlined and could access a session at SubstrateOptimizeSharedArenaAccessPhase.cleanupClusterNodes(:772) (identical on 25.0.1 and 25.0.3) |
Arena.ofShared() + close(), no -H:+SharedArenaSupport |
builds; at run time the close throws UnsupportedFeatureError: Support for Arena.ofShared is not active — creation/mapping/cross-thread reads all succeed, only close() is gated |
Arena.ofShared() without close(), no flag |
works, but leaks the mapping every remap — rejected in favour of Arena.ofAuto() |
Arena.ofAuto(), no flag, -H:+VectorAPISupport (current) |
works — native write smoke passes (~50 ms), SIMD kernels keep AVX codegen |
The restriction is on shared-arena close, not creation; since the SIMD kernels are
non-negotiable for query speed we keep -H:+VectorAPISupport and drop the shared-arena
close instead (exactly what the builder's own error message recommends).
Apples-to-apples: shred a 1 M-record DB once, then run the 9-query
ScaleBenchMain workload against that same on-disk DB on both runtimes
(-Dsirix.db=<dir>), so only query execution differs. Both use the columnar
SirixVectorizedExecutor over jdk.incubator.vector (AVX on both). Build:
# full -O3 -march=native (quickBuild=false); cap the builder heap on a shared box
./gradlew :sirix-query:nativeCompile -Pquick-build=false -Pnative.builderXmx=10g
DB=/tmp/sirix-perf-db
./bundles/sirix-query/build/native/nativeCompile/sirix-bench -Dsirix.shredDbPath=$DB 1000000 true 0 # shred once
perf stat -- ./bundles/sirix-query/build/native/nativeCompile/sirix-bench -Dsirix.db=$DB 1000000 true 30| query | JVM avg | native avg | factor |
|---|---|---|---|
| filterCount | 0.630 ms | 0.037 ms | 17× |
| groupByDept | 0.323 ms | 0.067 ms | 4.8× |
| sumAge | 0.300 ms | 0.028 ms | 11× |
| avgAge | 0.191 ms | 0.029 ms | 6.6× |
| minMaxAge | 0.262 ms | 0.049 ms | 5.3× |
| groupBy2Keys | 0.282 ms | 0.096 ms | 2.9× |
| filterGroupBy | 0.155 ms | 0.086 ms | 1.8× |
| countDistinct | 0.092 ms | 0.060 ms | 1.5× |
| compoundAndFilterCount | 0.128 ms | 0.065 ms | 2.0× |
perf stat deltas (whole 30-iter run): native retires more of its work
(tma_retiring 71 % vs JVM 53 %), runs at higher IPC (3.18 vs 2.90 core),
and has a lower branch-miss rate (0.05 % vs 0.51 %) — no deopt guards, no
tiering, fully-hydrated data. This is the headline native query win and it
holds even though predicate codegen falls back (below).
SirixVectorizedExecutor.compileToClass() JIT-emits a specialised
BatchPredicate class per distinct predicate via
MethodHandles.Lookup.defineHiddenClass. A native image cannot define
classes at runtime, so the first call of every distinct predicate throws
UnsupportedFeatureError: Classes cannot be defined at runtime by default
when using ahead-of-time Native Image compilation.
Tried to define class 'io/sirix/query/scan/SirixBatchPred$1'
and the executor falls back to the interpreted op-array predicate
(evalCompiledBatch). Correctness is identical, and warm the interpreter is
actually faster than the JVM's compiled predicate (above). But on a true cold
first query (-Dsirix.noWarmup=true, iter 1) the interpreted full-scan over
freshly page-faulted mmap data, with no JIT to amortise, is far slower than the
JVM:
| query (cold iter-1, no warmup) | JVM | native | note |
|---|---|---|---|
| filterCount (first predicate query) | 11.9 s | 43 s | cold mmap hydrate + interpreted predicate |
| groupByDept | 1.48 s | 13.6 s | |
| sumAge | 0.29 s | 4.7 s | |
| avgAge / minMaxAge (pure aggregate, no predicate codegen) | ~1–2 ms | ~0.1 ms | native already optimal — no fallback on this path |
So the earlier "cold iter-1 → 0.22 ms, 4000×" claim only held with a covering
projection index (-Dprojection=true, the ProjectionIndexByteScan path),
which sidesteps predicate codegen entirely — not the default generic predicate
path measured here. Cheap mitigation applied: COMPILED_PREDICATE_ENABLED
now defaults off in a native image (SirixVectorizedExecutor, gated on the
org.graalvm.nativeimage.imagecode property), so we skip the doomed classfile
build + throw/catch on the first call of each predicate and the noisy stderr
dump; the result is unchanged. The real fixes are larger and out of scope here:
emit the predicate variants at build time into static fields
(--initialize-at-build-time), or always use the projection-index scan for
predicate-bearing queries in native images.
Native shred measured here: 25.4 K rec/s (1 M records), vs JVM ~200 K
rec/s. Profile (prior perf stat, 200 K records) showed IPC 3.74 but
CPUs utilized = 1.72 / 20: the Gson JsonReader tokenizer is effectively
single-threaded and native-image's per-thread tokenizer throughput is ~10×
below HotSpot tiered. Not compute-bound — it's serialization + single-thread.
Pragmatic split: ingest on the JVM, query on native — both share the on-disk V0 format. A JVM-shredded DB queried natively hits the same warm numbers above. Future levers: an AOT-friendly JSON parser (fastjson2 / a record-shape-specific parser / simdjson) and parallel shred partitioning. (This split was largely unnecessary on the GraalVM 25.1-dev EA build measured in the update below.)
GraalVM commit 8edcbb77 ("Intrinsify MemorySegment.get/set before analysis",
2026-03-06) makes native-image intrinsify the scalar MemorySegment accessors
that HotSpot's JIT already intrinsifies. It was measured here on an Oracle GraalVM
25.1-dev EA build (graalvm-jdk-25e1-25.0.3-ea.32, 2026-06-16), and every
figure in this section comes from that build. A standalone scalar get/set
microbench goes 4466 ms → ~75 ms native (≈56×) on it — native is now ~2× the
JVM instead of ~100×.
Re-measured on this codebase/host, same binary recipe (-O3 -march=native),
1 M records:
| ingest (shred 1 M) | rec/s | vs JVM |
|---|---|---|
| JVM (GraalVM 25.1-dev) | 110 K | 1.0× |
native, GraalVM 25.1-dev -O3 |
~90 K | ~1.2× slower |
native, GraalVM 25.0.3 -O3 |
~23 K | ~4.8× slower |
The native ingest penalty drops from ~4.8× → ~1.2× (near parity). That a MemorySegment-specific fix alone buys ~4× indicates the un-intrinsified scalar accessor in the page-serialization write path was a substantial part of the native ingest cost — not only the single-threaded Gson tokenizer the earlier profile flagged. On that EA build a single native binary could ingest and query with only a ~20 % ingest tax (was ~5×).
The warm analytical kernels above are unchanged — they are already
AVX-vectorized (Vector API) and never touched the slow scalar accessor.
PGO did not help: ingest stayed flat and the sub-ms query kernels regressed
(e.g. filterCount 0.053 ms -O3 → 0.165 ms PGO) because the instrumented
profile is dominated by the ~28 s shred and mis-weights the microsecond kernels;
plain -O3 is the better build here.
Caveat: the 25.1 line is no longer pre-release. GraalVM 25.1.3 reached GA on
2026-06-30, 25.2.4 on 2026-07-28, and GraalVM CE 25.3.4.1 on 2026-08-25. Whether
commit 8edcbb77 is in any of these shipped releases has not been re-checked
here, so the figures above describe the 25.1-dev EA build only. Re-measure
them before quoting them for a stable GraalVM. This intrinsification is a
separate matter from the build-time downcall-handle support that the next
section depends on.
The portable configuration initializes FFM downcall adapters at run time, because the GraalVM
25.0.x LTS line rejects build-time adapters with a linkToNative compilation error. Build-time
downcall handles need a GraalVM 25 innovation release 25.1.3 or later, which carries upstream
oracle/graal PR 13309, "Support build-time
initialization of downcall handles". Keep the run-time default for every other toolchain, and do
not add the adapters to the shared native-image.properties build-time list: CI resolves
java-version: '25' to the LTS line, which would fail the build.
An optimized main image can opt in with -Pnative.preinitializeDowncalls=true. This adds:
--initialize-at-build-time=io.sirix.page.SirixLZ77NativeDecoder$DecodeCall,io.sirix.io.filechannel.PosixFadvise$AdviceCall
The option is independent of the chosen main class, schema, query, storage contents, and PGO profile.
Only pure call-signature adapters are initialized early. Library loading, symbol addresses, file
descriptors, and allocator state remain runtime state. The smoke-test image retains its portable
configuration. Shell commands must single-quote the raw Native Image argument to preserve $.
This optimization was verified on GraalVM CE 25.3.4.1 and on the Oracle GraalVM 25.4 development
build (25.4.4.1.1-dev, Java 25.0.4.1.1, compiler revision
cb905c0ea0e868072ee525107e468ac2b5ff964d), using -O3, G1 and the retained PGO profiles. Oracle
GraalVM 25.0.3 and 25.0.4 reject it with linkToNative, the same error CI hits. Builds with the two
adapters preinitialized reproduced all five 100M JSONBench answers exactly and recovered the Q2/Q3
regression that the run-time adapters introduced: roughly 160 ms per Q3 query and 90-120 ms per Q2
query. Treat the option as explicitly toolchain-dependent: validate that the image builds and run
the application's exactness checks before enabling it with another GraalVM build. It is not enabled
automatically by version string or by benchmark detection.
Pin the builder JDK when you enable it, and check the Java version / vendor version banner the
builder prints. Exporting JAVA_HOME is not enough: a Gradle daemon that is already running
supplies its own JDK, so the build can silently use a different GraalVM than you intended and fail
with linkToNative even though the right toolchain is installed. Pass
-Dorg.gradle.java.home=<graalvm> (which starts a daemon with that JDK) and confirm the banner.
NativeImageDowncallConfigTest (sirix-query, part of the ordinary test task) keeps the build script
and the two holder classes in step. The build script defines the argument once and hands the test what
Gradle evaluated, so it checks that the opt-in produces exactly one argument naming the two holders
and the default produces none, that the main image initializes the holders early only when the
build asked for it, that the smoke-test image never does, that the property the build consults is
still the -P switch named above, that the argument names classes that exist and still hold nothing
but their call signature, and that no shared native-image.properties initializes a holder early.
Without that guard, renaming a holder would turn the option into a silent no-op, because Native
Image does not reject a name that matches nothing; with it, a rename that reaches only one side
fails the test instead. Only the argument quoted earlier in this document is unchecked prose — keep
it in step by hand. The test builds no image, so it cannot tell whether an image built with the
option is fast, or builds at all on a given GraalVM; only a timed run of a real image can.