Skip to content

perf(backend-cpu): primitive FP32 fast paths for the eager CPU ops; cache DirectCpuExecutionContext.ops (#949) - #950

Merged
michalharakal merged 1 commit into
developfrom
feature/android-primitive-ops-949
Aug 11, 2026
Merged

michalharakal merged 1 commit into
developfrom
feature/android-primitive-ops-949

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Closes #949.

Implements the fix proposed in the issue: every hot eager op in DefaultCpuOps now tries a flat primitive loop over the dense FloatArray buffer before the generic per-element path (which stays as the fallback for other dtypes/layouts). Covered: binary + scalar arithmetic (same-shape / scalar / last-dim bias broadcast — exactly the coverage of DefaultCpuOpsJvm.vectorFloatBinary), the activation family (relu, leakyRelu, elu, sigmoid, silu, gelu), unary math (sqrt, exp, expm1, log, log2, log10, sin, cos, tanh, abs, sign, clamp, lt, ge, pow, powScalar), softmax/logSoftmax along the last dim (which also removes an O(n²)-per-slice max/denominator recompute in the generic version), sum/mean reductions (flat + strided, same accumulation order as the boxed loops), concat (block copy), and reshape/flatten (contiguous buffer copy). transpose already had a primitive 2D path.

Design notes:

  • The helpers are private inline deliberately — a non-inlined (Float, Float) -> Float lambda compiles to Function2 with boxed invoke, which would reintroduce the exact Float.valueOf churn the profile flagged.
  • Formulas and accumulation orders mirror the boxed bodies one-to-one, so results are bit-identical; result tensors are built with the same newTensor wiring (grad state unchanged).
  • variance is left on the generic path (divisor semantics deserve their own look); argMax already single-passes.
  • DirectCpuExecutionContext.ops is now cached — the getter used to construct a fresh ops instance per access (re-running per-instance lazy kernel resolution) in the eager hot loop.
  • This benefits every non-JVM target; the JVM keeps its Panama/FFM specializations, and its fallthrough cases now land on the primitive paths instead of the boxed ones.

Measured (transformers Android e2e spike, SKaiNET-transformers#288 — Pixel 8a, SmolLM2-135M Q8_0, NATIVE_OPTIMIZED, 44 tokens greedy, byte-identical output):

before (0.39.0) after
e2e decode 2.64 tok/s 10.31 tok/s (3.9×)
decode wall 16.6 s 4.27 s
matmul share (KernelProfile) 17% 61% — matmul-bound at last
non-matmul overhead 13.8 s 1.66 s (8.3×)

That clears the mobile field report's 3 tok/s usability gate by 3.4× (#920 arc). linuxX64 Kotlin/Native moves 0.6 → 0.78 tok/s only — it is scalar-matmul-bound, so the analogous big win there is native SIMD kernels (#722 / #910).

Verified: full jvmTest + linuxX64Test across all modules green; no public API change (helpers are private), apiCheck untouched.

….ops (#949)

The generic DefaultCpuOps paths paid per element: two IntArray allocations
for broadcast index mapping, a vararg-spread boxed data.get, a KClass
when(dtype) comparison, and a boxed lambda round-trip. On ART that
dominated LLM decode: 83% of e2e SmolLM2-135M decode on a Pixel 8a was
non-matmul overhead even with the NEON backend doing every matmul.

Hot ops now try a flat primitive loop over the dense FloatArray buffer
first (generic path unchanged as fallback): binary/scalar arithmetic
incl. the last-dim bias broadcast the JVM vector path covers, the
activation family, unary math, softmax/logSoftmax along the last dim
(also removing an O(n^2)-per-slice max/denominator recompute), sum/mean,
concat (block copy), reshape/flatten (buffer copy). The inline helpers
are private inline on purpose: a non-inlined (Float,Float)->Float lambda
would box through Function2 and reintroduce the churn being removed.

DirectCpuExecutionContext.ops is now cached; the getter previously built
a fresh ops instance (and re-ran per-instance lazy kernel resolution) on
every access in the eager hot loop.

Measured on the transformers Android e2e spike (Pixel 8a, 44 tokens,
NATIVE_OPTIMIZED, identical output): 2.64 -> 10.31 tok/s (3.9x), decode
16.6s -> 4.27s, matmul share 17% -> 61%, non-matmul overhead 13.8s ->
1.66s (8.3x). linuxX64 K/N: 0.6 -> 0.78 tok/s (still scalar-matmul-bound
pending #722/#910).
@michalharakal

Copy link
Copy Markdown
Contributor Author

Release note for maintainers: this is a pure perf fix — no public-API change (helpers are private, apiCheck untouched), bit-identical outputs, full jvm+linuxX64 suites green. It would be a legitimate engine 0.39.1 patch candidate: transformers could then ship the 2.64 → 10.31 tok/s Android improvement with only a skainet pin bump after its own 0.39.1 (which still pins 0.39.0).

@michalharakal
michalharakal merged commit 2f1261d into develop Aug 11, 2026
13 checks passed
@michalharakal
michalharakal deleted the feature/android-primitive-ops-949 branch August 11, 2026 09:57
michalharakal added a commit that referenced this pull request Aug 11, 2026
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.

Android eager decode is ~83% non-matmul overhead: base DefaultCpuOps runs boxed per-element ops — port the JVM primitive fast paths (#920 follow-up)

2 participants