In the last article in this series, we defined some custom lowering passes that modified an MLIR program. Notably, we accomplished that by implementing the required interfaces of the MLIR API directly.
This is not the way that most MLIR developers work. Instead, they use a code generation tool called tablegen to generate boilerplate for them, and then only add the implementation methods that are custom to their work. In this article, we’ll convert the custom passes we wrote previously to use this infrastructure, and going forward we will use tablegen for defining new dialects as well.
The code relevant to this article is contained in this pull request, and as usual the commits are organized so that they can be read in order.
How to think about tablegen
The basic idea of tablegen is that you can define a pass—or a dialect, as we’ll do next time—in the tablegen DSL (as specialized for MLIR) and then run the mlir-tblgen
binary with appropriate flags, and it will generate headers for the functions you need to implement your pass, along with default implementations of some common functions, documentation, and hooks to register the pass/dialect with the mlir-opt
-like entry point tool.
It sounds nice, but I have a love hate relationship with tablegen. I personally find it to be unpleasant to use, primarily because it provides poor diagnostic information when you do things wrong. Today, though, I realize that my part of my frustration came from having the wrong initial mindset around tablegen. I thought, incorrectly, that tablegen was an abstraction layer. That is, I could write my tablegen files, build them, and only think about the parts of the generated code that I needed to implement.
Instead, I’ve found that I still need to understand the details of the generated code, enough that I may as well read the generated code closely until it becomes familiar. This materializes in a few ways:
mlir-tablegen
doesn’t clearly tell you what functions are left unimplemented or explain the contract of the functions you have to write. For that you have to read the docs (which are often incomplete), or often the source code of the base classes that the generated boilerplate subclasses. Or, sadly, sometimes the Discourse threads or old Phabricator/GitHub commit messages are the only places to find the answers to some questions.- The main way to determine what is missing is to try to build the generated code with some code that uses it, and then sift through hundreds of lines of C++ compiler errors, which in turn requires understanding the various template gymnastics in the generated code.
- The generated code will make use of symbols that you have to know to import or forward-declare in the right places, and it expects you to manage the namespaces in which the generated code lives.
With the expectation that tablegen is a totally see-through code generator, and then just sitting down and learning the MLIR APIs, the result is not so bad. Indeed, that was the motivation for building the passes in the last article from “scratch” (without tablegen), because it makes the transition to using tablegen more transparent. Pass generation is also a good starting point for tablegen because, by comparison, using tablegen for dialects results in many more moving parts and configurable options.
Tablegen files and the mlir-tblgen
binary
We’ll start by migrating the AffineFullUnroll
pass from last time. The starting point is to write some tablegen code. This commit implements it, which I’ll abbreviate below. If you want the official docs on how to do this, see Declarative Pass Specification.
include "mlir/Pass/PassBase.td"
def AffineFullUnroll : Pass<"affine-full-unroll"> {
let summary = "Fully unroll all affine loops";
let description = [{
Fully unroll all affine loops. (could add more docs here like code examples)
}];
let dependentDialects = ["mlir::affine::AffineDialect"];
}
Aside: In the actual commit, there are two definitions, one for the AffineFullUnroll that walks the IR, and the other for AffineFullUnrollPatternRewrite which uses the pattern rewrite engine. In the article I’ll just show the generated code for the first.
As you can see, tablegen has concepts like classes and class inheritance (the : Pass<...>
is subclassing the Pass
base class defined in PassBase.td
, an upstream MLIR file. But the def
keyword here specifically implies we’re instantiating this thing in a way that the codegen tool should see and generate real code for (as opposed to the class
keyword, which is just for tablegen templating and code reuse).
So tablegen lets you let
-define string variables and lists, but one thing that won’t be apparent in this article, but will be apparent in the next article, is that tablegen lets you define variables and use them across definitions, as well as define snippets of C++ code that should be put into the generated classes (which can use the defined variables). This is visible in the present context in the PassBase.td
class which defines a code constructor
variable. If you have a special constructor for your pass, you can write the C++ code for it there.
Next we define a build rule using gentbl_cc_library
to run the mlir-tblgen
binary (in the same commit) with the right options. This gentbl_cc_library
bazel rule is provided by the MLIR devs, and it basically just assembles the CLI flags to mlir-tblgen
and ensures the code-genned files end up in the right places on the filesystem and are compatible with the bazel dependency system. The build rule invocation looks like
gentbl_cc_library(
name = "pass_inc_gen",
tbl_outs = [
(
[
"-gen-pass-decls",
"-name=Affine",
],
"Passes.h.inc",
),
],
tblgen = "@llvm-project//mlir:mlir-tblgen",
td_file = "Passes.td",
deps = [
"@llvm-project//mlir:OpBaseTdFiles",
"@llvm-project//mlir:PassBaseTdFiles",
],
)
The important part here is that td_file
specifies our input file, and tbl_outs
defines the generated file, Passes.h.inc
, which is at $GIT_ROOT/bazel-bin/lib/Transform/Affine/Passes.h.inc
.
The main quirk with gentbl_cc_library
is that the name of the bazel rule is not the target that actually generates the code. That is, if you run bazel build pass_inc_gen
(or from the git root, bazel build lib/Transform/Affine:pass_inc_gen
), it won’t create the files but the build will be successful. Instead, under the hood gentbl_cc_library
is a bazel macro that generates the rule pass_inc_gen_filegroup
, which is what you have to bazel build
to see the actual files.
I’ve pasted the generated code (with both version of the AffineFullUnroll) into a gist and will highlight the important parts here. The first quirky thing the generated code does is use #ifdef
as a sort of function interface for what code is produced. For example, you will see:
#ifdef GEN_PASS_DECL_AFFINEFULLUNROLL
std::unique_ptr<::mlir::Pass> createAffineFullUnroll();
#undef GEN_PASS_DECL_AFFINEFULLUNROLL
#endif // GEN_PASS_DECL_AFFINEFULLUNROLL
#ifdef GEN_PASS_DEF_AFFINEFULLUNROLL
... <lots of C++ code> ...
#undef GEN_PASS_DEF_AFFINEFULLUNROLL
#endif // GEN_PASS_DEF_AFFINEFULLUNROLL
This means that to use this file, we will need to define the appropriate symbol in a #define
macro before including this header. You can see it happening in this commit, but in brief it will look like this
// in file AffineFullUnroll.h
#define GEN_PASS_DECL_AFFINEFULLUNROLL
#include "lib/Transform/Affine/Passes.h.inc"
// in file AffineFullUnroll.cpp
#define GEN_PASS_DEF_AFFINEFULLUNROLL
#include "lib/Transform/Affine/Passes.h.inc"
... <implement the missing functions from the generated code> ...
I’m no C++ expert, and this was the first time I’d seen this pattern of using #include
as a function with #define
as the argument. It was a little unsettling to me, until I landed on that mindset that it’s meant to be a white-box codegen, not an abstraction. So read the generated code. Inside the GEN_PASS_DECL_...
guard, it defines a single function std::unique_ptr<::mlir::Pass> createAffineFullUnroll();
that is a very limited sole entry point for code that wants to use the pass. We don’t need to implement it unless our Pass
has a custom constructor. Then in the GEN_PASS_DEF_...
guard it defines a base class, whose functions I’ll summarize, but you should recognize many of them because we implemented them by hand last time.
template <typename DerivedT>
class AffineFullUnrollBase : public ::mlir::OperationPass<> {
AffineFullUnrollBase() : ::mlir::OperationPass<>(::mlir::TypeID::get<DerivedT>()) {}
AffineFullUnrollBase(const AffineFullUnrollBase &other) : ::mlir::OperationPass<>(other) {}
static ::llvm::StringLiteral getArgumentName() {...}
static ::llvm::StringRef getArgument() { ... }
static ::llvm::StringRef getDescription() { ... }
static ::llvm::StringLiteral getPassName() { ... }
static ::llvm::StringRef getName() { ... }
/// Support isa/dyn_cast functionality for the derived pass class.
static bool classof(const ::mlir::Pass *pass) { ... }
/// A clone method to create a copy of this pass.
std::unique_ptr<::mlir::Pass> clonePass() const override { ... }
/// Return the dialect that must be loaded in the context before this pass.
void getDependentDialects(::mlir::DialectRegistry ®istry) const override {
registry.insert<mlir::affine::AffineDialect>();
}
... <type_id stuff> ...
}
Notably, this doesn’t tell us what functions are left for us to implement. For that we have to either build it and read compiler error messages, or compare it to the base class (OperationPass) and it’s base class (Pass) to see that the only function left to implement is runOnOperation()
Or, since we did this last time from the raw API, we can observe that the boilerplate functions we implemented before like getArgument
are here, but runOnOperation
is not.
Another notable aspect of the generated code is that it uses the curiously recurring template pattern (CRTP), so that the base class can know the eventual name of its subclass, and use that name to hook the concrete subclass into the rest of the framework.
Lower in the generated file you’ll see another #define
-guarded block for GEN_PASS_REGISTRATION
, which implements hooks for tutorial-opt
to register the passes without having to depend on each internal Pass
class directly.
#ifdef GEN_PASS_REGISTRATION
inline void registerAffineFullUnroll() {
::mlir::registerPass([]() -> std::unique_ptr<::mlir::Pass> {
return createAffineFullUnroll();
});
}
inline void registerAffineFullUnrollPatternRewrite() {
::mlir::registerPass([]() -> std::unique_ptr<::mlir::Pass> {
return createAffineFullUnrollPatternRewrite();
});
}
inline void registerAffinePasses() {
registerAffineFullUnroll();
registerAffineFullUnrollPatternRewrite();
}
#undef GEN_PASS_REGISTRATION
#endif // GEN_PASS_REGISTRATION
This implies that, once we link everything properly, the changes to tutorial-opt
(in this commit) simplify to calling registerAffinePasses
. This registration macro is intended to go into a Passes.h
file that includes all the individual pass header files, as done in this commit. And we can use that header file as an anchor for a bazel build target that includes all the passes defined in lib/Transform/Affine
at once.
#include "lib/Transform/Affine/AffineFullUnroll.h"
#include "lib/Transform/Affine/AffineFullUnrollPatternRewrite.h"
namespace mlir {
namespace tutorial {
#define GEN_PASS_REGISTRATION
#include "lib/Transform/Affine/Passes.h.inc"
} // namespace tutorial
} // namespace mlir
Finally, after all this (abbreviated from this commit), the actual content of the pass reduces to the following subclass (with CRTP) and implementation of runOnOperation
, the body of which is identical to the last article except for a change from reference to pointer for the return value of getOperation
.
#define GEN_PASS_DEF_AFFINEFULLUNROLL
#include "lib/Transform/Affine/Passes.h.inc"
struct AffineFullUnroll : impl::AffineFullUnrollBase<AffineFullUnroll> {
using AffineFullUnrollBase::AffineFullUnrollBase;
void runOnOperation() {
getOperation()->walk([&](AffineForOp op) {
if (failed(loopUnrollFull(op))) {
op.emitError("unrolling failed");
signalPassFailure();
}
});
}
};
I split the AffineFullUnroll
migration into multiple commits to highlight the tablegen vs C++ code changes. For MulToAdd
, I did it all in one commit. The tests are unchanged, because the entry point is still the tutorial-opt
binary with the appropriate CLI flags, and those names are unchanged in the tablegen’ed code.
Bonus: mlir-tblgen
also has an option -gen-pass-doc
, which you’ll see in the commits, which generates a markdown file containing auto-generated documentation for the pass. A CI workflow can copy these to a website directory, as we do in HEIR, and you get free docs.
Addendum: hermetic Python
When I first set up this tutorial project, I didn’t realize that bazel’s Python rules use the system Python by default. Some early readers found an error that Python couldn’t find the lit
module when running tests. While pip install lit
in your system Python would work, I also migrated in this commit to a hermetic python runtime and explicit dependency on lit. It should all be handled automatically by bazel now.
Want to respond? Send me an email, post a webmention, or find me elsewhere on the internet.