Island Life

< ピアノレッスン75回目 | ピアノレッスン76回目 >

2012/12/27

Lispと兎と亀

Common Lispのそこそこ大きなプロジェクトにここ3年くらいかかわっていた。

性能とスケーラビリティが売りの製品なんで、処理系依存の低レベルコードや てんこもりの型宣言を大量のマクロで包んでがんがんチューニングしている。 そういうことが出来るというのは確かにLispの強みではあるんだが、 長い目で見たときに果たしてそれがLispにとって良いことだったのだろうか。

主流Lisp方言のコンパイラは確かに80年代とか90年代前半とかには 先端を走っていたのだろうけれど、 そこからあまり大きな進歩が無いように思える。 止まっているわけではないけれど、他のコンパイラテクノロジーの進歩に比べると見劣りする。 ちまちま逆アセンブル結果を見ながらコードをチューンしていると、 もう少しコンパイラが賢くなってくれないかと感じることはよくある。

「コンパイラは余計な気を回すより素直で予測可能なコードを吐くことに専念すべき。 高レベルの最適化の勘どころは人間が知っているのだから」という議論もある。 けれど、ライブラリを書いている時はどういう応用をされるか未知だから 最適化の勘どころもピンポイントではわからない。 また、よく「プロファイル取ってボトルネックとなる関数だけ最適化」って言われるが、 最適化の最終段階になるともう特定の関数がプロファイルで突出してくるってことは なくなってきて、そこからが最適化の本番だ。

結局やって欲しいのは手続き間最適化を含むグローバルな最適化なんだな。 ライブラリにしても、APIにある程度メタ情報を載せといてそれをうまく コンパイラに使って欲しいし。型推論とか。

マクロを使えばある程度ユーザ側で枠組みが作れるけれど、それをやると 吐き出されるコードは巨大なひとつのでっかい関数になっちゃって、 それを逆アセンブルしてさらにチューニングするってのはもう人間の手に負えない。 そういう枠組みでメタ情報 (型宣言など) を活用しようと思ったら、コンパイラが やってることを重複して実装することにもなる。

★ ★ ★

もちろんマイナー言語であるがゆえに割けるリソースが少ないという事情は (自分も処理系を持っているので)重々承知しているが、 それよりも、Lispの柔軟性によって「処理系をいじる前にユーザ自身で何とかできてしまう」 という事実が、かえって処理系の進歩を遅らせたのではないかという気がしてしまう。

新たなプログラマの流入がコンスタントにあり、新たなコードがたくさん書かれている 状況では、処理系をいじって速くすることのインパクトが大きいから、 自然とそこに資源も集まる。

しかし細く長くやってる人間が多数で、 みんなが処理系のツボを心得ている状況だと、各ユーザは処理系の進歩を待つより 目前のコードを最適化する方が効率が良い。処理系作成者も専業で喰ってけなくて 応用プロジェクトを抱えることになると、時間をかけて汎用的な最適化を処理系に入れるのと、 抱えているプロジェクトの性能要件にとにかく間に合わせるのとはトレードオフになる。 目先の稼ぎになるのは後者だ。

また、ユーザレベルでの実装に密着した最適化をほどこしたコードというのは 賞味期限があって、時代を経ると古びてくる。かつては命令数を減らす方が 効いたのが今はキャッシュの有効利用の方が重要になっているかもしれない。 処理系がコンパイル戦略を大きく変えようとしても、 アクロバティックなユーザのコードとの互換性を保つことが 足かせになる場合もある。

Schemeの最近の仕様は静的側に寄っていて、往年のなんでもいじれるLispが好きな 人々には不評なんだけど、コンパイラ作者にとってはそれなりに納得のゆく 選択であることが多い。

結局、特定の時代の特定のアーキテクチャや処理系に向けたチューニングというのは いずれ古びるものなので、それを許すフックまで仕様で決めといても意味がない という割り切りはありだと思う。 チューニング自体が処理系依存にならざるを得ないのだから、 必要なら各処理系ごとに内部へのアクセス手段を用意しておけばいい。

