Skip to content

Latest commit

 

History

History
352 lines (245 loc) · 22.1 KB

cooperative-threading.md

File metadata and controls

352 lines (245 loc) · 22.1 KB

協調型スレッディング

  この記事は Cooperative Threading を翻訳したものです。
  独自の説明を加筆したり、意訳したものところもあります。

エミュレータ実装の最大の課題は、プログラミングコードが本質的にシリアルであるのに対し、エミュレータでは多くのプロセスが並列に実行されていることです。

実際、レトロゲームのエミュレータを書く際に難しいのは、大体の場合、コンポーネント間の同期の管理です。

この記事では、私がエミュレータのために選んだアプローチである協調型マルチスレッドについて説明します。これはいくつかのアプローチのうちの1つであり、私はこのアプローチが最善だという主張をしたいわけではありません。私は、このアプローチの長所と短所をなるべく客観的に紹介するつもりです。

同期の概要

今回の目的は、エミュレータのコンポーネント間の同期です。

例えば、スーパーファミコンのエミュレータを実装する際に、システムを 汎用CPU、オーディオAPU、ビデオPPU、サウンド出力DSP の4つのコンポーネントに分けるとします。ここでは、説明のために少し物事を単純化しています(例えば、PPUプロセッサは2つあります)。

これらのコンポーネント(チップ)はそれぞれ約20MHzのクロックレートで動作します。

エミュレータでは各チップをシリアルに、つまり1つずつ動作させる必要がありますが、本物のスーファミではこれらのチップをすべてパラレルに、つまり同時に動作させます。

エミュレータでは1つのチップを一定時間動作させた後、そのチップに追いつくように他のチップを一定時間動作させて同期させます。

エミュレーションの精度を高くしたいなら、この同期間隔をより短くする必要があります。

1つの実装例としては、一度に1つのCPUオペコードを実行し、一度に1つのスキャンラインをレンダリングし、一度に1つのオーディオサンプルを生成することができます。そうすれば、1990年代後半に開発されたものと同じくらいの精度の、非常に高速なスーファミのエミュレータができあがります。

他の実装例としては、CPUのオペコードサイクルを1つ分実行し、一度に1ピクセルのレンダリングを行い、オーディオサンプルの生成を32の異なるサイクルステージに分割することもできます。そうすると、bsnesのような高精度なエミュレータができます。

エミュレーションの同期間隔が短くなるほど、コンポーネントから別のコンポーネントへの切り替えも複雑になります。例えば、CPUの命令をエミュレートしているときに、1サイクルごとにビデオPPUに切り替えて1ピクセルだけ描画し、再びCPUに戻る必要があるとします。これではエミュレータのコードがとても複雑になってしまいますが、これを解決する1つのアプローチが協調スレッド処理です。

先取り型スレッディング

最近のプロセッサは多くのコアを持っていますし、シングルコアのCPUでもOSのスレッド機能で複数のタスクをスレッドの形で並列に実行することができます。これを利用してエミュレータを作ることはできないのでしょうか?

答えは、残念ながらNO(できない)です。

スーファミを正確にエミュレートするには、エミュレートされた1秒間に数千万回の同期(コンテキストスイッチ)が必要になります。

CPUがシングルコアの場合、内部的にはすべてのスレッドはシリアルで実行され、OSはカーネルモードでコンテキストスイッチを実行します。ユーザーランドとカーネルモードの切り替えは、CPUにとって非常にコストが高く、どれだけ性能のいいCPUでも1秒間に数十万回程度です。

CPUがマルチコアの場合、シングルコアよりは状況はマシと言えますが、そもそも、シングルコアでもマルチコアでも、PCのクロック(約3GHz)とスーファミのクロック(約20MHz)が異なるという事実に対処しなければなりません。

結局、各コンポーネント間の同期を取るには、ロックやミューテックス、セマフォなどを使って、あるコンポーネントのスレッドで一定時間実行した後に、そのスレッドが他のスレッドを待つようにしてやる必要があります。

しかし、これらの方法も非常にコストが高く、インターロックインクリメントやインターロックデクリメントのようなアトミックな命令に落とし込んだとしても、現代のCPUは巨大なパイプラインと各CPUコア間の低速なデータ線で大規模な並列化を行っているため、それに対抗することはできません。

ステートマシーン

レトロゲームのエミュレータ開発では、ステートマシンを用いることが一般的です。

例としてサイクルベースのCPUインタプリタを、ステートマシンを使って分割する方法を紹介します。

