Graduate op versioning mechanism to OpBase.td

Hello everyone,

Recently there are folks (e.g., @jingpu) asking me how to reuse the op availability mechanism in the SPIR-V dialect for other dialects. The mechanism was originally designed and implemented to be generally applicable to any dialect that has needs for versioning, extension requirements and such. It is prototyped in the SPIR-V dialect given it’s one good example with particularly challenging needs. It has been working quite well thus far.

Not all dialects care about versioning, but for those (typically “edge” dialects) do, sharing something in common could be very beneficial. Now we have more interest parties, in order to avoid the overhead and duplication for each dialect to have its own definition and TableGen generator, I’d like to propose to graduate it and move the core definition to OpBase.td. Concretely, it would mean moving SPIRVAvailability.td (including the core Availability class and MinVersionBase and MaxVersionBase) into OpBase.td, and moving the associated TableGen generator logic to OpDefinitionsGen.

But of course, I’d like to seek feedback in the community first to make sure the design can be applicable to various interest parties. Happy to make modifications where it’s not. :slight_smile: The following is a slightly revised version of the original RFC sent quite some time ago. Back then MLIR was in transition of moving into LLVM so there weren’t many comments over it.

TL;DR: the gist of the proposal is to introduce Availability classes in ODS to let op declare availability, and autogenerate C++ OpInterfaces for availability checking and filtering, to layer well on top of existing mechanisms.


Hi All,

I’d like to introduce a generic mechanism to specify op availability. It can be used to implement op versioning, extension requirements, and others.

Dialects are evolving op collections. Typically there are backward/forward compatibility implications when introducing changes. This is especially true for edge dialects, which model external programming models or hardware so must mirroring the requirements.

A typical mechanism to guarantee compatibility is versioning. But there are other ways. For example, in SPIR-V, some ops are available only under certain capabilities or extensions. Generally, these are all mechanisms to control the availability of ops.

This RFC proposes to introduce a generic mechanism to specify op availability.

Design Goals

  • Generic. Different dialects have different requirements over op availability. Dialects should be able to define their own availability dimensions (version, extension, capability, etc.).
  • Descriptive. We already use ODS for specifying op definitions in a concise way. Availability information is part of that and should be integrated into op definitions.
  • Reusing existing core mechanisms. Prefer to avoid introducing additional mechanisms and prefer to layer on top of existing ones.
  • Auto-generation as much as possible. From the descriptive spec, optimally we should generate all C++ code to ease development.

There are a few common cases we see regarding op availability:

  • An op itself can be present or missing depending on the version/extension/capability.
  • An op’s operand/attribute/result can be present or missing.
  • The types supported by an op’s operands/attributes/results can differ among versions.
  • An enum attribute case can be present or missing.

So we need to provide mechanisms to control the availability of the op itself, its operands, attributes, results, and also enum attribute cases.

Design Proposal

The plan is to add an Availability class and allow Op, Type, Attr, and EnumAttrCaseInfo in ODS to carray a list<Availability> field. These descriptive specifications will be used to generate C++ OpInterfaces for the availability dimension and each op’s implementation of those interfaces. Specifically:

// The base class for defining op availability dimensions.
class Availability {
  // Fields for controlling the generated C++ OpInterface

  // The name for the generated C++ OpInterface subclass.
  string interfaceName = ?;
  // The documentation for the generated C++ OpInterface subclass.
  string interfaceDescription = "";

  // Fields for controlling the query function signature

  // The query function's return type in the generated C++ OpInterface subclass.
  string queryFnRetType = ?;
  // The query function's name in the generated C++ OpInterface subclass.
  string queryFnName = ?;

  // Fields for controlling the query function implementation

  // The logic for merging two availability requirements.
  // This is used to derive the final availability requirement when,
  // for example, an op has two operands and these two operands have
  // different availability requirements. 
  code mergeAction = ?;

  // The initializer for the final availability requirement.
  string initializer = ?;
  // A specific availability requirement's type.
  string instanceType = ?;
  // Fields for a concrete availability instance
  // The specific availability requirement carried by a concrete instance.
  string instance = ?;
}


