/// <summary> /// Returns limits to be used for the special case of first move of a game /// (more time is allocated because there is no tree use available). /// </summary> /// <param name="inputs"></param> /// <param name="estNumMovesLeftHard"></param> /// <returns></returns> ManagerGameLimitOutputs MoveTimeForFirstMove(ManagerGameLimitInputs inputs, float estNumMovesLeftHard) { float fractionFirstMove = 0.05f; float targetUnits = inputs.RemainingFixedSelf * fractionFirstMove; return(new ManagerGameLimitOutputs(new SearchLimit(inputs.TargetLimitType, targetUnits))); }
/// Determines how much time or nodes resource to /// allocate to the the current move in a game subject to /// a limit on total numbrer of time or nodes over /// some number of moves (or possibly all moves). public ManagerGameLimitOutputs ComputeMoveAllocation(ManagerGameLimitInputs inputs) { if (inputs.MaxMovesToGo.HasValue && inputs.MaxMovesToGo < 2) { return(new ManagerGameLimitOutputs(new SearchLimit(inputs.TargetLimitType, inputs.RemainingFixedSelf * 0.99f))); } ManagerGameLimitOutputs outputs = DoComputeMoveTime(inputs); return(outputs); }
/// Determines how much time or nodes resource to /// allocate to the the current move in a game subject to /// a limit on total numbrer of time or nodes over /// some number of moves (or possibly all moves). public ManagerGameLimitOutputs ComputeMoveAllocation(ManagerGameLimitInputs inputs) { if (inputs.MaxMovesToGo.HasValue && inputs.MaxMovesToGo < 2) { return(new ManagerGameLimitOutputs(new SearchLimit(inputs.TargetLimitType, inputs.RemainingFixedSelf * 0.99f))); } return(new ManagerGameLimitOutputs(new SearchLimit(inputs.TargetLimitType, inputs.RemainingFixedSelf * FRACTION_PER_MOVE + inputs.IncrementSelf))); }
float EstimatedNumDifficultMovesToGo(ManagerGameLimitInputs inputs) { int numPieces = inputs.StartPos.PieceCount; // On estimate of the number of moves left comes from the piece count. // This method is likely imprecise, but is always available. // We subtract 6 from number of pieces since game is mostly or completely // over when 6 pieces are recahed (either via tablebases or very simple play). float estNumMovesLeftPieces = Math.Max(5, (numPieces - 6) * 2); #if NOT // Attempts at using MLH were not immediately successful. float newM = inputs.Manager.Root.MAvg; // As a second estimate, use the output of the moves left head estimate float estNumMovesLeftMLH = (newM / 2); if (estNumMovesLeftMLH > 50) { // Shrink outlier very large estimates (more than 50 moves to go) estNumMovesLeftMLH = 50 + MathF.Sqrt(estNumMovesLeftMLH - 50); } // Compute the aggregate estimate (equal weight combo from both if available) // if (inputs.RootN == 0 || float.IsNaN(estNumMovesLeftMLH)) // else // estNumMovesLeft = Stats.Average(estNumMovesLeftPieces, estNumMovesLeftMLH); #endif float estNumMovesLeft = estNumMovesLeftPieces; // We assume that not all of these moves will be "hard", // e.g. at end of game the moves may become easier // due to tablebases or more transpositions available, // or narrower search trees due to simpler positions. const float MULTIPLIER = 0.65f; if (inputs.MaxMovesToGo.HasValue) { // Never assume more than 90% of remaining moves are hard float maxFromToGo = inputs.MaxMovesToGo.Value * 0.9f; float maxDefault = MULTIPLIER * estNumMovesLeft; return(Math.Min(maxDefault, maxFromToGo)); } else { return(MULTIPLIER * estNumMovesLeft); } }
/// <summary> /// Main algorithm for determining amount of resources to allocate to this node. /// </summary> /// <param name="inputs"></param> /// <returns></returns> ManagerGameLimitOutputs DoComputeMoveTime(ManagerGameLimitInputs inputs) { // Use more frontloading of early stopping enabled so we don't understoot in time spent float frontloadingAggressivemessMult = inputs.SearchParams.FutilityPruningStopSearchEnabled ? 1.5f : 1.3f; frontloadingAggressivemessMult *= inputs.SearchParams.GameLimitUsageAggressiveness; float estNumMovesLeftHard = EstimatedNumDifficultMovesToGo(inputs); // Special handling of first move if (inputs.IsFirstMoveOfGame) { return(MoveTimeForFirstMove(inputs, estNumMovesLeftHard)); } // When we are behind then it's worth taking a gamble and using more time // but when we are ahead, take a little less time to be sure we don't err in time pressure. // Testing suggests this feature is helpful (circa 10 elo?) float winningnessMultiplier = inputs.RootQ switch {
/// <summary> /// Determines what fraction of the base move should /// be consumed for this move. /// </summary> /// <param name="inputs"></param> /// <returns></returns> float FractionOfBasePerMoveToUse(ManagerGameLimitInputs inputs) { float factorLargeIncrement = 1.0f; const float MAX_LARGE_INCREMENT_MULTIPLIER = 2.0f; if (inputs.IncrementSelf > 0) { float estBasePerMove = inputs.RemainingFixedSelf / 30; float incrementRelativeToBasePerMove = inputs.IncrementSelf / estBasePerMove; // The more increment relative to base time, the more aggressively the base time is used up // (because we don't have to worry about running out of time if game runs to many moves). factorLargeIncrement = 1.0f + incrementRelativeToBasePerMove; // Don't allow multiplier to be too large, since it ultimately has an exponentially increasing impact. factorLargeIncrement = MathF.Min(MAX_LARGE_INCREMENT_MULTIPLIER, factorLargeIncrement); } // When we are behind then it's worth taking a gamble and using more time // but when we are ahead, take a little less time to be sure we don't err in time pressure. float factorWinningness = inputs.RootQ switch {
/// <summary> /// Determines what fraction of the base move should /// be consumed for this move. /// </summary> /// <param name="inputs"></param> /// <returns></returns> float FractionOfBasePerMoveToUse(ManagerGameLimitInputs inputs) { float factorLargeIncrement = 1.0f; if (inputs.IncrementSelf > 0) { // With increasingly significant increment, // frontload the use of base time more aggressively // because we don't have to worry about // "running out of time." const float MAX_LARGE_INCREMENT_MULTIPLIER = 2.5f; float estBasePerMove = inputs.RemainingFixedSelf / 30; float incrementRelativeToBasePerMove = inputs.IncrementSelf / estBasePerMove; if (incrementRelativeToBasePerMove > 0.2) { factorLargeIncrement = 1.0f + 0.5f * ((incrementRelativeToBasePerMove - 0.2f) / 0.3f); factorLargeIncrement = MathF.Min(MAX_LARGE_INCREMENT_MULTIPLIER, factorLargeIncrement); } } // When we are behind then it's worth taking a gamble and using more time // but when we are ahead, take a little less time to be sure we don't err in time pressure. float factorWinningness = inputs.RootQ switch {
/// <summary> /// /// </summary> /// <param name="store"></param> /// <param name="reuseOtherContextForEvaluatedNodes"></param> /// <param name="reusePositionCache"></param> /// <param name="reuseTranspositionRoots"></param> /// <param name="nnEvaluators"></param> /// <param name="searchParams"></param> /// <param name="childSelectParams"></param> /// <param name="searchLimit"></param> /// <param name="paramsSearchExecutionPostprocessor"></param> /// <param name="limitManager"></param> /// <param name="startTime"></param> /// <param name="priorManager"></param> /// <param name="gameMoveHistory"></param> /// <param name="isFirstMoveOfGame"></param> public MCTSManager(MCTSNodeStore store, MCTSIterator reuseOtherContextForEvaluatedNodes, PositionEvalCache reusePositionCache, TranspositionRootsDict reuseTranspositionRoots, NNEvaluatorSet nnEvaluators, ParamsSearch searchParams, ParamsSelect childSelectParams, SearchLimit searchLimit, ParamsSearchExecutionModifier paramsSearchExecutionPostprocessor, IManagerGameLimit limitManager, DateTime startTime, MCTSManager priorManager, List <GameMoveStat> gameMoveHistory, bool isFirstMoveOfGame) { StartTimeThisSearch = startTime; RootNWhenSearchStarted = store.Nodes.nodes[store.RootIndex.Index].N; ParamsSearchExecutionPostprocessor = paramsSearchExecutionPostprocessor; IsFirstMoveOfGame = isFirstMoveOfGame; PriorMoveStats = new List <GameMoveStat>(); // Make our own copy of move history. if (gameMoveHistory != null) { PriorMoveStats.AddRange(gameMoveHistory); } // Possibly convert time limit per game into time for this move. if (searchLimit.IsPerGameLimit) { SearchLimitType type = searchLimit.Type == SearchLimitType.SecondsForAllMoves ? SearchLimitType.SecondsPerMove : SearchLimitType.NodesPerMove; float rootQ = priorManager == null ? float.NaN : (float)store.RootNode.Q; limitsManagerInputs = new(store.Nodes.PriorMoves.FinalPosition, searchParams, PriorMoveStats, type, store.RootNode.N, rootQ, searchLimit.Value, searchLimit.ValueIncrement, float.NaN, float.NaN, maxMovesToGo : searchLimit.MaxMovesToGo, isFirstMoveOfGame : isFirstMoveOfGame); ManagerGameLimitOutputs timeManagerOutputs = limitManager.ComputeMoveAllocation(limitsManagerInputs); SearchLimit = timeManagerOutputs.LimitTarget; } else { SearchLimit = searchLimit; } // Possibly autoselect new optimal parameters ParamsSearchExecutionChooser paramsChooser = new ParamsSearchExecutionChooser(nnEvaluators.EvaluatorDef, searchParams, childSelectParams, searchLimit); // TODO: technically this is overwriting the params belonging to the prior search, that's ugly (but won't actually cause a problem) paramsChooser.ChooseOptimal(searchLimit.EstNumNodes(50_000, false), paramsSearchExecutionPostprocessor); // TODO: make 50_000 smarter int estNumNodes = EstimatedNumSearchNodesForEvaluator(searchLimit, nnEvaluators); // Adjust the nodes estimate if we are continuing an existing search if (searchLimit.Type == SearchLimitType.NodesPerMove && RootNWhenSearchStarted > 0) { estNumNodes = Math.Max(0, estNumNodes - RootNWhenSearchStarted); } Context = new MCTSIterator(store, reuseOtherContextForEvaluatedNodes, reusePositionCache, reuseTranspositionRoots, nnEvaluators, searchParams, childSelectParams, searchLimit, estNumNodes); ThreadSearchContext = Context; TerminationManager = new MCTSFutilityPruning(this, Context); LimitManager = limitManager; CeresEnvironment.LogInfo("MCTS", "Init", $"SearchManager created for store {store}", InstanceID); }