Skip to content

Shared bounds tracker machinery. - #9399

Open
mcourteaux wants to merge 10 commits into
mainfrom
mcourteaux/bounds-tracker
Open

Shared bounds tracker machinery.#9399
mcourteaux wants to merge 10 commits into
mainfrom
mcourteaux/bounds-tracker

Conversation

@mcourteaux

@mcourteaux mcourteaux commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Problem statement

When a simple call to simplify() is executed on the root of the IR tree, it gathers a lot of facts and bounds along the way, which can be exploited to simplify Exprs more given this context.

Several passes make use of simplify() when attempting to find an upper bound to some Expr:

  • BoundConstantLoopExtent: used for when a loop has to be unrolled, but the loop extent is variable, but has an upper bound: the loop extent will be set to the found upper bound, and if-guards will be inserted in the loop body.
  • BoundSmallAllocations: same principle: we like to put things on the stack, and not malloc, so this pass tries to find an upper bound to the required storage.
  • LowerWarpShuffles: finds size of hoisted allocations.

The problem with these is that the passes themselves drill down the IR to find the allocation or loop on which they have to work and find their upper bound. So they would walk past all the evidence that simplify() could have picked up, and then execute simplify() in without all that evidence passed. This obviously doesn't work so each of those passes implements some form of the same evidence gathering as simplify() does. Each pass implements it's own subset of such operations and passes those as Scope<ConstantBounds> or std::vector<Expr> assumptions to simplify(), which seems very ad-hoc.

Solution

This PR introduces the BoundsTracker: a shared utility which these bound-seeking passes can use to collect similar info as a regular simplify() on the root would do. The BoundsTracker now has a few utility functions the passes can make use of:

  • simplify_with_context() Runs the simplifier along with given facts and constant bounds intervals.
  • find_constant_bound() Tries to find an upper bound by passing constant bounds.
  • find_constant_bound_aggressive() Tries to find an upper bound by using find_constant_bound() first (as a fast path), if that fails, resorts to a sequence of simplify_with_context(), find_constant_bound(), and tighten_using_loop_monotonicity() as a hardest attempt to find an actual good lower/upper bound.

PR open for feedback. There are two more lowering passes that can make use of this:

  • StorageFolding tries to find an upper bound for the allocation too.
  • HexagonOptimize has one call size using find_constant_bound(). Perhaps not that useful. Used during lowering of div_round_to_zero and mod_round_to_zero.

Breaking changes

None.

These do not necessarily disqualify a PR from being merged, but they should at
least be tagged with the release_notes label.

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)

mcourteaux and others added 8 commits August 27, 2026 12:07
…cations/AllocationBoundsInference

Introduces BoundsTracker, a struct that accumulates enclosing pure
LetStmt/Let bindings and dominating facts while a mutator descends a
Stmt tree, and uses them to find constant bounds far more reliably
than a bare find_constant_bound() call. In addition to a Scope<Interval>
fast path, find_constant_bound_aggressive()/find_constant_bounds_aggressive()
fall back to wrapping an expression in all pending pure lets, inlining
them with substitute_in_all_lets, and re-simplifying under the
dominating facts -- generalizing the trick bound_constant_extent_loops
has always used to find constant loop extents.

Migrates all three targeted passes onto it:
 - BoundConstantExtentLoops: same two-tier (exact vs guarded upper
   bound) unroll/vectorize logic, now expressed via
   find_constant_bounds_aggressive()'s interval collapse-to-a-point
   check instead of a separate ad hoc IntImm check.
 - BoundSmallAllocations: Frame/visit_let chain now binds through
   tracker.push_let(); find_constant_bound() call sites upgraded to
   find_constant_bound_aggressive() so allocation/realize extents get
   the same aggressive treatment.
 - AllocationBoundsInference: gains LetStmt/For tracking it never had
   before, and runs the box_touched() result through
   tracker.simplify_with_context() before CSE, so a Realize's per-
   dimension min/max can be simplified using enclosing let context
   that box_touched (called with an empty scope) can't see on its own.

KNOWN ISSUE (not yet resolved): correctness_unroll_loop_with_implied_constant_bounds
segfaults via infinite recursion inside Simplify's fact/var_info
substitution machinery, triggered from BoundConstantExtentLoops's
aggressive fallback when two dominating facts (a bounds-query check
and a 4-way equality conjunction "three_channels") are both active
for the same simplify() call. Confirmed via debug instrumentation
that BoundsTracker builds the same wrapped expression and fact list
the original hand-rolled implementation would have; the crash is
inside the Simplify engine's own var_info replacement logic
(Simplify_Exprs.cpp:270-282), not in BoundsTracker's bookkeeping.
Root cause not yet isolated -- needs a minimal standalone repro
against Simplify() directly (bypassing BoundsTracker/Lower.cpp
entirely) to determine whether this is a latent pre-existing Simplify
bug that BoundConstantExtentLoops previously never triggered, or a
subtle behavioral difference between BoundsTracker's fact accumulation
and the original vector-based one. All other targeted correctness
tests (bounds, bound_small_allocations, unroll, vectorize, realize,
extern, sliding_window, partition_loops, split, etc.) pass.

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

