Skip to content

arm64: SME2 bf16 sbgemm kernel for VORTEXM4 (Apple M4) - #6142

Open
0tomhoyt wants to merge 4 commits into
OpenMathLib:developfrom
0tomhoyt:sme2-sbgemm
Open

0tomhoyt wants to merge 4 commits into
OpenMathLib:developfrom
0tomhoyt:sme2-sbgemm

Conversation

@0tomhoyt

@0tomhoyt 0tomhoyt commented Oct 20, 2026 •

Copy link
Copy Markdown

Adds an SME2 sbgemm (bf16 × bf16 → fp32) kernel in the style of the fp32/fp64 SME2 kernels of #6074, plus the packed-layout fixes it needed, and one crash fix in the shared SME2 detection.

What it does

BFMOPA ZA32 with .H operands accumulates 2-wide dot products, so one instruction covers two k steps. The kernel (kernel/arm64/sbgemm_kernel_sme2.c) zips rows l and l+1 of the standard packed [k][m] / [k][n] panels into the lane layout 2*i + (k&1) that the outer product needs; a missing second row of an odd k is loaded under a false predicate, which zero-fills. C accumulates in four ZA32 tiles of 16×16 fp32 through the load/store helpers of sme2_gemm_tile.h (SBGEMM_UNROLL_M 64, SBGEMM_UNROLL_N 16), the bfloat16 counterpart of the fp32 micro-kernel shape.

New bfloat16 packing copies (sbgemm_{n,t}copy_{64,16}_sme2.c) emit [k][w] panels for both operand layouts — 64-row chunks on the M side, power-of-two groups on the N side. These are needed because the generic copies emit column-major groups on the T side, which the kernel does not expect. Without SME2 at run time (or in the compiler), plain loops run on the same packed layout.

Fix included: the SME2 run-time check could not return false

s2_usable() computed sme2 && svcntsw() == 16. clang if-converts the conjunction and speculates svcntsw() (RDSVL — undefined without FEAT_SME) above the sysctl test, so the guard itself trapped instead of returning false. I ran into this by testing a dedicated TARGET=VORTEXM4 build on an M3 Pro (unsupported, but it showed the guard never gets a chance to work); with DYNAMIC_ARCH the VORTEXM4 code is only entered on M4, so this is defensive. The vector length is now read through a noinline helper with inline asm, which clang will not speculate.

Status

  • Static make TARGET=VORTEXM4 (Apple clang 21), make DYNAMIC_ARCH=1, and CMake all build cleanly; GCC keeps the generic kernels as before (the KERNEL entries sit inside the existing clang guard).
  • Correctness: 39,116 randomized cblas_sbgemm cases (all four transpose combinations, m/n/k from 1 to 257 plus 1000–1200 sized cases, α ∈ {1, −1, 0.5, 3}, β ∈ {0, 1, 0.25}, odd strides) against a double-precision reference: 0 failures on this branch, same as develop.
  • Not yet run on Apple M4 hardware: this machine is an M3 Pro, so the BFMOPA path itself is compile-verified only (the disassembly shows the intended bfmopa × 4 per k-pair on ZA0–ZA3 with predicated zips). The exercised path is the fallback loops plus the full driver/packing pipeline, which shares the packed-layout contract with the SME2 path. Marking as draft until someone can run the BLAS tests and a benchmark on an M4 — happy to iterate on the numbers.

Add an SME2 sbgemm (bf16 x bf16 -> fp32) kernel in the style of
gemm_kernel_sme2.c, for a 512-bit streaming vector length:

- BFMOPA ZA32 with .H operands accumulates 2-wide dot products, so the
  kernel zips rows l and l+1 of the packed [k][m] and [k][n] panels
  into the lane layout 2*i + (k & 1); a missing second row of an odd k
  is loaded under a false predicate, which zero-fills. C accumulates
  in four ZA32 tiles of 16x16 fp32 via the helpers of
  sme2_gemm_tile.h (SBGEMM_UNROLL_M 64, SBGEMM_UNROLL_N 16).
