Skip to content

Aligned splits, take 2. - #9409

Open
mcourteaux wants to merge 48 commits into
mainfrom
mcourteaux/aligned-split-clean
Open

mcourteaux wants to merge 48 commits into
mainfrom
mcourteaux/aligned-split-clean

Conversation

@mcourteaux

@mcourteaux mcourteaux commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Inner loops go from 0 to factor, to help with constant bounds analysis.
2D tiled test: compute_at test overwrites the compute and storage bounds by just passing those in the schedule.

// f.split(x, xo, xi, 32);
for (xo, f.min.0, f.max.0 / 32) {
  for (xi, 0, 31) {
      let x = xo * 32 + xi 
  }
}

It's now possible to align the first iteration of the inner loop, like so:

// f.split(x, xo, xi, 32, p);
for (xo, (f.min.0 - p) / 32, (f.max.0 - p) / 32) {
  for (xi, 0, 31) {
      let x = xo * 32 + xi + p
      if (x >= f.min.0 && x <= f.max.0) { // for GuardWithIf

      }
  }
}

Replaces #9371

Breaking changes

Checklist

  • Tests added or updated (not required for docs, CI config, or typo fixes)
  • Documentation updated (if public API changed)
  • Python bindings updated (if public API changed)
  • Benchmarks are included here if the change is intended to affect performance.
  • Commits include AI attribution where applicable (see Code of Conduct)

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.95775% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.24%. Comparing base (d10a1f2) to head (cde6451).

Files with missing lines Patch % Lines
src/Func.cpp 70.83% 6 Missing and 1 partial ⚠️
src/Simplify_Exprs.cpp 80.00% 0 Missing and 2 partials ⚠️
src/ApplySplit.cpp 98.57% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9409      +/-   ##
==========================================
+ Coverage   70.02%   70.24%   +0.21%     
==========================================
  Files         261      261              
  Lines       79761    79874     +113     
  Branches    19443    19471      +28     
==========================================
+ Hits        55855    56105     +250     
+ Misses      18004    17957      -47     
+ Partials     5902     5812      -90     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/ApplySplit.cpp
Comment thread src/ApplySplit.cpp
mask = select(base == old_base, likely(const_true()), mask);
Expr mask;
if (split.align.defined()) {
// Because base is anchored to align instead of old_min, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we have a nested tail strategy tail that tries lots of things in combination. It would be good to add aligned splits to it to get more coverage of this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have two tests added: split_aligned_nested and rfactor_split_aligned_nested which do this. I'm a bit hesitant to conflate the existing nested_tail_strategies with another axis of tests.

Comment thread src/Func.h Outdated
Comment thread src/Simplify_Add.cpp Outdated
Comment thread src/Simplify_Exprs.cpp Outdated
Comment thread src/Simplify_Exprs.cpp
Comment thread tutorial/lesson_25_aligned_split.cpp Outdated
Comment thread src/Simplify_Mod.cpp Outdated
mcourteaux and others added 16 commits September 8, 2026 13:34
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Gemini Pro 3.1 <gemini@aistudio.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… those blend operations in case of aligned splits.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Fix old copy-paste bug in simplifier rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rename split_aligned_2d_6x6.cpp to split_aligned_2d_3x3.cpp and shrink
the pattern to 3x3, which reproduces the surviving mux with a much
smaller amount of IR to read.

Also fix the test itself: realize the 3-D output with a 3-D shape, check
all three channels, sweep all nine (offset_x, offset_y) alignments, and
include c in the reorder so it stays innermost. With c left outermost it
was unrolled around the xo/yo nest, triplicating the loop nest and
recomputing R/G/B once per channel.

The test currently fails at the mux count (27 = 9 tile positions x 3
channels); the runtime results are correct for every alignment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A loop of eight whose first and last iterations are special and whose
interior is periodic with period two. Unrolling the interior by two
folds the % away, but only if the unrolled pairs line up with the
periodicity, which means the tiles have to start where the interior
does. An aligned split says exactly that, and partitioning then peels
one iteration at each end rather than two, leaving a steady-state loop
of three rather than two.

Checks the extent of the remaining loop, that the modulo folded away,
and the values. Dropping the alignment from the split fails the extent
check, so the test is measuring the thing it claims to.
mcourteaux and others added 8 commits September 12, 2026 00:03
…) to the outside of an index reconstruction of a split.

This allows the simplifier to peel the variable into the use-site and let terms cancel in some pipelines.

Co-authored-by: Andrew Adams <andrew.b.adams@gmail.com>
Missed in the C++ rename: the pybind11 binding still pointed at the
now-removed T::split(...,Expr,Expr,TailStrategy) overload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Js4TkBazPFvQkr9fnE2aXM
A vectorized loop over a repeating pattern only resolves its phases at
compile time if it gets deinterleaved by the pattern's period, so that each
resulting slice has a single phase. The stride search stopped at four, which
covered a Bayer sensor's period of two but not an X-Trans sensor's six.

It can't simply go to eight: deinterleaving a period of eight into eight
slices displaces permutes that a target may do in one instruction, and three
of simd_op_check_hvx's vdelta checks regress that way. Seven is the largest
value that leaves those alone. A period of eight still resolves if the loop
is unrolled rather than vectorized.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Js4TkBazPFvQkr9fnE2aXM
Distilled from NeonRAW's BilinearDemosaicAlignedGenerator, which selects an
interpolation per Bayer phase with a mux over (x - bayer_offset) % 2 and is
scheduled with splits aligned to that same offset so the phase is constant
per tile.

For every mux to resolve at compile time, the aligned split's alignment has
to cancel against the subtraction at the use site (the Simplify_Add rewrite
rule) and the vectorized loop has to be deinterleaved by the pattern's
period, so each slice has a phase of its own (Deinterleave.cpp). Covers a
Bayer sensor's period of two, a three-phase pattern, and an X-Trans sensor's
six.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Js4TkBazPFvQkr9fnE2aXM
…t rule

