末尾呼び出し最適化 — 再帰をループに変える
今日のゴール
return f(...); の形で自分自身を呼ぶ再帰を、ジャンプに変換する。
100万回の再帰でもスタックがあふれないコンパイラにする。
この回の位置づけ
| 項目 | 内容 |
|---|---|
| 必須の前提 | 着手はコマ6(関数呼び出し)まで。完了条件のうち golden.py は完成した final/mycc.py を土台に fixed17 を回すので、やり切るにはコマ15 も要る |
| 推奨の前提 | — |
| 改変しない | mycc.py、scaffold/、ラッパー tccc.py(配布済み・完成品) |
| 編集する | tailcall.py |
| 完了条件 | check.py の全 Step が PASS になり、golden.py が深い末尾再帰の通過と fixed17 全通を報告する |
| コマ数 | 1 |
| 備考 | 最適化入門発展シリーズの第3回。B1・B2 とは独立している。O 系列に対応する回は無い |
コマ6 の資料で「扱わない」とした末尾呼び出し最適化の回収である。 B1 は O7・O3 と、B2 は O4 と題材が重なるが、 末尾呼び出し最適化は O1〜O7 のどれにも出てこない。この回だけの題材である。 効果を命令数で見たいときは、O1 の物差し(静的/動的命令数)をそのまま使える。
末尾呼び出しとは
関数の最後の仕事が別の関数の呼び出しであるとき、それを末尾呼び出しという。
int sum_to(int n, int acc) {
if (n == 0) { return acc; }
return sum_to(n - 1, acc + n); // 末尾呼び出し
}対して、次は末尾呼び出しではない。
int fact(int n) {
if (n == 0) { return 1; }
return n * fact(n - 1); // 呼び出しの後に掛け算が残っている
}違いは「呼び出しから戻ってきた後に、まだやることがあるか」である。 やることが残っていないなら、呼び出し元のフレームはもう用済みである。
なぜジャンプに変えられるのか
sum_to が sum_to を呼ぶとき、いまのコンパイラは新しいフレームを積む。
sum_to(1000000, 0) のフレーム
sum_to(999999, 1000000) のフレーム
sum_to(999998, 1999999) のフレーム
... 100万段 → スタックがあふれる
しかし各フレームは、呼び出しから戻った瞬間に「戻り値をそのまま返す」だけである。 つまりフレームを積む意味がない。引数だけ新しい値に書き換えて、 関数の先頭に戻れば、同じ計算ができる。
# return sum_to(n - 1, acc + n);
# 新しい引数を計算して、引数スロットに書き戻し…
sd a0, -24(s0) # n ← n - 1
sd a1, -32(s0) # acc ← acc + n
j .Ltc_sum_to # フレームを積まずに先頭へ戻るcall が j になり、フレームが1つも増えない。再帰がループになった。
関数型言語では必須の最適化。
Scheme や OCaml では、ループを書く標準的な方法が末尾再帰である。
そのため言語仕様のレベルで「末尾呼び出しは必ずジャンプに変換すること」
(proper tail call)が要求される。C の規格には要求がないが、
gcc や clang は -O2 で同じ最適化を行う。
実装の3つの注意点
1. どこへ戻るか — プロローグの後
戻り先は関数の先頭ラベルではない。プロローグ(フレーム確保)と 引数の保存を飛ばした、本体の直前である。
sum_to:
addi sp, sp, -32 ┐ プロローグ
sd ra, 24(sp) │ ここに戻ると
... │ フレームが増えてしまう
sd a0, -24(s0) ┘ 引数の保存
.Ltc_sum_to: ← ここへ戻る
# 本体ラッパー tccc.py が、この位置にラベルを置いて cg.tc_label に入れてくれる。
2. 引数は「全部評価してから」書き戻す
return sum_to(n - 1, acc + n);第1引数を先に n のスロットへ書き込んでしまうと、
第2引数の acc + n が新しい n を読んでしまう。
コマ2 以来の退避パターンと同じ理由で、
すべて評価して退避 → まとめて書き戻すの順にする。
3. 変換してよい形を厳しく判定する
| 形 | 変換 | 理由 |
|---|---|---|
return f(a, b);(f は自分自身) |
する | 純粋な末尾呼び出し |
return f(a) + 1; |
しない | 呼び出しの後に演算が残る |
return g(a);(別の関数) |
しない | 引数スロットの配置が違う |
| 引数が9個以上 | しない | レジスタに収まらない |
「怪しいものは変換しない」— B1 と同じく、最適化の基本姿勢である。
判定が広すぎると fixed17 が壊れるので、そこで気づける。
実装
| Step | 実装対象 | 内容 |
|---|---|---|
| 1 | is_self_tail_call |
変換してよい形かの判定 |
| 2 | gen_tail_call |
引数の詰め替え + ジャンプの生成 |
python3 tccc.py tests/deep_sum.c # 生成結果を見る
python3 tccc.py tests/deep_sum.c | grep -c call # call が減っているかテスト
python3 check.py # Step ごとの単体テスト(未実装は SKIP)
python3 golden.py # 深い末尾再帰 + fixed17golden.py は3つを続けて確認する。
- 最適化なしで
tests/deep_sum.c(100万回の末尾再帰)が落ちること - 最適化ありで同じテストが通ること
fixed17が壊れていないこと
「落ちていたものが通るようになる」ので、効果が命令数より分かりやすい。
発展課題
- 相互末尾再帰:
is_even/is_oddのような相互再帰は、 自分自身ではないので今回の判定では変換されない。 別の関数への末尾呼び出しを変換するには何が必要か考える (フレームの解放、引数の渡し方、raの扱い) - 末尾位置の拡張:
if (c) return f(x); else return f(y);の両方が 末尾呼び出しであることは、いまの判定でも扱えている。 ではreturn c ? f(x) : g(y);は?三項演算子は Core にあるので実際に試せる。is_self_tail_callをCondの両方の枝に再帰させる拡張を設計する - fact を末尾再帰に書き換える:
fact(n)を累積引数つきのfact_acc(n, acc)に書き換え、深い階乗が動くことを確かめる - フレームサイズの計測: 最適化あり/なしで
spがどこまで下がるかをgdb-multiarchで観察する
コラム: 再帰とループは同じもの。
「末尾再帰はループに変換できる」という事実は、
逆に「ループは末尾再帰で書ける」ことも意味する。
実際、コマ5 で作った while の生成コードと、この回で作った末尾呼び出しの生成コードは
どちらも「変数を更新して先頭へ j」という同じ形をしている。
言語の見た目が違っても、機械語のレベルでは同じものになる。