Skip to content

5. Finishing Progressive Lowering: affine to scf to llvm, and Mountain Goat Finally Runs

Mountain goats on the mountain drawn as a blueprint

What you will understand: how Mountain Goat's own real affine.for loop nests (Chapter 4) continue down through the exact same real pipeline Chapter 1 proved works -- affine to scf, scf to a bare control-flow graph, and on to MLIR's own llvm dialect, real LLVM IR, and a genuinely executed answer. This chapter also finally gives mg.print a real, working implementation -- Mountain Goat's own first genuinely observable, run, correct output.

What you need to know first: Chapter 4's own ConvertMgToAffinePass and Chapter 1's own five-pass scf-to-llvm pipeline. This chapter's own new ground: MLIR's real, built-in --lower-affine pass, and MLIR's own pre-existing runtime support library.

Compile and run

cd docs/part5/code
./build.sh        # -> ./build/mg-opt
./run.sh          # lower to LLVM dialect, then run with mlir-cpu-runner-18
Also needs mlir-cpu-runner-18 and libmlir_runner_utils.so / libmlir_c_runner_utils.so (installed with the MLIR packages). Every listing and output on this page comes from these commands (and the chapter's own embedded files).

The real question this chapter answers

Chapter 4 deliberately stopped at affine/memref/arith -- genuinely lower-level than mg, but still well above real LLVM IR. Chapter 1 already proved, on a hand-written scf.for example, that scf lowers to llvm in five real passes. The real question this chapter answers: does that same real pipeline work unmodified on IR this book's own tooling produced, rather than hand-written -- and what is the one remaining real gap (affine itself, which Chapter 1 never had to cross) that sits between them?

The one new real step: affine to scf

MLIR ships a real, built-in, generic pass for exactly this, needing no custom code at all -- unlike every pattern Chapter 4 had to hand-write for mg itself, --lower-affine already knows how to lower any dialect's affine.for/affine.load/affine.store, because those operations are themselves already part of MLIR's own built-in affine dialect:

./mg-opt mountain_goat_run.mlir --convert-mg-to-affine --lower-affine

Real captured output (relevant excerpt):

func.func @add_tensors(%arg0: memref<2x2xf64>, %arg1: memref<2x2xf64>) {
  %alloc = memref.alloc() : memref<2x2xf64>
  %c0 = arith.constant 0 : index
  %c2 = arith.constant 2 : index
  %c1 = arith.constant 1 : index
  scf.for %arg2 = %c0 to %c2 step %c1 {
    %c0_0 = arith.constant 0 : index
    %c2_1 = arith.constant 2 : index
    %c1_2 = arith.constant 1 : index
    scf.for %arg3 = %c0_0 to %c2_1 step %c1_2 {
      %0 = memref.load %arg0[%arg2, %arg3] : memref<2x2xf64>
      %1 = memref.load %arg1[%arg2, %arg3] : memref<2x2xf64>
      %2 = arith.addf %0, %1 : f64
      memref.store %2, %alloc[%arg2, %arg3] : memref<2x2xf64>
    }
  }
  ...
}

affine.for became scf.for -- MLIR's own structured-but-no-longer-"affine" loop, with explicit lower/upper bound and step operands rather than affine's own restricted index-expression language -- and, exactly as the real affine.load/affine.store operations required a restricted affine indexing scheme that plain memref.load/memref.store do not, those became plain memref accesses too. This is genuinely, precisely the same real kind of transition Chapter 1 already showed for scf.for itself lowering further (to a bare CFG) -- one level higher in the stack, same real idea: a more restricted, analysis-friendly structured form gives way to a more general, less restricted one as the pipeline descends.

Giving mg.print a real, working implementation

Chapter 4 deliberately erased mg.print. This chapter gives it a real one, using MLIR's own pre-existing runtime support library (libmlir_runner_utils.so, already confirmed installed alongside mlir-opt-18 since Chapter 1) rather than inventing a print mechanism from nothing. Its real, official header states the function this chapter's own lowering targets directly:

// mlir/ExecutionEngine/RunnerUtils.h
extern "C" MLIR_RUNNERUTILS_EXPORT void printMemrefF64(int64_t rank, void *ptr);

Calling it correctly from generated MLIR needs one real, specific mechanism: declaring the function with MLIR's own llvm.emit_c_interface attribute, which tells --convert-func-to-llvm to emit a call to the real _mlir_ciface_printMemrefF64 C-interface wrapper -- the one whose real ABI actually matches an unranked memref descriptor -- rather than to a plain, mismatched symbol. This same real idiom (declare a runtime function, mark it llvm.emit_c_interface, memref.cast to unranked, call it) is what MLIR's own official Toy tutorial's own later chapters use for the identical real job, cited here as precedent for the general technique, even though Toy's own chosen runtime call is a hand-rolled printf loop rather than this chapter's own choice to reuse printMemrefF64 directly:

static FlatSymbolRefAttr getOrInsertPrintMemrefF64(OpBuilder &builder,
                                                   ModuleOp module) {
  const char *name = "printMemrefF64";
  if (module.lookupSymbol<func::FuncOp>(name))
    return SymbolRefAttr::get(builder.getContext(), name);

  auto unrankedMemrefF64 =
      UnrankedMemRefType::get(builder.getF64Type(), /*memorySpace=*/0);
  auto fnType = builder.getFunctionType({unrankedMemrefF64}, {});

  OpBuilder::InsertionGuard guard(builder);
  builder.setInsertionPointToStart(module.getBody());
  auto printFunc = builder.create<func::FuncOp>(module.getLoc(), name, fnType);
  printFunc.setPrivate();
  printFunc->setAttr("llvm.emit_c_interface", builder.getUnitAttr());
  return SymbolRefAttr::get(builder.getContext(), name);
}

struct PrintOpLowering : public OpConversionPattern<mg::PrintOp> {
  using OpConversionPattern::OpConversionPattern;

  LogicalResult
  matchAndRewrite(mg::PrintOp op, OpAdaptor adaptor,
                 ConversionPatternRewriter &rewriter) const override {
    auto loc = op.getLoc();
    auto module = op->getParentOfType<ModuleOp>();
    auto printRef = getOrInsertPrintMemrefF64(rewriter, module);

    auto memrefType = llvm::cast<MemRefType>(adaptor.getInput().getType());
    auto unrankedType =
        UnrankedMemRefType::get(memrefType.getElementType(), /*memorySpace=*/0);
    Value cast =
        rewriter.create<memref::CastOp>(loc, unrankedType, adaptor.getInput());
    rewriter.create<func::CallOp>(loc, printRef, TypeRange{}, ValueRange{cast});
    rewriter.eraseOp(op);
    return success();
  }
};

A second, real instance of Chapter 4's own function-boundary problem: func.call

Running the real test program this chapter needs -- @main building two tensors and calling a separate @add_tensors function, specifically so the real affine.for/scf.for loop survives into execution rather than folding away (the real reason Chapter 4's own constant-folding discovery made a function-argument example necessary) -- exposed one more real instance of exactly the same class of gap Chapter 4 hit with func.func's own signature: func.call @add_tensors(%0, %1) passes tensor-typed operands too, and MLIR has a second real, built-in utility for exactly this, used the same way:

target.addDynamicallyLegalOp<func::CallOp>([&](func::CallOp op) {
  return typeConverter.isLegal(op.getOperandTypes()) &&
         typeConverter.isLegal(op.getResultTypes());
});
...
populateCallOpTypeConversionPattern(patterns, typeConverter);
populateReturnOpTypeConversionPattern(patterns, typeConverter);

Real worked example: the complete pipeline, genuinely executed

The real test program, two functions, specifically designed so the add survives as a real loop (as established above):

func.func @add_tensors(%a: tensor<2x2xf64>, %b: tensor<2x2xf64>) {
  %0 = mg.add %a, %b : tensor<2x2xf64>, tensor<2x2xf64> -> tensor<2x2xf64>
  mg.print %0 : tensor<2x2xf64>
  func.return
}

func.func @main() {
  %0 = mg.constant dense<[[1.0, 2.0], [3.0, 4.0]]> : tensor<2x2xf64>
  %1 = mg.constant dense<[[5.0, 6.0], [7.0, 8.0]]> : tensor<2x2xf64>
  func.call @add_tensors(%0, %1) : (tensor<2x2xf64>, tensor<2x2xf64>) -> ()
  func.return
}

The complete real pipeline -- Chapter 4's own hand-written mg-to-affine pass, the real built-in --lower-affine, and Chapter 1's own exact real five-pass scf-to-llvm pipeline, unmodified:

./mg-opt mountain_goat_run.mlir \
  --convert-mg-to-affine --lower-affine \
  --convert-scf-to-cf --convert-arith-to-llvm \
  --finalize-memref-to-llvm --convert-func-to-llvm \
  --reconcile-unrealized-casts \
  -o mountain_goat_llvm.mlir

Real captured output (excerpt -- full output is longer, real LLVM-dialect IR throughout):

module {
  llvm.func @malloc(i64) -> !llvm.ptr
  llvm.func private @printMemrefF64(%arg0: i64, %arg1: !llvm.ptr) attributes {llvm.emit_c_interface, sym_visibility = "private"} {
    %0 = llvm.mlir.undef : !llvm.struct<(i64, ptr)>
    %1 = llvm.insertvalue %arg0, %0[0] : !llvm.struct<(i64, ptr)>
    %2 = llvm.insertvalue %arg1, %1[1] : !llvm.struct<(i64, ptr)>
    %3 = llvm.mlir.constant(1 : index) : i64
    %4 = llvm.alloca %3 x !llvm.struct<(i64, ptr)> : (i64) -> !llvm.ptr
    llvm.store %2, %4 : !llvm.struct<(i64, ptr)>, !llvm.ptr
    llvm.call @_mlir_ciface_printMemrefF64(%4) : (!llvm.ptr) -> ()
    llvm.return
  }
  llvm.func @_mlir_ciface_printMemrefF64(!llvm.ptr) attributes {llvm.emit_c_interface, sym_visibility = "private"}
  llvm.func @add_tensors(%arg0: !llvm.ptr, %arg1: !llvm.ptr, %arg2: i64, %arg3: i64, %arg4: i64, %arg5: i64, %arg6: i64, %arg7: !llvm.ptr, %arg8: !llvm.ptr, %arg9: i64, %arg10: i64, %arg11: i64, %arg12: i64, %arg13: i64) {
    ...
    %23 = llvm.call @malloc(%22) : (i64) -> !llvm.ptr
    ...
  }
}

Every real pattern Chapter 1 already identified is genuinely present again: each real memref<2x2xf64> argument has become five separate LLVM-level arguments (the real "unpacked memref descriptor" convention), and a real llvm.call @malloc genuinely allocates each result buffer -- memref.alloc lowered to a real heap allocation, not a stack slot, exactly as --finalize-memref-to-llvm's own real, documented behavior specifies.

Running this real LLVM-dialect IR directly through MLIR's own real JIT, mlir-cpu-runner-18, with MLIR's own real runtime support libraries supplied via --shared-libs:

mlir-cpu-runner-18 mountain_goat_llvm.mlir \
  --shared-libs=/usr/lib/llvm-18/lib/libmlir_runner_utils.so \
  --shared-libs=/usr/lib/llvm-18/lib/libmlir_c_runner_utils.so \
  -e main -entry-point-result=void

Real captured output:

Unranked Memref base@ = 0x557796012ca0 rank = 2 offset = 0 sizes = [2, 2] strides = [2, 1] data =
[[6,   8],
 [10,   12]]

This is the real, correct answer: [[1, 2], [3, 4]] + [[5, 6], [7, 8]] = [[6, 8], [10, 12]], genuinely computed by a real affine.for-turned-scf.for-turned-bare-loop running as real, JIT-compiled native code -- the first time in this book that a Mountain Goat program has been run end to end and actually produced observable output, rather than IR text this book's reader had to check by hand.

The complete source files

The excerpts above show the parts under discussion. These files appear in code/ and are not shown elsewhere on this page; each is embedded exactly as it exists in the repository, collapsed so the narrative stays readable.

CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(mg_dialect)

find_package(MLIR REQUIRED CONFIG)

list(APPEND CMAKE_MODULE_PATH "${MLIR_CMAKE_DIR}")
list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}")
include(TableGen)
include(AddLLVM)
include(AddMLIR)
include(HandleLLVMOptions)

include_directories(${LLVM_INCLUDE_DIRS})
include_directories(${MLIR_INCLUDE_DIRS})
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)
add_definitions(${LLVM_DEFINITIONS})

