TL;DR
Add a new builtin SSA value type (!token) to MLIR. A token is a value-typed handle that exists purely to encode a static def-use relationship between operations / regions. Think of a token as a pointer to another operation / region, reusing MLIR’s existing def-use machinery. Tokens cannot flow through “regular” value-forwarding constructs (cf.br, func.call, scf.for iter-args, region results, etc.).
This RFC is about the token type. This RFC is a continuation of the EuroLLVM 2026 round table on early exit and the follow-up discussion on Discourse.
This RFC was authored / discussed by: @gysit, @jpienaar, @matthias-springer, @mehdi_amini, @rengolin, @xheno.
Assisted-by: claude-opus-4.7-thinking-high
Open Questions
We are seeking feedback from the broader audience. Especially but not limited to:
- Minor question: A new builtin type or a new builtin type interface?
- Major question: Should the presence of tokens automatically (by design) affect CSE / DCE? If so, how and by what mechanism? See “5. Token is a unique opaque value in SSA scope” and “Interaction with Existing Infrastructure”.
Motivation
MLIR has several places where an operation needs to reference another specific operation or region in the IR — not a runtime value. Examples:
- Early exit from structured regions. A
break/continue-like terminator must identify which enclosing operation it transfers control to (example). Proposed designs use integer “depth” counters, symbol labels, or newOperationreferences. All of these require renumbering, uniquing, or extending the core data structure and its bytecode/textual format. - LLVM dialect fidelity. LLVM IR has a
tokentype. It is used by Windows EH funclets (catchpad/cleanuppad/catchswitch), GC statepoints, preallocated arguments, and convergence intrinsics (llvm.experimental.convergence.*). TheLLVMdialect cannot faithfully represent these without a value that is forbidden from appearing inphi,select, function arguments, etc. - Exception handling and cleanups in front-end dialects. CIR already uses a token-like value in its cleanup/EH design to thread structural relationships between
try, cleanup scopes, and unwind targets. - High-level coroutine and convergence modeling. Anywhere a producer–consumer relationship must survive transformation but must not be substitutable, forwarded, or passed across function boundaries.
- Linking two operations that model a concept. E.g., linking operations for loop-carried variables and yield operands when modeling a loop via separate ops. (E.g., see TF Executor example at the end.)
In all of these cases the property we want is the same:
“This operand of op
Bmust statically refer to a particular result/block-argument of opA. The link is structural; it carries no runtime data; transformations must not break it.”
A first-class builtin !token type lets us solve the problem once.
Non-goals
- This RFC does not propose any specific
scf.break/scf.continuedesign. That belongs in a follow-up RFC. - This RFC does not subsume
!async.token.!async.tokenis a runtime, dataflow-carrying value (it represents a future). The new type is purely structural. - This RFC does not change
Operation, the bytecode format, or the generic op syntax.
Intuition: A Token is a Static Reference to an Operation or Region
Every token use is — by construction — a reference to exactly one of:
- an operation result (the op that produced the token), or
- an entry block argument (which transitively identifies the block, its parent region, and the op that owns the region).
What makes the token type special is just that the SSA def–use edge is guaranteed to be static. With a normal value type, the def–use chain can be obscured by arith.select, cf.br block arguments, func.call boundaries, scf.for iter-args, etc., so you can’t generally walk back from a use and say “this came from that specific op”. With !token, you can.
This is also why tokens cannot be forwarded through calls, branches, selects, iter-args, and similar constructs. To be clear: any of those ops may legitimately produce a token as one of its results — but doing so just means the token refers to that producing op. There is no data flow. What is forbidden is taking a token in as an operand and re-emitting it (or one of several alternatives) as a result, so that downstream users can no longer tell which op the token refers to. For example, a cf.br that joins two predecessors, a call that crosses a function boundary, or an arith.select between two tokens would each make the answer to “which producer?” ambiguous or unknowable, and the type would lose the one property it exists to guarantee.
So !token is essentially a lightweight, SSA-native stand-in for Operation* / BlockArgument* references — without changing struct Operation, without a parallel use-list system, and without giving up dominance/region-scoping checks. Compared to other ways one might encode such a static reference:
- vs. an
Operation*operand onOperation: same expressive power for “which op am I referring to?”, but reuses the existingValue/OpOperandmachinery instead of introducing a parallel use-list system for ops. - vs. symbol labels: same expressive power, but the lookup is just following an SSA edge instead of resolving a name through a side table, and uniquing falls out of SSA value identity.
- vs. integer “depth” indices: an integer says “the N-th enclosing op of some kind”; a token says “that op”, so it’s stable when changing the nesting level without renumbering. Tokens also support relationships between sibling operations in a region (when the token is an op result) while numbering is only an alternative to tokens as block arguments (referring to a parent).
One nuance: a token can identify finer granularity than just the op. Because a token can be a n entry block argument, dialects can use it to refer to a specific region of a multi-region op (e.g. each region of scf.loop has its own entry-block token; all identify the same loop op but are distinguishable from each other). LLVM’s EH funclets rely on this distinction too — a catchpad token identifies a specific pad, not just the enclosing function.
Proposal
A New Builtin Type
Add a new builtin type, tentatively named !token (other candidates discussed below). It is a parameterless, opaque type with the following properties:
- It is an ordinary SSA value type — it can appear as an operation result or block argument and has the usual
Value/OpOperandmachinery and use lists. - It carries no runtime representation. Lowerings are expected to consume tokens entirely; they have no LLVM-level analogue (other than
LLVMTokenType, which uses the same name for a similar reason). - It has restricted structural semantics enforced by the framework (see below).
%t = some.op : !token
%x = some.consumer %t : !token -> i32
Note: Instead of a builtin type, the new token could also be a builtin type interface.
Structural Restrictions
A token value is, by construction:
-
Not value-forwarding. A token cannot appear as a forwarded value anywhere — that is, anywhere the framework would treat it as data flowing from one program point to another rather than as a fixed reference to its producer. Concretely, a token cannot be:
—
1.1. a forwarded result or operand (getArgOperands) of aCallOpInterfaceop,
—
1.2. a result of, or an argument or result type of, aFunctionOpInterfaceop (note: a token block argument inside a function body is fine — it just identifies that block; what’s disallowed is forwarding tokens across the call/return boundary),
—
1.3. a successor operand or successor block argument of aBranchOpInterfaceop (e.g.cf.br,cf.cond_br),
—
1.4. a forwarded operand to/from any region of aRegionBranchOpInterfaceop — including iter-args, region results, and yielded values (e.g.scf.foriter-args,scf.if/scf.whileresults,scf.yieldoperands), or
—
1.5. the result of any op that selects or merges values it does not understand (e.g.arith.select).
—
These rules are enforced by op verifiers on the relevant interfaces (CallOpInterface,FunctionOpInterface,BranchOpInterface,RegionBranchOpInterface) plus the default ODS predicate change described in ODS integration below, so that an op which has not explicitly opted in cannot accept a token as an arbitrary operand. -
Transparent to
IsolatedFromAbove.getUsedValuesDefinedAbove/visitUsedValuesDefinedAbove/ region isolation verifiers filter tokens out by default. A token used inside an isolated region but defined above is not a capture, because no runtime data is being captured. -
Statically resolvable. For any token use, walking the def–use chain reaches a producing op without crossing a “forwarding” boundary. Verifiers and lowerings may rely on this.
-
Cannot constant-fold. No constant of
!tokenexists. -
Token is a unique opaque value in SSA scope: each token has its unique identity within the current region (and nested ones). This property has consequences for transformations such as CSE. This part is still a bit unclear and there are multiple possible options (5.1 - 5.3) to implement this property:
—
5.1. Operation Equivalence: Two operations in the same region aren’t equivalent if they involve different tokens. When comparing operations in different regions, a structural equivalence is performed.
Downside: This makes operation equivalence contextual. Comparing two operations in isolation may be different from comparing two regions.
—
5.2. Operation Side Effect: A token-producing operation declares anAllocateTokenside effect, which prevents the operation from CSE’ing. Special handling can be added to the CSE pass to enable CSE’ing of regions with nested tokens.
Downside: Side effects are describing things that happen outside of SSA dataflow. Introducing side-effects into the token modeling makes it no longer a pure SSA representation and defeats most of the usefulness of a new construct. Side-effects are costly and pessimize / deactivate most optimizations.
—
5.3. Defined by Op Semantics: Drop the “5. Token is unique” property entirely and leave it up to the token-producing op. E.g., if CSE’ing of token-producing ops is desirable in your dialect, mark the op as pure. If not, leave the side effects unspecified (or add a AllocateToken effect), or utilize an IR design pattern that cannot be CSE’d (e.g., tokens are always block arguments).
These properties mirror what LLVM already documents for its own token type (“a value … all uses of [which] must not attempt to introspect or obscure it … not appropriate to have a phi or select of type token”). We are essentially lifting that contract into MLIR, where it is needed for more than just LLVM EH.
Verification
There are two ways a token could end up in a forwarding position, and each is already covered by an existing verification layer:
- Ops that take operands of specific types (the vast majority). Their operand types are declared in ODS using
AnyType,AnyTypeOf, etc. We update those predicates to exclude!tokenby default, so any such op simply will not type-check with a token operand. No new verifier is needed. - Ops that legitimately accept arbitrary types in forwarded positions. These all go through one of a small handful of interfaces:
CallOpInterface,FunctionOpInterface,BranchOpInterface, andRegionBranchOpInterface. Their existing interface verifiers gain a check that none of the forwarded positions (call/return operands and results, function signature, successor operands, region forwarded operands, region results, yielded values) has token type.
Together, these two layers ensure that no token can reach a forwarding position regardless of which op it flows through. There is no need for a global walk over the IR.
Dialects that produce or consume tokens (e.g. scf, llvm, cir) additionally keep their own local contract checks — for example, “every user of this block-argument token must be a region terminator of this op”. Those are op-specific and orthogonal to the framework-level non-forwarding guarantee above.
ODS Integration
Most of the framework’s existing type predicates (AnyType, AnyTypeOf<[...]>, etc.) must continue to exclude tokens by default, so that no existing op accidentally accepts them.
Concretely:
- Update
AnyTypeto (conceptually)Type<CPred<"!::llvm::isa<::mlir::TokenType>($_self)">, "any non-token type">(or a similar check). - Add
AnyTypeOrTokenas an explicit opt-in. - Add
Token : Type<CPred<"::llvm::isa<::mlir::TokenType>($_self)">, "token", "::mlir::TokenType">for ops that take a token specifically. - Example in TableGen:
let arguments = (ins Token:$scope, AnyType:$value);.
No changes are needed to the ODS backends; this is just predicate plumbing.
Bytecode, Textual Format, and Printing
- Textual: the type prints as
!token(or whatever name we settle on). No new syntax. - Generic op syntax: unchanged. Tokens flow through as ordinary SSA values.
- Bytecode: a new builtin type entry. No changes to op encoding.
- Custom assembly: dialects can choose to pretty-print tokens (mirroring how
scf.forpretty-prints the induction variable). This is a per-dialect choice, not a framework requirement.
Interaction with Existing Infrastructure
| Area | Behavior |
|---|---|
| Operation Equivalence | We discussed if tokens should have a unique identity within the same SSA scope and how this affects operation equivalence. Should two otherwise equivalent token-producing ops compare as “not equivalent” if they are within the same SSA scope? This felt wrong because it makes equivalence checking contextual. (E.g., checking ops in isolation vs. within a region may give different results.) |
| CSE | It is still unclear how (or if) the presence of token OpResults would affect CSE. |
| DCE | It is still unclear if the presence of token OpResults should prevent DCE. |
| Canonicalization | There is nothing new here. |
| Outlining | Outlining IR into a function is potentially dangerous: you cannot pass a token across function boundaries. |
| Data Flow Framework | Tokens carry no data flow. |
IRMapping / clone |
Standard remapping; the type is a normal Type. |
| LICM / Hoisting | Standard rules apply. SSA edge prevents hoisting too far out. |
| Loop Unrolling | Note: Duplicating the loop body may introduce additional users of a token. |
Lowering
Tokens usually get eliminated by lowerings, but this is very target specific. A common case of lowering is using the token relation during lowering, and dropping it when emitting the target construct that does need the token (e.g. scf.loop + its scf.break are lowered to cf.brs into a common exit block; the token disappears).
It is however possible to imagine targeting a bytecode or runtime environment directly consuming loops with early exits. In this case the tokens would be directly consumed by the target environment.
Naming
Options:
!token— short, mirrors LLVM. Risk: people read “token” as “async token” or “lexer token”.!tok— shorter, avoids conflict with existing mentions of “token”. Risk: slightly obfuscated?!structural_token— descriptive, longer.!scope_token/!control_token— biased toward the early-exit use case.
Code Examples
Early Exit from Structure Loop (Original Use Case)
%r = scf.loop -> i32 {
^bb0(%t: !token):
scf.if %cond {
scf.break %t, %value : !token, i32
}
scf.continue %t : !token
}
The token is an entry-block argument of the loop region. scf.break and scf.continue take it as an operand. The scf.loop verifier checks that all users of %t are region terminators belonging to one of its regions; the builtin verifier guarantees no one smuggled %t out through a cf.br, func.call, etc.
LLVM dialect EH funclets (Faithful Modeling)
^catch:
%cp = llvm.catchpad within %cs [%type_info] : !llvm.token
// TODO rejected: !llvm.token cannot be a call argument
llvm.call @handler(%cp) : (!llvm.token) -> ()
llvm.catchret from %cp to ^continue : !llvm.token
Today, LLVMTokenType exists but the framework cannot prevent it from leaking through func.call or block arguments. With this proposal, those misuses are rejected by construction.
Rejected IR Examples
// Rejected: token captured by a non-token-aware op.
%t = scf.loop -> i32 { ^bb0(%tok: !token): ... }
%bad = arith.select %c, %tok, %tok2 : !token
// Rejected: token passed across a call boundary.
func.func @f(%t: !token) { ... } // function type contains !token
%r = func.call @f(%tok) : (!token) -> () // verifier error
// (A token block argument *inside* a function body is fine — what's
// rejected is having a token as part of the function's signature, so
// that callers cannot forward tokens across the call.)
// Rejected: token forwarded as an scf.for iter-arg.
%out = scf.for %i = %lb to %ub step %s iter_args(%x = %tok) -> !token { ... }
Modeling Loops with Multiple Ops in Graph Region
This example is taken from TensorFlow (semantics). The abstraction has been deprecated / removed.
tf_executor.graph {
// A loop with 2 loop-carried variables.
// Note: You cannot CSE/DCE these. Doing so would reduce the number of
// loop-carried variables.
%a_next, %a_token = NextIteration.Source
%b_next, %b_token = NextIteration.Source
// …
// Token identifies which loop-carried variable gets which yielded value.
NextIteration.Sink [%a_token], %yield_value_a
NextIteration.Sink [%b_token], %yield_value_b
}
Implementation Plan
- Add
mlir::TokenTypeinBuiltinTypes.{td,h,cpp}. - Update
AnyTypepredicate; addTokenandAnyTypeOrTokenpredicates inCommonTypeConstraints.td. - Possibly extend the verifiers of
CallOpInterface,FunctionOpInterface,BranchOpInterface, andRegionBranchOpInterfaceto reject tokens in their forwarded positions; however this would be redundant with theAnyTypeverifier already checking this at the op level everywhere. This would only be useful for operations not defined in ODS, thus may not be a good tradeoff. - Teach
getUsedValuesDefinedAboveto filter tokens. - Add bytecode entry.
- Documentation:
BuiltinTypes.mdand a shortTokens.mddesign note. - Migrate
LLVMTokenTypeto be (or wrap) the new builtin type, so EH/GC/convergence ops get the structural checks for free. - (Follow-up RFC) Use
!tokento express early exit inscf.
Steps 1–6 are self-contained and can land before any user dialect adopts the type. Step 7 is the first real consumer.