Skip to content

Region-based shape inference with the proposed IsolatedFromAbove fix: standard-pass interactions #525

Description

@fhanuman

Summary

This is a follow-up to #512, which showed the shape region used a custom trait that MLIR's built-in passes don't recognize, so standard passes reached into the region and produced illegal IR. wcy123/onnx-hipdnn-ep#123 proposes switching to the standard IsolatedFromAbove trait and passing the op's operands in as block arguments; #519 implements that design. With the fix applied (verified locally), the two erroring cases in #512 verify clean. This follow-up does not question that fix — it documents how the standard passes interact with the now-correctly-sealed region.

Because the region is IsolatedFromAbove, CSE, canonicalize, and LICM cannot deduplicate, hoist, or share shape computations across its boundary: each op's operands enter as block arguments, so a region's tensor.dim %a is a distinct SSA value with nothing to merge across the boundary (they still fold within the region). Each case below is shown on hipsr.cast next to the shipping value-graph op (hip.cast), which keeps the shape as plain SSA in one scope and optimizes normally. The behavior is a property of the isolated region — every Hipsr_DpsOp carries it — not of hipsr.cast specifically.

(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 a local branch with the fix (standard IsolatedFromAbove + block-arg operands on Hipsr_DpsOp); every example verifies clean.
  • Snippets are verified before/after excerpts, edited for readability; those labeled "after <pass>" are results, not runnable inputs.
  • The region is populated, but the step that consumes or materializes it is not yet in tree; hipsr isn't in the shipping pipeline, whose shape path is the value-graph hip.* op + reifyResultShapes — the path the value-graph column shows.

Case 1 — -canonicalize -cse does not share a constant across the region boundary

CSE and canonicalization are foundational — they run in nearly every MLIR pipeline to eliminate duplicate constants and computations. Here they can't: the constant 8 exists both outside the op and inside its region (the fold of 2*4), and because the region is a sealed scope the two are never merged — both survive.

// REGION (hipsr.cast) — verifies clean; the two 8s are not shared
func.func @cast_const_no_share(%ctx: !hipsr.context, %input: tensor<?x8xf32>) -> (index, tensor<?x?xf16>) {
  %c8p  = arith.constant 8 : index                    // parent constant 8
  %init = hipsr.placeholder : tensor<?x?xf16>
  %0    = hipsr.cast(%ctx) ins(%input : tensor<?x8xf32>) outs(%init : tensor<?x?xf16>) : tensor<?x?xf16> shape_region {
  ^bb0(%ctxarg: !hipsr.context, %a: tensor<?x8xf32>):
    %c0 = arith.constant 0 : index
    %d0 = tensor.dim %a, %c0 : tensor<?x8xf32>
    %c2 = arith.constant 2 : index
    %c4 = arith.constant 4 : index
    %f  = arith.muli %c2, %c4 : index                 // folds to 8, inside the region
    hipsr.shape_yield (%d0, %f) : [f16]
  }
  return %c8p, %0 : index, tensor<?x?xf16>
}

The ordinary value-graph form keeps the shape as plain SSA in one scope. After the same passes the two 8s merge to one:

// VALUE GRAPH (hip.cast) — after -canonicalize -cse: one shared constant 8
func.func @cast_const_share_vg(%arg0: !hip.context, %arg1: tensor<?x8xf32>) -> (index, tensor<?x?xf16>) {
  %c8   = arith.constant 8 : index                    // one constant, shared
  %c0   = arith.constant 0 : index
  %dim  = tensor.dim %arg1, %c0 : tensor<?x8xf32>
  %0    = tensor.empty(%dim) : tensor<?x8xf16>
  %1    = hip.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%0 : tensor<?x8xf16>) {to = 10 : i64} : tensor<?x8xf16>
  %cast = tensor.cast %1 : tensor<?x8xf16> to tensor<?x?xf16>
  return %c8, %cast : index, tensor<?x?xf16>
}

Result: -canonicalize folds 2*4 to 8 inside the region, but -cse does not share it with the identical 8 outside — the region form keeps its own copy (2 arith.constant 8), while the value-graph form shares one (1). The constant is operand-free, so the only thing preventing the share is the region boundary.


Case 2 — -cse does not merge a dim used both inside the region and outside

CSE eliminates a computation that is already available elsewhere — a foundational cleanup. Here the same size (tensor.dim) is computed once outside the op and once inside its region, and CSE can't merge them: with operands entering as block arguments, the inside dim is over the region's own %a, a distinct SSA value from the outside tensor.dim %input — nothing to unify, both survive.

// REGION (hipsr.cast) — verifies clean; the two dims are not merged
func.func @cast_cse_capture(%ctx: !hipsr.context, %input: tensor<?x8xf32>) -> (index, tensor<?x8xf16>) {
  %c0   = arith.constant 0 : index
  %p    = tensor.dim %input, %c0 : tensor<?x8xf32>    // parent dim, over %input
  %init = hipsr.placeholder : tensor<?x8xf16>
  %0    = hipsr.cast(%ctx) ins(%input : tensor<?x8xf32>) outs(%init : tensor<?x8xf16>) : tensor<?x8xf16> shape_region {
  ^bb0(%ctxarg: !hipsr.context, %a: tensor<?x8xf32>):
    %c0i = arith.constant 0 : index
    %d0  = tensor.dim %a, %c0i : tensor<?x8xf32>       // over block arg %a, not %input
    %c8  = arith.constant 8 : index
    hipsr.shape_yield (%d0, %c8) : [f16]
  }
  return %p, %0 : index, tensor<?x8xf16>
}