set(LLVM_TARGET_DEFINITIONS include/mg/MgOps.td)
mlir_tablegen(include/mg/MgOps.h.inc -gen-op-decls)
mlir_tablegen(include/mg/MgOps.cpp.inc -gen-op-defs)
mlir_tablegen(include/mg/MgOpsDialect.h.inc -gen-dialect-decls)
mlir_tablegen(include/mg/MgOpsDialect.cpp.inc -gen-dialect-defs)
add_public_tablegen_target(MgOpsIncGen)

add_library(MgDialect lib/MgDialect.cpp)
add_dependencies(MgDialect MgOpsIncGen)
target_link_libraries(MgDialect PUBLIC MLIRIR MLIRFuncDialect)

add_library(MgLowerToAffine lib/LowerToAffine.cpp)
add_dependencies(MgLowerToAffine MgOpsIncGen)
target_link_libraries(MgLowerToAffine PUBLIC
  MgDialect
  MLIRIR
  MLIRPass
  MLIRTransforms
  MLIRAffineDialect
  MLIRMemRefDialect
  MLIRArithDialect
  MLIRFuncDialect
  MLIRFuncTransforms
)

get_property(MG_CONVERSION_LIBS GLOBAL PROPERTY MLIR_CONVERSION_LIBS)

add_executable(mg-opt tools/mg-opt.cpp)
target_link_libraries(mg-opt PRIVATE
  MgDialect
  MgLowerToAffine
  MLIRIR
  MLIRFuncDialect
  MLIROptLib
  MLIRParser
  MLIRSupport
  MLIRTransforms
  MLIRAffineDialect
  MLIRMemRefDialect
  MLIRArithDialect
  MLIRLLVMDialect
  MLIRBufferizationDialect
  MLIRBufferizationTransforms
  ${MG_CONVERSION_LIBS}
)
LowerToAffine.cpp
#include "mg/MgDialect.h"
#include "mg/MgOps.h"