// A modifier to attach availability spec to a certain type. 
def TypeAvailableAt<Type type, list<Availability> avail> : 
    TypeConstraint<type.predicate, type.description> {
  Type baseType = type;
  list<Availability> availability = avail;
}


// A modifier to attach availability spec to a certain attribute kind. 
class AttrAvailableAt<Attr attr, list<Availability> avail> :
    Attr<attr.predicate, attr.description> {
  let baseAttr = attr;
  list<Availability> availability = avail;
}


class Op<...> {
  // We can use `TypeAvailableAt` and `AttrAvailableAt` to attach availability
  // spec on op operands/attributes/results.
  let arguments = (ins ...);
  let results = (outs ...);

  // The list of availability specs for this op itself.
  list<Availability> availability = [];
}

Each Availability subclass is a distinct availability dimension. Version is a common one; we can also see extensions, capabilities for SPIR-V, and other possible dimensions.

Versioning

For example, versioning can be defined as:

class MinVersionBase<string name, I32EnumAttr scheme, I32EnumAttrCase min>
    : Availability {
  let interfaceName = name;

  let queryFnRetType = scheme.returnType;
  let queryFnName = "getMinVersion";

  let mergeAction = "$overall = static_cast<" # scheme.returnType # ">("
                      "std::max($overall, $instance))";
  let initializer = "static_cast<" # scheme.returnType # ">(uint32_t(0))";
  let instanceType = scheme.cppNamespace # "::" # scheme.className;

  let instance = scheme.cppNamespace # "::" # scheme.className # "::" #
                 min.symbol;
}

class MaxVersionBase<string name, I32EnumAttr scheme, I32EnumAttrCase max>
    : Availability {
  let interfaceName = name;

  let queryFnRetType = scheme.returnType;
  let queryFnName = "getMaxVersion";

  let mergeAction = "$overall = static_cast<" # scheme.returnType # ">("
                      "std::min($overall, $instance))";
  let initializer = "static_cast<" # scheme.returnType # ">(~uint32_t(0))";
  let instanceType = scheme.cppNamespace # "::" # scheme.className;

  let instance = scheme.cppNamespace # "::" # scheme.className # "::" #
                 max.symbol;
}

Note that in the above, MinVersionBase and MaxVersionBase are designed to take an IntEnumAttrCaseBase. The rationale behind is that versioning is not continuous; it’s discrete. But each dialect may have its own versioning scheme. A unified way of handling this is to let each dialect define a versioning enum attribute. So that In ODS we refer to the enum cases uniformly. EnumsGen will help to generate a corresponding C++ enum class so a dialect can map it to whatever scheme it may want.

Example

An example usage would be:

// Define dialect versions.
def EXAMPLE_V1 : I32EnumAttrCase<"V_1", 0>;
def EXAMPLE_V2 : I32EnumAttrCase<"V_2", 1>;
def EXAMPLE_V3 : I32EnumAttrCase<"V_3", 2>;


// This attribute here is mainly for generating the C++ enum class for the version.
// But it can also be used in op definition if suitable.
EXAMPLEVersionAttr : I32EnumAttr<
    "Version", "valid versions", [EXAMPLE_V1, EXAMPLE_V2, EXAMPLE_V3]> {
  let cppNamespace = "::mlir::example";
}


class MinVersion<I32EnumAttrCase min> : MinVersionBase<
    "QueryMinVersionInterface", EXAMPLEVersionAttr, min> {
  let interfaceDescription = [{ ... }];
}


class MaxVersion<I32EnumAttrCase max> : MaxVersionBase<
    "QueryMaxVersionInterface", EXAMPLEVersionAttr, max> {
  let interfaceDescription = [{ ... }];
}

The above will generate two op interfaces: QueryMinVersionInterface and QueryMaxVersionInterface:

class QueryMinVersionInterface : public OpInterface<QueryMinVersionInterface, detail::QueryMinVersionInterfaceTraits> {
public:
  ::mlir::example::Version getMinVersion();
};


class QueryMaxVersionInterface : public OpInterface<QueryMaxVersionInterface, detail::QueryMaxVersionInterfaceTraits> {
public:
  ::mlir::example::Version getMaxVersion();
};

