Skip to content

New pass: PropagateStructGets - #9271

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

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

Conversation

@kripken

@kripken kripken commented Oct 16, 2026

Copy link
Copy Markdown
Member

Our main escape analysis pass, Heap2Local, does not handle overlapping
live ranges, things like this:

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

More than one allocation is being read from in those x.value operations,
so Heap2Local sees the uses are not "exclusive", and it gives up.

Handling live ranges, sort of like liveness of locals for register allocation /
local coalescing, is possible, but quite complex. However, the pattern
above can be optimized in another way, which this new pass handles:
when the reference is always read from (using struct.get, which is the
.value operations above), then we can just shift from using the reference
to using data:

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

We store the data in a new local x_value, and that is all we need.

That is, this pass handles a situation that is more restricted than
Heap2Local, in that it doesn't do complex analysis of control flow
and handling of ref.eq and such, but it is more general in that it
can handle overlapping live ranges in a trivial way.

Fixes #8641 , a Dart testcase, and I saw this helps real-world binaries from
Kotlin and Java too - this is a common pattern.

@kripken
kripken requested a review from tlively October 16, 2026 20:36
@kripken
kripken requested a review from a team as a code owner October 16, 2026 20:36
Comment on lines +54 to +56
// Heap2Local cannot optimize this due to the overlapping live ranges (it sees
// the x.value operations are not exclusive to any single allocation). But this
// pass can optimize into this:

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.

The reason Heap2Local can't handle this is because it only analyzes a single allocation at a time, rather than doing a single analysis phase covering all allocations in the function. This design limitation is also responsible for significant additional complexity in the pass. Have you experimented with fixing Heap2Local to analyze all allocations at once?

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.

Yes,

Handling live ranges, sort of like liveness of locals for register allocation / local coalescing, is possible, but quite complex

I had Gemini get this fully working, then simplify as much as it (and I) could, and it was... not pretty.

Basically you need to layer an additional LivenessAnalysis in Heap2Local, just adding a lot of complexity. Sadly it can't replace any existing part. It is even harder than a normal locals-based liveness because we must track allocations as they live in locals, but also as they flow - e.g. two allocations flowing through arms of the same If.

return std::make_unique<PropagateStructGets>();
}

// Track the number of gets for each local, so that we can see if all gets are

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.

Suggested change
// Track the number of gets for each local, so that we can see if all gets are
// Track the number of gets for each local, so that we can see if all the local.gets are

// The sets for each local index.
std::unordered_map<Index, std::vector<Expression**>> sets;

// The parent struct.gets for each local index's gets.

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.

Suggested change
// The parent struct.gets for each local index's gets.
// The parent struct.gets for each local index's local.gets.

I want to keep the different gets as separate as possible :)

// The parent struct.gets for each local index's gets.
std::unordered_map<Index, std::vector<StructGet*>> getParents;

// The parent struct.get for each tee.

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 briefly say what happens when a local.get or local.tee does not have a parent struct.get.

Comment on lines +124 to +126
// Avoid unreachable code, and leave atomic operations for now TODO
if (curr->type == Type::unreachable ||
curr->order != MemoryOrder::Unordered) {

@tlively tlively Oct 19, 2026 •

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.

There should be no need to leave atomic struct.gets for later. If the accessed allocations don't escape, they can't be part of any synchronization between threads, so they can be optimized just like non-atomic accesses.

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.

Also, if we can optimize structs that don't come from allocations, this would still be valid when all their fields are immutable. Immutable fields cannot participate in cross-thread synchronization.

return;
}

auto* module = getModule();

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.

Suggested change
auto* module = getModule();
auto* wasm = getModule();

Maybe there's some more nuanced pattern about where we use module vs wasm, but my impression is that we generally use wasm in passes, at least.

Comment on lines +208 to +212
if (teeIt == teeParents.end()) {
// This tee does not feed into a struct.get, so the allocation(s)
// might escape.
ok = false;
break;

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.

This also could have been checked directly via an ExpressionStackWalker rather than after the fact.

Comment on lines +226 to +228
// Each used field's value must not change between the local.set and
// local.get: either the field is immutable, or every set writes a fresh
// struct.new that only ever flows into this local's struct.gets. (Note

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 could the field's value change, given that we have ruled out struct.sets? Also, it is not clear which local.set and local.get this refers to.

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.

Gemini says we've only ruled out struct.sets through this particular local, but aliasing could allow other sets to slip through. It would be good to clarify that in the comment.

// bar(x_value, x_value);
//
// Note how the `.value` (the struct.get) happens *before* `foo()`,
// after the optimization, so we may have moved a trap around here. It is

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.

Don't we know the local.set values always come from allocations, though?

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.

Gemini says the pass also supports structs coming from parameters or calls as long as their fields are all immutable. It would be good to update the top-level comment to reflect that.


void visitTry(Try* curr) { hasTry = true; }

void visitFunction(Function* func) {

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 nice to break this up into helper functions. It's huge.

Comment on lines +648 to +651
;; As above, but the immutable field has a non-nullable reference type
;; ((ref struct)). The optimizeed local uses a nullable type (since it is
;; not dominated in the spec's sense by sets), and we add ref.as_non_null
;; on the tees and get so validation succeeds across blocks.

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.

"optimizeed" => "optimized"

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.

Optimization issue where allocation is not eliminated

2 participants