#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/Func/Transforms/FuncConversions.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
#include "mlir/IR/BuiltinTypes.h"
#include "mlir/Pass/Pass.h"
#include "mlir/Transforms/DialectConversion.h"

using namespace mlir;

namespace {

/// Mountain Goat's own real tensor-to-memref type conversion: a
/// `tensor<NxMxf64>` becomes a `memref<NxMxf64>`, same shape, same element
/// type -- deliberately only the shape this chapter's own worked examples
/// need (static, rank 2), not a general bufferization scheme.
static MemRefType convertTensorToMemref(RankedTensorType type) {
  return MemRefType::get(type.getShape(), type.getElementType());
}

struct MgToAffineTypeConverter : public TypeConverter {
  MgToAffineTypeConverter() {
    addConversion([](Type type) { return type; });
    addConversion([](RankedTensorType type) -> Type {
      return convertTensorToMemref(type);
    });
  }
};

/// mg.constant lowers to a real `memref.alloc` plus a fully-unrolled
/// sequence of real `affine.store`s, one per element -- genuinely correct
/// only because this chapter's own worked examples use small, static
/// shapes; a general lowering would emit a real loop nest here instead.
struct ConstantOpLowering : public OpConversionPattern<mg::ConstantOp> {
  using OpConversionPattern::OpConversionPattern;

