A Simple End-to-End Example

This guide goes over the hello world example of e-graphs: optimizing (a * 2) / 2. This expression can be translated to MLIR IR:

// input.mlir
func.func @example(%a: i32) -> (i32) {
  %two = arith.constant 2 : i32
  %mul = arith.muli %a, %two : i32
  %div = arith.divsi %mul, %two : i32
  return %div : i32
}

Note

TLDR:

tamagoyaki-opt input.mlir \
  --equivalence-insert-graph \
  --ematch-saturate=patterns-file=pattern_pdl_interp.mlir \
  --equivalence-select-greedy=default-cost=1 \
  -equivalence-extract=remove-graphs=true

Inserting the Graph

Assuming you have succesfully built Tamagoyaki, we can convert this function body into an e-graph:

tamagoyaki-opt --equivalence-insert-graph input.mlir
// graph.mlir
func.func @example(%arg0: i32) -> i32 {
  %0 = equivalence.graph -> (i32) {
    %c2_i32 = arith.constant 2 : i32
    %1 = arith.muli %arg0, %c2_i32 : i32
    %2 = arith.divsi %1, %c2_i32 : i32
    equivalence.yield %2 : i32
  }
  return %0 : i32
}

Note that as long as no equivalences are introduced in the IR, the operations remain unchanged, they are just moved into a graph operation.

Note

While the graph operation is superfluous at this stage, it is required when running equality saturation. During rewriting, cycles can be introduced, these appear in IR as uses of a value before its defining operation. The graph operation contains an MLIR “graph region” which allows this.

Adding Rewrites

Tamagoyaki uses pdl as a language for rewrites. Take for example the rewrite x * 2 --> x << 1:

// pattern.mlir
pdl.pattern : benefit(1) {
  %x = pdl.operand
  %type = pdl.type
  %two_attr = pdl.attribute = 2 : i32
  %two_op = pdl.operation "arith.constant" {"value" = %two_attr} -> (%type : !pdl.type)
  %two = pdl.result 0 of %two_op
  %mul_op = pdl.operation "arith.muli" (%x, %two : !pdl.value, !pdl.value) -> (%type : !pdl.type)
  pdl.rewrite %mul_op {
    %one_attr = pdl.attribute = 1 : i32
    %one_op = pdl.operation "arith.constant" {"value" = %one_attr} -> (%type : !pdl.type)
    %one = pdl.result 0 of %one_op
    %shl_op = pdl.operation "arith.shli" (%x, %one : !pdl.value, !pdl.value) -> (%type : !pdl.type)
    %shl = pdl.result 0 of %shl_op
    pdl.replace %mul_op with (%shl : !pdl.value)
  }
}

Note

pdl is verbose to write. Writing it by hand should be avoided. The MLIR project also contains the PDLL language, which is higher-level language built on top of pdl. We have also written custom python tools to convert rewrite rules in a different format to pdl, for example for Herbie-MLIR (herbie_mlir/tools/herbie_pdl.py, in the case-studies repository).

To execute pdl patterns, the IR first needs to be lowered to pdl_interp. MLIR has a pass --convert-pdl-to-pdl-interp, but that pass is unaware of the changes to matching logic that need to be made to achieve e-matching. Instead you can use:

tamagoyaki-opt -ematch-convert-pdl-to-pdl-interp pattern.mlir > pattern_pdl_interp.mlir

-ematch-saturate also accepts the pdl patterns directly and runs this lowering itself.

Saturating

To run equality saturation:

tamagoyaki-opt -ematch-saturate=patterns-file=pattern_pdl_interp.mlir graph.mlir
// saturated.mlir
func.func @example(%arg0: i32) -> i32 {
  %0 = equivalence.graph -> (i32) {
    %c2_i32 = arith.constant 2 : i32
    %c1_i32 = arith.constant 1 : i32
    %1 = arith.shli %arg0, %c1_i32 : i32
    %2 = arith.muli %arg0, %c2_i32 : i32
    %3 = equivalence.class %2, %1 : i32
    %4 = arith.divsi %3, %c2_i32 : i32
    equivalence.yield %4 : i32
  }
  return %0 : i32
}

The equivalence is introduced: %3 represents the e-class containing e-nodes %2 and %1!

Extraction

Extracting a final program from the graph happens in two steps: selection and replacement

Selection

tamagoyaki-opt --equivalence-select-greedy=default-cost=1 saturated.mlir
// selected.mlir
func.func @example(%arg0: i32) -> i32 {
  %0 = equivalence.graph -> (i32) {
    %c2_i32 = arith.constant 2 : i32
    %c1_i32 = arith.constant 1 : i32
    %1 = arith.shli %arg0, %c1_i32 : i32
    %2 = arith.muli %arg0, %c2_i32 : i32
    %3 = equivalence.class %2, %1 (min_cost_index = 0) : i32
    %4 = arith.divsi %3, %c2_i32 : i32
    equivalence.yield %4 : i32
  }
  return %0 : i32
}

This pass has added min_cost_index=0 to the e-class. This indicates that the first operand of class should be selected.

Instead of using the same cost for each operation, it is possible to specify operation costs as attributes. Try, for example, changing the shift and multiplication operations to:

%1 = arith.shli %arg0, %c1_i32 {equivalence.cost = #equivalence.cost<2>} : i32
%2 = arith.muli %arg0, %c2_i32 {equivalence.cost = #equivalence.cost<3>} : i32

The min_cost_index now points to the shift operation.

Note

Tamagoyaki expects users to bring their own cost model. It is also possible to write more complex cost functions by passing a nodeCostFn to selectGreedy when writing custom passes using Tamagoyaki’s infrastructure.

Replacement

Finally, to extract the selected program from the graph, run:

tamagoyaki-opt -equivalence-extract=remove-graphs=true selected.mlir
module {
  func.func @example(%arg0: i32) -> i32 {
    %c2_i32 = arith.constant 2 : i32
    %c1_i32 = arith.constant 1 : i32
    %0 = arith.muli %arg0, %c2_i32 : i32
    %1 = arith.divsi %0, %c2_i32 : i32
    return %1 : i32
  }
}