Skip to content

Add TailCall optimization pass (--tail-call) - #9103

Merged
tlively merged 10 commits into
mainfrom
return-call-opt
Sep 16, 2026
Merged

tlively merged 10 commits into
mainfrom
return-call-opt

Conversation

@tlively

@tlively tlively commented Sep 11, 2026

Copy link
Copy Markdown
Member

Add a pass that converts call, call_indirect, and call_ref instructions in tail position into return calls. Whether expressions are in tail position is propagated down from parent expressions to children, so we need to do a pre-order traversal instead of our normal post-order traversal. Add a bespoke PreWalker class that additionally passes isTail to the various expression visitors. Keep track of the exception handling depth to avoid incorrectly turning calls inside exception handlers into return calls.

Add a top-down walker pass that converts call, call_indirect, and call_ref instructions in tail position into return calls. As it traverses the expression tree, it keeps a set of expressions known to be in tail position because they are children of returns, or the end of blocks or ifs in tail position, etc. When a call is found in tail position, it is turned into a return call. Keep track of the exception handling depth to avoid incorrectly turning calls inside exception handlers into return calls.
@tlively
tlively requested a review from a team as a code owner September 11, 2026 19:55
@tlively
tlively requested review from kripken and stevenfontanella and removed request for a team and stevenfontanella September 11, 2026 19:55
Comment thread src/passes/TailCall.cpp
@tlively

tlively commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

I've pushed your suggested approach in the last commit (although I would still need to go through and update test comments). It is possible to make the pass simpler, as you say, but IMO it is not a whole lot simpler and it's not worth the loss in optimization power since the original approach was also simple enough.

@KKiiim

KKiiim commented Sep 14, 2026

Copy link
Copy Markdown

Thanks for the alternative approach.

Comment thread test/lit/passes/tail-call.wast
Comment thread test/lit/passes/tail-call.wast
@tlively

tlively commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

I've pushed a third approach that uses a proper preorder traversal with custom infrastructure that lets us pass isTail as a parameter rather than storing it in a side table. This also lets us split the logic out into different visitors that propagate isTail as appropriate rather than doing everything in scan. This is the approach I think we should go with.

@KKiiim KKiiim 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.

LGTM

Comment thread src/passes/TailCall.cpp Outdated
// tail position is propagated down from parents to children. Define our own
// pre-order traversal task stack, and take the opportunity to pass `isTail`
// as an extra parameter to each task rather than storing it in a side table.
template<typename SubType> struct PreWalker {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PreWalker can be extracted from the current pass file and used as a shared helper class.

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 definitely of general use. Though I'm ok with leaving it here for now until we find another use case.

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'll leave it here for now. The part where we pass bool isTail as an extra argument would be more complicated to generalize.

@kripken kripken 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.

lgtm % comments

I do think an expression stack approach would be simpler, but this looks simple enough, so I don't feel strongly. And looks like this handles all the cases fully.

Comment thread src/passes/TailCall.cpp Outdated
}

Module* module = nullptr;
Function* func = nullptr;

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.

We can use getModule()/getFunction()

Comment thread src/passes/TailCall.cpp Outdated
PassRunner runner(module, options);
runner.setIsNested(true);
runner.add(create());
runner.run();

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 are we doing all this?

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 duplicated some infrastructure from WalkerPass. I've updated the implementation to use WalkerPass directly instead of duplicating this logic.

Comment thread src/passes/TailCall.cpp Outdated
PassRunner runner(module, getPassOptions());
runner.setIsNested(true);
runner.add("dce");
runner.runOnFunction(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.

Let's remove the manual addition of dce here: In general we don't add such operations in passes, and depend on general opts to handle later.

@tlively
tlively requested a review from kripken September 16, 2026 19:50
@tlively
tlively enabled auto-merge (squash) September 16, 2026 22:00
@tlively
tlively merged commit 0c068b7 into main Sep 16, 2026
16 checks passed
@tlively
tlively deleted the return-call-opt branch September 16, 2026 22:26
@kripken

kripken commented Sep 16, 2026

Copy link
Copy Markdown
Member

Fuzz bug:

(module
 (type $0 (func (param externref)))
 (rec
  (type $1 (func (result i32)))
  (type $2 (func (param v128) (result (ref struct))))
 )
 (type $3 (func))
 (import "fuzzing-support" "log-externref" (func $fimport$0 (type $0) (param externref)))
 (table $0 47 funcref)
 (elem $0 (i32.const 0) $0 $0 $0 $0 $0 $0 $1)
 (export "func_99_invoker" (func $2))
 (func $0 (type $2) (param $0 v128) (result (ref struct))
  (unreachable)
 )
 (func $1 (type $1) (result i32)
  (call $fimport$0
   (unreachable)
  )
 )
 (func $2 (type $3)
  (drop
   (unreachable)
  )
  (unreachable)
 )
)
bin/wasm-opt a.wat   --tail-call  -all
[wasm-validator error in function 1] return_call* callee return type must match caller return type, on 
(return_call $fimport$0
 (unreachable)
)
Fatal: error after opts

@tlively

tlively commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Looks like that DCE run was more load-bearing than I thought :)

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.

3 participants