Over the past few months there have been a lot of discussions on PRs that attempt to add canonicalizations. To avoid having long discussions on PRs, and to provide better guidance to new contributors, it might be time to update the canonicalization section of MLIR documentation. While the existing documentation provides some good basics for how to use canonicalizations, drawing inspiration from Dan Gohman’s article on canonicalizations, it does not provide more emphasis on the “Do No Harm” principle specified in that article. In my view most of the discussions have been centered around not having spelled this out clearly.
Proposed basic guidelines for something to be a canonicalization
Canonicalizations in MLIR should be designed in a way that they could be applied repeatedly, and at any level of the compilation stack. For this to be true, canonicalizations should be geared towards converting the input program into a state that is almost always beneficial (i.e. do no harm). The post already talks about cases where things are ambiguous (I find the point about redundancy elimination also extremely important and something I have seen have huge impact in practice). So some ambiguity here is unavoidable, but in my opinion the following thumb rules help
- Transformations need to have a relatively high bar for being a canonicalization. Conversely, there should be a relatively low bar to have transformations moved out of canonicalizations.
- Transformation that are dropping semantic information captured by operations should not be part of canonicalizations.
- Transformations that are just changing program representation, without necessarily making the program simpler should not be a canonicalization.
These are just a few to start with. I am happy to adapt/evolve them based on discussions here, and will update the General Design section of the Operation Canonicalization page accordingly.
Following are examples from a past discussions where the above thumb rule could be used to reach a decision.
tensor.expand_shape/collapse_shape vs tensor.extract_slice/insert_slice
This discussion came up recently on a PR. The change was attempting to add a tensor.insert_slice of the form
%0 = tensor.insert_slice %slice into
%x[0, 0, 0, 0, 0][1, 1, 1, 16, 32][1, 1, 1, 1, 1] :
tensor<16x32xf32> into tensor<1x1x1x16x32xf32>
to
%0 = tensor.expand_shape %slice[[0,1,2,3], [4]] :
tensor<16x32xf32> into tensor<1x1x1x16x32xf32>
These are equivalent representations. There is nothing to say which is better. It is equally valid for an analysis to uses tensor.extract_slice/insert_slice in a load-bearing manner (for example One Shot Bufferization in MLIR), as opposed to using tensor.collapse_shape/expand_shape. Moving from one form to another would break the subsequent program analysis; or force analysis to account for these two being essentially equivalent. The latter is essentially “doing harm” since the analysis would have to essentially undo the transformation applied as canonicalization. Furthermore one could add a transformation that converts tensor.expand_shape to tensor.insert_slice also as a canonicalization resulting in an infinite loop during canonicalization (without explicit break out). The fundamental issue here is that there is nothing that makes one representation “more canonical” than the other and should not something that canonicalizations do.
vector.transpose to vector.shape_cast
This commit added a pattern to convert vector.transpose of the form
vector.transpose %0, [1, 0] : vector<nx1x<eltty>> to vector<1xnx<elty>>
to a
vector.shape_cast %0 : vector<nx1x<eltty>> to vector<1xnx<elty>>
The motivation for this change was when lowered to LLVM, the extra-unit dimensions dont really matter. From LLVM backend’s perspective the source type of the vector.transpose and the destination type are the same, and hence this vector.transpose is a no-op and converting this to a vector.shape_cast essentially captures this “no-op” behavior. The problem with this though is that it violates the “canonicalizations shouldnt drop semantic information captured by an operation”. The vector.transpose version of the operation captures more semantic information about how the dimensions are changed, than the vector.shape_cast operation. So adding this as a canonicalization would result in this semantic information being dropped much earlier than it should be. It is true that such transposes should be a no-op for LLVM-based lowering. Such a transformation should then be run as part of, or as a step immediately preceding, the conversion to LLVM dialect. To make the distinction more concrete, consider
vector.transpose %0, [1, 0, 2] : vector<nx1x1x<eltty>> to vector<1xnx1x<elty>>
If this was converted to a vector.shape_cast this would be
vector.shape_cast %0 : vector<nx1x1x<eltty>> to vector<1xnx1x<elty>>
It would be unclear if this vector.shape_cast came from vector.transpose as written above or from this one
vector.transpose %0, [2, 0, 1] : vector<nx1x1x<eltty>> to vector<1xnx1x<elty>>
This ambiguity is a result of semantic information captured by the vector.transpose operation being dropped. Hence the change from vector.transpose → vector.shape_cast should not qualify as a canonicalization.
Good use of canonicalization
To highlight a good use of canonicalization, propagating static shapes in the program is a good use of canonicalization. This makes it easier to analyse programs and leads to a more compact representation.
Waiting to hear back from the community on their take on this.