- Custom bfloat16 A and B copies (sbgemm_{n,t}copy_{64,16}_sme2.c)
  emit [k][w] panels for both operand layouts, in 64-row chunks on
  the M side and power-of-two groups on the N side, like the A copies
  of the fp32 SME2 kernels (the generic copies emit column-major
  groups on the T side).
- Without SME2 at run time, or in the compiler, plain loops run on
  the same packed layout.

Also fix a crash of the existing SME2 kernels on cores without
FEAT_SME2 in s2_usable(): clang if-converts the conjunction and
speculates svcntsw() (RDSVL, undefined without FEAT_SME) above the
sysctl test. Reading the vector length through a noinline helper with
inline asm prevents that.
@martin-frbg

Copy link
Copy Markdown
Collaborator

Thanks - it doesn't make sense to undefine the SBGEMM_DEFAULT_(P,Q,R) of unrelated cpus though, this will only break builds that were using either dedicated sbgemm kernels or the generic C fallback

@martin-frbg

Copy link
Copy Markdown
Collaborator

Regarding your comment, a dedicated VORTEXM4 build is not expected/intended to work on M1 to M3 hardware - cpu autodetection at build or (in the case of a DYNAMIC_ARCH-built library) runtime will select the non-SME "VORTEX" target for these.

Only the UNROLL values are specific to the SME2 kernel (the packed
panel layout); P, Q and R stay at the shared defaults, which are
compatible with the 64-row M chunks.
@0tomhoyt

Copy link
Copy Markdown
Author

