web-dev-qa-db-ja.com

大きなswitchステートメントを高速化する方法はありますか?

慣例として、私はCPUシミュレーター(約1.78MHzで実行)で作業しており、switchステートメントを使用して、IR(命令レジスター)変数の値に基づいて正しいオペコードを実行しています。このswitchステートメントには256ケースが必要です。それほど大きくはないかもしれませんが、switchステートメントは短期間に何度も実行する必要があります。同じ目的でswitchステートメントを使用するよりも高速なコードを作成するためのより良い方法はありますか?

オペコードは、アドレス指定モードと実際の操作の2つの部分に分けることができます。コンパクトなコードの場合、これらはおそらく関数に配置され、必要なものに基づいてケースに配置されます。コード自体を大きくしても、それぞれを個別に書き出す方がはるかに効率的かどうかはわかりません。

まだ方法がわからない別のアイデアは、switchステートメントの前にオペコードからアドレッシングモードと操作を検出し、それを使用してアドレッシングモードを実行し、有効なアドレスを取得することです。次に、2番目のswitchステートメントで実際の操作を実行します(RMW命令の場合はメモリに書き戻します)。

だから何か考えは?ここでswitchステートメントが最良の選択であり、シミュレーションをスムーズに実行するために他にどのような最適化を行うことができますか?

3
NMITIMEN

このようなアーキテクチャでは、switchステートメントは実際にはかなり効率的です。ターゲット速度が1.78 MHz(一部のZ80マシンのような音、TRS-80モデルIIはこのクロック速度でした)で、エミュレーターが最新のCPUで実行されている場合、この詳細について心配する必要はありませんが、エミュレーターは高速です足りる。

エミュレートされたマシンコードをネイティブコードにジャストインタイムでコンパイルすることで、より高速化できますが、これが必要になることはほとんどありません。

9

ここではswitch命令が適切です。通常、オプティマイザは ジャンプテーブル または 間接分岐 を使用します。他の構成でそれを上回ることは困難です(たとえ古いCPUでも)。

別の方法は、関数ポインタのインデックス付き配列を使用することです。これは間接呼び出しを利用しますが、スタック管理とパラメーターの受け渡しに必要な追加の指示になる可能性があります。したがって、これは必ずしもスイッチよりも高いパフォーマンスをもたらすとは限りません。

6
Christophe

注:この答えは、実際のハードウェアデバイスのエミュレーションを記述していることを前提としています。これは、疑わしい数の命令とアドレッシングモードについての言及があるため、ほぼ正しいと思います。

ここでswitchステートメントが最良の選択であり、シミュレーションをスムーズに実行するために他にどのような最適化を行うことができますか?

システムが流暢に動作することを確認する最良の方法は、システムをテストして、「流暢に」という事前定義された基準を満たしているかどうかを確認することです。ここでの良いベンチマークは、エミュレートしようとしているハードウェアの公示速度です。私が正しく理解していて、その速度が1.78MHzである場合、1.78GHzプロセッサ(2019年後半にはスマートフォンの速度とほぼ同じ)を使用している場合は、1000以下の独自の命令で1つのエミュレートされた命令を実行する必要があります。

あなたのコードが「流暢」のベンチマークに失敗したとしましょう。次のステップは、コードをプロファイリングして、実際に時間をかけすぎている場所を確認することです。 switchステートメントに含まれている場合と含まれていない場合があります。

Switchステートメントが問題であると仮定しましょう。最新のコンパイラーは最適化に優れています。このようなswitchステートメントがある場合、既に Branch Table のようなものを使用していると思います。自分で実装する前に、コンパイラの生成されたアセンブリでこの種の手法を確認することをお勧めします。コンパイラー作成者は非常に賢いです。コンパイラーが既にそれを行っている場合、手動で改善することはほとんどありません。

Switchステートメントを最適化する試みが失敗したと仮定しましょう。次にお勧めするのは、ターゲットCPUの動作を調べることです。 microcode を使用しているかどうかを確認します。それが実際のCPUである場合、ハードウェアエンジニアリング入力を使用して設計されていることはほぼ確実です。命令セットを分析してみてください。 256命令は8ビットに過ぎないため、関連する命令のビット表現でパターンを探します。たとえば、「レジスタからの追加」と「即値の追加」がある場合、それらは1ビット異なる可能性があります。同様の乗算および減算命令を探します。 「ビット3は、最初の引数が即値かレジスタ番号かを制御する」のようなものを推測できる可能性が十分にあります。

いくつかのパターンを見つけたとしましょう。段階的なアプローチで一度に複数の命令をエミュレートすることを検討してください。常に3つの引数を取る5ビットの命令があるとすると、次のような結果が得られます。

// Parse out these values in advance
int instruction_id = memory[program_counter];
int firstArgRaw = memory[program_counter + 1];
int secondArgRaw = memory[program_counter + 2];
int thirdArgRaw = memory[program_counter + 3];

// Resolve first argument based on bit 0
int firstArgValue = 0;
if (instruction_id & FIRST_ARG_BITMASK){
    firstArgValue = firstArgRaw;
} else {
    firstArgValue = registers[firstArgRaw];
}

// Resolve second argument based on bit 1
int secondArgValue = 0;
if (instruction_id & SECOND_ARG_BITMASK){
    secondArgValue = secondArgRaw;
} else {
    secondArgValue = registers[secondArgRaw];
}

// Apply operator to args 1 and 2, which is defined by bits 2 and 3
int result = 0;
if (instruction_id & ADD_BITMASK == ADD_SUBINSTRUCTION){
    result = firstArgValue + secondArgValue;
} else if (instruction_id & SUB_BITMASK == SUB_SUBINSTRUCTION){
    result = firstArgValue - secondArgValue;
} else if (instruction_id & MUL_BITMASK == MUL_SUBINSTRUCTION){
    result = firstArgValue * secondArgValue;
} else if (instruction_id & DIV_BITMASK == DIV_SUBINSTRUCTION){
    result = firstArgValue / secondArgValue;
}

// Save result to register or memory as defined by bit 4
if (instruction_id & RESULT_BITMASK) {
    memory[thirdArgRaw] = result;
} else {
    registers[thirdArgRaw] = result;
}

上記は約31行のコードで、32の異なる命令を処理します。結果は異なる場合があります。

この設定では、ほとんどのロジックをバイパスするために、特定の値の状況チェックを早い段階で実行するのはかなり自然なことです。ここでNoopが思い浮かびます。

それが助けになったとしましょう、しかし十分ではありません。当然、何が遅いかを確認するためにプロファイルを作成する必要があります。潜在的な問題の1つは、上記のコードに多数のifステートメントが含まれていることです。データは基本的にランダムであるため、CPUの 分岐予測子 ではこれは非常に困難な場合があります。数学がうまくいったら、このようなifステートメントを最適化できるかもしれません

int firstArgValue = 0;
if (instruction_id & FIRST_ARG_BITMASK){
    firstArgValue = firstArgRaw;
} else {
    firstArgValue = registers[firstArgRaw];
}

このようなものに

int firstArgValue = (instruction_id & FIRST_ARG_BITMASK) * firstArgRaw +
                    !(instruction_id & FIRST_ARG_BITMASK) * registers[firstArgRaw];

ここでの考え方は、両方の値を計算し、それらの一方に1(それを維持するため)ともう一方に0(それを破棄するため)を乗算してから、それらを加算することです。あなたは常にこれを行い、正しい値を取得します。ここには予測を誤る分岐はありません。 命令パイプライン処理 を無効にする場合、分岐と1つまたは2つの命令は、固定の5つの命令よりも遅くなる可能性があります。すべてのパフォーマンスの微調整と同様に、それを測定するまで、それが役に立ったか、害があったかはわかりません。この場合も少し難読化されているので、ifステートメントを使用することで、より明確なifステートメントを使用しなかった理由を明確にするコメントが適切になります。

2
Joel Harmon

すべての中で最も多く使用された可能性がある1つのエミュレーターは、古いソフトウェアとの互換性を提供するために最初のPowerPCベースのMacで使用された68,000エミュレーターでした。

65,536ケースのswitchステートメントを使用しました。各ケースは、16バイト= 4つのPowerPC命令を使用して実装されました。最後の命令は、switchステートメントのコードへのジャンプでした。

3つの命令で実装できるものはすべて非常に高速でした。第2世代または第3世代までに、PowerPC Macは68,000のプロセッサよりも68,000のコードが高速に実行されたと思います。

1
gnasher729

私は専門家ではありませんが、覚えていることをお話しします。

8086プロセッサでは、コードはビットフィールドにエンコードされていました。同じバイトで、命令のフラグメントとデータのフラグメントを見つけることができます。

したがって、メモリ内のすべてのビットを分析するのではなく、デコードされる実際の情報に集中する必要があります。

例を挙げましょう。 3つの命令がビットエンコードされていると仮定します。

110
1110
1111

これで16 switchcasesを作成できます。または、最適化して4のみを作成することもできます。

1100
1101
1110
1111

それでもつのテストのみが必要、それ以外はすべて「ゴミ」です。

したがって、私は次のようなものを実装します:

if ( 1110 == code )
{

}
else if ( 1111 == code)
{

}
else if ( 110 == code)
{

}

テストの順序が重要になる場合があることに注意してください。また、上記のコードは実際には疑似コードです。バイト内から有用なデータを抽出するためのビット操作は示していません。

つまり、必要最低限​​のことは、不要なコードを記述しないことです。

0
virolino