BabelとTraceurでES6末尾再帰最適化を試す

ちょっと前にBabelに末尾再帰最適化が入って話題になったけど、同じくTraceurにもv0.0.85で最適化が入ったので試してみた。

末尾再帰最適化って何?

厳密な話はそちらの筋に任せるとして、ざっくりしたストーリーはこんな感じ。

  • 再帰って深くなるとstack overflowになっちゃう
  • 再帰をシンプルなループ(スタックを使わないジャンプ)に変換できればstack overflowを避けられる
  • 一般に末尾再帰であれば再帰をループに変換できる方法が知られている(これが末尾再帰最適化)
  • 末尾再帰ではない再帰関数でも、CPS変換を使うことで末尾再帰関数に変換が可能
    • CPS変換とは、関数を結果の値を受け渡すスタイルから継続渡しスタイルに書き換えること
  • つまり、普通の再帰関数 -> CPS変換で末尾再帰化 -> 末尾再帰最適化を適用 ということができる

このあたりのやさしい解説としてこちらが参考になった。

で、ES6にはこの末尾再帰最適化をすることが標準仕様として入ったので、今後JavaScript再帰関数を書くときは、末尾再帰になってるかどうかを気にしたり、CPS変換を施す場面が出てくることになると思う。

BabelとTraceur

ES6に入ったのでV8やSpiderMonkeyがそのうちネイティブで最適化してくれるはずだけど、血気盛んなES6トランスパイラ達も果敢に最適化に挑戦している。すなわちJS to JSの多少強引な書き換えで末尾再帰最適化を施すということ。

Babelではデフォルトで有効化されている。 TraceurではまだExperimentalでデフォルト無効なので、--proper-tail-calls trueを付けて実行する必要がある。

両方試してみて変換結果と実行結果を以下のリポジトリに上げておいた。

末尾再帰による階乗計算

まずはCPSではないシンプルな末尾再帰を試してみる。

オリジナル:

function factorial(n, acc) {
  if (n <= 1) return acc;
  return factorial(n - 1, n * acc);
}

[0,1,2,3,4,5,10,100,1000,10000,100000].forEach(function(n) {console.log(n, factorial(n, 1))});

実行すると100,000の階乗を計算するところでstack overflow ("RangeError: Maximum call stack size exceeded") が発生する(結果がNumberの上限を超えてしまう件は今回本題ではないので気にしないことにする)。

0 1
1 1
2 2
3 6
4 24
5 120
10 3628800
100 9.332621544394418e+157
1000 Infinity
10000 Infinity
/Users/teppeis/workspace/es6-tail-call-optimization/src/factorial.js:0
(function (exports, require, module, __filename, __dirname) { 'use strict';

RangeError: Maximum call stack size exceeded
    at factorial (/Users/teppeis/workspace/es6-tail-call-optimization/src/factorial.js)
    ...

Babelで変換するとこうなる。

function factorial(_x, _x2) {
  var _again = true;

  _function: while (_again) {
    _again = false;
    var n = _x,
        acc = _x2;

    if (n <= 1) {
      return acc;
    }_x = n - 1;
    _x2 = n * acc;
    _again = true;
    continue _function;
  }
}

再帰呼び出しがwhileループに展開されて、stack overflowを発生させずに実行できた。ランタイムを使わないので人間でもなんとなく読める。が、元のコードとはだいぶかけ離れている。

次はTraceurで変換:

var $__1 = $traceurRuntime.initTailRecursiveFunction(factorial);
function factorial(n, acc) {
  return $traceurRuntime.call(function(n, acc) {
    if (n <= 1)
      return acc;
    return $traceurRuntime.continuation(factorial, null, [n - 1, n * acc]);
  }, this, arguments);
}

Traceurはランタイムを使うのでこれだけ見るとよくわからないけど、基本的にはやってることはBabelと一緒で、$traceurRuntime.callがランタイムのtailCallを呼び出して内部でwhileループを回している。

ランタイムを使うけど、そのおかげで元のコードと構造レベルでの乖離は少ないと見ることもできる。

CPSによる階乗計算

次に継続渡しのスタイルで階乗計算してみる。

オリジナル:

function factorial(n) {
  var f = function(n, callback) {
    if (n <= 1) return callback(1);
    return f(n - 1, function(pre) {
      return callback(n * pre);
    });
  };
  return f(n, function(_) {return _;});
}

再帰関数がcalllback関数(継続)を受け取って末尾再帰する。

Babelで変換:

function factorial(n) {
  var f = (function (_f) {
    var _fWrapper = function f(_x, _x2) {
      return _f.apply(this, arguments);
    };

    _fWrapper.toString = function () {
      return _f.toString();
    };

    return _fWrapper;
  })(function (n, callback) {
    if (n <= 1) return callback(1);
    return f(n - 1, function (pre) {
      return callback(n * pre);
    });
  });
  return f(n, function (_) {
    return _;
  });
}

変換には成功しているが、実行すると相変わらずstack overflowが発生してしまう。 _ffが相互に呼ばれることで実質的な再帰が発生してしまっているのが原因、かな。 Babelの公式ページにもPartialと書いてあるとおり、現状ではBabelは継続渡しによる末尾再帰最適化には対応していない、ということらしい。

次にTraceurで変換:

var $__1 = $traceurRuntime.initTailRecursiveFunction(factorial);
function factorial(n) {
  return $traceurRuntime.call(function(n) {
    var f = $traceurRuntime.initTailRecursiveFunction(function(n, callback) {
      return $traceurRuntime.call(function(n, callback) {
        if (n <= 1)
          return $traceurRuntime.continuation(callback, null, [1]);
        return $traceurRuntime.continuation(f, null, [n - 1, $traceurRuntime.initTailRecursiveFunction(function(pre) {
          return $traceurRuntime.call(function(pre) {
            return $traceurRuntime.continuation(callback, null, [n * pre]);
          }, this, arguments);
        })]);
      }, this, arguments);
    });
    return $traceurRuntime.continuation(f, null, [n, function(_) {
      return _;
    }]);
  }, this, arguments);
}

こちらはstack overflowの解消に成功した。 ポイントは継続渡しのコールバック関数にもinitTailRecursiveFunctionが適用されているところ。

ランタイムの関数を使うことで淡々と機械的に変換できている印象。ランタイムの強みが出たか。

CPSによるフィボナッチ数列

こちらは継続渡しが2段階で発生するが、Traceurは無事最適化に成功した。 興味がある人はリポジトリを参照してください。

末尾再帰最適化、トランスパイラに頼る?

  • 変換で本当にロジックが壊れないか?
  • むしろパフォーマンスが落ちることはないか?
  • オリジナルと変換後のコードの乖離が大きくなる(ソースマップ無いとデバッグ辛くなる)

みたいな余計な心配が増えちゃうので、よほど再帰関数で困ってなければぶっちゃけ無効にしたいw

トランスパイラなんて使ってて今さらそんな心配してるんじゃねえ!っていうツッコミもあるかもしれないけど、特にパフォーマンスについては、初期のBabelの実装で実際にパフォーマンスが低下したことがあったりしたので。

チャレンジとしてはエキサイティングで楽しいですけどね!