// SNESをモデルとしていますが、説明のために大幅に簡略化しています。
void CPU::executeInstructionCycle() {
  // cycle = CPUサイクル
  cycle++;
  if (cycle == 1) {
    opcode = readMemory(PC++);
    return;
  }

  // M=1: 8-bit accumulator instructions in SNES.
  if(FlagM) {
    switch(opcode) {
      // B9: MOV A,[nnnn+Y], 4 CPUサイクル
      case 0xb9:
        switch(cycle) {
          case 2:
            address = readMemory(PC++);
            return;

          case 3:
            address = readMemory(PC++) | address << 8;
            return;

          case 4:
            // possible penalty cycle when crossing 8-bit page boundaries:
            if(address >> 8 != address + Y >> 8) {
              return;
            }
            cycle++;  // cycle 4 not needed; fall through to cycle 5

          case 5:
            A = readMemory(address + Y);
            cycle = 0;  // end of instruction; start a new instruction next time
            return;
        }
    }
  }
}

PPUによるレンダリングをピクセル単位のタイムスライスに分割したり、APUによるサンプル生成をサイクル単位のタイムスライスに分割したりと、それぞれのcycleに複雑な処理が必要です。

スーファミの場合、これだけでは足りません。CPUの1サイクルは、6〜12のマスタークロックサイクルからなります。アドレスバスに目的のアドレスが準備され、一定時間後に他のチップがバス上のデータを確認するのにかかる時間のせいです。これをbus hold delayといいます。

基本的に、CPUがPPUからデータを読み出す場合、PPUが瞬時にデータを返すことは期待できません。また、CPUがPPUに書き込む場合にも少々時間が必要です。

そのため、読み出しをクロックサイクル単位に分割するためには、上記のreadMemory()を呼び出すたびに、さらに細かいレベルのステートマシンを追加する必要があります。

// SNESをモデルとしていますが、説明のために大幅に簡略化しています。
void CPU::executeInstructionCycle() {
  // cycle = CPUサイクル
  if (cycle == 1) {
    opcode = readMemory(PC++);
    cycle = 2;
    return;
  }

  // M=1: 8-bit accumulator instructions in SNES.
  if (FlagM) {
    switch (opcode) {
      // B9: MOV A,[nnnn+Y], 4 CPUサイクル
      case 0xb9:
        switch (cycle) {
          case 2:
            switch (subcycle) {
              case 1:
                subcycle = 2;
                return;
              case 2:
                address = readMemory(PC++);
                subcycle = 1;
                return;
            }

          case 3:
            switch (subcycle) {
              case 1:
                subcycle = 2;
                return;
              case 2:
                address = readMemory(PC++) | address << 8;
                subcycle = 1;
                return;
            }

          case 4:
            // possible penalty cycle when crossing 8-bit page boundaries:
            if (address >> 8 != address + Y >> 8) {
              return;
            }
            cycle++;  //cycle 4 not needed; fall through to cycle 5

          case 5:
            switch (subcycle) {
              case 1:
                subcycle = 2;
                return;
              case 2:
                A = readMemory(address + Y);
                subcycle = 1;
                cycle = 0;  //end of instruction; start a new instruction next time
                return;
            }
        }
    }
  }
}

協調型スレッディング

協調型スレッディングの考え方は先取り型スレッディングと似ていますが、カーネルがスレッドを処理するのではなく、ユーザーランドのプロセスが代わりに処理します。

協調スレッドはすべてシリアルで実行されます。つまり、スレッドは一度に1つしか実行されません。必要に応じてコンテキストスイッチで別のスレッドに切り替え、前回終了したところから再開することになります。

コルーチンとは異なり、各協調スレッドは独自のスタックフレームを持ちます。これは、1つのスレッドが、スタックフレームの奥深くにある数層の関数呼び出しになり得ることを意味します。

上で見たように、ステートマシンのコードはとても記述量が多くなってしまいますが、協調型スレッドのアプローチに沿って書くとスタックフレームの一部として隠蔽することが可能です。

試しに、上のコードを書き換えると、次のようになります。

void CPU::executeInstruction() {
  opcode = readMemory(PC++);

  // M=1: 8-bit accumulator instructions in SNES.
  if(FlagM) {
    switch(opcode) {
      // B9: MOV A,[nnnn+Y], 4 CPUサイクル
      case 0xb9:
        address = readMemory(PC++);
        address = readMemory(PC++) | address << 8;
        if (address >> 8 != address + Y >> 8) wait(6);
        A = readMemory(address + Y);
    }
  }
}

