[RFC] Introduce named constraints (aka IR concepts)

Hi everyone,

following a round table discussion we had at the MLIR hackathon in Edinburgh, I’d like to propose the introduction of “named constraints” (aka IR concepts) to MLIR, and subsequently the MLIR core dialects.

If you have witnessed this discussion, you can safely skip to the next steps. I added some examples of how this can be used though, if you need some more inspiration.

Constraint declarations

To explain the purpose of named constraints, let’s first consider the existing TableGen Type and Attribute constraint mechanism. In TosaTypesBase.td, you’ll find the following snippet:

def Tosa_Int : AnyTypeOf<[Tosa_Bool,
                          Tosa_UInt8,
                          Tosa_UInt16,
                          Tosa_SignedInt]>;

...
def Tosa_IntLike : TypeOrContainer<Tosa_Int, "signless-integer-like">;
// It actually uses Tosa_TypeLike, but let's assume standard features are used.

This snippet declares the Tosa_IntLike type constraint, which can now be used by operation declarations to constrain the types of SSA values. In our example, the ApplyScaleOp is declared as:

def Tosa_ApplyScaleOp : Tosa_Op<"apply_scale", ...> {
  ...
  
  let arguments = (ins
    Tosa_IntLike:$value,
    Tosa_IntLike:$multiplier,
    Tosa_Int8Like:$shift,
    BoolAttr:$double_round
  );

  let results = (outs
    Tosa_IntLike:$output
  );

  ...
}

The result is that any consumer of a verified instance of the ApplyScaleOp operation can be certain that the concrete type of $value is (a container of) one of the allowed integers.

Constraints for attributes are handled almost the exact same way.

Implementation

In practice, the TableGen Op record determines how ODS will generate the verifyInvariantsImpl() method of the generated Op class. It causes ODS to generate a set of functions in the anonymous namespace of the CPP-sided include for the dialect that match the individual constraints. On both $value and $multiplier, the following function is called:

static ::mlir::LogicalResult __mlir_ods_local_type_constraint_TosaOps1(
    ::mlir::Operation *op, ::mlir::Type type, ::llvm::StringRef valueKind,
    unsigned valueIndex) {
  // Omitted a boatload of terms in the condition for brevity.
  if (!((((type.isSignlessInteger(1))) || ...) || ...)) {
    return op->emitOpError(valueKind) << " #" << valueIndex
        << " must be signless-integer-like, but got " << type;
  }
  return ::mlir::success();
}

The names of these functions are generated to be unique per constraint definition, but can effectively be considered anonymous. In other words, there is no expectation that the user would interact with this function voluntarily.

The function is generated by combining the predicates of the underlying Type or Attr records, i.e., arbitrary C++ expression snippets, into a single test. That is, although constraint declarations appear to have introspectable semantics, they are just some C++ snippets.

Named constraints

A named constraint is a strengthening of this mechanism that allows the user to:

  • carry over their strong domain terminology into C++
  • enjoy the convenience of LLVM-style casting, e.g., dyn_cast
  • use the C++ type system to propagate constraint information

Turning the above example into a named constraint could have the following opt-in syntax:

def Tosa_IntLike : NamedConstraint<"IntLikeType", TypeOrContainer<Tosa_Int, "signless-integer-like">> {
  let cppNamespace = "mlir::tosa";
}

As a result, a mlir::tosa::IntLikeType class would be generated that derives from Type. Without impacting any previous uses, the user could then ditch nested conditionals in favor of more idiomatic code such as:

T handleType(Type type) {
  return llvm::TypeSwitch<Type, T>(type)
    .Case([](IntLikeType ty) { return ... });
    .Case([](FloatType ty) { return ... });
    .Default([](auto) { return T{}; });
}

Additionally, the C++ type system will now carry the information that a type or attribute matches a certain constraint. In conjuction with the TypedValue<T> template and ODS-generated getters, this means clearer interfaces and less error potential caused by invalid assumptions.

Named constraints can also declare new methods that implement custom behavior relevant only to instances of the constraint, including dispatching to the concrete type or attribute. This can be very helpful in monomorphizing code in scenarios where interfaces are not quite applicable.

bool hasMyTrait(IntLikeType type) {
    return ty.getElementBitWidth() <= 64;
}

Such methods can of course also be builder methods, if you want users to construct instances of this constraint in a canonical form. (This also works on operations with the OpBuilder, if the constraint implements getOperationName(), returning the name of an already registered op.)

