今回は、将棋所で遊べるランダムプレイヤーの作り方を説明していきます。
search.cppの4つの関数
search.cppのほうに次の4つの関数があります。自分で書く場合は中身は要らないので好きな様に書き換えて大丈夫です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 起動時に呼び出される。時間のかからない探索関係の初期化処理はここに書くこと。 void Search::init() … // isreadyコマンドの応答中に呼び出される。時間のかかる処理はここに書くこと。 void Search::clear() … // 探索開始時に呼び出される。 void MainThread::think() … // 探索本体。並列化している場合、ここがslaveのエントリーポイント。 void Thread::search() … |
1. Search::init()は起動時に一度だけ呼びだされる。探索で用いる定数テーブルの初期化などはここに書く。
2. Search::clear()は対局開始時に毎回呼び出される。置換表のクリアなど、時間のかかる初期化処理はここに書く。
3. MainThread::think()はUSIプロトコルのgoコマンドの応答として呼び出される。探索を行なうメインスレッドはここから実行される。
4. Thread::search()は、探索を並列化するときにメインスレッド以外はここから実行される。メインスレッドもThread::search()を実行したいときは、MainThread::thinkのなかから明示的に呼び出すと良い。
将棋所で遊べるランダムプレイヤー
将棋所で遊べるランダムプレイヤーを作ってみよう。
いま、1.と2.は関係ないので、並列化もしないので4.も関係ない。3.に書けば良い。
3.に書くときに、これはMainThreadというclassのthink()を実装していることになるので、MainThreadのメンバーにアクセスできる。MainThreadのメンバーとして、以下の変数にアクセスできる。
rootPos : 探索開始時の局面
rootMoves : 探索開始局面での合法手
後者は少し特殊な構造体なのでいまはこれを使わずに前者だけ使って合法手を生成して、そのなかからランダムに1つ指し手を選ぶようにしてみよう。
1 2 3 4 5 6 |
void MainThread::think() { MoveList Move bestMove = (ml.size() == 0) ? MOVE_RESIGN : ml.at(rand() % ml.size()).move; sync_cout << "bestmove " << bestMove << sync_endl; } |
これですべてである。ランダムプレイヤー完成だ。
確かに将棋所で対戦できている。ランダムプレイヤーなのですこぶる弱いが、たった3行書くだけでルール通り指せるということに感動しないだろうか。
上のプログラムを解説しておこう。
「ml.size()==0」というのは「生成された合法手がない」場合で、これは詰みの局面であるから、投了(MOVE_RESIGN)する。これはUSIプロトコルでは”bestmove resign”とGUIに送れば、良いことになっている。見ての通り、Moveという指し手を表すenumにMOVE_RESIGNという定数を入れておくと、<< で標準出力に表示させるときに”resign”という文字列が出力される。
sync_out~sync_endl
sync_cout~sync_endlは、この出力をしているときに他のスレッドが統計情報などを標準出力に出力してしまうと文字列が分断されたりしておかしくなるので、スレッド排他をしながら標準出力に出力するための呪文である。思考中に標準出力に出力したいときは常にこの呪文を使うと良い。
これは、Stockfishと同じく次のようなマクロにより実現してある。
1 2 3 4 5 6 7 8 9 10 |
enum SyncCout { IO_LOCK, IO_UNLOCK }; #define sync_cout std::cout << IO_LOCK #define sync_endl std::endl << IO_UNLOCK std::ostream& operator<<(std::ostream& os, SyncCout sc) { static Mutex m; if (sc == IO_LOCK) m.lock(); if (sc == IO_UNLOCK) m.unlock(); return os; } |
並列化
やねうら王miniを用いれば、探索の並列化も簡単に出来る。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
void MainThread::think() { for (auto th : Threads.slaves) th->search_start(); // Thread::search()の実行を開始する search(); for (auto th : Threads.slaves) th->join(); // Thread::search()を抜けるのを待つ } void Thread::search() { sync_cout << "thread id = " << thread_id() << " is_main() = " << is_main() << sync_endl; } |
実行結果は以下のようになる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
> go thread id = 0 is_main() = 1 thread id = 1 is_main() = 0 thread id = 3 is_main() = 0 thread id = 2 is_main() = 0 > go thread id = 1 is_main() = 0 thread id = 0 is_main() = 1 thread id = 3 is_main() = 0 thread id = 2 is_main() = 0 > go thread id = 0 is_main() = 1 thread id = 1 is_main() = 0 thread id = 3 is_main() = 0 thread id = 2 is_main() = 0 |
スレッドidはMainThreadは必ず0である。slaveスレッドは1,2,3,…のように1から連番となる。
また、スレッド数はUSIプロトコルのoptionで設定された数だけ走る。
1 2 3 4 5 6 7 8 |
> setoption name Threads value 6 > go thread id = 0 is_main() = 1 thread id = 4 is_main() = 0 thread id = 1 is_main() = 0 thread id = 5 is_main() = 0 thread id = 3 is_main() = 0 thread id = 2 is_main() = 0 |
なお、このスレッド数は将棋所では、思考エンジン設定のところで変更出来るようになっている。(このへんの仕組みについてはまたの機会に詳しく書く)
YBWCを超えて
従来、将棋の探索の並列化はYBWC(Young Brothers Wait Concept)を用いるのが主流であった。Bonanza6からponanza(2015)に至るまで並列化はすべてYBWCである。
YBWCについて詳しいことはggrksである。
簡単に言うと、残り探索深さがたくさんあるときに、そのnodeを複数のスレッドで探索する。この、nodeをそれぞれのスレッドに割り振ることを(nodeの)”split”と呼ぶ。split後、それぞれのスレッドはそのnodeの指し手を分担して調べていく。
従来、どのタイミングでsplitするか、そのときにいくつのスレッドに割り振るかなどの戦略が探索上、重要なテクニックであった。
しかし、YBWCはもう終焉を迎えた。
近年、もっといい方法が発明されたのだ。これは探索(の並列化)における一大ブレイクスルーである。
Stockfishの最新版のmasterもYBWCを捨てて、その新しい並列化のコードになった。そちらのほうがコア数が多いときにパフォーマンスが良いというのだ。当然、Ponanzaも来年はYBWCを捨てるだろう。技巧(2015)は、電王トーナメントの時点でYBWCを採用していなかったと思う。
また、クラスター並列化もYBWCではなくその方法でやったほうがパフォーマンスがいい可能性も(少しだけ)ある。
詳しいことは書ききれないが、ともかく、今後YBWCは使わない&使われないということで間違いない。だからsplitという問題はない。masterとslaveの区別も必要ない。
そのへんを考慮して、やねうら王miniでは上のようなスレッドモデル(slaveとmasterがその区別なしに同じsearch関数を実行する作り)を想定している。
より詳しいことは探索の並列化をするときに書く。
ともかく、やねうら王miniの設計で大丈夫だということだけいまは記しておく。
ここまでのまとめ
・将棋所で対局できるランダムプレイヤーはわずか3行書くだけで出来る!
・並列化対策もやねうら王miniならバッチリ!
次回に続く。
>もっといい方法が発明された
>その新しい並列化のコード
>詳しいことは書ききれない
名前だけでも先に教えてもらえませんでしょうか。
名前がついていないのであれば、キーワードだけでも。。。
よろしくお願いします。
>もっといい方法が発明された
>その新しい並列化のコード
>詳しいことは書ききれない
この引用だけ見ると、やねさんがフェルマーみたいですね
ブログの欄外が狭すぎるので、書き切れない。。。といって連載が終了しない事を祈っています。
Lazy SMPと呼ぶようですね。以下にフォーラムの該当スレッドへのリンク等があります。
https://chessprogramming.wikispaces.com/Parallel+Search
アルゴリズムについては、簡単ですから、Stockfishのmasterのsearch.cppのThread::search()を少し見ればわかると思いますよ。
情報有難うございました。該当ファイルをダウンロードできました。勉強します。
自分のプログラムに反映できれば良いのですが。。。
search.cpp見てもThread::search()が見つからない??
Stockfish6ですよね??
https://github.com/official-stockfish/Stockfish/blob/master/src/search.cpp#L355
にあるでございます。(これがStockfishのどのバージョンのものかはよく知りませんけども..)
おお!あった!
どうもありがとうございます
そう言えば伊藤さんが「Puella α のソースを公開します。画面左上の「資料館」に置きました。使用法等については中のREADMEをご覧ください。」って言ってたこの手法ですか?
Puella α、実は見てないんですけど、話聞く感じroot付近のnode展開まとめて展開して、有望そうなnodeをクラスターに振るという、そんな感じのアルゴリズムではないでしたっけ。
今回のStockfishの並列化アルゴリズムは全然違ってて…
おっと誰か来たようだ。
自分はスレッドについてはど素人もいいところで全然触ったことがありません。
この前スレッドプールを自作してみたんですけど、C++11の壁に挟まれて見事死産しました。
なんでスレッド走ってるか知るためにブロックするの!!
グローバル変数は使いたくないのーー!!!
というわけで研究が進んでいません。
楽しそうではあるんですけどね。
こういうことがあるからC++はまだだなーと思います。
まぁ、怠慢なだけなんですが・・・。
やねうら王miniをフレームワークとして使うならスレッドの知識ゼロでも問題ないと思いますよ。
6日分読み飛ばしたかと思った。