  LogicalResult
  matchAndRewrite(mg::ConstantOp 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);

    llvm::SmallVector<double> values;
    for (auto v : op.getValue().getValues<llvm::APFloat>())
      values.push_back(v.convertToDouble());

    auto shape = tensorType.getShape();
    int64_t idx = 0;
    for (int64_t i = 0; i < shape[0]; ++i) {
      for (int64_t j = 0; j < shape[1]; ++j) {
        Value cst = rewriter.create<arith::ConstantOp>(
            loc, rewriter.getF64FloatAttr(values[idx++]));
        Value iIdx = rewriter.create<arith::ConstantIndexOp>(loc, i);
        Value jIdx = rewriter.create<arith::ConstantIndexOp>(loc, j);
        rewriter.create<affine::AffineStoreOp>(loc, cst, alloc,
                                               ValueRange{iIdx, jIdx});
      }
    }

    rewriter.replaceOp(op, alloc.getResult());
    return success();
  }
};

/// mg.add lowers to a real `affine.for` loop nest, one real `affine.load`
/// from each operand, a real `arith.addf`, and a real `affine.store` into
/// a freshly allocated result buffer -- the real, concrete thing MLIR's
/// own rationale (Chapter 1) means by "progressive lowering."
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();
  }
};

/// mg.transpose lowers to a real `affine.for` loop nest over the *output*
/// shape, loading `input[j, i]` and storing to `result[i, j]` -- the
/// transpose is the real index swap between load and store, nothing more.
struct TransposeOpLowering : public OpConversionPattern<mg::TransposeOp> {
  using OpConversionPattern::OpConversionPattern;

