Declarative Assembly Format requirement for type presence

Hello!

I have small question about one particular requirement of Declarative Assembly Format:

All operand and result types must appear within the format using the various type directives, either individually or with the operands or results directives.

IMHO, it introduces extra redundancy in ASM form for SSACFG regions:

%0 = some.op() -> tensor<1x2x3x4xf32> // %0 type is defined
%1 = other.op(%0 : tensor<1x2x3x4xf32>) -> tensor<5x6x7x8xf32> // duplicate %0 type

Since, for SSACFG region there is use-def dominance requirement, the values for operation’s operands should be already resolved and their types should be already known. But Declarative Assembly Format forces to duplicate the type information for values (unless some special cases). This leads to quite verbose ASM files, especially with complex types (tensor/memref with non trivial encoding/layout/memory space), which hard to read and analyze.

I can agree, that this types duplication adds extra validation logic during the ASM parsing, but can’t it be made optional?

That only applies in trivial cases:

^bb2:
  %1 = other.op(%0 : tensor<1x2x3x4xf32>) -> tensor<5x6x7x8xf32> // necessary %0 type
  ...

^bb1:
  %0 = some.op() -> tensor<1x2x3x4xf32>
  some.br ^bb2

The use-def requirement only asserts that the value is defined (logically) before the use, not that it appears before the use in the IR file.

– River

Thanks for clarification!

But, what if the block-based control flow is not used on some high level of the IR and operation-with-region based approach is used instead. Such scenario should have more strict and simpler use-def requirement.

%0 = some.op() -> tensor<1x2x3x4xf32>

scf.if (...) {
    %1 = other.op(%0 : tensor<1x2x3x4xf32>) -> tensor<5x6x7x8xf32>
}

If you have a region that is statically limited to a single block, you don’t strictly need to specify the types.
The problem is that when defining an operation in ODS, you don’t know that there can’t be one of the region above that has a CFG.

The problem is that when defining an operation in ODS, you don’t know that there can’t be one of the region above that has a CFG.

That’s true for the generic dialects library, like upstream provided dialects. But for some dialect internally used in single project we might know all the use cases.

Can we introduce some operation trait, which will allow to make the operand type requirement optional? I can think about the implementation details, if you don’t have objections from this feature.

Just, in our project we don’t use block-based CFG, so we have no cases, where the operand is not defined above its use. But the type presence requirement leads to quite verbose ASM files, which is hard to read and analyze.

I’ve submitted a patch with proposed changes - ⚙ D111650 [mlir][RFC] Relax ODS assembly format rules for operand types presence

Just, IMHO, the block-based CFG is one of the possible use-cases in MLIR, but not the only one. And users shouldn’t pay price for the feature they don’t use :slight_smile:

Yes, but we also have a vested interested in keeping the ecosystem composable and reusable as much as possible. This kind of feature allows to easily users to “shoot themselves in the foot”.
I’m quite hesitant because countless of my colleague who got started with MLIR didn’t get the CFG issues and it required a lot of explanations: so this would enable too easily people to create broken situation unknowningly.
It is also hard at the beginning of the development to know that for sure an operation will never be used ever in a CFG context, in particular because this cannot be thought out in terms of a single dialect!!
For example partial lowering can get someone to lower towards LLVM and start materializing a CFG that would still contain an operation with a nested region that isn’t lowered yet.

Because of all of this, I’d like such a feature to be safe.
That means finding a way for example to make it such that the type can’t be elided during printing if the producer isn’t in the same block, but never allow a syntax that would elide it unconditionally.

On another track, there is something to say about readability as well: the principle so far has been that an operation can always be read outside of context. You can print a single line and you should be able to know the full type of all the values on the line.
We already aggressively have the ability to omit redundant types (for example %a = add %b, %c : f32 where the type is the same on all values), but what you want to do here is a deeper change: in a large block you’d have now to scroll around to figure out the types instead (admittedly the work on the LSP can help with this though).

I understand your points and I’ve tried to implement this change in non-intrusive way - use current behavior as default, but provide an explicit flag to relax the restriction. The great thing with MLIR - it allows to have such flexibility.

particular because this cannot be thought out in terms of a single dialect!!

It applies to upstream dialects that must be as general as possible. But imaging the situation, when someone implements compiler for Foo language and uses its own foo dialect to represent it. The dialect has limited scope of use and it is used in isolation. In such cases we know all its use cases and we can provide a hint to MLIR - “We promise not to use block-based CFG, please relax some restrictions”.

In our particular case, we highly use complex MemRef types like this: memref<1x2x3x4x!someTypeAlias, #mapAlias1, #mapAlias2, "some memory space">. When you have operation, that takes 5 operands of such types and your have to specify all of them - the ASM form becomes quite human unfriendly.

Couldn’t you just always add aliases for those memrefs? (At least in the interim)

– River

Sure, but there are tradeoff in API design, and opening up dangerous APIs / paradigm that could lead to problems for the ecosystem, while benefiting a few does not look very appealing to me. We could consider that someone who is using MLIR in total isolation from the rest of the world could carry internal patches to allow this for example. Or we could do this behind a special build flag so it won’t be available in default build at all.

What do you think of the behavior I proposed though?

make it such that the type can’t be elided during printing if the producer isn’t in the same block

(so: make it possible to elide the type when the producer is actually in the same block)

I would think that this provides the benefits you’re looking for, without the drawbacks I’m afraid of.
(and I may even consider using such a feature in some internal dialects).

1 Like