In the ordinary value-graph form both dims are tensor.dim %input in one scope, so -cse merges them to one:

// VALUE GRAPH (hip.cast) — after -cse: the two tensor.dim merge to one
func.func @cast_cse_capture_vg(%arg0: !hip.context, %arg1: tensor<?x8xf32>) -> (index, tensor<?x8xf16>) {
  %c0  = arith.constant 0 : index
  %dim = tensor.dim %arg1, %c0 : tensor<?x8xf32>
  %0   = tensor.empty(%dim) : tensor<?x8xf16>
  %1   = hip.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%0 : tensor<?x8xf16>) {to = 10 : i64} : tensor<?x8xf16>
  return %dim, %1 : index, tensor<?x8xf16>
}

Result: region form keeps 2 tensor.dim; value-graph form keeps 1.


Case 3 — -loop-invariant-code-motion -cse does not hoist or dedup a loop-invariant dim

LICM hoists loop-invariant computation out of a loop, and CSE then dedups it — both foundational. Here neither can reach into the region: the cast is loop-variant (its running buffer is loop-carried), but the size it computes inside its region is invariant, and an identical size (%p) is computed above the loop. LICM can't lift the region's dim out (it lives only inside the region) and CSE can't merge it with %p (a distinct SSA value across the boundary), so the region recomputes the size every iteration while %p stays separate — two dims. (The value-graph form sums the dim into a returned accumulator so the invariant has a use for the passes to act on — the parallel to the region using its dim in shape_yield.)

// REGION (hipsr.cast) — verifies clean; the region's dim is neither hoisted nor merged
func.func @cast_licm(%ctx: !hipsr.context, %input: tensor<?x8xf32>, %seed: tensor<?x8xf16>,
                     %lb: index, %ub: index, %step: index) -> (tensor<?x8xf16>, index) {
  %c0 = arith.constant 0 : index
  %p  = tensor.dim %input, %c0 : tensor<?x8xf32>       // parent dim, above the loop, returned
  %r  = scf.for %i = %lb to %ub step %step iter_args(%acc = %seed) -> (tensor<?x8xf16>) {
    %0 = hipsr.cast(%ctx) ins(%input : tensor<?x8xf32>) outs(%acc : tensor<?x8xf16>) : tensor<?x8xf16> shape_region {
    ^bb0(%ctxarg: !hipsr.context, %a: tensor<?x8xf32>):
      %c0i = arith.constant 0 : index
      %d0  = tensor.dim %a, %c0i : tensor<?x8xf32>      // over block arg %a — distinct from %p, sealed
      %c8  = arith.constant 8 : index
      hipsr.shape_yield (%d0, %c8) : [f16]
    }
    scf.yield %0 : tensor<?x8xf16>
  }
  return %r, %p : tensor<?x8xf16>, index
}

In the ordinary value-graph form the in-loop dim is plain SSA over %input, so LICM hoists it above the loop and CSE then merges it with %p — one dim feeds both the accumulator and the returned value:

// VALUE GRAPH (hip.cast) — after -loop-invariant-code-motion -cse: one hoisted, deduped dim
func.func @cast_licm_vg(%arg0: !hip.context, %arg1: tensor<?x8xf32>, %arg2: tensor<?x8xf16>,
                        %arg3: index, %arg4: index, %arg5: index) -> (tensor<?x8xf16>, index, index) {
  %c0  = arith.constant 0 : index
  %dim = tensor.dim %arg1, %c0 : tensor<?x8xf32>       // hoisted + deduped
  %0:2 = scf.for %arg6 = %arg3 to %arg4 step %arg5 iter_args(%arg7 = %arg2, %arg8 = %c0)
      -> (tensor<?x8xf16>, index) {
    %1 = hip.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%arg7 : tensor<?x8xf16>) {to = 10 : i64} : tensor<?x8xf16>
    %2 = arith.addi %arg8, %dim : index
    scf.yield %1, %2 : tensor<?x8xf16>, index
  }
  return %0#0, %0#1, %dim : tensor<?x8xf16>, index, index
}

Result: LICM does not lift the region's dim out of the isolated region, and CSE does not merge it with %p across the boundary, so both survive in the region form (2 tensor.dim); the value-graph form hoists then dedups to 1. LICM is also an enabling pass — a hoisted invariant becomes visible to CSE and further folding — so keeping the size in the region can also foreclose the follow-on optimizations the hoist would enable.


Case 4 — -cse does not deduplicate identical shape math across ops (scales with op count)