BoundLoops::visit(For*) took bounds.max.as<IntImm>() as a raw pointer
while `bounds` was a stack-local Interval about to go out of scope. If
that Interval's Expr was the only thing keeping the underlying IntImm
node's refcount alive, the node could be freed as soon as `bounds` was
destroyed, leaving `e` dangling. The freed memory would typically get
reused shortly after (while unwinding through further LetStmt/IfThenElse
processing), corrupting the IntImm embedded in the constructed For loop
and manifesting later as an infinite Add/Sub/Variable recursion inside
Simplify -- reported by the user as a segfault in
correctness_unroll_loop_with_implied_constant_bounds, reproduced and
fixed with their help using an ASan build.

Fixed by copying the Expr into `extent_upper` (already a function-scoped
local used for the guarded-upper-bound case) before extracting the raw
IntImm pointer from it, so the node stays referenced for the rest of
the function regardless of which branch is taken.

Also fixes the CMake issue that blocked building an ASan config in the
first place: Halide_initmod (the object library holding the runtime's
embedded bitcode blobs, linked into the shared Halide target) never had
POSITION_INDEPENDENT_CODE set, unlike the Halide target itself. This
happened to link fine in optimized builds, where x86-64 codegen tends
to use RIP-relative addressing regardless, but failed with absolute
32-bit relocation errors in unoptimized/Debug/ASan builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
Both DetermineAllocStride and LowerWarpShuffles maintained their own
Scope<Interval> bounds, populated on For loops only when the loop's min
and max were literal constants (is_const(op->min) && is_const(op->max)),
and never updated by LetStmt/Let at all -- so any allocation size or
stride computation that depended on a let-bound intermediate value (very
common after earlier lowering passes hoist bounds calculations into
lets) had no way to resolve to a constant.

All uses of `bounds` in this file only ever feed it into simplify() or
reduce_expr() (itself simplify()-based), never bounds_of_expr_in_scope()
directly -- and Simplify's own internal bounds representation is
constant-only anyway (it converts via as_const_int at ingestion), so
BoundsTracker's constant-collapsing scope loses nothing here, unlike
SlidingWindow/HexagonOptimize which need genuinely symbolic interval
tracking BoundsTracker doesn't provide (left unmigrated).

Adds two small BoundsTracker capabilities needed by this pass:
 - interval_scope(): exposes the underlying Scope<Interval> for passes
   that feed it directly to simplify()/similar rather than going through
   find_constant_bound().
 - push_interval(): pushes an already-computed Interval directly,
   for LowerWarpShuffles::visit(IfThenElse*)'s lane-masking case, which
   narrows an existing binding rather than deriving a new one.

Verified with a full correctness suite run under an actual CUDA JIT
target (HL_TARGET/HL_JIT_TARGET=host-cuda, reconfigured via
-DHalide_TARGET=host-cuda since ctest bakes the target into each test's
ENVIRONMENT property at configure time rather than inheriting it from
the shell). One unrelated pre-existing failure
(correctness_gpu_register_at_block_level, in PromoteGPURegisters/
MultiRamp, which runs before LowerWarpShuffles in the pipeline) was
confirmed to reproduce identically against an unmodified origin/main
build with the same target override, so it predates this change.

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

SimplifyCorrelatedDifferences doesn't just back find_constant_bounds()
(via the exported bound_correlated_differences() on a single Expr) --
simplify_correlated_differences() is also run directly as a whole-tree
lowering pass in Lower.cpp, several times. Give it a BoundsTracker so it
gathers the same constant-bounds context the other migrated passes do,
and use it in cancel_correlated_subexpression()'s final simplify() call.

This complements rather than replaces the pass's existing `lets`
tracking (used to wrap terms for CSE before solve_expression): `lets`
deliberately excludes pure lets that are constant w.r.t. the current
loop_var, since the monotonicity analysis doesn't need them, but the
final simplify() can still benefit from resolving them, and from
dominating assert conditions this pass previously never looked at at
all (new visit(Block*) override, peeling leading asserts the same way
BoundConstantExtentLoops peels dominating if-conditions).

Deliberately uses interval_scope()/known_facts() fed straight into
simplify(), not find_constant_bound_aggressive()'s more powerful
wrap-every-pending-let-and-resimplify path: this pass is already
documented as quadratic in loop nesting depth and runs across the whole
tree multiple times, so paying that cost on every correlated-difference
site would be a real compile-time risk. (Chased what looked like a ~9x
compile-time regression down this path during development -- it turned
out to be an unrelated Debug-vs-RelWithDebInfo build type mismatch
between the two binaries being compared, not anything caused by this
change or the earlier migrations; a same-build-type comparison confirms
no regression.)

