第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;
};

主要な設計ポイントは以下の通りです。

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_PondercheckPonder(先読み)を有効にする
MultiPVspin候補手を複数表示する数
Threadsspin探索スレッド数(最大128)
Hashspin置換表サイズ(MB単位、最大65536)
MinimumThinkingTimespin最小思考時間(ミリ秒)
NetworkDelayspinネットワーク遅延の補正値
SlowMoverspin時間配分の緩急(100が標準)
ResignValuespin投了する評価値の閾値
EnteringKingRulecombo入玉ルール(CSARule24等)
USI_OwnBookcheckエンジン内蔵定跡の使用
BookDir / BookFilestring/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;
}

結果の出力は以下の優先順位で処理されます。

  1. 入玉宣言勝ちなら bestmove win。
  2. その他の終局(千日手、持将棋など)なら理由を info string で出力後、bestmove resign。
  3. 最善手がないか評価値が投了閾値以下なら bestmove resign。
  4. 通常時は 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 オプションでルート手ごとの内訳を表示でき、 既知の正解値との比較で合法手生成のバグを検出できます。