FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

SonicBOOMに関する調査 (3. FPUの構成)

次はBOOMのFPUについて解析する。BOOMのFPUパイプラインは以下のファイルで定義されている。

  • src/main/scala/exu/fp-pipeline.scala
  //**********************************
  // construct all of the modules

  val exe_units      = new boom.exu.ExecutionUnits(fpu=true)
  val issue_unit     = Module(new IssueUnitCollapsing(
                         issueParams.find(_.iqType == IQT_FP.litValue).get,
                         numWakeupPorts))
  issue_unit.suggestName("fp_issue_unit")
  val fregfile       = Module(new RegisterFileSynthesizable(numFpPhysRegs,
                         exe_units.numFrfReadPorts,
                         exe_units.numFrfWritePorts + memWidth,
                         fLen+1,
                         // No bypassing for any FP units, + memWidth for ll_wb
                         Seq.fill(exe_units.numFrfWritePorts + memWidth){ false }
                         ))

fregfileはFPUのレジスタファイルだが、ワード数は定義通りだとしても、ビット幅がfLEN+1で定義されている。これはFPUがHardFloatのRecodedフォーマットなので、fLEN+1ビット必要という訳だ。

実行ユニットは、FPU(fpu=1)の場合はHardFloatのモジュールがインスタンス化されるようになっている。

class ExecutionUnits(val fpu: Boolean)(implicit val p: Parameters) extends HasBoomCoreParameters
{
  val totalIssueWidth = issueParams.map(_.issueWidth).sum
  } else {
    val fp_width = issueParams.find(_.iqType == IQT_FP.litValue).get.issueWidth
    for (w <- 0 until fp_width) {
      val fpu_exe_unit = Module(new FPUExeUnit(hasFpu = true,
                                             hasFdiv = usingFDivSqrt && (w==0),
                                             hasFpiu = (w==0)))
      exe_units += fpu_exe_unit
    }
  }

FPUExeUnitの中身は以下のようになっている。パラメータとして、FP除算をサポートするか、整数との変換をサポートするかを指定できる。

