[Graph Optimization](revert) restore layout optimization (#1467) - #1560
Conversation
P0 — reject this nested loop-carried pointer shape before enabling
|
| graph optimization | result |
|---|---|
| off | exact |
| on | 2048 / 2048 outputs mismatch; max absolute difference 22525 |
Turning dynamic-CV on does not change the failure. Debug + overflow sanitization happens to be exact only because the extra use-def graph prevents this candidate from matching; debug without sanitization mismatches again. This isolates GraphOptimize rather than autotune, parallel compilation, or dynamic-CV.
Why this candidate matches
For the A coordinate, the rule sees all of the conditions in analyzeModulo:
%offs_am_29is a rank-1tensor<64xi32>remainder with att.make_range(0, 64)-derived dividend.- The divisor is
tt.splat %M: it is runtime/dynamic, not a compile-time constant, so the constant-divisor exclusion does not apply. - The unwrapped offset is also used by the final C-store guard,
offs_am_28 < M. - The modulo result has one
tt.expand_dims axis = 1. - Its address-only use chain is entirely allowed by
collectAddressedLoads: shape/arithmetic ops, outerscf.for, innerscf.for, and finallytt.load.
The B coordinate independently matches the same pattern (tensor<128xi32>, dynamic N, axis = 0). The problematic A path is the one that crosses two nested loop-carried pointer relays.
TTIR before the rule
%offs_am_28 = arith.addi %offs_am_27, %offs_am : tensor<64xi32>
%offs_am_29 = arith.remsi %offs_am_28, %offs_am_9 : tensor<64xi32>
// %offs_am_9 = tt.splat %M
%offs_bn_32 = arith.addi %offs_bn_31, %offs_bn : tensor<128xi32>
%offs_bn_33 = arith.remsi %offs_bn_32, %offs_bn_10 : tensor<128xi32>
// %offs_bn_10 = tt.splat %N
%a_ptrs_34 = tt.expand_dims %offs_am_29 {axis = 1 : i32}
%a_ptrs_35 = arith.muli %a_ptrs_34, %a_ptrs // %a_ptrs = splat(stride_am)
%a_ptrs_38 = tt.addptr %a_ptrs_13, %a_ptrs_37
%b_ptrs_39 = tt.expand_dims %offs_bn_33 {axis = 0 : i32}
%b_ptrs_42 = tt.addptr %b_ptrs_17, %b_ptrs_41
%outer:2 = scf.for ... iter_args(%a_outer = %a_ptrs_38, %acc = ...) {
%inner:3 = scf.for ... iter_args(%a_inner = %a_outer, %b_inner = %b_ptrs_42, %acc2 = %acc) {
...
%a_next = tt.addptr %a_inner, ...
%b_next = tt.addptr %b_inner, ...
scf.yield %a_next, %b_next, %acc_next
}
scf.yield %inner#0, %inner#2
}
%c_ptrs_43 = tt.expand_dims %offs_am_28 {axis = 1 : i32}
%c_ptrs_46 = tt.expand_dims %offs_bn_32 {axis = 0 : i32}
%c_mask_m = arith.cmpi slt, %c_ptrs_43, %c_mask // raw row < M
%c_mask_n = arith.cmpi slt, %c_ptrs_46, %c_mask_22 // raw col < N
tt.store %c_ptrs_49, %outer#1, (broadcast(%c_mask_m) & broadcast(%c_mask_n))TTIR after the rule
%offs_am_29 = arith.cmpi slt, %offs_am_28, %offs_am_9 : tensor<64xi1>
%offs_bn_33 = arith.cmpi slt, %offs_bn_32, %offs_bn_10 : tensor<128xi1>
%a_ptrs_34 = tt.expand_dims %offs_am_28 {axis = 1 : i32} // raw, no remsi
...
%b_ptrs_39 = tt.expand_dims %offs_bn_32 {axis = 0 : i32} // raw, no remsi
...
%a_k_mask = ... // original K mask
%a_m_mask = tt.broadcast (tt.expand_dims %offs_am_29 {axis = 1 : i32})
%a_load_mask = arith.andi %a_k_mask, %a_m_mask
%a = tt.load %a_inner, %a_load_mask, %zero
%b_k_mask = ...
%b_n_mask = tt.broadcast (tt.expand_dims %offs_bn_33 {axis = 0 : i32})
%b_load_mask = arith.andi %b_k_mask, %b_n_mask
%b = tt.load %b_inner, %b_load_mask, %zeroFor the first M tile, the exact lane values make the intended equivalence clear:
r = offs_am_28 = [0, 1, 2, ..., 63]- before:
r % M = r % 2 = [0, 1, 0, 1, ..., 0, 1] - after boundary mask:
r < 2 = [true, true, false, ..., false](2 true lanes, 62 false lanes)
Thus, before the rewrite, an A element at inner-K lane k is addressed as
A_base + (r % 2) * stride_am + k
and after it as
A_base + r * stride_am + k, guarded by (r < 2)
For rows 0 and 1, the addresses and mask remain exactly the same. For lanes 2..63, the old version reads wrapped rows 0/1 but their C stores are already masked out; the new version reads zero and the C stores are still masked out. Therefore this TTIR change is not itself the arithmetic bug.
For N=1024 and BLOCK_N=128, each of the eight N tiles has q * 128 + [0..127] < 1024, so the B boundary mask is all true for this particular shape. It is still syntactically dynamic and therefore matches the rule, but it does not explain the mismatch.
Where the pointer state is actually lost
The graph-on final TTAdapter starts the outer loop with the correct dynamic A offset and row stride, then uses them in the inner reinterpret cast:
%a_offset0 = arith.muli %offs_am, %stride_am : index
%outer:3 = scf.for ... iter_args(
%acc = ...,
%a_offset = %a_offset0,
%a_row_stride = %stride_am) {
%inner:3 = scf.for ... iter_args(
%acc2 = %acc, %inner_a_offset = %a_offset, %b_offset = %c0) {
%a_view = memref.reinterpret_cast %a_ptr
to offset: [%inner_a_offset], sizes: [64, 64],
strides: [%a_row_stride, %c1]
...
%next_a_offset = arith.addi %inner_a_offset, %c64
scf.yield %next_acc, %next_a_offset, %next_b_offset
}
scf.yield %inner#0, %c0, %c1
}The final outer scf.yield should forward the inner A offset and preserve the dynamic row stride, i.e. conceptually scf.yield %inner#0, %inner#1, %a_row_stride. Instead it emits literal 0 and 1.
Each inner loop advances the A offset by 16 * 64 = 1024 bytes. The correct A address for outer iteration i, inner iteration j, row lane r, and K lane c is:
A_base + r * stride_am + (i * 16 + j) * 64 + c
After the first outer iteration, the lowered IR resets the next outer iteration to offset=0, stride=1 instead of carrying offset += 1024 and the original stride_am. Consequently outer iterations 1..3 reuse/interpret the wrong A region, which corrupts all 2 * 1024 = 2048 valid C elements.
This is the loop-state path reached by LoopConverter<scf::ForOp> / BlockDataParser::rewriteLoopOp; the latter's terminator materialization is exactly where constant offset/stride attributes become 0 / 1. The rule must not introduce this unsupported nested carried-pointer shape until that lowering contract is fixed.
Requested P0 fix
Please make collectAddressedLoads track loop-carry depth and reject the second scf.for init-arg → region-iter-arg transition. In pseudocode:
bool collectAddressedLoads(..., unsigned loopCarryHops = 0) {
...
if (auto forOp = dyn_cast<scf::ForOp>(user)) {
if (loopCarryHops >= 1)
return false;
return collectAddressedLoads(forOp.getRegionIterArg(index), ..., loads,
loopCarryHops + 1);
}
if (auto yield = dyn_cast<scf::YieldOp>(user))
return collectAddressedLoads(forOp.getResult(index), ..., loads,
loopCarryHops);
}This is deliberately candidate-local: it retains the A remsi for this unsafe two-level relay, while a one-loop candidate such as B can still be optimized. Please add (1) a positive one-loop rule test, (2) a negative nested-loop A-pointer test that keeps the remainder under mask 256/511, and (3) this M=2, N=1024, K=4096 numerical regression.
✅ DCO Check PassedCommits
|
b065efb to
058f77c
Compare
…in-dev (triton-lang#1267)" (triton-lang#1467)" This reverts commit 678c78b. Signed-off-by: zhanwei33 <22679270+zhanwei33@users.noreply.github.com>
(cherry picked from commit 88f10af) Signed-off-by: zhanwei33 <22679270+zhanwei33@users.noreply.github.com>
058f77c to
62fd222
Compare
Summary
main-dev.merge-concat-load-bufferpass from [TritonToGraph](feat) Add merge-concat-load-buffer pass #1494, theDiscreteMaskAccessConversiondependency from [AtomicOp](fix) Fuse expanded floating-point atomic min/max #1460, and the current pytest backend marker registration.Validation
pre-commithooks: whitespace, EOF, Python AST, merge conflicts, private keys, large files, ruff, yapf, and clang-format.third_party/ascend/patch/, which the repository's pre-commit configuration explicitly excludes because it contains unified-diff context lines.Not run