Home GSoC 2026: Native Wasm Tail Call Support in the Kotlin/Wasm Backend
Post
Cancel

GSoC 2026: Native Wasm Tail Call Support in the Kotlin/Wasm Backend

Overview

The goal of the project is to let the Kotlin/Wasm backend emit native Wasm tail call instructions (return_call and return_call_ref) at call sites that the existing tailrec lowering cannot reach. This covers mutual recursion, self recursion in functions where the developer did not annotate tailrec, virtually dispatched tail calls, interface dispatched tail calls, and any non self tail call. The motivating problem is that Wasm has no specified maximum stack depth and host engines impose their own ceilings, so any unbounded recursive structure other than a direct self recursive tailrec function currently risks trapping.

Work status

IR opcode primitives (PR 1, open)

Adds RETURN_CALL (0x12) and RETURN_CALL_INDIRECT (0x13) to the WasmOp enum. RETURN_CALL_REF (0x15) was already present. Adds buildReturnCall and buildReturnCallRef helpers on WasmExpressionBuilder for the upcoming consumer in BodyGenerator. Removes the @Ignore on BinaryCodecTest.tail-call so the upstream WebAssembly tail call spec test suite round trips through Kotlin’s binary and text encoders against wabt. Includes a localized text emitter workaround for a known wabt 1.0.19 parser bug (WebAssembly/wabt#2018).

Static dispatch emission (PR 2, open)

Adds WasmTailCallCollector, a pre pass that walks an IrFunction body and collects every IrCall that lexically appears in tail position. The visitor topology mirrors TailRecursionCallsCollector (covers IrWhen branches, IrBlock and IrContainerExpression tails, IrReturn children, and excludes IrTry) but drops the self call requirement and the tailrec specific filters. BodyGenerator consumes the side table at its static dispatch emit site and swaps call for return_call when the call is in the set and the eligibility filter passes. The eligibility filter rejects constructor callees and requires matching Wasm result type signatures between caller and callee. A WASM_ENABLE_TAIL_CALLS configuration key (default on) gates the entire feature for fall back to plain calls when needed. Also passes --enable-tail-call to binaryen so the post compile wasm-opt step accepts the new opcodes.

Virtual and interface dispatch (PR 3, draft)

Extends the BodyGenerator change to the vtable virtual dispatch path and the itable interface dispatch path. Both produce return_call_ref from the typed funcref already loaded onto the stack. The receiver load, ref cast, and struct get sequences stay identical, only the terminal opcode changes. Drafted in the working branch, ready to split out once PR 2 review is settled.

Stress and correctness tests (PR 4, draft)

Mutual recursion at depths up to 1M, virtual dispatch bouncing across class hierarchies. Targets multiple JS engines via the existing WasmVM test runner. Will not include benchmark code since the standalone benchmark project lives outside the Kotlin tree.

Design decisions

Pre pass with side table, not flag threading

BodyGenerator extends IrVisitorVoid with no context parameter. Threading an isTailPosition flag would require updating every container site (IrWhen branches, IrBlock last statement, IrReturn children, IrTry skip, Unit tail handling) with push and pop discipline. The pre pass produces a Set<IrCall> once per function and BodyGenerator queries it at emit sites. This keeps the existing visitor untouched and centralizes eligibility filters in one place.

Tailrec stays as a loop

TailrecLowering runs in WasmLoweringPhases before BodyGenerator, so direct self recursive tailrec functions are already rewritten as do while loops by the time codegen sees them. The loop form is faster and produces smaller code, so leaving it alone is the right call. Native tail calls target what loop transformation cannot express. The benchmarks below confirm this: at depth 10,000 the same arithmetic body runs at 270k ops/sec via tailrec and 220k ops/sec via the unmarked native tail call equivalent, so the loop lowering retains about a 20% advantage even after the native version benefits from frame reuse.

Verification done

Benchmarks

Used kotlinx-benchmark 0.4.17 in a standalone gradle project that consumes the locally installed compiler from feature/wasm-tail-calls/04-stress-tests. Engine is the Node.js binary shipped by the Kotlin/Wasm gradle plugin (V8 in Node 25, with default --wasm-inlining enabled). 3 warmups and 5 iterations per data point. Comparison OFF was produced by switching the Kotlin checkout to master (with no PR in this series applied) and reinstalling.

Five patterns are measured. Static mutual recursion, Non tailrec self recursion, Virtual dispatch mutual, Interface dispatch mutual, and tailrec lowered to a loop.

Throughput in operations per second, higher is better. ON / OFF is the ratio.

patterndepthOFFONON / OFF
static mutual recursion10018,528,95321,064,2221.14 x
static mutual recursion1,0001,009,3052,471,5702.45 x
static mutual recursion10,00092,452251,8532.72 x
non tailrec self recursion10012,365,15720,727,0111.68 x
non tailrec self recursion1,000526,1902,243,4924.26 x
non tailrec self recursion10,00051,606223,2954.33 x
virtual dispatch mutual10011,533,44713,255,1881.15 x
virtual dispatch mutual1,000658,5841,097,2601.67 x
virtual dispatch mutual10,00060,498102,2301.69 x
interface dispatch mutual1001,849,4853,956,2552.14 x
interface dispatch mutual1,000182,777428,9912.35 x
interface dispatch mutual10,00014,02542,0663.00 x
tailrec lowered to a loop10030,714,99730,242,0670.98 x
tailrec lowered to a loop1,0002,650,1392,557,4740.97 x
tailrec lowered to a loop10,000272,930272,9791.00 x

Per-call cost at depth 10,000

  • Every non tailrec benchmarks gets faster with the feature on, and the gain grows with depth. Without the feature each recursive call pushes a new frame. With the feature each tail call reuses the caller frame, and the per call cost stops growing with depth.
  • tailrec loops are at parity, confirming the design choice to leave the existing lowering alone. Native tail calls only handle what loop lowering cannot express.
  • The absolute throughput ordering is static < self < virtual < interface in terms of per call cost, which mirrors the underlying dispatch chain. Static is a single call instruction, virtual adds a vtable struct get, and interface goes through vtable plus a ref cast.

Beyond the host stack limit

The numbers above stay within depths that V8 can handle in either configuration. The case the feature actually exists for is the one V8 cannot survive without it. To probe that directly I added a DepthStress benchmark class that calls each of the four patterns at depth 1,000,000.

On master the depth 1M run throws RangeError: Maximum call stack size exceeded partway through the recursion and kills the Node process.

1
2
3
4
5
6
7
8
… bench.DepthStress.interfaceMutualAt1M
RangeError: Maximum call stack size exceeded
    at null.<anonymous> (wasm://wasm/000f163a:1:149024)
    at null.<anonymous> (wasm://wasm/000f163a:1:149096)
    at null.<anonymous> (wasm://wasm/000f163a:1:195899)
    at null.<anonymous> (wasm://wasm/000f163a:1:195911)
    at null.<anonymous> (wasm://wasm/000f163a:1:195911)
    ...

With the feature enabled all four patterns complete.

Process so far

Initial design exploration was bottom up. I read TailrecLowering, BodyGenerator, Operators.kt, and the WasmLoweringPhases ordering, then sketched the eligibility filter list against the failure modes (try catch exclusion, signature mismatch, intrinsic handling, constructor receiver issues). After that the work has been one PR at a time, each gated on local tests and a regression sweep.

There were two unexpected findings during the work.

  1. The wabt 1.0.19 parser bug (WebAssembly/wabt#2018) blocks the spec test even after the opcode is added. The simplest fix is a localized text emitter workaround for return_call_indirect. I investigated upgrading wabt, but the upgrade chains into a testsuite revision bump that surfaces preexisting IR layer text emitter and parser bugs around reference types canonical forms. Some of those tests live in files that previously passed (binary.wast and binary-leb128.wast grew bulk memory binary edge cases), so skipping them would regress MVP coverage. Belongs as a separate effort coupled with an IR layer text emitter and parser refresh.

  2. BinaryenConfig.kt did not pass --enable-tail-call to wasm-opt. Without that flag, any Kotlin/Wasm module containing return_call is rejected by binaryen with unexpected false: return_call* requires tail calls. Added the one line fix as a separate commit on PR 2. This is a hard requirement for the feature to work end to end.

Follow ups

Three items I want to track explicitly.

  • wabt 1.0.33 upgrade. The text emitter workaround in WasmIrToText.kt exists because wabt 1.0.19 only accepts the canonical return_call_indirect (type X) form. The parser was fixed upstream by WebAssembly/wabt#2049 and wabt 1.0.33+ accepts both forms. Once the Kotlin test infrastructure moves to a newer wabt, the workaround branch is one line to delete and WasmOp.RETURN_CALL_INDIRECT should be added to the reversed immediate set alongside CALL_INDIRECT and TABLE_INIT. The blocker is that bumping wabt also bumps the upstream spec testsuite, which surfaces preexisting IR layer text emitter and parser issues unrelated to this work, so the upgrade belongs in its own PR.
  • Decision on whether to expose WASM_ENABLE_TAIL_CALLS externally. The configuration key exists but is currently internal. There is no CLI argument, no Kotlin Gradle Plugin DSL setter, and no -P property bridge, which is why the benchmark project in this writeup had to swap branches to compare ON and OFF. Exposing the key via something like -Xwasm-enable-tail-calls=false would let downstream projects opt out without rebuilding the compiler, but doing so adds a public surface to the compiler API. Whether that is desirable, what the naming should look like, and whether the flag should be experimental or stable are all questions for the Kotlin team rather than something to commit to unilaterally.
This post is licensed under CC BY 4.0 by the author.