FPGA開発日記

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

FPGA開発日記 カテゴリ別インデックス

続きを読む

RISC-V OoO を実装する (2. x0 の落とし穴と参照モデル TB)

前回、PRF (物理レジスタファイル) の実装を終わらせた。 ゼロレジスタ x0 の扱いは「preg 0 を sink にして、write を silent drop する」方式を採り、write side に guard を書いた:

if (i_wr_en[w] && (i_wr_addr[w] != 'h0)) begin   // ← preg 0 sink
  r_prf[i_wr_addr[w]] <= i_wr_data[w];
end

これで preg 0 は永遠に 0 のまま ─ と思っていた。 ところが F1 バイパスと組み合わさると、x0 = 0 の semantic が壊れる隙間ができていた。

バグのシチュエーション

以下 2 命令が同サイクルにパイプラインの WB / RRD に居るケースを考える。

  • 命令 A: addi x0, x10, 1 ─ リネームで rd=x0 → wr_en=1, wr_addr=0, wr_data=1 として PRF に届く
  • 命令 B: 同サイクルに add x2, x0, x11 ─ リネームで rs1=x0 → rd_addr=0 として PRF に届く

前回書いた PRF の read side はこうなっていた:

always_comb begin
  o_rd_data[p_idx] = r_prf[i_rd_addr[p_idx]];
  for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
    if (i_wr_en[w] & (i_wr_addr[w] == i_rd_addr[p_idx])) begin
      o_rd_data[p_idx] = i_wr_data[w];    // F1 bypass
    end
  end
end

このコードは write side の guard を経由せず、生の wr_en & (wr_addr == rd_addr) だけで判定している。 命令 A の write は wr_addr = 0、命令 B の read も rd_addr = 0、条件成立で wr_data = 1 が forward される。 命令 B は x0 = 1 を読む。

一方、write side は guard により r_prf[0] を更新しないので、次サイクル以降に preg 0 を読めばちゃんと 0 が返る。 同サイクルだけ 1 になる、時間的に不連続な不具合という気持ち悪い挙動。

気づいたきっかけ

自分でも当初は気づかず、テストベンチのシナリオを列挙している最中に踏んだ。

  • 「preg 0 に write する場合はどう?」 → 「write は sink されるから、次サイクル read すれば 0 が返る」 → OK
  • 「じゃあ同サイクルに write と read を preg 0 でやったら?」 → 「... あれ、F1 バイパスがそのまま流すのでは」

RTL を見返して、案の定 leak していた。 テストを書こうとする行為自体が、実装の穴を炙り出してくれた。 よくあるやつだが、経験するたびに TB の効能を実感する。

修正

read side の最後に、preg 0 read は必ず 0 を強制する 1 行を足した:

always_comb begin
  o_rd_data[p_idx] = r_prf[i_rd_addr[p_idx]];
  for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
    if (i_wr_en[w] & (i_wr_addr[w] == i_rd_addr[p_idx])) begin
      o_rd_data[p_idx] = i_wr_data[w];
    end
  end
  // arch x0: preg 0 always reads as 0 regardless of the bypass
  if (i_rd_addr[p_idx] == 'h0) begin
    o_rd_data[p_idx] = 'h0;
  end
end

これで F1 バイパスの結果が何であれ、preg 0 の読み出しは常に 0 になる。 write side の guard と併せて、preg 0 の semantic が全パスで一貫する。

教訓

このバグから抽出できる原則は 3 つ。

(1) 特殊値の semantic は、その値が通り得る全パスで一貫させる。 PRF は「storage への write / read」と「同サイクル F1 bypass」の 2 経路がある。 x0 の semantic を守るなら、write path (guard) と read path (force-zero) の両方で守らなくてはならない。 片方だけでは時間的に不連続な穴ができる。

(2) Guard を write side に置いても、read が生の入力を見ていれば意味がない。 write の guard は「storage が汚染されないこと」しか保証しない。 同サイクル bypass の入力信号を read が直接見ているなら、read 側でも同じ semantic を貫く必要がある。 "防御は最深部で" というよく言われる原則の一例。

(3) 発想の穴はテストを書く行為が炙り出す。 このバグは実装した本人が「次サイクル read すれば 0 が返る」で満足していた瞬間に埋め込まれた。 TB でシナリオを列挙する過程で「同サイクルは?」という素朴な問いが自然に出て、そこで初めて穴が可視化された。 TDD 的な運用の恩恵。

参照モデル駆動の TB

上の教訓を踏まえ、TB は golden reference model と DUT を並行実行して差分検出する、定番構造で組んだ。 3 段構え:

  1. DUT ─ 検証対象の PRF
  2. Reference model ─ DUT と同じ仕様を独立に SV で書き下したもの
  3. Comparator ─ 各サイクル、DUT と reference の出力を比較

これで DUT の RTL バグは、reference と挙動が食い違った時点で自動検出できる。 逆に、reference model 側にも同じバグを埋め込んでしまうと検出できないので、reference は「仕様に対して素朴に書く」ことが肝要。

Reference model のコード

DUT の PRF と同じ semantic を SV で 1:1 で書く:

logic [XLEN_W-1: 0] r_ref_prf [XPR_PRF_SIZE];

// Read semantics: baseline read + F1 bypass across all write ports,
// then arch x0 force-zero.
function automatic logic [XLEN_W-1: 0] ref_read(
  input logic [XPR_PRF_W-1: 0] addr
);
  ref_read = r_ref_prf[addr];
  for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
    if (i_wr_en[w] && (i_wr_addr[w] == addr)) begin
      ref_read = i_wr_data[w];
    end
  end
  if (addr == '0) ref_read = '0;
endfunction

// Write semantics: x0 sink drops preg 0 writes silently.
always_ff @(posedge i_clk, negedge i_rst_n) begin
  if (!i_rst_n) begin
    for (int i = 0; i < XPR_PRF_SIZE; i++) r_ref_prf[i] <= '0;
  end else begin
    for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
      if (i_wr_en[w] && (i_wr_addr[w] != '0)) begin
        r_ref_prf[i_wr_addr[w]] <= i_wr_data[w];
      end
    end
  end
end

DUT が読んでいる同じ入力 (i_wr_en, i_wr_addr, i_wr_data) を参照して、ref_read(addr) が「その addr を今読んだら何が返るべきか」を計算する。

シナリオ 9 個

DUT がどれくらいのケースをカバーしているかを明示的に列挙するため、シナリオ 9 個を task 単位で書いた。

# シナリオ 何を確認するか
1 reset_read Reset 直後、全 read port が 0 を返す
2 seq_wr_rd preg 1..47 に write して次サイクル read で戻る
3 bypass_same_port 同 port pair での F1 forward
4 bypass_cross_port 別 port 経由の F1 forward
5 parallel_read 4 read port が独立に正しい値
6 parallel_write 3 write port が同時 write
7 x0_write_sink preg 0 write が drop される
8 x0_bypass_leak ★ 上の x0 バグの regression 検出
9 random_stress 10K サイクル、ref_read と毎サイクル比較

★ #8 が上のバグを捕捉するテスト。 このシナリオを書くために「同サイクル wr_addr=0 と rd_addr=0」の問いを立てたのが、バグ発見のきっかけだった。

ランダム stress

9 のランダムテストがコスパ良く覆域を広げる。

各サイクルで write port と read port をランダム駆動し、DUT の read output を reference の ref_read() と比較する:

for (int c = 0; c < n_cycles; c++) begin
  @(negedge i_clk);
  // Random write drives
  for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
    i_wr_en  [w] = $urandom_range(0, 1);
    i_wr_addr[w] = $urandom_range(0, XPR_PRF_SIZE - 1);
    i_wr_data[w] = {$urandom(), $urandom()};
  end
  // Suppress non-zero write-port collisions (would trip DUT assertion)
  ... (省略)
  // Random read drives
  for (int r = 0; r < PRF_READ_PORTS; r++) begin
    i_rd_addr[r] = $urandom_range(0, XPR_PRF_SIZE - 1);
  end
  #1;
  // Compare
  for (int r = 0; r < PRF_READ_PORTS; r++) begin
    check($sformatf("random cyc=%0d port=%0d", c, r),
          o_rd_data[r], ref_read(i_rd_addr[r]));
  end
  @(posedge i_clk);
end

10K サイクル × 4 read port で 40,000 チェックをランダムに走らせる。 これに加えて #1-#8 の 62 チェック、合わせて 40,062 チェック。

「write port 間で同じ非零 preg を叩かないよう suppress する」処理を入れているのは、DUT に組んだ SVA assertion (同じ preg への同時 write 禁止) を意図的に踏まないため。 リネームは通常 unique preg を割り当てる invariant を持つので、その invariant を入力ベクタでも守るという趣旨。

実行結果

Verilator の --timing フラグで実行:

$ verilator --binary --timing --top-module tb_scariv_prf \
      -Wno-MULTITOP -Wno-WIDTHTRUNC -Wno-WIDTHEXPAND -Wno-TIMESCALEMOD \
      -f core/filelist_w2r32.f \
      core/rtl/backend/tb_scariv_prf.sv -o tb_scariv_prf
$ ./obj_dir/tb_scariv_prf
=== tb_scariv_prf : 40062 tests, 0 fail ===
PASS
- Verilator: $finish at 101us; walltime 0.020 s

40,062 チェックが wall time 20 ms で完了。 シミュレーション時間 101 us に対して wall 20 ms なので、リアルタイム比 5×。 Verilator の速度は圧倒的で、ランダムテストを気軽に増やせる。

Verilator の細かい罠

TB を Verilator で走らせるとき詰まった点を 3 つメモしておく。

  • // verilator xxx パターンは magic comment 扱い。TB のヘッダに verilator ... でコマンド例を書くとディレクティブと誤認される。$ verilator ... のように prefix を付けると回避できる。
  • #delay@(posedge) を使うなら --timing フラグ必須。ないと NEEDTIMINGOPT error になる。
  • TB は int 型と narrow bit vector を混ぜて書くのが自然なので、WIDTHTRUNC / WIDTHEXPAND / TIMESCALEMOD warning を suppress する運用が実務的。TB run script に -Wno-WIDTHTRUNC -Wno-WIDTHEXPAND -Wno-TIMESCALEMOD を予め入れておく。

以上で最初のモジュール PRF を「動作した状態まで持ち上げる」流れが完了。 次からは RAT (Register Alias Table) と Free list ─ PRF が保持する物理レジスタ番号をリネーム段が実際にどう割り当てるか ─ の実装に入る。

RISC-V OoO を実装する (1. マルチコンフィグ基盤と PRF 実装)

RVA23 準拠の 6-way out-of-order RISC-V CPU の実装を始めた。 性能目標は XiangShan Kunminghu クラスの 15 SPECint2006/GHz。 設計文書は別にまとめてあるので、こちらのシリーズでは実装フェーズで直面した具体的な設計判断とその根拠を書いていく。

段階拡張という前提

最終形態は 6-way OoO、L1/L2/L3 cache、MMU、TAGE-SC-L、Matrix Scheduler、Vector、Hypervisor、multicore と盛り沢山だが、これを最初から全部作るのは現実的でない。 方針は「小さい OoO を先に組み、機能・幅を段階的に拡張」する:

  • ステージ 1: 2-way OoO / bare-metal RV64I / ROB 32 / cache なし
  • ステージ 2: +L1 cache +MMU +M/A 拡張、OpenSBI が起動する
  • ステージ 3: 6-way に広げ、TAGE 予測と F/D 拡張を入れ、Linux が起動する
  • 以降: RVV → Multicore → Kunminghu 級最終仕様

各ステージ終端で「動く CPU がある」状態を保つ。 今回はステージ 1 の最初の 2 モジュール、「マルチコンフィグ基盤」と「物理レジスタファイル (PRF)」を扱う。

なぜマルチコンフィグが要るのか

段階拡張ゆえ、同じ RTL ツリーの中に複数の構成が並存する状態になる。 現在開発中の最小構成と、最終目標の 6-way 構成を、両方 lint clean に保っておきたい。 理由は 3 つ。

  1. 最小構成で開発中も「最終構成でも構文が通ること」を継続確認できる
  2. 最終形完成後の回帰試験で、小構成でも同じ RTL が走ることを保証したい
  3. 「構成に依存しない書き方」を書き手に強制する仕組みになる

命名: canonical + alias

Config は 6 つ想定し、canonical name と alias の 2 段構えで管理する。

ファイル名 Alias 内容
scariv_conf_w2r32_pkg.sv bare 2-way, ROB 32, bare-metal
scariv_conf_w2r64_pkg.sv boot +cache +MMU (OpenSBI 起動)
scariv_conf_w6r128_pkg.sv linux 6-way +TAGE +F/D (Linux boot)
scariv_conf_w6r192_pkg.sv vec +RVV 1.0
scariv_conf_w6r192_smp_pkg.sv smp +Multicore +AIA
scariv_conf_w6r256_pkg.sv full Kunminghu 級 (最終目標)

w<幅>r<ROB entries> の canonical で、幅と in-flight 深さが名前だけで判別できる。 alias は会話や doc 上での短縮呼び出し用。

パッケージ名は 1 本のまま

下流モジュールから見える package 名は全 config で共通の scariv_conf_pkg に統一する。 つまり上の 6 つのファイルは中身ではどれも同じ package scariv_conf_pkg; を宣言する。 モジュール側は常に

import scariv_conf_pkg::*;

とだけ書き、どの config が build に入っているかを一切知らない。 filelist で 1 つを選ぶ:

core/filelist_w2r32.f    → scariv_conf_w2r32_pkg.sv を include
core/filelist_w6r256.f   → scariv_conf_w6r256_pkg.sv を include
core/filelist.f          → いずれかへの symbolic link (現在 w2r32)

デフォルトは verilator -f core/filelist.f、意識的に別 config を選ぶ場合は -f core/filelist_w6r256.f と指定する。 この仕組みで、モジュール本体は複数構成に対して自然に対応できる。

最初のモジュール: 物理レジスタファイル (PRF)

基盤ができたので最初のモジュール、物理レジスタファイル (PRF) を実装する。 OoO では in-flight 命令が多数同時に存在するため、レジスタリネームにより物理レジスタを割り当てる。 アーキ x0..x31 の 32 本ではなく、物理的には数十本 (ステージ 1 では 48 本)、最終形では 224 本を持つ。 PRF はその物理レジスタを実際に保持するストレージで、実行段で operand を読み、writeback で結果を書き戻す先。

ポート数は config から自動で決める

ステージ 1 は 2-way 発行なので、operand は 1 サイクルに 2 命令 × 2 operand で 4 read port 必要。 Write port は producer unit ごとに 1 本ずつ持たせて、ALU + BRU + LD で 3 write port (store は PRF に書かない)。

M6 では 6-way になり ALU も増えるので、ポート数は 12R/8W に膨らむ。 config を切り替えるたびにモジュールを書き直すのは避けたい。 共通 pkg 側で derived localparam として書いた:

// scariv_pkg.sv
localparam int PRF_READ_PORTS  = 2 * DISP_SIZE;
localparam int PRF_WRITE_PORTS = ALU_INST_NUM + BRU_INST_NUM + LD_INST_NUM;
Config DISP_SIZE ALU+BRU+LD Read Write
bare (ステージ 1) 2 1+1+1 4 3
full (最終) 6 4+2+2 12 8

モジュール側は PRF_READ_PORTS/PRF_WRITE_PORTS を参照して port を宣言する:

input  logic [XPR_PRF_W-1: 0] i_rd_addr [PRF_READ_PORTS],
output logic [XLEN_W-1: 0]    o_rd_data [PRF_READ_PORTS],
input  logic                  i_wr_en   [PRF_WRITE_PORTS],
...

これで config を切り替えるだけで PRF のポート数も自動追従する。

F1 バイパスと F2 バイパス

OoO パイプラインには通常 2 種のフォワーディングが同居する。 混同されがちなので最初に整理しておく。

種類 発生シチュエーション 実装場所
F1 命令 X が WB 段、命令 Y が同サイクル RRD 段、同 preg PRF 内部
F2 命令 X の EX 段結果を、命令 Y が 0-cycle 遅れで受け取る 外部 bypass 網 (RRD/EX 段)

F1 は producer が既に PRF の write port を叩いている状態なので、PRF read port の中で wr_en & (wr_addr == rd_addr) を検出して mux するのが最短。 BOOM も XiangShan もこの方式。 F2 は複数 EX unit の出力を集約する必要があり、PRF は関与せず外部モジュール (RRD 段の bypass 網) で扱う。

PRF は F1 だけ担当し、F2 は後段モジュールで対応する。

PRF 本体の実装

FF ベースで素朴に書ける (48 × 64 = 3072 FF)。

logic [XLEN_W-1: 0] r_prf [XPR_PRF_SIZE];

// Read side (combinational, with F1 bypass)
generate for (genvar p_idx = 0; p_idx < PRF_READ_PORTS; p_idx++) begin
  always_comb begin
    o_rd_data[p_idx] = r_prf[i_rd_addr[p_idx]];
    for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
      if (i_wr_en[w] & (i_wr_addr[w] == i_rd_addr[p_idx])) begin
        o_rd_data[p_idx] = i_wr_data[w];    // F1 bypass
      end
    end
  end
end endgenerate

// Write side (synchronous)
always_ff @(posedge i_clk, negedge i_rst_n) begin
  if (!i_rst_n) begin
    for (int i = 0; i < XPR_PRF_SIZE; i++) r_prf[i] <= 'h0;
  end else begin
    for (int w = 0; w < PRF_WRITE_PORTS; w++) begin
      if (i_wr_en[w]) r_prf[i_wr_addr[w]] <= i_wr_data[w];
    end
  end
end

x0 の扱い方針

RISC-V の x0 (ゼロレジスタ) は「読むと 0」「書いても捨てられる」という特殊レジスタ。 問題は、ISA としては addi x0, x10, 1 は valid ということ。 命令自体は実行され、ただ結果が捨てられる。

実装として 2 派ある。

  • リネーム段で suppress する: rd = x0 の命令は wr_en = 0 に潰す。物理レジスタも割り当てない。
  • 物理レジスタ 0 を "sink" として使う: リネームは x0 write も普通に扱い、wr_en=1 wr_addr=0 を PRF に流す。PRF 側で preg[0] は不変にしておく。arch x0 → preg 0 固定 mapping なので、consumer は常に 0 を読む。

SCARIV は後者を採った。 リネーム段の特殊 case が減り、拡張しやすいため。 上の PRF 実装では、write side に guard を足せばいい:

if (i_wr_en[w] && (i_wr_addr[w] != 'h0)) begin   // ← preg 0 sink
  r_prf[i_wr_addr[w]] <= i_wr_data[w];
end

これで preg 0 は永遠に 0 のまま。 ... と思っていたら、これが F1 バイパスと組み合わさると隙間ができた。 次回はそのバグ story と、参照モデル TB で捕捉した経緯を書く。

RISC-V Server SoC Specificationを読んでみる (3. IOMMUについて)

RISC-V Server SoC Specification の IOMMU 要件を読んでいく。

Server 向け SoC では、IOMMU は単に「PCIe Device の DMA Address を変換する MMU」ではない。 DMA Protection、Virtual Machine への Device Direct Assignment、Shared Virtual Addressing、MSI Virtualization、ATS などを成立させるための中心的なコンポーネントとして位置付けられている。

そのため Server SoC Specification の IOMMU 節にはかなり多くの要件が並んでいる。

今回は個々の Rule を追いながら、「最終的に SoC 側には何が求められているのか」という視点で整理してみる。

1. まず、DMA を IOMMU の管理下に置く

最初に重要なのが IOM_010 と IOM_020 である。

IOM_010 では、

  • SoC 内のすべての IOMMU は RISC-V IOMMU Specification をサポートしなければならない(MUST)。

とされている。

さらに IOM_020 では、Application Processor hart 上の Software から Access 可能な、

  • すべての DMA-capable Peripheral
  • RCiEP
  • Non-PCIe Device
  • すべての PCIe Root Port

を IOMMU の管理下に置かなければならない。

ここでいう「IOMMU の管理下に置く」という表現には少し注意が必要である。

これは、「Device の DMA Data が必ず IOMMU Block の内部を物理的に通過しなければならない」という意味ではない。

重要なのは、Device が発行する DMA Request に対して、IOMMU による Address Translation / Protection が適用されることである。

概念的には以下のようになる。

PCIe Endpoint
      |
      | DMA Request
      v
PCIe Root Port
      |
      v
   I/O Bridge
      |
      +----------------------+
      |                      |
      |  device_id + address |
      +--------------------> IOMMU
      |                      |
      |   translated address |
      |      or fault        |
      <----------------------+
      |
      v
   Memory

つまり、SoC の I/O Path は、「Device -> IOMMU -> Memory」という単純な直列構成である必要はない。

例えば I/O Bridge が IOMMU に Address Translation を問い合わせ、その結果を使って Memory Transaction を発行する構成でもよい。

重要なのは、

Device DMA
    |
    v
IOMMU Translation / Protection
    |
    v
Physical Memory

という論理的な関係が成立していることである。

SoC に何が求められるか

つまり IOM_020 を SoC 設計の要件として読み替えると、「OS/Hypervisor から利用可能な DMA Master が、IOMMU を迂回して任意の System Memory に Access できる経路を作ってはいけない」ということになる。

例えば以下のような接続は問題になる。

DMA Device | +-------------------------> DRAM | +----> IOMMU

上側の Path を使えば Device が IOMMU Protection を回避できてしまう。

Server SoC としては、Software が Device ごとの DMA 到達範囲を制御できるようにする必要がある。

2. すべての SoC Master が対象ではない

ただし、IOM_020 は SoC 内のすべての Memory Master を IOMMU 配下に置けと言っているわけではない。

例えば以下は例外として挙げられている。

  • APLIC などの Interrupt Controller
  • IOMMU 自身
  • Debug Module の System Bus Access Block
  • Root of Trust Controller
  • Power Management Controller
  • その他 SoC Management Controller が専用 Resource へ Access する場合

つまり IOMMU は、主に Application Processor Software が利用する I/O Device の DMA を隔離するための仕組みとして要求されている。

SoC 内部の Trusted Management Engine まで必ず IOMMU 配下に置く、という規定ではない。

3. 「PCIe Root Port を管理する IOMMU」とは何か

IOMMU 節では、「PCIe Root Port を管理する IOMMU」という表現が何度も登場する。これは最初かなり分かりにくい。 IOMMU が Root Port の Register Configuration を管理するという意味ではない。

ここでいう「管理する」とは、「その Root Port 配下から SoC に入ってくる DMA Request の Address Translation / Protection を、その IOMMU が担当する」という意味である。

例えば、

                PCIe Root Port
                      |
                  PCIe Switch
                   /       \
                 GPU       NIC

という PCIe Hierarchy があったとする。

GPU が DMA Request を発行すると、その PCIe Transaction には Requester ID(RID)が含まれている。通常の RID は、

Bus Number       8 bit
Device Number    5 bit
Function Number  3 bit
----------------------
                16 bit

である。SoC の Host Bridge は、この RID を IOMMU に device_id として渡す。

PCIe Request
      |
      | RID = 03:00.0
      v
Host Bridge
      |
      | device_id = RID
      v
    IOMMU
      |
      v
Device Context
      |
      v
Page Table

この IOMMU が、「PCIe Root Port を管理する IOMMU」ということになる。

4. なぜ PCIe 用 IOMMU は 16-bit Device ID が必要なのか

IOM_030 では、「PCIe Root Port を管理する IOMMU は、少なくとも 16-bit 幅の Device ID をサポートしなければならない(MUST)。」とされている。 これは先ほどの PCIe RID を考えれば分かりやすい。PCIe RID = 16 bit なので、「device_id[15:0] = PCIe RID」として、そのまま IOMMU の Device Context 選択に使用できなければならない。

一方、PCIe Root Port を管理しない IOMMU では事情が異なる。

例えば、

DMA Engine ----+
               |
Accelerator ---+---- IOMMU
               |
Crypto Engine -+

のように SoC Internal Device だけを管理する IOMMU を考える。これらは PCIe RID を持たない。SoC 独自に、

DMA Engine   -> device_id = 0
Accelerator  -> device_id = 1
Crypto       -> device_id = 2

などと割り当てればよい。

そのため IOM_040 は、PCIe Root Port を管理しない IOMMU について、「管理対象 Device のすべての Requester ID を表現できる幅を持つこと」だけを要求している。

つまり SoC として重要なのは、「すべての DMA Request を、一意な device_id とともに IOMMU へ渡せること」である。

5. 複数 PCIe Segment を1つの IOMMU で管理する

さらに複雑になるのが IOM_270 である。

  • PCIe RID は、一つの PCIe Hierarchy 内では一意である。

しかし別の Hierarchy では同じ RID が存在できる。例えば、

PCIe Segment 0
    03:00.0

PCIe Segment 1
    03:00.0

では両方とも RID が同じになる。

もし一つの IOMMU が両方の Root Port を管理すると、device_id = RID だけでは Device を区別できない。

そこで Server SoC では、

device_id[23:16] = Segment Number
device_id[15:0]  = PCIe RID

という 24-bit Device ID を使用する。

したがって SoC Architecture を考えると、

Segment 0 / RP0 ----+
                    |
Segment 1 / RP1 ----+---- IOMMU
                    |
Segment 2 / RP2 ----+

という構成を採用するなら、その IOMMU は 24-bit Device ID を扱える必要がある。逆に、

RP0 ---- IOMMU0
RP1 ---- IOMMU1

のような構成で、それぞれ別 Hierarchy しか扱わないのであれば、16-bit RID だけで Device を識別できる。 つまり IOMMU の個数や配置は仕様で固定されていない。SoC Designer が自由に決められるが、「どの構成を選んでも Device を一意に識別できること」が要求される。

6. CPU MMU と同じ Virtual Memory Mode を扱う

IOM_050 も重要である。「IOMMU は、Application Processor hart が実装する Page-based Virtual Memory System Mode と Extension をサポートしなければならない。」

例えば hart 側が、Sv39、Sv48、をサポートするのであれば、IOMMU 側もそれらを扱える必要がある。

これは特に Shared Virtual Addressing を考えると重要になる。例えば CPU と Device が同じ Process Virtual Address を使う場合、

CPU
 |
 | VA
 v
CPU MMU
 |
 +-------> Page Table
 |
 v
Memory
Device
 |
 | VA
 v
IOMMU
 |
 +-------> Page Table
 |
 v
Memory

という構成になる。

CPU と IOMMU で Virtual Memory Architecture が大きく異なると、同じ Page Table や同じ Address Space を扱いにくい。

Server SoC ではこの不整合を避けるため、Application Processor hart が持つ Virtual Memory Capability に IOMMU を合わせることを要求している。

7. PASID: Device の中の Process を識別する

通常の device_id は、どの Device から来た Request かを識別する。

しかし GPU や Accelerator を複数 Process が同時に使用する場合、それだけでは足りない。例えば、

GPU
 |
 +-- Process A
 |
 +-- Process B
 |
 +-- Process C

という状態を考える。Process A と Process B は当然異なる Virtual Address Space を持つ。 そこで PCIe では PASID(Process Address Space ID)を使用する。RISC-V IOMMU では概念的に、device_id + process_id によって Translation Context を選択できる。

GPU
 |
 | device_id = GPU
 | process_id = PASID 42
 v
IOMMU
 |
 v
Process 42 の Address Translation

Server SoC では、PASID Capability を持つ IOMMU は 20-bit PASID を扱えることを要求している。

また IOM_310 では Host Bridge が PCIe PASID TLP Prefix から、

  • 20-bit PASID
  • PASID Validity
  • Execute Requested
  • Privilege Mode Requested

を IOMMU へ渡すことを要求している。

つまり SoC Interconnect / Host Bridge は、単に Address と RID だけ渡せばよいわけではない。

  • address
  • device_id
  • process_id
  • execute
  • privilege

など、IOMMU が Protection Decision を行うために必要な Metadata を正しく伝搬する必要がある。

自作SIMT GPGPUを作る (9) 連続アクセスを1回にまとめる、メモリコアレッシング

前回、1本のメモリを複数コアで共有すると、アクセスが直列化してメモリが律速になるのを見た。 今回はその律速に、コア側から答える。 複数レーンが連続したアドレスを触るとき、それを1回のメモリトランザクションにまとめる。 これをメモリコアレッシングと呼ぶ。

S_LSUはなぜ8サイクルかかるか

これまでのロードストアは、S_LSU でレーンを1本ずつ順に処理していた。 lane 0 のアドレスを出して1ワード、lane 1 を出して1ワード、と NLANE 回くり返す。 だからロード1命令で NLANE サイクル、ここでは8サイクルを占有する。

この8サイクルは、マルチwarpでも隠せなかった。 隠せるのは「待っている時間」であって、「ポートを使っている時間」ではないからである。 占有そのものを減らすには、アクセスの回数を減らすしかない。

連続アドレスなら、1回で足りる

vecadd を思い出す。 warp 内の各レーンは A[i0 + l] を読む。 lane 0 が A[i0]、lane 1 が A[i0+1]、と隣り合っている。 アドレスにすれば、ちょうど4バイトずつ連続している。

連続しているなら、8回に分ける必要はない。 先頭から NLANE ワードをまとめて1回で読めばよい。 これがコアレッシングの発想である。

ただし前提が1つある。 まとめても、1ワードずつしか運べないメモリのままでは速くならない。 NLANE ワードを1サイクルで運べる幅を、メモリ側に持たせる必要がある。 今回の主眼は、この「判定」と「幅の広い経路」の2つになる。

全レーンのアドレスと、連続性の判定

まず、全レーンの実効アドレスを同時に組合せで出す。 これまでは処理中の1レーン分しか計算していなかったので、全レーンへ広げる。

logic [31:0] w_lane_addr [NLANE];
generate for (genvar l = 0; l < NLANE; l++)
    assign w_lane_addr[l] = w_rv1[l] + (w_is_store ? w_imm_s : w_imm_i);
endgenerate

次に、これらが連続かを判定する。 全レーンが active で、隣同士がちょうど4バイト差なら coalesced とみなす。

logic w_coalesced;
always_comb begin
    w_coalesced = &w_active;                      // 全レーン active(発散時はフォールバック)
    for (int l = 1; l < NLANE; l++)
        if (w_lane_addr[l] != w_lane_addr[0] + 32'(4*l))
            w_coalesced = 1'b0;
end

&w_active を条件に入れているのが要点である。 分岐で一部のレーンしか生きていないときは coalesced にしない。 そういうアクセスは連続とは限らないので、素直に従来のやり方へ戻す。

S_LSUを2つの経路に分ける

判定ができたら、S_LSU を2経路にする。 coalesced なら、NLANE ワードを1サイクルで全レーンに取り込み、すぐ S_WB へ進む。

S_LSU: if (i_mem_gnt) begin
    if (w_coalesced) begin
        if (w_is_load)
            for (int l = 0; l < NLANE; l++)
                r_ld_data[r_warp_iter][l] <= i_mem_line_rdata[l];   // 8レーンを一括
        r_state[r_warp_iter] <= S_WB;                              // 8サイクル→1サイクル
    end else begin
        // 従来の per-lane ループ(フォールバック)
        if (w_is_load && w_active[r_lane_iter[r_warp_iter]])
            r_ld_data[r_warp_iter][r_lane_iter[r_warp_iter]] <= i_mem_rdata;
        if (r_lane_iter[r_warp_iter] == (NLANE-1)) r_state[r_warp_iter] <= S_WB;
        else r_lane_iter[r_warp_iter] <= r_lane_iter[r_warp_iter] + 1'b1;
    end
end

S_WB から先は触っていない。 coalesced は「r_ld_data を一括で埋める」だけなので、その後の書き戻しは共通のまま流用できる。

幅の広いデータ経路

メモリを触る要求そのものは、前回の o_mem_reqi_mem_gnt をそのまま使う。 coalesced も非coalesced も、S_LSU であることに変わりはないからである。 足すのはデータ経路だけで、3本で済む。

output logic                    o_mem_line,        // このサイクルは coalesced ライン転送
output logic [NLANE-1:0][31:0]  o_mem_line_wdata,  // ストアする NLANE ワード
input  logic [NLANE-1:0][31:0]  i_mem_line_rdata,  // ロードした NLANE ワード

o_mem_addr はライン時、先頭レーンのアドレスに使う。 o_mem_line が立ったサイクルは、メモリが先頭から NLANE 連続ワードを一括で読み書きする。 連続の判定より w_lane_addr[l] = w_lane_addr[0] + 4l なので、i_mem_line_rdata[l] はちょうどレーン l の欲しいワードになる。

前回のアービタとマルチコアにも、このライン信号を通した。 アービタは勝ったコアのラインをメモリへ流し、読みラインは全コアへ直結する。 これで、共有ポートのマルチコアでもコアレッシングが効く。

実行結果: 占有が8分の1になる

単コアの vecadd で、コアレッシングの有無を比べた。 サイクルに加えて、メモリトランザクション数も数えた。

サイクル メモリtxn txnの内訳
コアレッシングなし 220 148 フェッチ52 + データ96
コアレッシングあり 136 64 フェッチ52 + データ12

データのトランザクションが96から12へ、8分の1に落ちた。 メモリ命令は1 warp あたり3つ(ロード2、ストア1)で、それぞれ8レーン分だったのが1ラインになったからである。 サイクルは84減った。 これは「3命令 × (8-1)サイクル × 4 warp」にちょうど一致する。 S_LSU の直列がまるごと畳まれた分である。

分岐するカーネル(if-elseと可変ループ)も試したが、どちらも正しく通った。 発散している間はフォールバックの per-lane ループが動き、再収束したストアだけがコアレッシングする。 連続なら1回、そうでなければ従来どおり、と使い分けられている。

マルチコアの律速に効く

前回のマルチコア(共有1ポート、4コア)でも比べた。

共有1ポート, NCORE=4 サイクル
コアレッシングなし(前回) 647
コアレッシングあり(今回) 311

647から311へ、2倍以上速くなった。 各ロードストアが8トランザクションから1トランザクションに減ったので、共有ポートの奪い合いが大きく緩んだ。 前回「メモリが律速」と示したその律速に、コアレッシングがそのまま効いている。

演算器を並べるだけでは、データを運ぶ道の幅で頭打ちになる。 その道を賢く使う。 連続アクセスを束ねて、無駄な往復を減らす。 GPUがメモリアクセスの合体に力を入れるのは、この効きの大きさゆえである。

自作SIMT GPGPUを作る (8) メモリを1本に共有して調停する

前回はコアを並べてマルチコアにした。 ただしメモリは、コアごとに独立したポートにして調停を避けた。 理由は、コアに「今は待て」と言われて止まる仕組みが無かったからである。 今回はそこへ踏み込む。 1本のメモリを複数コアで共有し、アービタで順番を決める。 そして「メモリが律速になる」を、サイクル数で見る。

なぜコアを止める必要があるか

これまでのコアのメモリインタフェースは、アドレスを出せばその場でデータが返る前提だった。 「今は待て」を受け取る線が無い。 独立ポートなら誰も待たないので、これで足りていた。

共有にすると前提が崩れる。 同じサイクルに複数コアが、1本のメモリを欲しがる。 通せるのは1つだけで、負けたコアは止まらなければならない。 だからまず、コアに待ちのハンドシェイクを足す。

コアに要求と許可を足す

足す線は2本である。 o_mem_req は「このサイクル、ポートが欲しい」、i_mem_gnt は「このサイクル、許可された」を表す。

メモリを触る状態は2つだけである。 命令フェッチの S_FETCH と、ロードストアの S_LSU である。 この2状態のときだけ要求を立てる。

assign o_mem_req = w_ready[r_warp_iter] &
                   (r_state[r_warp_iter] inside {S_FETCH, S_LSU});

そして、この2状態の前進を i_mem_gnt でゲートする。 許可されなければ、その状態のまま足踏みする。

S_FETCH: if (i_mem_gnt) begin
    r_insn[r_warp_iter] <= i_mem_rdata;   // 許可されたときだけ取り込む
    r_state[r_warp_iter] <= S_EXEC;
end

要求する状態とゲートする状態は、きっちり一対一にするのが要点である。 S_FETCHS_LSU だけが要求し、その2つだけをゲートする。 メモリに触らない S_EXECS_WB はゲートしない。 ここを取り違えると事故になる。 要求していない状態をゲートすると、その状態は永遠に許可されずデッドロックする。 逆に要求している状態を素通りさせると、負けても止まらず競合が効かない。

面白いのは、待たされたコアが「バブル」になるだけで、状態が失われないことである。 状態はwarpごとに持っている(前回までのマルチwarp)ので、別のwarpへ回しても、戻れば続きから再開する。 レイテンシ隠蔽のために作った器が、そのまま競合待ちの受け皿になる。

アービタ

誰の要求を通すかを決めるのがアービタである。 方式はラウンドロビンにした。 優先ポインタ r_rr を持ち、そこから順に見て、最初に要求しているコアを勝者にする。

for (int i = 0; i < NCORE; i++) begin
    int idx = (r_rr + i) % NCORE;
    if (!w_found && i_req[idx]) begin
        w_winner_idx = idx;
        w_found      = 1'b1;
    end
end

勝者が決まったら、その書き込み経路だけを1本のメモリへ流し、許可を返す。

assign o_mem_addr  = i_addr [w_winner_idx];
assign o_mem_wdata = i_wdata[w_winner_idx];
assign o_mem_we    = w_found & i_we[w_winner_idx];
assign o_gnt[c]    = w_found & (w_winner_idx == c);

肝は、次サイクルの優先ポインタを「勝者の1つ隣」にすることである。

r_rr <= w_found ? (w_winner_idx + 1) % NCORE : r_rr;

これで、今通ったコアは次のサイクルで最下位に落ちる。 ずっと要求し続けるコアが2つあっても、交互に通る。 もしポインタを勝者そのものに置くと、同じコアが毎回勝ち、隣は永遠に飢える。 「勝者を次は最下位へ」が、公平の一行である。

実際、最初にここを取り違えて、勝者と次ポインタを1つの信号で兼ねてしまった。 勝者に winner+1 を使うと、許可とデータが「隣のコア」へ出る。 次ポインタに winner を使うと、飢餓が出る。 勝者は下流ぜんぶ(許可、アドレス、書き込み)、winner+1 は次ポインタの1箇所だけ、と役割を分けて直した。

読みデータは配って、勝者だけが受け取る

書き込みは勝者1つに絞るが、読みはどうか。 メモリの読み出しには副作用が無い。 だから読みデータは、全コアへ配ってしまってよい。 取り込むのは、許可を得たコアだけである。 そのコアは S_FETCHS_LSU を許可で通しているので、その瞬間だけ読みデータをラッチする。 許可されなかったコアは、同じ読みデータが見えていても取り込まない。

結果として、アービタは読みデータを扱わない。 トップで、唯一のメモリの読み出しを全コアへ直結するだけでよい。 アービタの仕事は「要求から勝者を選び、書き込みを1本へ絞る」に収まる。

メモリを1本にまとめる

トップの gpu_multicore で、コアごとに出していたメモリポートを1本に畳む。 各コアの要求と書き込み信号を束ねてアービタへ渡し、アービタの出力を唯一のメモリポートへ出す。 テストベンチのメモリモデルも、コア数ぶんあった読み書きを1本にする。

assign w_mem_rdata = mem[w_mem_addr[15:2]];               // 1本の組合せ読み
always_ff @(posedge w_clk)
    if (w_mem_we) mem[w_mem_addr[15:2]] <= w_mem_wdata;   // 1本の同期書き

これで、4コアが1本のメモリを取り合う構成になった。

競合だけを見るためにMEMLATを0にする

マルチwarpの回で、メモリレイテンシを模す偽の待ち MEMLAT を入れていた。 今回はこれを0にする。 見たいものを1つに絞るためである。

レイテンシの隠蔽は、すでにマルチwarpで見た。 今回見たいのは、1本のメモリを奪い合う競合だけである。 MEMLAT を残すと、隠蔽と競合が混ざって、どちらが効いているか読めない。 0にすれば、独立ポートとの差は、まるごと競合のコストになる。

実行結果: メモリが律速になる

ベクトル加算を、コア数を振って流した。 独立ポート(前回)と、共有1ポート(今回)を並べる。

NCORE 要素数 独立ポート 共有1ポート 倍率
1 32 244 244 1.00
2 64 244 330 1.35
4 128 244 647 2.65
8 256 244 1286 5.27

独立ポートは、コアを増やしても244で動かない。 各コアが自分のメモリを持ち、完全に並列に走るからである。 要素数はコア数ぶん増えるのに、かかる時間は変わらない。 これがスループットの理想である。

共有1ポートは、コアを増やすほど伸びる。 1本のメモリしかないので、フェッチもロードストアも順番待ちになる。 NCORE=8では5.27倍、ほぼコア数に比例している。 計算はコアをまたいで重なるが、メモリアクセスは1本に直列化するからである。 律速がメモリへ移り、コアをいくら並べても速くならない姿が、そのままサイクル数に出た。

ここで見えるのは、並列計算の一般則である。 演算器をいくら増やしても、データを運ぶ道が1本なら、その道の幅で頭打ちになる。 GPUが広いメモリ帯域とキャッシュに力を注ぐのは、この頭打ちを押し上げるためである。

ニュース記事: アリババのRISC-Vチップ「XuanTie C950」が、Qwen-3.8 27Bモデルをネイティブで実行可能となる

ソース: wccftech.com

アリババは、NVIDIAの定評ある垂直統合戦略を模倣しているようだ。同社は、32GBのVRAMのみで動作可能な高性能AIモデル「Qwen-3.8 27B」に対し、自社開発のRISC-Vチップ「XuanTie C950」での「発売初日から」のサポートを実現した。

アリババは現在、自社開発のチップ上で「Qwen」シリーズのAIモデルを実行できるようになり、世界で最も競争の激しいAI市場の一つにおいて、自社に強固な競争優位性を築き上げた。 A block diagram of the 'C950 Core #0 RVA23 Profile' shows components including RISC-V Debug/Nexus Trace, AIA, Vector, FPU, I-Cache, D-Cache, MMU, PMP, SDAP, L3 Cache, SCU, and BUS I/F.

アリババは2026年3月、「XuanTie C950」を発表し、このチップをエッジAI向けのRISC-Vベースの製品として売り出した。一般的なASICとは異なり、XuanTie C950はAIワークロードの処理にGPUに依存していない。その代わりに、このチップは基本的にサーバーグレードの64ビットRISC-Vプロセッサであり、単一のシリコンチップ上に64個の演算コアを搭載し、クロック周波数は最大3.20GHzまで拡張可能で、複数のクラスター(1クラスターあたり8コア)が高速AMBA CHIファブリックを用いてネイティブに相互接続されています。さらに、負荷の高いAIワークロードに対応するため、行列およびベクトル演算アクセラレーションエンジンがチップに直接組み込まれており

関連記事 Appleがオープンソースの「Qwen」を開発したオリジナルチームを引き抜いたと報じられており、中国市場におけるSiriの立て直しに向けてアリババのAIに賭けているという アリババの「XuanTie C950」は、標準的なL1キャッシュと、柔軟かつ高度に構成可能なL2キャッシュを備えており、コア間の通信ボトルネックを解消するために、オプションの共有L3キャッシュにも対応しています。また、このチップはハードウェアレベルのインテリジェントなデータプリフェッチアルゴリズムを採用しており、実行エンジンが要求する前にメモリストリングをキャッシュ階層に読み込みます。その他の重要な仕様は以下の通りです:

このチップはオープンソースのRISC-V ISAを基盤としており、これによりアリババはx86アーキテクチャやARMの設計に伴うライセンス料を回避できる。また、これにより、より高度なカスタマイズも可能となる。 このチップは8命令のデコード幅を採用しており、コアが大量の命令を同時に読み込んで処理することが可能となっている。 このチップは16段のパイプラインを採用しており、各命令を16つの部分に分解することで、高いクロック速度を維持しつつ、複雑なサーバーおよびAIワークロードを効率的に実行するというバランスを実現しています。 GPUとは異なり、XuanTie C950はソケットごとに1つの推論スレッドを実行するため、高並行性のパブリックAPIよりも、エッジ展開やプライベート推論に適しています。 カスタムパイプラインと統合されたアクセラレーションエンジンの組み合わせにより、XuanTie C950は、10億パラメータ規模の大型言語モデル(LLM)を、エミュレーションや変換レイヤーを一切必要とせず、完全にネイティブに実行できるよう設計された初のRISC-Vプロセッサとなっています。そのハードウェアユニットと命令セット拡張機能は、中小規模のAIモデルに必要なコア演算を直接実行できるように設計されています。 重要な点として、XuanTie C950はTSMCの5nmプロセスで製造されているとみられているが、アリババからは直接的な確認は発表されていない。

これで、本日のトピックの核心にたどり着きました。アリババは、最新の「Qwen-3.8 27B」モデルに対し、XuanTie C950チップでの「デイゼロ」サポートを実現し、秒間30トークンのデコード速度と、わずか1.9秒の「Time To First Token(TTFT)」を実現しました。

ご存じない方のために説明すると、Qwen-3.8 27Bは、270億パラメータを持つオープンウェイトAIモデルであり、Opus 4.5と同等のコーディング能力を備えながらも、1台のMacBook上で動作することが可能です。

XuanTie C950においてこのモデルに対する「デイゼロ」サポートを実現することで、アリババは顧客を自社のエコシステム内に閉じ込めようとしているだけでなく、自社のコンピューティング環境における選択肢を大幅に拡大しようとしている。何しろ、アリババは自社のデータセンター内でC950を他のAIアクセラレータと容易に組み合わせることで、推論関連のワークロードを効率的に処理することができるからだ。