[Python] Attribute -> ConcreteAttribute?

Is there some way I’m not seeing to ‘downcast’ a PyAttribute to the correct (unknown) PyConcreteAttribute (e.g. mlir.ir.Attribute to mlir.ir.IntegerAttr or mlir.ir.ArrayAttr, whichever is appropriate)? I’m trying to inspect operations and op.attributes only seems to give access to PyAttribute from which I can only get a string.

If my vision is still OK, what’s the best way to implement this? I think it would be to detect and return the concrete attribute instead of the mlir.ir.Attribute; however, I don’t think this is possible for custom (dialect-defined) attributes, which I do care about. Should I add a .type RO property to mlir.ir.Attribute so I know the concrete type to which I should downcast in Python?

The issue is a bit more low level than that: in the C++ codebase, there is no mechanism beyond isa/cast to determine whether a generic Type or Attribute is a concrete instance. The operation hierarchy has OperationName which makes serves this purpose (and lets us return an appropriate OpView subclass dynamically when traversing, etc). The AbstractAttribute does provide access to an opaque TypeID which could technically be used for this, but it would need some discussion (i.e. should it be exposed to the C-API vs be an implementation detail, etc).

Imo, it would be best if AbstractAttribute and AbstractType exposed an Identifier name like AbstractOperation does. Then in python land, we could do the same kind of registration as we do for operations.

1 Like

For historical context: both attributes and types used to be pretty basic in terms of extensibility, and have been slowly moving closer to a similar level as the operation hierarchy (at least in some ways). This might just be one more step on that path.

So this doesn’t work for types either… That may be a problem for me as well. I just assumed that types worked. If I can’t figure out a way to avoid the round-tripping (Python is creating the ops as well, then specifically forgets about them), we’ll probably have to get into this. As of now, I’m doing the try-catch approach: circt/support.py at f6d29032cc4066aee9af59c9f00a094177baa2e6 · llvm/circt · GitHub

AbstractType looks pretty bare bones and Type doesn’t seem to know the type name either, so I assume plumbing that info all the way to the C-API would be quite a task…

Yeah, it has been generalized on an as-needed basis. If AbstractAttribute and AbstractType carried an identifier like AbstractOperation does, I’m pretty confident that I could plumb this through the C and Python side in pretty short order (advantage of having done it once).

For the C++ side, @River707, do you see any reason to not move Attribute/Type further towards Operation in this respect? I’ve not been keeping up on the evolution there in recent months and legitimately don’t know.

Types and attributes have a mnemonic for parsing, but it isn’t exposed or even managed by the C++ class itself unfortunately. This is all in the dialect implementation itself. I don’t think there is a fundamental reason to not evolve this so that AbstractAttribute carries this identifier instead.

That said typeid should provide what you need here as well right?

I think it would, yes.

I hadn’t looked into it quite that far, but that was my fear. The only thing that makes this change slightly less daunting is these days the builtin types use tblgen!

The main reason to do it this way (IMO) is consistency with the way the Python bindings deal with operations.

Does the AbstractOperation class carry the mnemonic or the full name of the operation?

Attributes and Types are fundamentally different from Operations in this respect though. You can’t operate on Attribute/Type classes generically like you can for Operations, because all of the internal representation of Attribute/Type classes are different. Given that you can’t reasonably operate on these constructs without knowing (or at least being able to see) the C++ class, it doesn’t really make sense to encode a mnemonic (Note that mnemonics for Attributes/Types aren’t required, and not all have one or even need to have one).

– River

Given that the constructs being represented are fundamentally different, why is this important?

AbstractOperation carries the full name, nothing carries just the mnemonic part.

– River

There may be other reasons, but it’s just one less difference for developers to remember and thus more intuitive. That (and other potential reasons of which I am unaware) obviously has to be weighed against the implementation complexity.

Somewhat tangentially, if I’m understanding correctly this means that type and attribute definitions are tightly integrated with C++ so it would be difficult (though probably not impossible) to implement a C API to define them from other languages? If so, do we want to add that capability in the future and eventually move to generic types/attributes (like operations)? It doesn’t seem high priority (or useful yet) but I think defining dialects in other languages would be very neat though I don’t have a specific, well-thought-out use case.

