347 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Markdown
		
	
	
	
			
		
		
	
	
			347 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Markdown
		
	
	
	
| # 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](../../LangRef.md#tensor-type) that `Toy` operates on to the
 | |
| [MemRefType](../../LangRef.md#memref-type) 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`, `MemRef` and `Standard` dialects
 | |
| for further optimization. To start off the lowering, we first define our
 | |
| conversion target:
 | |
| 
 | |
| ```c++
 | |
| void ToyToAffineLoweringPass::runOnFunction() {
 | |
|   // 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`, `MemRef` and `Standard` dialects.
 | |
|   target.addLegalDialect<mlir::AffineDialect, mlir::memref::MemRefDialect,
 | |
|                          mlir::StandardOpsDialect>();
 | |
| 
 | |
|   // 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*.
 | |
|   target.addIllegalDialect<ToyDialect>();
 | |
|   target.addLegalOp<PrintOp>();
 | |
|   ...
 | |
| }
 | |
| ```
 | |
| 
 | |
| 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) also uses
 | |
| [RewritePatterns](../QuickstartRewrites.md) to perform the conversion logic.
 | |
| These patterns may be the `RewritePatterns` seen before or a new type of pattern
 | |
| specific to the conversion framework `ConversionPattern`. `ConversionPatterns`
 | |
| are different from traditional `RewritePatterns` in that they accept an
 | |
| additional `operands` 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](../../LangRef.md#tensor-type) currently being operated on to the
 | |
| [MemRefType](../../LangRef.md#memref-type). 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 mlir::ConversionPattern {
 | |
|   TransposeOpLowering(mlir::MLIRContext *ctx)
 | |
|       : mlir::ConversionPattern(TransposeOp::getOperationName(), 1, ctx) {}
 | |
| 
 | |
|   /// Match and rewrite the given `toy.transpose` operation, with the given
 | |
|   /// operands that have been remapped from `tensor<...>` to `memref<...>`.
 | |
|   mlir::LogicalResult
 | |
|   matchAndRewrite(mlir::Operation *op, ArrayRef<mlir::Value> operands,
 | |
|                   mlir::ConversionPatternRewriter &rewriter) const final {
 | |
|     auto loc = op->getLoc();
 | |
| 
 | |
|     // Call to a helper function that will lower the current operation to a set
 | |
|     // of affine loops. We provide a functor that operates on the remapped
 | |
|     // operands, as well as the loop induction variables for the inner most
 | |
|     // loop body.
 | |
|     lowerOpToLoops(
 | |
|         op, operands, rewriter,
 | |
|         [loc](mlir::PatternRewriter &rewriter,
 | |
|               ArrayRef<mlir::Value> memRefOperands,
 | |
|               ArrayRef<mlir::Value> loopIvs) {
 | |
|           // Generate an adaptor for the remapped operands of the TransposeOp.
 | |
|           // This allows for using the nice named accessors that are generated
 | |
|           // by the ODS. This adaptor is automatically provided by the ODS
 | |
|           // framework.
 | |
|           TransposeOpAdaptor transposeAdaptor(memRefOperands);
 | |
|           mlir::Value input = transposeAdaptor.input();
 | |
| 
 | |
|           // Transpose the elements by generating a load from the reverse
 | |
|           // indices.
 | |
|           SmallVector<mlir::Value, 2> reverseIvs(llvm::reverse(loopIvs));
 | |
|           return rewriter.create<mlir::AffineLoadOp>(loc, input, reverseIvs);
 | |
|         });
 | |
|     return success();
 | |
|   }
 | |
| };
 | |
| ```
 | |
| 
 | |
| Now we can prepare the list of patterns to use during the lowering process:
 | |
| 
 | |
| ```c++
 | |
| void ToyToAffineLoweringPass::runOnFunction() {
 | |
|   ...
 | |
| 
 | |
|   // 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::runOnFunction() {
 | |
|   ...
 | |
| 
 | |
|   // 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.
 | |
|   auto function = getFunction();
 | |
|   if (mlir::failed(mlir::applyPartialConversion(function, 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](../../OpDefinitions.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
 | |
| 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 @main() {
 | |
|   %cst = constant 1.000000e+00 : f64
 | |
|   %cst_0 = constant 2.000000e+00 : f64
 | |
|   %cst_1 = constant 3.000000e+00 : f64
 | |
|   %cst_2 = constant 4.000000e+00 : f64
 | |
|   %cst_3 = constant 5.000000e+00 : f64
 | |
|   %cst_4 = constant 6.000000e+00 : f64
 | |
| 
 | |
|   // Allocating buffers for the inputs and outputs.
 | |
|   %0 = alloc() : memref<3x2xf64>
 | |
|   %1 = alloc() : memref<3x2xf64>
 | |
|   %2 = 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 = mulf %3, %4 : f64
 | |
|       affine.store %5, %0[%arg0, %arg1] : memref<3x2xf64>
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Print the value held by the buffer.
 | |
|   toy.print %0 : memref<3x2xf64>
 | |
|   dealloc %2 : memref<2x3xf64>
 | |
|   dealloc %1 : memref<3x2xf64>
 | |
|   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 `MemRefDataFlowOpt` passes to
 | |
| the pipeline gives the following result:
 | |
| 
 | |
| ```mlir
 | |
| func @main() {
 | |
|   %cst = constant 1.000000e+00 : f64
 | |
|   %cst_0 = constant 2.000000e+00 : f64
 | |
|   %cst_1 = constant 3.000000e+00 : f64
 | |
|   %cst_2 = constant 4.000000e+00 : f64
 | |
|   %cst_3 = constant 5.000000e+00 : f64
 | |
|   %cst_4 = constant 6.000000e+00 : f64
 | |
| 
 | |
|   // Allocating buffers for the inputs and outputs.
 | |
|   %0 = alloc() : memref<3x2xf64>
 | |
|   %1 = 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 = mulf %2, %2 : f64
 | |
|       affine.store %3, %0[%arg0, %arg1] : memref<3x2xf64>
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Print the value held by the buffer.
 | |
|   toy.print %0 : memref<3x2xf64>
 | |
|   dealloc %1 : memref<2x3xf64>
 | |
|   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.
 |