13. Dynamic Shapes: Closing Chapter 12's Biggest Unknown¶
What you will understand: what actually happens when Mountain Goat meets a tensor whose size is not known until runtime (tensor<?x?xf64>), what broke, how it was fixed on both of this book's lowering paths, and what is still not safe. Chapter 12 listed "dynamic shapes are untested" as the largest unknown about this compiler; this chapter turns that unknown into measured facts. The page shows the old code that carried the hazard, the real error output of the unmodified compiler, the new helper and the complete diff, and the real output of every test, so nothing has to be taken on trust.
What you need to know first: Chapter 3's verifiers and canonicalizer, Chapter 4/5's hand-written --convert-mg-to-affine, Chapter 6's bufferization models, and Chapter 8's calling convention. New ground: ? dimensions, memref.dim, and dynamic memref.alloc.
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.
Why this page shows FAIL lines, and why that is good
Parts of this page come from negative controls: the same tests run against an older build, or against deliberately broken code (a "mutation"). In those runs a FAIL is the expected, wanted result: it means a test noticed the problem, which is how we know the tests are worth anything. What would be wrong is the opposite, a deliberately broken build that passes everything. The baseline (the unmodified current build) must always show every test passing, and it does. Each script that does this says so in its own header and prints a reminder at the top of its output.
Background: static and dynamic shapes¶
Every example in Chapters 1 through 12 uses shapes written into the program: tensor<2x2xf64> means exactly four doubles, and the compiler knows that while it compiles. A dynamic extent is written ?: tensor<?x?xf64> is a rank-2 tensor whose two sizes are only known when the program runs, so one compiled function can serve a 2x3 input and a 1x6 input. Three consequences drive everything in this chapter:
- A
?is not a number. Code that readsshape[0]from the type gets-1(MLIR's sentinel for "dynamic"), not a size. To loop over a dynamic dimension the compiler must emit code that reads the size at runtime: thememref.dimoperation. - Allocation needs the sizes too.
memref.alloc()of amemref<?x?xf64>must be given one size operand per?; a barememref.alloc()is invalid. - Two
?s are not known to be equal.tensor<?x2xf64>andtensor<3x2xf64>can agree at runtime (when the?is 3), so a verifier that rejects them is too strict, and a verifier that accepts?x?against?x?cannot know whether they agree at all (Chapter 14's subject).
Separately, MLIR distinguishes ranked tensors (the number of dimensions is known, even if their sizes are not) from unranked ones (tensor<*xf64>, not even the rank is known). mg only supports ranked ones; the question is how loudly it says so.
Step 1: predict, then test¶
Before running anything, Chapter 5's own lowering code was read. The mg.add pattern, which both Chapter 4's hand-written pass and (in a different form) Chapter 6's bufferization carry:
struct AddOpLowering : public OpConversionPattern<mg::AddOp> {
using OpConversionPattern::OpConversionPattern;
LogicalResult
matchAndRewrite(mg::AddOp op, OpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
auto loc = op.getLoc();
auto tensorType = llvm::cast<RankedTensorType>(op.getResult().getType());
auto memrefType = convertTensorToMemref(tensorType);
auto alloc = rewriter.create<memref::AllocOp>(loc, memrefType);
auto shape = tensorType.getShape();
Value lhs = adaptor.getLhs();
Value rhs = adaptor.getRhs();
affine::buildAffineLoopNest(
rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
[&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
Value lhsVal =
nestedBuilder.create<affine::AffineLoadOp>(nestedLoc, lhs, ivs);
Value rhsVal =
nestedBuilder.create<affine::AffineLoadOp>(nestedLoc, rhs, ivs);
Value sum = nestedBuilder.create<arith::AddFOp>(nestedLoc, lhsVal, rhsVal);
nestedBuilder.create<affine::AffineStoreOp>(nestedLoc, sum, alloc, ivs);
});
rewriter.replaceOp(op, alloc.getResult());
return success();
}
};
The hazard is on lines 84 to 91: tensorType.getShape() returns -1 for a ?, that value goes straight into buildAffineLoopNest as a loop bound, and memref.alloc is given no size operands. The verifiers were also read (Chapter 3's originals):
mlir::LogicalResult TransposeOp::verify() {
auto inputType = llvm::cast<mlir::RankedTensorType>(getInput().getType());
auto resultType = llvm::cast<mlir::RankedTensorType>(getResult().getType());
if (inputType.getRank() != 2 || resultType.getRank() != 2)
return emitOpError("mg.transpose only supports rank-2 tensors");
auto inShape = inputType.getShape();
auto outShape = resultType.getShape();
if (inShape[0] != outShape[1] || inShape[1] != outShape[0])
return emitOpError("mg.transpose result shape must be the input shape reversed");
return mlir::success();
}
mlir::LogicalResult AddOp::verify() {
auto lhsType = llvm::cast<mlir::RankedTensorType>(getLhs().getType());
auto rhsType = llvm::cast<mlir::RankedTensorType>(getRhs().getType());
if (lhsType.getShape() != rhsType.getShape())
return emitOpError("mg.add operands must have the same shape, got ")
<< lhsType << " and " << rhsType;
return mlir::success();
}
The prediction: the verifiers compare shapes with !=, which treats -1 == -1 as equal, so they accept ?x? against ?x?; the lowerings fail. The first real run used an unmodified mg-opt, the Chapter 7 build, rebuilt from Chapters 3 through 7's own files. before.sh runs the dynamic inputs through it and saves each output:
#!/bin/sh
# Real outputs of the UNMODIFIED compiler (the Chapter 7 build, before this chapter's fixes) on dynamic-shape inputs.
# Needs ../../part15/code/older_builds.sh to have been run once (it builds the Chapter 7 mg-opt this uses).
# READ THIS FIRST: this script shows how the UNMODIFIED, earlier compiler behaves on this chapter's programs.
# FAIL lines (and non-zero exit statuses) in this script's output are the EXPECTED result: the earlier compiler cannot handle dynamic shapes, so errors and failures here are the "before" picture and are expected.
# 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); OLD=$HERE/../../part15/code/work/ch7/build/mg-opt; O=$HERE/before; mkdir -p $O; cd $HERE
LOW='--lower-affine --convert-scf-to-cf --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts'
$OLD dyn_add.mlir > $O/dyn_add_verify.txt 2>&1
$OLD dyn_transpose.mlir > $O/dyn_transpose_verify.txt 2>&1
$OLD mixed_add.mlir > $O/mixed_add_verify.txt 2>&1
$OLD dyn_add.mlir --convert-mg-to-affine > $O/dyn_add_convert.txt 2>&1
$OLD dyn_add.mlir --one-shot-bufferize="bufferize-function-boundaries" > $O/dyn_add_bufferize.txt 2>&1
$OLD unranked.mlir > $O/unranked_verify.txt 2>&1
$OLD tt.mlir --canonicalize > $O/tt_on_chapter7_build.txt 2>&1
# The canonicalizer bug only shows once the verifier accepts the program, i.e. with the relaxed verifiers but WITHOUT
# the exact-type guard. That state is Chapter 15's mutation 1 (needs ../../part15/code/mutation.sh to have been run).
MUT1=$HERE/../../part15/code/work/mut-1/build/mg-opt
[ -x $MUT1 ] && $MUT1 tt.mlir --canonicalize > $O/tt_without_guard.txt 2>&1
exit 0 # the compiler errors above are the point; do not propagate them
The inputs (dyn_add.mlir, dyn_transpose.mlir, mixed_add.mlir):
func.func @add_dyn(%a: tensor<?x?xf64>, %b: tensor<?x?xf64>) -> tensor<?x?xf64> {
%0 = mg.add %a, %b : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
func.return %0 : tensor<?x?xf64>
}
func.func @mixed(%a: tensor<?x2xf64>, %b: tensor<3x2xf64>) -> tensor<?x2xf64> {
%0 = mg.add %a, %b : tensor<?x2xf64>, tensor<3x2xf64> -> tensor<?x2xf64>
func.return %0 : tensor<?x2xf64>
}
What the unmodified compiler did¶
Parsing and verifying the dynamic add: accepted, as predicted.
module {
func.func @add_dyn(%arg0: tensor<?x?xf64>, %arg1: tensor<?x?xf64>) -> tensor<?x?xf64> {
%0 = mg.add %arg0, %arg1 : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
return %0 : tensor<?x?xf64>
}
}
Mixed ?x2 plus 3x2: rejected, which the prediction had missed. The plain != treats ? and 3 as different, though they can be equal:
mixed_add.mlir:2:8: error: 'mg.add' op mg.add operands must have the same shape, got 'tensor<?x2xf64>' and 'tensor<3x2xf64>'
%0 = mg.add %a, %b : tensor<?x2xf64>, tensor<3x2xf64> -> tensor<?x2xf64>
^
mixed_add.mlir:2:8: note: see current operation: %0 = "mg.add"(%arg0, %arg1) : (tensor<?x2xf64>, tensor<3x2xf64>) -> tensor<?x2xf64>
Lowering the dynamic add with Chapter 4/5's pass fails inside the generated IR, as predicted:
dyn_add.mlir:2:8: error: 'memref.alloc' op dimension operand count does not equal memref dynamic dimension count
%0 = mg.add %a, %b : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
^
dyn_add.mlir:2:8: note: see current operation: %0 = "memref.alloc"() <{operandSegmentSizes = array<i32: 0, 0>}> : () -> memref<?x?xf64>
and Chapter 6's One-Shot Bufferize path fails identically:
dyn_add.mlir:2:8: error: 'memref.alloc' op dimension operand count does not equal memref dynamic dimension count
%0 = mg.add %a, %b : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
^
dyn_add.mlir:2:8: note: see current operation: %0 = "memref.alloc"() <{operandSegmentSizes = array<i32: 0, 0>}> : () -> memref<?x?xf64>
| Input | Result |
|---|---|
| dynamic add / transpose, parse + verify | accepted |
?x2 + 3x2 |
rejected (verifier too strict) |
--convert-mg-to-affine on dynamic add |
error: 'memref.alloc' op dimension operand count does not equal memref dynamic dimension count |
--one-shot-bufferize (Chapter 6) |
the identical memref.alloc error |
So the prediction held for the lowerings, and the verifier was wrong in a way it missed. The lowering failures are loud, which is the good kind: the verifier on memref.alloc caught what would otherwise have been loops with a -1 upper bound.
Step 2: the fix, in one shared header¶
All three lowering sites (Chapter 5's AddOpLowering and TransposeOpLowering, Chapter 6's two bufferization models) had the same shape of code, so the fix lives in one new header. In full (DynamicShapes.h):
#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/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);
}
/// 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
What each helper does:
compatibleDim(a, b): two extents are compatible if equal, or if either is?.extentOf(...): a realmemref.dim, reading a buffer's runtime size along one dimension.allocFor(...):memref.allocwith a size operand for each dynamic dimension of the result.nest2D(...): the loop nest. A fully static shape takes exactly the old constant-bound call, so every earlier chapter's output is unchanged; a shape with a?takes the operand-bound path, reading each bound from the extents.
For mg.add the result's dynamic extents come from the left operand; for mg.transpose the result's extent along dimension d is the input's extent along 1 − d. The verifiers now use compatibleDim (and check rank first):
mlir::LogicalResult TransposeOp::verify() {
auto inputType = llvm::dyn_cast<mlir::RankedTensorType>(getInput().getType());
auto resultType = llvm::dyn_cast<mlir::RankedTensorType>(getResult().getType());
if (!inputType || !resultType)
return emitOpError("mg.transpose only supports ranked tensors");
if (inputType.getRank() != 2 || resultType.getRank() != 2)
return emitOpError("mg.transpose only supports rank-2 tensors");
auto inShape = inputType.getShape();
auto outShape = resultType.getShape();
if (!mg::dyn::compatibleDim(inShape[0], outShape[1]) ||
!mg::dyn::compatibleDim(inShape[1], outShape[0]))
return emitOpError("mg.transpose result shape must be the input shape reversed");
return mlir::success();
}
mlir::LogicalResult AddOp::verify() {
auto lhsType = llvm::dyn_cast<mlir::RankedTensorType>(getLhs().getType());
auto rhsType = llvm::dyn_cast<mlir::RankedTensorType>(getRhs().getType());
auto resType = llvm::dyn_cast<mlir::RankedTensorType>(getResult().getType());
if (!lhsType || !rhsType || !resType)
return emitOpError("mg.add only supports ranked tensors");
if (lhsType.getRank() != rhsType.getRank() || lhsType.getRank() != resType.getRank())
return emitOpError("mg.add operands and result must have the same rank");
for (int64_t d = 0; d < lhsType.getRank(); ++d)
if (!mg::dyn::compatibleDim(lhsType.getDimSize(d), rhsType.getDimSize(d)) ||
!mg::dyn::compatibleDim(lhsType.getDimSize(d), resType.getDimSize(d)) ||
!mg::dyn::compatibleDim(rhsType.getDimSize(d), resType.getDimSize(d)))
return emitOpError("mg.add operands must have compatible shapes, got ")
<< lhsType << " and " << rhsType << " -> " << resType;
return mlir::success();
}
The complete change against Chapters 3, 5 and 6, all 160 lines, so nothing is hidden:
--- ../../part3/code/MgDialect.cpp 2026-10-01 21:21:01.991785523 +0000
+++ MgDialect.cpp 2026-10-01 22:53:13.160361103 +0000
@@ -1,5 +1,6 @@
#include "mg/MgDialect.h"
#include "mg/MgOps.h"
+#include "mg/DynamicShapes.h"
#include "mlir/IR/Builders.h"
#include "mlir/IR/Matchers.h"
@@ -43,23 +44,34 @@
mlir::OpFoldResult ConstantOp::fold(FoldAdaptor adaptor) { return getValueAttr(); }
mlir::LogicalResult TransposeOp::verify() {
- auto inputType = llvm::cast<mlir::RankedTensorType>(getInput().getType());
- auto resultType = llvm::cast<mlir::RankedTensorType>(getResult().getType());
+ auto inputType = llvm::dyn_cast<mlir::RankedTensorType>(getInput().getType());
+ auto resultType = llvm::dyn_cast<mlir::RankedTensorType>(getResult().getType());
+ if (!inputType || !resultType)
+ return emitOpError("mg.transpose only supports ranked tensors");
if (inputType.getRank() != 2 || resultType.getRank() != 2)
return emitOpError("mg.transpose only supports rank-2 tensors");
auto inShape = inputType.getShape();
auto outShape = resultType.getShape();
- if (inShape[0] != outShape[1] || inShape[1] != outShape[0])
+ if (!mg::dyn::compatibleDim(inShape[0], outShape[1]) ||
+ !mg::dyn::compatibleDim(inShape[1], outShape[0]))
return emitOpError("mg.transpose result shape must be the input shape reversed");
return mlir::success();
}
mlir::LogicalResult AddOp::verify() {
- auto lhsType = llvm::cast<mlir::RankedTensorType>(getLhs().getType());
- auto rhsType = llvm::cast<mlir::RankedTensorType>(getRhs().getType());
- if (lhsType.getShape() != rhsType.getShape())
- return emitOpError("mg.add operands must have the same shape, got ")
- << lhsType << " and " << rhsType;
+ auto lhsType = llvm::dyn_cast<mlir::RankedTensorType>(getLhs().getType());
+ auto rhsType = llvm::dyn_cast<mlir::RankedTensorType>(getRhs().getType());
+ auto resType = llvm::dyn_cast<mlir::RankedTensorType>(getResult().getType());
+ if (!lhsType || !rhsType || !resType)
+ return emitOpError("mg.add only supports ranked tensors");
+ if (lhsType.getRank() != rhsType.getRank() || lhsType.getRank() != resType.getRank())
+ return emitOpError("mg.add operands and result must have the same rank");
+ for (int64_t d = 0; d < lhsType.getRank(); ++d)
+ if (!mg::dyn::compatibleDim(lhsType.getDimSize(d), rhsType.getDimSize(d)) ||
+ !mg::dyn::compatibleDim(lhsType.getDimSize(d), resType.getDimSize(d)) ||
+ !mg::dyn::compatibleDim(rhsType.getDimSize(d), resType.getDimSize(d)))
+ return emitOpError("mg.add operands must have compatible shapes, got ")
+ << lhsType << " and " << rhsType << " -> " << resType;
return mlir::success();
}
@@ -92,6 +104,11 @@
auto innerTranspose = op.getInput().getDefiningOp<TransposeOp>();
if (!innerTranspose)
return mlir::failure();
+ // With dynamic shapes the inner input's type can differ from this op's
+ // result type (`tensor<?x2xf64>` vs `tensor<?x?xf64>`); replacing one
+ // with the other would produce invalid IR, so only fire on an exact match.
+ if (innerTranspose.getInput().getType() != op.getType())
+ return mlir::failure();
rewriter.replaceOp(op, innerTranspose.getInput());
return mlir::success();
}
--- ../../part5/code/LowerToAffine.cpp 2026-10-01 21:21:01.993681169 +0000
+++ LowerToAffine.cpp 2026-10-01 22:53:13.160390133 +0000
@@ -1,5 +1,6 @@
#include "mg/MgDialect.h"
#include "mg/MgOps.h"
+#include "mg/DynamicShapes.h"
#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
@@ -81,14 +82,16 @@
auto loc = op.getLoc();
auto tensorType = llvm::cast<RankedTensorType>(op.getResult().getType());
auto memrefType = convertTensorToMemref(tensorType);
- auto alloc = rewriter.create<memref::AllocOp>(loc, memrefType);
- auto shape = tensorType.getShape();
Value lhs = adaptor.getLhs();
Value rhs = adaptor.getRhs();
+ 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());
+ auto alloc = mg::dyn::allocFor(rewriter, loc, memrefType, extents);
- affine::buildAffineLoopNest(
- rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
+ mg::dyn::nest2D(
+ rewriter, loc, tensorType, extents,
[&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
Value lhsVal =
nestedBuilder.create<affine::AffineLoadOp>(nestedLoc, lhs, ivs);
@@ -115,13 +118,16 @@
auto loc = op.getLoc();
auto tensorType = llvm::cast<RankedTensorType>(op.getResult().getType());
auto memrefType = convertTensorToMemref(tensorType);
- auto alloc = rewriter.create<memref::AllocOp>(loc, memrefType);
- auto shape = tensorType.getShape();
Value input = adaptor.getInput();
+ // The result's extent along d is the input's extent along 1 - d.
+ llvm::SmallVector<Value, 2> extents;
+ for (int64_t d = 0; d < 2; ++d)
+ extents.push_back(tensorType.isDynamicDim(d) ? mg::dyn::extentOf(rewriter, loc, input, 1 - d) : Value());
+ auto alloc = mg::dyn::allocFor(rewriter, loc, memrefType, extents);
- affine::buildAffineLoopNest(
- rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
+ mg::dyn::nest2D(
+ rewriter, loc, tensorType, extents,
[&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
Value i = ivs[0];
Value j = ivs[1];
--- ../../part6/code/MgBufferizableOpInterfaceImpl.cpp 2026-10-01 21:21:01.994519969 +0000
+++ MgBufferizableOpInterfaceImpl.cpp 2026-10-01 22:53:13.160415833 +0000
@@ -1,5 +1,6 @@
#include "mg/MgDialect.h"
#include "mg/MgOps.h"
+#include "mg/DynamicShapes.h"
#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
@@ -122,11 +123,13 @@
auto tensorType = cast<RankedTensorType>(addOp.getResult().getType());
auto memrefType =
MemRefType::get(tensorType.getShape(), tensorType.getElementType());
- auto alloc = rewriter.create<memref::AllocOp>(loc, memrefType);
- auto shape = tensorType.getShape();
+ 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());
+ auto alloc = mg::dyn::allocFor(rewriter, loc, memrefType, extents);
- affine::buildAffineLoopNest(
- rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
+ mg::dyn::nest2D(
+ rewriter, loc, tensorType, extents,
[&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
Value lhsVal = nestedBuilder.create<affine::AffineLoadOp>(
nestedLoc, *lhsBuffer, ivs);
@@ -179,11 +182,13 @@
cast<RankedTensorType>(transposeOp.getResult().getType());
auto memrefType =
MemRefType::get(tensorType.getShape(), tensorType.getElementType());
- auto alloc = rewriter.create<memref::AllocOp>(loc, memrefType);
- auto shape = tensorType.getShape();
+ llvm::SmallVector<Value, 2> extents;
+ for (int64_t d = 0; d < 2; ++d)
+ extents.push_back(tensorType.isDynamicDim(d) ? mg::dyn::extentOf(rewriter, loc, *inputBuffer, 1 - d) : Value());
+ auto alloc = mg::dyn::allocFor(rewriter, loc, memrefType, extents);
- affine::buildAffineLoopNest(
- rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
+ mg::dyn::nest2D(
+ rewriter, loc, tensorType, extents,
[&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
Value i = ivs[0];
Value j = ivs[1];
The result for a dynamic add (the real output, from the test run below):
#map = affine_map<(d0) -> (d0)>
module {
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>
%c1 = arith.constant 1 : index
%dim_0 = memref.dim %arg0, %c1 : memref<?x?xf64>
%alloc = memref.alloc(%dim, %dim_0) : memref<?x?xf64>
%c0_1 = arith.constant 0 : index
affine.for %arg2 = #map(%c0_1) to #map(%dim) {
affine.for %arg3 = #map(%c0_1) to #map(%dim_0) {
%0 = affine.load %arg0[%arg2, %arg3] : memref<?x?xf64>
%1 = affine.load %arg1[%arg2, %arg3] : memref<?x?xf64>
%2 = arith.addf %0, %1 : f64
affine.store %2, %alloc[%arg2, %arg3] : memref<?x?xf64>
}
}
return %alloc : memref<?x?xf64>
}
}
Real memref.dim reads, a dynamic memref.alloc(%dim, %dim_0), and loops bounded by those values.
Step 3: two more bugs that dynamic shapes exposed¶
Testing edge cases found two problems that neither the static examples nor the prediction anticipated.
The canonicalizer produced invalid IR¶
Chapter 3's SimplifyRedundantTranspose rewrites transpose(transpose(x)) to x unconditionally. The test program (tt.mlir):
func.func @tt(%a: tensor<?x2xf64>) -> tensor<?x?xf64> {
%0 = mg.transpose %a : tensor<?x2xf64> to tensor<2x?xf64>
%1 = mg.transpose %0 : tensor<2x?xf64> to tensor<?x?xf64>
func.return %1 : tensor<?x?xf64>
}
has x : tensor<?x2xf64> but an outer result type of tensor<?x?xf64>, so replacing the result with x changes the type. Note first what happens on the unmodified Chapter 7 build: the program never reaches the canonicalizer, because the old verifier rejects it (? against 2 is "not equal"):
tt.mlir:3:8: error: 'mg.transpose' op mg.transpose result shape must be the input shape reversed
%1 = mg.transpose %0 : tensor<2x?xf64> to tensor<?x?xf64>
^
tt.mlir:3:8: note: see current operation: %1 = "mg.transpose"(%0) : (tensor<2x?xf64>) -> tensor<?x?xf64>
The canonicalizer bug only appears once the verifier has been relaxed but the pattern has not. That state is reproduced exactly by the build with the relaxed verifiers and the pattern's guard removed (Chapter 15's first mutation build):
tt.mlir:4:3: error: type of return operand 0 ('tensor<?x2xf64>') doesn't match function result type ('tensor<?x?xf64>') in function @tt
func.return %1 : tensor<?x?xf64>
^
tt.mlir:4:3: note: see current operation: "func.return"(%arg0) : (tensor<?x2xf64>) -> ()
The rewrite replaced the result with %arg0, whose type is tensor<?x2xf64>, and the function says it returns tensor<?x?xf64>. The fix is conservative: the pattern now fires only when x's type equals the result type exactly. The alternative, inserting a tensor.cast, would pull in another dialect.
struct SimplifyRedundantTranspose : public mlir::OpRewritePattern<TransposeOp> {
using OpRewritePattern<TransposeOp>::OpRewritePattern;
mlir::LogicalResult
matchAndRewrite(TransposeOp op, mlir::PatternRewriter &rewriter) const override {
auto innerTranspose = op.getInput().getDefiningOp<TransposeOp>();
if (!innerTranspose)
return mlir::failure();
// With dynamic shapes the inner input's type can differ from this op's
// result type (`tensor<?x2xf64>` vs `tensor<?x?xf64>`); replacing one
// with the other would produce invalid IR, so only fire on an exact match.
if (innerTranspose.getInput().getType() != op.getType())
return mlir::failure();
rewriter.replaceOp(op, innerTranspose.getInput());
return mlir::success();
}
};
} // namespace
void TransposeOp::getCanonicalizationPatterns(mlir::RewritePatternSet &results,
mlir::MLIRContext *context) {
results.add<SimplifyRedundantTranspose>(context);
}
Check: exact types still fold (tt2.mlir reduces to return %arg0); mixed types are left alone.
func.func @tt2(%a: tensor<?x2xf64>) -> tensor<?x2xf64> {
%0 = mg.transpose %a : tensor<?x2xf64> to tensor<2x?xf64>
%1 = mg.transpose %0 : tensor<2x?xf64> to tensor<?x2xf64>
func.return %1 : tensor<?x2xf64>
}
The verifiers cast unranked tensors without checking¶
F64Tensor also accepts tensor<*xf64>, and Chapter 3's verifiers used llvm::cast<RankedTensorType>. In a release build (no assertions) that cast checks nothing. The unmodified build printed this for an unranked transpose:
unranked.mlir:2:8: error: 'mg.transpose' op mg.transpose only supports rank-2 tensors
%0 = mg.transpose %a : tensor<*xf64> to tensor<*xf64>
^
unranked.mlir:2:8: note: see current operation: %0 = "mg.transpose"(%arg0) : (tensor<*xf64>) -> tensor<*xf64>
which looks sensible, but reading the code shows that was luck, not design: only supports rank-2 tensors was produced by reading a rank out of an object that is not a ranked type. Both verifiers now use dyn_cast and emit an explicit only supports ranked tensors (visible in the verifier code above).
Step 4: run it for real, on non-square shapes¶
The dynamic program, with both functions in one file (both.mlir):
func.func @add_dyn(%a: tensor<?x?xf64>, %b: tensor<?x?xf64>) -> tensor<?x?xf64> {
%0 = mg.add %a, %b : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
func.return %0 : tensor<?x?xf64>
}
func.func @t_dyn(%a: tensor<?x?xf64>) -> tensor<?x?xf64> {
%0 = mg.transpose %a : tensor<?x?xf64> to tensor<?x?xf64>
func.return %0 : tensor<?x?xf64>
}
It went through both lowering paths to native code: Chapter 4/5's --convert-mg-to-affine, and Chapter 6's --one-shot-bufferize="bufferize-function-boundaries", each followed by the same five-pass lowering, mlir-translate-18 and clang-18, linked with a C harness. The harness fills the rank-2 memref descriptor Chapter 8 introduced, but with sizes chosen at runtime:
#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 *a0, double *a1, int64_t off0, int64_t s0_0, int64_t s0_1, int64_t st0_0, int64_t st0_1,
double *b0, double *b1, int64_t off1, int64_t s1_0, int64_t s1_1, int64_t st1_0, int64_t st1_1);
extern Memref2D t_dyn(double *a0, double *a1, int64_t off0, int64_t s0_0, int64_t s0_1, int64_t st0_0, int64_t st0_1);
static void show(const char *name, Memref2D r) {
printf("%s -> %ldx%ld:\n", name, (long)r.sizes[0], (long)r.sizes[1]);
for (int64_t i = 0; i < r.sizes[0]; i++) {
for (int64_t j = 0; j < r.sizes[1]; j++) printf("%g ", r.aligned[r.offset + i * r.strides[0] + j * r.strides[1]]);
printf("\n");
}
}
int main(void) {
double a[6] = {1, 2, 3, 4, 5, 6}, b[6] = {10, 20, 30, 40, 50, 60};
show("add 2x3", add_dyn(a, a, 0, 2, 3, 3, 1, b, b, 0, 2, 3, 3, 1));
show("add 3x2", add_dyn(a, a, 0, 3, 2, 2, 1, b, b, 0, 3, 2, 2, 1));
show("add 1x6", add_dyn(a, a, 0, 1, 6, 6, 1, b, b, 0, 1, 6, 6, 1));
show("transpose 2x3", t_dyn(a, a, 0, 2, 3, 3, 1));
show("transpose 1x6", t_dyn(a, a, 0, 1, 6, 6, 1));
#ifdef STRIDED
// The same 6 numbers viewed as a 2x3 matrix in COLUMN-major order: strides (1, 2) instead of (3, 1).
// Logical matrix: [[1,3,5],[2,4,6]]. Only a callee whose type carries strides can honor this.
show("transpose 2x3 column-major view", t_dyn(a, a, 0, 2, 3, 1, 2));
#endif
return 0;
}
The scripts that build the compiler and run every check:
#!/bin/sh
# Rebuild mg-opt from the book's own files: the earlier chapters' latest versions, with this chapter's
# four changed/added files overriding them. 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 $D/part7/code/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 $D/part7/code/mg-opt.cpp $T/tools/
cp $HERE/DynamicShapes.h $T/include/mg/
cp $HERE/MgDialect.cpp $HERE/LowerToAffine.cpp $HERE/MgBufferizableOpInterfaceImpl.cpp $T/lib/
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
#!/bin/sh
# Chapter 13's checks, in order. Run ./build.sh first.
HERE=$(cd "$(dirname "$0")" && pwd); M=$HERE/build/mg-opt; 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. mixed ?x2 + 3x2 add now verifies:"; $M ../mixed_add.mlir 2>&1 | sed -n 3p
echo "### 2. unranked tensors get an explicit diagnostic:"; $M ../unranked.mlir 2>&1 | head -1; $M ../unranked_add.mlir 2>&1 | head -1
echo "### 3. canonicalizer: mixed types left alone, exact types still fold:"
$M ../tt.mlir --canonicalize | sed -n 3,5p; $M $HERE/tt2.mlir --canonicalize | sed -n 3p
echo "### 4. static output 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 "### 5. dynamic add to affine:"; $M ../dyn_add.mlir --convert-mg-to-affine
$M ../both.mlir --convert-mg-to-affine $LOW -o a_llvm.mlir
$M ../both.mlir --one-shot-bufferize="bufferize-function-boundaries" -o b_buf.mlir
$M b_buf.mlir $LOW -o b_llvm.mlir
for p in a b; 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 ../harness_dyn.c a.o -o run_a; echo "### 6. native run, path A (Chapter 4/5 conversion):"; ./run_a
clang-18 -DSTRIDED ../harness_dyn.c b.o -o run_b; echo "### 7. native run, path B (Chapter 6 bufferization) + column-major view:"; ./run_b
clang-18 ../harness_mismatch.c a.o -o mism; echo "### 8. KNOWN GAP, runtime shape mismatch (a=2x3, b=1x2):"; ./mism
The complete real output¶
### 1. mixed ?x2 + 3x2 add now verifies:
%0 = mg.add %arg0, %arg1 : tensor<?x2xf64>, tensor<3x2xf64> -> tensor<?x2xf64>
### 2. unranked tensors get an explicit diagnostic:
../unranked.mlir:2:8: error: 'mg.transpose' op mg.transpose only supports ranked tensors
../unranked_add.mlir:2:8: error: 'mg.add' op mg.add only supports ranked tensors
### 3. canonicalizer: mixed types left alone, exact types still fold:
%0 = mg.transpose %arg0 : tensor<?x2xf64> to tensor<2x?xf64>
%1 = mg.transpose %0 : tensor<2x?xf64> to tensor<?x?xf64>
return %1 : tensor<?x?xf64>
return %arg0 : tensor<?x2xf64>
### 4. static output byte-identical to Chapter 10's:
IDENTICAL
### 5. dynamic add to affine:
#map = affine_map<(d0) -> (d0)>
module {
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>
%c1 = arith.constant 1 : index
%dim_0 = memref.dim %arg0, %c1 : memref<?x?xf64>
%alloc = memref.alloc(%dim, %dim_0) : memref<?x?xf64>
%c0_1 = arith.constant 0 : index
affine.for %arg2 = #map(%c0_1) to #map(%dim) {
affine.for %arg3 = #map(%c0_1) to #map(%dim_0) {
%0 = affine.load %arg0[%arg2, %arg3] : memref<?x?xf64>
%1 = affine.load %arg1[%arg2, %arg3] : memref<?x?xf64>
%2 = arith.addf %0, %1 : f64
affine.store %2, %alloc[%arg2, %arg3] : memref<?x?xf64>
}
}
return %alloc : memref<?x?xf64>
}
}
### 6. native run, path A (Chapter 4/5 conversion):
add 2x3 -> 2x3:
11 22 33
44 55 66
add 3x2 -> 3x2:
11 22
33 44
55 66
add 1x6 -> 1x6:
11 22 33 44 55 66
transpose 2x3 -> 3x2:
1 4
2 5
3 6
transpose 1x6 -> 6x1:
1
2
3
4
5
6
### 7. native run, path B (Chapter 6 bufferization) + column-major view:
add 2x3 -> 2x3:
11 22 33
44 55 66
add 3x2 -> 3x2:
11 22
33 44
55 66
add 1x6 -> 1x6:
11 22 33 44 55 66
transpose 2x3 -> 3x2:
1 4
2 5
3 6
transpose 1x6 -> 6x1:
1
2
3
4
5
6
transpose 2x3 column-major view -> 3x2:
1 2
3 4
5 6
### 8. KNOWN GAP, runtime shape mismatch (a=2x3, b=1x2):
result 2x3: 11 22 4 5 7 9
Reading the sections: 1 shows the mixed add now verifies; 2 shows the explicit unranked diagnostics; 3 shows the canonicalizer leaving mixed types alone and folding exact ones; 4 is the regression check, byte-identical to Chapter 10's stored static output (cmp reports no difference); 5 is the dynamic add's lowering; 6 and 7 are the native runs. One compiled function handled five different shapes (2x3, 3x2 and 1x6 adds; 2x3 and 1x6 transposes), which is the point of dynamic shapes, and a transposed result had a different shape from its input at runtime. Path A and Path B print identical, correct output.
The strided-view test: where the two paths really differ¶
Chapter 6 noted that bufferization's function arguments are strided memrefs (memref<?x?xf64, strided<[?, ?], offset: ?>>) while Chapter 4's are plain row-major; here that difference becomes observable. Section 7 above ends with Path B being called with the same six numbers described as a column-major 2x3 matrix (strides (1, 2)), whose logical value is [[1,3,5],[2,4,6]]:
That is the correct transpose of the logical matrix. Path A cannot be given this test fairly: its argument type is memref<?x?xf64> with the default row-major layout, so a caller passing different strides would be violating that type's contract. The finding is not that A is wrong; it is that B's types carry strides and A's do not, a real capability difference that static 2x2 examples could never show.
Static regression¶
--convert-mg-to-affine on Chapter 10's static add_tensors.mlir is byte-identical to Chapter 10's stored add_affine.mlir. Chapter 8's static add_tensors run natively through both paths with Chapter 8's own harness still prints [[6, 8], [10, 12]], and Chapter 3's canonicalize demo still folds to the same two-operation program.
What is still not safe¶
Runtime shape mismatch is silent memory corruption. (Update, added after Chapter 14: closed for mg.add. The dynamic add now aborts on a runtime mismatch instead of reading out of bounds; see Chapter 14. The description below is the state when this chapter was written.) The verifier can only compare shapes it knows at compile time. Called through the dynamic function with a as 2x3 and b as 1x2 (a buffer holding two doubles):
#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;
}
the add runs to completion and returns garbage (the last line of the real output above):
The loop bounds come from a; the loads from b run off the end of its two-element buffer and read whatever sits beyond it. There is no runtime check. The first two values are right (the first two elements exist in both) and 4 5 7 9 is not meaningful, and the values after the first two are not guaranteed to be reproducible: they are whatever happened to be in memory (they came out identical across the two runs made for this chapter, which proves nothing about other builds or machines). This is an out-of-bounds read, so it is undefined behavior, and a different run or build could crash instead. Closing it means emitting a runtime comparison of the operands' memref.dims and aborting on mismatch (e.g. a cf.assert); this chapter did not do that, because it also needs to confirm the cf lowering is present in the five-pass pipeline, and that is a separate piece of verified work. Mountain Goat is not memory safe for dynamic shapes until then.
Also still open:
- Only rank 2. The verifiers and loops are hard-wired to two dimensions; a rank-3 tensor is rejected by the verifier, which is correct but means dynamic rank is out of scope.
mg.constantstays static. A literal's shape is always known, so this is by design, not a gap.- Chapter 7's loop transforms and Chapters 10 through 12's GPU path were not re-run on dynamic shapes. (Update, added after Chapter 17: the loop transforms now have been; see Chapter 17. The GPU path has not.) Fusion, tiling and unrolling with a
?bound are untested; full unrolling in particular cannot work without a constant trip count. The GPU path'sbare-ptroption requires static shapes (Chapter 12), so dynamic-shape GPU lowering was not attempted. - No automated tests. (Update, added after Chapter 15: now a
lit/FileChecksuite; see Chapter 15.)code/run_tests.shis a script that prints results for a human to read, not an assertion-based suite.
Reproducing this chapter¶
cd docs/part13/code
./build.sh # assembles the tree from the earlier chapters' files plus this chapter's four, builds ./build/mg-opt
./run_tests.sh # runs every check above; output matches run_out.txt
./before.sh # the unmodified compiler's real outputs (needs ../../part15/code/older_builds.sh and mutation.sh run once)
Both build.sh and run_tests.sh were run from this state after the build directories were removed, and run_out.txt is their unedited output; build/, tree/ and work/ are git-ignored. Needs mlir-18-tools, libmlir-18-dev, llvm-18-dev, cmake, clang-18. 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 12, no documentation pages were consulted; every claim comes from running the code or reading it.
Chapter summary¶
This chapter turned Chapter 12's biggest unknown, dynamic shapes, into measured facts. Run on an unmodified compiler, tensor<?x?xf64> parsed, but a mixed ?x2 plus 3x2 add was wrongly rejected and both lowering paths (the hand-written conversion and One-Shot Bufferize) failed on a dynamic memref.alloc with no size operands. One shared header (DynamicShapes.h) fixed all three lowering sites, reading runtime extents with memref.dim, allocating dynamically and bounding loops by operands, while leaving static programs on their old path (byte-identical to Chapter 10's stored output). The work exposed two more bugs: the transpose-of-transpose canonicalizer produced invalid IR on mixed dynamic types (now guarded), and the verifiers cast unranked tensors without checking (now dyn_cast with a diagnostic). Native runs of non-square shapes on both paths were correct, and a column-major strided view only the bufferization path could honor showed a real difference between the two.
Deliberately out of scope, stated explicitly: runtime shape agreement was not checked (closed for mg.add in Chapter 14), only rank 2 is supported, and loop transforms and the GPU path were not re-run on dynamic bounds.
Self-check questions¶
1. Why can the lowering not simply use tensorType.getShape()[0] as a loop bound when the dimension is ??
Worked answer: for a dynamic dimension getShape() returns MLIR's sentinel for "dynamic", -1, not a size, so the number would become a bogus loop bound. A dynamic extent has to be read at runtime, with a memref.dim operation on the buffer, and passed to the loop and to memref.alloc as an operand. The unmodified compiler never got as far as running such a loop: the verifier on memref.alloc rejected an allocation of a memref<?x?xf64> with no size operands.
2. tensor<?x2xf64> plus tensor<3x2xf64> was rejected by the original verifier. Why is rejecting it wrong, and what rule replaced it?
Worked answer: the ? may be 3 at runtime, so the two shapes can agree, and a compile-time error would reject a valid program. The original check compared shapes with !=, which treats ? and 3 as different. compatibleDim(a, b) now accepts two extents if they are equal or if either is dynamic; the verifier applies it to every dimension of both operands and the result.
3. The canonicalizer bug did not appear when the program was run on the unmodified compiler. Why not, and how was it reproduced?
Worked answer: on the unmodified build the mixed-type transpose program was rejected earlier, by the old verifier (? against 2 counted as unequal), so the canonicalizer never saw it. The bug only appears once the verifier has been relaxed but the pattern has not been guarded, a state reproduced exactly by the build with the relaxed verifiers and the exact-type guard removed, which printed that the function returns tensor<?x2xf64> but is declared to return tensor<?x?xf64>.
4. What does the column-major strided-view test show about the two lowering paths?
Worked answer: the bufferization path's function arguments are strided memref types (strided<[?, ?], offset: ?>), so it honors a caller's strides, and it produced the correct transpose of the logical matrix [[1,3,5],[2,4,6]]. The hand-written path's arguments are plain row-major memrefs, so passing different strides would violate its type's contract; the test is not run against it and the chapter says that is not a defect of that path. The finding is a capability difference that static 2x2 examples could not show.
5. Why did the mismatched-size call (2x3 plus 1x2) return numbers instead of failing?
Worked answer: the loop bounds come from the first operand and nothing compares the operands' runtime sizes, so the loads from the second operand run off the end of its two-element buffer and read whatever memory is there. The first two sums are right because those elements exist in both; the rest are meaningless and not guaranteed to be reproducible. That silent out-of-bounds read is what Chapter 14 closes with a runtime check.