You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Ternary decode on 4× Cortex-A78C: row-thread pool only 40–60 % busy, 1.8 tok/s (BitNet 2B4T) — dispatch-bound, not bandwidth-bound #1357
During BitNet b1.58 2B4T decode on a linux-arm64 JVM with the native kernels loaded, the 4 worker cores are only 40–60 % busy, and decode is 1.8 tok/s. The implied weight traffic is about 1.4 GB/s against roughly 24 GB/s of measured DRAM read bandwidth, so decode is neither bandwidth-bound nor compute-saturated. That points at dispatch and row-thread overhead.
For comparison, on the same cores llama.cpp b9868 decodes the same model (TQ2_0, f16 tied head) at 17.3 tok/s.
Setup
Arduino Ventuno Q (QCS8275), docker --cpuset-cpus=0-3 (4× Cortex-A78C up to 2.36 GHz), Temurin 21.0.12.1.
The first SKaiNET frame on the main thread, across 12 jcmd Thread.print samples during decode:
Frame
Samples
NativeTernaryLmheadKernel.lmheadStage1
4
NativeTernaryF32GemvKernel.gemvPackedStorage
4
BitNetTwoStageDecode.topK
2
ResidualAdd.onForward
1
JvmVectorKernels.binaryFloat
1
Rebuilding the NeoGPU kernel and its adapter with -mcpu=cortex-a78c instead of the armv8-a pin made no difference (1.82 tok/s).
Questions / suggestions
Row partitioning and barrier cost in skainet_row_threads.c for the many small 2560-wide GEMVs per token (q/k/v/o, gate/up/down × 30 layers). Is one job per GEMV too fine-grained? Could batching q/k/v and gate/up into one pool job help?
Do the FFM downcalls themselves add measurable overhead? 7 of the 10 deep samples were inside DowncallStub. A per-token batched entry point might help.
topK over the 128k-vocab stage-1 scores runs on the JVM main thread while the pool idles. Could it be parallelised or moved native?
Two more data points from the same board (Ventuno Q, 4× Cortex-A78C, cpuset 0-3) and the same ggml-model-i2_s.gguf file:
Engine
Decode tok/s
Load
bitnet.cpp @ de371b7 (the fixture's oracle commit), i2_s, -DBITNET_ARM_TL1=OFF
8.58
—
SKaiNET 0.61.0, JVM (Temurin 21, FFM)
1.8
~45 s
SKaiNET 0.61.0, Kotlin/Native linuxArm64 (cinterop, same C kernels)
0.87
64 s
All three produce the same 32-token greedy continuation as golden-greedy-2b4t.txt, so parity holds on ARM.
The JVM/FFM-overhead hypothesis is refuted. Removing the JVM and FFM entirely (a K/N binary with the C archive linked in, kernels reported as ternary_f32_gemv/cinterop and ternary_planes_matmul/cinterop) makes decode 2× slower, not faster. The C kernels are the same in both builds. What differs is the Kotlin-side per-token work (norms, RoPE, attention, residuals, topK) and allocation.
During K/N decode:
cores sit at 25–50 %, with the main thread at about 80 %;
the GC thread used 22 s of CPU over about 75 s;
RSS was 4.5 GB for a 1.19 GB model.
That points at per-token allocation. Next I'm profiling allocation for one decode step on the JVM (JFR allocation samples) to find the top allocation sites, and I'll post the results here.
K/N CLI used for this: a small llm-apps:kbitnet-native module (linuxX64 + linuxArm64 executables, about 60 lines). I can open a PR if useful.
BitNetTwoStageDecode.topK (full sorts over the 128k vocab)
~4.5 s
ScalarHeadAttentionKernel.decodeHead
~1.4 s
TernaryCodec.decodeBitNetPlanesRow
~0.6 s
The implied ternary GEMV rate is about 12 GMAC/s on 4× A78C, with the cores only 40–60 % busy. The native ternary kernels plus row-pool partitioning are the main lever. bitnet.cpp's i2_s path (int8 activations + dotprod) is about 4.8× faster end-to-end on the same file.
Allocation: about 100 MB per decoded token (~190 MB/s; G1 pauses only 4–10 ms, so cheap on the JVM, but this is what hurts K/N). 61 % of it comes from FloatVector.fromArray0Template ← JvmVectorKernels.binaryFloat ← DefaultCpuOpsJvm.vectorFloatBinary. binaryFloat takes the op as a Function2 lambda, so C2 doesn't intrinsify the Vector API and runs the Java fallback, which copies arrays and boxes Float128Vector. Specialising per op (VectorOperators.ADD/MUL constants) should remove most of it. The rest is Arrays.copyOf(int[]) and Integer.valueOf from topK's boxed candidate list.
Experiment: topK without full sorts. I replaced the sorts with an O(n·k) selection for the k-th lower bound and a primitive IntArray for candidates, keeping the same candidate set (ties go to the lower row). BitNetTwoStageDecodeTest passes 4/4 and the 128-token greedy output is byte-identical. Decode went from 1.857 to 1.886 tok/s (+1.6 %): the sorts look large in Java-only samples but are a small share of wall time. It's still worth having as a cleanup, and I can PR it to SKaiNET-transformers.
Suggested order: (1) ternary GEMV / lm_head stage-1 efficiency and pool partitioning on A78C-class cores; (2) per-op binaryFloat specialisation; (3) the topK cleanup.
Found the main cause of the 40–60 % core utilisation: the vendored NeoGPU kernel creates and joins 4 pthreads on every call (dispatch() in hs_ml_ternary_neon.c), about 211 calls per decode step, bypassing the persistent row pool.
I benchmarked the kernel alone at the 2B4T shapes on 4× A78C, with weights streamed from DRAM:
create/join (stock)
single thread
row pool
q/o 2560×2560
654 µs
1080 µs
309 µs
k/v 640×2560
251 µs
272 µs
84 µs
gate/up 6912×2560
1371 µs
2905 µs
785 µs
down 2560×6912
1430 µs
3041 µs
831 µs
lm_head stage 1 (4 × 128k)
56.6 ms
164 ms
45.5 ms
all kernels per token
237 ms
497 ms
140 ms
So stock kernels alone cap decode at 4.2 tok/s, and pooled kernels at 7.2 tok/s.
Fix: #1359. The adapter drives the vendored proj from skainet_run_rows in < 512-row slices, so each slice runs inline and the vendored file stays byte-identical. End to end: 1.90 → 2.26 tok/s (+19 %) with byte-identical greedy output. Also pooling lm_head (it needs an upstream NeoGPU entry point that doesn't spawn threads) gave 2.40 in my experiment.
What's left:
At stock, kernels are ~237 ms of a ~520 ms token. The remaining ~280 ms is Kotlin-side work; the per-op binaryFloat Vector API fix is the obvious first item there.
Even pooled, the LUT kernel moves only 5–7 GB/s of the 24 GB/s available (~6 GMAC/s per core, limited by loads and compute). bitnet.cpp's whole token is ~116 ms. A dotprod- or tbl-based ternary kernel (T-MAC style), or an opt-in int8-activation mode, is the next kernel-side lever.
Harness: a single C file that #includes the vendored source and links skainet_row_threads.c. Happy to add it under benchmarks/ if useful.
Activity
michalharakal commentedon Oct 17, 2026
Two more data points from the same board (Ventuno Q, 4× Cortex-A78C, cpuset 0-3) and the same
ggml-model-i2_s.gguffile:-DBITNET_ARM_TL1=OFFAll three produce the same 32-token greedy continuation as
golden-greedy-2b4t.txt, so parity holds on ARM.The JVM/FFM-overhead hypothesis is refuted. Removing the JVM and FFM entirely (a K/N binary with the C archive linked in, kernels reported as
ternary_f32_gemv/cinteropandternary_planes_matmul/cinterop) makes decode 2× slower, not faster. The C kernels are the same in both builds. What differs is the Kotlin-side per-token work (norms, RoPE, attention, residuals, topK) and allocation.During K/N decode:
That points at per-token allocation. Next I'm profiling allocation for one decode step on the JVM (JFR allocation samples) to find the top allocation sites, and I'll post the results here.
K/N CLI used for this: a small
llm-apps:kbitnet-nativemodule (linuxX64 + linuxArm64 executables, about 60 lines). I can open a PR if useful.michalharakal commentedon Oct 17, 2026
Profile results, JVM decode only. JFR
settings=profilerecorded a 45 s decode window plus-Xlog:gc. Same board, cpuset 0-3, SKaiNET 0.61.0.Where the main thread spends time. JFR samples Java every 10 ms and native every 20 ms; this is weighted accordingly.
NativeTernaryF32GemvKernel.gemvPackedStorageNativeTernaryLmheadKernel.lmheadStage1BitNetTwoStageDecode.topK(full sorts over the 128k vocab)ScalarHeadAttentionKernel.decodeHeadTernaryCodec.decodeBitNetPlanesRowThe implied ternary GEMV rate is about 12 GMAC/s on 4× A78C, with the cores only 40–60 % busy. The native ternary kernels plus row-pool partitioning are the main lever. bitnet.cpp's i2_s path (int8 activations + dotprod) is about 4.8× faster end-to-end on the same file.
Allocation: about 100 MB per decoded token (~190 MB/s; G1 pauses only 4–10 ms, so cheap on the JVM, but this is what hurts K/N). 61 % of it comes from
FloatVector.fromArray0Template←JvmVectorKernels.binaryFloat←DefaultCpuOpsJvm.vectorFloatBinary.binaryFloattakes the op as aFunction2lambda, so C2 doesn't intrinsify the Vector API and runs the Java fallback, which copies arrays and boxesFloat128Vector. Specialising per op (VectorOperators.ADD/MULconstants) should remove most of it. The rest isArrays.copyOf(int[])andInteger.valueOffrom topK's boxed candidate list.Experiment: topK without full sorts. I replaced the sorts with an O(n·k) selection for the k-th lower bound and a primitive
IntArrayfor candidates, keeping the same candidate set (ties go to the lower row).BitNetTwoStageDecodeTestpasses 4/4 and the 128-token greedy output is byte-identical. Decode went from 1.857 to 1.886 tok/s (+1.6 %): the sorts look large in Java-only samples but are a small share of wall time. It's still worth having as a cleanup, and I can PR it to SKaiNET-transformers.Suggested order: (1) ternary GEMV / lm_head stage-1 efficiency and pool partitioning on A78C-class cores; (2) per-op
binaryFloatspecialisation; (3) the topK cleanup.perf(native-cpu): run the ternary f32 GEMV on the shared row pool
michalharakal commentedon Oct 17, 2026
Found the main cause of the 40–60 % core utilisation: the vendored NeoGPU kernel creates and joins 4 pthreads on every call (
dispatch()inhs_ml_ternary_neon.c), about 211 calls per decode step, bypassing the persistent row pool.I benchmarked the kernel alone at the 2B4T shapes on 4× A78C, with weights streamed from DRAM:
So stock kernels alone cap decode at 4.2 tok/s, and pooled kernels at 7.2 tok/s.
Fix: #1359. The adapter drives the vendored proj from
skainet_run_rowsin < 512-row slices, so each slice runs inline and the vendored file stays byte-identical. End to end: 1.90 → 2.26 tok/s (+19 %) with byte-identical greedy output. Also pooling lm_head (it needs an upstream NeoGPU entry point that doesn't spawn threads) gave 2.40 in my experiment.What's left:
binaryFloatVector API fix is the obvious first item there.tbl-based ternary kernel (T-MAC style), or an opt-in int8-activation mode, is the next kernel-side lever.Harness: a single C file that
#includes the vendored source and linksskainet_row_threads.c. Happy to add it underbenchmarks/if useful.