FForge
← Labsmlir-aarch64-02MLIR on AArch64●●●●

Scalable vectors in MLIR: one binary, every SVE vector length

mlir-aarch64/02_sve_matmul.mdneeds: mlir-opt, clang, qemu (`~/debug_lab/tools/a64-setup.sh`)
evidence to leave with
One binary proved correct at 128, 256 and 512-bit VL under qemu, and the two bugs fixed to get there

AARCH64 LAB #02 — Scalable vectors in MLIR: one binary, every SVE vector length

TRACK: aarch64 | DIFFICULTY: 4 | NEEDS: mlir-opt, clang, qemu (~/debug_lab/tools/a64-setup.sh) FILES: sched_sve.mlir (schedule), matmul.mlir + bench_matmul.c (copied from track 04)

What this teaches

In track 04 the N tile was a number (16). For SVE it becomes [4], read as "4 × vscale": one z-register of f32 whatever the hardware width is (128 bits on most phone cores, 256 on Neoverse-V1, up to 2048). Three consequences, all visible in this lab:

  1. Tile sizes are runtime values. The loop step becomes %c4_vscale, and the last tile may be partial, so vectorization needs explicit, masked vector sizes: vectorize %t vector_sizes [TM, [4], 1].
  2. Lowering needs the ArmSVE path. convert-vector-to-llvm{enable-arm-sve}, plus arm-sve-legalize-vector-storage. This is $LOWER_SVE in tools/env.sh.
  3. Correctness must be tested at several vector lengths, because a bug in tail or mask handling hides at the width you own (same meta-lesson as ~/debug_lab/aarch64 lab 02).

This lab also documents three real failures hit while building it. They are the most valuable part: each is a bug class you'll meet in a production MLIR backend.

STEP 1 — Apply the scalable schedule and look at the types

sed 's/TM/2/g; s|HOIST||' sched_sve.mlir > out/s.mlir mlir-opt matmul.mlir --transform-preload-library="transform-library-paths=out/s.mlir" \ --transform-interpreter --test-transform-dialect-erase-schedule -o out/sve.t.mlir grep -oE 'vector<[^>]*\[[0-9]+\][^>]*>' out/sve.t.mlir | sort | uniq -c grep -m1 -o 'step %c4_vscale' out/sve.t.mlir

Types like vector<2x[4]xf32> (data) and vector<2x[4]xi1> (masks), and a loop that steps by 4 * vscale.

STEP 2 — Build once, run at 128 / 256 / 512 bits

mlir-opt out/sve.t.mlir --pass-pipeline="$LOWER_SVE" | mlir-translate --mlir-to-llvmir -o out/sve.ll clang --target=aarch64-linux-gnu -O2 -march=armv8.2-a+sve -c out/sve.ll -o out/sve.o -Wno-override-module a64cc -O2 bench_matmul.c out/sve.o -o out/mm_sve -lm for vl in 16 32 64; do printf "VL=%4d bits: " $((vl*8)); a64sve $vl ./out/mm_sve 1 sve 2>&1 | grep -oE 'maxerr.*'; done

Real output (about 75 s under qemu; the timings it prints are meaningless under emulation):

VL= 128 bits: maxerr 0.0e+00 OK VL= 256 bits: maxerr 0.0e+00 OK VL= 512 bits: maxerr 0.0e+00 OK

STEP 3 — Read the inner loop