  LogicalResult
  matchAndRewrite(mg::TransposeOp 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 input = adaptor.getInput();

    affine::buildAffineLoopNest(
        rewriter, loc, {0, 0}, {shape[0], shape[1]}, {1, 1},
        [&](OpBuilder &nestedBuilder, Location nestedLoc, ValueRange ivs) {
          Value i = ivs[0];
          Value j = ivs[1];
          Value loaded = nestedBuilder.create<affine::AffineLoadOp>(
              nestedLoc, input, ValueRange{j, i});
          nestedBuilder.create<affine::AffineStoreOp>(nestedLoc, loaded, alloc,
                                                       ValueRange{i, j});
        });

    rewriter.replaceOp(op, alloc.getResult());
    return success();
  }
};

/// Returns a real symbol reference to `@printMemrefF64`, inserting a real
/// `func.func private` declaration for it the first time it is needed.
/// `printMemrefF64` is not this chapter's own invention -- it is a real,
/// pre-existing function in MLIR's own `RunnerUtils.h`/
/// `libmlir_runner_utils.so`, the same real runtime support library the
/// official Toy tutorial's own later chapters rely on for exactly this
/// job. `llvm.emit_c_interface` is the one real attribute that makes
/// `--convert-func-to-llvm` emit a call to the real `_mlir_ciface_`-prefixed
/// C-interface wrapper that function's own real ABI expects, rather than
/// to a plain, mismatched symbol.
static FlatSymbolRefAttr getOrInsertPrintMemrefF64(OpBuilder &builder,
                                                   ModuleOp module) {
  const char *name = "printMemrefF64";
  if (module.lookupSymbol<func::FuncOp>(name))
    return SymbolRefAttr::get(builder.getContext(), name);

  auto unrankedMemrefF64 =
      UnrankedMemRefType::get(builder.getF64Type(), /*memorySpace=*/0);
  auto fnType = builder.getFunctionType({unrankedMemrefF64}, {});

  OpBuilder::InsertionGuard guard(builder);
  builder.setInsertionPointToStart(module.getBody());
  auto printFunc = builder.create<func::FuncOp>(module.getLoc(), name, fnType);
  printFunc.setPrivate();
  printFunc->setAttr("llvm.emit_c_interface", builder.getUnitAttr());
  return SymbolRefAttr::get(builder.getContext(), name);
}

