Home Kotlin/Wasm: Accumulator Recursion Lowering
Post
Cancel

Kotlin/Wasm: Accumulator Recursion Lowering

The pattern

Some recursive functions wrap their self-call in an associative operator.

1
2
3
4
fun countDown(n: Int): Int {
    if (n == 0) return 0
    return 1 + countDown(n - 1)
}

countDown(n - 1) is not in tail position because 1 + wraps the result. -Xwasm-enable-tail-calls cannot help, and tailrec rejects this shape. On V8, the function overflows around depth 10,000.

PR #16 adds WasmAccumulatorRecursionLowering, which rewrites this into accumulator-passing form so the self-call lands in tail position.

The transform

The compiler generates a private $accum helper that threads the pending operand as an extra parameter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Original
fun countDown(n: Int): Int {
    if (n == 0) return 0
    return 1 + countDown(n - 1)
}

// Generated helper
private fun countDown$accum(n: Int, acc: Int): Int {
    if (n == 0) return acc + 0     // base case folds the accumulator in
    return countDown$accum(n - 1, acc + 1)  // tail position
}

// Original is rewritten to delegate
fun countDown(n: Int): Int = countDown$accum(n - 1, 1)

The self-call in countDown$accum sits in tail position, so WasmTailCallLowering emits return_call. After the transform, countDown(1_000_000) runs in constant stack.

Supported operators

The lowering targets two categories.

Commutative operators on Int and Long require only one accumulator because a ⊕ b = b ⊕ a. The operand can appear on either side of the recursive call.

OperatorTypeExample
+Int, Longreturn n + sumTo(n - 1)
*Int, Longreturn n * factorial(n - 1)
andInt, Longreturn maskChain(n - 1, bit) and bit
orInt, Longreturn maskChain(n - 1, bit) or bit
xorInt, Longreturn xorChain(n - 1, bit) xor bit

String.plus is associative but not commutative. The lowering handles it for one-sided patterns by preserving operand order.

1
2
3
4
fun repeatStr(s: String, n: Int): String {
    if (n == 0) return ""
    return s + repeatStr(s, n - 1)  // right-recursive: operand on the left
}

The generated helper accumulates from the left, building (((seed + s) + s) + s), which produces the same result as s + (s + (s + seed)) because String.plus is associative.

1
2
3
4
private fun repeatStr$accum(s: String, n: Int, acc: String): String {
    if (n == 0) return acc + ""
    return repeatStr$accum(s, n - 1, acc + s)
}

Performance

kotlinx-benchmark 0.4.17 in a standalone project, Kotlin 2.5.255-SNAPSHOT, Node.js v24.12.0 (V8 13.6). 5 warmups, 10 iterations, 1 second each. AccumulatorIntro.kt measures three accumulator patterns at three depths.

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

patterndepthOFFONON / OFF
Int addition10012,292,12425,305,9932.06 x
Int addition1,000571,3062,801,2264.90 x
Int addition10,00047,150253,0015.37 x
Int multiplication1007,732,4768,709,1361.13 x
Int multiplication1,000563,647964,2551.71 x
Int multiplication10,00054,915102,3861.86 x
String concatenation100997,2041,103,1081.11 x
String concatenation1,00090,058106,3301.18 x
String concatenation10,0005,4265,7671.06 x

Accumulator lowering per-call cost at depth 10,000

  • Int addition shows the largest gain at 5.37x because the per-step cost is dominated by the recursion overhead that the lowering eliminates. Multiplication follows the same trend at 1.86x.
  • String concatenation shows only 1.06x at depth 10,000 because the String.plus allocation dominates per-step cost, making the recursion overhead a negligible fraction.
  • All three patterns survive depth 1,000,000 with the lowering enabled. Without it, they overflow the V8 Wasm stack around depth 14,000.
This post is licensed under CC BY 4.0 by the author.