Home GSoC 2026 Final Report: Tail call support in the Kotlin/Wasm backend
Post
Cancel

GSoC 2026 Final Report: Tail call support in the Kotlin/Wasm backend

Project

Description

This project integrates the WebAssembly tail call proposal into the Kotlin/Wasm compiler backend. Wasm’s call stack limit is unspecified and varies across host engines. Kotlin’s tailrec rewrites simple self-recursion into a loop, but it cannot handle mutual recursion or indirect calls. The tail call proposal adds return_call and return_call_ref instructions that reuse the caller’s frame, reducing stack consumption from O(n) to O(1) regardless of recursion structure. I implemented these instructions in the backend so that the compiler detects calls in tail position and emits the corresponding Wasm tail call instruction.

What work was done

Merged upstream

JetBrains/kotlin#6396

This PR adds the -Xwasm-enable-tail-calls compiler flag. With it enabled, the compiler detects calls in tail position and emits return_call for static dispatch and return_call_ref for virtual and interface dispatch. This is the main contribution of this project.

Benchmarking

I measured these with kotlinx-benchmark in a standalone project, running on V8 via Node 25.

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.
  • At depth 1,000,000, V8 throws RangeError: Maximum call stack size exceeded without tail calls. With tail calls, all four patterns complete.

V8 bug fix

[wasm] Apply WKI fast path for return_call to imported functions

Benchmarking the tail call compiler on Compose Multiplatform, JetBrains/markdown, and rhizomedb showed up to 45% slowdown on V8 as described in this blog post.

I traced the cause to V8’s Turboshaft graph builder, where ReturnCall did not check HandleWellKnownImport. CallDirect inlines wasm:js-string builtins to skip the JS-Wasm bridge, but ReturnCall always went through the bridge. Kotlin compiles string operations to thin wrappers around these builtins, and the tail call compiler rewrites the forwarding call inside each wrapper to return_call, so every string operation fell off the fast path. The fix adds the same WKI check to ReturnCall and makes the slowdown disappear.

What’s left to do

#19 callRef tail calls

Function references like ::foo dispatch through an invoke bridge that was not emitting tail calls even when the call was in tail position. In this PR, I modified the codegen to emit return_call_ref when the Wasm-level return types match. This covers indirect higher-order calls through function references.

#16 Accumulator recursion lowering

When a recursive call is wrapped in an associative operator like return 1 + self(n-1), it is not in tail position. In this PR, the lowering rewrites the function to carry an intermediate result so that the recursive call moves to tail position. It covers plus, times, and, or, xor on Int/Long and plus on String.

Explored

I tried two additional approaches so that the compiler emits more return_call.

#14 Tail-modulo-cons lowering

Based on this paper, I created an experimental implemetation of Tail-modulo-cons lowering.

When a recursive call is wrapped in a constructor like return Cons(x, self(n-1)), it is not in tail position. In this PR, the lowering rewrites such functions into destination-passing style, where the constructor is allocated first with a null placeholder, the recursion runs as a tail call, and the result is patched in afterward. It handles both self-recursion and mutual recursion cycles of any size, and reports a compilation error when the pattern cannot be transformed.

#20 CPS lowering

This lowering handles recursive calls that none of the above patterns cover by rewriting them into continuation-passing style. I defunctionalize the continuation into typed heap frames and a trampoline loop. The prototype passes tests, but each recursive call allocates one heap frame, leading to slowdowns. I think I should introduce a hybrid approach in which this approach is only applied when the depth is more than a threshold. It needs more performance tuning.

Acknowledgement

Thank you to my mentor Charlie Zhang san for his guidance throughout the project. He pointed me to how to take benchmarks early on, which led to the V8 bug discovery. He also sent me the papers that shaped the TMC lowerings. I also thank other maintainers for taking time to review the PR, Filipp Zhinkin san for reviewing my onboarding PR, and the Kotlin Foundation and Google Summer of Code for making this project possible.

This post is licensed under CC BY 4.0 by the author.