コンパイラだって誰かが書いたソフトウェアなんだから

髙橋です。

今回は、技術的な話を織り交ぜながら、ソフトウェアビジネスなんて話ではなく、ソフトウェア開発について考えてみたいと思います。

わたしたちの会社で開発しているソフトウェアは、例えば1億行あるCSVファイルを食べて、数値のカラムであれば、その最大値、最小値、平均値といった集計結果をグラフや表にしてWebブラウザ上で「パっ」と高速に表示する、なんてことができます。

このソフトウェアは大きく分けて2つの役割にわけることができます。

一つ目はこのソフトウェアを使う人=ユーザー、のためのユーザーインターフェースです。具体的には、ユーザーがCSVファイルをアップロードするための画面を表示したり、もう一つのソフトウェアが集計した結果を受け取ってグラフや表を表示する役割を持つソフトウェア

二つ目はユーザーインターフェースを通して受け取ったCSVファイルの中身を見て、最大最小値や平均値の算出などユーザーに見せたい集計結果を生成する役割を持つソフトウェア

この2つのソフトウェアの設計において必ず考慮しなければならないこと

があります。

ユーザーインターフェースの方は、Webブラウザの種類やバージョンの違いです。裏を返せば、ブラウザの種類、バージョンを1つに決めてしまえば考慮しなくてよくなりますが、ブラウザのシェアは1強ではないので、頑張って複数のブラウザに対応することになります。
集計の方は、広義においてターゲットとなるOSの種類やバージョンです。こちらは、ブラウザの選択とは異なり、自分達で決めればいい事なんですが、これまたOSもソフトウェアですから、その設計思想により特性があるので、あちらを立てればこちらが立たず、なんて事もあり、なかなか悩ましいところです。
いずれにせよ、ソフトウェア動作環境の違いをプログラムの書き方で吸収する、という考慮が必要です。

これ以外のこと、CPU、メモリ、ディスクを効率良く使用するという点おいて、動作環境の違いを吸収するプログラムを書く必要はありません。
ここで、

「ん?なんで?そんなアホなことないでしょ?」

と思った人は、この先読まなくてもいいと思うんですが、言いたいことはそこではないので、もうしばらくお付き合いください。

21世紀に入ったばかりのころ

前回の話題と同じ常駐先でのできごとです。

ある日の夕方、後輩のH君がわたしのところにやってきました。
 H君「高橋さん、なんか変なバグがあるんですが。すごく不思議なことが起きるんです。」
わたし「(先輩からの受け売り)あのね、不思議なことは起きないの。君が書いた通りにしか動かないから」
 H君「わかってますって。だから、穴のあくほどソース見直しても、バグはないんですっ!」
わたし「何の処理だったっけ?」
 H君「ハード(ハードウェアのこと)が書いてるカウンタをこっち(ソフトウェア処理側という意味)に持ってくるだけの処理です。あんまり時間がない(ハード側もいつまでも保持してられないので早く回収しないと消えちゃうという意味)ので効率よくこっちに持ってくるようにしてるつもりなんですけど、なぜかわけのわからないところで落ちる(プログラムが正常に動作しなくなるという意味)んです。もうわけがわかりません。」
わたし「へー、どれどれ」

ということで、H君が書いたC言語で書かれたプログラムを見始めました。

metrics copybuffer;
metrics *basep = (metrics *)0x4321;
copybuffer.txBytePerSec = basep->txBytePerSec;
copybuffer.rxBytePerSec = basep->rxBytePerSec;
....このような代入文がずらーっと並んでるだけ...

で、この代入文の実行途中で落ちる、と。
落ちる場所は毎回同じようなので、何かあるのでしょうが、

ソースをいくら眺めてみても原因はわかりません

しかしあきらめるわけにもいかず、とりあえず、アセンブリを出力してみたわけです。
下記アセンブリは雰囲気を伝えるために上記プログラムをLiunx/x86-64のgcc -Sして、コピー元のポインタに即値を代入している行以降の部分だけを抜粋しました
あくまで雰囲気です!

movq $17185, -8(%rbp)
movq -8(%rbp), %rax
movq (%rax), %rax
movq %rax, -32(%rbp)
movq -8(%rbp), %rax
movq 8(%rax), %rax
movq %rax, -24(%rbp)

