14. A Runtime Shape Check: Closing Chapter 13's Memory-Safety Hole¶
What you will understand: how to turn Chapter 13's silent out-of-bounds read into a loud, immediate abort, on both of this book's lowering paths, with no new pass and no cost for static programs. The chapter shows the helper code in full, the complete diff against Chapter 13, the generated IR before and after, and the real output of every test. Along the way: why a compile-time verifier cannot be enough once shapes are dynamic, what cf.assert is and how it reaches machine code, one crash that came from MLIR's lazy dialect loading (reproduced here from source, not just remembered), and one real weakness in how MLIR's own cf.assert reports its message.
What you need to know first: Chapter 13's dynamic-shape lowering (memref.dim, dynamic memref.alloc) and its recorded "known gap". New ground: cf.assert and arith.cmpi.
Compile and run
Every command, listing and output on this page is reproduced by the commands in Reproducing this chapter at the bottom of the page, which also lists what must be built first. The chain of builds is in Getting Started.
The problem, restated with the real evidence¶
Chapter 13 ended with a call that should never have run to completion. a is a 2x3 matrix; b is declared 1x2 and its buffer holds only two doubles. The harness:
#include <stdint.h>
#include <stdio.h>
typedef struct { double *allocated; double *aligned; int64_t offset; int64_t sizes[2]; int64_t strides[2]; } Memref2D;
extern Memref2D add_dyn(double*,double*,int64_t,int64_t,int64_t,int64_t,int64_t,double*,double*,int64_t,int64_t,int64_t,int64_t,int64_t);
int main(void) {
double a[6] = {1,2,3,4,5,6}, b[2] = {10,20}; // b has only 2 elements
Memref2D r = add_dyn(a,a,0,2,3,3,1, b,b,0,1,2,2,1); // a is 2x3, b is 1x2: MISMATCHED at runtime
printf("result %ldx%ld: ", (long)r.sizes[0], (long)r.sizes[1]);
for (int i = 0; i < 6; i++) printf("%g ", r.aligned[i]);
printf("\n"); return 0;
}
and what Chapter 13 recorded when it ran (the last two lines of its run_out.txt):
The loop bounds come from a (2x3, six elements); the loads from b run off the end of its two-element buffer. The first two sums are right (11 22, those elements exist in both); the rest is whatever sat in memory beyond b. No verifier could have stopped this: the verifier sees types, and tensor<?x?xf64> plus tensor<?x?xf64> is compatible as types. Whether the two sizes agree is a fact about the values arriving at runtime, so the only place left to enforce it is the compiled code itself.
Background: what cf.assert is¶
MLIR's cf (control-flow) dialect has an operation cf.assert %condition, "message": if the i1 condition is false at runtime, the program must stop. That is exactly the semantics wanted here: compute "do the two sizes match", assert it, and only then run the loop. How it reaches machine code was checked directly, before writing any dialect code, on a ten-line function that compares two dynamic sizes and asserts they are equal:
func.func @f(%a: memref<?xf64>, %b: memref<?xf64>) {
%c0 = arith.constant 0 : index
%da = memref.dim %a, %c0 : memref<?xf64>
%db = memref.dim %b, %c0 : memref<?xf64>
%eq = arith.cmpi eq, %da, %db : index
cf.assert %eq, "shapes differ"
return
}
Running the book's existing five-pass lowering on it (--lower-affine --convert-scf-to-cf --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts) produced this (probe_cf_assert_lowered.mlir; the long run of insertvalue lines is Chapter 1's memref-descriptor unpacking, and the part that matters is the end):
module {
llvm.func @abort()
llvm.func @puts(!llvm.ptr)
llvm.mlir.global private constant @assert_msg(dense<[115, 104, 97, 112, 101, 115, 32, 100, 105, 102, 102, 101, 114, 0]> : tensor<14xi8>) {addr_space = 0 : i32} : !llvm.array<14 x i8>
llvm.func @f(%arg0: !llvm.ptr, %arg1: !llvm.ptr, %arg2: i64, %arg3: i64, %arg4: i64, %arg5: !llvm.ptr, %arg6: !llvm.ptr, %arg7: i64, %arg8: i64, %arg9: i64) {
%23 = llvm.icmp "eq" %17, %22 : i64
llvm.cond_br %23, ^bb1, ^bb2
^bb1: // pred: ^bb0
llvm.return
^bb2: // pred: ^bb0
%24 = llvm.mlir.addressof @assert_msg : !llvm.ptr
llvm.call @puts(%24) : (!llvm.ptr) -> ()
llvm.call @abort() : () -> ()
llvm.unreachable
}
The assertion became an icmp, a conditional branch, and on the failing side a call to puts (print the message) followed by abort. No new pass was needed: Chapter 13 had predicted this lowering might be missing from the pipeline, and it is not. The lowering is evidently provided by --convert-func-to-llvm, the same pass that handles the cf.br/cf.cond_br that scf-to-cf emits; this chapter did not read that pass's source, only observed that no --convert-cf-to-llvm was needed (running it anyway produced the same two calls).
The design, and what it deliberately skips¶
Before each dynamic mg.add loop, compare the two operands' sizes with memref.dim and arith.cmpi eq, and cf.assert the result. All of the logic lives in one helper, which both lowering paths call, so the two paths cannot drift apart on this. The helper in full (DynamicShapes.h, extending Chapter 13's version with assertSameShape):
#ifndef MG_DYNAMIC_SHAPES_H
#define MG_DYNAMIC_SHAPES_H
#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
#include "mlir/IR/BuiltinTypes.h"
#include "llvm/ADT/STLFunctionalExtras.h"
namespace mg::dyn {
/// Two extents are compatible if they are equal, or if either one is not
/// known until runtime (`?`). Chapter 3's verifiers used plain equality,
/// which rejects `tensor<?x2xf64>` against `tensor<3x2xf64>` even though
/// the two can be equal at runtime.
inline bool compatibleDim(int64_t a, int64_t b) {
return mlir::ShapedType::isDynamic(a) || mlir::ShapedType::isDynamic(b) || a == b;
}
/// The runtime extent of dimension `d` of a buffer: a real `memref.dim`.
inline mlir::Value extentOf(mlir::OpBuilder &b, mlir::Location loc,
mlir::Value memref, int64_t d) {
return b.create<mlir::memref::DimOp>(loc, memref, d);
}
/// Runtime check that two equal-rank operand buffers have the same extent
/// along every dimension. Compile-time checks (the verifier) can only
/// compare extents known statically; where either side is `?`, the only
/// place left to look is runtime. Emits a `cf.assert` per such dimension,
/// which the book's existing five-pass pipeline already lowers to a
/// message plus `abort()`. A dimension static on both sides is skipped: the
/// verifier already rejected any static mismatch.
inline void assertSameShape(mlir::OpBuilder &b, mlir::Location loc,
mlir::Value lhsBuf, mlir::Value rhsBuf,
mlir::RankedTensorType lhsType,
mlir::RankedTensorType rhsType,
llvm::StringRef opName) {
for (int64_t d = 0; d < lhsType.getRank(); ++d) {
if (!lhsType.isDynamicDim(d) && !rhsType.isDynamicDim(d))
continue;
mlir::Value l = b.create<mlir::memref::DimOp>(loc, lhsBuf, d);
mlir::Value r = b.create<mlir::memref::DimOp>(loc, rhsBuf, d);
mlir::Value eq = b.create<mlir::arith::CmpIOp>(loc, mlir::arith::CmpIPredicate::eq, l, r);
std::string msg = (opName + ": operand shapes differ at runtime in dimension " + llvm::Twine(d)).str();
b.create<mlir::cf::AssertOp>(loc, eq, b.getStringAttr(msg));
}
}
/// Allocate a buffer of type `type`, taking every dynamic dimension's size
/// from `extents[d]` (entries for static dimensions are ignored).
inline mlir::memref::AllocOp allocFor(mlir::OpBuilder &b, mlir::Location loc,
mlir::MemRefType type,
llvm::ArrayRef<mlir::Value> extents) {
llvm::SmallVector<mlir::Value> sizes;
for (int64_t d = 0; d < type.getRank(); ++d)
if (type.isDynamicDim(d))
sizes.push_back(extents[d]);
return b.create<mlir::memref::AllocOp>(loc, type, sizes);
}
/// A 2-D `affine.for` nest over [0, extent0) x [0, extent1). Static shapes
/// take exactly the constant-bound path every earlier chapter used, so
/// their output is unchanged; a shape with a `?` takes the operand-bound
/// path, with each bound read from `extents`.
inline void nest2D(
mlir::OpBuilder &b, mlir::Location loc, mlir::RankedTensorType type,
llvm::ArrayRef<mlir::Value> extents,
llvm::function_ref<void(mlir::OpBuilder &, mlir::Location, mlir::ValueRange)> body) {
auto shape = type.getShape();
if (type.hasStaticShape()) {
mlir::affine::buildAffineLoopNest(b, loc, {0, 0}, {shape[0], shape[1]}, {1, 1}, body);
return;
}
mlir::Value zero = b.create<mlir::arith::ConstantIndexOp>(loc, 0);
llvm::SmallVector<mlir::Value, 2> ubs;
for (int64_t d = 0; d < 2; ++d)
ubs.push_back(type.isDynamicDim(d)
? extents[d]
: b.create<mlir::arith::ConstantIndexOp>(loc, shape[d]).getResult());
mlir::affine::buildAffineLoopNest(b, loc, mlir::ValueRange{zero, zero}, ubs, {1, 1}, body);
}
} // namespace mg::dyn
#endif // MG_DYNAMIC_SHAPES_H
The rules it implements:
- A dimension that is static on both sides is skipped (
continue): the verifier already rejected any static mismatch, and a check there would be dead code. So static programs are untouched: the staticadd_affineoutput is still byte-identical to Chapter 10's stored file (cmp, test 1 below). - A dimension that is dynamic on at least one side is checked, including the mixed case:
tensor<?x2xf64>plustensor<3x2xf64>gets exactly one check, on dimension 0; dimension 1 is static on both sides and skipped. mg.transposeneeds no check: it has one operand, so there is nothing for its shape to disagree with.
What the lowering now emits¶
The dynamic add after --convert-mg-to-affine (compare with Chapter 13's version, which went straight from the extents to the allocation):
func.func @add_dyn(%arg0: memref<?x?xf64>, %arg1: memref<?x?xf64>) -> memref<?x?xf64> {
%c0 = arith.constant 0 : index
%dim = memref.dim %arg0, %c0 : memref<?x?xf64>
%c0_0 = arith.constant 0 : index
%dim_1 = memref.dim %arg1, %c0_0 : memref<?x?xf64>
%0 = arith.cmpi eq, %dim, %dim_1 : index
cf.assert %0, "mg.add: operand shapes differ at runtime in dimension 0"
%c1 = arith.constant 1 : index
%dim_2 = memref.dim %arg0, %c1 : memref<?x?xf64>
%c1_3 = arith.constant 1 : index
%dim_4 = memref.dim %arg1, %c1_3 : memref<?x?xf64>
%1 = arith.cmpi eq, %dim_2, %dim_4 : index
cf.assert %1, "mg.add: operand shapes differ at runtime in dimension 1"
%c0_5 = arith.constant 0 : index
%dim_6 = memref.dim %arg0, %c0_5 : memref<?x?xf64>
%c1_7 = arith.constant 1 : index
%dim_8 = memref.dim %arg0, %c1_7 : memref<?x?xf64>
%alloc = memref.alloc(%dim_6, %dim_8) : memref<?x?xf64>
Reading it: lines 4 to 9 read the row extents of both operands, compare, assert; lines 10 to 15 do the same for columns; only then does the allocation (memref.alloc(%dim_6, %dim_8)) and the loop nest follow. The mixed case, where only dimension 0 is dynamic on one side:
func.func @mixed(%arg0: memref<?x2xf64>, %arg1: memref<3x2xf64>) -> memref<?x2xf64> {
%c0 = arith.constant 0 : index
%dim = memref.dim %arg0, %c0 : memref<?x2xf64>
%c0_0 = arith.constant 0 : index
%dim_1 = memref.dim %arg1, %c0_0 : memref<3x2xf64>
%0 = arith.cmpi eq, %dim, %dim_1 : index
cf.assert %0, "mg.add: operand shapes differ at runtime in dimension 0"
%c0_2 = arith.constant 0 : index
%dim_3 = memref.dim %arg0, %c0_2 : memref<?x2xf64>
%alloc = memref.alloc(%dim_3) : memref<?x2xf64>
The complete change¶
The unified diff against Chapter 13's files, all 145 lines, so nothing is hidden. It touches the helper, both lowerings, the mg-opt registration and the CMake link lines:
--- ../../part13/code/DynamicShapes.h 2026-10-01 22:53:13.158342568 +0000
+++ DynamicShapes.h 2026-10-01 23:01:57.025915584 +0000
@@ -3,6 +3,7 @@
#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
+#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
#include "mlir/IR/BuiltinTypes.h"
#include "llvm/ADT/STLFunctionalExtras.h"
@@ -23,6 +24,29 @@
return b.create<mlir::memref::DimOp>(loc, memref, d);
}
+/// Runtime check that two equal-rank operand buffers have the same extent
+/// along every dimension. Compile-time checks (the verifier) can only
+/// compare extents known statically; where either side is `?`, the only
+/// place left to look is runtime. Emits a `cf.assert` per such dimension,
+/// which the book's existing five-pass pipeline already lowers to a
+/// message plus `abort()`. A dimension static on both sides is skipped: the
+/// verifier already rejected any static mismatch.
+inline void assertSameShape(mlir::OpBuilder &b, mlir::Location loc,
+ mlir::Value lhsBuf, mlir::Value rhsBuf,
+ mlir::RankedTensorType lhsType,
+ mlir::RankedTensorType rhsType,
+ llvm::StringRef opName) {
+ for (int64_t d = 0; d < lhsType.getRank(); ++d) {
+ if (!lhsType.isDynamicDim(d) && !rhsType.isDynamicDim(d))
+ continue;
+ mlir::Value l = b.create<mlir::memref::DimOp>(loc, lhsBuf, d);
+ mlir::Value r = b.create<mlir::memref::DimOp>(loc, rhsBuf, d);
+ mlir::Value eq = b.create<mlir::arith::CmpIOp>(loc, mlir::arith::CmpIPredicate::eq, l, r);
+ std::string msg = (opName + ": operand shapes differ at runtime in dimension " + llvm::Twine(d)).str();
+ b.create<mlir::cf::AssertOp>(loc, eq, b.getStringAttr(msg));
+ }
+}
+
/// Allocate a buffer of type `type`, taking every dynamic dimension's size
/// from `extents[d]` (entries for static dimensions are ignored).
inline mlir::memref::AllocOp allocFor(mlir::OpBuilder &b, mlir::Location loc,
--- ../../part13/code/LowerToAffine.cpp 2026-10-01 22:53:13.160390133 +0000
+++ LowerToAffine.cpp 2026-10-01 23:01:57.028331238 +0000
@@ -85,6 +85,9 @@
Value lhs = adaptor.getLhs();
Value rhs = adaptor.getRhs();
+ mg::dyn::assertSameShape(rewriter, loc, lhs, rhs,
+ llvm::cast<RankedTensorType>(op.getLhs().getType()),
+ llvm::cast<RankedTensorType>(op.getRhs().getType()), "mg.add");
llvm::SmallVector<Value, 2> extents;
for (int64_t d = 0; d < 2; ++d)
extents.push_back(tensorType.isDynamicDim(d) ? mg::dyn::extentOf(rewriter, loc, lhs, d) : Value());
@@ -206,14 +209,14 @@
void getDependentDialects(DialectRegistry ®istry) const override {
registry.insert<affine::AffineDialect, memref::MemRefDialect,
- arith::ArithDialect>();
+ arith::ArithDialect, cf::ControlFlowDialect>();
}
void runOnOperation() override {
MgToAffineTypeConverter typeConverter;
ConversionTarget target(getContext());
target.addLegalDialect<affine::AffineDialect, memref::MemRefDialect,
- arith::ArithDialect>();
+ arith::ArithDialect, cf::ControlFlowDialect>();
target.addIllegalDialect<mg::MgDialect>();
// A real, genuine gap this chapter's own first attempt hit directly:
--- ../../part13/code/MgBufferizableOpInterfaceImpl.cpp 2026-10-01 22:53:13.160415833 +0000
+++ MgBufferizableOpInterfaceImpl.cpp 2026-10-01 23:01:57.028362847 +0000
@@ -6,6 +6,7 @@
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Bufferization/IR/BufferizableOpInterface.h"
#include "mlir/Dialect/Bufferization/IR/Bufferization.h"
+#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
#include "mlir/IR/Dialect.h"
@@ -123,6 +124,9 @@
auto tensorType = cast<RankedTensorType>(addOp.getResult().getType());
auto memrefType =
MemRefType::get(tensorType.getShape(), tensorType.getElementType());
+ mg::dyn::assertSameShape(rewriter, loc, *lhsBuffer, *rhsBuffer,
+ cast<RankedTensorType>(addOp.getLhs().getType()),
+ cast<RankedTensorType>(addOp.getRhs().getType()), "mg.add");
llvm::SmallVector<Value, 2> extents;
for (int64_t d = 0; d < 2; ++d)
extents.push_back(tensorType.isDynamicDim(d) ? mg::dyn::extentOf(rewriter, loc, *lhsBuffer, d) : Value());
@@ -251,6 +255,10 @@
namespace mg {
void registerBufferizableOpInterfaceExternalModels(DialectRegistry ®istry) {
registry.addExtension(+[](MLIRContext *ctx, MgDialect *dialect) {
+ // AddOpInterface::bufferize creates `cf.assert`; dialects load lazily,
+ // so without this the first bufferization of a dynamic mg.add aborts with
+ // "Building op `cf.assert` but it isn't known in this MLIRContext".
+ ctx->loadDialect<cf::ControlFlowDialect>();
ConstantOp::attachInterface<ConstantOpInterface>(*ctx);
AddOp::attachInterface<AddOpInterface>(*ctx);
TransposeOp::attachInterface<TransposeOpInterface>(*ctx);
--- ../../part7/code/mg-opt.cpp 2026-10-01 21:21:01.994964588 +0000
+++ mg-opt.cpp 2026-10-01 23:01:57.030167769 +0000
@@ -5,6 +5,7 @@
#include "mlir/Dialect/Affine/Passes.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Bufferization/IR/Bufferization.h"
+#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h"
#include "mlir/Dialect/Bufferization/Transforms/FuncBufferizableOpInterfaceImpl.h"
#include "mlir/Dialect/Bufferization/Transforms/Passes.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
@@ -33,7 +34,7 @@
registry.insert<mlir::func::FuncDialect, mlir::affine::AffineDialect,
mlir::memref::MemRefDialect, mlir::arith::ArithDialect,
mlir::bufferization::BufferizationDialect,
- mlir::LLVM::LLVMDialect>();
+ mlir::cf::ControlFlowDialect, mlir::LLVM::LLVMDialect>();
registry.insert<mg::MgDialect>();
mg::registerBufferizableOpInterfaceExternalModels(registry);
mlir::bufferization::func_ext::registerBufferizableOpInterfaceExternalModels(
--- ../../part7/code/CMakeLists.txt 2026-10-01 21:21:01.994934536 +0000
+++ CMakeLists.txt 2026-10-01 23:01:57.032008443 +0000
@@ -31,6 +31,7 @@
add_dependencies(MgLowerToAffine MgOpsIncGen)
target_link_libraries(MgLowerToAffine PUBLIC
MgDialect
+ MLIRControlFlowDialect
MLIRIR
MLIRPass
MLIRTransforms
@@ -45,6 +46,7 @@
add_dependencies(MgBufferizableOpInterfaceImpl MgOpsIncGen)
target_link_libraries(MgBufferizableOpInterfaceImpl PUBLIC
MgDialect
+ MLIRControlFlowDialect
MLIRIR
MLIRBufferizationDialect
MLIRAffineDialect
@@ -57,6 +59,7 @@
add_executable(mg-opt tools/mg-opt.cpp)
target_link_libraries(mg-opt PRIVATE
MgDialect
+ MLIRControlFlowDialect
MgLowerToAffine
MgBufferizableOpInterfaceImpl
MLIRIR
Two real problems on the way¶
1. Path B crashed on first use¶
The Path A version worked immediately. The same check from One-Shot Bufferize (Chapter 6's path) aborted mg-opt. This is reproduced from source: the chapter's tree was copied, only the line that loads the cf dialect was removed, and it was rebuilt and run on Chapter 13's dyn_add.mlir (crash_without_loadDialect.txt):
LLVM ERROR: Building op `cf.assert` but it isn't known in this MLIRContext: the dialect may not be loaded or this operation hasn't been added by the dialect. See also https://mlir.llvm.org/getting_started/Faq/#registered-loaded-dependent-whats-up-with-dialects-management
PLEASE submit a bug report to https://github.com/llvm/llvm-project/issues/ and include the crash backtrace.
Stack dump:
Adding cf to the mg-opt registry (needed anyway) made the dialect known but not loaded: MLIR loads dialects on demand, and nothing in that bufferization run had asked for cf. This chapter had assumed all registered dialects were already loaded; that assumption was wrong. Why affine, arith and memref never hit the same error in Chapters 5 and 6 was not investigated (a plausible but unchecked explanation is that other passes in those runs declared them as dependent dialects). The fix is one line in the external-model registration, next to the attachInterface calls:
namespace mg {
void registerBufferizableOpInterfaceExternalModels(DialectRegistry ®istry) {
registry.addExtension(+[](MLIRContext *ctx, MgDialect *dialect) {
// AddOpInterface::bufferize creates `cf.assert`; dialects load lazily,
// so without this the first bufferization of a dynamic mg.add aborts with
// "Building op `cf.assert` but it isn't known in this MLIRContext".
ctx->loadDialect<cf::ControlFlowDialect>();
ConstantOp::attachInterface<ConstantOpInterface>(*ctx);
AddOp::attachInterface<AddOpInterface>(*ctx);
TransposeOp::attachInterface<TransposeOpInterface>(*ctx);
PrintOp::attachInterface<PrintOpInterface>(*ctx);
});
}
} // namespace mg
Path A instead declares cf as a dependent and legal dialect in its own pass. The practical rule: any op a bufferization model creates should have its dialect loaded by that model's registration, rather than relying on something else having loaded it.
2. The abort works, but its message can vanish¶
Run with output on a terminal (or line-buffered) the program prints the message; run with stdout as a pipe or file it printed nothing before dying (test 9 below shows both). The lowered code in the probe above is the explanation: call @puts(...) then call @abort(). puts writes to stdout, which the C library fully buffers when it is not a terminal, and abort() does not flush stdio buffers. This is a property of how MLIR's cf.assert is lowered, observed here, not something this book chose. The safety property is intact either way (the program still dies, exit code 134, SIGABRT, before touching memory out of bounds); what is lost is the explanation, in exactly the situations (CI logs, captured output) where it would be most useful. A fix (lowering to a fputs on stderr, or flushing first) would be a custom conversion and is not done here. (Update, added after Chapter 19: done there, with a write(2, ...) pass that delivers the message under every capture mode tried.)
Building and testing it¶
The two scripts. build.sh assembles the compiler from the earlier chapters' files plus this chapter's overrides:
#!/bin/sh
# Rebuild mg-opt: earlier chapters' latest files, with this chapter's overrides. 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/part3/code/MgOps.td $D/part3/code/MgDialect.h $D/part3/code/MgOps.h $T/include/mg/
cp $HERE/DynamicShapes.h $T/include/mg/
cp $D/part13/code/MgDialect.cpp $T/lib/
cp $HERE/LowerToAffine.cpp $HERE/MgBufferizableOpInterfaceImpl.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
run_tests.sh runs eleven checks, reusing Chapter 13's inputs unchanged (plus one new harness for the mixed case):
#!/bin/sh
# Chapter 14's checks. Run ./build.sh first. Inputs are Chapter 13's, reused unchanged.
HERE=$(cd "$(dirname "$0")" && pwd); M=$HERE/build/mg-opt; P13=$HERE/../../part13/code; D=$HERE/../..; W=$HERE/work; mkdir -p $W; cd $W
LOW='--lower-affine --convert-scf-to-cf --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts'
echo "### 1. static output still byte-identical to Chapter 10's:"
$M $D/part10/code/add_tensors.mlir --convert-mg-to-affine | cmp - $D/part10/code/add_affine.mlir && echo IDENTICAL
echo "### 2. the emitted check, dynamic add (path A):"
$M $P13/dyn_add.mlir --convert-mg-to-affine | grep -E "cmpi|cf.assert"
echo "### 3. mixed ?x2 + 3x2: only the dimension that is dynamic is checked:"
$M $P13/mixed_add.mlir --convert-mg-to-affine | grep -E "cf.assert"
echo "### 4. same check from path B (One-Shot Bufferize):"
$M $P13/dyn_add.mlir --one-shot-bufferize="bufferize-function-boundaries" | grep -E "cf.assert"
$M $P13/both.mlir --convert-mg-to-affine $LOW -o a_llvm.mlir
$M $P13/both.mlir --one-shot-bufferize="bufferize-function-boundaries" -o b_buf.mlir; $M b_buf.mlir $LOW -o b_llvm.mlir
$M $P13/mixed_add.mlir --convert-mg-to-affine $LOW -o m_llvm.mlir
for p in a b m; do mlir-translate-18 --mlir-to-llvmir ${p}_llvm.mlir -o $p.ll; clang-18 -c $p.ll -o $p.o 2>/dev/null; done
clang-18 $P13/harness_dyn.c a.o -o run_a; echo "### 5. valid shapes, path A (compared automatically with Chapter 13's recorded output):"; ./run_a > outA.txt
sed -n '/^### 6\./,/^### 7\./p' $P13/run_out.txt | sed '1d;$d' | cmp - outA.txt && echo "MATCHES Chapter 13 (path A)"
clang-18 -DSTRIDED $P13/harness_dyn.c b.o -o run_b; echo "### 6. valid shapes, path B + column-major view (compared automatically with Chapter 13's recorded output):"; ./run_b > outB.txt
sed -n '/^### 7\./,/^### 8\./p' $P13/run_out.txt | sed '1d;$d' | cmp - outB.txt && echo "MATCHES Chapter 13 (path B)"
clang-18 $P13/harness_mismatch.c a.o -o mism_a; clang-18 $P13/harness_mismatch.c b.o -o mism_b
echo "### 7. runtime mismatch (a=2x3, b=1x2), path A, stdout line-buffered so the message survives:"
stdbuf -oL ./mism_a 2>&1; echo "exit code $?"
echo "### 8. same, path B:"; stdbuf -oL ./mism_b 2>&1; echo "exit code $?"
echo "### 9. same, path A, stdout is a pipe (default full buffering): is the message printed?"
./mism_a 2>&1 | cat; echo "(pipeline end; abort exit code of the program itself: $(./mism_a >/dev/null 2>&1; echo $?))"
clang-18 $HERE/harness_mixed.c m.o -o mixed_run
echo "### 10. mixed ?x2 + 3x2, matching (a has 3 rows):"; ./mixed_run 3
echo "### 11. mixed ?x2 + 3x2, mismatched (a has 2 rows):"; stdbuf -oL ./mixed_run 2 2>&1; echo "exit code $?"
The one new harness, which takes the dynamic operand's row count as an argument so the same binary can be run with matching and mismatched sizes:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct { double *allocated; double *aligned; int64_t offset; int64_t sizes[2]; int64_t strides[2]; } Memref2D;
extern Memref2D mixed(double*,double*,int64_t,int64_t,int64_t,int64_t,int64_t,double*,double*,int64_t,int64_t,int64_t,int64_t,int64_t);
int main(int argc, char **argv) {
int rows = argc > 1 ? atoi(argv[1]) : 3; // rows of the dynamic operand; the static one always has 3
double a[16] = {1,2,3,4,5,6,7,8}, b[6] = {10,20,30,40,50,60};
Memref2D r = mixed(a,a,0,rows,2,2,1, b,b,0,3,2,2,1);
printf("mixed with a=%dx2, b=3x2 -> %ldx%ld:", rows, (long)r.sizes[0], (long)r.sizes[1]);
for (int i = 0; i < r.sizes[0]*r.sizes[1]; i++) printf(" %g", r.aligned[i]);
printf("\n"); return 0;
}
Real output, check by check¶
The complete, unedited output (run_out.txt):
### 1. static output still byte-identical to Chapter 10's:
IDENTICAL
### 2. the emitted check, dynamic add (path A):
%0 = arith.cmpi eq, %dim, %dim_1 : index
cf.assert %0, "mg.add: operand shapes differ at runtime in dimension 0"
%1 = arith.cmpi eq, %dim_2, %dim_4 : index
cf.assert %1, "mg.add: operand shapes differ at runtime in dimension 1"
### 3. mixed ?x2 + 3x2: only the dimension that is dynamic is checked:
cf.assert %0, "mg.add: operand shapes differ at runtime in dimension 0"
### 4. same check from path B (One-Shot Bufferize):
cf.assert %0, "mg.add: operand shapes differ at runtime in dimension 0"
cf.assert %1, "mg.add: operand shapes differ at runtime in dimension 1"
### 5. valid shapes, path A (compared automatically with Chapter 13's recorded output):
MATCHES Chapter 13 (path A)
### 6. valid shapes, path B + column-major view (compared automatically with Chapter 13's recorded output):
MATCHES Chapter 13 (path B)
### 7. runtime mismatch (a=2x3, b=1x2), path A, stdout line-buffered so the message survives:
mg.add: operand shapes differ at runtime in dimension 0
Aborted
exit code 134
### 8. same, path B:
mg.add: operand shapes differ at runtime in dimension 0
Aborted
exit code 134
### 9. same, path A, stdout is a pipe (default full buffering): is the message printed?
Aborted
(pipeline end; abort exit code of the program itself: 134)
### 10. mixed ?x2 + 3x2, matching (a has 3 rows):
mixed with a=3x2, b=3x2 -> 3x2: 11 22 33 44 55 66
### 11. mixed ?x2 + 3x2, mismatched (a has 2 rows):
mg.add: operand shapes differ at runtime in dimension 0
Aborted
exit code 134
What each numbered section establishes:
| # | Check | Result |
|---|---|---|
| 1 | static output vs Chapter 10's stored file | IDENTICAL: the check costs static programs nothing |
| 2 | dynamic add, Path A | one cmpi + cf.assert per dimension, as shown above |
| 3 | mixed ?x2 + 3x2 |
exactly one assert, for dimension 0 |
| 4 | Path B (One-Shot Bufferize) | the same two asserts from the same helper |
| 5, 6 | valid shapes, Paths A and B (including the column-major view) | compared automatically (cmp) with Chapter 13's recorded output: MATCHES Chapter 13 |
| 7, 8 | mismatch (2x3 + 1x2), Paths A and B | message, Aborted, exit code 134; no garbage result printed |
| 9 | same, stdout is a pipe | no message, still exit code 134 (the buffering problem above) |
| 10, 11 | mixed ?x2 + 3x2, matching then mismatched |
11 22 33 44 55 66, then abort with exit code 134 |
In every abort case the harness never reached its printf of the result: the check fires before the loop, so no out-of-bounds load happens. This does not show the checks are free, only that this chapter did not measure their cost. By construction each check is two memref.dim reads, one comparison and one branch per dynamic dimension per mg.add call, outside the loop; whether LLVM's optimizer folds or hoists them further was not examined.
What is still not safe¶
- The check trusts the descriptors. It compares the sizes the caller declared. A caller that passes a descriptor claiming 2x3 over a buffer holding two doubles is lying about memory, and no compiled check can detect that. The guarantee is "operand sizes agree", not "buffers are as large as their descriptors say".
- Abort is the only response. There is no error return or recoverable failure; a library embedding this compiled code gets a process death.
- Only
mg.addis checked, the one op with two shaped operands. Allocation failure (memref.allocreturning null for a huge size), integer overflow inrows * cols, and negative sizes in a descriptor are not examined. - The GPU path has no check. Chapters 10 through 12's kernels were static-shape; dynamic-shape GPU lowering was never attempted, so nothing here applies there.
- Chapter 7's loop transforms on dynamic bounds are still untested (Update, added after Chapter 17: tested there, and the assert survived tiling and unrolling on that program), as are any new passes' interactions with the assert (for example, whether
--canonicalizeor fusion preserves it across a transformation). - This chapter's own scripts still print results for a human to read. (Update, added after Chapter 15: the assertion-based suite now exists, and includes a test that mismatches only the columns, which this chapter's own scripts never exercised.)
Reproducing this chapter¶
cd docs/part14/code
./build.sh # earlier chapters' files + this chapter's overrides -> ./build/mg-opt
./run_tests.sh # eleven checks; output matches run_out.txt
Both were run from an empty state (build, tree and work directories deleted first) and run_out.txt is their unedited output. Every file and output above is embedded from the repository when the site is built, so this page cannot drift from the code it describes. As in Chapters 10 through 13, no documentation was consulted: every claim comes from running the code or reading it.
Chapter summary¶
This chapter closed Chapter 13's out-of-bounds read for mg.add: before each dynamic add loop, the lowering reads both operands' sizes with memref.dim, compares them with arith.cmpi, and asserts with cf.assert, from one helper shared by both lowering paths. Dimensions static on both sides are skipped, so static output stays byte-identical to Chapter 10's, and a mixed ?x2 plus 3x2 add gets exactly one check. The book's existing five-pass lowering already turns cf.assert into puts plus abort, so no new pass was needed. Valid shapes matched Chapter 13's recorded output exactly on both paths, and every mismatch case aborted with exit status 134 before any out-of-bounds load. Two real problems are recorded: the bufferization path crashed because the cf dialect was registered but never loaded, and the assert's message is lost when stdout is a pipe.
Deliberately out of scope, stated explicitly: the check trusts the caller's descriptors, abort is the only response, only mg.add is guarded, the GPU path and loop transforms on dynamic bounds were not examined, and the runtime cost was not measured.
Self-check questions¶
1. Why can no compile-time check catch the 2x3-plus-1x2 call?
Worked answer: the verifier only sees types, and tensor<?x?xf64> plus tensor<?x?xf64> is compatible as types. Whether the two sizes agree is a property of the values that arrive at runtime, so the only place left to enforce it is the compiled code, which is why the lowering emits a comparison and an assertion.
2. Why does the helper skip a dimension that is static on both sides?
Worked answer: the verifier already rejected any static mismatch, so a check there could never fire and would be dead code. Skipping it is also what keeps static programs untouched: the static add's lowering has no cf.assert and no memref.dim, and the --convert-mg-to-affine output for it is byte-identical to the file stored in Chapter 10.
3. The bufferization path crashed on first use while the hand-written path worked. What was the cause, and what is the rule?
Worked answer: One-Shot Bufferize's model created a cf.assert, but the cf dialect, though registered with mg-opt, had not been loaded into the context, and MLIR loads dialects on demand; the build aborted with "Building op cf.assert but it isn't known in this MLIRContext". The fix is one ctx->loadDialect<cf::ControlFlowDialect>() in the external-model registration. The rule: any op a bufferization model creates should have its dialect loaded by that model's registration instead of relying on something else having loaded it. Why affine, arith and memref never hit this was not investigated.
4. Run through a pipe, the aborting program printed no message. Why, and does that weaken the safety property?
Worked answer: the lowered code calls puts (stdout) then abort(). When stdout is not a terminal it is fully buffered, and abort() does not flush stdio buffers, so the message is discarded. The safety property is untouched, since the program still dies with exit status 134 (SIGABRT) before any out-of-bounds access; what is lost is the explanation, in exactly the cases (CI logs, captured output) where it is most needed. Under a terminal or line-buffered output the message appears.
5. What does the check still not guarantee?
Worked answer: it compares the sizes the caller declared in the descriptors. A caller whose descriptor claims 2x3 over a buffer of two doubles is lying about memory, and no compiled comparison can detect that. The guarantee is that the operand sizes agree, not that each buffer is as large as its descriptor claims; the response to a mismatch is process death, with no recoverable error.