Users can also use named constraints to declare overload sets that reduce the amount of constraint checking if instances are statically known to satisfy them:

bool hasMyTrait(IntLikeType type) { ... }
bool hasMyTrait(Type type) {
  if (const auto intLikeTy = llvm::dyn_cast<IntLikeType>(type))
    return hasMyTrait(intLikeTy);
  if (...)
    return ...;
  return false;
}

Implementation

In essence, by associating a type name and namespace to a constraint, we will have ODS generate a specialization of the underlying fancy pointer class, e.g., Attribute or Type. A prime example of how this looks like in core is the FloatType implementation in BuiltinTypes.h.

In its minimum viable form, this looks like:

class MyTypeConstraint : public Type {
public:
  [[nodiscard]] static bool classof(Type type) {
    return // old ODS predicate expression goes here.
  }
  
  using Type::Type;
};

It is permissible to specify any T that std::is_base_of_v<Attribute, T> or std::is_base_of_v<Type, T> as a base, if a constraint should directly specialize another.

Additionally, we would allow the TableGen record to also provide some extraClassDeclaration segment that adds methods to the constraint type. Until introspection for constraints becomes available, implicit conversions to a constraint, in cases where a constraint is trivially matched, may also be added this way:

[[nodiscard]] static bool classof(IntegerType) { return true; }
/*implicit*/ MyTypeConstraint(IntegerType type)
    : Type(llvm::cast<Type>(type).getImpl())
{}

Whenever ODS generates a verifier, it will still produce an anonymous method to emit the error if needed, but will convert all tests for MyTypeConstraint to simply ::llvm::isa<::mlir::my_ns::MyTypeConstraint>(type). In fact, this behavior can already be achieved today by explicitly declaring an ODS type or attribute:

def AnyFloat : Type<CPred<"$_self.isa<::mlir::FloatType>()">, "floating-point",
                    "::mlir::FloatType">;

Where ::mlir::FloatType is the fancy pointer specialization that implements the constraint.

Pitfalls

  • Named constraints could also apply to Ops (via OpState).

There was a historic use of a ConstantIntOp in the arith dialect (it might even have been std back then) that did this for convenience. Compared to Type and Attribute, however, this is not that simple because OpState does not provide the entire interface generic consumers of Op expect. In other words, only specializations of ODS-generated Ops are trivial.

Additionally, this highlights a “time of check vs. time of use” problem. Operations are mutable and may therefore cease matching a constraint. One can argue this is also the case for types and attributes, but certainly less often.

  • Adding named constraints will introduce uses of llvm::isa that may execute arbitrarily complex C++.

Although it can be argued that this will encourage users to have more such potentially expensive checks in the code, the proposed change will not impact any existing users. Users that do decide to opt-in will need to be aware of this issue. However, in places where such matching logic is necessary, it is already being paid for, we just make it cleaner.

In fact, the usage of overload sets here might reduce the total number of predicate checks performed.

  • Giving structure to constraints could further reduce matching overhead.

Consider a complex constraint C that is the intersection of simpler constraints C ={C_1, ..., C_N}. Matching some T that is statically known to satisfy T = {T_1, ..., T_N} to C should at most have to check all constraints C \ T (set minus).

Assuming all users of this mechanism go through TableGen records such as And and Or predicates, we might be able to model this and other simplifications using template metaprogramming on sets. However, the benefit is questionable at considerable maintenance effort.

IRDL will introduce its own model of attribute and type constraints in due time (hopefully), which may later be used to do constraint simplification.

Next steps

At the hackathon, we had reached some sort of agreement that we consider this a valid use of the casting mechanism, and that it may be beneficial. I think named constraints are a good name for this feature, and the opt-in behavior should allow for easy introduction.

We have not agreed on a definitive syntax for the new TableGen record, but it seems rather trivial. As promised, I’m going to whip up a tblgen-mlir patch that implements the feature during the next week.

I want to draw attention to the second part of this proposal, which is porting uses of this pattern in the core dialects. Specifically, FloatType is such an example. While defined in BuiltinTypes.h, meaning BuiltinTypes.td being the “right place” for it, its current counterpart is actually in OpBase.td. IMHO, that is dodgy anyways, with an eye on decoupling the built-ins. Although I don’t think many people will do type stuff without including the built-in types, this may be breaking for downstream users, even though the API does not change at all.

Thanks for all the support and great discussion over the last couple of days,
~Karl

8 Likes