clang --target=aarch64-linux-gnu -O2 -march=armv8.2-a+sve -S out/sve.ll -o out/sve.s -Wno-override-module awk '/Inner Loop Header: Depth=3/{p=1} p{print} p&&/b\.ne/{exit}' out/sve.s
ld1w { z0.s }, p1/z, [x14, x15, lsl #2] B row slice (predicated by p1) ld1w { z1.s }, p1/z, [x16] C row 0 <-- loaded every k ld1rw { z2.s }, p0/z, [x18] A[m0][k] broadcast to all lanes ld1w { z3.s }, p1/z, [x17] C row 1 <-- loaded every k ld1rw { z4.s }, p0/z, [x0] A[m1][k] broadcast fmla z1.s, p1/m, z2.s, z0.s C0 += A0 * B fmla z3.s, p1/m, z4.s, z0.s C1 += A1 * B st1w { z1.s }, p1, [x16] C row 0 <-- stored every k st1w { z3.s }, p1, [x17] C row 1 <-- stored every k

It's correct and vector-length-agnostic, but it's lab 04/01's variant C (no hoisting): C goes through memory on every k step. Adding hoisting for masked, scalable transfers is the exercise below, and it's exactly the kind of gap you'll close in a production MLIR backend.

The three failures (keep these; they will happen to you)

Failure 1 — Dialect 'ub' not found for custom op 'ub.poison' in mlir-translate. Masked vector code introduces ub.poison, and the original LOWER_STD had no convert-ub-to-llvm. This is lab 02_lowering/02, STEP 1, for real: new IR shapes need new conversion passes. Fixed in tools/env.sh.

Failure 2 — the AArch64 backend crashed:

fatal error: error in backend: Cannot select: 0x...: nxv4i1,ch = load<(load (<vscale x 1 x s8>) ... In function: matmul PLEASE submit a bug report ... /tmp/sve-XXXX.ll /tmp/sve-XXXX.sh

Cause: pass order. convert-vector-to-scf spills masks to stack temporaries (memref<vector<...xi1>>), and arm-sve-legalize-vector-storage must run after it to make those stores legal for SVE predicates. In the first version it ran before. The wrong order is kept as $LOWER_SVE_BAD so you can reproduce it; lab 06_debugging/04 walks through minimizing it. Lesson: an LLVM backend crash on MLIR-generated IR is usually an MLIR pipeline bug, i.e. IR the target can't legalize. Check the pipeline against an upstream test that does the same thing (here mlir/test/Integration/Dialect/Linalg/CPU/ArmSVE/matmul.mlir) before filing an LLVM bug.

Failure 3 — correct build, then qemu: uncaught target signal 11 (Segmentation fault) after 5 s.

mlir-opt out/sve.t.mlir --pass-pipeline="builtin.module(func.func(convert-linalg-to-loops), canonicalize, cse, convert-vector-to-scf)" | grep -nE 'alloca|scf.for' | head -8
19: scf.for %arg3 = %c0 to %c512 step %c2 { 20: scf.for %arg4 = %c0 to %c512 step %c4_vscale { 21: scf.for %arg5 = %c0 to %c512 step %c1 { 22: %alloca = memref.alloca() : memref<vector<2x1xf32>> 23: %alloca_1 = memref.alloca() : memref<vector<1x[4]xf32>> ...

memref.alloca inside the innermost loop. Each iteration grows the stack; 512 × 128 × 512 iterations blow it. The upstream test passes only because its matrices are 5×15. The fix is convert-vector-to-scf{full-unroll=true}, which lowers the transfers without temporaries (0 allocas). Lesson: tests with tiny shapes hide resource bugs. Always run at least one realistic size.

Try this yourself

  1. Add hoisting: replace HOIST with %fh = transform.structured.hoist_redundant_vector_transfers %func : (!transform.any_op) -> !transform.any_op and make the following apply_patterns use %fh. Do the st1ws leave the loop? If not, why? (Hint: masked transfers, and whether the mask is loop-invariant.)
  2. Try TM=4 and TM=8. Compare instruction counts in the inner loop.
  3. Reproduce failure 2: mlir-opt out/sve.t.mlir --pass-pipeline="$LOWER_SVE_BAD" | mlir-translate --mlir-to-llvmir | clang --target=aarch64-linux-gnu -O2 -march=armv8.2-a+sve -x ir -c - -o /dev/null
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.