アセンブリを眺め始めた段階では、あくまでH君を疑って、いろいろ尋問しながら解析していたのですが、とはいえ単純な代入処理だけなので、アセンブリにしても原因はわかりませんでした。
これ以上H君を尋問しても何もでてこないので、ここからはわたしひとりで解析をすることにしました。

ここまできたら、

コンパイラ屋を疑うしかない

ということで、MIPSの仕様書を片手に、挙動がおかしくなる箇所あたりのアセンブリを眺め始めました。
よく見たら、挙動がおかしくなるあたりは分岐命令(beq)があってそこから変なとこにPC(Program Counter)が移動してしまう感じでした。
そこで、

MIPSの仕様書をよく読むと

その命令のあと結果を得るためには1クロック待たなければいけない
というようなことが書いてありました。
その観点でもう一度よくアセンブリを見てみると、似たような処理をしていて正しく動作している箇所は、
beq(branch on equal)命令の次は nop(no operation、すなわち何もしない)命令になっているのですが、
挙動がおかしくなる箇所にはそのnopがありません。
試しにnopを挿入してみると正しく動いたので、

nop命令が挿入されていない

ことが直接的な原因であると判明しました。
となると、次に調べる必要があるのは

なぜnop命令が挿入されていないのかを調べる必要があります。

同じような処理を記述しているのに、正常にコンパイルされるケースとそうでないケースの違いはなにか、に着目して考えてみました。
同じような処理を記述しているソースコードが複数あり、その違いを観察してみると、中間ファイル(オブジェクトファイル)がある一定のサイズを超えると変になるのではないか、という感じがしました。

これはあくまで勘だったのだけど、

プログラムってすごく簡単に言うと

こっちの箱(メモリ)からあっちの箱(メモリ)にデータを移すという処理の連続なんです。

その時に使う、箱のサイズ・タイプが決まっていて、箱のサイズが決まると、その箱の中に入れられる情報の数も決まります。で、

箱の選択を見誤る

というプログラマーが見落としがちなよくあるプログラムミスのパターンってのがあるので、その間違いをコンパイラ屋さんが踏んでいるのではないか、という感じがしたわけです。
その仮説に基づき、不具合が発生するソースコードを試しに仮説の範囲内のサイズに収まるように処理を削ると正しくコンパイルされるようになりました。
ここまで来たらまずは運用回避策としてコンパイラがバグを踏んでいないかチェックすればいいので、

中間ファイル(オブジェクトファイル)が正常か異常かを判定するプログラム

をawk(awkでもバイナリファイルのチェックツールとか簡単に作れます)で書いて、実行ファイルを作成するmakefileを改造してそのツールを呼ぶようにしました。
こうして、とりあえず、運用回避はできるようになったのは明け方でした。

ひと眠りした後、常駐先のM主任技師に報告し、コンパイラ屋さん宛に不具合指摘文書を作成して送ったのですが、数日後に返ってきた答えは、

次のバージョンで修正されていますので、バージョンアップしてください

というものでした。

システムによっては、コンパイラのバージョンアップで対応する、という選択肢もありますが、この時のプログラムでは、
コンパイラを変えたらその装置の機能をすべてテストし評価しなおす必要がある
という大変な事態になってしまうため、コンパイラのバージョンアップは見送り、運用回避でしのぐ、ということになりました。

ということで、教訓は

他人の書いたプログラムを鵜のみにしてはいけない

ということです。

もちろん、その前に、

自分が書いた通りプログラムは動く

ということも忘れてはなりませんね。

 

*MIPSは、ミップス・コンピュータシステムズが開発したMIPSアーキテクチャのことを指しています。当時わたしが扱っていたプロセッサの製造メーカーがどこであったのかは忘れてしまいました。
*SPARCは、サン・マイクロシステムズが開発・製造したマイクロプロセッサです。20世紀中はSPARCのアセンブリは扱っていたのですが、SPARCはレジスタの扱いが独特でそれに慣れていたので少してこずりました。
*コンパイラ屋は、わたしが扱っていたMIPSプロセッサ用のCコンパイラからアセンブラーまでを一式提供していたいわゆるコンパイラーベンダーのことを指しています。こちらもベンダーがどこだったのかは忘れてしまいました。