Uh oh!
There was an error while loading. Please reload this page.
Allow the simplifier to use facts in its can_prove() predicates. - #9400
Allow the simplifier to use facts in its can_prove() predicates.#9400mcourteaux wants to merge 22 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## main #9400 +/- ##
==========================================
- Coverage 70.12% 70.00% -0.13%
==========================================
Files 261 261 Lines 79405 79699 +294 Branches 19362 19452 +90 ==========================================
+ Hits 55684 55791 +107 - Misses 17896 17950 +54 - Partials 5825 5958 +133 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
abadams
commented
Aug 31, 2026
Was this the one that inflated the lowering time of lens_blur? Is this superseded by your approach in aligned splits take 2? |
abadams
commented
Sep 1, 2026
Some more data: yesterday in a research branch I came across a case where max(x, y) was not simplifying inside an if (x <= y) branch, and it was causing wmma ops to fail to be extracted. This is a case we need to handle, we just need to figure out how to do it without increasing compile times. |
abadams
commented
Sep 1, 2026
I think an approach to make this fast might be to define an operator< that can compare IRMatcher patterns to Exprs, so that the pattern can be looked up in the set of known facts without constructing an IR node, rather than needing to build an Expr just to do the lookup. |
mcourteaux
commented
Sep 1, 2026
This indeed did slow down lens blur by 10% more ore less. It's not superseded: take 2 just works around the simplification issue by using .bound_extent() and .bound_storage().
🤝
That sounds like a decent approach! Feel free to take over this branch! |
What if instead, we special handle the generic-form As such, no We can keep the more expensive machinery for non-trivial rules, such as the ones now in Simplify_Div, which wouldn't trigger for every Max/Min node, because the LHS of the rewrite rule is more specific: has_facts()&&(rewrite(max(x*c0,y)/c0,x,c0>0&&known_true(x>=y/c0,this))||rewrite(max(y,x*c0)/c0,x,c0>0&&known_true(x>=y/c0,this))||rewrite(min(x*c0,y)/c0,x,c0>0&&known_true(x<=y/c0,this))||rewrite(min(y,x*c0)/c0,x,c0>0&&known_true(x<=y/c0,this)))The existing Later, when we |
mcourteaux
commented
Sep 4, 2026
Offline discussion with @abadams, with some ideas going back and forth, Andrew proposed to:
As such, we can have a function that return rewrite(max(x, y), x, min_diff(x, y, this) >= 0) // because x - y >= 0, we know that x >= y. |
The condition of a can_prove predicate in a rewrite rule was simplified on its own, without any of the facts the simplifier has learned on the way down the IR. Substitute those facts into the condition first, and store facts in the same comparison direction the simplifier produces, so that a fact stated as x > y is usable when it visits y < x. This makes fact-driven rewrite rules possible: max/min now pick a side when the facts order the operands, and a division can cancel a multiplication inside a max or min. Co-authored-by: Claude <noreply@anthropic.com>
Facts and the conditions of can_prove predicates are now looked up in the same canonical form: GT and GE are mapped onto LT, Not is unwrapped, and a comparison can be settled by the other strictness of the same comparison in either direction. This means it no longer matters how a fact was spelled relative to how the rule that consumes it was, and a strict fact such as x > y settles the non-strict predicate the max/min rules ask for. Those rules ask non-strictly, since a tie makes either side of a max or min an equally good answer, so a fact of x >= y is enough to pick a side. Co-authored-by: Claude <noreply@anthropic.com>
Simplifying the condition of a can_prove predicate visits the operands again, so a fact-driven rule that matches every node of its type recursed without bound on nested min/max trees. Disable those rules while inside a can_prove condition; the facts themselves are still substituted in at every level. Co-authored-by: Claude <noreply@anthropic.com>
Recursing further is occasionally useful in principle, but measurably expensive: at a limit of 2, correctness_likely goes from 1.0s to 4.2s and correctness_autodiff from 3.4s to 11.4s, with no test producing a better simplification. Keep the limit at one level, but name the constant. Co-authored-by: Claude <noreply@anthropic.com>
can_prove as a rewrite predicate recursively invokes the simplifier on every expression matching the rule's left-hand side, so a rule whose left-hand side also matches something built while proving the predicate recurses. It is also simply expensive. known_true instead looks the condition up in the facts directly. It cannot recurse, and it is cheap enough to use on a rule that matches every node of its type. The fact-driven max, min and division rules now use it, which is enough for all of them: looking up a comparison already understands direction and strictness. Co-authored-by: Claude <noreply@anthropic.com>
The depth limit was checked in has_facts, which only protects rules that consult it. Checking it on entry to the condition simplification instead protects every can_prove, including the pre-existing rules and any future one, and returning the condition unsimplified is the natural way to decline: the predicate simply fails to prove anything. That also frees has_facts to be a plain check, so the non-recursive known_true rules can fire at any depth. The limit is raised to four, which restricts nothing today: instrumenting every correctness test shows the deepest can_prove nesting any of them reaches is one. Co-authored-by: Claude <noreply@anthropic.com>
Refusing to simplify the condition past the depth limit meant the predicate could never be proven there, even when the fact needed was already known. substitute_facts is a plain tree walk (mutate_with over the generic IRMutator base traversal) that never invokes a rewrite rule, so it cannot re-trigger can_prove or known_true and stays safe at any depth: use it as the fallback instead of returning the condition untouched. Added a regression test built on the pre-existing can_prove-based min/max subtraction cancellations in Simplify_Sub.cpp (the rules that motivated the depth limit in the first place, since their predicate constructs a fresh subtraction that can itself match the same rule). With the limit disabled it hangs (confirmed: 15s timeout); with it in place it completes in under a second. Co-authored-by: Claude <noreply@anthropic.com>
The previous fallback ran substitute_facts, a full tree walk, on the condition. But the only thing the caller checks is whether the result is literally the constant true, and nothing runs afterward to fold a compound expression: an And of two individually-known-true operands stays an unfolded And, never becoming true. So substitute_facts's ability to resolve facts about pieces of a compound condition was wasted work here — it can't prove anything is_known_true on the condition itself couldn't already, since folding that partial progress into a verdict is exactly the recursive work the cap exists to avoid. Co-authored-by: Claude <noreply@anthropic.com>
known_true had to build the comparison it was asked about, so a rule like rewrite(max(x, y), a, known_true(y <= x, this)) allocated on every max node with a fact in scope -- and lookup_fact allocated a few more internally while canonicalizing. Measured on a nest of 200 max/min nodes with one fact, that was several allocations per node. Instead, learn a ConstantInterval on the difference between the two sides of each comparison, and ask about it with the operands a rule already has bound. MatcherState holds raw node pointers, so the query touches no reference counts and builds nothing: the same benchmark now allocates nothing per node. Direction and strictness stop being special cases: the other direction is the negated interval, and strictness is just whether the bound is -1 or 0. The complement of a half-line is a half-line, so only the negation of an equality fails to be an interval, and that is always a single point removed, which is what KnownBound::invert represents. A removed point tightens the bounds when it lands on an end, and is otherwise only tracked when it is at zero, which is what decides known_not_equal. Constant offsets are peeled off both the facts and the queries, so a fact about x and y + 3 settles a question about x and y. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
The limit governs how much work an adversarial expression can provoke, and the growth is steep: on a nest of min(x, y) - min(z, w) the simplify test costs 0.02s at a limit of 1 or 2, 0.11s at 3 and 0.72s at 4. Nothing needs the extra depth -- instrumenting every correctness test shows the deepest nesting any of them reaches is one -- and correctness_likely and correctness_autodiff are unchanged across limits of 1, 2, 4 and 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
Two constants, and a min or max compared against one of its own operands, bound their difference on their own. Deriving those needs no facts, no recursion and no allocation -- a node type check and a couple of the inlined equal() comparisons -- so fold them in alongside what the fact table says rather than treating facts as the only source of knowledge. The fact table being empty must no longer short-circuit the whole query, since that would skip these too. No rule needs this yet: the max and min rules that consume min_diff are already covered for these shapes by dedicated rewrite rules, so this changes no behaviour on its own. It is what makes the difference helpers strong enough to replace can_prove in rules that currently rely on it proving things structurally, which without this loses cancellations such as min(x, y) - min(x, w) where y is min(a, b) and w is a. Cost is confined to a synthetic max/min chain (0.070 to 0.079 ms on a 200-deep nest); correctness_likely and correctness_autodiff are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
A min is at most either of its operands and a max is at least either, which bounds their difference on one side without any facts. Knowing the two are unequal removes the endpoint of that bound, and the two together decide a comparison that neither decides alone -- which is what makes these reachable through the max and min rules, where the shapes that structural knowledge settles on its own are already covered by dedicated rewrite rules. The two negative cases pin that down: drop the inequality and the difference could still be zero, drop the shape and there is no bound to tighten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
The fact list is not short in practice. Lowering lens_blur performs 35594 difference lookups, about two thirds of them with 39 to 54 facts in scope, and not one of them matches: every lookup scanned the whole list, following two pointers per record, to establish nothing. That scan was most of what the fact-driven max and min rules cost. Summarize each side of a record by its node type, plus the name or value of the leaves that distinguish otherwise identical nodes. Equal Exprs always summarize alike, so a mismatched summary rules a record out without touching the Exprs, and the scan becomes a pass over integers stored in the record itself. Measured on lens_blur lowering in retired instructions, which wall time is far too noisy to resolve: 2.187G on main, 2.297G before this change, 2.218G after, so it removes about seventy percent of the overhead. Of what remains, 18M is the rules being attempted on every max and min at all, and only 13M is the scan -- so an associative container in place of the vector could recover at most a further half percent, while costing the O(1) scope teardown that truncating a vector gives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
has_facts is true whenever anything at all has been learned, but a fact only leaves a record for min_diff and max_diff to find if it is a comparison of non-overflowing integers. A boolean fact, or one about a type that can wrap, satisfies has_facts while leaving the difference table empty, so the max and min rules were running lookups that could not possibly match. Lowering lens_blur did that 6998 times, a fifth of all its difference lookups. They scanned nothing -- there was nothing to scan -- but still paid for the call, the constant peeling and the structural check. Gating on the table the predicates actually read removes them: 35594 lookups become 28596, with the records scanned unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
Xoring the two fingerprints gives a key that is the same whichever way round the pair is asked about, so a single bit serves both directions of a record. Keeping a bit per key over the whole table turns the common answer -- that nothing is known about this pair -- into one test instead of a walk. The summary belongs to the table rather than to each record: the fallback scan walks every record, so keeping those small matters more than where the summary lives, and a scope can then save and restore it wholesale, which is what makes undoing it free when bits cannot be cleared one at a time. Four words rather than one because a table of a few dozen facts saturates 64 bits and lets four queries in ten through; at 256 it rejects 79.5% of them. Lowering lens_blur, in retired instructions against 2.187G on main: 2.216G before, 2.210G at 64 bits, 2.208G at 256. Skipping the scan entirely would be 2.205G, so what remains of it is 3M instructions, or 0.14%. An associative container cannot do better than not looking at all, so that is the whole of what one could still win here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
…e bit Only leaves carry anything that tells two nodes of the same type apart, so every Add summarizes alike, as does every Min. Xoring a pair of them therefore gives zero whatever the type, and Add against Add, Min against Min and every other same-type pair shared a single bit of the table summary. Keying that case by the kind instead lifts rejection on lens_blur from 79.5% to 81.6% for the cost of one comparison, and the summary is no sparser for it: 32.8 bits of 256 either way. Two larger changes were tried first and both measured worse. Summarizing an Expr recursively rather than only at its root costs more to compute than the scan it saves (2.212G against 2.208G). Replacing the xor with a key built from the sum as well spreads same-type pairs properly but aligns query keys with record keys far more often, dropping rejection to 52.3%. The scan that is left is 3M instructions, so there was never much here to win. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
032f724 to
260cc16Comparehannk's average and max pooling clamp the index they read the input at, and then restrict the reduction domain with a predicate that says the same thing: that the index is within the input. Learning a bound on the difference from that predicate let the max and min rules drop the clamp, which is true of the value but not of the region: bounds inference only partly models the conditions of ifs, so it went on to ask for a region the clamp had been keeping in range, and the pipeline failed its own bounds check -- input is accessed at 0, which is before the min (1) in dimension 1. A clamp around an index is load-bearing for more than its value, so only record differences from sources whose ranges bounds inference derives the same way we do: loop bounds, and assumptions the caller states outright. The lowered IR for every hannk generator matches main again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
Suppressing those facts outright, as the previous commit did, fixed hannk by making the feature inert: lowering lens_blur learned 663 differences and used none of them. The condition of an if is the richest source of orderings there is, and loop partitioning, which produces most of them, runs long after the regions are settled. What matters is not where a fact came from but when it is used. Until lowering has finished reading regions and allocation sizes out of the IR, a clamp around an index is part of how those are derived and must not be removed on the strength of a condition; afterwards those regions are IR of their own and a redundant clamp is only a redundant clamp. So gate on that instead, at the one point that decides it: don't learn the difference, rather than remembering it and hoping every consumer checks. A future consumer of known_difference cannot get this wrong, and nothing pays to build a table that may not be read. Lowering lens_blur now learns 2704 differences and settles 522 comparisons with them, and every hannk generator still lowers to what main does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
mcourteaux
commented
Sep 5, 2026
@abadams Ready for review. Original post updated. This was all Claude with a lot of guidance, so perhaps a lot of comments are still too verbose, and maybe a few names left and right could be better. However, I think the approach is good. Performance impact of the lookups was real, so we iterated a bit to make them even faster using a cheap hashing scheme. This was the initial histogram of number of facts present, during fact lookup happening within lens_blur: ![]() The massive amount of lookups when there were no facts was fixed after this chart was made. Claude argued by doing some analysis on different runs of retired instruction count that a std::map would not make things faster; not actually measured yet. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…eads Four things from review. A difference only means what we take it to mean for integers that don't wrap, which is why learning a fact checks the operand type -- but the structural bound did not, and fired for uint8 and for floats, where a NaN makes even min(p, q) <= p false. It only ever fed an ordering decision, which is why nothing went wrong, and Halide's dedicated lattice rules already cover those types unconditionally. Check the type anyway, so the two halves agree on what a difference is. Removing a point that is the whole interval was leaving min above max, which is not an interval at all: contradictory facts are a statement about reachability, not about a difference. Say nothing instead. Contradicting facts now leave [0, 0] rather than [1, -1]. known_equal had no caller, and would not have earned one: learning a == b already registers a substitution, so the equality is gone from the IR before a predicate could ask about it. known_not_equal had no caller either, and with both gone nothing reads the interior-hole flag, so that goes too and a difference is once again just a ConstantInterval. A hole at an endpoint still tightens the bound, which is what the tests rely on. The remaining question was whether the flag around the two scoped facts in visit(IfThenElse) is pointless. It is what keeps hannk correct: without it the clamp disappears again. It has to be per-source rather than a single test inside learn_difference, because gating every source on the phase would also silence the assumptions a caller states outright, which arrive through simplify() with no lowering in progress at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
The rule is about when a fact may be used, not about where it came from, so there is no reason for the if visitor to know anything about it. It learns as it always did; learn_difference declines until regions have been read out of the IR. One test, in one place, and nothing to keep in step. The reason it had been per-source was that gating everything broke the tests for assumptions passed to simplify(), which arrive with no lowering in progress. That was the wrong conclusion: those rules are for the part of lowering that runs once regions are settled, so the tests say so, the same way they say what is assumed. check_facts now opens with a ScopedRegionsInferred, and the two tests that had been rewritten to assert an if's condition is ignored go back to asserting that both branches learn from it. hannk is unaffected -- the clamps survive and both pool generators still lower to what main does -- and the facts are still there to use: lowering lens_blur learns 2079 differences and settles 338 comparisons with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu
Given x >= y + 5 over uint8, y = 253 makes y + 5 equal 2, so x = 10 satisfies the fact while sitting far below y. Rewriting min(x, y) to y on the strength of it would pick the wrong side, and nothing pinned that down. learn_difference already declines any type whose overflow is defined, which is why this passes rather than fixing anything, but the boundary is worth stating: uint8, int8, int16 and uint32 are left alone, int32 and int64 are ordered. Drop the check and the int8 case rewrites min to the wrong operand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1YKwTyubRmLMfA58gM1Pu

