06 — Lowering Between Dialects

Lowering is the act of rewriting ops from one dialect into ops from another. Conceptually it's another pass; structurally it's special because the input and output dialects differ. cp-18 implements one lowering: tiny.*ll.*.

std::unique_ptr<Op> lowerTinyToLL(Op& tinyModule) {
    auto newMod = makeModule();
    Builder b;
    std::unordered_map<Value*, Value*> valueMap;

    Block* topB = newMod->region(0)->entry();
    b.setInsertionPointToEnd(topB);

    for each tiny.func in tinyModule:
        Op* newFunc = ll::func(b, fname);
        Builder bf;
        bf.setInsertionPointToEnd(newFunc->region(0)->entry());

        for each op in tinyFunc.body:
            switch (op.name) {
                case "tiny.const":   valueMap[old] = ll::constant(bf, value); break;
                case "tiny.add":     valueMap[old] = ll::add(bf, map(a), map(b)); break;
                case "tiny.mul":     valueMap[old] = ll::mul(bf, map(a), map(b)); break;
                case "tiny.print":   ll::call(bf, "ml_print_int", map(a));        break;
                case "tiny.return":  ll::ret(bf, map(a));                          break;
            }
}

The pattern: for each source op, emit one or more target ops and record the value mapping. When a later source op uses an SSA result, look up its replacement in the map.

Why the value map?

Because we can't reuse the source ops' Value* in the target IR — they belong to the old module which is about to die (or persist independently). Every source value needs a corresponding fresh target value.

This map is the heart of dialect conversion. In real MLIR ConversionPatternRewriter maintains it implicitly: when a pattern matches and emits replacement ops, the rewriter records the value mapping and rewires uses automatically. cp-18 maintains it explicitly because it's clearer pedagogically.

One-to-one vs one-to-many

tiny.constll.const is one-to-one. tiny.print(x)ll.call(x) {callee = "ml_print_int"} is also one-to-one but with name rewriting. A more interesting case: a single linalg.matmul lowers to a nested scf.for loop body that calls arith.mulf and arith.addf — one op blowing up into a dozen. cp-18 doesn't show one-to-many because tiny.* is too simple; the framework supports it (just create more ops in the case branch).

Partial vs full conversion

  • Full conversion insists no source-dialect ops remain. cp-18's test #5 verifies this: CHECK_NOT_CONTAINS(ir, "tiny.").
  • Partial conversion lowers some ops, leaves others. Useful when a dialect contains a mix of low-level and high-level concerns.

Our lowerTinyToLL is full: every tiny.* op has a case in the switch. Real MLIR's applyFullConversion will fail loudly if any unconverted source ops survive; applyPartialConversion will leave them in place.

Where the framework matters

Notice what didn't change for the lowering: the printer, the walks, the passes. The ll-dialect module prints with the same printer, walks with the same walker. The DCE pass works on ll.* ops because isPure lists ll.const/add/mul. The constant folder, however, doesn't recognise ll.const + ll.const → ll.const — by design. If you want post-lowering folding, you add another pass that matches ll.* ops.

That separation — generic infrastructure, dialect-specific patterns — is exactly what makes MLIR scale to dozens of dialects.

Lowering chains

cp-18 has one lowering step. Real MLIR pipelines often chain several:

tosa → linalg → scf+memref → llvm → LLVM IR

Each step is implemented as a set of patterns + a populate*Patterns function + a target spec declaring which ops are "legal" in the output. The shape of each step is identical to lowerTinyToLL: walk the input, emit the output, thread the value map.