This document outlines how MLIR models side effects and how speculation works in MLIR.
This rationale only applies to operations used in CFG regions. Side effect modeling in graph regions is TBD.
Many MLIR operations don't exhibit any behavior other than consuming and producing SSA values. These operations can be reordered with other operations as long as they obey SSA dominance requirements and can be eliminated or even introduced (e.g. for rematerialization) as needed.
However, a subset of MLIR operations have implicit behavior than isn't reflected in their SSA data-flow semantics. These operations need special handing, and cannot be reordered, eliminated or introduced without additional analysis.
This doc introduces a categorization of these operations and shows how these operations are modeled in MLIR.
Operations with implicit behaviors can be broadly categorized as follows:
printf
(which can be modeled as “writing” to the console and reading from the input buffers).scf.while
where the condition is always true.longjmp
, operations that throw exceptions.Finally, a given operation may have a combination of the above implicit behaviors. The combination of implicit behaviors during the execution of the operation may be ordered. We use ‘stage’ to label the order of implicit behaviors during the execution of ‘op’. Implicit behaviors with a lower stage number happen earlier than those with a higher stage number.
Modeling these behaviors has to walk a fine line -- we need to empower more complicated passes to reason about the nuances of such behaviors while simultaneously not overburdening simple passes that only need a coarse grained “can this op be freely moved” query.
MLIR has two op interfaces to represent these implicit behaviors:
MemoryEffectsOpInterface
op interface is used to track memory effects.ConditionallySpeculatable
op interface is used to track undefined behavior and infinite loops.Both of these are op interfaces which means operations can dynamically introspect themselves (e.g. by checking input types or attributes) to infer what memory effects they have and whether they are speculatable.
We don't have proper modeling yet to fully capture non-local control flow semantics.
When adding a new op, ask:
MemoryEffectsOpInterface
.MemoryEffectsOpInterface
and model the effect as a read from or write to an abstract Resource
. Please start an RFC if your operation has a novel side effect that cannot be adequately captured by MemoryEffectsOpInterface
.ConditionallySpeculatable
.ConditionallySpeculatable
.longjmp
)? We don't have proper modeling for these yet, patches welcome!Pure
. (TODO: revisit this name since it has overloaded meanings in C++.)This section describes a few very simple examples that help understand how to add side effect correctly.
Consider a SIMD backend dialect with a “simd.abs” operation which reads all values from the source memref, calculates their absolute values, and writes them to the target memref:
func.func @abs(%source : memref<10xf32>, %target : memref<10xf32>) { simd.abs(%source, %target) : memref<10xf32> to memref<10xf32> return }
The abs operation reads each individual value from the source resource and then writes these values to each corresponding value in the target resource. Therefore, we need to specify a read side effect for the source and a write side effect for the target. The read side effect occurs before the write side effect, so we need to mark the read stage as earlier than the write stage. Additionally, we need to indicate that these side effects apply to each individual value in the resource.
A typical approach is as follows:
def AbsOp : SIMD_Op<"abs", [...] { ... let arguments = (ins Arg<AnyRankedOrUnrankedMemRef, "the source memref", [MemReadAt<0, FullEffect>]>:$source, Arg<AnyRankedOrUnrankedMemRef, "the target memref", [MemWriteAt<1, FullEffect>]>:$target); ... }
In the above example, we attach the side effect [MemReadAt<0, FullEffect>]
to the source, indicating that the abs operation reads each individual value from the source during stage 0. Likewise, we attach the side effect [MemWriteAt<1, FullEffect>]
to the target, indicating that the abs operation writes to each individual value within the target during stage 1 (after reading from the source).
Memref.load is a typical load like operation:
func.func @foo(%input : memref<10xf32>, %index : index) -> f32 { %result = memref.load %input[index] : memref<10xf32> return %result : f32 }
The load like operation reads a single value from the input memref and returns it. Therefore, we need to specify a partial read side effect for the input memref, indicating that not every single value is used.
A typical approach is as follows:
def LoadOp : MemRef_Op<"load", [...] { ... let arguments = (ins Arg<AnyMemRef, "the reference to load from", [MemReadAt<0, PartialEffect>]>:$memref, Variadic<Index>:$indices, DefaultValuedOptionalAttr<BoolAttr, "false">:$nontemporal); ... }
In the above example, we attach the side effect [MemReadAt<0, PartialEffect>]
to the source, indicating that the load operation reads parts of values from the memref during stage 0. Since side effects typically occur at stage 0 and are partial by default, we can abbreviate it as [MemRead]
.