Operations were designed with generality in mind. There are a limited number of things that can be contained within an operation, and all operations must be able to represent their semantics within those bounds. This has the nice benefit of making bindings/APIs/etc really easy to define, because it effectively established a low level API for operations. Attributes/Types don’t have this restriction, and can hold pretty much anything you want to throw in there. If you wanted to define attributes/types in a different language, you will need something to anchor them to the C++ side of the world (which is something you will never be able to avoid, even for operations). The things that become complicated in this respect are: defining the TypeID and defining the data storage (which is technically everything I guess). This seems complicated at first, but it depends on how you structure the “anchor” part between C++ and whatever else you are using. TypeID isn’t that big of a problem, given that you aren’t required to define them via static templates (you could just malloc it). The anchor for the custom storage is realistically just finding some opaque C++ value/reference to use for the real storage defined in the other language.

There are different trade offs to any kind of language interop, and you may not be guaranteed the performance characteristics/usability you get from something natively defined (IMO defining something that tries to hard to work for every language inevitably fails to be expressive/performant/usable in the ones you actually care about). For upstream MLIR, I don’t foresee operations/types/transformations/etc. defined in anything other than C++ (for a myriad of reasons). For downstream users that want to play around, I’m open to collaboration and tweaking the core API to enable these types of use cases when it makes sense.

Just as an FYI in terms of visibility, @math-fehr is working on effectively what you described right now.

– River

1 Like

Since I think that adding a C API to get the TypeID of types/attributes is the most straightforward thing to do (and least controversial and most efficient), here’s a simple proposal for exporting that through the C API:

DEFINE_C_API_STRUCT(MlirTypeId, const void)
DEFINE_C_API_METHODS(MlirTypeId, mlir::TypeID)
MLIR_CAPI_EXPORTED MlirTypeId mlirTypeGetTypeID(MlirType type);
MLIR_CAPI_EXPORTED MlirTypeId mlirAttributeGetTypeID(MlirAttribute attr);

I don’t understand the purpose of functions like mlirTypeEqual. Can’t MlirTypes just be compared with == given that Type equality is just pointer equality (thanks to Type unique’ing)? If necessary, I think we’d need two additional functions:

MLIR_CAPI_EXPORTED bool mlirTypeIdEqual(MlirTypeId t1, MlirTypeId t2);
// To implement efficient lookups for typeid lookups.
MLIR_CAPI_EXPORTED size_t mlirTypeIdHash(MlirTypeId typeId);

This should be simple enough to implement.

The tricky part without a fixed name is that you’ll also need a per-attribute/type-function to get the typeid of the class, not just an instance. Otherwise, you’ll have no way to make the association you want at the python level because until the python side has seen an instance, it will have no way to associate the python level class with the TypeID.

Question: are TypeIDs global or per-context? If the latter, the TypeID solution becomes significantly more difficult.

TypeID is independent from the context


Sooo… I have a pretty convoluted, hacky potential thought on this: IRCore.cpp would have a registry of TypeID to python class with a python registration function. Said registration would take a function which returns an instance of the concrete class. Those functions would have to be executed in a context, so the register function would have to create a throwaway context, run the function, get the TypeID, and register it. Python concrete type/attribute classes would have to call the registration as some point with a function which creates an instance of itself. No part of this feels good.

So while this could be made to work with just TypeIDs, I can’t think of any sane way… Thoughts?

This would be immensely easier if AbstractType knew the type name.

Yeah, that’s what I was hoping to avoid. You also need to have a fragile dependency on how to construct a dummy instance of each.

Or if not the type name, a stable identifier we could depend on pragmatically. Personally, I don’t feel like having such an identifier is too much to ask of an extensible system like this and it would enable various kinds of reflective access.

The other option is a C API for each attribute and type class which returns it’s TypeID.

I agree, but that’s a big change. It does lead us in the direction of putting types on more equal footing with operations though (IMO a good thing).

