Add remake methods to IR nodes for copying with new children - #9245
Add remake methods to IR nodes for copying with new children#9245abadams wants to merge 3 commits into
Conversation
Constructing a new IR node from an existing one, changing only the child
Exprs/Stmts, is a very common pattern in the compiler. Doing it with
X::make requires respelling every unchanged field, which is verbose and
makes it easy to silently drop a field when a new one is added to a node.
Add remake methods to the nodes where this happens most. Each takes only
the node's children (plus the alignment describing a passed-in index, for
Load and Store) and copies everything else from the node it is called on.
They also return the original node when the new children are all the same
as the existing ones, which subsumes the hand-written same_as guards that
mutators would otherwise need. Since remake is a method and make is
static, the two are distinguishable at a callsite even where they take
the same arguments.
Load::remake takes the lane count of its type from the new index, since
Load::make already required the two to agree.
Allocate::remake and IfThenElse::remake take new_expr and else_case with
no default, because an undefined value is already meaningful for both
("use the default allocator", "no else clause") and so cannot also mean
"leave it alone".
Converts the callsites where this applies and drops the same_as guards
that remake makes redundant.
Also fixes a bug this exposed: the Allocate visitor in
optimize_hexagon_instructions dropped the padding field when rebuilding
an allocation. optimize_hexagon_shuffles runs earlier and sets padding on
allocations holding vlut lookup tables so their loads can safely overread,
so dropping it risked out-of-bounds reads.
StageStridedLoads deliberately keeps using Load::make, as it rebuilds
loads specifically to get distinct node addresses for use as map keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
remake usually returns the node it was called on, having not consumed its arguments at all, so taking them by value made every callsite that passes an lvalue pay an atomic refcount increment and decrement for nothing. 239 of the 279 callsites pass lvalues. Taking them by const-ref shrinks libHalide's text by 26k. Lowering time is unchanged: on local_laplacian and lens_blur the difference between the two is under 0.5% and the two benchmarks disagree about the sign, so the refcounting was never measurable to begin with. Also drops the std::move wrappers at remake callsites, which bind to a const ref and so no longer move. The generated code is byte-for-byte identical with and without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most of the conflicts were mechanical: #9207 added an is_streaming field to Load and Store, so main added an is_streaming argument to the Load::make and Store::make calls that this branch had converted to remake. Load::remake and Store::remake now copy is_streaming from the node they are called on, so the remake form is kept at those 55 sites. Two resolutions were not mechanical: RemoveDeadAllocations now mutates new_expr, to mark an aliasing allocation's backing allocation as used. The three-argument Allocate::remake would have kept the original new_expr and discarded that, so this uses the four-argument overload instead. Bounds inference builds a Load to represent the bound of a load. On main that call predates is_streaming and so leaves it false, whereas remake inherits it. Left inheriting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
With the caveat that I understand this is bikeshedding... Can we consider using an existing name for this operation? I'd suggest I'd just prefer not to use a novel term ( I asked Opus 5 to put together a taxonomy of terminology across several programming languages and this is how it responded. Prompt: What are the different syntaxes in programming languages for persistent record updates? What is the usual nomenclature? Include for pure functional languages and functional-flavored frameworks in multi-paradigm/imperative languages.
|
|
Aside: I find it very funny (and a bit flattering) that Opus wanted to explain Perceus to me. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #9245 +/- ##
==========================================
+ Coverage 70.13% 70.30% +0.17%
==========================================
Files 255 255
Lines 78895 78678 -217
Branches 18865 18833 -32
==========================================
- Hits 55332 55316 -16
+ Misses 17866 17784 -82
+ Partials 5697 5578 -119 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
"remake" is a little clunky, but it has a few things going for it: First, it's obviously a variant of "make". Second, it's short. Third, most other choices imply something else in the compiler: "mutate" is already taken (IRMutator), as is "update" (update definitions). with_* is our convention elsewhere, but in this case, with what? with_new_children? with only really works in the examples if there are field names after it. I briefly considered just "with" but that looked weird. "clone_with" or "copy_with" start to sound like scheduling directives and I wanted to stay away from that part of the namespace. Sometimes I think it's good to just coin novel terms (e.g. RDom). They're awkward at first but the advantage of novelty is that you don't confuse them with something else that already exists. |
This is an experiment in adding methods to IR nodes to make a copy of them with some fields changed. It turned out to be a net deletion of code, moving a bunch of same_as boilerplate into methods inside IR.cpp. It also reduces code size slightly by moving that boilerplate behind a function call. No measurable effect on lowering time.
Claude says:
Constructing a new IR node from an existing one, changing only the child Exprs/Stmts, is a very common pattern in the compiler. Doing it with X::make requires respelling every unchanged field, which is verbose and makes it easy to silently drop a field when a new one is added to a node.
Add remake methods to the nodes where this happens most. Each takes only the node's children (plus the alignment describing a passed-in index, for Load and Store) and copies everything else from the node it is called on. They also return the original node when the new children are all the same as the existing ones, which subsumes the hand-written same_as guards that mutators would otherwise need. Since remake is a method and make is static, the two are distinguishable at a callsite even where they take the same arguments.
Load::remake takes the lane count of its type from the new index, since Load::make already required the two to agree.
Allocate::remake and IfThenElse::remake take new_expr and else_case with no default, because an undefined value is already meaningful for both ("use the default allocator", "no else clause") and so cannot also mean "leave it alone".
Converts the callsites where this applies and drops the same_as guards that remake makes redundant.
Also fixes a bug this exposed: the Allocate visitor in optimize_hexagon_instructions dropped the padding field when rebuilding an allocation. optimize_hexagon_shuffles runs earlier and sets padding on allocations holding vlut lookup tables so their loads can safely overread, so dropping it risked out-of-bounds reads.
StageStridedLoads deliberately keeps using Load::make, as it rebuilds loads specifically to get distinct node addresses for use as map keys.
Unlike make, remake takes args by const-ref because not mutating the ref-counts is the common-case (when the same_as logic fires).