Fair point — dropped in 0bc31e8. The VORTEXM4 block now only overrides the UNROLL values (these define the packed panel layout, same #undef + redefine pattern as the NEOVERSEN2 block), and P/Q/R keep the shared defaults, which are compatible with the 64-row M chunks (re-ran the sbgemm correctness sweep on top: unchanged).

@0tomhoyt

Copy link
Copy Markdown
Author

Understood on the intended usage — I'd misframed that section (this machine is an M3 Pro, so I was testing the dedicated build directly on unsupported hardware). Two clarifications on why the s2_usable() change is still in the PR, feel free to disagree:

  • The check as written is meant to gate at run time, but clang if-converts sme2 && svcntsw() == 16 and hoists RDSVL above the sysctl test, so the check itself trapped before it could return false — i.e. the guard existed but could never do its job. The noinline helper just makes the existing guard behave as written.
  • It also means a DYNAMIC_ARCH library that somehow lands on the VORTEXM4 table entry (mis-detection, or a future core reporting an M4-ish cpuid without FEAT_SME2) fails closed instead of SIGILLing.

By the same token I'm fine leaving the SME1 smstart path as you prefer — it only bites the same unsupported dedicated-build case, so I've nothing pending on that.

@0tomhoyt
0tomhoyt marked this pull request as ready for review October 21, 2026 02:46
@0tomhoyt
0tomhoyt marked this pull request as draft October 21, 2026 02:48
The edit that added the SBGEMM P/Q/R overrides to the VORTEXM4 block
matched on the SBGEMM UNROLL undef lines, which occur in several
other cpu blocks as well, so the undefs landed in four of them and
broke their setparam compilation under DYNAMIC_ARCH (SBGEMM_DEFAULT_P
undeclared, e.g. for NEOVERSEV1 with GCC). This is what the review
comment about unrelated cpus was about; the overrides themselves were
already dropped in 0bc31e8.
0tomhoyt added a commit to 0tomhoyt/OpenBLAS that referenced this pull request Oct 21, 2026
sbgemm on the Apple targets so far ran the generic C fallback; with
FEAT_BF16 (present at least since M2, probed at run time so older
cores keep the fallback) the new kernel accumulates C in sixteen quad
registers of eight columns by two row groups. One BFDOT does a
two-wide dot per lane, so rows l and l+1 of the packed panels are
zipped into the lane layout (i, k & 1); the B pair of a column is
duplicated across the lanes and the A pairs cover four rows each. An
odd k is a zero vector, blocks narrower than eight run in scalar code.

With SBGEMM_UNROLL_M = SBGEMM_UNROLL_N the driver routes the A side
through the O copies as well, so both panels come in the power-of-two
grouping of OpenMathLib#6142's copies, which this renames to role-based names
(oncopy/otcopy for both sides, ncopy/tcopy kept for the M-side-only
use of the M4 SME2 target).

On an M3 Pro: 39116-case randomized sbgemm sweep against a
double-precision reference passes unchanged, and against the generic
fallback of develop the packed path is 34x at 1024^3 (57 vs 1.7
GFLOPS) and 63x at 2048x2048x1024 (114 vs 1.8 GFLOPS), about a
quarter of the fp32 NEON sgemm of the same build.
@0tomhoyt

Copy link
Copy Markdown
Author

Now I see what you were pointing at, and I owe you a correction: my reply above fixed only the undefs inside the VORTEXM4 block. The edit that had added them matched on the SBGEMM UNROLL undef lines, which occur verbatim in several other cpu blocks, so the same three undefs had landed in four unrelated blocks as well - and their setparam then failed under DYNAMIC_ARCH (this is also what broke CI on #6145: SBGEMM_DEFAULT_P undeclared for NEOVERSEV1 with GCC). All stray copies are gone in c151d85, the param.h diff against develop is now down to the two SBGEMM_UNROLL overrides plus the comment.

@tesch1

tesch1 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Tested on an M4 Pro (SME2, SVL 512), Apple clang 21, static make BUILD_BFLOAT16=1 (TARGET VORTEXM4 detected), at c151d85.

As posted, the SME2 path fails on M4 in two ways:

  1. Abort in test_sbgemm. Backtrace: sbgemm_kernel -> s2b_drive -> s2b_blk -> __arm_tpidr2_save -> abort. s2b_drive and s2b_blk are both __arm_new("za"), so the call from one to the other sets up a lazy ZA save, and the callee commits it through __arm_tpidr2_save. In OpenBLAS that is the aborting weak stub of kernel/arm64/sme_abi.h. The fp32/fp64 kernels avoid this: only the drive function has __arm_new("za"), and the block functions share ZA (S2_S S2_ZA).
  2. Wrong results. With the abort fixed, test_sbgemm reports SBGEMM FAILURES: 3231080; every case with k >= 2 is wrong and k = 1 is correct. The BFMOPA governing predicates are per 16-bit element, but s2b_mac passes svptrue_b32(), which enables only the even bf16 lanes, so the k + 1 half of each pair is dropped.

The fix (both in sbgemm_kernel_sme2.c):

-  const svbool_t pt = svptrue_b32();
+  const svbool_t pt = svptrue_b16();
...
-__arm_new("za") __arm_locally_streaming static void s2b_blk(BLASLONG k, const IFLOAT *a, BLASLONG wm, const IFLOAT *b,
-                                                            BLASLONG wn, FLOAT *C, BLASLONG ldc, FLOAT alpha,
-                                                            int sgn) {
+S2_NOINL void s2b_blk(BLASLONG k, const IFLOAT *a, BLASLONG wm, const IFLOAT *b, BLASLONG wn, FLOAT *C, BLASLONG ldc,
+                      FLOAT alpha, int sgn) S2_S S2_ZA {

With the fix:

  • test_sbgemm passes with 1, 2 and the default number of threads; the BLAS/CBLAS tests and utest (144/144, 1564/1564) pass.
  • Randomized cblas_sbgemm against a double reference (m, n, k 1-257 and some 900-1200, all transposes, alpha in {1, -1, 0.5, 3, 0}, beta in {0, 1, 0.25}, padded leading dimensions, the padding checked to stay untouched): 3000 trials, 0 failures.

Speed (GFLOPS, square NN, M4 Pro):

n develop sbgemm, 1 thread this PR + fix, 1 thread sgemm, 1 thread this PR + fix, default threads
64 0.48 157 980
256 0.48 676 1781 206
1024 1169 1817 558
2048 1193 1690
4096 839

Two notes on speed, not blockers:

  • Thread cap. With the default threads sbgemm is slower than with one thread: 839 GFLOPS at n = 4096, against about 1190 on one thread. With OPENBLAS_NUM_THREADS=2 (one thread per SME unit on the M4 Pro) it is 981 / 1921 / 2347 at n = 256 / 1024 / 4096, about 2.8x the default at n = 4096. The cause: sme_level3_threads() in common_thread.h caps the threads only for real precision, because its condition contains !defined(BFLOAT16). With the SME2 sbgemm kernel, SBGEMM needs the same cap. bgemm (also built with BFLOAT16, plus BGEMM) still uses the NEON kernels on VORTEXM4, so the condition could be (!defined(BFLOAT16) || !defined(BGEMM)) in place of !defined(BFLOAT16).
  • BFMOPA does twice the flops of FMOPA per instruction, so a bf16 kernel could go above sgemm. At about 0.65x of sgemm on one thread there is room left, probably in the per-k-pair zips and loads of s2b_mac.

Two fixes from Michael Tesch's M4 Pro testing:

- s2b_blk had its own __arm_new("za"), so the call from the equally-
  annotated s2b_drive set up a lazy ZA save that __arm_tpidr2_save
  (OpenBLAS's aborting weak stub) committed. It now shares ZA from the
  drive function through S2_S S2_ZA like the fp32/fp64 block functions.
- BFMOPA governs per 16-bit element, but the predicates were
  svptrue_b32(), which enables only the even bf16 lanes and drops the
  k+1 half of every pair (every k >= 2 wrong, k = 1 right). Now
  svptrue_b16().

Also apply the suggested thread cap to SBGEMM: sme_level3_threads()
capped only real precision (!BFLOAT16), so default threads contended
for the SME units - 839 GFLOPS at n = 4096 against about 1190 on one
thread and 2347 with one thread per unit on the M4 Pro. The condition
is now (!BFLOAT16 || !BGEMM): bgemm keeps the NEON kernels and its
threading.

With the fixes his run reports the sbgemm/BLAS/CBLAS/utest suites
passing and 3000 randomized trials clean; 1193 GFLOPS at 2048^2 single
thread against 0.48 on develop.
0tomhoyt added a commit to 0tomhoyt/OpenBLAS that referenced this pull request Oct 22, 2026
sbgemm on the Apple targets so far ran the generic C fallback; with
FEAT_BF16 (present at least since M2, probed at run time so older
cores keep the fallback) the new kernel accumulates C in sixteen quad
registers of eight columns by two row groups. One BFDOT does a
two-wide dot per lane, so rows l and l+1 of the packed panels are
zipped into the lane layout (i, k & 1); the B pair of a column is
duplicated across the lanes and the A pairs cover four rows each. An
odd k is a zero vector, blocks narrower than eight run in scalar code.

With SBGEMM_UNROLL_M = SBGEMM_UNROLL_N the driver routes the A side
through the O copies as well, so both panels come in the power-of-two
grouping of OpenMathLib#6142's copies, which this renames to role-based names
(oncopy/otcopy for both sides, ncopy/tcopy kept for the M-side-only
use of the M4 SME2 target).

On an M3 Pro: 39116-case randomized sbgemm sweep against a
double-precision reference passes unchanged, and against the generic
fallback of develop the packed path is 34x at 1024^3 (57 vs 1.7
GFLOPS) and 63x at 2048x2048x1024 (114 vs 1.8 GFLOPS), about a
quarter of the fp32 NEON sgemm of the same build.
@0tomhoyt

Copy link
Copy Markdown
Author

Thank you for the thorough test run - both fixes and the thread cap are in bce51f1 (kernel predicates, S2_S S2_ZA block handoff, and the (!BFLOAT16 || !BGEMM) condition in sme_level3_threads), with the sbgemm-bfdot follow-up (#6145) rebased on top. The ZA handoff one is exactly the case I got backwards when I mirrored the STRSM block style instead of the fp32 s2p pattern, and the predicate one was invisible on the M3 Pro I developed on since only the fallback loops execute there - same reason I could only compile-check the BFMOPA path, as the PR description warned. The M4 numbers are much better than I dared extrapolate.

If you are up for one more measurement: #6145 gives VORTEX (M1-M3) a NEON BFDOT sbgemm (57 GFLOPS at 1024^3 on an M3 Pro, 34x over the generic fallback). It is stacked on this PR and reuses its packing copies, so a quick sbgemm check on the same M4 build (which picks VORTEXM4, hence this PR's kernel, for its own tables) plus a VORTEX build if convenient would cover the remaining matrix.

This branch has not been deployed

No deployments
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.

3 participants