[RFC] Allow symbol references to escape from SymbolTable

This RFC proposes to add a mechanism for inner references to symbols outside of a symbol table.

Background

Currently, it is impossible to reference from within a symbol table symbols that are outside that symbol table. From the symbol table documentation:

// This `func.func` operation defines a symbol named `symbol`.
func.func @symbol()

// Here we define a nested symbol table. References within this operation will
// not resolve to any symbols defined above.
module {
  // Error. We resolve references with respect to the closest parent operation
  // that defines a symbol table, so this reference can't be resolved.
  "foo.user"() {uses = [@symbol]} : () -> ()
}

However, it is often desirable to define scopes and nesting of symbols while still being able to navigate the parent scope. Many programming languages offer this feature in their item path references, which are hard to model in MLIR using symbols.

The issue has already been encountered in upstream MLIR, for example in IRDL:

irdl.dialect @some_dialect { // symbol table
    irdl.type @foo
}

// From outside the dialect definition, some_dialect.foo can easily
// be referenced.
%foo_type_from_outside = irdl.base @some_dialect::@foo

irdl.dialect @other_dialect { // symbol table
    irdl.type @bar

    irdl.operation @baz {
        // Referencing a type defined in the same dialect is possible.
        %bar_type = irdl.base @bar

        // Referencing another dialect is however impossible.
        %foo_type = irdl.base @??? // How to reference some_dialect.foo here?
        
        // ...
    }
}

Proposal

We propose a way for a symbol reference to target symbol tables that are the parent of the current symbol table. This is achieved by prefixing symbol references with a super-like construct, for which the proposed syntax is @.super. Symbol tables would no longer isolate inner symbol references to themselves.

A general-purpose super-like referencing construct has the unfortunate consequence of creating multiple paths to reference the same symbol. Indeed, symbol reference @foo::@bar would now be equivalent to @foo::@.super::@foo::@bar. To limit this effect and keep implementation simple, we propose that @.super may only be used at the beginning of a symbol reference. While @.super::@foo::@bar and @bar are still equivalent in a symbol table called @foo, the implementation does not need backward lookups. It may be interesting to nonetheless offer infrastructure to canonicalize symbols at a later time, as passes like CSE may not be able to prove equality between symbols otherwise.

The syntax for a symbol reference would now be:

symbol-part ::= `@` suffix-id | string-literal
symbol-ref-id ::= `@.super::`* symbol-part (`::` symbol-part)* 

Consider the following example:

module {
    module @foo {
        func.func @nested()
    }

    module @bar {
        "symbol_user.user"() {uses = [@.super::@foo::@nested]} : () -> ()
    }
}

The user operation here references the @nested function from the @foo module.

The resolution algorithm is unchanged, except that for each @.super:: prefix to the reference, the selected symbol table for resolution is one level higher in the parent hierarchy.

It may be important, for example for multithreading purposes, to ensure symbol references cannot escape certain scopes. This has so far been achieved by implicitly adding this property to the SymbolTable trait. We propose to decouple this by introducing an IsolatedSymbols trait. When going up the parent hierarchy to find which symbol table to start from, if an operation implementing IsolatedSymbols is encountered, an appropriate error is raised, informing that a symbol has attempted to cross the symbol isolation boundary. This not only allows to have symbol tables that do not isolate symbols, but it also enables operations that are not symbol tables to act as boundaries.

In the scope of this RFC, IsolatedSymbols still allows accessing inner symbols from outside, in order to mimmick current symbol resolution behavior. It is not clear if it is useful to introduce visibility modifiers to let users disallow this.

Implementation

The implementation will be carried out at once, where:

  • The SymbolRefAttr attribute will be updated to support prefix @.super.
  • The IsolatedSymbols trait will be created and manually added to all upstream operations with the SymbolTable trait to avoid breaking any previous assumption.
  • The symbol resolution algorithm will be updated to skip as many symbol tables as required by the references, checking for the presence of IsolatedSymbols operations.

Existing upstream users of SymbolTable that desire removing the isolation currently provided may drop the IsolatedSymbols trait afterwards.

Alternatives

This RFC makes symbol isolation explicitly opt-in via the IsolatedSymbols trait. However, while this is taken care of manually upstream, this may create inconvenience to downstream users as they may depend on the isolation in some way. The opposite approach could be considered, where operations with the SymbolTable trait could implement a TransparentSymbolTable trait that allows going to the parent via @.super. However, we believe that isolation being opt-in is a more natural default.

We would like to hear from users:

  • Does your use of symbol tables depend on the provided isolation?
  • Do you know of a better way to communicate the changes to downstream users if we keep the opt-in isolation approach?

Thank you for your attention.

1 Like

This has the potential to break many invariants in the model of symbols in MLIR (did we discuss this in person? I remember a discussion on this exact topic, maybe at EuroLLVM).
As such this is a can of worms that requires a proposal that first understand deeply the invariants of the current system (the current proposal apparently wseems to just consider this an arbitrary restriction that can be freely removed).

Right now, accessing symbols from a sibling table isn’t modeled natively in the system but enabled through external references: that is just like calling a function from another module in LLVM.

Isn’t it the “nested” visibility?

Which programming models? And also, you’re not making it very clear in the motivation why is it desirable to use MLIR native symbols for this purpose?
The alternative can also be to not rely on MLIR symbols for this kind of modeling.

1 Like

