Skip to content

feat(kernels): NEON bitnet_gemv with a capability-gated pack and a reference fallback (SKEEP-003 P6, S2.9) - #1092

Merged
michalharakal merged 1 commit into
developfrom
feature/1041-neon-bitnet-pack
Aug 24, 2026
Merged

michalharakal merged 1 commit into
developfrom
feature/1041-neon-bitnet-pack

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Closes #1041 · Phase P6 · Milestone M2 · PRD M2-F4, M2-A2 · Proposal §5.2, §5.3

The kernel

bitnet_gemv.c: int8 activations (one absmax scale per token) against canonical TQ2_0 ternary weights. A ternary weight is an add, a subtract or nothing, so the vector work is the unpacking — a whole vector of 2-bit codes becomes int8 signs with a shift and a mask, after which sdot accumulates sixteen products per instruction where the core has ARMv8.2 dot-product.

Two things in it worth a look:

  • The codes stay in {0,1,2} through the inner loop. Converting them to {-1,0,+1} costs a subtract per sixteen weights; instead the bias comes off once per block, because Σ(c−1)·a = Σc·a − Σa and Σa is the same for every output row.
  • The strips take pre-shifted vectors rather than a shift amount. NDK clang rejects vshrq_n_u8(x, 0) (valid range [1,8]) where gcc accepts it — worth knowing before the next intrinsics kernel.

TQ1_0's base-3 packing and BitNet's own per-tensor packing stay on the scalar path, with the reason in the source: they are storage formats, and a checkpoint headed for this kernel ships as TQ2_0.

The pack contract

TernaryKernelPacks.install(native, capabilities, warn) registers the portable reference first, lets a pack override it, and treats the artifact's absence as a configuration, not a failure: one warning, a slower kernel, no crash. NativeBitNetGemvKernel unwraps the views once and falls back to the reference for anything the native kernel does not take — a prefill row batch, non-heap storage — so the fast path stays an optimization.

The kernel is registered under both a capability-carrying key and the operand-only key the dispatcher actually builds, because operands say nothing about the CPU and the pack only installs itself on a device that has the capability.

Unlike the Q4_0…Q6_K SPI kernels, this one is bridged into the view registry: it was written against TernaryCodec's canonical row-major order, which is what a GGUF holds, so the block-order ambiguity of #973 does not arise — and the parity test pins it rather than assuming it.

Measured on an ARMv8.2 Cortex-A55 reference board (asimddp)

gcc -O3 -ffast-math -march=armv8.2-a+dotprod:

shape kernel worst relative error
k=1024, n=256 0.091 ms/call 0
k=2560, n=2560 2.329 ms/call 0

Parity is exact — the arithmetic is integer until the block scale, so there is nothing to round.

Against the fallback, on the same core, k=1024 n=256: the portable Kotlin kernel takes 21.3 ms/call, the NEON kernel 0.091 ms/call. M2-A2's "≥ 3×" is met with room to spare — but that number came from a debug Kotlin/Native build of the fallback, so treat it as an upper bound rather than the figure.

The honest measure of what the intrinsics themselves buy: 1.08–1.24× over compiler-vectorized C at -O3 -ffast-math on this A55. Hand-written NEON is not a large win over what clang/gcc already do here; the large win is being native at all. Worth knowing before investing in an i8mm variant.

The seven ternary kernel tests and the timing test were also run on that board from the Kotlin/Native binary — all pass. The pack contract (registration, fallback, capability, shape refusal) is tested in common code on every target.

Gate

scripts/pr-gate.sh — all legs passed. The AAR builds for arm64-v8a and x86_64 under NDK clang, and both library variants export the new symbol.

Keeps develop green by

The kernel is an optional artifact by construction: with no pack installed, TernaryKernelPacks.install() registers exactly what #1040 registered and warns. Nothing outside the ternary formats changes path, and no existing kernel, key or provider is touched.

🤖 Generated with Claude Code

…ference fallback

Closes #1041 (SKEEP-003 P6, S2.9, proposal §5.2/§5.3; M2-F4, M2-A2).

- `bitnet_gemv.c`: int8 activations against canonical TQ2_0 ternary weights.
  A ternary weight is an add, a subtract or nothing, so the vector work is
  the *unpacking*: a whole vector of 2-bit codes becomes int8 signs with a
  shift and a mask, after which `sdot` accumulates sixteen products per
  instruction on ARMv8.2. Codes stay in {0,1,2} through the inner loop and
  the bias is removed once per block — `Σ(c-1)a = Σca − Σa`, and `Σa` is
  the same for every output row. Built into both JNI variants and the
  native-cpu library; TQ1_0 and BitNet's own packing stay scalar, and the
  comment says why.
- `BitNetGemvNative` + `TernaryKernelPacks.install(native, capabilities,
  warn)`: the reference is registered first, a pack overrides it, and its
  absence produces one warning and a slower kernel rather than a crash. The
  native kernel is registered under both a capability-carrying key and the
  operand-only key the dispatcher builds, since operands say nothing about
  the CPU and the pack only installs itself where the capability exists.
- `NativeBitNetGemvKernel` unwraps the views once and falls back to the
  reference for anything the native kernel does not take — a prefill row
  batch or non-heap storage — so the fast path stays an optimization.
- JNI shim + `JniKernels.bitnetGemvTq20` + `JniBitNetGemv.install()`, which
  picks the capability from the library variant `/proc/cpuinfo` already
  chose.

Unlike the Q4_0…Q6_K SPI kernels, this one is bridged into the view
registry: it was written against TernaryCodec's canonical row-major order,
which is what a GGUF holds, so #973's block-order ambiguity does not arise
here — and the parity test pins it.

Measured on an ARMv8.2 Cortex-A55 reference board (asimddp), gcc -O3
-ffast-math -march=armv8.2-a+dotprod:

  k=1024 n=256    kernel 0.091 ms   worst relative error 0
  k=2560 n=2560   kernel 2.329 ms   worst relative error 0

Parity is exact — the arithmetic is integer until the block scale. Against
the portable Kotlin kernel that serves when the artifact is absent, on the
same core, k=1024 n=256: 21.3 ms → 0.091 ms. M2-A2's "≥ 3x" is met with
room to spare, though that multiple is measured against a *debug*
Kotlin/Native build of the fallback and so is an upper bound; against
compiler-vectorized C the hand-written NEON is 1.08–1.24x, which is the
honest measure of what the intrinsics themselves buy on an A55.

The seven ternary kernel tests and the timing test also run on that board
from the Kotlin/Native binary; the pack contract (registration, fallback,
capability, shape refusal) is tested in common code on every target.

Gate: scripts/pr-gate.sh — all legs passed; the AAR builds for arm64-v8a
and x86_64 with NDK clang (which, unlike gcc, rejects a zero `vshrq_n_u8`
shift — the strips take pre-shifted vectors because of it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalharakal
michalharakal merged commit d093b43 into develop Aug 24, 2026
18 checks passed
@michalharakal
michalharakal deleted the feature/1041-neon-bitnet-pack branch August 24, 2026 14:38
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.

[S2.9] P6: NEON bitnet_gemv kernel pack skainet-kernels-android-neon (JNI, optional artifact, reference fallback)

1 participant