class FPUExeUnit(
  hasFpu  : Boolean = true,
  hasFdiv : Boolean = false,
  hasFpiu : Boolean = false
  )
  (implicit p: Parameters)
  extends ExecutionUnit(
    readsFrf  = true,
    writesFrf = true,
    writesLlIrf = hasFpiu,
    writesIrf = false,
    numBypassStages = 0,
    dataWidth = p(tile.TileKey).core.fpu.get.fLen + 1,
    bypassable = false,
    hasFpu  = hasFpu,
    hasFdiv = hasFdiv,
    hasFpiu = hasFpiu) with tile.HasFPUParameters
{

FPUUnitがおそらくFMAの実体らしい。

  // FPU Unit -----------------------
  var fpu: FPUUnit = null
  val fpu_resp_val = WireInit(false.B)
  val fpu_resp_fflags = Wire(new ValidIO(new FFlagsResp()))
  fpu_resp_fflags.valid := false.B
  if (hasFpu) {
    fpu = Module(new FPUUnit())
    fpu.io.req.valid         := io.req.valid &&
                                (io.req.bits.uop.fu_code_is(FU_FPU) ||
                                io.req.bits.uop.fu_code_is(FU_F2I)) // TODO move to using a separate unit
    fpu.io.req.bits.uop      := io.req.bits.uop
    fpu.io.req.bits.rs1_data := io.req.bits.rs1_data
    fpu.io.req.bits.rs2_data := io.req.bits.rs2_data
    fpu.io.req.bits.rs3_data := io.req.bits.rs3_data
    fpu.io.req.bits.pred_data := false.B
    fpu.io.req.bits.kill     := io.req.bits.kill
    fpu.io.fcsr_rm           := io.fcsr_rm
    fpu.io.brupdate          := io.brupdate
    fpu.io.resp.ready        := DontCare
    fpu_resp_val             := fpu.io.resp.valid
    fpu_resp_fflags          := fpu.io.resp.bits.fflags

    fu_units += fpu
  }

FPUUnitから先は見たことのあるHardFloatの実装となっていた。

class FPUUnit(implicit p: Parameters)
  extends PipelinedFunctionalUnit(
    numStages = p(tile.TileKey).core.fpu.get.dfmaLatency,
    numBypassStages = 0,
    earliestBypassStage = 0,
    dataWidth = 65,
    needsFcsr = true)

経産省の「半導体・デジタル産業戦略」の資料を読んでみた

経済産業省がまとめた「半導体・デジタル産業戦略」の資料が公開されていたので読んでみた。いわゆる「半導体の国家戦略」と元となる資料で、どういう施策を計画しているのか興味があるので読んでみることにした。

www.meti.go.jp

資料は4種類用意されているが、読んだのは一番最初の「半導体・デジタル産業戦略(PDF形式:814KB)」と最後の「半導体戦略(PDF形式:8,652KB)」の2種類だ。 戦略どうのこうのは私は良く分からないが、半導体を概略した資料としてはとても読みやすく分かりやすいと感じた。

戦略としては以下の4つに分けられるようだ。

これはTSMCを誘致するとかいう話だと思う。海外ファウンドリと、産総研で共同開発して前工程と後工程を開発するという話か。

f:id:msyksphinz:20210606010730p:plain

たしか東大とTSMCがアライアンスを組んでいたようだけど、これはどうなったのだろう?

www.u-tokyo.ac.jp

  • 国内対策② デジタル投資の加速と先端ロジック半導体の設計・開発強化

これはロジック半導体の設計の話。かなり具体的な企業名も出ている様子。ポスト5G、アプリケーションシステム基盤半導体、AIチップで分けられている。 そんでもって面子を見るにNEDOプロジェクトを持っている人たちがそのまま継続という感じか。これは、目新しさは感じられない。 参考資料を見ても、既存の取り組みの紹介が多いので、今ある組合を継続して取り組む、という感じかな。

f:id:msyksphinz:20210606010919p:plain

これはデバイスっぽい話に見えるな。パワー半導体とか、光デバイスの話が中心っぽい。専門外なので良く分からない。

f:id:msyksphinz:20210606011246p:plain

最後は全体的な施策。人材を育てて技術開発を促進。

f:id:msyksphinz:20210606013304p:plain

SonicBOOMに関する調査 (2. リネームマップの調査)

SonicBOOMのロールバックについてもう少し。 よく考えてみると、物理レジスタIDは新しい命令から順にロールバックして行かないと確定した最も古い命令まで戻ってくることができないので、ROBエントリを逆順に戻してくるはずだ(まさに「ロールバック」なのでその通りなのだが)。 この辺の動作を確認していく。

まず、ロード命令において例外が発生すると、io.rollbackが有効化され物理レジスタがマップテーブルに戻されていく。これは昨日見た通りだ。

このとき、どのように戻されていくのかというのをROB側で観察すると、やはりROBのポインタが通常とは逆方向に回っていることが確認できた。

f:id:msyksphinz:20210603233300p:plain

通常はrob_tailは順方向に上っていくのだが、この時だけは逆方向にロールバックする。

通常は以下のように順方向に進むはずである。

f:id:msyksphinz:20210603233324p:plain

これを実現しているのがROBのステートマシンである。以下のようになっていた。

  • src/main/scala/exu/rob.scala
  when (rob_state === s_rollback && (rob_tail =/= rob_head || maybe_full)) {
    // Rollback a row
    rob_tail     := WrapDec(rob_tail, numRobRows)
    rob_tail_lsb := (coreWidth-1).U
    rob_deq := true.B
  } .elsewhen (rob_state === s_rollback && (rob_tail === rob_head) && !maybe_full) {
    // Rollback an entry
    rob_tail_lsb := rob_head_lsb
  } .elsewhen (io.brupdate.b2.mispredict) {
    rob_tail     := WrapInc(GetRowIdx(io.brupdate.b2.uop.rob_idx), numRobRows)
    rob_tail_lsb := 0.U
  } .elsewhen (io.enq_valids.asUInt =/= 0.U && !io.enq_partial_stall) {
    rob_tail     := WrapInc(rob_tail, numRobRows)
    rob_tail_lsb := 0.U
    rob_enq      := true.B
  } .elsewhen (io.enq_valids.asUInt =/= 0.U && io.enq_partial_stall) {
    rob_tail_lsb := PriorityEncoder(~MaskLower(io.enq_valids.asUInt))
  }

SonicBOOMに関する調査 (リネームマップの調査)

SonicBOOMのリネームマップについてその構造を調査した。リネームマップは、論理レジスタから物理レジスタIDへの変換を行うことで仮想的に論理レジスタよりも多くのレジスタを扱うことができるような機能で、アウトオブオーダ実行を行うCPUではほぼ必須の機能だと考えて良い。

今回調査したいのはリネームマップ本体なので、リネームのほかの機能(freelist, busytable)についてはとりあえず省略する。マップの所だけを見ていくことにする。

リネームマップの入出力ポートは以下のような構成になっていた。

  val io = IO(new BoomBundle()(p) {
    // Logical sources -> physical sources.
    val map_reqs    = Input(Vec(plWidth, new MapReq(lregSz)))
    val map_resps   = Output(Vec(plWidth, new MapResp(pregSz)))

    // Remapping an ldst to a newly allocated pdst?
    val remap_reqs  = Input(Vec(plWidth, new RemapReq(lregSz, pregSz)))

    // Dispatching branches: need to take snapshots of table state.
    val ren_br_tags = Input(Vec(plWidth, Valid(UInt(brTagSz.W))))

    // Signals for restoring state following misspeculation.
    val brupdate      = Input(new BrUpdateInfo)
    val rollback    = Input(Bool())
  })

最初のmap_reqs, map_respsはリネーム情報のリクエストのためのポートだ。命令はmap_reqsを通じて論理レジスタ番号を入力し、map_respsを通じて物理レジスタ番号を返してもらう。通常のパスだ。

次のremap_reqは新しいリネームIDの配置操作となっている。おそらくはfreelistから取り出した新しい物理レジスタIDを、論理レジスタ番号とのペアで挿入して新たにリネームIDに配置する。

ren_br_tagsは分岐命令毎のスナップショット取得信号だと思われる。分岐命令に遭遇したら、リネームマップのスナップショットをとって退避しておく。そして万が一分岐予測に失敗した場合、ロールバック信号brupdateを使ってリネームマップを元に戻す。このときに、各分岐命令にはbrtagが付けられるとドキュメントには書いてあり、それぞれのbrtagに基づいてスナップショットのエントリに退避されるものと思われる。

以下がレジスタの定義となっている。map_tableは最新の論理レジスタ→物理レジスタマッピングを示しており、br_snapshotは分岐命令毎のスナップショットを保持しているということになる。map_tableは物理レジスタビット幅$\times$論理レジスタの数(32)であり、br_snapshotsは物理レジスタビット幅$\times$論理レジスタの数(32)$\times$brtagsの数となっており、とうぜんスナップショットのための配列はデカい。

  // The map table register array and its branch snapshots.
  val map_table = RegInit(VecInit(Seq.fill(numLregs){0.U(pregSz.W)}))
  val br_snapshots = Reg(Vec(maxBrCount, Vec(numLregs, UInt(pregSz.W))))

remap_tableは、それぞれリネームマップにアクセスしてきた命令(同時に複数命令をリネームするので)、それぞれについてリネームを行うための最新の情報を含んだリネームマップテーブルを作り上げる。

  // Uops requesting changes to the map table.
  val remap_pdsts = io.remap_reqs map (_.pdst)
  val remap_ldsts_oh = io.remap_reqs map (req => UIntToOH(req.ldst) & Fill(numLregs, req.valid.asUInt))

  // Figure out the new mappings seen by each pipeline slot.
  for (i <- 0 until numLregs) {
    if (i == 0 && !float) {
      for (j <- 0 until plWidth+1) {
        remap_table(j)(i) := 0.U
      }
    } else {
      val remapped_row = (remap_ldsts_oh.map(ldst => ldst(i)) zip remap_pdsts)
        .scanLeft(map_table(i)) {case (pdst, (ldst, new_pdst)) => Mux(ldst, new_pdst, pdst)}

      for (j <- 0 until plWidth+1) {
        remap_table(j)(i) := remapped_row(j)
      }
    }
  }

そしてそれらを、br_snapshotsに分岐命令に応じて格納する。

  // Create snapshots of new mappings.
  for (i <- 0 until plWidth) {
    when (io.ren_br_tags(i).valid) {
      br_snapshots(io.ren_br_tags(i).bits) := remap_table(i+1)
    }
  }

もし分岐予測に失敗したら、当該br_snapshotsからリネームマップリストを引っ張り出してきて復元するという訳だ。

  when (io.brupdate.b2.mispredict) {
    // Restore the map table to a branch snapshot.
    map_table := br_snapshots(io.brupdate.b2.uop.br_tag)
  } .otherwise {
    // Update mappings.
    map_table := remap_table(plWidth)
  }

と、ここまでなら分岐命令に応じて正しく動作しそうだが、これは例外が発生した場合もちゃんと動くのだろうか?例外命令が発生してPCをアップデートしてどこかに飛ばす場合、どのようにリネームマップを復元するのだろう?

と思ったら実装があった。例外のようなケースには、io.rollbackが有効化され、それに応じて古い物理レジスタIDがロールバックされるらしい。なるほど。

  // Generate maptable requests.
  for ((((ren1,ren2),com),w) <- ren1_uops zip ren2_uops zip io.com_uops.reverse zipWithIndex) {
    map_reqs(w).lrs1 := ren1.lrs1
    map_reqs(w).lrs2 := ren1.lrs2
    map_reqs(w).lrs3 := ren1.lrs3
    map_reqs(w).ldst := ren1.ldst

    remap_reqs(w).ldst := Mux(io.rollback, com.ldst      , ren2.ldst)
    remap_reqs(w).pdst := Mux(io.rollback, com.stale_pdst, ren2.pdst)
  }

TileLinkのCache Coherencyプロトコル (8. TileLink-Cの動きを把握する)

TileLinkによるTL-Cによるコヒーレントプロトコルの確認、もう少し波形を読み解いていく。

  1. まず、アドレス0x80002540に対するメモリアクセスが発生し、Core0がL2キャッシュにたいしてそのキャッシュラインのアクセスリクエスト(AcquireBlock)を出す。

  2. それに対して、L2キャッシュはCore-1(実際のキャッシュラインを持っているコア)に対してProbeコマンド(チャネルB)を発出する。

  3. これに対してCore-1はチャネルCを使ってProbeAckProbeAckDataを返す。

  4. これに伴いCore-0に対してL2キャッシュがGrantAckDataを使ってデータを返す

という流れになっている。

f:id:msyksphinz:20210602000616p:plain

ここから先のリクエストキューの動きを追いたいが、時間切れ...

TileLinkのCache Coherencyプロトコル (7. TileLinkのプロトコル確認)

前回のプログラムにおいて、TileLinkがどのような動きをしているのか確認してみる。

あるキャッシュブロックを取得する際、L2キャッシュはBチャネルを使ってProbeコマンドを送り、そのブロックを持っているキャッシュに対してそのブロックを取得するためのコマンドを送出するようだ。 以下の図は、BチャネルによるキャッシュブロックのProbeに対するCチャネルでのレスポンスを示している。

f:id:msyksphinz:20210601001633p:plain

キャッシュブロックを取り合う様子を見てみようと思う。データの更新回数を1000回にして、その間にブロックがコア間を行きかうのを見てみようと思う。

 void slave_main(int hartid)
 {
   // printf ("hartid = %d\n", hartid);
   for (int i = 0; i < 1000; i++) {
     false_share_data.core_data[hartid]++;
   }
   __asm__ __volatile__ ("amoadd.w x0, %0, (%1)": :"r"(1), "r"(&finish_wait));
   while(finish_wait != 2);

   // printf("core_data[%d] = %d\n", hartid, false_share_data.core_data[hartid]);
 }
f:id:msyksphinz:20210601002750p:plain

こんな感じで、各コアでブロックを取り合っているのが分かると思う。

TileLinkのCache Coherencyプロトコル (6. DualCore環境でのメモリを取り合うプログラムを試す)

msyksphinz.hatenablog.com

前回のコードは、ブートローダの動きを観察するにCRTに多少の改造を加えなければならないことが分かった。

  • crt.S
  # get core id
  csrr a0, mhartid
  # 最大4コアまでを仮定する
  li a1, 4
1:bgeu a0, a1, 1b

main()関数の中の動きは基本的に同じだが、各コアをシンクロするためにカウンタを持っておき、すべてのコアがアトミックに値を更新し終えるまで待ち合わせるような原始的な同期方法を取っている。

volatile int init_wait = 0;

int main (int hartid)
{
  if (hartid == 0) {
    for (int i = 0; i < 16; i++) {
      false_share_data.core_data[i] = 0;
    }
  }

  __asm__ __volatile__ ("amoadd.w x0, %0, (%1)": :"r"(1), "r"(&init_wait));

  while(init_wait != 2);

  slave_main(hartid);
}

全てのデータのアップデートを終えると、終了条件を確認するために、もう一度同期して終了する。

volatile int finish_wait = 0;

void slave_main(int hartid)
{
  printf ("hartid = %d\n", hartid);
  for (int i = 0; i < 100; i++) {
    false_share_data.core_data[hartid]++;
  }
  printf("core_data[%d] = %d\n", hartid, false_share_data.core_data[hartid]);

  __asm__ __volatile__ ("amoadd.w x0, %0, (%1)": :"r"(1), "r"(&finish_wait));

  while(finish_wait != 2);
}

これでRTLをシミュレーションして動作を確認してみる。

./simulator-chipyard-DualMediumBoomConfig-debug --vcd=false_sharing.dual.vcd --verbose \
    ../../software/test/false_sharing/main.riscv 2>&1 | spike-dasm | tee false_sharing.dual.log
$ spike-dasm < false_sharing.dual.log | grep -v ^1  | grep -v ^0
using random seed 1622289306
This emulator compiled with JTAG Remote Bitbang client. To enable, use +jtag_rbb_enable=1.
Listening on port 61167
hartid = 0
core_data[0] = 100
core_data[1] = 100
*** PASSED *** Completed after 75563 cycles
[UART] UART0 is here (stdin/stdout).

とりあえず2コアで動作するようになった。よしよし。次はL2エントリの取り合いを波形で確認してみる。