[RFC] Modeling type hierarchies in C API

Context: [RFC] Rebooting C APIs for core IR

Proceeding with C API implementation, we need to decide on the support that the API provides for type hierarchies. In particular, we are interested in Type, Attribute and Operation hierarchies that can be extended by dialects. The Value hierarchy is fairly simple, closed and seems adaptable to any modeling choice we make for the other hierarchies.

Option 1: Expose Only Top-Level Types as C API Types

In the minimalist spirit of core bindings, we can limit the API to have only top-level types mlirType, mlirAttribute and mlirOperation respectively. The derived type is encoded in the naming convention of the function that applies to objects of top-level types, and calls cast<> internally. For example, a function that is only available on shaped types would have the signature mlirShapedType(MlirType, <<...>>) For all derived types, an “isa” function is provided with the following signature bool mlir<<BaseType>>IsA<<DerivedType>>(BaseType). This function allows to safeguard other calls. This approach is similar to what LLVM C API provides. The code using this approach resembles the following.

MlirType elementTypeOrSelf(MlirType t) {
  if (mlirTypeIsShapedType(t))
    return mlirShapedTypeGetElementType(t);
  return t;
}

MlirType buildStructureType(MlirContext ctx)
  MlirType a = mlirIntegerTypeGet(ctx, /*width=*/32);
  MlirType b = mlirFloatTypeF32Get(ctx);
  intptr_t vectorSize = 4;
  MlirType c = mlirVectorTypeGet(b, /*rank=*/1, &vectorSize);
  MlirType types[] = {a, b, c};
  return mlirTupleTypeGet(ctx, /*size=*/3, types);
}

The benefit of this model is all types being immediately usable in all APIs, i.e. no need for an explicit upcast. The drawback is the flip side of the benefit: it is impossible for a method to require only specific derived classes so that the requirement is enforced at compile time. (However, it is still possible to define a typedef MlirType MlirVectorType and use the alias for documentation purposes in the signature).

Option 2: Expose All Types in the Hierarchy as C API Types

An alternative modeling defines C API types for all types in the C++ hierarchy. This requires explicit up- and downcasting. They can be provided by complementing the “isa” function with “as” functions. Optionally, the latter can be split into “as” (upcast) and “cast” (downcast) functions following the same naming scheme: mlir<<BaseType>>CastTo<<DerivedType>> and mlir<<DerivedType>>As<<BaseType>>. Two different function names can be used communicate the directionality of the cast, provided that an upcast always succeeds but a downcast may fail. This provides slightly more compile-time safety as some verbosity cost:

MlirType elementTypeOrSelf(MlirType t) {
  if (mlirTypeIsShapedType(t)) {
    MlirShapedType shaped = mlirTypeCastToShapedType(t);
    return mlirShapedTypeGetElementType(shaped);
  }
  return t;
}

MlirType buildStructureType(MlirContext ctx)
  MlirIntegerType a = mlirIntegerTypeGet(ctx, /*width=*/32);
  MlirFloatType b = mlirFloatTypeF32Get(ctx);
  intptr_t vectorSize = 4;
  MlirVectorType c = mlirVectorTypeGet(b, /*rank=*/1, &vectorSize);
  MlirType ap = mlirIntegerTypeAsType(a);
  MlirType bp = mlirFloatTypeAsType(b);
  MlirType cp = mlirVectorTypeAsType(c);
  MlirType types[] = {ap, bp, cp};
  MlirTypeType t = mlirTupleTypeGet(ctx, /*size=*/3, types);
  return mlirTupleTypeAsType(t);
}

Any preferences or alternative suggestions?

This is a pretty significant benefit, imo, primarily because, as open type hierarchies, it limits the amount of “binding code” that needs to exist for each new custom type. A lot of C-level APIs for open type systems, in my experience, end up erasing the specifics of the object hierarchy in favor of a generic top-level type. I am aware of some that blend option 1 and option 2 for certain blessed internal types (i.e. the jobject hierarchy for JNI and various things in the Python C-API).

In my experience having used a lot of the Type/Attribute subclasses and custom types, often times the receiver of the instance needs to do more detailed verification than can be simply encoded in a C-like type system and likely needs to have error paths anyway (i.e. verify that it is a vector of a certain shape or element type, etc). Preferring the generic MlirType, MlirAttribute and MlirOperation in such cases and in all black box cases seems right to me. For the things that are left, I think I would prefer option 1, but for certain highly trafficed internal types, we may find that we want a pattern to downcast to a specific type and use type-safe accessors.

This seems more likely to become important for the attribute hierarchy than the type hierarchy. If providing methods for DictionaryAttr, for example, I could see wanting to define those as functions that take an MlirDictionaryAttr which must be downcast from the generic MlirAttr in order to invoke them. Ditto for all of the *ElementsAttr classes. On the flip-side, these APIs are likely going to be somewhat harder to keep API version stability for and the ability to introspect and manipulate them is not required for all uses.

