The tail CSE in _make_ktir merges an author-written store/load pair into one access tile
What happens
_make_ktir ends with add_canonicalizer + add_cse. On a kernel where the
author writes the HBM round-trip themselves — an intermediate declared as a
pointer argument with its own tl.make_tensor_descriptor, its own
tl.spyre_tensor_layout, an explicit store and an explicit load — that CSE
merges the store's and the load's ktdp.construct_access_tile into one
value, and the two tensor.empty ops into one. dbo-opt's
ComputeGroupExtraction then aborts.
The smallest case is out = sqrt(exp(x)) with exp(x) round-tripped through a
scratch pointer: two computes, one round-trip.
Before CSE (canonicalized _make_ktir output, abridged)
Two access tiles against the scratch view %2 — %16 for the store, %21 for
the load — and two tensor.empty, one per compute:
%4 = arith.muli %0, %c128 : index
%5 = arith.divsi %4, %c32 : index
%6 = arith.muli %0, %c128 : index
%7 = arith.remsi %6, %c32 : index
%8 = ktdp.construct_access_tile %1[%5, %7] {...} : memref<4x32xf32> -> !ktdp.access_tile<4x32xindex>
%9 = ktdp.load %8 : <4x32xindex> -> tensor<4x32xf32>
%10 = tensor.empty() : tensor<4x32xf32>
%11 = linalg.generic ... ins(%9 ...) outs(%10 ...) { math.exp } -> tensor<4x32xf32>
%12 = arith.muli %0, %c128 : index
%13 = arith.divsi %12, %c32 : index
%14 = arith.muli %0, %c128 : index
%15 = arith.remsi %14, %c32 : index
%16 = ktdp.construct_access_tile %2[%13, %15] {...} : memref<4x32xf32> -> !ktdp.access_tile<4x32xindex>
ktdp.store %11, %16 : tensor<4x32xf32>, <4x32xindex> // store side
%17 = arith.muli %0, %c128 : index
%18 = arith.divsi %17, %c32 : index
%19 = arith.muli %0, %c128 : index
%20 = arith.remsi %19, %c32 : index
%21 = ktdp.construct_access_tile %2[%18, %20] {...} : memref<4x32xf32> -> !ktdp.access_tile<4x32xindex>
%22 = ktdp.load %21 : <4x32xindex> -> tensor<4x32xf32> // load side
%23 = tensor.empty() : tensor<4x32xf32>
%24 = linalg.generic ... ins(%22 ...) outs(%23 ...) { math.sqrt } -> tensor<4x32xf32>
%29 = ktdp.construct_access_tile %3[%26, %28] {...}
ktdp.store %24, %29 : tensor<4x32xf32>, <4x32xindex>
After CSE
One access tile %11 on both sides of the fence — the store writes it and the
ktdp.load reads it — and one tensor.empty %9 as the outs of both
computes:
%4 = arith.muli %0, %c128 : index
%5 = arith.divsi %4, %c32 : index
%6 = arith.remsi %4, %c32 : index
%7 = ktdp.construct_access_tile %1[%5, %6] {...}
%8 = ktdp.load %7 : <4x32xindex> -> tensor<4x32xf32>
%9 = tensor.empty() : tensor<4x32xf32>
%10 = linalg.generic ... ins(%8 ...) outs(%9 ...) { math.exp } -> tensor<4x32xf32>
%11 = ktdp.construct_access_tile %2[%5, %6] {...}
ktdp.store %10, %11 : tensor<4x32xf32>, <4x32xindex> // store side
%12 = ktdp.load %11 : <4x32xindex> -> tensor<4x32xf32> // load side, SAME tile
%13 = linalg.generic ... ins(%12 ...) outs(%9 ...) { math.sqrt } -> tensor<4x32xf32>
%14 = ktdp.construct_access_tile %3[%5, %6] {...}
ktdp.store %13, %14 : tensor<4x32xf32>, <4x32xindex>
The merge order matters
The two construct_access_tile ops are not structurally equal in the input:
%16 takes [%13, %15] and %21 takes [%18, %20], four distinct SSA values.
What makes them equal is that CSE merges the index arithmetic first — the
duplicated muli / divsi / remsi triples collapse onto %4 / %5 / %6
within the same walk — and only then are both tiles %2[%5, %6] and mergeable.
This happens with --cse alone; the preceding canonicalizer is not needed.
The two assertions
Both are in
dataflow-scheduler/lib/Conversion/frontend/KTIRToScheduleIR/ComputeGroupExtraction.cpp,
and they are distinct failures from distinct merges. Measured by feeding dbo-opt
three versions of the same module:
Both merges (i.e. plain --cse) — the shared access tile transitively
collapses every access tile into one equivalence class, so the walk reaches a
store with no load-established class leader:
dbo-opt: .../ComputeGroupExtraction.cpp:570: void ComputeGroupExtractionPass::processBlockForExtraction(mlir::Block*, llvm::EquivalenceClasses<mlir::Value>&): Assertion `current_class_leader && "StoreOp found before any LoadOp"' failed.
Only the two tensor.empty merged, access tiles left alone — a separate
break, so fixing the tile alone would not be enough:
dbo-opt: .../ComputeGroupExtraction.cpp:472: void ComputeGroupExtractionPass::extractComputeGroup(mlir::ModuleOp, mlir::ModuleOp, const ComputeGroup&): Assertion `op->use_empty() && "Operation should have no uses left"' failed.
Neither merged — dbo-opt exits 0 and emits SpyreCode.
Why this cannot be fixed by exempting the ops
Both merged op kinds are Pure, so CSE is within its rights:
ktdp.construct_access_tile is declared
Ktdp_Op<"construct_access_tile", [AttrSizedOperandSegments, Pure]>
(ktir-mlir-frontend, include/ktir/Dialect/KTDP/KTDP.td).
tensor.empty is Pure upstream.
Upstream's CSE offers no trait, attribute or interface hook to exempt an
operation — it tests isMemoryEffectFree and OperationEquivalence, with no
opt-out — and --cse takes no options. So there is no way to keep the pass and
tell it that these ops are owned per compute group.
The structural point
This is the reason the issue exists, rather than the individual assertions.
HbmRoundtrip (#158) does the same thing an author does — it puts an
intermediate through HBM — and escapes this problem only by position: it runs
after the CSE and re-materializes its own per-group access tiles and
tensor.empty ops, so nothing merges them afterwards. Position is not available
to an author. Their ops exist before CSE by construction, because they came in
from the Triton source.
So per-group ownership of an access tile and of a compute's outs is a property
the scheduler requires and the pipeline does not maintain — it only happens to
hold for the passes that run late enough. Any pass, or any author, producing
these ops earlier hits this.
Measured cost of removing the CSE
On fabianlim/declared-buffers-doc plus six author-written declared-buffer
fixtures (elementwise__1d_device_dag_buffers and the five pooled variants), on
hardware:
|
result |
| tail CSE present |
6 failed, 191 passed, 2 xfailed (182 s) |
| tail CSE removed |
197 passed, 2 xfailed (190 s) |
No test that passes with the CSE fails without it. The only outcome difference
is the six new device launches, which flip from the StoreOp found before any LoadOp abort to passing and matching the NumPy oracle.
The cost is IR size — the redundant index arithmetic the CSE used to fold now
reaches dbo-opt, which accepts it:
| kernel |
ops with CSE |
ops without |
elementwise__1d_device[fp32, add] |
21 |
30 |
softmax[M=1024] |
47 |
58 |
elementwise__2d[M=512] |
30 |
34 |
Wall-clock on the suite moved 182 s → 190 s, which is within run-to-run noise
for a device suite.
Note on a second CSE
_make_spyrecode also runs add_canonicalizer + add_cse, inside the
materialize_base_addresses branch. That branch is skipped in symbolic-argument
mode, which is the default (BUNDLE_SYMBOLIC_ARGS defaults to "1"), so it does
not fire on the path the tests take — but an address-bound compile of the same
kernel would hit the identical problem. Any resolution has to cover both sites.
Options, not a recommendation
Removing the tail CSE is what the accompanying commit does, and it is not
obviously wrong: nothing in the suite depends on it, and the canonicalizer
already handles the folding that motivated it (muli x, 1, cast chains). But it
is also not obviously right — it gives up a cheap cleanup for every kernel to
accommodate one op's ownership rule.
Recorded so the alternatives are visible rather than lost:
- Leave the CSE out (current state). Cheapest; costs IR size everywhere.
- Make the KTDP ops that must be owned per compute group not
Pure, or give
them a side effect on their memory view. Correct in principle; changes the
dialect contract, and affects every consumer of KTIR.
- Re-materialize per-group access tiles and
tensor.empty in the spyrecode
stage, whatever their provenance — i.e. do for author-written round-trips what
HbmRoundtrip does for inferred ones, so ownership stops depending on which
pass produced the op.
- Have the scheduler tolerate a shared access tile / shared
outs rather than
assert.
The purpose of this issue is to record the problem and its options, not to argue
for one.
The tail CSE in
_make_ktirmerges an author-written store/load pair into one access tileWhat happens
_make_ktirends withadd_canonicalizer+add_cse. On a kernel where theauthor writes the HBM round-trip themselves — an intermediate declared as a
pointer argument with its own
tl.make_tensor_descriptor, its owntl.spyre_tensor_layout, an explicitstoreand an explicitload— that CSEmerges the store's and the load's
ktdp.construct_access_tileinto onevalue, and the two
tensor.emptyops into one. dbo-opt'sComputeGroupExtractionthen aborts.The smallest case is
out = sqrt(exp(x))withexp(x)round-tripped through ascratch pointer: two computes, one round-trip.
Before CSE (canonicalized
_make_ktiroutput, abridged)Two access tiles against the scratch view
%2—%16for the store,%21forthe load — and two
tensor.empty, one per compute:After CSE
One access tile
%11on both sides of the fence — the store writes it and thektdp.loadreads it — and onetensor.empty%9as theoutsof bothcomputes:
The merge order matters
The two
construct_access_tileops are not structurally equal in the input:%16takes[%13, %15]and%21takes[%18, %20], four distinct SSA values.What makes them equal is that CSE merges the index arithmetic first — the
duplicated
muli/divsi/remsitriples collapse onto%4/%5/%6within the same walk — and only then are both tiles
%2[%5, %6]and mergeable.This happens with
--csealone; the preceding canonicalizer is not needed.The two assertions
Both are in
dataflow-scheduler/lib/Conversion/frontend/KTIRToScheduleIR/ComputeGroupExtraction.cpp,and they are distinct failures from distinct merges. Measured by feeding dbo-opt
three versions of the same module:
Both merges (i.e. plain
--cse) — the shared access tile transitivelycollapses every access tile into one equivalence class, so the walk reaches a
store with no load-established class leader:
Only the two
tensor.emptymerged, access tiles left alone — a separatebreak, so fixing the tile alone would not be enough:
Neither merged — dbo-opt exits 0 and emits SpyreCode.
Why this cannot be fixed by exempting the ops
Both merged op kinds are
Pure, so CSE is within its rights:ktdp.construct_access_tileis declaredKtdp_Op<"construct_access_tile", [AttrSizedOperandSegments, Pure]>(
ktir-mlir-frontend,include/ktir/Dialect/KTDP/KTDP.td).tensor.emptyisPureupstream.Upstream's CSE offers no trait, attribute or interface hook to exempt an
operation — it tests
isMemoryEffectFreeandOperationEquivalence, with noopt-out — and
--csetakes no options. So there is no way to keep the pass andtell it that these ops are owned per compute group.
The structural point
This is the reason the issue exists, rather than the individual assertions.
HbmRoundtrip(#158) does the same thing an author does — it puts anintermediate through HBM — and escapes this problem only by position: it runs
after the CSE and re-materializes its own per-group access tiles and
tensor.emptyops, so nothing merges them afterwards. Position is not availableto an author. Their ops exist before CSE by construction, because they came in
from the Triton source.
So per-group ownership of an access tile and of a compute's
outsis a propertythe scheduler requires and the pipeline does not maintain — it only happens to
hold for the passes that run late enough. Any pass, or any author, producing
these ops earlier hits this.
Measured cost of removing the CSE
On
fabianlim/declared-buffers-docplus six author-written declared-bufferfixtures (
elementwise__1d_device_dag_buffersand the five pooled variants), onhardware:
No test that passes with the CSE fails without it. The only outcome difference
is the six new device launches, which flip from the
StoreOp found before any LoadOpabort to passing and matching the NumPy oracle.The cost is IR size — the redundant index arithmetic the CSE used to fold now
reaches dbo-opt, which accepts it:
elementwise__1d_device[fp32, add]softmax[M=1024]elementwise__2d[M=512]Wall-clock on the suite moved 182 s → 190 s, which is within run-to-run noise
for a device suite.
Note on a second CSE
_make_spyrecodealso runsadd_canonicalizer+add_cse, inside thematerialize_base_addressesbranch. That branch is skipped in symbolic-argumentmode, which is the default (
BUNDLE_SYMBOLIC_ARGSdefaults to"1"), so it doesnot fire on the path the tests take — but an address-bound compile of the same
kernel would hit the identical problem. Any resolution has to cover both sites.
Options, not a recommendation
Removing the tail CSE is what the accompanying commit does, and it is not
obviously wrong: nothing in the suite depends on it, and the canonicalizer
already handles the folding that motivated it (
muli x, 1, cast chains). But itis also not obviously right — it gives up a cheap cleanup for every kernel to
accommodate one op's ownership rule.
Recorded so the alternatives are visible rather than lost:
Pure, or givethem a side effect on their memory view. Correct in principle; changes the
dialect contract, and affects every consumer of KTIR.
tensor.emptyin the spyrecodestage, whatever their provenance — i.e. do for author-written round-trips what
HbmRoundtripdoes for inferred ones, so ownership stops depending on whichpass produced the op.
outsrather thanassert.
The purpose of this issue is to record the problem and its options, not to argue
for one.