Physicalize linalg.broadcast in RewriteDescriptorLayout - #142
Conversation
RewriteDescriptorLayout leaves linalg.broadcast logical, so a reduce feeding a broadcast feeding arithmetic ends with operands of disagreeing rank (issue torch-spyre#91). These three lit files state what the pass owes, ahead of the code to satisfy them: the softmax shape, where the split axis is also the reduced one and the broadcast must re-add both physical dims; the same broadcast with no reduce in front of it, which must be left alone; the cancelling expand/collapse pair the layout has to survive; and a splat divisor that must reach the physical shape. They fail today on arith.subf's same-type verifier at the logical broadcast, so the CHECK lines describe the intended output rather than the current one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
…hape ElementwiseRequirement::match declined when an op's tensor operands disagreed on a shape. Between Phase 1 and the end of Phase 2B the IR is half-retyped by design, so a mid-chain arith.addf legitimately has one physical operand and one logical sibling -- the state the pass exists to resolve, not evidence the op is unknown. The comparison also gated nothing, since induce is `return req` regardless of shape. Its only effect was to stop the backward walk one op short of whatever came next. Shape-changing ops are excluded by kind instead, via a shared isShapeChangingOp in Types.h. Naming the kind is what the predicate actually needs: a shape comparison is vacuous on a single-operand op, and tensor.concat -- which LowerComputeOps emits from tt.join -- has operands of equal shape, so neither is caught by comparing operands to each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
The rule was a placeholder returning failure(), so a backward walk stopped at a linalg.broadcast. Implement it as a projection: the requirement cannot cross unchanged, since the result has dims the operand does not, but the physical dims whose phys_src names an axis the operand carries do survive -- keep those, drop the ones naming an axis the broadcast adds, and renumber what is left to the operand's rank. The init takes a different answer from the input. It carries the result's shape, so it gets the requirement whole, as TransposeRequirement already does for its own init. A carried axis split across two physical dims declines: projecting would demand an operand of higher rank than linalg.broadcast gives it. This is what lets a requirement reach the reduce behind a broadcast, which is the shape softmax and layernorm are written in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
Both were placeholders returning failure(), so a size-1 reshape stopped the layout in either direction. LowerComputeOps lowers tt.expand_dims and tt.broadcast independently, which leaves a cancelling expand/collapse pair between a reduce and its broadcast -- exactly where the layout has to pass. ReshapeRequirement::induce crosses a reshape that only adds or drops size-1 dims, and keeps declining when a reassociation regroups a real axis, since a per-physical-dim requirement has no dim to map a fused axis to. The two directions are not symmetric: reassociation groups index the higher-rank side, the result for expand_shape and the operand for collapse_shape, so expand drops entries where collapse must grow them. The safety condition is on the marker rather than on extents, because a stick index can itself have extent 1 while still carrying coordinate meaning. ReshapePropagation::propagate carries a layout forward when the incoming value was not itself split, which is what its own type answers -- not the marker, since a reduce result carries the store's split marker even when its own output is unsplit. The two land together. Opening the backward walk without the forward rule lets Phase 2B make a value physical that Phase 2A never predicted, which trips verifyPhysicalTypeAgreement on a real softmax input; no fixture in the suite reproduces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
The rule was a placeholder returning failure(). So when a broadcast's input went physical, its result kept the logical shape its outs operand was built with, and the consuming subtract saw two different types. Implement it by pushing the result's logical extents through the marker's coordinate map. An axis the broadcast adds may split freely: it is new in the output, so splitting it changes only outs and dimensions, never the input. Four guards, each protecting against something no verifier would catch: - src must be the broadcast's input. Operands are (input, init), and the helper that picks one returns the first that resolves, so an init can arrive instead and supply the marker from the wrong side of the op. - canRebuildPhysicalInit must hold, as ReducePropagation already requires, or the analysis promises a physical result the rewrite cannot build. - phys_src must name every logical dim. max(phys_src)+1 is a maximum, not a count, so a gap in it yields a type of the wrong rank. - the carried axes must stay in ascending order. The rewrite renumbers by scanning physical dims in order, and a permuting marker -- legal today -- would transpose the data while still passing every verifier. RewriteElementwisePattern gains the same kind exclusion here rather than in its own commit. It has matched any op since torch-spyre#125, and its shape test is vacuous on a single-operand op. Making the reduce physical is what first makes that reachable, so on its own this rule retypes the reshape between reduce and broadcast into a no-op. No fixture catches it; softmax fails on a broken expand_shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
…hape Same fix as the backward rule, on the forward side. ElementwisePropagation::match declined when an op's tensor operands disagreed on a shape. Mid-analysis they routinely do: Phase 1 physicalizes loads and stops, so an op with one load-fed operand and one not is guaranteed to see a mismatch. That is the state the pass exists to resolve, not evidence the op is unknown, and propagate never reads a sibling's shape, so the comparison gated nothing. Its effect was to leave the subtract that consumes a broadcast unclaimed, so the analysis never predicted a type for it. Shape-changing ops are excluded by kind instead, via the shared isShapeChangingOp. That also covers what the shape test could not: it is satisfied vacuously by a single-operand op, so a reshape or a broadcast passed it as though it preserved shape. Nothing observable changes yet -- emitted IR is byte-identical across the reduce, elementwise-chain and reduce-batch-dim fixtures. This lands first because the broadcast rewrite records into ctx.physicalValues, and verifyPhysicalTypeAgreement requires the analysis to have predicted anything it records. Without this the rewrite aborts on softmax. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
Softmax compiles with this: %reduced = linalg.reduce ins(%3) outs(tensor<64xf32>) dimensions = [0, 2] %broadcasted = linalg.broadcast ins(%reduced) outs(tensor<2x64x64xf32>) dimensions = [0, 2] %7 = arith.subf %3, %broadcasted : tensor<2x64x64xf32> A broadcast keeps its target shape in two places, so both move. `dimensions` is renumbered to physical output positions. The `outs` init is rebuilt at the physical shape, reusing rebuildPhysicalInit. The renumbering maps a logical axis to every physical dim sourced from it, and those dims need not be adjacent. Under the stick-outermost marker most fixtures here use, phys_src=[1,0,1], the split axis owns dims 0 and 2 with the untouched row between them. A rule that assumed a contiguous pair, or read physical order off logical order, would name [1, 2] and reduce the wrong axes. Both orders are now covered by -broadcast.mlir. Whether to fire is Phase 2A's call, not an operand's. A broadcast's input is usually still logical, so gating on a physical operand would never fire. Users are re-enqueued because the greedy driver does not do it for them, and a user's match reads the result type this changes. Layernorm still fails, now on arith.divf -- the splat divisor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
Layernorm compiles with this. Its divisor is the reduction length, which reaches the pass as `arith.constant dense<1.28e2>` -- a splat, one value repeated over the whole tensor. Both of its broadcasts already physicalized, but the divisor stayed logical and arith.divf saw two types. No propagation rule can reach a constant. The forward analysis walks along operands, asking each user what type its result takes, and a constant has no operands. So this is a seed instead: when an elementwise op has one physical operand and one splat-constant operand, the constant is rebuilt at the physical shape. Sound because every element is identical -- there is no data to move and no coordinate map to rewrite. Only a splat qualifies. A general dense<[...]> constant has per-element data whose stick-tiled placement is a real question, and guessing it would pass every verifier while computing wrong numbers. Only a compile-time splat is covered. A splat of a runtime scalar stays a linalg.fill, which this does not match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kcirred <16872435+kcirred@users.noreply.github.com>
|
|
||
| // ----- | ||
|
|
||
| // One splat feeding TWO consumers: an annotated path that goes physical and an |
There was a problem hiding this comment.
these tests are confusing tro me because I dont actually see the splat in the below mlir
| // added logical axis that splits contributes every physical dim sourced | ||
| // from it, which is what keeps linalg.broadcast's rank arithmetic | ||
| // (input_rank + |dimensions| == init_rank) true by construction. | ||
| llvm::SmallDenseSet<int64_t> addedLogical(bc.getDimensions().begin(), |
There was a problem hiding this comment.
this kind of logic is repeated many times, and in general these functions are quite long, so I wonder if we should make some shared helpers.
fabianlim
left a comment
There was a problem hiding this comment.
The primary structural finding
2A resolves a result from one physical operand. 2B requires all operands
to agree. Those are different notions of "resolved", and most of the
problems below live in the gap between them.
Concretely, on the layernorm divide:
%x = ktdp.load %tile : <64x2x64xindex> -> tensor<64x2x64xf32> // physical (Phase 1)
%cst = arith.constant dense<1.28e2> : tensor<64x128xf32> // logical, zero operands
%y = arith.divf %x, %cst : tensor<64x128xf32>What 2A does. getPhysicalizedType calls findPhysicalTensorOperand, which
returns the first tensor operand already in roots/resolved — here %x —
and stops looking. ElementwisePropagation::propagate then takes the result
shape from that one operand:
info.type = RankedTensorType::get(srcTy.getShape(), resTy.getElementType());No sibling is consulted, and after this PR ElementwisePropagation::match no
longer compares operand shapes either. So 2A records: %y is physical,
tensor<64x2x64xf32>.
What 2B does. RewriteElementwisePattern walks every tensor operand and
returns failure() the moment one disagrees. %cst is still 64x128, so 2B
declines the very op 2A just promised.
Why that is silent. verifyPhysicalTypeAgreement is one-directional:
2B's physicalValues ⊆ 2A's PhysicalTypeMap. It catches 2A under-claiming,
which the design doc explains at length ("2B reads absence from the map as
genuinely logical and commits on that basis"). 2A over-claiming is precisely
the direction it does not check. So the gap has no alarm on it.
Which reframes seedSplatOperands. It is not a missing propagation rule and
not a phase-placement slip. It exists to retroactively make 2A's over-claim
true — mutating IR until 2B's all-operands condition holds. That is why it must
run before the agreement check, and why its failure mode is mutate-then-decline
(finding 2): a repair aimed at a condition the pattern has not yet evaluated
cannot know whether the repair was warranted.
Two further consequences of the same gap:
- The PR removes the operand-shape-agreement check from
ElementwisePropagation(2A) and keeps it inRewriteElementwisePattern
(2B). Both changes are individually justified in the diff. Together they widen
the gap and write it into the code as intended behavior. - The codebase already has the right concept and does not apply it here:
reconcileOperandSet— "the trip count of a stick loop is a property of the
operand set, not of one operand, and that agreement is one question, answered
once per op." Source ops get that. Elementwise ops do not.
The fix is to make one of the two notions authoritative. Since 2B's is the one
that must hold for the IR to verify, 2A should adopt it: resolve the operand
set, reconcile it, and only then record a result type — step 3 below.
Two catch-alls, and the permissive one shadows the strict one
getPhysicalizedType already has a strict catch-all, and its intent is
exactly right:
const PhysicalPropagationPattern *pattern = lookupPattern(defOp, patterns);
if (!pattern) {
// No pattern is this op's rule. An untaught op stays visible here rather
// than acquiring a default: if an op can appear on a physicalized chain it
// needs a pattern, and guessing one would silently compute the wrong slice.
LLVM_DEBUG(...);
return failure();
}Two things make it dead code:
ElementwisePropagationis registered last and matches almost anything,
so ops never reach that branch.populatePhysicalPropagationPatterns'
comment documents the ordering hazard in the other direction ("every
named-op pattern must be asked before the structural elementwise rule") — the
fragility was seen, but treated as an ordering constraint to maintain rather
than as evidence that a residual rule should not exist.- "Stays visible" means
LLVM_DEBUG— not a diagnostic, not a counter, not
an error. In 2B it is worse:RewriteElementwisePatternis
MatchAnyOpTypeTag()at benefit 1 with no error branch at all, and the
design doc concedes "if every pattern declines, the worklist drains and the
driver calls that a fixpoint."
So the escape hatch that should catch an untaught op exists and is unreachable.
The catch-all should be the thing that errors. Elementwise should be a named
rule like any other.
Consequence: a rule for arith.constant is reachable, and no seeding is needed
getPhysicalizedType already recurses on operands:
Value src = findPhysicalTensorOperand(defOp, roots, resolved);
if (!src) {
// No operand is known physical yet; try to resolve one transitively.
for (Value o : defOp->getOperands()) { ... getPhysicalizedType(o, ...) ... }
}So getPhysicalizedType(%cst) can be called and would reach
lookupPattern(arith.constant). Registering a rule is enough — no new
machinery, and in particular no seeding, forward or backward.
But note the gate: the recursion runs only when no operand is already known
physical. In layernorm the load-fed operand resolves immediately, src is found,
and the loop never executes, so %cst is never queried. The constant is
reachable only in the case where nothing else is, which is backwards.
Ungate that, and the splat's shape comes from the operand set — it never needed
to come from the store's requirement.
Proposed order of work
- Demote elementwise from catch-all. Trait-based allowlist —
OpTrait::Elementwise/SameOperandsAndResultShape, plus
linalg::isElementwiseforlinalg.generic.isShapeChangingOp's
hand-maintained denylist deletes; finding 5 is fixed;arith.constantstops
being incidentally excluded bysawTensorOperandand becomes visibly
unhandled. Same inversion in 2B, and dropMatchAnyOpTypeTag. - Make the strict catch-all speak. Promote the null-pattern branch from
LLVM_DEBUGto a real diagnostic naming the op; give 2B the branch it does
not have. - 2A resolves the operand set, not one operand. Ungate the transitive
recursion and add the reconciliationreconcileOperandSetalready models.
This is what makes 2A's and 2B's "resolved" the same predicate. - Then the splat is a one-line rule.
SplatConstantPropagation: "I am
layout-invariant; I take whatever the operand set agreed on." Recorded in 2A,
so 2B retypes off the record.seedSplatOperandsdeletes, finding 2
evaporates,verifyPhysicalTypeAgreementkeeps full strength.
Land 1–2 as a measurement first. Count unhandled ops across the 149 registry
variants; do not fail yet. That is the pattern #132 used — the backward analysis
landed as a measurement that agreed with the forward walk, and was only made
authoritative afterwards. It de-risks the real hazard: today's silent
fallthrough-to-logical may be load-bearing for kernels that currently pass.
Promote to a hard error once the count is zero.
The reshape rules are working around IR that should not exist
ReshapeRequirement / ReshapePropagation, ~150 lines across two analyses,
exist to see through an expand_shape 1→1x1 immediately followed by
collapse_shape 1x1→1. That pair is a no-op. It exists only because
LowerComputeOps lowers tt.expand_dims and tt.broadcast independently
(rules A3 and A4) — the PR's own comments say exactly this.
Evidence that the rule is fitted rather than derived: the two directions test
different conditions under the same name.
- Backward: "no reassociation group of size > 1 contains a floordiv/mod dim" — a
real statement about reassociation. - Forward:
srcTy == operandTy, i.e. "this value is not actually split, so
nothing can go wrong." Not a reshape rule at all; a "nothing to do here"
guard that holds only because a reduce result in softmax is rank-1 and unsplit.
Consequence: the forward rule cannot cross the stick-split reduce result that
#131 just added support for (the [2, 64] Physical output-axis-space case).
The two PRs' capabilities do not compose.
The fix is upstream, not here — see the next section.
Should a cleanup prepass run before this pass?
Yes for targeted reshape folding. No for general CSE — and the
distinction is load-bearing, not fastidiousness.
The ordering today. In _make_ktir, canonicalize and cse are added
after the entire core pipeline (_CORE_PIPELINE_PASSES, then
distribute_work). So LowerComputeOps → RewriteDescriptorLayout runs with no
cleanup between them, and the no-op expand/collapse pair is alive when the pass
sees it.
Targeted folding — do it. MLIR already canonicalizes collapse(expand(x))
for compatible reassociations, so a fold anchored after lower_compute_ops would
very likely delete the pair for free, retiring most of the reshape rules above.
The pipeline even has the insertion mechanism — options.required_fixes anchors
a pass after a named core pass — though it currently names KTDP passes, so a
common-pass or a small dedicated KTDP cleanup would need wiring.
General CSE — do not. It would change decisions this pass makes, in ways the
design doc explicitly relies on not happening:
Wider point. The no-op reshape pair and the shaped splat constant are two
instances of one thing: an earlier pass committed to a shape it did not need to,
and this pass pays to un-commit it. Folding handles one; steps 1–4 handle the
other. A third instance would mean the fix belongs in LowerComputeOps.
Summary
RewriteDescriptorLayout could not physicalize a
linalg.broadcast, andthat single gap stopped every softmax- and layernorm-shaped kernel from
carrying a
tt.spyre_tensor_layoutannotation. Both failed with asame-type error on the
arith.subfthat consumes the broadcast --pointing at the user's subtract rather than at anything about layouts.
Both now compile. Addresses #91.
Four propagation rules were placeholders returning
failure(), soneither half of Phase 2A could cross a broadcast or the size-1 reshape
that
LowerComputeOpsleaves in front of it. They are implemented here:BroadcastRequirement::induceprojects a requirement onto the axes thebroadcast carries, dropping the ones it adds. This is what lets a
requirement reach the reduce behind a broadcast, which a reduce needs
before it will take the physical output space.
ReshapeRequirement::induceandReshapePropagation::propagatecross areshape that only adds or drops size-1 axes, and keep declining when a
reassociation regroups a real axis. The narrow case is exactly what
tt.expand_dimsfollowed bytt.broadcastproduces.BroadcastPropagation::propagaterecomputes the target shape againstthe physical rank, and
RewriteBroadcastPatternrenumbersdimensionsand rebuilds the
outsinit to match.Three
matchpredicates were declining whenever an op's tensor operandsdisagreed on a shape. Mid-pass they routinely do: Phase 1 physicalizes
loads and stops, so an op with one load-fed operand and one not is
guaranteed to see a mismatch -- the state the pass exists to resolve, not
evidence the op is unknown. The test was also vacuous on a single-operand
op, so a reshape or a broadcast passed it as though it preserved shape.
All three now exclude shape-changing ops by kind, through one shared
isShapeChangingOp.Layernorm needed one more piece. Its divisor is the reduction length,
which arrives as a splat constant with no operands, so the operand-driven
forward walk cannot reach it and no propagation rule can claim it.
seedSplatOperandsrebuilds such a constant at the physical shape fromthe consumer side. Only a splat qualifies: a general
dense<[...]>constant has per-element data whose stick-tiled placement is a real
question, and guessing it would satisfy every verifier while computing
wrong numbers.
Known limitations, each pinned by a test that fails when it is fixed:
than physicalizing. The input would need retyping too, which a rule
returning only a result type cannot express; repairing it at the
consuming op is separate work.
tensor.reshapein the chain still blocks the layout. It carries itstarget shape as a runtime operand, so there is no reassociation to
project through, even though the result type here is static.
linalg.fill, whichseedSplatOperandsdoes not match.Scope
Testing
Commands and results:
Checklist
third_party/spyre, I explained why in the summary.