Skip to content

feat(ir): Require elementwise valid-region agreement - #2159

Open
Hzfengsy wants to merge 3 commits into
hw-native-sys:mainfrom
Hzfengsy:feat/elementwise-valid-region-agreement
Open

feat(ir): Require elementwise valid-region agreement#2159
Hzfengsy wants to merge 3 commits into
hw-native-sys:mainfrom
Hzfengsy:feat/elementwise-valid-region-agreement

Conversation

@Hzfengsy

@Hzfengsy Hzfengsy commented Jul 27, 2026

Copy link
Copy Markdown
Member

Implements brief 12 of the unified valid_shape series (Require elementwise
valid-region agreement
). Follows #2031 (canonicalization), #2037 (proof/bounds),
#2043 (type boundaries), #2048 (window reads) and #2051 (unary/reduction).

Summary

One shared rule now decides the valid region of every elementwise result, for
tensor and tile alike. Previously each deducer copied GetValidShape(arg0) onto
the result under a standing TODO(YunjiQin): assumes both src tiles have the same valid_shape, which silently widened or narrowed the result whenever the operands
disagreed, and made commutative operations operand-order-dependent.

  • InferElementwiseValidShape right-aligns the operands to the result rank
    and classifies each per output dimension — missing leading axis, physical
    singleton broadcasting up, or non-broadcast contributor — then requires every
    non-broadcast contributor to hold a provably equal valid extent.
  • MakeElementwiseTileView pairs that with layout taken from an operand
    whose physical shape equals the output shape, falling back to the layout
    implied by the output shape. A broadcast singleton can no longer dictate the
    result's layout.
  • part_add / part_mul / part_max / part_min instead take the union,
    admitted only when it is representable as an origin-anchored rectangle.
  • Fresh results no longer inherit pad, and tensor scalar-binary ops now
    preserve validity
    — the two items feat(ir): preserve valid regions through unary and reduction ops #2051 deferred to this brief.

A fully valid input still yields a fully valid, view-less result, so no existing
program changes shape.

What the ISA says

Both the CPU model and the a2a3 NPU path assert the sources against the
destination (cpu/TAdd.hpp:70-74, npu/a2a3/TAdd.hpp:65-70):

unsigned validRows = dst.GetValidRow();
unsigned validCols = dst.GetValidCol();
PTO_ASSERT(src0.GetValidRow() == validRows && src0.GetValidCol() == validCols, ...);
PTO_ASSERT(src1.GetValidRow() == validRows && src1.GetValidCol() == validCols, ...);

That assert is what this PR's equality rule encodes. But see the open question
at the bottom
PTO_ASSERT is _DEBUG-only, and the release kernel enforces
the weaker dst.valid <= every src.valid, which changes what the right rule is.

TPartOp.hpp conversely tests each source per cell and zeroes cells in
neither:

bool InSrc0 = i < Src0ValidRow && j < Src0ValidCol;
bool InSrc1 = i < Src1ValidRow && j < Src1ValidCol;
...
} else { dst[DstOffset] = 0; }

That is exactly why the union must be a representable rectangle: claiming
[6, 8] for [4, 8] ∪ [6, 6] would type silently-zeroed cells as valid data.

Observable behavior

add(valid [8,4], valid [8,4])          -> [8,4]        (was [8,4])
add(valid [8,4], valid [8,10])         -> rejected     (was [8,4] — silently narrowed)
add(full,        full)                 -> no valid_shape (canonicalized, unchanged)
add(t[4,8] v[4,6], t[8] v[6])          -> [4,6]        (rank-1 right-aligned)
add(t[4,1] v[4,1], t[1,8] v[1,8])      -> [4,8]        (mutually broadcasting)
row_expand_sub(v [8,4], row [8,1] v [8,1]) -> [8,4]    (singleton exempt on cols)
row_expand_sub(v [8,4], row [8,1] v [8,0]) -> rejected (its one cell is not valid)
part_add(valid [8,4], valid [8,10])    -> [8,10]       (union; order-independent)
part_add(valid [4,10], valid [8,4])    -> rejected     (L-shape, not a rectangle)
cmp(v [8,40], v [8,40])                -> mask valid [8,5]  (ceil(40/8), agreed region)
exp(fillpad(t, zero))                  -> pad = null   (was pad = zero)
tensor.adds(v [8,4], 2.0)              -> [8,4]        (was: validity dropped)

