第IV部 並列化とプロトコル
第14章 USIプロトコルの実装
USI(Universal Shogi Interface)は、将棋 GUI とエンジンが標準入出力を通じて通信するためのプロトコルです。
本章では、Hayanagi の UsiEngine クラスがどのように USI プロトコルを実装しているかを、
コマンドの受信からスレッド管理、Ponder モードまで詳細に解説します。
14.1 USI プロトコルの通信フロー
USI プロトコルにおける典型的な通信フローは以下の通りです。
GUI → Engine: usi
Engine → GUI: id name Hayanagi
Engine → GUI: id author OpenAI
Engine → GUI: option name USI_Ponder type check default false
Engine → GUI: option name MultiPV type spin default 1 min 1 max 32
... (その他のオプション)
Engine → GUI: usiok
GUI → Engine: setoption name Hash value 256
GUI → Engine: isready
Engine → GUI: info string book loaded book/standard_book.db
Engine → GUI: readyok
GUI → Engine: usinewgame
GUI → Engine: position startpos moves 7g7f 3c3d
GUI → Engine: go btime 60000 wtime 60000 byoyomi 10000
Engine → GUI: info depth 1 score cp 30 nodes 120 time 5 nps 24000 pv 2g2f
Engine → GUI: info depth 2 score cp 45 nodes 3400 time 20 nps 170000 pv 2g2f 8c8d
...
Engine → GUI: bestmove 2g2f ponder 8c8d
GUI → Engine: quit
エンジンは標準入力からコマンドを 1 行ずつ読み取り、処理結果を標準出力に書き出します。 すべての通信はテキストベースで行われます。
14.2 UsiEngine クラスの設計
UsiEngine クラスの宣言を見てみましょう(usi_engine.h)。
class UsiEngine {
public:
UsiEngine() = default;
~UsiEngine();
void loop();
private:
Position position_;
Search search_;
Book book_;
mutable std::mutex mutex_;
mutable std::mutex search_state_mutex_;
std::condition_variable search_state_cv_;
std::thread search_thread_;
std::atomic_bool stop_requested_{false};
std::atomic_bool searching_{false};
std::atomic_bool usi_ponder_{false};
std::atomic_int multi_pv_{1};
std::atomic_int threads_{1};
bool pondering_ = false;
bool ponderhit_ = false;
bool suppress_bestmove_ = false;
// ... 時間管理・定跡関連のメンバ省略 ...
void handle_line(const std::string& line);
void set_option(const std::string& line);
void stop_search(bool report_bestmove = false);
void start_search(const std::string& line);
bool set_position(const std::string& line);
SearchOptions parse_go_options(const std::string& line) const;
void report_bestmove(const SearchResult& result, ...) const;
void print_info(const SearchInfo& info) const;
};
主要な設計ポイントは以下の通りです。
position_が現在の局面を保持し、mutex_で保護される。search_thread_で探索をバックグラウンド実行し、stop_requested_で停止を通知する。- Ponder 関連の状態は
search_state_mutex_とsearch_state_cv_で同期する。 - スレッド安全が必要な設定値は
std::atomicで宣言されている。
14.3 loop() — メインコマンドループ
エンジンのエントリポイントとなるのが loop() です。
void UsiEngine::loop() {
std::string line;
while (std::getline(std::cin, line)) {
handle_line(line);
if (line == "quit") {
break;
}
}
}
標準入力から 1 行ずつ読み取り、handle_line() に委譲します。
quit を受信するか、入力が EOF に達するとループを抜けます。
デストラクタで探索スレッドの停止が保証されています。
14.4 handle_line() — コマンドディスパッチ
受信した行を解析し、対応する処理に振り分けます。
void UsiEngine::handle_line(const std::string& line) {
if (line == "usi") {
// エンジン情報とオプション一覧を出力
std::cout << "id name Hayanagi" << std::endl;
std::cout << "id author OpenAI" << std::endl;
// ... 各オプションの出力 ...
std::cout << "usiok" << std::endl;
return;
}
if (line == "isready") {
// 定跡読み込み → readyok
// ...
std::cout << "readyok" << std::endl;
return;
}
if (line == "usinewgame") {
stop_search();
std::lock_guard<std::mutex> lock(mutex_);
position_.set_startpos();
return;
}
if (line.rfind("position ", 0) == 0) {
stop_search();
set_position(line);
return;
}
if (line.rfind("go", 0) == 0) {
start_search(line);
return;
}
if (line == "stop") {
stop_search(true);
return;
}
if (line == "ponderhit") {
std::lock_guard<std::mutex> lock(search_state_mutex_);
if (pondering_) {
ponderhit_ = true;
search_state_cv_.notify_all();
}
return;
}
if (line.rfind("setoption ", 0) == 0) {
set_option(line);
return;
}
if (line == "quit") {
stop_search();
}
}
文字列の先頭一致(rfind(..., 0) == 0)でコマンドを判別しています。
bench と perft は USI 標準にはない独自拡張コマンドです。
14.5 set_option() — オプション管理
setoption name <名前> value <値> コマンドを処理します。
void UsiEngine::set_option(const std::string& line) {
const auto tokens = split_tokens(line);
if (tokens.size() < 3 || tokens[0] != "setoption" || tokens[1] != "name") {
return;
}
std::string value_token;
for (std::size_t i = 3; i + 1 < tokens.size(); ++i) {
if (tokens[i] == "value") {
value_token = tokens[i + 1];
break;
}
}
bool rules_changed = false;
if (tokens[2] == "USI_Ponder") {
usi_ponder_.store(parse_bool_option(value_token, kDefaultUsiPonder));
} else if (tokens[2] == "MultiPV") {
multi_pv_.store(std::clamp(parse_int(value_token, kDefaultMultiPv),
1, kMaxMultiPv));
} else if (tokens[2] == "Threads") {
threads_.store(std::clamp(parse_int(value_token, kDefaultThreads),
1, kMaxThreads));
} else if (tokens[2] == "Hash") {
const std::size_t hash_size_mb = std::clamp<std::size_t>(
parse_uint64(value_token, Search::hash_size_mb()),
kMinHashSizeMb, kMaxHashSizeMb);
stop_search();
if (!Search::set_hash_size_mb(hash_size_mb)) {
std::cout << "info string hash resize_failed "
<< hash_size_mb << std::endl;
}
} else if (tokens[2] == "EnteringKingRule") {
// ... 入玉ルールの設定 ...
} else if (tokens[2] == "USI_OwnBook") {
usi_own_book_ = parse_bool_option(value_token, usi_own_book_);
} else if (tokens[2] == "BookDir" || tokens[2] == "BookFile") {
// ... 定跡設定の更新、book_loaded_ をリセット ...
}
if (rules_changed) {
std::lock_guard<std::mutex> lock(mutex_);
apply_position_rules(position_);
}
}
主なオプションの一覧です。
| オプション名 | 型 | 説明 |
|---|---|---|
| USI_Ponder | check | Ponder(先読み)を有効にする |
| MultiPV | spin | 候補手を複数表示する数 |
| Threads | spin | 探索スレッド数(最大128) |
| Hash | spin | 置換表サイズ(MB単位、最大65536) |
| MinimumThinkingTime | spin | 最小思考時間(ミリ秒) |
| NetworkDelay | spin | ネットワーク遅延の補正値 |
| SlowMover | spin | 時間配分の緩急(100が標準) |
| ResignValue | spin | 投了する評価値の閾値 |
| EnteringKingRule | combo | 入玉ルール(CSARule24等) |
| USI_OwnBook | check | エンジン内蔵定跡の使用 |
| BookDir / BookFile | string/combo | 定跡ファイルの指定 |
Hash の変更時は探索を停止してからリサイズを行うなど、安全性に配慮しています。
BookDir や BookFile が変更されると book_loaded_ がリセットされ、
次の isready で再読み込みが行われます。
14.6 set_position() — position コマンドの処理
bool UsiEngine::set_position(const std::string& line) {
const auto tokens = split_tokens(line);
if (tokens.size() < 2) {
return false;
}
Position next;
apply_position_rules(next);
if (!load_position_from_tokens(tokens, 1, next)) {
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
position_ = next;
return true;
}
position startpos moves ... または position sfen ... moves ... の形式を受け付けます。
ヘルパー関数 load_position_from_tokens() が実際のパースを担当し、
startpos なら初期局面、sfen なら指定された SFEN から局面を構築します。
moves 以降のトークンは apply_usi_move() で 1 手ずつ適用されます。
パースに成功した場合のみ、mutex で保護された position_ を更新します。
14.7 parse_go_options() — go コマンドのパース
SearchOptions UsiEngine::parse_go_options(const std::string& line) const {
SearchOptions options;
options.multi_pv = std::clamp(multi_pv_.load(), 1, kMaxMultiPv);
options.threads = std::clamp(threads_.load(), 1, kMaxThreads);
const auto tokens = split_tokens(line);
int movetime = 0;
int black_time = 0, white_time = 0;
int black_inc = 0, white_inc = 0;
int byoyomi = 0;
for (std::size_t i = 1; i < tokens.size(); ++i) {
const std::string& token = tokens[i];
if (token == "depth" && i + 1 < tokens.size())
options.max_depth = std::max(1, parse_int(tokens[++i]));
else if (token == "movetime" && i + 1 < tokens.size())
movetime = std::max(1, parse_int(tokens[++i]));
else if (token == "ponder")
options.ponder = true;
else if (token == "nodes" && i + 1 < tokens.size())
options.node_limit = std::max<std::uint64_t>(1, parse_uint64(tokens[++i], 0));
else if (token == "btime" && ...) // btime, wtime, binc, winc, byoyomi も同様
...
else if (token == "infinite") {
options.infinite = true;
options.max_depth = kMaxDepth;
}
}
// ... 時間配分の計算 ...
return options;
}
go コマンドは多くのサブオプションを持ちます。
depth、nodes、movetime は固定条件の探索、
btime/wtime/binc/winc/byoyomi は持ち時間に基づく探索、
infinite は stop まで探索を続けるモードです。
時間配分の計算では、残り時間の 1/30 を基本とし、
SlowMover で緩急を調整、NetworkDelay で通信遅延を差し引くなど、
実戦的な時間管理を行います。
14.8 探索のスレッド管理
14.8.1 start_search() — ワーカースレッドの起動
void UsiEngine::start_search(const std::string& line) {
stop_search();
stop_requested_.store(false);
Position snapshot;
{
std::lock_guard<std::mutex> lock(mutex_);
snapshot = position_;
}
const SearchOptions options = parse_go_options(line);
// 定跡参照(前章で解説済み)
// ...
{
std::lock_guard<std::mutex> lock(search_state_mutex_);
pondering_ = options.ponder;
ponderhit_ = false;
suppress_bestmove_ = false;
}
const int resign_value = resign_value_;
searching_.store(true);
search_thread_ = std::thread(
[this, snapshot, options, resign_value]() mutable {
const SearchResult result = search_.find_best_move(
snapshot, options, stop_requested_,
[&](const SearchInfo& info) { print_info(info); });
// Ponder 待機処理
bool emit_bestmove = true;
{
std::unique_lock<std::mutex> lock(search_state_mutex_);
if (options.ponder && !stop_requested_.load()
&& !ponderhit_ && !suppress_bestmove_) {
search_state_cv_.wait(lock, [this]() {
return stop_requested_.load()
|| ponderhit_ || suppress_bestmove_;
});
}
emit_bestmove = !suppress_bestmove_;
pondering_ = false;
ponderhit_ = false;
}
if (emit_bestmove) {
report_bestmove(result, snapshot, resign_value);
}
searching_.store(false);
search_state_cv_.notify_all();
});
}
探索は専用のスレッド(search_thread_)で実行されます。
メインスレッドは start_search() を呼んだ後すぐに制御を返し、
次のコマンド(stop や ponderhit)を受け付けられる状態になります。
局面のスナップショットをコピーしてスレッドに渡すことで、
探索中にメインスレッドが position_ を変更しても問題が起きません。
14.8.2 stop_search() — 停止と結果取得
void UsiEngine::stop_search(bool report_bestmove) {
{
std::lock_guard<std::mutex> lock(search_state_mutex_);
suppress_bestmove_ = !report_bestmove;
}
stop_requested_.store(true);
search_state_cv_.notify_all();
if (search_thread_.joinable()) {
search_thread_.join();
}
searching_.store(false);
std::lock_guard<std::mutex> lock(search_state_mutex_);
pondering_ = false;
ponderhit_ = false;
suppress_bestmove_ = false;
}
stop_requested_ を true にして探索の中断を要求し、
search_state_cv_ を通知して Ponder 待機中のスレッドを起こします。
join() でスレッドの完了を待ちます。
引数 report_bestmove が true(stop コマンド由来)の場合は
bestmove を出力し、false(内部呼び出し)の場合は抑制します。
14.9 Ponder モード
Ponder(先読み)は、相手の思考中にエンジンが予測手に対する探索を進める機能です。
14.9.1 go ponder — bestmove を保留
go ponder を受信すると options.ponder = true となり、
探索完了後に bestmove の出力を保留します。
スレッドは condition_variable の wait() でブロックされます。
14.9.2 ponderhit — 通常探索に復帰
相手が予測通りの手を指した場合、GUI は ponderhit を送信します。
if (line == "ponderhit") {
std::lock_guard<std::mutex> lock(search_state_mutex_);
if (pondering_) {
ponderhit_ = true;
search_state_cv_.notify_all();
}
return;
}
ponderhit_ フラグを立てて条件変数を通知することで、
待機中のスレッドが起きて bestmove を出力します。
14.9.3 stop — 即座に bestmove 返却
予測が外れた場合は stop が送られます。
stop_search(true) により探索を中断し、その時点での最善手を bestmove として返します。
14.10 report_bestmove() と print_info()
report_bestmove()
void UsiEngine::report_bestmove(const SearchResult& result,
const Position& snapshot,
int resign_value) const {
if (result.terminal.outcome == TerminalOutcome::Win &&
result.terminal.reason == TerminalReason::DeclarationWin) {
std::cout << "bestmove win" << std::endl;
return;
}
if (result.terminal.is_terminal()) {
std::cout << "info string terminal "
<< terminal_reason_text(result.terminal) << " "
<< terminal_outcome_text(result.terminal) << std::endl;
std::cout << "bestmove resign" << std::endl;
return;
}
if (!result.has_best_move || result.score_cp <= -resign_value) {
std::cout << "bestmove resign" << std::endl;
return;
}
std::cout << "bestmove " << snapshot.move_to_usi(result.best_move);
if (usi_ponder_.load()) {
const std::string ponder = extract_ponder_move(result.pv);
if (!ponder.empty()) {
std::cout << " ponder " << ponder;
}
}
std::cout << std::endl;
}
結果の出力は以下の優先順位で処理されます。
- 入玉宣言勝ちなら
bestmove win。 - その他の終局(千日手、持将棋など)なら理由を
info stringで出力後、bestmove resign。 - 最善手がないか評価値が投了閾値以下なら
bestmove resign。 - 通常時は
bestmove <手>を出力。USI_Ponder が有効なら PV の 2 手目をponderとして付加。
print_info()
void UsiEngine::print_info(const SearchInfo& info) const {
std::cout << "info depth " << info.depth;
if (info.show_multipv) {
std::cout << " multipv " << info.multipv;
}
std::cout << " score cp " << info.score_cp
<< " nodes " << info.nodes
<< " time " << info.elapsed_ms
<< " nps " << compute_nps(info.nodes, info.elapsed_ms)
<< " pv " << info.pv << std::endl;
}
探索の各反復で呼ばれるコールバックです。
深さ、評価値、ノード数、経過時間、NPS、PV(最善応手列)を出力します。
MultiPV モードでは multipv フィールドも付加されます。
14.11 MultiPV 対応
MultiPV は、1 つの局面に対して複数の候補手とその評価を表示する機能です。
MultiPV オプションで数を指定すると、
探索エンジンが上位 N 手それぞれの PV と評価値を info 行で出力します。
各行に multipv 1, multipv 2, ... のように順位が付きます。
これは GUI の検討モードで特に有用です。
14.12 bench と perft の実装概要
bench — ベンチマーク
bench コマンドは組み込みの 6 局面(初期局面、角交換型など)に対して探索を実行し、
合計ノード数・NPS・置換表使用率を集計します。
bench depth 5 // 深さ5で探索
bench nodes 10000 // 10000ノードで探索
bench current // 現在の局面のみ
perft — 合法手生成の検証
perft コマンドは指定深さまでの全合法手を列挙し、
ノード数・取る手・成り・王手・詰みの件数を集計します。
divide オプションでルート手ごとの内訳を表示でき、
既知の正解値との比較で合法手生成のバグを検出できます。