アセンブリーでの JIT コンパイラーの作成



非 JIT VM としては十分なパフォーマンスを発揮する仮想マシンを C で作成しましたが、何か新しいことを学び、パフォーマンスを向上させたいと考えています。私の現在の実装では、スイッチを使用して VM バイトコードから命令に変換し、ジャンプ テーブルにコンパイルします。私が言ったように、それが何であるかについてはまともなパフォーマンスですが、私は JIT コンパイラでしか克服できない障壁にぶつかりました.


少し前に自己変更コードについて同様の質問をしましたが、適切な質問をしていないことに気付きました.


したがって、私の目標は、この C 仮想マシン用の JIT コンパイラを作成することであり、x86 アセンブリで実行したいと考えています。 (アセンブラとして NASM を使用しています) これを行う方法がよくわかりません。私はアセンブリに慣れており、自己変更コードの例をいくつか調べましたが、コード生成の方法はまだわかりません.


これまでの主なブロックは、 を使用して命令を実行可能なメモリにコピーすることです。 私の主張。 NASM で特定の行にラベルを付け、静的引数を使用してそのアドレスから行全体をコピーできることは承知していますが、これはあまり動的ではなく、JIT コンパイラでは機能しません。バイトコードから命令を解釈し、それを実行可能メモリにコピーし、最初の引数を解釈してメモリにコピーし、次に 2 番目の引数を解釈してメモリにコピーできる必要があります。


GNU lightning や LLVM など、この作業を容易にするいくつかのライブラリについて知らされています。ただし、外部リソースを使用する前に、これがどのように機能するかを理解するために、最初にこれを手動で記述したいと思います.


このタスクを開始するために、このコミュニティが提供できるリソースや例はありますか? 「add」や「mov」などの 2 つまたは 3 つの命令を使用して実行可能コードを生成し、引数をメモリ内で動的に生成する単純な例は、驚くべきことです。


答え:


アセンブリで JIT を書くことはまったくお勧めしません。 インタプリタの最も頻繁に実行されるビットを記述するための適切な議論があります。 組み立て中。これがどのように見えるかの例については、LuaJIT の作成者である Mike Pall からのこのコメントを参照してください。


JIT に関しては、さまざまな複雑さを持つさまざまなレベルがあります。



  1. インタプリタのコードをコピーするだけで、基本ブロック (分岐しない命令のシーケンス) をコンパイルします。たとえば、いくつかの (レジスタベースの) バイトコード命令の実装は次のようになります:


    ; ebp points to virtual register 0 on the stack
    instr_ADD:
    <decode instruction>
    mov eax, [ebp + ecx * 4] ; load first operand from stack
    add eax, [ebp + edx * 4] ; add second operand from stack
    mov [ebp + ebx * 4], eax ; write back result
    <dispatch next instruction>
    instr_SUB:
    ... ; similar

    したがって、命令シーケンス ADD R3, R1, R2 が与えられた場合 、 SUB R3, R3, R4 単純な JIT は、インタープリター実装の関連部分を新しいマシン コード チャンクにコピーできます。


        mov ecx, 1
    mov edx, 2
    mov ebx, 3
    mov eax, [ebp + ecx * 4] ; load first operand from stack
    add eax, [ebp + edx * 4] ; add second operand from stack
    mov [ebp + ebx * 4], eax ; write back result
    mov ecx, 3
    mov edx, 4
    mov ebx, 3
    mov eax, [ebp + ecx * 4] ; load first operand from stack
    sub eax, [ebp + edx * 4] ; add second operand from stack
    mov [ebp + ebx * 4], eax ; write back result

    これは単に関連するコードをコピーするだけなので、それに応じて使用されるレジスタを初期化する必要があります。より良い解決策は、これを機械命令 mov eax, [ebp + 4] に直接変換することです 、しかし、要求された命令を手動でエンコードする必要があります.


    この手法により、解釈のオーバーヘッドが取り除かれますが、それ以外の場合、効率はあまり向上しません。コードが 1 回か 2 回しか実行されない場合、最初にそれをマシン コードに変換する価値はありません (少なくとも I キャッシュの一部をフラッシュする必要があります)。


  2. 一部の JIT はインタープリターの代わりに上記の手法を使用しますが、頻繁に実行されるコードに対してより複雑な最適化メカニズムを採用します。これには、実行されたバイトコードを追加の最適化が実行される中間表現 (IR) に変換することが含まれます。


    ソース言語と JIT の種類によっては、これが非常に複雑になる場合があります (これが、多くの JIT がこのタスクを LLVM に委譲する理由です)。メソッドベースの JIT は、制御フロー グラフの結合を処理する必要があるため、SSA フォームを使用し、それに対してさまざまな分析を実行します (例:ホットスポット)。


    トレース JIT (LuaJIT 2 など) は直線コードのみをコンパイルするため、多くの実装が容易になりますが、トレースの選択方法と複数のトレースを効率的にリンクする方法には十分注意する必要があります。 Gal と Franz は、この論文 (PDF) で 1 つの方法について説明しています。別の方法については、LuaJIT ソース コードを参照してください。どちらの JIT も C (またはおそらく C++) で記述されています。