Skip to content

perf(backend-cpu): let C2 intrinsify the elementwise Vector API paths - #1360

Merged
michalharakal merged 1 commit into
developfrom
perf/jvm-vector-binary-intrinsics
Oct 17, 2026
Merged

michalharakal merged 1 commit into
developfrom
perf/jvm-vector-binary-intrinsics

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Problem

In a JVM decode profile of BitNet b1.58 2B4T (#1357), 61 % of all allocation came from FloatVector.fromArray0Template ← JvmVectorKernels.binaryFloat ← DefaultCpuOpsJvm.vectorFloatBinary: 9.6 GB in a 45 s window. That's the Vector API's Java fallback, not intrinsified code. There were two causes:

  1. The op was passed as a lambda. binaryFloat/unaryFloat and vectorFloatBinary/vectorFloatUnary took the FloatVector op as Function2/Function1. One shared helper serves add/sub/mul/div/relu/…, so the call is megamorphic. The vectors escape into the generic invoke, and C2 can't box-eliminate them.
  2. The species was an instance field. DefaultCpuOpsJvm.floatSpecies was an instance val. C2 constant-folds static final fields only, and the Vector API intrinsics need a constant species. relu alone allocated 3× its output because of this.

Change

  • binaryFloat, unaryFloat, vectorFloatBinary and vectorFloatUnary are now inline, so each op gets its own monomorphic loop with the lambda body in place.
  • DefaultCpuOpsJvm.floatSpecies reads JvmVectorKernels.floatSpecies (an object property, so a static final field) through an inline getter.
  • The scalar-broadcast and last-dim (bias) broadcast loops reuse the same inline helpers: binaryFloat gains outOffset, plus new binaryFloatScalarLeft/Right. That removes about 60 lines of duplicated loop code.

All of it is internal API; apiCheck passes.

Evidence

  • :skainet-backends:skainet-backend-cpu:jvmTest: 490/490 pass.
  • Host microbenchmark, bytes allocated per op on 1×2560 FP32 after warm-up (output alone is 10 256 B):
    • relu: 31 016 → 10 536 B/op, 6.9 → 1.5 µs/op.
    • same-shape add/sub/mul/div: output-only in both builds when warmed in isolation.
    • Scalar and bias broadcast still showed fallback boxes in the isolated, lambda-driven probe on x86 under tiered compilation; with -XX:-TieredCompilation scalar-mul is output-only. I didn't chase that further, because what matters is the real decode below.
  • Real decode on the board (Arduino Ventuno Q, 4× Cortex-A78C, Temurin 21, JFR settings=profile, 45 s decode window): FloatVector.fromArray0Template disappears from the allocation-by-site list (it was 61 %). Greedy 128-token output is byte-identical (md5 52f57d1d…).
  • Decode speed is unchanged within noise (2.28 → 2.32 tok/s). On the JVM, the fallback mostly cost allocation, not wall time, since GC pauses are 4–10 ms. The win is allocation pressure, which matters more for Kotlin/Native and Android heaps.

Not in this PR: the next allocation sites the profile now surfaces

With the fallback gone, decode allocation is dominated by generic element-wise copies that spread get(*indices), which copies an IntArray and boxes a Float per element:

  • RMSNormalization.eagerFusedRmsNorm → MmapFloatTensorData.copyToFloatArray() (TensorData default, per forward, on mmap'd norm gains): ~10 GB / 45 s. A bulk FloatBuffer override removes it; I verified that locally.
  • DefaultCpuOpsBase.split (used by RoPE.applyRoPESplitHalfFull): ~7.8 GB / 45 s.

I'm happy to send those as follow-ups.

Refs #1357

🤖 Generated with Claude Code

JvmVectorKernels.binaryFloat/unaryFloat and DefaultCpuOpsJvm.vectorFloatBinary/Unary
took the FloatVector op as a Function1/2. Shared by every elementwise op the call is
megamorphic, the vectors escape into the generic invoke, and the Vector API runs its
allocating Java fallback (fromArray0Template: 61 % of decode allocation in a BitNet
2B4T JVM profile). They are now inline, so each op gets a monomorphic loop.

DefaultCpuOpsJvm.floatSpecies was an instance field; C2 only constant-folds static
finals and the Vector API needs a constant species, so it now reads the object's
(static) species. The scalar- and bias-broadcast loops go through the same inline
helpers (binaryFloat with outOffset, binaryFloatScalarLeft/Right).

Refs #1357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michalharakal
michalharakal merged commit 8708885 into develop Oct 17, 2026
16 checks passed
@michalharakal
michalharakal deleted the perf/jvm-vector-binary-intrinsics branch October 17, 2026 19:44
michalharakal added a commit that referenced this pull request Oct 21, 2026
CHANGELOG [0.62.0] - 2026-10-21 with the parity headline over #1363, #1364,
#1368, #1369, #1426, #1427, #1428 and #1430, plus entries for the operator
coverage matrix (#1370), the BitNet decode allocation and threading fixes
(#1359, #1360, #1361, #1362), the ClassificationSupport extraction (#1429)
and the toolchain bumps (Kotlin 2.4.21, kotest 6.2.6, Gradle 9.8.1).
VERSION_NAME, CITATION, Antora version, README bom coordinate, What's New
and contributors, Android tutorial coordinates; regenerated operator and
kernel-support matrices; SKEEP-007 rollout step 3 marked shipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant