SECDV Schemeの実装 - 意外に遅い問題と小手先の最適化
というわけでGCヒープも一通り実装が完了し、そろそろ他の言語への移植を考えるフェーズに移ってきた。大体ネイティブ実行、つまり普通のSchemeインタプリタで実行した速度の100倍程度の遅さ(= インタプリタの実行速度ギャップ20倍 x PCと実機のIPC比5倍)なら実ゲームを載せて検証できるのでそれくらいの性能を目標にしている。
(コアになるゲームロジック -- いわゆる当たり判定等フレーム単位の処理が必要なもの -- はSchemeではなく専用のVMを使う予定で、Schemeで書かれるのはシーンのロード処理やイベント用のスクリプトのみ。このような分割を行なわないと特に携帯機では処理量的に厳しい。)
...で、いわゆるマイクロベンチマークでは比較的余裕でこれを達成していたのだけど、ちょっと複雑なSchemeコードを実行するとなんと0.4秒で終わる処理がSECDV VMだと2分(それもGC等を行わない状態で)という300倍もの速度差が付いてしまったのでちょっと最適化を考えることにした。
プロファイルの採取
yuniの重要な特徴は複数のScheme処理系で同じコードが走ることなので、とりあえず今回はGaucheとChez Schemeのプロファイリング機能を使う。つまり、
- Gaucheのプロファイラで大量に呼ばれている手続きを特定する - Gaucheのプロファイラは手続き単位の呼び出し回数を直ぐに見ることができる
- Chezのプロファイラでホットスポットを特定する - Chezのプロファイラはカバレッジツールのように基本ブロック単位での実行回数を追跡することができる
Chezのプロファイラはコードから呼び出して使う必要があるが、単にloadすれば良いだけなので簡単。
(import (chezscheme)) (parameterize ((compile-profile 'source) (command-line (list "-PROG" "vmbench.sps"))) (load "_launcher.sps")) (profile-dump-html)
loadするコードのcommand-lineはparameterizeで変更できる。
観察と最適化
今回のベンチマークは、"Schemeで書かれたS式リーダを使って実プログラムを3回readする"というもの。当然一瞬で終わることを期待している(し、実際ネイティブでは1秒未満で終わる)。
まず、最初に目を引くのは、VM命令の実行数よりもヒープ操作が多いという点。
Name calls call(ms) samples ---------------------------------------------------+------+-------+----------- object? 132888717 0.0000 0( 0%) object-tag 106132674 0.0000 0( 0%) (vmcore-new cycle) 87540015 0.0000 0( 0%) object-datum 64302819 0.0000 0( 0%) - snip - real 1m55.139s user 2m5.890s sys 0m1.936s
VM命令の実行数はcycle手続きの実行数に対応する。この場合は87540015命令の実行に2分掛かっていることになる。先に結論すると、既に0.7MIPS(約8700万命令/2分 = 0.725M命令/sec)出ていることになるので、VMの性能という意味では既に十分なパフォーマンスが出ていて、VMコンパイラで最適化をサポートするか、ランタイムライブラリを最適化することが必要と言える。ただ、開発のイテレーション的にはVM自体の最適化も無意味ではないので、最適化をしてみる。
object-tagはヒープオブジェクトの種別の取得、object-datumはヒープオブジェクトのホストSchemeへの変換を行う手続きで、これらの処理はMOV命令等大半の命令では不要であることを考えると命令ミックス的におかしいと言える。
というわけでChezのプロファイラ出力を眺めてみると、
ホストSchemeの文字オブジェクトをターゲットに戻す処理が突出して多いことがわかる(紫色)。適当にbreakを入れて確認したところ、この処理は定数ロードに使用しているLDI命令の実装に由来することがわかったので、LDI命令のたびにターゲット→ホスト変換をしていたのを、実行前にまとめて行うように変更してみた。
- https://github.com/okuoku/yuni/commit/39693a4b0dbda6bb8cd67f9b13d9e30f89985f02#diff-b2e63cec266e713b1fd9acaeaf40af78L394
- heapin手続きの内部で、ホスト→ターゲットのインポート処理をしている。変換が必要になるのは、VMがホストのS式を中間表現として使用しているため。
更に、分岐命令BRVでターゲット→ホスト変換を毎回行っていたのを、ターゲットのFalseかどうかを直接判定するだけにした。
- https://github.com/okuoku/yuni/commit/7bbebbf8270461ec23058dc8d6642ecb79d6e92f#diff-6fdc362c430095b390367d490d3d664aL79
- ここのhost手続きで、ターゲット→ホスト変換を行っていた。これはAssertの一種で、未定義値による条件分岐をトラップするために入っていた(未定義値はターゲット←→ホスト変換できない)。当然、(if (長いリストやvector) ... ...)のようなコードが有ると長いリスト全体を変換してしまうので効率が悪い。
ここまでの最適化で実行時間は1m40.161sになった(87%)。10%の改善はまぁまぁの効果と言える。
... が、CALL/RET命令のそれぞれの実装でクロージャを確保していたのを修正したら1m9.489s(60%)になった。
- https://github.com/okuoku/yuni/commit/2990ba941d79987a770a4717a0f6c6f97f506d14
- https://github.com/okuoku/yuni/commit/16df3656c02778bbb2f850f7ec1623ca6d1ff294
- CALL命令でクロージャを確保していたのを止める。
このまま1分は切れるくらいまでは最適化できるのではないだろうか。まぁ先に書いた通りこれ以上高速化しても仕方ないのでやらないけど。
方針の再検討
とりあえず、現状の方針ではVMコンパイラ/ランタイム実装が頭悪すぎと結論して良さそうなので、もう少し真面目に実行負荷見積りを出す必要があるのではないかといえる。
方針は2通り考えられて:
後者かな。。もっとドラスティックな案としては、VM構成を単純に捨ててコンパイラ前提の方針に修正することか。。
現状では、85359803命令の実行(cycle)に対して25162773フレームが生成されており、約3命令に1度フレームが生成されている(= letやlambda等変数のbindが発生している)ことになる。
- https://gist.github.com/okuoku/2853201168b1bb5721a64489e33c6981#file-fourth-profile-txt-L12
- fake-chain-cons 手続きがスタックフレームの生成に使われている
インライン化や定数畳み込み等を真面目に実装してVM負荷を減らすことがかなり重要そうなのはわかる。ただこれらを真面目に実装するのは避けたいので、どうにかホスト言語にこの負担を押し付ける方法は無いものか。。
例えば、必ずleaf関数になることが保証されているプリミティブを呼び出すときは、そのパラメタを格納するためのフレームを新規に確保する必要は無い(= 使いまわしができる)。このような小手先の最適化の効果を予測するために、コードの性質を良く計測する必要がある。