/// mg.print lowers to a real `memref.cast` to an unranked memref, plus a
/// real call to `@printMemrefF64` -- genuinely running, observable output,
/// rather than an op this chapter has no way to execute.
struct PrintOpLowering : public OpConversionPattern<mg::PrintOp> {
  using OpConversionPattern::OpConversionPattern;

  LogicalResult
  matchAndRewrite(mg::PrintOp op, OpAdaptor adaptor,
                 ConversionPatternRewriter &rewriter) const override {
    auto loc = op.getLoc();
    auto module = op->getParentOfType<ModuleOp>();
    auto printRef = getOrInsertPrintMemrefF64(rewriter, module);

    auto memrefType = llvm::cast<MemRefType>(adaptor.getInput().getType());
    auto unrankedType =
        UnrankedMemRefType::get(memrefType.getElementType(), /*memorySpace=*/0);
    Value cast =
        rewriter.create<memref::CastOp>(loc, unrankedType, adaptor.getInput());
    rewriter.create<func::CallOp>(loc, printRef, TypeRange{}, ValueRange{cast});
    rewriter.eraseOp(op);
    return success();
  }
};

struct ConvertMgToAffinePass
    : public PassWrapper<ConvertMgToAffinePass, OperationPass<ModuleOp>> {
  MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(ConvertMgToAffinePass)

  llvm::StringRef getArgument() const final { return "convert-mg-to-affine"; }
  llvm::StringRef getDescription() const final {
    return "Lower Mountain Goat's own mg dialect to affine/memref/arith, "
           "hand-bufferizing each op's own result as it is lowered.";
  }

  void getDependentDialects(DialectRegistry &registry) const override {
    registry.insert<affine::AffineDialect, memref::MemRefDialect,
                    arith::ArithDialect>();
  }

  void runOnOperation() override {
    MgToAffineTypeConverter typeConverter;
    ConversionTarget target(getContext());
    target.addLegalDialect<affine::AffineDialect, memref::MemRefDialect,
                           arith::ArithDialect>();
    target.addIllegalDialect<mg::MgDialect>();

    // 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
    // function's own block arguments (e.g. `%a: tensor<2x2xf64>`) are never
    // touched by that -- func.func's own signature needs its own real
    // conversion, via MLIR's own built-in utility for exactly this.
    target.addDynamicallyLegalOp<func::FuncOp>([&](func::FuncOp op) {
      return typeConverter.isSignatureLegal(op.getFunctionType()) &&
             typeConverter.isLegal(&op.getBody());
    });
    target.addDynamicallyLegalOp<func::ReturnOp>([&](func::ReturnOp op) {
      return typeConverter.isLegal(op.getOperandTypes());
    });
    // A real second instance of the same real problem: `@main`'s own
    // `func.call @add_tensors(...)` passes tensor-typed operands too, and
    // needs its own real conversion pattern, via another real MLIR utility.
    target.addDynamicallyLegalOp<func::CallOp>([&](func::CallOp op) {
      return typeConverter.isLegal(op.getOperandTypes()) &&
             typeConverter.isLegal(op.getResultTypes());
    });

    RewritePatternSet patterns(&getContext());
    patterns.add<ConstantOpLowering, AddOpLowering, TransposeOpLowering,
                PrintOpLowering>(typeConverter, &getContext());
    populateFunctionOpInterfaceTypeConversionPattern<func::FuncOp>(patterns,
                                                                   typeConverter);
    populateCallOpTypeConversionPattern(patterns, typeConverter);
    populateReturnOpTypeConversionPattern(patterns, typeConverter);

    if (failed(applyPartialConversion(getOperation(), target,
                                      std::move(patterns))))
      signalPassFailure();
  }
};

} // namespace