This is the case that compounds. CSE deduplicates identical computation across a whole function — here two ops read the same dimension of the same input, each computing it inside its own region. Even after CSE dedups everything outside the regions (the two hipsr.placeholder inits collapse to a single %0, so both casts share outs(%0)), the tensor.dim inside each region stays separate: each op has its own region scope with its own block argument, so the two dims are distinct SSA values in separate scopes with nothing to unify. In the value-graph form the shared tensor.dim is plain SSA in one scope, so -cse merges it to one. A real graph threads the same dimensions (batch, sequence) through many ops, so the region form carries a separate copy of the shape math per op — O(N) where the value-graph keeps O(1). (The two casts are identical here only for brevity; real ops reading a dimension differ, but their per-region shape math is in separate scopes regardless.)

// REGION (hipsr.cast) — after -cse: the placeholder inits dedup to one %0, but each cast's
// tensor.dim stays in its own region scope, so the two dims are not shared (2 tensor.dim remain)
func.func @cast_lost_dedup(%arg0: !hipsr.context, %arg1: tensor<?x8xf32>) -> (tensor<?x8xf16>, tensor<?x8xf16>) {
  %0 = hipsr.placeholder : tensor<?x8xf16>
  %1 = hipsr.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%0 : tensor<?x8xf16>) : tensor<?x8xf16> shape_region {
  ^bb0(%arg2: !hipsr.context, %arg3: tensor<?x8xf32>):
    %c0 = arith.constant 0 : index
    %dim = tensor.dim %arg3, %c0 : tensor<?x8xf32>
    %c8 = arith.constant 8 : index
    hipsr.shape_yield (%dim, %c8) : [f16]
  }
  %2 = hipsr.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%0 : tensor<?x8xf16>) : tensor<?x8xf16> shape_region {
  ^bb0(%arg2: !hipsr.context, %arg3: tensor<?x8xf32>):
    %c0 = arith.constant 0 : index
    %dim = tensor.dim %arg3, %c0 : tensor<?x8xf32>
    %c8 = arith.constant 8 : index
    hipsr.shape_yield (%dim, %c8) : [f16]
  }
  return %1, %2 : tensor<?x8xf16>, tensor<?x8xf16>
}
// VALUE GRAPH (hip.cast) — after -cse: the dims, the inits, and both casts merge to one
func.func @two_hipcast(%arg0: !hip.context, %arg1: tensor<?x8xf32>) -> (tensor<?x8xf16>, tensor<?x8xf16>) {
  %c0  = arith.constant 0 : index
  %dim = tensor.dim %arg1, %c0 : tensor<?x8xf32>
  %0   = tensor.empty(%dim) : tensor<?x8xf16>
  %1   = hip.cast(%arg0) ins(%arg1 : tensor<?x8xf32>) outs(%0 : tensor<?x8xf16>) {to = 10 : i64} : tensor<?x8xf16>
  return %1, %1 : tensor<?x8xf16>, tensor<?x8xf16>
}

Result: after -cse, the region form keeps 2 tensor.dim — one sealed in each cast's region — while the value-graph form keeps 1 (and N vs 1 across N such ops).


Open questions

Two things hold today: the standard passes (CSE, canonicalize, LICM) cannot optimize across the region boundary, and the current shape consumers — buffer sizing, tensor.dim folding, and pool sizing — read value-graph SSA rather than the region. The step that materializes the region's shape values into the surrounding value graph is not in the tree yet, so these questions concern the intended design, not whether the duplication remains in the final IR.

  1. Bufferization. Bufferization determines a result buffer's size from the op's DPS init, not from the region, and a hipsr init (hipsr.placeholder) has a shaped type but no SSA operands for its dynamic extents. Is the region materialized as tensor.empty / tensor.dim before bufferization, or is bufferization expected to consume the region directly?

  2. Optimization. CSE, canonicalize, and LICM can optimize inside the region but cannot share or hoist its shape math across the boundary. Is their cross-op effect recovered by materializing the shape into the value graph before those passes run (making the region a staging form), or would the passes be extended to understand the region? If so, which passes, and how would that stay compatible with upstream MLIR?

  3. Materialization point. Where does region-to-value-graph materialization run, and which passes run before it? It must precede bufferization (Q1) and any pass expected to optimize shapes across ops (Q2). Does materialization erase the region? If not, what does retaining an isolated region add over emitting the value-graph SSA directly?

  4. Benefit over the existing path. The compiler already represents per-op shapes as value-graph SSA: converters build the tensor.empty inits, and reifyResultShapes answers result-dimension queries, for the hip.* ops that bufferize and lower today. If the intended path materializes the region into that same SSA, what does the persistent isolated region provide that the existing path, a shared shape builder, or a transient non-isolated wrapper does not?

  5. Passes and dominance. The stated goal is fewer passes, but this design introduces PopulateShapeRegion, materialization, and pool-domain/barrier stages while replacing ONNX→HIP with ONNX→hipsr. Which existing stages do these replace, and is the net pass count lower? Today ResolveTensorDims and ResolveMemRefDims canonicalize size dependencies, HoistAllocSizeArith hoists the remaining size arithmetic, and PoolAllocs consumes it. What does enforcing dominance structurally through regions, barriers, and pool_domain add over that pipeline if the region ultimately materializes the same SSA values?

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions