実践・C コンパイラ演習

末尾呼び出し最適化 — 再帰をループに変える

今日のゴール

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    # 深い末尾再帰 + fixed17

golden.py は3つを続けて確認する。

  1. 最適化なしで tests/deep_sum.c(100万回の末尾再帰)が落ちること
  2. 最適化ありで同じテストが通ること
  3. fixed17 が壊れていないこと

「落ちていたものが通るようになる」ので、効果が命令数より分かりやすい。

発展課題

  1. 相互末尾再帰: is_even / is_odd のような相互再帰は、 自分自身ではないので今回の判定では変換されない。 別の関数への末尾呼び出しを変換するには何が必要か考える (フレームの解放、引数の渡し方、ra の扱い)
  2. 末尾位置の拡張: if (c) return f(x); else return f(y); の両方が 末尾呼び出しであることは、いまの判定でも扱えている。 では return c ? f(x) : g(y); は?三項演算子は Core にあるので実際に試せる。 is_self_tail_call を Cond の両方の枝に再帰させる拡張を設計する
  3. fact を末尾再帰に書き換える: fact(n) を累積引数つきの fact_acc(n, acc) に書き換え、深い階乗が動くことを確かめる
  4. フレームサイズの計測: 最適化あり/なしで sp がどこまで下がるかを gdb-multiarch で観察する
補足

コラム: 再帰とループは同じもの。 「末尾再帰はループに変換できる」という事実は、 逆に「ループは末尾再帰で書ける」ことも意味する。 実際、コマ5 で作った while の生成コードと、この回で作った末尾呼び出しの生成コードは どちらも「変数を更新して先頭へ j」という同じ形をしている。 言語の見た目が違っても、機械語のレベルでは同じものになる。

この資料の原稿を見る