はじめに
現在、私は趣味の個人開発で、Claude Code を使って、SystemVerilogシミュレータ mrun(仮)
をC++23をベースに実装しています。コアとなる機能の動作確認は徐々に進んでいるのですが、実用的な規模のRTLを扱うには、今のシングルスレッド実装では性能がまったく足りないと予想しています。
そこで、次の開発フェーズとして、約3ヶ月後を目処にシミュレーション性能を上げるための並列化に取り組む計画です。この記事は、現時点(2025年)で最適な技術選定を行うことを目的に執筆しています。今回の開発の経験から、Claude Code には明確な方針の明示をした方がよい事がわかっているので、本格的な実装を始める前に、どのような並列化のアプローチがあるのかを比較・検討し、開発の優先順位や技術的な課題、そして具体的な実装計画をまとめてみたいと思います。
1. まずは準備から:プロファイリング戦略
並列化に取り掛かる前に、現状のどこに時間がかかっているのか(ボトルネック)をきちんと数字で把握することがとても重要です。そのために、以下のようなプロファイリング戦略を計画しています。
-
ホットスポット分析:
perf
(Linux) やVTune
(Intel) といったツールを使って、シミュレータの実行時間の大半を占めている処理(ホットスポット)を特定します。特に、イベントを処理するループや、回路情報をたどる処理、波形をファイルに出力する処理などが主な分析対象になります。 - キャッシュ効率分析: メモリの使い方が効率的かどうかを、キャッシュミス率などから測定します。特に、複数のコアで処理を分担するようになると問題になりがちな「False Sharing」という現象が起きないか、事前に評価しておきます。
- 並列化できそうな箇所の特定: シミュレーションの各段階(イベント評価、**ノンブロッキング代入(Non-Blocking Assignment, NBA)**更新など)にかかる時間の比率を測り、アムダールの法則を参考に、並列化によってどれくらい性能が上がりそうか予測を立てます。
2. 並列化のアプローチとライブラリを比べてみる
2.1. アプローチごとの特徴比較
並列化方法で、それぞれのアプローチにどんな特徴があるのか比べてみます。
比較項目 | GPU 並列 (CUDA / SYCL / OpenCL) | CPU マルチスレッド (TBB / OpenMP など) | マルチプロセス/分散 (MPI) |
---|---|---|---|
典型的な並列粒度 | 1ゲートや1ネット、 1デルタサイクルを 数万〜数百万スレッド でまとめて処理します |
1デルタサイクルや1階層といった単位を 8〜128 スレッド で実行します |
タイムスライスや設計ブロックごとに コンピュータ単位 で分割します |
期待できる最大スピードアップ | 10〜100倍 (注1) | 2〜16倍 (NUMA最適化で32倍) | 10〜200倍 (クラスタの規模によります) |
ボトルネックになりやすい点 | データ転送の遅延と、スレッドごとの処理のばらつき(ワープ発散) | キャッシュの一貫性維持と、データの取り合い(ロック競合) | ネットワーク遅延、同期アルゴリズム選択 (Conservative/Optimistic) (注2) |
イベントキュー実装の難しさ | ★★★★☆ (注3) | ★★☆☆☆ (注3) | ★★★★☆ (注3) |
4値(0/1/X/Z)の扱い | 2bit/信号にパック可能。ただし4値論理の演算は複雑で、性能に影響します (注4) | CPUがネイティブで扱えます (4値 LUT) | ノード間で X/Z の伝搬を同期させる必要があります |
テストベンチ (UVM/DPI) との相性 | 低い: CPU↔GPU間のデータのやり取りが頻繁に発生します | 高い: 同じプロセス内でメモリを共有できます | 中くらい: プロセス間の通信(RPC)が必要です |
スケールの上限 | GPUメモリに収まる規模まで(〜数千万ゲート)(注5) | NUMAソケットの数まで | クラスタと予算が許す限り |
デバッグや再現性の確保 | カーネル内で $display 不可。波形の再構成が難しいです |
GDB/LTTngなどが使えます | ノード間の時刻のズレで、波形の整合性をとるのが課題です |
ハードウェアコスト | 1枚あたり USD 500 〜 20,000 | 既存のサーバー内で無料です | ネットワークとノードの台数分かかります |
導入の難しさ(主観) | ★★★★☆ | ★★☆☆☆ | ★★★★★ |
向いているケース | ・大規模なRTLを高速にシミュレーションしたい ・2値/サイクル精度で十分 |
・既存の商用シミュレータのMTオプションのように 手軽に高速化したい |
・クラスターを常時稼働させ、夜間バッチなどで 大量のテストを回したい |
注1: 論文[1]では、理想的な条件下で2値サイクルベースシミュレータがCPU実装比で最大1000倍の性能向上を報告していますが、これは特定のアーキテクチャに最適化された結果です。4値イベントドリブンなど、より汎用的なシミュレータでは、30〜60倍程度の高速化が現実的な目標値となります。
注2: 分散シミュレーションの性能は、PDES (Parallel Discrete Event Simulation) の同期アルゴリズム選択に大きく依存します。
注3: 難易度の目安です: ★1=既存APIで完結, ★2=既存APIの組み合わせ, ★3=API+少しカスタムした同期処理, ★4=自作のlock-freeキュー実装, ★5=分散同期プロトコルの実装。
注4: GPU上で4値論理を効率的に扱うには、ビット演算を駆使したカスタムの演算ロジックを実装する必要があります。
注5: 信号値だけでなく、接続情報やイベントキューなどのメタデータを含めると、シミュレータが必要とするメモリはゲート数に対して膨大になります。現行の単一GPUに搭載されるHBM(48GB〜96GB)や、PCIeの転送帯域を考慮すると、現実的に扱える規模は数千万ゲートが上限です。NVIDIA GH200の144GB構成を用いても、およそ1.5億ゲート程度が上限と考えられます。
2.2. 主要な並列ライブラリの現状と活発度(2025年6月時点)
ライブラリ | 管轄団体 | 最新仕様(発行年月) | 活発度の指標 | 主な実装/対応コンパイラ | 今後 3 年の展望* |
---|---|---|---|---|---|
OpenMP | OpenMP ARB | 6.0(2024/11)✅ | - TR13 草案作業中 - 年次カンファレンスで新機能議論 |
GCC 14⁺ / LLVM 18⁺ / Intel oneAPI / Clang for NVPTX & AMDGPU | 毎年アップデートが続いています。CPUスレッド+GPUオフロードの両方が標準化の中心です。 |
CUDA | NVIDIA | CUDA Toolkit 12.5(2025/4) | - ドライバとToolkitが四半期ごとにリリース - GitHub “cuda-samples” はほぼ毎週更新 ✅ |
nvcc / clang-cuda | NVIDIA製ハードに依存しますが、エコシステムは最大です。性能を最優先するなら事実上の一択です。 |
SYCL | Khronos | Rev 9(2024/11)✅ | - 週次のWG/GitHub活動が活発 - oneAPI DPC++ は毎月リリース |
Intel oneAPI DPC++, Codeplay ComputeCpp, hipSYCL | 複数ベンダーのGPUに対応する上での最有力候補です。C++26への標準提案も進んでいます。 |
OpenCL | Khronos | 3.1(2022/2) | - 仕様更新は緩やかです ⚠️ - PoCL/Intel NEO など OSS ランタイムは保守されています |
PoCL / Intel GPU / AMD ROCm | HPC分野では SYCL が“上位互換” の立ち位置にあり、新機能はSYCL側で追加されています。 |
TBB (oneTBB) | Intel | 2021.7 → oneTBB 2025.1 | - oneAPIのリズムで四半期ごとに更新 | GCC / Clang / MSVC (ヘッダのみ) | C++23のstd::execution を裏で支えるライブラリとして存続しています。 |
MPI | MPI Forum | 4.1 (2023/11) ✅ | - 規格は安定し、実装は各ベンダーが保守 | Open MPI, MPICH, Intel MPI など | 分散メモリ環境での並列計算のデファクトスタンダード。低レイテンシ同期を要するHPCや科学技術計算で必須です。 |
*展望は、公開されている資料から、筆者が勝手に判断したものです。
3. 実装の優先順位について考えてみる
3.1. 【第1優先】CPU マルチスレッド
コストパフォーマンスが最も高く、最初のステップとして最適です。
C++23環境での並列化APIの選び方とコード例
C++23とOpenMPは問題なく一緒に使えます1。イベントドリブンのシミュレーションでは、個々のイベントの処理時間がバラバラになりがちなので、動的にタスクを割り振るスケジューリングが有効です。ただし、ループの処理一回あたりが非常に短い場合、スケジューリングのオーバーヘッドが無視できなくなるため、イベント数が多い場合には schedule(guided, 64)
のような工夫も検討する価値があるかもしれません。
// コンパイル例: g++ -std=c++23 -O3 -fopenmp main.cpp
#include <vector>
#include <omp.h> // 関数呼び出しがなくても警告防止のためインクルードを推奨します
void evaluate_events_omp(std::vector<Event>& current_events) {
#pragma omp parallel for schedule(dynamic)
for (size_t i = 0; i < current_events.size(); ++i) {
if (current_events[i].is_ready()) { // 依存関係をチェックします
current_events[i].evaluate();
}
}
}
3.2. 【第2優先】求める性能に応じたアプローチの選択
A) マルチプロセス(タスク並列)
たくさんのテストをまとめて実行する回帰テスト(リグレッションテスト)全体の時間を短縮する上で、とても効果的だと思われます。
B) GPU並列
一つのシミュレーションの実行性能を極限まで高めたい場合に選択肢となります。先ほどのライブラリ比較の通り、シミュレータの複雑さを考えると、ディレクティブベース(OpenACCなど)で性能をチューニングするのは非常に難しくなります。したがって、性能を追求するならば、CUDA や SYCL といった、より細かい制御ができるAPIを選ぶことになるかもしれません。
4. SystemVerilogならではの並列化の課題
どのアプローチを選ぶにしても、SystemVerilogの言語仕様からくる以下の課題を解決する必要があります。
-
ノンブロッキング代入(NBA)の2フェーズ実行:
NBAの「評価」と「更新」の2つのフェーズは、並列実行する上での見えない「同期バリア」となります。この同期を安全に実装するには、固定数のワーカースレッドプールを用意し、C++20のstd::barrier
を使うのが堅牢です。#include <vector> #include <thread> #include <barrier> // ワーカースレッドは常にループし、バリアに参加し続ける void worker_thread_func(int thread_id, std::barrier<>& phase_barrier) { while (true) { // タスクキューから自分の担当分を取得 auto tasks = get_evaluation_tasks(thread_id); if (tasks.empty() && should_terminate()) break; // 評価フェーズ for (auto& task : tasks) { task.evaluate(); } // 全ワーカーが評価を終えるまで同期 phase_barrier.arrive_and_wait(); // 更新フェーズ tasks = get_update_tasks(thread_id); for (auto& task : tasks) { task.update(); } // 全ワーカーが更新を終えるまで同期(次のタイムステップへ) phase_barrier.arrive_and_wait(); } } int main() { const int num_threads = std::thread::hardware_concurrency(); std::barrier phase_barrier(num_threads); std::vector<std::thread> workers; for (int i = 0; i < num_threads; ++i) { workers.emplace_back(worker_thread_func, i, std::ref(phase_barrier)); } for (auto& w : workers) { w.join(); } }
(注) この固定ワーカープールモデルは、タスクの有無でスレッド数が変動するモデルと比べて安全です。ただし、
should_terminate()
がtrue
になった後、全ワーカーがループを安全に抜けるためには、終了通知後の「ドレインフェーズ」を設け、全スレッドが確実にバリアを通過しきるように設計する必要があります。 -
always
ブロック間の依存関係:
どのデータがどのデータに依存しているかを静的に解析し、依存関係のないブロックのグループを見つけて並列に実行できるようにする必要があります。この依存関係解析は、一般的にシミュレーション実行前のElaboration(elab)フェーズで行われます。この段階でRTLの階層構造を展開し、信号間のデータフローグラフ(DFG)を構築することで、依存関係のない処理をグループ化し、並列実行のスケジュールを決定します。これはVerilatorなどのツールが採用しているコア技術の一つです。// 依存関係あり(順番に実行する必要があります) always @(posedge clk) reg_a <= data_in; always @(posedge clk) reg_b <= reg_a; // reg_aの評価結果に依存します // 依存関係なし(並列に実行できます) always @(posedge clk) reg_c <= data_in1; always @(posedge clk) reg_d <= data_in2;
5. 並列環境でのデバッグとエラー処理
並列化は、デバッグの複雑さを飛躍的に高めてしまいます。その対策として、以下の戦略を用意しておくことが大切と思われます。
- スレッドセーフなロギング: ログの書き込みで競合が起きないように、ミューテックスで保護されたロガークラスを実装します。
-
決定論的な実行: デバッグ中は、並列実行の順序を固定化(例:OpenMPで
schedule(static, 1)
を使用)して、いつでも同じ結果になるようにし、再現性を確保します。 - 波形ダンプの最適化: VCD/FSTなどの波形ダンプは、ファイルI/Oがボトルネックになりやすいです。各スレッドが直接ファイルへ書き込むと性能が著しく落ちるため、スレッドローカルなメモリバッファに一旦書き込み、バッファが一杯になったら、そのバッファを単一のI/Oスレッドが管理するキューに渡して非同期でファイルに書き出す、という設計が求められます。fmtlibのバッファリング機能やmoodycamel::ConcurrentQueueのようなlock-freeキューは、この実装の参考になります。
6. まとめと今後の計画
6.1. ベンチマーク計画
性能評価には、以下の公開されている回路や合成した回路を使用する予定です。テストシナリオとして、各回路である程度のサイクルのシミュレーションを実行し、実行時間を測定します。
- 中規模: RISC-V RV32I コア (PicoRV32) (~20K gates)
-
大規模: IWLS 2005 Benchmarks の
aes_core
(~100K gates) - 合成データパス: 32x32ビット乗算器アレイ(並列性の純粋な評価用)
6.2. 開発ロードマップ
-
第1フェーズ (基盤づくり): CPUマルチスレッド化
- 採用技術: OpenMPをメインに使い、パフォーマンスが特に重要な箇所では Intel TBB (oneTBB) の利用も検討します。
-
第2フェーズ (活用の幅を広げる): マルチプロセスによるタスク並列
- 採用技術: GitHub ActionsやJenkinsといったCI/CDツールと、Python/Bashスクリプトを組み合わせて、テストケースの分散実行と結果の集約を自動化することを目指します。
-
OSS仙人フェーズ (夢的な目標): GPU並列化
- 採用技術: パフォーマンスとエコシステムの観点からNVIDIA CUDAを第一候補とします。複数のベンダーへの対応も視野に入れる場合は、SYCLを代替として検討します。
6.3. 並列化に影響がある SystemVerilogの既存機能 (let
構文) への対応
-
let
構文: この構文は、IEEE 1800-2005で導入されている機能です[5]。そして、IEEE 1800-2023で仕様の明確化が行われました。let
構文とは?
let
は、再利用可能な式や Assertion を定義するための構文です。テキスト置換を行うマクロ(\``define
)と似ていますが、より構造化されており、スコープを持つという大きな違いがあります。コード例とマクロとの違い:
module let_example ( input logic clk, input logic [7:0] a, b, c ); // a と b の平均値を計算する let を定義 let average(x, y) = (x + y) / 2; // a > b かつ c が a,b の平均値より大きいかチェック // `average` はこのモジュール内でのみ有効 always_comb begin if (a > b && c > average(a, b)) begin $display("Condition met"); end end endmodule
マクロと比べた
let
の主な利点は以下の通りです。-
スコープ:
let
は宣言されたモジュールやブロック内でのみ有効です。一方、\``define
はファイル全体や+define
で指定された範囲に影響を与え、意図しない名前の衝突を引き起こす可能性があります。 -
構文解析:
let
は宣言時に構文的にチェックされるため、使用される前にエラーを発見できます。
並列化への影響:
シミュレータ実装の観点では、let
はマクロと同様にElaborationフェーズでインライン展開されると考えることができます。この展開によって、新たな信号間の依存関係が明示的に現れます。例えば、上記のaverage(a, b)
はa
とb
に依存します。シミュレータは、この依存関係をデータフローグラフに正しく組み込み、並列実行のスケジュールを決定する必要があります。2023年版では仕様の明確化が行われましたが、シミュレータが対応すべき基本的な振る舞いに大きな変更はありません。 -
スコープ:
7. 参考文献
- [1] Chaitanya, K. K. S., et al. "GCS: a fast and scalable GPU-based cycle-accurate simulator." 2014 24th International Conference on Field Programmable Logic and Applications (FPL). IEEE, 2014. DOI:
10.1109/FPL.2014.6927515
. - [2] The Verilator Project. https://veripool.org/verilator/ (v5.022 時点、--threads オプションが関連)
- [3] IWLS 2005 Benchmarks. https://iwls.org/iwls2005/benchmarks.html
- [4] OpenMP Architecture Review Board. "OpenMP Application Programming Interface, Version 6.0". 2024-11-14. https://www.openmp.org/wp-content/uploads/OpenMP-API-Specification-6-0.pdf
- [5] Cohen, B., et al. SystemVerilog Assertions and Functional Coverage. Springer, 2019. (see Chapter on
let
construct). DOI:10.1007/978-3-030-24737-9
-
OpenMP 6.0仕様はC++17を前提としており、一部のC++23機能(例:
std::move_only_function
を含むラムダ)をparallel for
に渡すと古いコンパイラでビルドに失敗することがあります。GCC 14やLLVM 18以降ではこの問題は解消されています。 ↩