Summary
In the hipsr direction, each op is designed to compute its own output shape inside a small region attached to the op — a shape_region (filled in by populateShapeRegion) ending in hipsr.shape_yield. That region is meant to be "sealed": it may read the op's own operands, but nothing else in the surrounding function.
To express "sealed, but may read the op's operands," hipsr defines a standalone trait, IsolatedFromAboveButAllowOperands. MLIR's built-in passes don't know about it — they act only on the standard sealing marker, IsIsolatedFromAbove. So a standard pass sees no boundary on a hipsr region and treats it as an ordinary, non-isolated region it can freely reach into and rewrite.
The result is a mismatch: the passes move values across a boundary they can't see, so the IR they produce breaks the region's sealing rule — it is illegal. This issue shows three concrete cases, each reproducible on the merged hipsr.cast op, and asks how the pipeline plans to stay correct when standard MLIR passes run.
The failure is a property of the IsolatedFromAboveButAllowOperands trait, not of hipsr.cast specifically: every Hipsr_DpsOp carries that trait, and the same -cse / -canonicalize errors reproduce on hipsr.pool_domain (the other merged op that carries it). The examples below use the merged hipsr.cast + hipsr.placeholder ops (both on main) only because their shape-region examples are the clearest.
(Shape population — populateShapeRegion — emits shape.shape_of + shape.get_extent; the shape dialect isn't registered in hip-mlir-opt, so the examples hand-write the equivalent with tensor.dim — same meaning: read an input's size, same trait, same downstream behavior.)
Environment
hip-mlir-opt built from current main (which has the merged hipsr.cast, hipsr.placeholder, and hipsr.pool_domain ops, plus the empty-region allowance on Hipsr_DpsOp).
Case 1 — -cse pulls an outside value into the region, producing illegal IR
CSE (common-subexpression elimination) removes duplicate computations. Here the function computes a size (tensor.dim %input) once outside the op and once inside the op's region. Because CSE doesn't see the region as sealed, it merges the two — rewriting the inside copy to point at the outside one. Now the region reads a value from outside itself, which the sealing rule forbids.
func.func @cast_cse_capture(%input: tensor<?x8xf32>) -> (index, tensor<?x8xf16>) {
%c0 = arith.constant 0 : index
%p = tensor.dim %input, %c0 : tensor<?x8xf32> // computed once, outside the op
%init = hipsr.placeholder : tensor<?x8xf16>
%0 = hipsr.cast ins(%input : tensor<?x8xf32>) outs(%init : tensor<?x8xf16>)
-> tensor<?x8xf16> shape_region {
%c0i = arith.constant 0 : index
%d0 = tensor.dim %input, %c0i : tensor<?x8xf32> // the same size, computed inside
%c8 = arith.constant 8 : index
hipsr.shape_yield (%d0, %c8) : [f16]
}
return %p, %0 : index, tensor<?x8xf16>
}
$ hip-mlir-opt -cse cast_cse_capture.mlir
error: 'hipsr.shape_yield' op using value defined outside the region
hipsr.shape_yield (%d0, %c8) : [f16]
^
note: may only use values defined in its regions or the op's operands
This isn't a contrived input. Everything above is legal IR, and from CSE's point of view the rewrite — merging a duplicate into the value that dominates it — is a routine, semantics-preserving step. But the result violates the region's sealing invariant, so the IR is now illegal; the verifier only reports what the pass already produced. Any graph in which a dim computed inside a region also appears at a point that dominates the op (main-graph shape arithmetic, or a dim an earlier pass reified out) hits this under a plain -cse.
Case 2 — -canonicalize moves a folded constant out of the region, producing illegal IR
When canonicalization folds a constant (here 2 * 4 = 8), it places the new constant at the top of the nearest sealed region so it can be shared. It checks for the standard sealing marker, doesn't find it on the hipsr op, and so keeps going up and puts the constant in the enclosing function — outside the region. The region's shape_yield then reads a value that now lives outside it.
func.func @cast_fold_hoist(%input: tensor<8xf32>) -> tensor<8xf16> {
%init = hipsr.placeholder : tensor<8xf16>
%0 = hipsr.cast ins(%input : tensor<8xf32>) outs(%init : tensor<8xf16>)
-> tensor<8xf16> shape_region {
%c2 = arith.constant 2 : index
%c4 = arith.constant 4 : index
%f = arith.muli %c2, %c4 : index // folds to 8
hipsr.shape_yield (%f) : [f16]
}
return %0 : tensor<8xf16>
}
$ hip-mlir-opt -canonicalize cast_fold_hoist.mlir
error: 'hipsr.shape_yield' op using value defined outside the region
hipsr.shape_yield (%f) : [f16]
^
note: may only use values defined in its regions or the op's operands
Even the most basic canonicalization — folding a constant — breaks the sealing rule.
Case 3 — loop-invariant code motion can't reach into the region (no error, just a missed optimization)
LICM moves a whole operation out of a loop when its result doesn't change between iterations. It does not look inside an operation's region to move a piece of it out. Here the cast as a whole does change each iteration (its running buffer is loop-carried), but the size it computes inside its region (tensor.dim %input) does not. LICM can't move the whole op, and won't reach into the region to lift just the size — so that size is recomputed every iteration. (Threading the loop-carried buffer through the init is an illustrative way to make the op loop-variant while its region's shape stays invariant; the general point is that whenever those two hold, the region's shape math can't be lifted out.)
func.func @cast_licm(%input: tensor<?x8xf32>, %seed: tensor<?x8xf16>,
%lb: index, %ub: index, %step: index) -> tensor<?x8xf16> {
%r = scf.for %i = %lb to %ub step %step iter_args(%acc = %seed) -> (tensor<?x8xf16>) {
%0 = hipsr.cast ins(%input : tensor<?x8xf32>) outs(%acc : tensor<?x8xf16>)
-> tensor<?x8xf16> shape_region {
%c0 = arith.constant 0 : index
%d0 = tensor.dim %input, %c0 : tensor<?x8xf32> // same every iteration, but sealed in
%c8 = arith.constant 8 : index
hipsr.shape_yield (%d0, %c8) : [f16]
}
scf.yield %0 : tensor<?x8xf16>
}
return %r : tensor<?x8xf16>
}
After -loop-invariant-code-motion, tensor.dim %input stays inside the region, inside the loop. Written the ordinary way (the size as a plain value in the loop body, not sealed in a region), the same pass lifts it above the loop:
// ordinary version, after -loop-invariant-code-motion: the size is lifted above the loop
%dim = tensor.dim %input, %c0 : tensor<?x8xf32>
%0 = scf.for ... { ... }
LICM is also an enabling pass: a hoisted invariant becomes visible to CSE, constant folding, and further hoisting. So sealing the size in the region doesn't just cost one recompute per iteration — it forecloses the follow-on optimizations the hoist would have unlocked.
What this issue is not saying
- It is not saying the pipeline errors out end-to-end today — that depends on whether any standard MLIR pass runs while a region is still populated.
- Case 3 is recovered if the region is reified into ordinary values before those passes run — but that reification is the ordinary value-based approach.
Open questions
- A standard MLIR pass produces the illegal IR before the verifier can reject it, so staying correct means ensuring no such pass ever runs while a region is populated. Is there a way to guarantee that — enforceable for all future passes, front-ends, and outside contributors — and if so, how?
- If the shape is instead reified into ordinary value-graph SSA before those passes run (which is what makes it safe), what does the region add over keeping the shape as ordinary SSA from the start?
Summary
In the
hipsrdirection, each op is designed to compute its own output shape inside a small region attached to the op — ashape_region(filled in bypopulateShapeRegion) ending inhipsr.shape_yield. That region is meant to be "sealed": it may read the op's own operands, but nothing else in the surrounding function.To express "sealed, but may read the op's operands,"
hipsrdefines a standalone trait,IsolatedFromAboveButAllowOperands. MLIR's built-in passes don't know about it — they act only on the standard sealing marker,IsIsolatedFromAbove. So a standard pass sees no boundary on ahipsrregion and treats it as an ordinary, non-isolated region it can freely reach into and rewrite.The result is a mismatch: the passes move values across a boundary they can't see, so the IR they produce breaks the region's sealing rule — it is illegal. This issue shows three concrete cases, each reproducible on the merged
hipsr.castop, and asks how the pipeline plans to stay correct when standard MLIR passes run.The failure is a property of the
IsolatedFromAboveButAllowOperandstrait, not ofhipsr.castspecifically: everyHipsr_DpsOpcarries that trait, and the same-cse/-canonicalizeerrors reproduce onhipsr.pool_domain(the other merged op that carries it). The examples below use the mergedhipsr.cast+hipsr.placeholderops (both onmain) only because their shape-region examples are the clearest.(Shape population —
populateShapeRegion— emitsshape.shape_of+shape.get_extent; theshapedialect isn't registered inhip-mlir-opt, so the examples hand-write the equivalent withtensor.dim— same meaning: read an input's size, same trait, same downstream behavior.)Environment
hip-mlir-optbuilt from currentmain(which has the mergedhipsr.cast,hipsr.placeholder, andhipsr.pool_domainops, plus the empty-region allowance onHipsr_DpsOp).Case 1 —
-csepulls an outside value into the region, producing illegal IRCSE (common-subexpression elimination) removes duplicate computations. Here the function computes a size (
tensor.dim %input) once outside the op and once inside the op's region. Because CSE doesn't see the region as sealed, it merges the two — rewriting the inside copy to point at the outside one. Now the region reads a value from outside itself, which the sealing rule forbids.This isn't a contrived input. Everything above is legal IR, and from CSE's point of view the rewrite — merging a duplicate into the value that dominates it — is a routine, semantics-preserving step. But the result violates the region's sealing invariant, so the IR is now illegal; the verifier only reports what the pass already produced. Any graph in which a dim computed inside a region also appears at a point that dominates the op (main-graph shape arithmetic, or a dim an earlier pass reified out) hits this under a plain
-cse.Case 2 —
-canonicalizemoves a folded constant out of the region, producing illegal IRWhen canonicalization folds a constant (here
2 * 4 = 8), it places the new constant at the top of the nearest sealed region so it can be shared. It checks for the standard sealing marker, doesn't find it on thehipsrop, and so keeps going up and puts the constant in the enclosing function — outside the region. The region'sshape_yieldthen reads a value that now lives outside it.Even the most basic canonicalization — folding a constant — breaks the sealing rule.
Case 3 — loop-invariant code motion can't reach into the region (no error, just a missed optimization)
LICM moves a whole operation out of a loop when its result doesn't change between iterations. It does not look inside an operation's region to move a piece of it out. Here the
castas a whole does change each iteration (its running buffer is loop-carried), but the size it computes inside its region (tensor.dim %input) does not. LICM can't move the whole op, and won't reach into the region to lift just the size — so that size is recomputed every iteration. (Threading the loop-carried buffer through the init is an illustrative way to make the op loop-variant while its region's shape stays invariant; the general point is that whenever those two hold, the region's shape math can't be lifted out.)After
-loop-invariant-code-motion,tensor.dim %inputstays inside the region, inside the loop. Written the ordinary way (the size as a plain value in the loop body, not sealed in a region), the same pass lifts it above the loop:LICM is also an enabling pass: a hoisted invariant becomes visible to CSE, constant folding, and further hoisting. So sealing the size in the region doesn't just cost one recompute per iteration — it forecloses the follow-on optimizations the hoist would have unlocked.
What this issue is not saying
Open questions