Skip to content

New pass: HeapUntangling - #9330

Open
kripken wants to merge 134 commits into
WebAssembly:mainfrom
kripken:psgs
Open

kripken wants to merge 134 commits into
WebAssembly:mainfrom
kripken:psgs

Conversation

@kripken

@kripken kripken commented Oct 30, 2026

Copy link
Copy Markdown
Member

This is a form of escape analysis, like Heap2Local, but it can handle multiple
allocations which Heap2Local can't, but otoh those must fit a simple, easy-to
identify pattern.

The pattern here is that while structs are allocated and used, all the uses
are simple struct.gets/sets. So we can just use the data in the fields
directly:

   x = new Foo(42);
   if (condition) x = new Foo(1337);
   else x.value = -1;
   bar(x.value, x.value);

=>

   x_value = (new Foo(42)).value;
   if (condition) x_value = (new Foo(1337)).value;
   else x_value = -1;
   bar(x_value, x_value);

We add a new x_value local here which stores the field, and write to
it from struct.new/struct.set, and read from it in struct.get. If those
are the only operations - no escape of the reference, and no ref.eq
etc. - then this is clearly valid to do. And after doing it, the allocations are
"untangled" - each is used just to write its fields, so their live ranges do
not overlap, and Heap2Local will remove them all. So this new pass,
when it optimizes, always leads to beneficial results, without tradeoffs.

This is a reboot of #9271, with the changes that it handles struct.sets,
and does not handle unknown values (the latter introduced unclear
tradeoffs). As a result the code in the pass is a lot simpler now.

This helps #8641 and almost fixes it. Specifically, once we merge
the two locals there, then this new pass will do all the rest. And we
should be getting those locals merged either through @stevenfontanella's
nullability pass, or my TypeGeneralizing pass.

Aside from helping that original testcase in #8641 (after I manually
merged the locals), this also helps 3/4 large wasm testcases I tried on,
and has no downside on 1/4.

@kripken
kripken requested a review from tlively October 30, 2026 23:03
@kripken
kripken requested a review from a team as a code owner October 30, 2026 23:03

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new optimization pass, HeapUntangling, which performs multi-allocation escape analysis to cleanly separate multiple allocations written to a single local. Feedback on the implementation suggests clearing the localInfos and optimizedLocals collections at the start of doWalkFunction to prevent stale data from being carried over across functions. Additionally, it is recommended to explicitly mark a local as unoptimizable when a LocalSet (tee) is used as the reference of a StructSet instead of relying on an implicit count mismatch.

Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved

@tlively tlively left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments so far. Still need to read the tests.

Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative name for your consideration: LocalUnboxing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, "unboxing" is interesting, but "local" doesn't seem as clear as something with Heap. HeapUnboxing? But I like that untangling clarifies that we are separating/making something simpler.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Local" works with two meanings: the pass is function-local (similar to LocalCSE, although that might mean block-local) and specifically unboxes values stored in locals.

HeapUnboxing or Unboxing both suggest a more global optimization to me.

Untangling certainly suggests that we are making something simpler, but it communicates almost nothing about how.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think what I don't like about unboxing is that it doesn't refer to the core aspect of handling multiple (tangled) allocations. That is, HeapUnboxing could have been the name of Heap2Local.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point that "unboxing" doesn't apply to this any more or less than it would to Heap2Local. Really this is just another version of Heap2Local that does a different escape analysis with different strengths and weaknesses. SimpleHeap2Local???

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MultiHeap2Local...?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't mind MultiHeap2Local.

Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved
Comment thread src/passes/HeapUntangling.cpp Resolved
auto localType = getFunction()->getLocalType(localIndex);
auto& fields = localType.getHeapType().getStruct().fields;

// Store the reference, then store struct.gets of all used fields in the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why even bother keeping the reference around for Heap2Local to clean up? Wouldn't it be simple to eliminate the struct.new completely because we know it is immediately set to a local we are unboxing?

@kripken kripken Nov 2, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eliminating the struct.new could be done, but we'd need to handle effects and default values and maybe other stuff I'm forgetting (edit: we'd need to handle the descriptor too). It is trivial to just keep the struct.new and do struct.gets from it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like it would be worth it, though. Right now this pass is useless without a follow-on Heap2Local, but only because it leaves these allocations around. If it removed the allocations, it would stand completely on its own. This would at the very least simplify the tests, but would also let us experiment with running it in more places in the optimization pipeline.

And it should be pretty simple: ChildLocalizer + ref.as_non_null on the descriptor, if it exists.

(Unless there are more complications that I'm not thinking about.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I let Gemini implement this, and it had to also handle local interactions:

(local.set $x
  (struct.new
    (i32.const 42)
    (struct.get 0
      (local.get $x)
    )
  )
)

=>

(local.set $x_field0
  (i32.const 42)
)
(local.set $x_field1
  (local.get $x_field0) ;; oops, this is 42, but shouldn't be
)

It is possible to detect such short-circuits but it seems messy... I think it's better to leave all that to Heap2Local?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, fair enough. That is more complicated than I was thinking.

kripken and others added 8 commits November 2, 2026 12:38
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>

@tlively tlively left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to see if we can get coverage for the remaining uncovered branches as well.

(func $optimize-struct-get-different-fields (param $x i32) (result i32)
;; Optimize when gets of $ref read different fields (field 0 and field 1).
(local $ref (ref null $struct-2fields))
(if

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all the tests need this if-then-else structure? It's definitely important to show we can handle it, but then for some of these other tests, I think a sequence of (drop (struct.get (local.get $x))) and (struct.set (local.get $x)...) would be sufficient.

I guess this is showing that Heap2Local benefits? Again, I think we can cover that in just a couple tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A straightline sequence would have dead code, which I think makes it harder to read. That is, the if-else sequence makes it more realistic. Also more robust to possible future code changes.

)
)
(drop
(local.get $no)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this different from the previous test with a dropped local.get? Is it just that there's also a local.tee in this one?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test has locals $yes and $no, and optimizes one and not the other. I'll add to the comment This tests that one local does not interfere with another.

@kripken

kripken commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

It would be good to see if we can get coverage for the remaining uncovered branches as well.

Where are you seeing that data? Is the new bot running somewhere I can read its output?

This branch has not been deployed

No deployments
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