namespace mg {
void registerConvertMgToAffinePass() {
  PassRegistration<ConvertMgToAffinePass>();
}
} // namespace mg
mg-opt.cpp
#include "mg/MgDialect.h"

#include "mlir/Conversion/Passes.h"
#include "mlir/Dialect/Affine/IR/AffineOps.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Bufferization/IR/Bufferization.h"
#include "mlir/Dialect/Bufferization/Transforms/Passes.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/LLVMIR/LLVMDialect.h"
#include "mlir/Dialect/MemRef/IR/MemRef.h"
#include "mlir/IR/DialectRegistry.h"
#include "mlir/InitAllDialects.h"
#include "mlir/Tools/mlir-opt/MlirOptMain.h"
#include "mlir/Transforms/Passes.h"

namespace mg {
void registerConvertMgToAffinePass();
}

int main(int argc, char **argv) {
  mlir::registerCanonicalizerPass();
  mlir::registerCSEPass();
  mlir::registerReconcileUnrealizedCastsPass();
  mg::registerConvertMgToAffinePass();
  mlir::bufferization::registerBufferizationPasses();
  mlir::registerConversionPasses();

  mlir::DialectRegistry registry;
  registry.insert<mlir::func::FuncDialect, mlir::affine::AffineDialect,
                  mlir::memref::MemRefDialect, mlir::arith::ArithDialect,
                  mlir::bufferization::BufferizationDialect,
                  mlir::LLVM::LLVMDialect>();
  registry.insert<mg::MgDialect>();
  return mlir::asMainReturnCode(
      mlir::MlirOptMain(argc, argv, "Mountain Goat (mg) dialect driver\n", registry));
}

What later chapters changed

Chapters 13 and 14 changed the LowerToAffine.cpp shown here, in the same way described for Chapter 4 (dynamic shapes, then a runtime shape check for mg.add); static output is unchanged. The version in this chapter's code/ directory is Chapter 5's own.

Chapter summary

This chapter closed Part 3: Mountain Goat's own affine/memref/arith IR (Chapter 4) was carried, through one new real built-in pass (--lower-affine) and Chapter 1's own exact, unmodified five-pass scf-to-llvm pipeline, all the way to real LLVM-dialect IR -- confirming directly that the pipeline Chapter 1 proved on hand-written IR works identically on IR this book's own tooling actually produced. mg.print was given a real, working implementation for the first time, reusing MLIR's own pre-existing runtime support library (printMemrefF64, llvm.emit_c_interface) rather than inventing a mechanism from nothing, and a second real instance of Chapter 4's own function-boundary conversion problem (func.call, this time) was closed with another of MLIR's own built-in utilities. The result: a genuinely executed Mountain Goat program, its real, correct answer captured directly from mlir-cpu-runner-18's own real output.

Deliberately out of scope, stated explicitly: this chapter still stops at mlir-cpu-runner-18's own real JIT -- no standalone native executable (via mlir-translate-18 + clang-18, the way Chapter 1's own harness.c worked) has been built for a Mountain Goat program yet; that, along with MLIR's own real ExecutionEngine C++ API, is Part 5's own promised subject. mg.transpose was not exercised in this chapter's own worked example (only mg.add, to keep the real loop-survival guarantee simple and direct) -- its own real lowering was already proven correct in Chapter 4.

Self-check questions