Each op in EXAMPLE dialect with availability spec will derive from the corresponding interface and have the implementation for the query methods (getMinVersion and/or getMaxVersion) synthesized. For example, if we have the following op:

def EXAMPLE_A_Op: Op<"a", ...> {
  let availability = [MinVersion<EXAMPLE_V1>];
  let arguments = (ins
    AttrAvailabeAt<F32Attr, [MinVersion<Example_V2>]>:$attr
  );
  let results = (outs
    AnyTypeOf<[I32, TypeAvailableAt<F32, [MinVersion<EXAMPLE_V3]>]>:$result
  );

}

ODS should generate:

class AOp: Op<..., QueryMinVersionInterface::Trait> {
public:
  ::mlir::example::Version getMinVersion();
};

::mlir::example::Version AOp::getMinVersion() {
  ::mlir::example::Version final = static_cast<::mlir::example::Version>(uint32_t(0));
  // Update with op availability requirement
  final = static_cast<::mlir::example::Version>(std::max(static_cast<int>(final), static_cast<int>(::mlir::example::Version::V_1));
  // Update with attribute availability requirement
  {
    if (/* check whether attr is f32 here */)
      final = static_cast<::mlir::example::Version>(std::max(static_cast<int>(final), static_cast<int>(::mlir::example::Version::V_2));
  }

  // Update with result availability requirement
  {
    auto type = this->result()->getType();
    if (/* check whether type is f32 here */)
      final = static_cast<::mlir::example::Version>(std::max(static_cast<int>(final), static_cast<int>(::mlir::example::Version::V_3));
  }
  return final;
}

The getMinVersion generated from the above look at the whole op’s availability info, and computes a final version requirement accordingly. This is where the mergeAction in Availability definition is used.

The above shows specifying the availability for a specific op. One can also set a default availability in the dialect base op to let every op have it and override in each op accordingly.

The above example can also give an indication that TypeAvailableAt and AttrAvailableAt, together with Availability subclasses, should be flexible enough to support common cases like adding more operands/attributes/results in newer versions or extending their supported types.

Usage

  • Querying the availability. The above allows an op instance to tell what version(s) it is available in, what extension it requires, etc. This is the foundation of all other uses.
  • Validating according to a specific target environment. This is based on the querying in the above and essentially looping over all ops to see whether they are not available to the execution environment.
  • Controlling pattern application in dialect conversions. See the below.
Conversion Target

With op availability modelled, a conversion target can be further enhanced to be more fine-grained. We can register ops as dynamic legal depending on their availability. This allows us to target a dialect of a specific version. This way patterns can be written without worrying about op availability; the legalization framework will take care of pattern application and reject those generating ops not available in the current target environment.

For example, if we want to target Vulkan 1.1 (which requires up to SPIR-V 1.3 if without additional extension), we can describe a Vulkan 1.1 conversion target as:

ConvesionTarget vulkan11(...);

// Returns true if the given `op` is legal to use under the current target
// environment.
auto isLegalOp = [&](Operation *op) {
  // Make sure this op is available at the given version. Ops not implementing
  // QueryMinVersionInterface/QueryMaxVersionInterface are available to all
  // SPIR-V versions.
  if (auto minVersion = dyn_cast<QueryMinVersionInterface>(op))
    if (minVersion.getMinVersion() > SPV_V_1_3)
      return false;
  if (auto maxVersion = dyn_cast<QueryMaxVersionInterface>(op))
    if (maxVersion.getMaxVersion() < SPV_V_1_3)
      return false;
  // Check extension/capability…
  return true;
};

vulkan11.addDynamicallyLegalDialect<SPIRVDialect>(
      Optional<ConversionTarget::DynamicLegalityCallbackFn>(isLegalOp));

Thank you for the RFC, @antiagainst ! Our current use case is to version each op as a whole in our dialect (so we don’t need to verson attributes or types immediately), and we want to be able to check the min version of a program, and potentially have transformation to emulate newer ops with older ops if a user asks for compatibility. This RFC provides a good solution for all, so I look forward to it.

Any comments? Any objections? I’ll wait for another week before getting the patch ready for review. :slight_smile: