9

私は単純な 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 キャッシュに「拡張ディスパッチ ループ」を適合させるという私の理論が保持されている限り、「多くの命令」アプローチが最高のパフォーマンスを得る可能性が最も高いように見えます。ですから、ここであなたの専門知識と経験が活かされます。コンテキストが狭まり、いくつかのサポート アイデアが提示されたので、より遅い解釈されたコードの量を減らすことによって、より大きな命令セットの利点がネイティブ コードのサイズの増加よりも優先されるかどうかについて、より具体的な答えを出すのがより簡単になるでしょう。 .

私の指示サイズ データは、これらの統計に基づいています。

4

5 に答える 5

5

VM ISA とその実装を分離することを検討することをお勧めします。

たとえば、私が書いた VM には、「値を直接ロードする」命令がありました。命令ストリームの次の値は、命令としてデコードされませんでしたが、値としてレジスタにロードされました。この 1 つのマクロ命令または 2 つの個別の値を考慮することができます。

私が実装したもう 1 つの命令は、(定数テーブルのベース アドレスとオフセットを使用して) メモリから定数をロードする「定数値のロ​​ード」でした。したがって、命令ストリームの一般的なパターンはload value direct (index); load constant value. VM 実装はこのパターンを認識し、単一の最適化された実装でペアを処理する場合があります。

明らかに、十分なビットがあれば、それらのいくつかを使用してレジスタを識別することができます。8 ビットの場合、すべての操作に対して 1 つのレジスタが必要になる場合があります。with register Xただし、次の操作を変更する別の命令を追加することもできます。C++ コードでは、その命令はcurrentRegister、他の命令が使用するポインターを設定するだけです。

于 2013-08-12T11:42:58.143 に答える
1

あなたは間違った質問をしていると思いますが、それが悪い質問だからではなく、逆に興味深いテーマであり、多くの人が私と同じように結果に興味を持っているのではないかと思います.

しかし、これまでのところ誰も同じような経験を共有していないので、開拓を行う必要があるのではないでしょうか。定型コードの実装にどのアプローチを使用して時間を浪費するかを考える代わりに、言語の構造とプロパティを記述する「リフレクション」コンポーネントの作成に集中し、パフォーマンスを心配することなく、仮想メソッドを使用して適切なポリモーフィック構造を作成し、実行時に組み立てることができるモジュラー コンポーネントであり、オブジェクト階層を確立したら、宣言型言語を使用するオプションさえあります。あなたは Qt を使用しているように見えるので、半分の作業が必要になります。次に、ツリー構造を使用して、さまざまな異なるコードを分析および生成できます。C コードをコンパイルするか、特定の VM 実装用のバイトコードを作成できます。

この一連のアドバイスは、具体的な答えを事前に得ずにこのテーマについて先駆的な取り組みを行う場合に、より有益であると思います。これにより、すべてのシナリオを簡単にテストし、個人的な仮定ではなく実際のパフォーマンスに基づいて判断することができます。他人のもの。その後、結果を共有し、パフォーマンス データで質問に答えることができます。

于 2013-08-13T16:33:57.087 に答える
0

バイト単位の命令の長さは、かなり長い間同じ方法で処理されてきました。明らかに、実行したい操作の種類が非常に多い場合、256 命令に制限されることは良いことではありません。

これが、プレフィックス値がある理由です。ゲームボーイ アーキテクチャでは、必要な 256 ビット制御命令を含める十分なスペースがありませんでした。そのため、1 つのオペコードがプレフィックス命令として使用されていました。これにより、元の 256 個のオペコードと、そのプレフィックス バイトで始まる場合はさらに 256 個のオペコードが保持されます。

例: 1 つの操作は次のようになります: D6 FF=SUB A, 0xFF

ただし、接頭辞付きの命令は次のように表示されます。CB D6 FF=SET 2, (HL)

プロセッサが読み取っCBた場合、すぐに 256 オペコードの別の命令セットを探し始めます。

同じことが今日の x86 アーキテクチャにも当てはまります。で始まる0F命令は、基本的に別の命令セットの一部になります。

エミュレータで使用している種類の実行では、これが命令セットを拡張する最良の方法です。16 ビットのオペコードは必要以上に多くのスペースを占有し、プレフィックスはそれほど長い検索を提供しません。

于 2013-08-19T22:53:03.770 に答える
0

決定すべきことの 1 つは、コード ファイル サイズの効率、キャッシュの効率、生の実行速度の効率の間でどのようなバランスを取るかです。解釈しているコードのコーディング パターンによっては、コード ファイル内の長さに関係なく、各命令をポインターと整数を含む構造体に変換すると便利な場合があります。最初のポインターは、命令情報構造体と実行コンテキストへのポインターを受け取る関数を指します。したがって、メインの実行ループは次のようになります。

do
{
  pc = pc->func(pc, &context);
} while(pc);

「短い即時命令の追加」に関連付けられた関数は次のようになります。

INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context)
{
  context->op_stack[0] += pc->operand;
  return pc+1;
}

INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context) { context->op_stack[0] += (uint32_t)pc->operand + ((int64_t)(pc[1].operand ) << 32); pc+2 を返します。}

「add local」命令に関連付けられた関数は次のようになります。

INSTRUCTION *add_instruction(INSTRUCTION *pc, EXECUTION_CONTEXT *context)
{
  CONTEXT_ITEM *op_stack = context->op_stack;
  op_stack[0].asInt64 += op_stack[pc->operand].asInt64;
  return pc+1;
}

「実行可能ファイル」は圧縮されたバイトコード形式で構成されますが、実行時に命令をデコードする際の間接的なレベルを排除して、命令のテーブルに変換されます。

于 2014-10-20T03:00:17.947 に答える