Skip to content

Aligned splits, take 2. - #9409

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

mcourteaux wants to merge 37 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
} else {
// Legacy: structurally guaranteed to be >= old_min
guarded = promise_clamped(old_var, old_var, old_max);
}

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 original comment explaining why promise_clamped is necessary seems to have been removed. Also, imo would be simpler as:

Expr guarded = promise_clamped(old_var, split.align.defined() ? old_min : old_var, old_max);

@mcourteaux mcourteaux Sep 1, 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.

Restored the comment. I'll leave the if in place and the cases as separate as I like the style of comments better.

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
rewrite(h_or(broadcast(x, arg_lanes) < ramp(y, z, arg_lanes), 1),
x < y + max(z * (arg_lanes - 1), 0)) ||
rewrite(h_or(broadcast(x, arg_lanes) < ramp(y, z, arg_lanes), 1),
rewrite(h_or(broadcast(x, arg_lanes) <= ramp(y, z, arg_lanes), 1),

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.

So was this a bug?

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, Claude highlighted this as being a copy-paste error from the same rule above (but with < instead of <=). I guess we (that is Claude and I) ran into this during testing and Claude figured this out. It sure looks like a bug to me.

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 9 commits September 8, 2026 13:34
- <stdio.h> -> <cstdio> (modernize-deprecated-headers)
- main() no longer takes unused argc/argv (misc-unused-parameters)
- Explicit `protected:` on the visit() overrides in Counter and
  FindProducer, matching IRVisitor's own visibility instead of the
  implicit private (misc-override-with-different-visibility)
- .extents[0] -> .extents.at(0) (cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
- Nested ternaries replaced with immediately-invoked if/else lambdas
  (readability-avoid-nested-conditional-operator)
- reserve(9) before the two 9-element ways.push_back() loops
  (performance-inefficient-vector-operation)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Andrew Adams <andrew.b.adams@gmail.com>
This is the align handling from d54fbb3, which was reverted in the working
tree while trying a substitution-based rewrite of the split. That rewrite is
abandoned; without this, aligned splits do not lower correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbXuRsMMLQDkqE3mwtEKMs
x + x already becomes x*2, but only when the two terms are adjacent. In a
nested sum they never meet, so x + y + y stays as written and both the
rewrite rules and modulus_remainder lose the fact that it is even in y.

Found while investigating why an aligned split's alignment fails to cancel
against a matching subtraction at the use site: the sum there is of the form
(even + off) + off, whose evenness is exactly what the mux index needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbXuRsMMLQDkqE3mwtEKMs
@alexreinking
alexreinking force-pushed the mcourteaux/aligned-split-clean branch from 49501ce to bf66758 Compare September 8, 2026 17:34
@mcourteaux

mcourteaux commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Three questions remain @alexreinking @abadams, for the Monday-meeting.

  • You wanted to think about some "algebra of aligning a split", IIRC.
  • Naming: split_aligned/aligned_split or just split.
  • 2D-tiling helper: aligned_tile and gpu_aligned_tile? This is an advanced scheduling directive that probably won't more than 3 people world-wide; I'm fine typing out two splits and a reorder.

My take: I think split_aligned is favorable as that leaves open the door to adding tile_aligned and gpu_tile_aligned. In case of a split and tile overloads with extra align params, tile becomes ambiguous:

  • tile(x, y, xo, yo, xi, yi, offset_x, offset_y) is clear
  • tile(x, y, xi, yi, offset_x, offset_y): variable reused + aligned?
  • tile(x, y, xo, yo, xi, yi): variables explicit + not aligned?

But xi is a Var which is an Expr, and offset_x is also an Expr so the overload is unclear.

UPDATE: I did the rename to split_aligned already.

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
void set_dim_device_api(const VarOrRVar &var, DeviceAPI device_api);
void split(const std::string &old, const std::string &outer, const std::string &inner,
const Expr &factor, bool exact, TailStrategy tail);
void split(const std::string &old, const std::string &outer, const std::string &inner,

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 overload that doesn't take an alignment should be removed, or this one should be renamed

Comment thread src/Func.h
Comment thread src/Simplify_Add.cpp
Comment thread src/Simplify_Add.cpp
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.

Comment thread src/Simplify_Add.cpp
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?

Comment thread src/Simplify_Div.cpp
rewrite(max((x * c0), c1) / c2, max(x * fold(c0 / c2), fold(c1 / c2)), c0 % c2 == 0 && c2 > 0) ||

rewrite((x * c0 + y) / c1, y / c1 + x * fold(c0 / c1), c0 % c1 == 0 && c1 > 0) ||
rewrite((x * c0 - y) / c1, (0 - y) / c1 + x * fold(c0 / c1), c0 % c1 == 0 && 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.

Does this obey the reduction order? The RHS has more ops than the LHS.

Comment thread src/Simplify_Exprs.cpp
Comment thread src/Simplify_Mod.cpp
// simplifier matters: the simplifier may first collect the
// term into a product with an unrelated one, after which it
// is no longer syntactically available to cancel.
rewrite(min(x + y, z) - x, min(y, z - x)) ||

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.

Shouldn't solve have done this? It removes a repeated instance of the variable, which is solve's job.

@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.

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