DSL-style builders
Context: Evolving Builder APIs based on lessons learned from EDSC
Introduction
This document proposes an ODS-backed approach to defining IR-building APIs, complementary to OpBuilder
. It intends to replace EDSC’s intrinsics mechanism with auto-generated APIs that rely on the main Builder infrastructure in a transparent way.
Proposal
Dialect-specific builder classes
The main concept of this proposal is a dialect-specific builder class. This class caches OpBuilder
and Location
and defines methods with names derived from Op names that forward their arguments to the builder. Here’s an example:
class StdBuilder {
public:
StdBuilder(OpBuilder &b, Location loc) : b(b), loc(loc) {}
AddFOp addFOp(Type result, Value lhs, Value rhs) {
/* this may need std::forward to make sure rvalue references are handled correctly*/
b.create<AddFOp>(loc, result, lhs, rhs);
}
ReturnOp returnOp(ValueRange args, ArrayRef<NamedAttribute> attrs = {}) {
b.create<ReturnOp>(loc, args, attrs);
}
protected:
OpBuilder &b;
Location loc;
};
Such classes can be created as local named variables and used to construct IR in a “DSL-like” manner, making the code that constructs IR visually resemble the IR it constructs.
StdBuilder std(builder, loc);
Value v1 = std.addFOp(arg1.getType(), arg1, arg2);
Value v2 = std.addFOp(v1.getType(), v1, v2);
std.returnOp({v1, v2});
The methods in these classes are trivially inlinable so they are not expected to yield additional overhead at runtime compared to builders.
This brings a major usability improvement over the current building API – IDEs can understand such functions – meaning you get autocompletion, type prompts, inline type mismatch checking and so on. Today, it is impossible to get through the template <typename... Args> create(Args &&...)
, that seems to blindly accept any arguments.
Examples of the prototype with YouCompleteMe + clangd in vim:
Automatic generation
Such classes can be generated automatically from ODS, using the builders
field of the Op
class. This can happen at compilation time, together with actual Op definitions.
For historical reasons, the builder function signature is a plain string in the ODS syntax, so it requires some additional manipulation to chop off the builder and state arguments and introduce names for the formal arguments that are missing them. This has been prototyped using a relatively naive approach, which has worked just fine on the upstream dialects.
This can be a good time to transition the builders
field to use the DAG-based format that we already use in OpInterfaces and other more modern parts of ODS, i.e. (ins "Type ":$name)
. This is a one-time transition that will make it easy to programmatically process argument lists for builders. This opens the door for other useful features such as documenting individual builder arguments.
If we discover out-of-tree dialects that cannot be processed by the naive parsing approach, we can piggy-back on clang and get a full-fledged C++ parser to perform the transition.
For dialects with Ops that opted out of ODS, it is simple to derive from the autogenerated class and add more methods that follow the general pattern.
Multi-dialect builder classes
In many cases, operations from multiple dialects are constructed together. Having one builder object per dialect will make the “preparation” code more verbose, and create a situation where different dialects may be inserted into different locations using different builders.
In multi-dialect context, a utility “container” class can be introduced as follows.
/* Dialect-specific autogenerated wrapper. */
struct StdOps {
StdOps(OpBuilder &b, Location loc) : std(b, loc) {}
StdBuilder std;
};
/* Container class. */
template <typename… Dialects>
struct MultiDialectBuilder : public Dialects… {
MultiDialectBuilder(OpBuilder &b, Location loc) : Dialects(b, loc)... {}
};
This scheme can be used to combine multiple dialect-specific builders under one object while preserving the per-dialect difference to avoid name clashes between ops and keep the dialect name visible in the API.
MuitiDialectBuilder<StdOps, ScfOps> create(...);
create.scf.forOp(...);
create.std.addFOp(...);
Given the concerns about the EDSC naming scheme (see “Misleading Names” in Evolving Builder APIs based on lessons learned from EDSC), this approach may be encouraged even for single-dialect cases.
Interaction with the Builder infrastructure
Since these classes are mostly thin wrappers around OpBuilder
, they are expected to interoperate with the Builder infrastructure seamlessly. Only PatternRewriter::replaceOpWithNewOp
does not have a direct counterpart, but can be replaced by a dialect-builder call followed by PatternRewriter::replaceOp
.
A potentially dangerous behavior can appear in the case of nested-region builders, for example, in
create.scf.forOp(..., [&](OpBuilder &nested, Location loc, …) {
// `create` must be updated to point to the new builder before being used here
DialectBuilder::Scoped raii(create, nested, loc);
create.scf.yieldOp(...);
});
one may forget to update the insertion point of the multi-dialect builder. Practically, in-tree nested-region builders forward the OpBuilder
on which they were called as nested
after updating the builder’s insertion point. However this behavior is not guaranteed. Note that this issue is not specific to this proposal and can also be triggered with regular OpBuidler
s by using the wrong (outer) builder instead of the nested one.
There are no current plans of replacing the OpBuilder
infrastructure with this approach, OpBuilder
remains necessary as the insertion point manager and notifier for mutation listeners.
Interaction with EDSC
This mechanism is expected to replace EDSC intrinsics by providing similar functionality that is more clearly based on the main builder infrastructure. It also replaces most boilerplate “intrinsic” lists such as https://github.com/llvm/llvm-project/blob/master/mlir/include/mlir/Dialect/Vector/EDSC/Intrinsics.h#L17 by tooling.
EDSC still remains based on the ScopedContext
container that stores the stack of builders + insertion locations in thread_local
storage. In order to make use of the proposed API, one has to either extract the builder+location from ScopedContext
when constructing the dialect builder or to pass around the pre-constructed dialect builder by-reference. In any case, the interaction with the “implicit context” becomes visible and can be progressively removed by always passing the dialect builder. While enabled by this proposal, the removal of ScopedContext
is beyond its scope.
Improvements over EDSC
This proposal addresses several EDSC drawbacks identified in the previous discussion.
Cost of Extra Abstraction
This removes edsc::OperationBuilder
and edsc::ValueBuilder
. While it does also introduce a new concept of a (multi-)dialect builder, it is arguably simpler as it clearly just forwards the construction to an OpBuilder
, without involving ScopedContext
. Furthermore, it removes the notion of “intrinsic”, which is badly overloaded in a compiler context.
As a nice bonus, it removes the instances where the EDSC intrinsics API required extra parentheses due to the most vexing parse in C++.
Misleading Names
Construction API looks less like a function call from which it is unclear whether some IR is being constructed. In the minimal case, the construction is a method call on an object with a clearly identifiable “xxBuilder” type that implies IR is being built. With multi-dialect builders, one can also name the builder instance in such a way that the act of construction is obvious from each call, e.g. create
or build
. At the same time, the names of individual functions are derived (in ODS) from C++ class names of the operations. Furthermore, when delegating parts of the IR construction to other functions, this approach encourages (but does not require due to ScopedContext
) passing the builder as argument, which allows one to infer if the function constructs IR from its signature.
Scalability and Boilerplate
This replaces boilerplate code currently required by tooling that generates code from ODS.
Known objections
But locations are important!
Adopting this approach will essentially cache the Location
instance, making it less visible in the code and removing the incentive for users to keep them up-to-date.
This approach is no worse than literally caching the location in a variable:
Location loc = builder.getUnkownLocation();
/* two screens of code here */
builder.create<Op1>(loc, …);
builder.create<Op2>(loc, …);
which happens a lot in the codebase. A common place for this pattern are rewrite patterns where one just forwards the location information in a rather verbose way. We can easily generate factory function overloads that also take Location
as the leading argument, and provide means to set builder locations in a scoped way via RAII.
This is merely saving a couple of keystrokes!
One can just always call the OpBuilder
instance b
and achieve the same result.
Assuming a single-letter variable for location (e.g., l
) + multi-dialect builder + absence of using namespace
other than mlir
, this saves 12 characters per builder call:
b.scf.ForOp(...);
b.create<scf::ForOp>(l, ...);
This might feel like nothing, but it’s 15% of the total line length. In practice, this number may get larger depending on location naming conventions. When combined with line reflow, this can make code substantially more compact.
Usability gains are also not limited to less keystrokes to type. One no longer needs to visually filter through the “API crust” of builder.create< >(loc
in long construction chains.
EDSC produces ops in unspecified order, this is the same!
This API, EDSC and OpBuilder all can be used in such a way that they produce operations in an unspecified order, until C++17. Builder API is just sufficiently verbose so that one is almost never tempted to use it that way:
OpBuilder b(...);
b.create<AddFOp>(type, b.create<MulFOp>(type, v1, v2), b.create<MulFOp>(type, v3, v4));
will produce the two mulf
operations in an unspecified order.
Builders just work for me!
Builders are not going away. However, people will (and already started to) build abstractions around them, including in the core repository. By providing usable abstractions out-of-the-box, we make sure there is a carefully designed, maintained abstraction layer that is common across the infrastructure.