Shared bounds tracker machinery. - #9399
Open
mcourteaux wants to merge 10 commits into
Open
Conversation
…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
commented
Aug 27, 2026
| // 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) || |
Contributor
Author
There was a problem hiding this comment.
@abadams I think these can be in the general Simplify_Sub? They are genuine simplifications.
…seful as we're not trying to find any upper bound.
mcourteaux
commented
Aug 27, 2026
| PROPERTIES | ||
| EXPORT_COMPILE_COMMANDS NO | ||
| POSITION_INDEPENDENT_CODE ON | ||
| ) |
Contributor
Author
There was a problem hiding this comment.
@alexreinking Drive-by fix for PIC on the initmod.
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 executesimplify()in without all that evidence passed. This obviously doesn't work so each of those passes implements some form of the same evidence gathering assimplify()does. Each pass implements it's own subset of such operations and passes those asScope<ConstantBounds>orstd::vector<Expr> assumptionstosimplify(), 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 regularsimplify()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 usingfind_constant_bound()first (as a fast path), if that fails, resorts to a sequence ofsimplify_with_context(),find_constant_bound(), andtighten_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:
StorageFoldingtries to find an upper bound for the allocation too.HexagonOptimizehas 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_noteslabel.Checklist