コードが一気にシンプルになりました。

どのような仕組みになっているのでしょうか? そのためには、readMemory()関数の中を見てみましょう。

uint8_t CPU::readMemory(uint16_t address) {
  wait(2);
  uint8_t data = memory[address];
  wait(4);
  return data;
}

wait()の中も見てみましょう。

void CPU::wait(uint clockCycles) {
  apuCounter += clockCycles;
  while (apuCounter > 0) {
    yield(apu);
  }
}

wait()はクロックサイクルを消費するだけですが、CPUがAPUよりも先に進んだ状態になると、CPUは別スレッドで実行されているAPUに実行を譲り、APUは前回の続きから再開することになります。

APUが追いつき、CPUよりも先に進んだ時点で、APUはCPUに処理を譲り、CPUは最後のyield()した直後から再開します。

協調型スレッドは呼び出し(Call)よりもジャンプと考えることができます。APUでは、戻るのではなく、CPUにジャンプしただけです。(結果だけ見るとCallのように戻っていますが)

技術的には、これを対称型の協調スレッドと呼びます。

典型的なcall/returnのスタイルのように機能する非対称型の協調スレッドもありますが、私の考えでは、多くのスレッドがすべて並行して実行されるエミュレータには適していません。CPUがAPUにジャンプし、APUがCPUに戻るのではなくPPUにジャンプする場合などがあり、そのような場合にcall/returnスタイルでは対応しきれないからです。

協調型スレッディングの最適化

ひとつ気になることがあります。それは、なぜクロックサイクルごとにCPUとAPUを同期させているのかということです。

例えば、CPUがAPUからアクセスできない内部メモリを読み取る場合、APUがその前に内部メモリの内容を変更する可能性は無いので同期を取る必要がありません。

基本的には、プロセッサ間で同期をとる場合は、エミュレートしたプロセッサを可能な限り小さなタイムスライスに分割し、それに合わせてステップを踏む必要があります。

エミュレータは、CPUがあるサイクルでAPUにアクセスしようとしていないことを確認して、APUを少し動かす前にもっと多くのサイクルを走らせることができますが、実際にはステートマシンを通じてすでにオーバーヘッドを支払っていることになります。

しかし、協調スレッドは、私が「jit同期」と呼ぶ最適化の機会を与えてくれます。先ほどの例をもう少し詳しく説明しましょう。

uint8_t CPU::readMemory(uint16_t address) {
  wait(2);
  if (address >= 0x2140 && address <= 0x2143) {
    // この読み出しは、APUとの共有メモリにアクセスすることになります。
    // つまり、CPUが読み出す前にAPUがその内容を変更している可能性があるので、APUに処理を渡してCPUに追いつかせる必要があります。
    while (apuCounter > 0) {
      yield(apu);
    }
  }

  uint8_t data = memory[address];
  wait(4);
  return data;
}
void CPU::wait(uint clockCycles) {
  apuCounter += clockCycles;
}

上のコード例では、APUとの共有メモリ領域からの読み出しを試みるまで、CPUが動作し続けるようにしています。そして、共有メモリの場合は、APUがCPUに追いついてから読み出しを行うようにしました。

CPUがAPUとの同期を頻繁に取る必要がなければ、あるいは逆にAPUがCPUとの同期を頻繁に取る必要があれば、1秒間のコンテキストスイッチの回数は劇的に減少します。

実際にスーファミ(bsnes)の場合、CPUとAPUを同期させる必要があるのは1秒間に数千回程度でした。

協調型スレッディングの限界

しかし、2つのプロセッサ(ここではCPUとAPU)がお互いにすべてのメモリアドレス空間を共有している場合はどうでしょうか?

そうすると、(現在先行している)CPUがメモリを読み出す前にAPUがメモリを変更する可能性があるため、常に同期を取る必要があります。

例えば、Nemesis社のExodusエミュレータのように、ロールバック機構を実装するなどの方法がありますが、その場合、このアプローチによるコードの簡素化の利点が失われてしまいます。

最悪の場合、協調スレッド処理は、ステートマシンと同じレベルのオーバーヘッドに戻ってしまいます。

パイプラインのストール

実際には、最悪の場合、協調スレッド処理はステートマシン方式よりも多少遅くなります。

最近のCPUは、多段のパイプラインを構築することで高速化を実現しています。