Problem statement
While preparing #9371, I hit several dead ends trying to produce very neat IR. The reason is the simplifier cannot simplify
max(x, y) = xwhen we give the assumptionx >= y. Intuitively, one would write inSimplify_Max.cpp:However, the way
can_prove(Expr, Prover)is implemented is to recursivelymutate()the Expr with the Prover (i.e.,thisinstance ofSimplify). This however, does not substitute in the facts (truths), and therefore fails to "prove" thatx > y.A secondary problem with rewrite rules that use
can_prove()is that they recursively invoke the simplifier, which leads potentially to infinite recursions. Specifically, Andrew stated:Solution: Let the simplifier use what it knows about ordering
The simplifier learns facts on the way down the IR (
ifconditions, asserts, loop bounds), but rewrite rules could only consult them throughcan_prove, which recursively re-invokes the simplifier on a freshly built condition. That is both expensive and unsound to use in a rule matching a common node type: if the rule's own left-hand side matches something built while proving its predicate, it recurses without bound. This branch replaces that mechanism for ordering questions and adds rules that use it.What it adds
known_difference— the simplifier now keeps aConstantIntervalon the difference between pairs of expressions. Every comparison it can learn from is a statement abouta - b:a < bmeans at most-1,!(a < b)at least0,a == bexactly zero. Direction and strictness stop being special cases — the other direction is the negated interval, strictness is whether the bound is-1or0.Because the complement of a half-line is a half-line, only a negated equality fails to be an interval, and that is always a single point removed (
KnownBound::invert). A removed point tightens the bounds when it lands on an end, and is otherwise tracked only at zero, which is what decidesknown_not_equal.Constant offsets are peeled off both facts and queries, so a fact about
xandy + 3settles a question aboutxandy. Two constants, and amin/maxcompared against one of its own operands, are decided from shape alone with no facts at all.Two new rewrite predicates,
min_diff(x, y, this)andmax_diff(x, y, this)(plusknown_equal/known_not_equal, not yet used by any rule). They read the nodes a rule has already bound —MatcherStateholds raw node pointers — so testing one allocates nothing. They are restricted to wildcard operands bystatic_assert, which makes that property structural rather than a thing to be careful about.known_true— a non-recursivecan_prove, kept for rules whose predicate isn't a pairwise difference (the division rules).A depth limit on
can_prove, checked where the recursion happens rather than in each rule's guard, so it covers pre-existing rules too.New rules:
max/minpick a side when the facts order the operands, and a division can cancel a multiplication inside amax/min.Why the depth limit matters
Before it,
apps/lens_blurdid not compile — lowering ran for 52 minutes of CPU before I killed it. Themin(x, y) - min(z, w) → y - w, can_prove(x - y == z - w)family inSimplify_Sub.cppbuilds a fresh subtraction to test, which can match the same rule again; a 10-deep nest of that shape never terminates.test/correctness/simplify.cpphas a regression test for it that hangs if the limit is removed.Compile-time impact
Lowering only (generator with
-e stmt, so no LLVM codegen), against maina3690b3b6:lens_blur, retired instructionslocal_laplacian, retired instructionslens_blur, wall (min of 15)local_laplacian, wall (min of 15)Lowered output is byte-identical on both apps — these pipelines pay a small cost and get nothing back; the new rules never fire in them.
Across the whole correctness suite (411 tests, 52 skipped, sequential, peak RSS and wall time per test): median wall-time change +0.00%, total 576.3 s → 576.0 s, median peak RSS −0.41%. Both trees green, 0 failures.
Most of the cost was removed rather than accepted. On
lens_blur, overhead versus main went +5.0% → +0.98%:can_proveper node)known_trueinstead ofcan_prove¹ measured at an earlier baseline; the trend is what matters.
The lookup work itself is now 3 M instructions (0.14%) of lowering — the rest is the rules being attempted on every
max/min, which no lookup change addresses.Notes for review
has_facts()was split.min_diff/max_diffgate onhas_difference_facts(), because a boolean fact or one about a wrapping type satisfies the old predicate while leaving the difference table empty — that was a fifth of all lookups onlens_blur, each one guaranteed to find nothing.ScopedFact's move constructor is no longer= default. The existing members are containers that end up empty after a move, so the moved-from destructor was harmless; a plainsize_tindex is not, and silently truncated away facts that had just been learned.Two caveats before you post: the
+0.98%is real but sits close to run-to-run variation in wall time, so I'd lead with the instruction counts as I have. And the "0 matched a fact" observation behind the filter design is a property oflens_blur, not a general law — a pipeline whose facts actually relate itsmax/minoperands would behave differently.Checklist