「新しいLisp」を作り、旧来のコードの互換性を忘れて現時点の知見で有望と 思える設計を取捨選択するってのもありだろう。 Clojureはそのへんうまくやっていると思う。

★ ★ ★

確かに現場では、10年20年前のコードがそのまま動くという保証は重要だ。 けれど技術の進歩の中にはdisruptiveなものがあって、 どうしてもそのまま動かすことはできないってことがある。 動かすなら、断絶以前の環境をエミュレートせざるを得ないというものだ。 (典型的なのがSMP対応で、SMPを考慮せずにかかれたライブラリを 「そのまま」シームレスに動かすことはできない。ライブラリ自体に手を加えたくなければ グローバルロックで対応することになるが、それって結局non SMP環境を エミュレートしていることになる)。

で、エミュレートで良いんだったら、例えばうんと性能の良い非互換なLispを作って、 その上に「Common Lispエミュレーション環境」を載せてやれば、 互換性の問題はカタがつくんではないかとも思う。 無論エミュレーションだと性能にハンディがあるけれど、 「互換性が足かせになってCL処理系の進歩が他の処理系の進歩に比べて遅くなる」というのが 真であれば、時間を経るにつれCL処理系の性能と新Lisp処理系の性能の差は開いてゆき、 どこかの時点で「ネイティブCL処理系の性能」と「新Lisp+CLエミュレータ」の性能が 拮抗することになるんじゃなかろうか。

★ ★ ★

等々、年の瀬につれづれ考えていた。

CLのプロジェクトは、私の関わる範囲は一段落しつつあるので (まだ担当チケットが残ってるんで 休めないんだけど) 来年はGaucheに軸足を戻していろいろ試してみたい。 スケジュールは今のところわりとオープンなので仕事のお話歓迎です。

Tags: Lisp, Scheme, Programming

Past comment(s)

sumii (2013/01/04 11:50:26):

あけましておめでとうございます。興味本位なのですが、この話はLispが動的型付き言語であることにどれぐらい依存するでしょうか? もし仮にすべての式の型が静的にわかっていても、やはり手続き間最適化がないと有意に性能が悪くなるケースは多いでしょうか。(静的型付き言語だと、例えばOCamlなどはコンパイラは比較的単純な最適化しかしない&プログラマもあまり低水準な最適化はしないので)

shiro (2013/01/04 20:13:59):

もちろん動的であるために最適化がやりにくいということはあるのですが、その面においても例えば「分かっている範囲で手続き間最適化を行い、再定義がかかったら依存している部分を動的再コンパイルする」など手はあるはずで、そういのが実験的にはともかく、プロダクトとしての処理系にもっと積極的に取り込まれてもいいんじゃないかなという気がします。

手続き間最適化については、Haskellのfusionみたいなことをやってくれたらいいのになと思うことがよくあります。特に私が扱う最適化ではアロケーションを減らすことがかなり重要で、グローバル解析をやればここアロケートしなくてもいいって分かるはずなのに… なんていう場所を個別にマクロ使ったり醜いコード変形をしたりして凌いでいます。「マクロ使えばコードを抽象的に保ったままユーザがコンパイラをカスタマイズできるでしょ」というのがLispの売り文句なわけですが、何となく似たようなパターンについて何度も個別にマクロを書いてるとだんだん飽きてきます。そのパターンを抽象化するマクロ生成マクロを書く…という方向に走ると今度はデバッグが恐ろしく大変になります。

グローバル解析でアロケーションを減らすことは本質的には静的型でも一緒だと思うのですが、多分静的型の方がそこまでグローバルに見ないでも最適化できる余地が大きいんじゃないかと想像します。そのためグローバル解析して欲しいっていう欲求があまり高まらないとか。いかがでしょう。

まあ一般的にやるのは難しい話ではあるのですが、それでも個別の工夫が処理系に反映されずに各ユーザの手元に留まってしまうと、進歩が停滞しますよね。Lispも最強と主張したいなら、他の言語処理系がびっくりするくらいの最適化をやってみせないとな、と思います。いつまでもStalinを持ち出すのもどうかと思いますし。

Post a comment

Name: