私は単純な VM を開発しており、岐路に立っています。
私の最初の目標は、バイト長の命令を使用することでした。したがって、小さなループと迅速な計算された goto ディスパッチを使用しました。
しかし、現実はそれから遠く離れることはできません.256は、符号付きおよび符号なしの8、16、32、および64ビット整数、浮動小数点数と倍精度浮動小数点数、ポインター操作、アドレス指定のさまざまな組み合わせをカバーするのに十分ではありません. 1 つのオプションは byte と short を実装しないことでしたが、目標は完全な C サブセットとベクトル演算をサポートする VM を作成することです。これは、異なる実装ではあるものの、とにかくほとんどどこにでもあるためです。
そこで、16 ビット命令に切り替えたので、移植可能な SIMD 組み込み関数と、解釈されないことでパフォーマンスを大幅に節約する、よりコンパイルされた共通ルーチンを追加できるようになりました。最初にベースポインターオフセットとしてコンパイルされたグローバルアドレスのキャッシュもあります。最初にアドレスがコンパイルされると、オフセットと命令が単純に上書きされるため、次回は直接ジャンプになります。命令によるグローバルの各使用。
私はプロファイリングの段階にないので、ジレンマに陥っています。追加の命令は柔軟性を高める価値がありますか?より多くの命令が存在し、したがって命令を前後にコピーしないことで、増加したディスパッチ ループ サイズが補われるでしょうか? 手順は、それぞれの組み立て手順のほんの一部にすぎないことに注意してください。たとえば、次のとおりです。
.globl __Z20assign_i8u_reg8_imm8v
.def __Z20assign_i8u_reg8_imm8v; .scl 2; .type 32; .endef
__Z20assign_i8u_reg8_imm8v:
LFB13:
.cfi_startproc
movl _ip, %eax
movb 3(%eax), %cl
movzbl 2(%eax), %eax
movl _sp, %edx
movb %cl, (%edx,%eax)
addl $4, _ip
ret
.cfi_endproc
LFE13:
.p2align 2,,3
.globl __Z18assign_i8u_reg_regv
.def __Z18assign_i8u_reg_regv; .scl 2; .type 32; .endef
__Z18assign_i8u_reg_regv:
LFB14:
.cfi_startproc
movl _ip, %edx
movl _sp, %eax
movzbl 3(%edx), %ecx
movb (%ecx,%eax), %cl
movzbl 2(%edx), %edx
movb %cl, (%eax,%edx)
addl $4, _ip
ret
.cfi_endproc
LFE14:
.p2align 2,,3
.globl __Z24assign_i8u_reg_globCachev
.def __Z24assign_i8u_reg_globCachev; .scl 2; .type 32; .endef
__Z24assign_i8u_reg_globCachev:
LFB15:
.cfi_startproc
movl _ip, %eax
movl _sp, %edx
movl 4(%eax), %ecx
addl %edx, %ecx
movl %ecx, 4(%eax)
movb (%ecx), %cl
movzwl 2(%eax), %eax
movb %cl, (%eax,%edx)
addl $8, _ip
ret
.cfi_endproc
LFE15:
.p2align 2,,3
.globl __Z19assign_i8u_reg_globv
.def __Z19assign_i8u_reg_globv; .scl 2; .type 32; .endef
__Z19assign_i8u_reg_globv:
LFB16:
.cfi_startproc
movl _ip, %eax
movl 4(%eax), %edx
movb (%edx), %cl
movzwl 2(%eax), %eax
movl _sp, %edx
movb %cl, (%edx,%eax)
addl $8, _ip
ret
.cfi_endproc
この例には、次の手順が含まれています。
- 即値からレジスタへの符号なしバイトの代入
- レジスタからレジスタへ符号なしバイトを割り当てる
- グローバルオフセットからレジスタに符号なしバイトを割り当て、キャッシュして直接命令に変更する
- グローバル オフセットからレジスタに符号なしバイトを割り当てます (現在キャッシュされている以前のバージョン)。
- ... 等々...
当然、そのためのコンパイラを作成すると、製品コードで命令フローをテストし、メモリ内の命令の配置を最適化して、頻繁に使用される命令をまとめてキャッシュ ヒットを増やすことができます。
そのような戦略が良いアイデアであるかどうかを判断するのに苦労しています.肥大化は柔軟性を補いますが、パフォーマンスはどうですか? より多くのコンパイルされたルーチンは、より大きなディスパッチ ループを補いますか? グローバルアドレスをキャッシュする価値はありますか?
また、GCC によって生成されたコードの品質について意見を表明するために、まともなアセンブリの誰かにお願いしたいと思います - 明らかな非効率性と最適化の余地はありますか? 状況を明確にするためにsp
、レジスタを実装するスタックを指すポインターがあり (他のスタックはありません)、ip
論理的には現在の命令ポインターでありgp
、グローバル ポインターです (参照されず、オフセットとしてアクセスされます)。
編集:また、これは私が指示を実装している基本的な形式です:
INSTRUCTION assign_i8u_reg16_glob() { // assign unsigned byte to reg from global offset
FETCH(globallAddressCache);
REG(quint8, i.d16_1) = GLOB(quint8);
INC(globallAddressCache);
}
FETCH は、オペコードに基づいて命令が使用している構造体への参照を返します。
REG は、オフセットからレジスタ値 T への参照を返します。
GLOB は、キャッシュされたグローバル オフセット (実質的に絶対アドレス) からグローバル値への参照を返します。
INC は、命令ポインタを命令のサイズだけインクリメントします。
マクロの使用に反対する人もいるでしょうが、テンプレートを使用すると非常に読みにくくなります。このように、コードは非常に明白です。
編集:質問にいくつかの点を追加したいと思います:
グローバルでもヒープでも、レジスタと「メモリ」の間でのみデータを移動できる「レジスタ操作のみ」のソリューションを使用できます。この場合、すべての「グローバル」アクセスとヒープ アクセスは、値をコピーし、変更または使用し、それを元に戻して更新する必要があります。このようにして、ディスパッチ ループは短くなりますが、非レジスタ データをアドレス指定する命令ごとにいくつかの追加命令が必要になります。したがって、ジレンマは、より長い直接ジャンプで数倍多くのネイティブ コード、または短いディスパッチ ループで数倍多くの解釈された命令です。短いディスパッチ ループで、余分でコストのかかるメモリ操作を補うのに十分なパフォーマンスが得られますか? おそらく、短いディスパッチ ループと長いディスパッチ ループの間のデルタは、実際の違いを生むには十分ではありませんか? キャッシュ ヒットに関しては、アセンブリ ジャンプのコストに関して。
私は追加のデコードに行くことができ、8ビット幅の命令のみを使用できますが、これにより別のジャンプが追加される可能性があります-この命令が処理される場所にジャンプし、特定のアドレス指定スキームが処理されるケースにジャンプするか、操作をデコードしてより複雑な実行方法。最初のケースでは、ディスパッチ ループはさらに大きくなり、さらに別のジャンプが追加されます。2 番目のオプション - レジスタ操作を使用してアドレス指定をデコードできますが、何かをアドレス指定するには、不明なコンパイル時間のより複雑な命令が必要になります。これが短いディスパッチ ループとどのように積み重なっていくのか、もう一度よくわかりませんが、私の「短くて長いディスパッチ ループ」が、アセンブリ命令、それらが必要とするメモリ、および速度に関して短いまたは長いと見なされるものとどのように関連するかは不明です。彼らの実行の。
「多くの命令」の解決策に行くことができます-ディスパッチループは数倍大きくなりますが、事前に計算された直接ジャンプを使用します。複雑なアドレス指定は、命令ごとに固有かつ最適化され、ネイティブにコンパイルされるため、「レジスタのみ」のアプローチで必要となる余分なメモリ操作はコンパイルされ、ほとんどがレジスタで実行され、パフォーマンスが向上します。一般に、アイデアは命令セットにさらに追加することですが、事前にコンパイルして単一の「命令」で実行できる作業量も追加します。孤立した命令セットは、より長いディスパッチ ループ、より長いジャンプ (最小化するように最適化できますが)、より少ないキャッシュ ヒットも意味しますが、問題はどのくらいですか? すべての「指示」がほんの数個の組み立て指示であることを考えると、約 7 ~ 8k の命令のアセンブリ スニペットは正常と見なされますか、それとも多すぎると見なされますか? 平均命令サイズが 2 ~ 3b 前後であることを考慮すると、これは 20k を超えるメモリではなく、ほとんどの L1 キャッシュに完全に収まるのに十分です。しかし、これは具体的な数学ではなく、グーグルで調べたものにすぎないので、私の「計算」は間違っているのでしょうか? それとも、そのようには機能しませんか?私はキャッシュメカニズムの経験がありません。
私は現在、議論に重みを付けているので、L1 キャッシュに「拡張ディスパッチ ループ」を適合させるという私の理論が保持されている限り、「多くの命令」アプローチが最高のパフォーマンスを得る可能性が最も高いように見えます。ですから、ここであなたの専門知識と経験が活かされます。コンテキストが狭まり、いくつかのサポート アイデアが提示されたので、より遅い解釈されたコードの量を減らすことによって、より大きな命令セットの利点がネイティブ コードのサイズの増加よりも優先されるかどうかについて、より具体的な答えを出すのがより簡単になるでしょう。 .
私の指示サイズ データは、これらの統計に基づいています。