It’s there for “historical reasons”. Many will hate me for saying this, but breaking downstream users is OK if it’s to correct a mistake :slight_smile:. We shouldn’t have the duplication between BuiltinTypes and OpBase anyways.

Happy to see the work moving forward!

2 Likes

Thanks for the proposal, this looks quite interesting! If this can also result in the conceptual cleanup of Type vs TypeConstraint vs TypeDef in ODS, that would be even better. I have a couple of questions and comments.

The biggest one is layering and, more specifically, where do the generated classes live in the library structure? Currently, constraints are just inlined into various verifiers so it is “cheap” to add some more in my dialect’s .td file or borrow some from another dialect. Materializing them in C++ will turn that into a library-level dependency between dialects.

A related one is cost: what is your best take on how this will affect the compile time of MLIR itself? the size of libraries due to potentially increased dependency footprint?

Name bikeshedding: I’d prefer this to be called named constraints and not concepts to avoid confusion with C++ concepts.

Could you provide an example where interfaces are not quite applicable? This proposal feels like a mere inversion of responsibility compared to interfaces, i.e. the constraint knows about the types it supports vs. the type knows the interfaces it supports. We could also just include some type-specific dispatch logic into the methodBody part of the interface, which is ignored by most interfaces right now and just dispatches to the type itself though it doesn’t have to.

Attributes and types must specifically indicate that they are mutable. Furthermore, they must have an immutable part that is used for uniquing. The mechanism proposed here can be limited to operate only on the immutable part.

I remember writing some primitive logic simplification rules on the tree of predicates in tablegen. I do not remember ever touching that code in the past 4+ years. So I would claim the maintenance effort is close to zero. Doing this in templates though comes with a considerable compile-time cost.

I would love to, but I’ll first have to work my way into tblgen.

Let me split this into 2 parts:

  • Which tblgen target generates named constraints?

    ATM, you can define a constraint anywhere, and it will be instantiated by its users. If we continue to let their uses generate them, we run into another version of the same duplication problem we have with interfaces not being tied to dialects for the purposes of -gen-*-interface-*.

    The only reasonable solution seems to be having some tblgen targets, e.g., -gen-typedef-* and -gen-attrdef-*, be responsible, and thus have ordering with the same rules users already have to respect when structuring their includes.

    Still, I take issue with this ordering problem, and actually use named constraints to resolve it using out-of-line definitions where unavoidable. I also argue that it makes sense to declare named constraints next to interfaces, for external users to consume. I’m open to whether this means we add another tblgen target, or consider this an advanced use where the existing manual way of doing things using Attr/Type should be used.

  • How to handle dialect-external uses of a named constraint?

    I argue that it is not just good practice but rather required to include the accompanying C++ header to a TableGen definitions file. Similarly, I argue it is then also required to link against the definition. In other words, to me it is kind of a moot point: if you were using someone else’s TableGen definitions, you had better linked against them. However, I concede that this did not matter for constraints until this change.

    If you absolutely must, you should redeclare the constraint inside your own namespace, I think.

    I am much more concerned with the fact that most of the named constraints I implemented so far, and also the built-ins, are usually defined inline. This is not generally possible with arbitrary predicates. Additionally, users already made up their mind on what to put where (consider getI1SameShape and such common helpers), which would have to be moved to achieve inline definitions.

This inversion also matches my view exactly. I have also used the methodBody for this purpose before. Here are some arguments for constraints over interfaces:

  • Constraints on a foreign IR object (no static trait) are more light-weight than an interface with an ExternalModel.
  • Constraints can model aggregates of interfaces (unions and intersections).
  • Constraints can model context/role semantics, i.e., “softly override” interface behavior.
  • Constraints are transient and weak, meaning that just like a C++ concept, two foreign constraints that are semantically equivalent are compatible (as opposed to two interfaces with the same methods, for example).

I recognize now I was still stuck in the C++ way of doing it, where I had no TableGen support previously. Template metaprogramming is necessary to achieve this only when I needed to do this without a code generator. The key phrase here being “everyone must go through And and Or”, so it really becomes simpler.

In fact, after having a stab at it yesterday, I think this should be a required feature of this proposal. In particular, I have tried a scheme where existing leaves (other named constraints, for example) produce overload sets as described above. That also gives us some implicit conversions, which are a big usability factor in my book, and may also reduce downstream breakage.

My question is concerned more with libraries rather than tablegen targets. Targets and include orderings are trivially solvable issues IMO as long as there are no cycles.

Specifically:

