Intel Triton XPU #7791 — Why Every Mask Had to Be Validated Before the Loop Was Mutated
Intel Triton XPU RemoveMasks, Part 3 of 3 — Mutation-free preflight, result-less scf.if regions, unsupported stores, and a walker whose current loop had already been erased
The first two articles in this series examined two kinds of failure in Intel Triton’s TritonIntelRemoveMasks pass.
Part 1 examined an invalid replacement:
Always-false masked load
+
no `other` operand
↓
null value passed into RAUW
↓
compiler crashPart 2 examined an invalid proof:
Mask resembles a canonical boundary expression
but
its `N`, tile width, or predicate
does not match the generated guard
↓
mask can be removed without being impliedPR #7791 also fixed a third boundary.
Even a correct proof and a correct local rewrite can fail if the transformation changes the IR before it has established that the whole transformation is legal.
Loop versioning is not a one-operation edit.
The pass creates a new condition, inserts an scf.if, clones the original loop into two branches, removes masks from the fast-path clone, replaces the original loop’s results, and then erases the original loop.
Original loop
↓
prove fast-path condition
↓
create `scf.if`
↓
clone loop twice
↓
remove masks from then branch
↓
replace uses
↓
erase original loopEvery step changes the graph.
The old implementation could begin this process after considering only one representative mask, even though it would later remove the mask from every collected load.
It could also:
collect masked stores even though the versioner could not rewrite them,
add a second
scf.yieldto a no-resultscf.ifwhose builder had already created an implicit terminator,and tell a pre-order walker to continue into a loop that the callback had just erased.
These were not four unrelated coding mistakes.
They were all violations of one transformation rule:
Before mutating IR, a pass must prove that every object it will rewrite is supported, every region it will construct is structurally valid, and every operation it will continue walking still exists.
PR #7791 added that discipline to RemoveMasks. The merged PR describes six defects reproduced on valid MLIR, including validation-before-mutation, the duplicate-yield failure, unsupported store collection, and walker handling after loop erasure.
Loop versioning changes the whole loop at once
Consider a loop with one canonical masked load:
%result = scf.for %iv = %c0 to %ub step %c1
iter_args(%acc = %init)
-> tensor<32xf16> {
%remaining = %N - %iv * %c32
%mask = %range < splat(%remaining)
%value = tt.load %ptrs, %mask, %zero
%next = arith.addf %acc, %value
scf.yield %next
}The optimizer may generate a scalar condition such as:
N % 32 == 0
and
N > 32Then it creates two loop versions.
Condition true
→ all tiles complete
→ fast loop uses an unmasked loadCondition false
→ original masked loop remainsConceptually:
%result = scf.if %full_tiles
-> tensor<32xf16> {
%fast = scf.for ... {
%value = tt.load %ptrs
...
}
scf.yield %fast
} else {
%safe = scf.for ... {
%value = tt.load %ptrs, %mask, %zero
...
}
scf.yield %safe
}The fast path is correct only if its scalar condition proves every mask the pass removes.
The transformation does not version each load independently.
It versions one entire loop.
One branch condition governs every collected load
Suppose the loop contains two masked loads.
Load A
mask A
→ depends on N_A and END_ALoad B
mask B
→ depends on N_B and END_BThe versioner creates one scf.if.
one condition
↓
one fast loop clone
↓
masks removed from A and BThe required proof is therefore:
condition
⇒
mask A is true for every laneand:
condition
⇒
mask B is true for every laneIt is not sufficient to prove only:
condition
⇒
mask Aand then remove both masks because both expressions looked canonical.
The mutation set defines the proof set.
Masks that will be removed
=
Masks that must be validatedThe old versioner selected one collected mask as its representative
Before the fix, the canonical versioner effectively did this:
Collect masked operations that resemble canonical masks
↓
select one collected operation
↓
derive a versioning condition from that mask
↓
create the versioned loop
↓
drop the mask from every collected operationThe selected mask supplied the branch condition.
The remaining collected masks were not all checked against the loop upper bound before the rewrite began.
This created an asymmetry.
Proof derived from:
one maskMutation applied to:
all masksA representative condition is valid only when the pass has already established that every candidate belongs to the same proof class.
The old code assumed that relationship from local pattern recognition.
Local mask validity and whole-loop versionability are different
MaskedOpsCollector already used the associated validator when deciding whether an operation looked like a candidate.
For a canonical tail mask, that local analysis could establish facts such as:
The mask has the expected range expression.
The comparison uses the expected strict less-than form.
The loop induction variable appears in the expected arithmetic.That makes the mask a plausible candidate.
It does not yet prove:
The loop upper bound uses the same `N`.
The loop upper bound uses the same tile width.
The scalar condition generated for this loop implies this mask.The distinction is:
isValidMask()
→ Does this operation locally resemble
a canonical masked access?canVersion()
→ Can this loop’s versioning condition
safely remove this exact mask?PR #7791 added the second question as a mutation-free preflight.
The two-mask regression demonstrates the whole-loop obligation
The regression two-canonical-masks.mlir contains two loads inside one loop.
Load A
N = %arg2
END = 32Its mask matches the loop upper bound:
(%arg2 + 31) / 32Load B
N = %arg3
END = 64Its mask does not follow from the same upper bound.
Both masks have a canonical-looking form.
range(0, END)
<
N - iv × ENDBut they use different symbols and different tile widths.
If the pass builds the fast-path condition from Load A:
%arg2 % 32 == 0that says nothing about whether Load B’s sixty-four lanes are valid under %arg3.
Because the fast-path clone would remove both masks, the only correct result is:
do not version the loopThe regression therefore requires:
no `scf.if`
both masked loads remainThe old transformation could cross the mutation boundary too early
Generating a versioning condition is itself an IR mutation.
For a canonical loop, the pass may create operations equivalent to:
%remainder =
arith.remsi %N, %END
%divisible =
arith.cmpi eq, %remainder, %zero
%large_enough =
arith.cmpi sgt, %N, %END
%condition =
arith.andi %divisible, %large_enoughThese operations are inserted before the original loop.
If the pass later discovers that another mask cannot be versioned, it would need to:
remove the newly created condition operations,
restore every changed use,
remove any partially built regions,
and guarantee that no mutation escaped.
The simpler and safer design is:
Phase 1:
validate without creating IRPhase 2:
after every validation succeeds,
create the condition and perform the rewriteThat is the role of canVersion().
canVersion() performs a read-only preflight
The new method checks:
Does the mask resolve to a usable final value?
Does it satisfy the canonical-mask structure?
Does the loop upper bound have the matching canonical form?
Do `N` and `END` match?It does not create:
a remainder operation,
a comparison,
an
and,an
scf.if,or a cloned loop.
Conceptually:
bool canVersion(loop, mask) {
if (!isValidMask(loop, mask))
return false;
return hasCanonicalUpperBound(
loop,
getMaskInfo(loop, mask)
);
}The versioner first calls this preflight for every collected operation.
for every collected load:
if canVersion(load mask) is false:
return without changing IROnly after every mask passes does it call the mutating:
getVersioningCond()for one representative mask.
The landed source explicitly documents canVersion() as the non-mutating counterpart to getVersioningCond().
A failed preflight now leaves the original loop intact
The new sequence is:
Collect candidates
↓
validate Mask A without mutation
↓
validate Mask B without mutation
↓
one fails
↓
return false
↓
original loop remains exactly as it wasThere is no temporary versioning condition left behind.
There is no partial branch.
There is no fast-path clone containing only some transformed loads.
This gives the pass a transaction-like boundary.
Before commit:
analysis onlyAfter every precondition succeeds:
perform complete rewriteMLIR does not automatically make arbitrary pass mutations transactional.
The pass must choose where its commit point begins.
After preflight, one condition is sufficient
Why can the pass still generate only one versioning condition after checking all masks?
Because the new preflight establishes that every accepted mask shares the loop upper bound’s:
N
and
ENDFor canonical masks in the same loop:
same N
+
same END
→ same generated full-tile conditionThe conditions would be identical.
The pass therefore does not need:
condition A
and
condition B
and
condition Cwhen every condition represents the same fact.
It needs to prove the equivalence first.
Before validation:
one representative condition
→ insufficientAfter every mask is proven to use the same N and END:
one representative condition
→ sufficientThe important change was not the number of conditions.
It was the proof that one condition legitimately represented the complete mutation set.
Validation-before-mutation also improves failure locality
Suppose one loop contains ten masks.
Nine are valid.
The tenth is not.
A rewrite that mutates as it checks can fail after performing substantial work.
create condition 1
clone loop
rewrite mask 1
rewrite mask 2
...
reach mask 10
discover failureNow the pass needs rollback logic.
With preflight:
check mask 1
check mask 2
...
check mask 10
rejectNo rollback is required.
The failure remains located in analysis rather than becoming an IR-repair problem.
The collector must not promise operations the rewriter cannot handle
A second contract existed between:
MaskedOpsCollectorand:
LoopVersionerThe collector determines which operations the transformation intends to rewrite.
Before #7791, it collected:
tt.load
tt.store
arith.selectacross mask validators.
But the loop-versioning implementation knew how to remove a mask only from:
tt.loadIts fast-path rewrite created a replacement unmasked load.
The store path contained only a remaining:
TODOThere was no corresponding transformation that recreated an unmasked tt.store.
The collector’s declared scope was therefore broader than the consumer’s implementation.
Collection is a capability claim
Once an operation enters the collected set, the rest of the pass assumes:
This operation is supported by the upcoming transformation.That assumption affects:
proof construction,
branch creation,
operation mapping into the cloned loop,
and fast-path mask removal.
A collector is not merely a search helper.
It defines the rewrite domain.
Collected operation
→ pass claims it knows how to transform itIf the pass cannot perform that transformation, the operation should not enter the set.
A store-only loop exposed the scope mismatch
The new regression contains a loop with only one masked store.
scf.for ... {
%mask = ...
tt.store %ptrs, %value, %mask
}The store uses a canonical-looking tail mask.
But the versioner has no implemented store-mask removal.
The correct behavior is:
keep the original loop
keep the masked store
do not create a versioning branchThe regression explicitly checks:
no `scf.if`
one `scf.for`
masked `tt.store` remainsVersioning a store-only loop would not create a fast path
Imagine cloning the store-only loop into:
then branch
else branchbut leaving the store mask in both.
Then:
masked storeElse:
same masked storeThe scalar condition now adds control flow without removing the operation cost it was created to avoid.
At best:
the transformation is pointlessAt worst, unsupported assumptions elsewhere in the versioner can produce invalid IR.
A compiler pass should not version an operation merely because it can recognize the mask.
It should version it only when it can complete the intended rewrite.
The patch narrows collection to implemented operations
The merged collector always gathers:
masked `tt.load`For the RemovableMaskValidator, which uses the direct dropMask() helper, it also gathers:
arith.selectThe direct helper can replace:
an always-true or always-false load result,
and a select result.
The loop versioners receive only loads because their fast-path rewrite is implemented only for loads.
Masked stores are no longer collected.
The code also simplifies later assertions and casts accordingly:
versioner input
→ guaranteed `tt.load`rather than:
load or store,
with a store path that is not actually implementedFail-closed does not mean stores can never be optimized
The patch does not prove:
A masked store can never be safely unmasked.It establishes:
This versioner did not implement that rewrite.A future store optimization would need to define:
which store masks are provably true,
how the unmasked store is constructed,
whether the store has side-effect ordering constraints,
how aliasing and memory effects are preserved,
and which regression tests protect the path.
Until then:
unsupported store
→ leave it unchangedis the correct compiler result.
A result-less scf.if already has a terminator contract
The third failure involved SCF region construction.
A versioned loop may produce values.
For example:
%result = scf.for ...
iter_args(%acc = %init)
-> tensor<32xf16> {
...
scf.yield %next
}Versioning such a loop creates an scf.if that also produces a value.
%result = scf.if %condition
-> tensor<32xf16> {
%fast = scf.for ...
scf.yield %fast
} else {
%safe = scf.for ...
scf.yield %safe
}Each branch must explicitly yield the value corresponding to the scf.if result.
That is valid.
But a loop may also produce no SSA results.
A loop can perform useful work without returning a value
The regression’s second function contains:
masked load
then
unmasked storeinside the loop.
scf.for ... {
%value =
tt.load %input_ptrs, %mask, %zero
tt.store %output_ptrs, %value
}The loop has no iter_args.
It produces no result.
Its effect is the output store.
Versioning it still makes sense.
Fast branch
→ unmasked load
→ storeFallback branch
→ masked load
→ storeBut the surrounding scf.if also has no results.
scf.if %condition {
scf.for ... {
...
}
} else {
scf.for ... {
...
}
}No value needs to be yielded from either branch.
No-result regions receive an implicit scf.yield
The SCF builder supplies a zero-operand terminator for a no-result scf.if region.
Conceptually:
scf.if %condition {
...
scf.yield
} else {
...
scf.yield
}The terminator is structurally required.
But because it carries no values, the builder can create it automatically.
The transformation must not add another one.
The old versioner added an explicit yield unconditionally
The old canonical versioner cloned the loop into both branches and then created:
scf::YieldOpfor the then region and the else region regardless of result count.
For a result-bearing scf.if, that was necessary.
For a no-result scf.if, each region already contained its implicit terminator.
The result was effectively:
scf.if %condition {
scf.for ...
scf.yield
scf.yield
}A block cannot have two valid terminators.
The first terminator ends the block.
A second operation after it produces invalid SCF structure.
PR #7791 lists this as a confirmed crash-producing defect on valid MLIR.
The fix checks the result arity before creating yields
The repaired source asks:
if (ifOp.getNumResults() != 0) {
create then yield;
create else yield;
}The two cases are now explicit.
Result-bearing scf.if
number of results > 0
→ create yields carrying cloned-loop valuesResult-less scf.if
number of results = 0
→ use the implicit terminators already created by the builderThe pass no longer treats:
zero resultsas though it were:
a result list that happens to contain zero operands
and therefore needs another explicit operationThe region builder already owns that case.
Result count is part of region construction
Compiler transformations often focus on operation types:
Create an `scf.if`.
Clone an `scf.for`.
Insert a yield.But operation arity changes the structural contract.
scf.if with results
≠
scf.if without resultsThey share one operation name.
Their terminator obligations differ.
This is similar to optional operands in Part 1.
Optional `other`
→ changes replacement constructionOptional result list
→ changes region termination constructionA generic rewrite must preserve both forms.
The regression checks for the absence of a stray yield
The load_only_no_results test does more than verify that triton-opt no longer crashes.
It checks the generated versioning condition:
N % 32 == 0
and
N > 32It then checks:
`scf.if` has no result
fast branch contains an unmasked load
fallback branch contains the original masked loadMost importantly, it requires that no extra explicit:
scf.yieldappears after either cloned loop and before the region closes.
The test therefore protects:
valid fast-path transformation
and
valid SCF terminator structureA process that does not crash can still emit invalid IR
A transformation bug does not always fail at the point where it is created.
The pass may construct malformed IR and continue.
A later verifier or pass then reports the failure.
RemoveMasks creates duplicate terminator
↓
another stage verifies SCF
↓
compiler fails laterThat can obscure the origin.
The structural FileCheck test makes the intended region form explicit at the pass boundary that creates it.
A walker cannot descend into an operation that has been erased
The fourth lifecycle problem involved the pass driver.
TritonIntelRemoveMasks walks the module in pre-order.
Visit parent operation
then
visit its nested operationsWhen the callback encounters an scf.for, it may call:
LoopVersioner::version(forOp, collector)A successful versioning operation:
creates a new
scf.if,clones the loop into two branches,
redirects uses,
and erases the original
forOp.
The walker callback is therefore invoked with an operation that may cease to exist before the callback returns.
Pre-order traversal normally expects to visit the current operation’s children
A simplified pre-order walk is:
visit current operation
↓
callback returns “advance”
↓
walk current operation’s regions
↓
continue with later siblingsThis is safe when the current operation still exists.
After successful versioning:
current operation
→ erasedIts original regions and body are no longer valid traversal roots.
Returning:
WalkResult::advance()would tell the walker:
continue descending through this operationeven though the transformation had just deleted it.
The pass now returns WalkResult::skip() after versioning
The repaired callback checks the versioner’s Boolean result.
bool loopVersioned =
LoopVersioner::version(forOp, collector);
if (loopVersioned)
return WalkResult::skip();Here, skip() means:
Do not descend into the current operation’s nested regions.It does not mean:
Stop the entire module walk.The walker can continue to later sibling operations.
This is the correct response to:
the current operation has been replaced and erasedThe fix was applied to both canonical-mask and invariant-mask versioning walks.
Erasing the current operation changes traversal ownership
Before the callback:
walker owns traversal of original `scf.for`During callback:
versioner creates replacement `scf.if`
versioner erases original `scf.for`After callback:
walker must not use original `scf.for`The versioner owns the new graph.
The old traversal cursor must give up the erased node’s children.
This is another ownership transition.
IR node lifetime
and
walker traversal lifetimemust remain aligned.
Why skip() is better than stopping the whole walk
The pass could avoid use-after-erasure by interrupting traversal entirely.
But then later loops in the same function would never be optimized.
Version first loop
↓
stop module walk
↓
second loop remains untouchedThat would preserve safety at the cost of incomplete pass behavior.
skip() is narrower.
Do not enter deleted current loop
but
continue to following siblingsThe regression checks exactly that distinction.
Two sibling loops protect the traversal boundary
The new sibling-loops-versioned.mlir test places two independent, versionable scf.for operations in the same function.
Loop 1
→ canonical masked loadLoop 2
→ another canonical masked loadThe expected result contains two separate versioning branches.
`scf.if` for Loop 1
followed by
`scf.if` for Loop 2Each then branch contains an unmasked load.
Each else branch preserves the masked load.
This proves two traversal properties simultaneously.
The walker does not descend into the loop erased by the first rewrite.The walker still reaches and versions the later sibling loop.The test protects continuation, not only crash avoidance
A weaker test could include one versioned loop and verify that the compiler does not crash.
That would not distinguish:
walker safely skips erased operationfrom:
walker stops the whole traversal after the first rewriteThe second sibling makes the difference observable.
First loop transformed
+
second loop transformed
→ traversal continued correctlyThis is a strong example of a regression input designed around walker control flow rather than only output values.
These fixes form one transformation transaction
The major Part 3 corrections can be placed in one sequence.
1. Candidate collection
Collect only operation kinds
the rewriter actually supports.After the patch:
loop versioning candidates
→ masked loadsnot:
masked stores that cannot be rewritten2. Preflight validation
Check every collected mask
without modifying IR.If one mask fails:
reject complete versioning attempt3. Commit construction
Generate one valid scalar condition
create `scf.if`
clone loop
remove masks from supported loads4. Arity-correct region termination
Result-bearing if
→ explicit value yieldsResult-less if
→ keep implicit zero-operand terminators5. Original-node retirement
replace result uses
erase original loop6. Traversal handoff
return WalkResult::skip()
do not descend into erased node
continue to sibling operationsEach stage owns a separate invariant.
A failure in any stage can invalidate the whole pass.
Analysis and rewrite now have a clearer boundary
The transformation can be summarized as two phases.
Analysis phase
Find candidate loads.
Validate local mask shape.
Validate loop-level implication.
Validate operation support.
Determine result requirements.Rewrite phase
Create guard.
Create branch.
Clone loops.
Remove masks.
Create required yields.
Replace results.
Erase original node.
Repair traversal position.The patch does not create a formal transaction object.
Architecturally, however, it moves the commit point after the complete precondition set.
Partial validity requires complete rejection
The two-mask example is the clearest case.
Mask A
→ versionableMask B
→ not versionableThe pass cannot choose:
remove Mask A in one cloned loop
leave Mask B in placewithout defining and testing such a mixed transformation.
The current versioner’s contract is:
remove every collected maskTherefore its acceptance rule must be:
every collected mask is versionableIf the implementation later supports per-operation partial versioning, that would be a different optimization with a different proof and test matrix.
Unsupported operations should not be smuggled through a generic collector
The masked-store issue shows another common compiler failure mode.
A shared collector can appear convenient:
loads
stores
selectsall have conditions.
But common syntax does not imply common rewrite semantics.
Masked load
→ produces a value
→ rewrite must replace resultMasked store
→ has a side effect
→ rewrite must preserve memory behaviorSelect
→ chooses between two values
→ rewrite replaces one resultThe collector should follow the capabilities of its consumer, not the superficial similarity of its inputs.
Builder defaults are part of the IR API
The duplicate-yield bug shows that transformations also need to respect what builders create automatically.
A builder is not merely a convenience wrapper around an empty operation.
It may establish required invariants.
No-result `scf.if` builder
→ creates region terminatorsA pass that adds another terminator is not being more explicit.
It is violating the structure the builder already guaranteed.
Before adding an operation to a newly created region, the transformer needs to know:
What does the builder already own?An erased node cannot remain the walker’s current object
The walker issue is the lifetime equivalent.
Callback input:
original loopCallback output:
original loop erasedThe callback must report that change to the traversal mechanism.
Returning the ordinary continuation result hides the lifetime transition.
IR mutation
→ changed traversal validityThe walker result is therefore part of rewrite correctness.
The regressions protect several different kinds of evidence
PR #7791 did not rely on one broad “pass completed” test.
The new files check distinct boundaries.
two-canonical-masks.mlir
One invalid mask among several
→ no partial or whole-loop versioning
→ both masks retainedversioning-no-results.mlir
Store-only loop
→ not collected or versionedand:
No-result load loop
→ versioned correctly
→ no duplicate `scf.yield`sibling-loops-versioned.mlir
First loop erased and replaced
→ walker skips its old body
→ later sibling loop still transformedThe tests are structural.
They inspect the exact MLIR produced by triton-opt.
They require no Intel XPU device.
Structure is the result being tested
For these defects, numerical output would be too late or too indirect.
The relevant questions are:
Was an `scf.if` inserted?
Were masks removed from the correct loads?
Did a masked store remain?
Did a region receive an extra terminator?
Did the walker reach the second loop?Those are graph properties.
FileCheck can assert them directly.
The tests do not need to wait for device code or runtime execution to expose malformed IR.
Valid MLIR should not be punished for selecting an unsupported edge case
The issue states that all six failures were reproduced from valid MLIR.
That matters for Part 3 as well.
The compiler cannot defend itself by saying:
A no-result versioned loop should never occur.
A loop should never contain two different masks.
A masked-store-only loop is malformed.
Two sibling loops should not be walked this way.Those are legitimate IR forms.
The pass must either:
transform them correctlyor:
leave them unchangedThe old implementation sometimes did neither.
What the patch directly changed in this area
The merged patch directly:
adds a mutation-free
canVersion()check,validates every collected canonical mask before generating the branch condition,
requires the versioning condition to imply every mask removed in the fast branch,
stops collecting masked stores,
limits loop-versioning candidates to operation kinds the versioner can rewrite,
creates explicit
scf.yieldoperations only when thescf.ifhas results,returns
WalkResult::skip()after successful loop replacement,applies the walker fix to canonical and invariant versioning,
and adds targeted MLIR/FileCheck regressions for the corresponding boundaries.
The changes are visible in the landed RemoveMasks.cpp and the three regression files discussed above.
What the patch does not establish
The public evidence does not establish that:
every
RemoveMaskstransformation is now transactionally rollback-safe,every mask form supported by Triton is covered,
masked stores can never be optimized,
all nested loops are supported,
every MLIR walker mutation pattern is safe,
no later pass can introduce malformed SCF,
the new rejection paths have zero performance cost,
the pass has been formally verified,
or these compiler defects produced a documented numerical failure in a production XPU model.
The supported conclusion is narrower:
The old pass could begin a whole-loop rewrite without proving every mask it would remove, could collect operations its versioner did not support, could construct an invalid no-result
scf.if, and could continue walking through an erased loop. The merged patch closes those specific IR-lifecycle boundaries.
The complete three-part series
Part 1 — The memory effect disappeared, but the SSA value did not
Always-false load
→ no memory read
but
→ result still consumedWithout other, the pass had to materialize a typed zero rather than perform a null replacement.
Part 2 — A familiar mask shape was not a proof
Canonical-looking expression
≠
guard implies every laneDynamic N, mismatched N and END, and eq or ne predicates had to remain masked.
Part 3 — A correct local fact was not enough for a valid whole-loop rewrite
Validate all masks.
Collect only supported operations.
Respect result arity.
Stop walking erased nodes.Optimization correctness included the full lifetime of the IR transformation.
The final lesson is that compiler rewrites need a commit point
A pass can recognize one valid pattern and still be wrong to mutate.
The real precondition may involve:
every operation in a collection,
the relationship among their symbols,
the capabilities of the rewriter,
the result arity of newly created control flow,
and the lifetime of the operation currently being traversed.
The safe structure is:
discover
↓
validate complete set
↓
commit rewrite
↓
retire old IR
↓
repair traversal stateNot:
discover first promising operation
↓
begin mutation
↓
learn about the remaining constraints laterA compiler transformation should cross its mutation boundary only after every assumption consumed by the final graph has already been proven.
Intel Triton XPU #7791 repaired RemoveMasks by moving that boundary later—and by making the rewrite’s supported operations, region structure, and walker lifetime agree with the IR it actually produced.
Previous articles
Intel Triton XPU #7791 — Why an Always-False Masked Load Without
otherCrashedRemoveMasksIntel Triton XPU #7791 — Why a Boundary-Shaped Mask Was Not Enough to Prove It Removable
Link the titles above to Parts 1 and 2 after publication.
Related material
Intel Triton XPU Issue #7790 —
RemoveMaskscrashes and unsound mask dropsIntel Triton XPU PR #7791 — Fix crashes and unsound mask drops
Patch status: Merged into Intel Triton XPU main
Affected pass: TritonIntelRemoveMasks
Whole-loop proof rule: Every collected mask must pass mutation-free canVersion() validation
Commit boundary: Versioning condition and scf.if are created only after complete validation
Supported versioning operation: Masked tt.load
Unsupported collection removed: Masked tt.store
No-result region rule: Preserve builder-created implicit terminators; do not add a second scf.yield
Walker rule: Return WalkResult::skip() after the original loop is erased
Traversal regression: Two sibling loops must both be reached and versioned
Regression form: Valid MLIR through triton-opt and FileCheck
Hardware requirement: None
Evidence boundary: Compiler IR construction and traversal; no XPU runtime-performance claim
This is Part 3 and the final article in the Intel Triton XPU RemoveMasks series.
Part 1 examined why eliminating an always-false memory access still required a valid typed SSA replacement.
Part 2 examined why canonical expression shape did not prove that one scalar guard implied every vector lane.
This final article examined why the pass had to validate the complete mutation set before creating IR, restrict its candidates to operations it could actually rewrite, preserve SCF terminator contracts, and stop traversing a loop that no longer existed.
#Intel #Triton #XPU #MLIR #CompilerOptimization #LoopVersioning #IRTransformation #SCF #CompilerCorrectness #RegressionTesting #CodeAnalysis