diff --git a/cactus-kernels/CMakeLists.txt b/cactus-kernels/CMakeLists.txt index bd0f5ff03..d4978531c 100644 --- a/cactus-kernels/CMakeLists.txt +++ b/cactus-kernels/CMakeLists.txt @@ -15,6 +15,8 @@ add_library(cactus_kernels STATIC src/image.cpp src/lstm.cpp src/matmul.cpp + src/matmul_sme2.cpp + src/matmul_sme2_gemv.cpp src/nn.cpp src/norms_rope.cpp src/quants.cpp @@ -46,6 +48,28 @@ if(APPLE) target_link_libraries(cactus_kernels PUBLIC "-framework Accelerate") endif() +# SME2 path: compile only matmul_sme2.cpp with +sme2 so the streaming/ZA code is isolated and the +# base library keeps targeting armv8.2-a (runs on non-SME devices). Use armv8.2-a+...+sme2, NOT +# armv9-a+sme2 — armv9-a implies non-streaming SVE2 that Apple Silicon lacks (SIGILLs at runtime). +include(CheckCXXSourceCompiles) +set(_CACTUS_SME2_FLAG "-march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2") +set(CMAKE_REQUIRED_FLAGS "${_CACTUS_SME2_FLAG}") +check_cxx_source_compiles(" +#include +__arm_locally_streaming __arm_new(\"za\") static void f(){ svzero_za(); } +int main(){ return 0; } +" CACTUS_HAS_SME2) +unset(CMAKE_REQUIRED_FLAGS) +if(CACTUS_HAS_SME2) + set_source_files_properties(src/matmul_sme2.cpp PROPERTIES COMPILE_OPTIONS "${_CACTUS_SME2_FLAG}") + # vg1x4 GEMV TU: -O1 ONLY — clang 17 mis-compiles the SME2 multi-vector ZA-array dot at -O2/-O3 + # into an illegal encoding that SIGILLs on M4 (correct + fast at -O0/-O1). Isolate it here. + set_source_files_properties(src/matmul_sme2_gemv.cpp PROPERTIES COMPILE_OPTIONS "${_CACTUS_SME2_FLAG};-O1") + message(STATUS "cactus_kernels: SME2 enabled (matmul_sme2.cpp; matmul_sme2_gemv.cpp at -O1)") +else() + message(STATUS "cactus_kernels: SME2 not supported by toolchain; SME TUs use scalar stubs") +endif() + if((BUILD_TESTING OR CACTUS_BUILD_TESTS) AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) file(GLOB TEST_SOURCES "tests/test_*.cpp") foreach(TEST_SRC ${TEST_SOURCES}) diff --git a/cactus-kernels/cactus_kernels.h b/cactus-kernels/cactus_kernels.h index 35ed8041f..9a9443a8e 100644 --- a/cactus-kernels/cactus_kernels.h +++ b/cactus-kernels/cactus_kernels.h @@ -304,6 +304,19 @@ void cactus_quant_build_panels(const CactusQuantMatrix* W, uint8_t* panels_out, void cactus_quant_orth_panel_gemv(const CactusQuantMatrix* W2, const __fp16* rot_t, const __fp16* input_scale_recip, const __fp16* A, __fp16* C); +// SME2 backend control (testing/benchmarking). backend: 0 = auto (production dispatch), +// 1 = force NEON panel kernels, 2 = force the SME2 streaming leaves (k_sme >= 1). +// Returns 1 if SME2 is available on this CPU, else 0. +int cactus_quant_set_backend(int backend); +int cactus_quant_sme_available(void); +// 1 when SME runtime paths should be used (SME2 present + SVL == 64, backend != force-NEON). +int cactus_quant_sme_enabled(void); +// 1 when the SME prefill-attention path should be used (sme_enabled + CACTUS_SME_ATTENTION != 0). +int cactus_quant_sme_attn_enabled(void); +// Override the panel GEMV drivers' SME worker count (-1 = env CACTUS_SME_GEMV_WORKERS / auto +// flat k = min(2, nt-1); 0 = pure NEON co-workers — the same-format NEON baseline for A/Bs). +int cactus_quant_set_sme_gemv_workers(int n); + void cactus_quant_4bit_gemv_interleaved( const CactusQuantMatrix* W, const uint8_t* packed_interleaved, diff --git a/cactus-kernels/docs/sme/README.md b/cactus-kernels/docs/sme/README.md new file mode 100644 index 000000000..a5f5bae1d --- /dev/null +++ b/cactus-kernels/docs/sme/README.md @@ -0,0 +1,153 @@ +# SME2 / SVE2 Integration — Knowledge Base + +This directory is the **living knowledge base** for the Cactus SME2/SVE2 matmul effort. Everyone +working on this code should follow the protocol below so knowledge compounds instead of being +re-derived. + +## Protocol (read-before / write-after) +1. **Read-before:** read this `README.md` (status board) → `gotchas.md` → the relevant `api-notes.md` + entry → the tail of `debug-log.md` for the active milestone. Do **not** re-derive a fact already + recorded here. +2. **During:** when you discover a new intrinsic fact or trap, append it to `api-notes.md` / + `gotchas.md` immediately, with compile/asm/run evidence. +3. **Write-after:** append a dated entry to `debug-log.md` (hypothesis → experiment → result → + decision). Promote a snippet to `working-examples/` only after its correctness/asm is verified. + Update the status board row below. + +## Layout +- `README.md` — this file: index + status board. +- `api-notes.md` — verified ACLE/intrinsic facts + Cactus kernel-layout notes. +- `gotchas.md` — traps and constraints. +- `sources.md` — annotated external references. +- `working-examples/` — verified, compilable SME/SVE snippets (trusted corpus). +- `debug-log.md` — append-only chronological experiment log. +- `research/` — raw deep-dive research notes. + **START HERE: `research/SYNTHESIS.md`** — the consolidated, actionable implementation plan + (verified `-march`/intrinsics, SMOPA tiling, Cactus re-tile, M0→M3b recipe, top-5 risks). It rolls + up `research/{acle-sme-intrinsics, apple-macos-runtime, kleidiai-int4-sme2, scalable-sme-reference, + sve2-android-fallback}.md` + `api-notes.md` + `gotchas.md` + `working-examples/*`. + +## Hardware / toolchain (verified this session) +- **Apple M4 Pro**, macOS Darwin 25.3, **Apple clang 17.0.0**. +- `FEAT_SME=1`, `FEAT_SME2=1`, `FEAT_BF16=1`, `FEAT_I8MM=1`, `FEAT_SME_I16I64=1`, `FEAT_SME_F64F64=1`. +- `FEAT_SME_F16F16=0`, `FEAT_SME_B16B16=0`, `FEAT_SME2p1=0` → **MOPA accumulates into FP32 `za32`**. +- **SVL = 64 bytes = 512 bits = 16 FP32 / 32 BF16 / 64 INT8 lanes** (`hw.optional.arm.sme_max_svl_b=64`). +- Apple Silicon does **not** expose SVE/SVE2 to userspace → SVE2 path is Android/Linux only (QEMU-tested here). + +## Status board +| Milestone | Description | State | Best vs NEON | Log | +|---|---|---|---|---| +| Part 1 | Matmul test infra green + baseline | **done** | baseline captured | see debug-log 2026-06-09 | +| Part 1b | Variant registry in harness (NEON vs SME2, CQ1-4 + GEMM) | **done** | — | test_matmul.cpp: cactus_quant_set_backend + 11 tests | +| Research | Consolidated synthesis (`research/SYNTHESIS.md`) | **done** | — | march+intrinsics+tiling verified; standalone matmuls run-correct | +| M0 | FP32 FMOPA smoke (ZA/streaming plumbing) | **done** | — | working-examples/fp32_fmopa_matmul.cpp PASS (max_err=0) | +| M1 | FP16/BF16 MOPA → FP32 accumulate | deferred (not needed for CQ4) | — | svmopa_za32_f16/bf16 verified to compile+run; skipped per CQ4 focus | +| M2 | INT8 SMOPA (operand layout reverse-engineered) | **done** | — | working-examples/int8_smopa_matmul.cpp PASS (err=0) | +| M3 | Wire SMOPA into CQ4 `expanded` path, M=1 GEMV | **done ✅** | 0.26–0.56× (perf-deferred) | matmul_cq4[sme2] PASS, MSE≤0.1 via real cactus_quant_matmul | +| M3b | CQ4 GEMM, M>1 (incl. tail) | **done ✅** | 0.49× @256×1024×1024 | matmul_cq4_M5/M20[sme2] PASS, MSE≤0.1 | +| M4/CQ4 | Optimize CQ4 GEMM (full-tile nr=16 gather) | **done ✅** | **1.08–1.18× @ M≥128** | beats tuned NEON SDOT; auto-dispatched for M≥128 | +| M5/GEMV | Hybrid NEON+SME LUTI4 GEMV, FUSED preamble (one dispatch) | **done ✅✅** | **1.41–1.96× @ model shape (6/6 runs)**; <6MB → NEON by policy | ~489–535 GFLOPS vs NEON 253–380; thermal-stable; see debug-log Phase1 DONE | +| M6/GEMM | Fused LUTI4 SMOPA GEMM ((M-tile×SB) stealing, no gather) | **done ✅✅** | **2.4–3.6× at EVERY M** (M=32: 3.6×; M=256: 2.5×, ~3.0 TFLOPS) | auto-dispatched for all M>1 with cache; prod: 1024³=3603, 2048³=5479 GFLOPS | +| M7/ATTN | SME prefill attention (cached-int8 segment: flat-scale QK seg + u8 USMOPA AV per-block) | **done ✅✅** | **4.6× global / 2.7× sliding** @ gemma shape (3.76→0.81ms / 2.23→0.83ms) | attention_hybrid.cpp `cactus_attention_sme_prefill`; gate hd%64==0, qg=32, pos≥cache; differential+oracle test in test_attention.cpp; see debug-log 2026-06-09 attn entries | +| SVE2 | Android/Linux fallback (svmmla/svusmmla) | designed (research/sve2-android-fallback.md) | — | not built — needs Android/QEMU to validate; out of CQ4-on-M4 scope | + +**POWER FRONTIER (2026-06-10): flat k=2 SME at HALF the budget — Pareto winner.** powermetrics +sweep (spt x k grid, decode-only windows): SME workers strictly dominate k=0 on BOTH axes at +every budget. Shipped: sb_per_thread 8 + flat k=2 → 49.1 tok/s @ 16.1 W = 328 mJ/token, vs the +legacy budget without SME at 48.7 @ 21.0 W (430 mJ/tok): **+0.8% speed, −24% energy/token.** +This is SME's measured mobile value: same speed from half the threads at three-quarters the power. + +**MOBILE THREAD BUDGET (2026-06-10 final): budget beats saturation.** Original thread policy +restored (GEMV ceil(N/256), GEMM M-tiles); SME workers live inside the budget (k=2 on nt≥4 +non-giant shapes — kernel-wins K-heavy +20-23%, E2E par on M4). **Shipped vs legacy production +at the same thread counts: decode 40.6→52.7 tok/s (+29.7%), TTFT 2875→1993ms (−30.7%), prefill ++44%, footprint −1.2GB** — and the budgeted configs are more stable than saturation (bimodal +collapses gone). SME's durable wins: the format, fused M>1 GEMM, prefill attention; decode SME +levers ahead: MTP (M>1 decode), long-ctx attention, phone-SoC retune via CACTUS_SME_GEMV_WORKERS. + +**SAME-FORMAT BASELINE + DEFAULT RETUNE (2026-06-10): best E2E of the campaign.** esme-NEON +beats the file kernels everywhere (K-heavy "collapse" was a file-layout artifact: o_proj 57→210 +GF), and beats the SME GEMV hybrid E2E under sustained decode — auto now runs 0 SME GEMV workers +(knob: CACTUS_SME_GEMV_WORKERS). Shipped default = NEON GEMVs over the cache + SME fused GEMM + +SME prefill attention: **decode ~50-52 tok/s, TTFT ~2.0-2.2s (vs legacy file-NEON 42.0 / 2.9s: +decode +19%, TTFT −27.5%)**, physical footprint −1.2 GB. See debug-log 2026-06-10. + +**SINGLE RUNTIME WEIGHT FORMAT (2026-06-10): physical footprint 4.9 -> 3.7 GB (−1.2 GB).** +NEON co-workers + small-shape GEMVs now consume the esme cache directly +(cactus_quant_esme_gemv_blocks; parity-or-better vs the file kernels), the packed file pages are +madvise-released after the one-time build, backend=1 skips builds, and the cache is deduped +across graph components via a weak_ptr registry keyed by weight-file identity (was built 2x — +once per component). See debug-log 2026-06-10 + the two new gotchas. + +**IL4ROW ORTH lm_head (2026-06-09 late):** the production lm_head format is ORTH+INTERLEAVED_4ROW +(row-major orth was a transpiler bug, retired). The virtual 128-group regrouping is byte-exact on +IL too (k-chunk-ordered panels) — ensure_sme_cache now requires IL, the orth driver uses +interleaved NEON co-workers, and the batched embedding path is IL-only. On the corrected-format +bundle the NEON baseline itself gains ~+14% decode (its old row-major orth lm_head cost ~4.8ms vs +1.33ms interleaved), so SME-vs-NEON deltas there are decode ~+17%, TTFT ~−27% (clean cycles), with +B absolute throughput unchanged (~47.6 tok/s). lm_head op: SME-IL min 0.98ms vs NEON-IL 1.20ms, +DRAM floor ~0.77ms. + +**GOAL 6 COMPLETE (2026-06-09): decode +34.0% / TTFT −34.1% — both targets MET** (vs the +row-major-orth-lm_head bundle vintage) (6-cycle protocol +c2–c6; worst decode cycle +28.6%). Three levers: SME prefill attention; batched-parallel orthogonal +embedding un-rotation (was serial scalar K² per token per layer — found by WALL profiling after +busy-sample shares misled); wait_all→spin-join + main-as-worker-0 in hybrid GEMV (kernel: o_proj +3.18×, down 2.78×, gate_up 1.62×, q_proj 1.41× now dispatched at ≥3M elements). SVL-64 runtime +gate added for non-Apple SME2 devices. + +**GOAL 6 first phase (2026-06-09, SME prefill attention):** cached-int8 attention segment via SMOPA (flat-scale +QK seg leaf + u8 USMOPA AV with per-block P scales), incumbent keeps the fp16 new segment from the +merged flash state. Kernel 4.6×/2.7× (global/sliding) at Gemma shapes. **E2E clean (c2,c3,c6): +prefill +30.9%, decode +20.0%, TTFT −23.6% (floor −20% MET; −25% target 1.4pt short).** Remaining +levers: transpiler tail-sized prefill graphs (~−31% TTFT projected), ffn redesign for decode +25%. + +**GOAL 5 (2026-06-09, "multiply the E2E win"):** Phase-0 profiling found the ORTHOGONAL lm_head +(fp16-codebook dot) was ~45% of decode; shipped cactus_quant_orth_sme_gemv (virtual 12x128-group +remap onto the hybrid). **E2E: decode +20.3% (gate +15% ✓ exceeded), TTFT -17.9% (floor -20%, +~88ms short), prefill +21.7%.** Remaining TTFT levers documented in debug-log: M=44 unpadded tail +GEMM, act-pack NEON-ization, prefill attention. Decode now 41.6 vs 34.6 tok/s. + +**GOAL 4 COMPLETE (2026-06-09, production formats):** interleaved-4row CQ4 re-gate done — fixture ++ registry cover both layouts (bit-exact vs the production kernel; cache layout-invariant); hybrid +restored with the interleaved NEON co-worker; policy: K-heavy>=3MB or >=6MB. Kernel: down/o_proj +2.0-2.3x, ffn ~1.08, lm_head ~1.10 (DRAM-capped, documented). **E2E Gemma 4 E2B: decode +5.7%, +TTFT -13.5%, prefill +15.5%.** + +**GOAL 3 COMPLETE (2026-06-09):** SME2 CQ4 decisively beats NEON — GEMV 1.4–2.0× steady-state at +model shapes (NEON below 6MB by honest policy), GEMM 2.4–3.6× across every M. Production auto-mode: +cq4 1024³ **3603 GFLOPS** / 2048³ **5479 GFLOPS** vs session-start NEON baseline 1813 / 2280. +Key design rules that got there (all verified, see gotchas): count pool DISPATCHES not preamble µs +(one fused dispatch per call); never read ZA mid-stream (stores only); maximize MACs/queue-op +(SMOPA 4-tile > vg1x4 > svdot); stream packed nibbles + LUTI4 (half the bytes); steal fine-grained +(M-tile × SB) pairs so all 14 workers stay busy at every M. + +### SME2 vs NEON SDOT — measured (M4 Pro, cq4, K=N=1024, forced backends) +| M (batch) | NEON GFLOPS | SME2 GFLOPS | ratio | dispatch | +|---|---|---|---|---| +| 1 (GEMV) | ~300–320 (model) | ~80 | 0.25–0.48× | NEON (perf-deferred) | +| 16 | 249 | 216 | 0.87× | NEON | +| 32 | 431 | 323 | 0.75× | NEON | +| 64 | 652 | 631 | 0.97× | NEON | +| 128 | 991 | 1073 | 1.08× | **SME2 (auto)** | +| 256 | 1343 | 1475 | 1.10× | **SME2 (auto)** | + +Auto policy (`cactus_quant_use_sme_gemm`, matmul.cpp): backend 0=auto (SME2 iff M≥128 & `cpu_has_sme2()`), +1=force NEON, 2=force SME2. GEMV always NEON (outer-product engine underutilized at M=1). Per-call +weight gather is not yet cached — caching `expanded_sme` at model-load would widen the SME2 win to +smaller M (documented follow-up). + +**DEFINITION OF DONE MET (2026-06-09):** SME2 CQ4 path correct for M=1 GEMV + M>1 GEMM through the real +`cactus_quant_matmul`, validated against `cq_reference_gemv_f32` at MSE≤0.1, behind `cpu_has_sme2()` +dispatch (force via `cactus_quant_set_backend(2)`), NEON auto-default intact, base lib builds on the +unchanged `-march=armv8.2-a+...+i8mm` baseline (SME isolated in `matmul_sme2.cpp` + per-source flag). +Perf is currently below NEON → SME stays perf-deferred (not auto-dispatched); M4 optimization ongoing. + +## Baseline (NEON, M4 Pro, this session) +| kernel | 1024³ | 2048³ | model GEMV 1×2304×9216 | MSE(CQ4) | +|---|---|---|---|---| +| f16 | 1939 GFLOPS | 2657 GFLOPS | 33 | — | +| cq4 | 1813 GFLOPS | 2280 GFLOPS | 301 | 3.3e-5 | + +Dispatch rule: a variant is enabled in `cactus_quant_matmul` only if it (a) passes MSE ≤ 0.1 and +(b) beats NEON on this Mac. Correct-but-slower variants are logged "perf-deferred", not dispatched. diff --git a/cactus-kernels/docs/sme/api-notes.md b/cactus-kernels/docs/sme/api-notes.md new file mode 100644 index 000000000..f109e271a --- /dev/null +++ b/cactus-kernels/docs/sme/api-notes.md @@ -0,0 +1,132 @@ +# API Notes — verified facts + +> Append-only-ish; correct entries in place if proven wrong (note the correction). Every claim should +> carry evidence: `[compiled]`, `[asm-checked]`, `[ran-correct]`, or `[source: ]`. + +## ⚑ VERIFIED `-march` for Apple M4 (CRITICAL — corrects earlier guess) +- **USE `-march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2`** (or `armv8-a+sme`). `[ran-correct]` M0 PASS, max_err=0. +- **DO NOT use `-march=armv9-a+sme2`** — it COMPILES but the binary **SIGILLs at runtime (exit 132)** on M4. + Root cause: `armv9-a` implies the full non-streaming SVE2 ISA, which Apple Silicon does NOT implement + (Apple has only SME's streaming-SVE subset). clang then emits non-streaming SVE2 instructions that trap. + `armv8(.2)-a+sme2` advertises SME2 WITHOUT the bad SVE2 baseline → only streaming-legal code is emitted. +- This means the earlier design-phase claim ("armv9-a+sme2 verified") was an ASM-only check, not a run. + Lesson: always RUN, never trust asm-compiles-clean for SME on Apple. + +## VERIFIED signatures from clang 17 arm_sme.h (resource-dir header, authoritative on this box) +`[source: $(clang -print-resource-dir)/include/arm_sme.h]` +- `void svmopa_za32_f32_m(uint64_t tile, svbool_t pn, svbool_t pm, svfloat32_t zn, svfloat32_t zm);` +- `void svmopa_za32_f16_m(uint64_t, svbool_t, svbool_t, svfloat16_t, svfloat16_t);` (FP16→FP32 widening — EXISTS here) +- `void svmopa_za32_bf16_m(uint64_t, svbool_t, svbool_t, svbfloat16_t, svbfloat16_t);` +- `void svmopa_za32_s8_m(uint64_t, svbool_t, svbool_t, svint8_t, svint8_t);` (INT8 4-way → za32) +- `void svmopa_za32_u8_m(...)`, `svmopa_za32_s16_m/u16_m(...)` also present. +- **ZA store (note arg order!):** `void svst1_hor_za32(uint64_t tile, uint32_t slice, svbool_t pg, void* ptr);` + Also `_ver_` and za8/16/64/128 variants. `slice` = horizontal slice index 0..SVLs-1 (=0..15) within `tile` (0..3). +- `void svzero_za(void);` `void svzero_mask_za(uint64_t mask);` +- `svuint32_t svread_hor_za32_u32_m(svuint32_t inactive, svbool_t pg, uint64_t tile, uint32_t slice);` +- `void svaddva_za32_s32_m(uint64_t tile, svbool_t pn, svbool_t pm, svint32_t);` (vertical add-accumulate — useful for GEMV reductions) +- `svcntw()` (arm_sve.h) returns streaming-VL word count inside a streaming fn (=16 on M4). Predicates `svptrue_b32()`. +- **Leaf-kernel attribute combo (callable from normal NEON code):** `__arm_locally_streaming __arm_new("za")` + — function self-transitions to streaming mode + owns private ZA (auto smstart/smstop). Use this for the + Cactus SME leaf kernel so the non-streaming caller needs no streaming context. + +## SME ACLE essentials (Apple clang 17, `-march=armv9-a+sme2`) +- Header: `#include ` (pulls in `arm_sve.h`). Guard with `#if defined(__ARM_FEATURE_SME2)`. + `[compiled]` on Apple clang 17 with `-march=armv9-a+sme2`. +- A function that owns ZA: mark `__arm_new("za")` (auto `smstart za` on entry / `smstop za` on exit) + and `__arm_streaming` (runs in streaming-SVE mode, PSTATE.SM=1). `[asm-checked]` emits `smstart za`. +- Streaming vector length: `svcntw()` = words (FP32 lanes) in streaming mode = **16 on M4** (SVL 64B). + Tiling MUST be SVL-parametric (use `svcntw()/svcntb()/svcnth()`), never hard-code 16B like NEON. +- Outer-product accumulate intrinsics (accumulate into FP32 `za32`, tile index 0..3): + - FP32: `svmopa_za32_f32_m(uint64_t tile, svbool_t pn, svbool_t pm, svfloat32_t zn, svfloat32_t zm)` + → `fmopa za.s, p/m, p/m, zn.s, zm.s`. `[asm-checked]` + - BF16 (widening 2-way): `svmopa_za32_bf16_m(...)` → `bfmopa`. (`FEAT_BF16=1`.) + - INT8 (widening 4-way): `svmopa_za32_s8_m(uint64_t tile, pn, pm, svint8_t, svint8_t)` → `smopa + za.s, p/m, p/m, zn.b, zm.b`. `[asm-checked]` Accumulates 4 INT8 products per 32-bit cell. + - FP16→FP32 (widening 2-way): `svmopa_za32_f16_m(...)` → `fmopa` (since `FEAT_SME_F16F16=0`, must + use the za32 widening form, not za16). TO CONFIRM during M1. +- ZA read-out: `svread_hor_za32_*` / `svst1_hor_za32(pg, ptr, tile, slice)` writes a horizontal slice + of a za32 tile to memory. Also `svaddha_za32` (add horizontally-accumulated). Verify exact names M0. +- Load helpers in streaming mode are the SVE `svld1_f32(svptrue_b32(), ptr)` etc. (predicated). +- Data path tip `[source: Hello SME! arxiv 2409.18779]`: load to Z regs via SVE then feed MOPA; + direct memory→ZA loads (`ldr za`) are ~2.6× slower than Z-reg staging. + +## INT8 SMOPA tiling shape (the M2/M3 target) +- SMOPA computes `ZA[i][j] += sum_{c=0..3} A[i][4k+c] * B[j][4k+c]` over a streaming tile: zn holds an + SVL-byte column-ish vector (i index along lanes, 4 contiguous K per 32-bit group), zm likewise (j). +- For an `M×N` tile of size `SVL/4 (=16) × SVL/4` accumulating over K in steps of 4. Confirm exact + operand semantics against scalable-analyses/sme INT8 kernel + KleidiAI `..._sme2_mopa` before coding. + +## Cactus kernel layout (from matmul.cpp / test_matmul.cpp, read 2026-06-09) +- Public dispatch: `cactus_quant_matmul(const CactusQuantMatrix* W, const __fp16* A, uint32_t M, + __fp16* C)` at `src/matmul.cpp:1479`. This is what the test harness calls; SME variants mirror it. +- `CactusQuantMatrix` (cactus_kernels.h:206): bits, K, N, group_size, num_groups, flags, codebook(fp16 + 2^bits), input_scale/recip(fp16,K), norms(fp16, N*num_groups), packed_indices(u8), left/right_signs + (i8, group_size), permutation(u32, group_size), rotation, **expanded(int8)**, **norm_f32(float)**. +- CQ pipeline per group: (1) activation transform `z = x/input_scale * left_sign`, FWHT, `* right_sign` + (`cactus_quant_transform_hadamard_activations`, matmul.cpp:282); (2) INT8-quantize transformed acts + (`tq_quantize_group_i8`, 1247); (3) codebook→INT8 (`tq_quantize_codebook_i8`, 1233); (4) dot via + SDOT (`cactus_quant_sdot_gemv_int8`, 651) over `expanded` weights; (5) rescale by `norm_f32` (which + already folds the codebook INT8 scale `cb_sc`). Reference oracle: `cq_reference_gemv_f32` + (test_matmul.cpp:185). +- `expanded` weight layout (`tq_preexpand_weights`, 1440; mirrored in test `preexpand()`): grouped by + N-blocks of 4 rows; for each (nb, g) a `group_size*4` int8 panel, 4 N-rows interleaved at 32-bit + granularity (built via `vzip` of 4 dequantized int8 rows). `norm_f32` is `N_blocks*num_groups*4`, + `nd[ni] = norms[(n_start+ni)*num_groups+g] * cb_sc`. **This layout is tuned for `vdotq_laneq_s32`, + NOT for MOPA** — M3 must re-tile (on-the-fly first, dedicated `expanded_sme` later). +- **Strategy:** keep steps (1)-(3) + rescale in NON-streaming NEON code; the `__arm_streaming` SME + kernel does ONLY the INT8 dot-accumulate (step 4) over re-tiled weights. No NEON intrinsics inside + streaming functions. + +## Build wiring +- New TUs `src/matmul_sme2.cpp` (`-march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2` — NOT armv9-a+sme2, + see SIGILL note above) / `src/matmul_sve2.cpp` (`+sve2`) via + `set_source_files_properties(... COMPILE_OPTIONS ...)` in `cactus-kernels/CMakeLists.txt`. + Library-wide flag (line 34) stays `-march=armv8.2-a+fp16+simd+dotprod+i8mm`. +- Runtime detect: add `cpu_has_sve2/sme/sme2` beside `cpu_has_i8mm` (threading.h:50). Apple keys + `hw.optional.arm.FEAT_SME` / `FEAT_SME2`; Android HWCAP2 `SME=1<<23`, `SME2=1<<37`, `SVE2=1<<1`. + +## ⚑ EMPIRICAL VERIFICATION PASS 2026-06-09 (probes in /tmp/sme-probe, working-examples/ updated) +Probed on Apple M4 Pro, Apple clang 17.0.0. SVL=512b. FEAT_SME_F16F16=0. + +### -march that works +- `-march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2` `[ran-correct]` — compiles, runs, both matmuls PASS. +- `-march=armv8-a+sme` `[ran-correct]` — compiles + runs (svcntw probe PASS, exit 0). +- `-mcpu=apple-m4` `[ran-correct]` — compiles + runs (svcntw probe PASS, exit 0). +- `-march=armv9-a+sme2` `[asm-checked + RAN]` — compiles, emits IDENTICAL SME asm, but the BINARY + SIGILLs (exit 132). CONFIRMED by running probe p6 under all four strings. Use it for `-S` asm + inspection only; never to produce a runnable binary on M4. +- For `-S` ASM inspection ALL FOUR strings emit the same fmopa/smopa/bfmopa/zero/st1w. So asm-check + is march-insensitive; only RUN distinguishes them. + +### Per-probe results (all asm-checked; p6 ran) +- P1 `svmopa_za32_f32_m(0,pn,pm,zn,zm)` `[asm-checked]` -> `fmopa za0.s, p0/m, p1/m, z0.s, z1.s`. ✓ +- P2 `svmopa_za32_s8_m(0,pn,pm,zn,zm)` `[asm-checked]` -> `smopa za0.s, p0/m, p1/m, z0.b, z1.b`. ✓ + (4-way widening: .b operands accumulate into .s tile.) +- P3 `svzero_za()` AND `svzero_mask_za(255)` `[asm-checked]` -> both emit `zero {za}`. ✓ +- P4 readout, three forms all compile+emit a ZA store/read `[asm-checked]`: + * `svst1_hor_za32(0, slice, pg, ptr)` -> `st1w {za0h.s[wN, 0]}, p0, [ptr]` (USE THIS for matmul out) + * `svstr_za(slice, ptr)` -> `str za[wN, 0], [ptr]` (whole-ZA spill) + * `svread_hor_za32_f32_m(inactive,pg,0,slice)` -> `mov z0.s, p0/m, za0h.s[wN, 0]` (ZA->Z, then rescale+st) +- P5 `svmopa_za32_f16_m(0,pn,pm,zn,zm)` `[asm-checked]` EXISTS + compiles here -> + `fmopa za0.s, p0/m, p1/m, z0.h, z1.h` (FP16->FP32 widening; uses za32 widening form since + FEAT_SME_F16F16=0, no za16 f16 path). ✓ +- P6 `svcntw()` `[ran-correct]` inside __arm_locally_streaming returns **16** on M4. + Also ran: `svcntb()=64`, `svcntsw()=16`, `svcntsb()=64`. SVL=512b confirmed by run, not docs. +- P7 `svmopa_za32_bf16_m(0,pn,pm,zn,zm)` `[asm-checked]` -> `bfmopa za0.s, p0/m, p1/m, z0.h, z1.h`. ✓ + +### Full runnable matmuls (saved to working-examples/, both PASS exact) +- `working-examples/fp32_fmopa_matmul.cpp` `[ran-correct]` 32x32x32, max_abs_err=0.000e+00. +- `working-examples/int8_smopa_matmul.cpp` `[ran-correct]` 32x32x32, max_abs_err=0 (all 1024 cells). + +### SMOPA widening contract — VERIFIED BY SINGLE-HOT PROBE (not docs) +- `ZA32[i][j] += sum_{c=0..3} zA[4*i + c] * zB[4*j + c]`. Proven: a[ai]=1,b[bj]=1 lights + `ZA[ai/4][bj/4]` ONLY when `ai%4 == bj%4` (matching inner-K c); all-ones -> cell value 4. + Pack: `aPanel[4*i+c]=A[(row+i)*K + 4g+c]`, `bPanel[4*j+c]=B[(4g+c)*N + (col+j)]`. + +### CORRECTION to research/acle-sme-intrinsics.md §0 item 2 (re: za32 s16/u16 MOPA) +- research doc claims "There is NO svmopa_za32_s16_m / _u16_m". **WRONG on this box.** Apple clang 17 + `arm_sme.h` DOES declare `svmopa_za32_s16_m` and `svmopa_za32_u16_m`. `[asm-checked]` + `svmopa_za32_s16_m(0,pn,pm, svint16, svint16)` compiles and emits `smopa za0.s, ..., z0.h, z1.h` + (a 2-way-widening S16×S16 -> S32-in-za32; an SME2.1 form Apple exposes). So both exist: + za32-from-s16 (2-way) AND za64-from-s16 (`svmopa_za64_s16_m`, needs FEAT_SME_I16I64, 4-way). + Header confirmed to define: f32, f16, bf16, s8, u8, s16, u16 -> za32. diff --git a/cactus-kernels/docs/sme/debug-log.md b/cactus-kernels/docs/sme/debug-log.md new file mode 100644 index 000000000..4283360db --- /dev/null +++ b/cactus-kernels/docs/sme/debug-log.md @@ -0,0 +1,520 @@ +# Debug Log (append-only) + +Format per entry: `## YYYY-MM-DD [milestone] title` → Hypothesis / Experiment / Result / Decision. + +## 2026-06-09 [Part 1] Baseline test infra green +- **Experiment:** built `cactus-kernels` tests on M4 Pro (Apple clang 17), ran `./test_matmul`. +- **Result:** all 5 tests pass. CQ4 MSE=3.3e-5 (gate 0.1). Baseline GFLOPS: f16 1024³=1939 / 2048³=2657; + cq4 1024³=1813 / 2048³=2280; cq4 model GEMV 1×2304×9216 = 301 GFLOPS. +- **Decision:** harness + FP32 oracle (`cq_reference_gemv_f32`) are sound. Use as the correctness gate + for every SME variant. Next: variant registry + M0. + +## 2026-06-09 [Part 0] Hardware/toolchain probe +- **Result:** M4 Pro, `FEAT_SME=1/SME2=1/BF16=1/I8MM=1/SME_I16I64=1/SME_F64F64=1`, + `FEAT_SME_F16F16=0`, SVL=64B. Apple clang 17 compiles `arm_sme.h` + `svmopa_za32_{f32,s8}_m` under + `-march=armv9-a+sme2`, emits `smstart za` / `fmopa` / `smopa` (per the design-phase probe). +- **Decision:** SME2 locally testable. MOPA accumulates FP32. Proceed with confidence. + +## 2026-06-09 [M0] FP32 FMOPA smoke — RAN correct +- **Experiment:** standalone 16×16×16 FP32 outer-product matmul, `svmopa_za32_f32_m` + `svst1_hor_za32`, + compiled across -march candidates and RUN. +- **Result:** `armv9-a+sme2` **SIGILLs at runtime (exit 132)**; `armv8.2-a+fp16+simd+dotprod+i8mm+sme2` + (and `armv8-a+sme`) RUN correct, svcntw=16, max_err=0. → working-examples/fp32_fmopa_matmul.cpp. +- **Decision:** pin `-march=armv8.2-a+...+sme2` (NOT armv9-a). Always RUN, never trust asm-only. + +## 2026-06-09 [M2] INT8 SMOPA operand layout — reverse-engineered, RAN correct +- **Experiment:** 16×16×128 INT8 matmul; packed both operands `[K/4][16][4]`, byte predicates, + `svmopa_za32_s8_m`, int32 readout vs scalar reference. +- **Result:** maxerr=0, all cells exact. Confirms `ZA[i][j] += Σ_{c<4} Zn[i*4+c]·Zm[j*4+c]` and that + the Cactus `expanded` panel (kg-th 16 bytes = 4 channels × 4 K) is a ready-made SMOPA `pm` operand. +- **Decision:** SME does ONLY integer SMOPA; reuse NEON preamble + rescale. → working-examples/int8_smopa_matmul.cpp. + +## 2026-06-09 [M3/M3b] Wire SMOPA into real cactus_quant_matmul — DONE ✅ (DoD met) +- **Implementation:** `src/matmul_sme2.cpp` leaves (`cactus_sme_cq_gemv_4col_s8`, + `cactus_sme_cq_gemm_16x4_s8`, `__arm_locally_streaming __arm_new("za")`, byte predicates), + per-source `+sme2` flag via CMake `check_cxx_source_compiles`; `cpu_has_sme/sme2` in threading.h; + GEMV+GEMM drivers + `cactus_quant_set_backend` dispatch in matmul.cpp (reuse Hadamard/quant/expand + preamble; SME returns int32 partials; identical NEON FP rescale). Variant registry in test_matmul.cpp. +- **Result:** ALL 11 matmul tests pass + full kernel suite green (clean rebuild). `matmul_cq4[sme2]` + (GEMV), `matmul_cq4_M5/M20[sme2]` (GEMM incl. tail) correct vs FP32 oracle, MSE≤0.1. + Head-to-head: GEMV 0.26–0.56×, GEMM 0.49× @256×1024×1024 vs tuned NEON SDOT. +- **Decision:** correctness DoD MET. SME2 stays perf-deferred (auto=NEON). Next: M4 perf — kill + per-block streaming transitions (batch N-blocks per streaming call), use full ZA tile width + (nr=16/64 across za0..za3), NEON-ize act packing + rescale. + +## 2026-06-09 [M4/CQ4] Full-tile nr=16 GEMM — SME2 BEATS NEON ✅ +- **Hypothesis:** the nr=4 GEMM wastes 12/16 ZA columns; gathering the four 4-channel `expanded` + panels into one contiguous 16-channel SMOPA panel (4× 16-byte memcpy) gives full-tile utilization + and 4× fewer SMOPA instructions. +- **Implementation:** `cactus_sme_cq_gemm_16x16_s8` (pm=svptrue_b8, full 16 cols) + a gather driver + (`cactus_quant_sme_gemm_tiles`, weight+norm gather amortized over M-blocks). +- **Result:** correctness preserved (16/16 matmul tests incl. CQ1-4, M/N tails). Crossover (cq4, + K=N=1024): M4 1.16×, M16 0.87×, M32 0.75×, M64 0.97×, M128 1.08×, M256 1.10× (up to 1.18× @256×1024×1024). + SME2 reliably beats tuned NEON SDOT for **M≥128**. +- **Decision:** auto-dispatch SME2 GEMM for **M≥128** (`cactus_quant_use_sme_gemm`); NEON for smaller M + and all GEMV. Numerically equivalent (same int32 SMOPA dot + same FP rescale as SDOT, MSE≤0.1). + Full clean rebuild + entire kernel suite green. **GOAL COMPLETE: working, validated, shipping SME2 + CQ4 path.** Follow-ups: cache `expanded_sme` at model-load (widen win to smaller M); multi-ZA-tile + nr=64; SVE2 Android leaf (svmmla) under QEMU. + +## 2026-06-09 [PRODUCTION GOAL ✅ COMPLETE] E2E A/B on interleaved bundles: decode +5.7%, TTFT -13.5% +- **Phase 3 (Gemma 4 E2B IT, 1068-tok ctx, 32 tokens, MTP off, 6 alternating cycles, cycle 1 + skipped):** decode 34.41 -> 36.38 tok/s (+5.7%, non-overlapping distributions); TTFT 4099 -> + 3547 ms (-13.5%); prefill 260.7 -> 301.1 tok/s (+15.5%); total -12.1%. Text coherent, temp-0 + output matches NEON. +- **Phase 2 verdict (honest):** kernel gate >=1.15x met decisively on K-heavy shapes (down 2.1-2.3x, + o_proj 1.9-2.2x), marginal on ffn (~1.08), structurally capped on lm_head (~1.10; both sides at + ~200 GB/s vs ~230 DRAM ceiling — the interleave already streams 4-bit for NEON, so byte-halving + is neutralized there). Pivot rule applied: documented the wall instead of grinding. +- **Shipped policy:** GEMV hybrid when (K>N and K*N>=3MB) or K*N>=6MB; GEMM fused for all M>1 with + cache; k_sme=4. All correctness gates green on BOTH weight formats (19 matmul tests, 7 suites, + bit-exact differential vs cactus_quant_4bit_gemv_interleaved, esme layout invariance). +- **New model insight:** production NEON-il collapses on K-heavy GEMV (64-190 GF; per-group loop x + few N-blocks) — that asymmetry, not byte-halving, is where SME2 buys real decode time on this + format. Remaining headroom: fp16 attention matmuls (untouched), ffn-shape SME-side L2-regime. + +## 2026-06-09 [INTERLEAVED re-gate] Production-format hybrid: K-heavy GEMVs 2.0-2.3x, policy reshaped +- **Fixture fixed:** SyntheticCQ interleaved-4row mode (encoder = exact inverse of the shipped + decoder); registry now gates BOTH formats; `esme_layout_invariance` proves the SME cache is + byte-identical from either source; differential vs cactus_quant_4bit_gemv_interleaved bit-exact. +- **Hybrid restored on interleaved:** extracted cactus_quant_interleaved4_gemv_blocks (post-preamble + core of the production kernel) as the NEON co-worker; k_sme default 4. +- **REAL baseline measured (6 alternating best-of runs, gemma shapes):** NEON-il is STRONG on + N-heavy shapes (ffn 287-363 GF; lm_head 715-810 GF ~ at the DRAM wall since the interleave already + streams 4-bit) but COLLAPSES on K-heavy GEMVs (down 6144x1536: 146-190 GF; o_proj 2048x1536: + 64-75 GF — per-group loop x few N-blocks starves it). +- **Hybrid results:** down 2.10-2.26x, o_proj 1.89-2.19x, ffn mean 1.075x (1.00-1.18), q_proj ~1.0x, + lm_head mean 1.10x (1.04-1.15) — lm_head is MODEL-CAPPED: both sides stream ~200 GB/s vs ~230 + ceiling, so >=1.15x is at/beyond the wall (goal pivot rule applied, documented not ground). +- **Auto policy (shipped):** hybrid when K>N and K*N>=3MB (the 2x regime) or K*N>=6MB; NEON below / + for small N-heavy. Projection over gemma's per-token byte mix: ~1.23x decode-matmul time. +- Phase 2 gate verdict: decisively exceeded on K-heavy shapes, marginal ffn, capped lm_head — the + honest aggregate is the byte-weighted ~1.23x, validated next by the Phase 3 E2E A/B. + +## 2026-06-09 [Phase2 ✅ DONE — GOAL COMPLETE] Fused LUTI4 SMOPA GEMM: 2.4–3.6× NEON at EVERY M +- **Design (from the queue model, predictions stated up front):** root causes of mid-M losses were + (1) worker starvation (old driver parallelized M-tiles only: M=32 -> 2 workers) and (2) the + per-call w_sme gather. New fused driver: ONE pool dispatch — phase A steals (M-tile, group) items + (Hadamard + int8 quantize + SMOPA act-pack [g][kg][16r][4K]), spin barrier, phase B steals + (M-tile x SB64) pairs. New leaf `cactus_sme_cq_gemm_luti4_s8` (-O1 TU): per (group, kg) ONE 64B + act load + 2 luti4_x2 + 4 SMOPA into za0..za3 (the four 16-ch sub-blocks) = 4096 MACs / 9 queue + ops (~4x the vg1x4 MAC density); per-group partials stored fire-and-forget; NEON rescale per pair. +- **Predictions vs actuals:** predicted M=32 >=600 GFLOPS (1.7x) / M=256 >=1800 (1.3x). + Actual (cq4, K=N=1024, forced backends): M=4 3.0-3.1x, M=16 2.6-3.4x, M=32 1109-1192 GFLOPS + (3.0-3.6x), M=64 1795-1877 (3.1-3.4x), M=128 2214-2343 (2.5-2.8x), M=256 2802-2997 (2.4-2.5x). + Production auto rows: cq4 1024^3 = 3603 GFLOPS, cq4 2048^3 = 5479 GFLOPS (session-start NEON + baseline: 1813 / 2280). All 55 tests green. +- **Auto-dispatch updated:** fused-cache GEMM for ALL M>1 when expanded_sme present + (cactus_quant_use_sme_gemm_fused); legacy gather path keeps M>=128 when cache absent. +- **Why actuals beat predictions:** pair-stealing fixed starvation more than modeled AND SMOPA's + queue-op density compounds with the single-dispatch budget (the Phase-1 lesson: count dispatches). + +## 2026-06-09 [Phase1 ✅ DONE] Fused-preamble hybrid: 1.41–1.96× (6/6 runs) at model shape +- **Change:** fused Hadamard+act-quantize into the hybrid's single pool dispatch (group-parallel + phase A, spin barrier, SB-steal phase B). Removed the standalone Hadamard parallel_for + serial + quantize + second dispatch. +- **Result:** model shape SME2 489–535 GFLOPS vs NEON 253–380 → **1.41–1.96×, all 6 alternating + runs ≥1.41×** (gate was ≥1.15). Total call ~135µs → ~79µs. All 55 tests green. +- **Model correction (prediction was +15-20µs, actual +56µs):** a pool dispatch round-trip costs + ~40-50µs (wake+sync of 14 workers), not ~12µs — explains the earlier pooltest anomaly too. Fusing + also pre-warms threads + act-buffer caches for phase B. LESSON: count DISPATCHES, not just µs of + preamble work; one dispatch per matmul call is the budget. +- Small shape (1x1024x1024) still NEON by policy (0.79–0.95 forced; fixed costs + L2-resident + weights). Threshold K*N>=6MB stands. +- **Phase 2 implication:** the GEMM driver parallelizes over M_blocks ONLY (M=32 → 2 workers!) — + worker starvation explains the mid-M losses at least as much as the per-call gather. Fix both: + steal over (M-tile × SB) pairs + LUTI4-packed leaf (no gather) + one fused dispatch. + +## 2026-06-09 [Phase1/GEMV] FINAL ARC: ladder of kernels → hybrid NEON+SME, model shape won at steady state +- **Kernel ladder (model shape 1x2304x9216, vs NEON SDOT GEMV best-of):** + mopa 4-col (M3): 0.25× → plain svdot: 0.70× → vg1x4 16-ch: 0.90× → vg1x4 64-ch (esme cache): 0.95× + → +dynamic work-steal: ~1.0× noisy → LUTI4 packed (halve bytes): 0.85-0.95 → z-acc (no ZA): 0.55× ✗ + → **partials-to-memory + NEON rescale (no ZA reads): 1.08-1.17×** → both-packed hybrid: steady ~313 vs + NEON warm ~280-310 / cold ~340-365. +- **Decisive experiments:** (1) lutibench: same dot stream w/o reads = 406 GMAC/s vs kernel 137 → + in-order-queue read-drain model (see gotchas). (2) loadbw: streaming loads FASTER than NEON + (97 vs 79 GB/s 1T) — bandwidth was never the limit; round-trips were. (3) NEON-packed GEMV = 252 + GFLOPS (vqtbl-limited, 63 GB/s) → hybrid NEON workers also read packed → call streams K*N/2 total. +- **Hybrid architecture (shipped):** one dynamic SB64 queue; workers 0..k_sme-1 run the SME LUTI4 + partials leaf + NEON rescale, the rest run cactus_quant_sdot_packed_blocks_rt (expand-from-packed + SDOT). k_sme env CACTUS_SME_GEMV_WORKERS (default 5). Auto-dispatch: K*N >= 6MB (below: fixed + costs dominate + weights fit L2 → NEON wins; goal's "keep NEON and log why" branch). +- **Thermal finding:** NEON fades 365→277 under sustained load; hybrid holds ~310-315. Sustained + decode (real workload) favors the hybrid; cold benches flatter NEON. +- **Open levers (next):** fuse preamble (Hadamard+quantize) into the hybrid's pool dispatch (saves a + round-trip + parallelizes quantize, ~+8-12%); 2-SB interleave per SME worker (deeper queue MLP); + svprfb prefetch. GEMM Phase 2: reuse esme_packed + LUTI4 in the SMOPA GEMM leaf (kills per-call + gather), apply partials-store model (it already stores), act-pack NEON-ization. + +## 2026-06-09 [Phase1/GEMV] svdot is streaming-legal on M4 — the right GEMV primitive +- **Probe:** `svdot_s32` + `svreinterpret_s8_u32(svdup_n_u32(a4))` broadcast-activation GEMV in an + `__arm_locally_streaming` fn, 16 channels, K=256 → maxerr=0, RAN. (working-examples/svdot_gemv16.cpp) +- **Why it matters:** SMOPA/mopa is fundamentally ~16× wasteful for GEMV (M=1 uses 1 of 16 ZA rows). + `svdot` uses all 16 int32 lanes = 16 channels/instr, all useful — 4× the lanes of NEON SDOT (128b). +- **Physics caveat:** GEMV is BANDWIDTH-BOUND on the int8 `expanded` weights — NEON model GEMV + (1×2304×9216) ≈ 307 GFLOPS reads ~21 MB in 0.138 ms ≈ 154 GB/s (near M4 practical BW). So svdot over + int8 will tie-to-slightly-beat NEON (≥1.0× achievable); a BIG win needs reading the 4-bit packed + weights (≈10.6 MB) with in-streaming `svtbl` codebook dequant (Phase-1.5 stretch). +- **Design:** svdot GEMV needs a CACHED 16-channel weight layout (per-call re-gather is fatal at M=1). + Add `expanded_sme`/`norm_sme` to CactusQuantMatrix (built once, like `expanded`); leaf reads it + directly, one streaming call per thread (super-block range), NEON rescale outside. + +## 2026-06-09 [PHASE 0] Decode decomposition (sample-profile, backend=1, 1024 ctx, real Gemma 4 E2B) +- **Top-of-stack samples (6s decode window):** cactus_quant_ORTHOGONAL_matmul workers 4938; + interleaved4_gemv_blocks 4868; build_sme_cache 1287 (transient, leaks into backend=1 — gate later); + attention_hybrid_int8_fp16_decode_dot only 345; embed-row dequants ~145; everything else <100. + __psynch_cvwait 61k => workers idle much of decode (serialization/dispatch headroom). +- **CORRECTION to the goal's premise:** CQ matmuls are ~85% of decode compute, not ~29%. The + "untouched 71%" was mostly the ORTHOGONAL path. Attention is negligible at 1k ctx (KV already + INT8-hybrid) — lever (a) killed by data. +- **Header scan:** 316 CQ4 mats interleaved (all layers); ONE orthogonal: token_embeddings + (262144x1536, gs=1536) = the tied LM_HEAD; one 'plain' per-layer-embed (row dequant only). +- **NEW #1 LEVER:** orthogonal lm_head GEMV ~40-45% of decode at ~half the per-byte speed of + NEON-il => SME hybrid at ~2x there predicts ~+25% decode tps. gs=1536 (num_groups=1), row-major + packed, rotation preamble instead of Hadamard — SME LUTI4 core applies; needs rotation phase A + + cache enabled for ORTH (currently gated off) + dispatch hook before the orthogonal early-return. + +## 2026-06-09 [GOAL: MULTIPLY E2E] lm_head SME path: decode +20.3% ✓ (gate +15%); TTFT -17.9% (floor -20%) +- **Phase 0 overturned the premise:** CQ matmuls ≈85% of decode compute (not 29%); attention tiny + (KV already INT8-hybrid). #1 term = ORTHOGONAL lm_head (token_embeddings 262144x1536, gs=1536, + fp16-codebook dot — no SDOT — ~2x slower per byte; ~45% of decode). +- **Lever shipped:** cactus_quant_orth_sme_gemv — exact VIRTUAL 12x128-group remap (norms constant + across subgroups; nibbles subgroup-contiguous) onto the standard hybrid; phase A = rotation + (a_scaled . rot_t rows, fp32 acc) + per-group int8 quant fused per work item; cache builds rot_t + + norms_rep + esme via the production builder. Gated by oracle test (matmul_cq4_orth[sme2], 20/20) + + temp-0 text + cactus_quant_sme_orth_enabled (backend honor). +- **Debug lesson:** TWO ops_nn orthogonal call sites + the bench binary statically links + libcactus.a — a stale link made the lever look dead for one full A/B (profile said + orthogonal_matmul still hot). ALWAYS relink the bench after lib changes; verify engagement by + symbol-level profile, not by ratios. +- **E2E (cycles 2-6):** decode 34.56 -> 41.57 tok/s (**+20.3%**, gate +15% ✓); TTFT 4079 -> 3351 ms + (-17.9%; floor -20% ✗ by ~88ms); prefill 261.9 -> 318.8 (+21.7%). +- **TTFT residual decomposition (measured/derived):** prefill 3.34s ≈ GEMM chunks (~0.8-1.2s, 8x + M=128) + **44-token scalar tail run as M=1 GEMVs (~0.8s — chunk=128 fixed, tail-padding disabled + for the sliding-window-cache bugfix)** + attention/norms/cache-copies (~1-1.4s). Next levers, in + order: (1) batch the tail as ONE M=44 GEMM call (NOT padded — padding was the old bug; an unpadded + M=44 pass is the fused GEMM's native case), (2) NEON-ize the GEMM act-pack scalar loops, + (3) prefill-side k_sme/GEMM retune on interleaved fixtures, (4) attention prefill (fp16/int8-KV + GEMM — SMOPA fp16 widening verified available). +- Profiles: /tmp/decode_profile_d.txt (engaged), /tmp/prefill_profile2.txt (prefill GEMV evidence). + +## 2026-06-09 [GOAL v2 checkpoint] act-pack A/B done: decode +22.0%, TTFT -18.6% (floors: +20% ✓ / -20% ✗ by ~57ms) +- **Act-pack NEON-ization measured (cycles 2-6, ex outlier c3):** decode 34.61 -> 42.24 (+22.0%); + TTFT 4065.6 -> 3310.1 (-18.6%). Lever delivered only ~20ms vs ~100ms predicted — MODEL + CORRECTION: clang already auto-vectorized the fixed-size 4B memcpy pack loops adequately; scalar + pack was NOT the prefill term. (Change kept: correct, marginally positive, 20/20 green.) +- **Gate status:** decode floor +20% MET (target +25% open). TTFT floor -20% MISSED by ~57ms. +- **NEXT LEVER (highest-confidence, precisely specified):** batch the 44-token scalar prefill tail + as ONE UNPADDED M=44 pass. Evidence: prefill window sample (prefill_profile2.txt) is full of M=1 + GEMV symbols; chunk=128 fixed (engine.h get_prefill_chunk_size); 1068 = 8x128 + 44 tail; + last_prefill_scalar_tail_tokens counter exists in engine.h. Tail at B-decode-step rate ~14ms x 44 + ~= 0.6s of 3.3s prefill -> batching to one M=44 GEMM (~50ms) saves ~0.5s -> TTFT ~-31%, clearing + BOTH floor and target. CAUTION: tail-PADDING was the old sliding-window-cache bug + (project_gemma_chunked_prefill_mask_bug) — implement UNPADDED M=44 (graphs may be fixed-shape at + 128: check model.cpp run_chunked_prefill ~line 1409 + decoder_prefill_chunk component; if graph + shapes are static, investigate variable-M execution or a second tail-size graph). Verify KV/mask + semantics vs the scalar path bit-for-bit + temp-0 text before any perf claim. +- Then decode +25%: ffn-shape SME L2-regime probe, lm_head co-worker split retune, eager cache + build at load (gate off under backend=1). + +## 2026-06-09 [GOAL v2 PIVOT — wall math documented] M=44 tail batch is GRAPH-structural, not kernel-level +- run_chunked_prefill (model.cpp:917): `effective_chunk` is FORCED to component_tokens (line ~930) + — the decoder_prefill graph is FIXED-SHAPE at 128 tokens. An unpadded M=44 pass requires a new + variable-shape (or tail-sized) graph component — engine/transpiler scope, not kernels. +- `pad_tail` exists but is (correctly) disabled for sliding-window models per + project_gemma_chunked_prefill_mask_bug (padding shifted the sliding-window KV cache). Gemma 4 has + sliding windows -> 44 scalar tail tokens are the production-correct behavior today. +- **Wall math:** tail = ~0.6s of B's 3.31s TTFT; only a tail-sized graph component (e.g. emit + decoder_prefill_chunk variants at 16/32/64, pick the largest fitting) recovers it -> projected + TTFT ~-31%. That is the highest-value remaining TTFT lever and needs transpiler/engine work. +- **Gate status at session end:** decode +22.0% (floor +20% MET; target +25% open — next: ffn-shape + SME L2-regime probe, lm_head co-worker retune). TTFT -18.6% (floor -20% open by ~57ms; blocked on + the graph-level tail lever; act-pack lever measured ~20ms, model corrected). +- All correctness green at checkpoint: 20/20 matmul tests, 7 suites, both production formats, + temp-0 text verified. Session cumulative: decode 34.6->42.2 tok/s, TTFT 4.07->3.31s. + +## 2026-06-09 [GOAL v2 FINAL] Cluster-spread probe + k=6 A/B: gate met per protocol (with documented outlier caveat) +- **Probe (ffn shape, leaf-only, L2-resident):** 1 SME worker = 125 GMAC/s (~9.6 NEON cores' worth); + totals plateau ~165-180 at nt=2-4 (ONE cluster queue saturated) then JUMP to 352 at nt=6 — macOS + spreads workers onto the second P-cluster only at higher worker counts. MODEL ADDITION: k_sme + must be large enough to statistically engage BOTH cluster queues (k=4 often single-cluster-bound). +- **k=6 full 6-cycle A/B (skip c1):** per protocol-as-written: decode 34.17 -> 42.75 (+25.1% >= +25 + TARGET MET); TTFT 4558 -> 3417 (-25.0% <= -25 TARGET MET). CAVEAT: cycle-3 A-side is a system + outlier (A prefill 163 tok/s, TTFT 6543ms); excluding it: decode +24.7% (floor met, target + borderline), TTFT -16.4% (k=6 trades ~85ms TTFT for ~+0.7 tok/s decode vs k=4's +22.0%/-18.6%). +- **Disposition:** k=4 default kept (better TTFT); k=6 documented as decode-optimal via + CACTUS_SME_GEMV_WORKERS. Neither k dominates both metrics; the dominating levers remain the + transpiler tail components (TTFT ~-31% projected) and the ffn redesign now informed by the + cluster-spread finding (e.g., per-shape k, or SME-worker count keyed to cluster topology). +- Session-cumulative validated: decode +22-25%, TTFT -16 to -19% (clean), prefill +22%, all + correctness green on both production formats. + +## 2026-06-09 [GOAL v2 CLOSE] k-space exhausted with clean data; TTFT floor unreachable by worker-split tuning +- Clean (outlier-free) 6-cycle A/Bs: k=4: decode +22.0% / TTFT -18.6%; k=6: +24.7% / -16.4%; + shape-aware (K-heavy 6 / N-heavy 3): +21.1% / -17.5% (prediction missed — reverted to k=4). +- VERDICT: decode floor +20% robustly met (best clean +24.7% at k=6); TTFT floor -20% is NOT + reachable by any worker split — the missing ~60-90ms is structural (44-token scalar tail = + fixed-shape 128-token prefill graph; sliding-window padding correctly forbidden). The two + goal-closing levers are engine/transpiler scope (tail-sized graph components, projected TTFT + ~-31%) and the cluster-aware ffn redesign — both fully specified above for the next session. +- Shipped config: k=4 default, all dispatch policies as measured; 20/20 matmul + 7 suites green. + +## 2026-06-09 [GOAL v2 — final term-by-term residual, clean prefill profile (B, measured rep)] +- Prefill (3.34s) busy-sample shares: SCALAR TAIL GEMVs ~33% (interleaved4_gemv 507 + gemv_luti4 + 458 + sdot_packed 255 + shells) ~= 1.0-1.1s; GEMM chunks ~30% (gemm_luti4 607 + shells + + hadamard 187); attention_hybrid prefill ~9% (~300ms); orth embed rows ~8%; rest dispatch/misc. +- TTFT gap closure paths, quantified: tail-sized transpiler graph components: -0.9s+ (TTFT ~-31%, + clears target); SME prefill attention at ~2x: ~-150ms (TTFT ~-22%, clears floor) — lever 4 is + now PROVEN binding-enough and is the largest KERNEL-scope TTFT lever remaining; GEMM headroom + ~30% share at already-2.5x = small. +- Decode +25% target path unchanged: ffn redesign with the cluster-spread constraint. +- This entry completes the gate's "residual gap explained term-by-term" clause with clean data. + +## 2026-06-09 [SME prefill attention — primitives validated, design fixed] +- **QK SMOPA tile PROVEN (max_err=0):** 16 q-rows x 64 kv x head_dim, K int8 (per-32-group scales), + Q int8-quantized per-32-group (matching KV group structure). Layout = the proven GEMM + conventions (zn=[16q][4dim] pack; zm=4x[16kv][4dim] panels -> za0..3 = kv quartiles); per + quant-group: 8 dim-group SMOPAs then rescale by qs[r][g]*ks[c][g] on readout. (/tmp/qkprobe.cpp) +- **AV SMOPA tile PROVEN (max_err=0, unit scales)** with the critical constraint found BEFORE + integration: per-(kv,dim-group) V scales cannot factor out of the kv-contraction. DESIGN: fold + v_scale into the P operand — per 64-dim block, quantize P'[r][c] = P[r][c]*vs[c][g] once per + 32-dim scale-group (2 variants/block, 16x64 quantize each = cheap). (/tmp/avprobe.cpp) +- **Incumbent structure (attention_hybrid.cpp:329):** per-QUERY-ROW work items, flash-style online + softmax over 32-kv blocks, fp16 Q x dequant-int8 K; keys_new/values_new segments are fp16 + (current chunk, not yet quantized). Integration plan: NEW gated kernel for the cached-int8 + segment (16-row tiles, SME QK + folded-scale SME AV, per-row flash state), incumbent continues + over keys_new from the merged state; gate: head_dim%32==0, qg==32, v_head_dim==head_dim. + Cached-segment share of prefill QK work ~78%. +- Predicted TTFT effect: attention ~300ms (9% share); SME cached QK+AV at ~2x -> -90 to -150ms -> + TTFT ~-21 to -22.3% (clears -20% floor; -25% target additionally needs GEMM push or tail). + +## 2026-06-09 [SME prefill attention — SHIPPED to kernel level, iterated 2.44x -> 4.6x] +- **v1 (per-qgroup QK readout, s8 global-scale P):** correct (oracle diff test green) but + rel_err 2.1% (vs NEON fp16 0.12%) and only 2.44x/1.49x (global/sliding) at gemma shape + (c=896,s=128,8q/4kv,hd=256). Profile: 57% of busy time stalled in qk leaf. +- **Accuracy fix (measured 2.1% -> 0.55%):** (a) USMOPA u8 P (svusmopa_za32_u8_m PROBED exact on + M4, /tmp/usmopa_probe.cpp — softmax P >= 0, sign bit was wasted); (b) PER-BLOCK P scales + (ps = max_c(P*vs)/255 per 64-kv block) with per-block ZA readout — global row scales waste + resolution on blocks far below the row max. +- **QK redesign (the deep-think win):** per-qgroup readout was STORE-dominated (512 ZA stores vs + 256 SMOPA per block; 32KB partials round-trip per tile-block = ~29MB/layer-call). Fix = FLAT + scales: Q one scale/row; K REQUANTIZED at pre-pack to one scale/kv (ksflat=max_g, ratios folded + into int8, <=1 bit loss) -> ZA accumulates the whole head_dim, ONE 4KB readout per block, one + streaming entry per tile (qk_seg). 409 MACs/queue-op (GEMM regime). qk leaf samples 430 -> 14. + Accuracy cost only ~0.1% (rel 0.55 -> 0.6-0.72%). expf -> fast_exp_f32x4 (threading.h) too. +- **Load-balance fix (1.39ms -> 0.81ms):** parallel_for's splitter FLOORS work_per_thread and + dumps the remainder on the LAST worker (64 tiles/14 workers = 13x4 + 1x12 -> ~60% pool idle, + measured via cv-wait samples). Fix = strided round-robin (slot, slot+nw, ...) for both the tile + loop and pre-pack. GOTCHA for ALL coarse-item parallel_for uses. +- **Kernel A/B (test_attention bench, gemma shape):** global 3.76 -> 0.81ms (4.6x); sliding-512 + 2.23 -> 0.83ms (2.7x). Differential+oracle test: 4 cases (global mid-prefill, rolled sliding + with sink, offset-0 window, partial tile/block hd=128 B=2) rel_sme 0.60-0.72% vs gate 1%. +- NEXT: full suite + E2E (predict TTFT -22 to -23.5% from ~200ms attention savings on B=3.31s). + +## 2026-06-09 [SME prefill attention — E2E VALIDATED, TTFT floor -20% CLEARED] +- **6-cycle alternating A/B (gemma-4-e2b-it, 1068-tok prompt, 32 tok, temp 0, MTP off):** + clean cycles 2,3,6 (c4-B and c5-A were MUTUAL outliers — both sides collapsed to ~190 prefill + tps in adjacent runs = system interference, c6 fully recovered): + prefill 263.7 -> 345.2 tok/s (+30.9%); decode 34.66 -> 41.59 (+20.0%); TTFT 4050 -> 3094 ms + (**-23.6%**). Protocol-as-written incl. outliers: +18.5% decode / -11.0% TTFT. +- vs prior session clean state (decode +22.0%, TTFT -18.6%): attention lever delivered ~-215ms + TTFT (predicted -150 to -200ms) -> **TTFT floor -20% is now MET** (was structurally blocked at + worker-split level). Decode unchanged as designed (decode uses the seq==1 fast path). +- Correctness at ship: 60/60 kernel tests (7 suites), differential+oracle attention test 4 cases + rel 0.60-0.72%, E2E temp-0 text COHERENT but NOT bit-identical to NEON (expected: attention path + now quantizes Q/K int8 + P u8; both texts correct readings of the prompt; escape hatch + CACTUS_SME_ATTENTION=0). Engagement profile-verified (attn_sme_prefill + both leaves hot). +- **Gate status:** decode +20.0-22% (floor +20% met; +25% target open — ffn redesign lever). + TTFT -23.6% (floor -20% MET; -25% target ~1.4pt short — remaining levers: transpiler tail-sized + prefill graphs (~-31% projected), attention NEON-side passes (ppack quantize fusion, one fused + dispatch instead of prepack+main, new-segment SME)). + +## 2026-06-09 [GOAL v3 — wall-time decomposition found the REAL #2 term: serial scalar orth-embedding un-rotation] +- Busy-sample shares had MISLED the TTFT decomposition: phase timers (CACTUS_SME_PROF, new) put + the fused GEMM at only ~320ms WALL per prefill (phaseA ~97ms / phaseB ~227ms core/14) — not the + ~1s the 30%-of-busy-samples figure implied. Phase B core-time 3.2s over 14 workers also shows + ~11 workers stalled issuing into the saturated queue (co-worker fuel, lever still open). +- CACTUS_PROFILE_FILE per-op wall profile (built into CactusGraph::execute!) then showed: + EMBEDDING ~2.7ms x ~38 calls/chunk — Gemma 4 E2B per-layer embeddings in ORTHOGONAL CQ4 dequant + through cactus_quant_dequantize_orthogonal_embedding_row = a SERIAL SCALAR K^2 (1536^2 = 2.4M + scalar FMA) un-rotation matvec PER UNIQUE TOKEN, single-threaded inside the graph op. ~0.7-1.0s + per prefill. Also found: first-call lazy SME cache builds = ~5.3s (chunk graph) + ~3.2s (decode + graph) single-threaded in warmup — eager-parallel build at load still queued. +- **Fix (portable NEON, not backend-gated):** new cactus_quant_dequantize_orthogonal_embedding_rows + — dedup indices, fold norm into fp32 dq, 8-wide fp16->fp32 NEON dot, parallel_for over + (row, 64-ch) blocks; same fp32-accumulate math (differential test orth_embed_rows_batched, both + packed layouts, 21/21 green). compute_embedding_node batches via it. +- **Single-run E2E after fix:** B: prefill 459 tok/s, decode 44.41, TTFT 2325ms; A (also gains — + shared term): prefill 337, decode 35.36, TTFT 3165ms. Ratios: TTFT -26.5%, decode +25.6% — + BOTH targets met single-run (removing a shared absolute term GROWS the relative gap; 6-cycle + A/B running). Temp-0 text identical A vs B and vs pre-change NEON. +- Portability (user requirement): cpu_has_sme2() now ALSO gates on cactus_sme2_svl_bytes()==64 — + all layouts are SVL-512-pinned; other-SVL SME2 devices (e.g. Samsung S26 class) fall back to + NEON for correctness. Embedding fix is plain NEON/aarch64-portable. + +## 2026-06-09 [GOAL v3 — decode push: wait_all cv-sleep was taxing EVERY hybrid GEMV 20-30%] +- Steady-decode sample: ~88% of busy time inside the hybrid GEMV (luti4 1201 + sdot 784 of ~2250 + busy samples); everything else already small (lm_head 1.18ms post-SME — the old "45% of decode" + share is STALE; attention decode_dot 2.97ms; gelu fine; embedding now 13 samples). +- In-engine vs bench gap (gate_up 90us vs 64us/call) -> dispatch suspect. k=6 kernel-checked: + neutral-to-harmful (o_proj -9%) — dropped for good. +- **Fix: main-participates + spin-join** in gemv_fused + orth drivers: enqueue nt-1 workers, main + runs SME worker 0 (zero wake latency, earliest SME issue), spin on own done counter instead of + pool.wait_all() cv sleep. Kernel A/B (best-of-5x30, same alternating bench): + o_proj 149->194 GF (3.18x vs NEON), down 386->462 (2.78x), gate_up 588->707 (1.62x), + ffn 350->428 (1.51x), q_proj 145->192 (1.41x). 21/21 tests green (same integer compute). +- **Policy update (re-measured):** N-heavy threshold 6M -> 3M elements — q_proj-class (1536x2048) + now dispatches to the hybrid at 1.41x. Single K*N >= 3M-element rule for all shapes. +- GOTCHA addition: pool.wait_all() costs a main-thread cv wake per call (~5-15us wall) AND wastes + main as a worker — for sub-100us kernels this is 20-30% of the call. Predicted decode step + -2.5ms (B ~44 -> ~48-49 tok/s); 6-cycle A/B running. Single-runs unreliable now (machine + thermally saturated after hours of benching; alternating protocol handles it). + +## 2026-06-09 [GOAL v3 COMPLETE — both gates MET: decode +34.0%, TTFT -34.1%] +- **6-cycle alternating A/B (protocol, c2-c6):** decode 35.79 -> 47.98 tok/s (**+34.0%**, worst + cycle +28.6% — every cycle clears the +25% target); TTFT 3413 -> 2249 ms (**-34.1%**); prefill + 319.5 -> 477.4 (+49.4%). Conservative read dropping the two anomalous cycles (c2 A-side 4495ms + outlier, c5 B-side 2504ms soft): decode +35.6%, TTFT -31.0% — both targets met either way. +- Session arc to the gate (each step correctness-gated, 61/61 tests + temp-0 coherent text): + (1) SME prefill attention (flat-scale QK seg + u8 USMOPA AV, per-block P scales) — kernel + 4.6x/2.7x, TTFT -18.6% -> -23.6%; + (2) batched+parallel orthogonal embedding un-rotation (was serial scalar K^2 per unique token, + per layer per chunk — found via CACTUS_PROFILE_FILE wall decomposition after busy-sample + shares misled) — TTFT -> -29.7%, helps BOTH backends; + (3) wait_all->spin-join + main-as-SME-worker-0 in the hybrid GEMV drivers + N-heavy policy + 3M elements (q_proj joins at 1.41x) — decode +21.6% -> +34.0%, TTFT -> -34.1%. +- Portability hardened per user requirement: cpu_has_sme2() gates on streaming svcntb()==64 + (layouts are SVL-512-pinned; other SME2 SVLs fall back to NEON); feature detection dual-path + (Apple sysctl / Android HWCAP2); embedding + dispatch fixes are plain portable NEON/pthreads. +- Remaining documented headroom (not needed for the gate): eager-parallel SME cache build at load + (~8.5s of first-call lazy builds), GEMM-phase spin-join + NEON co-workers, transpiler tail-sized + prefill components (44 x ~20ms scalar tail still present), SME decode attention (2.97ms/step, + grows with ctx). + +## 2026-06-09 [IL4ROW orthogonal lm_head — SME path on the PRODUCTION format; row-major orth retired] +- CONTEXT: token_embeddings bundles have three vintages (INT8/FP16 -> CQ4 ORTH row-major -> + CQ4 ORTH+IL4ROW). Row-major orth was a TRANSPILER BUG; IL4ROW orth is the intended production + format (all bundles from 2026-05-27 on). gemma-4-e2b-it was recompiled to IL4ROW at 16:29 on + 2026-06-09 (after the day's A/Bs); ensure_sme_cache's old gate refused IL orth, silently + dropping the SME lm_head path on the new bundle. +- **KEY EQUIVALENCE (proven by panel math + empirically by the oracle test):** the virtual + 128-wide group regrouping is byte-exact on INTERLEAVED_4ROW too — IL panel bytes are + k-chunk-ordered, so the gs=128 re-view lands on contiguous 256-byte sub-panels at + (nb*vng+g)*256 == physical nb*2K + g*256. Same trick as row-major, zero repacking. +- Changes: ensure_sme_cache now REQUIRES IL4ROW for orth (gate flipped), keeps the IL flag on the + virtual view, and replicates norms in the interleaved [(nb*ng+g)*4+ni] layout; ops_nn W2 keeps + flags=IL4ROW (both dispatch sites); orth driver NEON co-workers switched from the row-major + sdot reader to cactus_quant_interleaved4_gemv_blocks (consumes W2's virtual view natively). +- Row-major orth support REMOVED per project decision: batched embedding rows fn is IL-only + (ops_tensor routes legacy row-major orth to the per-row fallback); test_orth_sme converted to + an IL fixture (encoder = exact inverse of the shipped panel decoder); orth_embed_rows test + IL-only. Legacy row-major bundles (gemma-4-e2b-16k, qwen3-vl-2b, lfm2.5-vl) still LOAD via + incumbent NEON paths but get no SME lm_head / batched embeddings — recompile them. +- Validation: 61/61 tests; engagement on the recompiled bundle restored (orth_sme_gemv hot, + orthogonal_interleaved_lmhead 0 samples); temp-0 text IDENTICAL across backends; lm_head op + isolated via CACTUS_PROFILE_FILE: SME-IL min 0.98ms vs NEON-IL incumbent min 1.20ms (-19%), + means within thermal noise (machine saturated); DRAM floor ~0.77ms (192MB nibbles/call) — the + lm_head is bandwidth-capped, SME min sits ~25% above floor. + +## 2026-06-10 [SINGLE RUNTIME WEIGHT FORMAT — esme serves NEON too; -1.2GB physical footprint] +- PROBLEM (user): demand paging keeps the packed .weights file bytes resident (NEON co-workers + + small-shape GEMVs streamed them every token) ON TOP of the materialized esme cache -> ~2x + weight RAM. +- **Fix 1 — NEON-over-esme kernel** (cactus_quant_esme_gemv_blocks): esme nibbles expand (lo + nibble first) to the classic SDOT panel order [4 vec][16 ch][4 K]; and/shr/2xtbl/2xzip/2xdot + per 16 B (9 ops vs the file kernel's 7 — the zips are structural: LUTI4's identity nibble + mapping pins the order, and planar nibble packing would move the zips into the SME queue). + 16 independent accumulators recover the ILP. NOTE: a vqtbl2q "mask-free" variant is INVALID + (tbl2 indexes only 0..31; raw bytes reach 255) — caught by the test suite. +- **Fix 2 — single dispatch**: use_sme_gemv_svdot is now cache-present => fused driver always; + k_sme sized in-driver (>=3M elements -> min(env,nt-1), below -> 0 = pure esme-NEON). Hybrid + + orth co-workers consume esme. M>1 fused GEMM already esme-only. Kernel A/B: parity-or-better + everywhere incl. pure-NEON small shape (kv_proj 1536x512: esme-NEON 71.6 GF vs file 55.6 = + 1.29x); hybrid ratios unchanged within thermal noise (o_proj 3.17x, down 2.93x, gate_up 1.51x). +- **Fix 3 — page release**: BufferDesc::mmap_backed (set by io.cpp loaders) + + cactus_quant_release_packed_pages (madvise DONTNEED, page-aligned interior of the packed + region) after cache build. backend=1 skips builds entirely (cactus_quant_sme_enabled) — the + A/B baseline stays pure file-kernel and refaults work. +- **Fix 4 — GOTCHA: per-component cache duplication.** Every graph component (prefill chunk, + decode step, ...) maps the same weight FILE separately -> distinct BufferDescs AND distinct + mmap addresses; per-desc caches built the entire esme cache PER COMPONENT (measured 2x, 554 + builds for 277 weights) and the 2nd build re-faulted just-released pages. Fix: process-wide + weak_ptr registry keyed by BufferDesc::weight_key = FNV-1a of the resolved weight path (data + pointers do NOT identify a weight across components). 554 -> 277 builds/releases. +- **Measured (gemma-4-e2b-it, backend 0): physical footprint at the same late-decode moment + 4.9 GB -> 3.7 GB (-1.2 GB ~= released packed pages ~0.65 GB + deduped cache copy ~0.55 GB); + max RSS 5992 -> 5018 MB.** Perf parity: alternating old-vs-new binary decode 48.9 vs 49.2 + tok/s (excl. one bimodal outlier), TTFT slightly better; 61/61 tests; temp-0 coherent. +- CACTUS_RAM_DEBUG=1 logs each release. Build-transient peak remains (w_il int8 temp in the + builder, lm_head ~400 MB) — streaming the cache build is the noted follow-up. + +## 2026-06-10 [SAME-FORMAT NEON BASELINE — SME GEMV retired from auto; best E2E of the campaign] +- User asked for results vs NEON ON THE SAME FORMAT (esme-NEON had already beaten the file + kernels). Added cactus_quant_set_sme_gemv_workers(); three-way kernel bench (file-NEON vs + esme-NEON vs hybrid, alternating best-of) + three-way E2E. +- **Kernel three-way (gemma shapes):** esme-NEON crushes file-NEON everywhere — o_proj 57-67 -> + ~200-235 GF (the "NEON collapses on K-heavy" gotcha was a FILE-LAYOUT artifact, NOT a NEON + limit); vs esme-NEON the hybrid keeps only down 1.1-1.3x, ffn/gate_up/q_proj ~1.0-1.2x, and + LOSES on o_proj (0.82-0.94x, 3 consistent runs) and lm_head (~0.97x). +- **E2E three-way (c2-c4 means, 1068-tok prompt):** file-NEON 42.0 tok/s / TTFT 2902; + same-format NEON (k=0, attention NEON) 51.4 / 2192; full-SME-GEMV config 44.8 / 2342. + PURE NEON BEATS THE SME-GEMV HYBRID E2E BY ~13% DECODE despite bursty kernel wins — the + queue-limited GEMV leaf (~64-85 MACs/queue-op nibble streaming) nets negative under sustained + load once NEON runs on the good layout. SME's true wins all along: the LAYOUT, the fused M>1 + GEMM (2.4-3.6x), and prefill attention. +- **Config C (shipped default): NEON GEMVs over esme + SME fused GEMM + SME prefill attention: + decode ~50-52 tok/s, TTFT ~2010-2230 — vs legacy file-NEON: decode +19%, TTFT -27.5%** (hot + machine; every config measured in the same alternating windows). C beats every config measured + this campaign (prev best: 47.98 / 2172). +- Shipped: CACTUS_SME_GEMV_WORKERS default 0 (auto = pure esme-NEON M=1, incl. the orth lm_head + driver); env/setter re-enables for experiments/other silicon; backend 2 forces >= 1 worker so + the [sme2] correctness tests keep covering the leaf (test_orth_sme pins 4 explicitly). +- GOTCHA CORRECTED: "NEON-il collapses on K-heavy GEMV" -> file-layout artifact; superseded by + the esme-NEON numbers above. + +## 2026-06-10 [MOBILE THREAD BUDGET RESTORED — budget beats saturation; SME GEMV par-in-budget] +- USER CONTEXT: the legacy kernels' low thread counts were DELIBERATE mobile/battery policy, not + a bug. Restored the original budgets in the fused drivers: GEMV nt = ceil(N/256) (kv 2, + o_proj/down 6, gate_up/lm_head pool-capped — the exact legacy formula), GEMM nt = M-tiles, + embedding lookups ~3 threads at decode. SME workers live INSIDE the budget (replace NEON + workers, never add threads). Latency fixes that add no threads stay (fused dispatch, parallel + phase A on the same woken set, spin-join). +- **Budget BEATS saturation E2E:** decode 52.6-52.7 tok/s + TTFT ~1990ms at ORIGINAL thread + counts vs ~50-51 / 2100-2200 for the all-cores configs — and the evening's bimodal run + collapses vanished (over-saturation was causing them). Battery policy costs nothing on M4; + it pays. +- **k-sweep at budgeted threads (kernel, best-of alternating):** k=2 SME workers win K-heavy + (o_proj 216->260 GF +20%, down 324->399 +23%) and gate_up (+11%); par on ffn/q_proj; lose on + tiny shapes (kv nt=2: per-call SME overhead) and DRAM-bound lm_head. Auto policy shipped: + k = 2 when nt >= 4 && N < 65536, else 0; lm_head/orth driver 0; CACTUS_SME_GEMV_WORKERS or + setter overrides; backend 2 clamps >= 1 for leaf test coverage. +- **E2E at matched budget: budget-SME-auto vs budget-NEON = decode +0.2%, TTFT +0.2% (par).** + The per-shape kernel wins wash out E2E on M4 Pro. Honest decode position: the FORMAT + driver + are the decode win (+29.7% vs legacy at the same thread counts); the SME GEMV leaf is + kernel-positive on K-heavy but E2E-neutral here. SME's meaningful decode levers: M>1 decode + via MTP drafting (fused GEMM 2.4-3.6x applies directly), long-context decode attention + (~3ms/step at 1k ctx and growing), and re-tuning k on phone SoCs where per-core NEON is + weaker relative to the SME unit. +- **Final shipped numbers vs legacy production (same thread budget): decode 40.6 -> 52.7 + (+29.7%), TTFT 2875 -> 1993 (-30.7%), prefill 371 -> 536 (+44%), physical footprint -1.2 GB.** + 61/61 tests; temp-0 coherent. + +## 2026-06-10 [POWER/SPEED FRONTIER — flat k=2 SME at HALF the thread budget is Pareto-optimal] +- Measured with powermetrics (sudo grant), decode-only 6s window, 420-token decodes, 2-pass + thermal pairing, grid = CACTUS_GEMV_SB_PER_THREAD {2,4,8} x CACTUS_SME_GEMV_WORKERS {0,1,2}: + spt4 k0 (legacy budget, no SME): 48.7 tok/s @ 21.0 W = 430 mJ/tok + spt4 k1: 50.0 tok/s @ 18.3 W = 367 mJ/tok (speed point) + spt8 k2 (half budget, 2 SME): 49.1 tok/s @ 16.1 W = 328 mJ/tok (SHIPPED default) +- **SME workers strictly dominate k=0 at every thread budget — faster AND lower power.** The + "E2E-neutral" verdict from tok/s-only measurement was incomplete: the SME GEMV leaf's value is + on the POWER axis (replacing NEON-saturated cores with one queue-feeding core + the matrix + block). Flat k=2 also beat the per-shape gating policy (removed). +- Shipped defaults: sb_per_thread 8 (half the legacy ceil(N/256) budget), flat k=min(2,nt-1) + everywhere incl. the orth lm_head. vs the legacy budget WITHOUT SME: +0.8% speed, -23% power, + -24% energy/token. Verification runs at defaults: 49.0-50.6 tok/s, TTFT ~2000ms, 14.8-15.8 W; + 61/61 tests; temp-0 coherent. Knobs stay for retuning (CACTUS_GEMV_SB_PER_THREAD, + CACTUS_SME_GEMV_WORKERS / setter). +- Galaxy S26 (SM-S942U1, "canoe") recon via adb: CPU exposes sve/sve2/svei8mm + SME1 + (smei8i32/smef16f32/...) but NO sme2 -> our gates correctly fall back to esme-NEON there; the + designed-but-unbuilt SVE2 path is the phone's SME-class lever. PR #698's tests/android-e2e + harness is the on-device runner for the thread-budget frontier validation (NEON-only). diff --git a/cactus-kernels/docs/sme/gotchas.md b/cactus-kernels/docs/sme/gotchas.md new file mode 100644 index 000000000..608c233e2 --- /dev/null +++ b/cactus-kernels/docs/sme/gotchas.md @@ -0,0 +1,187 @@ +# Gotchas & Constraints + +> Hard-won traps. Read before writing any SME/SVE code. + +## Streaming mode (PSTATE.SM) +- Inside an `__arm_streaming` function the vector length is the **streaming VL (SVL)**, NOT NEON's + 128-bit. NEON Advanced-SIMD intrinsics are largely **illegal/UB in streaming mode** — do not call + `vdotq_*`, `vfmaq_*`, etc. inside a streaming function. +- Entering/leaving streaming mode (`smstart`/`smstop`) **zeroes the SVE Z/P registers** and is not + free. Batch a whole N-panel of work per streaming call; never one column at a time. +- Keep the Hadamard transform, INT8 quantization, codebook handling, and final rescale in the + **non-streaming** NEON caller; the streaming kernel does ONLY the MOPA accumulate. + +## ZA state +- `__arm_new("za")` makes the function own ZA (auto smstart/smstop). Keep ZA lifetime within one + function — don't pass live ZA across calls unless using `__arm_in/out/inout("za")` deliberately. +- ZA tile count is inverse to element size: 32-bit → 4 tiles (za0.s..za3.s); 8-bit → 1 tile worth of + slices. Plan tiling around `za32` (FP32 accumulators) because `FEAT_SME_F16F16=0` here. +- macOS xnu auto-saves/restores ZA across context switches; no entitlement needed. **Do NOT use SME in + signal handlers** (corrupts interrupted thread's ZA). + +## Apple Silicon specifics +- Apple exposes **SME/SME2 but not SVE/SVE2** to userspace → the SVE2 kernel path can't be natively + run/tested here; validate it under QEMU / on Android. SME2 is the locally-testable path. +- `-march` string is contested between sources: probe says `-march=armv9-a+sme2` compiles on Apple + clang 17; some sources use `-march=armv8-a+sme`. RESOLUTION: pin whatever compiles AND emits the + expected `smstart`/`mopa` asm; keep per-source in CMake. (Verify in M0.) +- E-cores are ~5× slower at MOPA than P-cores → pin SME work to P-cores (existing affinity hooks). + +## Toolchain / build +- Confine SME to its own TU compiled with `+sme2`; do NOT add `+sme2` to the library-wide flag, or the + optimizer may emit streaming/SME code into NEON paths and the base lib stops running on non-SME HW. +- Double-guard each SME TU: `#if defined(__ARM_FEATURE_SME2)` real kernel / `#else` NEON-fallback stub, + so older toolchains still build and link. +- ACLE attribute syntax drifted: keyword form `__arm_streaming` / `__arm_new("za")` (post-2024 Q1) vs + older `__attribute__((arm_streaming))`. Use the keyword form on clang 17. + +## Numerics +- All MOPA accumulates into FP32 here (no F16F16) — this is *good* for the MSE-0.1 gate; matches the + existing NEON CQ path which also accumulates INT8 dots in FP32. Watch FP16 input rounding only. + +## NEW TRAPS — empirical verification 2026-06-09 (Apple M4 Pro, clang 17) + +### Attribute POSITION matters: streaming/ZA-io attrs are TYPE attrs (go AFTER the param list) +- `__arm_streaming`, `__arm_streaming_compatible`, `__arm_in/out/inout/preserves("za")` are + **type attributes** — they must appear in the TYPE position, AFTER the parameter list: + `void f(args) __arm_streaming __arm_inout("za") { ... }`. +- `__arm_new("za")` and `__arm_locally_streaming` are **declaration attributes** — they go in the + PREFIX position, BEFORE the function: `__arm_new("za") __arm_locally_streaming void f(args) {...}`. +- Putting `__arm_streaming` in the prefix position is a HARD ERROR on clang 17: + `error: '__arm_streaming' cannot be applied to a declaration`. `[compiled-fail then fixed]` +- A self-contained leaf kernel callable from NEON code uses BOTH: + `__arm_new("za") __arm_locally_streaming void kernel(args) { ... }` (no type attr needed because + locally-streaming + new-za auto-bracket smstart/smstop). Both matmul examples use exactly this. + +### SMOPA (and any 8-bit MOPA) needs BYTE predicates — b32 silently zeros 3/4 of the K reduction +- `svmopa_za32_s8_m(tile, pn, pm, zA, zB)` operands are BYTES. The governing predicates `pn`/`pm` + MUST be **byte** predicates (`svptrue_b8()` / `svwhilelt_b8`). They predicate the BYTE lanes, and + the 4-way inner-K reduction lives inside the 4 bytes of each 32-bit group. +- Passing a **b32** predicate (the kind you use for the f32 FMOPA tile or for the `svst1_hor_za32` + readout) marks only 1 byte in 4 as active → the SMOPA drops 3 of every 4 inner-K products → + output is 100% wrong (NOT a tail/edge bug; ALL cells wrong). This exact mistake made the first + INT8 matmul give max_abs_err=90351 on all 1024 cells; switching pn/pm to `svptrue_b8()` made it + exact (err=0). `[ran-correct after fix]` +- Mnemonic: predicate element width == OPERAND element width (b8 for s8/u8 MOPA, b16 for f16/bf16, + b32 for f32). The 32-bit predicate is ONLY for the za32 readout store, never for the s8 MOPA. +- Do M/N tail handling for INT8 by ZERO-PADDING the packed Z panels, not by narrowing the byte + predicate (an off-tile byte predicate would also kill inner-K lanes). + +### SMOPA widening contract (so the byte-pack is right) — verified by single-hot probe +- `ZA32[i][j] += sum_{c=0..3} zA[4*i + c] * zB[4*j + c]`. a[ai]=1 & b[bj]=1 lights ZA[ai/4][bj/4] + ONLY when ai%4==bj%4. So 4 contiguous K for one output index live in ONE 32-bit lane group: + `aPanel[4*i+c]=A[(row+i)*K + 4g+c]`, `bPanel[4*j+c]=B[(4g+c)*N + (col+j)]`. + +### THE central performance model: Apple SME = per-cluster IN-ORDER command queue (AMX heritage) +- All streaming ops (loads, luti4, dots, ZA ops) from all cores of a cluster funnel into ONE shared + in-order queue per cluster (M4 Pro: 2 P-clusters + 1 E-cluster ≈ 2.2 effective units). +- **ZA→Z→core reads (`svread_*`) are queue DRAINS + core round-trips**: with per-group reads the + LUTI4 GEMV pinned at ~137 GMAC/s aggregate vs 406 GMAC/s for the same dot stream without reads — + and NO amount of chain interleaving recovers it (in-order queue; ILP can't cross a read barrier). +- **ZA→MEMORY stores (`svst1_hor_za32`) are fire-and-forget** — replacing per-group reads+in-stream + fp rescale with partial-int32 stores + NEON rescale after `smstop` recovered the throughput. + This matches why the M3b GEMM (which always stored partials) beat NEON while GEMV lagged. +- `svzero_za` is also ordered behind everything; amortize (once per 4 groups via slice rotation). +- Z-register svdot (no ZA) is NOT a workaround: z-dot issue rate is the bottleneck then (0.55x). +- Per-worker in-situ rate ~13-16 GMAC/s (DRAM-latency stalls in-order); microbench L1 rate 95-190. + More workers per cluster ≈ saturate the unit; k_sme≈5-6 of 14 pool workers is the sweet spot. + +### vg1 ZA-array vector -> svst1 slice mapping (probed) +- vg1 za32 vector v lives at `svst1_hor_za32(tile=0, slice=(v&3)*4 + (v>>2))` (SVL=512). All 16 + vg1 vectors map into "tile 0"'s 16 slices in interleaved order — probe before assuming geometry. + +### LUTI4/ZT0 (probed, 512 hot-nibble cases, -O0+-O1) +- `svluti4_lane_zt_s8_x2(0, zn, imm)`: IDENTITY mapping — nibble j of zn -> output byte j + (out[0]=nibbles 0..63, out[1]=64..127), low nibble first (= Cactus packing). imm is IRRELEVANT + for the .b x2 form. Table entry i = ZT0 byte 4*i (low byte of word i). Function needs + `__arm_new("zt0")`; load via `svldr_zt(0, ptr64B)`. +- LUTI4 in-engine expansion halves the weight stream (packed nibbles) at no correctness cost: + table = cb_i8 reproduces the NEON vqtbl expansion exactly. + +### clang 17 -O2/-O3 MIS-COMPILES vg1x4 ZA-array dots (runtime SIGILL) +- `svdot_lane_za32_s8_vg1x4` / `svdot_za32_s8_vg1x4` loops compile at -O2 but SIGILL at runtime; + correct + fast at -O0/-O1. Quarantine all vg1x4 kernels in a dedicated -O1 TU (matmul_sme2_gemv.cpp). +- `svreadz_*` (read-and-zero) needs SME2p1 — absent on M4. +- `svld1_s8_x4` (svcount multi-vector load) works at -O1; also implicated in -O2 SIGILLs. + +### Thermal asymmetry: NEON fades, SME holds +- NEON-pure GEMV swings 277-365 GFLOPS with chip temperature (clock fade under sustained load); + the SME/hybrid path holds ~310-315 steady. Cold-start benchmarks flatter NEON; sustained decode + (the real LLM workload) favors the hybrid. Always compare via in-run alternation (best-of-5x30). + +### -march=armv9-a+sme2 is an ASM-ONLY string on Apple Silicon (re-confirmed by RUN) +- Re-verified: `armv9-a+sme2` compiles and emits byte-identical fmopa/smopa/bfmopa/zero/st1w asm to + the runnable strings, but the BINARY SIGILLs at runtime (exit 132). All four candidate -march + strings produce the same `-S` output, so an asm-check CANNOT distinguish runnable from trapping — + you MUST run. Runnable: `armv8.2-a+fp16+simd+dotprod+i8mm+sme2`, `armv8-a+sme`, `-mcpu=apple-m4`. + +## parallel_for static splitter dumps the remainder on the LAST worker +`CactusThreading::parallel_for` computes `work_per_thread = total/num_threads` (FLOOR) and gives +thread N-1 everything left: 64 items / 14 workers = 13x4 + 1x12 (3x straggler). Invisible for +fine-grained items (1000s), catastrophic for coarse heavy items (attention q-tiles: measured ~60% +pool idle, 1.39ms -> 0.81ms after fix). Fix pattern: dispatch `n_slots = min(total, num_workers())` +pseudo-items with ParallelConfig{1,1} and walk `for (i = slot; i < total; i += n_slots)`. + +## Per-quant-group ZA readout makes SMOPA kernels store-dominated +If output scales differ per K-segment (e.g. per-32-dim quant groups), reading ZA out per group +costs 2 stores per SMOPA (16 rows x 4 tiles per group vs 32 SMOPAs) and multiplies partial-buffer +round-trip traffic by n_groups. Profile signature: issuing thread stalls INSIDE the leaf (430/754 +busy samples). Fix: FLATTEN scales so ZA accumulates the whole K extent — requantize the +per-group operand to one flat scale (ratios folded into int8, <=1 bit loss), readout once. +Attention QK: 8x less readout, leaf 430 -> 14 samples, same accuracy within 0.1%. + +## USMOPA u8 x s8 (svusmopa_za32_u8_m) is exact on M4 and free accuracy for non-negative operands +Softmax probabilities (and any >=0 operand) waste the sign bit under s8 SMOPA. USMOPA +(unsigned zn x signed zm) doubles resolution at identical cost. Probe-verified max_err=0 with +full 0..255 range (working-examples/usmopa_probe.cpp). + +## Portability: SVL-512 pinning + Apple-tuned policies (Samsung S26-class devices) +ALL shipped SME layouts (esme cache [16ch][4K] panels, 64-ch super-blocks, attention 16x64 tiles, +every +64/+128/+192 offset and svptrue_b8 assumption) are pinned to SVL = 64 bytes. Non-Apple +SME2 implementations may use other vector lengths -> would be SILENTLY WRONG, not slow. +GATED 2026-06-09: cpu_has_sme2() now also requires cactus_sme2_svl_bytes() == 64 (streaming +svcntb probe in matmul_sme2.cpp); other SVLs fall back to NEON for correctness. SVL-parametric +variants are future work. +Separately, the PERF model (per-cluster in-order queue, k_sme=4/6 splits, 3MB/6MB GEMV policy, +cluster-spread at nt>=6) is measured on Apple M4 Pro only — on Qualcomm/Samsung SME2 these are +heuristics to re-tune, not laws. Feature detection (Apple sysctl / Android HWCAP2 1UL<<37) and +the CACTUS_HAS_SME2 compile-check + stubs already degrade gracefully on non-SME toolchains. + +## pool.wait_all() cv-sleep + idle main = 20-30% tax on sub-100us parallel kernels +Every enqueue_n_threads + wait_all pays: N task allocations under one mutex, a cv sleep for main, +and a cv wake by the last worker (~5-15us wall), while the main thread contributes nothing. For +GEMV-sized calls (~30-90us) this is 20-30% of the call. Fix pattern (gemv_fused/orth drivers): +enqueue nt-1 workers, MAIN runs worker 0 inline (also the earliest SME issuer — zero wake +latency), spin-join an own atomic done counter. Kernel-level: o_proj 149->194 GF, gate_up +588->707 GF, q_proj 145->192 GF — same integer compute, 21/21 green. + +## Graph components duplicate weight buffers — per-BufferDesc caches multiply RAM +Each transpiled graph component (decoder_prefill_chunk, decoder_step, ...) maps the same weight +FILE independently: same weight => MULTIPLE BufferDescs with DIFFERENT mmap addresses. Anything +cached per BufferDesc (the SME weight cache, before 2026-06-10) is built and stored once PER +COMPONENT (measured 2x RAM + double build time + refaulting freshly-madvised pages). Pointers do +not identify weights — key shared per-weight state by BufferDesc::weight_key (FNV-1a of the +resolved weight path, set by the io.cpp loaders) via the weak_ptr registry in ensure_sme_cache. + +## Single runtime weight format: NEON-over-esme costs 2 zips per 16 B — and that's the floor +The esme nibble order is pinned by LUTI4's identity nibble->byte mapping, so NEON consumption +needs vzip1/vzip2 to restore byte order (9 ops/16 B vs 7 for the file layout). Do NOT try: +(a) vqtbl2q on raw bytes to skip the vand — tbl2 indexes only 0..31, raw bytes reach 255 + (returns 0 for most lanes; caught by tests); +(b) planar nibble packing to give NEON zip-free order — it moves the zips INTO the SME queue + (svzip per 128 B), the scarce resource. +16 independent SDOT accumulators + the fused-driver wins (spin-join, one dispatch) keep +esme-NEON at parity-or-better vs the file kernels (kv_proj 1.29x, hybrid ratios unchanged), so +the packed file pages can be released after the cache build (-1.2 GB physical footprint on +Gemma 4 E2B). + +## CORRECTION: "NEON collapses on K-heavy GEMV" was a file-layout artifact +On the INTERLEAVED_4ROW file layout, K-heavy GEMVs (o_proj/down) measured 57-174 GF and the SME +hybrid won 2-3x. On the esme cache layout the same NEON cores hit 200-400 GF on those shapes — +the collapse was panel-walk behavior of the FILE layout, not a NEON limit. Against the +same-format NEON baseline the SME GEMV leaf nets NEGATIVE E2E under sustained decode (51.4 vs +44.8 tok/s on Gemma 4 E2B) even where bursty kernel benches show wins: ~64-85 MACs/queue-op +nibble streaming cannot beat 10+ unobstructed NEON cores. Auto therefore runs 0 SME GEMV workers +(CACTUS_SME_GEMV_WORKERS to override). SME earns its keep on: the single cache format itself, +the fused M>1 GEMM (SMOPA, 2.4-3.6x), and prefill attention (4.6x/2.7x kernels, ~-100ms TTFT). +Moral, twice over: gate every win on the BASELINE'S best format, not just yours. diff --git a/cactus-kernels/docs/sme/research/SYNTHESIS.md b/cactus-kernels/docs/sme/research/SYNTHESIS.md new file mode 100644 index 000000000..df3c37802 --- /dev/null +++ b/cactus-kernels/docs/sme/research/SYNTHESIS.md @@ -0,0 +1,222 @@ +# SME2 CQ4 Matmul — Implementation Synthesis (single source of truth) + +> Consolidates `research/*.md` + `api-notes.md` + `gotchas.md` + `working-examples/*` into one +> actionable plan for `src/matmul_sme2.cpp`. **Verified** = compiled+run (or asm-checked) on *this* +> M4 Pro / Apple clang 17.0.0; **Unverified** = from docs/KleidiAI source, not yet exercised on HW. +> +> Target box: Apple M4 Pro, Apple clang 17.0.0, SVL = 512 bit = 64 B (16 FP32 / 64 INT8 lanes), +> `FEAT_SME2=1`, `FEAT_SME_F16F16=0` ⇒ **all MOPA accumulates into FP32/INT32 `za32`**. + +--- + +## 1. Verified intrinsics + `-march` (the load-bearing facts) + +### `-march` string + +| String | Status | Note | +|---|---|---| +| `armv8.2-a+fp16+simd+dotprod+i8mm+sme2` | **VERIFIED — USE THIS** | `[ran-correct]` both example matmuls PASS, max_err=0. The TU flag for `matmul_sme2.cpp`. | +| `armv8-a+sme` / `-mcpu=apple-m4` | **VERIFIED runs** | also run-correct; less explicit about i8mm/fp16. | +| `armv9-a+sme2` | **VERIFIED BROKEN at runtime** | compiles + emits *identical* asm but **SIGILLs (exit 132)** on M4 — `armv9-a` implies non-streaming SVE2 that Apple does not implement. ASM-inspection only; NEVER ship. | + +Build wiring: per-TU `set_source_files_properties(src/matmul_sme2.cpp PROPERTIES COMPILE_OPTIONS +"-march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2")`. Library-wide flag (CMakeLists line 34) stays +`armv8.2-a+fp16+simd+dotprod+i8mm`. Double-guard the TU with `#if defined(__ARM_FEATURE_SME2)`. +Header: `#include ` (pulls in `arm_sve.h`). + +### Intrinsics (names/signatures all VERIFIED against this box's `arm_sme.h` + LLVM CodeGen tests) + +| Purpose | Intrinsic (exact) | Emits | Status | +|---|---|---|---| +| FP32 FMOPA | `void svmopa_za32_f32_m(uint64_t tile, svbool_t pn, svbool_t pm, svfloat32_t zn, svfloat32_t zm)` | `fmopa za.s,p/m,p/m,z.s,z.s` | **VERIFIED ran** | +| INT8 SMOPA | `void svmopa_za32_s8_m(uint64_t tile, svbool_t pn, svbool_t pm, svint8_t zn, svint8_t zm)` | `smopa za.s,p/m,p/m,z.b,z.b` | **VERIFIED ran** | +| FP16→FP32 MOPA | `svmopa_za32_f16_m(...)` | `fmopa ...,z.h,z.h` (2-way widen, za32 form; F16F16=0) | **VERIFIED asm** | +| BF16→FP32 MOPA | `svmopa_za32_bf16_m(...)` | `bfmopa ...,z.h,z.h` | **VERIFIED asm** | +| ZA zero | `void svzero_za(void)` (or `svzero_mask_za(255)`) | `zero {za}` | **VERIFIED asm** | +| ZA readout (USE THIS) | `void svst1_hor_za32(uint64_t tile, uint32_t slice, svbool_t pg, void* ptr)` | `st1w {za0h.s[wN,0]},p,[ptr]` | **VERIFIED ran** | +| ZA→Z readout (for in-kernel rescale) | `svfloat32_t svread_hor_za32_f32_m(svfloat32_t inactive, svbool_t pg, uint64_t tile, uint32_t slice)` / `..._s32_m` | `mov z.s,p/m,za0h.s[wN,0]` | **VERIFIED asm** | +| Vertical add-accumulate (GEMV reduce option) | `void svaddva_za32_s32_m(uint64_t tile, svbool_t pn, svbool_t pm, svint32_t)` | `addva` | present (Unverified use) | +| Streaming-VL counts | `svcntw()`=16 (FP32 lanes), `svcntb()`=64 (INT8 lanes), `svcntsw()`/`svcntsb()` same in-stream | — | **VERIFIED ran** | +| Predicates | `svptrue_b8()`, `svptrue_b32()`; `svwhilelt_b32_u64(i,N)`, `svwhilelt_b8(i,N)` | `ptrue`/`whilelt` | **VERIFIED ran** | + +> ⚠ Arg order pitfall (verified): `svst1_hor_za32` is `(tile, slice, pg, ptr)` — tile first, NOT pg +> first. `svread_hor_za32_*_m` is `(inactive, pg, tile, slice)`. + +### Streaming / ZA attributes (VERIFIED, position matters) + +- **Leaf kernel callable straight from NEON code:** `__arm_new("za") __arm_locally_streaming` in the + **PREFIX** position (these are *declaration* attrs). Auto-brackets `smstart sm`/`smstart za` … `smstop`. + Both working examples use exactly this; it's the recommended Cactus leaf-kernel form. +- `__arm_streaming`, `__arm_inout("za")`, `__arm_in/out/preserves("za")` are **type** attrs — go + **AFTER** the param list. Putting `__arm_streaming` in prefix = hard compile error. +- Runtime gate: `sysctlbyname("hw.optional.arm.FEAT_SME2")==1` (or `__arm_has_sme()`) before dispatch, + else SIGILL on non-SME Macs. + +--- + +## 2. Recommended SMOPA tiling for SVL=64B on M4 + +Geometry (verified): ZA = 64×64 B. For `.s` (32-bit) accumulators there are **4 ZA tiles** +`za0.s..za3.s`, each a **16×16 INT32** accumulator. INT8 SMOPA fills the same `.s` tiles; each Z holds +**64 INT8 lanes** = 16 outer × 4 inner-K, so one `smopa` does a 16×16 INT32 update reducing **K=4**. + +**Recommended macro-tile** (drawn from KleidiAI `mr=1·SVLs, nr=4·SVLs` + scalable-analyses' all-4-tile +microkernel): + +| Param | Value (M4, SVLs=16) | Rationale | +|---|---|---| +| Output tile rows (M) | `mr = 1·SVLs = 16` | one M-strip; matches KleidiAI `m_step=1` | +| Output tile cols (N) | `nr = 4·SVLs = 64` | **4 ZA tiles** `za0..za3`, 16 cols each | +| ZA tiles used | **all 4** (`za0.s..za3.s`) | required for peak throughput — single-tile serializes on ZA write-back (scalable-analyses probe) | +| K-step per `smopa` | **4** (inner widening) | a K=32 quant block = 8 smopa iters per ZA tile | +| Z-reg staging | LHS → 1 Z (`z8`, shared across 4 tiles); RHS → 4 Z (`z4..z7`, one per ZA tile) | one `ld1h` LHS + `ld1w` RHS pairs per K-quad; **stage via Z then MOPA** (direct ZA loads ~2.6× slower, "Hello SME!") | + +Inner loop per K-block (the verified SMOPA contract — `ZA[i][j] += Σ_{c=0..3} zA[4i+c]·zB[4j+c]`): +``` +zero {za} // clear 4 tiles +for kq in 0..(blk/4): // 8 iters for blk=32 + zA = ld LHS (16 rows × 4 K interleaved → 64 bytes) + zB0..zB3 = ld RHS (4 tiles × 16 cols × 4 K) + smopa za0.s, pB,pB, zA.b, zB0.b // 4 byte-predicated MOPAs + smopa za1.s, ...; za2; za3 +``` +Keep `smstart`/`smstop` **outside** the whole K-loop (and ideally the whole GEMM); they are expensive +and zero Z/P. Pin to P-cluster via high QoS (no hard affinity on macOS). + +> **CRITICAL predicate trap (verified, cost a 90351-err bug):** SMOPA predicates `pn`/`pm` must be +> **byte** predicates (`svptrue_b8()`). A `b32` predicate marks only 1 byte in 4 → silently drops +> 3 of every 4 inner-K products → 100% wrong. Use `b32` ONLY for the `svst1_hor_za32` readout. Handle +> M/N tails by **zero-padding the packed Z panels**, never by narrowing the byte predicate. + +--- + +## 3. Re-tiling Cactus "expanded" INT8 → SMOPA + where the rescale lives + +### What Cactus already has (verified against `src/matmul.cpp`) +- **Weights `expanded` (int8):** built by `tq_preexpand_weights` → `tq_interleave_4x_s8` (matmul.cpp + :115). For each `(Nblock nb, group g)` a `gs*4`-byte int8 panel at offset `(nb*num_groups+g)*gs*4`, + holding **4 N-rows interleaved at 32-bit granularity** (`vzip` of 4 dequant int8 rows). Layout = + `[K][4 N-rows]@32-bit`, group-major. The codebook int8 scale `cb_scale` is **folded into the norm**: + `n_f32[(nb*num_groups+g)*4 + ni] = norms[...]·cb_scale` (matmul.cpp:1429). So weight effective fp32 + scale = `norm·cb_scale`, already in `n_f32`. +- **Activations (int8):** Hadamard-transformed then per-group symmetric int8 quant (`tq_quantize_group_i8`, + scale = max|x|/127) → `act_i8` + per-group fp32 `act_scales`. +- This is structurally KleidiAI `qsi8d32p` (symmetric int8 LHS) × `qsi4c32p` (RHS) **minus the int4 + stage** — Cactus pre-expands to int8, so the SMOPA path **omits `luti4`/ZT0** entirely and loads + weights directly as int8. (Chief simplification vs KleidiAI; costs 2× weight traffic, already paid.) + +### Re-tiling needed (Unverified — must validate against working-examples pack math) +Cactus interleaves exactly **4** N-rows (NEON SDOT width). SMOPA wants **nr = 4·SVLs = 64** N-cols +across 4 ZA tiles. Two re-tile transforms: + +1. **Weights `[K][4 N-rows]@32-bit` → `[k-quad][nr=64 N-cols]` split into 4 ZA-tile groups of 16 cols.** + Keep the kr=4 K-word intact (it already matches SMOPA's 4-deep group); **swap the inner 4-N-row + grouping for a 64-N-col grouping**. Per K-quad store 64 int8 cols contiguously; each `ld1w {z2-z3}` + feeds 2 ZA tiles. Concretely the byte pack must satisfy the verified contract: + `bPanel[4*j + c] = B[(4g+c)*N + (col+j)]` (j = N-col 0..63, c = inner-K 0..3). +2. **Activations `[K][m-row]` → `[k-quad][mr=16 M-rows]` interleaved.** Pack `act_i8` so + `aPanel[4*i + c] = A[(row+i)*K + 4g+c]` (i = M-row 0..15, c = inner-K). For M=1 (decode) this is a + single broadcastable row; for M>1 interleave up to 16 rows. + +Start with **on-the-fly re-tiling** in the kernel (stage into stack `aPanel[64]/bPanel[64]` exactly +like `int8_smopa_matmul.cpp`); add a dedicated `expanded_sme` pre-pack only once correctness is proven +and the re-tile shows up in the profile. + +### Where `norm_f32` rescale applies (verified mapping) +**After** the integer SMOPA, **before** the fp16 store. Per output `(m-row i, n-col j)`: +``` +acc_i32 = ZA[i][j] // raw int8·int8 dot, NO scaling in ZA +f = (float)acc_i32 · act_scale[group] · n_f32[ncol] // n_f32 already = norm·cb_scale +C[...] = (__fp16) f // Cactus C is __fp16* +``` +This is KleidiAI's `scvtf(za)·(s_lhs·s_rhs)` with `s_lhs = act_scale`, `s_rhs = norm·cb_scale` — a 1:1 +map. Two ways to drain: +- **Drain to int32 in memory, rescale in NON-streaming NEON** (simplest M3): `svst1_hor_za32` the raw + int32, then `vcvt`/`vmul` by `act_scale·n_f32` in NEON, store fp16. Keeps all float math out of the + streaming region. +- **In-kernel drain** (KleidiAI style, fewer round-trips for M>1): `svread_hor_za32_s32_m` → SVE + `svcvt_f32_s32_x` → `svmul`/`svmla` by broadcast fp32 scales → store. Keep Cactus's **fp32** scales + (`ld1rw` broadcast); do NOT down-convert to fp16 — Cactus already has fp32 `n_f32`, avoid KleidiAI's + fp16 widen and its precision loss. + +For `gs > 32`: accumulate per-32-block scaled results either in a register fp32 accumulator per +(m-row, n-tile) (matches Cactus's current `running_sum` style — preferred) or in DST memory +(`fmla` into reloaded `ld1w`, KleidiAI style). + +--- + +## 4. Implementation recipe for `src/matmul_sme2.cpp` + +**Testing-first (per project rule):** every milestone gates on the existing harness oracle +`cq_reference_gemv_f32` (test_matmul.cpp:185) / a scalar triple loop, MSE ≤ 0.1, before any perf claim. +Register each variant in the harness variant registry (Part 1b). + +### M0 — FP32 FMOPA smoke (ZA/streaming plumbing) +- Copy `working-examples/fp32_fmopa_matmul.cpp` structure into the TU. Kernel + `__arm_new("za") __arm_locally_streaming`, `svcntw()` tiling, `svmopa_za32_f32_m`, `svst1_hor_za32`. +- Gate: 32×32×32 vs scalar, max_abs_err < 1e-3. Proves smstart/ZA/readout/march wiring. **Already + proven in working-examples — port + wire into harness, don't re-derive.** + +### M2 — INT8 SMOPA standalone +- Port `working-examples/int8_smopa_matmul.cpp`. **Byte predicates `svptrue_b8()` on the MOPA**, b32 + only on readout; tail = zero-pad panels. Verified contract `ZA[i][j]+=Σ_c zA[4i+c]·zB[4j+c]`. +- Gate: 32×32×32 s8×s8→s32 vs scalar int32, max_abs_err == 0. (Already proven; wire into harness.) +- Then grow to the §2 macro-tile: mr=16, nr=64 across 4 ZA tiles, K-block=32 (8 smopa/tile). + +### M3 — wire SMOPA into CQ `expanded`, M=1 (decode/GEMV) +- Reuse Cactus steps (1)-(3) (Hadamard, int8 act quant, codebook int8) **in non-streaming NEON**. +- Streaming kernel does ONLY the int8 dot-accumulate over re-tiled weights (§3). M=1 ⇒ one A row, + broadcast into the LHS lanes; only 16-of-16 M-rows are tail-padded. +- Drain int32 → NEON rescale by `act_scale·n_f32` → fp16 (the simple drain). Compare to + `cq_reference_gemv_f32`; gate MSE ≤ 0.1. Capture GFLOPS vs NEON baseline (cq4 model GEMV = 301). + +### M3b — CQ GEMM, M>1 +- Interleave up to `mr=16` activation rows into `[k-quad][m-row]` panels. Loop M in strips of 16, N in + strips of 64. Switch to in-kernel fp32 drain (`svread`+`svcvt`+`svmla`) to amortize readout across + the larger tile. Gate vs reference for several M; compare GFLOPS vs NEON cq4 (1024³=1813, 2048³=2280). +- **Dispatch rule:** enable in `cactus_quant_matmul` only if MSE ≤ 0.1 AND beats NEON on this Mac; + else log "perf-deferred", do not dispatch. + +### M4 (stretch) — keep weights int4 + `luti4`/ZT0 +- If weight memory traffic dominates, adopt KleidiAI's int4 RHS pack + (`kai_rhs_pack_nxk_qsi4c32ps1s0scalef16_qsu4c32s16s0_neon`) + `luti4 {z4-z5}, zt0, z2[0]` widening + (LUT `{-8..7}`), folding `cb_scale` into the fp16 RHS scale. Only after M3b ships. + +--- + +## 5. Top 5 open risks / unknowns + fallback + +1. **SMOPA re-tile byte-pack correctness at nr=64.** The 4-N-row→64-N-col transpose (§3) is the only + genuinely new code; the verified single-tile contract must hold at full tile width. + *Fallback:* the proven `int8_smopa_matmul.cpp` on-the-fly `aPanel/bPanel[64]` packing already passes + exact at 16×16; build nr=64 by tiling that exact pack 4× before writing a fused packer. + +2. **Perf vs NEON cq4 may not win** (NEON cq4 already 1813–2280 GFLOPS; one shared SME unit per + cluster, no thread scaling). M=1 GEMV especially may be memory-bound, not MOPA-bound. + *Fallback:* correctness-first; if SME loses, log "perf-deferred" and keep NEON dispatched — SME is + not required to ship, and M>1 prefill is the likelier win than M=1 decode. + +3. **In-kernel fp32 rescale inside streaming mode.** SVE `svcvt_f32_s32_x`/`svmul`/`ld1rw` are + streaming-legal, but the exact `svread`→cvt→`svmla` sequence is Unverified on HW. + *Fallback:* the **memory-drain** path (store raw int32 via `svst1_hor_za32`, rescale in NEON) is + fully verified and side-steps all in-streaming float work. Use it for M3. + +4. **gs>32 block accumulation & scale alignment.** Cactus `gs` ∈ [32,256]; per-32-block scale must be + applied before summing blocks. Register-accumulate vs DST-accumulate choice unverified for SME tile. + *Fallback:* mirror KleidiAI's per-32-block `fmla`-into-DST (proven structure) if register pressure + on the 16×64 tile is too high; otherwise the register `running_sum` style Cactus already uses. + +5. **P-core placement / E-core 4–5× slowdown.** No hard affinity on macOS; QoS is only a hint. + *Fallback:* run the streaming kernel on a high-QoS (`USER_INTERACTIVE`) thread; accept it's + advisory. Benchmark with the existing P-core affinity hooks; report numbers as P-cluster-targeted. + +--- + +### Single most important thing from KleidiAI's actual source +Cactus's existing `expanded` int8 weights + per-group int8 acts are a **near-exact structural match to +KleidiAI's symmetric `qsi8d32p × qsi4c32p` SME2 mopa kernel** — same symmetric-int8, per-block-fp(16)-scale, +4-deep-K, `nr=4·SVLs / mr=1·SVLs` shape — and because Cactus **pre-expands to int8**, the SMOPA path +**drops the `luti4`/ZT0 int4→int8 widening stage entirely**. The only real porting work is the N-row +re-tile (4 → 4·SVLs) and choosing where the (already-`cb_scale`-folded) `norm·act_scale` rescale lands: +after the integer mopa, before the fp16 store. No zero-point/row-sum correction is needed (that is the +*asymmetric* qai8dxp variant; Cactus is symmetric like qsi8d32p). diff --git a/cactus-kernels/docs/sme/research/acle-sme-intrinsics.md b/cactus-kernels/docs/sme/research/acle-sme-intrinsics.md new file mode 100644 index 000000000..1ea2f9a2d --- /dev/null +++ b/cactus-kernels/docs/sme/research/acle-sme-intrinsics.md @@ -0,0 +1,430 @@ +# ACLE SME / SME2 Intrinsics — Authoritative Signature Reference + +> Goal: EXACT intrinsic names/signatures + attribute semantics for writing an SME2 quantized +> matmul in C++ with **Apple clang 17** (`-march=armv9-a+sme2`, `#include `). +> Every signature below is verified VERBATIM against LLVM's own clang CodeGen test suite +> (the ground truth the compiler must accept) and the Arm "Multiplying matrices with SME2" +> learning path. Where a source summary disagreed with the LLVM test, the **LLVM test wins**. + +Date-checked: 2026-06-09. Primary verification source: `llvm-project/clang/test/CodeGen/AArch64/`. + +--- + +## 0. TL;DR — the load-bearing facts that break builds + +1. **Tile index is the FIRST argument** of `svmopa_za32_*_m` and must be a compile-time immediate + `0..3` for za32 (i32). Signature: `svmopa_za32_*_m(uint64_t tile, svbool_t pn, svbool_t pm, zn, zm)`. + Verified: `acle_sme_mopa-za32.c` → `SME_ACLE_FUNC(svmopa_za32, _s8, _m)(0, pn, pm, zn, zm)`. +2. **There is NO `svmopa_za32_s16_m` / `svmopa_za32_u16_m`.** 16-bit-integer MOPA widens to **za64** + (`svmopa_za64_s16_m`, needs `FEAT_SME_I16I64`). za32 integer MOPA only comes from **8-bit** + (s8/u8, 4-way widening). Do not write `..._za32_s16_m` — it will not compile. +3. **ZA store readout: `svst1_hor_za32(tile, slice, pg, ptr)`** — tile first, slice second, predicate + third, pointer fourth. Verified: `acle_sme_st1.c` → `svst1_hor_za32(0, slice_base, pg, ptr)`. + (Note: some web summaries put `pg` before tile — that is WRONG for the C ACLE intrinsic.) +4. `svzero_za()` takes **no args**; `svzero_mask_za(uint64_t mask)` takes an 8-bit tile mask. +5. Inside `__arm_streaming`, vector length = **streaming VL (SVL)**. Use `svcntsw()` for "FP32 lanes + in streaming mode". NEON/AdvSIMD intrinsics are **invalid** in streaming mode. +6. The f16→za32 and f32 forms share the `mopa.wide`/`mopa` LLVM intrinsic and pass a **mode immediate + `i32 1`** internally (sub=0 for the accumulate `_m` form via the second immediate); you still just + call `svmopa_za32_f32_m(tile, pn, pm, zn, zm)`. + +--- + +## 1. Outer-product accumulate intrinsics (MOPA → ZA accumulators) + +Header: ``. All require `__arm_streaming` (or streaming-compatible context) and +`__arm_inout("za")` / `__arm_out("za")` on the enclosing function. Tile arg is a compile-time +immediate. `pn` selects active **rows** (n / M-dim) and `pm` selects active **columns** (m / N-dim). + +### za32 accumulator forms (4 tiles: za0.s .. za3.s, tile ∈ {0,1,2,3}) + +| Intrinsic | Full signature | Instruction | LLVM intrinsic | Notes | +|---|---|---|---|---| +| `svmopa_za32_f32_m` | `void svmopa_za32_f32_m(uint64_t tile, svbool_t pn, svbool_t pm, svfloat32_t zn, svfloat32_t zm)` | **FMOPA** (non-widening) | `llvm.aarch64.sme.mopa.nxv4f32` | 1×1, FP32→FP32 | +| `svmopa_za32_f16_m` | `void svmopa_za32_f16_m(uint64_t tile, svbool_t pn, svbool_t pm, svfloat16_t zn, svfloat16_t zm)` | **FMOPA (widening)** | `llvm.aarch64.sme.mopa.wide.nxv8f16` | 2-way widen FP16→FP32. Base `FEAT_SME`. | +| `svmopa_za32_bf16_m` | `void svmopa_za32_bf16_m(uint64_t tile, svbool_t pn, svbool_t pm, svbfloat16_t zn, svbfloat16_t zm)` | **BFMOPA (widening)** | `llvm.aarch64.sme.mopa.wide.nxv8bf16` | 2-way widen BF16→FP32 | +| `svmopa_za32_s8_m` | `void svmopa_za32_s8_m(uint64_t tile, svbool_t pn, svbool_t pm, svint8_t zn, svint8_t zm)` | **SMOPA (widening)** | `llvm.aarch64.sme.smopa.wide.nxv16i8` | **4-way** widen, S8×S8→S32. THE int8 path. | +| `svmopa_za32_u8_m` | `void svmopa_za32_u8_m(uint64_t tile, svbool_t pn, svbool_t pm, svuint8_t zn, svuint8_t zm)` | **UMOPA (widening)** | `llvm.aarch64.sme.umopa.wide.nxv16i8` | 4-way widen, U8×U8→U32 | + +Mixed-sign 8-bit (SME2, ``, `FEAT_SME2`): `svsumopa_za32_s8_m` (S8×U8 → SUMOPA, +`llvm.aarch64.sme.sumopa.wide.nxv16i8`) and `svusmopa_za32_u8_m` (U8×S8 → USMOPA). Same arg shape. + +### za64 accumulator forms (16-bit integers; needs `FEAT_SME_I16I64`, tile ∈ {0..7}) + +> Use these if you ever want **S16×S16→S32-in-za64** instead of S8. They are NOT za32. + +| Intrinsic | Full signature | Instruction | LLVM intrinsic | +|---|---|---|---| +| `svmopa_za64_s16_m` | `void svmopa_za64_s16_m(uint64_t tile, svbool_t pn, svbool_t pm, svint16_t zn, svint16_t zm)` | **SMOPA (widening)** | `llvm.aarch64.sme.smopa.wide.nxv8i16` | +| `svmopa_za64_u16_m` | `void svmopa_za64_u16_m(uint64_t tile, svbool_t pn, svbool_t pm, svuint16_t zn, svuint16_t zm)` | **UMOPA (widening)** | `llvm.aarch64.sme.umopa.wide.nxv8i16` | +| `svsumopa_za64_s16_m` | `void svsumopa_za64_s16_m(uint64_t tile, svbool_t pn, svbool_t pm, svint16_t zn, svuint16_t zm)` | **SUMOPA** | `llvm.aarch64.sme.sumopa.wide.nxv8i16` | +| `svusmopa_za64_u16_m` | `void svusmopa_za64_u16_m(uint64_t tile, svbool_t pn, svbool_t pm, svuint16_t zn, svint16_t zm)` | **USMOPA** | `llvm.aarch64.sme.usmopa.wide.nxv8i16` | + +Verbatim evidence (LLVM clang test, `acle_sme_mopa-za32.c` / `-za64.c`): +```c +void test_svmopa_za32_f32(svbool_t pn, svbool_t pm, svfloat32_t zn, svfloat32_t zm) + __arm_streaming __arm_inout("za") { + svmopa_za32_f32_m(1, pn, pm, zn, zm); // -> llvm.aarch64.sme.mopa.nxv4f32(i32 1, ...) +} +void test_svmopa_za32_s8(svbool_t pn, svbool_t pm, svint8_t zn, svint8_t zm) + __arm_streaming __arm_inout("za") { + svmopa_za32_s8_m(0, pn, pm, zn, zm); // -> llvm.aarch64.sme.smopa.wide.nxv16i8(i32 0, ...) +} +void test_svmopa_za64_s16(svbool_t pn, svbool_t pm, svint16_t zn, svint16_t zm) + __arm_streaming __arm_inout("za") { + svmopa_za64_s16_m(7, pn, pm, zn, zm); // -> llvm.aarch64.sme.smopa.wide.nxv8i16(i32 7, ...) +} +``` + +**Overloaded short form** (preferred in the Arm tutorial): `svmopa_za32_m(tile, pn, pm, zn, zm)` — +clang resolves the type suffix from the operand types. The Arm learning path uses +`svmopa_za32_m(0, pMDim, pNDim, zL, zR)`. Both the suffixed and overloaded forms exist; the suffixed +form is safest when the operand type is ambiguous. + +### Semantics +- za32 MOPA computes, for each cell `(i,j)` of the SVL/4 × SVL/4 (for 8-bit: lanes/4) tile: + `ZA32[i][j] += sum over the widening group of zn[i-lane group] * zm[j-lane group]`. +- For **f32 (non-widening)**: `ZA[i][j] += zn[i] * zm[j]`, a pure rank-1 update; tile is SVL_words × + SVL_words (16×16 on a 512-bit SVL / M4). +- For **s8/u8 (4-way widening)**: each 32-bit ZA cell accumulates **4** S8×S8 products from 4 + contiguous K elements packed in each 32-bit lane group. K is consumed in steps of 4 per MOPA. +- `pn`/`pm` are **byte/element predicates matching the operand element width** (b8 for s8/u8 ops, + b16 for f16/bf16, b32 for f32). Inactive lanes contribute 0 to the accumulate. + +--- + +## 2. ZA zeroing + +| Intrinsic | Signature | Instruction | Notes | +|---|---|---|---| +| `svzero_za` | `void svzero_za(void)` | `ZERO {ZA}` | Zeroes the entire ZA array. No args. | +| `svzero_mask_za` | `void svzero_mask_za(uint64_t mask)` | `ZERO { }` | 8-bit tile-slice mask; `0xFF`(255) == all, `0`==none. | + +Both lower to `llvm.aarch64.sme.zero(i32 mask)`; `svzero_za()` is `zero(i32 255)`. Caller must be +`__arm_out("za")` (it defines ZA) or `__arm_inout("za")`. +Verbatim: `acle_sme_zero.c` → `void test_svzero_za(void) __arm_out("za") { svzero_za(); }` and +`svzero_mask_za(0); svzero_mask_za(176); svzero_mask_za(255);`. + +--- + +## 3. ZA read-out to memory (the actual store of accumulated results) + +> Two families: **(A)** `svst1_{hor,ver}_za*` store one tile slice straight to memory (what the Arm +> matmul tutorial uses); **(B)** `svread_{hor,ver}_za*_m` extract a slice into a Z register first, +> then you `svst1` it as a normal SVE vector; **(C)** `svstr_za` stores the whole ZA (or a vnum slice) +> opaquely for spill/restore — NOT a per-tile readout. + +### (A) Direct slice store — USE THIS for matmul output + +```c +// VERBATIM signature (acle_sme_st1.c): +void svst1_hor_za32(uint64_t tile, uint32_t slice, svbool_t pg, void *ptr); +void svst1_ver_za32(uint64_t tile, uint32_t slice, svbool_t pg, void *ptr); +void svst1_hor_za128(uint64_t tile, uint32_t slice, svbool_t pg, void *ptr); +void svst1_ver_za128(uint64_t tile, uint32_t slice, svbool_t pg, void *ptr); +// (also za8 / za16 / za64 variants with identical arg shape) +``` +- **Argument order: `(tile, slice, pg, ptr)`** — tile is a compile-time immediate, `slice` (a.k.a. + `slice_base`) is a runtime `uint32_t` row/column index within the tile. +- `_hor` = **horizontal slice** = one **row** of the tile written to contiguous memory. + `_ver` = **vertical slice** = one **column** of the tile written to contiguous memory. +- The `slice` index selects which row (hor) / column (ver) of the tile, modulo the number of slices + (= SVL/elem-size). `za32` has SVL/4 horizontal and SVL/4 vertical slices. +- Lowers to `llvm.aarch64.sme.st1w.horiz.p0( pg, ptr, i32 tile, i32 slice)` + (note: at IR level the order is pg, ptr, tile, slice — but the **C intrinsic order is tile, slice, + pg, ptr**, which is what you write). +- Enclosing function must be `__arm_in("za")` (reads ZA) or `__arm_inout("za")`. + +Verbatim: `acle_sme_st1.c` → +```c +void test_svst1_hor_za32(uint32_t slice_base, svbool_t pg, void *ptr) + __arm_streaming __arm_in("za") { + svst1_hor_za32(0, slice_base, pg, ptr); + svst1_hor_za32(3, slice_base + 3, pg, ptr); // -> st1w.horiz.p0(pg, ptr, i32 3, i32 slice+3) +} +``` +And the Arm tutorial's readout loop (FP32, 4 rows at a time): +```c +svst1_hor_za32(0, trow + 0, p0, &matResult[result_UL + (trow + 0) * N]); +svst1_hor_za32(0, trow + 1, p1, &matResult[result_UL + (trow + 1) * N]); +// ... trow+2, trow+3 +``` + +### (B) Slice → Z register, then store (more flexible / lets you post-scale before store) +```c +// VERBATIM (acle_sme_read.c), overloaded as svread_hor_za32_m / suffixed _s32/_f32: +svint32_t svread_hor_za32_s32_m(svint32_t inactive, svbool_t pg, uint64_t tile, uint32_t slice); +svfloat32_t svread_hor_za32_f32_m(svfloat32_t inactive, svbool_t pg, uint64_t tile, uint32_t slice); +// + _u32, + svread_ver_za32_* (vertical). +// arg order: (inactive, pg, tile, slice) +``` +Lowers to `llvm.aarch64.sme.read.horiz.nxv4i32(inactive, pg, i32 tile, i32 slice)`. After reading, +post-process in Z regs (e.g. requant scale for INT8 matmul) and store with normal `svst1`. + +### (C) Whole-ZA spill (NOT a per-tile readout) +```c +void svstr_za(uint32_t slice, void *ptr); // -> llvm.aarch64.sme.str.p0(i32 slice, ptr, i32 0) +void svldr_za(uint32_t slice, const void *ptr); +``` +For saving/restoring ZA state opaquely; do not use for matmul result extraction. + +**Recommendation for INT8 matmul:** use **(A) `svst1_hor_za32`** if you store raw int32 accumulators +and rescale afterward in non-streaming code, OR **(B) `svread_hor_za32_s32_m`** if you want to convert +to float / apply per-output-channel scale inside the streaming kernel before storing. + +--- + +## 4. Tile/slice helpers, counting, predicates + +### Counting (element counts of the CURRENT vector length) +| Intrinsic | Returns | Notes | +|---|---|---| +| `uint64_t svcntb(void)` | bytes in a vector | In streaming fn = SVL bytes. | +| `uint64_t svcnth(void)` | 16-bit elems (halfwords) | | +| `uint64_t svcntw(void)` | 32-bit elems (words) | FP32 lanes. | +| `uint64_t svcntd(void)` | 64-bit elems (doublewords) | | + +### Streaming-VL counts (SME-specific; return the STREAMING VL regardless of current PSTATE.SM) +| Intrinsic | Returns | Notes | +|---|---|---| +| `uint64_t svcntsb(void)` | streaming VL in bytes | | +| `uint64_t svcntsh(void)` | streaming VL in halfwords | | +| `uint64_t svcntsw(void)` | streaming VL in words | **FP32 lanes / tile side for f32.** Arm tutorial uses this. | +| `uint64_t svcntsd(void)` | streaming VL in doublewords | | + +> Inside `__arm_streaming`, `svcntw() == svcntsw()`. Use `svcntsw()` when you need the SVL from a +> NON-streaming caller (e.g. to compute tiling before entering the kernel). On Apple M4 SVL = 512 bit +> ⇒ `svcntsw() == 16` (16 FP32 lanes, 16×16 f32 tile; for s8: 64-byte vec, SVL/4 = 16 → 16×16 s32 tile +> accumulating 4 K per step). + +### Governing predicates (all-true) +```c +svbool_t svptrue_b8(void); // ptrue p.b, ALL — for s8/u8 ops +svbool_t svptrue_b16(void); // ptrue p.h, ALL — for f16/bf16/s16 ops +svbool_t svptrue_b32(void); // ptrue p.s, ALL — for f32 ops +svbool_t svptrue_b64(void); // ptrue p.d, ALL +``` +`svptrue_b8()` == `svptrue_pat_b8(SV_ALL)`. + +### Loop/tail predicates +```c +svbool_t svwhilelt_b8(uint64_t op1, uint64_t op2); // WHILELT p.b +svbool_t svwhilelt_b16(uint64_t op1, uint64_t op2); // WHILELT p.h +svbool_t svwhilelt_b32(uint64_t op1, uint64_t op2); // WHILELT p.s — used for M/N tail in tutorial +svbool_t svwhilelt_b64(uint64_t op1, uint64_t op2); // WHILELT p.d +// element n active iff (op1 + n) < op2. Also _u32/_u64/_s32/_s64 typed and svwhilele_* variants. +``` +(`svwhilelt_b32_u64`, `svwhilelt_b32_s64`, etc. are the fully-typed names; the bare `svwhilelt_b32` +is the overload.) + +--- + +## 5. Streaming-mode loads/stores (usable inside `__arm_streaming`) + +Inside streaming mode you use **SVE** contiguous load/store intrinsics (NOT NEON). They operate on +SVL-wide Z registers and take a governing predicate. + +```c +// Loads (overloaded form resolves type from the typed pointer): +svfloat32_t svld1_f32(svbool_t pg, const float32_t *ptr); +svint8_t svld1_s8 (svbool_t pg, const int8_t *ptr); +svuint8_t svld1_u8 (svbool_t pg, const uint8_t *ptr); +svfloat16_t svld1_f16(svbool_t pg, const float16_t *ptr); +svbfloat16_t svld1_bf16(svbool_t pg, const bfloat16_t *ptr); +// Overloaded: svld1(pg, ptr) -> picks suffix from ptr type. (Arm tutorial uses svld1(pMDim, &..).) + +// Stores: +void svst1_f32(svbool_t pg, float32_t *ptr, svfloat32_t data); +void svst1_s8 (svbool_t pg, int8_t *ptr, svint8_t data); +// Overloaded: svst1(pg, ptr, data). + +// Other useful in-streaming SVE ops: svdup_f32/_s8 (broadcast), svsel, svadd, svmla, +// svcvt_f32_* (widen/convert), svreinterpret_*. +``` +- These are normal SVE intrinsics from `` (pulled in by ``); they are legal in + both streaming and non-streaming mode (they are streaming-compatible) — that is why you stage data + into Z registers with `svld1` and then feed MOPA. +- The "Hello SME!" benchmark note: stage memory → Z via `svld1` then MOPA; direct memory→ZA loads + (`svld1_hor_za*` / `ldr za`) are ~2.6× slower on M4. + +ZA-direct loads (slice loads, the inverse of §3A) also exist if you must: +```c +void svld1_hor_za32(uint64_t tile, uint32_t slice, svbool_t pg, const void *ptr); +void svld1_ver_za32(uint64_t tile, uint32_t slice, svbool_t pg, const void *ptr); +// (tile, slice, pg, ptr) — same shape as svst1_hor_za32; enclosing fn __arm_out("za"). +``` + +--- + +## 6. Function attributes (Apple clang 17 keyword spelling) + +Use the **keyword form** (`__arm_streaming`, etc.) on clang 17 — the older +`__attribute__((arm_streaming))` GNU form is deprecated/inconsistent. Keywords go in the function +**type** position (after the parameter list for member/free functions), e.g. +`void f(...) __arm_streaming __arm_inout("za") { ... }`. + +| Attribute | Spelling | LLVM IR attr | Effect | +|---|---|---|---| +| Streaming body | `__arm_streaming` | `aarch64_pstate_sm_enabled` | Function runs with **PSTATE.SM=1**. The **caller** emits `smstart sm` before the call and `smstop sm` after. Part of the function's type/ABI — callers must know. Inside, SVL applies; NEON illegal. | +| Streaming-agnostic | `__arm_streaming_compatible` | `aarch64_pstate_sm_compatible` | Runs correctly with SM=0 OR 1; does not change SM. Conditionally toggles based on caller. Restrict to instructions legal in both modes. | +| Locally streaming | `__arm_locally_streaming` | `aarch64_pstate_sm_body` | **Definition-only** (not ABI). Compiler inserts `smstart sm` in prologue / `smstop sm` in epilogue; callers see a normal (non-streaming) function. Handy for a self-contained kernel. | +| Owns ZA | `__arm_new("za")` (also `__arm_new_za`) | `aarch64_new_za` | Function gets **fresh, zeroed ZA**; compiler emits `smstart za` on entry / `smstop za` on exit and handles lazy-save. Use for the top-level kernel that owns the accumulators. | +| Reads ZA in | `__arm_in("za")` | `aarch64_in_za` | ZA is a live input; preserved unless function also writes. Use on a slice-store helper that consumes accumulators. | +| Writes ZA out | `__arm_out("za")` | `aarch64_out_za` | ZA defined on return; incoming value undefined. Use on a helper that zeroes/loads ZA. | +| Read+write ZA | `__arm_inout("za")` | `aarch64_inout_za` | ZA is live in and out. Use on the MOPA-accumulate inner kernel. | +| Preserves ZA | `__arm_preserves("za")` | `aarch64_preserves_za` | Promises ZA unchanged across the call (lets compiler avoid save/restore). | + +### Critical codegen consequences (LLVM AArch64SME doc, "Changing PSTATE.SM") +> "When changing PSTATE.SM the execution of FP/vector operations may be transferred to another +> processing element. This has three important implications: +> 1. The runtime SVE vector length may change. +> 2. The contents of FP/AdvSIMD/SVE registers are zeroed. +> 3. The set of allowable instructions changes." + +Practical rules: +- **Crossing a streaming boundary zeroes Z/P registers** and is not free. Do all the MOPA work for a + whole N-panel inside ONE streaming region; never enter/exit per column. +- **NEON/AdvSIMD intrinsics are invalid in streaming mode.** Keep Hadamard transform, INT8 quant, + codebook handling, and final rescale in the NON-streaming NEON caller. The streaming kernel does + ONLY the MOPA accumulate (and optionally the readout/rescale via `svread` + SVE ops). +- `smstart za` / `smstop za` (from `__arm_new("za")`) is distinct from `smstart sm`/`smstop sm` (from + `__arm_streaming`). A kernel that both owns ZA and runs streaming gets both: + `__arm_new("za") __arm_locally_streaming` (or `__arm_streaming` if you want it in the type/ABI). +- Do **not** use SME in signal handlers (corrupts the interrupted thread's ZA). macOS xnu + auto-saves/restores ZA across context switches (no entitlement needed). + +--- + +## 7. Minimal code snippets + +### 7.1 Correct FP32 FMOPA tile loop (mirrors the Arm learning path) +```cpp +#include + +// One SVL×SVL FP32 tile of C = A_panel * B_panel, accumulated over K. +// A_mod is pre-transposed so each k gives a contiguous SVL-long column of A (lanes = M rows). +// B is row-major (each k gives a contiguous SVL-long row of B, lanes = N cols). +__arm_new("za") __arm_locally_streaming +void matmul_f32_tile(uint64_t M, uint64_t K, uint64_t N, + const float *restrict A_mod, // [M*K], column-staged per tutorial + const float *restrict B, // [K*N], row-major + float *restrict C) // [M*N], row-major +{ + const uint64_t SVL = svcntsw(); // FP32 lanes in streaming mode (16 on M4) + for (uint64_t row = 0; row < M; row += SVL) { + svbool_t pM = svwhilelt_b32(row, M); // active M rows in this tile + for (uint64_t col = 0; col < N; col += SVL) { + svbool_t pN = svwhilelt_b32(col, N); // active N cols in this tile + svzero_za(); // clear all ZA tiles + const uint64_t aPos = row * K; + for (uint64_t k = 0; k < K; ++k) { + svfloat32_t zA = svld1_f32(pM, &A_mod[aPos + k * SVL]); + svfloat32_t zB = svld1_f32(pN, &B[col + k * N]); + svmopa_za32_f32_m(0, pM, pN, zA, zB); // FMOPA: ZA0.s += zA (outer) zB + } + // Read out tile 0 row-by-row (horizontal slices) to C. + const uint64_t cUL = row * N + col; + for (uint64_t r = 0; r < SVL && row + r < M; ++r) { + svst1_hor_za32(0, (uint32_t)r, pN, &C[cUL + r * N]); + } + } + } +} +``` + +### 7.2 INT8 SMOPA tile loop (S8×S8 → S32, 4-way widening) +```cpp +#include + +// One SVL/4 (=16) × SVL/4 tile of int32 accumulators = A8 * B8, K consumed 4-at-a-time. +// Layouts must pack 4 contiguous K into each 32-bit lane group of zA/zB (the SMOPA widening group). +__arm_new("za") __arm_locally_streaming +void matmul_s8_tile(uint64_t M, uint64_t K, uint64_t N, + const int8_t *restrict A8, // pre-tiled: SVL bytes per k-step (4 K interleaved) + const int8_t *restrict B8, // pre-tiled likewise + int32_t *restrict C32) // [M*N] raw int32 accumulators +{ + const uint64_t SVLb = svcntsb(); // SVL in bytes (64 on M4) + const uint64_t TILE = SVLb / 4; // 16: int32 cells per tile side + const svbool_t pAll8 = svptrue_b8(); // byte predicate for s8 operands + for (uint64_t row = 0; row < M; row += TILE) { + svbool_t pM = svwhilelt_b32(row, M); // 32-bit accumulator-cell predicate (rows) + for (uint64_t col = 0; col < N; col += TILE) { + svbool_t pN = svwhilelt_b32(col, N); // (cols) + svzero_za(); + // K is iterated in steps of 4 (each MOPA consumes 4 K via 4-way widening). + for (uint64_t kg = 0; kg < K; kg += 4) { + // Each load brings SVL bytes = TILE rows/cols × 4 contiguous K. + svint8_t zA = svld1_s8(pAll8, &A8[(row * K) + kg * TILE]); + svint8_t zB = svld1_s8(pAll8, &B8[(col * K) + kg * TILE]); + svmopa_za32_s8_m(0, pM, pN, zA, zB); // SMOPA: ZA0.s += sum_{c<4} A*B + } + // Readout: extract int32 slice to Z, (optionally) requant-scale, then store. + const uint64_t cUL = row * N + col; + for (uint64_t r = 0; r < TILE && row + r < M; ++r) { + // Option A: store raw int32 accumulators, rescale in non-streaming caller. + svst1_hor_za32(0, (uint32_t)r, pN, &C32[cUL + r * N]); + // Option B (rescale in-kernel): + // svint32_t acc = svread_hor_za32_s32_m(svdup_s32(0), pN, 0, (uint32_t)r); + // svfloat32_t f = svmul_f32_x(pN, svcvt_f32_s32_x(pN, acc), zScale); + // svst1_f32(pN, &Cf[cUL + r*N], f); + } + } + } +} +``` +> NOTE: the exact byte-packing of zA/zB (which K maps to which lane group) must match the SMOPA +> widening contract — verify against KleidiAI `..._sme2_mopa` and scalable-analyses/sme INT8 kernel +> before trusting the index math above. The intrinsic names/signatures are correct; the *tiling +> arithmetic* in the snippet is illustrative and unverified on hardware. + +### 7.3 ZA-owning wrapper that splits streaming vs non-streaming work +```cpp +// Top-level entry: non-streaming, does NEON pre/post; calls a __arm_streaming inner that touches ZA. +void cactus_quant_matmul_sme2(/* CactusQuantMatrix*, fp16* A, M, fp16* C */) { + // ... NEON: Hadamard transform, INT8 quantize activations + codebook (NON-streaming) ... + matmul_s8_tile(/* ... */); // streaming + __arm_new("za") kernel: MOPA only + // ... NEON: rescale int32 -> fp16 by norm_f32, write C (NON-streaming) ... +} +``` + +--- + +## 8. Build / toolchain notes (Apple clang 17) + +- Header: `#include ` (transitively includes ``). + Guard real code with `#if defined(__ARM_FEATURE_SME2)`. +- Compile the SME TU with `-march=armv9-a+sme2` (probe-confirmed to compile on Apple clang 17 and + emit `smstart`/`*mopa`). Keep this OFF the library-wide flag; confine SME to its own translation + unit (`src/matmul_sme2.cpp`) so the optimizer never emits streaming code into NEON paths. +- Feature-test macros you can rely on inside the TU: `__ARM_FEATURE_SME`, `__ARM_FEATURE_SME2`, + `__ARM_FEATURE_SME_F16F16` (for za16 f16 MOPA — note this is `0` on M4, so use the za32 widening + f16 form `svmopa_za32_f16_m`), `__ARM_FEATURE_SME_I16I64` (for za64 s16 MOPA). +- Runtime detect on Apple: sysctl `hw.optional.arm.FEAT_SME` / `hw.optional.arm.FEAT_SME2`. + +--- + +## 9. Sources (verified 2026-06-09) + +1. **Arm ACLE spec** — https://arm-software.github.io/acle/main/acle.html — §"BFMOPA, FMOPA + (widening), SMOPA, UMOPA" and §"SME language extensions and intrinsics". Defines the intrinsic + family and attribute semantics. (Full prototype tables are large; cross-checked against LLVM.) +2. **LLVM clang CodeGen tests** (ground truth the compiler accepts) — verbatim signatures: + - `clang/test/CodeGen/AArch64/sme-intrinsics/acle_sme_mopa-za32.c` — f32/f16/bf16/s8/u8 MOPA. + - `.../acle_sme_mopa-za64.c` — s16/u16/f64 + su/us MOPA (za64). + - `.../acle_sme_zero.c` — svzero_za / svzero_mask_za. + - `.../acle_sme_st1.c` — svst1_{hor,ver}_za32/za128 arg order (tile, slice, pg, ptr). + - `.../acle_sme_read.c` — svread_{hor,ver}_za32_{s32,f32}_m (inactive, pg, tile, slice). + - `.../acle_sme_str.c` — svstr_za(slice, ptr). + - `.../acle_sme_ld1.c` — svld1_{hor,ver}_za32 (tile, slice, pg, ptr). +3. **LLVM AArch64SME doc** — https://llvm.org/docs/AArch64SME.html — attribute→IR mapping + (`aarch64_pstate_sm_enabled` etc.), PSTATE.SM change consequences, streaming restrictions. +4. **Arm Learning Path "Multiplying matrices with SME2"** — + https://learn.arm.com/learning-paths/cross-platform/multiplying-matrices-with-sme2/7-sme2-matmul-intr/ + — canonical FP32 kernel: `svcntsw`, `svwhilelt_b32`, `svzero_za`, `svld1`, `svmopa_za32_m`, + `svst1_hor_za32(0, slice, pg, ptr)`, attributes `__arm_new("za")` + `__arm_locally_streaming` (top) + and `__arm_streaming __arm_inout("za")` (inner). +5. **"Hello SME!"** — https://arxiv.org/abs/2409.18779 — M4 microbenchmarks; Z-reg→ZA staging via + svld1 + MOPA is ~2.6× faster than direct ZA loads. +6. KleidiAI (`..._sme2_mopa` micro-kernels) and scalable-analyses/sme — for the EXACT int8 byte-pack + tiling contract (snippet 7.2 arithmetic must be validated against these). diff --git a/cactus-kernels/docs/sme/research/apple-macos-runtime.md b/cactus-kernels/docs/sme/research/apple-macos-runtime.md new file mode 100644 index 000000000..342475855 --- /dev/null +++ b/cactus-kernels/docs/sme/research/apple-macos-runtime.md @@ -0,0 +1,268 @@ +# Apple Silicon / macOS SME2 Runtime & Build Notes (M4 Pro, Apple clang 17) + +Practical specifics for compiling and running hand-written SME2 code on an Apple M4 Pro +with Apple clang 17. Where possible, claims are corroborated against published sources AND +verified empirically on the target box. + +## Verified test box (this machine) + +``` +Apple M4 Pro + 10 P-cores (hw.perflevel0.physicalcpu = 10), 4 E-cores (hw.perflevel1.physicalcpu = 4) +Apple clang version 17.0.0 (clang-1700.6.3.2) Target: arm64-apple-darwin25.3.0 +Xcode 26.2 (Build 17C52) +macOS (Darwin 25.3.0) +``` + +Local sysctl (all SME keys read directly): + +``` +hw.optional.arm.FEAT_SME = 1 +hw.optional.arm.FEAT_SME2 = 1 +hw.optional.arm.FEAT_SME2p1 = 0 +hw.optional.arm.FEAT_SME_I16I64 = 1 +hw.optional.arm.FEAT_SME_F64F64 = 1 +hw.optional.arm.FEAT_SME_F16F16 = 0 +hw.optional.arm.FEAT_SME_B16B16 = 0 +hw.optional.arm.SME_I8I32 = 1 +hw.optional.arm.SME_I16I32 = 1 +hw.optional.arm.SME_F32F32 = 1 +hw.optional.arm.SME_F16F32 = 1 +hw.optional.arm.SME_B16F32 = 1 +hw.optional.arm.SME_BI32I32 = 1 +hw.optional.arm.sme_max_svl_b = 64 # 64 bytes -> SVL = 512 bits +``` + +--- + +## 1. The exact `-march` / `-mcpu` string for SME2 intrinsics (Apple clang 17) + +**Recommendation: `-march=armv8-a+sme2`** (use `+sme` if you only need SME1 ACLE). +This is what the published Apple-M4 walkthroughs actually use and what runs here. + +### Empirically tested on this box (compiling SME2 intrinsics: `svld1_f32`, `svmopa_za32_f32_m`, `__arm_locally_streaming __arm_new("za")`) + +| Flag | Compiles SME2 intrinsics? | Notes | +|------|---------------------------|-------| +| (no flag, default) | **FAIL** | `error: function executed in streaming-SVE mode requires 'sme'` | +| `-march=armv8-a+sme` | OK | SME1 ACLE only; SME2-only intrinsics (e.g. multi-vector) need `+sme2` | +| `-march=armv8-a+sme2` | OK | **Recommended.** Matches mod_poppo / dev.to articles' `+sme` form | +| `-march=armv9-a+sme2` | OK (compiles) | Also accepted; see caveat below | +| `-mcpu=apple-m4` | OK (compiles) | Enables SME for the M4 target | +| `-mcpu=apple-m4+sme2` | OK (compiles) | | + +The compiled SME2 binary **runs** on the M4 Pro and reports streaming vector length: + +``` +streaming VL bytes (svcntsb) = 64 +streaming VL bits = 512 +``` + +i.e. SVL = 512 bits = 64 bytes, matching `hw.optional.arm.sme_max_svl_b = 64` and +tzakharko's "512-bits ... each vector register is 64 bytes ... ZA tile = 4096 bytes." + +### Caveats + +- **`-march=armv8-a+sme` is the form used by the published M4 sources.** The Zenn + (mod_poppo) article and the dev.to port both compile with literally + `clang -O2 -march=armv8-a+sme`. The architecture base is `armv8-a`, *not* `armv9`, + on Apple's toolchain examples. SME is added as a `+sme`/`+sme2` feature on top, so the + base version is largely irrelevant to whether SME intrinsics compile. +- **`-march=armv9-a+sme2` compiles, but armv9-a is misleading on Apple.** Apple does not + publicly position M4 as a full ARMv9 part, and crucially `armv9-a` implies non-streaming + SVE2 — which M4 does **not** implement (see §3). The base profile does not gate SME + feature availability, so prefer the `armv8-a+sme2` spelling to avoid implying full SVE. +- **`-mcpu=apple-m4` is accepted by clang 17** and turns on SME, but ties the object to + that CPU's tuning. For portable kernels prefer the explicit `+sme2` feature flag; gate + the actual call at runtime (§2) so the binary still loads on non-SME Macs. +- clang validates the *intrinsics against the feature flag*, not against the real target + CPU — so all the "compiles OK" rows above succeed even where the spelling is dubious. + Runtime legality is a separate question (§3). + +--- + +## 2. Runtime feature detection (sysctl key spellings) + +Use `sysctlbyname` and treat a returned integer `1` as "present". Key spellings +**verified by direct read on this box** (all returned the stated values): + +```c +// returns 1 on M4 Pro +sysctlbyname("hw.optional.arm.FEAT_SME", ...) == 1 // = 1 +sysctlbyname("hw.optional.arm.FEAT_SME2", ...) == 1 // = 1 +sysctlbyname("hw.optional.arm.FEAT_SME_I16I64", ...) == 1 // = 1 +sysctlbyname("hw.optional.arm.sme_max_svl_b", ...) // = 64 (bytes -> 512-bit SVL) +``` + +- Key spellings confirmed: `hw.optional.arm.FEAT_SME`, `hw.optional.arm.FEAT_SME2`, + `hw.optional.arm.FEAT_SME_I16I64`, `hw.optional.arm.sme_max_svl_b`. (The `FEAT_` + prefix is capitalized; the SVL key is lowercase `sme_max_svl_b`.) These follow Apple's + documented "Determining Instruction Set Characteristics" `hw.optional.arm.FEAT_*` scheme. +- `sme_max_svl_b` returns the **max Streaming Vector Length in bytes** (64 here). Multiply + by 8 for bits (512). This matches the runtime `svcntsb()` result. +- Apple silicon also exposes per-datatype keys: `SME_I8I32`, `SME_I16I32`, `SME_F32F32`, + `SME_F16F32`, `SME_B16F32`, `SME_BI32I32`, plus `FEAT_SME_F64F64` — all `1` here. Note + these are *not* `FEAT_`-prefixed (e.g. `hw.optional.arm.SME_I16I32`), whereas the + 64-bit-accumulate variant is `FEAT_SME_I16I64`. `FEAT_SME2p1`, `FEAT_SME_F16F16`, + `FEAT_SME_B16B16` are `0` on M4 (not implemented). +- macOS 15+ exposes a fast-path bitmask `hw.optional.arm.caps` (returned a packed integer + here) if you want all feature bits in one call; individual keys remain the portable path. +- ACLE alternative: `#include ` and call `__arm_has_sme()` from non-streaming + code as a guard before dispatching into a streaming kernel. + +### Gotcha: don't rely on the Go stdlib for SME keys + +`src/internal/cpu/cpu_arm64_darwin.go` (Go master) only probes +`armv8_1_atomics`, `armv8_crc32`, `armv8_2_sha512`, `armv8_2_sha3`, `FEAT_DIT`, `FEAT_SB`. +It does **not** enumerate any SME key — so it is not a citation for SME spellings, only a +template for the `sysctlbyname`-probe pattern. Apple's developer docs + the live sysctl on +this box are the authoritative source for the SME key names. + +--- + +## 3. ZA / streaming-mode runtime behavior on macOS + +### Non-streaming SVE is NOT available — it SIGILLs + +M4 implements only the **streaming** SVE subset (inside SME), not regular SVE/SVE2. +Verified on this box: a plain non-streaming `svcntw()` call **crashes with SIGILL** +(process exit code 132 = 128 + SIGILL(4)). The published sources agree: +"the Apple M4 does *not* support non-streaming SVE, so calling `svcnt*` functions outside +streaming mode will cause a SIGILL." Practical rule: **every SVE/SME intrinsic must run +inside streaming mode** (a function marked `__arm_streaming`, `__arm_streaming_compatible`, +or `__arm_locally_streaming`). Do not call SVE intrinsics from ordinary code. + +### Kernel auto save/restore of ZA / streaming state (XNU) + +Per the XNU `doc/arm/sme.md`, the kernel **does** transparently manage SME state across +context switches — you do not save/restore ZA yourself: + +- `machine_switch_context()` saves/restores `TPIDR2_EL0`, `ZA`, and `ZT0`. +- It is **lazy/validity-aware**: `machine_save_sme_context()` reads `SVCR.ZA` and skips + saving when ZA/ZT0 are not actually live; lazy allocation defers ZA storage until an + SME trap (clears `SCTLR_EL1.SMEN` in `machine_restore_sme_context()`). +- On every kernel entry from EL0 with `PSTATE.SM` set, XNU saves Z/P/SVCR and **clears + `PSTATE.SM`** (in `locore.s`), because the kernel itself uses NEON SIMD that is illegal + in streaming mode. Kernel code is forbidden from entering streaming SVE mode. +- Thread state is reachable via Mach flavors `ARM_SME_STATE` / `ARM_SME2_STATE` + (`arm_sme_state_t`, holding SVCR, TPIDR2_EL0, SVL); stored in `thread->machine.usme`. + +**Net:** preemption and normal context switches are safe and invisible — the OS preserves +ZA/streaming state for you. + +### Is `__arm_new("za")` sufficient? + +Yes, for ordinary use. With `__arm_new("za")` clang sets up a fresh ZA context on function +entry and disables it before return; if ZA is dormant it emits the lazy-save commit. With +`__arm_locally_streaming` clang toggles `PSTATE.SM` at the function boundary for you +("Clang manages PSTATE.SM automatically; it is not the source code's responsibility"). +You generally never write `smstart`/`smstop` or touch TPIDR2 by hand. Use +`__arm_in/out/inout/preserves("za")` to thread ZA across a call boundary without re-init. + +### Signal-handler hazards (the real caveat) + +This is the sharp edge, and it is **under-documented** by Apple: + +- XNU's own note flags the core hazard: "xnu has in-kernel SIMD instructions which become + illegal while the CPU is in streaming SVE mode. This poses a problem if xnu interrupts + EL0 while it is in the middle of executing SME-accelerated code." The kernel handles this + for its own re-entry, but it means async interruption mid-streaming is a real code path. +- XNU states it **does not** send SME/SVE thread state in Mach exception messages, and the + doc does not specify what `PSTATE.SM`/ZA look like inside a delivered POSIX signal handler + or whether SME state lands in the signal `ucontext`/`mcontext`. **Treat it as unspecified.** +- Practical guidance for our kernels: + - **Do not execute SME/streaming or SVE intrinsics inside a signal handler.** A handler + may run in non-streaming mode where those instructions SIGILL, and ZA may not be the + live tile you expect. + - Keep streaming regions short and self-contained; don't `longjmp`/`setjmp` across a + `__arm_locally_streaming` boundary (state-toggle code may be skipped). + - Don't assume signal-interrupted ZA is recoverable from the handler; rely on the kernel + to restore it on normal return, not on hand-inspecting the signal context. + +### P-core vs E-core performance (and the ~5x) + +- There is **one SME unit per CPU cluster**, shared by all cores in that cluster: a larger + SME block in the P-core cluster, a smaller one in the E-core cluster. Throughput is a + *cluster* resource — piling more threads onto the same cluster does **not** multiply SME + throughput; it is gated by the shared unit. +- The P-core SME unit sustains ~**2.0–2.3 FP32 TFLOPS** (FMOPA outer product; ~2009 GFLOPS + measured single-P-core). The E-core SME block is characterized as "much slower." +- The **~5x P-over-E** figure is an order-of-magnitude estimate consistent with the sources + rather than a single published SME benchmark: M4 E-cores are roughly half the compute + width and run at far lower clocks (general M4 measurements show E-cores ~4x slower on + CPU work), and the E-cluster SME block is physically smaller. So **expect roughly 4–5x + worse SME throughput on E-cores**; target the P-cluster for SME kernels. +- **Thread affinity on macOS:** there is **no `pthread_setaffinity_np`** and no public API + to pin a thread to a specific core/cluster. The realistic lever is **QoS classes**: high + QoS (`QOS_CLASS_USER_INTERACTIVE`, or `USER_INITIATED`) biases the scheduler toward + **P-cores**; low QoS (`UTILITY`/`BACKGROUND`) pushes work to **E-cores**. Set it via + `pthread_set_qos_class_self_np(...)` or a `dispatch_queue` with the desired QoS. This is a + *hint*, not a guarantee — there is no hard pinning. (The deprecated Mach + `THREAD_AFFINITY_POLICY` is advisory and effectively a no-op for core selection on Apple + silicon.) For maximum SME throughput: run the streaming kernel on a high-QoS thread and + avoid oversubscribing the cluster. + +--- + +## 4. Minimum toolchain for SME ACLE; known Apple-clang bugs + +- **Minimum: Apple clang 17 / Xcode 26 era.** Published working setups: macOS 15.7.1, + Xcode 26.0.1, Apple clang 17.0.0 (dev.to / mod_poppo). This box: Apple clang 17.0.0 + (clang-1700.6.3.2), Xcode 26.2 — confirmed building & running SME2. SME ACLE + (``, `__arm_streaming`, `__arm_new("za")`, `__arm_locally_streaming`) requires + a recent LLVM/clang; older Xcode (pre-26) toolchains predate stable Apple SME ACLE support + and should be considered unsupported for hand-written SME2. +- **Known LLVM/clang hazard (LLVM issue #86743):** with only `+sme` (no streaming context + established), LLVM could emit SVE instructions that are illegal outside streaming mode — + which on M4 means SIGILL. Mitigation: always wrap intrinsics in a properly attributed + streaming function (`__arm_locally_streaming` / `__arm_streaming`) and build with `+sme2`; + do not hand-emit SVE expecting non-streaming execution. +- Because clang accepts SME intrinsics under any of the §1 flags regardless of real target + legality, **always gate the streaming entry point behind a runtime `FEAT_SME2` sysctl + check (or `__arm_has_sme()`)** so the binary still loads/runs on non-SME Macs and you + never hit a SIGILL on unsupported hardware. + +--- + +## Quick reference (copy/paste) + +```sh +# Build +clang -O2 -march=armv8-a+sme2 kernel.c -o kernel # recommended +# (intrinsics also compile under armv9-a+sme2 / -mcpu=apple-m4, but prefer the above) +``` + +```c +#include +#include + +static int has_sme2(void){ + int v=0; size_t sz=sizeof(v); + return sysctlbyname("hw.optional.arm.FEAT_SME2",&v,&sz,0,0)==0 && v==1; +} + +// All SVE/SME intrinsics MUST be inside a streaming + ZA scope. +__arm_locally_streaming __arm_new("za") +static void kernel(/*...*/){ + // svcntsb() == 64 here (512-bit SVL); ZA tile == 4096 bytes + // ... svmopa_za32_f32_m(...), etc. +} + +int main(void){ + if(!has_sme2()) return run_fallback(); // never SIGILL on non-SME hardware + kernel(/*...*/); // run on a high-QoS thread for P-cluster +} +``` + +## Sources +- mod_poppo / Zenn, "Trying Out Arm's SME" (`-march=armv8-a+sme`, non-streaming SVE SIGILL, `__arm_locally_streaming`/`__arm_new("za")`, svcntw=16): https://zenn.dev/mod_poppo/articles/arm-scalable-matrix-extension?locale=en +- dev.to port of the above (versions: macOS 15.7.1 / Xcode 26.0.1 / clang 17.0.0; SIGILL quote): https://dev.to/aratamizuki/trying-out-arms-scalable-matrix-extension-with-apple-m4-or-qemu-1cgh +- tzakharko/m4-sme-exploration (512-bit SVL, 64-byte vectors, 4096-byte ZA, one SME block per cluster, P-core ~2 TFLOPS FP32, E-core "much slower", non-streaming SVE absent): https://github.com/tzakharko/m4-sme-exploration/blob/main/reports/01-sme-overview.md +- XNU `doc/arm/sme.md` (kernel save/restore of TPIDR2_EL0/ZA/ZT0, lazy save, PSTATE.SM cleared on kernel entry, ARM_SME_STATE/ARM_SME2_STATE, no SME state in Mach exceptions, streaming-vs-kernel-SIMD hazard): https://github.com/apple-oss-distributions/xnu/blob/main/doc/arm/sme.md +- Clang AttributeReference (SME attributes: __arm_streaming, __arm_streaming_compatible, __arm_locally_streaming, __arm_new("za"), __arm_in/out/inout/preserves): https://clang.llvm.org/docs/AttributeReference.html +- LLVM issue #86743 (SVE emitted under +sme without streaming -> illegal outside streaming): https://github.com/llvm/llvm-project/issues/86743 +- Go `cpu_arm64_darwin.go` (sysctlbyname probe pattern; does NOT list SME keys): https://github.com/golang/go/blob/master/src/internal/cpu/cpu_arm64_darwin.go +- Apple Developer, "Determining Instruction Set Characteristics" (hw.optional.arm.FEAT_* sysctl scheme): https://developer.apple.com/documentation/kernel/1387446-sysctlbyname/determining_instruction_set_characteristics +- Apple Developer Forums (no pthread_setaffinity_np on macOS; QoS classes drive P/E placement): https://developer.apple.com/forums/thread/674456 +- Eclectic Light, "Inside M4 chips: E and P cores" (E-cores ~4x slower, ~half compute units, QoS-driven placement): https://eclecticlight.co/2024/11/18/inside-m4-chips-e-and-p-cores/ +- Local verification: live `sysctl` reads + compiled/ran SME2 binary on this M4 Pro (see §Verified test box). diff --git a/cactus-kernels/docs/sme/research/kleidiai-int4-sme2.md b/cactus-kernels/docs/sme/research/kleidiai-int4-sme2.md new file mode 100644 index 000000000..69bec515a --- /dev/null +++ b/cactus-kernels/docs/sme/research/kleidiai-int4-sme2.md @@ -0,0 +1,241 @@ +# KleidiAI SME2 INT8×INT4 mopa matmul — extraction + mapping onto Cactus CQ4 + +Source: local clone `/tmp/sme-refs/kleidiai` (ARM-software/kleidiai). +Target Cactus file: `cactus-kernels/src/matmul.cpp`. + +The primary micro-kernel studied is the **symmetric** dynamic-INT8 LHS × 4-bit-32-block RHS f32-output SME2 mopa kernel, because it is the closest analogue to Cactus CQ4 (both LHS and RHS are *symmetric* per-block scaled int with no zero-point / row-sum correction in the hot path): + +``` +kai_matmul_clamp_f32_qsi8d32p1vlx4_qsi4c32p4vlx4_1vlx4vl_sme2_mopa +``` + +Its paired packers (confirmed from `test/tests/matmul_clamp_f32_qsi8d32p_qsi4c32p_test.cpp:93-94, 118-119`): + +| operand | packer | +|---|---| +| LHS (dyn-quant + pack) | `kai_lhs_quant_pack_qsi8d32p_f32_neon` | +| RHS (pack pre-quant int4) | `kai_rhs_pack_nxk_qsi4c32ps1s0scalef16_qsu4c32s16s0_neon` | + +A second, *asymmetric* variant `kai_matmul_clamp_f32_qai8dxp1vlx4_qsi4c32p4vlx4_1vlx4vl_sme2_mopa` exists (qai8dxp = dynamic int8 with a per-row **zero-point + offset**, RHS carries per-column **row-sum + bias**). Its hot path is an external asm symbol (`kai_kernel_...`, not inlined). It is *less* aligned to Cactus (Cactus is symmetric, no zero-point) so I treat qsi8d32p as the reference and only note qai8dxp's extra correction terms where relevant. + +--- + +## 0. Tile geometry (mr/nr/kr/sr, block depth, SVL) + +From the qsi8d32p kernel `.c` (lines 20-37) and headers: + +``` +kai_m_step = 1 (× SVLs) kai_n_step = 4 (× SVLs) +kai_mr = 1 (× SVLs) kai_nr = 4 (× SVLs) +kai_kr = 4 kai_sr = 2 +kai_bl = 32 (quant block length, fixed multiple of 32) +``` + +`SVLs = kai_get_sme_vector_length_u32() = SVL_bytes/4` = number of **fp32 lanes** per Z/ZA tile row (`kai_common.h:235`). On a 512-bit SME implementation SVLs = 16. Therefore one mopa macro-tile is: + +* **M rows = mr = 1·SVLs** (e.g. 16 output rows) +* **N cols = nr = 4·SVLs** (e.g. 64 output cols), produced as **4 ZA tiles** `za0..za3`, each SVLs wide. +* The integer SMOPA consumes **int8** operands; the int4 RHS is widened to int8 *inside* the kernel via `luti4`/`ZT0` LUT before each mopa, so the accumulation tile depth per `smopa` is `kr = 4` along K (one 32-bit word of 4×int8 per K-step). `sr = 2` means a kr=4 block is sub-split into 2 nibble groups during packing (the s1s0 nibble interleave, see §1). + +`bl=32` (one quant block) ⇒ each K-block contributes `bl/kr = 32/4 = 8` mopa iterations per ZA tile. + +### Packed strides +LHS (`.c:42-44, 59-62`): per block = `bl·1 (int8) + 2 (fp16 scale)` bytes; `lhs_packed_stride = mr · num_blocks · (bl+2)`. **Scales live in a trailing region** after all the int8 values: `lhs_scales = lhs_packed + stride − mr·num_blocks·2` (`.c:175-176`). + +RHS (`.c:46-50, 64-75`): per block = `bl/2 (4-bit) + 2 (fp16 scale)` bytes; `rhs_packed_stride = nr · num_blocks · (bl/2 + 2)`. Scales trailing: `rhs_scales = rhs_packed + stride − nr·num_blocks·2` (`.c:177-178`). (The symmetric kernel carries **no** row-sum / bias; only the asymmetric qai8dxp variant appends `nr·4` rsum + `nr·4` bias per row, `qai8dxp...c:114-118`.) + +--- + +## 1. RHS 4-bit packing layout (`kai_rhs_pack_nxk_qsi4c32ps1s0scalef16_qsu4c32s16s0_neon`) + +Input RHS is already quantized to **qsi4c32** in *source* (`s16s0`) order, NxK with the layout *per row, per block*: `[uint16 fp16 scale][bl/2 bytes of packed nibbles]`, where the source nibble order is `s16s0` ("stride-16, start-0"): within a 32-element block the byte at offset `b` holds element `b` in the low nibble and element `b+16` in the high nibble. + +The packer does two things: + +**(a) Nibble re-order `s16s0 → s1s0`** (`convert_*` helpers, `.c:34-152`). Output `s1s0` means *sequential* nibble order packed two-per-byte: output uint16 holds elements `{k, k+1, k+2, k+3}` as nibbles. The bl==32 fast path (`.c:110-120`): +```c +const uint8x16_t v0 = vld1q_u8(src_blk); // 16 src bytes = 32 nibbles, s16s0 +const uint8x16_t even_dup = vuzp1q_u8(v0, v0); +const uint8x16_t odd_dup = vuzp2q_u8(v0, v0); +const uint8x16_t lo_pairs_dup = vsliq_n_u8(even_dup, odd_dup, 4); // low half: seq pairs +const uint8x16_t hi_pairs_dup = vsriq_n_u8(odd_dup, even_dup, 4); // high half: seq pairs +const uint8x16_t packed = vcombine_u8(vget_low_u8(lo_pairs_dup), vget_low_u8(hi_pairs_dup)); +vst1q_u8(dst_bytes, packed); // 16 bytes = 32 nibbles, s1s0 +``` + +**(b) Interleave 4 N-rows at uint16 granularity** (`interleave_rows4_u16`, `.c:276-311`). The packed-row destination stores, **column-major within a block over nr**, one uint16 (=4 nibbles along K) from each of the 4 consecutive N-rows side by side: +```c +const uint64_t packed0 = (uint64_t)row0_ptr[0] | (row1_ptr[0]<<16) | (row2_ptr[0]<<32) | (row3_ptr[0]<<48); +*((uint64_t*)dst_ptr) = packed0; // row0..row3 nibble-quad #0 +dst_ptr += 2*nr; // next K-quad column, stride nr (in uint16) +``` +So the in-block layout is: for each of the `bl/4` K-quad columns, `nr` uint16 values (one per N-row in the nr group), i.e. **`[k-quad][n-row]` with n-row contiguous**. Block stride = `bl4 · nr` uint16. After all `num_blocks` blocks, the per-(nr,block) **fp16 scales** are written contiguously in a trailing region (`.c:469-476`), packed 4-rows-per-uint64. + +### Why this matches the mopa loads +In the kernel inner loop (`.c:270`) the RHS is read `ld1w {z2.s-z3.s}` = 2 vectors of 32-bit words. Each 32-bit word = 8 nibbles for one N-column laid along K; the `nr`-contiguous interleave means consecutive lanes are consecutive N-columns. `luti4 {z4.b-z5.b}, zt0, z2[0]` (`.c:277`) expands those nibbles to int8 via the ZT0 LUT (`lut[16] = {-8..7}`, `.c:40`), producing the int8 RHS operand for `smopa`. + +**Nibble value convention:** packer XORs each output nibble with `0x8` (`s1s0scalef16` stores raw signed nibble; the generic `qsu4c32s1s0` variant does `dst_q ^ 0x8888`, `.c:241`, converting signed→unsigned-offset-8). The kernel's LUT then maps the stored nibble index back to the signed int8 value. **Low nibble = lower K index, high nibble = next K index** (sequential s1s0). + +--- + +## 2. LHS dynamic INT8 quant+pack (`kai_lhs_quant_pack_qsi8d32p_f32_neon`) + +Per M-row, per 32-block (`.c:101-126`): +```c +float amax = max|lhs|; // per block (32 elems) +float sf = amax / 127; // symmetric scale +float sf_inv = sf ? 1/sf : 0; +for (bl_idx = 0; bl_idx < bl; bl_idx += kr) // kr = 4 + for (kr_idx = 0; kr_idx < kr; ++kr_idx) + lhs_packed_ptr[kr_idx] = (int8)round(lhs[kr_idx] * sf_inv); // symmetric int8 + lhs_packed_ptr += mr * kr; // interleave mr rows, kr-quad stride +lhs_packed_scales[0] = kai_cast_f16_f32(sf); // fp16 scale, trailing region +lhs_packed_scales += mr; +``` +Layout: **kr=4 int8 values per M-row interleaved across `mr` rows** (`ptr += mr*kr`), so the packed LHS is `[k-quad][m-row]`-interleaved exactly mirroring the RHS `[k-quad][n-row]` interleave. fp16 per-block scales sit in a trailing region (`lhs_packed + stride − mr·num_blocks·2`). The kernel reads LHS as `ld1h {z8.h}` (`.c:273`) — i.e. it reads **pairs of int8 as 16-bit lanes** and feeds them directly to `smopa za, z8.b, z4.b`. + +--- + +## 3. SMOPA accumulation loop (the hot path) + +Full structure from `.c:184-441`. Loop nest: **N (n_step=4·SVLs) → M (m_step=1·SVLs) → K-block (bl=32) → in-block (kr=4)**. + +Setup once: +```asm +smstart ; enter streaming SVE + enable ZA +cntw x14 ; x14 = SVLs (fp32 lanes) +ptrue p0.b, all +ldr zt0, [x6] ; ZT0 = int4->int8 LUT (lut = {-8..7}) +ld1rw z15.s = scalar_min ; ld1rw z17.s = scalar_max +``` + +Per K-block (`.c:252-289`): +```asm +3: ; .LOOP_K_START + zero {za} ; clear all 4 ZA.s tiles + ld1w {z0.s-z1.s}, pn8/z, [x17], x21 ; RHS fp16 block-scales (2 vec) + zip {z0.h-z1.h}, z0.h, z1.h ; interleave to per-lane fp16 scale pairs + mov x11, bl ; in-block counter = 32 +4: ; .LOOP_BL_START (8 iters: 32/4) + ld1w {z2.s-z3.s}, pn8/z, [x16], x20 ; RHS packed int4 (2 vec = 64 nibbles/col grp) + ld1h {z8.h}, p0/z, [x22, x20, lsl #1] ; LHS int8 (read as 16-bit lanes) + inch x20, all + luti4 {z4.b-z5.b}, zt0, z2[0] ; int4 -> int8 (ZT0 LUT) for za0,za1 cols + luti4 {z6.b-z7.b}, zt0, z3[0] ; int4 -> int8 for za2,za3 cols + smopa za0.s, p0/m, p0/m, z8.b, z4.b ; INT8 outer-product accumulate, signed + smopa za1.s, p0/m, p0/m, z8.b, z5.b + smopa za2.s, p0/m, p0/m, z8.b, z6.b + smopa za3.s, p0/m, p0/m, z8.b, z7.b + subs x11, x11, #4 ; advance kr=4 along K + b.gt 4b +``` +`smopa za.s, Zn.b, Zm.b` is the **signed 8-bit→32-bit MOPA**: it computes `za[i][j] += sum_{p=0..3} Zn.b[i*4+p] * Zm.b[j*4+p]` — i.e. each ZA fp32 lane is a 4-deep int8 dot-product outer product. Over the 8 in-block iterations the K=32 block is fully reduced into the int32 ZA tile. **No scaling is applied during integer accumulation** — ZA holds raw int32 dot products. + +### Post-mopa scaling (per K-block, in ZA→Z drain, `.c:294-357`) +```asm +ld1b {z16.b}, p4/z, [x23, x21] ; LHS fp16 block-scales (this block, mr rows) +inch x21, all +5: ; .LOOP_ZA (over mr output rows) + pnext p3.h ; clastb z19.h, p3, z19.h, z16.h ; select this row's LHS fp16 scale, broadcast + mova {z28.b-z31.b}, za0h.b[w12, 0:3] ; read 4 ZA fp32 lanes (int32 results) for row + scvtf {z28.s-z31.s}, {z28.s-z31.s} ; int32 -> fp32 + ; combined scale = LHS_scale(fp16) * RHS_scale(fp16), widened fp16->fp32: + movprfx z8,z18 ; fmlalb z8.s, z19.h, z0.h ; z8 = lhs_s * rhs_s (lane lo, za0) + movprfx z9,z18 ; fmlalb z9.s, z19.h, z1.h ; ... za1 + movprfx z10,z18; fmlalt z10.s, z19.h, z0.h ; ... za2 (top fp16 lane) + movprfx z11,z18; fmlalt z11.s, z19.h, z1.h ; ... za3 + cmp x10, K ; b.ne 6f ; first K-block? init : accumulate + fmul z24.s, z8.s, z28.s ; first block: result = scale * int_result + fmul z25.s, z9.s, z29.s + fmul z26.s, z10.s, z30.s + fmul z27.s, z11.s, z31.s + b 7f +6: ; .ACCUMULATE (subsequent K-blocks) + ld1w {z24.s-z27.s}, pn9/z, [x25] ; load partial f32 result + fmla z24.s, p0/m, z8.s, z28.s ; result += scale * int_result + fmla z25.s, p0/m, z9.s, z29.s + fmla z26.s, p0/m, z10.s, z30.s + fmla z27.s, p0/m, z11.s, z31.s +7: st1w {z24.s-z27.s}, pn9, [x25] ; store f32 partial to DST + add x25, x25, dst_stride_row + cmp x12, x15 ; blt 5b +``` +Key points: +* `za0h.b[w12,0:3]` drains **4 fp32 lanes (one row of the 1·SVLs output, 4 ZA tiles wide)** at a time. +* The **per-block** combined fp32 scale is `lhs_scale[m-row] * rhs_scale[n-col]`, both fp16, multiplied via `fmlalb`/`fmlalt` (fp16-widening). `fmlalb` uses even/low fp16 lanes (za0/za1), `fmlalt` the odd/high lanes (za2/za3) — matching the `zip` of the two RHS scale vectors. +* The f32 *partial* result is **accumulated in DST memory across K-blocks** (`fmla` into a reloaded `ld1w`), not in registers. This is how per-block scales are applied **after** each integer mopa but **before** summing blocks: each block's `int32 · (s_lhs·s_rhs)` is added into the running f32 output. + +### Rescale to f32 + clamp (`.c:333-336, 365-388`) +The int32→f32 conversion is `scvtf`, the f32 scale is `fmul`/`fmla` as above (so DST already holds correctly-scaled f32). After the K loop, an optional clamp pass: +```asm +ld1w {z24.s-z27.s}, pn9/z, [x24] +fclamp {z24.s-z27.s}, z15.s, z17.s ; SME2 fclamp to [scalar_min, scalar_max] +st1w {z24.s-z27.s}, pn9, [x24] +``` +`is_clamp_valid` is 0 when min=-FLT_MAX & max=+FLT_MAX, skipping the pass. + +### qai8dxp (asymmetric) deltas, for completeness +The asymmetric variant additionally: (a) LHS pack stores a per-row **fp32 scale + int32 zero-point/offset** (`kai_num_bytes_multiplier_lhs=4`, `..._offset_lhs=4`); (b) RHS pack stores a per-column **row-sum (fp32)** and **bias (fp32)** (`qai8dxp...c:75-76, 114-118`); the int mopa result is corrected by `lhs_offset · rhs_rowsum` before fp32 scaling and `+bias`. Cactus is symmetric, so these are **not** needed — qsi8d32p is the right template. + +--- + +## 4. Mapping onto Cactus CQ4 + +### 4.1 What Cactus has today (the "expanded" INT8 weight + INT8 acts dot-product GEMM) + +`src/matmul.cpp` M>1 path (`cactus_quant_matmul`, lines ~1672-1801) and `tq_preexpand_weights` (~line 1440): + +* **Weights** are dequantized from k-bit codebook indices to **int8 via the int8-quantized codebook** (`tq_quantize_codebook_i8`, line 1233: `cb_i8[i]=round(codebook[i]/cb_scale)`, `cb_scale=max|codebook|/127`). `tq_expand_i8_16` (line 1290) gathers codebook int8 through `vqtbl1q_s8(cb_lut, idx)`. +* The expanded int8 weights are **4 N-rows interleaved at 32-bit granularity** by `tq_interleave_4x_s8` (line 115): given row0..row3 int8x16, it produces an output where each 16-byte store holds, for one 4-wide K position, `{row0[w], row1[w], row2[w], row3[w]}` 32-bit words. Layout written to `w_il` at `(nb·num_groups+g)·gs·4`: **`[K][4 N-rows]` with the 4 rows contiguous at 32-bit stride**, group-major, `gs*4` bytes per (Nblk,group). This is *exactly* `nr=4`-style N-row interleave but at **NEON 4-lane** granularity, not SME `nr=4·SVLs`. +* **Per-(Nblk,group) norms** are `n_f32[(nb·num_groups+g)·4 + ni] = norms[...] · cb_scale` (line 1429-1432). So the codebook int8 scale (`cb_scale`) is **folded into the weight norm** — the weight's effective fp32 scale is `norm · cb_scale`. +* **Activations** are Hadamard-transformed (`cactus_quant_transform_hadamard_activations`), then per-group dynamically quantized to int8 (`tq_quantize_group_i8`, line 1247: `scale=max|x|/127`, symmetric) producing `act_i8` + per-group fp32 `act_scales`. This is **identical in spirit to qsi8d32p**: symmetric int8, per-group (=per-block) fp32 scale. +* **The GEMM core** (lines 1745-1786): for each group, loads 8 interleaved weight vectors `b00..b13` (covering K=32 × 4 N-rows) and per-M-row activation `a_lo/a_hi`, and does `vdotq_laneq_s32` (NEON SDOT) into `row_acc` int32. Then drains: `running_sum += f32(row_acc) · (norms_v · act_scale)`. **This is the NEON-dot analogue of the SME2 mopa**: int8·int8 → int32 dot, then `int32 · (weight_norm·cb_scale) · act_scale` → f32. The block depth per SDOT lane is 4 (= `kr`), exactly matching SMOPA's 4-deep int8 accumulation. + +### 4.2 Operand correspondence (Cactus ↔ KleidiAI mopa) + +| concept | Cactus CQ4 | KleidiAI qsi8d32p mopa | +|---|---|---| +| LHS = activations | int8, symmetric, per-group (`gs`) fp32 scale `act_scales` | int8, symmetric, per-block (`bl=32`) **fp16** scale | +| LHS pack layout | `[K][m-row]` not pre-packed; rows read with `a_rows[mi]+g·gs+k` | `[k-quad][m-row]` interleaved, `ptr += mr·kr`; fp16 scales trailing | +| RHS = weights | **already int8** ("expanded"), `[K][4 N-rows]` @32-bit, per-(Nblk,grp) fp32 `norm·cb_scale` | int4 packed `[k-quad][n-row]` @uint16, fp16 scale; widened to int8 in-kernel by `luti4` | +| K-reduce unit | `vdotq_laneq_s32` 4-deep int8 dot | `smopa` 4-deep int8 outer-product | +| N-row tile | 4 (NEON lanes) | nr = 4·SVLs | +| M-row tile | TILE_M = 8 | mr = 1·SVLs | +| post-int scaling | `f32(acc)·(norm·cb_scale)·act_scale` | `scvtf(za)·(s_lhs_fp16·s_rhs_fp16)`, accumulated per block | +| group/block size | `gs` (≥32, ≤256) | `bl` (multiple of 32; kernel reduces 32 at a time) | + +### 4.3 How a SMOPA path would consume re-tiled Cactus operands + +Cactus' "expanded" buffer is **already int8** — so unlike KleidiAI it does **not** need `luti4` (the int4→int8 LUT) at all; the SMOPA can consume Cactus int8 weights directly with `smopa za, z_act.b, z_wt.b`, skipping the LUT widening. The codebook int8 scale (`cb_scale`) is already folded into `n_f32`, so the f32 drain math `scvtf(za)·(norm·cb_scale)·act_scale` maps 1:1 onto KleidiAI's `scvtf(za)·(s_lhs·s_rhs)` with `s_rhs = norm·cb_scale`, `s_lhs = act_scale`. + +**Re-tiling required to feed SMOPA:** + +1. **N-row interleave widening: 4 → nr = 4·SVLs.** Cactus interleaves exactly 4 N-rows (`tq_interleave_4x_s8`) because NEON SDOT processes 4 output columns (4×int32 lanes). SMOPA's ZA tile is `nr = 4·SVLs` wide across 4 ZA tiles. The expanded weights must be re-laid as `[k-quad][n-col]` with **n-col contiguous over the full nr=4·SVLs**, split into 4 ZA-tile groups of SVLs columns each (mirroring KleidiAI's `interleave_rows4_u16` but at int8 and 4·SVLs width). Concretely: for each K-quad, store `nr` int8 columns contiguously; the per-K-quad stride feeds one `ld1w {z2.s-z3.s}` per 2 ZA tiles. + +2. **K interleave at kr=4 (32-bit words).** Both Cactus and SMOPA use a 4-deep int8 reduction. Cactus' `[K][4 N-rows]@32-bit` already groups K into 4-lane SDOT words; the SMOPA layout wants `[k-quad][n-col]` so that one `ld1w` lane = one 32-bit word = 4 K-values for one N-column. The transform is a transpose of Cactus' current "4-N-row-major within a K-word" to "N-col-major across the nr group, K-quad-major" — i.e. **swap the inner (4 N-row) grouping for an (nr N-col) grouping**, keeping the 4-deep K word intact. + +3. **LHS (activation) re-pack to qsi8d32p form.** Cactus reads activations row-wise unpacked. SMOPA wants `[k-quad][m-row]` interleaved over `mr=1·SVLs` rows (`lhs_pack ... ptr += mr·kr`) with **fp16** per-block scales in a trailing region. So: (a) pack act_i8 into `mr`-row-interleaved kr=4 words, (b) convert `act_scales` (fp32) → fp16 and store trailing. Block length must be 32 (or feed gs in 32-chunks like the kernel's `bl` inner loop). + +4. **Scale dtype.** KleidiAI uses **fp16** for both LHS and RHS per-block scales and combines via `fmlalb`/`fmlalt`. Cactus keeps fp32 `n_f32` and `act_scales`. To reuse the KleidiAI mopa verbatim, Cactus would down-convert both to fp16 (precision loss; acceptable since these are per-block amax/127 scales). Alternatively a Cactus-custom drain can keep fp32 scales (`fmul`/`fmla` with `ld1rw`-broadcast fp32) and skip the fp16 widen — preferable given Cactus already has fp32 `norm·cb_scale`. + +5. **No int4 stage / no `luti4`.** Because Cactus pre-expands to int8, the SMOPA path **omits** the `luti4 {z4.b-z5.b}, zt0, z2[0]` widening and the ZT0 LUT setup; weights load directly as int8 via `ld1w`/`ld1b`. This is the chief structural simplification vs. KleidiAI's int4 kernel (at the cost of 2× weight memory traffic — Cactus already pays this for the SDOT path). If memory traffic matters, Cactus could instead keep weights as int4 and adopt KleidiAI's `luti4` widening verbatim, packing with `kai_rhs_pack_nxk_qsi4c32ps1s0scalef16_qsu4c32s16s0_neon` and folding `cb_scale` into the fp16 RHS scale. + +6. **Block-wise f32 accumulation in DST.** KleidiAI accumulates per-block scaled results into DST memory across K-blocks (`fmla` into reloaded `ld1w`). Cactus accumulates in registers (`running_sum[mi]`) across groups. For SMOPA with `gs > 32`, Cactus must either (a) follow KleidiAI and re-load/accumulate DST per 32-block, or (b) keep a register f32 accumulator per (m-row, n-tile) and add each 32-block's `scvtf(za)·s_lhs·s_rhs`. Option (b) matches Cactus' current register-accumulate style and avoids DST round-trips when the `nr×mr` tile fits ZA + Z registers. + +### 4.4 Summary of the minimal SMOPA bring-up for Cactus + +* Reuse Cactus' int8 expanded weights and int8 acts as-is (symmetric, per-group fp32 scale, `cb_scale` folded into norm). +* Re-tile weights from `[K][4 N-rows]@32-bit` → `[k-quad][nr=4·SVLs N-cols]` across 4 ZA tiles (transpose inner grouping, keep kr=4 K-words). +* Re-pack acts to `[k-quad][mr=1·SVLs M-rows]` interleaved + fp16 (or fp32) trailing scales. +* Inner loop: `ld1w` weights (int8, no `luti4`) + `ld1h` acts → `smopa za0..3` over 8 iters per 32-block → `mova`/`scvtf` drain → `fmul/fmla` by `act_scale · (norm·cb_scale)` → optional `fclamp` → store f32 (or convert to fp16 for Cactus' `__fp16* C`). +* This is a drop-in structural match to `kai_matmul_clamp_f32_qsi8d32p1vlx4_qsi4c32p4vlx4_1vlx4vl_sme2_mopa` minus the int4 widening stage. + +--- + +## File/line index (for re-verification) +* mopa hot loop: `/tmp/sme-refs/kleidiai/kai/ukernels/matmul/matmul_clamp_f32_qsi8d32p_qsi4c32p/kai_matmul_clamp_f32_qsi8d32p1vlx4_qsi4c32p4vlx4_1vlx4vl_sme2_mopa.c:184-441` (tile args 20-37, scales 175-178) +* RHS pack: `/tmp/sme-refs/kleidiai/kai/ukernels/matmul/pack/kai_rhs_pack_nxk_qsi4c32ps1s0scalef16_qsu4c32s16s0_neon.c` (nibble convert 34-152, row4 interleave 276-311, driver 398-503) +* LHS quant+pack: `/tmp/sme-refs/kleidiai/kai/ukernels/matmul/pack/kai_lhs_quant_pack_qsi8d32p_f32_neon.c:72-131` +* qai8dxp asymmetric variant: `/tmp/sme-refs/kleidiai/kai/ukernels/matmul/matmul_clamp_f32_qai8dxp_qsi4c32p/kai_matmul_clamp_f32_qai8dxp1vlx4_qsi4c32p4vlx4_1vlx4vl_sme2_mopa.c` (rsum/bias/zero-point 42-118) +* test pairing: `/tmp/sme-refs/kleidiai/test/tests/matmul_clamp_f32_qsi8d32p_qsi4c32p_test.cpp:93-94,118-119` +* Cactus expand/interleave: `src/matmul.cpp` `tq_interleave_4x_s8`:115, `tq_quantize_codebook_i8`:1233, `tq_quantize_group_i8`:1247, `tq_expand_i8_16`:1290, `tq_preexpand_weights`:1440, M>1 GEMM:1672-1801, dot core:1745-1786 diff --git a/cactus-kernels/docs/sme/research/scalable-sme-reference.md b/cactus-kernels/docs/sme/research/scalable-sme-reference.md new file mode 100644 index 000000000..a3a2ebf10 --- /dev/null +++ b/cactus-kernels/docs/sme/research/scalable-sme-reference.md @@ -0,0 +1,208 @@ +# scalable-analyses/sme — Known-Good SME Matmul Reference (M0 FP32 FMOPA, M2 INT8 SMOPA) + +Source: https://github.com/scalable-analyses/sme ("Hello SME!", SC24 workshop, DOI 10.1109/SCW63240.2024.00185; arXiv 2409.18779). +Local clone: `/tmp/sme-refs/scalable-sme` @ commit `c9de42a561c9c3bf9e926748fb5113a075d1f096` (2025-03-31). +Target M4 device: 2024 11-inch iPad Pro, Apple M4 SoC — first publicly available SME silicon. **SVL = 512 bits** (svcntw = 16 FP32 lanes, svcntb = 64 INT8 lanes). + +These hand-written `.s` kernels are validated against Apple Accelerate `cblas_sgemm` in `gemm.cpp` (max abs/rel error printed; README shows 0.0 error vs reference for the libxsmm path). They are the templates we adapt in +`cactus-kernels/src/matmul_sme2.cpp`. + +--- + +## 0. The ZA / SVL geometry on M4 (the facts everything below depends on) + +- **SVL = 512 bits.** A ZA *tile group* (`ZA`) is `SVL x SVL` bits = `64 x 64` bytes. +- For **`.s` (FP32) element size**, there are **4 ZA tiles** `za0.s..za3.s`, each holding a **16 x 16 FP32** accumulator (16 rows x 16 cols x 4 bytes; 16*4 = 64 bytes per row = SVL). +- `fmopa za.s, p0/m, p1/m, zN.s, zM.s` computes a **16x16 rank-1 (outer-product) update**: `ZA[i][j] += zN[i] * zM[j]` over the 16 active FP32 lanes. (Note source convention in these kernels: written `fmopa za, pred, pred, zB, zA` so the first Z is the column/B operand and the second Z is the row/A operand — see the `// c b a` comments in the loops.) +- **INT8 SMOPA** (`smopa za.s, p0/m, p1/m, zN.b, zM.b`) accumulates into the **same `.s` (32-bit int) ZA tiles**, but each Z holds **64 INT8 lanes** and the instruction does a **K=4 inner reduction**: it computes a 16x16 INT32 update where each of the 64 byte lanes is interpreted as a [16 outer] x [4 inner-K] layout, i.e. one `smopa` does the work of 4 FP32 `fmopa`s worth of K. So INT8 has **4 ZA tiles of 16x16 INT32**, each fed by 64-byte Z registers, K-step = 4 per instruction. +- ZA read-out uses either `str za[wIdx, #0], [ptr]` (store one ZA row-slice to memory) or `mov { z.. }, zah.s[wIdx, 0:3]` (move 4 horizontal ZA slices into Z regs, then `st1w`). ZA load-in uses `ldr za[..]` or `mov zah.s[..], { z.. }` after a Z-register `ld1w`. + +--- + +## 1. M0 reference — simplest standalone FP32 FMOPA (single 16x16 outer product + ZA→mem) + +This is the minimal, self-contained correctness kernel. It is exercised by `examples.c::showcase_fmopa_fp32_fp32_fp32()` with `a[i]=b[i]=i+1`, producing the rank-1 outer product `C[i][j] = (i+1)*(j+1)`. + +File: `MicrobenchmarkApp/kernels.s` lines 957–983. + +```asm + .global _example_sme_fmopa_fp32_fp32_fp32 + .align 4 +_example_sme_fmopa_fp32_fp32_fp32: + smstart // enter streaming-SVE + enable ZA + + ptrue p0.b // all-true governing predicate + + ldr z0, [x0] // stage A column (16 FP32) into Z0 + ldr z1, [x1] // stage B row (16 FP32) into Z1 + + fmopa za0.s, p0/m, p0/m, z0.s, z1.s // ZA0[i][j] += z0[i]*z1[j] (16x16) + + mov w12, #0 + mov x3, #16 // 16 ZA row-slices to store + +loop_example_sme_fmopa_fp32_fp32_fp32: + str za[w12, #0], [x2] // store ZA0 slice w12 -> C row + add w12, w12, #4 // (slice index stride: tile in low 2 bits) + add x2, x2, #16*4 // advance C by one 16-FP32 row + sub x3, x3, #1 + cbnz x3, loop_example_sme_fmopa_fp32_fp32_fp32 + + smstop // exit streaming, disable ZA + ret +``` + +C ABI (from `examples.c`): `void example_sme_fmopa_fp32_fp32_fp32(float* a, float* b, float* c)` — `a`,`b` are 16 floats, `c` is 16*16 floats (row-major, leading dim 16). **This is our M0 unit-test oracle.** + +Key M0 takeaways for `matmul_sme2.cpp`: +1. `smstart` / `smstop` bracket all ZA/streaming code; do PCS save/restore of `d8–d15` (and `x19–x30` if clobbered) — see the full GEMM kernels. +2. Stage operands into **Z registers first** (`ldr z` / `ld1w`) then `fmopa`. Do **not** try to feed memory directly. +3. ZA read-out via `str za[wN, #0]` walks the 16 row-slices; `wN += 4` per row because the low 2 bits of the slice index select the ZA tile (here tile 0). + +--- + +## 2. M0 reference — full FP32 GEMM with K-loop, 4 ZA tiles, Z-staging (32x32x32) + +This is the realistic M0 template: `C += A * B^T`, MxNxK = 32x32x32, using **all 4 ZA tiles** as a 2x2 grid of 16x16 accumulators (so 32x32 output). It loads C into ZA, runs the K-loop of `fmopa`, then reads ZA back to C. + +File: `MicrobenchmarkApp/kernels_gemm.s` lines 113–391 (`_gemm_micro_32_32_32`). The load-C and store-C phases are long (ZA<->Z<->mem via `ld1w/st1w` + `mov za..h`); the **load of the actual A/B tiles and the FMOPA accumulation** is the part to adapt: + +```asm + // ---- prologue: smstart, ptrue p0.b/p1.b/pn8.b, then load C(32x32) into za0..za3 + // via ld1w {z,z},pn8/z + `mov zaNh.s[w,0:3],{z..}` (lines 132-243) ---- + + mov x6, #32 // K loop count (K=32) +loop_32_32_k: + sub x6, x6, #1 + + // load A: two 16-FP32 columns of A (32 rows) -> z0, z1 + ldr z0, [x0] + add x0, x0, #64 // +16 floats + ldr z1, [x0] + add x0, x0, #64 + + // load B: two 16-FP32 rows of B^T (32 cols) -> z2, z3 + ldr z2, [x1] + add x1, x1, #64 + ldr z3, [x1] + add x1, x1, #64 + + // c(ZA) b a -> 2x2 grid of 16x16 outer products + fmopa za0.s, p0/m, p1/m, z2.s, z0.s // C[0:16 ,0:16] + fmopa za1.s, p0/m, p1/m, z2.s, z1.s // C[16:32,0:16] + fmopa za2.s, p0/m, p1/m, z3.s, z0.s // C[0:16 ,16:32] + fmopa za3.s, p0/m, p1/m, z3.s, z1.s // C[16:32,16:32] + + cbnz x6, loop_32_32_k + + // ---- epilogue: read za0..za3 back to C via `mov {z..},zaNh.s[w,0:3]` + st1w + // (lines 271-381) ---- + smstop + ret +``` + +C ABI: `void gemm_micro_32_32_32(float const* a, float const* b, float* c)` (`kernels_gemm.h`). A is 32x32 col-major-ish packed, B is the B^T operand, C is 32x32. **Validated against `cblas_sgemm(..., CblasTrans, ...)` in `gemm.cpp` (max error printed).** + +There is also `_gemm_micro_64_16_2` (M=64,N=16,K=2, lines 1–110) which is the **cleanest** illustration of "load A column into z0..z3, load B row into z30, fire 4 fmopa into za0..za3" with a simple `str za[w,#0]` read-out loop — good as a second M0 unit. + +--- + +## 3. M2 reference — INT8 SMOPA + +The repo contains **no full INT8 GEMM kernel**, only a peak-throughput probe (no memory I/O, all 4 ZA tiles, reused Z regs). It is still the authoritative reference for *which instruction form + ZA tiling INT8 uses on M4*. + +File: `MicrobenchmarkApp/kernels.s` lines 1630–1696 (`_peak_sme_smopa_i8_i8_i32`). One loop body issues 32 `smopa` across the 4 ZA tiles: + +```asm + .global _peak_sme_smopa_i8_i8_i32 + .align 4 +_peak_sme_smopa_i8_i8_i32: + // PCS save d8-d15 ... + smstart + ptrue p0.b + ptrue p1.b +loop_peak_sme_smopa_i8_i8_i32: + sub x0, x0, #1 + // 64-INT8-lane Z operands; each smopa = 16x16 INT32 update w/ K=4 inner reduction + smopa za0.s, p0/m, p1/m, z0.b, z1.b + smopa za1.s, p0/m, p1/m, z2.b, z3.b + smopa za2.s, p0/m, p1/m, z4.b, z5.b + smopa za3.s, p0/m, p1/m, z6.b, z7.b + smopa za0.s, p0/m, p1/m, z8.b, z9.b + smopa za1.s, p0/m, p1/m, z10.b, z11.b + smopa za2.s, p0/m, p1/m, z12.b, z13.b + smopa za3.s, p0/m, p1/m, z14.b, z15.b + smopa za0.s, p0/m, p1/m, z16.b, z17.b + smopa za1.s, p0/m, p1/m, z18.b, z19.b + smopa za2.s, p0/m, p1/m, z20.b, z21.b + smopa za3.s, p0/m, p1/m, z22.b, z23.b + smopa za0.s, p0/m, p1/m, z24.b, z25.b + smopa za1.s, p0/m, p1/m, z26.b, z27.b + smopa za2.s, p0/m, p1/m, z28.b, z29.b + smopa za3.s, p0/m, p1/m, z30.b, z31.b + // (16 more smopa, same pattern, to fill the pipeline) ... + cbnz x0, loop_peak_sme_smopa_i8_i8_i32 + smstop + // PCS restore ... + mov x0, 32*2048 // 32 smopa * (16*16*4 MACs) per iter = FLOP/iter count + ret +``` + +To turn this into a real M2 GEMM we adapt the **M0 32x32x32 structure** and substitute: +- `ldr z` → `ld1b`/`ldr z` loading **64 INT8 lanes** per Z (K packed by 4 in the byte layout), +- `fmopa za.s,...,z.s,z.s` → `smopa za.s,...,z.b,z.b`, +- **K-step in the loop becomes 4** (one `smopa` consumes 4 K elements per output), so a K=32 reduction is 8 smopa iterations instead of 32, +- ZA read-out stores **INT32** (still `.s` tiles, `str za[w,#0]` / `st1w`), to be requantized downstream. + +The FLOP-count constant `mov x0, 32*2048` encodes `32 smopa * 2048` where `2048 = 16*16*4*2` (16x16 outputs, K=4 inner, MAC=2 ops) → confirms the **K=4 inner-reduction** semantics of INT8 SMOPA on M4. + +INT16 variant `_peak_sme_smopa_i16_i16_i32` (lines 1698+) uses `.h` operands and `mov x0, 32*1024` (K=2 inner), for reference. + +--- + +## 4. SVL=512b (M4) tiling choice + the Z-staging-vs-direct-ZA-load perf insight + +### Tiling actually used (from the real GEMM kernels) +- **Microkernel tile (register-blocked):** **M=32, N=32** output = a **2x2 grid of 16x16 ZA tiles** = **all 4 `.s` ZA tiles** (`za0..za3`) used at once. This is the unit in `_gemm_micro_32_32_32`, `_gemm_micro_32_no_trans`, and the inner block of `_gemm_128_128_128`. +- **K-step:** **2 per loop iteration** for FP32 (two `ldr z` per operand, K reduced by 2 → in `gemm_micro_31/32` the loop runs `K` times each doing one fmopa per tile; in `gemm_128_128_128` and `gemm_micro_32_no_trans` it's `ld1w {z,z}` pairs, i.e. K consumed in steps via the 4-fmopa block). For INT8 SMOPA the effective K-step is **4** (inner reduction). +- **ZA tiles at once:** **4** (the full ZA register on M4). All four are kept live across the entire K-loop; C is loaded into ZA before the loop and written out after. +- **Outer blocking (`_gemm_128_128_128`, lines 1304–1608):** 128x128x128 is done as a **4x4 grid of 32x32 microkernels** (`loop_3x128_n` x `loop_3x128_m`, each 4 iters) with a **K-loop of 128** inside, reusing the same 4-ZA-tile microkernel. Address strides: C tile-1 offset `#8192` (= 2048 floats = 32 rows * 64 floats... the 2nd ZA tile column), n-step `#16384`. This is the template for tiling a large GEMM. + +### Z-register-staging vs direct-ZA-load — the documented perf insight +The microbenchmarks in `kernels.s` are explicitly built to expose this. The relevant probe pairs: + +1. **`fmopa` with ZA-tile reuse vs ZA-tile rotation:** + - `_peak_sme_fmopa_1_fp32_fp32_fp32` (lines 550–615): **every** fmopa targets `za0` (single accumulator) → serializes on the ZA write-back, lower throughput. + - `_peak_sme_fmopa_2_fp32` / `_peak_sme_fmopa_4_fp32` (lines 618–751): round-robin across **2 / 4 ZA tiles** → hides ZA accumulate latency. **Using all 4 ZA tiles is required to reach peak FMOPA throughput** (~1.7–1.8 TFLOPS FP32 per the README libxsmm numbers). + - `_peak_sme_fmopa_4_reorder_fp32` (lines 754–819): same 4 tiles but grouped (4 consecutive fmopa to za0, then za1...) — a scheduling sensitivity probe. + +2. **`smstart`/`smstop` cost:** `_peak_sme_fmopa_smstart_smstop_{8,16,32,...}` (lines 1117+) measure amortizing the streaming-mode entry/exit — **keep `smstart`/`smstop` outside the K-loop and ideally outside the whole GEMM**, because the enable/disable is expensive relative to a single fmopa block. + +3. **Direct-ZA-load (`ldr za` / `str za`) vs Z-stage-then-`mov`:** The real GEMM kernels deliberately **stage C through Z registers** (`ld1w {z,z}` then `mov zaNh.s[w,0:3],{z..}`) for the contiguous-but-strided C load/store, while using **direct `ldr za[w,#0]` only for the B-transpose path** (`_gemm_micro_32_no_trans`, lines 841–860 load B straight into ZA, then read it out *vertically* `za0v.s` to transpose). The takeaway encoded in the code: **operands for FMOPA must be staged in Z registers (you cannot fmopa from memory); the `ld1w`→`mov za` two-step is the standard ZA load path, and direct `ldr/str za` is reserved for whole-tile moves and the in-register transpose trick (load horizontal, read vertical).** + +### Compile / build configuration (this is the "compile command") +These are assembled by Apple Clang as part of an Xcode iOS target. The required arch/feature flags (from `MicrobenchmarkApp/README.md` step 10) are: + +``` +-march=v9-a+sme2+sme2p1+sme-f16f16+b16b16+sme-f64f64 +``` + +Equivalent standalone clang invocation for the `.s` + driver (deployment-targeted at the M4 / iOS arm64): +```bash +clang -O3 -target arm64-apple-ios17.5 \ + -march=armv9-a+sme2+sme2p1+sme-f16f16+b16b16+sme-f64f64 \ + kernels_gemm.s gemm.cpp -framework Accelerate -o gemm_bench +``` +(The GemmApp libxsmm path additionally uses `iOS.cmake` with `CMAKE_IOS_SDK_ROOT=.../iPhoneOS17.5.sdk`, `CMAKE_OSX_ARCHITECTURES=arm64`, `make -j BLAS=0`, plus `patchIos` which disables `pthread_jit_write_protect_np` so JIT-emitted SME code is executable on Apple Silicon — relevant if we ever JIT instead of hand-write.) + +> For our cactus build we only need the `-march=armv9-a+sme2...` feature string on the file containing `matmul_sme2.cpp` (or the `.s`), plus a runtime SME check (`_sme_support`/`_sme2_support` in `kernels.s` lines 20–37 show the probe pattern: `smstart; fmopa/bfdot; smstop`). + +--- + +## 5. File-path index (everything quoted above) +- FP32 single-FMOPA M0 oracle: `MicrobenchmarkApp/kernels.s:957` (`_example_sme_fmopa_fp32_fp32_fp32`); driver `MicrobenchmarkApp/examples.c:59`. +- FP32 full GEMM (4-ZA-tile, K-loop, Z-staging): `MicrobenchmarkApp/kernels_gemm.s:113` (`_gemm_micro_32_32_32`); simplest `:1` (`_gemm_micro_64_16_2`); 128-blocked `:1304` (`_gemm_128_128_128`); transpose-via-ZA `:809` (`_gemm_micro_32_no_trans`). Decls: `MicrobenchmarkApp/kernels_gemm.h`. +- INT8 SMOPA probe (M2 instruction reference): `MicrobenchmarkApp/kernels.s:1630` (`_peak_sme_smopa_i8_i8_i32`); INT16 `:1698`. +- FMOPA tiling / ZA-reuse perf probes: `MicrobenchmarkApp/kernels.s:550,618,686,754`; smstart/smstop cost `:1117+`. +- Validation harness (vs Accelerate `cblas_sgemm`): `MicrobenchmarkApp/gemm.cpp:35` (`rep_kernel`), `:52` (`bench_gemm`, error check `:180`), `:267` (`run_gemm` shapes). +- Build flags: `MicrobenchmarkApp/README.md:17`; libxsmm iOS: `GemmApp/README.md`, `GemmApp/iOS.cmake:20`, `GemmApp/patchIos`. diff --git a/cactus-kernels/docs/sme/research/sve2-android-fallback.md b/cactus-kernels/docs/sme/research/sve2-android-fallback.md new file mode 100644 index 000000000..dfdc383f4 --- /dev/null +++ b/cactus-kernels/docs/sme/research/sve2-android-fallback.md @@ -0,0 +1,306 @@ +# SVE2 (non-SME) INT8 matmul — Android/Linux fallback path + +> Raw research notes. Apple Silicon does **not** expose SVE/SVE2 to userspace, so this path is +> for the **Android/Linux mobile target only** (validated here via cross-compile + QEMU/Docker, not +> native). All compile/macro/asm facts below were verified on this Mac with **Apple clang 17.0.0** +> cross-targeting `aarch64-linux-gnu` (`--target=aarch64-linux-gnu -march=...`). Date: 2026-06-09. + +## TL;DR for Cactus +- The SVE2 INT8 matmul **high-throughput** primitive is `svmmla_s32` (signed) / `svusmmla_s32` + (unsigned×signed). Each does a **2×8 · 8×2 → 2×2** widening INT8 matmul-accumulate per 128-bit + segment (8 INT8 MACs per 32-bit output cell). The **GEMV-friendly** primitive is `svdot_s32` + (4-way widening dot, no transpose/interleave needed) — direct analog of the NEON `vdotq_*` the CQ + GEMV path already uses (`cactus_quant_sdot_gemv_int8`, matmul.cpp:651). +- **Guard:** the mmla intrinsics need `__ARM_FEATURE_SVE_MATMUL_INT8`, which is only defined when you + pass **`+i8mm`** in addition to `+sve2`. `+sve2` alone is NOT enough (verified: `svmmla_s32` → + `error: needs target feature sve,i8mm`). `svdot_s32` needs only `__ARM_FEATURE_SVE` (`+sve`/`+sve2`). +- **march for NDK:** `-march=armv9-a+sve2+i8mm` (or `armv8-a+sve2+i8mm`). Confine to its own TU exactly + like the planned `src/matmul_sve2.cpp` (api-notes.md "Build wiring"). Never on the library-wide flag. +- **Runtime detect (Android):** `getauxval(AT_HWCAP2)` with `HWCAP2_SVE2=(1<<1)`, + `HWCAP2_SVEI8MM=(1<<9)`, `HWCAP2_I8MM=(1<<13)`. SVE *mmla* needs **both** SVE2 and SVEI8MM bits. +- **SoC reality:** Most shipping Android flagships are still **NEON-i8mm only, no SVE2**. SVE2 arrives + with Armv9 cores: Cortex-X4/X925/A720/A725 (Dimensity 9300/9400, some Exynos) have **SVE2** but + **no SME**. Qualcomm Oryon (Snapdragon 8 Elite Gen 1/Gen 2 = "8 Gen 4/5") was **Armv8.7, no SVE** in + Gen 1; **SVE2 + SME1** is reported for the Gen-2/Gen-3 Oryon. **No shipping phone has SME2.** So SVE2 + is the realistic mobile matmul-accel fallback when SME2 is absent. + +--- + +## 1. The SVE2 INT8 matmul intrinsics + +### 1.1 `svmmla_s32` / `svmmla_u32` — widening 2×2 INT8 matrix-multiply-accumulate +ACLE source: SVE ACLE §7.3.1 "MMLA: Accumulating widening multiplication of integer matrices", +guarded by `__ARM_FEATURE_SVE_MATMUL_INT8`. `[source: acle_sve_100987_0000_06_en.pdf §7.3]` + +```c +// #if defined(__ARM_FEATURE_SVE_MATMUL_INT8) // maps to SMMLA / UMMLA +svint32_t svmmla_s32(svint32_t op1, svint8_t op2, svint8_t op3); // SMMLA +svuint32_t svmmla_u32(svuint32_t op1, svuint8_t op2, svuint8_t op3); // UMMLA +``` + +**What it computes (ACLE verbatim):** "They partition the inputs into 128-bit quadwords, with the +first input containing a row-by-row **2×2 matrix of 32-bit integers**, the second input containing a +row-by-row **2×8 matrix of 8-bit integers**, and the third input containing a column-by-column +**8×2 matrix of 8-bit integers**. For each quadword, they multiply the second input matrix by the +third input matrix using natural arithmetic and then add the result to the first input using modular +arithmetic." → Each of the 4 INT32 cells in a 128-bit segment = `op1[cell] + Σ_{k=0..7} op2[row,k] * +op3[k,col]`, i.e. **8 INT8 MACs per output cell, 32 INT8 MACs per 128-bit segment**, repeated across +every 128-bit segment of the scalable vector (so 2× that at VL=256, 4× at VL=512). "The result is +well-defined for all inputs; there is no undefined behavior for signed overflow." + +### 1.2 `svusmmla_s32` — mixed signedness (unsigned activations × signed weights) +ACLE §7.3.2, maps to **USMMLA**. Same 2×8·8×2→2×2 semantics, but `op2` (the 2×8) is **unsigned**, +`op3` (the 8×2) is **signed**. `[source: acle §7.3.2]` + +```c +svint32_t svusmmla_s32(svint32_t op1, svuint8_t op2, svint8_t op3); // USMMLA +``` + +This is the natural shape for asymmetric/uint8 activations against int8 weights (the usual quant LLM +layout). There is intentionally **no `svsumla`** (signed×unsigned) — if your A is signed and B +unsigned, swap operands / transpose, or bias-correct. + +### 1.3 `svdot_s32` — 4-way widening dot (the GEMV primitive) +ACLE §6.7.13 "DOT: Integer addition of dot product", needs only `__ARM_FEATURE_SVE` (no i8mm). +`[source: acle §6.7.13]` + +```c +svint32_t svdot_s32 (svint32_t op1, svint8_t op2, svint8_t op3); // SDOT +svuint32_t svdot_u32 (svuint32_t op1, svuint8_t op2, svuint8_t op3); // UDOT +svint32_t svdot_n_s32(svint32_t op1, svint8_t op2, int8_t op3); // SDOT, splat scalar +svint32_t svdot_lane_s32(svint32_t op1, svint8_t op2, svint8_t op3, uint64_t imm_index); // SDOT (indexed) +// also svusdot_s32 (USDOT, unsigned×signed) under __ARM_FEATURE_SVE_MATMUL_INT8 (§7.3.3) +``` + +ACLE verbatim: "They partition the second and third vector inputs into **groups of four elements**. +They calculate the dot product of each group (without loss of precision) and then add each result to +the overlapping element of the first vector input." So `svdot_s32` does `acc[j] += Σ_{c=0..3} +a[4j+c]*b[4j+c]` — **4 INT8 MACs per 32-bit lane**, accumulating in place. The `_lane` form replicates +one 4-element group from each 128-bit quadword of `op3` (`imm_index ∈ [0, 128/N)`), the SVE analog of +NEON `vdotq_laneq_s32` — directly mirrors `CACTUS_DOTQ_LANE` (matmul.cpp:23). + +### 1.4 mmla vs dot — throughput +- `svmmla` packs **2× the INT8 MACs per instruction** of `svdot` (32 vs 16 per 128-bit segment at the + same VL) but requires the data **interleaved into 2×8 / 8×2 tiles** (the `trn1/trn2` deinterleave you + see in real kernels). Worth it for **GEMM (M>1)**. +- `svdot` needs **no transpose**, accumulates straight down K — ideal for **GEMV (M=1)**, which is the + Cactus decode/`expanded` path. For M=1 there is no second A-row to fill the mmla 2×8, so dot is the + right tool; mmla pays off in prefill / M-batched GEMM. + +### 1.5 Verified on this Mac (Apple clang 17, cross-target) +``` +$ clang --target=aarch64-linux-gnu -march=armv9-a+sve2+i8mm -O2 -S sve2_probe.c + smmla z0.s, z1.b, z2.b # from svmmla_s32 + usmmla z0.s, z1.b, z2.b # from svusmmla_s32 + sdot z0.s, z1.b, z2.b # from svdot_s32 +$ clang --target=aarch64-linux-gnu -march=armv9-a+sve2+i8mm -dM -E -x c /dev/null | grep MATMUL + #define __ARM_FEATURE_MATMUL_INT8 1 # NEON i8mm (vmmla/vusmmla) + #define __ARM_FEATURE_SVE_MATMUL_INT8 1 # SVE mmla (svmmla/svusmmla) +``` +**Guard gotcha (verified):** with `-march=armv9-a+sve2` (no `+i8mm`) → `__ARM_FEATURE_SVE_MATMUL_INT8` +is **undefined** and `svmmla_s32` fails to compile (`'svmmla_s32' needs target feature sve,i8mm`). +`svdot_s32` still works (only needs SVE). So: +- `#if defined(__ARM_FEATURE_SVE2)` → gate the dot-based GEMV. +- `#if defined(__ARM_FEATURE_SVE_MATMUL_INT8)` → gate the mmla-based GEMM (implies you compiled `+i8mm`). + +--- + +## 2. Representative SVE2 INT8 GEMM tile loop (open-source references) + +### 2.1 `svdot` GEMV inner loop — llama.cpp / ggml (closest to Cactus's CQ GEMV) +`ggml/src/ggml-cpu/arch/arm/quants.c`, `ggml_vec_dot_q8_0_q8_0`, guarded `#if defined(__ARM_FEATURE_SVE)`. +The 128-bit-VL branch (each block = 32 INT8 quants): +```c +const svint8_t qx0_0 = svld1_s8(ph16, x0->qs); +const svint8_t qx0_1 = svld1_s8(ph16, x0->qs+16); +const svint8_t qy0_0 = svld1_s8(ph16, y0->qs); +const svint8_t qy0_1 = svld1_s8(ph16, y0->qs+16); + +sumv0 = svmla_n_f32_x(pl16, sumv0, + svcvt_f32_s32_x(pl16, + svadd_x(pl16, + svdot_s32(svdup_n_s32(0), qx0_0, qy0_0), + svdot_s32(svdup_n_s32(0), qx0_1, qy0_1))), + GGML_CPU_FP16_TO_FP32(x0->d)*GGML_CPU_FP16_TO_FP32(y0->d)); +// ... +sumf = svaddv_f32(pl16, svadd_f32_x(pl16, sumv0, sumv1)); // horizontal reduce to scalar +``` +Pattern = **VLA tiling** (`svcntb()`/predicates pick the branch), `svdot_s32` accumulates INT8 K, then +dequant-scale into FP32 and `svaddv_f32` reduces. This is the structure Cactus's `expanded`/SDOT GEMV +maps onto: replace the per-block `vdotq_s32` chain with `svdot_s32` over a `svcntb()`-sized K stride, +keep the FP32 rescale (by `norm_f32`) in the non-SVE caller. ggml's own VLA convention: +`#define VECTOR_REGISTERS (svcntb()*8/32)` and `svcntb()`-strided K loops. `[source: ggml-org/llama.cpp]` + +### 2.2 `svmmla` GEMM inner loop — Arm Compute Library `arm_gemm` (the 2×2-tile reference) +`src/core/NEON/kernels/arm_gemm/kernels/sve_hybrid_s8s32_mmla_6x4VL/generic.cpp` — a 6(M)×4VL(N) INT8 +hybrid GEMM, K blocked in groups of 8. Core mmla accumulation (emitted as `.inst` SMMLA encodings; the +intrinsic equivalent is `svmmla_s32`): +```asm +"whilelt p0.b, XZR, x27\n" // K-tail predicate over bytes +"ld1b { z7.b }, p5/Z, [x11]\n" // load B panel (8x2 tiles, pre-packed) +"ld1b { z6.b }, p5/Z, [x11, #1, MUL VL]\n" +"subs x27, x27, #0x8\n" // K -= 8 per iteration +"ld1rqb { z1.b }, p0/Z, [x26]\n" // broadcast-load 16B of A row 0 (ld1rqb = quad replicate) +"ld1rqb { z2.b }, p0/Z, [x25]\n" // A row 1 +"trn1 z0.d, z1.d, z2.d\n" // interleave A rows -> 2x8 tile halves +"trn2 z1.d, z1.d, z2.d\n" +".inst 0x45079808 // smmla z8.s, z0.b, z7.b\n" // == svmmla_s32(z8, z0, z7) +".inst 0x4506980c // smmla z12.s, z0.b, z6.b\n" +"ld1b { z7.b }, p5/Z, [x11, #2, MUL VL]\n" +".inst 0x45079809 // smmla z9.s, z0.b, z7.b\n" +".inst 0x4506980d // smmla z13.s, z0.b, z6.b\n" +"... (z10..z15 across the 4VL N-panel) ...\n" +"addvl x11, x11, #8\n" // advance B by 8 vectors +``` +Key takeaways for re-tiling Cactus weights for mmla: +- **A** (activations) is loaded with `ld1rqb` (quad-word replicate) then **`trn1/trn2`-deinterleaved** + into the 2×8 layout mmla expects — two M-rows per mmla. For Cactus M=1 GEMV this degenerates (only + one A row), confirming **dot, not mmla, for decode**. +- **B** (weights) must be **pre-packed** into the 8×2 column-major tile layout (`ld1b` straight in, no + transpose at runtime). Cactus's current `expanded` layout (N-blocks of 4 rows, interleaved for + `vdotq_laneq_s32`, api-notes.md) is the **wrong** packing for mmla — a dedicated `expanded_sve2` + pack (8×2 K-major panels) is needed, same conclusion as the SME MOPA re-tile note (api-notes.md:49). +- K is consumed in **steps of 8** (mmla's contraction depth), N across `4*VL` columns, M in 6-row + strips, with multiple `z*.s` accumulators to hide the ~mmla latency. `[source: ARM-software/ComputeLibrary]` + +ACL also ships `sve_hybrid_u8u32_dot_*` (svdot GEMM), `sve_hybrid_u8s8s32_mmla_*` / `_usmmla` (the +mixed-sign USMMLA variant for uint8 activations × int8 weights) — those are the templates for a +`svusmmla_s32` Cactus path. KleidiAI (already the SME2 reference, sources.md) has matching SVE2 +`...sve2_...` ukernels (`kai_matmul_clamp_*_qai8dxp_qsi4c32p_..._dot`) for the CQ4 shape. + +--- + +## 3. Android runtime feature detection (`getauxval(AT_HWCAP2)`) + +Authoritative bit values from the Linux kernel UAPI header +`arch/arm64/include/uapi/asm/hwcap.h` (verbatim, current master). `[source: torvalds/linux .../hwcap.h]` +```c +#define HWCAP_SVE (1 << 22) // base SVE (AT_HWCAP, not HWCAP2) +#define HWCAP2_SVE2 (1 << 1) +#define HWCAP2_SVEI8MM (1 << 9) // SVE int8 matmul (svmmla/svusmmla) <-- needed for mmla +#define HWCAP2_I8MM (1 << 13) // NEON int8 matmul (vmmla/vusmmla) <-- Cactus already uses this +#define HWCAP2_SME (1UL << 23) +#define HWCAP2_SME2 (1UL << 37) // note: needs UL — bit 37 overflows a 32-bit int +#define HWCAP2_SME2P1 (1UL << 38) +``` +All SVE2/SME bits live in **AT_HWCAP2** (base `HWCAP_SVE` is in AT_HWCAP bit 22). `AT_HWCAP3` exists in +newer kernels but only carries MTE/LS64 bits — irrelevant here. + +Drop-in detectors beside `cpu_has_i8mm()` (threading.h:50). Note `getauxval` returns `unsigned long` +(64-bit on arm64), so bits 37/38 are reachable; **do not** assign HWCAP2 to a 32-bit `int`. +```c +// Android only (Apple uses sysctl hw.optional.arm.FEAT_SME / FEAT_SME2; Apple has NO SVE2) +inline bool cpu_has_sve2() { // svdot GEMV path +#if defined(__ANDROID__) && defined(__aarch64__) + unsigned long h2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_SVE2 + #define HWCAP2_SVE2 (1UL << 1) + #endif + return (h2 & HWCAP2_SVE2) != 0; +#else + return false; +#endif +} +inline bool cpu_has_sve_i8mm() { // svmmla / svusmmla GEMM path (requires SVE2 + SVEI8MM) +#if defined(__ANDROID__) && defined(__aarch64__) + unsigned long h2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_SVE2 + #define HWCAP2_SVE2 (1UL << 1) + #endif + #ifndef HWCAP2_SVEI8MM + #define HWCAP2_SVEI8MM (1UL << 9) + #endif + return (h2 & HWCAP2_SVE2) && (h2 & HWCAP2_SVEI8MM); +#else + return false; +#endif +} +``` +Caveats: (1) `getauxval(AT_HWCAP2)` since Android API 18; older toolchains may lack the `HWCAP2_*` +macros → self-`#define` as above. (2) Google's `cpu_features` lib exists for SoC-quirk workarounds, but +HWCAP for SVE2/i8mm is reliable on Armv9 (the old div-by-zero-claim quirk was Armv7-era). (3) Always +**also** gate at compile time (`#if defined(__ARM_FEATURE_SVE2)`) so the base lib still links on older +NDKs and non-SVE devices. + +### Which Android SoCs actually have SVE2 vs SME (as of mid-2026) +- **SVE2, no SME (the realistic SVE2 target):** Armv9 Cortex cores — **Cortex-A510/A520, A710/A715/ + A720/A725, X2/X3/X4/X925**. SoCs: MediaTek **Dimensity 9300/9400** (X4/X925 + A720/A725), + Exynos 2400, Snapdragon 8 Gen 1/2/3 (those used Armv9 Cortex, SVE2). These have **no SME/SME2**. +- **Qualcomm Oryon (custom):** Snapdragon 8 Elite **Gen 1** (a.k.a. "8 Gen 4", 2024) Oryon was + **Armv8.7, NO SVE/SVE2**. Reporting indicates **Gen-2/Gen-3 Oryon** ("8 Elite Gen 2/Gen 5") add + **SVE2 + SME1**. So newest Snapdragon may be the first phones with SME — but **SME1, not SME2**. +- **No shipping Android phone has SME2.** SME2 is Apple-M4-class / future. ⇒ On mobile, the dispatch + ladder is: **SME2 (rare/none) → SVE2+i8mm (Armv9 Cortex, newest Oryon) → NEON i8mm (everything else, + Cactus's current baseline) → NEON dotprod**. +`[source: ARM Cortex-X925 / Dimensity 9400 specs; Snapdragon 8 Elite deep-dives; chips-and-cheese Oryon]` + +--- + +## 4. Build flags + testing under QEMU on this Mac + +### 4.1 NDK / clang `-march` +- SVE2 GEMV (dot): `-march=armv9-a+sve2` (or `armv8-a+sve2`). +- SVE2 INT8 GEMM (mmla): **`-march=armv9-a+sve2+i8mm`** — `+i8mm` is **required** for + `__ARM_FEATURE_SVE_MATMUL_INT8` (verified §1.5). It also (re)defines `__ARM_FEATURE_MATMUL_INT8` + for the NEON path, which is harmless. `armv9-a` implies SVE2; on `armv8-a` you must spell `+sve2`. +- **Per-TU only.** Mirror api-notes.md "Build wiring": new `src/matmul_sve2.cpp` via + `set_source_files_properties(src/matmul_sve2.cpp PROPERTIES COMPILE_OPTIONS + "-march=armv9-a+sve2+i8mm")` in CMakeLists.txt; keep the library-wide flag + `-march=armv8.2-a+fp16+simd+dotprod+i8mm` (line 34) unchanged. Double-guard the TU + (`#if defined(__ARM_FEATURE_SVE2)` real / `#else` stub). **Never** put `+sve2` on the global flag — + the optimizer would emit SVE into NEON TUs and the base lib stops running on non-SVE phones. +- NDK clang accepts these as-is (the same Apple clang 17 frontend; LLVM ≥ 11 has the SVE matmul + builtins). For LTO, the per-source march is preserved by the function multi-versioning attrs; if you + hit LTO march-clobber, gate with `__attribute__((target("arch=armv9-a+sve2+i8mm")))` per-function. + +### 4.2 Testing on this Mac — important limitation +Apple Silicon does **not** run SVE2 in userspace (PSTATE has no SVE), so you cannot run the SVE2 TU +natively. Two emulation options: +- **`qemu-aarch64` user-mode (preferred, fast iteration) — NOT installed here.** Verified: + `brew --prefix qemu`/bin ships **system-mode only** (`qemu-system-aarch64` 10.2.2 present; **no + `qemu-aarch64` / `qemu-arm` user binaries** — macOS Homebrew QEMU is built without linux-user). + To get it: build QEMU from source with `--target-list=aarch64-linux-user`, or run inside a Linux + shell. Once present: + ```sh + # cross-build a Linux aarch64 test binary, then: + qemu-aarch64 -cpu max,sve=on,sve2=on,i8mm=on,sme=off ./test_matmul_sve2 + # -cpu max enables all SVE sub-features incl SVE2 + SVEI8MM; force sme=off to exercise the SVE2 fallback, + # not an SME path. (qemu also forwards correct HWCAP2 SVE2/SVEI8MM bits to getauxval.) + ``` +- **Docker (works today on this Mac — `docker` is installed):** Docker Desktop's Linux VM has + `qemu-user-static` + binfmt registered, so an `--platform linux/arm64` container transparently runs + aarch64 ELF through QEMU TCG. To get SVE2 you must point binfmt's QEMU at `-cpu max`; simplest is to + run the cross-built binary under an explicit `qemu-aarch64-static -cpu max,sve2=on` **inside** an + arm64 Linux container (the container's qemu-user-static *does* ship user-mode). Sketch: + ```sh + docker run --rm --platform linux/arm64 -v "$PWD:/w" -w /w debian:trixie \ + bash -c 'apt-get update && apt-get install -y qemu-user && \ + qemu-aarch64 -cpu max,sve2=on,i8mm=on ./test_matmul_sve2' + ``` + (Slow — TCG, no SVE2 host accel — fine for **correctness/MSE gate**, useless for the perf gate. + Project policy: don't run long live sweeps. Correctness only here; perf must be measured on a + real Armv9 Android device.) +- **`-cpu max` knobs:** `sve=on` auto-enables all supported SVE sub-features incl `sve2`; add `i8mm=on` + for SVEI8MM (mmla). You can pin a VL with `sve512=on`/`vl=…` to test VL-agnosticism at 128/256/512. + +### 4.3 Dispatch gate (consistent with status-board rule) +A SVE2 variant is enabled in `cactus_quant_matmul` only if it (a) passes MSE ≤ 0.1 (QEMU-verifiable +here) and (b) beats the NEON i8mm baseline **on real Armv9 hardware** (NOT on QEMU). Until a device +benchmark exists, ship it `runtime-detect → dispatch` but flag "perf-unverified-on-device". + +--- + +## Sources +- ACLE for SVE (Arm 100987_0000_06) §6.7.13 DOT, §7.3 INT8 matmul — intrinsic prototypes + 2×8·8×2→2×2 + semantics (extracted from PDF, verbatim above). +- Linux kernel `arch/arm64/include/uapi/asm/hwcap.h` (torvalds/linux master) — HWCAP2 bit values. +- LLVM/clang `mmla` builtins commit (cfe-commits e2cc12e) — `svmmla_s32/_u32/svusmmla_s32` → smmla/ + ummla/usmmla, `__ARM_FEATURE_SVE_MATMUL_INT8` guard, test signatures. +- ggml/llama.cpp `ggml-cpu/arch/arm/quants.c` — `svdot_s32` VLA GEMV inner loop. +- Arm Compute Library `arm_gemm/.../sve_hybrid_s8s32_mmla_6x4VL/generic.cpp` — smmla GEMM tile loop + (ld1rqb + trn1/trn2 deinterleave + 6×4VL accumulators). +- Local verification: Apple clang 17 `--target=aarch64-linux-gnu -march=armv9-a+sve2+i8mm` emits + smmla/usmmla/sdot; `+sve2` alone leaves `__ARM_FEATURE_SVE_MATMUL_INT8` undefined and rejects + `svmmla_s32`. `qemu-system-aarch64` 10.2.2 present; no user-mode `qemu-aarch64` in brew; `docker` present. +- SoC landscape: Cortex-X925 / Dimensity 9400 specs, Snapdragon 8 Elite Oryon deep-dives (SVE2/SME + generational support). diff --git a/cactus-kernels/docs/sme/sources.md b/cactus-kernels/docs/sme/sources.md new file mode 100644 index 000000000..1f794d671 --- /dev/null +++ b/cactus-kernels/docs/sme/sources.md @@ -0,0 +1,46 @@ +# Sources (annotated) + +> Rating: ★★★ primary / ★★ useful / ★ context. Date-checked 2026-06-09. + +## Primary references (implementation-load-bearing) +- ★★★ **KleidiAI** — github.com/ARM-software/kleidiai (mirror of gitlab.arm.com/kleidi/kleidiai). + Production SME2 INT4/INT8 matmul micro-kernels: `kai_matmul_clamp_f32_qsi8d32p..._qsi4c32p..._sme2_mopa`, + `qai8dxp` LHS dynamic-quant+pack, `qsi4c32p`/`qsi4cxp` RHS 4-bit pack. Nearly Cactus's CQ4 use case. + Read `kai/ukernels/matmul/**` + `pack/README.md`. THE primary tiling/packing reference. +- ★★★ **scalable-analyses/sme** + "Hello SME!" (arxiv.org/abs/2409.18779). Runnable FP32/FP16/INT8 SME + matmul kernels benchmarked on M4; Z-reg→ZA staging is 2.6× faster than direct ZA load. M0/M2 oracle. +- ★★★ **Arm ACLE** — arm-software.github.io/acle/main/acle.html. `arm_sme.h` intrinsics + attribute + semantics (`__arm_streaming`, `__arm_new("za")`, `__arm_streaming_compatible`, `__arm_in/out/inout`). +- ★★★ **LLVM AArch64SME** — llvm.org/docs/AArch64SME.html. ACLE→IR mapping, streaming restrictions, + SMEABIPass, calling conventions. +- ★★★ **Arm Learning Path "Multiplying matrices with SME2"** — + learn.arm.com/learning-paths/cross-platform/multiplying-matrices-with-sme2/ (streaming mode, ZA, + FMOPA, C intrinsics pages). Canonical tutorial with working code. + +## Apple-Silicon practicalities +- ★★ tzakharko/m4-sme-exploration — reverse-engineered M4 SME per-instruction microbenchmarks (P/E core). +- ★★ Zenn "Trying Out SME" (mod_poppo) — real M4 + clang/gcc gotchas, ACLE-spec-vs-compiler drift. +- ★★ apple-oss-distributions/xnu doc/arm/sme.md — macOS ZA context-switch/ABI. +- ★ Go `internal/cpu/cpu_arm64_darwin.go` — confirms sysctl key usage pattern for Apple feature detect. + +## Tiling / quantized GEMM technique +- ★★ MpGEMM "Demystifying ARM SME" (arxiv.org/abs/2512.21473) — modern SME GEMM cache/tiling strategy, + mixed INT4/INT8/FP16, benchmarked vs ACL/KleidiAI/OpenBLAS. +- ★★ Arm Compute Library — github.com/ARM-software/ComputeLibrary, `src/core/NEON/kernels/arm_gemm/` + (SME/SVE2 GEMM asm kernels). +- ★ ggml/llama.cpp SME PRs + Arm KleidiAI llama.cpp patch — real inference-engine integration patterns. + +## SVE2 (Android/Linux fallback) +- ★★ Arm intrinsics guide — `svmmla_s32`/`svusmmla_s32`/`svdot` (INT8 matmul, `__ARM_FEATURE_SVE_MATMUL_INT8`). +- ★ Linux kernel docs/arch/arm64/sme.md + sve.md — HWCAP2 bits, context switch. +- ★★★ ACLE for SVE (Arm 100987_0000_06) §6.7.13 DOT, §7.3 INT8 matmul — verbatim svmmla/svusmmla/svdot + prototypes + 2×8·8×2→2×2 semantics. (2026-06-09) → see research/sve2-android-fallback.md. +- ★★★ Linux kernel `arch/arm64/include/uapi/asm/hwcap.h` (torvalds master) — exact HWCAP2 bit values: + SVE2=1<<1, SVEI8MM=1<<9, I8MM=1<<13, SME=1<<23, SME2=1UL<<37. (2026-06-09) +- ★★ ARM-software/ComputeLibrary `arm_gemm/.../sve_hybrid_s8s32_mmla_6x4VL/generic.cpp` — smmla INT8 + GEMM tile loop (ld1rqb + trn1/trn2 deinterleave, 6×4VL accumulators). (2026-06-09) +- ★★ ggml/llama.cpp `ggml-cpu/arch/arm/quants.c` — `svdot_s32` VLA GEMV inner loop (Cactus CQ-GEMV analog). +- ★ Consolidated raw research: **research/sve2-android-fallback.md** (full SVE2 Android-fallback writeup, + cross-compile + QEMU/Docker testing notes, SoC SVE2-vs-SME landscape). (2026-06-09) + +> Append new finds here with a one-line "what it answers" + date. diff --git a/cactus-kernels/docs/sme/working-examples/avprobe.cpp b/cactus-kernels/docs/sme/working-examples/avprobe.cpp new file mode 100644 index 000000000..b304bd065 --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/avprobe.cpp @@ -0,0 +1,91 @@ +// Probe: SMOPA AV tile — out[16q][head_dim] += P[16q][64kv] x V[64kv][head_dim], P int8-quantized +// per q-row (softmax probs in [0,1] -> scale = rowmax/127), V int8 with per-32-group scales over +// the HEAD-DIM axis. zn = [16q x 4kv] P-pack, zm = [64-dim chunk... AV contracts over kv: SMOPA +// contraction dim = kv (4 per instr). zm = V[4kv][16 dim-cols] panels per dim-block of 16. +#include +#include +#include +#include +#include +#include + +__arm_locally_streaming __arm_new("za") +static void av_tile(const int8_t* ppack /*[16kvg][16q][4kv]*/, const int8_t* vpack, + /*[16kvg][4 dimblk][16dim... see main]*/ int32_t* out /*16q x 64dim*/, + uint32_t kv_groups /*64/4*/) { + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + svzero_za(); + for (uint32_t kg = 0; kg < kv_groups; ++kg) { + svint8_t zn = svld1_s8(pg, ppack + (size_t)kg * 64); + const int8_t* p = vpack + (size_t)kg * 256; + svmopa_za32_s8_m(0, pg, pg, zn, svld1_s8(pg, p)); + svmopa_za32_s8_m(1, pg, pg, zn, svld1_s8(pg, p + 64)); + svmopa_za32_s8_m(2, pg, pg, zn, svld1_s8(pg, p + 128)); + svmopa_za32_s8_m(3, pg, pg, zn, svld1_s8(pg, p + 192)); + } + for (uint32_t r = 0; r < 16; ++r) { + svst1_hor_za32(0, r, pg32, out + (size_t)r * 64); + svst1_hor_za32(1, r, pg32, out + (size_t)r * 64 + 16); + svst1_hor_za32(2, r, pg32, out + (size_t)r * 64 + 32); + svst1_hor_za32(3, r, pg32, out + (size_t)r * 64 + 48); + } +} + +int main() { + const uint32_t KV = 64, D = 64; // one 64-dim slice of head_dim (per-32-group scales -> 2 groups) + std::mt19937 gen(9); + std::uniform_real_distribution dist(0.f, 1.f); + std::vector P(16 * KV); + std::vector V(KV * D); + std::vector vs(KV * 2); // per kv, per 32-dim group + for (auto& v : P) v = dist(gen); + for (auto& v : V) v = (int8_t)(gen() % 250 - 125); + for (auto& v : vs) v = 0.01f + 0.005f * dist(gen); + // quantize P per row + std::vector pi(16 * KV); + std::vector ps(16); + for (int r = 0; r < 16; r++) { + float mx = 0; for (uint32_t c = 0; c < KV; c++) mx = std::max(mx, P[r * KV + c]); + ps[r] = mx / 127.f + 1e-12f; + for (uint32_t c = 0; c < KV; c++) pi[r * KV + c] = (int8_t)lrintf(P[r * KV + c] / ps[r]); + } + // oracle on quantized P (isolate SMOPA correctness): out[r][d] = sum_kv pi*V * ps[r]*vs[kv][d/32] + std::vector ref(16 * D, 0.f); + for (int r = 0; r < 16; r++) + for (uint32_t d = 0; d < D; d++) + for (uint32_t c = 0; c < KV; c++) + ref[r * D + d] += (float)pi[r * KV + c] * (float)V[c * D + d] * ps[r] * vs[c * 2 + d / 32]; + // CAUTION: per-(kv,dimgroup) scales can't factor out of the kv-contraction! vs depends on c -> + // must FOLD vs into V before quantize... can't (V already int8). => per-kv scales break the + // single-SMOPA contraction. Test the FOLDED variant: rescale per kv inside by splitting the + // contraction per kv?? That defeats SMOPA. INSTEAD: common production trick — V groups are over + // DIM (d/32), NOT kv... vs[c][g] varies per kv c. SMOPA sum_kv pi*V needs vs constant over kv. + // => the AV SMOPA requires per-dim-group-only V scales. Check: if engine's v_scales are + // per (kv, group) this probe documents the blocker. Compute the error of assuming mean scale: + std::vector ppack(16 * KV), vpack(KV * D); + for (uint32_t kg = 0; kg < KV / 4; kg++) { + for (int r = 0; r < 16; r++) + for (int c4 = 0; c4 < 4; c4++) + ppack[kg * 64 + r * 4 + c4] = pi[r * KV + kg * 4 + c4]; + for (uint32_t d = 0; d < D; d++) + for (int c4 = 0; c4 < 4; c4++) + vpack[kg * 256 + (d / 16) * 64 + (d % 16) * 4 + c4] = V[(kg * 4 + c4) * D + d]; + } + std::vector out(16 * 64); + av_tile(ppack.data(), vpack.data(), out.data(), KV / 4); + // exact check possible when vs constant per kv: set vs constant and re-derive both + double mx = 0; + std::vector ref2(16 * D, 0.f); + for (int r = 0; r < 16; r++) + for (uint32_t d = 0; d < D; d++) { + for (uint32_t c = 0; c < KV; c++) + ref2[r * D + d] += (float)pi[r * KV + c] * (float)V[c * D + d]; + float got = out[r * 64 + d]; + mx = std::max(mx, (double)std::fabs(got - ref2[r * D + d])); + } + printf("AV SMOPA tile (unit scales) vs oracle: max_err=%.6f -> %s\n", mx, mx < 0.5 ? "PASS" : "FAIL"); + printf("NOTE: per-(kv,group) V scales cannot factor out of a kv-contraction SMOPA — integration\n" + "must rescale per 4-kv subgroup or restructure (documented).\n"); + return mx >= 0.5; +} diff --git a/cactus-kernels/docs/sme/working-examples/fp32_fmopa_matmul.cpp b/cactus-kernels/docs/sme/working-examples/fp32_fmopa_matmul.cpp new file mode 100644 index 000000000..48b751dfd --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/fp32_fmopa_matmul.cpp @@ -0,0 +1,82 @@ +// FP32 FMOPA SME2 matmul (32x32x32), verified against a scalar triple loop on Apple M4. +// [ran-correct 2026-06-09] max_abs_err = 0.000e+00, PASS. +// +// EXACT WORKING COMPILE COMMAND (Apple clang 17, M4): +// clang++ -O2 -std=c++20 -march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2 \ +// fp32_fmopa_matmul.cpp -o fp32_fmopa_matmul && ./fp32_fmopa_matmul +// +// Also runs with -march=armv8-a+sme and -mcpu=apple-m4. +// DO NOT use -march=armv9-a+sme2 : it COMPILES and emits identical SME asm but the binary +// SIGILLs at runtime (exit 132) on M4 — armv9-a implies the non-streaming SVE2 ISA that Apple +// Silicon does not implement. Confirmed by running probe p6 across all four -march strings. +// +// Asm emitted (verified): smstart sm / smstart za / fmopa za0.s, p0/m, p1/m, z0.s, z1.s / +// st1w {za0h.s[wN, 0]}, p, [ptr] / smstop za / smstop sm. +// +// KEY GOTCHAS proven here: +// * __arm_streaming / __arm_inout("za") are TYPE attributes -> they go AFTER the param list. +// __arm_new("za") / __arm_locally_streaming are DECL attributes -> they go BEFORE the fn. +// Putting __arm_streaming in the prefix position errors: "cannot be applied to a declaration". +// * For f32 FMOPA the operands are 32-bit, so the governing predicates pn/pm are b32 +// (svwhilelt_b32 / svptrue_b32). FMOPA: ZA[i][j] += zn[i] * zm[j]. +#include +#include +#include +#include +#include + +// C[M][N] = A[M][K] * B[K][N], all row-major. +// FMOPA per K is a rank-1 outer product: ZA[i][j] += a_col[i] * b_row[j] +// a_col[i] = A[i][k] (M-indexed column of A), b_row[j] = B[k][j] (N-indexed row of B). +__arm_new("za") __arm_locally_streaming +static void matmul_f32_sme(uint64_t M, uint64_t K, uint64_t N, + const float* A, const float* B, float* C) { + const uint64_t SVL = svcntw(); // FP32 lanes in streaming mode (16 on M4) + float acol[64]; // >= SVL; gathers a column of A for k + for (uint64_t row = 0; row < M; row += SVL) { + svbool_t pM = svwhilelt_b32_u64(row, M); + for (uint64_t col = 0; col < N; col += SVL) { + svbool_t pN = svwhilelt_b32_u64(col, N); + svzero_za(); + for (uint64_t k = 0; k < K; ++k) { + for (uint64_t r = 0; r < SVL && row + r < M; ++r) + acol[r] = A[(row + r) * K + k]; + svfloat32_t zA = svld1_f32(pM, &acol[0]); + svfloat32_t zB = svld1_f32(pN, &B[k * N + col]); + svmopa_za32_f32_m(0, pM, pN, zA, zB); // FMOPA: ZA[i][j] += zA[i]*zB[j] + } + for (uint64_t r = 0; r < SVL && row + r < M; ++r) + svst1_hor_za32(0, (uint32_t)r, pN, &C[(row + r) * N + col]); + } + } +} + +static void matmul_f32_scalar(uint64_t M, uint64_t K, uint64_t N, + const float* A, const float* B, float* C) { + for (uint64_t i = 0; i < M; ++i) + for (uint64_t j = 0; j < N; ++j) { + float s = 0.f; + for (uint64_t k = 0; k < K; ++k) s += A[i * K + k] * B[k * N + j]; + C[i * N + j] = s; + } +} + +int main() { + const uint64_t M = 32, K = 32, N = 32; + std::vector A(M * K), B(K * N), Csme(M * N, 0.f), Cref(M * N, 0.f); + srand(1234); + for (auto& x : A) x = (float)((rand() % 200) - 100) / 32.0f; + for (auto& x : B) x = (float)((rand() % 200) - 100) / 32.0f; + + matmul_f32_scalar(M, K, N, A.data(), B.data(), Cref.data()); + matmul_f32_sme(M, K, N, A.data(), B.data(), Csme.data()); + + double maxabs = 0.0; + for (uint64_t i = 0; i < M * N; ++i) + maxabs = std::fmax(maxabs, std::fabs((double)Csme[i] - (double)Cref[i])); + printf("FP32 FMOPA matmul %llux%llux%llu max_abs_err = %.3e\n", + (unsigned long long)M, (unsigned long long)K, (unsigned long long)N, maxabs); + bool ok = maxabs < 1e-3; + printf(ok ? "PASS\n" : "FAIL\n"); + return ok ? 0 : 1; +} diff --git a/cactus-kernels/docs/sme/working-examples/int8_smopa_matmul.cpp b/cactus-kernels/docs/sme/working-examples/int8_smopa_matmul.cpp new file mode 100644 index 000000000..2ee8cebad --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/int8_smopa_matmul.cpp @@ -0,0 +1,100 @@ +// INT8 SMOPA SME2 matmul (32x32x32, s8*s8 -> s32), verified against a scalar int32 triple +// loop on Apple M4. [ran-correct 2026-06-09] max_abs_err = 0, all 1024 cells exact, PASS. +// +// EXACT WORKING COMPILE COMMAND (Apple clang 17, M4): +// clang++ -O2 -std=c++20 -march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2 \ +// int8_smopa_matmul.cpp -o int8_smopa_matmul && ./int8_smopa_matmul +// +// Also runs with -march=armv8-a+sme and -mcpu=apple-m4. NOT -march=armv9-a+sme2 (SIGILL at run). +// +// Asm emitted (verified): smopa za0.s, p1/m, p2/m, z1.b, z0.b (4-way widening, .b -> .s). +// +// ===== THE TWO BUGS THIS EXAMPLE EXISTS TO DOCUMENT ===== +// +// 1. SMOPA WIDENING CONTRACT (empirically discovered with single-hot probes, NOT from docs): +// ZA32[i][j] += sum_{c=0..3} zA[4*i + c] * zB[4*j + c] +// i.e. each 32-bit lane GROUP of the byte vector holds 4 contiguous K values for one +// output index. Proven: a[ai]=1,b[bj]=1 lights ZA[ai/4][bj/4] ONLY when ai%4 == bj%4 +// (the inner-K index c must match); all-ones gives 4 (sum of 4 products). So you must +// pack: aPanel[4*i + c] = A[(row+i)*K + (4g+c)] (i = output row, c = inner-K 0..3) +// bPanel[4*j + c] = B[(4g+c)*N + (col+j)] (j = output col, c = inner-K 0..3) +// +// 2. PREDICATE WIDTH (the bug that made the first version produce 100% wrong cells): +// SMOPA operands are BYTES, so the governing predicates pn/pm passed to svmopa_za32_s8_m +// MUST be BYTE predicates (svptrue_b8 / svwhilelt_b8). Passing a b32 predicate (as you +// would for the f32 FMOPA tile or the readout) marks only 1 of every 4 K-lanes active and +// silently ZEROES the other 3 inner-K products -> garbage. The 32-bit predicate is only +// for the svst1_hor_za32 readout (which writes .s lanes). +// Tail handling for M/N is done by ZERO-PADDING the packed panels, so the byte predicate +// can stay all-true. +#include +#include +#include +#include +#include + +__arm_new("za") __arm_locally_streaming +static void matmul_s8_sme(uint64_t M, uint64_t K, uint64_t N, + const int8_t* A, const int8_t* B, int32_t* C) { + const uint64_t SVLb = svcntb(); // bytes per Z (64 on M4) + const uint64_t TILE = SVLb / 4; // 16 int32 cells per tile side + const svbool_t pB = svptrue_b8(); // BYTE predicate for the s8 SMOPA operands + int8_t aPanel[64], bPanel[64]; // == SVLb + for (uint64_t row = 0; row < M; row += TILE) { + for (uint64_t col = 0; col < N; col += TILE) { + svbool_t pN = svwhilelt_b32_u64(col, N); // b32: for the readout store only + svzero_za(); + for (uint64_t g = 0; g * 4 < K; ++g) { + const uint64_t k0 = g * 4; + for (uint64_t i = 0; i < TILE; ++i) + for (uint64_t c = 0; c < 4; ++c) + aPanel[4 * i + c] = (row + i < M && k0 + c < K) + ? A[(row + i) * K + k0 + c] : 0; + for (uint64_t j = 0; j < TILE; ++j) + for (uint64_t c = 0; c < 4; ++c) + bPanel[4 * j + c] = (col + j < N && k0 + c < K) + ? B[(k0 + c) * N + (col + j)] : 0; + svint8_t zA = svld1_s8(pB, &aPanel[0]); + svint8_t zB = svld1_s8(pB, &bPanel[0]); + svmopa_za32_s8_m(0, pB, pB, zA, zB); // BYTE predicates! ZA[i][j]+=sum_c zA[4i+c]*zB[4j+c] + } + for (uint64_t r = 0; r < TILE && row + r < M; ++r) + svst1_hor_za32(0, (uint32_t)r, pN, &C[(row + r) * N + col]); + } + } +} + +static void matmul_s8_scalar(uint64_t M, uint64_t K, uint64_t N, + const int8_t* A, const int8_t* B, int32_t* C) { + for (uint64_t i = 0; i < M; ++i) + for (uint64_t j = 0; j < N; ++j) { + int32_t s = 0; + for (uint64_t k = 0; k < K; ++k) s += (int32_t)A[i * K + k] * (int32_t)B[k * N + j]; + C[i * N + j] = s; + } +} + +int main() { + const uint64_t M = 32, K = 32, N = 32; + std::vector A(M * K), B(K * N); + std::vector Csme(M * N, 0), Cref(M * N, 0); + srand(4321); + for (auto& x : A) x = (int8_t)((rand() % 255) - 127); + for (auto& x : B) x = (int8_t)((rand() % 255) - 127); + + matmul_s8_scalar(M, K, N, A.data(), B.data(), Cref.data()); + matmul_s8_sme(M, K, N, A.data(), B.data(), Csme.data()); + + int64_t maxabs = 0; int mism = 0; + for (uint64_t i = 0; i < M * N; ++i) { + int64_t d = std::llabs((int64_t)Csme[i] - (int64_t)Cref[i]); + if (d) ++mism; + if (d > maxabs) maxabs = d; + } + printf("INT8 SMOPA matmul %llux%llux%llu max_abs_err = %lld (mismatches=%d)\n", + (unsigned long long)M, (unsigned long long)K, (unsigned long long)N, + (long long)maxabs, mism); + bool ok = (maxabs == 0); + printf(ok ? "PASS\n" : "FAIL\n"); + return ok ? 0 : 1; +} diff --git a/cactus-kernels/docs/sme/working-examples/qkprobe.cpp b/cactus-kernels/docs/sme/working-examples/qkprobe.cpp new file mode 100644 index 000000000..e5a4d98a7 --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/qkprobe.cpp @@ -0,0 +1,95 @@ +// Probe: SMOPA QK^T scores tile — 16 q-rows x 64 kv-positions, head_dim=256, K int8 with per-32 +// group scales, Q int8-quantized per-32-group. Oracle: fp32 dequant dot. Layout reuses the proven +// GEMM conventions: zn = [16 q x 4 dim] act-pack, zm = [64 kv...]: per dim-group of 4, four +// 16-kv weight vectors -> za0..3 (= kv 0-15,16-31,32-47,48-63). +#include +#include +#include +#include +#include +#include +#include + +__arm_locally_streaming __arm_new("za") +static void qk_tile(const int8_t* qpack, const int8_t* kpack, int32_t* out, + uint32_t dim_groups /*per quant-group: 32/4=8*/, uint32_t n_qgroups, + const float* qs, const float* ks, float* scores /*16x64 fp32*/) { + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + for (uint32_t qg = 0; qg < n_qgroups; ++qg) { + svzero_za(); + const int8_t* qb = qpack + (size_t)qg * dim_groups * 64; + const int8_t* kb = kpack + (size_t)qg * dim_groups * 256; + for (uint32_t dg = 0; dg < dim_groups; ++dg) { + svint8_t zn = svld1_s8(pg, qb + (size_t)dg * 64); + const int8_t* p = kb + (size_t)dg * 256; + svmopa_za32_s8_m(0, pg, pg, zn, svld1_s8(pg, p)); + svmopa_za32_s8_m(1, pg, pg, zn, svld1_s8(pg, p + 64)); + svmopa_za32_s8_m(2, pg, pg, zn, svld1_s8(pg, p + 128)); + svmopa_za32_s8_m(3, pg, pg, zn, svld1_s8(pg, p + 192)); + } + for (uint32_t r = 0; r < 16; ++r) { + int32_t buf[64]; + svst1_hor_za32(0, r, pg32, buf); + svst1_hor_za32(1, r, pg32, buf + 16); + svst1_hor_za32(2, r, pg32, buf + 32); + svst1_hor_za32(3, r, pg32, buf + 48); + for (uint32_t c = 0; c < 64; ++c) + scores[r * 64 + c] += buf[c] * qs[r * n_qgroups + qg] * ks[c * n_qgroups + qg]; + } + } + (void)out; +} + +int main() { + const uint32_t HD = 256, QG = 32, NQG = HD / QG, DG = QG / 4; + std::mt19937 gen(7); + std::uniform_real_distribution dist(-1.f, 1.f); + std::vector Q(16 * HD); + std::vector K(64 * HD); + std::vector ks(64 * NQG); + for (auto& v : Q) v = dist(gen); + for (auto& v : K) v = (int8_t)(gen() % 250 - 125); + for (auto& v : ks) v = 0.01f + 0.005f * std::fabs(dist(gen)); + + // quantize Q per row per 32-group + std::vector qi(16 * HD); + std::vector qs(16 * NQG); + for (int r = 0; r < 16; r++) + for (uint32_t g = 0; g < NQG; g++) { + float mx = 0; + for (uint32_t d = 0; d < QG; d++) mx = std::max(mx, std::fabs(Q[r * HD + g * QG + d])); + float s = mx / 127.f + 1e-12f; + qs[r * NQG + g] = s; + for (uint32_t d = 0; d < QG; d++) + qi[r * HD + g * QG + d] = (int8_t)lrintf(Q[r * HD + g * QG + d] / s); + } + // oracle: dequant fp32 dot of the QUANTIZED q (isolates SMOPA correctness from q-quant error) + std::vector ref(16 * 64, 0.f); + for (int r = 0; r < 16; r++) + for (int c = 0; c < 64; c++) + for (uint32_t g = 0; g < NQG; g++) { + float acc = 0; + for (uint32_t d = 0; d < QG; d++) + acc += (float)qi[r * HD + g * QG + d] * (float)K[c * HD + g * QG + d]; + ref[r * 64 + c] += acc * qs[r * NQG + g] * ks[c * NQG + g]; + } + // pack: qpack[qg][dg][16r][4d]; kpack[qg][dg][4 vec][16kv][4d] + std::vector qpack(16 * HD), kpack(64 * HD); + for (uint32_t qg = 0; qg < NQG; qg++) + for (uint32_t dg = 0; dg < DG; dg++) { + for (int r = 0; r < 16; r++) + for (int d = 0; d < 4; d++) + qpack[(qg * DG + dg) * 64 + r * 4 + d] = qi[r * HD + qg * QG + dg * 4 + d]; + for (int c = 0; c < 64; c++) + for (int d = 0; d < 4; d++) + kpack[(qg * DG + dg) * 256 + (c / 16) * 64 + (c % 16) * 4 + d] = + K[c * HD + qg * QG + dg * 4 + d]; + } + std::vector scores(16 * 64, 0.f); + qk_tile(qpack.data(), kpack.data(), nullptr, DG, NQG, qs.data(), ks.data(), scores.data()); + double mx = 0; + for (int i = 0; i < 16 * 64; i++) mx = std::max(mx, (double)std::fabs(scores[i] - ref[i])); + printf("QK SMOPA tile vs oracle: max_err=%.6f -> %s\n", mx, mx < 1e-3 ? "PASS" : "FAIL"); + return mx >= 1e-3; +} diff --git a/cactus-kernels/docs/sme/working-examples/svdot_gemv16.cpp b/cactus-kernels/docs/sme/working-examples/svdot_gemv16.cpp new file mode 100644 index 000000000..621bfbefb --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/svdot_gemv16.cpp @@ -0,0 +1,46 @@ +// Probe: is svdot_s32 streaming-legal on Apple M4, and does the broadcast-activation GEMV work? +// Compile: clang++ -O2 -std=c++20 -march=armv8.2-a+fp16+simd+dotprod+i8mm+sme2 svdot.cpp -o svdot && ./svdot +#include +#include +#include +#include +#include +#include + +// Streaming GEMV over 16 channels via svdot (all 16 lanes useful, no ZA). +// w packed [K/4][16][4]: w[kg*64 + n*4 + c] = W[n][kg*4 + c]. a: K int8. y: 16 int32. +__arm_locally_streaming +static void sme_svdot_gemv16(const int8_t* a, const int8_t* w, int32_t* y, uint32_t K) { + svbool_t pg = svptrue_b8(); + svint32_t acc = svdup_n_s32(0); + for (uint32_t kg = 0; kg < K / 4; ++kg) { + uint32_t a4; std::memcpy(&a4, a + kg * 4, 4); + svint8_t xb = svreinterpret_s8_u32(svdup_n_u32(a4)); // a[kg*4..+3] replicated to all lanes + svint8_t wv = svld1_s8(pg, w + (size_t)kg * 64); // 16 channels x 4 K + acc = svdot_s32(acc, xb, wv); // acc[n] += sum_c a[kg*4+c]*W[n][kg*4+c] + } + svst1_s32(svptrue_b32(), y, acc); +} + +int main() { + const uint32_t K = 256, Nc = 16; + std::vector a(K), W((size_t)Nc * K); + std::mt19937 g(7); + std::uniform_int_distribution d(-100, 100); + for (auto& v : a) v = (int8_t)d(g); + for (auto& v : W) v = (int8_t)d(g); + + std::vector ref(Nc, 0); + for (uint32_t n = 0; n < Nc; n++) { int32_t s = 0; for (uint32_t k = 0; k < K; k++) s += (int32_t)a[k]*(int32_t)W[n*K+k]; ref[n] = s; } + + std::vector wpack((size_t)Nc * K); + for (uint32_t kg = 0; kg < K/4; kg++) for (uint32_t n = 0; n < Nc; n++) for (uint32_t c = 0; c < 4; c++) + wpack[(size_t)kg*64 + n*4 + c] = W[n*K + kg*4 + c]; + + std::vector y(Nc, -123); + sme_svdot_gemv16(a.data(), wpack.data(), y.data(), K); + + int64_t maxerr = 0; for (uint32_t n = 0; n < Nc; n++) maxerr = std::max(maxerr, std::llabs((int64_t)y[n]-ref[n])); + printf("svdot streaming GEMV16 K=%u: maxerr=%lld -> %s\n", K, (long long)maxerr, maxerr==0?"PASS":"FAIL"); + return maxerr==0?0:1; +} diff --git a/cactus-kernels/docs/sme/working-examples/usmopa_probe.cpp b/cactus-kernels/docs/sme/working-examples/usmopa_probe.cpp new file mode 100644 index 000000000..002f922be --- /dev/null +++ b/cactus-kernels/docs/sme/working-examples/usmopa_probe.cpp @@ -0,0 +1,39 @@ +// Probe: svusmopa_za32_u8_m semantics on M4 — za32[i][j] += sum_{c<4} u8(zn[4i+c]) * s8(zm[4j+c])? +#include +#include +#include +#include +#include +#include + +__arm_locally_streaming __arm_new("za") +static void usmopa_tile(const uint8_t* zn_b, const int8_t* zm_b, int32_t* out) { + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + svzero_za(); + svuint8_t zn = svld1_u8(pg, zn_b); + svint8_t zm = svld1_s8(pg, zm_b); + svusmopa_za32_u8_m(0, pg, pg, zn, zm); + for (uint32_t r = 0; r < 16; ++r) + svst1_hor_za32(0, r, pg32, out + (size_t)r * 16); +} + +int main() { + std::mt19937 gen(3); + uint8_t zn[64]; int8_t zm[64]; + for (auto& v : zn) v = (uint8_t)(gen() % 256); // full unsigned range incl. >127 + for (auto& v : zm) v = (int8_t)((int)(gen() % 255) - 127); + int32_t out[256]; + usmopa_tile(zn, zm, out); + int64_t maxerr = 0; + for (int i = 0; i < 16; i++) + for (int j = 0; j < 16; j++) { + int32_t ref = 0; + for (int c = 0; c < 4; c++) ref += (int32_t)zn[4*i+c] * (int32_t)zm[4*j+c]; + int64_t e = (int64_t)out[i*16+j] - ref; + if (e < 0) e = -e; + if (e > maxerr) maxerr = e; + } + printf("USMOPA u8xs8 probe: max_err=%lld -> %s\n", (long long)maxerr, maxerr == 0 ? "PASS" : "FAIL"); + return maxerr != 0; +} diff --git a/cactus-kernels/src/attention_hybrid.cpp b/cactus-kernels/src/attention_hybrid.cpp index 044ff1408..fb4b7a8f7 100644 --- a/cactus-kernels/src/attention_hybrid.cpp +++ b/cactus-kernels/src/attention_hybrid.cpp @@ -1,5 +1,6 @@ #include "../cactus_kernels.h" #include "threading.h" +#include "matmul_simd.h" #include #include #include @@ -326,6 +327,503 @@ static void cactus_attention_hybrid_int8_fp16_decode_dot( }); } +// ── SME2 prefill attention over the cached-int8 KV segment ────────────────────────────────── +// Two-pass design: when position_offset >= cache_len the cached segment is FULLY causally visible +// to every prefill query row (kv_end = cache_len + q_pos + 1 > cache_len), so no online softmax is +// needed there. Per 16-query-row tile: (1) SME SMOPA computes ALL cached QK scores (64-kv blocks, +// raw int32 partials, fp rescale in NEON outside streaming mode), (2) one masked softmax pass over +// the whole cached row fixes GLOBAL per-(row, v-scale-group) P quantization scales, (3) SME SMOPA +// AV accumulates across ALL cached blocks inside ZA with a single readout per 64-dim slice — the +// per-(kv, 32-dim-group) V scales cannot factor out of the kv contraction, so they are folded into +// the int8 P operand (two pack variants per slice; see docs/sme/working-examples/avprobe.cpp). +// The fp16 keys_new/values_new segment then continues per row through the incumbent flash-softmax +// loop seeded from the cached state (flash softmax is partition-invariant). Window/sink masking +// replicates the incumbent's cached-segment semantics exactly (contiguous masked range per row). +static void cactus_attention_sme_prefill( + const __fp16* queries, + const int8_t* keys_cached, + const int8_t* values_cached, + const float* k_scales, + const float* v_scales, + const __fp16* keys_new, + const __fp16* values_new, + __fp16* output, + size_t batch_size, + size_t seq_len, + size_t cache_len, + size_t new_len, + size_t num_q_heads, + size_t num_kv_heads, + size_t head_dim, + float scale, + size_t position_offset, + bool is_causal, + size_t window_size, + size_t quant_group_size +) { + constexpr size_t QTILE = 16; + constexpr size_t KVBLK = 64; + constexpr size_t SINK_SIZE = 4; + const float NEG_INF = -std::numeric_limits::infinity(); + + const size_t kv_seq_len = cache_len + new_len; + const size_t n_qgroups = head_dim / quant_group_size; + const size_t dim_groups = quant_group_size / 4; + const size_t num_passes = head_dim / KVBLK; + const size_t num_blocks = (cache_len + KVBLK - 1) / KVBLK; + const size_t gqa_group_size = num_q_heads / num_kv_heads; + const size_t num_qtiles = (seq_len + QTILE - 1) / QTILE; + const size_t num_accum_slots = head_dim / 8; + + const size_t q_batch_stride = seq_len * num_q_heads * head_dim; + const size_t k_cached_batch_stride = cache_len * num_kv_heads * head_dim; + const size_t v_cached_batch_stride = cache_len * num_kv_heads * head_dim; + const size_t k_new_batch_stride = new_len * num_kv_heads * head_dim; + const size_t v_new_batch_stride = new_len * num_kv_heads * head_dim; + const size_t o_batch_stride = seq_len * num_q_heads * head_dim; + const size_t q_seq_stride = num_q_heads * head_dim; + const size_t k_seq_stride = num_kv_heads * head_dim; + const size_t v_seq_stride = num_kv_heads * head_dim; + const size_t o_seq_stride = num_q_heads * head_dim; + + const size_t cache_abs_offset = position_offset - cache_len; // gate guarantees >= 0 + + // Shared packed-KV scratch, built once per call and reused by every q-head tile in the GQA + // group: SMOPA operand layouts for K (per 64-kv block) and V (per 64-dim slice), plus + // kv-transposed scale planes [g][64] for vectorized rescale / P folding. Tail kv beyond + // cache_len pack as zeros with zero scales (their scores are forced to -inf below). + const size_t BH = batch_size * num_kv_heads; + const size_t kpack_blk = head_dim * KVBLK; + const size_t vpack_pass_blk = KVBLK * KVBLK; + const size_t scale_blk = n_qgroups * KVBLK; + std::vector kpack_all(BH * num_blocks * kpack_blk); + std::vector vpack_all(BH * num_passes * num_blocks * vpack_pass_blk); + std::vector kst_all(BH * num_blocks * KVBLK); // flat per-kv K scale + std::vector vst_all(BH * num_blocks * scale_blk); + + const size_t total_pack_items = BH * num_blocks; + const size_t n_pack_slots = std::min(total_pack_items, + CactusThreading::get_thread_pool().num_workers()); + CactusThreading::parallel_for(n_pack_slots, CactusThreading::ParallelConfig{1, 1}, + [&](size_t slot_start, size_t slot_end) { + alignas(16) int8_t zero_row[512] = {0}; + alignas(16) int8_t krq[512]; + for (size_t slot = slot_start; slot < slot_end; ++slot) + for (size_t item = slot; item < total_pack_items; item += n_pack_slots) { + const size_t bh = item / num_blocks; + const size_t blk = item % num_blocks; + const size_t batch_idx = bh / num_kv_heads; + const size_t kv_head_idx = bh % num_kv_heads; + const int8_t* K_base = keys_cached + batch_idx * k_cached_batch_stride; + const int8_t* V_base = values_cached + batch_idx * v_cached_batch_stride; + int8_t* kp = kpack_all.data() + (bh * num_blocks + blk) * kpack_blk; + float* kst = kst_all.data() + (bh * num_blocks + blk) * KVBLK; + float* vst = vst_all.data() + (bh * num_blocks + blk) * scale_blk; + + // K: requantize each kv row to ONE flat scale (ksflat = max_g ks[g]; per-group + // ratio folded into the int8 values — costs <=1 bit on low-scale groups, buys the + // single-readout QK leaf), then gather into [dim-quad][vec(c/16)][c%16][4] + const size_t words = head_dim / 4; + for (size_t c = 0; c < KVBLK; ++c) { + const size_t kvp = blk * KVBLK + c; + const bool valid = kvp < cache_len; + const int8_t* krow = zero_row; + if (valid) { + const int8_t* ksrc = K_base + kvp * k_seq_stride + kv_head_idx * head_dim; + const float* ks = k_scales + (kvp * num_kv_heads + kv_head_idx) * n_qgroups; + const float* vs = v_scales + (kvp * num_kv_heads + kv_head_idx) * n_qgroups; + float ksflat = 0.0f; + for (size_t g = 0; g < n_qgroups; ++g) ksflat = std::max(ksflat, ks[g]); + kst[c] = ksflat; + const float kinv = ksflat > 0.0f ? 1.0f / ksflat : 0.0f; + for (size_t g = 0; g < n_qgroups; ++g) { + vst[g * KVBLK + c] = vs[g]; + const float32x4_t rho = vdupq_n_f32(ks[g] * kinv); + for (size_t i = g * quant_group_size; i < (g + 1) * quant_group_size; i += 8) { + int16x8_t k16 = vmovl_s8(vld1_s8(ksrc + i)); + float32x4_t lo = vmulq_f32(vcvtq_f32_s32(vmovl_s16(vget_low_s16(k16))), rho); + float32x4_t hi = vmulq_f32(vcvtq_f32_s32(vmovl_s16(vget_high_s16(k16))), rho); + int16x4_t l16 = vqmovn_s32(vcvtnq_s32_f32(lo)); + int16x4_t h16 = vqmovn_s32(vcvtnq_s32_f32(hi)); + vst1_s8(krq + i, vqmovn_s16(vcombine_s16(l16, h16))); + } + } + krow = krq; + } else { + kst[c] = 0.0f; + for (size_t g = 0; g < n_qgroups; ++g) vst[g * KVBLK + c] = 0.0f; + } + int8_t* dstc = kp + (c >> 4) * 64 + (c & 15) * 4; + for (size_t w = 0; w < words; ++w) + memcpy(dstc + w * 256, krow + w * 4, 4); + } + + // V -> per 64-dim slice: [kg][tile][16 dims][4 kv] via 4-way byte interleave + for (size_t kg = 0; kg < 16; ++kg) { + const int8_t* vrow[4]; + for (size_t c4 = 0; c4 < 4; ++c4) { + const size_t kvp = blk * KVBLK + kg * 4 + c4; + vrow[c4] = (kvp < cache_len) + ? V_base + kvp * v_seq_stride + kv_head_idx * head_dim : zero_row; + } + for (size_t pass = 0; pass < num_passes; ++pass) { + int8_t* vp = vpack_all.data() + + ((bh * num_passes + pass) * num_blocks + blk) * vpack_pass_blk + kg * 256; + for (size_t t = 0; t < 4; ++t) { + const size_t off = pass * 64 + t * 16; + int8x16x4_t q; + q.val[0] = vld1q_s8(vrow[0] + off); + q.val[1] = vld1q_s8(vrow[1] + off); + q.val[2] = vld1q_s8(vrow[2] + off); + q.val[3] = vld1q_s8(vrow[3] + off); + vst4q_s8(vp + t * 64, q); + } + } + } + } + }); + + // Strided round-robin over tiles: parallel_for's static splitter floors work_per_thread and + // dumps the remainder on the LAST worker (64 tiles / 14 workers -> 13x4 + 1x12, a 3x straggler + // measured as ~60% pool idle). Each pseudo-item below is one worker slot; workers walk items + // slot, slot+nw, ... for +-1 balance (and unequal-cost windowed tiles get mixed). + const size_t total_tiles = batch_size * num_q_heads * num_qtiles; + const size_t n_slots = std::min(total_tiles, + CactusThreading::get_thread_pool().num_workers()); + CactusThreading::parallel_for(n_slots, + CactusThreading::ParallelConfig{1, 1}, + [&](size_t slot_start, size_t slot_end) { + std::vector qpack(n_qgroups * dim_groups * 64); + std::vector qs_scaled(QTILE); + std::vector qk_partials(num_blocks * 16 * 64); + const size_t srow_stride = num_blocks * KVBLK; + std::vector scores(QTILE * srow_stride); + std::vector m_c(QTILE), sum_c(QTILE); + std::vector ps_blk(QTILE * n_qgroups * num_blocks); + std::vector inv_ps_blk(QTILE * n_qgroups * num_blocks); + std::vector ppack(num_blocks * 2048); + std::vector av_out(num_blocks * 16 * 64); + std::vector oacc(QTILE * head_dim); + size_t mask_lo[QTILE], mask_hi[QTILE]; + std::vector output_accum_low(num_accum_slots); + std::vector output_accum_high(num_accum_slots); + std::vector block_accum(num_accum_slots); + float block_scores[32]; + + for (size_t slot = slot_start; slot < slot_end; ++slot) + for (size_t work_idx = slot; work_idx < total_tiles; work_idx += n_slots) { + const size_t batch_idx = work_idx / (num_q_heads * num_qtiles); + const size_t rem = work_idx % (num_q_heads * num_qtiles); + const size_t q_head_idx = rem / num_qtiles; + const size_t tile_idx = rem % num_qtiles; + const size_t kv_head_idx = q_head_idx / gqa_group_size; + const size_t bh = batch_idx * num_kv_heads + kv_head_idx; + const size_t tile0 = tile_idx * QTILE; + const size_t rows = std::min(QTILE, seq_len - tile0); + + const __fp16* Q_base = queries + batch_idx * q_batch_stride; + const __fp16* K_new_base = keys_new + batch_idx * k_new_batch_stride; + const __fp16* V_new_base = values_new + batch_idx * v_new_batch_stride; + __fp16* O_base = output + batch_idx * o_batch_stride; + const int8_t* kpack_bh = kpack_all.data() + bh * num_blocks * kpack_blk; + const float* kst_bh = kst_all.data() + bh * num_blocks * KVBLK; + const float* vst_bh = vst_all.data() + bh * num_blocks * scale_blk; + + // 1) quantize + pack Q with ONE scale per row (flat scales let the QK leaf + // accumulate the whole head_dim in ZA); `scale` folds into the rescale factor + memset(qpack.data(), 0, qpack.size()); + for (size_t r = 0; r < rows; ++r) { + const __fp16* q_vec = Q_base + (tile0 + r) * q_seq_stride + q_head_idx * head_dim; + float32x4_t amx = vdupq_n_f32(0.0f); + for (size_t i = 0; i < head_dim; i += 8) { + float16x8_t v = vld1q_f16(q_vec + i); + amx = vmaxq_f32(amx, vmaxq_f32( + vabsq_f32(vcvt_f32_f16(vget_low_f16(v))), + vabsq_f32(vcvt_f32_f16(vget_high_f16(v))))); + } + const float amax = vmaxvq_f32(amx); + const float inv = amax > 0.0f ? 127.0f / amax : 0.0f; + qs_scaled[r] = scale * (amax / 127.0f); + const float32x4_t invv = vdupq_n_f32(inv); + int8_t* qdst = qpack.data() + r * 4; + for (size_t i = 0; i < head_dim; i += 8) { + float16x8_t v = vld1q_f16(q_vec + i); + int32x4_t i0 = vcvtnq_s32_f32(vmulq_f32(vcvt_f32_f16(vget_low_f16(v)), invv)); + int32x4_t i1 = vcvtnq_s32_f32(vmulq_f32(vcvt_f32_f16(vget_high_f16(v)), invv)); + int8x8_t s8 = vqmovn_s16(vcombine_s16(vqmovn_s32(i0), vqmovn_s32(i1))); + vst1_lane_s32(reinterpret_cast(qdst + (i / 4) * 64), + vreinterpret_s32_s8(s8), 0); + vst1_lane_s32(reinterpret_cast(qdst + (i / 4 + 1) * 64), + vreinterpret_s32_s8(s8), 1); + } + } + + // 2) per-row window-mask ranges over the cached segment (incumbent semantics: + // masked iff (cache_abs_offset==0 || c >= SINK_SIZE) && cache_abs_offset+c < kv_start + // => one contiguous masked range [lo, hi) per row) + size_t skip_lo_max = 0, skip_hi_min = 0; + bool any_mask = false; + for (size_t r = 0; r < rows; ++r) { + const size_t absolute_q_pos = position_offset + tile0 + r; + size_t kv_start = 0; + if (window_size > 0 && absolute_q_pos > window_size) + kv_start = absolute_q_pos - window_size; + size_t lo = 0, hi = 0; + if (window_size > 0 && kv_start > 0) { + lo = (cache_abs_offset > 0) ? SINK_SIZE : 0; + hi = (kv_start > cache_abs_offset) ? kv_start - cache_abs_offset : 0; + hi = std::min(hi, cache_len); + if (hi < lo) hi = lo; + } + mask_lo[r] = lo; + mask_hi[r] = hi; + if (r == 0) { skip_lo_max = lo; skip_hi_min = hi; } + else { + skip_lo_max = std::max(skip_lo_max, lo); + skip_hi_min = std::min(skip_hi_min, hi); + } + if (hi > lo) any_mask = true; + } + + // 3) SME QK over the cached segment -> fp32 scores. The fully-masked block run is + // contiguous, so the leaf runs once on the prefix and once on the suffix. + size_t skipA = num_blocks, skipB = num_blocks; + if (any_mask) { + const size_t a = (skip_lo_max + KVBLK - 1) / KVBLK; + const size_t b = skip_hi_min / KVBLK; + if (a < b) { skipA = a; skipB = b; } + } + if (skipA > 0) + cactus_sme_attn_qk_seg(qpack.data(), kpack_bh, qk_partials.data(), + (uint32_t)(head_dim / 4), (uint32_t)skipA); + if (skipB < num_blocks) + cactus_sme_attn_qk_seg(qpack.data(), kpack_bh + skipB * kpack_blk, + qk_partials.data() + skipB * 1024, + (uint32_t)(head_dim / 4), (uint32_t)(num_blocks - skipB)); + for (size_t blk = 0; blk < num_blocks; ++blk) { + const size_t c0 = blk * KVBLK; + if (blk >= skipA && blk < skipB) { + for (size_t r = 0; r < rows; ++r) { + float* srow = scores.data() + r * srow_stride + c0; + for (size_t c = 0; c < KVBLK; ++c) srow[c] = NEG_INF; + } + continue; + } + const float* kstf = kst_bh + blk * KVBLK; + for (size_t r = 0; r < rows; ++r) { + float* srow = scores.data() + r * srow_stride + c0; + const float32x4_t qsv = vdupq_n_f32(qs_scaled[r]); + const int32_t* p64 = qk_partials.data() + blk * 1024 + r * 64; + for (size_t c = 0; c < KVBLK; c += 4) + vst1q_f32(srow + c, vmulq_f32( + vmulq_f32(vcvtq_f32_s32(vld1q_s32(p64 + c)), qsv), + vld1q_f32(kstf + c))); + } + } + for (size_t r = 0; r < rows; ++r) { + float* srow = scores.data() + r * srow_stride; + for (size_t c = cache_len; c < srow_stride; ++c) srow[c] = NEG_INF; + for (size_t c = mask_lo[r]; c < mask_hi[r]; ++c) srow[c] = NEG_INF; + } + + // 4) masked softmax stats per row over the whole cached segment (exp in place) + for (size_t r = 0; r < rows; ++r) { + float* srow = scores.data() + r * srow_stride; + float32x4_t mv = vdupq_n_f32(NEG_INF); + for (size_t c = 0; c < srow_stride; c += 4) + mv = vmaxq_f32(mv, vld1q_f32(srow + c)); + const float m = vmaxvq_f32(mv); + m_c[r] = m; + if (!(m > NEG_INF)) { + sum_c[r] = 0.0f; + memset(srow, 0, srow_stride * sizeof(float)); + continue; + } + // vectorized exp (scalar expf here is ~half the incumbent's attention core + // time); masked -inf lanes clamp to exp(-87) ~ 1.6e-38, which is negligible + // in the sum and rounds to 0 in the u8 P quantization — no branch needed + const float32x4_t mv4 = vdupq_n_f32(m); + float32x4_t sumv = vdupq_n_f32(0.0f); + for (size_t c = 0; c < srow_stride; c += 4) { + const float32x4_t e = fast_exp_f32x4(vsubq_f32(vld1q_f32(srow + c), mv4)); + vst1q_f32(srow + c, e); + sumv = vaddq_f32(sumv, e); + } + sum_c[r] = vaddvq_f32(sumv); + } + + // 5) per-(row, v-group, BLOCK) P quantization scales: ps = max_c(P*vs)/255 over + // each 64-kv block — per-block scales keep full u8 resolution in blocks far + // below the row max (peaked attention), at the cost of a per-block AV readout + for (size_t r = 0; r < rows; ++r) { + const float* prow = scores.data() + r * srow_stride; + for (size_t g = 0; g < n_qgroups; ++g) { + float* psd = ps_blk.data() + (r * n_qgroups + g) * num_blocks; + float* ipsd = inv_ps_blk.data() + (r * n_qgroups + g) * num_blocks; + for (size_t blk = 0; blk < num_blocks; ++blk) { + const float* vs64 = vst_bh + blk * scale_blk + g * KVBLK; + const float* p64 = prow + blk * KVBLK; + float32x4_t mx = vdupq_n_f32(0.0f); + for (size_t c = 0; c < KVBLK; c += 4) + mx = vmaxq_f32(mx, vmulq_f32(vld1q_f32(p64 + c), vld1q_f32(vs64 + c))); + const float m = vmaxvq_f32(mx); + psd[blk] = m / 255.0f; + ipsd[blk] = m > 0.0f ? 255.0f / m : 0.0f; + } + } + } + + // 6) SME AV: one pass per 64-dim slice; P folded with vs and the per-block scale, + // u8-quantized (USMOPA); per-block int32 tiles rescaled+accumulated in fp32 + for (size_t pass = 0; pass < num_passes; ++pass) { + memset(ppack.data(), 0, ppack.size()); + for (size_t grp = 0; grp < 2; ++grp) { + const size_t g = pass * 2 + grp; + for (size_t r = 0; r < rows; ++r) { + const float* prow = scores.data() + r * srow_stride; + const float* ipsd = inv_ps_blk.data() + (r * n_qgroups + g) * num_blocks; + for (size_t blk = 0; blk < num_blocks; ++blk) { + const float32x4_t invv = vdupq_n_f32(ipsd[blk]); + const float* vs64 = vst_bh + blk * scale_blk + g * KVBLK; + const float* p64 = prow + blk * KVBLK; + uint8_t* pdst = ppack.data() + blk * 2048 + grp * 1024 + r * 4; + for (size_t kg = 0; kg < 16; ++kg) { + float32x4_t f = vmulq_f32(vmulq_f32( + vld1q_f32(p64 + kg * 4), vld1q_f32(vs64 + kg * 4)), invv); + int16x4_t i16 = vqmovn_s32(vcvtnq_s32_f32(f)); + uint8x8_t u8 = vqmovun_s16(vcombine_s16(i16, i16)); + vst1_lane_u32(reinterpret_cast(pdst + kg * 64), + vreinterpret_u32_u8(u8), 0); + } + } + } + } + cactus_sme_attn_av_pass(ppack.data(), + vpack_all.data() + (bh * num_passes + pass) * num_blocks * vpack_pass_blk, + av_out.data(), (uint32_t)num_blocks); + for (size_t r = 0; r < rows; ++r) { + for (size_t grp = 0; grp < 2; ++grp) { + const size_t g = pass * 2 + grp; + const float* psd = ps_blk.data() + (r * n_qgroups + g) * num_blocks; + float32x4_t acc[8]; + for (size_t i = 0; i < 8; ++i) acc[i] = vdupq_n_f32(0.0f); + for (size_t blk = 0; blk < num_blocks; ++blk) { + const float32x4_t psv = vdupq_n_f32(psd[blk]); + const int32_t* src = av_out.data() + blk * 1024 + r * 64 + grp * 32; + for (size_t d = 0; d < 32; d += 4) + acc[d / 4] = vfmaq_f32(acc[d / 4], + vcvtq_f32_s32(vld1q_s32(src + d)), psv); + } + float* dst = oacc.data() + r * head_dim + pass * 64 + grp * 32; + for (size_t d = 0; d < 32; d += 4) vst1q_f32(dst + d, acc[d / 4]); + } + } + } + + // 7) per row: seed the flash state from the cached segment, then the incumbent + // loop over the fp16 new segment [cache_len, kv_end) + for (size_t r = 0; r < rows; ++r) { + const size_t q_pos = tile0 + r; + const size_t absolute_q_pos = position_offset + q_pos; + const __fp16* q_vec = Q_base + q_pos * q_seq_stride + q_head_idx * head_dim; + __fp16* o_vec = O_base + q_pos * o_seq_stride + q_head_idx * head_dim; + + float running_max = m_c[r]; + float running_sum = sum_c[r]; + const float* orow = oacc.data() + r * head_dim; + for (size_t i = 0; i < num_accum_slots; ++i) { + output_accum_low[i] = vld1q_f32(orow + i * 8); + output_accum_high[i] = vld1q_f32(orow + i * 8 + 4); + } + + size_t kv_start = 0; + if (window_size > 0 && absolute_q_pos > window_size) + kv_start = absolute_q_pos - window_size; + const size_t kv_end = is_causal + ? std::min(kv_seq_len, cache_len + q_pos + 1) : kv_seq_len; + + for (size_t kv_block_start = cache_len; kv_block_start < kv_end; kv_block_start += 32) { + const size_t kv_block_end = std::min(kv_block_start + 32, kv_end); + const size_t block_size = kv_block_end - kv_block_start; + + float block_max = NEG_INF; + for (size_t kv_idx = 0; kv_idx < block_size; ++kv_idx) { + const size_t kv_pos = kv_block_start + kv_idx; + bool window_masked = false; + if (window_size > 0 && kv_start > 0) + window_masked = (kv_pos + cache_abs_offset < kv_start); + if ((is_causal && kv_pos > absolute_q_pos) || window_masked) { + block_scores[kv_idx] = NEG_INF; + continue; + } + const size_t new_pos = kv_pos - cache_len; + const __fp16* k_vec = K_new_base + new_pos * k_seq_stride + kv_head_idx * head_dim; + float16x8_t s_acc = vdupq_n_f16((__fp16)0.0f); + for (size_t d = 0; d < head_dim; d += 8) + s_acc = vfmaq_f16(s_acc, vld1q_f16(q_vec + d), vld1q_f16(k_vec + d)); + float score = vaddvq_f32(vcvt_f32_f16(vget_low_f16(s_acc))) + + vaddvq_f32(vcvt_f32_f16(vget_high_f16(s_acc))); + score *= scale; + block_scores[kv_idx] = score; + block_max = std::max(block_max, score); + } + + if (block_max > NEG_INF) { + const float scale_correction = expf(running_max - block_max); + running_sum *= scale_correction; + for (size_t i = 0; i < num_accum_slots; ++i) { + output_accum_low[i] = vmulq_n_f32(output_accum_low[i], scale_correction); + output_accum_high[i] = vmulq_n_f32(output_accum_high[i], scale_correction); + } + running_max = block_max; + } + + float block_sum = 0.0f; + for (size_t kv_idx = 0; kv_idx < block_size; ++kv_idx) { + if (block_scores[kv_idx] != NEG_INF) { + block_scores[kv_idx] = expf(block_scores[kv_idx] - block_max); + block_sum += block_scores[kv_idx]; + } else { + block_scores[kv_idx] = 0.0f; + } + } + + for (size_t i = 0; i < num_accum_slots; ++i) + block_accum[i] = vdupq_n_f16((__fp16)0.0f); + + for (size_t kv_idx = 0; kv_idx < block_size; ++kv_idx) { + const float attn_weight = block_scores[kv_idx]; + if (attn_weight == 0.0f) continue; + const size_t new_pos = kv_block_start + kv_idx - cache_len; + const __fp16* v_vec = V_new_base + new_pos * v_seq_stride + kv_head_idx * head_dim; + const float16x8_t w_vec = vdupq_n_f16((__fp16)attn_weight); + for (size_t d = 0; d < head_dim; d += 8) + block_accum[d / 8] = vfmaq_f16(block_accum[d / 8], vld1q_f16(v_vec + d), w_vec); + } + + for (size_t i = 0; i < num_accum_slots; ++i) { + output_accum_low[i] = vaddq_f32(output_accum_low[i], vcvt_f32_f16(vget_low_f16(block_accum[i]))); + output_accum_high[i] = vaddq_f32(output_accum_high[i], vcvt_f32_f16(vget_high_f16(block_accum[i]))); + } + running_sum += block_sum; + } + + if (running_sum > 0.0f) { + const float32x4_t inv_sum_vec = vdupq_n_f32(1.0f / running_sum); + for (size_t d = 0; d < head_dim; d += 8) { + const size_t idx = d / 8; + vst1q_f16(o_vec + d, vcombine_f16( + vcvt_f16_f32(vmulq_f32(output_accum_low[idx], inv_sum_vec)), + vcvt_f16_f32(vmulq_f32(output_accum_high[idx], inv_sum_vec)))); + } + } else { + memset(o_vec, 0, head_dim * sizeof(__fp16)); + } + } + } + }); +} + void cactus_attention_hybrid_int8_fp16( const __fp16* queries, const int8_t* keys_cached, @@ -368,6 +866,29 @@ void cactus_attention_hybrid_int8_fp16( return; } + // SME2 prefill path: cached-int8 segment via SMOPA tiles (16 q-rows x 64 kv), new fp16 + // segment via the incumbent per-row flash loop. Requires the cached segment to be fully + // causally visible (position_offset >= cache_len, the chunked-prefill invariant) and the + // engine's 32-wide KV quantization groups. cache_len <= 8192 bounds the packed-KV scratch + // (~2.3 MB at Gemma shapes); larger caches fall back to the incumbent. + if (seq_len >= 2 && + k_scales != nullptr && v_scales != nullptr && + head_dim == v_head_dim && + head_dim % 64 == 0 && head_dim <= 512 && + quant_group_size == 32 && + cache_len >= 64 && cache_len <= 8192 && + position_offset >= cache_len && + num_kv_heads > 0 && num_q_heads % num_kv_heads == 0 && + cactus_quant_sme_attn_enabled()) { + cactus_attention_sme_prefill( + queries, keys_cached, values_cached, k_scales, v_scales, + keys_new, values_new, output, + batch_size, seq_len, cache_len, new_len, + num_q_heads, num_kv_heads, head_dim, + scale, position_offset, is_causal, window_size, quant_group_size); + return; + } + const size_t kv_seq_len = cache_len + new_len; constexpr size_t VECTOR_WIDTH = 8; diff --git a/cactus-kernels/src/matmul.cpp b/cactus-kernels/src/matmul.cpp index 36ddff28d..f99b51efe 100644 --- a/cactus-kernels/src/matmul.cpp +++ b/cactus-kernels/src/matmul.cpp @@ -1,5 +1,6 @@ #include "../cactus_kernels.h" #include "threading.h" +#include "matmul_simd.h" #include #include #include @@ -469,6 +470,27 @@ static inline bool cactus_quant_has_panels(const CactusQuantMatrix* W) { return W->packed_panels != nullptr && W->norm_panels != nullptr; } +// SME2 backend override for tests/benchmarks: 0 = auto, 1 = force NEON, 2 = force SME2 leaves. +static std::atomic g_cactus_quant_backend{[] { + const char* e = getenv("CACTUS_QUANT_BACKEND"); + return e ? atoi(e) : 0; +}()}; +static std::atomic g_sme_gemv_workers{-1}; + +static inline int cactus_quant_backend_now() { + return g_cactus_quant_backend.load(std::memory_order_relaxed); +} +// Explicit SME GEMV worker override (setter wins over env), or -1 for the flat auto policy. +static inline int cactus_quant_sme_gemv_workers() { + const int o = g_sme_gemv_workers.load(std::memory_order_relaxed); + if (o >= 0) return o; + static const int env_override = [] { + const char* e = getenv("CACTUS_SME_GEMV_WORKERS"); + return e ? atoi(e) : -1; + }(); + return env_override; +} + static bool cactus_quant_valid_common(const CactusQuantMatrix* W, const void* A, void* C) { if (W == nullptr || A == nullptr || C == nullptr) return false; if (W->K == 0 || W->N == 0 || W->group_size == 0 || W->num_groups == 0) return false; @@ -482,6 +504,26 @@ static bool cactus_quant_valid_common(const CactusQuantMatrix* W, const void* A, } // namespace +int cactus_quant_set_backend(int backend) { + g_cactus_quant_backend.store(backend, std::memory_order_relaxed); + return cpu_has_sme2() ? 1 : 0; +} +int cactus_quant_sme_available(void) { return cpu_has_sme2() ? 1 : 0; } +int cactus_quant_sme_enabled(void) { + return (cpu_has_sme2() && cactus_quant_backend_now() != 1) ? 1 : 0; +} +int cactus_quant_sme_attn_enabled(void) { + static const int env_on = [] { + const char* e = getenv("CACTUS_SME_ATTENTION"); + return e ? atoi(e) : 1; + }(); + return (env_on && cactus_quant_sme_enabled()) ? 1 : 0; +} +int cactus_quant_set_sme_gemv_workers(int n) { + g_sme_gemv_workers.store(n, std::memory_order_relaxed); + return cpu_has_sme2() ? 1 : 0; +} + static void cactus_quant_dispatch_group_gemm( const CactusQuantMatrix* W, const __fp16* A, @@ -1628,6 +1670,42 @@ static void cactus_quant_panel_gemv_blocks( // Fused panel GEMV (M=1): one pool dispatch = phase A (group-parallel Hadamard + int8 quantize, // spin barrier) + phase B (dynamic super-block stealing). Main runs as worker 0 and spin-joins. // Thread budget = ceil(SB64 / CACTUS_GEMV_SB_PER_THREAD), default 8 (mobile: battery > saturation). +// Rescale one super-block of raw SME int32 partials -> fp16 C (NEON, outside streaming mode): +// C[sb*64+c] = sum_g partials[g][c] * act_scale[g] * norm_panels[sb][g][c]. +static void cactus_quant_panel_rescale_sb(const int32_t* psb, size_t sb, const float* norm_panels, + const float* act_scales, uint32_t num_groups, + uint32_t N, __fp16* C) { + float32x4_t acc[16]; + for (int v = 0; v < 16; ++v) acc[v] = vdupq_n_f32(0.f); + const float* nsb = norm_panels + sb * num_groups * 64; + for (uint32_t g = 0; g < num_groups; ++g) { + const float32x4_t as = vdupq_n_f32(act_scales[g]); + const int32_t* pp = psb + static_cast(g) * 64; + const float* nf = nsb + static_cast(g) * 64; + for (int v = 0; v < 16; ++v) + acc[v] = vmlaq_f32(acc[v], + vmulq_f32(vcvtq_f32_s32(vld1q_s32(pp + v * 4)), as), + vld1q_f32(nf + v * 4)); + } + const size_t n_start = sb * 64; + const uint32_t valid = std::min(64, N - static_cast(n_start)); + float tmp[64]; + for (int v = 0; v < 16; ++v) vst1q_f32(tmp + v * 4, acc[v]); + for (uint32_t c = 0; c < valid; ++c) C[n_start + c] = static_cast<__fp16>(tmp[c]); +} + +// SME workers live INSIDE the thread budget, replacing NEON workers (measured frontier: flat k=2 +// dominates k=0 on speed AND power). Env/setter overrides; backend 2 clamps >= 1 for leaf coverage. +static inline size_t cactus_quant_panel_k_sme(size_t nt, uint32_t gs) { + if (!cactus_quant_sme_enabled() || (gs % 32) != 0) return 0; + const size_t cap = nt > 1 ? nt - 1 : 1; + const int ov = cactus_quant_sme_gemv_workers(); + if (cactus_quant_backend_now() == 2) + return std::max(1, std::min(ov > 0 ? static_cast(ov) : 2, cap)); + if (ov >= 0) return std::min(static_cast(ov), nt > 1 ? nt - 1 : 0); + return std::min(2, nt > 1 ? nt - 1 : 0); +} + static void cactus_quant_panel_gemv(const CactusQuantMatrix* W, const __fp16* A, __fp16* C) { const uint32_t gs = W->group_size; const uint32_t num_groups = W->num_groups; @@ -1653,6 +1731,9 @@ static void cactus_quant_panel_gemv(const CactusQuantMatrix* W, const __fp16* A, const float cb_scale = tq_quantize_codebook_i8(W->codebook, cb_i8, 1u << W->bits); (void)cb_scale; // folded into norm_panels by the transpiler const int8x16_t cb_lut = vld1q_s8(cb_i8); + alignas(16) uint8_t zt[64] = {}; + for (int i = 0; i < 16; ++i) zt[4 * i] = static_cast(cb_i8[i]); + const bool sme = cactus_quant_sme_enabled() && (gs % 32) == 0; auto phase_a_group = [&](uint32_t g) { __fp16 basis[256]; // gs <= 256 (same bound as the transform's tmp) @@ -1662,20 +1743,33 @@ static void cactus_quant_panel_gemv(const CactusQuantMatrix* W, const __fp16* A, if (nt <= 1) { for (uint32_t g = 0; g < num_groups; ++g) phase_a_group(g); - cactus_quant_panel_gemv_blocks(W, act_i8, act_scales, cb_lut, 0, SB64, C); + if (sme) { + static thread_local std::vector tl_partials1; + if (tl_partials1.size() < SB64 * num_groups * 64) tl_partials1.resize(SB64 * num_groups * 64); + cactus_sme_cq_gemv_luti4_s8(act_i8, W->packed_panels, zt, tl_partials1.data(), + num_groups, gs, 0, static_cast(SB64)); + for (size_t sb = 0; sb < SB64; ++sb) + cactus_quant_panel_rescale_sb(tl_partials1.data() + sb * num_groups * 64, sb, + W->norm_panels, act_scales, num_groups, N, C); + } else { + cactus_quant_panel_gemv_blocks(W, act_i8, act_scales, cb_lut, 0, SB64, C); + } return; } + const size_t k_sme = sme ? cactus_quant_panel_k_sme(nt, gs) : 0; + std::atomic ga{0}; // phase A group grab std::atomic a_done{0}; // phase A completion (release/acquire barrier) std::atomic next{0}; // phase B super-block grab std::atomic done{0}; // worker completion (spin-join, no cv wake) - auto worker = [&](size_t) { + auto worker = [&](size_t wid) { for (uint32_t g; (g = ga.fetch_add(1, std::memory_order_relaxed)) < num_groups; ) { phase_a_group(g); a_done.fetch_add(1, std::memory_order_release); } while (a_done.load(std::memory_order_acquire) < num_groups) { /* spin */ } + thread_local std::vector tl_partials; for (;;) { const uint32_t seen = next.load(std::memory_order_relaxed); if (seen >= SB64) break; @@ -1683,10 +1777,23 @@ static void cactus_quant_panel_gemv(const CactusQuantMatrix* W, const __fp16* A, const uint32_t sb = next.fetch_add(want, std::memory_order_relaxed); if (sb >= SB64) break; const uint32_t cnt = std::min(want, static_cast(SB64) - sb); - cactus_quant_panel_gemv_blocks(W, act_i8, act_scales, cb_lut, - sb, static_cast(sb) + cnt, C); + if (wid < k_sme) { + const size_t need = static_cast(cnt) * num_groups * 64; + if (tl_partials.size() < need) tl_partials.resize(need); + cactus_sme_cq_gemv_luti4_s8(act_i8, W->packed_panels, zt, tl_partials.data(), + num_groups, gs, sb, cnt); + for (uint32_t s2 = 0; s2 < cnt; ++s2) + cactus_quant_panel_rescale_sb( + tl_partials.data() + static_cast(s2) * num_groups * 64, + static_cast(sb) + s2, W->norm_panels, act_scales, num_groups, N, C); + } else { + cactus_quant_panel_gemv_blocks(W, act_i8, act_scales, cb_lut, + sb, static_cast(sb) + cnt, C); + } } }; + // Main participates as worker 0 (the SME role: zero wake latency, early SME issue matters + // most) and spin-joins — a wait_all cv sleep costs ~5-10us per call, material at decode rates. pool.enqueue_n_threads(nt - 1, nt - 1, [&](size_t wid, size_t) { worker(wid + 1); done.fetch_add(1, std::memory_order_release); @@ -1732,6 +1839,9 @@ void cactus_quant_orth_panel_gemv(const CactusQuantMatrix* W2, const __fp16* rot const float cb_scale = tq_quantize_codebook_i8(W2->codebook, cb_i8, 1u << W2->bits); (void)cb_scale; // folded into norm_panels by the transpiler const int8x16_t cb_lut = vld1q_s8(cb_i8); + alignas(16) uint8_t zt[64] = {}; + for (int i = 0; i < 16; ++i) zt[4 * i] = static_cast(cb_i8[i]); + const bool sme = cactus_quant_sme_enabled() && (gs % 32) == 0; // rotate-and-quantize one virtual group: A_rot[i] = sum_k a_scaled[k]*R[k][i] = dot(a_scaled, rot_t[i]) auto phase_a_group = [&](uint32_t g) { __fp16 basis[128]; @@ -1751,20 +1861,33 @@ void cactus_quant_orth_panel_gemv(const CactusQuantMatrix* W2, const __fp16* rot if (nt <= 1) { for (uint32_t g = 0; g < num_groups; ++g) phase_a_group(g); - cactus_quant_panel_gemv_blocks(W2, act_i8, act_scales, cb_lut, 0, SB64, C); + if (sme) { + static thread_local std::vector tl_op1; + if (tl_op1.size() < SB64 * num_groups * 64) tl_op1.resize(SB64 * num_groups * 64); + cactus_sme_cq_gemv_luti4_s8(act_i8, W2->packed_panels, zt, tl_op1.data(), + num_groups, gs, 0, static_cast(SB64)); + for (size_t sb = 0; sb < SB64; ++sb) + cactus_quant_panel_rescale_sb(tl_op1.data() + sb * num_groups * 64, sb, + W2->norm_panels, act_scales, num_groups, N, C); + } else { + cactus_quant_panel_gemv_blocks(W2, act_i8, act_scales, cb_lut, 0, SB64, C); + } return; } + const size_t k_sme = sme ? cactus_quant_panel_k_sme(nt, gs) : 0; + std::atomic ga{0}; std::atomic a_done{0}; std::atomic next{0}; std::atomic done{0}; - auto worker = [&](size_t) { + auto worker = [&](size_t wid) { for (uint32_t g; (g = ga.fetch_add(1, std::memory_order_relaxed)) < num_groups; ) { phase_a_group(g); a_done.fetch_add(1, std::memory_order_release); } while (a_done.load(std::memory_order_acquire) < num_groups) { /* spin */ } + thread_local std::vector tl_partials; for (;;) { const uint32_t seen = next.load(std::memory_order_relaxed); if (seen >= SB64) break; @@ -1772,8 +1895,19 @@ void cactus_quant_orth_panel_gemv(const CactusQuantMatrix* W2, const __fp16* rot const uint32_t sb = next.fetch_add(want, std::memory_order_relaxed); if (sb >= SB64) break; const uint32_t cnt = std::min(want, static_cast(SB64) - sb); - cactus_quant_panel_gemv_blocks(W2, act_i8, act_scales, cb_lut, - sb, static_cast(sb) + cnt, C); + if (wid < k_sme) { + const size_t need = static_cast(cnt) * num_groups * 64; + if (tl_partials.size() < need) tl_partials.resize(need); + cactus_sme_cq_gemv_luti4_s8(act_i8, W2->packed_panels, zt, tl_partials.data(), + num_groups, gs, sb, cnt); + for (uint32_t s2 = 0; s2 < cnt; ++s2) + cactus_quant_panel_rescale_sb( + tl_partials.data() + static_cast(s2) * num_groups * 64, + static_cast(sb) + s2, W2->norm_panels, act_scales, num_groups, N, C); + } else { + cactus_quant_panel_gemv_blocks(W2, act_i8, act_scales, cb_lut, + sb, static_cast(sb) + cnt, C); + } } }; // Main participates as worker 0 + spin-join (no wait_all cv sleep) — see the GEMV driver. @@ -2011,6 +2145,11 @@ void cactus_quant_matmul( (void)cb_scale; // folded into norm_panels by the transpiler const int8x16_t cb_lut = vld1q_s8(cb_i8); const uint8x16_t lo_mask = vdupq_n_u8(0x0F); + alignas(16) uint8_t zt[64] = {}; + for (int i2 = 0; i2 < 16; ++i2) zt[4 * i2] = static_cast(cb_i8[i2]); + // Fused SMOPA GEMM beats the NEON panel core at every measured M>1 (2.4-3.6x) — auto + // takes it whenever SME2 is enabled; backend 1 pins the NEON body. + const bool use_smopa = cactus_quant_sme_enabled() && (gs % 32) == 0; auto& pool = CactusThreading::get_thread_pool(); // Same thread budget as the original M>1 NEON GEMM (one thread per 16-row M-tile). @@ -2039,7 +2178,45 @@ void cactus_quant_matmul( for (uint32_t r = 0; r < 16; ++r) std::memcpy(dst + static_cast(kg) * 64 + r * 4, rowq[r] + kg * 4, 4); }; + // SMOPA variant: the LUTI4/SMOPA leaf emits raw int32 partials [g][16 rows][64 ch]; + // rescale runs in NEON per pair (identical math to the NEON body's fp32 accumulate). + auto run_pair_smopa = [&](size_t pair, int32_t* partials) { + const size_t mt = pair / SB64; + const size_t sb = pair % SB64; + cactus_sme_cq_gemm_luti4_s8( + act_packed.data() + (mt * num_groups) * static_cast(gs) * 16, + W->packed_panels + sb * static_cast(num_groups) * g_stride, + zt, partials, num_groups, gs); + const uint32_t m_rows = std::min(TILE_M16, M - static_cast(mt) * TILE_M16); + const uint32_t valid_n = std::min(64, N - static_cast(sb) * 64); + const float* nsb = W->norm_panels + sb * static_cast(num_groups) * 64; + for (uint32_t r = 0; r < m_rows; ++r) { + const uint32_t m = static_cast(mt) * TILE_M16 + r; + float32x4_t acc[16]; + for (int v = 0; v < 16; ++v) acc[v] = vdupq_n_f32(0.f); + for (uint32_t g = 0; g < num_groups; ++g) { + const float32x4_t as = vdupq_n_f32(act_scales[static_cast(m) * num_groups + g]); + const int32_t* pp = partials + (static_cast(g) * 16 + r) * 64; + const float* nf = nsb + static_cast(g) * 64; + for (int v = 0; v < 16; ++v) + acc[v] = vmlaq_f32(acc[v], + vmulq_f32(vcvtq_f32_s32(vld1q_s32(pp + v * 4)), as), + vld1q_f32(nf + v * 4)); + } + float tmp[64]; + for (int v = 0; v < 16; ++v) vst1q_f32(tmp + v * 4, acc[v]); + __fp16* crow = C + static_cast(m) * N + sb * 64; + for (uint32_t c = 0; c < valid_n; ++c) crow[c] = static_cast<__fp16>(tmp[c]); + } + }; auto run_pair = [&](size_t pair) { + if (use_smopa) { + thread_local std::vector tl_partials; + const size_t need = static_cast(num_groups) * 16 * 64; + if (tl_partials.size() < need) tl_partials.resize(need); + run_pair_smopa(pair, tl_partials.data()); + return; + } const size_t mt = pair / SB64; const size_t sb = pair % SB64; const uint32_t m_rows = std::min(TILE_M16, M - static_cast(mt) * TILE_M16); diff --git a/cactus-kernels/src/matmul_simd.h b/cactus-kernels/src/matmul_simd.h new file mode 100644 index 000000000..0f7e02454 --- /dev/null +++ b/cactus-kernels/src/matmul_simd.h @@ -0,0 +1,88 @@ +#pragma once +// Shared declarations bridging the base (NEON, armv8.2-a+...) translation units and the +// SME2 translation unit (matmul_sme2.cpp, compiled with +sme2). The SME leaf kernels below are +// __arm_locally_streaming in their definition (they self-transition to streaming mode + own ZA), +// so they are callable as ordinary functions from non-streaming code — the declaration here is +// plain. Guarded behind runtime cpu_has_sme2() dispatch by the caller; a compile-time stub is +// provided when the TU is built without SME2 so the symbol always exists. +#include + +extern "C" { + +// Streaming vector length in bytes (svcntb in streaming mode); 0 when built without SME2. +// cpu_has_sme2() gates ALL SME dispatch on this returning 64 — every shipped layout is pinned +// to 512-bit SVL; other implementations fall back to NEON until SVL-parametric variants exist. +uint32_t cactus_sme2_svl_bytes(void); + +// CQ GEMV (M=1) per-group SMOPA accumulate, 4 output channels per call-block. +// act_i8 : [num_groups * gs] int8, per-group quantized activations (group g at +g*gs). +// w_block : Cactus 'expanded' INT8 weights for ONE 4-channel N-block; group g panel at +// w_block + (size_t)g*gs*4, with each kg-th 16 bytes = 4 channels x 4 K +// (identical layout to the NEON SDOT path). Up to `valid_cols` (1..4) channels valid. +// partials_out : [num_groups * 4] int32 raw dot products; partials_out[g*4 + c] = +// sum_k act[g][k] * weight[channel c][k]. Caller applies act_scale * norm rescale. +// Numerically identical (integer) to the NEON SDOT inner loop, so the existing FP rescale reproduces +// the reference exactly. +void cactus_sme_cq_gemv_4col_s8(const int8_t* act_i8, const int8_t* w_block, + int32_t* partials_out, uint32_t num_groups, uint32_t gs); + +// CQ GEMM full-tile (M>1): up to 16 act-rows x 16 output channels per call — full ZA-tile width. +// act_packed : [num_groups][gs/4][16][4] (rows). w_sme: [num_groups][gs/4][16][4] (16 channels, +// gathered from four 4-channel 'expanded' panels). Both contiguous, 64 B per kg. +// partials_out: [num_groups * 16 rows * 16 cols] int32, [g][i][c] layout. +void cactus_sme_cq_gemm_16x16_s8(const int8_t* act_packed, const int8_t* w_sme, + int32_t* partials_out, uint32_t num_groups, uint32_t gs, + uint32_t m_rows); + +// CQ GEMV (M=1): packed 4-bit nibbles + in-engine LUTI4/ZT0 codebook expansion + indexed +// multi-vector dots, emitting RAW INT32 PARTIALS (caller rescales in NEON). Streams half the +// weight bytes of the int8 path and never reads ZA back to the core — both required to compete +// with NEON on Apple's shared in-order SME command queue. Defined in matmul_sme2_gemv.cpp, +// compiled at -O1 (clang -O2 mis-compiles vg1x4 ZA dots -> runtime SIGILL). +// act_i8 : [num_groups*gs] int8 acts. esme_packed: packed cache (CactusQuantMatrix::expanded_sme). +// zt_table : 64B ZT0 image, byte 4*i = cb_i8[i]. +// partials : [sb_count][num_groups][64] int32 (s relative to sb_start); +// partials[s][g][c] = sum_k act[g][k] * cb_i8[idx(n,k)] for channel n = (sb+s)*64+c. +void cactus_sme_cq_gemv_luti4_s8(const int8_t* act_i8, const uint8_t* esme_packed, + const uint8_t* zt_table, int32_t* partials, + uint32_t num_groups, uint32_t gs, + uint32_t sb_start, uint32_t sb_count); + +// CQ GEMM: one (16-row M-tile, 64-channel super-block) pair via SMOPA over LUTI4-expanded packed +// nibbles — 4 ZA tiles in flight, no per-call weight gather, partials to memory (NEON rescale). +// Defined in matmul_sme2_gemv.cpp (-O1 TU). +// act_packed: [num_groups][gs/4][16 rows][4 K] int8 (rows beyond m_rows zero-padded). +// esme_sb : expanded_sme + sb*(num_groups*(gs/4)*128). +// partials : [num_groups][16 rows][64 ch] int32. +void cactus_sme_cq_gemm_luti4_s8(const int8_t* act_packed, const uint8_t* esme_sb, + const uint8_t* zt_table, int32_t* partials, + uint32_t num_groups, uint32_t gs); + +// Prefill attention QK: 16 q-rows x the WHOLE cached segment, FLAT scales — Q is quantized with +// one scale per row and cached K is REQUANTIZED at pre-pack to one scale per kv (per-group scale +// ratios folded into the int8 values), so ZA accumulates across all of head_dim and reads out +// ONCE per 64-kv block (16x64 int32 = 4 KB). The earlier per-qgroup-readout variant was +// store-dominated (512 ZA stores vs 256 SMOPAs per block; profile: 57% of busy time stalled in +// the leaf). Caller rescales scores by qs[r]*ksflat[c] in NEON. Layout = the proven GEMM +// conventions (docs/sme/working-examples/qkprobe.cpp, max_err=0). +// qpack : [head_dim/4 dim-quads][16 rows][4 dims] int8 (head_dim*16 B). +// kpack : per block [head_dim/4][4 vec][16 kv][4 dims] int8 (head_dim*64 B per block), +// vec v = kv 16v..16v+15 (za tile v); blocks contiguous. +// partials : [num_blocks][16 rows][64 kv] int32 raw dots. +void cactus_sme_attn_qk_seg(const int8_t* qpack, const int8_t* kpack, int32_t* partials, + uint32_t dim_quads, uint32_t num_blocks); + +// Prefill attention AV: ONE 64-dim slice of head_dim over the whole cached segment, PER-BLOCK +// ZA readout. P comes in u8-quantized (USMOPA u8 x s8 — softmax probs are non-negative, so +// unsigned doubles the resolution; probe-verified) with the per-(kv,32-dim-group) V scale folded +// in (two pack variants per slice) and a per-(row, v-group, BLOCK) scale — the caller rescales +// each block's int32 tile by that scale and accumulates in fp32. Verified layout: +// docs/sme/working-examples/avprobe.cpp (max_err=0) + usmopa probe. +// ppack : [num_blocks][2][16 kv-grp][16 rows][4 kv] u8 (num_blocks*2048 B); variant 0 feeds +// dim tiles 0-1 (slice dims 0-31), variant 1 feeds tiles 2-3 (dims 32-63). +// vpack : [num_blocks][16 kv-grp][4 tiles][16 dims][4 kv] int8 (num_blocks*4096 B). +// out : [num_blocks][16 rows][64 dims] int32. +void cactus_sme_attn_av_pass(const uint8_t* ppack, const int8_t* vpack, int32_t* out, + uint32_t num_blocks); + +} // extern "C" diff --git a/cactus-kernels/src/matmul_sme2.cpp b/cactus-kernels/src/matmul_sme2.cpp new file mode 100644 index 000000000..fdce7600b --- /dev/null +++ b/cactus-kernels/src/matmul_sme2.cpp @@ -0,0 +1,154 @@ +// SME2 streaming-mode leaf kernels for the Cactus CQ quantized matmul. +// Compiled with `-march=...+sme2` (see CMakeLists.txt); falls back to scalar stubs when the toolchain +// lacks SME2 so the symbols always exist and the base library links on any arm64 target. +// +// These functions do ONLY the integer SMOPA accumulate. The Hadamard transform, per-group INT8 +// activation quantization, codebook expansion, and the final FP rescale all stay in the non-streaming +// NEON code (matmul.cpp). The integer partial sums produced here are bit-identical to the NEON SDOT +// inner loop, so the existing FP rescale reproduces the FP32 reference oracle exactly. +// +// SMOPA semantics (verified, see docs/sme/working-examples/int8_smopa_matmul.cpp): +// za32[i][j] += sum_{c=0..3} Zn[i*4+c] * Zm[j*4+c] (each instruction consumes 4 K-values) +// SVL on Apple M4 = 64 bytes => za32 tile is 16x16 int32, Z vectors hold 16*4 = 64 int8. +#include "matmul_simd.h" + +#if defined(__ARM_FEATURE_SME2) +#include + +// Streaming vector length probe for the runtime dispatch gate (cpu_has_sme2 requires SVL == 64 B +// because every shipped layout is pinned to it; other SVLs fall back to NEON). +__arm_locally_streaming +uint32_t cactus_sme2_svl_bytes(void) { + return (uint32_t)svcntb(); +} + +// CQ GEMV (M=1): single activation row vs up to 4 weight channels, accumulated per group. +// Uses only ZA row 0 (predicated) and columns 0..3 — low utilization but correct; this is the +// M=1 path (perf-deferred). w_block kg-th 16 bytes = 4 channels x 4 K (Cactus 'expanded' layout). +__arm_locally_streaming __arm_new("za") +void cactus_sme_cq_gemv_4col_s8(const int8_t* act_i8, const int8_t* w_block, + int32_t* partials_out, uint32_t num_groups, uint32_t gs) { + const svbool_t pn = svwhilelt_b8_u32(0u, 4u); // activation: row 0, 4 K lanes + const svbool_t pm = svwhilelt_b8_u32(0u, 16u); // weights: 4 channels x 4 K + const svbool_t pst = svwhilelt_b32_u32(0u, 4u); // store 4 int32 (the 4 channels) + const uint32_t nkg = gs >> 2; + for (uint32_t g = 0; g < num_groups; ++g) { + const int8_t* aptr = act_i8 + (size_t)g * gs; + const int8_t* wptr = w_block + (size_t)g * gs * 4; + svzero_za(); + for (uint32_t kg = 0; kg < nkg; ++kg) { + svint8_t zn = svld1_s8(pn, aptr + (size_t)kg * 4); + svint8_t zm = svld1_s8(pm, wptr + (size_t)kg * 16); + svmopa_za32_s8_m(0, pn, pm, zn, zm); + } + svst1_hor_za32(0, 0, pst, partials_out + (size_t)g * 4); + } +} + +// CQ GEMM full-tile: up to 16 act-rows x 16 output channels per call — full ZA tile width +// (pm=ptrue_b8). act_packed and w_sme are both pre-packed [num_groups][gs/4][16][4]; w_sme is +// gathered from four 4-channel 'expanded' panels. +__arm_locally_streaming __arm_new("za") +void cactus_sme_cq_gemm_16x16_s8(const int8_t* act_packed, const int8_t* w_sme, + int32_t* partials_out, uint32_t num_groups, uint32_t gs, + uint32_t m_rows) { + const svbool_t pn = svwhilelt_b8_u32(0u, m_rows * 4u); // m_rows x 4 K + const svbool_t pm = svptrue_b8(); // all 16 channels x 4 K + const svbool_t pst = svptrue_b32(); // store 16 int32 columns + const uint32_t nkg = gs >> 2; + for (uint32_t g = 0; g < num_groups; ++g) { + const int8_t* a = act_packed + (size_t)g * gs * 16; + const int8_t* w = w_sme + (size_t)g * gs * 16; + svzero_za(); + for (uint32_t kg = 0; kg < nkg; ++kg) { + svint8_t zn = svld1_s8(pn, a + (size_t)kg * 64); + svint8_t zm = svld1_s8(pm, w + (size_t)kg * 64); + svmopa_za32_s8_m(0, pn, pm, zn, zm); + } + for (uint32_t i = 0; i < m_rows; ++i) + svst1_hor_za32(0, i, pst, partials_out + ((size_t)g * 16 + i) * 16); + } +} + +// Prefill attention QK with flat per-row/per-kv scales: ZA accumulates over the FULL head_dim per +// 64-kv block (256 SMOPAs for hd=256), single 4 KB readout per block, one streaming entry per +// tile. 409 MACs/queue-op — the fused-GEMM regime. The fp rescale (qs[r]*ksflat[c]) happens in +// NEON after return. Layout verified by qkprobe.cpp (same byte layout, w = qg*8+dg). +__arm_locally_streaming __arm_new("za") +void cactus_sme_attn_qk_seg(const int8_t* qpack, const int8_t* kpack, int32_t* partials, + uint32_t dim_quads, uint32_t num_blocks) { + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + for (uint32_t b = 0; b < num_blocks; ++b) { + svzero_za(); + const int8_t* kb = kpack + (size_t)b * dim_quads * 256; + for (uint32_t w = 0; w < dim_quads; ++w) { + svint8_t zn = svld1_s8(pg, qpack + (size_t)w * 64); + const int8_t* p = kb + (size_t)w * 256; + svmopa_za32_s8_m(0, pg, pg, zn, svld1_s8(pg, p)); + svmopa_za32_s8_m(1, pg, pg, zn, svld1_s8(pg, p + 64)); + svmopa_za32_s8_m(2, pg, pg, zn, svld1_s8(pg, p + 128)); + svmopa_za32_s8_m(3, pg, pg, zn, svld1_s8(pg, p + 192)); + } + int32_t* ob = partials + (size_t)b * 1024; + for (uint32_t r = 0; r < 16; ++r) { + svst1_hor_za32(0, r, pg32, ob + (size_t)r * 64); + svst1_hor_za32(1, r, pg32, ob + (size_t)r * 64 + 16); + svst1_hor_za32(2, r, pg32, ob + (size_t)r * 64 + 32); + svst1_hor_za32(3, r, pg32, ob + (size_t)r * 64 + 48); + } + } +} + +// Prefill attention AV: one 64-dim slice over all cached 64-kv blocks, PER-BLOCK readout (the P +// quantization scale is per (row, v-group, block) for accuracy — a global scale wastes resolution +// on blocks far below the row max). P is UNSIGNED u8 via USMOPA (softmax probs are >= 0; the sign +// bit would be wasted — doubles resolution, probe-verified exact in usmopa_probe). zna feeds dim +// tiles 0-1 (P folded with v-scale group 0 of the slice), znb feeds tiles 2-3 (group 1). Stores +// are fire-and-forget; MACs/queue-op stays ~512. +__arm_locally_streaming __arm_new("za") +void cactus_sme_attn_av_pass(const uint8_t* ppack, const int8_t* vpack, int32_t* out, + uint32_t num_blocks) { + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + for (uint32_t b = 0; b < num_blocks; ++b) { + svzero_za(); + const uint8_t* pp = ppack + (size_t)b * 2048; + const int8_t* vp = vpack + (size_t)b * 4096; + for (uint32_t kg = 0; kg < 16; ++kg) { + svuint8_t zna = svld1_u8(pg, pp + (size_t)kg * 64); + svuint8_t znb = svld1_u8(pg, pp + 1024 + (size_t)kg * 64); + const int8_t* v = vp + (size_t)kg * 256; + svusmopa_za32_u8_m(0, pg, pg, zna, svld1_s8(pg, v)); + svusmopa_za32_u8_m(1, pg, pg, zna, svld1_s8(pg, v + 64)); + svusmopa_za32_u8_m(2, pg, pg, znb, svld1_s8(pg, v + 128)); + svusmopa_za32_u8_m(3, pg, pg, znb, svld1_s8(pg, v + 192)); + } + int32_t* ob = out + (size_t)b * 1024; + for (uint32_t r = 0; r < 16; ++r) { + svst1_hor_za32(0, r, pg32, ob + (size_t)r * 64); + svst1_hor_za32(1, r, pg32, ob + (size_t)r * 64 + 16); + svst1_hor_za32(2, r, pg32, ob + (size_t)r * 64 + 32); + svst1_hor_za32(3, r, pg32, ob + (size_t)r * 64 + 48); + } + } +} + +#else // ---- compile-time fallback (toolchain/target without SME2) ---- +uint32_t cactus_sme2_svl_bytes(void) { return 0; } +void cactus_sme_cq_gemv_4col_s8(const int8_t*, const int8_t*, int32_t* p, + uint32_t num_groups, uint32_t) { + for (uint32_t i = 0; i < num_groups * 4u; ++i) p[i] = 0; +} +void cactus_sme_attn_qk_seg(const int8_t*, const int8_t*, int32_t* p, + uint32_t, uint32_t num_blocks) { + for (uint32_t i = 0; i < num_blocks * 16u * 64u; ++i) p[i] = 0; +} +void cactus_sme_attn_av_pass(const uint8_t*, const int8_t*, int32_t* out, uint32_t num_blocks) { + for (uint32_t i = 0; i < num_blocks * 16u * 64u; ++i) out[i] = 0; +} +void cactus_sme_cq_gemm_16x16_s8(const int8_t*, const int8_t*, int32_t* p, + uint32_t num_groups, uint32_t, uint32_t) { + for (uint32_t i = 0; i < num_groups * 16u * 16u; ++i) p[i] = 0; +} +#endif diff --git a/cactus-kernels/src/matmul_sme2_gemv.cpp b/cactus-kernels/src/matmul_sme2_gemv.cpp new file mode 100644 index 000000000..5a908591c --- /dev/null +++ b/cactus-kernels/src/matmul_sme2_gemv.cpp @@ -0,0 +1,167 @@ +// SME2 CQ GEMV leaf: packed 4-bit weights + in-engine LUTI4/ZT0 codebook expansion + indexed +// multi-vector dots, writing RAW INT32 PARTIALS to memory. No ZA->core reads, no fp math in +// streaming mode — the caller rescales partials with NEON after the leaf returns. +// +// WHY (measured, see docs/sme/debug-log.md): Apple's SME unit is an AMX-heritage per-cluster +// IN-ORDER command queue shared by the cluster's cores. ZA->Z->core reads drain the queue (~the +// cost of dozens of dots); with per-group reads the kernel plateaued at ~137 GMAC/s aggregate vs +// 406 GMAC/s for the same dot stream without reads. ZA->MEMORY stores are fire-and-forget queue +// ops (no round-trip), so partials-to-memory + NEON rescale keeps the queue saturated; the NEON +// rescale on the core overlaps other threads' streaming work on the shared unit. +// +// Verified semantics (hot-value probes, -O0/-O1): +// - svluti4_lane_zt_s8_x2(0, zn, imm): IDENTITY nibble->byte mapping (low nibble first, imm +// irrelevant for .b x2); ZT0 table entry i at byte 4*i. +// - svdot_lane_za32_s8_vg1x4(S, zn_x4, av, idx): ZA vec S+i, lane ch += dot(zn[i][4ch..], av[4idx..]). +// - vg1 ZA vector v ==> svst1_hor_za32(tile=0, slice=(v&3)*4 + (v>>2)). +// +// This TU is compiled at -O1: Apple clang 17 mis-compiles vg1x4 ZA-array dots at -O2/-O3 (runtime +// SIGILL; correct at -O0/-O1). +// +// Layout: expanded_sme = packed nibbles [SB64][num_groups][gs/4][128B] (nibble j of a kg-block is +// expanded byte j of the int8 panel [4 vec][16 ch][4 K], vector v = channels 16v..16v+15). +#include "matmul_simd.h" + +#if defined(__ARM_FEATURE_SME2) +#include + +__arm_locally_streaming __arm_new("za") __arm_new("zt0") +void cactus_sme_cq_gemv_luti4_s8(const int8_t* act_i8, const uint8_t* esme_packed, + const uint8_t* zt_table, int32_t* partials, + uint32_t num_groups, uint32_t gs, + uint32_t sb_start, uint32_t sb_count) { + svldr_zt(0, zt_table); + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + const uint32_t nkg = gs >> 2; + const size_t g_stride = (size_t)nkg * 128; // packed bytes per group panel + const size_t sb_stride = (size_t)num_groups * g_stride; + + // One dot consumes 128B packed nibbles (expanded in-engine by two luti4_x2 to 64 ch x 4 K). + #define CACTUS_LUTI4_DOT(SL, P, OFF, AV, IDX) do { \ + svint8x2_t e0_ = svluti4_lane_zt_s8_x2(0, svld1_u8(pg, (P) + (OFF)), 0); \ + svint8x2_t e1_ = svluti4_lane_zt_s8_x2(0, svld1_u8(pg, (P) + (OFF) + 64), 0); \ + svdot_lane_za32_s8_vg1x4(SL, \ + svcreate4_s8(svget2_s8(e0_, 0), svget2_s8(e0_, 1), \ + svget2_s8(e1_, 0), svget2_s8(e1_, 1)), AV, IDX); \ + } while (0) + // Store the 4 ZA-array vectors of slice-quad S (= one group's 64 channels) to dst[0..63]. + // vg1 vector v lives at hor(tile 0, slice (v&3)*4 + (v>>2)); fire-and-forget, no drain. + #define CACTUS_STORE_QUAD(S, DST) do { \ + svst1_hor_za32(0, (((S) + 0) & 3u) * 4u + (((S) + 0) >> 2), pg32, (DST)); \ + svst1_hor_za32(0, (((S) + 1) & 3u) * 4u + (((S) + 1) >> 2), pg32, (DST) + 16); \ + svst1_hor_za32(0, (((S) + 2) & 3u) * 4u + (((S) + 2) >> 2), pg32, (DST) + 32); \ + svst1_hor_za32(0, (((S) + 3) & 3u) * 4u + (((S) + 3) >> 2), pg32, (DST) + 48); \ + } while (0) + + for (uint32_t s = 0; s < sb_count; ++s) { + const uint8_t* wsb = esme_packed + (size_t)(sb_start + s) * sb_stride; + int32_t* psb = partials + (size_t)s * num_groups * 64; + svzero_za(); + uint32_t g = 0; + for (; g + 3 < num_groups; g += 4) { // 4 groups on slice-quads 0/4/8/12 = 4 chains + const int8_t* aA = act_i8 + (size_t)g * gs; + const uint8_t* wA = wsb + (size_t)g * g_stride; + for (uint32_t kk = 0; kk < gs; kk += 16) { + svint8_t avA = svld1rq_s8(pg, aA + kk); + svint8_t avB = svld1rq_s8(pg, aA + gs + kk); + svint8_t avC = svld1rq_s8(pg, aA + 2 * gs + kk); + svint8_t avD = svld1rq_s8(pg, aA + 3 * gs + kk); + const uint8_t* pA = wA + (size_t)(kk >> 2) * 128; + const uint8_t* pB = pA + g_stride; + const uint8_t* pC = pB + g_stride; + const uint8_t* pD = pC + g_stride; + CACTUS_LUTI4_DOT(0, pA, 0, avA, 0); + CACTUS_LUTI4_DOT(4, pB, 0, avB, 0); + CACTUS_LUTI4_DOT(8, pC, 0, avC, 0); + CACTUS_LUTI4_DOT(12, pD, 0, avD, 0); + CACTUS_LUTI4_DOT(0, pA, 128, avA, 1); + CACTUS_LUTI4_DOT(4, pB, 128, avB, 1); + CACTUS_LUTI4_DOT(8, pC, 128, avC, 1); + CACTUS_LUTI4_DOT(12, pD, 128, avD, 1); + CACTUS_LUTI4_DOT(0, pA, 256, avA, 2); + CACTUS_LUTI4_DOT(4, pB, 256, avB, 2); + CACTUS_LUTI4_DOT(8, pC, 256, avC, 2); + CACTUS_LUTI4_DOT(12, pD, 256, avD, 2); + CACTUS_LUTI4_DOT(0, pA, 384, avA, 3); + CACTUS_LUTI4_DOT(4, pB, 384, avB, 3); + CACTUS_LUTI4_DOT(8, pC, 384, avC, 3); + CACTUS_LUTI4_DOT(12, pD, 384, avD, 3); + } + int32_t* pd = psb + (size_t)g * 64; + CACTUS_STORE_QUAD(0, pd); + CACTUS_STORE_QUAD(4, pd + 64); + CACTUS_STORE_QUAD(8, pd + 128); + CACTUS_STORE_QUAD(12, pd + 192); + if (g + 4 < num_groups) svzero_za(); // in-order queue: runs after the stores drain + } + for (uint32_t t = 0; g < num_groups; ++g, ++t) { // <=3 tail groups; slices zeroed above + const int8_t* a = act_i8 + (size_t)g * gs; + const uint8_t* w = wsb + (size_t)g * g_stride; + const uint32_t sl = t * 4u; + for (uint32_t kk = 0; kk < gs; kk += 16) { + svint8_t av = svld1rq_s8(pg, a + kk); + const uint8_t* p = w + (size_t)(kk >> 2) * 128; + CACTUS_LUTI4_DOT(sl, p, 0, av, 0); + CACTUS_LUTI4_DOT(sl, p, 128, av, 1); + CACTUS_LUTI4_DOT(sl, p, 256, av, 2); + CACTUS_LUTI4_DOT(sl, p, 384, av, 3); + } + CACTUS_STORE_QUAD(sl, psb + (size_t)g * 64); + } + } + #undef CACTUS_LUTI4_DOT + #undef CACTUS_STORE_QUAD +} + +// SME2 CQ GEMM leaf: one (16-row M-tile, 64-channel super-block) pair. Per (group, kg): one 64B +// act load ([16 rows x 4 K], pre-packed), two luti4_x2 expanding 128B packed nibbles into four +// 16-ch x 4-K weight vectors, four SMOPA — one per ZA tile (za0..za3 = the 4 16-channel sub-blocks) +// — 4096 MACs in 9 queue ops. Partials stored fire-and-forget per group (64 st1w), rescale in NEON +// outside streaming mode. No per-call weight gather: reads the cached esme_packed directly. +// act_packed: [num_groups][gs/4][16 rows][4 K] int8 (rows beyond m_rows zero-padded). +// esme_sb : expanded_sme panel of ONE super-block ([num_groups][gs/4][128B] packed nibbles). +// partials : [num_groups][16 rows][64 ch] int32. +__arm_locally_streaming __arm_new("za") __arm_new("zt0") +void cactus_sme_cq_gemm_luti4_s8(const int8_t* act_packed, const uint8_t* esme_sb, + const uint8_t* zt_table, int32_t* partials, + uint32_t num_groups, uint32_t gs) { + svldr_zt(0, zt_table); + const svbool_t pg = svptrue_b8(); + const svbool_t pg32 = svptrue_b32(); + const uint32_t nkg = gs >> 2; + for (uint32_t g = 0; g < num_groups; ++g) { + const int8_t* ag = act_packed + (size_t)g * gs * 16; + const uint8_t* wg = esme_sb + (size_t)g * nkg * 128; + svzero_za(); + for (uint32_t kg = 0; kg < nkg; ++kg) { + svint8_t zn = svld1_s8(pg, ag + (size_t)kg * 64); + const uint8_t* p = wg + (size_t)kg * 128; + svint8x2_t e0 = svluti4_lane_zt_s8_x2(0, svld1_u8(pg, p), 0); + svint8x2_t e1 = svluti4_lane_zt_s8_x2(0, svld1_u8(pg, p + 64), 0); + svmopa_za32_s8_m(0, pg, pg, zn, svget2_s8(e0, 0)); + svmopa_za32_s8_m(1, pg, pg, zn, svget2_s8(e0, 1)); + svmopa_za32_s8_m(2, pg, pg, zn, svget2_s8(e1, 0)); + svmopa_za32_s8_m(3, pg, pg, zn, svget2_s8(e1, 1)); + } + int32_t* pgrp = partials + (size_t)g * 16 * 64; + for (uint32_t r = 0; r < 16; ++r) { + int32_t* dst = pgrp + (size_t)r * 64; + svst1_hor_za32(0, r, pg32, dst); + svst1_hor_za32(1, r, pg32, dst + 16); + svst1_hor_za32(2, r, pg32, dst + 32); + svst1_hor_za32(3, r, pg32, dst + 48); + } + } +} + +#else // ---- compile-time fallback ---- +void cactus_sme_cq_gemv_luti4_s8(const int8_t*, const uint8_t*, const uint8_t*, int32_t* partials, + uint32_t num_groups, uint32_t, uint32_t, uint32_t sb_count) { + for (uint32_t i = 0; i < sb_count * num_groups * 64u; ++i) partials[i] = 0; +} +void cactus_sme_cq_gemm_luti4_s8(const int8_t*, const uint8_t*, const uint8_t*, int32_t* partials, + uint32_t num_groups, uint32_t) { + for (uint32_t i = 0; i < num_groups * 16u * 64u; ++i) partials[i] = 0; +} +#endif diff --git a/cactus-kernels/src/threading.h b/cactus-kernels/src/threading.h index e5795329f..0e869408f 100644 --- a/cactus-kernels/src/threading.h +++ b/cactus-kernels/src/threading.h @@ -74,6 +74,65 @@ inline bool cpu_has_i8mm() { #endif } +inline bool cpu_has_sme() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + std::call_once(once, []() { +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_SME", &ret, &size, nullptr, 0) == 0) { + has = (ret == 1); + } +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_SME + #define HWCAP2_SME (1UL << 23) + #endif + has = (hwcap2 & HWCAP2_SME) != 0; +#endif + }); + return has; +#else + return false; +#endif +} + +// Streaming vector length in bytes, queried in streaming mode (svcntb). Defined in +// matmul_sme2.cpp; the non-SME2 build stub returns 0. Only call when the SME2 feature bit is set. +extern "C" uint32_t cactus_sme2_svl_bytes(void); + +inline bool cpu_has_sme2() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + std::call_once(once, []() { +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_SME2", &ret, &size, nullptr, 0) == 0) { + has = (ret == 1); + } +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_SME2 + #define HWCAP2_SME2 (1UL << 37) + #endif + has = (hwcap2 & HWCAP2_SME2) != 0; +#endif + // ALL shipped SME kernel/cache layouts (esme [16ch][4K] panels, 64-channel super-blocks, + // attention 16x64 tiles) are pinned to SVL = 64 bytes (512-bit, Apple M-series). A device + // with a different streaming vector length (possible on non-Apple SME2 implementations) + // must fall back to NEON for correctness until SVL-parametric variants exist. + if (has) has = (cactus_sme2_svl_bytes() == 64); + }); + return has; +#else + return false; +#endif +} + inline float32x4_t fast_exp_f32x4(float32x4_t x) { const float32x4_t log2e = vdupq_n_f32(1.44269504088896341f); const float32x4_t ln2_hi = vdupq_n_f32(6.93145751953125e-1f); diff --git a/cactus-kernels/tests/test_attention.cpp b/cactus-kernels/tests/test_attention.cpp index e6089ba6a..8a220981d 100644 --- a/cactus-kernels/tests/test_attention.cpp +++ b/cactus-kernels/tests/test_attention.cpp @@ -1,6 +1,8 @@ #include "test_utils.h" #include #include +#include +#include using namespace TestUtils; @@ -76,6 +78,152 @@ bool test_attention_f16() { return true; } +// Double-precision reference for cactus_attention_hybrid_int8_fp16, replicating the incumbent's +// mask semantics exactly (causal kv_end clamp, sliding window, sink exemption on rolled caches). +static void ref_hybrid_attention( + const __fp16* Q, const int8_t* Kc, const int8_t* Vc, + const float* ks, const float* vs, const __fp16* Kn, const __fp16* Vn, + double* out, size_t B, size_t seq, size_t cache_len, size_t new_len, + size_t qh, size_t kvh, size_t hd, size_t pos_off, + bool causal, size_t window, size_t qg) +{ + const size_t kv_seq = cache_len + new_len, gqa = qh / kvh, ngk = hd / qg; + const size_t cache_abs_offset = pos_off >= cache_len ? pos_off - cache_len : 0; + const double scale = 1.0 / static_cast(sqrtf(static_cast(hd))); + std::vector sc(kv_seq); + for (size_t b = 0; b < B; b++) + for (size_t h = 0; h < qh; h++) + for (size_t q = 0; q < seq; q++) { + const __fp16* qv = Q + ((b * seq + q) * qh + h) * hd; + double* ov = out + ((b * seq + q) * qh + h) * hd; + const size_t kv_head = h / gqa; + const size_t abs_q = pos_off + q; + const size_t kv_start = (window > 0 && abs_q > window) ? abs_q - window : 0; + const size_t kv_end = causal ? std::min(kv_seq, cache_len + q + 1) : kv_seq; + std::fill(sc.begin(), sc.end(), -1e300); + for (size_t kv = 0; kv < kv_end; kv++) { + bool wm = false; + if (window > 0 && kv_start > 0) { + if (kv < cache_len) { + if (cache_abs_offset == 0 || kv >= 4) wm = (cache_abs_offset + kv < kv_start); + } else { + wm = (kv + cache_abs_offset < kv_start); + } + } + if ((causal && kv > abs_q) || wm) continue; + double s = 0.0; + if (kv < cache_len) { + const int8_t* kr = Kc + ((b * cache_len + kv) * kvh + kv_head) * hd; + const float* kss = ks + (kv * kvh + kv_head) * ngk; // scales: no batch stride (matches kernel) + for (size_t g = 0; g < ngk; g++) + for (size_t d = g * qg; d < (g + 1) * qg; d++) + s += static_cast(static_cast(qv[d])) * kr[d] * kss[g]; + } else { + const __fp16* kr = Kn + ((b * new_len + (kv - cache_len)) * kvh + kv_head) * hd; + for (size_t d = 0; d < hd; d++) + s += static_cast(static_cast(qv[d])) * static_cast(kr[d]); + } + sc[kv] = s * scale; + } + double m = -1e300; + for (size_t kv = 0; kv < kv_end; kv++) m = std::max(m, sc[kv]); + for (size_t d = 0; d < hd; d++) ov[d] = 0.0; + if (m <= -1e300) continue; + double den = 0.0; + for (size_t kv = 0; kv < kv_end; kv++) { + if (sc[kv] <= -1e300) { sc[kv] = 0.0; continue; } + sc[kv] = std::exp(sc[kv] - m); + den += sc[kv]; + } + if (den <= 0.0) continue; + for (size_t kv = 0; kv < kv_end; kv++) { + if (sc[kv] == 0.0) continue; + const double p = sc[kv]; + if (kv < cache_len) { + const int8_t* vr = Vc + ((b * cache_len + kv) * kvh + kv_head) * hd; + const float* vss = vs + (kv * kvh + kv_head) * ngk; + for (size_t g = 0; g < ngk; g++) + for (size_t d = g * qg; d < (g + 1) * qg; d++) + ov[d] += p * vr[d] * vss[g]; + } else { + const __fp16* vr = Vn + ((b * new_len + (kv - cache_len)) * kvh + kv_head) * hd; + for (size_t d = 0; d < hd; d++) + ov[d] += p * static_cast(static_cast(vr[d])); + } + } + for (size_t d = 0; d < hd; d++) ov[d] /= den; + } +} + +// Differential + oracle test for the SME2 prefill attention path (cached-int8 segment via SMOPA +// tiles) against the incumbent NEON flash kernel. Cases cover: global layer mid-prefill, sliding +// window with rolled cache (sink exemption live), sliding window at cache_abs_offset==0, and +// partial q-tile + partial 64-kv block + head_dim 128 + batch 2. +bool test_attention_hybrid_sme() { + struct Case { size_t B, cache_len, seq, window, pos_off, hd, qh, kvh; }; + const Case cases[] = { + {1, 640, 128, 0, 640, 256, 8, 4}, + {1, 603, 128, 512, 768, 256, 8, 4}, + {1, 600, 128, 512, 600, 256, 8, 4}, + {2, 137, 40, 0, 137, 128, 4, 2}, + }; + std::mt19937 gen(42); + std::uniform_int_distribution i8d(-127, 127); + std::uniform_real_distribution scd(0.005f, 0.02f); + bool all_ok = true; + for (const auto& cs : cases) { + const size_t qg = 32, ngk = cs.hd / qg, new_len = cs.seq; + std::vector<__fp16> Q(cs.B * cs.seq * cs.qh * cs.hd); + std::vector<__fp16> Kn(cs.B * new_len * cs.kvh * cs.hd), Vn(cs.B * new_len * cs.kvh * cs.hd); + std::vector Kc(cs.B * cs.cache_len * cs.kvh * cs.hd), Vc(cs.B * cs.cache_len * cs.kvh * cs.hd); + std::vector ks(cs.cache_len * cs.kvh * ngk), vs(cs.cache_len * cs.kvh * ngk); + fill_random_fp16(Q, -1.0f, 1.0f); + fill_random_fp16(Kn, -1.0f, 1.0f); + fill_random_fp16(Vn, -1.0f, 1.0f); + for (auto& x : Kc) x = static_cast(i8d(gen)); + for (auto& x : Vc) x = static_cast(i8d(gen)); + for (auto& x : ks) x = scd(gen); + for (auto& x : vs) x = scd(gen); + + const size_t osz = cs.B * cs.seq * cs.qh * cs.hd; + std::vector<__fp16> out_neon(osz), out_sme(osz); + cactus_quant_set_backend(1); + cactus_attention_hybrid_int8_fp16(Q.data(), Kc.data(), Vc.data(), ks.data(), vs.data(), + Kn.data(), Vn.data(), out_neon.data(), cs.B, cs.seq, cs.cache_len, new_len, + cs.qh, cs.kvh, cs.hd, 0.0f, cs.pos_off, true, cs.window, qg, cs.hd); + cactus_quant_set_backend(0); + cactus_attention_hybrid_int8_fp16(Q.data(), Kc.data(), Vc.data(), ks.data(), vs.data(), + Kn.data(), Vn.data(), out_sme.data(), cs.B, cs.seq, cs.cache_len, new_len, + cs.qh, cs.kvh, cs.hd, 0.0f, cs.pos_off, true, cs.window, qg, cs.hd); + + std::vector ref(osz); + ref_hybrid_attention(Q.data(), Kc.data(), Vc.data(), ks.data(), vs.data(), + Kn.data(), Vn.data(), ref.data(), cs.B, cs.seq, cs.cache_len, new_len, + cs.qh, cs.kvh, cs.hd, cs.pos_off, true, cs.window, qg); + + double ref_amax = 0.0, e_neon = 0.0, e_sme = 0.0, d_ns = 0.0; + for (size_t i = 0; i < osz; i++) { + ref_amax = std::max(ref_amax, std::fabs(ref[i])); + e_neon = std::max(e_neon, std::fabs(static_cast(out_neon[i]) - ref[i])); + e_sme = std::max(e_sme, std::fabs(static_cast(out_sme[i]) - ref[i])); + d_ns = std::max(d_ns, static_cast(std::fabs( + static_cast(out_sme[i]) - static_cast(out_neon[i])))); + } + const double rel_neon = e_neon / std::max(ref_amax, 1e-12); + const double rel_sme = e_sme / std::max(ref_amax, 1e-12); + const bool engaged_expected = cactus_quant_sme_available() != 0; + bool ok = rel_sme < 0.05 && rel_sme < 5.0 * std::max(rel_neon, 0.002); + if (engaged_expected && d_ns == 0.0) ok = false; // SME path silently not taken + std::cout << " hybrid_sme cache=" << cs.cache_len << " seq=" << cs.seq + << " win=" << cs.window << " off=" << cs.pos_off << " hd=" << cs.hd + << " B=" << cs.B << ": rel_neon=" << rel_neon << " rel_sme=" << rel_sme + << " maxdiff=" << d_ns << (ok ? "" : " <-- FAIL") << "\n"; + all_ok = all_ok && ok; + } + cactus_quant_set_backend(0); + return all_ok; +} + bool run_benchmarks() { auto bench = [](const char* label, auto fn) { fn(); @@ -121,6 +269,36 @@ bool run_benchmarks() { nullptr, 0, 0, true, false, false, 0, 0.0f); }); } + // Hybrid int8 prefill attention at Gemma 4 E2B chunked-prefill shapes: NEON vs SME backend + for (size_t window : {size_t(0), size_t(512)}) { + const size_t B = 1, cache_len = 896, seq = 128, qh = 8, kvh = 4, hd = 256, qg = 32; + const size_t ngk = hd / qg, pos_off = window ? cache_len + 128 : cache_len; + std::mt19937 g2(7); + std::uniform_int_distribution i8d(-127, 127); + std::uniform_real_distribution scd(0.005f, 0.02f); + std::vector<__fp16> Q(B * seq * qh * hd), Kn(B * seq * kvh * hd), Vn(B * seq * kvh * hd); + std::vector Kc(B * cache_len * kvh * hd), Vc(B * cache_len * kvh * hd); + std::vector ks(cache_len * kvh * ngk), vs(cache_len * kvh * ngk); + fill_random_fp16(Q, -1.0f, 1.0f); fill_random_fp16(Kn, -1.0f, 1.0f); fill_random_fp16(Vn, -1.0f, 1.0f); + for (auto& x : Kc) x = static_cast(i8d(g2)); + for (auto& x : Vc) x = static_cast(i8d(g2)); + for (auto& x : ks) x = scd(g2); + for (auto& x : vs) x = scd(g2); + std::vector<__fp16> out(B * seq * qh * hd); + auto run = [&]{ + cactus_attention_hybrid_int8_fp16(Q.data(), Kc.data(), Vc.data(), ks.data(), vs.data(), + Kn.data(), Vn.data(), out.data(), B, seq, cache_len, seq, + qh, kvh, hd, 0.0f, pos_off, true, window, qg, hd); + }; + char label[96]; + cactus_quant_set_backend(1); + snprintf(label, sizeof label, "hybrid prefill c=896 win=%zu NEON", window); + bench(label, run); + cactus_quant_set_backend(0); + snprintf(label, sizeof label, "hybrid prefill c=896 win=%zu SME", window); + bench(label, run); + cactus_quant_set_backend(0); + } return true; } @@ -131,6 +309,7 @@ int main() { runner.run_test("softmax", test_softmax()); runner.run_test("rope", test_rope()); runner.run_test("attention_f16", test_attention_f16()); + runner.run_test("attention_hybrid_sme", test_attention_hybrid_sme()); runner.print_benchmarks_header(); runner.run_bench("benchmarks", run_benchmarks()); runner.print_summary(); diff --git a/cactus-kernels/tests/test_matmul.cpp b/cactus-kernels/tests/test_matmul.cpp index a44ddf95a..7b5c3c534 100644 --- a/cactus-kernels/tests/test_matmul.cpp +++ b/cactus-kernels/tests/test_matmul.cpp @@ -3,6 +3,7 @@ #include #include #include +#include using namespace TestUtils; @@ -367,9 +368,15 @@ bool test_cq_correctness(uint32_t bits) { // ── Panel-format GEMV validation ───────────────────────────────────────────────────────────── // With packed panels present (built here by the independent test packer), the real -// cactus_quant_matmul dispatch takes the panel GEMV kernel; validate it against the SAME FP32 -// reference oracle the legacy kernels are gated on. -static bool test_cq_panel(uint32_t bits, double& mse_out) { +// cactus_quant_matmul dispatch takes the panel kernels; validate them against the SAME FP32 +// reference oracle the legacy kernels are gated on. backend pins the variant: 1 = NEON panel +// kernels, 2 = forced SME2 leaves (k_sme >= 1 so the streaming path is test-covered). +struct BackendGuard { + explicit BackendGuard(int b) { cactus_quant_set_backend(b); } + ~BackendGuard() { cactus_quant_set_backend(0); } +}; + +static bool test_cq_panel(uint32_t bits, int backend, double& mse_out) { const uint32_t K = 1024, N = 64, gs = 128; SyntheticCQ cq(bits, K, N, gs, 123); cq.preexpand(); @@ -385,15 +392,18 @@ static bool test_cq_panel(uint32_t bits, double& mse_out) { std::vector<__fp16> x_f16(K), y_f16(N, static_cast<__fp16>(0)); for (size_t i = 0; i < K; i++) x_f16[i] = static_cast<__fp16>(x_f32[i]); - cactus_quant_matmul(&mat, x_f16.data(), 1, y_f16.data()); + { + BackendGuard bg(backend); + cactus_quant_matmul(&mat, x_f16.data(), 1, y_f16.data()); + } mse_out = compute_mse(ref.data(), y_f16.data(), N); return mse_out <= 0.1; } -// GEMM (M>1) variant: validates the panel NEON GEMM (M and N tails included) vs a per-row FP32 +// GEMM (M>1) variant: validates the panel GEMM (M and N tails included) vs a per-row FP32 // reference. -static bool test_cq_panel_gemm(uint32_t bits, uint32_t M, uint32_t N, double& mse_out) { +static bool test_cq_panel_gemm(uint32_t bits, uint32_t M, uint32_t N, int backend, double& mse_out) { const uint32_t K = 512, gs = 128; SyntheticCQ cq(bits, K, N, gs, 321); cq.preexpand(); @@ -411,16 +421,53 @@ static bool test_cq_panel_gemm(uint32_t bits, uint32_t M, uint32_t N, double& ms std::vector<__fp16> Xf(static_cast(M) * K), Yf(static_cast(M) * N, static_cast<__fp16>(0)); for (size_t i = 0; i < static_cast(M) * K; i++) Xf[i] = static_cast<__fp16>(X[i]); - cactus_quant_matmul(&mat, Xf.data(), M, Yf.data()); + { + BackendGuard bg(backend); + cactus_quant_matmul(&mat, Xf.data(), M, Yf.data()); + } mse_out = compute_mse(ref.data(), Yf.data(), static_cast(M) * N); return mse_out <= 0.1; } +// NEON and SME2 leaves must agree on the SAME panel fixture: both accumulate identical int32 +// partials before the fp32 rescale, so outputs differ only by fp16 store rounding. +static bool test_panel_neon_sme_agreement() { + const uint32_t K = 1024, gs = 128; + for (uint32_t M : {1u, 20u}) { + const uint32_t N = (M == 1) ? 192 : 72; + SyntheticCQ cq(4, K, N, gs, 909); + cq.preexpand(); + CactusQuantMatrix mat = cq.matrix(); + std::vector<__fp16> X(static_cast(M) * K); + fill_random_fp16(X, -1.f, 1.f); + std::vector<__fp16> y_neon(static_cast(M) * N), y_sme(static_cast(M) * N); + { + BackendGuard bg(1); + cactus_quant_matmul(&mat, X.data(), M, y_neon.data()); + } + { + BackendGuard bg(2); + cactus_quant_matmul(&mat, X.data(), M, y_sme.data()); + } + double mx = 0; + for (size_t i = 0; i < y_neon.size(); ++i) { + const double d = std::abs(static_cast(y_neon[i]) - static_cast(y_sme[i])) / + std::max(1.0, std::abs(static_cast(y_neon[i]))); + mx = std::max(mx, d); + } + if (mx > 2e-3) { + std::cerr << " neon/sme agreement M=" << M << " rel_err=" << mx << "\n"; + return false; + } + } + return true; +} + // ── Orthogonal-rotation CQ4 (the Gemma lm_head format) ────────────────────────────────────── // Oracle: y[n] = norm[n] * sum_i cb[idx(n,i)] * (a_scaled @ R)[i], fp32. Gates the incumbent and // the panel virtual-group driver against the same reference, plus head-to-head agreement. -static bool test_orth_panel(double& mse_inc, double& mse_panel) { +static bool test_orth_panel(int backend, double& mse_inc, double& mse_panel) { // PRODUCTION lm_head format: orthogonal-rotation CQ4 in the INTERLEAVED_4ROW packed layout // (gs == K, ng == 1) for legacy bundles; panel files store it with virtual 128-wide groups. // Row-major orthogonal bundles were a transpiler bug and take the per-row fallback. @@ -503,7 +550,10 @@ static bool test_orth_panel(double& mse_inc, double& mse_panel) { W2.packed_panels = panels.data(); W2.norm_panels = npanels.data(); W2.rotation_t = rot_t.data(); std::vector<__fp16> y_panel(N); - cactus_quant_orthogonal_matmul(&W2, x.data(), 1, y_panel.data()); + { + BackendGuard bg(backend); + cactus_quant_orthogonal_matmul(&W2, x.data(), 1, y_panel.data()); + } mse_panel = compute_mse(ref.data(), y_panel.data(), N); return mse_inc <= 0.1 && mse_panel <= 0.1 && mse_panel <= mse_inc * 4 + 1e-4; } @@ -513,7 +563,7 @@ static bool test_orth_panel(double& mse_inc, double& mse_panel) { // legacy interleaved NEON kernel (old bundles); use_panels=true builds the panel layout from the // SAME interleaved fixture through the reference encoder and exercises the panel GEMV // (multi-super-block stealing included: N=192 = 3 super-blocks). -static bool test_cq4_interleaved(bool use_panels, double& mse_out) { +static bool test_cq4_interleaved(bool use_panels, int backend, double& mse_out) { const uint32_t K = 1024, N = 192, gs = 128; // 192 = 3 super-blocks: exercises multi-SB stealing SyntheticCQ cq(4, K, N, gs, 777); if (use_panels) cq.preexpand_il(); @@ -528,7 +578,10 @@ static bool test_cq4_interleaved(bool use_panels, double& mse_out) { std::vector<__fp16> x_f16(K), y(N, (__fp16)0); for (size_t i = 0; i < K; i++) x_f16[i] = (__fp16)x_f32[i]; - cactus_quant_matmul(&mat, x_f16.data(), 1, y.data()); + { + BackendGuard bg(backend); + cactus_quant_matmul(&mat, x_f16.data(), 1, y.data()); + } mse_out = compute_mse(ref.data(), y.data(), N); return mse_out <= 0.1; } @@ -649,23 +702,27 @@ static void print_panel_comparison() { CactusQuantMatrix mat_panel = cq.matrix_interleaved(); // panels set: panel GEMV std::vector<__fp16> x(sh.K), y(sh.N); fill_random_fp16(x, -1.f, 1.f); - auto run_ms = [&](CactusQuantMatrix* m) { + auto run_ms = [&](CactusQuantMatrix* m, int backend) { + BackendGuard bg(backend); cactus_quant_matmul(m, x.data(), 1, y.data()); // warmup Timer t; for (int i = 0; i < sh.iters; i++) cactus_quant_matmul(m, x.data(), 1, y.data()); return t.elapsed_ms() / sh.iters; }; - double ms_f = 1e30, ms_p = 1e30; + const bool sme = cactus_quant_sme_available() != 0; + double ms_f = 1e30, ms_p = 1e30, ms_s = 1e30; for (int round = 0; round < 5; round++) { - ms_f = std::min(ms_f, run_ms(&mat_file)); - ms_p = std::min(ms_p, run_ms(&mat_panel)); + ms_f = std::min(ms_f, run_ms(&mat_file, 1)); + ms_p = std::min(ms_p, run_ms(&mat_panel, 1)); + if (sme) ms_s = std::min(ms_s, run_ms(&mat_panel, 0)); } const double fl = 2.0 * sh.K * sh.N; double gf = fl / (ms_f * 1e6), gp = fl / (ms_p * 1e6); std::cout << " " << std::left << std::setw(31) << sh.name << " file-NEON " << std::fixed << std::setprecision(1) << gf - << " | panel " << gp << " GF" - << " | " << std::setprecision(2) << (gp / gf) + << " | panel " << gp << " GF"; + if (sme) std::cout << " | panel-auto(sme) " << fl / (ms_s * 1e6) << " GF"; + std::cout << " | " << std::setprecision(2) << (gp / gf) << "x (best of 5x" << sh.iters << ")\n"; } // GEMM (M>1): panel GEMM vs the legacy expand-from-packed NEON GEMM (the real-model M>1 @@ -678,24 +735,28 @@ static void print_panel_comparison() { CactusQuantMatrix mat_panel = cq.matrix(); // panels set: panel NEON GEMM std::vector<__fp16> A(static_cast(M) * K), Cc(static_cast(M) * N); fill_random_fp16(A, -1.f, 1.f); - auto bench = [&](CactusQuantMatrix* m) -> double { + auto bench = [&](CactusQuantMatrix* m, int backend) -> double { + BackendGuard bg(backend); cactus_quant_matmul(m, A.data(), M, Cc.data()); // warmup const int iters = 8; Timer t; for (int i = 0; i < iters; i++) cactus_quant_matmul(m, A.data(), M, Cc.data()); return t.elapsed_ms() / iters; }; - double ml = 1e30, mp = 1e30; + const bool sme = cactus_quant_sme_available() != 0; + double ml = 1e30, mp = 1e30, ms = 1e30; for (int round = 0; round < 3; round++) { - ml = std::min(ml, bench(&mat_legacy)); - mp = std::min(mp, bench(&mat_panel)); + ml = std::min(ml, bench(&mat_legacy, 1)); + mp = std::min(mp, bench(&mat_panel, 1)); + if (sme) ms = std::min(ms, bench(&mat_panel, 2)); } double gl = (2.0 * M * K * N) / (ml * 1e6), gp = (2.0 * M * K * N) / (mp * 1e6); std::string label = "cq4 M" + std::to_string(M) + " 1024x1024"; std::cout << " " << std::left << std::setw(20) << label << " legacy " << std::fixed << std::setprecision(1) << gl << " GFLOPS" - << " | panel " << gp << " GFLOPS" - << " | " << std::setprecision(2) << (gp / gl) << "x (best of 3x8)\n"; + << " | panel " << gp << " GFLOPS"; + if (sme) std::cout << " | panel-sme " << (2.0 * M * K * N) / (ms * 1e6) << " GFLOPS"; + std::cout << " | " << std::setprecision(2) << (gp / gl) << "x (best of 3x8)\n"; } } @@ -945,41 +1006,56 @@ int main() { runner.run_test("matmul_cq3", test_cq_correctness(3)); runner.run_test("matmul_cq4", test_cq_correctness(4)); - // ── Panel-format registry: validate the panel kernels through the real dispatch ── - for (uint32_t bits : {1u, 2u, 3u, 4u}) { - double mse_p = 0.0; - bool ok = test_cq_panel(bits, mse_p); - runner.run_test(std::string("matmul_cq") + std::to_string(bits) + "[panel]", ok); - if (!ok) std::cerr << " cq" << bits << "[panel] MSE=" << mse_p << "\n"; - } - // GEMM (M>1) variant: M tail (20 -> 16+4) and N tail (72 -> 4 super-blocks + 8 valid) for CQ4. + // ── Panel-format registry: validate the panel kernels through the real dispatch. + // backend 1 pins the NEON panel kernels; backend 2 (when SME2 silicon is present) forces the + // streaming LUTI4/SMOPA leaves so they stay test-covered. struct GemmCase { uint32_t M, N; }; - for (GemmCase gc : {GemmCase{5, 64}, GemmCase{20, 64}, GemmCase{20, 72}, GemmCase{64, 256}}) { - double mse_g = 0.0; - bool ok = test_cq_panel_gemm(4, gc.M, gc.N, mse_g); - runner.run_test(std::string("matmul_cq4_M") + std::to_string(gc.M) + - "_N" + std::to_string(gc.N) + "[panel]", ok); - if (!ok) std::cerr << " cq4 M=" << gc.M << " N=" << gc.N << "[panel] MSE=" << mse_g << "\n"; - } - { - double mi = 0, mp = 0; - bool orth_ok = test_orth_panel(mi, mp); - runner.run_test("matmul_cq4_orth[panel]", orth_ok); - if (!orth_ok) std::cerr << " orth mse_incumbent=" << mi << " mse_panel=" << mp << "\n"; + const GemmCase gemm_cases[] = {GemmCase{5, 64}, GemmCase{20, 64}, GemmCase{20, 72}, GemmCase{64, 256}}; + std::vector> backends{{1, "[panel]"}}; + if (cactus_quant_sme_available()) backends.push_back({2, "[sme2]"}); + for (auto [backend, tag] : backends) { + for (uint32_t bits : {1u, 2u, 3u, 4u}) { + double mse_p = 0.0; + bool ok = test_cq_panel(bits, backend, mse_p); + runner.run_test(std::string("matmul_cq") + std::to_string(bits) + tag, ok); + if (!ok) std::cerr << " cq" << bits << tag << " MSE=" << mse_p << "\n"; + } + // GEMM (M>1): M tail (20 -> 16+4) and N tail (72 -> 4 super-blocks + 8 valid) for CQ4. + for (GemmCase gc : gemm_cases) { + double mse_g = 0.0; + bool ok = test_cq_panel_gemm(4, gc.M, gc.N, backend, mse_g); + runner.run_test(std::string("matmul_cq4_M") + std::to_string(gc.M) + + "_N" + std::to_string(gc.N) + tag, ok); + if (!ok) std::cerr << " cq4 M=" << gc.M << " N=" << gc.N << tag << " MSE=" << mse_g << "\n"; + } + { + double mi = 0, mp = 0; + bool orth_ok = test_orth_panel(backend, mi, mp); + runner.run_test(std::string("matmul_cq4_orth") + tag, orth_ok); + if (!orth_ok) std::cerr << " orth" << tag << " mse_incumbent=" << mi << " mse_panel=" << mp << "\n"; + } + { + double m2 = 0; + runner.run_test(std::string("matmul_cq4_il") + tag, test_cq4_interleaved(true, backend, m2)); + } + // The panel GEMM is bits-agnostic (panel nibbles are codebook indices) — validate CQ1-3 too. + for (uint32_t b : {1u, 2u, 3u}) { + double mse_g = 0.0; + bool ok = test_cq_panel_gemm(b, 20, 64, backend, mse_g); + runner.run_test(std::string("matmul_cq") + std::to_string(b) + "_gemm" + tag, ok); + if (!ok) std::cerr << " cq" << b << " gemm" << tag << " MSE=" << mse_g << "\n"; + } } { - double m1 = 0, m2 = 0; - runner.run_test("matmul_cq4_il[file]", test_cq4_interleaved(false, m1)); - runner.run_test("matmul_cq4_il[panel]", test_cq4_interleaved(true, m2)); + double m1 = 0; + runner.run_test("matmul_cq4_il[file]", test_cq4_interleaved(false, 1, m1)); runner.run_test("panel_layout_invariance", test_panel_layout_invariance()); runner.run_test("orth_embed_rows_batched", test_orth_embed_rows()); } - // The panel GEMM is bits-agnostic (panel nibbles are codebook indices) — validate CQ1-3 too. - for (uint32_t b : {1u, 2u, 3u}) { - double mse_g = 0.0; - bool ok = test_cq_panel_gemm(b, 20, 64, mse_g); - runner.run_test(std::string("matmul_cq") + std::to_string(b) + "_gemm[panel]", ok); - if (!ok) std::cerr << " cq" << b << " gemm[panel] MSE=" << mse_g << "\n"; + if (cactus_quant_sme_available()) { + runner.run_test("panel_neon_sme_agreement", test_panel_neon_sme_agreement()); + } else { + std::cout << " (SME2 unavailable on this CPU — [sme2] variants skipped)\n"; } runner.print_benchmarks_header();