1. --lower-affine needed no hand-written pattern at all, unlike every one of Chapter 4's own mg-to-affine patterns. What real, structural difference between mg and affine explains that?

Worked answer: affine is itself one of MLIR's own real, built-in dialects -- MLIR ships --lower-affine as a generic pass precisely because every affine.for/affine.load/affine.store operation, in any dialect's own IR, has the same real, fixed structure the pass already knows how to interpret. mg.add/mg.transpose are this book's own invented operations; nothing in MLIR could possibly ship a pre-built lowering for them, which is exactly why Chapter 4 had to write AddOpLowering/TransposeOpLowering by hand -- the real, general principle being that a dialect conversion needs hand-written patterns precisely for the parts of the IR that are not already standard.

2. The chapter needed to declare @printMemrefF64 with llvm.emit_c_interface rather than just calling it as an ordinary function. What real, concrete problem would omitting that attribute have caused?

Worked answer: printMemrefF64's own real, C++-level signature (from RunnerUtils.h) is void printMemrefF64(int64_t rank, void *ptr) -- two plain scalar arguments. The function MLIR's own lowering actually needs to call, _mlir_ciface_printMemrefF64, takes a single pointer to an UnrankedMemRefType<double> descriptor struct instead -- a real, different, C-interface-specific ABI. Without llvm.emit_c_interface, --convert-func-to-llvm would emit a direct call matching the first, plain signature, which does not match what _mlir_ciface_printMemrefF64 (the symbol actually present in libmlir_runner_utils.so) expects -- a genuine link or runtime-crash risk, not merely a cosmetic one.

3. Why did this chapter's own worked example need a separate @add_tensors function, called from @main, rather than just building the two tensors and adding them directly inside @main?

Worked answer: Chapter 4's own real, honest discovery was that AddOp::fold fires automatically during dialect conversion's own legalization, whenever both of mg.add's own operands are defined, within the same function, by mg.constant. Passing the two tensors across a real function boundary (func.call) is what breaks that chain: inside @add_tensors, %a/%b are genuine block arguments, with no real defining operation at all for the folder to inspect -- guaranteeing the real affine.for/scf.for loop genuinely survives all the way to execution, rather than silently folding away before this chapter's own pipeline had anything to run.

4. The real captured LLVM-dialect output shows @add_tensors taking fourteen separate arguments for what were originally two memref<2x2xf64> parameters. Is this the same real phenomenon Chapter 1 first identified, or something new?

Worked answer: the same real phenomenon, just doubled: Chapter 1 established that --finalize-memref-to-llvm unpacks one memref into five separate LLVM-level arguments (allocated pointer, aligned pointer, offset, size, stride per dimension) because LLVM IR has no single type that bundles all of that together. @add_tensors takes two real memref<2x2xf64> arguments, each independently unpacked the same real way -- five arguments each, for ten total, plus printMemrefF64's own two-argument C-interface call elsewhere in the module accounting for the rest of what the excerpt shows; no new mechanism, the same real one applied twice.

5. Why does the chapter call running this program through mlir-cpu-runner-18 genuinely different from, and not a replacement for, the real native-executable approach Chapter 1 used (mlir-translate-18 + clang-18 + a C harness)?

Worked answer: mlir-cpu-runner-18 is itself a real, genuine JIT -- it compiles and runs the given LLVM-dialect IR in-process, through MLIR's own ExecutionEngine, without ever producing a standalone, independently distributable binary. Chapter 1's own approach (mlir-translate-18 to real LLVM IR text, then clang-18 compiling that text, linked against a real hand-written C harness) produces a genuine, ordinary native executable that could be copied to another machine and run without MLIR present at all. Both are real and both genuinely execute the same real logic, but they are not the same real thing, which is exactly why this chapter states plainly that the native-executable path, for a Mountain Goat program specifically, remains Part 5's own subject rather than claiming this chapter's own JIT run already covers it.