Skip to content

Add remake methods to IR nodes for copying with new children - #9245

Open
abadams wants to merge 3 commits into
mainfrom
abadams/remake_ir_nodes
Open

Add remake methods to IR nodes for copying with new children#9245
abadams wants to merge 3 commits into
mainfrom
abadams/remake_ir_nodes

Conversation

@abadams

@abadams abadams commented Jul 27, 2026

Copy link
Copy Markdown
Member

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

abadams and others added 3 commits July 27, 2026 14:29
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>
@alexreinking

Copy link
Copy Markdown
Member

With the caveat that I understand this is bikeshedding...

Can we consider using an existing name for this operation? I'd suggest update, with, or if you want to make it clear that a copy happens, copy_with.

I'd just prefer not to use a novel term (remake) for a common operation.

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.

Nomenclature

The umbrella term is functional record update — producing a new record that agrees with the old one except at named fields. Common near-synonyms, roughly by community:

  • copy-and-update expression (F#, C# spec language; C# also says non-destructive mutation)
  • record update syntax (Haskell, Elm, PureScript)
  • struct update syntax (Rust)
  • delta aggregate (Ada 2022)
  • wither / withX methods (Java, C++ builder style)
  • copy / copyWith (Scala, Kotlin, Dart)
  • spread (JS/TS object spread; Python {**d})
  • evolve / assoc / put-in (Clojure, Ramda, Elixir)
  • override / merge (Nix //, row-typed languages)
  • lens / optic, with setter, set/over, focus for the nested case

Underneath, the implementation vocabulary is Driscoll et al.'s: path copying, structural sharing, partial/full/confluent persistence.

Dedicated update expressions

with-family (record on the left, fields on the right):

OCaml/Reason  { r with x = 1; y }        (* punning allowed *)
F#            { r with X = 1 }
Lean 4        { r with x := 1 }
C# 9+         r with { X = 1 }
Ada 2022      (R with delta X => 1)
Ur/Web        r -- #x ++ {x = 1}         (* restriction ++ extension *)
Nix           r // { x = 1; }

Brace-suffix (juxtaposition, no keyword):

Haskell       r { x = 1 }                -- type-changing allowed
PureScript    r { x = 1, a { b = 2 } }   -- nested; _ { x = 1 } is a section
Erlang        R#rec{f = V}               -- maps: M#{k := V} vs M#{k => V}
Idris 2       { x := v, y $= f } r       -- $= applies a function

Pipe / separator forms:

Elm           { r | x = 1 }              -- r must be a name, not an expression
Elixir        %{m | k: v}                -- raises if k absent
              %Struct{s | f: v}

Rust is the odd one — Foo { x: 1, ..base } moves the remaining fields out of base unless they're Copy, so it's an update only incidentally; it's really aggregate initialization with a defaulting tail.

Method- and function-based (no syntax)

Scala/Kotlin  r.copy(x = 1)              -- compiler-generated for case/data classes
Dart          r.copyWith(x: 1)           -- convention; freezed generates it
Python        dataclasses.replace(r, x=1); attrs.evolve; nt._replace(x=1)
Racket        (struct-copy point p [x 1])
Clojure       (assoc m :x 1) / (update m :x f) / (assoc-in ...) / (update-in ...)
JS            {...o, x: 1} / Object.assign({}, o, {x: 1})
Java          r.withX(1)                 -- Immutables/AutoValue; JEP draft for `with { }`

Standard ML notably has no record update at all — the standard workaround is generated or combinator-built update functions, which is why SML# and various Successor-ML proposals kept revisiting it.

Swift also has none: you write var c = r; c.x = 1, relying on value semantics plus copy-on-write, which is semantically an update expression with the sharing decision deferred to isKnownUniquelyReferenced.

Nested updates

Flat syntax doesn't compose through nesting, so every ecosystem grows a second layer:

  • Optics: Haskell lens (r & a . b .~ v, %~ for over), Scala Monocle (r.focus(_.a.b).replace(v)), PureScript profunctor-lenses, Rust lens-rs. The Lens s t a b four-parameter form exists precisely to model type-changing update.
  • Path functions: Clojure assoc-in/update-in, Specter's navigators; Elixir put_in/update_in over the Access protocol; Ramda assocPath/evolve.
  • Mutable-draft interpreters: Immer's produce(base, draft => { draft.a.b = 1 }) — a proxy records mutations to a draft and replays them as path copying. Nice example of surface-syntax imperative, semantics persistent.

Axes that actually differ (probably the interesting part)

  1. Update vs. extension vs. restriction. Elm, Haskell, OCaml permit update only. Row-polymorphic systems (PureScript, Ur/Web, Rows/Links, OCaml objects via {< x = 1 >}) separate { ρ | x :: A } update from field addition/removal, and type the update polymorphically in the rest-row.
  2. Type-changing update. Haskell's r { x = "s" } may instantiate a different type argument; C#'s with and Scala's copy cannot. This is the whole reason Lens is quantified over s t a b.
  3. Missing-field behavior. Erlang := requires presence, => inserts; Elixir's | raises, Map.put doesn't. Statically-typed languages fold this into the type.
  4. Name resolution. Haskell's field-selector-as-function design made duplicate field names painful; DuplicateRecordFields, HasField, setField, and OverloadedRecordUpdate (GHC 9.2+) are all patches around the fact that r { x = 1 } needs to resolve x without a principal type for r.
  5. Subject generality. Elm restricts the updated expression to a variable; Haskell/OCaml/F# allow arbitrary expressions.

The compilation angle

Since you're in the HPC/PL space, the part worth flagging is that "persistent" is a semantic claim, not an allocation strategy. Several systems recover in-place update:

  • Uniqueness types (Clean) and ownership (Rust) prove the old value is dead statically.
  • Reference-count reuse: Koka's Perceus with FBIP, and Lean 4's runtime, both turn { r with x := v } into an in-place field store when the RC is 1 — so a functional-update-heavy inner loop can compile to the same code as destructive mutation.
  • Copy-on-write (Swift, C++ shared_ptr idioms) makes the same decision dynamically but per-object rather than per-field.

For record-of-arrays workloads this matters a lot: path copying an n-field record is O(n) word copies, which is fine, but the same syntax over a nested tree is O(depth) allocations per update, and that's where lens-heavy code quietly loses to a mutable accumulator unless the reuse analysis fires.

@alexreinking

Copy link
Copy Markdown
Member

Aside: I find it very funny (and a bit flattering) that Opus wanted to explain Perceus to me.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.55721% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.30%. Comparing base (faa3500) to head (d0323a4).

Files with missing lines Patch % Lines
src/IR.cpp 95.04% 0 Missing and 6 partials ⚠️
src/AsyncProducers.cpp 86.84% 5 Missing ⚠️
src/LowerWarpShuffles.cpp 0.00% 5 Missing ⚠️
src/CodeGen_Hexagon.cpp 33.33% 4 Missing ⚠️
src/RemoveUndef.cpp 66.66% 3 Missing ⚠️
src/CodeGen_LLVM.cpp 66.66% 2 Missing ⚠️
src/CodeGen_PTX_Dev.cpp 0.00% 2 Missing ⚠️
src/Deinterleave.cpp 80.00% 1 Missing and 1 partial ⚠️
src/HexagonOptimize.cpp 66.66% 2 Missing ⚠️
src/PartitionLoops.cpp 83.33% 2 Missing ⚠️
... and 10 more
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.
📢 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.

@abadams

abadams commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

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

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.

2 participants