From 8b0cef2785a27104aa66c07b509240ac059c999d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20C=C3=A1ceres?= Date: Mon, 27 May 2024 22:57:15 +0200 Subject: [PATCH] Add ponder support (#772) In case of ponderhit, the search is restarted with usual time limitations --- src/Lynx.Cli/appsettings.json | 1 + src/Lynx/Configuration.cs | 19 +-- src/Lynx/Engine.cs | 128 ++++++++++++------ src/Lynx/Search/IDDFS.cs | 7 +- src/Lynx/UCI/Commands/Engine/OptionCommand.cs | 1 + src/Lynx/UCI/Commands/GUI/GoCommand.cs | 9 +- src/Lynx/UCIHandler.cs | 13 +- 7 files changed, 108 insertions(+), 70 deletions(-) diff --git a/src/Lynx.Cli/appsettings.json b/src/Lynx.Cli/appsettings.json index 40ebef71f..9ddf6383a 100644 --- a/src/Lynx.Cli/appsettings.json +++ b/src/Lynx.Cli/appsettings.json @@ -15,6 +15,7 @@ "UseOnlineTablebaseInSearch": false, "OnlineTablebaseMaxSupportedPieces": 7, "ShowWDL": false, + "IsPonder": false, "SPSA_OB_R_end": 0.02, "HardTimeBoundMultiplier": 0.52, diff --git a/src/Lynx/Configuration.cs b/src/Lynx/Configuration.cs index 7be08b542..bb8702fea 100644 --- a/src/Lynx/Configuration.cs +++ b/src/Lynx/Configuration.cs @@ -12,7 +12,6 @@ public static class Configuration #pragma warning disable IDE1006 // Naming Styles private static int _UCI_AnalyseMode = 0; #pragma warning restore IDE1006 // Naming Styles - private static int _ponder = 0; public static bool IsDebug { @@ -46,22 +45,6 @@ public static bool UCI_AnalyseMode } } - public static bool IsPonder - { - get => Interlocked.CompareExchange(ref _ponder, 1, 1) == 1; - set - { - if (value) - { - Interlocked.CompareExchange(ref _ponder, 1, 0); - } - else - { - Interlocked.CompareExchange(ref _ponder, 0, 1); - } - } - } - public static int Hash { get => EngineSettings.TranspositionTableSize; @@ -105,6 +88,8 @@ public sealed class EngineSettings public bool ShowWDL { get; set; } = false; + public bool IsPonder { get; set; } = false; + public double SPSA_OB_R_end { get; set; } = 0.02; #region Time management diff --git a/src/Lynx/Engine.cs b/src/Lynx/Engine.cs index 0863b789a..6b75ee09f 100644 --- a/src/Lynx/Engine.cs +++ b/src/Lynx/Engine.cs @@ -14,21 +14,31 @@ public sealed partial class Engine private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); private readonly ChannelWriter _engineWriter; + private bool _isSearching; + + /// + /// Ongoing search is a pondering one and there has been a ponder hit + /// + private bool _isPonderHit; + + /// + /// Ongoing search is a pondering one + /// + private bool _isPondering; + #pragma warning disable IDE0052, CS0414, S4487 // Remove unread private members private bool _isNewGameCommandSupported; private bool _isNewGameComing; - private bool _isPondering; #pragma warning restore IDE0052, CS0414 // Remove unread private members private Move? _moveToPonder; + public double AverageDepth { get; private set; } public RegisterCommand? Registration { get; set; } public Game Game { get; private set; } - public bool IsSearching { get; private set; } - public bool PendingConfirmation { get; set; } private CancellationTokenSource _searchCancellationTokenSource; @@ -143,12 +153,14 @@ public void AdjustPosition(ReadOnlySpan rawPositionCommand) public void PonderHit() { - Game.MakeMove(_moveToPonder!.Value); // TODO ponder: do we also receive the position command? If so, remove this line - _isPondering = false; + _isPonderHit = true; + StopSearching(); } public SearchResult BestMove(GoCommand goCommand) { + bool isPondering = goCommand.Ponder; + _searchCancellationTokenSource = new(); _absoluteSearchCancellationTokenSource = new(); @@ -172,56 +184,64 @@ public SearchResult BestMove(GoCommand goCommand) // Inspired by Alexandria: time overhead to avoid timing out in the engine-gui communication process const int engineGuiCommunicationTimeOverhead = 50; - if (goCommand.WhiteTime != 0 || goCommand.BlackTime != 0) // Cutechess sometimes sends negative wtime/btime + if (!isPondering) { - const int minSearchTime = 50; - - var movesDivisor = goCommand.MovesToGo == 0 - ? Configuration.EngineSettings.DefaultMovesToGo - : goCommand.MovesToGo; + if (goCommand.WhiteTime != 0 || goCommand.BlackTime != 0) // Cutechess sometimes sends negative wtime/btime + { + const int minSearchTime = 50; - millisecondsLeft -= engineGuiCommunicationTimeOverhead; - millisecondsLeft = Math.Clamp(millisecondsLeft, minSearchTime, int.MaxValue); // Avoiding 0/negative values + var movesDivisor = goCommand.MovesToGo == 0 + ? Configuration.EngineSettings.DefaultMovesToGo + : goCommand.MovesToGo; - hardLimitTimeBound = (int)(millisecondsLeft * Configuration.EngineSettings.HardTimeBoundMultiplier); + millisecondsLeft -= engineGuiCommunicationTimeOverhead; + millisecondsLeft = Math.Clamp(millisecondsLeft, minSearchTime, int.MaxValue); // Avoiding 0/negative values - var softLimitBase = (millisecondsLeft / movesDivisor) + (millisecondsIncrement * Configuration.EngineSettings.SoftTimeBaseIncrementMultiplier); - softLimitTimeBound = Math.Min(hardLimitTimeBound, (int)(softLimitBase * Configuration.EngineSettings.SoftTimeBoundMultiplier)); + hardLimitTimeBound = (int)(millisecondsLeft * Configuration.EngineSettings.HardTimeBoundMultiplier); - _logger.Info("Soft time bound: {0}s", 0.001 * softLimitTimeBound); - _logger.Info("Hard time bound: {0}s", 0.001 * hardLimitTimeBound); + var softLimitBase = (millisecondsLeft / movesDivisor) + (millisecondsIncrement * Configuration.EngineSettings.SoftTimeBaseIncrementMultiplier); + softLimitTimeBound = Math.Min(hardLimitTimeBound, (int)(softLimitBase * Configuration.EngineSettings.SoftTimeBoundMultiplier)); - //return default!; + _logger.Info("Soft time bound: {0}s", 0.001 * softLimitTimeBound); + _logger.Info("Hard time bound: {0}s", 0.001 * hardLimitTimeBound); - _searchCancellationTokenSource.CancelAfter(hardLimitTimeBound); - } - else if (goCommand.MoveTime > 0) - { - softLimitTimeBound = hardLimitTimeBound = goCommand.MoveTime - engineGuiCommunicationTimeOverhead; - _logger.Info("Time to move: {0}s", 0.001 * hardLimitTimeBound); + _searchCancellationTokenSource.CancelAfter(hardLimitTimeBound); + } + else if (goCommand.MoveTime > 0) + { + softLimitTimeBound = hardLimitTimeBound = goCommand.MoveTime - engineGuiCommunicationTimeOverhead; + _logger.Info("Time to move: {0}s", 0.001 * hardLimitTimeBound); - _searchCancellationTokenSource.CancelAfter(hardLimitTimeBound); - } - else if (goCommand.Depth > 0) - { - maxDepth = goCommand.Depth > Constants.AbsoluteMaxDepth ? Constants.AbsoluteMaxDepth : goCommand.Depth; - } - else if (goCommand.Infinite) - { - maxDepth = Configuration.EngineSettings.MaxDepth; - _logger.Info("Infinite search (depth {0})", maxDepth); + _searchCancellationTokenSource.CancelAfter(hardLimitTimeBound); + } + else if (goCommand.Depth > 0) + { + maxDepth = goCommand.Depth > Constants.AbsoluteMaxDepth ? Constants.AbsoluteMaxDepth : goCommand.Depth; + } + else if (goCommand.Infinite) + { + maxDepth = Configuration.EngineSettings.MaxDepth; + _logger.Info("Infinite search (depth {0})", maxDepth); + } + else + { + maxDepth = DefaultMaxDepth; + _logger.Warn("Unexpected or unsupported go command"); + } } else { - maxDepth = DefaultMaxDepth; - _logger.Warn("Unexpected or unsupported go command"); + maxDepth = Configuration.EngineSettings.MaxDepth; + _logger.Info("Pondering search (depth {0})", maxDepth); } SearchResult resultToReturn = IDDFS(maxDepth, softLimitTimeBound); //SearchResult resultToReturn = await SearchBestMove(maxDepth, decisionTime); Game.ResetCurrentPositionToBeforeSearchState(); - if (resultToReturn.BestMove != default && !_absoluteSearchCancellationTokenSource.IsCancellationRequested) + if (!isPondering + && resultToReturn.BestMove != default + && !_absoluteSearchCancellationTokenSource.IsCancellationRequested) { Game.MakeMove(resultToReturn.BestMove); Game.UpdateInitialPosition(); @@ -276,19 +296,38 @@ private async ValueTask SearchBestMove(int maxDepth, int softLimit public void Search(GoCommand goCommand) { - if (IsSearching) + if (_isSearching) { _logger.Warn("Search already in progress"); } - - _isPondering = goCommand.Ponder; - IsSearching = true; + _isSearching = true; Thread.CurrentThread.Priority = ThreadPriority.Highest; try { + _isPondering = goCommand.Ponder; var searchResult = BestMove(goCommand); + + if (_isPondering) + { + // Using either field or local copy for the rest of the method, since goCommand.Ponder could change + + // Avoiding the scenario where search finishes early (i.e. mate detected, max depth reached) and results comes + // before a potential ponderhit command + SpinWait.SpinUntil(() => _isPonderHit || _absoluteSearchCancellationTokenSource.IsCancellationRequested); + + if (_isPonderHit) + { + _isPonderHit = false; + _isPondering = false; + goCommand.DisablePonder(); + + searchResult = BestMove(goCommand); + } + } + + // We print best move even in case of go pondeer + stop, and IDEs are expected to ignore it _moveToPonder = searchResult.Moves.Count >= 2 ? searchResult.Moves[1] : null; _engineWriter.TryWrite(BestMoveCommand.BestMove(searchResult.BestMove, _moveToPonder)); } @@ -298,15 +337,14 @@ public void Search(GoCommand goCommand) } finally { - IsSearching = false; + _isSearching = false; + _isPondering = false; } - // TODO ponder: if ponder, continue with PonderAction, which is searching indefinitely for a move } public void StopSearching() { _absoluteSearchCancellationTokenSource.Cancel(); - IsSearching = false; } private void InitializeTT() diff --git a/src/Lynx/Search/IDDFS.cs b/src/Lynx/Search/IDDFS.cs index e6490744e..a7cbb8c47 100644 --- a/src/Lynx/Search/IDDFS.cs +++ b/src/Lynx/Search/IDDFS.cs @@ -307,7 +307,12 @@ private SearchResult GenerateFinalSearchResult(SearchResult? lastSearchResult, SearchResult finalSearchResult; if (lastSearchResult is null) { - _logger.Warn("Search cancelled at depth 1, choosing first found legal move as best one"); + // In the event of a quick ponderhit/stop while pondering because the opponent moved quickly, we don't want no warning triggered here + // when cancelling the pondering search + if (!_isPondering) + { + _logger.Warn("Search cancelled at depth 1, choosing first found legal move as best one"); + } finalSearchResult = new(firstLegalMove, 0, 0, [firstLegalMove], alpha, beta); } else diff --git a/src/Lynx/UCI/Commands/Engine/OptionCommand.cs b/src/Lynx/UCI/Commands/Engine/OptionCommand.cs index 14e484d18..4c1f2b54a 100644 --- a/src/Lynx/UCI/Commands/Engine/OptionCommand.cs +++ b/src/Lynx/UCI/Commands/Engine/OptionCommand.cs @@ -131,6 +131,7 @@ public sealed class OptionCommand : EngineBaseCommand $"option name Hash type spin default {Configuration.EngineSettings.TranspositionTableSize} min {Constants.AbsoluteMinTTSize} max {Constants.AbsoluteMaxTTSize}", $"option name OnlineTablebaseInRootPositions type check default {Configuration.EngineSettings.UseOnlineTablebaseInRootPositions}", "option name Threads type spin default 1 min 1 max 1", + $"option name Ponder type check default {Configuration.EngineSettings.IsPonder}", .. Configuration.GeneralSettings.EnableTuning ? SPSAAttributeHelpers.GenerateOptionStrings() : [] ]; diff --git a/src/Lynx/UCI/Commands/GUI/GoCommand.cs b/src/Lynx/UCI/Commands/GUI/GoCommand.cs index 601fdc093..1430e3f88 100644 --- a/src/Lynx/UCI/Commands/GUI/GoCommand.cs +++ b/src/Lynx/UCI/Commands/GUI/GoCommand.cs @@ -64,7 +64,7 @@ public sealed partial class GoCommand : GUIBaseCommand public int Depth { get; } public int MoveTime { get; } public bool Infinite { get; } - public bool Ponder { get; } + public bool Ponder { get; private set; } public int Nodes => throw new NotImplementedException(); public int Mate => throw new NotImplementedException(); @@ -104,7 +104,10 @@ public GoCommand(string command) Infinite = true; break; case "ponder": - Ponder = true; + if (Configuration.EngineSettings.IsPonder) + { + Ponder = true; + } break; case "depth": Depth = int.Parse(group.Value); @@ -121,4 +124,6 @@ public GoCommand(string command) } public static string Init() => Id; + + public void DisablePonder() => Ponder = false; } diff --git a/src/Lynx/UCIHandler.cs b/src/Lynx/UCIHandler.cs index 5cde61708..6688abf60 100644 --- a/src/Lynx/UCIHandler.cs +++ b/src/Lynx/UCIHandler.cs @@ -3,10 +3,10 @@ using Lynx.UCI.Commands.GUI; using NLog; using System.Diagnostics; -using System.Reflection; using System.Runtime.Intrinsics.X86; using System.Text.Json; using System.Text.Json.Nodes; +using System.Threading; using System.Threading.Channels; namespace Lynx; @@ -161,10 +161,14 @@ private async Task HandleUCI(CancellationToken cancellationToken) private void HandlePonderHit() { - if (Configuration.IsPonder) + if (Configuration.EngineSettings.IsPonder) { _engine.PonderHit(); } + else + { + _logger.Warn("Unexpected 'ponderhit' command, given pondering is disabled. Ignoring it"); + } } private void HandleSetOption(ReadOnlySpan command) @@ -186,9 +190,8 @@ private void HandleSetOption(ReadOnlySpan command) { if (length > 4 && bool.TryParse(command[commandItems[4]], out var value)) { - Configuration.IsPonder = value; + Configuration.EngineSettings.IsPonder = value; } - _logger.Warn("Ponder not supported yet"); break; } case "uci_analysemode": @@ -608,7 +611,7 @@ private async Task HandleFEN(CancellationToken cancellationToken) private async ValueTask HandleOpenBenchSPSA(CancellationToken cancellationToken) { - foreach(var tunableValue in SPSAAttributeHelpers.GenerateOpenBenchStrings()) + foreach (var tunableValue in SPSAAttributeHelpers.GenerateOpenBenchStrings()) { await SendCommand(tunableValue, cancellationToken); }