#三行で頼む
- コンパイラが斜め上の最適化をするようになったからnull安全ないと怖いよね
一行で終わっちゃった。
#本文
最近、ツイッターを見ていると、プログラマの間でnull安全という言葉がバズっていますね。私も次のようなエントリを楽しく眺めていた訳です:
さてそんな中、少しだけ私の心に留まったエントリがこれです:
これを読んで、私がまず直感的に思ったのは、むしろもはやnull安全のない言語はダメかもしれないということでした。こう書くと、このエントリの内容を否定してると誤解されるかもしれませんが、全くそんなことはありません。このエントリの筆者さんもnull安全の有用性を否定しているわけではないですし、私も古いタイプのプログラマですから、「null安全を実現した場合、必ずしも追加のコストが発生しないということもないだろう」という重箱の隅突き的なことを言いたくなるのももっともだなあと思います。で、このエントリの何が私にこの直感を生じさせたのかというとnullチェックの省略という観点。これを見て、少し前にあった脆弱性を思い出したのでした。
「少し前の脆弱性」について話をする前に、時間をだいぶ昔まで巻き戻しますが、90年代くらいのころ、我々プログラマとコンパイラの関係は、おおむね良好でした。人間はうまくコンパイラを乗りこなせていたし、コンパイラは比較的従順な命令列を吐いていたと思います。プログラムを適当に書いてg++ -S -O2 hoge.ccなどと実行すると、9割以上は予測可能なアセンブラコードが吐かれ、残りのほとんどのケースでは少しだけ我々の予想を超えた「良い」コードが吐かれ、ごくまれにコンパイラのバグによって無茶苦茶なコードが吐かれる、といった具合でした。私が学生だった頃は、新しいgccのバージョンが出るたびに、適当なコードを書いては-Sでどうコンパイルされるのかを観察して、C++の良くわからない仕様を理解したりしてましたが(まだgcc-2.7あたりの、pre-ISO C++な時代の話)。-Sの悦びを知りやがって……。
ところが、今世紀に入ってくると、状況が少しばかり変わってきます。2009年に発見されたCVE-2009-1895というLinuxカーネルの脆弱性は、多くの人々の耳目、特に我々のような低レベルプログラマの耳目を集めました。「少し前の脆弱性」とはこれのことです。これについての日本語の解説は、次のブログエントリが必要十分かつ簡潔で良いでしょう:
要約すると、NULLでないことが明らかなポインタに対するNULLチェックは削除されるということです。というかほとんどGCCの最適化オプション名の直訳だなコレ。……この脆弱性の周囲では、我々人間の予断が三つほど観測されます。つまり、
- このコードを書いた人間の「ポインタtunはNULLじゃないだろう」という予断
- このコードを読んだ人間の「ポインタtunがNULLならtun->skでパニックするだろう」という予断
- ほとんど誰も「tunに対するNULLチェックがコンパイラによって削除されるとは考えていなかった」という予断
このうち、最初の予断は、プログラムを書いた人間の単純なミスであり、二つ目の予断は、Linuxカーネル内という特殊な実行環境の仕様のコーナーケースへの不理解であって、この話の本筋とは関係ないので無視します。ここで注目したいのは最後の予断です。そのため、わざわざ強調してあります。つまり、ほとんどの人が、そんな最適化手法があるなんてことを知らなかったし、それが既にgccに実装されていたということを、この脆弱性が発表されたことによって初めて知ったという人が多かったということが重要。
……まあ、この手の問題は、実のところ90年代から既に存在していたりもするし、上で書いた「ごくまれにコンパイラのバグによって無茶苦茶なコードが吐かれる」という話の何%かは、単にプログラマがコンパイラのせいにしていただけで、実際にはプログラマの無知に起因するような、この脆弱性と同種のバグだったりもするのです。しかしながら、最近の最適化手法の進歩は、この手の落とし穴をますます増やしていくのではないかという気がしています。なぜならば、
- 未定義動作の積極的な利用 - より厳密には、正しいプログラムには未定義動作が含まれているはずはないという仮定の積極的な利用。件の脆弱性はこれによって発生していますし、他の例としては、たとえば「本の虫」のOld New Thing: 未定義動作はタイムトラベルを引き起こす(他にもいろいろあるけど、タイムトラベルが一番ぶっ飛んでる)あたりが読んでいて興味深い。もっと短いのがいいなら、同じ「本の虫」からとても賢いコンパイラーの逆襲とか。
- 大域最適化 - リンカのレベルで関数をまたがった検査を行い、冗長なコードを検出して削除したりする。
というようなことが、ごく普通に行われるようになりつつあるからです。90年代後半は色々な要因(たとえば、ハードウェアがあまり速くなかったとか、32bitで仮想メモリ空間が狭かったとか、髭面のおっさんが笛ばっかり吹いていたせいでgccの進歩が阻害されていたとか)によって、(少なくともフリーな)コンパイラの進歩が遅かったのに対し、今世紀に入ってからのllvmの台頭やgccの開発の活発化といった状況の変化によって、急速に進歩するようになったように思います。
つまり、現在はgccにしろllvm/clangにしろ、コンパイラが人間の想像の斜め上の命令列を吐くのが当たり前になりつつある。商用のiccやmsvcも多分似たような感じでしょう。……少なくとも、そういう未来が、もうすぐそこまで来ている、という訳です。ワシの-Oは108式まであるぞ。
さて、null安全な言語は、本当にゼロコストかの筆者さんも書いていますが、C/C++は率直に言ってクソなんですけども、クソって言うと良くないので、もうちょっとポリティカリーにコレクトな言い方をすれば、要するに「未定義動作が多くてうっかりそれを踏みがち」ということですね。その中でもメジャーなものの一つがnull参照です。C/C++のデバッグといえば、null参照との戦いと言っても過言ではありませんし(……いや過言だけど)、世界全体で見れば3秒に1回程度の割合でnull参照のバグが生み出されていると言われています。そして、C++のようにテンプレートやらインライン関数やらでメタなアレに起因する冗長な中間コードが生み出されるような状況では、nullがらみの最適化の有無はそれなりにパフォーマンスへのインパクトがある。だから、コンパイラも積極的に斜め上のコードを吐きに行くことになる。そして、このような冗長な中間コードへの対処の必要性は、必ずしもC++だけに限らず、コンパイラ前提の言語では多かれ少なかれ存在するし、インタプリタ型の言語であっても、JITが当たり前の昨今では、やはり似たような状況が生まれうる。つまり、null参照によって今まで以上に意味不明で深刻なバグが生み出される可能性が高くなっていくわけです。神崎美月先輩も「それがnullの世界。これから先の戦いは、もっと厳しいものになるよ……」と言っていたような気がしますね。
そこで、このエントリのタイトルであるコンパイラのリミッタが外れつつある今、null安全は必須なのかもしれないという結論に繋がってくるわけです。もはやコンパイラは我々人間が乗りこなせるような乗り物では無くなりつつある現在、背後を脅かされる要素を一つでも多く排除するという観点から、我々としても、真剣にnull離れを検討しなければいけない時期に来たのかもしれません。特異点は事象の地平線の向こう側に隠してしまうのが常世の安泰のため、というわけです(Nullableは型システム的にはSome[T]とNullの直和型みたいな形で表現されますが、null安全サポートのある多くの言語のコンパイラではnullチェックの自動化と隠蔽みたいな感じで実装されてますね)。
もちろん、null安全性を確保したところで、それ以外にもいっぱい落とし穴があるので、安泰には程遠いという状況はなかなか無くなりそうにはありませんが、少なくともnull参照に起因する斜め上のバグに脅かされることが無くなる分だけ気が楽です。それに、null安全というのは、比較的言語サポートが簡単な割に効果が非常に大きいし、ランタイムのコストはほとんどの場合0にできるというのは、最初に並べた各エントリでも示されている通りです。よく、メリットが大きい割にデメリットがほとんどないようなテクニックのことを割のいい賭けなどと表現しますが、まさにnull安全は割のいい賭けなんじゃないかと思います。一方ソ連は-O0を採用した。
#余談
ところで、null安全な言語は、本当にゼロコストかの中には次のような一言があります:
しかし、コンパイラーにはこの依存性を見抜くのが困難です。
うん、でも、たぶん、そんなに遠くない未来には、人間よりは得意になってるんじゃないかなあという予感がします。……まあ筆者さんも
早すぎる最適化だと言われればその通りだと思います
と書いておられるとおり、そんなことは百も承知なんじゃないか、とは思いますが。
というわけで、実験。
std::optionalとかiostreamとか使うとアセンブラが読みにくくなるので、better C的なC++に書き直した次のようなコード:
#include <cstdio>
class X {
int va = 0;
int vb = 0;
int *pa = nullptr;
int *pb = nullptr;
public:
X() = default;
X(int a) : va(a), pa(&va) { }
void initialize() {
if (pa) {
pb = &vb;
*pb = *pa * 2;
}
}
void print();
};
inline_ void X::print() {
if (pa && pb)
std::printf("%d,%d¥n", *pa, *pb);
}
int
main()
{
X xa(42);
xa.initialize();
xa.print();
return 0;
}
を用意しました。これを g++ -S -std=gnu++11 -O2 -Dinline_=inline a.cc としてコンパイルすると、現在のgccでも次のように思いっきり最適化してくれます:
.file "a.cc"
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%d,%d\302\245n"
.section .text.unlikely,"ax",@progbits
.LCOLDB1:
.section .text.startup,"ax",@progbits
.LHOTB1:
.p2align 4,,15
.globl main
.type main, @function
main:
.LFB35:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $84, %ecx
movl $42, %edx
movl $.LC0, %esi
movl $1, %edi
xorl %eax, %eax
call __printf_chk
xorl %eax, %eax
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE35:
.size main, .-main
.section .text.unlikely
.LCOLDE1:
.section .text.startup
.LHOTE1:
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
paどころかpbのチェックすらせず……それどころかxa向けのスタックフレームすら確保してませんな。完全にインライン展開できる場合には、コンパイラはもはやここまで賢くなっている。多分、std::optionalを使ってもあんまり結果は変わらないと思います。
一方、g++ -S -std=gnu++11 -O2 -Dinline_= a.cc として X::print 関数のインライン展開を抑止してコンパイルした場合には、次のような命令列になっています(抜粋):
_ZN1X5printEv:
.LFB34:
.cfi_startproc
movq 8(%rdi), %rax
testq %rax, %rax
je .L1
movq 16(%rdi), %rdx
testq %rdx, %rdx
je .L1
movl (%rdx), %ecx
movl (%rax), %edx
movl $.LC0, %esi
movl $1, %edi
xorl %eax, %eax
jmp __printf_chk
.p2align 4,,10
.p2align 3
.L1:
rep ret
.cfi_endproc
さすがに、これは依存関係を見抜いてくれておらず、paとpbの両方でNULLチェックをしている。そもそも単一の翻訳単位だけを見ているこの段階でpaとpbの間の依存関係を判定できるのかという問題があるので、未来のgccを持ってきたとしても、-Sで単一のアセンブラファイルを吐かせている状況ではこんなものかも。
というわけで、最後にリンク時最適化(LTO)を有効にした場合も見てみましょうか。 g++ -flto -std=gnu++11 -O2 -Dinline_= a.cc として、 objdump -d a.out > a.s すると、次のような命令列を吐いてます(抜粋):
0000000000400450 <main>:
400450: 48 83 ec 08 sub $0x8,%rsp
400454: b9 54 00 00 00 mov $0x54,%ecx
400459: ba 2a 00 00 00 mov $0x2a,%edx
40045e: be 04 06 40 00 mov $0x400604,%esi
400463: bf 01 00 00 00 mov $0x1,%edi
400468: 31 c0 xor %eax,%eax
40046a: e8 c1 ff ff ff callq 400430 <__printf_chk@plt>
40046f: 31 c0 xor %eax,%eax
400471: 48 83 c4 08 add $0x8,%rsp
400475: c3 retq
既に、この程度のものはLTOで完全にインライン展開に直してくれちゃう世の中になっていますね。もっとX::printを複雑にしてインライン展開されないようなケースを作ったらどうなるのかが興味深いところですが、面倒くさいのでこの辺で。でも、近い将来、仮にそのようなケースであっても、コンパイラがpaとpbの間の依存関係を検出してpaに対するNULLチェックを省いたX::printを吐くようになっていたとして、そんなに驚くことではないような気はしますね。