Cyanのソースハックを試みる 2倍速達成

Cyanはせっかく面白いのに、現在の実装が遅すぎてまだ遊びにくいのが残念です。とくに、単純繰り返しにすごく弱い。

#1000個の数字から二乗が1になるものだけ抽出(つまり1だけ)
iota(1000).filter^(x){ x * x == 1 }

これ、自分のマシンで約8秒もかかりました。スタックマシンじゃないからといっても、Lispの評価機がそんなに遅いはずがない、というわけで、
cyan-1.0.2 - takuto_hの日記
をベースにソースをハックしてみました。

src/Cyan/Evalutor.cs の Evaluor::Run メソッド(仮想マシンの最小単位命令を回すループっぽい)になにやら怪しげな部分を発見。

while (instructions.Count > 0)
{
    insn = instructions[0];
    instructions.RemoveAt(0);
    
    //PrintState(insn);

    GetType().GetMethod(insn.Key).Invoke(this, insn.Value);
}

GetMethodしてInvokeて…、プログラムの最小単位で毎回リフレクションって、そりゃ遅くなるわけだ。enumで全オペコードを定義(enum Opcode...)して、リフレクションで書かれた部分を

switch(insn.Key)
{
    case Opcode.Push:
        Push((CyBase)insn.Values[0]);
        break;
    case Opcode.Pop:
        Pop();
        break;
    // (ry
}

こんな感じになるように作り変えてみる(インストラクションのリストの、命令名を列挙の値になるように変更する)と、なんと、8秒かかってたのが4秒ちょっと切るところまで行きました。だいぶ速くなったけど、まだまだ…。

次に怪しいのは、instructions.RemoveAt(0) あたりでしょうか。C#でList(普通の配列だよね、たしか)の先頭を取り除くというのは、1から末尾までの要素を1つづつずらす必要があるので、インデックスを進めることによって「消したことにする」か、連鎖リストにしておきたいところです。でも、生成コストとのトレードオフを考えないと、と思い、そのデータの出所を調べてみると…。

src/Cyan/Objects/CyContinuation.cs のコンストラクタの

instructions = new List<KeyValuePair<string, object[]>>(insns);

ってのがその主要な発生源でした。Cyanの継続というのは、「実行されたところ以降しか残ってない(未実行の)命令リスト」を、「コピーバックアップ」したものか。なるほどこれはシンプル。継続を使うときは、バックアップから未実行命令のリストを復元するだけ。すぐ隣には、なにやらスタックのコピーを取ってるところもありました。

って、少々重くても頻度が少なけりゃ、と思いきや、なななんと、CyContinuationって、生成回数が尋常じゃないぐらい多いんですよね。2個の整数を加算するだけで3個、3つの整数の加算で6個のCyContinuationが生成されました。つまり、1000回ループで演算子2個だと、軽く見積もって6000回CyContinuationコンストラクタを通ることになります。いや、6000回のnewが問題どころの騒ぎじゃなくて、命令リストとスタックのコピーと復元の量たるや…。

Cyanの構文はCyanのマクロや関数で定義されてて、かつ、Cyanのreturnは継続で実現されている、ということを考えると、やっぱり、継続の生成と命令リストのコピー/復元には、最適化の効果が期待できそうです。

連鎖リストのノードを次のノードに進めるような感じで命令を消化し、バックアップを取るときは現在ノードへのポインタを取るようにすればどうだろう? スタックもコピーを取らず、どうにかポインタ切り替えにならないだろうか…。あとは、大量にnewしてもリサイクルが期待できるオブジェクトじゃないと。

って、考えたところまでで限界。どうも独自のコレクションクラスが要りそうだし、続きはそうとう暇なときに考えよう。ただ、これで確かになったのは、現バージョンのCyanの実装は、まだ出せる性能を出し切っていないってことです。ああ面白い。

うーん、末尾再帰(というか、命令リスト空状態での継続発生、か)の最適化もできそうだ。なんとなく。