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.const → ll.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.