is not how this works right now. There are no “tablegen definitions” that one can link against. Tablegen backend generates files that are #include-ed into actual source files, and since CMake models headers as one flat hierarchy, there are no library-level dependencies caused by tablegen includes.

As an example, let’s say I have my AlexDialect that currently uses a type constraints from the LLVMDialect, defined in LLVMOpBase.td. Currently, this costs me almost nothing in terms of compile time or the size of by libAlexDialect.so because the constraint logic is just replicated inside the ops of my dialect by the ODS backend. After this change, I’d need to make libAlexDialect.so depend on libLLVMDialect.so and everything it includes, which may be way more than I actually need.

At scale, this may create a denser dependency graph between components than we would otherwise have, with larger final binary size (mlir-opt links everything, but not everyone wants that), longer compile times because of dependency chains and link times. This is a rather important point when you have large projects that use MLIR, or you build MLIR regularly as it will end up costing a noticeable amount.

Such replication is a code smell. It is only a matter of time before the actual constraint logic diverges and we end up with similarly-named-yet-different constraints in two namespaces.

I don’t think somebody would deliberately make the choice of putting that helper in ArithOps if they wanted it to be reusable.

I’d like to have some thought go into layering here. We have lib/Interfaces at the top level, maybe we can also have lib/Constraints for things that are truly generic. Maybe we should have lib/Dialect/<dialect-name>/Constraints in the dialect that defines the types. Maybe something else, but this doesn’t look like a problem that can be just brushed away. It may seem like it will “just work”, but it will not once we scale up the usage of named constraints beyond trivial cases defined in OpBase.td (and even for those, it’s unclear whether we want the classes to live under lib/IR or lib/Dialect/Builtin or something else).

I think we need some criteria where one would use constraints or interfaces. A simple line could be to disallow extraClassDeclarations on constraints and let them be just what their name says - constraints. I am in favor of the idea, but we should be mindful of the overall complexity of MLIR system of abstractions.

1 Like

I understand, but I disagree. A TableGen definitions file is relevant only through the code it generates. The usual convention is to have an include file that includes the generated code, which must also include any dependencies of that generated code. So far, I think we agree.

What takes me further is that generated code may make use of arbitrary C++. A linking issue is only guaranteed to not exist for entirely inlined definitions. That’s not the case in reality though. In the absence of a mechanism to constrain what the user is allowed to have in their generated code, any user that includes generated code must make no assumptions about used symbols.

In that particular case, I only see two options:

  • LLVMOpBase.td is a definitions file that does not generate any code, or includes verbatim snippets that will be part of generated code. In that case, using it is fine. If it declares named constraints, it will cease to be such a file, and option two applies.
  • LLVMOpBase.td is a definitions file that code will be generated from, or contains such code. Thus, it is “undefined behavior” at best to include it, without also linking against all targets that define symbols which may be transitively used by it. That’s what I mean by linking against the definitions, sorry for the inaccuracy.

Very true. However, this could also be effective at decoupling dialect APIs. While the dependency is still there, you make API breaking changes easier to follow. I think it should be used sparingly, but does have its uses.

I still think that a fragmentation of the MLIR ecosystem is unavoidable, and that these processes already happen on a larger scale. I am no longer as opposed to it. Rather, I think constraints and other such mechanisms can help re-couple these systems. After all, two independent dialects can agree on the same notion of a particular IR element in this way.

This is actually what I used to do, and I like this the most! I thought it would be a bit much for now, but I realize that we should have a solid roadmap from the start. Definitely before we start porting any existing uses of this pattern.

I think named constraints could also be defined “locally” in the IR includes, but having a place to put exported ones, and thus decouple targets to link (such as with interfaces) is the nicest. It is a cooperative solution to the linking issue that I have been using so far.

I am also in favor of setting up a concrete guideline, and then using that to revisit all the patterns currently in use. It would only make sense to make sure it is applied consistently in core.

I am strongly against disallowing extraClassDeclarations though, as it is one of the main C++ convenience features I use. Although the proposal is still useful without it, this is a feature that I consider essential. I’d have to end up resorting to my current work-arounds.

I acknowledge that a different method of achieving the same API may be desirable though. Here’s one proposal: let constraints implement (inherit) traits. In turn, allow definition of traits from TableGen. Technically, all functionality dispatched through a constraint can be implemented as a trait on the underlying base type. This makes constraints even more similar to interfaces though, to the point where it would make sense for them to share some of their codegen path. A key difference would be that these traits don’t know the concrete type (well, they are traits on the supertype).