Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/fuzz_opt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2336,6 +2336,10 @@ def do_run(vm, js, wasm):
#
# Ignore it, as details of traces differ based on optimizations.
continue
elif not line:
# V8 may print blank lines before stack traces when the top
# frame has no script location (e.g. after a return_call to JS).
continue
cleaned.append(line)
cleaned = '\n'.join(cleaned)

Expand Down Expand Up @@ -2765,6 +2769,7 @@ def write_commands(commands, filename):
("--simplify-locals-notee",),
("--simplify-locals-notee-nostructure",),
("--ssa",),
("--tail-call",),
("--tuple-optimization",),
("--type-finalizing",),
("--type-refining",),
Expand Down
1 change: 1 addition & 0 deletions src/passes/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ set(passes_SOURCES
StackCheck.cpp
StripEH.cpp
SSAify.cpp
TailCall.cpp
TupleOptimization.cpp
TranslateEH.cpp
TypeFinalizing.cpp
Expand Down
324 changes: 324 additions & 0 deletions src/passes/TailCall.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/*
* Copyright 2026 WebAssembly Community Group participants
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

//
// Convert calls in tail position to return calls (tail calls).
//

#include <unordered_set>

#include "ir/effects.h"
#include "ir/properties.h"
#include "ir/utils.h"
#include "pass.h"
#include "wasm-traversal.h"
#include "wasm.h"

namespace wasm {

namespace {

// We are doing a pre-order traversal (i.e. parents before children) rather
// than the normal post-order traversal because whether an expression is in
// 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 : public Walker<SubType, Visitor<SubType>> {
using TaskFunc = void (*)(SubType*, Expression**, bool);

struct Task {
TaskFunc func;
Expression** currp;
bool isTail;
Task() = default;
Task(TaskFunc func, Expression** currp, bool isTail)
: func(func), currp(currp), isTail(isTail) {}
};

SmallVector<Task, 10> stack;

void push(Expression** currp, bool isTail) {
assert(*currp);
stack.emplace_back(doVisit, currp, isTail);
}

void maybePush(Expression** currp, bool isTail) {
if (*currp) {
stack.emplace_back(doVisit, currp, isTail);
}
}

Task popTask() {
auto ret = stack.back();
stack.pop_back();
return ret;
}

static void doVisit(SubType* self, Expression** currp, bool isTail) {
self->visit(*currp, isTail);
}

void walk(Expression*& root) {
assert(stack.empty());
push(&root, true);
while (!stack.empty()) {
auto task = popTask();
task.func(static_cast<SubType*>(this), task.currp, task.isTail);
}
}

void doWalkFunction(Function* func) { walk(func->body); }

void visitExpression(Expression* curr, bool isTail) {
assert(!Properties::isControlFlowStructure(curr) &&
"unexpected control flow structure");

#define DELEGATE_ID curr->_id
#define DELEGATE_START(id) [[maybe_unused]] auto* cast = curr->cast<id>();
#define DELEGATE_END(id)
#define DELEGATE_GET_FIELD(id, field) cast->field
#define DELEGATE_FIELD_CHILD(id, field) push(&cast->field, false);
#define DELEGATE_FIELD_OPTIONAL_CHILD(id, field) maybePush(&cast->field, false);
#define DELEGATE_FIELD_INT(id, field)
#define DELEGATE_FIELD_LITERAL(id, field)
#define DELEGATE_FIELD_NAME(id, field)
#define DELEGATE_FIELD_SCOPE_NAME_DEF(id, field)
#define DELEGATE_FIELD_SCOPE_NAME_USE(id, field)
#define DELEGATE_FIELD_TYPE(id, field)
#define DELEGATE_FIELD_HEAPTYPE(id, field)
#define DELEGATE_FIELD_ADDRESS(id, field)

#include "wasm-delegations-fields.def"
}

#define DELEGATE(CLASS_TO_VISIT) \
void visit##CLASS_TO_VISIT(CLASS_TO_VISIT* curr, bool isTail) { \
static_cast<SubType*>(this)->visitExpression(curr, isTail); \
}

#include "wasm-delegations.def"

void visit(Expression* curr, bool isTail) {
assert(curr);
switch (curr->_id) {
#define DELEGATE(CLASS_TO_VISIT) \
case Expression::Id::CLASS_TO_VISIT##Id: \
return static_cast<SubType*>(this)->visit##CLASS_TO_VISIT( \
static_cast<CLASS_TO_VISIT*>(curr), isTail);

#include "wasm-delegations.def"

default:
WASM_UNREACHABLE("unexpected expression type");
}
}
};

struct TailCall : public WalkerPass<PreWalker<TailCall>> {
bool isFunctionParallel() override { return true; }

std::unique_ptr<Pass> create() override {
return std::make_unique<TailCall>();
}

// Names of blocks whose exit flows directly out of the function.
std::unordered_set<Name> tailBlocks;
// Nesting depth of active exception handlers that catch or redirect
// exceptions within the current function.
size_t ehDepth = 0;
// Whether any call in the current function was converted to a return call.
bool changed = false;

void pushEnterTry() { stack.emplace_back(doEnterTryBody, nullptr, false); }

void pushLeaveTry() { stack.emplace_back(doLeaveTryBody, nullptr, false); }

static void doEnterTryBody(TailCall* self, Expression**, bool) {
++self->ehDepth;
}

static void doLeaveTryBody(TailCall* self, Expression**, bool) {
assert(self->ehDepth > 0);
--self->ehDepth;
}

bool hasUnremovableSideEffects(Expression* expr) {
return EffectAnalyzer(getPassOptions(), *getModule(), expr)
.hasUnremovableSideEffects();
}

bool allTargetsInTailBlocks(Switch* curr) {
if (!tailBlocks.contains(curr->default_)) {
return false;
}
for (auto target : curr->targets) {
if (!tailBlocks.contains(target)) {
return false;
}
}
return true;
}

bool isTailTransfer(Break* curr, bool isTail) {
if (!tailBlocks.contains(curr->name)) {
return false;
}
// Converting a call in a conditional branch or br_table to a return_call
// skips evaluating the condition, so the condition must not have side
// effects.
return !curr->condition ||
(isTail && !hasUnremovableSideEffects(curr->condition));
}

bool isTailTransfer(Switch* curr) {
return allTargetsInTailBlocks(curr) &&
!hasUnremovableSideEffects(curr->condition);
}

bool isTailTransfer(Expression* curr, bool isTail) {
if (curr->is<Return>()) {
return true;
}
if (auto* br = curr->dynCast<Break>()) {
return isTailTransfer(br, isTail);
}
if (auto* sw = curr->dynCast<Switch>()) {
return isTailTransfer(sw);
}
return false;
}
Comment thread
kripken marked this conversation as resolved.

template<typename CallType> void handleCall(CallType* call, bool isTail) {
// A call in tail position can have a type incompatible with the function's
// return type if it is dead code at the end of a block following an earlier
// unreachable instruction.
if (call->isReturn || !isTail ||
!Type::isSubType(call->type, getFunction()->getResults())) {
return;
}
if (ehDepth > 0 &&
ShallowEffectAnalyzer(getPassOptions(), *getModule(), call).throws()) {
return;
}
call->isReturn = true;
call->finalize();
changed = true;
}

void visitBlock(Block* curr, bool isTail) {
if (isTail && curr->name.is()) {
tailBlocks.insert(curr->name);
}
bool nextIsTail = isTail;
for (int i = int(curr->list.size()) - 1; i >= 0; --i) {
bool itemIsTail = false;
if (i == int(curr->list.size()) - 1) {
itemIsTail = isTail;
} else if (getFunction()->getResults() == Type::none &&
isTailTransfer(curr->list[i + 1], nextIsTail)) {
itemIsTail = true;
}
nextIsTail = itemIsTail;
push(&curr->list[i], itemIsTail);
}
}

void visitIf(If* curr, bool isTail) {
maybePush(&curr->ifFalse, isTail);
push(&curr->ifTrue, isTail);
push(&curr->condition, false);
}

void visitLoop(Loop* curr, bool isTail) { push(&curr->body, isTail); }

void visitBreak(Break* curr, bool isTail) {
bool valueIsTail = curr->value && isTailTransfer(curr, isTail);
maybePush(&curr->condition, false);
maybePush(&curr->value, valueIsTail);
}

void visitSwitch(Switch* curr, bool isTail) {
bool valueIsTail = curr->value && isTailTransfer(curr);
push(&curr->condition, false);
maybePush(&curr->value, valueIsTail);
}

void visitReturn(Return* curr, bool isTail) { maybePush(&curr->value, true); }

void visitTry(Try* curr, bool isTail) {
for (int i = int(curr->catchBodies.size()) - 1; i >= 0; --i) {
push(&curr->catchBodies[i], isTail);
}
// A try block that delegates directly to the caller does not catch any
// exceptions in this function; exceptions thrown in its body already unwind
// the frame to the caller just like a return_call would. All other try
// blocks (catch/catch_all or delegating to an outer try) establish a local
// handler that would be bypassed by return_call.
bool hasLocalHandler =
!curr->isDelegate() || curr->delegateTarget != DELEGATE_CALLER_TARGET;
if (hasLocalHandler) {
pushLeaveTry();
}
push(&curr->body, isTail);
if (hasLocalHandler) {
pushEnterTry();
}
}

void visitTryTable(TryTable* curr, bool isTail) {
bool hasLocalHandler = !curr->catchTags.empty();
if (hasLocalHandler) {
pushLeaveTry();
}
push(&curr->body, isTail);
if (hasLocalHandler) {
pushEnterTry();
}
}

void visitCall(Call* curr, bool isTail) {
handleCall(curr, isTail);
visitExpression(curr, false);
}

void visitCallIndirect(CallIndirect* curr, bool isTail) {
handleCall(curr, isTail);
visitExpression(curr, false);
}

void visitCallRef(CallRef* curr, bool isTail) {
handleCall(curr, isTail);
visitExpression(curr, false);
}

void doWalkFunction(Function* func) {
if (!getModule()->features.hasTailCall() || func->imported()) {
return;
}
tailBlocks.clear();
walk(func->body);
if (changed) {
ReFinalize().walkFunctionInModule(func, getModule());
}
}
};

} // anonymous namespace

Pass* createTailCallPass() { return new TailCall(); }

} // namespace wasm
3 changes: 3 additions & 0 deletions src/passes/pass.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,9 @@ void PassRegistry::registerPasses() {
registerPass("stack-check",
"enforce limits on llvm's __stack_pointer global",
createStackCheckPass);
registerPass("tail-call",
"convert calls in tail position to return calls",
createTailCallPass);
registerPass("strip-debug",
"strip debug info (including the names section)",
createStripDebugPass);
Expand Down
1 change: 1 addition & 0 deletions src/passes/passes.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ Pass* createStubUnsupportedJSOpsPass();
Pass* createSSAifyPass();
Pass* createSSAifyNoMergePass();
Pass* createTable64LoweringPass();
Pass* createTailCallPass();
Pass* createTranslateToExnrefPass();
Pass* createTupleOptimizationPass();
Pass* createTypeGeneralizingPass();
Expand Down
3 changes: 3 additions & 0 deletions test/lit/help/wasm-metadce.test
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@
;; CHECK-EMPTY:
;; CHECK-NEXT: --table64-lowering alias for memory64-lowering
;; CHECK-EMPTY:
;; CHECK-NEXT: --tail-call convert calls in tail position
;; CHECK-NEXT: to return calls
;; CHECK-EMPTY:
;; CHECK-NEXT: --trace-calls instrument the build with code
;; CHECK-NEXT: to intercept specific function
;; CHECK-NEXT: calls
Expand Down
3 changes: 3 additions & 0 deletions test/lit/help/wasm-opt.test
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,9 @@
;; CHECK-EMPTY:
;; CHECK-NEXT: --table64-lowering alias for memory64-lowering
;; CHECK-EMPTY:
;; CHECK-NEXT: --tail-call convert calls in tail position
;; CHECK-NEXT: to return calls
;; CHECK-EMPTY:
;; CHECK-NEXT: --trace-calls instrument the build with code
;; CHECK-NEXT: to intercept specific function
;; CHECK-NEXT: calls
Expand Down
3 changes: 3 additions & 0 deletions test/lit/help/wasm2js.test
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,9 @@
;; CHECK-EMPTY:
;; CHECK-NEXT: --table64-lowering alias for memory64-lowering
;; CHECK-EMPTY:
;; CHECK-NEXT: --tail-call convert calls in tail position
;; CHECK-NEXT: to return calls
;; CHECK-EMPTY:
;; CHECK-NEXT: --trace-calls instrument the build with code
;; CHECK-NEXT: to intercept specific function
;; CHECK-NEXT: calls
Expand Down
Loading
Loading