Perhaps favor Option 1 for the “core IR” and have extension APIs that make consistent use of Option 2 for defining accessors/methods that operate on specific types in the hierachies. Such extension headers might have names like StandardAttrs.h, StandardTypes.h, etc. And we could provide a different expectation of compatibility for them.

Many ops have methods to directly return values of an attributes instead of the attribute itself. So we can wrap and pass around those values. For example, for DictionaryAttr, we can pass around a list of named attributes (as we already do in op construction). There is little use for the DictionaryAttr itself: because of attribute uniquing, one cannot just write dictionaryAttr.insert(a, b) but needs to re-create the attribute from its new value, and it’s actually better/cheaper to manipulate the value and call the creation+uniquing under lock only once. ElementsAttr is trickier because it can have different underlying storage so having an opaque object that can be iterated over sounds better here. That being said, ElementsAttr is an outlier, other standard attributes look like they can be just replaced by their values.

“Core IR” is only the “Value -> {BlockArgument, OpResult}” hierarchy…

This is exactly what I want to do.

FWIW, what Stella describes makes sense to me.

I agree. I think it is completely sufficient for the implementation of these can include a cast<> to catch type errors dynamically with an assertion. The primary purpose of these APIs is to support bindings, and those bindings can address the safety issue in different ways depending on the language constraints (e.g. whether the binding language is dynamically or statically typed).

1 Like

I’m not sure I am following the discussion correctly. In particular, where do we draw the line between what gets option-1 interface and what gets option-2 interface.

Let’s take concrete examples.

A) “Core IR” hierarchy: mlir::BlockArgument : Value, and mlir::OpResult : Value.
This looks clear, we want option-1. Only MlirValue is exposed as a type. We have bool mlirValueIsAOpResult(MlirValue) and bool mlirValueIsABlockArgument(MlirValue) as safety mechanisms that C API users (e.g. bindings) can invoke. We further have functions like intptr_t MlirBlockArgumentGetArgNumber(MlirValue) that assert their argument is of a correct subtype.

B) Standard types: e.g., mlir::IntegerType : Type. It looks like the consensus here is to also have option-1 interface, i.e. bool mlirTypeIsAIntegerType(MlirType) and functions on type unsigned mlirIntegerTypeGetBitWidth(MlirType). This is consistent with the methods we have in C++ on mlir::Type for some “privileged” types. Do we also want an explicit downcast?

C) Standard attributes: e.g., mlir::DictionaryAttr : Attribute. This gets less clear. Do we want option-1 MlirAttribute mlirDictionaryAttrGetAttr(MlirAttribute, const char *), option-2 MlirAttribute mlirDictionaryAttrGetAttr(MlirDictionaryAttr, const char *) with explicit downcast, or both? If both, we will need a naming scheme to disambiguate the cases.

D) Dialect-specific types: e.g., mlir::LLVM::LLVMIntegerType : Type. It looks like option-2 is preferred here. This makes the standard dialect “more built-in” than other dialects. Do we want this? Do we rather want to have “extension” functions defined in separate headers that still take MlirType and assume it’s an LLVM dialect type? Are dialects allowed to expose only a subset of their internal hierarchy, i.e. expose only mlir::LLVM::LLVMType?

+1

I think I’m +1 on this. It feels slightly arbitrary to implicitly be grouping some of these as privileged, while potentially having type specificity on dialect-types. I’m not sure I have a principled reason to draw the line other than “ints and floats seem ok to be special.” As you say, they already have special carve-outs on the Type class.

How about we bootstrap here with int/float/index type-erased, then as a very next step, we’re going to need to define an API for the ShapedType hierarchy, and then we can decide to revise the int/float version? I’d rather get something that feels right for those, with their more complicated usage patterns vs trying to answer the questions on the very trivial ones. I suspect that the pattern we decide on here will also apply to case (D).

I am inclined towards option-1 (with a nit to maybe name it mlirDictionaryAttrGetNamed), but I think we should follow the convention we decide for case (D) and not try to support both.

The more I’m looking at this, the more I’m leaning towards option-1 consistently with IsA functions and concrete type checking internal to the C-implementation. I’d rather the checking and asserting is in code on the MLIR-CAPI side versus inconsistently scattered at all call sites (i.e. the casts will need to all be null-guarded with appropriate assert/check). In my experience, for this kind of C-API, you always want to armor it internally with additional checks on the actual types/values (or non-nullness) of things since there is so much slop in what can be expressed.

As in previous cases, I would be arguing for more compile-time type-modeling in a higher level language.

If others advocate for a more specific modeling of derived Type and Attributes, I would then advocate that we apply the pattern consistently to cases B-D.

I think it is probably better to go with option-1 completely for simplicity and consistency, since it is hard to decide (and describe if even we agree on a line) where the line is.