Verified with a full correctness suite run (CUDA JIT target enabled):
462/463 passed, with the one failure being the same pre-existing,
unrelated GPU register-allocation issue already confirmed to reproduce
against an unmodified origin/main build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
A producer compute_at a plain (non-aligned) split tile of its consumer,
with the tail handled by PredicateStores, still needs bounds inference
to find a compile-time-constant bound for the tile's extent in order to
unroll it -- PredicateStores only predicates the consumer's store, not
the loads that feed it, so the producer's required region for a
boundary tile stays tied to the consumer's declared extent rather than
becoming an unconditional full tile.

Combined with align_bounds() rounding that region's extent up to a
multiple of the tile factor, the resulting extent expression is a
ceiling-divide of a min-clamped quantity minus the unclamped multiple
the clamp is anchored to: (min(x*c0 + c1, y) + c2)/c0 - x. No existing
rule covered it, so bounds inference found no bound at all and
unrolling failed outright, even though the region provably fits in one
tile.

Add that as an exact identity (not just a bound) to
SimplifyCorrelatedDifferences's PartiallyCancelDifferences: c0 > 0
means both "+c2" and "/c0" distribute over min, so it reduces to
min((y+c2)/c0 - x, (c1+c2)/c0) unconditionally, not just when c1/c2
happen to be multiples of c0.

Also push the enclosing loop's own range into BoundConstantExtentLoops'
BoundsTracker before recursing into its body, and resimplify the
extent with that context before giving up -- matching the pattern
BoundSmallAllocations, AllocationBoundsInference, and LowerWarpShuffles
already use. Not load-bearing for the new test (the
SimplifyCorrelatedDifferences rule alone already finds the bound), but
the same class of gap for any nested extent that only resolves once an
enclosing loop's bound is in scope.
An unrolled producer tile inside a PredicateStores split gets an extent
of the form (min(x*c + c, y) + c)/c*c - x*c, where the enclosing tile
loop's own max (a ceiling-divide of y) is exactly what bounds y from
below and makes the ceiling-divide exact. BoundConstantExtentLoops could
only find the upper bound, so it unrolled to the split factor and
wrapped the body in a guard that is always true.

Two gaps, both on BoundsTracker's deliberately-expensive slow path:

simplify_with_context inlines the enclosing lets, which is what makes
the loop variable appear on both sides of the extent's subtraction --
but nothing cancelled it back out. Run bound_correlated_differences and
re-simplify, keeping the result only when it actually shrank (it can
grow the expression). That turns the extent into
min((y + c)/c - x, 1)*c.

That form is monotonic in the loop variable, so its extremes over the
loop are reached at the ends of the loop's range -- but push_for only
recorded a constants-only Interval, which drops the fact that the
loop's max mentions y too. Record the range symbolically as well, and
have find_constant_bounds_aggressive substitute the endpoints into an
expression is_monotonic() says is monotonic in that variable.
Substitution keeps y correlated between the expression and the loop
bound where per-node interval arithmetic can't, so the extent comes out
as exactly [c, c].

This reuses Monotonic.h's existing analysis rather than teaching the
simplifier a new kind of fact, and costs nothing until the cheap paths
have already failed to find a bound.

split_predicate_stores_compute_at now checks for no guard at all
inside the unrolled tile, rather than at most one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SvYp54SUAXaJ49MizJg63
…_aggressive

The single-Direction form was missing the loop-monotonicity fallback the
Interval form has, so the two disagreed about how hard they tried.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8YeexA67LffkteXd9ekdy
@mcourteaux
mcourteaux requested a review from abadams August 27, 2026 11:32
// happen to be multiples of c0 -- because c0 > 0 means both
// "+c2" and "/c0" distribute over min.
rewrite((min(x * c0 + c1, y) + c2) / c0 - x, min((y + c2) / c0 - x, fold((c1 + c2) / c0)), c0 > 0) ||
rewrite((min(y, x * c0 + c1) + c2) / c0 - x, min((y + c2) / c0 - x, fold((c1 + c2) / c0)), c0 > 0) ||

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.

@abadams I think these can be in the general Simplify_Sub? They are genuine simplifications.

PROPERTIES
EXPORT_COMPILE_COMMANDS NO
POSITION_INDEPENDENT_CODE ON
)

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.

@alexreinking Drive-by fix for PIC on the initmod.

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.52083% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.07%. Comparing base (347b0d1) to head (a3528fb).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/LowerWarpShuffles.cpp 0.00% 24 Missing ⚠️
src/BoundsTracker.cpp 82.24% 10 Missing and 9 partials ⚠️
src/BoundConstantExtentLoops.cpp 84.00% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9399      +/-   ##
==========================================
- Coverage   70.14%   70.07%   -0.07%     
==========================================
  Files         261      263       +2     
  Lines       79388    79512     +124     
  Branches    19357    19388      +31     
==========================================
+ Hits        55690    55722      +32     
- Misses      17874    17887      +13     
- Partials     5824     5903      +79     

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

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.

1 participant