Commutative operations now produce structurally equal result types in either
order: extents are chosen per dimension, preferring a constant representative.

New diagnostics

Condition Error
Non-broadcast operands disagree (proven unequal or undecided) <op>: operands <a> and <b> do not provably agree on the valid extent of dimension <n> (<a>: X, <b>: Y). An elementwise operation reads every operand at each cell and writes one region, so every non-broadcast operand must have a provably equal valid extent. Narrow the wider operand, or fill its padding with a fillpad before combining
Broadcast singleton whose sole cell is not provably valid <op>: operand <label> broadcasts dimension <n> from a physical extent of 1, but its valid extent there is X, which is not provably 1. A broadcast singleton replicates its one cell across the whole output dimension, so that cell must be valid
part_* operands with no provable ordering on some dimension <op>: the operand valid regions (...) have no provably widest extent on dimension <n>, so their union cannot be expressed as a valid_shape...
part_* operands not ordered by containment <op>: the operand valid regions (...) are not ordered by containment, so their union [...] is not an origin-anchored rectangle and would claim cells that are valid in no operand. Narrow one operand so that one region contains the other

Undecided symbolic equality rejects deliberately: no runtime guard is emitted, so
taking an unproved relation on trust would silently widen or narrow the region.

Operand audit

Scratch and predicate operands are excluded from value-validity agreement:

Deducer Value operands Excluded
binary (add/sub/mul/div/max/min/fmod/and/or, part_*) 0, 1
shift (shl/shr) 0, 1 — the shift tile is read per element
ternary (rem/xor/prelu) 0, 1 2 = tmp scratch
tri-tile (addc/subc) 0, 1, 2
tile-scalar-tile rems: 0 · addsc/subsc: 0, 2 rems arg 2 = tmp
sel 1, 2 0 = packed mask, 3 = TSEL scratch
sels 0, 1 2 = scalar mode
cmp / cmps 0, 1 / 0 result is a packed mask
row_expand* / col_expand* 0, 1
tensor binary + part_* 0, 1
tensor scalar-binary 0 scalar

Two consequences worth calling out:

  • DeduceTileOpTileScalarTileType served both tile.rems (arg 2 = scratch)
    and tile.addsc/subsc (arg 2 = real addend). It gained a
    third_is_addend flag; without it a scratch buffer would be held to agreement.
  • MakePackedPredicateTileType now bit-packs the agreed logical region rather
    than reading lhs directly, so mask validity follows the same rule as value
    validity.

Deviations from the brief

  • fractal is derived, not inherited. The brief says "inherit production
    layout/fractal". InheritTileViewLayout has never copied fractal, and adding
    it would change unary/gather/sort too — outside this brief's inherited scope.
    The derive-from-output-shape branch yields the TileView default (512), which
    is correct for these Vec-space ops.
  • Tensor scalar-binary validity is in this PR rather than split out. It is a
    validity-semantics change and is what makes the brief's acceptance criterion
    "tensor and tile operations follow the same validity rule" hold.

A pre-existing bug this surfaces

A partially-valid [M, 1] column-broadcast add now fails to compile.
Measured on both sides of the change:

at = pl.load(a, [0, 0], [128, 128], valid_shapes=[40, 64])
bt = pl.load(b, [0, 0], [128, 1],   valid_shapes=[40, 1])
r  = pl.add(bt, at)
result
before compiles; tile.add result carries pl.TileView(valid_shape=[1, 128]) — 1 row x 128 cols, when the real data is 40 x 64. The store writes the wrong region.
after rejected: tile.add: operands lhs and rhs do not provably agree on the valid extent of dimension 1 (lhs: 128, rhs: 64)

The old output was silently wrong, so the loud failure is strictly better.
Root cause is ResolveBackendOpLayouts' column-vector repair
(src/ir/transforms/resolve_backend_op_layouts_pass.cpp:188-204), which reshapes
[M,1] -> [1,M] and naively transposes valid_shape instead of mapping it onto
the broadcast axis it actually occupies. That is layout-repair territory, not
elementwise deduction — the deducers only ever see the already-reshaped operand.
The row-broadcast [1,N] form and all fully-valid broadcasts are unaffected.

Test-scaffolding change