The Add simplifier now collects x*c + (z*c + y) into (x+z)*c + y when the two
terms share a coefficient, which is sound under wraparound arithmetic even
when c is INT_MIN (multiplication distributes over addition mod 2^32
regardless of intermediate overflow). Update the expected output to match;
the test's actual purpose, checking that the coefficient itself is never
negated, still holds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Js4TkBazPFvQkr9fnE2aXM
…fferences

solve_expression pulls a correlated loop variable leftmost inside a min or
max, but PartiallyCancelDifferences had no rule to sink it into the other
side, so the cancellation was left to the simplifier that runs afterwards.
The simplifier may first collect that term into a product with an unrelated
one, after which the loop variable is no longer syntactically available to
cancel, and bounds inference then counts its contribution twice.

Doing the cancellation here instead makes it independent of what the
simplifier chooses to collect. This fixes an over-allocation in nested
GuardWithIf / PredicateStores tail strategies, where a compute_root producer
was handed roughly twice the memory it needed, even when the extent was an
exact multiple of every split factor. Reproduced by
correctness_nested_tail_strategies with seed 1789169226.

The eight new rules are the min and max forms of two identities, written out
for each operand ordering because the matcher does not match commutatively.
All eight were proved with apps/simplifier_rule_verifier; they hold under
Halide's no-signed-overflow model for Int(32), which is the type the
enclosing block is already guarded on, and not under wrapping arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Js4TkBazPFvQkr9fnE2aXM
Comment thread src/Func.h
Comment thread src/Func.h Outdated
Comment thread src/Simplify_Add.cpp Outdated

rewrite(x * c0 + y * c1, (x + y * fold(c1 / c0)) * c0, c1 % c0 == 0) ||
rewrite(x * c0 + y * c1, (x * fold(c0 / c1) + y) * c1, c0 % c1 == 0) ||
rewrite(x * c0 + (y * c1 + z), (x * fold(c0 / c1) + y) * c1 + z, c0 % c1 == 0) ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The variant of this where c1 % c0 == 0 should be added too, for uniformity with the pair of rules above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding this trips up nested_tail_strategies. It rewrites some bounds Expr that used to cancel into a form that no longer cancels. Precisely the opposite of what the new rule here tries to work around.

The rule we added here was very ad-hoc to make the +offset and up in the back to let the simplifier peel. As the c1 % c0 == 0 counterpart rule breaks things for now, and we are pushing back deciding what to do with this more general simplifier problem, I'd argue to just leave this rule alone without the counterpart you suggest.

I can add a comment about it though.

Note that none of the 3 rules in this block adhere to the reduction order, according to the verifier, but do according to your "constant becomes smaller" rule. This is probably something that needs to be addressed properly in the verifier, or these rules should be kicked out.

Comment thread src/Simplify_Add.cpp Outdated
rewrite((x / w) * w + (z + x % w), select(w == 0, 0, x) + z) ||
rewrite(x / 2 + x % 2, (x + 1) / 2) ||

rewrite((0 - (x % 2)) / 2 * 2 + (x % 2), 0 - (x % 2)) ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this should simplify by a different chain. First:
(0 - x % 2) / 2 is just (0 - x % 2), and then we have the pattern (0 - y) * 2 + y which I think will simplify already.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Neither of these rules are present in the respective files. Will add those.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding in the first rules brings the simplifier to: ((x % 2)*-1) which seems like a suboptimal endpoint, so I added rewrite(x * -1, 0 - x) in Simplify_Mul to have it land on (0 - (x % 2)). Does that sound right?

Comment thread src/Simplify_Add.cpp Outdated
rewrite(x + ((c0 - x) / c1) * c1, c0 - ((c0 - x) % c1), c1 > 0) ||
rewrite(x + ((c0 - x) / c1 + y) * c1, y * c1 - ((c0 - x) % c1) + c0, c1 > 0) ||
rewrite(x + (y + (c0 - x) / c1) * c1, y * c1 - ((c0 - x) % c1) + c0, c1 > 0) ||
rewrite(((0 - x) / c0) + ((x % c0 + c1) / c0), fold(c1 / c0) - (x / c0), c0 > 0 && (c1 + 1) % c0 == 0) ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems like a hyper-specific rule. What's it for? Is this a pattern produced by the new split logic?

@mcourteaux mcourteaux Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's hyper specific indeed. Disabling breaks one of the tests (correctness_aligned_split_mux_phase). Verified by z3 and the verifier.

Comment thread src/Simplify_Div.cpp Outdated
Comment thread src/Simplify_Exprs.cpp
Comment thread src/Simplify_Mod.cpp Outdated
Comment thread src/SimplifyCorrelatedDifferences.cpp Outdated
@abadams

abadams commented Sep 14, 2026

Copy link
Copy Markdown
Member

Many comments on the simplifier rules. Were they all verified?

@mcourteaux

Copy link
Copy Markdown
Contributor Author

I'll delete all the simplifier rules, and see what breaks, because I don't know anymore by now.

mcourteaux and others added 11 commits September 16, 2026 10:38
SolveExpression::visit(Add) had no case for a zero operand, so a
cancelled or negated term left a stray `+ 0` in the solved expression.
This surfaced once `x * -1` started canonicalizing to `0 - x`: the Div
visitor's `(f(x)*a)/b -> f(x)*(a/b)` rewrite produces `0 - x`, the Sub
visitor turns that into `negate(x) + 0`, and nothing folded it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQ1NNAmM1ki6K5L4DC9ERu
… found by Claude, verified by z3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Andrew Adams <andrew.b.adams@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants