20. The Mountain Goat Demo: A Surface Syntax, a Driver, and C++ Calling Compiled Code¶
What you will understand: how every earlier chapter fits together by using the compiler. You will write Mountain Goat programs in a small text language, compile them to native executables with one command, call compiled Mountain Goat functions from a C++ program, and push a program down the GPU path to PTX. This chapter adds a front end (so there is finally a language to write), a driver (mgc) and one small lowering, and it runs ten example programs.
What you need to know first: nothing new beyond the earlier chapters' vocabulary: tensors and mg.add/mg.transpose (Chapters 1 to 5), dynamic shapes and the runtime check (Chapters 13 and 14), the stderr abort (Chapter 19) and the GPU recipe (Chapters 10 and 11). Each section says which chapter it is using.
Compile and run
cd docs/part20/code
./build.sh # builds ./build/mg-opt (Chapter 20's)
./mgc run examples/03_dynamic.mg # compile one example to native code and run it
./mgc ptx examples/05_gpu.mg # compile one example to PTX (not run: no GPU here)
./demo.sh > demo_out.txt # all ten examples; the output on this page is this file
cpp/run.sh # build and run the C++ program
cd ../part15/code && ./run_lit.sh # the test suite
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.
Primer: what "a compiler driver" is¶
Compilers are rarely one program. gcc hello.c looks like one command, but it runs a preprocessor, a compiler proper, an assembler and a linker, passing files between them. The command you type is the driver: it knows the stages, their order and their flags, so you do not have to.
Mountain Goat has had all its stages since Chapter 8, but you had to run them by hand: mg-opt for the dialect lowering, mlir-translate for LLVM IR, clang for the executable, and a hand-written C main to call the result. And Mountain Goat programs were written as raw MLIR text (%0 = mg.add %a, %b : tensor<2x2xf64>, ...), because the language itself had no surface syntax: nothing a person would call "writing Mountain Goat".
A surface syntax (or concrete syntax) is the text a programmer writes. A front end reads it, checks it, and produces the compiler's internal form, here MLIR in the mg dialect. This chapter builds both missing pieces: a front end for a small language, and the driver that connects it to every stage the book already built.
The language¶
A short, example-driven introduction to the syntax, declarations and types is on its own page: The Mountain Goat Language: A Short Tour. This section is the formal summary.
One statement per line. # starts a comment. Every value is a two-dimensional matrix of 64-bit floats, which is exactly what the mg dialect can represent.
program := (def | stmt)*
def := 'def' NAME '(' [NAME ':' type (',' NAME ':' type)*] ')' '=' expr
stmt := 'let' NAME '=' expr | 'print' expr
type := 'tensor' '[' dim 'x' dim ']' dim := INT | '?'
expr := term ('+' term)*
term := NAME | matrix | 'transpose' '(' expr ')' | NAME '(' expr (',' expr)* ')' | '(' expr ')'
matrix := '[' row (',' row)* ']' row := '[' NUM (',' NUM)* ']'
Three things to notice. A def defines a function whose whole body is one expression. A parameter type is written tensor[2x3] for a fixed shape or tensor[?x?] for a dynamic shape, where ? means "known only at run time" (Chapter 13). Statements outside a def form the program's main.
The language is deliberately tiny. It has no loops, no scalars, no if, no way to index a matrix, and the only operations are + and transpose, because those are the only operations the mg dialect has. A bigger language would be a different book.
The front end, mgfront.py¶
The front end is about 240 lines of Python. It is a hand-written recursive-descent parser: one small method per grammar rule, each reading tokens and calling the methods for the rules it contains. It does not build a tree first; it type-checks as it parses and writes MLIR text directly, tracking each value's shape (a pair of integers, where None means dynamic).
The pieces, in order. The error type and the tokenizer (a regular expression that recognizes numbers, names and punctuation):
class MgError(Exception):
def __init__(self, line, msg): self.line, self.msg = line, msg
TOKEN = re.compile(r"\s*(?:(?P<num>-?\d+\.?\d*(?:[eE][-+]?\d+)?)|(?P<id>[A-Za-z_]\w*)|(?P<sym>[()\[\],+=:?]))")
def tokenize(text, line):
toks, pos = [], 0
text = text.rstrip()
while pos < len(text):
m = TOKEN.match(text, pos)
if not m:
raise MgError(line, f"unexpected character {text[pos:].lstrip()[0]!r}")
pos = m.end()
kind = m.lastgroup
toks.append((kind, m.group(kind)))
return toks
def fmt(shape): # (2,None) -> 'tensor<2x?xf64>'
return "tensor<" + "x".join("?" if d is None else str(d) for d in shape) + "xf64>"
The expression parser is where the language's rules live. Addition checks that the two shapes can agree (an integer against an integer must be equal; anything against ? is accepted and left to the run-time check), transpose swaps the pair, and a call inserts a tensor.cast whenever an argument's shape is not exactly the parameter's:
class Parser:
def __init__(self, toks, line, fn):
self.t, self.i, self.line, self.fn = toks, 0, line, fn
def peek(self): return self.t[self.i] if self.i < len(self.t) else (None, None)
def take(self, want=None):
k, v = self.peek()
if k is None: raise MgError(self.line, "unexpected end of line")
if want is not None and v != want:
raise MgError(self.line, f"expected {want!r} but found {v!r}")
self.i += 1; return k, v
def expr(self):
v, s = self.term()
while self.peek()[1] == "+":
self.take("+"); w, s2 = self.term()
for a, b in zip(s, s2):
if a is not None and b is not None and a != b:
raise MgError(self.line, f"cannot add shapes {'x'.join(map(str,s))} and {'x'.join(map(str,s2))}")
shape = tuple(a if a is not None else b for a, b in zip(s, s2))
r = self.fn.tmp()
self.fn.emit(f"{r} = mg.add {v}, {w} : {fmt(s)}, {fmt(s2)} -> {fmt(shape)}")
v, s = r, shape
return v, s
def term(self):
k, v = self.peek()
if v == "(":
self.take("("); r = self.expr(); self.take(")"); return r
if v == "[": return self.matrix()
if k == "id":
self.take()
if v == "transpose":
self.take("("); x, s = self.expr(); self.take(")")
shape = (s[1], s[0]); r = self.fn.tmp()
self.fn.emit(f"{r} = mg.transpose {x} : {fmt(s)} to {fmt(shape)}")
return r, shape
if self.peek()[1] == "(":
return self.call(v)
if v not in self.fn.env: raise MgError(self.line, f"unknown name {v!r}")
return self.fn.env[v]
raise MgError(self.line, f"unexpected {v!r}")
def call(self, name):
if name not in self.fn.em.funcs: raise MgError(self.line, f"unknown function {name!r}")
params, result = self.fn.em.funcs[name]
self.take("("); args = [self.expr()]
while self.peek()[1] == ",": self.take(","); args.append(self.expr())
self.take(")")
if len(args) != len(params):
raise MgError(self.line, f"{name} takes {len(params)} arguments, got {len(args)}")
vals = []
for (v, s), p in zip(args, params):
for a, b in zip(s, p):
if b is not None and a != b:
raise MgError(self.line, f"argument shape {'x'.join('?' if d is None else str(d) for d in s)} does not fit parameter {'x'.join('?' if d is None else str(d) for d in p)}")
if s != p: # static -> dynamic (or the reverse): a tensor.cast
c = self.fn.tmp(); self.fn.emit(f"{c} = tensor.cast {v} : {fmt(s)} to {fmt(p)}"); v = c
vals.append(v)
r = self.fn.tmp()
sig = ", ".join(fmt(p) for p in params)
self.fn.emit(f"{r} = func.call @{name}({', '.join(vals)}) : ({sig}) -> {fmt(result)}")
return r, result
def matrix(self):
rows = []
self.take("[")
while True:
self.take("["); row = [self.take()[1]]
while self.peek()[1] == ",": self.take(","); row.append(self.take()[1])
self.take("]"); rows.append(row)
if self.peek()[1] == ",": self.take(","); continue
break
self.take("]")
if len({len(r) for r in rows}) != 1: raise MgError(self.line, "matrix rows have different lengths")
def f(x):
if not re.fullmatch(r"-?\d+\.?\d*(?:[eE][-+]?\d+)?", x): raise MgError(self.line, f"{x!r} is not a number")
return x if ("." in x or "e" in x.lower()) else x + ".0"
body = "[" + ", ".join("[" + ", ".join(f(x) for x in r) + "]" for r in rows) + "]"
shape = (len(rows), len(rows[0])); r = self.fn.tmp()
self.fn.emit(f"{r} = mg.constant dense<{body}> : {fmt(shape)}")
return r, shape
The rest of mgfront.py: types, the top level, and the C++ header generator (click to expand)
def parse_type(p):
"""tensor[RxC] where each of R, C is an integer or '?'."""
p.take("tensor"); p.take("[")
dims = []
for i in range(2):
v = p.take()[1]
if v == "?": dims.append(None)
elif v.isdigit(): dims.append(int(v))
else: raise MgError(p.line, f"bad dimension {v!r}")
if i == 0 and p.take()[1] != "x": raise MgError(p.line, "expected 'x' between dimensions")
p.take("]"); return tuple(dims)
def compile_source(src, lib=False, header=False, guard="MG_GENERATED_H"):
em, funcs_text, main = Emitter(), [], None
em.defs = []
main = Func(em, None)
for ln, raw in enumerate(src.splitlines(), 1):
text = raw.split("#", 1)[0].strip()
if not text: continue
text = re.sub(r"(\d|\?)x(?=\d|\?)", r"\1 x ", text) # '2x3' -> '2 x 3' so the tokenizer sees three tokens
toks = tokenize(text, ln)
if toks[0][1] == "def":
fn = Func(em, ln); p = Parser(toks, ln, fn); p.take("def")
name = p.take()[1]; p.take("(")
params, names = [], []
while p.peek()[1] != ")":
n = p.take()[1]; p.take(":"); shp = parse_type(p)
names.append(n); params.append(shp)
if p.peek()[1] == ",": p.take(",")
p.take(")"); p.take("=")
for i, (n, shp) in enumerate(zip(names, params)): fn.env[n] = (f"%{n}", shp)
v, s = p.expr()
if p.i != len(toks): raise MgError(ln, f"unexpected {p.peek()[1]!r}")
em.funcs[name] = (params, s)
em.defs.append((name, names, params, s))
sig = ", ".join(f"%{n}: {fmt(sh)}" for n, sh in zip(names, params))
funcs_text.append(f"func.func @{name}({sig}) -> {fmt(s)} {{\n" + "\n".join(fn.lines) + f"\n func.return {v} : {fmt(s)}\n}}")
continue
p = Parser(toks, ln, main); kw = p.take()[1]
if kw == "let":
n = p.take()[1]; p.take("="); v, s = p.expr(); main.env[n] = (v, s)
elif kw == "print":
v, s = p.expr(); main.emit(f"mg.print {v} : {fmt(s)}")
else:
raise MgError(ln, f"a statement must start with 'let', 'print' or 'def', not {kw!r}")
if p.i != len(toks): raise MgError(ln, f"unexpected {p.peek()[1]!r}")
if header: return make_header(em.defs, guard)
if lib:
if main.lines: raise MgError(0, "lib mode: top-level 'let'/'print' statements are not allowed (only 'def')")
return "\n".join(funcs_text) + "\n"
main.emit("%zero = arith.constant 0 : i32")
return "\n".join(funcs_text + ["func.func @main() -> i32 {\n" + "\n".join(main.lines) + "\n func.return %zero : i32\n}"]) + "\n"
HEADER_TOP = """// Generated by mgfront.py -- do not edit. C++ view of compiled Mountain Goat functions.
#pragma once
#include <cstdint>
#include <cstdlib>
#include <stdexcept>
#include <string>
#include <vector>
namespace mg {
// A dense row-major f64 matrix that owns its storage.
struct Matrix {
int64_t rows = 0, cols = 0;
std::vector<double> data;
Matrix() = default;
Matrix(int64_t r, int64_t c, std::vector<double> d) : rows(r), cols(c), data(std::move(d)) {
if ((int64_t)data.size() != r * c) throw std::invalid_argument("Matrix: data size != rows*cols");
}
double &operator()(int64_t i, int64_t j) { return data[i * cols + j]; }
double operator()(int64_t i, int64_t j) const { return data[i * cols + j]; }
};
// The memref descriptor the compiled code returns (rank 2).
struct Desc { double *allocated, *aligned; int64_t offset, sizes[2], strides[2]; };
inline Matrix take(Desc d) { // copy out through the strides, then release the callee's malloc
Matrix m; m.rows = d.sizes[0]; m.cols = d.sizes[1]; m.data.resize(m.rows * m.cols);
for (int64_t i = 0; i < m.rows; i++)
for (int64_t j = 0; j < m.cols; j++)
m.data[i * m.cols + j] = d.aligned[d.offset + i * d.strides[0] + j * d.strides[1]];
std::free(d.allocated);
return m;
}
inline void expect(const Matrix &m, int64_t r, int64_t c, const char *fn, const char *arg) {
if ((r >= 0 && m.rows != r) || (c >= 0 && m.cols != c))
throw std::invalid_argument(std::string(fn) + ": argument '" + arg + "' has the wrong shape");
}
} // namespace mg
"""
def make_header(defs, guard):
out = [HEADER_TOP, 'extern "C" {']
for name, names, params, _ in defs:
out.append(f"mg::Desc {name}(" + ", ".join("double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t" for _ in params) + ");")
out.append('}\n\nnamespace mg {')
for name, names, params, _ in defs:
sig = ", ".join(f"const Matrix &{n}" for n in names)
out.append(f"// {name}(" + ", ".join(f"{n}: " + "x".join('?' if d is None else str(d) for d in p) for n, p in zip(names, params)) + ")")
out.append(f"inline Matrix {name}({sig}) {{")
for n, p in zip(names, params):
r = -1 if p[0] is None else p[0]; c = -1 if p[1] is None else p[1]
if r >= 0 or c >= 0: out.append(f' expect({n}, {r}, {c}, "{name}", "{n}");')
args = []
for n in names:
d = f"const_cast<double*>({n}.data.data())"
args.append(f"{d}, {d}, 0, {n}.rows, {n}.cols, {n}.cols, 1")
out.append(f" return take(::{name}(" + ", ".join(args) + "));")
out.append("}")
out.append("} // namespace mg")
return "\n".join(out) + "\n"
if __name__ == "__main__":
flags = [a for a in sys.argv[1:] if a.startswith("--")]; path = [a for a in sys.argv[1:] if not a.startswith("--")][0]
try:
sys.stdout.write(compile_source(open(path).read(), lib="--lib" in flags, header="--header" in flags))
except MgError as e:
print(f"{path}:{e.line}: error: {e.msg}", file=sys.stderr); sys.exit(1)
Mistakes are reported with the file and line, and before any MLIR exists, so a user never sees an MLIR verifier message for a typo:
################ mgc run examples/errors/bad_shape.mg
| # Rejected by the front end, before any MLIR exists: 1x2 + 2x1.
| let r = [[1, 2]] + [[1], [2]]
| print r
----- output
errors/bad_shape.mg:2: error: cannot add shapes 1x2 and 2x1
----- exit status 1
################ mgc run examples/errors/bad_syntax.mg
| # Rejected by the front end: a statement must start with let, print or def.
| let a = [[1, 2], [3, 4]]
| show a
----- output
errors/bad_syntax.mg:3: error: a statement must start with 'let', 'print' or 'def', not 'show'
----- exit status 1
The shape check is a convenience, not the safety net. The front end can only catch mismatches it can see; for ? dimensions the run-time check from Chapter 14 is what protects the program, and Chapter 19's pass makes its message visible.
One new lowering: tensor.cast¶
Passing a fixed-shape matrix to a function that takes tensor[?x?] needs a type change on the way in. MLIR spells that tensor.cast: it changes only the static type of a value, never its contents. Chapters 13 and 14 never needed it, because their dynamic functions were called from C, where the caller supplies raw memref descriptors, not from Mountain Goat code.
The first attempt failed at once, with MLIR's own message:
error: Dialect `tensor' not found for custom op 'tensor.cast'
note: Registered dialects: affine, arith, bufferization, builtin, cf, func, llvm, memref, mg
Two things were missing: mg-opt never registered the tensor dialect, and --convert-mg-to-affine had no pattern for the op. The fix is small. The whole change, against the earlier files:
--- ../../part14/code/LowerToAffine.cpp 2026-10-01 23:01:57.028331238 +0000
+++ LowerToAffine.cpp 2026-10-02 02:52:35.252838267 +0000
@@ -7,6 +7,7 @@
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/Func/Transforms/FuncConversions.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
+#include "mlir/Dialect/Tensor/IR/Tensor.h"
#include "mlir/IR/BuiltinTypes.h"
#include "mlir/Pass/Pass.h"
#include "mlir/Transforms/DialectConversion.h"
@@ -197,6 +198,25 @@
}
};
+/// Chapter 20: tensor.cast, which the surface-syntax front end emits when a
+/// statically shaped value is passed to a function taking tensor<?x?xf64>.
+/// The bufferized form of a cast between tensor types is a memref.cast between
+/// the converted memref types; it changes only the *type*, never the data.
+struct TensorCastLowering : public OpConversionPattern<tensor::CastOp> {
+ using OpConversionPattern::OpConversionPattern;
+
+ LogicalResult
+ matchAndRewrite(tensor::CastOp op, OpAdaptor adaptor,
+ ConversionPatternRewriter &rewriter) const override {
+ Type resultType = getTypeConverter()->convertType(op.getType());
+ if (!resultType)
+ return failure();
+ rewriter.replaceOpWithNewOp<memref::CastOp>(op, resultType,
+ adaptor.getSource());
+ return success();
+ }
+};
+
struct ConvertMgToAffinePass
: public PassWrapper<ConvertMgToAffinePass, OperationPass<ModuleOp>> {
MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(ConvertMgToAffinePass)
@@ -209,7 +229,8 @@
void getDependentDialects(DialectRegistry ®istry) const override {
registry.insert<affine::AffineDialect, memref::MemRefDialect,
- arith::ArithDialect, cf::ControlFlowDialect>();
+ arith::ArithDialect, cf::ControlFlowDialect,
+ tensor::TensorDialect>();
}
void runOnOperation() override {
@@ -218,6 +239,7 @@
target.addLegalDialect<affine::AffineDialect, memref::MemRefDialect,
arith::ArithDialect, cf::ControlFlowDialect>();
target.addIllegalDialect<mg::MgDialect>();
+ target.addIllegalOp<tensor::CastOp>();
// A real, genuine gap this chapter's own first attempt hit directly:
// mg.add/mg.transpose's own *operations* are made illegal above, but a
@@ -241,7 +263,7 @@
RewritePatternSet patterns(&getContext());
patterns.add<ConstantOpLowering, AddOpLowering, TransposeOpLowering,
- PrintOpLowering>(typeConverter, &getContext());
+ PrintOpLowering, TensorCastLowering>(typeConverter, &getContext());
populateFunctionOpInterfaceTypeConversionPattern<func::FuncOp>(patterns,
typeConverter);
populateCallOpTypeConversionPattern(patterns, typeConverter);
--- ../../part19/code/mg-opt.cpp 2026-10-02 01:50:44.084955341 +0000
+++ mg-opt.cpp 2026-10-02 02:52:35.252958209 +0000
@@ -11,6 +11,7 @@
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/LLVMIR/LLVMDialect.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
+#include "mlir/Dialect/Tensor/IR/Tensor.h"
#include "mlir/IR/DialectRegistry.h"
#include "mlir/InitAllDialects.h"
#include "mlir/Tools/mlir-opt/MlirOptMain.h"
@@ -36,7 +37,8 @@
registry.insert<mlir::func::FuncDialect, mlir::affine::AffineDialect,
mlir::memref::MemRefDialect, mlir::arith::ArithDialect,
mlir::bufferization::BufferizationDialect,
- mlir::cf::ControlFlowDialect, mlir::LLVM::LLVMDialect>();
+ mlir::cf::ControlFlowDialect, mlir::LLVM::LLVMDialect,
+ mlir::tensor::TensorDialect>();
registry.insert<mg::MgDialect>();
mg::registerBufferizableOpInterfaceExternalModels(registry);
mlir::bufferization::func_ext::registerBufferizableOpInterfaceExternalModels(
--- ../../part19/code/CMakeLists.txt 2026-10-02 01:50:44.084972667 +0000
+++ CMakeLists.txt 2026-10-02 02:52:41.806961706 +0000
@@ -37,6 +37,7 @@
MLIRTransforms
MLIRAffineDialect
MLIRMemRefDialect
+ MLIRTensorDialect
MLIRArithDialect
MLIRFuncDialect
MLIRFuncTransforms
@@ -74,6 +75,7 @@
MLIRTransforms
MLIRAffineDialect
MLIRMemRefDialect
+ MLIRTensorDialect
MLIRArithDialect
MLIRLLVMDialect
MLIRBufferizationDialect
The pattern is the bufferized meaning of the op: a tensor.cast between tensor types is a memref.cast between the converted memref types. Static-to-dynamic is always legal, so nothing is checked here; a dynamic-to-static cast would need a run-time check, and the front end never emits one. To rebuild mg-opt:
#!/bin/sh
# Rebuild mg-opt for Chapter 20: Chapter 19's build plus one new lowering (tensor.cast -> memref.cast). 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 $D/part14/code/DynamicShapes.h $T/include/mg/
cp $D/part13/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
The driver, mgc¶
#!/bin/sh
# mgc: the Mountain Goat compiler driver. Every stage is a real tool; nothing is hidden.
# mgc run prog.mg compile to a native executable and run it
# mgc build prog.mg [-o exe] compile to a native executable
# mgc lib prog.mg [-o dir] compile the 'def's to prog.o plus a C++ header prog.h (no main), for linking into your own program
# mgc mlir prog.mg print the mg-dialect MLIR the front end produces
# mgc ptx prog.mg compile to PTX (no launch: this machine has no GPU) and print the PTX
# Env: MG_OPT (default Chapter 20 build), MGC_KEEP=dir (keep every intermediate file there).
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
MG_OPT=${MG_OPT:-$HERE/../../part20/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]" >&2; exit 2; }
[ $# -ge 2 ] || usage
cmd=$1; src=$2; shift 2; out=
while [ $# -gt 0 ]; do case $1 in -o) out=$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/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 part20/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 -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
dir=${out:-$W}; mkdir -p "$dir"
clang-18 -c "$W/$base.ll" -o "$dir/$base.o" 2>/dev/null
python3 "$HERE/mgfront.py" --header "$src" > "$dir/$base.h"
echo "wrote $dir/$base.o and $dir/$base.h"; exit 0
fi
exe=${out:-$W/$base.exe}
clang-18 "$W/$base.ll" -o "$exe" -L"$LIBDIR" -lmlir_runner_utils -Wl,-rpath,"$LIBDIR" 2>/dev/null # 5. -> native executable
if [ "$cmd" = run ]; then exec "$exe"; fi # (Chapter 23: this used to be `[ ... ] && exec`, which made `mgc build` exit with status 1 on success)
;;
ptx)
$MG_OPT "$W/$base.mlir" --convert-mg-to-affine -o "$W/$base.affine.mlir"
mlir-opt-18 "$W/$base.affine.mlir" \
--pass-pipeline='builtin.module(func.func(affine-parallelize,lower-affine,gpu-map-parallel-loops,convert-parallel-loops-to-gpu),gpu-kernel-outlining)' -o "$W/$base.gpu.mlir"
mlir-opt-18 "$W/$base.gpu.mlir" \
--pass-pipeline='builtin.module(lower-affine,nvvm-attach-target{chip=sm_70 features=+ptx60},gpu.module(convert-gpu-to-nvvm{index-bitwidth=64},reconcile-unrealized-casts),gpu-module-to-binary{format=isa})' -o "$W/$base.bin.mlir"
python3 "$HERE/../../part10/code/decode_ptx.py" "$W/$base.bin.mlir"
;;
*) usage;;
esac
Read it as the book's table of contents in shell. Step 1 is the front end. Step 2 is Chapters 4, 5, 13 and 14 (--convert-mg-to-affine, including the run-time shape checks). Step 3 is the standard lowering to the LLVM dialect plus Chapter 19's --mg-lower-assert-to-stderr, placed after --convert-scf-to-cf for the reason Chapter 19 found. Step 4 is mlir-translate. Step 5 is clang, linked against MLIR's libmlir_runner_utils, which supplies printMemrefF64, the function mg.print calls (Chapter 13).
A first version of this driver linked libmlir_c_runner_utils instead and failed with undefined reference to '_mlir_ciface_printMemrefF64'; in LLVM 18 the printing functions live in the other library (nm -D shows printMemrefF64 in libmlir_runner_utils.so only). The driver hides the compiler's stderr during the final link, so that failure first showed up as a silent exit status 1; running the clang line by hand showed the cause. That is a driver bug of mine, and it is why the test suite includes a mutation that removes the library.
The stages of one real program, 03_dynamic.mg, with the size of each artifact:
21 lines 1_front_end.mlir
136 lines 2_after_convert_mg_to_affine.mlir
363 lines 3_llvm_dialect.mlir
269 lines 4_llvm_ir.ll
write(2,... calls in stage 4: 2; puts calls: 0
The front end's 21 lines become 363 lines of LLVM dialect. Nothing in those lines is a surprise if you have read the earlier chapters; the point of show.sh is that every intermediate file is kept if you ask (MGC_KEEP=dir). The front end's output for this program:
func.func @addt(%a: tensor<?x?xf64>, %b: tensor<?x?xf64>) -> tensor<?x?xf64> {
%t1 = mg.add %a, %b : tensor<?x?xf64>, tensor<?x?xf64> -> tensor<?x?xf64>
%t2 = mg.transpose %t1 : tensor<?x?xf64> to tensor<?x?xf64>
func.return %t2 : tensor<?x?xf64>
}
func.func @main() -> i32 {
%t1 = mg.constant dense<[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]> : tensor<2x3xf64>
%t2 = mg.constant dense<[[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]]> : tensor<2x3xf64>
%t3 = tensor.cast %t1 : tensor<2x3xf64> to tensor<?x?xf64>
%t4 = tensor.cast %t2 : tensor<2x3xf64> to tensor<?x?xf64>
%t5 = func.call @addt(%t3, %t4) : (tensor<?x?xf64>, tensor<?x?xf64>) -> tensor<?x?xf64>
mg.print %t5 : tensor<?x?xf64>
%t6 = mg.constant dense<[[1.0], [2.0], [3.0]]> : tensor<3x1xf64>
%t7 = mg.constant dense<[[0.5], [0.5], [0.5]]> : tensor<3x1xf64>
%t8 = tensor.cast %t6 : tensor<3x1xf64> to tensor<?x?xf64>
%t9 = tensor.cast %t7 : tensor<3x1xf64> to tensor<?x?xf64>
%t10 = func.call @addt(%t8, %t9) : (tensor<?x?xf64>, tensor<?x?xf64>) -> tensor<?x?xf64>
mg.print %t10 : tensor<?x?xf64>
%zero = arith.constant 0 : i32
func.return %zero : i32
}
Ten examples¶
Each example is a file in docs/part20/code/examples/, shown with its real output by demo.sh. Memref base addresses (the 0x…) change from run to run and are masked below; everything else is exactly what the program printed.
1 and 2. Constants, printing, add and transpose (static shapes)¶
################ mgc run examples/01_hello.mg
| # Constants and printing: the smallest Mountain Goat program.
| let a = [[1, 2], [3, 4]]
| print a
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[1, 2],
[3, 4]]
----- exit status 0
################ mgc run examples/02_add_transpose.mg
| # Static shapes: add two 2x3 matrices, transpose the sum, print both.
| let a = [[1, 2, 3], [4, 5, 6]]
| let b = [[10, 20, 30], [40, 50, 60]]
| let s = a + b
| let t = transpose(s)
| print s
| print t
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 3] strides = [3, 1] data =
[[11, 22, 33],
[44, 55, 66]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [3, 2] strides = [2, 1] data =
[[11, 44],
[22, 55],
[33, 66]]
----- exit status 0
mg.constant becomes stores into an allocation; mg.add and mg.transpose become loops (Chapter 4); mg.print becomes a call to printMemrefF64. The sizes and strides fields show the layout: a 2x3 matrix has strides [3, 1], so element (i, j) is at offset 3i + j.
3 and 9. Dynamic shapes: one function, many sizes¶
################ mgc run examples/03_dynamic.mg
| # Dynamic shapes: one function over tensor[?x?], called with two different sizes.
| def addt(a: tensor[?x?], b: tensor[?x?]) = transpose(a + b)
| print addt([[1, 2, 3], [4, 5, 6]], [[10, 20, 30], [40, 50, 60]])
| print addt([[1], [2], [3]], [[0.5], [0.5], [0.5]])
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [3, 2] strides = [2, 1] data =
[[11, 44],
[22, 55],
[33, 66]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [1, 3] strides = [3, 1] data =
[[1.5, 2.5, 3.5]]
----- exit status 0
The function addt is compiled once. The first call passes 2x3 matrices and gets a 3x2 result; the second passes 3x1 matrices and gets 1x3. Example 9 does the same with a row vector (1x4) and a column vector (4x1) through one function:
################ mgc run examples/09_vectors.mg
| # Row and column vectors through ONE dynamic function: the same compiled code handles 1x4 and 4x1.
| def flip(a: tensor[?x?], b: tensor[?x?]) = transpose(a + b)
| print flip([[1, 2, 3, 4]], [[10, 10, 10, 10]])
| print flip([[1], [2], [3], [4]], [[5], [5], [5], [5]])
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [4, 1] strides = [1, 1] data =
[[11],
[12],
[13],
[14]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [1, 4] strides = [4, 1] data =
[[6, 7, 8, 9]]
----- exit status 0
4 and 10. When shapes do not match at run time¶
################ mgc run examples/04_mismatch.mg
| # Shapes that match only at compile time of the CALLER but not at run time: 2x3 + 3x2 through a ?x? function.
| def add(a: tensor[?x?], b: tensor[?x?]) = a + b
| print add([[1, 2, 3], [4, 5, 6]], [[1, 2], [3, 4], [5, 6]])
----- output
mg.add: operand shapes differ at runtime in dimension 0
Aborted
----- exit status 134
The front end accepted this program: both arguments are fitted to tensor[?x?]. The mismatch (2x3 against 3x2) exists only at run time. The message appears on stderr, the process dies by SIGABRT (exit status 134), and nothing else is printed. That message is Chapter 19's contribution; with the default lowering from Chapter 14 it would have been lost when stdout is not a terminal.
Example 10 is the mixed case: one parameter has a dynamic row count, the other is exactly 3x2. The first call agrees and prints; the second passes a 2x2 for a, and the check fires on dimension 0, after the first result has already been printed:
################ mgc run examples/10_mixed.mg
| # One dimension fixed, one dynamic: a has ?x2 rows, b is exactly 3x2.
| # The first call's shapes agree. The second call passes a 2x2 for a: the runtime check aborts.
| def bump(a: tensor[?x2], b: tensor[3x2]) = a + b
| print bump([[1, 2], [3, 4], [5, 6]], [[10, 10], [10, 10], [10, 10]])
| print bump([[1, 2], [3, 4]], [[10, 10], [10, 10], [10, 10]])
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [3, 2] strides = [2, 1] data =
[[11, 12],
[13, 14],
[15, 16]]
mg.add: operand shapes differ at runtime in dimension 0
Aborted
----- exit status 134
6, 7 and 8. Composition¶
################ mgc run examples/06_transpose_twice.mg
| # Transposing twice gives the original back. The shape goes 2x3 -> 3x2 -> 2x3.
| let a = [[1, 2, 3], [4, 5, 6]]
| print transpose(a)
| print transpose(transpose(a))
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [3, 2] strides = [2, 1] data =
[[1, 4],
[2, 5],
[3, 6]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 3] strides = [3, 1] data =
[[1, 2, 3],
[4, 5, 6]]
----- exit status 0
################ mgc run examples/07_expressions.mg
| # Expressions nest and chain: + is left-associative, transpose(...) can wrap anything.
| let a = [[1, 2], [3, 4]]
| let b = [[10, 20], [30, 40]]
| let c = [[100, 200], [300, 400]]
| print a + b + c
| print transpose(a + transpose(b))
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[111, 222],
[333, 444]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[11, 23],
[32, 44]]
----- exit status 0
################ mgc run examples/08_functions.mg
| # Functions with fixed (static) shapes, called from the main program and from each other.
| def double(a: tensor[2x2]) = a + a
| def quad(a: tensor[2x2]) = double(double(a))
| let m = [[1, 2], [3, 4]]
| print double(m)
| print quad(m)
----- output
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[2, 4],
[6, 8]]
Unranked Memref base@ = 0x… rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[4, 8],
[12, 16]]
----- exit status 0
Example 6 transposes twice and returns to the original 2x3 shape. Example 7 shows that + is left-associative and that transpose can wrap any expression. Example 8 defines double and builds quad out of it: functions calling functions, with fixed shapes checked entirely by the front end.
5. The GPU path, compile-only¶
################ mgc ptx examples/05_gpu.mg
| # The GPU path. Parameters (not constants), so nothing folds away at compile time.
| # Use: mgc ptx 05_gpu.mg Compile-only: this machine has no GPU.
| def add(a: tensor[2x2], b: tensor[2x2]) = a + b
----- output (first 30 lines)
//
// Generated by LLVM NVPTX Back-End
//
.version 6.0
.target sm_70
.address_size 64
// .globl add_kernel
.visible .entry add_kernel(
.param .u64 add_kernel_param_0,
.param .u64 add_kernel_param_1,
.param .u64 add_kernel_param_2,
.param .u64 add_kernel_param_3,
.param .u64 add_kernel_param_4,
.param .u64 add_kernel_param_5,
.param .u64 add_kernel_param_6,
.param .u64 add_kernel_param_7,
.param .u64 add_kernel_param_8,
.param .u64 add_kernel_param_9,
.param .u64 add_kernel_param_10,
.param .u64 add_kernel_param_11,
.param .u64 add_kernel_param_12,
.param .u64 add_kernel_param_13,
.param .u64 add_kernel_param_14,
.param .u64 add_kernel_param_15,
.param .u64 add_kernel_param_16,
.param .u64 add_kernel_param_17,
.param .u64 add_kernel_param_18,
mgc ptx runs Chapter 10's recipe on the program (affine-parallelize, gpu-kernel-outlining, convert-gpu-to-nvvm, gpu-module-to-binary) and prints the PTX. The example uses a def with parameters on purpose: the first draft used let constants, and the whole program was constant-folded into stores of the precomputed answer, so there was no loop and no kernel at all. (The first symptom was Chapter 10's decoder crashing on a missing assembly field.)
The kernel takes 23 parameters because each matrix argument expands to a 7-field memref descriptor (Chapter 10); the PTX is the real output of the real pipeline. Nothing was launched. This machine has no GPU and no CUDA driver, and the PTX was never assembled with ptxas.
Using it from C++¶
A compiler is more useful when other programs can call what it compiled. mgc lib compiles only the defs (no main) to an object file and generates a C++ header that wraps the compiled functions. The Mountain Goat source:
# Mountain Goat functions to be called from C++.
def add(a: tensor[?x?], b: tensor[?x?]) = a + b
def add_t(a: tensor[?x?], b: tensor[?x?]) = transpose(a + b)
def scale2(a: tensor[?x?]) = a + a
def rot(a: tensor[2x3]) = transpose(a)
The generated header, kernels_generated.h, has three parts. A mg::Matrix class that owns a row-major std::vector<double>; a table of extern "C" declarations using the memref calling convention Chapter 8 introduced (each matrix argument is seven scalars: two pointers, an offset, two sizes and two strides); and one inline C++ function per def, which packs the descriptor, calls the compiled code, copies the result out through the strides, and frees the buffer the compiled code allocated:
The generated kernels_generated.h (click to expand)
// Generated by mgfront.py -- do not edit. C++ view of compiled Mountain Goat functions.
#pragma once
#include <cstdint>
#include <cstdlib>
#include <stdexcept>
#include <string>
#include <vector>
namespace mg {
// A dense row-major f64 matrix that owns its storage.
struct Matrix {
int64_t rows = 0, cols = 0;
std::vector<double> data;
Matrix() = default;
Matrix(int64_t r, int64_t c, std::vector<double> d) : rows(r), cols(c), data(std::move(d)) {
if ((int64_t)data.size() != r * c) throw std::invalid_argument("Matrix: data size != rows*cols");
}
double &operator()(int64_t i, int64_t j) { return data[i * cols + j]; }
double operator()(int64_t i, int64_t j) const { return data[i * cols + j]; }
};
// The memref descriptor the compiled code returns (rank 2).
struct Desc { double *allocated, *aligned; int64_t offset, sizes[2], strides[2]; };
inline Matrix take(Desc d) { // copy out through the strides, then release the callee's malloc
Matrix m; m.rows = d.sizes[0]; m.cols = d.sizes[1]; m.data.resize(m.rows * m.cols);
for (int64_t i = 0; i < m.rows; i++)
for (int64_t j = 0; j < m.cols; j++)
m.data[i * m.cols + j] = d.aligned[d.offset + i * d.strides[0] + j * d.strides[1]];
std::free(d.allocated);
return m;
}
inline void expect(const Matrix &m, int64_t r, int64_t c, const char *fn, const char *arg) {
if ((r >= 0 && m.rows != r) || (c >= 0 && m.cols != c))
throw std::invalid_argument(std::string(fn) + ": argument '" + arg + "' has the wrong shape");
}
} // namespace mg
extern "C" {
mg::Desc add(double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t, double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t);
mg::Desc add_t(double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t, double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t);
mg::Desc scale2(double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t);
mg::Desc rot(double*, double*, int64_t, int64_t, int64_t, int64_t, int64_t);
}
namespace mg {
// add(a: ?x?, b: ?x?)
inline Matrix add(const Matrix &a, const Matrix &b) {
return take(::add(const_cast<double*>(a.data.data()), const_cast<double*>(a.data.data()), 0, a.rows, a.cols, a.cols, 1, const_cast<double*>(b.data.data()), const_cast<double*>(b.data.data()), 0, b.rows, b.cols, b.cols, 1));
}
// add_t(a: ?x?, b: ?x?)
inline Matrix add_t(const Matrix &a, const Matrix &b) {
return take(::add_t(const_cast<double*>(a.data.data()), const_cast<double*>(a.data.data()), 0, a.rows, a.cols, a.cols, 1, const_cast<double*>(b.data.data()), const_cast<double*>(b.data.data()), 0, b.rows, b.cols, b.cols, 1));
}
// scale2(a: ?x?)
inline Matrix scale2(const Matrix &a) {
return take(::scale2(const_cast<double*>(a.data.data()), const_cast<double*>(a.data.data()), 0, a.rows, a.cols, a.cols, 1));
}
// rot(a: 2x3)
inline Matrix rot(const Matrix &a) {
expect(a, 2, 3, "rot", "a");
return take(::rot(const_cast<double*>(a.data.data()), const_cast<double*>(a.data.data()), 0, a.rows, a.cols, a.cols, 1));
}
} // namespace mg
An ordinary C++ program that uses it:
// An ordinary C++ program using Mountain Goat functions through the generated header.
#include <cstdio>
#include "kernels.h"
static void show(const char *name, const mg::Matrix &m) {
std::printf("%s (%ldx%ld):\n", name, (long)m.rows, (long)m.cols);
for (int64_t i = 0; i < m.rows; i++) {
for (int64_t j = 0; j < m.cols; j++) std::printf(" %g", m(i, j));
std::printf("\n");
}
}
int main(int argc, char **argv) {
mg::Matrix a(2, 3, {1, 2, 3, 4, 5, 6});
mg::Matrix b(2, 3, {10, 20, 30, 40, 50, 60});
show("add(a, b)", mg::add(a, b));
show("add_t(a, b)", mg::add_t(a, b));
show("scale2(a)", mg::scale2(mg::scale2(a))); // results are values, so calls compose
show("rot(a)", mg::rot(a)); // static shape 2x3, checked by the header
// Static shape violated: the C++ wrapper throws before the compiled code runs.
try { mg::rot(mg::Matrix(3, 2, {1, 2, 3, 4, 5, 6})); }
catch (const std::invalid_argument &e) { std::printf("caught: %s\n", e.what()); }
// Dynamic shapes violated: the compiled code's own runtime check aborts (message on stderr).
if (argc > 1) {
std::printf("about to add 2x3 and 3x2...\n"); std::fflush(stdout);
mg::add(a, mg::Matrix(3, 2, {1, 2, 3, 4, 5, 6}));
std::printf("not reached\n");
}
return 0;
}
Built and run by run.sh:
#!/bin/sh
# Compile kernels.mg with mgc, then build and run the C++ program against it. Output: run_out.txt
HERE=$(cd "$(dirname "$0")" && pwd); W=$HERE/../work/cpp; mkdir -p $W
$HERE/../mgc lib $HERE/kernels.mg -o $W
cp $W/kernels.h $HERE/kernels_generated.h
clang++-18 -std=c++17 -Wall -I$W $HERE/app.cpp $W/kernels.o -o $W/app
echo "--- ./app"; $W/app
echo "--- ./app mismatch (exit status follows)"; $W/app mismatch; echo "exit=$?"
wrote /home/user/ir_forge_compiler_from_scratch/docs/part20/code/cpp/../work/cpp/kernels.o and /home/user/ir_forge_compiler_from_scratch/docs/part20/code/cpp/../work/cpp/kernels.h
--- ./app
add(a, b) (2x3):
11 22 33
44 55 66
add_t(a, b) (3x2):
11 44
22 55
33 66
scale2(a) (2x3):
4 8 12
16 20 24
rot(a) (3x2):
1 4
2 5
3 6
caught: rot: argument 'a' has the wrong shape
--- ./app mismatch (exit status follows)
add(a, b) (2x3):
11 22 33
44 55 66
add_t(a, b) (3x2):
11 44
22 55
33 66
scale2(a) (2x3):
4 8 12
16 20 24
rot(a) (3x2):
1 4
2 5
3 6
caught: rot: argument 'a' has the wrong shape
about to add 2x3 and 3x2...
mg.add: operand shapes differ at runtime in dimension 0
Aborted
exit=134
Two kinds of shape error appear at two different layers. rot was declared tensor[2x3], so the generated C++ wrapper checks the shape and throws std::invalid_argument before the compiled code runs; the program catches it and continues. add was declared tensor[?x?], so a mismatch reaches the compiled code's own run-time check, which writes its message to stderr and aborts the whole process; C++ cannot catch an abort. That asymmetry is a real property of this design, not a bug to hide: a compiled function can only report a failed check the way Chapter 19's pass makes it report one.
What about CUDA?¶
Mountain Goat's GPU path ends at PTX, and PTX is exactly what a CUDA program loads. A C++ host program would load the PTX with the CUDA driver API (cuModuleLoadData) and launch the kernel with cuLaunchKernel, passing the 23 kernel parameters in the order Chapter 10 decoded. This chapter does not do that, for a plain reason: there is no GPU and no CUDA driver in this environment, so any such host program could be neither built against cuda.h nor run, and the book does not print code it never executed. What is done and tested is everything up to the PTX (example 5, and Chapters 10, 11 and 16), and Chapter 11's CPU stub runtime checks the host-side glue. Running the kernel on a real GPU is open work, listed under the limits.
Tests¶
Eight new lit tests (suite: 60), in test/frontend/. They run the real examples from part20/code/examples/ instead of copies, so the book and the tests cannot drift apart.
| Test | What it checks |
|---|---|
hello-and-static |
examples 1 and 2 compile, run, and print the right matrices |
dynamic-runs |
examples 3 and 9: one compiled function, several sizes |
more-examples |
examples 6, 7 and 8 |
runtime-mismatch-aborts |
examples 4 and 10: abort with the message on stderr (and example 10's first result still printed) |
front-end-errors |
both error files: the exact file:line: error: text, and no MLIR emitted |
emit-mlir |
the front end's output for example 3, including the tensor.cast |
ptx-from-source |
example 5 yields an add_kernel entry with loads, add.rn.f64, store |
cpp-interop |
the C++ program builds with -Wall -Werror, runs, and the mismatch aborts with the message |
To check that these tests can fail, prove_tests_can_fail.sh runs them against the Chapter 19 build (which has no tensor.cast lowering) and against four deliberately broken copies of the front end and driver:
#!/bin/sh
# Show that the Chapter 20 tests can fail: (1) against Chapter 19's mg-opt (no tensor.cast lowering),
# (2) against four deliberately broken copies of the front end / driver. Output: prove_tests_can_fail_out.txt
# READ THIS FIRST: this script runs the Chapter 20 tests against an older build and against deliberately broken copies of the front end and driver.
# FAIL lines (and non-zero exit statuses) in this script's output are the EXPECTED result: each broken copy SHOULD make at least one test fail (a FAIL line means a test caught the bug). The "baseline" sections must show all tests passing.
# 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; W=$HERE/work/prove; rm -rf $W; mkdir -p $W
run() { # label, env... -> prints which frontend tests failed
printf '%s\n' "### $1"; shift
env "$@" MG_TEST_TMP=$W/out sh $LIT/run_lit.sh --filter frontend 2>&1 | grep -E "^(FAIL| Passed| Failed)" | sed 's#IR Forge / Mountain Goat :: ##'
}
run "baseline: Chapter 20 build" MG_OPT=$HERE/build/mg-opt
run "Chapter 19 build (no tensor.cast lowering)" MG_OPT=$HERE/../../part19/code/build/mg-opt
# Broken copies: the lit config reads mgc/mgfront from part20/code, so mutate in place and always restore.
cp $HERE/mgfront.py $W/mgfront.orig; cp $HERE/mgc $W/mgc.orig
restore() { cp $W/mgfront.orig $HERE/mgfront.py; cp $W/mgc.orig $HERE/mgc; }
trap restore EXIT
mutate() { # label file sed-expr
restore; cp $2 $W/before; sed -i "$3" $2
if cmp -s $2 $W/before; then echo "### $1: MUTATION DID NOT APPLY"; return; fi
run "mutation: $1" MG_OPT=$HERE/build/mg-opt
}
mutate "front end stops checking static add shapes" $HERE/mgfront.py 's/if a is not None and b is not None and a != b:/if False:/'
mutate "front end drops tensor.cast for dynamic calls" $HERE/mgfront.py 's/if s != p: # static/if False: # static/'
mutate "driver forgets Chapter 19's stderr pass" $HERE/mgc "s/ --mg-lower-assert-to-stderr//"
mutate "driver links without the runtime printer" $HERE/mgc 's/ -lmlir_runner_utils//'
restore; echo "### restored"; run "baseline again" MG_OPT=$HERE/build/mg-opt
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.
### baseline: Chapter 20 build
Passed : 8 (8.89%)
### Chapter 19 build (no tensor.cast lowering)
FAIL: frontend/dynamic-runs.mlir (1 of 8)
FAIL: frontend/runtime-mismatch-aborts.mlir (5 of 8)
Passed : 6 (6.67%)
Failed : 2 (2.22%)
### mutation: front end stops checking static add shapes
FAIL: frontend/front-end-errors.mlir (5 of 8)
Passed : 7 (7.78%)
Failed : 1 (1.11%)
### mutation: front end drops tensor.cast for dynamic calls
FAIL: frontend/dynamic-runs.mlir (1 of 8)
FAIL: frontend/runtime-mismatch-aborts.mlir (3 of 8)
FAIL: frontend/emit-mlir.mlir (5 of 8)
Passed : 5 (5.56%)
Failed : 3 (3.33%)
### mutation: driver forgets Chapter 19's stderr pass
FAIL: frontend/runtime-mismatch-aborts.mlir (2 of 8)
FAIL: frontend/cpp-interop.mlir (6 of 8)
Passed : 6 (6.67%)
Failed : 2 (2.22%)
### mutation: driver links without the runtime printer
FAIL: frontend/runtime-mismatch-aborts.mlir (1 of 8)
FAIL: frontend/dynamic-runs.mlir (2 of 8)
FAIL: frontend/more-examples.mlir (3 of 8)
FAIL: frontend/hello-and-static.mlir (5 of 8)
Passed : 4 (4.44%)
Failed : 4 (4.44%)
### restored
### baseline again
Passed : 8 (8.89%)
Every one of the five breakages makes at least one test fail, and the baseline passes before and after (the script restores the files it mutates). The full suite passes against the new build:
PASS: IR Forge / Mountain Goat :: lowering/dynamic-transpose-no-check.mlir (58 of 60)
PASS: IR Forge / Mountain Goat :: lowering/bufferize-dynamic-add.mlir (59 of 60)
PASS: IR Forge / Mountain Goat :: verifier/dynamic-accepted.mlir (60 of 60)
Testing Time: 1.48s
Total Discovered Tests: 60
Passed: 60 (100.00%)
Limits and what is not established¶
- The language is tiny. Matrices of
f64, rank 2,+andtranspose. No loops, scalars, conditionals, element access, or other element types. - The front end is a convenience, not a security boundary. It catches static shape mismatches; dynamic ones are caught only at run time, by an abort.
- A matrix literal's numbers are parsed as written; there is no check for overflow or
nan. - Function names become global C symbols. A
defnamedexporfreewould collide with the C library. The front end does not check. - C++ side: the generated wrapper copies matrices in and out on every call, which is simple and correct but not fast. Nothing here measures performance, and no speed claim is made. A failed dynamic check aborts the process and cannot be caught.
- GPU: PTX was produced and inspected but never assembled with
ptxas, never loaded by a driver, never launched. No claim about correctness on a GPU, or speed, is made. No CUDA host program exists in this book. - Platform: x86-64 Linux with LLVM 18 and the
mlir-18packages; POSIX only (Chapter 19's limits still hold). - Output format:
mg.printuses MLIR's runtime printer; its format (with thebase@address) is not Mountain Goat's design. - The driver hides the C compiler's stderr during the final link (shown above to have hidden a real error once). A better driver would show it.
Reproducing¶
cd docs/part20/code
./build.sh # builds ./build/mg-opt (Chapter 20's)
./mgc run examples/03_dynamic.mg
./mgc ptx examples/05_gpu.mg
./demo.sh > demo_out.txt # all ten examples
cpp/run.sh # the C++ program
cd ../part15/code && ./run_lit.sh # 60 tests
Chapter summary¶
- Mountain Goat finally has a surface syntax and a front end:
.mgfiles are parsed, shape-checked with file-and-line errors, and translated tomgdialect MLIR. - The driver
mgcruns every stage built in the book (front end, dialect lowering with run-time shape checks, Chapter 19's stderr abort, LLVM IR, clang) behind one command, and keeps every intermediate file on request. - A single new lowering,
tensor.casttomemref.cast, was needed so fixed-shape values can be passed to dynamic-shape functions. - Ten example programs ran for real: static, dynamic, mismatched, composed, and one compiled to PTX.
mgc libgenerates a C++ header, so ordinary C++ code calls compiled Mountain Goat functions; static shape errors throw, dynamic ones abort with a message on stderr.- The CUDA story ends at PTX: real, inspected, and not launched.
- Eight new tests (60 total). Each test file was shown to fail under at least one injected bug; that is a weaker claim than "each test is strong", and Chapter 21's review section shows what the difference looks like.
Self-check questions¶
-
What is the difference between a front end and a driver? Which parts of this chapter are each?
Answer
A front end reads the source text, checks it, and produces the compiler's internal form: here
mgfront.py, which parses.mgtext and writesmg-dialect MLIR. A driver runs the stages in order and connects them: heremgc, which calls the front end, thenmg-opt,mlir-translateandclang. The front end knows the language; the driver knows the pipeline. -
Why does the front end accept
add([[1,2,3],[4,5,6]], [[1,2],[3,4],[5,6]])whenaddis declaredtensor[?x?], and what stops it at run time?Answer
The front end can only compare dimensions it knows. Both parameters are
tensor[?x?], so it accepts any arguments that fit?x?; the sizes 2x3 and 3x2 are not compared against each other. At run time the lowering ofmg.addcompares the actual extents withcf.assert, and Chapter 19's pass makes that assert write its message to stderr and abort. -
Why did the first GPU example produce no kernel, and what changed to fix it?
Answer
With
letconstants as inputs,mg.addof twomg.constantoperands was folded by the compiler into a single precomputed constant, so the program became stores of the final numbers: no loop, hence no loop to turn into a kernel. The fix was to make the inputs function parameters, whose values the compiler cannot know, so the addition survives as a loop. -
Why can the C++ wrapper throw an exception for
rotbut not for a failed dynamic check inadd?Answer
The
rotparameter has a static shape (2x3), which the generated C++ code can compare against the matrix'srowsandcolsbefore calling anything, so it throwsstd::invalid_argument. A failed dynamic check happens inside the compiled code, which has no way to throw a C++ exception: it writes its message and callsabort(), which ends the process. -
What does
tensor.castfromtensor<2x3xf64>totensor<?x?xf64>change, and what would a cast in the other direction need that this one does not?Answer
It changes only the static type: the data is the same, the compiler just stops promising the sizes. Static to dynamic is always safe. The reverse direction (
?x?to2x3) would claim sizes the compiler cannot know, so a correct lowering would need a run-time check that the actual sizes are 2 and 3. -
Which of the five breakages in
prove_tests_can_fail_out.txtis caught by thecpp-interoptest, and why?Answer
The
cpp-interoptest is caught by 'driver forgets Chapter 19's stderr pass': that test checks the abort message on stderr for the C++ program's mismatch run, and without the pass the message goes to stdout (and is lost when stdout is not a terminal). -
Why is the CUDA host program not in this chapter, and what exactly would it need that this environment lacks?
Answer
There is no GPU and no CUDA driver in this environment, and no
cuda.hto compile against. Such a program would load the PTX withcuModuleLoadDataand launch withcuLaunchKernel, passing the 23 kernel parameters in Chapter 10's order; none of that can be built or run here, and the book does not print code it never executed.