Two cases in test_convert_tensor_to_tile_ops.py added a narrowed slice to a
fully-valid gm input — programs that would trip PTO_ASSERT on device. The
add there is scaffolding; the subject is tensor.slice -> tile.slice preserving
valid_shape / pad_value. The gm operand is now narrowed to match, and its load
carries that valid shape. No assertion was weakened.

Testing

  • 46 new tests across tests/ut/ir/operators/{test_tile_ops,test_tensor_ops}.py:
    matching partials, static and symbolic disagreement, symbolic agreement,
    singleton full/empty/unknown validity, lower-rank and mutually broadcasting
    shapes, operand order, every tile multi-operand family (binary, shift, ternary,
    tri-tile, tile-scalar-tile, sel, sels, cmp), mask validity, part_* union and
    its rejections, and fresh-result metadata (no alias, stride, offset or pad).
  • Full unit suite: 7814 passed, 2 skipped.
  • tests/st/codegen + tests/st/examples: no new failures against the
    pre-change baseline.
  • pre-commit and the diff-based clang-tidy gate are clean.

User-facing English and Chinese documentation is deferred to the series'
documentation PR, per the series rules.


⚠️ Open question: two device jobs fail, one root cause

dist-system-tests and pypto-lib-model both fail, and both reduce to the same
pattern — a statically-full operand combined with a symbolically-narrowed one:

tests/st/distributed/collectives/test_l3_reduce_scatter.py:116
  acc  = pl.load(scratch, [0, my_rank*SIZE], [1, SIZE])   # valid [1, 64]
  recv = pld.tile.remote_load(...)                        # valid [1, min(max(STAGED - rank*64, 0), 64)]
  pl.add(acc, recv)   -> rejected: 64 vs the clamp is not provable

pypto-lib models/qwen3/14b/prefill_fwd.py:1085
  out_proj_chunk = pl.slice(out_proj_tile, ...)                       # valid [64, ...]
  resid_chunk    = pl.slice(hidden_states, ..., valid_shape=[valid_tok, ...])
  pl.add(out_proj_chunk, resid_chunk)  -> rejected: 64 vs valid_tok is not provable

Both are real, currently-passing programs (Qwen3-14B decode still passes on
device with this branch; only prefill trips).

What the hardware actually does. PTO_ASSERT is compiled out unless _DEBUG
(pto/common/debug.h:33-36), and the release kernel bounds its loops by dst's
extents while reading the sources by row-stride offset
(npu/a2a3/TAdd.hpp:37-51, TADD_IMPL passing dst.GetValidRow()/GetValidCol()).
So the enforced release contract is not equality but

