| # Chapter 5: Partial Lowering to Lower-Level Dialects for Optimization | |
| [TOC] | |
| At this point, we are eager to generate actual code and see our Toy language | |
| take life. We will use LLVM to generate code, but just showing the LLVM builder | |
| interface here wouldn't be very exciting. Instead, we will show how to perform | |
| progressive lowering through a mix of dialects coexisting in the same function. | |
| To make it more interesting, in this chapter we will consider that we want to | |
| reuse existing optimizations implemented in a dialect optimizing affine | |
| transformations: `Affine`. This dialect is tailored to the computation-heavy | |
| part of the program and is limited: it doesn't support representing our | |
| `toy.print` builtin, for instance, neither should it! Instead, we can target | |
| `Affine` for the computation heavy part of Toy, and in the | |
| [next chapter](Ch-6.md) directly target the `LLVM IR` dialect for lowering | |
| `print`. As part of this lowering, we will be lowering from the | |
| [TensorType](../../Dialects/Builtin.md/#rankedtensortype) that `Toy` operates on | |
| to the [MemRefType](../../Dialects/Builtin.md/#memreftype) that is indexed via | |
| an affine loop-nest. Tensors represent an abstract value-typed sequence of data, | |
| meaning that they don't live in any memory. MemRefs, on the other hand, | |
| represent lower level buffer access, as they are concrete references to a region | |
| of memory. | |
| # Dialect Conversions | |
| MLIR has many different dialects, so it is important to have a unified framework | |
| for [converting](../../../getting_started/Glossary.md/#conversion) between them. | |
| This is where the `DialectConversion` framework comes into play. This framework | |
| allows for transforming a set of *illegal* operations to a set of *legal* ones. | |
| To use this framework, we need to provide two things (and an optional third): | |
| * A [Conversion Target](../../DialectConversion.md/#conversion-target) | |
| - This is the formal specification of what operations or dialects are | |
| legal for the conversion. Operations that aren't legal will require | |
| rewrite patterns to perform | |
| [legalization](../../../getting_started/Glossary.md/#legalization). | |
| * A set of | |
| [Rewrite Patterns](../../DialectConversion.md/#rewrite-pattern-specification) | |
| - This is the set of [patterns](../QuickstartRewrites.md) used to convert | |
| *illegal* operations into a set of zero or more *legal* ones. | |
| * Optionally, a [Type Converter](../../DialectConversion.md/#type-conversion). | |
| - If provided, this is used to convert the types of block arguments. We | |
| won't be needing this for our conversion. | |
| ## Conversion Target | |
| For our purposes, we want to convert the compute-intensive `Toy` operations into | |
| a combination of operations from the `Affine`, `Arith`, `Func`, and `MemRef` dialects | |
| for further optimization. To start off the lowering, we first define our | |
| conversion target: | |
| ```c++ | |
| void ToyToAffineLoweringPass::runOnOperation() { | |
| // The first thing to define is the conversion target. This will define the | |
| // final target for this lowering. | |
| mlir::ConversionTarget target(getContext()); | |
| // We define the specific operations, or dialects, that are legal targets for | |
| // this lowering. In our case, we are lowering to a combination of the | |
| // `Affine`, `Arith`, `Func`, and `MemRef` dialects. | |
| target.addLegalDialect<affine::AffineDialect, arith::ArithDialect, | |
| func::FuncDialect, memref::MemRefDialect>(); | |
| // We also define the Toy dialect as Illegal so that the conversion will fail | |
| // if any of these operations are *not* converted. Given that we actually want | |
| // a partial lowering, we explicitly mark the Toy operations that don't want | |
| // to lower, `toy.print`, as *legal*. `toy.print` will still need its operands | |
| // to be updated though (as we convert from TensorType to MemRefType), so we | |
| // only treat it as `legal` if its operands are legal. | |
| target.addIllegalDialect<ToyDialect>(); | |
| target.addDynamicallyLegalOp<toy::PrintOp>([](toy::PrintOp op) { | |
| return llvm::none_of(op->getOperandTypes(), llvm::IsaPred<TensorType>); | |
| }); | |
| ... | |
| } | |
| ``` | |
| Above, we first set the toy dialect to illegal, and then the print operation as | |
| legal. We could have done this the other way around. Individual operations | |
| always take precedence over the (more generic) dialect definitions, so the order | |
| doesn't matter. See `ConversionTarget::getOpInfo` for the details. | |
| ## Conversion Patterns | |
| After the conversion target has been defined, we can define how to convert the | |
| *illegal* operations into *legal* ones. Similarly to the canonicalization | |
| framework introduced in [chapter 3](Ch-3.md), the | |
| [`DialectConversion` framework](../../DialectConversion.md) uses a special kind | |
| of `ConversionPattern` to perform the conversion logic. `ConversionPatterns` | |
| are different from traditional `RewritePatterns` in that they accept an | |
| additional `operands` (or `adaptor`) parameter containing operands that have | |
| been remapped/replaced. This is used when dealing with type conversions, as the | |
| pattern will want to operate on values of the new type but match against the | |
| old. For our lowering, this invariant will be useful as it translates from the | |
| [TensorType](../../Dialects/Builtin.md/#rankedtensortype) currently being | |
| operated on to the [MemRefType](../../Dialects/Builtin.md/#memreftype). Let's | |
| look at a snippet of lowering the `toy.transpose` operation: | |
| ```c++ | |
| /// Lower the `toy.transpose` operation to an affine loop nest. | |
| struct TransposeOpLowering : public OpConversionPattern<toy::TransposeOp> { | |
| using OpConversionPattern<toy::TransposeOp>::OpConversionPattern; | |
| LogicalResult | |
| matchAndRewrite(toy::TransposeOp op, OpAdaptor adaptor, | |
| ConversionPatternRewriter &rewriter) const final { | |
| auto loc = op->getLoc(); | |
| lowerOpToLoops(op, rewriter, | |
| [&](OpBuilder &builder, ValueRange loopIvs) { | |
| Value input = adaptor.getInput(); | |
| // Transpose the elements by generating a load from the | |
| // reverse indices. | |
| SmallVector<Value, 2> reverseIvs(llvm::reverse(loopIvs)); | |
| return affine::AffineLoadOp::create(builder, loc, input, | |
| reverseIvs); | |
| }); | |
| return success(); | |
| } | |
| }; | |
| ``` | |
| Now we can prepare the list of patterns to use during the lowering process: | |
| ```c++ | |
| void ToyToAffineLoweringPass::runOnOperation() { | |
| ... | |
| // Now that the conversion target has been defined, we just need to provide | |
| // the set of patterns that will lower the Toy operations. | |
| mlir::RewritePatternSet patterns(&getContext()); | |
| patterns.add<..., TransposeOpLowering>(&getContext()); | |
| ... | |
| ``` | |
| ## Partial Lowering | |
| Once the patterns have been defined, we can perform the actual lowering. The | |
| `DialectConversion` framework provides several different modes of lowering, but, | |
| for our purposes, we will perform a partial lowering, as we will not convert | |
| `toy.print` at this time. | |
| ```c++ | |
| void ToyToAffineLoweringPass::runOnOperation() { | |
| ... | |
| // With the target and rewrite patterns defined, we can now attempt the | |
| // conversion. The conversion will signal failure if any of our *illegal* | |
| // operations were not converted successfully. | |
| if (mlir::failed(mlir::applyPartialConversion(getOperation(), target, patterns))) | |
| signalPassFailure(); | |
| } | |
| ``` | |
| ### Design Considerations With Partial Lowering | |
| Before diving into the result of our lowering, this is a good time to discuss | |
| potential design considerations when it comes to partial lowering. In our | |
| lowering, we transform from a value-type, TensorType, to an allocated | |
| (buffer-like) type, MemRefType. However, given that we do not lower the | |
| `toy.print` operation, we need to temporarily bridge these two worlds. There are | |
| many ways to go about this, each with their own tradeoffs: | |
| * Generate `load` operations from the buffer | |
| One option is to generate `load` operations from the buffer type to | |
| materialize an instance of the value type. This allows for the definition of | |
| the `toy.print` operation to remain unchanged. The downside to this approach | |
| is that the optimizations on the `affine` dialect are limited, because the | |
| `load` will actually involve a full copy that is only visible *after* our | |
| optimizations have been performed. | |
| * Generate a new version of `toy.print` that operates on the lowered type | |
| Another option would be to have another, lowered, variant of `toy.print` | |
| that operates on the lowered type. The benefit of this option is that there | |
| is no hidden, unnecessary copy to the optimizer. The downside is that | |
| another operation definition is needed that may duplicate many aspects of | |
| the first. Defining a base class in [ODS](../../DefiningDialects/Operations.md) may | |
| simplify this, but you still need to treat these operations separately. | |
| * Update `toy.print` to allow for operating on the lowered type | |
| A third option is to update the current definition of `toy.print` to allow | |
| for operating the on the lowered type. The benefit of this approach is that | |
| it is simple, does not introduce an additional hidden copy, and does not | |
| require another operation definition. The downside to this option is that it | |
| requires mixing abstraction levels in the `Toy` dialect. | |
| For the sake of simplicity, we will use the third option for this lowering. This | |
| involves updating the type constraints on the PrintOp in the operation | |
| definition file: | |
| ```tablegen | |
| def PrintOp : Toy_Op<"print"> { | |
| ... | |
| // The print operation takes an input tensor to print. | |
| // We also allow a F64MemRef to enable interop during partial lowering. | |
| let arguments = (ins AnyTypeOf<[F64Tensor, F64MemRef]>:$input); | |
| } | |
| ``` | |
| ## Complete Toy Example | |
| Let's take a concrete example: | |
| ```mlir | |
| toy.func @main() { | |
| %0 = toy.constant dense<[[1.000000e+00, 2.000000e+00, 3.000000e+00], [4.000000e+00, 5.000000e+00, 6.000000e+00]]> : tensor<2x3xf64> | |
| %2 = toy.transpose(%0 : tensor<2x3xf64>) to tensor<3x2xf64> | |
| %3 = toy.mul %2, %2 : tensor<3x2xf64> | |
| toy.print %3 : tensor<3x2xf64> | |
| toy.return | |
| } | |
| ``` | |
| With affine lowering added to our pipeline, we can now generate: | |
| ```mlir | |
| func.func @main() { | |
| %cst = arith.constant 1.000000e+00 : f64 | |
| %cst_0 = arith.constant 2.000000e+00 : f64 | |
| %cst_1 = arith.constant 3.000000e+00 : f64 | |
| %cst_2 = arith.constant 4.000000e+00 : f64 | |
| %cst_3 = arith.constant 5.000000e+00 : f64 | |
| %cst_4 = arith.constant 6.000000e+00 : f64 | |
| // Allocating buffers for the inputs and outputs. | |
| %0 = memref.alloc() : memref<3x2xf64> | |
| %1 = memref.alloc() : memref<3x2xf64> | |
| %2 = memref.alloc() : memref<2x3xf64> | |
| // Initialize the input buffer with the constant values. | |
| affine.store %cst, %2[0, 0] : memref<2x3xf64> | |
| affine.store %cst_0, %2[0, 1] : memref<2x3xf64> | |
| affine.store %cst_1, %2[0, 2] : memref<2x3xf64> | |
| affine.store %cst_2, %2[1, 0] : memref<2x3xf64> | |
| affine.store %cst_3, %2[1, 1] : memref<2x3xf64> | |
| affine.store %cst_4, %2[1, 2] : memref<2x3xf64> | |
| // Load the transpose value from the input buffer and store it into the | |
| // next input buffer. | |
| affine.for %arg0 = 0 to 3 { | |
| affine.for %arg1 = 0 to 2 { | |
| %3 = affine.load %2[%arg1, %arg0] : memref<2x3xf64> | |
| affine.store %3, %1[%arg0, %arg1] : memref<3x2xf64> | |
| } | |
| } | |
| // Multiply and store into the output buffer. | |
| affine.for %arg0 = 0 to 3 { | |
| affine.for %arg1 = 0 to 2 { | |
| %3 = affine.load %1[%arg0, %arg1] : memref<3x2xf64> | |
| %4 = affine.load %1[%arg0, %arg1] : memref<3x2xf64> | |
| %5 = arith.mulf %3, %4 : f64 | |
| affine.store %5, %0[%arg0, %arg1] : memref<3x2xf64> | |
| } | |
| } | |
| // Print the value held by the buffer. | |
| toy.print %0 : memref<3x2xf64> | |
| memref.dealloc %2 : memref<2x3xf64> | |
| memref.dealloc %1 : memref<3x2xf64> | |
| memref.dealloc %0 : memref<3x2xf64> | |
| return | |
| } | |
| ``` | |
| ## Taking Advantage of Affine Optimization | |
| Our naive lowering is correct, but it leaves a lot to be desired with regards to | |
| efficiency. For example, the lowering of `toy.mul` has generated some redundant | |
| loads. Let's look at how adding a few existing optimizations to the pipeline can | |
| help clean this up. Adding the `LoopFusion` and `AffineScalarReplacement` passes | |
| to the pipeline gives the following result: | |
| ```mlir | |
| func.func @main() { | |
| %cst = arith.constant 1.000000e+00 : f64 | |
| %cst_0 = arith.constant 2.000000e+00 : f64 | |
| %cst_1 = arith.constant 3.000000e+00 : f64 | |
| %cst_2 = arith.constant 4.000000e+00 : f64 | |
| %cst_3 = arith.constant 5.000000e+00 : f64 | |
| %cst_4 = arith.constant 6.000000e+00 : f64 | |
| // Allocating buffers for the inputs and outputs. | |
| %0 = memref.alloc() : memref<3x2xf64> | |
| %1 = memref.alloc() : memref<2x3xf64> | |
| // Initialize the input buffer with the constant values. | |
| affine.store %cst, %1[0, 0] : memref<2x3xf64> | |
| affine.store %cst_0, %1[0, 1] : memref<2x3xf64> | |
| affine.store %cst_1, %1[0, 2] : memref<2x3xf64> | |
| affine.store %cst_2, %1[1, 0] : memref<2x3xf64> | |
| affine.store %cst_3, %1[1, 1] : memref<2x3xf64> | |
| affine.store %cst_4, %1[1, 2] : memref<2x3xf64> | |
| affine.for %arg0 = 0 to 3 { | |
| affine.for %arg1 = 0 to 2 { | |
| // Load the transpose value from the input buffer. | |
| %2 = affine.load %1[%arg1, %arg0] : memref<2x3xf64> | |
| // Multiply and store into the output buffer. | |
| %3 = arith.mulf %2, %2 : f64 | |
| affine.store %3, %0[%arg0, %arg1] : memref<3x2xf64> | |
| } | |
| } | |
| // Print the value held by the buffer. | |
| toy.print %0 : memref<3x2xf64> | |
| memref.dealloc %1 : memref<2x3xf64> | |
| memref.dealloc %0 : memref<3x2xf64> | |
| return | |
| } | |
| ``` | |
| Here, we can see that a redundant allocation was removed, the two loop nests | |
| were fused, and some unnecessary `load`s were removed. You can build `toyc-ch5` | |
| and try yourself: `toyc-ch5 test/Examples/Toy/Ch5/affine-lowering.mlir | |
| -emit=mlir-affine`. We can also check our optimizations by adding `-opt`. | |
| In this chapter we explored some aspects of partial lowering, with the intent to | |
| optimize. In the [next chapter](Ch-6.md) we will continue the discussion about | |
| dialect conversion by targeting LLVM for code generation. |