ODS could be extended to auto-generate the C APIs of which you speak, but I feel that would be a step in the wrong direction.

I’m going to need someone to explain in detail what the python side does now (for operations, etc.), because I have, unfortunately, put little effort thus far into following its development. This is useful to me at least, because this thread assumes knowledge of what python does (and because adding a name to Attributes/Types provides mostly no benefit from the C++ side).

I don’t understand what you mean here. At least from the C++ side of the infra, adding a name won’t realistically change anything in how attributes/types compare to operations (names wouldn’t be used in the same way). From my perspective, it would only be useful for language integrations.

– River

Sure (also happy to chat f2f if easier). Operations are the most thought out: attributes and types (outside the built-in set) are quite a bit less advanced. John and the circt folks have been pushing the bounds on this the most, which leads us to this discussion.

For Operations

Take this tablegen excerpt from the memref dialect as an example:

class AssumeAlignmentOp(_ods_ir.OpView):
  OPERATION_NAME = "memref.assume_alignment"

  _ODS_REGIONS = (0, True)

  def __init__(self, memref, alignment, *, loc=None, ip=None):
    operands = []
    results = []
    attributes = {}
    attributes["alignment"] = alignment
      attributes=attributes, results=results, operands=operands,
      loc=loc, ip=ip))

  def memref(self):
    return self.operation.operands[0]

  def alignment(self):
    return _ods_ir.IntegerAttr(self.operation.attributes["alignment"])

  def alignment(self, value):
    if value is None:
      raise ValueError("'None' not allowed as value for mandatory attributes")
    self.operation.attributes["alignment"] = value

Here, as on the C++ side, there is a split between generic Operation and ODS-generated wrappers (which we call OpView and is similar to the C++ side Op nomenclature – I just avoided perpetuating Op/Operation because it made this API quite a bit more obtuse). When the Python OpView sublcass is defined, the function register_operation is called with its class (here done as a decorator but that is just Python for a function call that takes the class it is attached to).

Then on the C++ implementation of register_operation, we get the attribute OPERATION_NAME, which ODS generates, and stash it in a map of {OperationName->Python OpView subclass}.

For builder style APIs, this is where it all ends: you build things of the right type and you are done. For traversal, however, when the Python API is mapping a C++ Operation* to a Python Operation instance, it checks this registerd operations map and sees if it has a more specific subclass for the operation name. If it does, it returns one of those instead of the generic Operation instance.

The effect is that, for traversal, you automatically get fully typed operations and have access to their ODS generated attributes, helper methods, etc. When we built the Python API, we built it more for builder-style interactions but did model this so that traversal worked too.

For attributes and types

Attributes and types in Python have none of that kind of sophistication. Each one is a Pybind11 custom class definition (although using some helpers to reduce boiler-plate) which interacts with the backing C-API for the attribute/type. By convention, they all:

  • Inherit from either Attribute or Type.
  • Have a one-arg __init__ which takes an existing attribute/type, type checks it and allows the instance to be constructed if it type checks (via the C-API IsA functions).
  • Have get* static functions for newing up specific instances.

Again, since our original focus was on builder style APIs, this works just fine. In the rare instance that you have a generic Attribute or Type, you just manually “down-cast” it like MyCustomAttribute(some_attr).

What I think @jdd is looking for is an auto-downcasting mechanism similar to what we get for operations. That way (I presume), it is more natural to access IR constructs just via “dotted” notation and let the system be responsible for giving you back the right concrete Attribute/Type subclass.

Honestly, we’ve done the bare minimum needed for attributes and types on the python side, and I feel that some evolution here is called for. It’d be good if that was convergent in some way with the newer ways of using Tablegen to create them, but I’m not read up enough on that to have an opinion. Fundamentally, the disconnect between them and operations is pretty deep, though: Operation defines a fully general API for accessing everything about the operation. But the base Attribute and Type classes are really just there as an anchor and for identity/interfaces/etc: everything interesting about Attributes/Types comes by way of a C++ (and C, and Python) dedicated API.

Does that help?