dst.valid <= every src.valid          (else the op reads that source's padding)

with the _DEBUG equality assert acting as a stricter "you should have narrowed
already" check.

That makes all three candidate rules comparable:

rule Qwen prefill sound on release path?
old (dst = lhs.valid) compiles no — reads the residual's padding rows for rows >= valid_tok
this PR (provable equality, unknown rejects) rejected yes
intersection (dst = min(...)) compiles yes — min(64, valid_tok) <= both

Intersection accepts both programs and is sound, which suggests the brief's
"provably equal / unknown rejects" wording may be the wrong rule. Changing it is a
core-semantics decision beyond what this PR should take unilaterally, so I have
left the brief's rule implemented as specified and am flagging it for a call.

Copilot AI review requested due to automatic review settings July 27, 2026 08:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Hzfengsy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1446203f-039a-46f0-be7c-b9f744645c76

📥 Commits

Reviewing files that changed from the base of the PR and between 340d6c5 and 24465df.

📒 Files selected for processing (8)
  • include/pypto/ir/type_inference.h
  • src/ir/op/tensor_ops/elementwise.cpp
  • src/ir/op/tile_ops/broadcast.cpp
  • src/ir/op/tile_ops/elementwise.cpp
  • src/ir/op/type_inference.cpp
  • tests/ut/ir/operators/test_tensor_ops.py
  • tests/ut/ir/operators/test_tile_ops.py
  • tests/ut/ir/transforms/test_convert_tensor_to_tile_ops.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e291e3ebcd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ir/op/type_inference.cpp Outdated
Comment thread src/ir/op/type_inference.cpp Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 09:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Hzfengsy added 3 commits July 28, 2026 15:37
Implements brief 12 of the unified valid_shape series. One shared rule now
decides the valid region of every elementwise result, for tensor and tile
alike. Each deducer previously copied GetValidShape(arg0) onto the result
under a standing TODO, which silently widened or narrowed the result when
the operands disagreed and made commutative ops operand-order-dependent.

InferElementwiseValidShape right-aligns operands to the result rank and
classifies each per output dimension (missing leading axis, physical
singleton broadcasting up, or non-broadcast contributor), then requires
every non-broadcast contributor to hold a provably equal valid extent.
An undecided symbolic relation rejects too: no runtime guard is emitted.

This is the hardware contract, not a policy choice. TAdd_Impl asserts
src0/src1 valid extents against dst's, so when the operands disagree no
choice of result region satisfies the assert -- rejecting is the only
sound answer. TPartInstr conversely tests each source per cell and zeroes
cells in neither, which is why part_add/part_mul/part_max/part_min take
the union and admit it only when it is an origin-anchored rectangle.

MakeElementwiseTileView takes layout from an operand owning the full
output shape, falling back to the layout implied by that shape, so a
broadcast singleton can no longer dictate the result's layout.

Also lands the two items deferred by the unary/reduction PR:
- Fresh results no longer inherit pad. A computed result is written only
  across its valid region, so the source's promise about the cells outside
  it does not survive; exp of a zero-padded tile is not zero-padded. This
  changes all five InheritTileViewLayout families atomically (elementwise,
  unary, broadcast, gather, sort) -- doing it per-family makes the DSL fail
  to type-check across reassignments -- and it is codegen-visible, since
  TileView::pad flows into the emitted tile-buf type. The fillpad family
  still establishes its own pad immediately after the call.
- Tensor scalar-binary ops (adds/subs/muls/divs/fmods) preserve validity,
  matching their tile equivalents.

Scratch and predicate operands are excluded from value-validity agreement.
DeduceTileOpTileScalarTileType gains third_is_addend because it serves both
tile.rems (scratch tmp) and tile.addsc/subsc (real addend), and
MakePackedPredicateTileType now bit-packs the agreed logical region rather
than reading lhs directly.

A fully valid input still yields a fully valid, view-less result, so no
existing program changes shape.

Adds 48 tests covering matching partials, static and symbolic disagreement,
singleton full/empty/unknown validity, lower-rank and mutually broadcasting
shapes, operand order, every tile multi-operand family, mask validity, the
part_* union and its rejections, layout derivation, and fresh-result
metadata.
Addresses two review findings on the elementwise valid-region rule.

ClassifyAxis matched a literal ConstInt(1) result extent to decide that a
unit operand axis was not widening. BroadcastShapes keeps the *first*
operand's expression for provably equal dims, so a unit result axis can
arrive in symbolic form (n - n + 1 against a literal 1). The literal
operand was then classified as a broadcast singleton and its valid extent
required to be 1 -- rejecting an empty axis that widens nothing, and only
in one operand order. Prove the result extent instead of pattern-matching
a literal.

PrefersAsRepresentative preferred a ConstInt, which leaves the first-seen
expression in place when the analyzer proves two *non-constant* extents
equal (n against n + 0). TileView/TensorView equality and hashing compare
structurally, so a commutative operation could still produce unequal types
and trip a reassignment or join check downstream. Break that tie on a
stable printed key.

Both cases are pinned by tests that fail without the corresponding fix.
Rebasing onto the PTOAS op batch (hw-native-sys#2157) put two overlapping checks on the
same programs. `ConvertTensorToTileOps` gained a divisor-cover check for
`tensor.div` row broadcasting and a same-valid_shape check for plain tdiv;
the elementwise valid-region rule already rejects those operand pairs while
the program is still being built, so the pass never sees them and the
`pytest.raises` around the pass no longer fires.

Move the expectation to where the rejection now happens. Each test still
asserts that exactly the same operand pair is refused, and the positive
shared-symbol case still lowers through the pass unchanged. The pass-level
checks stay in place for IR that reaches them without going through
operator type deduction.
@Hzfengsy
Hzfengsy force-pushed the feat/elementwise-valid-region-agreement branch from 33814e9 to 24465df Compare July 28, 2026 07:58
Copilot AI review requested due to automatic review settings July 28, 2026 07:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants