24. Closing the Gap: A Matrix Product That Walks Memory in a Better Order¶
What you will understand: how a change to the order of three loops, with no change to the arithmetic, can make a program several times faster, and how to add that change to a compiler without breaking anything that depends on the old behavior. Chapter 23 measured Mountain Goat's matrix product, found it more than twice as slow as a simple hand-written C++ loop at N = 512, and listed "isolating why" as unfinished. This chapter finishes the job: it adds a second loop order to the lowering, measures it, looks at the machine code, and shows that the two orders give identical answers to the last bit.
What you need to know first: Chapter 23 (the benchmark, the noise, -O and --passes), Chapter 21 (how mg.matmul is lowered), and Chapter 7 (loop tiling). The new idea (loop order and memory access) is explained from scratch.
Compile and run
cd docs/part24/code
./build.sh # builds ./build/mg-opt (Chapter 24's: adds the matmul-order option)
./mgc run examples/matmul_odd.mg --matmul-order ikj # one program with the new loop order
./run_bench.sh > bench_out_1.txt # the benchmark, pass 1 (about a minute)
./run_bench.sh > bench_out_2.txt # pass 2
python3 summarize.py bench_out_1.txt bench_out_2.txt > summary.txt
./inspect_asm.sh > inspect_asm_out.txt # vector instructions in the machine code
./bits_check.sh > bits_check_out.txt # are the two orders identical to the last bit?
./machine_info.sh > machine_info_out.txt
./mgc_mutation.py > mgc_mutation_out.txt # breaks mgc on purpose
./lowering_mutation.sh > lowering_mutation_out.txt # breaks the lowering on purpose (a rebuild each; several minutes)
cd ../../part15/code && ./run_lit.sh # the whole test suite
Why this page shows FAIL lines, and why that is good
The two mutation outputs near the end come from negative controls: deliberately broken code run against the tests. There a "caught by" line or a FAIL is the expected, wanted result. The baseline lines must say "all tests pass", and they do.
Primer: why loop order matters¶
The matrix product of A (m by k) and B (k by n) is C[i][j] = sum over k of A[i][k] * B[k][j]. The textbook way to compute it is three nested loops. Which loop is outermost, which is in the middle and which is innermost can be chosen freely, because the arithmetic is the same. The two orders in this chapter are named by the loop variables from outermost to innermost:
ijk(what Chapters 21 to 23 generate): for each result elementC[i][j], run a loop overkthat adds up the products. The innermost loop readsA[i][k](walking along a row of A: consecutive addresses) andB[k][j](walking down a column of B).ikj: for each rowiof A and eachk, take the single numberA[i][k]and addA[i][k] * B[k][j]intoC[i][j]for everyj. The innermost loop now readsB[k][j]and writesC[i][j]whilejincreases, and both of those walk along a row: consecutive addresses.
Why that matters. Memory is stored one row after another ("row-major"), so walking along a row visits neighboring addresses, and walking down a column jumps by a whole row's length each step (for a 512-wide matrix, 4096 bytes). A processor reads memory in chunks called cache lines (typically 64 bytes, eight numbers); a walk along a row uses all eight numbers of each chunk it fetches, while a walk down a column uses one number per chunk and wastes the rest. There is a second benefit: a loop that touches consecutive addresses is the kind of loop a compiler can turn into vector instructions (SIMD: one instruction operating on several numbers), which a loop jumping through memory is not.
What does not change. For every result element C[i][j], both orders add the products in the same order (k = 0, 1, 2, …). Floating-point addition is not associative (adding the same numbers in a different order can change the last bits), but because the order per element is unchanged, the two loop orders give bit-for-bit identical results. That is an argument; this chapter also tests it (below).
The change¶
mg.matmul already lowers to a zero-fill loop nest plus a three-deep accumulate nest (Chapter 21). The new code builds the accumulate nest with its bounds and induction variables in either order, selected by a pass option, --convert-mg-to-affine=matmul-order=ikj (the default stays ijk, so every earlier chapter's output is unchanged). The lowering pattern now remembers which order it was asked for:
struct MatmulOpLowering : public OpConversionPattern<mg::MatmulOp> {
/// Chapter 24: `ikj` runs the three loops in the order i, k, j instead of i, j, k. Same arithmetic, same
/// order of additions into each result element (k increases for every i, j), so the results are bit-for-bit
/// identical; only the memory access pattern differs.
bool ikj;
MatmulOpLowering(TypeConverter &converter, MLIRContext *ctx, bool ikj)
: OpConversionPattern(converter, ctx), ikj(ikj) {}
LogicalResult matchAndRewrite(mg::MatmulOp op, OpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
auto loc = op.getLoc();
The accumulate nest, with the order choice in the loop bounds and in which induction variable plays which role:
Value n = bound(outType.getDimSize(1), rhs, 1, Value());
int64_t kStatic = !lt.isDynamicDim(1) ? lt.getDimSize(1) : rt.getDimSize(0);
Value k = bound(kStatic, lhs, 1, Value());
Value c0 = rewriter.create<arith::ConstantIndexOp>(loc, 0);
// Loop bounds in nest order: (m, n, k) for ijk, (m, k, n) for ikj. ivs follow the same order.
llvm::SmallVector<Value, 3> bounds = ikj ? llvm::SmallVector<Value, 3>{m, k, n} : llvm::SmallVector<Value, 3>{m, n, k};
affine::buildAffineLoopNest(rewriter, loc, ValueRange{c0, c0, c0}, bounds, {1, 1, 1},
[&](OpBuilder &b, Location l, ValueRange ivs) {
Value i = ivs[0], j = ikj ? ivs[2] : ivs[1], kk = ikj ? ivs[1] : ivs[2];
Value acc = b.create<affine::AffineLoadOp>(l, alloc, ValueRange{i, j});
Value x = b.create<affine::AffineLoadOp>(l, lhs, ValueRange{i, kk});
Value y = b.create<affine::AffineLoadOp>(l, rhs, ValueRange{kk, j});
Value prod = b.create<arith::MulFOp>(l, x, y);
b.create<affine::AffineStoreOp>(l, b.create<arith::AddFOp>(l, acc, prod), alloc, ValueRange{i, j});
});
An option on a pass is declared as a member of the pass class. Because a pass with options must be copyable (MLIR clones passes), the class also gets an explicit copy constructor, and the pass rejects any value other than ijk or ikj with an error instead of silently falling back:
MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(ConvertMgToAffinePass)
ConvertMgToAffinePass() = default;
ConvertMgToAffinePass(const ConvertMgToAffinePass &other) : PassWrapper(other) {} // needed because of the Option below
Option<std::string> matmulOrder{*this, "matmul-order",
llvm::cl::desc("loop order for mg.matmul: ijk (the default, as in Chapters 21 to 23) or ikj"),
llvm::cl::init("ijk")};
llvm::StringRef getArgument() const final { return "convert-mg-to-affine"; }
llvm::StringRef getDescription() const final {
return "Lower Mountain Goat's own mg dialect to affine/memref/arith, "
"hand-bufferizing each op's own result as it is lowered.";
}
void getDependentDialects(DialectRegistry ®istry) const override {
registry.insert<affine::AffineDialect, memref::MemRefDialect,
arith::ArithDialect, cf::ControlFlowDialect,
tensor::TensorDialect>();
}
void runOnOperation() override {
if (matmulOrder != "ijk" && matmulOrder != "ikj") {
getOperation().emitError("convert-mg-to-affine: matmul-order must be ijk or ikj, got '") << matmulOrder << "'";
return signalPassFailure();
}
MgToAffineTypeConverter typeConverter;
mgc gets a flag that passes it through: --matmul-order ikj. The complete change (this chapter's lowering and driver, against Chapter 22's and 23's):
The full diff for this chapter (click to expand)
--- ../../part22/code/LowerToAffine.cpp 2026-10-02 11:17:33.277001200 +0000
+++ LowerToAffine.cpp 2026-10-02 18:03:38.091631141 +0000
@@ -281,7 +281,12 @@
/// mg.matmul: result[i,j] = sum over k of lhs[i,k] * rhs[k,j]. A zero-fill nest, then a 3-deep
/// accumulate nest. For `?` dimensions the inner extents must agree at run time: one cf.assert.
struct MatmulOpLowering : public OpConversionPattern<mg::MatmulOp> {
- using OpConversionPattern::OpConversionPattern;
+ /// Chapter 24: `ikj` runs the three loops in the order i, k, j instead of i, j, k. Same arithmetic, same
+ /// order of additions into each result element (k increases for every i, j), so the results are bit-for-bit
+ /// identical; only the memory access pattern differs.
+ bool ikj;
+ MatmulOpLowering(TypeConverter &converter, MLIRContext *ctx, bool ikj)
+ : OpConversionPattern(converter, ctx), ikj(ikj) {}
LogicalResult matchAndRewrite(mg::MatmulOp op, OpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
auto loc = op.getLoc();
@@ -315,9 +320,11 @@
int64_t kStatic = !lt.isDynamicDim(1) ? lt.getDimSize(1) : rt.getDimSize(0);
Value k = bound(kStatic, lhs, 1, Value());
Value c0 = rewriter.create<arith::ConstantIndexOp>(loc, 0);
- affine::buildAffineLoopNest(rewriter, loc, ValueRange{c0, c0, c0}, ValueRange{m, n, k}, {1, 1, 1},
+ // Loop bounds in nest order: (m, n, k) for ijk, (m, k, n) for ikj. ivs follow the same order.
+ llvm::SmallVector<Value, 3> bounds = ikj ? llvm::SmallVector<Value, 3>{m, k, n} : llvm::SmallVector<Value, 3>{m, n, k};
+ affine::buildAffineLoopNest(rewriter, loc, ValueRange{c0, c0, c0}, bounds, {1, 1, 1},
[&](OpBuilder &b, Location l, ValueRange ivs) {
- Value i = ivs[0], j = ivs[1], kk = ivs[2];
+ Value i = ivs[0], j = ikj ? ivs[2] : ivs[1], kk = ikj ? ivs[1] : ivs[2];
Value acc = b.create<affine::AffineLoadOp>(l, alloc, ValueRange{i, j});
Value x = b.create<affine::AffineLoadOp>(l, lhs, ValueRange{i, kk});
Value y = b.create<affine::AffineLoadOp>(l, rhs, ValueRange{kk, j});
@@ -440,6 +447,13 @@
: public PassWrapper<ConvertMgToAffinePass, OperationPass<ModuleOp>> {
MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(ConvertMgToAffinePass)
+ ConvertMgToAffinePass() = default;
+ ConvertMgToAffinePass(const ConvertMgToAffinePass &other) : PassWrapper(other) {} // needed because of the Option below
+
+ Option<std::string> matmulOrder{*this, "matmul-order",
+ llvm::cl::desc("loop order for mg.matmul: ijk (the default, as in Chapters 21 to 23) or ikj"),
+ llvm::cl::init("ijk")};
+
llvm::StringRef getArgument() const final { return "convert-mg-to-affine"; }
llvm::StringRef getDescription() const final {
return "Lower Mountain Goat's own mg dialect to affine/memref/arith, "
@@ -453,6 +467,10 @@
}
void runOnOperation() override {
+ if (matmulOrder != "ijk" && matmulOrder != "ikj") {
+ getOperation().emitError("convert-mg-to-affine: matmul-order must be ijk or ikj, got '") << matmulOrder << "'";
+ return signalPassFailure();
+ }
MgToAffineTypeConverter typeConverter;
ConversionTarget target(getContext());
target.addLegalDialect<affine::AffineDialect, memref::MemRefDialect,
@@ -486,8 +504,9 @@
ElementwiseBinaryLowering<mg::SubOp, arith::SubFOp>,
ElementwiseBinaryLowering<mg::MulOp, arith::MulFOp>,
ElementwiseBinaryLowering<mg::DivOp, arith::DivFOp>,
- ScalarOpLowering, NegOpLowering, MatmulOpLowering,
+ ScalarOpLowering, NegOpLowering,
ReluOpLowering, ReduceOpLowering, BroadcastOpLowering>(typeConverter, &getContext());
+ patterns.add<MatmulOpLowering>(typeConverter, &getContext(), matmulOrder == "ikj");
populateFunctionOpInterfaceTypeConversionPattern<func::FuncOp>(patterns,
typeConverter);
populateCallOpTypeConversionPattern(patterns, typeConverter);
--- ../../part23/code/mgc 2026-10-02 16:31:18.251194699 +0000
+++ mgc 2026-10-02 18:08:07.897995992 +0000
@@ -7,27 +7,28 @@
# mgc ptx prog.mg compile to PTX (no launch: this machine has no GPU) and print the PTX
# Options (Chapter 23): -O0|-O1|-O2|-O3 optimization level for the final clang step (default -O0, as in every earlier chapter)
# --passes "--affine-loop-tile=tile-size=32" extra mg-opt passes run on the affine IR, before lowering (quote as one argument)
-# Env: MG_OPT (default Chapter 22 build), MGC_KEEP=dir (keep every intermediate file there), MGC_CFLAGS (extra flags for clang, e.g. -march=native),
+# --matmul-order ikj|ijk (Chapter 24) the loop order mg.matmul is lowered to (default ijk)
+# Env: MG_OPT (default Chapter 24 build), MGC_KEEP=dir (keep every intermediate file there), MGC_CFLAGS (extra flags for clang, e.g. -march=native),
# MGC_CLANG (the clang to call; tests set it to `echo` to see exactly which flags mgc passes).
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
-MG_OPT=${MG_OPT:-$HERE/../../part22/code/build/mg-opt}
+MG_OPT=${MG_OPT:-$HERE/../../part24/code/build/mg-opt}
LIBDIR=${MLIR_LIBDIR:-/usr/lib/llvm-18/lib}
-usage() { echo "usage: mgc run|build|lib|mlir|ptx prog.mg [-o exe] [-O0..-O3] [--passes \"...\"]" >&2; exit 2; }
+usage() { echo "usage: mgc run|build|lib|mlir|ptx prog.mg [-o exe] [-O0..-O3] [--passes \"...\"] [--matmul-order ikj|ijk]" >&2; exit 2; }
[ $# -ge 2 ] || usage
-cmd=$1; src=$2; shift 2; out=; OPT=; PASSES=
-while [ $# -gt 0 ]; do case $1 in -o) out=$2; shift 2;; -O0|-O1|-O2|-O3) OPT=$1; shift;; --passes) PASSES=$2; shift 2;; *) usage;; esac; done
+cmd=$1; src=$2; shift 2; out=; OPT=; PASSES=; CONVOPT=
+while [ $# -gt 0 ]; do case $1 in -o) out=$2; shift 2;; -O0|-O1|-O2|-O3) OPT=$1; shift;; --passes) PASSES=$2; shift 2;; --matmul-order) CONVOPT="=matmul-order=$2"; shift 2;; *) usage;; esac; done
[ -f "$src" ] || { echo "mgc: no such file: $src" >&2; exit 2; }
W=${MGC_KEEP:-$(mktemp -d)}; mkdir -p "$W"
base=$(basename "$src" .mg)
LIBFLAG=; [ "$cmd" = lib ] && LIBFLAG=--lib
python3 "$HERE/../../part22/code/mgfront.py" $LIBFLAG "$src" > "$W/$base.mlir" # 1. surface syntax -> mg dialect (exit 1 + message on a syntax/shape error)
[ "$cmd" = mlir ] && { cat "$W/$base.mlir"; exit 0; }
-[ -x "$MG_OPT" ] || { echo "mgc: no mg-opt at $MG_OPT (run part22/code/build.sh)" >&2; exit 2; }
+[ -x "$MG_OPT" ] || { echo "mgc: no mg-opt at $MG_OPT (run part24/code/build.sh)" >&2; exit 2; }
case $cmd in
build|run|lib)
LOWER='--lower-affine --convert-scf-to-cf --mg-lower-assert-to-stderr --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts'
- $MG_OPT "$W/$base.mlir" --convert-mg-to-affine $PASSES -o "$W/$base.affine.mlir" # 2. mg -> affine/memref/arith (+ runtime shape checks)
+ $MG_OPT "$W/$base.mlir" --convert-mg-to-affine$CONVOPT $PASSES -o "$W/$base.affine.mlir" # 2. mg -> affine/memref/arith (+ runtime shape checks)
$MG_OPT "$W/$base.affine.mlir" $LOWER -o "$W/$base.llvm.mlir" # 3. -> LLVM dialect, assertions -> write(2,..)+abort
mlir-translate-18 --mlir-to-llvmir "$W/$base.llvm.mlir" -o "$W/$base.ll" # 4. -> LLVM IR
if [ "$cmd" = lib ]; then # object + header; the object is linked by the caller
To rebuild mg-opt:
#!/bin/sh
# Rebuild mg-opt for Chapter 24: Chapter 22's build with this directory's LowerToAffine.cpp (mg.matmul gains an `ikj` loop order,
# chosen with --convert-mg-to-affine=matmul-order=ikj; the default stays ijk). Produces ./build/mg-opt.
# Needs mlir-18-tools, libmlir-18-dev, llvm-18-dev, cmake, make.
set -e
HERE=$(cd "$(dirname "$0")" && pwd); D=$HERE/../..; T=$HERE/tree
mkdir -p $T/include/mg $T/lib $T/tools
cp $HERE/CMakeLists.txt $T/
cp $D/part3/code/MgDialect.td $D/part22/code/MgOps.td $D/part3/code/MgDialect.h $D/part3/code/MgOps.h $T/include/mg/
cp $D/part14/code/DynamicShapes.h $T/include/mg/
cp $D/part22/code/MgDialect.cpp $T/lib/
cp $D/part14/code/MgBufferizableOpInterfaceImpl.cpp $T/lib/
cp $D/part19/code/AssertToStderr.cpp $T/lib/
cp $HERE/LowerToAffine.cpp $T/lib/
cp $HERE/mg-opt.cpp $T/tools/
mkdir -p $HERE/build/include/mg && cd $HERE/build
cmake $T -DMLIR_DIR=/usr/lib/llvm-18/lib/cmake/mlir -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm \
-DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_ASSERTIONS=OFF > cmake_out.txt 2>&1
make -j8 2>&1 | tail -2
You can see the order in the lowered code. This is a 2 by 3 times 3 by 4 product in each order; the accumulate nest has bounds 2, 4, 3 for ijk (the reduction k innermost) and 2, 3, 4 for ikj (the output column j innermost):
### matmul-order=ijk (the zero-fill nest is omitted; this is the accumulate nest)
affine.for %arg2 = 0 to 2 {
affine.for %arg3 = 0 to 4 {
affine.for %arg4 = 0 to 3 {
%0 = affine.load %alloc[%arg2, %arg3] : memref<2x4xf64>
%1 = affine.load %arg0[%arg2, %arg4] : memref<2x3xf64>
%2 = affine.load %arg1[%arg4, %arg3] : memref<3x4xf64>
%3 = arith.mulf %1, %2 : f64
%4 = arith.addf %0, %3 : f64
affine.store %4, %alloc[%arg2, %arg3] : memref<2x4xf64>
}
}
}
### matmul-order=ikj (the zero-fill nest is omitted; this is the accumulate nest)
affine.for %arg2 = 0 to 2 {
affine.for %arg3 = 0 to 3 {
affine.for %arg4 = 0 to 4 {
%0 = affine.load %alloc[%arg2, %arg4] : memref<2x4xf64>
%1 = affine.load %arg0[%arg2, %arg3] : memref<2x3xf64>
%2 = affine.load %arg1[%arg3, %arg4] : memref<3x4xf64>
%3 = arith.mulf %1, %2 : f64
%4 = arith.addf %0, %3 : f64
affine.store %4, %alloc[%arg2, %arg4] : memref<2x4xf64>
}
}
}
Results¶
The experiment is Chapter 23's, on the same kind of machine, with the new order added:
CPU: Intel(R) Xeon(R) Processor @ 2.10GHz
CPU(s): 4
Thread(s) per core: 1
Core(s) per socket: 4
Socket(s): 1
L1d cache: 192 KiB (4 instances)
L2 cache: 8 MiB (4 instances)
L3 cache: 260 MiB (1 instance)
vector extensions available: avx avx2 avx512f fma sse4_2
memory: 16094 MiB
kernel: Linux 6.18.44-fc-v51
compiler: Ubuntu clang version 18.1.3 (1ubuntu1)
mlir: Ubuntu LLVM version 18.1.3
This is a shared cloud virtual machine, not a quiet benchmarking box; see the chapter for what that means for the numbers.
As before: a shared cloud virtual machine, pinned to one core, two complete passes, every result compared with a reference by exact equality. The variants this time:
| Name | What it is |
|---|---|
mg_O2 |
Chapter 23's baseline: loop order ijk, clang -O2 |
mg_O2_ikj |
the new order, -O2 |
mg_O2_ikj_tile32 |
the new order, tiled 32 by 32, -O2 |
mg_O3_native_tile32 |
Chapter 23's best dynamic-size variant: ijk, tiled, -O3 -march=native |
mg_O3_native_ikj |
the new order, -O3 -march=native |
mg_O3_native_ikj_tile32 |
the new order, tiled, -O3 -march=native |
C++ ijk loop, clang -O2, C++ ikj loop, -O3 native |
the hand-written loops from Chapter 23, for comparison |
The raw output of both passes (click to expand):
Pass 1: bench_out_1.txt
machine: 4 CPUs, Intel(R) Xeon(R) Processor @ 2.10GHz, Ubuntu clang version 18.1.3 (1ubuntu1)
pinned with: taskset -c 3 load average before: 0.86 0.43 0.17
mg_O2 (dynamic sizes) N=64 min 0.094 ms median 0.104 ms 5.06 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=128 min 2.211 ms median 2.257 ms 1.86 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=256 min 17.219 ms median 17.788 ms 1.89 GFLOP/s (median) result ok
mg_O2 (static 256) N=256 min 16.667 ms median 16.867 ms 1.99 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=512 min 443.277 ms median 490.884 ms 0.55 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=64 min 0.063 ms median 0.064 ms 8.13 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=128 min 0.587 ms median 0.646 ms 6.49 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=256 min 6.264 ms median 6.296 ms 5.33 GFLOP/s (median) result ok
mg_O2_ikj (static 256) N=256 min 3.636 ms median 3.645 ms 9.21 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=512 min 43.697 ms median 61.176 ms 4.39 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=64 min 0.060 ms median 0.061 ms 8.65 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=128 min 0.470 ms median 0.470 ms 8.92 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=256 min 6.272 ms median 7.019 ms 4.78 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (static 256) N=256 min 4.967 ms median 5.065 ms 6.63 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=512 min 38.142 ms median 38.334 ms 7.00 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=64 min 0.100 ms median 0.103 ms 5.07 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=128 min 1.239 ms median 1.309 ms 3.20 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=256 min 11.280 ms median 11.903 ms 2.82 GFLOP/s (median) result ok
mg_O3_native_tile32 (static 256) N=256 min 3.388 ms median 3.478 ms 9.65 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=512 min 78.325 ms median 81.522 ms 3.29 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=64 min 0.035 ms median 0.042 ms 12.50 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=128 min 0.491 ms median 0.509 ms 8.24 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=256 min 3.865 ms median 3.939 ms 8.52 GFLOP/s (median) result ok
mg_O3_native_ikj (static 256) N=256 min 3.370 ms median 3.580 ms 9.37 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=512 min 34.144 ms median 36.563 ms 7.34 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=64 min 0.061 ms median 0.068 ms 7.76 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=128 min 0.366 ms median 0.418 ms 10.04 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=256 min 4.007 ms median 4.863 ms 6.90 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (static 256) N=256 min 2.601 ms median 2.835 ms 11.84 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=512 min 38.855 ms median 42.166 ms 6.37 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=64 min 0.130 ms median 0.136 ms 3.86 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=128 min 2.337 ms median 2.424 ms 1.73 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=256 min 17.151 ms median 17.407 ms 1.93 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=512 min 435.574 ms median 489.424 ms 0.55 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=64 min 0.037 ms median 0.038 ms 13.95 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=128 min 0.428 ms median 0.443 ms 9.47 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=256 min 4.076 ms median 4.534 ms 7.40 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=512 min 33.838 ms median 35.939 ms 7.47 GFLOP/s (median) result ok
load average after: 0.91 0.48 0.19
Pass 2: bench_out_2.txt
machine: 4 CPUs, Intel(R) Xeon(R) Processor @ 2.10GHz, Ubuntu clang version 18.1.3 (1ubuntu1)
pinned with: taskset -c 3 load average before: 0.92 0.49 0.20
mg_O2 (dynamic sizes) N=64 min 0.094 ms median 0.094 ms 5.58 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=128 min 2.156 ms median 2.208 ms 1.90 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=256 min 16.735 ms median 17.191 ms 1.95 GFLOP/s (median) result ok
mg_O2 (static 256) N=256 min 16.598 ms median 16.915 ms 1.98 GFLOP/s (median) result ok
mg_O2 (dynamic sizes) N=512 min 435.352 ms median 463.103 ms 0.58 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=64 min 0.060 ms median 0.061 ms 8.56 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=128 min 0.563 ms median 0.580 ms 7.23 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=256 min 3.942 ms median 5.028 ms 6.67 GFLOP/s (median) result ok
mg_O2_ikj (static 256) N=256 min 4.211 ms median 5.394 ms 6.22 GFLOP/s (median) result ok
mg_O2_ikj (dynamic sizes) N=512 min 62.850 ms median 66.111 ms 4.06 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=64 min 0.085 ms median 0.092 ms 5.71 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=128 min 0.649 ms median 0.674 ms 6.22 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=256 min 4.697 ms median 4.941 ms 6.79 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (static 256) N=256 min 3.164 ms median 3.334 ms 10.06 GFLOP/s (median) result ok
mg_O2_ikj_tile32 (dynamic sizes) N=512 min 39.203 ms median 42.462 ms 6.32 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=64 min 0.103 ms median 0.179 ms 2.93 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=128 min 0.773 ms median 0.875 ms 4.79 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=256 min 7.778 ms median 8.036 ms 4.18 GFLOP/s (median) result ok
mg_O3_native_tile32 (static 256) N=256 min 3.292 ms median 3.706 ms 9.05 GFLOP/s (median) result ok
mg_O3_native_tile32 (dynamic sizes) N=512 min 85.210 ms median 110.995 ms 2.42 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=64 min 0.035 ms median 0.044 ms 11.80 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=128 min 0.408 ms median 0.450 ms 9.32 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=256 min 3.301 ms median 3.450 ms 9.73 GFLOP/s (median) result ok
mg_O3_native_ikj (static 256) N=256 min 3.190 ms median 3.332 ms 10.07 GFLOP/s (median) result ok
mg_O3_native_ikj (dynamic sizes) N=512 min 37.478 ms median 38.669 ms 6.94 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=64 min 0.049 ms median 0.059 ms 8.93 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=128 min 0.352 ms median 0.361 ms 11.62 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=256 min 3.759 ms median 3.970 ms 8.45 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (static 256) N=256 min 2.417 ms median 2.714 ms 12.36 GFLOP/s (median) result ok
mg_O3_native_ikj_tile32 (dynamic sizes) N=512 min 32.267 ms median 35.527 ms 7.56 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=64 min 0.092 ms median 0.104 ms 5.05 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=128 min 2.219 ms median 2.227 ms 1.88 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=256 min 16.938 ms median 17.020 ms 1.97 GFLOP/s (median) result ok
C++ ijk loop, clang -O2 N=512 min 404.418 ms median 500.284 ms 0.54 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=64 min 0.035 ms median 0.038 ms 13.74 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=128 min 0.419 ms median 0.428 ms 9.80 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=256 min 3.256 ms median 3.348 ms 10.02 GFLOP/s (median) result ok
C++ ikj loop, -O3 native N=512 min 34.176 ms median 36.967 ms 7.26 GFLOP/s (median) result ok
load average after: 0.95 0.54 0.23
And the combined table (median time per call in each pass, how much the passes differ, average GFLOP/s, and speedup versus mg_O2 at the same size; every result matched the reference):
implementation N pass 1 ms pass 2 ms differ GFLOP/s vs mg -O2 results
mg_O2 (dynamic sizes) 64 0.104 0.094 10% 5.32 1.00x ok
mg_O2_ikj (dynamic sizes) 64 0.064 0.061 5% 8.35 1.58x ok
mg_O2_ikj_tile32 (dynamic sizes) 64 0.061 0.092 41% 7.18 1.29x ok
mg_O3_native_tile32 (dynamic sizes) 64 0.103 0.179 54% 4.00 0.70x ok
mg_O3_native_ikj (dynamic sizes) 64 0.042 0.044 5% 12.15 2.30x ok
mg_O3_native_ikj_tile32 (dynamic sizes) 64 0.068 0.059 14% 8.34 1.56x ok
C++ ijk loop, clang -O2 64 0.136 0.104 27% 4.46 0.83x ok
C++ ikj loop, -O3 native 64 0.038 0.038 0% 13.84 2.61x ok
mg_O2 (dynamic sizes) 128 2.257 2.208 2% 1.88 1.00x ok
mg_O2_ikj (dynamic sizes) 128 0.646 0.580 11% 6.86 3.64x ok
mg_O2_ikj_tile32 (dynamic sizes) 128 0.470 0.674 36% 7.57 3.90x ok
mg_O3_native_tile32 (dynamic sizes) 128 1.309 0.875 40% 4.00 2.04x ok
mg_O3_native_ikj (dynamic sizes) 128 0.509 0.450 12% 8.78 4.66x ok
mg_O3_native_ikj_tile32 (dynamic sizes) 128 0.418 0.361 15% 10.83 5.73x ok
C++ ijk loop, clang -O2 128 2.424 2.227 8% 1.80 0.96x ok
C++ ikj loop, -O3 native 128 0.443 0.428 3% 9.64 5.13x ok
mg_O2 (dynamic sizes) 256 17.788 17.191 3% 1.92 1.00x ok
mg_O2 (static 256) 256 16.867 16.915 0% 1.98 1.04x ok
mg_O2_ikj (dynamic sizes) 256 6.296 5.028 22% 6.00 3.09x ok
mg_O2_ikj (static 256) 256 3.645 5.394 39% 7.71 3.87x ok
mg_O2_ikj_tile32 (dynamic sizes) 256 7.019 4.941 35% 5.79 2.92x ok
mg_O2_ikj_tile32 (static 256) 256 5.065 3.334 41% 8.35 4.16x ok
mg_O3_native_tile32 (dynamic sizes) 256 11.903 8.036 39% 3.50 1.75x ok
mg_O3_native_tile32 (static 256) 256 3.478 3.706 6% 9.35 4.87x ok
mg_O3_native_ikj (dynamic sizes) 256 3.939 3.450 13% 9.12 4.73x ok
mg_O3_native_ikj (static 256) 256 3.580 3.332 7% 9.72 5.06x ok
mg_O3_native_ikj_tile32 (dynamic sizes) 256 4.863 3.970 20% 7.67 3.96x ok
mg_O3_native_ikj_tile32 (static 256) 256 2.835 2.714 4% 12.10 6.30x ok
C++ ijk loop, clang -O2 256 17.407 17.020 2% 1.95 1.02x ok
C++ ikj loop, -O3 native 256 4.534 3.348 30% 8.71 4.44x ok
mg_O2 (dynamic sizes) 512 490.884 463.103 6% 0.56 1.00x ok
mg_O2_ikj (dynamic sizes) 512 61.176 66.111 8% 4.22 7.49x ok
mg_O2_ikj_tile32 (dynamic sizes) 512 38.334 42.462 10% 6.66 11.81x ok
mg_O3_native_tile32 (dynamic sizes) 512 81.522 110.995 31% 2.85 4.96x ok
mg_O3_native_ikj (dynamic sizes) 512 36.563 38.669 6% 7.14 12.68x ok
mg_O3_native_ikj_tile32 (dynamic sizes) 512 42.166 35.527 17% 6.96 12.28x ok
C++ ijk loop, clang -O2 512 489.424 500.284 2% 0.55 0.96x ok
C++ ikj loop, -O3 native 512 35.939 36.967 3% 7.37 13.09x ok
Reading the numbers¶
1. The new loop order alone is a large win. At -O2, ikj is 3.6× faster than ijk at N = 128, 3.1× at N = 256 and 7.5× at N = 512 (4.22 against 0.56 GFLOP/s). Nothing about the arithmetic changed.
2. With the CPU's full instruction set, the gap from Chapter 23 closes. Chapter 23's result 6 said the best Mountain Goat variant at N = 512 was about 2.1× slower than a hand-written ikj loop. Now mg_O3_native_ikj reaches 7.14 GFLOP/s against the hand-written loop's 7.37, within the noise (the two differ by 6% and 3% between passes). At N = 256 with dynamic sizes it reaches 9.12 against the hand-written loop's 8.71, but that hand-written figure moved by 30% between passes, so the right reading is "comparable", not "faster".
3. Tiling no longer clearly helps. On top of ikj with all instructions, tiling gives 6.96 against 7.14 GFLOP/s at N = 512 and 7.67 against 9.12 at N = 256 with dynamic sizes: no gain, and the passes differ by up to 20%. At N = 128 the tiled version is faster (10.83 against 8.78), but the passes differ by 12% and 15%, so it is not a firm result. The loop order had already fixed most of what tiling was fixing in Chapter 23. There are two exceptions, and both are specific: at plain -O2, tiling still helps ikj (6.66 against 4.22 GFLOP/s at N = 512, 1.6×), and with a fixed shape at N = 256 the tiled ikj native build reaches 12.10 GFLOP/s against 9.72 untiled, the best number in the table and the only one clearly above the hand-written loop (and that comparison is at one size, against a hand-written figure that was itself noisy).
4. It is the loop order that unlocks vectorization. Chapter 23 left this open (it had only seen vector instructions in one build and could not say which ingredient mattered). inspect_asm.sh counts them again:
ijk, untiled, mm_dyn: 0 vector/FMA instructions
ijk, untiled, mm_256: 0 vector/FMA instructions
ijk, tiled (32), mm_dyn: 0 vector/FMA instructions
ijk, tiled (32), mm_256: 64 vector/FMA instructions
ikj, untiled, mm_dyn: 8 vector/FMA instructions
ikj, untiled, mm_256: 16 vector/FMA instructions
ikj, tiled (32), mm_dyn: 8 vector/FMA instructions
ikj, tiled (32), mm_256: 32 vector/FMA instructions
With ijk, the only function with vector instructions is the tiled fixed-shape one (64). With ikj, even the untiled dynamic-size function has them (8), and the fixed-shape ones have more (16 untiled, 32 tiled). So for dynamic sizes the loop order alone is enough; the fixed shape and the tiling are not what was needed. The consistent explanation is that the ikj inner loop reads and writes consecutive addresses, which is what a vectorizer wants. I did not test other loop orders (for example jki) or look at the vectorizer's own reports, so "consistent" is as far as this goes.
5. The numbers are noisy, and some spreads are large. Between the two passes the same experiment differs by up to 54% (mg_O3_native_tile32 at N = 64), 41% and 39% at N = 256. As in Chapter 23, differences of 2× and more are claims; differences of 1.0× to 1.3× are not.
Same answers, to the last bit¶
The argument above says the two loop orders add each element's products in the same order. bits_check.sh tests it with numbers where it would show: a 5 by 7 times 7 by 4 product of values that are not exactly representable, printed as hexadecimal floating point (every bit of every result), built once with each loop order:
// Prints every element of a matrix product in hexadecimal floating point (%a), which shows every bit. The inputs are not
// "nice" numbers (they are not exactly representable and the products round), so a different order of additions would change
// bits. Built once against an ijk library and once against an ikj library; the two outputs must be identical.
#include <cstdio>
#include "ops.h"
int main() {
const int m = 5, k = 7, n = 4;
std::vector<double> a(m * k), b(k * n);
for (int i = 0; i < m * k; i++) a[i] = 0.1 * (i % 13) + 1.0 / (i + 3);
for (int i = 0; i < k * n; i++) b[i] = 0.7 / (i % 11 + 1) - 0.01 * i;
mg::Matrix r = mg::matmul(mg::Matrix(m, k, a), mg::Matrix(k, n, b));
for (double v : r.data) std::printf("%a\n", v);
return 0;
}
ijk and ikj outputs: identical (20 elements, every bit)
compiled ikj output equals an independent Python sum in increasing k: True
the same sum in DECREASING k order differs from it in 19 of 20 elements (so this check would notice a changed order)
The two outputs are identical in all 20 elements. The script also checks that the check is able to fail: summing the same products in decreasing k order, instead of increasing, changes 19 of the 20 results, so a loop order that changed the order of additions would have been caught. (The benchmark itself uses small whole numbers, where every sum is exact in any order, so on its own it could not have shown this.)
Tests¶
Six new test files in test/loop-order/ (the suite now has 99 tests). As in Chapter 23, none asserts speed.
| Test | What it checks |
|---|---|
structure |
the default and matmul-order=ijk give loop bounds 2, 4, 3 for a 2×3 @ 3×4 product, and ikj gives 2, 3, 4 (with the zero-fill nest before it) |
bad-option |
matmul-order=jki is an error with a clear message, not a silent fallback |
results-identical |
the same 3×5 @ 5×2 product (no dimension a multiple of the tile size) prints the same at ijk, ikj, and ikj tiled by 2 and by 3, static and dynamic, equal to the hand-computed 12 15 / 32 35 / 52 55 |
cpp-ikj |
Chapter 21's whole C++ program (every elementwise result, matmul against a plain C++ loop, the dirty-heap check, the abort message) against a library built with ikj |
flag-reaches-pass |
mgc --matmul-order really reaches the pass: the kept affine IR differs in loop order |
bitwise-identical |
the hexadecimal-float comparison above, as a test |
Two bugs were injected into mgc and four into the lowering, one at a time:
#!/usr/bin/env python3
# READ THIS FIRST: this script injects deliberate bugs into the mgc driver and re-runs the Chapter 24 tests (test/loop-order).
# "caught by <tests>" lines are the EXPECTED, wanted result: a test noticed the bug. "NOT CAUGHT" would be a test gap.
# mgc is restored afterwards. Output: mgc_mutation_out.txt
import os, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__)); MGC = os.path.join(HERE, "mgc")
LIT = os.path.join(HERE, "..", "..", "part15", "code", "run_lit.sh")
print("NOTE: this script deliberately breaks mgc. 'caught by' lines are EXPECTED: they show the tests can detect the bug.")
def run(label, baseline=False):
r = subprocess.run(["sh", LIT, "--filter", ":: loop-order/"], capture_output=True, text=True,
env={**os.environ, "MG_OPT": os.path.join(HERE, "build", "mg-opt"), "MG_TEST_TMP": os.path.join(HERE, "work", "perfmut")})
fails = [l.split("::")[1].strip().split(" (")[0] for l in r.stdout.splitlines() if l.startswith("FAIL")]
if baseline: print(f"{label}: " + ("all tests pass (expected)" if not fails else "UNEXPECTED FAILURES: " + ", ".join(fails)))
else: print(f"{label}: " + ("caught by " + ", ".join(fails) if fails else "NOT CAUGHT (a test gap)"))
orig = open(MGC).read()
base = "run baseline (no bug)"
run(base, baseline=True)
muts = [("--matmul-order ignored by mgc", '--convert-mg-to-affine$CONVOPT $PASSES -o', '--convert-mg-to-affine $PASSES -o'),
("--matmul-order value passed to the wrong option", 'CONVOPT="=matmul-order=$2"', 'CONVOPT="=matmul-order=ijk"')]
try:
for label, a, b in muts:
if a not in orig: print(label, ": MUTATION DID NOT APPLY"); continue
open(MGC, "w").write(orig.replace(a, b, 1)); run(label)
finally:
open(MGC, "w").write(orig)
run("restored (baseline again)", baseline=True)
NOTE: this script deliberately breaks mgc. 'caught by' lines are EXPECTED: they show the tests can detect the bug.
run baseline (no bug): all tests pass (expected)
--matmul-order ignored by mgc: caught by loop-order/flag-reaches-pass.mlir
--matmul-order value passed to the wrong option: caught by loop-order/flag-reaches-pass.mlir
restored (baseline again): all tests pass (expected)
#!/bin/sh
# Rebuild mg-opt with one bug injected into LowerToAffine.cpp and run the ops tests against it.
# Each build is a full rebuild in work/mutN (about two minutes). Output: lowering_mutation_out.txt
# READ THIS FIRST: this script rebuilds mg-opt with one deliberate bug injected into the lowering at a time (a full rebuild each) and runs the tests.
# FAIL lines (and non-zero exit statuses) in this script's output are the EXPECTED result: each bug SHOULD make at least one test fail (a FAIL line means a test caught it). A section with NO FAIL line would be a test gap.
# The unmodified, current build is the baseline and must show no failures. A mutation or old build that makes NO test fail would be the problem.
echo "NOTE: this script deliberately runs broken or older code. FAIL lines below are EXPECTED: they show the tests can detect the problem. The baseline (unmodified current build) must show none."
HERE=$(cd "$(dirname "$0")" && pwd); LIT=$HERE/../../part15/code; M=$HERE/work/mut; rm -rf $M; mkdir -p $M
n=0
try() { # label sed-expr
n=$((n+1)); d=$M/$n; mkdir -p $d/build; cp -r $HERE/tree $d/tree; rm -rf $d/tree/build
cp $d/tree/lib/LowerToAffine.cpp $d/before.cpp; sed -i "$2" $d/tree/lib/LowerToAffine.cpp
if cmp -s $d/before.cpp $d/tree/lib/LowerToAffine.cpp; then echo "### $1: MUTATION DID NOT APPLY"; return; fi
( cd $d/build && cmake $d/tree -DMLIR_DIR=/usr/lib/llvm-18/lib/cmake/mlir -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_ASSERTIONS=OFF >/dev/null 2>&1 && mkdir -p include/mg && make -j8 >/dev/null 2>&1 )
[ -x $d/build/mg-opt ] || { echo "### $1: BUILD FAILED"; return; }
printf '%s\n' "### lowering mutation: $1"
MG_OPT=$d/build/mg-opt MG_TEST_TMP=$d/out sh $LIT/run_lit.sh --filter ':: loop-order/' 2>&1 | grep -E "^(FAIL)| Passed| Failed" | sed 's#IR Forge / Mountain Goat :: ##'
}
try "the ikj order is ignored (always lowers as ijk)" 's/: OpConversionPattern(converter, ctx), ikj(ikj) {}/: OpConversionPattern(converter, ctx), ikj(false) {}/'
try "ikj bounds not reordered (m, n, k used for i, k, j)" 's/ikj ? llvm::SmallVector<Value, 3>{m, k, n} : llvm::SmallVector<Value, 3>{m, n, k}/llvm::SmallVector<Value, 3>{m, n, k}/'
try "the right-hand matrix is read transposed" 's/Value y = b.create<affine::AffineLoadOp>(l, rhs, ValueRange{kk, j});/Value y = b.create<affine::AffineLoadOp>(l, rhs, ValueRange{j, kk});/'
try "the matmul-order option is not validated" 's/if (matmulOrder != "ijk" \&\& matmulOrder != "ikj") {/if (false) {/'
NOTE: this script deliberately runs broken or older code. FAIL lines below are EXPECTED: they show the tests can detect the problem. The baseline (unmodified current build) must show none.
### lowering mutation: the ikj order is ignored (always lowers as ijk)
FAIL: loop-order/flag-reaches-pass.mlir (2 of 6)
FAIL: loop-order/structure.mlir (3 of 6)
Passed : 4 (4.04%)
Failed : 2 (2.02%)
### lowering mutation: ikj bounds not reordered (m, n, k used for i, k, j)
FAIL: loop-order/flag-reaches-pass.mlir (2 of 6)
FAIL: loop-order/results-identical.mlir (3 of 6)
FAIL: loop-order/structure.mlir (4 of 6)
FAIL: loop-order/cpp-ikj.mlir (5 of 6)
FAIL: loop-order/bitwise-identical.mlir (6 of 6)
Passed : 1 (1.01%)
Failed : 5 (5.05%)
### lowering mutation: the right-hand matrix is read transposed
FAIL: loop-order/cpp-ikj.mlir (4 of 6)
FAIL: loop-order/results-identical.mlir (6 of 6)
Passed : 4 (4.04%)
Failed : 2 (2.02%)
### lowering mutation: the matmul-order option is not validated
FAIL: loop-order/bad-option.mlir (1 of 6)
Passed : 5 (5.05%)
Failed : 1 (1.01%)
Reading the mutation results:
- "The ikj order is ignored" produces correct answers (the two orders are identical by design), so only tests that look at the structure of the generated loops can catch it:
structureandflag-reaches-passdo, and nothing else fails. - "Bounds not reordered" (loops run in
i, k, jorder but with thei, j, ksizes) is caught by five of the six tests: it produces wrong answers or reads outside the matrices. - "The right-hand matrix is read transposed" is caught by only two tests,
results-identical(it checks a hand-computed answer) andcpp-ikj. It is not caught bybitwise-identical, and that is worth understanding: that test compares theijkresult with theikjresult, and this bug is in the code both orders share, so both are equally wrong and still agree with each other. A differential test (compare two implementations) cannot catch a bug that both implementations have; a test against a known right answer can. - "The option is not validated" is caught by exactly one test,
bad-option, the only test that passes a bad value.
The full suite with the Chapter 24 build:
Limits and what is not established¶
- Same machine, same caveats as Chapter 23: a shared cloud VM, one core, one session, noisy (up to 54% between passes here).
- Still one operation. Square
f64products from 64 to 512; nothing about other shapes (a tall-thin product has different memory behavior), and not about the other operations. - Two loop orders only.
ijkandikj. The other four orders were not tried, so "the loop order matters" is shown for this pair, not as a ranking of all six. - One tile size (32), still untuned, and tiling was measured on top of
ikjonly at that size. - No hardware counters and no vectorizer reports. The explanations (contiguous inner loop, cache lines) are consistent with the data and with the instruction counts, not demonstrated by measuring cache misses.
- The default is still
ijk. The faster order is opt-in (--matmul-order ikj), because changing the default would change every earlier chapter's generated code and recorded output. A real compiler would make the better order the default. - No multithreading, no GPU measurement, and nothing like a tuned library's blocking, packing or register micro-kernel; the best result here is still modest next to what a tuned library reaches on this CPU.
- The benchmark's exact-equality check uses small whole numbers; the bit-for-bit claim rests on the separate hexadecimal-float check (one 5×7×4 product), on the argument about the order of additions, and on that check being shown able to fail.
Reproducing¶
cd docs/part24/code
./build.sh
./run_bench.sh > bench_out_1.txt && ./run_bench.sh > bench_out_2.txt
python3 summarize.py bench_out_1.txt bench_out_2.txt > summary.txt
./inspect_asm.sh > inspect_asm_out.txt && ./bits_check.sh > bits_check_out.txt && ./machine_info.sh > machine_info_out.txt
./mgc_mutation.py > mgc_mutation_out.txt
./lowering_mutation.sh > lowering_mutation_out.txt # four rebuilds
cd ../../part15/code && ./run_lit.sh # 99 tests
Chapter summary¶
- A second loop order for
mg.matmul,ikj, is selected with--convert-mg-to-affine=matmul-order=ikjormgc --matmul-order ikj; the default (ijk) is unchanged. - It is 3× to 7.5× faster than
ijkat-O2on this machine, and with all CPU instructions it matches a hand-writtenikjloop at N = 512 (7.14 against 7.37 GFLOP/s), closing the gap Chapter 23 left. - Tiling on top of
ikjhelps little or not at all, except at-O2and for one fixed shape; the loop order did most of the work. - Loop order alone is enough for the compiler to produce vector instructions for dynamic sizes; Chapter 23's open question is answered for this pair of orders.
- The two orders give bit-identical results, shown by a hexadecimal-float test that can fail; the benchmark's own check could not have shown it.
- Six tests (suite: 99) and six injected bugs, all caught.
Self-check questions¶
Each answer is collapsed; try the question first.
-
In
ijkandikj, which loop is innermost, and which memory does that innermost loop walk?Answer
In
ijkthe innermost loop isk: it readsA[i][k]along a row of A (consecutive addresses) andB[k][j]down a column of B (jumping a whole row each step). Inikjthe innermost loop isj: it readsB[k][j]and updatesC[i][j], and both walk along a row, so consecutive addresses. -
Why does walking down a column of a row-major matrix cost more than walking along a row?
Answer
Memory is fetched in cache lines of about 64 bytes (eight doubles). Along a row, every number of a fetched line is used. Down a column, each step lands in a different line, so one number of each fetched line is used and the rest is wasted, and for large matrices those lines are evicted before they are needed again. A walk along a row also lets the compiler use vector instructions; a walk with a large stride does not.
-
Why are the
ijkandikjresults identical to the last bit, rather than merely close?Answer
Floating-point addition is not associative, so the order of additions matters. But in both loop orders, each result element
C[i][j]accumulates its products in the same order (kincreasing from 0), so each element goes through exactly the same sequence of additions and the same roundings. What differs between the orders is only the order in which different elements are worked on, which does not affect any element's value. -
Why does the benchmark's exact-equality check not prove that, and what does?
Answer
The benchmark uses small whole numbers, for which every product and sum is exact in any order, so any order would pass. The proof needs numbers that round:
bits_check.shmultiplies values that are not exactly representable, prints every bit, and compares the two orders. It also shows the comparison could fail: adding in decreasingkorder changes 19 of the 20 results. -
Chapter 23 could not say whether tiling or fixed sizes unlocked vectorization. What did this chapter find, and what did it not test?
Answer
With the
ikjorder, even the untiled dynamic-size function contains vector instructions (8, against 0 withijk), so for dynamic sizes the loop order alone is sufficient; neither tiling nor fixed shape was needed. It did not test other loop orders, did not read the compiler's vectorization reports, and did not measure why the fixed-shape builds have more vector instructions. The explanation (consecutive addresses in the inner loop) is consistent with the evidence, not proven. -
Tiling gave 2.6× to 5.2× in Chapter 23 but little here. Why, and where does it still help?
Answer
Part of what tiling does is reduce cache misses caused by the column walk. The
ikjorder removed the column walk, so there is less left for tiling to fix. It still helps at plain-O2(1.6× at N = 512) and with a fixed 256 shape and all instructions (12.10 against 9.72 GFLOP/s), where other effects are in play that this chapter did not isolate. -
Why is the default still
ijkifikjis better?Answer
Changing the default would change the generated code for every earlier chapter's matmul, and with it recorded outputs, structure tests and page text. Making the better order opt-in kept everything earlier valid. A production compiler would make the better order the default and keep the old one selectable, or choose per shape.
-
The test
bad-optionchecks thatmatmul-order=jkiis an error. What would the mutation "option not validated" do without it?Answer
The pass would accept any string and treat everything other than
ikjasijk, sojki(or a typo likeijkorIKJ) would silently produce the default order. The user would think they had changed the order and would see no change. The test makes a wrong value fail loudly. -
The mutation "the ikj order is ignored (always lowers as ijk)" produces correct results. How can any test catch it?
Answer
Only by looking at the structure of the lowered code, because the results are identical by design.
structureandflag-reaches-passcheck the loop bounds in the generated IR (2, 3, 4 forikj). This is the same situation as Chapter 22's equivalent mutant: a bug with no effect on results can only be caught by checking implementation, not behavior. -
What would you have to do to claim that
ikjis "the" right loop order for Mountain Goat?Answer
Try all six orders, many shapes (including tall, thin and small ones), more machines and compilers, and look at hardware counters and vectorizer reports to confirm the mechanism. Then compare against a tuned library. This chapter measured two orders on square products on one shared virtual machine.