From 67ce7944a141f473d6dd3b961fb4029ae41682b9 Mon Sep 17 00:00:00 2001 From: yaneurao Date: Sat, 12 Oct 2024 22:08:11 +0900 Subject: [PATCH] =?UTF-8?q?-=20TTData=E5=B0=8E=E5=85=A5=E3=80=82=20-=20ext?= =?UTF-8?q?ract=5Fponder=5Ffrom=5Ftt=E3=81=A7iteration=202=E5=9B=9E?= =?UTF-8?q?=E7=9B=AE=E3=81=A7=E5=8F=96=E5=BE=97=E3=81=97=E3=81=9Fponder?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=81=99=E3=82=8B=E3=81=AE=E3=82=84=E3=82=81?= =?UTF-8?q?=E3=82=8B=E3=80=82(=E3=81=93=E3=82=93=E3=81=AA=E3=81=AE?= =?UTF-8?q?=E8=A9=B2=E5=BD=93=E3=81=99=E3=82=8B=E3=82=B1=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=81=8C=E7=A8=80)=20-=20TT.probe()=E3=81=A7TT=E3=81=AE?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=8A=B6=E6=85=8B=E3=81=8C=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8=E3=81=AF?= =?UTF-8?q?=E4=BF=9D=E8=A8=BC=E3=81=95=E3=82=8C=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=AA=E3=81=A3=E3=81=9F=E3=80=82=20-=20USI::pv()?= =?UTF-8?q?=20=E2=86=92=20search=E3=81=AE=E3=81=BB=E3=81=86=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E5=8B=95=20-=20TODO=20:=20LEARN=E7=89=88=E3=81=AETT?= =?UTF-8?q?=E3=81=AE=E5=88=9D=E6=9C=9F=E5=8C=96=E3=81=A8=E7=A2=BA=E4=BF=9D?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=BC=E3=83=89=E3=80=81=E3=81=82=E3=81=A8?= =?UTF-8?q?=E3=81=A7=E6=9B=B8=E3=81=8F=E3=80=82=20-=20TODO=20:=20=E8=89=B2?= =?UTF-8?q?=E3=80=85=E5=A3=8A=E3=81=97=E3=81=A6=E3=81=97=E3=81=BE=E3=81=A3?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=82=8B=E3=81=AE=E3=81=A7=E3=82=80=E3=81=A3?= =?UTF-8?q?=E3=81=A1=E3=82=83=E5=BC=B1=E3=81=84=E3=80=82=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BD=9C=E6=A5=AD=E4=B8=AD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yaneuraou-engine/yaneuraou-search.cpp | 508 +++++++++++----- source/position.cpp | 6 +- source/search.h | 23 +- source/thread.cpp | 22 +- source/thread.h | 9 +- source/tt.cpp | 548 ++++++++++-------- source/tt.h | 285 +++------ source/types.cpp | 33 +- source/usi.cpp | 196 +------ source/usi.h | 4 - 10 files changed, 820 insertions(+), 814 deletions(-) diff --git a/source/engine/yaneuraou-engine/yaneuraou-search.cpp b/source/engine/yaneuraou-engine/yaneuraou-search.cpp index 9a17e7e1b..4ed2ba697 100644 --- a/source/engine/yaneuraou-engine/yaneuraou-search.cpp +++ b/source/engine/yaneuraou-engine/yaneuraou-search.cpp @@ -446,7 +446,7 @@ void Search::clear() // ----------------------- // Time.availableNodes = 0; - TT.clear(); + Threads.main()->tt.clear(); Threads.clear(); // Tablebases::init(Options["SyzygyPath"]); // Free up mapped files @@ -611,7 +611,7 @@ void MainThread::search() // よってslaveが動く前であるこのタイミングで置換表の世代を進めるべきである。 // cf. Call TT.new_search() earlier. : https://github.com/official-stockfish/Stockfish/commit/ebc563059c5fc103ca6d79edb04bb6d5f182eaf5 - TT.new_search(); + tt.new_search(); // Stockfishでは評価関数の正常性のチェック、ここにあるが…。 // isreadyに対する応答でやっているのでここはコメントアウトしておく。 @@ -694,7 +694,8 @@ SKIP_SEARCH:; !Limits.silent ) - sync_cout << USI::pv(bestThread->rootPos, bestThread->completedDepth) << sync_endl; + + sync_cout << Search::pv(bestThread->rootPos, bestThread->tt, bestThread->completedDepth) << sync_endl; /* bestThreadがmainThreadではなくなる場合、探索した最大depthが減ることがありうる。 @@ -792,7 +793,7 @@ SKIP_SEARCH:; // pvにはbestmoveのときの読み筋(PV)が格納されているので、ponderとしてpv[1]があればそれを出力してやる。 // また、pv[1]がない場合(rootでfail highを起こしたなど)、置換表からひねり出してみる。 if (bestThread->rootMoves[0].pv.size() > 1 - || bestThread->rootMoves[0].extract_ponder_from_tt(rootPos, ponder_candidate)) + || bestThread->rootMoves[0].extract_ponder_from_tt(tt, rootPos)) std::cout << " ponder " << bestThread->rootMoves[0].pv[1]; std::cout << sync_endl; @@ -1120,7 +1121,7 @@ void Thread::search() { // 最後に出力した時刻を記録しておく。 mainThread->lastPvInfoTime = Time.elapsed(); - sync_cout << USI::pv(rootPos, rootDepth) << sync_endl; + sync_cout << Search::pv(rootPos, tt, rootDepth) << sync_endl; } // aspiration窓の範囲外 @@ -1185,7 +1186,7 @@ void Thread::search() ) { mainThread->lastPvInfoTime = Time.elapsed(); - sync_cout << USI::pv(rootPos, rootDepth) << sync_endl; + sync_cout << Search::pv(rootPos, tt, rootDepth) << sync_endl; } } // multi PV @@ -1409,17 +1410,13 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo //ASSERT_ALIGNED(&st, Eval::NNUE::CacheLineSize); - // TTのprobe()の返し値 - TTEntry* tte; - // このnodeのhash key HASH_KEY posKey; - // ttMove : 置換表の指し手 // move : MovePickerから1手ずつもらうときの一時変数 // excludedMove : singular extemsionのときに除外する指し手 // bestMove : このnodeのbest move - Move ttMove, move, excludedMove, bestMove; + Move move, excludedMove, bestMove; // extension : 延長する深さ // newDepth : 新しいdepth(残り探索深さ) @@ -1464,6 +1461,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // nodeの初期化 Thread* thisThread = pos.this_thread(); + auto& tt = thisThread->tt; ss->inCheck = pos.checkers(); priorCapture = pos.captured_piece(); Color us = pos.side_to_move(); @@ -1641,15 +1639,12 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo **/ posKey = pos.hash_key(); - tte = TT.probe(posKey, ss->ttHit); - - // 置換表上のスコア - // 置換表にhitしなければVALUE_NONE + auto [ttHit, ttData, ttWriter] = tt.probe(posKey, pos); - // singular searchとIIDとのスレッド競合を考慮して、ttValue , ttMoveの順で取り出さないといけないらしい。 - // cf. More robust interaction of singular search and iid : https://github.com/official-stockfish/Stockfish/commit/16b31bb249ccb9f4f625001f9772799d286e2f04 + // Need further processing of the saved data + // 保存されたデータのさらなる処理が必要です - ttValue = ss->ttHit ? value_from_tt(tte->value(), ss->ply /*, pos.rule50_count()*/) : VALUE_NONE; + ss->ttHit = ttHit; // 置換表の指し手 // 置換表にhitしなければMOVE_NONE @@ -1659,16 +1654,21 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // tte->move()にはMOVE_WINも含まれている可能性がある。 // この時、pos.to_move(MOVE_WIN) == MOVE_WINなので、ttMove == MOVE_WINとなる。 - ttMove = rootNode ? thisThread->rootMoves[thisThread->pvIdx].pv[0] - : ss->ttHit ? pos.to_move(tte->move()) - : MOVE_NONE; + ttData.move = rootNode ? thisThread->rootMoves[thisThread->pvIdx].pv[0] + : ttHit ? ttData.move + : MOVE_NONE; + + // 置換表上のスコア + // 置換表にhitしなければVALUE_NONE + + // singular searchとIIDとのスレッド競合を考慮して、ttValue , ttMoveの順で取り出さないといけないらしい。 + // cf. More robust interaction of singular search and iid : https://github.com/official-stockfish/Stockfish/commit/16b31bb249ccb9f4f625001f9772799d286e2f04 + + ttValue = ss->ttHit ? value_from_tt(ttData.value, ss->ply /*, pos.rule50_count()*/) : VALUE_NONE; - ASSERT_LV3(pos.legal_promote(ttMove)); + ASSERT_LV3(pos.legal_promote(ttData.move)); - // pos.to_move()でlegalityのcheckに引っかかったパターンなので置換表にhitしなかったことにする。 - // → TTのhash衝突で先手なのに後手の指し手を取ってきたパターンとかもある。 - if (tte->move().to_u16() && !ttMove) - ss->ttHit = false; + ss->ttPv = excludedMove ? ss->ttPv : PvNode || (ttHit && ttData.is_pv); // 置換表の指し手がcaptureであるか。 // 置換表の指し手がcaptureなら高い確率でこの指し手がベストなので、他の指し手を @@ -1680,30 +1680,29 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 歩の成り、香の成り、桂の成り ぐらいは調べても良さそうな…。 // → Stockfishでcapture_stage()になっているところはそれに倣うことにした。[2023/11/05] - ttCapture = ttMove && pos.capture_stage(ttMove); + ttCapture = ttData.move && pos.capture_stage(ttData.move); // At this point, if excluded, skip straight to step 6, static eval. However, // to save indentation, we list the condition in all code between here and there. - // この段階で、除外されている場合(excludedMoveがある場合)は、ステップ6の静的評価に直接スキップします。 - // しかし、インデントを節約するため、ここからそこまでのすべてのコードに条件を列挙します。 + // この時点で、除外された場合は、ステップ6の静的評価に直接進みます。 + // しかし、インデントを減らすために、ここからそこまでのコード内の条件をすべて記載しています。 + // ■ 補足 + // // 置換表にhitしなかった時は、PV nodeのときだけttPvとして扱う。 // これss->ttPVに保存してるけど、singularの判定等でsearchをss+1ではなくssで呼び出すことがあり、 // そのときにss->ttPvが破壊される。なので、破壊しそうなときは直前にローカル変数に保存するコードが書いてある。 - if (!excludedMove) - ss->ttPv = PvNode || (ss->ttHit && tte->is_pv()); - // At non-PV nodes we check for an early TT cutoff // 置換表の値による枝刈り - if ( !PvNode // PV nodeでは置換表の指し手では枝刈りしない(PV nodeはごくわずかしかないので..) + if ( !PvNode // PV nodeでは置換表の指し手では枝刈りしない(PV nodeはごくわずかしかないので..) && !excludedMove - && tte->depth() > depth // 置換表に登録されている探索深さのほうが深くて - && ttValue != VALUE_NONE // Possible in case of TT access race or if !ttHit - // (VALUE_NONEだとすると他スレッドからTTEntryが読みだす直前に破壊された可能性がある) - && (tte->bound() & (ttValue >= beta ? BOUND_LOWER : BOUND_UPPER))) + && ttData.depth > depth - (ttData.value <= beta) // 置換表に登録されている探索深さのほうが深くて + && ttData.value != VALUE_NONE // Possible in case of TT access race or if !ttHit + // (VALUE_NONEだとすると他スレッドからTTEntryが読みだす直前に破壊された可能性がある) + && (ttData.bound & (ttData.value >= beta ? BOUND_LOWER : BOUND_UPPER))) // ttValueが下界(真の評価値はこれより大きい)もしくはジャストな値で、かつttValue >= beta超えならbeta cutされる // ttValueが上界(真の評価値はこれより小さい)だが、tte->depth()のほうがdepthより深いということは、 // 今回の探索よりたくさん探索した結果のはずなので、今回よりは枝刈りが甘いはずだから、その値を信頼して @@ -1732,37 +1731,30 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // If ttMove is quiet, update move sorting heuristics on TT hit (~2 Elo) // 置換表にhitした時に、ttMoveがquietの指し手であるなら、指し手並び替えheuristics(quiet_statsのこと)を更新する。 - if (ttMove) + if (ttData.move && ttData.value >= beta) { - if (ttValue >= beta) - { - // Bonus for a quiet ttMove that fails high (~2 Elo) - // fail highしたquietなquietな(駒を取らない)ttMove(置換表の指し手)に対するボーナス + // Bonus for a quiet ttMove that fails high (~2 Elo) + // fail highしたquietなquietな(駒を取らない)ttMove(置換表の指し手)に対するボーナス - if (!ttCapture) - update_quiet_histories(pos, ss, ttMove, stat_bonus(depth)); + if (!ttCapture) + update_quiet_histories(pos, ss, ttData.move, stat_bonus(depth)); - // Extra penalty for early quiet moves of the previous ply (~0 Elo on STC, ~2 Elo on LTC) - // 1手前の早い時点のquietの指し手に対する追加のペナルティ + // Extra penalty for early quiet moves of the previous ply (~0 Elo on STC, ~2 Elo on LTC) + // 1手前の早い時点のquietの指し手に対する追加のペナルティ - // 1手前がMOVE_NULLであることを考慮する必要がある。 + // 1手前がMOVE_NULLであることを考慮する必要がある。 - if (prevSq != SQ_NONE && (ss - 1)->moveCount <= 2 && !priorCapture) - update_continuation_histories(ss - 1, pos.piece_on(prevSq), prevSq, -stat_malus(depth + 1)); + if (prevSq != SQ_NONE && (ss - 1)->moveCount <= 2 && !priorCapture) + update_continuation_histories(ss - 1, pos.piece_on(prevSq), prevSq, -stat_malus(depth + 1)); - } - // Penalty for a quiet ttMove that fails low (~1 Elo) - // fails lowのときのquiet ttMoveに対するペナルティ - else if (!ttCapture) - { - int penalty = -stat_malus(depth); - thisThread->mainHistory(us, from_to(ttMove)) << penalty; - update_continuation_histories(ss, pos.moved_piece_after(ttMove), to_sq(ttMove), penalty); - } } // Partial workaround for the graph history interaction problem // For high rule50 counts don't produce transposition table cutoffs. + + // グラフ履歴の相互作用問題に対する部分的な回避策 + // rule50カウントが高い場合は、トランスポジションテーブルによるカットオフを行いません。 + // ⇨ 将棋では関係のないルールなので無視して良いが、rule50_count < 90 が通常の状態なので、 // if成立時のreturnはしなければならない。 @@ -1796,7 +1788,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 置換表にhitしていないときは宣言勝ちの判定をまだやっていないということなので今回やる。 // PvNodeでは置換表の指し手を信用してはいけないので毎回やる。 - if (!ttMove || PvNode) + if (!ttHit || PvNode) { // 王手がかかってようがかかってまいが、宣言勝ちの判定は正しい。 // (トライルールのとき王手を回避しながら入玉することはありうるので) @@ -1809,8 +1801,8 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo ASSERT_LV3(pos.legal_promote(m)); if (is_ok(m)) - tte->save(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, - MAX_PLY, m, ss->staticEval); + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, + MAX_PLY, m, ss->staticEval, tt.generation()); // [2023/10/17] // → MOVE_WINの値は、置換表に書き出さないほうがいいと思う。 @@ -1865,8 +1857,8 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // staticEvalの代わりに詰みのスコア書いてもいいのでは.. ASSERT_LV3(pos.legal_promote(move)); - tte->save(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, - std::min(MAX_PLY - 1, depth + 6), move, /* ss->staticEval */ bestValue); + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, + std::min(MAX_PLY - 1, depth + 6), move, /* ss->staticEval */ bestValue, tt.generation()); // ■ 【計測資料 39.】 mate1plyの指し手を見つけた時に置換表の指し手でbeta cutする時と同じ処理をする。 @@ -1903,8 +1895,8 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo bestValue = mate_in(ss->ply + PARAM_WEAK_MATE_PLY); ASSERT_LV3(pos.legal_promote(move)); - tte->save(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, - std::min(MAX_PLY - 1, depth + 8), move, /* ss->staticEval */ bestValue); + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, BOUND_EXACT, + std::min(MAX_PLY - 1, depth + 8), move, /* ss->staticEval */ bestValue, tt.generation()); return bestValue; } @@ -1949,7 +1941,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // Never assume anything about values stored in TT // TTに格納されている値に関して何も仮定はしない - ss->staticEval = eval = tte->eval(); + ss->staticEval = eval = ttData.eval; // 置換表にhitしたなら、評価値が記録されているはずだから、それを取り出しておく。 // あとで置換表に書き込むときにこの値を使えるし、各種枝刈りはこの評価値をベースに行なうから。 @@ -1971,9 +1963,9 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 2. ttValue < evaluate()でかつ、ttValueがBOUND_UPPERなら、真の値はこれより小さいはずだから、 // evalとしてttValueを採用したほうがこの局面に対する評価値の見積りとして適切である。 - if ( ttValue != VALUE_NONE - && (tte->bound() & (ttValue > eval ? BOUND_LOWER : BOUND_UPPER))) - eval = ttValue; + if ( ttData.value != VALUE_NONE + && (ttData.bound & (ttData.value > eval ? BOUND_LOWER : BOUND_UPPER))) + eval = ttData.value; } else @@ -1991,7 +1983,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // cf . Add / remove leaves from search tree ttPv : https://github.com/official-stockfish/Stockfish/commit/c02b3a4c7a339d212d5c6f75b3b89c926d33a800 // 上の方にある else if (excludedMove) でこの条件は除外されている。 - tte->save(posKey, VALUE_NONE, ss->ttPv, BOUND_NONE, DEPTH_UNSEARCHED, MOVE_NONE, eval); + ttWriter.write(posKey, VALUE_NONE, ss->ttPv, BOUND_NONE, DEPTH_UNSEARCHED, MOVE_NONE, eval, tt.generation()); // どうせ毎node評価関数を呼び出すので、evalの値にそんなに価値はないのだが、mate1ply()を // 実行したという証にはなるので意味がある。 @@ -2090,7 +2082,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo - (ss - 1)->statScore / 321 >= beta && eval >= beta && eval < 29462 // smaller than TB wins - && (!ttMove || ttCapture)) + && (!ttData.move || ttCapture)) // 29462の根拠はよくわからないが、VALUE_TB_WIN_IN_MAX_PLY より少し小さい値にしたいようだ。 // そこまではfutility pruningで枝刈りして良いと言うことなのだろう。 @@ -2179,11 +2171,15 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo } // ----------------------- - // Step 10. If the position doesn't have a ttMove, decrease depth by 2 - // (or by 4 if the TT entry for the current position was hit and the stored depth is greater than or equal to the current depth). - // Use qsearch if depth is equal or below zero (~9 Elo) + // Step 10. Internal iterative reductions (~9 Elo) + // For PV nodes without a ttMove, we decrease depth. + // + // Step 10. 内部反復リダクション(約9 Elo) + // ttMoveのないPVノードでは、深さを減少させます。 // ----------------------- + // ■ 補足 + // // この局面にttMoveがない場合、深さを2減少させます // (または、現在の位置のTTエントリがヒットし、保存されている深さが現在の深さ以上の場合は4減少させます) // 深さがゼロ以下の場合はqsearchを使用します。 @@ -2193,8 +2189,8 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 探索が完了しているほうが助かるため) if ( PvNode - && !ttMove) - depth -= 2 + 2 * (ss->ttHit && tte->depth() >= depth); + && !ttData.move) + depth -= 2 + 2 * (ss->ttHit && ttData.depth >= depth); if (depth <= 0) return qsearch(pos, ss, alpha, beta); @@ -2203,7 +2199,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo if ( cutNode && depth >= 8 - && !ttMove) + && !ttData.move) depth -= 2; // probCutに使うbeta値。 @@ -2233,13 +2229,13 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // なぜなら、probCut searchはdepth - 4に設定されていますが、我々はその前に指すので、 // 実効的な深さはdepth - 3と同じになるからです。 - && !( tte->depth() >= depth - 3 - && ttValue != VALUE_NONE - && ttValue < probCutBeta)) + && !( ttData.depth >= depth - 3 + && ttData.value != VALUE_NONE + && ttData.value < probCutBeta)) { ASSERT_LV3(probCutBeta < VALUE_INFINITE && probCutBeta > beta); - MovePicker mp(pos, ttMove, probCutBeta - ss->staticEval, &captureHistory); + MovePicker mp(pos, ttData.move, probCutBeta - ss->staticEval, &captureHistory); // 試行回数は2回(cutNodeなら4回)までとする。(よさげな指し手を3つ試して駄目なら駄目という扱い) // cf. Do move-count pruning in probcut : https://github.com/official-stockfish/Stockfish/commit/b87308692a434d6725da72bbbb38a38d3cac1d5f @@ -2287,7 +2283,9 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // Save ProbCut data into transposition table // ProbCutのdataを置換表に保存する。 - tte->save(posKey, value_to_tt(value, ss->ply), ss->ttPv, BOUND_LOWER, depth - 3, move, ss->staticEval); + ttWriter.write(posKey, value_to_tt(value, ss->ply), + ss->ttPv, BOUND_LOWER, depth - 3, move, ss->staticEval,tt.generation()); + return std::abs(value) < VALUE_TB_WIN_IN_MAX_PLY ? value - (probCutBeta - beta) : value; } @@ -2318,10 +2316,10 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo if ( ss->inCheck && !PvNode && ttCapture - && (tte->bound() & BOUND_LOWER) - && tte->depth() >= depth - 4 - && ttValue >= probCutBeta - && std::abs(ttValue) < VALUE_TB_WIN_IN_MAX_PLY + && (ttData.bound & BOUND_LOWER) + && ttData.depth >= depth - 4 + && ttData.value >= probCutBeta + && std::abs(ttData.value) < VALUE_TB_WIN_IN_MAX_PLY && std::abs(beta) < VALUE_TB_WIN_IN_MAX_PLY ) return probCutBeta; @@ -2337,7 +2335,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo (ss - 3)->continuationHistory , (ss - 4)->continuationHistory , nullptr , (ss - 6)->continuationHistory }; - MovePicker mp(pos, ttMove, depth, &thisThread->mainHistory, &thisThread->lowPlyHistory, + MovePicker mp(pos, ttData.move, depth, &thisThread->mainHistory, &thisThread->lowPlyHistory, &thisThread->captureHistory, contHist, #if defined(ENABLE_PAWN_HISTORY) &thisThread->pawnHistory, @@ -2349,20 +2347,6 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo moveCountPruning = false; - // Indicate PvNodes that will probably fail low if the node was searched - // at a depth equal to or greater than the current depth, and the result - // of this search was a fail low. - - // 現在の深さと同じかそれ以上の深さでノードが探索され、 - // その探索の結果がfail lowだった場合、おそらくfail lowになるであろうPvNodesを示す。 - - // ノードが現在のdepth以上で探索され、fail lowである時に、PvNodeがfail lowしそうであるかを示すフラグ。 - bool likelyFailLow = PvNode - && ttMove - && (tte->bound() & BOUND_UPPER) - && tte->depth() >= depth; - - // ----------------------- // Step 13. Loop through all pseudo-legal moves until no moves remain // or a beta cutoff occurs. @@ -2551,8 +2535,9 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // ----------------------- // We take care to not overdo to avoid search getting stuck. - // 探索がstuckしないように、やりすぎに気をつける。 - // (rootDepthの2倍より延長しない) + // 探索が停滞しないように、やりすぎないよう注意します。 + // + // ⇨ rootDepthの2倍より延長しない if (ss->ply < thisThread->rootDepth * 2) { @@ -2587,13 +2572,13 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // singular延長をするnodeであるか。 if (!rootNode - && move == ttMove + && move == ttData.move && !excludedMove // 再帰的なsingular延長を除外する。 && depth >= PARAM_SINGULAR_EXTENSION_DEPTH - (thisThread->completedDepth > 24) + ss->ttPv /* && ttValue != VALUE_NONE Already implicit in the next condition */ - && std::abs(ttValue) < VALUE_TB_WIN_IN_MAX_PLY // 詰み絡みのスコアはsingular extensionはしない。(Stockfish 10~) - && (tte->bound() & BOUND_LOWER) - && tte->depth() >= depth - 3) + && std::abs(ttData.value) < VALUE_TB_WIN_IN_MAX_PLY // 詰み絡みのスコアはsingular extensionはしない。(Stockfish 10~) + && (ttData.bound & BOUND_LOWER) + && ttData.depth >= depth - 3) // このnodeについてある程度調べたことが置換表によって証明されている。(ttMove == moveなのでttMove != MOVE_NONE) // (そうでないとsingularの指し手以外に他の有望な指し手がないかどうかを調べるために // null window searchするときに大きなコストを伴いかねないから。) @@ -2752,15 +2737,22 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo pos.do_move(move, st, givesCheck); // These reduction adjustments have proven non-linear scaling. - // They are optimized to time controls of 180 + 1.8 and longer so - // changing them or adding conditions that are similar - // requires tests at these types of time controls. + // They are optimized to time controls of 180 + 1.8 and longer, + // so changing them or adding conditions that are similar requires + // tests at these types of time controls. + + // これらのリダクション調整は、非線形スケーリングであることが証明されています。 + // 調整は180 + 1.8およびそれ以上の持ち時間に最適化されています。 + // したがって、これらを変更したり、似た条件を追加する場合には、 + // 同じ種類の持ち時間でテストが必要です。 + + // Decrease reduction if position is or has been on the PV (~7 Elo) + // 局面がPVにあるか、あった場合、リダクションを減少させます(約7 Elo) - // Decrease reduction if position is or has been on the PV (~5 Elo) // この局面がPV上にあり、fail lowしそうであるならreductionを減らす // (fail lowしてしまうとまた探索をやりなおさないといけないので) if (ss->ttPv) - r -= 1 + (ttValue > alpha) + (ttValue > beta && tte->depth() >= depth); + r -= 1 + (ttData.value > alpha) + (ttData.value > beta && ttData.depth >= depth); // Decrease reduction for PvNodes (~0 Elo on STC, ~2 Elo on LTC) if (PvNode) @@ -2776,7 +2768,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 【計測資料 18.】cut nodeのときにreductionを増やすかどうか。 if (cutNode) - r += 2 - (tte->depth() >= depth && ss->ttPv); + r += 2 - (ttData.depth >= depth && ss->ttPv); // Increase reduction if ttMove is a capture (~3 Elo) // 【計測資料 3.】置換表の指し手がcaptureのときにreduction量を増やす。 @@ -2794,7 +2786,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 最初に生成された手(ttMove)のreductionを減らす。 // ただし、0以下になることは許さない。 // - else if (move == ttMove) + else if (move == ttData.move) r = std::max(0, r - 2); // 【計測資料 11.】statScoreの計算でcontHist[3]も調べるかどうか。 @@ -2873,7 +2865,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // Increase reduction if ttMove is not present (~1 Elo) // ttMoveが存在しないならreductionを増やす。 - if (!ttMove) + if (!ttData.move) r += 2; // Note that if expected reduction is high, we reduce search depth by 1 here (~9 Elo) @@ -3021,7 +3013,7 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo if (value >= beta) { - ss->cutoffCnt += 1 + !ttMove; + ss->cutoffCnt += 1 + !ttData.move; ASSERT_LV3(value >= beta); // Fail high // value >= beta なら fail high(beta cut) @@ -3167,9 +3159,14 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo // 置換表に保存する // ----------------------- - // Write gathered information in transposition table - // 集めた情報を置換表に書き込む + // Write gathered information in transposition table. Note that the + // static evaluation is saved as it was before correction history. + + // 収集した情報をトランスポジションテーブルに書き込みます。 + // 静的評価は、修正履歴が適用される前の状態で保存されることに注意してください。 + // ■ 補足 + // // betaを超えているということはbeta cutされるわけで残りの指し手を調べていないから真の値はまだ大きいと考えられる。 // すなわち、このとき値は下界と考えられるから、BOUND_LOWER。 // さもなくば、(PvNodeなら)枝刈りはしていないので、これが正確な値であるはずだから、BOUND_EXACTを返す。 @@ -3180,11 +3177,11 @@ Value search(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth, boo if (!excludedMove && !(rootNode && thisThread->pvIdx)) { ASSERT_LV3(pos.legal_promote(bestMove)); - tte->save(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), ss->ttPv, bestValue >= beta ? BOUND_LOWER : PvNode && bestMove ? BOUND_EXACT : BOUND_UPPER, - depth, bestMove, ss->staticEval); + depth, bestMove, ss->staticEval, tt.generation()); } // qsearch()内の末尾にあるassertの文の説明を読むこと。 @@ -3266,23 +3263,18 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) //ASSERT_ALIGNED(&st, Eval::NNUE::CacheLineSize); - // 置換表にhitしたときの置換表のエントリーへのポインタ - TTEntry* tte; - // この局面のhash key HASH_KEY posKey; - // ttMove : 置換表に登録されていた指し手 // move : MovePickerからもらった現在の指し手 // bestMove : この局面でのベストな指し手 - Move ttMove, move, bestMove; + Move move, bestMove; // bestValue : best moveに対する探索スコア(alphaとは異なる) // value : 現在のmoveに対する探索スコア - // ttValue : 置換表に登録されていたスコア // futilityValue : futility pruningに用いるスコア // futilityBase : futility pruningの基準となる値 - Value bestValue, value, ttValue, futilityValue, futilityBase; + Value bestValue, value, futilityValue, futilityBase; // pvHit : 置換表から取り出した指し手が、PV nodeでsaveされたものであった。 // givesCheck : MovePickerから取り出した指し手で王手になるか @@ -3308,6 +3300,7 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) } Thread* thisThread = pos.this_thread(); + auto& tt = thisThread->tt; bestMove = MOVE_NONE; ss->inCheck = pos.checkers(); moveCount = 0; @@ -3377,28 +3370,28 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) // 置換表のlookup posKey = pos.hash_key(); - tte = TT.probe(posKey, ss->ttHit); - ttValue = ss->ttHit ? value_from_tt(tte->value(), ss->ply /* pos.rule50_count() */) : VALUE_NONE; - ttMove = ss->ttHit ? pos.to_move(tte->move()) : MOVE_NONE; + auto [ttHit, ttData, ttWriter] = tt.probe(posKey, pos); + + // Need further processing of the saved data + // 保存されたデータのさらなる処理が必要です - // ⇑ここ、tte->move()はMove16なので、やねうら王ではpos.to_move()でMoveに変換する必要があることに注意。 - // pos.to_move()でlegalityのcheckに引っかかったパターンなので置換表にhitしなかったことにする。 - if (tte->move().to_u16() && !ttMove) - ss->ttHit = false; + ss->ttHit = ttHit; - pvHit = ss->ttHit && tte->is_pv(); + ttData.move = ttHit ? ttData.move : MOVE_NONE; + ttData.value = ttHit ? value_from_tt(ttData.value, ss->ply /* pos.rule50_count() */) : VALUE_NONE; + pvHit = ttHit && ttData.is_pv; - ASSERT_LV3(pos.legal_promote(ttMove)); + ASSERT_LV3(pos.legal_promote(ttData.move)); // At non-PV nodes we check for an early TT cutoff // nonPVでは置換表の指し手で枝刈りする // PVでは置換表の指し手では枝刈りしない(前回evaluateした値は使える) if ( !PvNode - && tte->depth() >= DEPTH_QS - && ttValue != VALUE_NONE // Only in case of TT access race or if !ttHit + && ttData.depth >= DEPTH_QS + && ttData.value != VALUE_NONE // Only in case of TT access race or if !ttHit // ↑置換表から取り出したときに他スレッドが値を潰している可能性があるのでこのチェックが必要 - && (tte->bound() & (ttValue >= beta ? BOUND_LOWER : BOUND_UPPER))) + && (ttData.bound & (ttData.value >= beta ? BOUND_LOWER : BOUND_UPPER))) // ↑ここは、↓この意味。 @@ -3410,7 +3403,7 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) // 今回の探索よりたくさん探索した結果のはずなので、今回よりは枝刈りが甘いはずだから、その値を信頼して // このままこの値でreturnして良い。 - return ttValue; + return ttData.value; // ----------------------- // eval呼び出し @@ -3474,24 +3467,27 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) // 置換表に評価値が格納されているとは限らないのでその場合は評価関数の呼び出しが必要 // bestValueの初期値としてこの局面のevaluate()の値を使う。これを上回る指し手があるはずなのだが.. - if ((ss->staticEval = bestValue = tte->eval()) == VALUE_NONE) + if ((ss->staticEval = bestValue = ttData.eval) == VALUE_NONE) ss->staticEval = bestValue = evaluate(pos); // 毎回evaluate()を呼ぶならtte->eval()自体不要なのだが、 // 置換表の指し手でこのまま枝刈りできるケースがあるから難しい。 // 評価関数がKPPTより軽ければ、tte->eval()をなくしても良いぐらいなのだが…。 - // ttValue can be used as a better position evaluation (~13 Elo) + // ttValue can be used as a better position evaluation (~7 Elo) + // ttValueは、より良い局面評価として使用できます(約7 Elo) + // ■ 備考 + // // 置換表に格納されていたスコアは、この局面で今回探索するものと同等か少しだけ劣るぐらいの // 精度で探索されたものであるなら、それをbestValueの初期値として使う。 - + // // ただし、mate valueは変更しない方が良いので、abs(ttValue) < VALUE_TB_WIN_IN_MAX_PLY は、 // そのための条件。 - if (std::abs(ttValue) < VALUE_TB_WIN_IN_MAX_PLY - && (tte->bound() & (ttValue > bestValue ? BOUND_LOWER : BOUND_UPPER))) - bestValue = ttValue; + if (ttData.value != VALUE_NONE + && (ttData.bound & (ttData.value > bestValue ? BOUND_LOWER : BOUND_UPPER))) + bestValue = ttData.value; } else { @@ -3525,13 +3521,18 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) } // Stand pat. Return immediately if static value is at least beta + // Stand pat。静的評価値が少なくともベータ値に達している場合は直ちに返します + + // ■ 補足 + // // 現在のbestValueは、この局面で何も指さないときのスコア。recaptureすると損をする変化もあるのでこのスコアを基準に考える。 // 王手がかかっていないケースにおいては、この時点での静的なevalの値がbetaを上回りそうならこの時点で帰る。 + if (bestValue >= beta) { if (!ss->ttHit) - tte->save(posKey, value_to_tt(bestValue, ss->ply), false, BOUND_LOWER, - DEPTH_UNSEARCHED, MOVE_NONE, ss->staticEval); + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), false, BOUND_LOWER, + DEPTH_UNSEARCHED, MOVE_NONE, ss->staticEval, tt.generation()); return bestValue; } @@ -3564,7 +3565,7 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) // 現在の局面に対してMovePickerオブジェクトを初期化し、手を探索する準備を行います。 // 現在、静止探索では2段階の手生成器を使用しています:キャプチャ、またはチェックされた場合の回避のみです。 - MovePicker mp(pos, ttMove, DEPTH_QS, &thisThread->mainHistory, &thisThread->lowPlyHistory, + MovePicker mp(pos, ttData.move, DEPTH_QS, &thisThread->mainHistory, &thisThread->lowPlyHistory, &thisThread->captureHistory, contHist #if defined(ENABLE_PAWN_HISTORY) ,&thisThread->pawnHistory @@ -3808,13 +3809,21 @@ Value qsearch(Position& pos, Stack* ss, Value alpha, Value beta, Depth depth) if (std::abs(bestValue) < VALUE_TB_WIN_IN_MAX_PLY && bestValue >= beta) bestValue = (3 * bestValue + beta) / 4; - // Save gathered info in transposition table + // Save gathered info in transposition table. The static evaluation + // is saved as it was before adjustment by correction history. + + // 収集した情報をトランスポジションテーブルに保存します。 + // 静的評価は、修正履歴による調整が行われる前の状態で保存されます。 + + // ■ 補足 + // // 詰みではなかったのでこれを書き出す。 // ※ qsearch()の結果は信用ならないのでBOUND_EXACTで書き出すことはない。 + ASSERT_LV3(pos.legal_promote(bestMove)); - tte->save(posKey, value_to_tt(bestValue, ss->ply), pvHit, - bestValue >= beta ? BOUND_LOWER : BOUND_UPPER, - DEPTH_QS, bestMove, ss->staticEval); + ttWriter.write(posKey, value_to_tt(bestValue, ss->ply), pvHit, + bestValue >= beta ? BOUND_LOWER : BOUND_UPPER, DEPTH_QS, bestMove, + ss->staticEval, tt.generation()); // 置換表には abs(value) < VALUE_INFINITEの値しか書き込まないし、この関数もこの範囲の値しか返さない。 // しかし置換表が衝突した場合はそうではない。3手詰めの局面で、置換表衝突により1手詰めのスコアが @@ -4344,7 +4353,198 @@ void init_param() #endif } -// --- 学習時に用いる、depth固定探索などの関数を外部に対して公開 +// -------------------- +// 読み筋の出力 +// -------------------- + +namespace Search { + + // depth : iteration深さ + std::string pv(const Position& pos, const TranspositionTable& tt, Depth depth) + { + std::stringstream ss; + + TimePoint elapsed = Time.elapsed() + 1; +#if defined(__EMSCRIPTEN__) + // yaneuraou.wasm + // Time.elapsed()が-1を返すことがある + // https://github.com/lichess-org/stockfish.wasm/issues/5 + // https://github.com/lichess-org/stockfish.wasm/commit/4f591186650ab9729705dc01dec1b2d099cd5e29 + elapsed = std::max(elapsed, TimePoint(1)); +#endif + const auto& rootMoves = pos.this_thread()->rootMoves; + size_t pvIdx = pos.this_thread()->pvIdx; + size_t multiPV = std::min(size_t(Options["MultiPV"]), rootMoves.size()); + + uint64_t nodes_searched = Threads.nodes_searched(); + + // MultiPVでは上位N個の候補手と読み筋を出力する必要がある。 + for (size_t i = 0; i < multiPV; ++i) + { + // この指し手のpvの更新が終わっているのか + bool updated = rootMoves[i].score != -VALUE_INFINITE; + + if (depth == 1 && !updated && i > 0) + continue; + + // 1より小さな探索depthで出力しない。 + Depth d = updated ? depth : std::max(1, depth - 1); + Value v = updated ? rootMoves[i].usiScore : rootMoves[i].previousScore; + + // multi pv時、例えば3個目の候補手までしか評価が終わっていなくて(PVIdx==2)、このとき、 + // 3,4,5個目にあるのは前回のiterationまでずっと評価されていなかった指し手であるような場合に、 + // これらのpreviousScoreが-VALUE_INFINITE(未初期化状態)でありうる。 + // (multi pv状態で"go infinite"~"stop"を繰り返すとこの現象が発生する。おそらく置換表にhitしまくる結果ではないかと思う。) + if (v == -VALUE_INFINITE) + v = VALUE_ZERO; // この場合でもとりあえず出力は行う。 + + //bool tb = TB::RootInTB && abs(v) < VALUE_MATE_IN_MAX_PLY; + //v = tb ? rootMoves[i].tbScore : v; + + if (ss.rdbuf()->in_avail()) // 1行目でないなら連結のための改行を出力 + ss << std::endl; + + ss << "info" + << " depth " << d + << " seldepth " << rootMoves[i].selDepth +#if defined(USE_PIECE_VALUE) + << " score " << USI::value(v) +#endif + ; + + // これが現在探索中の指し手であるなら、それがlowerboundかupperboundかは表示させる + if (i == pvIdx && /*!tb &&*/ updated) // tablebase- and previous-scores are exact + ss << (rootMoves[i].scoreLowerbound ? " lowerbound" : (rootMoves[i].scoreUpperbound ? " upperbound" : "")); + + // 将棋所はmultipvに対応していないが、とりあえず出力はしておく。 + if (multiPV > 1) + ss << " multipv " << (i + 1); + + ss << " nodes " << nodes_searched + << " nps " << nodes_searched * 1000 / elapsed + << " hashfull " << tt.hashfull() + << " time " << elapsed + << " pv"; + + + // PV配列からPVを出力する。 + // ※ USIの"info"で読み筋を出力するときは"pv"サブコマンドはサブコマンドの一番最後にしなければならない。 + + auto out_array_pv = [&]() + { + for (Move m : rootMoves[i].pv) + ss << " " << m; + }; + + // 置換表からPVをかき集めてきてPVを出力する。 + auto out_tt_pv = [&]() + { + auto pos_ = const_cast(&pos); + Move moves[MAX_PLY + 1]; + StateInfo si[MAX_PLY]; + int ply = 0; + + while (ply < MAX_PLY) + { + // 千日手はそこで終了。ただし初手はPVを出力。 + // 千日手がベストのとき、置換表を更新していないので + // 置換表上はMOVE_NONEがベストの指し手になっている可能性があるので早めに検出する。 + auto rep = pos.is_repetition(ply); + if (rep != REPETITION_NONE && ply >= 1) + { + // 千日手でPVを打ち切るときはその旨を表示 + ss << " " << rep; + break; + } + + Move m; + + // まず、rootMoves.pvを辿れるところまで辿る。 + // rootMoves[i].pv[0]は宣言勝ちの指し手(MOVE_WIN)の可能性があるので注意。 + if (ply < int(rootMoves[i].pv.size())) + m = rootMoves[i].pv[ply]; + else + { + // 次の手を置換表から拾う。 + // ただし置換表を破壊されるとbenchコマンドの時にシングルスレッドなのに探索内容の同一性が保証されなくて + // 困るのでread_probe()を用いる。 + + auto [ttHit, ttData, ttWriter] = tt.probe(pos.key(), pos); + // 置換表になかった + if (!ttHit) + break; + + m = ttData.move; + + // leaf nodeはわりと高い確率でMOVE_NONE + if (m == MOVE_NONE) + break; + + // 置換表にはpsudo_legalではない指し手が含まれるのでそれを弾く。 + // 宣言勝ちでないならこれが合法手であるかのチェックが必要。 + if (m != MOVE_WIN) + { + // 歩の不成が読み筋に含まれていようともそれは表示できなくてはならないので + // pseudo_legal_s()を用いて判定。 + if (!(pos.pseudo_legal_s(m) && pos.legal(m))) + break; + } + } + +#if defined (USE_ENTERING_KING_WIN) + // 宣言勝ちである + if (m == MOVE_WIN) + { + // これが合法手であるなら宣言勝ちであると出力。 + if (pos.DeclarationWin() != MOVE_NONE) + ss << " " << MOVE_WIN; + + break; + } +#endif + // leaf node末尾にMOVE_RESIGNがあることはないが、 + // 詰み局面で呼び出されると1手先がmove resignなので、これでdo_move()するのは + // 非合法だから、do_move()せずにループを抜ける。 + if (!is_ok(m)) + { + ss << " " << m; + break; + } + + moves[ply] = m; + ss << " " << m; + + // 注) + // このdo_moveで Position::nodesが加算されるので探索ノード数に影響が出る。 + // benchコマンドで探索ノード数が一致しない場合、これが原因。 + // → benchコマンドでは、ConsiderationMode = falseにすることで + //  PV表示のためにdo_move()を呼び出さないようにした。 + + pos_->do_move(m, si[ply]); + ++ply; + } + while (ply > 0) + pos_->undo_move(moves[--ply]); + }; + + // 検討用のPVを出力するモードなら、置換表からPVをかき集める。 + // (そうしないとMultiPV時にPVが欠損することがあるようだ) + // fail-highのときにもPVを更新しているのが問題ではなさそう。 + // Stockfish側の何らかのバグかも。 + if (Search::Limits.consideration_mode) + out_tt_pv(); + else + out_array_pv(); + } + + return ss.str(); + } +} + +// ============================================================ +// 学習時に用いる、depth固定探索などの関数を外部に対して公開 +// ============================================================ + #if defined (EVAL_LEARN) diff --git a/source/position.cpp b/source/position.cpp index 0c351b909..3bdb9c476 100644 --- a/source/position.cpp +++ b/source/position.cpp @@ -1288,7 +1288,7 @@ void Position::do_move_impl(Move m, StateInfo& new_st, bool givesCheck) // なるべく早い段階でのTTに対するprefetch // 駒打ちのときはこの時点でTT entryのアドレスが確定できる const HASH_KEY key = k + h; - prefetch(TT.first_entry(key)); + prefetch(thisThread->tt.first_entry(key)); #if defined(USE_EVAL_HASH) Eval::prefetch_evalhash(hash_key_to_key(key)); #endif @@ -1471,7 +1471,7 @@ void Position::do_move_impl(Move m, StateInfo& new_st, bool givesCheck) // 駒打ちでないときはprefetchはこの時点まで延期される。 const HASH_KEY key = k + h; - prefetch(TT.first_entry(key)); + prefetch(thisThread->tt.first_entry(key)); #if defined(USE_EVAL_HASH) Eval::prefetch_evalhash(hash_key_to_key(key)); #endif @@ -1897,7 +1897,7 @@ void Position::do_null_move(StateInfo& newSt) { // CPUによっては有効なので一応やっておく。 const HASH_KEY key = st->hash_key(); - prefetch(TT.first_entry(key)); + prefetch(thisThread->tt.first_entry(key)); // これは、さっきアクセスしたところのはずなので意味がない。 // Eval::prefetch_evalhash(key); diff --git a/source/search.h b/source/search.h index a008f01f9..d442bdd85 100644 --- a/source/search.h +++ b/source/search.h @@ -71,11 +71,17 @@ struct RootMove // pv[0]には、このコンストラクタの引数で渡されたmを設定する。 explicit RootMove(Move m) : pv(1, m) {} - // ponderの指し手がないときにponderの指し手を置換表からひねり出す。pv[1]に格納する。 - // ponder_candidateが2手目の局面で合法手なら、それをpv[1]に格納する。 - // それすらなかった場合はfalseを返す。 - // ※ Stockfishにはこの関数に第二引数はない。やねうら王が独自に追加した。 - bool extract_ponder_from_tt(Position& pos, Move ponder_candidate); + // Called in case we have no ponder move before exiting the search, + // for instance, in case we stop the search during a fail high at root. + // We try hard to have a ponder move to return to the GUI, + // otherwise in case of 'ponder on' we have nothing to think about. + + // 探索を終了する前にponder moveがない場合に呼び出されます。 + // 例えば、rootでfail highが発生して探索を中断した場合などです。 + // GUIに返すponder moveをできる限り準備しようとしますが、 + // そうでない場合、「ponder on」の際に考えるべきものが何もなくなります。 + + bool extract_ponder_from_tt(const TranspositionTable& tt, Position& pos); // std::count(),std::find()などで指し手と比較するときに必要。 bool operator==(const Move& m) const { return pv[0] == m; } @@ -258,6 +264,13 @@ void init(); // 置換表のクリアなど時間のかかる探索の初期化処理をここでやる。isreadyに対して呼び出される。 void clear(); +// pv(読み筋)をUSIプロトコルに基いて出力する。 +// pos : 局面 +// tt : このスレッドに属する置換表 +// depth : 反復深化のiteration深さ。 +std::string pv(const Position& pos, const TranspositionTable& tt, Depth depth); + + } // end of namespace Search #endif // _SEARCH_H_INCLUDED_ diff --git a/source/thread.cpp b/source/thread.cpp index afa792dae..38f6bb37c 100644 --- a/source/thread.cpp +++ b/source/thread.cpp @@ -3,10 +3,14 @@ #include "thread.h" #include "usi.h" +#include "tt.h" ThreadPool Threads; // Global object -Thread::Thread(size_t n) : idx(n) , stdThread(&Thread::idle_loop, this) +// global TTここに置いておく。あとで修正する。 +TranspositionTable TT; + +Thread::Thread(TranspositionTable& tt, size_t n) : tt(tt), idx(n) , stdThread(&Thread::idle_loop, this) { #if !defined(__EMSCRIPTEN__) // スレッドはsearching == trueで開始するので、このままworkerのほう待機状態にさせておく @@ -159,10 +163,10 @@ void ThreadPool::set(size_t requested) if (requested > 0) { // 要求された数だけのスレッドを生成 #if !defined(__EMSCRIPTEN__) - threads.push_back(new MainThread(0)); + threads.push_back(new MainThread(TT, 0)); while (size() < requested) - threads.push_back(new Thread(size())); + threads.push_back(new Thread(TT, size())); #else // yaneuraou.wasm while (size() < requested) @@ -179,11 +183,13 @@ void ThreadPool::set(size_t requested) //TT.resize(size_t(Options["USI_Hash"])); } -#if defined(EVAL_LEARN) - // 学習用の実行ファイルでは、スレッド数が変更になったときに各ThreadごとのTTに - // メモリを再割り当てする必要がある。 - TT.init_tt_per_thread(); -#endif +//#if defined(EVAL_LEARN) +// // 学習用の実行ファイルでは、スレッド数が変更になったときに各ThreadごとのTTに +// // メモリを再割り当てする必要がある。 +// TT.init_tt_per_thread(); +//#endif + + // TODO : LEARN版のTTの初期化と確保のコード、あとで書く。 } diff --git a/source/thread.h b/source/thread.h index 1d0f0d895..a5fdb57c3 100644 --- a/source/thread.h +++ b/source/thread.h @@ -94,7 +94,7 @@ class Thread public: // ThreadPoolで何番目のthreadであるかをコンストラクタで渡すこと。この値は、idx(スレッドID)となる。 - explicit Thread(size_t n); + explicit Thread(TranspositionTable& tt , size_t n); virtual ~Thread(); // slaveは、main threadから @@ -215,10 +215,9 @@ class Thread // MainThreadなら0、slaveなら1,2,3,... size_t thread_id() const { return idx; } -#if defined(EVAL_LEARN) - // 学習用の実行ファイルでは、スレッドごとに置換表を持ちたい。 - TranspositionTable tt; -#endif + // 置換表への参照。 + // ※ 学習用の実行ファイルでは、スレッドごとに置換表を持ちたい。 + TranspositionTable& tt; }; diff --git a/source/tt.cpp b/source/tt.cpp index 9a6dd83b1..f1642c32e 100644 --- a/source/tt.cpp +++ b/source/tt.cpp @@ -14,73 +14,174 @@ // やねうら王独自拡張 #include "extra/key128.h" -TranspositionTable TT; // 置換表をglobalに確保。 +// ============================================================ +// 置換表エントリー +// ============================================================ -// 置換表のエントリーに対して与えられたデータを保存する。上書き動作 +// 本エントリーは10bytesに収まるようになっている。3つのエントリーを並べたときに32bytesに収まるので +// CPUのcache lineに一発で載るというミラクル。 +/// ※ cache line sizeは、IntelだとPentium4やPentiumMからでPentiumⅢ(3)までは32byte。 +/// そこ以降64byte。AMDだとK8のときには既に64byte。 + +// TTEntry struct is the 10 bytes transposition table entry, defined as below: +// +// key 16 bit +// depth 8 bit +// generation 5 bit +// pv node 1 bit +// bound type 2 bit +// move 16 bit +// value 16 bit +// evaluation 16 bit +// +// These fields are in the same order as accessed by TT::probe(), since memory is fastest sequentially. +// Equally, the store order in save() matches this order. + +// TTEntry 構造体は以下のように定義された10バイトのトランスポジションテーブルエントリです: +// +// key 16 bit +// depth 8 bit +// generation 5 bit +// pv node 1 bit +// bound type 2 bit +// move 16 bit +// value 16 bit +// evaluation 16 bit +// +// これらのフィールドは、メモリが順次アクセスされるときに最も高速であるため、 +// TT::probe()によってアクセスされる順序と同じ順序で配置されています。 +// 同様に、save()内の保存順序もこの順序に一致しています。 + +// ■ 各メンバーの意味 +// +// key 16 bit : hash keyの下位16bit(bit0は除くのでbit16..1) +// depth 8 bit : 格納されているvalue値の探索深さ +// move 16 bit : このnodeの最善手(指し手16bit ≒ Move16 , Moveの上位16bitは無視される) +// generation 5 bit : このエントリーにsave()された時のTTの世代カウンターの値 +// pv node 1 bit : PV nodeで調べた値であるかのフラグ +// bound type 2 bit : 格納されているvalue値の性質(fail low/highした時の値であるだとか) +// value 16 bit : このnodeでのsearch()の返し値 +// eval value 16 bit : このnodeでのevaluate()の返し値 + +struct TTEntry { + + // Convert internal bitfields to external types + // 内部ビットフィールドを外部型に変換します + + TTData read() const { + return TTData{ static_cast(move16.to_u16()), Value(value16), + Value(eval16), Depth(depth8 + DEPTH_ENTRY_OFFSET), + Bound(genBound8 & 0x3), bool(genBound8 & 0x4) }; + } + + // このEntryが使われているか? + + bool is_occupied() const; + + // 探索した情報をこの構造体に保存する。 + + void save(Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8); + + // The returned age is a multiple of TranspositionTable::GENERATION_DELTA + // 返されるエイジは、TranspositionTable::GENERATION_DELTA の倍数です + // ⇨ 相対的なageに変換して返す。 + + uint8_t relative_age(const uint8_t generation8) const; + +private: + friend class TranspositionTable; + + uint16_t key16; + uint8_t depth8; + uint8_t genBound8; + Move16 move16; + int16_t value16; + int16_t eval16; +}; + + +// `genBound8` is where most of the details are. We use the following constants to manipulate 5 leading generation bits +// and 3 trailing miscellaneous bits. + +// These bits are reserved for other things. + +// genBound8には大部分の詳細が含まれています。 +// 次の定数を使用して、5ビットの先頭世代ビットと3ビットの末尾のその他のビットを操作します。 + +// これらのビットは他の用途のために予約されています。 + +static constexpr unsigned GENERATION_BITS = 3; + +// increment for generation field +// 世代フィールドをインクリメント + +static constexpr int GENERATION_DELTA = (1 << GENERATION_BITS); + +// cycle length +// サイクル長 + +static constexpr int GENERATION_CYCLE = 255 + GENERATION_DELTA; + +// mask to pull out generation number +// 世代番号を抽出するためのマスク + +static constexpr int GENERATION_MASK = (0xFF << GENERATION_BITS) & 0xFF; + + +// DEPTH_ENTRY_OFFSET exists because 1) we use `bool(depth8)` as the occupancy check, but +// 2) we need to store negative depths for QS. (`depth8` is the only field with "spare bits": +// we sacrifice the ability to store depths greater than 1<<8 less the offset, as asserted in `save`.) + +// DEPTH_ENTRY_OFFSETが存在する理由は、 +// 1) `bool(depth8)`を使用してエントリの占有状態を確認しますが、 +// 2) QSのために負の深さを保存する必要があるためです。(`depth8`は「予備のビット」を持つ唯一のフィールドです。 +// その結果、オフセットを引いた値が1<<8より大きな深さを保存する能力を犠牲にしています。このことは`save`で検証されます。) +// ※ QS = 静止探索 + +bool TTEntry::is_occupied() const { return bool(depth8); } + + +// Populates the TTEntry with a new node's data, possibly +// overwriting an old position. The update is not atomic and can be racy. + +// TTEntryに新しいノードのデータを格納し、古い局面を上書きする可能性があります。 +// この更新はアトミックではなく、競合が発生する可能性があります。 + +// ⇨ 置換表のエントリーに対して与えられたデータを保存する。上書き動作 // v : 探索のスコア // eval : 評価関数 or 静止探索の値 // m : ベストな指し手(指し手16bit ≒ Move16 , Moveの上位16bitは無視される) // gen : TT.generation() // 引数のgenは、Stockfishにはないが、やねうら王では学習時にスレッドごとに別の局面を探索させたいので // スレッドごとに異なるgenerationの値を指定したくてこのような作りになっている。 -void TTEntry::save_(TTEntry::KEY_TYPE key_for_ttentry, Value v, bool pv , Bound b, Depth d, Move m , Value ev) -{ - // ASSERT_LV3((-VALUE_INFINITE < v && v < VALUE_INFINITE) || v == VALUE_NONE); - // 置換表にVALUE_INFINITE以上の値を書き込んでしまうのは本来はおかしいが、 - // 実際には置換表が衝突したときにqsearch()から書き込んでしまう。 - // - // 例えば、3手詰めの局面で、置換表衝突により1手詰めのスコアが返ってきた場合、VALUE_INFINITEより - // 大きな値を書き込む。 - // - // 逆に置換表をprobe()したときにそのようなスコアが返ってくることがある。 - // しかしこのようなスコアは、mate distance pruningで補正されるので問題ない。 - // (ように、探索部を書くべきである。) - // - // Stockfishで、VALUE_INFINITEを32001(int16_tの最大値よりMAX_PLY以上小さな値)にしてあるのは - // そういった理由から。 - - // このif式だが、 - // A = m!=MOVE_NONE - // B = ((u16)(k >> 1)) != key16) - // として、ifが成立するのは、 - // a) A && !B - // b) A && B - // c) !A && B - // の3パターン。b),c)は、B == trueなので、その下にある次のif式が成立して、この局面のhash keyがkey16に格納される。 - // a)は、B == false すなわち、((u16)(k >> 1)) == key16であり、この局面用のentryであるから、その次のif式が成立しないとしても - // 整合性は保てる。 - // a)のケースにおいても、指し手の情報は格納しておいたほうがいい。 - // これは、このnodeで、TT::probeでhitして、その指し手は試したが、それよりいい手が見つかって、枝刈り等が発生しているような - // ケースが考えられる。ゆえに、今回の指し手のほうが、いまの置換表の指し手より価値があると考えられる。 +void TTEntry::save( + Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8) { // Preserve the old ttmove if we don't have a new one - if (m || key_for_ttentry != key) - move16 = uint16_t(m); - - // このエントリーの現在の内容のほうが価値があるなら上書きしない。 - // 1. hash keyが違うということはTT::probeでここを使うと決めたわけだから、このEntryは無条件に潰して良い - // 2. hash keyが同じだとしても今回の情報のほうが残り探索depthが深い(新しい情報にも価値があるので - //  少しの深さのマイナスなら許容) - // 3. BOUND_EXACT(これはPVnodeで探索した結果で、とても価値のある情報なので無条件で書き込む) - // 1. or 2. or 3. - if ( b == BOUND_EXACT - || key_for_ttentry != key - || d - DEPTH_ENTRY_OFFSET + 2 * pv > depth8 - 4) - // ここ、 2 * pv を入れたほうが強いらしい。 - // https://github.com/official-stockfish/Stockfish/commit/94514199123874c0029afb6e00634f26741d90db + // 新しいttmoveがない場合、古いttmoveを保持します + + if (m || uint16_t(k) != key16) + move16 = m; + + // Overwrite less valuable entries (cheapest checks first) + // より価値の低いエントリを上書きします(最も安価なチェックを優先) + + if (b == BOUND_EXACT || uint16_t(k) != key16 || d - DEPTH_ENTRY_OFFSET + 2 * pv > depth8 - 4 + || relative_age(generation8)) { - ASSERT_LV3(d > DEPTH_ENTRY_OFFSET); - ASSERT_LV3(d < 256 + DEPTH_ENTRY_OFFSET); + assert(d > DEPTH_ENTRY_OFFSET); + assert(d < 256 + DEPTH_ENTRY_OFFSET); - key = key_for_ttentry; - depth8 = uint8_t(d - DEPTH_ENTRY_OFFSET); // DEPTH_ENTRY_OFFSETだけ下駄履きさせてある。 - genBound8 = uint8_t(TT.generation8 | uint8_t(pv) << 2 | b); + key16 = uint16_t(k); + depth8 = uint8_t(d - DEPTH_ENTRY_OFFSET); + genBound8 = uint8_t(generation8 | uint8_t(pv) << 2 | b); value16 = int16_t(v); eval16 = int16_t(ev); } } + uint8_t TTEntry::relative_age(const uint8_t generation8) const { // Due to our packed storage format for generation and its cyclic // nature we add GENERATION_CYCLE (256 is the modulus, plus what @@ -88,6 +189,15 @@ uint8_t TTEntry::relative_age(const uint8_t generation8) const { // the result) to calculate the entry age correctly even after // generation8 overflows into the next cycle. + // 世代のパックされた保存形式とその循環的な性質により、 + // 世代エイジを正しく計算するために、GENERATION_CYCLEを加えます + // (256がモジュロとなり、関係のない下位nビットが + // 結果に影響を与えないようにするために必要な値も加えます)。 + // これにより、generation8が次のサイクルにオーバーフローした後でも、 + // エントリのエイジを正しく計算できます。 + + // ■ 補足情報 + // // generationは256になるとオーバーフローして0になるのでそれをうまく処理できなければならない。 // a,bが8bitであるとき ( 256 + a - b ) & 0xff のようにすれば、オーバーフローを考慮した引き算が出来る。 // このテクニックを用いる。 @@ -96,237 +206,219 @@ uint8_t TTEntry::relative_age(const uint8_t generation8) const { // b := genBound8は下位3bitにはBoundが入っているのでこれはゴミと考える。 // ( 256 + a - b + c) & 0xfc として c = 7としても結果に影響は及ぼさない、かつ、このゴミを無視した計算が出来る。 - return (TranspositionTable::GENERATION_CYCLE + generation8 - genBound8) - & TranspositionTable::GENERATION_MASK; + return (GENERATION_CYCLE + generation8 - genBound8) & GENERATION_MASK; } -// 置換表のサイズを確保しなおす。 -void TranspositionTable::resize(size_t mbSize) { -#if defined(TANUKI_MATE_ENGINE) || defined(YANEURAOU_MATE_ENGINE) || defined(YANEURAOU_ENGINE_DEEP) - // これらのエンジンでは、この置換表は用いないので確保しない。 - return; -#endif +// TTWriter is but a very thin wrapper around the pointer +// TTWriterはポインタを包む非常に薄いラッパーに過ぎません - // Optionのoverrideによってスレッド初期化前にハンドラが呼び出された。これは無視する。 - if (Threads.size() == 0) - return; +TTWriter::TTWriter(TTEntry* tte) : + entry(tte) {} - // 探索が終わる前に次のresizeが来ると落ちるので探索の終了を待つ。 - Threads.main()->wait_for_search_finished(); +void TTWriter::write( + Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8) { + entry->save(k, v, pv, b, d, m, ev, generation8); +} - // mbSizeの単位は[MB]なので、ここでは1MBの倍数単位のメモリが確保されるが、 - // 仕様上は、1MBの倍数である必要はない。 - size_t newClusterCount = mbSize * 1024 * 1024 / sizeof(Cluster); +// A TranspositionTable is an array of Cluster, of size clusterCount. Each cluster consists of ClusterSize number +// of TTEntry. Each non-empty TTEntry contains information on exactly one position. The size of a Cluster should +// divide the size of a cache line for best performance, as the cacheline is prefetched when possible. - // clusterCountは偶数でなければならない。 - // この理由については、TTEntry::first_entry()のコメントを見よ。 - // しかし、1024 * 1024 / sizeof(Cluster)の部分、sizeof(Cluster)==64なので、 - // これを掛け算するから2の倍数である。 - ASSERT_LV3((newClusterCount & 1) == 0); +// TranspositionTableは、clusterCountのサイズを持つClusterの配列です。 +// 各クラスターはClusterSize個のTTEntryで構成されます。 +// 各非空のTTEntryは、正確に1つの局面に関する情報を含んでいます。 +// クラスターのサイズは、パフォーマンスを最大化するためにキャッシュラインのサイズを割り切れるべきです。 +// キャッシュラインは可能な場合にプリフェッチされます。 - // 同じサイズなら確保しなおす必要はない。 +// ■ 補足情報 +// +// StockfishではClusterSize == 3固定だが、やねうら王では、ビルドオプションで変更できるようになっている。 +// +// 1クラスターにおけるTTEntryの数 +// TT_CLUSTER_SIZE == 2のとき、TTEntry 10bytes×3つ + 2(padding) = 32bytes +// TT_CLUSTER_SIZE == 3のとき、TTEntry 16bytes×2つ + 0(padding) = 32bytes +// TT_CLUSTER_SIZE == 4のとき、TTEntry 16bytes×4つ + 0(padding) = 64bytes +// TT_CLUSTER_SIZE == 6のとき、TTEntry 16bytes×6つ + 0(padding) = 96bytes +// TT_CLUSTER_SIZE == 8のとき、TTEntry 16bytes×8つ + 0(padding) = 128bytes - // Stockfishのコード、問答無用で確保しなおしてゼロクリアしているが、 - // ゼロクリアの時間も馬鹿にならないのであまり良いとは言い難い。 +static constexpr int ClusterSize = TT_CLUSTER_SIZE; - if (newClusterCount == clusterCount) - return; +struct Cluster { + TTEntry entry[ClusterSize]; - aligned_large_pages_free(table); +#if TT_CLUSTER_SIZE == 3 + char padding[2]; // Pad to 32 bytes + // 全体を32byteぴったりにするためのpadding +#endif +}; - clusterCount = newClusterCount; +// static_assert(sizeof(Cluster) == 32, "Suboptimal Cluster size"); - // tableはCacheLineSizeでalignされたメモリに配置したいので、CacheLineSize-1だけ余分に確保する。 - // callocではなくmallocにしないと初回の探索でTTにアクセスするとき、特に巨大なTTだと - // 極めて遅くなるので、mallocで確保して自前でゼロクリアすることでこれを回避する。 - // cf. Explicitly zero TT upon resize. : https://github.com/official-stockfish/Stockfish/commit/2ba47416cbdd5db2c7c79257072cd8675b61721f +// Sets the size of the transposition table, +// measured in megabytes. Transposition table consists +// of clusters and each cluster consists of ClusterSize number of TTEntry. - // Large Pageを確保する。ランダムメモリアクセスが5%程度速くなる。 - table = static_cast(aligned_large_pages_alloc(clusterCount * sizeof(Cluster))); +// トランスポジションテーブルのサイズをメガバイト単位で設定します。 +// トランスポジションテーブルはクラスターで構成されており、 +// 各クラスターはClusterSize個のTTEntryで構成されます。 - // clear(); +void TranspositionTable::resize(size_t mbSize/*, ThreadPool& threads */) { + aligned_large_pages_free(table); - // → Stockfish、ここでclear()呼び出しているが、Search::clear()からTT.clear()を呼び出すので - // 二重に初期化していることになると思う。 + clusterCount = mbSize * 1024 * 1024 / sizeof(Cluster); -#if defined(EVAL_LEARN) - // スレッドごとにTTを持つ実装なら、確保しているメモリサイズが変更になったので、 - // スレッドごとのTTを初期化してやる必要がある。 - init_tt_per_thread(); -#endif + table = static_cast(aligned_large_pages_alloc(clusterCount * sizeof(Cluster))); + + if (!table) + { + std::cerr << "Failed to allocate " << mbSize << "MB for transposition table." << std::endl; + exit(EXIT_FAILURE); + } + + clear(/* threads */); } -void TranspositionTable::clear() -{ +// Initializes the entire transposition table to zero, +// in a multi-threaded way. + +// トランスポジションテーブル全体をマルチスレッドでゼロに初期化します。 + +void TranspositionTable::clear(/* ThreadPool& threads */) { #if defined(TANUKI_MATE_ENGINE) || defined(YANEURAOU_MATE_ENGINE) // MateEngineではこの置換表は用いないのでクリアもしない。 return; #endif + generation8 = 0; + // Stockfishのコード +#if 0 + const size_t threadCount = threads.num_threads(); + + for (size_t i = 0; i < threadCount; ++i) + { + threads.run_on_thread(i, [this, i, threadCount]() { + // Each thread will zero its part of the hash table + const size_t stride = clusterCount / threadCount; + const size_t start = stride * i; + const size_t len = i + 1 != threadCount ? stride : clusterCount - start; + + std::memset(&table[start], 0, len * sizeof(Cluster)); + }); + } + + for (size_t i = 0; i < threadCount; ++i) + threads.wait_on_thread(i); +#endif + auto size = clusterCount * sizeof(Cluster); // 進捗を表示しながら並列化してゼロクリア // Stockfishのここにあったコードは、独自の置換表を実装した時にも使いたいため、tt.cppに移動させた。 Tools::memclear("USI_Hash", table, size); + } -// probe()の内部実装用。 -// key_for_index : first_entry()で使うためのkey -// key_for_ttentry : TTEntryに格納するためのkey -TTEntry* TranspositionTable::probe(const Key key_for_index, const TTEntry::KEY_TYPE key_for_ttentry , bool& found) const -{ - ASSERT_LV3(clusterCount != 0); +// Returns an approximation of the hashtable +// occupation during a search. The hash is x permill full, as per UCI protocol. +// Only counts entries which match the current generation. -#if defined(USE_GLOBAL_OPTIONS) - if (!GlobalOptions.use_hash_probe) - { - // 置換表にhitさせないモードであるなら、見つからなかったことにして - // つねに確保しているメモリの先頭要素を返せば良い。(ここに書き込まれたところで問題ない) - return found = false, first_entry(0); - } -#endif +// 検索中のハッシュテーブルの占有率を概算して返します。 +// ハッシュはUCIプロトコルに従って、xパーミルで満たされています。 +// 現在の世代と一致するエントリのみをカウントします。 - // 最初のTT_ENTRYのアドレス(このアドレスからTT_ENTRYがClusterSize分だけ連なっている) - // keyの下位bitをいくつか使って、このアドレスを求めるので、自ずと下位bitはいくらかは一致していることになる。 - TTEntry* const tte = first_entry(key_for_index); +int TranspositionTable::hashfull(int maxAge) const { + int cnt = 0; + for (int i = 0; i < 1000; ++i) + for (int j = 0; j < ClusterSize; ++j) + { + if (table[i].entry[j].is_occupied()) + { + int age = (generation8 >> GENERATION_BITS) + - ((table[i].entry[j].genBound8 & GENERATION_MASK) >> GENERATION_BITS); + if (age < 0) + age += 1 << (8 - GENERATION_BITS); + cnt += age <= maxAge; + } + } - // クラスターのなかから、keyが合致するTT_ENTRYを探す - for (int i = 0; i < ClusterSize; ++i) - { - // returnする条件 - // 1. 空のエントリーを見つけた(そこまではkeyが合致していないので、found==falseにして新規TT_ENTRYのアドレスとして返す) - // 2. keyが合致しているentryを見つけた。(found==trueにしてそのTT_ENTRYのアドレスを返す) + return cnt / ClusterSize; +} - // Stockfishのコードだと、1.が成立したタイミングでもgenerationのrefreshをしているが、 - // save()のときにgenerationを書き出すため、このケースにおいてrefreshは必要ない。 - // (しかしソースコードをStockfishに合わせておくことに価値があると思うので、Stockfishに合わせておく) +void TranspositionTable::new_search() { - // Stockfish11まではkey16 == 0が空のTTEntryを意味することになっていたが、 - // Stockfish12からはdepth8 == 0が空のTTEntryを意味するように変わった。 - // key16は1/65536の確率で0になりうるので…。 + // increment by delta to keep lower bits as is + // 下位ビットをそのままにして、デルタでインクリメントします - if (tte[i].key == key_for_ttentry) - { - tte[i].genBound8 = uint8_t(generation8 | (tte[i].genBound8 & (GENERATION_DELTA - 1))); // Refresh + generation8 += GENERATION_DELTA; +} - return found = bool(tte[i].depth8), &tte[i]; - } - } - // 空きエントリーも、探していたkeyが格納されているentryが見当たらなかった。 - // クラスター内のどれか一つを潰す必要がある。 +uint8_t TranspositionTable::generation() const { return generation8; } - TTEntry* replace = tte; - for (int i = 1; i < ClusterSize; ++i) +// Looks up the current position in the transposition +// table. It returns true if the position is found. +// Otherwise, it returns false and a pointer to an empty or least valuable TTEntry +// to be replaced later. The replace value of an entry is calculated as its depth +// minus 8 times its relative age. TTEntry t1 is considered more valuable than +// TTEntry t2 if its replace value is greater than that of t2. - // ・深い探索の結果であるものほど価値があるので残しておきたい。すなわち、depth8 が高いほど良い探索結果だから残しておきたい。 - // ・generationがいまの探索generationに近いものほど価値があるので残しておきたい。depth8 - generation のようにする。 - //   gerationは、次の局面が来るごとに8ずつ増える。普通は2手先の局面が来る。 - // (新規対局時にはTTを丸ごとクリアしているから新規対局時のことは考えなくて良い) - //   すなわち2手前の局面の探索結果については、depth8 - 8 の深さで探索した結果であるとみなす。(depth8 - 2でもいいような気は少しするが、 - //   この情報が現局面から到達可能な局面の情報とは限らないので、少し大きめのペナルティを加えているのだと思う。) - // - // 以上に基いてスコアリングする。 - // 以上の合計が一番小さいTTEntryを使う。 - // - // 詳しくは、以下のブログ記事に書いた。 - // https://yaneuraou.yaneu.com/2023/06/09/replacement-strategy-in-transposition-table/ - // - - if (replace->depth8 - replace->relative_age(generation8) * 2 - > tte[i].depth8 - tte[i].relative_age(generation8) * 2) - replace = &tte[i]; - - return found = false, replace; -} +// 現在の局面をトランスポジションテーブルで検索します。局面が見つかった場合、trueを返します。 +// そうでない場合、falseと、後で置き換えるための空または最も価値の低いTTEntryへのポインタを返します。 +// エントリの置き換え値は、その深さから相対的なエイジの8倍を引いたものとして計算されます。 +// TTEntry t1は、t2の置き換え値より大きい場合、t2よりも価値があると見なされます。 -// read onlyであることが保証されているprobe() -TTEntry* TranspositionTable::read_probe(const Key key_for_index, const TTEntry::KEY_TYPE key_for_ttentry , bool& found) const -{ - ASSERT_LV3(clusterCount != 0); +// やねうら王独自拡張 +// probe()してhitしたときに ttData.moveは Move16のままなので ttData.move32(pos)を用いて取得する必要がある。 +// そこで、probe()の第2引数にPositionを渡すようにして、Move16ではなくMoveに変換されたTTDataを返すことにする。 -#if defined(USE_GLOBAL_OPTIONS) - if (!GlobalOptions.use_hash_probe) - return found = false, first_entry(0); -#endif +std::tuple TranspositionTable::probe(const Key key, const Position& pos) const { + + TTEntry* const tte = first_entry(key); - TTEntry* const tte = first_entry(key_for_index); + // Use the low 16 bits as key inside the cluster + // クラスター内で下位16ビットをキーとして使用します + + const uint16_t key16 = uint16_t(key); for (int i = 0; i < ClusterSize; ++i) - { - if (tte[i].key == key_for_ttentry || !tte[i].depth8) - return found = (bool)tte[i].depth8, &tte[i]; - } - return found = false, nullptr; -} + if (tte[i].key16 == key16) -// やねうら王独自拡張 -// -// hash keyを64bit,128bit,256bitに自由に変更できる。 → config.h で HASH_KEY_BITS を設定する。 -// またTTClusterSizeを2または3を選択できる。 -// -// TTClusterSizeとして2を選択した場合、TTEntryに格納されるhash keyは64bitになる。 -// TTClusterSizeとして3を選択した場合、TTEntryに格納されるhash keyは16bitになる。 -// → config.h で TTClusterSize を設定する。 - -void TTEntry::save(Key k, Value v, bool pv , Bound b, Depth d, Move m, Value ev) { save_((TTEntry::KEY_TYPE)(k >> 1) ,v, pv, b, d, m, ev);} -void TTEntry::save(Key128& k, Value v, bool pv , Bound b, Depth d, Move m, Value ev) { save_((TTEntry::KEY_TYPE) k.extract64<1>() ,v, pv, b, d, m, ev);} -void TTEntry::save(Key256& k, Value v, bool pv , Bound b, Depth d, Move m, Value ev) { save_((TTEntry::KEY_TYPE) k.extract64<1>() ,v, pv, b, d, m, ev);} -TTEntry* TranspositionTable::probe (const Key key, bool& found) const { return probe(key ,(TTEntry::KEY_TYPE)(key >> 1 ), found); } -TTEntry* TranspositionTable::probe (const Key128& key, bool& found) const { return probe(key.extract64<0>(),(TTEntry::KEY_TYPE)(key.extract64<1>()), found); } -TTEntry* TranspositionTable::probe (const Key256& key, bool& found) const { return probe(key.extract64<0>(),(TTEntry::KEY_TYPE)(key.extract64<1>()), found); } -TTEntry* TranspositionTable::read_probe (const Key key, bool& found) const { return read_probe(key ,(TTEntry::KEY_TYPE)(key >> 1 ), found); } -TTEntry* TranspositionTable::read_probe (const Key128& key, bool& found) const { return read_probe(key.extract64<0>(),(TTEntry::KEY_TYPE)(key.extract64<1>()), found); } -TTEntry* TranspositionTable::read_probe (const Key256& key, bool& found) const { return read_probe(key.extract64<0>(),(TTEntry::KEY_TYPE)(key.extract64<1>()), found); } -TTEntry* TranspositionTable::first_entry(const Key key) const { return _first_entry(key ); } -TTEntry* TranspositionTable::first_entry(const Key128& key) const { return _first_entry(key.extract64<0>()); } -TTEntry* TranspositionTable::first_entry(const Key256& key) const { return _first_entry(key.extract64<0>()); } - -int TranspositionTable::hashfull() const -{ - // すべてのエントリーにアクセスすると時間が非常にかかるため、先頭から1000エントリーだけ - // サンプリングして使用されているエントリー数を返す。 - - // Stockfish11では、1000 Cluster(3000 TTEntry)についてサンプリングするように変更されたが、 - // 計測時間がもったいないので、古いコードのままにしておく。 + // This gap is the main place for read races. + // After `read()` completes that copy is final, but may be self-inconsistent. - int cnt = 0; - for (int i = 0; i < 1000 / ClusterSize; ++i) - for (int j = 0; j < ClusterSize; ++j) - cnt += table[i].entry[j].depth8 && (table[i].entry[j].genBound8 & GENERATION_MASK) == generation8; + // このギャップが、読み取り競合の主な発生場所です。 + // `read()`が完了した後、そのコピーは最終的なものですが、自己矛盾している可能性があります。 - // return cnt;でも良いが、そうすると最大で999しか返らず、置換表使用率が100%という表示にならない。 - return cnt * 1000 / (ClusterSize * (1000 / ClusterSize)); -} + { + auto data = tte[i].read(); + // TTEntryのMoveを32bit化しようとして失敗したら、置換表のhit失敗したという扱いにする。 + Move move = pos.to_move(data.move); + if (move) + { + data.move = move; + return { tte[i].is_occupied(), data, TTWriter(&tte[i]) }; + } + } -#if defined(EVAL_LEARN) -// スレッド数が変更になった時にThread.set()から呼び出される。 -// これに応じて、スレッドごとに保持しているTTを初期化する。 -void TranspositionTable::init_tt_per_thread() -{ - // スレッド数 - size_t thread_size = Threads.size(); + // Find an entry to be replaced according to the replacement strategy + // 置換戦略に従って、置き換えるエントリを見つけます - // エンジン終了時にThreads.set(0)で全スレッド終了させるコードが書いてあるので、 - // そのときに、Threads.size() == 0の状態で呼び出される。 - // ここで抜けないと、このあとゼロ除算することになる。 - if (thread_size == 0) - return; + TTEntry* replace = tte; + for (int i = 1; i < ClusterSize; ++i) + if (replace->depth8 - replace->relative_age(generation8) * 2 + > tte[i].depth8 - tte[i].relative_age(generation8) * 2) + replace = &tte[i]; - // 1スレッドあたりのクラスター数(端数切捨て) - // clusterCountは2の倍数でないと駄目なので、端数を切り捨てるためにLSBを0にする。 - size_t clusterCountPerThread = (clusterCount / thread_size) & ~(size_t)1; + return { false, TTData(), TTWriter(replace) }; +} - ASSERT_LV3((clusterCountPerThread & 1) == 0); - // これを、自分が確保したglobalな置換表用メモリから切り分けて割当てる。 - for (size_t i = 0; i < thread_size; ++i) - { - auto& tt = Threads[i]->tt; - tt.clusterCount = clusterCountPerThread; - tt.table = this->table + clusterCountPerThread * i; - } +TTEntry* TranspositionTable::first_entry(const Key key) const { + return &table[mul_hi64(key, clusterCount)].entry[0]; } -#endif + +//} // namespace Stockfish + + diff --git a/source/tt.h b/source/tt.h index 0e3d5ca85..e7d569224 100644 --- a/source/tt.h +++ b/source/tt.h @@ -12,86 +12,64 @@ struct Key256; // cf.【決定版】コンピュータ将棋のHASHの概念について詳しく : http://yaneuraou.yaneu.com/2018/11/18/%E3%80%90%E6%B1%BA%E5%AE%9A%E7%89%88%E3%80%91%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF%E5%B0%86%E6%A3%8B%E3%81%AEhash%E3%81%AE%E6%A6%82%E5%BF%B5%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/ -// -------------------- -// 置換表 -// -------------------- - -/// 置換表エントリー -/// 本エントリーは10bytesに収まるようになっている。3つのエントリーを並べたときに32bytesに収まるので -/// CPUのcache lineに一発で載るというミラクル。 -/// -/// ※ cache line sizeは、IntelだとPentium4やPentiumMからでPentiumⅢ(3)までは32byte。 -/// そこ以降64byte。AMDだとK8のときには既に64byte。 -/// -/// key 16 bit : hash keyの下位16bit(bit0は除くのでbit16..1) -/// depth 8 bit : 格納されているvalue値の探索深さ -/// move 16 bit : このnodeの最善手(指し手16bit ≒ Move16 , Moveの上位16bitは無視される) -/// generation 5 bit : このエントリーにsave()された時のTTの世代カウンターの値 -/// pv node 1 bit : PV nodeで調べた値であるかのフラグ -/// bound type 2 bit : 格納されているvalue値の性質(fail low/highした時の値であるだとか) -/// value 16 bit : このnodeでのsearch()の返し値 -/// eval value 16 bit : このnodeでのevaluate()の返し値 -struct TTEntry { - - Move16 move() const { return Move16(move16); } - Value value() const { return Value(value16); } - Value eval() const { return Value(eval16 ); } - Depth depth() const { return Depth(depth8 + DEPTH_ENTRY_OFFSET); } - bool is_pv() const { return bool (genBound8 & 0x4); } - Bound bound() const { return Bound(genBound8 & 0x3); } - - // 置換表のエントリーに対して与えられたデータを保存する。上書き動作 - // v : 探索のスコア - // ev : 評価関数 or 静止探索の値 - // pv : PV nodeであるか - // d : その時の探索深さ - // m : ベストな指し手 - // ※ KeyとしてKey(64 bit)以外に 128,256bitのhash keyにも対応。(やねうら王独自拡張) - void save(Key k, Value v, bool pv , Bound b, Depth d, Move m, Value ev); - void save(Key128& k, Value v, bool pv , Bound b, Depth d, Move m, Value ev); - void save(Key256& k, Value v, bool pv , Bound b, Depth d, Move m, Value ev); +//class ThreadPool; +struct TTEntry; +struct Cluster; + +// There is only one global hash table for the engine and all its threads. For chess in particular, we even allow racy +// updates between threads to and from the TT, as taking the time to synchronize access would cost thinking time and +// thus elo. As a hash table, collisions are possible and may cause chess playing issues (bizarre blunders, faulty mate +// reports, etc). Fixing these also loses elo; however such risk decreases quickly with larger TT size. +// +// probe is the primary method: given a board position, we lookup its entry in the table, and return a tuple of: +// 1) whether the entry already has this position +// 2) a copy of the prior data (if any) (may be inconsistent due to read races) +// 3) a writer object to this entry +// The copied data and the writer are separated to maintain clear boundaries between local vs global objects. + +//エンジンとそのすべてのスレッドに対して、グローバルなハッシュテーブルは1つだけ存在します。 +// 特にチェスにおいては、TT(トランスポジションテーブル)間でのスレッド間の競合的な更新も許可しており、 +// アクセスを同期化するための時間を費やすと思考時間が減少し、それに伴いEloレーティングも下がるためです。 +// +// ハッシュテーブルであるため、衝突が発生する可能性があり、 +// それが原因でチェスプレイに問題が生じる場合があります(奇妙なミスや誤ったチェックメイト報告など)。 +// これらを修正することもEloレーティングを失うことにつながりますが、大きなTTサイズではそのリスクは急速に減少します。 +// +// probeは主なメソッドであり、ボードの局面を与えられると、テーブル内のエントリを検索し、以下のタプルを返します: +// +// そのエントリがすでにこの局面を持っているかどうか +// 以前のデータのコピー(あれば)(読み取り競合により不整合がある可能性があります) +// このエントリへのライターオブジェクト +// コピーされたデータとライターは、ローカルオブジェクトとグローバルオブジェクトの境界を明確にするために分離されています。 + + +// A copy of the data already in the entry (possibly collided). `probe` may be racy, resulting in inconsistent data. + +// すでにエントリに存在するデータのコピー(衝突している可能性があります)。 +// `probe` は競合が発生することがあり、不整合なデータを返す可能性があります。 + +struct TTData { + Move move; + Value value, eval; + Depth depth; + Bound bound; + bool is_pv; +}; + +// This is used to make racy writes to the global TT. - uint8_t relative_age(const uint8_t generation8) const; +// これはグローバルTTへの競合的な書き込みを行うために使用されます。 - // -- やねうら王独自拡張 - // やねうら王では、TTClusterSizeを変更できて、これが2の時は、TTEntryに格納するhash keyは64bit。(Stockfishのように)3の時は16bit。 -#if TT_CLUSTER_SIZE == 3 - typedef uint16_t KEY_TYPE; -#else // TT_CLUSTER_SIZEが2,4,6,8の時は64bit。5,7は選択する意味がないと思うので考えない。 - typedef uint64_t KEY_TYPE; -#endif +struct TTWriter { +public: + // TTのTTEntryに書き込む。 + void write(Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8); private: friend class TranspositionTable; - - // save()の内部実装用 - void save_(TTEntry::KEY_TYPE key_for_ttentry, Value v, bool pv , Bound b, Depth d, Move m, Value ev); - - // hash keyの下位bit16(bit0は除く) - // Stockfishの最新版[2020/11/03]では、key16はhash_keyの下位16bitに変更になったが(取り出しやすいため) - // やねうら王ではhash_keyのbit0を先後フラグとして用いるので、bit16..1を使う。 - // hash keyの上位bitは、TTClusterのindexの算出に用いるので、下位を格納するほうが理にかなっている。 - TTEntry::KEY_TYPE key; - - // 指し手(の下位16bit。Moveの上位16bitには移動させる駒種などが格納される) - uint16_t move16; - - // このnodeでのsearch()の値 - int16_t value16; - - // このnodeでのevaluate()の値 - int16_t eval16; - - // entryのgeneration上位5bit + PVであるか1bit + Bound下位2bitのpackしたもの。 - // generationはエントリーの世代を表す。TranspositionTableで新しい探索ごとに+8されていく。 - uint8_t genBound8; - - // そのときの残り深さ(これが大きいものほど価値がある) - // 1バイトに収めるために、DepthをONE_PLYで割ったものを格納する。 - // 符号付き8bitだと+127までしか表現できないので、符号なしにして、かつ、 - // DEPTH_NONEが-6なのでこの分だけ下駄履きさせてある。(+6して格納してある) - uint8_t depth8; + TTEntry* entry; + TTWriter(TTEntry* tte); }; // --- 置換表本体 @@ -100,71 +78,16 @@ struct TTEntry { // このクラスターが、clusterCount個だけ確保されている。 class TranspositionTable { - // 1クラスターにおけるTTEntryの数 - // TT_CLUSTER_SIZE == 2のとき、TTEntry 10bytes×3つ + 2(padding) = 32bytes - // TT_CLUSTER_SIZE == 3のとき、TTEntry 16bytes×2つ + 0(padding) = 32bytes - // TT_CLUSTER_SIZE == 4のとき、TTEntry 16bytes×4つ + 0(padding) = 64bytes - // TT_CLUSTER_SIZE == 6のとき、TTEntry 16bytes×6つ + 0(padding) = 96bytes - // TT_CLUSTER_SIZE == 8のとき、TTEntry 16bytes×8つ + 0(padding) = 128bytes - static constexpr int ClusterSize = TT_CLUSTER_SIZE; - - struct Cluster { - TTEntry entry[TT_CLUSTER_SIZE]; -#if TT_CLUSTER_SIZE == 3 - u8 padding[2]; // 全体を32byteぴったりにするためのpadding -#endif - }; - - static_assert((sizeof(Cluster) % 32) == 0, "Unexpected Cluster size"); - - // --- Constants used to refresh the hash table periodically - - // nb of bits reserved for other things - // generation8の下位↓bitは、generation用ではなく、別の情報を格納するのに用いる。 - // (PV nodeかどうかのフラグとBoundに用いている。) - static constexpr unsigned GENERATION_BITS = 3; - - // increment for generation field - // 次のgenerationにするために加算する定数。2の↑乗。 - static constexpr int GENERATION_DELTA = (1 << GENERATION_BITS); - - // cycle length - // generationを加算していき、1周して戻ってくるまでの長さ。 - static constexpr int GENERATION_CYCLE = 255 + (1 << GENERATION_BITS); - - // mask to pull out generation number - // generationを取り出す時のmask。 - static constexpr int GENERATION_MASK = (0xFF << GENERATION_BITS) & 0xFF; - public: - ~TranspositionTable() { aligned_large_pages_free(table); } - - // 新しい探索ごとにこの関数を呼び出す。(generationを加算する。) - // USE_GLOBAL_OPTIONSが有効のときは、このタイミングで、Options["Threads"]の値を - // キャプチャして、探索スレッドごとの置換表と世代カウンターを用意する。 - void new_search() { generation8 += GENERATION_DELTA; } // 下位3bitはPV nodeかどうかのフラグとBoundに用いている。 - - // 置換表のなかから与えられたkeyに対応するentryを探す。 - // 見つかったならfound == trueにしてそのTT_ENTRY*を返す。 - // 見つからなかったらfound == falseで、このとき置換表に書き戻すときに使うと良いTT_ENTRY*を返す。 - // ※ KeyとしてKey(64 bit)以外に 128,256bitのhash keyにも対応。(やねうら王独自拡張) - TTEntry* probe(const Key key, bool& found) const; - TTEntry* probe(const Key128& key, bool& found) const; - TTEntry* probe(const Key256& key, bool& found) const; + ~TranspositionTable() { aligned_large_pages_free(table); } - // probe()の、置換表を一切書き換えないことが保証されている版。(やねうら王独自拡張) - // ConsiderationMode時のPVの出力時は置換表をprobe()したいが、hitしないときに空きTTEntryを作る挙動が嫌なので、 - // こちらを用いる。 - // ※ KeyとしてKey(64 bit)以外に 128,256bitのhash keyにも対応。(やねうら王独自拡張) - TTEntry* read_probe(const Key key, bool& found) const; - TTEntry* read_probe(const Key128& key, bool& found) const; - TTEntry* read_probe(const Key256& key, bool& found) const; + // Set TT size + // 置換表のサイズを変更する。mbSize == 確保するメモリサイズ。[MB]単位。 - // 置換表の使用率を1000分率で返す。(USIプロトコルで統計情報として出力するのに使う) - int hashfull() const; + void resize(size_t mbSize /*, ThreadPool& threads */); - // 置換表のサイズを変更する。mbSize == 確保するメモリサイズ。MB単位。 - void resize(size_t mbSize); + // Re-initialize memory, multithreaded + // メモリを再初期化、マルチスレッド対応 // 置換表のエントリーの全クリア // 並列化してクリアするので高速。 @@ -174,52 +97,50 @@ class TranspositionTable { // 教師生成を行う時は、対局の最初にスレッドごとのTTに対して、 // このclear()が呼び出されるものとする。 // 例) th->tt.clear(); + void clear(); - // keyを元にClusterのindexを求めて、その最初のTTEntry*を返す。 - // ※ ここで渡されるkeyのbit 0は局面の手番フラグ(Position::side_to_move())であると仮定している。 - TTEntry* first_entry(const Key key) const; - TTEntry* first_entry(const Key128& key) const; - TTEntry* first_entry(const Key256& key) const; + // Approximate what fraction of entries (permille) have been written to during this root search + // このルート探索中に書き込まれたエントリの割合(パーミル単位)を概算します。 + // ⇨ 置換表の使用率を1000分率で返す。(USIプロトコルで統計情報として出力するのに使う) -#if defined(EVAL_LEARN) - // スレッド数が変更になった時にThread.set()から呼び出される。 - // これに応じて、スレッドごとに保持しているTTを初期化する。 - void init_tt_per_thread(); -#endif + int hashfull(int maxAge = 0) const; -private: - friend struct TTEntry; + // This must be called at the beginning of each root search to track entry aging + // エントリのエイジングを追跡するために、各ルート検索の開始時にこれを呼び出す必要があります。 + // ⇨ 新しい探索ごとにこの関数を呼び出す。(generationを加算する。) - // keyを元にClusterのindexを求めて、その最初のTTEntry*を返す。内部実装用。 - // ※ ここで渡されるkeyのbit 0は局面の手番フラグ(Position::side_to_move())であると仮定している。 - TTEntry* _first_entry(const Key key) const { - // Stockfishのコード - // mul_hi64は、64bit * 64bitの掛け算をして下位64bitを取得する関数。 - //return &table[mul_hi64(key, clusterCount)].entry[0]; + // USE_GLOBAL_OPTIONSが有効のときは、このタイミングで、Options["Threads"]の値を + // キャプチャして、探索スレッドごとの置換表と世代カウンターを用意する。 + // ⇨ 下位3bitはPV nodeかどうかのフラグとBoundに用いている。 + void new_search(); + + // The current age, used when writing new data to the TT + // 新しいデータをTTに書き込む際に使用される現在のエイジ + + uint8_t generation() const; - // key(64bit) × clusterCount / 2^64 の値は 0 ~ clusterCount - 1 である。 - // 掛け算が必要にはなるが、こうすることで custerCountを2^Nで確保しないといけないという制約が外れる。 - // cf. Allow for general transposition table sizes. : https://github.com/official-stockfish/Stockfish/commit/2198cd0524574f0d9df8c0ec9aaf14ad8c94402b + // The main method, whose retvals separate local vs global objects + // メインメソッドで、その戻り値はローカルオブジェクトとグローバルオブジェクトを区別します + + // 置換表のなかから与えられたkeyに対応するentryを探す。 + // 見つかったならfound == trueにしてそのTT_ENTRY*を返す。 + // 見つからなかったらfound == falseで、このとき置換表に書き戻すときに使うと良いTT_ENTRY*を返す。 + // ※ KeyとしてKey(64 bit)以外に 128,256bitのhash keyにも対応。(やねうら王独自拡張) - // ※ 以下、やねうら王独自拡張 + // ⇨ このprobe()でTTの内部状態が変更されないことは保証されている。(されるようになった) - // やねうら王では、keyのbit0(先後フラグ)がindexのbit0に反映される必要がある。 - // このときclusterCountが奇数だと、(index & ~(u64)1) | (key & 1) のようにしたときに、 - // (clusterCount - 1)が上限であるべきなのにclusterCountになりかねない。 - // そこでclusterCountは偶数であるという制約を課す。 - ASSERT_LV3((clusterCount & 1) == 0); + std::tuple probe(const Key key , const Position& pos) const; - // indexのbit0は、keyのbit0(先後フラグ)が反映されなければならない。 - // → 次のindexの計算ではbit0を潰して計算するためにkeyを2で割ってからmul_hi64()している。 + // This is the hash function; its only external use is memory prefetching. + // これはハッシュ関数です。外部での唯一の使用目的はメモリのプリフェッチです。 + // ⇨ keyを元にClusterのindexを求めて、その最初のTTEntry*を返す。 + //  ここで渡されるkeyのbit 0は局面の手番フラグ(Position::side_to_move())であると仮定している。 - // (key/2) * clusterCount / 2^64 をするので、indexは 0 ~ (clusterCount/2)-1 の範囲となる。 - uint64_t index = mul_hi64((u64)key >> 1, clusterCount); + TTEntry* first_entry(const Key key) const; - // indexは0~(clusterCount/2)-1の範囲にあるのでこれを2倍すると、0~clusterCount-2の範囲。 - // clusterCountは偶数で、ここにkeyのbit0がbit-orされるので0~clusterCount-1の範囲の値が得られる。 - return &table[(index << 1) | ((u64)key & 1)].entry[0]; - } +private: + friend struct TTEntry; // この置換表が保持しているクラスター数。 // Stockfishはresize()ごとに毎回新しく置換表を確保するが、やねうら王では @@ -232,28 +153,10 @@ class TranspositionTable { // 不用意に使った場合に確実にアクセス保護違反で落ちるので都合が良い。 Cluster* table = nullptr; - // 確保されたメモリの先頭(alignされていない) - //void* mem; - // → やねうら王では、LargeMemoryで確保するのでこれは不要 - - // 世代カウンター。new_search()のごとに8ずつ加算する。TTEntry::save()で用いる。 + // Size must be not bigger than TTEntry::genBound8 + // サイズはTTEntry::genBound8を超えてはなりません。 + // ⇨ 世代カウンター。new_search()のごとに8ずつ加算する。TTEntry::save()で用いる。 uint8_t generation8; - - // --- やねうら王独自拡張 - - // probe()の内部実装用。 - // key_for_index : first_entry()で使うためのkey - // key_for_ttentry : TTEntryに格納するためのkey - TTEntry* probe (const Key key_for_index, const TTEntry::KEY_TYPE key_for_ttentry, bool& found) const; - - // read_probe()の内部実装用 - // key_for_index : first_entry()で使うためのkey - // key_for_ttentry : TTEntryに格納するためのkey - TTEntry* read_probe(const Key key_for_index, const TTEntry::KEY_TYPE key_for_ttentry, bool& found) const; - }; -// global object。探索部からこのinstanceを参照する。 -extern TranspositionTable TT; - #endif // #ifndef TT_H_INCLUDED diff --git a/source/types.cpp b/source/types.cpp index f7952fd6b..5c87885d1 100644 --- a/source/types.cpp +++ b/source/types.cpp @@ -109,41 +109,32 @@ namespace Search { // We try hard to have a ponder move to return to the GUI, // otherwise in case of 'ponder on' we have nothing to think about. - // 探索を抜ける前にponderの指し手がないとき(rootでfail highしているだとか)にこの関数を呼び出す。 - // ponderの指し手として何かを指定したほうが、その分、相手の手番において考えられて得なので。 + // 探索を終了する前にponder moveがない場合に呼び出されます。 + // 例えば、rootでfail highが発生して探索を中断した場合などです。 + // GUIに返すponder moveをできる限り準備しようとしますが、 + // そうでない場合、「ponder on」の際に考えるべきものが何もなくなります。 - bool RootMove::extract_ponder_from_tt(Position& pos, Move ponder_candidate) + bool RootMove::extract_ponder_from_tt(const TranspositionTable& tt, Position& pos) { StateInfo st; - bool ttHit; - // ASSERT_LV3(pv.size() == 1); + ASSERT_LV3(pv.size() == 1); // 詰みの局面が"ponderhit"で返ってくることがあるので、ここでのpv[0] == MOVE_RESIGNであることがありうる。 if (!is_ok(pv[0])) return false; - pos.do_move(pv[0], st, pos.gives_check(pv[0])); - TTEntry* tte = TT.read_probe(pos.state()->hash_key(), ttHit); - Move m; + pos.do_move(pv[0], st); + + auto [ttHit, ttData, ttWriter] = tt.probe(pos.key(), pos); if (ttHit) { - m = pos.to_move(tte->move()); // SMP safeにするためlocal copy - if (MoveList(pos).contains(m)) - goto FOUND; + if (MoveList(pos).contains(ttData.move)) + pv.push_back(ttData.move); } - // 置換表にもなかったので以前のiteration時のpv[1]をほじくり返す。 - m = ponder_candidate; - if (MoveList(pos).contains(m)) - goto FOUND; pos.undo_move(pv[0]); - return false; - FOUND:; - pos.undo_move(pv[0]); - pv.push_back(m); - // std::cout << m << std::endl; - return true; + return pv.size() > 1; } } diff --git a/source/usi.cpp b/source/usi.cpp index 2499fa3f8..2b5717a8a 100644 --- a/source/usi.cpp +++ b/source/usi.cpp @@ -8,6 +8,7 @@ #if !defined(YANEURAOU_ENGINE_DEEP) #include "tt.h" +extern TranspositionTable TT; #endif #if defined(__EMSCRIPTEN__) @@ -145,201 +146,6 @@ namespace YaneuraouTheCluster #endif #endif -namespace USI -{ - // -------------------- - // 読み筋の出力 - // -------------------- - - // depth : iteration深さ - std::string pv(const Position& pos, Depth depth) - { -#if defined(YANEURAOU_ENGINE_DEEP) - // ふかうら王では、この関数呼び出さないからまるっと要らない。 - - return string(); -#else - std::stringstream ss; - - TimePoint elapsed = Time.elapsed() + 1; -#if defined(__EMSCRIPTEN__) - // yaneuraou.wasm - // Time.elapsed()が-1を返すことがある - // https://github.com/lichess-org/stockfish.wasm/issues/5 - // https://github.com/lichess-org/stockfish.wasm/commit/4f591186650ab9729705dc01dec1b2d099cd5e29 - elapsed = std::max(elapsed, TimePoint(1)); -#endif - const auto& rootMoves = pos.this_thread()->rootMoves; - size_t pvIdx = pos.this_thread()->pvIdx; - size_t multiPV = std::min(size_t(Options["MultiPV"]), rootMoves.size()); - - uint64_t nodes_searched = Threads.nodes_searched(); - - // MultiPVでは上位N個の候補手と読み筋を出力する必要がある。 - for (size_t i = 0; i < multiPV; ++i) - { - // この指し手のpvの更新が終わっているのか - bool updated = rootMoves[i].score != -VALUE_INFINITE; - - if (depth == 1 && !updated && i > 0) - continue; - - // 1より小さな探索depthで出力しない。 - Depth d = updated ? depth : std::max(1, depth - 1); - Value v = updated ? rootMoves[i].usiScore : rootMoves[i].previousScore; - - // multi pv時、例えば3個目の候補手までしか評価が終わっていなくて(PVIdx==2)、このとき、 - // 3,4,5個目にあるのは前回のiterationまでずっと評価されていなかった指し手であるような場合に、 - // これらのpreviousScoreが-VALUE_INFINITE(未初期化状態)でありうる。 - // (multi pv状態で"go infinite"~"stop"を繰り返すとこの現象が発生する。おそらく置換表にhitしまくる結果ではないかと思う。) - if (v == -VALUE_INFINITE) - v = VALUE_ZERO; // この場合でもとりあえず出力は行う。 - - //bool tb = TB::RootInTB && abs(v) < VALUE_MATE_IN_MAX_PLY; - //v = tb ? rootMoves[i].tbScore : v; - - if (ss.rdbuf()->in_avail()) // 1行目でないなら連結のための改行を出力 - ss << endl; - - ss << "info" - << " depth " << d - << " seldepth " << rootMoves[i].selDepth -#if defined(USE_PIECE_VALUE) - << " score " << USI::value(v) -#endif - ; - - // これが現在探索中の指し手であるなら、それがlowerboundかupperboundかは表示させる - if (i == pvIdx && /*!tb &&*/ updated) // tablebase- and previous-scores are exact - ss << (rootMoves[i].scoreLowerbound ? " lowerbound" : (rootMoves[i].scoreUpperbound ? " upperbound" : "")); - - // 将棋所はmultipvに対応していないが、とりあえず出力はしておく。 - if (multiPV > 1) - ss << " multipv " << (i + 1); - - ss << " nodes " << nodes_searched - << " nps " << nodes_searched * 1000 / elapsed - << " hashfull " << TT.hashfull() - << " time " << elapsed - << " pv"; - - - // PV配列からPVを出力する。 - // ※ USIの"info"で読み筋を出力するときは"pv"サブコマンドはサブコマンドの一番最後にしなければならない。 - - auto out_array_pv = [&]() - { - for (Move m : rootMoves[i].pv) - ss << " " << m; - }; - - // 置換表からPVをかき集めてきてPVを出力する。 - auto out_tt_pv = [&]() - { - auto pos_ = const_cast(&pos); - Move moves[MAX_PLY + 1]; - StateInfo si[MAX_PLY]; - int ply = 0; - - while ( ply < MAX_PLY ) - { - // 千日手はそこで終了。ただし初手はPVを出力。 - // 千日手がベストのとき、置換表を更新していないので - // 置換表上はMOVE_NONEがベストの指し手になっている可能性があるので早めに検出する。 - auto rep = pos.is_repetition(ply); - if (rep != REPETITION_NONE && ply >= 1) - { - // 千日手でPVを打ち切るときはその旨を表示 - ss << " " << rep; - break; - } - - Move m; - - // まず、rootMoves.pvを辿れるところまで辿る。 - // rootMoves[i].pv[0]は宣言勝ちの指し手(MOVE_WIN)の可能性があるので注意。 - if (ply < int(rootMoves[i].pv.size())) - m = rootMoves[i].pv[ply]; - else - { - // 次の手を置換表から拾う。 - // ただし置換表を破壊されるとbenchコマンドの時にシングルスレッドなのに探索内容の同一性が保証されなくて - // 困るのでread_probe()を用いる。 - bool found; - auto* tte = TT.read_probe(pos.state()->hash_key(), found); - - // 置換表になかった - if (!found) - break; - - m = pos.to_move(tte->move()); - - // leaf nodeはわりと高い確率でMOVE_NONE - if (m == MOVE_NONE) - break; - - // 置換表にはpsudo_legalではない指し手が含まれるのでそれを弾く。 - // 宣言勝ちでないならこれが合法手であるかのチェックが必要。 - if (m != MOVE_WIN) - { - // 歩の不成が読み筋に含まれていようともそれは表示できなくてはならないので - // pseudo_legal_s()を用いて判定。 - if (!(pos.pseudo_legal_s(m) && pos.legal(m))) - break; - } - } - -#if defined (USE_ENTERING_KING_WIN) - // 宣言勝ちである - if (m == MOVE_WIN) - { - // これが合法手であるなら宣言勝ちであると出力。 - if (pos.DeclarationWin() != MOVE_NONE) - ss << " " << MOVE_WIN; - - break; - } -#endif - // leaf node末尾にMOVE_RESIGNがあることはないが、 - // 詰み局面で呼び出されると1手先がmove resignなので、これでdo_move()するのは - // 非合法だから、do_move()せずにループを抜ける。 - if (!is_ok(m)) - { - ss << " " << m; - break; - } - - moves[ply] = m; - ss << " " << m; - - // 注) - // このdo_moveで Position::nodesが加算されるので探索ノード数に影響が出る。 - // benchコマンドで探索ノード数が一致しない場合、これが原因。 - // → benchコマンドでは、ConsiderationMode = falseにすることで - //  PV表示のためにdo_move()を呼び出さないようにした。 - - pos_->do_move(m, si[ply]); - ++ply; - } - while (ply > 0) - pos_->undo_move(moves[--ply]); - }; - - // 検討用のPVを出力するモードなら、置換表からPVをかき集める。 - // (そうしないとMultiPV時にPVが欠損することがあるようだ) - // fail-highのときにもPVを更新しているのが問題ではなさそう。 - // Stockfish側の何らかのバグかも。 - if (Search::Limits.consideration_mode) - out_tt_pv(); - else - out_array_pv(); - } - - return ss.str(); -#endif // defined(YANEURAOU_ENGINE_DEEP) - } -} - // -------------------- // USI関係のコマンド処理 // -------------------- diff --git a/source/usi.h b/source/usi.h index 5beb4f510..7418572a5 100644 --- a/source/usi.h +++ b/source/usi.h @@ -157,10 +157,6 @@ namespace USI // " 7g7f 8c8d" のように返る。 std::string move(const std::vector& moves); - // pv(読み筋)をUSIプロトコルに基いて出力する。 - // depth : 反復深化のiteration深さ。 - std::string pv(const Position& pos, Depth depth); - // 局面posとUSIプロトコルによる指し手を与えて // もし可能なら等価で合法な指し手を返す。 // 合法でないときはMOVE_NONEを返す。(この時、エラーである旨を出力する。)