I recall relative and absolute symbol “paths” discussions (e.g., adding a .. or /), but yes not sure whom and where. I recall part discussion was had even when symbols were added initially.

Now, where this has also come up, the end result was often a pivot to multiple contexts/files and having a “top-level” marker followed by symbols. These are similar to what Mehdi mentions wrt sibling tables. It came up where folks were reaching for was a single, self-contained .mlir file but then ended up instead multiple contexts and files as the lifetime, reuse etc didn’t line up.

Thank you for the replies!

Could you detail this more? My understanding is that IsolatedSymbols addresses the cases where this feature may not be desirable by, in practice, locally reverting to the isolated model. In any case, I would like to know where this limitation is depended on. I know in IREE there are operations containing kernels that should be entirely separately compiled, for which I believe IsolatedSymbols is enough to cover.

Yes. We believe it would be an improvement to have a mechanism in MLIR to have the ability to model symbol hierarchies where sibling tables can be accessed.

No I am pretty sure we did not, I have only personally been interested in making this change in the past week.

Sorry, I meant add visibility modifiers to IsolatedSymbols. It is otherwise handled by visibility levels on the symbol operations themselves, hence why it is probably not necessary to add visibility modifiers on IsolatedSymbols.

The most obvious example that comes to my mind is Rust modules, in which in a single file you can reference items (such as functions, constants, types, traits, etc.) while navigating a hierarchy of modules. Python also has some of this, although I think it is limited to navigating file hierarchies.

In the example above, this sort of hierarchical access is used to reference things like functions, which in MLIR are modeled using symbols. What else would you use to reference functions from a call site in MLIR? For IRDL, what other infrastructure would be used to allow referencing other operations in a statically checked manner?

The reverse question is also interesting: what are nested MLIR symbols intended for if not this?

Could you detail what you mean by this? I am not sure I understand what one would be pivoting towards instead of symbol resolution?

IsolatedSymbols isn’t an answer: the invariant isn’t about “some projects depends on this” but rather the MLIR Core infra is built around this invariant.
I can’t provide a comprehensive list on top of my head, but at least it interacts with SymbolTable construction, as well as their invalidation.
At the moment a SymbolTable operation is also a “standalone” entity that is “moveable”, which wouldn’t be the case anymore (again the implications are hard to grasp).

Yes, one of the implications of this change is that symbol tables would not be movable by default, IsolatedSymbols would be. This RFC is about making this change, hence why we want to make sure it will not be too much of a breaking change in practice and to find the list of things that should be modified (this is why this RFC exists). Do you mean the symbol table infrastructure is frozen?

No: but to reiterate what I wrote before: profound changes to the infra require a lot of consideration of the existing invariants and all the implications.

Could you clarify what you meant here?

  • construction: right now building a SymbolTable walks an operation. Now for completeness it would need to walk up and down.
  • invalidation: right now when I have a SymbolTable object, it is valid as long as I don’t change any symbol nested under the associated symbol table operation. If I rename a symbol, I don’t need to think about a SymbolTable constructed on a sibling operation.

Ah I think I see what you mean. The way we wanted to implement this was by just walking up the parent chain on lookup, similarly to how getNearestSymbolTable does it. There would not be a need to cache anything in the symbol tables, and in fact the data structure would not change. Does that clarify your concerns?

It does not actually: this is overly expensive to do on a per-query basis.
Actually all the APIs like getNearestSymbolTable are problematic and we discussed quite a few times about deprecating these (or at least name-spacing them to make it very explicit about their cost and discourage their uses). Instead there are some options to making the SymbolTable more integrated to the PassManager in order to facilitate keeping them up-to-date through the pass pipeline.

Note also that I mentioned before that this is not intended to be an exhaustive list of issues.

Okay. We have a concrete use case for this in IRDL. Without this feature, this is the sort of solution we have. It feels very suboptimal as it requires arbitrarily moving the root of symbol lookups, which is hard to predict and inefficient.

Is this the sort of workaround you would recommend as an alternative to adding the proposed functionality? Or do you have a suggestion for a different API to upstream to MLIR proper?

Just to reiterate what Mehdi is saying: this is a very large change to the semantics – so much so that it seems closer to something else than a modification of the symbol mechanism.

It may not be obvious, but the current symbol system is very loosely coupled to MLIR: there is really nothing stopping someone from defining something else.

There are many kinds of scopes and nesting data structures in the universe, and I don’t think it is the job of this one to justify why it shouldn’t be a different one. I don’t have any insights into the needs of IRDL, but I can tell you that the invariants that SymbolTable currently provides are very important for performance of the compilers we build with MLIR, and I think they are about as general as they can be while still enabling that performance. The design isn’t frozen, but it would be a high bar to justify dramatic changes at this point.

A half step to something else may be a different kind of symbol ref attr and a different mechanism for resolution/verification than SymbolTable.

2 Likes

I agree with @Moxinilian that there are weaknesses with the upstream SymbolTable implementation and they are reflected in the motivation in this RFC. E.g. the inability to refer to symbols in certain contexts.

However, the base infrastructure (interfaces, lookup helpers, SymbolTableCollection, etc) is flexible enough to implement different lookup semantics on top of it without reinventing the whole world. This is to @stellaraccident’s point to roll-your-own symbol tables. But I’m not a huge fan of people solving the same problems downstream. Perhaps we can cleanly separate this “base infrastructure” from the APIs that implement lookup semantics to provide multiple symbol reference semantics upstream? I would love to have absolute references, personally.