協調スレッディングでスタックポインタを変更すると、このパイプラインがパニックに陥る可能性が高いです。

  例えば、組み立てラインがあるとします。ラインに沿って、製品がゆっくりと組み立てられていきます。
  ここで、順調に製品を組み立てている最中に、別の製品の注文が入り、待ちきれなくなったとします。しかし、組み立てラインは1本しかありませんでした。
  組立ラインを止めて、すべての商品を外し、新しい商品の組立を始めなければなりません。1秒間に何千万回も、これを繰り返すことを想像すると、サイクル精度の高いレトロゲームのエミュレータがなぜこれほどまでにマシンパワーを要求するのかがわかってきます。

とはいえ、ステートマシンも同じ問題を抱えています。ある実行コード(CPUエミュレーション)と別の実行コード(APUエミュレーション)を切り替えるのは、動作中に歯車が止まるようなもので、L1命令キャッシュを使い切ってしまうなどの問題があります。

しかし、協調スレッディングでスタックポインタを変更するのは、L1キャッシュミスどころではないほどのペナルティです。私の経験則ですが、単一レベルのステートマシンを使ってプロセッサをエミュレートすることができれば、協調スレッドによるアプローチよりもかなり高速に動作することが多いです。

一方で、前述のCPUのオペコードの例のように、2段階以上のステートマシンになると、協調スレッドモデルが性能面で明らかに勝ることになります。

また、協調スレッドがステートマシーン方式より遅い場合でも、先ほど説明したjit同期の適用度合いによって結果が変わってきます。

設計の選択肢

高速な動作を望んでいるなら、エミュレート対象のシステムが、

・コンポーネント間でどれくらいリソースを共有しているか
・ステートマシンにするなら深さはどれくらいか

などを考えて、協調型スレッドとステートマシンを使い分けるべきです。

Higanエミュレータでは、一貫性を重視してすべてのプロセッサを協調スレッドでエミュレートすることにしました。もちろんパフォーマンス上のペナルティは承知の上です。

シリアライズ

おそらくエミュレータ開発者にとって、協調型スレッディングの最大の関心事はシリアライズ、つまりセーブステートの保存と読み込みです。

セーブステートは、進捗状況を保存したり復元したりするのに便利なだけでなく、遅延のないネット対戦や入力の先取りなどの機能の実現に必要なものです。

ステートマシンを使用する場合、シリアライズとは、先ほどのコード例で言えば、opcodecyclesubcycleなどの変数をすべて保存・復元することです。

しかし、協調型スレッディングを使用している場合、これらの情報は暗黙の了解となり、コールフレームやCPUコンテキストレジスタの形で個々のスタックに保存されます。

協調型スレッディングでシリアライズを実装することは可能ですが、複雑なテーマなので、このテーマについては別の記事を書きました。その記事はこちらからご覧いただけます。

Cooperative Threading - Serialization

実装

ここまで読んでいただいて、協調スレッディングを試してみようと考えている人は、実装する言語で協調型スレッドモデルの実装が必要です。

C++20では、コルーチンの拡張機能が提案されていますが、この記事を書いている時点ではまだ実装が不完全なのでエミュレーションに役立つかどうかはわかりません。おそらく最大の障壁は、C++20のコルーチンがスタックレスであることで、中程度の複雑さのものであってもその可能性が大きく制限されてしまうことです。

代わりに、私はC89コードで独自の協調スレッドライブラリを開発しましたが、これはC++コードでも使用できます。そのソースコードはbsnes/libcoで見ることができます。

基本的には、コンテキストスイッチングを実装するために、サポートされている各アーキテクチャのアセンブラを触る必要があります。なぜなら、CPUのコンテキストレジスタやスタックフレームを直接変更することは、ほとんどのプログラミング言語では許可されていないからです。しかし、x86のコンテキストスイッチはわずか12行のアセンブラコードで、32bitARMはわずか3行で実装することができるため、思っているよりは簡単なはずです。

libcoはISCライセンスで、非常に多くのCPUアーキテクチャをサポートしているため、libcoを使うなら協調スレッディングの実装をすべて自分で行う必要はありません。

libcoを使わなかったとしても、ほとんどのメジャーな言語で協調スレッディングの実装例やライブラリがあるので、それを使ってもいいですし、それでも不満なら全部自分で作ってもいいでしょう。

終わりに

いずれにしても、協調スレッディングは、ステートマシンを管理する負担を取り除くことで、コードを読みやすくするという点で、私のエミュレータに独自の利点を与えてくれていると思いますし、クロックサイクルに正確に対応することも簡単です。

繰り返しになりますが、協調スレッディングはレトロゲームのエミュレータを書くための最良の方法とは限りません。

最後まで読んでくれてありがとうございました。