How to avoid persistent `unrealized_conversion_cast`s when converting dialects?

I’m currently working on converting CIR to LLVM dialect, however, there are a few unrealized_conversion_casts being emitted and I’m not entirely understanding why they are not removed after the conversion is over.

I’m aware that the -reconcile-unrealized-casts pass can remove these, but I wanted to confirm if there is any method to prevent them from appearing in the first place.

Issue

Take the CIR code below:

#true = #cir.bool<true> : !cir.bool
module {
  cir.func @l0() {
    cir.loop for(cond : {
      %0 = cir.const(#true) : !cir.bool
      cir.yield %0 : !cir.bool
    }, step : {
      cir.yield
    }) {
      cir.yield
    }
    cir.return
  }
}

The CIR and Builting dialects are marked as illegal, while our target dialect, LLVM, is marked as legal. After converted to LLVM, the code above looks like this:

  ^bb1:  // 3 preds: ^bb0, ^bb2, ^bb3
    %0 = llvm.mlir.constant(true) : i8
    %1 = builtin.unrealized_conversion_cast %0 : i8 to !cir.bool
    %2 = llvm.trunc %0 : i8 to i1
    llvm.cond_br %2, ^bb3, ^bb4
  ^bb2:  // no predecessors
    llvm.br ^bb1
  ^bb3:  // pred: ^bb1
    llvm.br ^bb1

There is an unrealized cast that is seemingly wrong and unnecessary: %1 has no uses and converts a legal type i8 to an illegal one !cir.bool.

Debugging the conversion, I’ve noticed that, after the operations are converted, we are left with the following code:

  ^bb1:  // 4 preds: ^bb1, ^bb1, ^bb4, ^bb4
    %0 = "llvm.mlir.constant"() <{value = true}> : () -> i8
    %1 = "cir.const"() {value = #cir.bool<true> : !cir.bool} : () -> !cir.bool
    %2 = "builtin.unrealized_conversion_cast"(%1) : (!cir.bool) -> i8
    "cir.yield"(%1) : (!cir.bool) -> ()
    %3 = "llvm.trunc"(%2) : (i8) -> i1
    "llvm.cond_br"(%3)[^bb3, ^bb5] <{operand_segment_sizes = array<i32: 1, 0, 0>}> : (i1) -> ()
    "cir.brcond"(%1)[^bb3, ^bb5] : (!cir.bool) -> ()

The unrealized cast above is not the one that persists after the conversion, but a second unrealized cast is added when finalizing the rewrite:

  ^bb2:  // 4 preds: ^bb1, ^bb1, ^bb4, ^bb4
    %0 = "llvm.mlir.constant"() <{value = true}> : () -> i8
    %1 = "builtin.unrealized_conversion_cast"(%0) : (i8) -> !cir.bool
    %2 = "cir.const"() {value = #cir.bool<true> : !cir.bool} : () -> !cir.bool
    %3 = "builtin.unrealized_conversion_cast"(%2) : (!cir.bool) -> i8
    "cir.yield"(%2) : (!cir.bool) -> ()
    %4 = "llvm.trunc"(%0) : (i8) -> i1
    "llvm.cond_br"(%4)[^bb3, ^bb5] <{operand_segment_sizes = array<i32: 1, 0, 0>}> : (i1) -> ()
    "cir.brcond"(%2)[^bb3, ^bb5] : (!cir.bool) -> ()

Finally, the conversion is considered valid and MLIR proceeds to apply the rewrites. When doing so, it tries to remove unresolved materialization ops, but in the example above It removes only the first unrealized cast, not the second. Resulting in the LLVM Dialect code shown in the second snippet.

Questions

Q1: If use applyFullConversion, but the final code has a value with an illegal type, shouldn’t the conversion fail?

Q2: Why is the second unrealized cast required at all if its resulting value has no users?

Q3: Why is the second cast, which is created when finalizing the conversion, not automatically removed by MLIR?

Q4: How can I avoid these types of “leftover” unrealized casts when converting between dialects?

On a general note, dialect conversion is quite convoluted and, at this point, is subject to Hyrum’s law. The best way to understand why something does or does not happen is to find the relevant code location and consider the conditions that lead there

Conceptually, it would make sense to fail the conversion. However, the notion of full/partial is defined on the validity of operations, and it may not be possible in general to weed out all invalid types. Consider, for example, a property that is a list of attributes, such of which are type attributes. We have no way of generically inspecting properties so we wouldn’t even know. It’s up to the caller to configure the operation legality hooks so they inspect all places where the types can appear in the IR being converted.

This is where you’d need to follow the code. I don’t suppose anybody has that knowledge ready in their head.

It will be if you run some pass that performs DCE.

Make sure your dialect conversion setup has enough patterns to convert all operations into the target type system, and also include the patterns exercised by the reconciliation pass.

That does not really answer the question about why couldn’t DialectConversion automatically eliminate unrealised_conversion_cast though ; these are kind of “special” with respect to the conversion framework.

When something breaks, I always try several variants of the input that are one epsilon smaller. It should eventually give you a hint what the root cause of the error is.

I don’t know enough internals to say whether the generation of that extra unrealized cast is needed or just a bug. But I did notice this exact behavior one year ago and I handled it inside the unrealized casts reconciliation pass, when I introduced the possibility of folding chains of casts.
See llvm-project/mlir/lib/Conversion/ReconcileUnrealizedCasts/ReconcileUnrealizedCasts.cpp at 14d073b50f960674a62ef8ad2c34f6fc1e9b0061 · llvm/llvm-project · GitHub

See if this helps: ⚙ D116661 Fold certain ops during dialect conversion

Did you mean “couldn’t” here instead of “could”?

1 Like

Indeed!