Skip to content

Optimize resume of non-suspending continuations - #9071

Merged
tlively merged 15 commits into
mainfrom
directize-resume
Sep 9, 2026
Merged

Optimize resume of non-suspending continuations#9071
tlively merged 15 commits into
mainfrom
directize-resume

Conversation

@tlively

@tlively tlively commented Sep 2, 2026

Copy link
Copy Markdown
Member

Add a visitResume in OptimizeInstructions that does the normal optimizations on null continuations and then tries to turn resumes into calls. We can do this when we are resuming a freshly allocated continuation created with a reference to a known function that GlobalEffects tells us will not suspend.

This will be necessary for a future optimization that turns resumes of continuations that never suspend into calls. Update the effect analysis of suspends to set the new effect and clobber global state because the suspend handler might do anything before returning. Test that the effects are analyzed as intended and that they work with global effect analysis.
Add a `visitResume` in OptimizeInstructions that does the normal optimizations on null continuations and then tries to turn resumes into calls. We can do this when we are resuming a freshly allocated continuation created with a reference to a known function that GlobalEffects tells us will not suspend.
@tlively
tlively requested a review from a team as a code owner September 2, 2026 19:00
@tlively
tlively requested review from a team, kripken and stevenfontanella and removed request for a team and stevenfontanella September 2, 2026 19:00
Comment thread src/passes/OptimizeInstructions.cpp Outdated
Comment on lines +1514 to +1544
// If the continuation expression has no side effects, we can eliminate it
// entirely and replace the resume with a direct call.
if (!effects(curr->cont).hasSideEffects()) {
replaceCurrent(
builder.makeCall(target->name, curr->operands, target->getResults()));
return;
}

// The continuation expression has side effects. In Wasm, resume operands
// are evaluated before the continuation expression. If there are no
// operands, evaluate the continuation (dropped) and then call.
if (curr->operands.empty()) {
replaceCurrent(builder.makeSequence(
builder.makeDrop(curr->cont),
builder.makeCall(target->name, {}, target->getResults())));
return;
}

// In the presence of operands, execute the code in curr->cont after the
// operands and before the call happens by spilling the last operand to a
// temporary local.
auto* lastOperand = curr->operands.back();
auto lastOperandType = lastOperand->type;
Index tempLocal = builder.addVar(getFunction(), lastOperandType);
auto* set = builder.makeLocalSet(tempLocal, lastOperand);
auto* drop = builder.makeDrop(curr->cont);
auto* get = builder.makeLocalGet(tempLocal, lastOperandType);
curr->operands.back() = builder.makeBlock({set, drop, get});
replaceCurrent(
builder.makeCall(target->name, curr->operands, target->getResults()));
}

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.

Can we use ChildLocalizer to simplify all this code 1514-1544?

That is, put all the operands of curr in locals, then make a block that ends with a call?

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 don't think so. ChildLocalizer will remove children without side effects, but we're trying to hook up the children to a new parent.

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 doesn't remove children, it puts them into locals if they have effects. After it runs, the updated children are simple things without effects, which can be moved.

After running ChildLocalizer on the cont.new that has the children we want to move, we can then simply copy the updated children to the new place. We will be copying things like local.gets.

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.

Ah, right. That sounds like it would work. Thanks!

@tlively
tlively requested a review from kripken September 3, 2026 18:45
Base automatically changed from suspends-effect to main September 3, 2026 18:49
Comment thread src/ir/effects.h

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 is from the other PR I think? Do stacked PRs not hide the shared parts?

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 isn't stacked as far as GH is concerned because I've been trying to keep using merges instead of rebases. I guess it didn't help in this case.

Comment thread src/passes/OptimizeInstructions.cpp Outdated
}

auto* funcExpr = Properties::getFallthrough(
contNew->func, getPassOptions(), *getModule(), behavior);

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
contNew->func, getPassOptions(), *getModule(), behavior);
contNew->func, getPassOptions(), *getModule());

The fallthrough of the function doesn't need to care about other uses and traps and such.


auto* block =
ChildLocalizer(curr, getFunction(), *getModule(), getPassOptions())
.getChildrenReplacement();

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 realize that this is almost but not quite right. ChildLocalizer considers child effects wrt each other. After the operation, it is safe to reorder and remove them. But moving them across other effects might not be safe.

Unless we know no other effects can be in the middle, here? But it seems like there can be:

(resume
  (OPERANDS)
  (block
    (STUFF A)
    (cont.new
      (block
        (STUFF B)
        (ref.func)
      )
    )
  )
)

=>

(STUFF A)
(STUFF B)
(call
  (OPERANDS)
)

OPERANDS is moved past the STUFFs.

This is simple to handle, though: add a flag to ChildLocalizer to control this behavior. Where it computes

      // Use a local if we need to. That is the case either if this has side
      // effects we can't remove, or if it interacts with other children.

then, we can have a mode where it uses a local for any child with effects, even removable ones.

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.

But ChildLocalizer sees all the children of the resume, including the one that has STUFF A and STUFF B here, so I think this is safe. See the $test-eval-order test, for example.

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.

Oh, right... all the last child (with STUFF) is, is just another child for ChildLocalizer. Nice it works out so well!

@tlively
tlively requested a review from kripken September 9, 2026 21:14

auto* block =
ChildLocalizer(curr, getFunction(), *getModule(), getPassOptions())
.getChildrenReplacement();

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.

Oh, right... all the last child (with STUFF) is, is just another child for ChildLocalizer. Nice it works out so well!

;; Test that optimize-instructions turns resumptions of continuations that never
;; suspend into direct calls.

;; RUN: wasm-opt %s --all-features --generate-global-effects --remove-unused-names --optimize-instructions -S -o - | filecheck %s --check-prefix NO-TNH

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.

is removed-unused-names needed here and below?

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.

Nope, removed.

;; VACUUM-NEXT: )
;; VACUUM-NEXT: )
(func $test-eval-order-non-nullable (param $x (ref any)) (result (ref any))
;; Evaluation order test: non-nullable operands are preserved when spilled.

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.

Which non-nullable operand is "spilled" here? I was guessing x but no local is added for it.

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.

Yeah, this test seems obsolete now that we're using ChildLocalizer. I've removed it.

@tlively
tlively enabled auto-merge (squash) September 9, 2026 22:43
@tlively
tlively merged commit 3e2bcc0 into main Sep 9, 2026
16 checks passed
@tlively
tlively deleted the directize-resume branch September 9, 2026 23:26
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