C--で末尾再帰最適化を試してみる
想定される読者
- Haskellの処理系であるGHCをインストールしたことがある
- 処理系がハードディスクに残っている
- C言語は、まあまあ読める
- 言語の処理系そのものに興味がある
- アセンブリ言語についても、おおまかには知っている
はじめに
Haskellの代表的な処理系であるGHCでは、ソースコードはC--(を構文木にしたもの)にコンパイルされる。CではなくC--を使う理由のひとつとして、処理系の流れのコントロールをより自由にできることがある。一例としてC--では末尾再帰の(明示的な)最適化が非常にわかりやすく表現できることを示す。
一例を示すのみで、構文の説明などはしない。また、著者の環境(x86-64, Linux)の例であり、それ以外の環境では調整が必要かもしれない。
まちがい等々がありましたら「やさしく」ご指摘ください。ここで紹介するコードは以下から入手できます。
C--とは
C--がどんな感じなのかは、以下の論文にまとめられている。
かんたんに説明すると、つぎのようになる。
高級言語のコンパイラを作成しようというときに、それぞれのハードウェアに対して、それぞれの機械語を生成するのではなく、どのハードウェアについても共通する中間的な表現を生成して、それをそれぞれの機械語のコードに変換するほうが効率がいい。そのような「共通する中間的な表現」として、C言語が使われることがあった。C言語のコンパイラは広く普及していたので、悪くない選択ではある。しかし、C言語はそのような「中間的な表現」として設計されているわけではないので、いろいろと問題が生じる。そのような問題を解決できるような「表現」が求められた。そこで設計されたのがC言語とアセンブラの中間的な表現であるC--である。
C--のコンパイラは独立したプロジェクトとして、いくつか開発されていたが、現在ではいずれもメンテナンスされていない。なので、メンテナンスされているC--の処理系として、GHCを使うことにする。
Cの関数mainから呼び出す
Cの関数mainから呼び出して、C--からの返り値を表示してみよう。以下のようなコードを用意する。
foo()
{
return(123);
}
foo
という手続き(ブロック)を用意する。これは返り値として123
を返す。つぎに、foo
を呼び出すC言語のコードを用意しよう。
#include <stdio.h>
int
main(int argc, char *argv[])
{
long ret;
__asm__(
"addq $-16, %%rsp\n\t"
"moveq %%rbp, 8(%%rsp)\n\t"
"moveq %%rsp, %%rbp\n\t"
"moveq $print_ret, (%%rbp)\n\t"
"jmp foo\n"
"print_ret:\n\t"
"moveq 8(%%rsp), %%rbp\n\t"
"addq $16, %%rsp\n"
: "=b"(ret)
:
: );
printf("ret: %ld\n", ret);
return 0;
}
関数main
のなかで、C--のfoo
を呼び出している。C--では%rbp
をスタックポインタとして使うので、%rbp
が指すアドレスにラベルprint_ret
の値を代入している。%rsp
, %rbp
はこの前後で値を変化させたくないので、つぎのような処理を行っている。
-
%rsp
から16減算する -
%rsp
の値に8加算したアドレスに%rbp
の値を代入 -
%rsp
の値を%rbp
にコピーする -
%rbp
の値が指すアドレスにprint_ret
の値を代入 -
foo
へジャンプ -
foo
からは%rbp
の値が指すアドレスであるprint_ret
に処理がもどる -
%rsp
の値に8加算したアドレスから%rbp
に値をもどす -
%rsp
に16加算する
たぶん、これで安全にC--の処理を呼び出すことができる(と思う)。コンパイルして試してみよう。
% ghc fun.cmm -no-hs-main call_cmm.c -o fun
% ./fun
ret: 123
GHCはデフォルトではCの関数main
を用意してくれる。今回は自前のmain
を使いたいので-no-hs-main
オプションが必要だ。
末尾再帰の最適化とは
つぎのような階乗を求める処理があるとする。
- 処理の名前は
factorial
- 引数は
n
とs
のふたつ -
n
が0
より大きければn - 1
とs * n
を引数にしてfactorial
を呼び出す -
n
が0
ならばs
の値をかえす
この処理はn
を1
ずつ小さくしていき、s
はその都度n倍になる。つまり返り値はn
の階乗になる。これをC言語の「関数呼び出し」で実装すると、factorial
を呼ぶたびにスタックに「もどりアドレス」が積まれていき、n
が0
になったところで、そのスタックに積まれたアドレスに、順に処理がかえっていく。
しかし、そんなことをする必要はない。n
が0
になった段階で一気にはじめの呼び出しもとにもどってしまえばいい。そうすればスタック領域を無駄にすることもない。
そのように、「スタックにもどりアドレスを積んでいく」という処理と「その積まれたアドレスを順にたどる」という処理とを省略するのが末尾再帰の最適化である(たぶん)。
階乗を計算する処理(最適化していないバージョン)
末尾再帰最適化をしないバージョンの「階乗を計算する処理」は、つぎのようになる。
factorial(bits64 n, bits64 s)
{
if (n > 0) {
(bits64 ret) = call factorial(n - 1, s * n);
return(ret);
} else {
return(s);
}
}
foo()
{
(bits64 ret) = call factorial(10, 1);
return(ret);
}
だいたいわかるかと思う。呼び出しもとになるC言語のソースコードはcall_cmm.c
を流用する。
% ghc fac_no_opt.cmm -no-hs-main call_cmm.c -o fac_no_opt
% ./fac_no_opt
ret: 3628800
階乗を計算する処理(最適化したバージョン)
それでは、末尾再帰を最適化したバージョンを試してみよう。C--には処理を呼び出す方法にcall
だけではなくjump
もあるというところがポイントだ。つぎのようになる。
factorial(bits64 n, bits64 s)
{
if (n > 0) {
jump factorial(n - 1, s * n);
} else {
return(s);
}
}
foo()
{
(bits64 ret) = call factorial(10, 1);
return(ret);
}
n
が0
より大きかったときの処理がcall
ではなくてjump
になっている。jumpで飛んだときには処理はもどってこない。スタックにもどりアドレスが積まれることもない。そうしてn
回factorial
に飛んで、n + 1
回目にreturn
命令でfactorial
をはじめに呼んだfoo
に処理がもどる。余計なスタックの消費や余計なジャンプが省略されている。コンパイルして試してみよう。
% ghc fac_opt.cmm -no-hs-main call_cmm.c -o fac_opt
% ./fac_opt
ret: 3628800
1からnまでの和を計算する処理で比較
「階乗」だと結果が大きくなりすぎるので1
からn
までの総和という題材で最適化する前後の処理を比較する。
最適化なし
最適化なしでのコードはつぎのようになる。
summation(bits64 n, bits64 s)
{
if (n > 0) {
(bits64 ret) = call summation(n - 1, s + n);
return(ret)
} else {
return(s);
}
}
foo()
{
(bits64 ret) = call summation(2000000, 0);
return(ret);
}
乗算を和算に変えた。とりあえずn
を2000000
(200万)にしてみた。試してみよう。
% ghc sum_no_opt.cmm -no-hs-main call_cmm.c -o sum_no_opt
% ./sum_no_opt
zsh: segmentation fault ./sum_no_opt
スタックの領域を使い切ってしまったのだろう。セグメンテーションフォールトになっている。
最適化した場合
末尾再帰を最適化したコードはつぎのようになる。
summation(bits64 n, bits64 s)
{
if (n > 0) {
jump summation(n - 1, s + n);
} else {
return(s);
}
}
foo()
{
(bits64 ret) = call summation(2000000, 0);
return(ret)
}
最適化していないバージョンでセグメンテーションフォールトになった2000000
(200万)で試してみる。
% ghc sum_opt.cmm -no-hs-main call_cmm.c -o sum_opt
% ./sum_opt
ret: 2000001000000
全然、余裕だ。それでは2000000
(200万)ではなく1000000000
(10億)で試してみよう。
...
(bits64 ret) = call summation(1000000000, 0);
...
% ghc sum_opt.cmm -no-hs-main call_camm.c -o sum_opt
% ./sum_opt
ret: 500000000500000000
10億回、呼び出してもビクともしない。
まとめ
高級言語のコンパイラを作成するときに、それぞれのハードウェアごとの機械語を生成するのは大変だ。そのようなときにC言語を中間言語として使うのは「あり」だ。しかし、C言語はもともとそのような用途に使うようには作られていないので、いろいろと問題がある。その問題のひとつに、末尾再帰の最適化が困難というものがある。C--は高級言語をコンパイルするときの中間優現として設計された言語である。中間表現として、C--がCに対して優れている点として「末尾再帰の最適化がかんたん」という点がある。処理のブロックを呼び出すときにcall
というやりかたでなく、jump
というやりかたが用意してあるためだ。かんたんな例で、C--による末尾再帰の最適化の様子を示した。
Discussion