예제 #1
0
파일: Program.cs 프로젝트: miroskv/Ceres
        /// <summary>
        /// Startup method for Ceres UCI chess engine and supplemental features.
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
#if DEBUG
            Console.WriteLine();
            ConsoleUtils.WriteLineColored(ConsoleColor.Red, "*** WARNING: Ceres binaries built in Debug mode and will run much more slowly than Release");
#endif

            OutputBanner();
            CheckRecursiveOverflow();
            HardwareManager.VerifyHardwareSoftwareCompatability();

            // Load (or cause to be created) a settings file.
            if (!CeresUserSettingsManager.DefaultConfigFileExists)
            {
                Console.WriteLine();
                ConsoleUtils.WriteLineColored(ConsoleColor.Red, $"*** NOTE: Configuration file {CeresUserSettingsManager.DefaultCeresConfigFileName} not found in working directory.");
                Console.WriteLine();
                Console.WriteLine($"Prompting to for required values to initialize:");
                CeresUserSettingsManager.DoSetupInitialize();
            }

            // Configure logging level
            const bool LOG = false;
            CeresEnvironment.MONITORING_EVENTS = LOG;
            LogLevel    logLevel    = LOG ? LogLevel.Information : LogLevel.Critical;
            LoggerTypes loggerTypes = LoggerTypes.WinDebugLogger | LoggerTypes.ConsoleLogger;
            CeresEnvironment.Initialize(loggerTypes, logLevel);

            CeresEnvironment.MONITORING_METRICS = CeresUserSettingsManager.Settings.LaunchMonitor;

            //      if (CeresUserSettingsManager.Settings.DirLC0Networks != null)
            //        NNWeightsFilesLC0.RegisterDirectory(CeresUserSettingsManager.Settings.DirLC0Networks);

            MCTSEngineInitialization.BaseInitialize();


            Console.WriteLine();

#if DEBUG
            CheckDebugAllowed();
#endif

            if (args.Length > 0 && args[0].ToUpper() == "CUSTOM")
            {
                TournamentTest.Test(); return;
                //        SuiteTest.RunSuiteTest(); return;
            }

            StringBuilder allArgs = new StringBuilder();
            for (int i = 0; i < args.Length; i++)
            {
                allArgs.Append(args[i] + " ");
            }
            string allArgsString = allArgs.ToString();

            DispatchCommands.ProcessCommand(allArgsString);


            //  Win32.WriteCrashdumpFile(@"d:\temp\dump.dmp");
        }
예제 #2
0
        /// <summary>
        /// Constructor to create a store of specified maximum size.
        /// </summary>
        /// <param name="maxNodes"></param>
        /// <param name="priorMoves"></param>
        public MCTSNodeStore(int maxNodes, PositionWithHistory priorMoves = null)
        {
            if (priorMoves == null)
            {
                priorMoves = PositionWithHistory.StartPosition;
            }

            MaxNodes = maxNodes;
            int allocNodes = maxNodes;

            Nodes = new MCTSNodeStructStorage(allocNodes, null,
                                              MCTSParamsFixed.STORAGE_USE_INCREMENTAL_ALLOC,
                                              MCTSParamsFixed.STORAGE_LARGE_PAGES,
                                              MCTSParamsFixed.STORAGE_USE_EXISTING_SHARED_MEM);

            long reserveChildren = maxNodes * (long)AVG_CHILDREN_PER_NODE;

            Children = new MCTSNodeStructChildStorage(this, reserveChildren);

            // Save a copy of the prior moves
            Nodes.PriorMoves = new PositionWithHistory(priorMoves);

            CeresEnvironment.LogInfo("NodeStore", "Init", $"MCTSNodeStore created with max {maxNodes} nodes, max {reserveChildren} children");

            MCTSNodeStruct.ValidateMCTSNodeStruct();
            RootIndex = new MCTSNodeStructIndex(1);
        }
예제 #3
0
        /// <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)
        {
            if (searchLimit.IsPerGameLimit)
            {
                throw new Exception("Per game search limits not supported");
            }

            StartTimeThisSearch                = startTime;
            RootNWhenSearchStarted             = store.Nodes.nodes[store.RootIndex.Index].N;
            ParamsSearchExecutionPostprocessor = paramsSearchExecutionPostprocessor;
            IsFirstMoveOfGame = isFirstMoveOfGame;
            SearchLimit       = searchLimit;


            // Make our own copy of move history.
            PriorMoveStats = new List <GameMoveStat>();
            if (gameMoveHistory != null)
            {
                PriorMoveStats.AddRange(gameMoveHistory);
            }

            // 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, searchLimit.SearchMoves);
            LimitManager       = limitManager;

            CeresEnvironment.LogInfo("MCTS", "Init", $"SearchManager created for store {store}", InstanceID);
        }
예제 #4
0
        /// <summary>
        /// Runs a search, possibly continuing from node
        /// nested in a prior search (tree reuse).
        /// </summary>
        /// <param name="priorSearch"></param>
        /// <param name="reuseOtherContextForEvaluatedNodes"></param>
        /// <param name="moves"></param>
        /// <param name="newPositionAndMoves"></param>
        /// <param name="gameMoveHistory"></param>
        /// <param name="searchLimit"></param>
        /// <param name="verbose"></param>
        /// <param name="startTime"></param>
        /// <param name="progressCallback"></param>
        /// <param name="thresholdMinFractionNodesRetained"></param>
        /// <param name="isFirstMoveOfGame"></param>
        public void SearchContinue(MCTSearch priorSearch,
                                   MCTSIterator reuseOtherContextForEvaluatedNodes,
                                   IEnumerable <MGMove> moves, PositionWithHistory newPositionAndMoves,
                                   List <GameMoveStat> gameMoveHistory,
                                   SearchLimit searchLimit,
                                   bool verbose, DateTime startTime,
                                   MCTSManager.MCTSProgressCallback progressCallback,
                                   float thresholdMinFractionNodesRetained,
                                   bool isFirstMoveOfGame = false)
        {
            CountSearchContinuations = priorSearch.CountSearchContinuations;
            Manager = priorSearch.Manager;

            MCTSIterator  priorContext    = Manager.Context;
            MCTSNodeStore store           = priorContext.Tree.Store;
            int           numNodesInitial = Manager == null ? 0 : Manager.Root.N;

            MCTSNodeStructIndex newRootIndex;

            using (new SearchContextExecutionBlock(priorContext))
            {
                MCTSNode newRoot = FollowMovesToNode(Manager.Root, moves);

                // New root is not useful if contained no search
                // (for example if it was resolved via tablebase)
                // thus in that case we pretend as if we didn't find it
                if (newRoot != null && (newRoot.N == 0 || newRoot.NumPolicyMoves == 0))
                {
                    newRoot = null;
                }

                // Check for possible instant move
                (MCTSManager, MGMove, TimingStats)instamove = CheckInstamove(Manager, searchLimit, newRoot);

                if (instamove != default)
                {
                    // Modify in place to point to the new root
                    continationSubroot = newRoot;
                    BestMove           = instamove.Item2;
                    TimingInfo         = new TimingStats();
                    return;
                }
                else
                {
                    CountSearchContinuations = 0;
                }

                // TODO: don't reuse tree if it would cause the nodes in use
                //       to exceed a reasonable value for this machine
#if NOT
// NOTE: abandoned, small subtrees will be fast to rewrite so we can always do this
                // Only rewrite the store with the subtree reused
                // if it is not tiny relative to the current tree
                // (otherwise the scan/rewrite is not worth it
                float       fracTreeReuse        = newRoot.N / store.Nodes.NumUsedNodes;
                const float THRESHOLD_REUSE_TREE = 0.02f;
#endif
                // Inform contempt manager about the opponents move
                // (compared to the move we believed was optimal)
                if (newRoot != null && newRoot.Depth == 2)
                {
                    MCTSNode opponentsPriorMove = newRoot;
                    MCTSNode bestMove           = opponentsPriorMove.Parent.ChildrenSorted(n => (float)n.Q)[0];
                    if (bestMove.N > opponentsPriorMove.N / 10)
                    {
                        float bestQ   = (float)bestMove.Q;
                        float actualQ = (float)opponentsPriorMove.Q;
                        Manager.Context.ContemptManager.RecordOpponentMove(actualQ, bestQ);
                        //Console.WriteLine("Record " + actualQ + " vs best " + bestQ + " target contempt " + priorManager.Context.ContemptManager.TargetContempt);
                    }
                }

                bool storeIsAlmostFull          = priorContext.Tree.Store.FractionInUse > 0.9f;
                bool newRootIsBigEnoughForReuse = newRoot != null && newRoot.N >= (priorContext.Root.N * thresholdMinFractionNodesRetained);
                if (priorContext.ParamsSearch.TreeReuseEnabled && newRootIsBigEnoughForReuse && !storeIsAlmostFull)
                {
                    SearchLimit searchLimitAdjusted = searchLimit;

                    if (Manager.Context.ParamsSearch.Execution.TranspositionMode != TranspositionMode.None)
                    {
                        // The MakeChildNewRoot method is not able to handle transposition linkages
                        // (this would be complicated and could involve linkages to nodes no longer in the retained subtree).
                        // Therefore we first materialize any transposition linked nodes in the subtree.
                        // Since this is not currently multithreaded we can turn off tree node locking for the duration.
                        newRoot.Tree.ChildCreateLocks.LockingActive = false;
                        newRoot.MaterializeAllTranspositionLinks();
                        newRoot.Tree.ChildCreateLocks.LockingActive = true;
                    }

                    // Now rewrite the tree nodes and children "in situ"
                    PositionEvalCache reusePositionCache = null;
                    if (Manager.Context.ParamsSearch.TreeReuseRetainedPositionCacheEnabled)
                    {
                        reusePositionCache = new PositionEvalCache(0);
                    }

                    TranspositionRootsDict newTranspositionRoots = null;
                    if (priorContext.Tree.TranspositionRoots != null)
                    {
                        int estNumNewTranspositionRoots = newRoot.N + newRoot.N / 3; // somewhat oversize to allow for growth in subsequent search
                        newTranspositionRoots = new TranspositionRootsDict(estNumNewTranspositionRoots);
                    }

                    // TODO: Consider sometimes or always skip rebuild via MakeChildNewRoot,
                    //       instead just set a new root (move it into place as first node).
                    //       Perhaps rebuild only if the MCTSNodeStore would become excessively large.
                    TimingStats makeNewRootTimingStats = new TimingStats();
                    using (new TimingBlock(makeNewRootTimingStats, TimingBlock.LoggingType.None))
                    {
                        MCTSNodeStructStorage.MakeChildNewRoot(store, Manager.Context.ParamsSelect.PolicySoftmax, ref newRoot.Ref, newPositionAndMoves,
                                                               reusePositionCache, newTranspositionRoots);
                    }
                    MCTSManager.TotalTimeSecondsInMakeNewRoot += (float)makeNewRootTimingStats.ElapsedTimeSecs;

                    CeresEnvironment.LogInfo("MCTS", "MakeChildNewRoot", $"Select {newRoot.N:N0} from {numNodesInitial:N0} "
                                             + $"in {(int)(makeNewRootTimingStats.ElapsedTimeSecs/1000.0)}ms");

                    // Finally if nodes adjust based on current nodes
                    if (searchLimit.Type == SearchLimitType.NodesPerMove)
                    {
                        searchLimitAdjusted = new SearchLimit(SearchLimitType.NodesPerMove, searchLimit.Value + store.RootNode.N);
                    }

                    // Construct a new search manager reusing this modified store and modified transposition roots
                    MCTSManager manager = new MCTSManager(store, reuseOtherContextForEvaluatedNodes, reusePositionCache, newTranspositionRoots,
                                                          priorContext.NNEvaluators, priorContext.ParamsSearch, priorContext.ParamsSelect,
                                                          searchLimitAdjusted, Manager.ParamsSearchExecutionPostprocessor, Manager.LimitManager,
                                                          startTime, Manager, gameMoveHistory, isFirstMoveOfGame: isFirstMoveOfGame);
                    manager.Context.ContemptManager = priorContext.ContemptManager;

                    bool possiblyUsePositionCache = false; // TODO could this be relaxed?
                    (MGMove move, TimingStats stats)result = MCTSManager.Search(manager, verbose, progressCallback, possiblyUsePositionCache);
                    BestMove   = result.move;
                    TimingInfo = result.stats;
                    Manager    = manager;
                }

                else
                {
                    // We decided not to (or couldn't find) that path in the existing tree
                    // Just run the search from scratch
                    if (verbose)
                    {
                        Console.WriteLine("\r\nFailed nSearchFollowingMoves.");
                    }

                    Search(Manager.Context.NNEvaluators, Manager.Context.ParamsSelect,
                           Manager.Context.ParamsSearch, Manager.LimitManager,
                           null, reuseOtherContextForEvaluatedNodes, newPositionAndMoves, searchLimit, verbose,
                           startTime, gameMoveHistory, progressCallback, false);
                }
            }


#if NOT
            // This code partly or completely works
            // We don't rely upon it because it could result in uncontained growth of the store,
            // since detached nodes are left
            // But if the subtree chosen is almost the whole tree, maybe we could indeed use this techinque as an alternate in these cases
            if (store.ResetRootAssumingMovesMade(moves, thresholdFractionNodesRetained))
            {
                SearchManager manager = new SearchManager(store, priorContext.ParamsNN,
                                                          priorContext.ParamsSearch, priorContext.ParamsSelect,
                                                          null, limit);
                manager.Context.TranspositionRoots = priorContext.TranspositionRoots;

                return(Search(manager, false, verbose, progressCallback, false));
            }
#endif
        }
예제 #5
0
        /// <summary>
        /// Constructor.
        /// </summary>
        /// <param name="store"></param>
        /// <param name="nnParams"></param>
        /// <param name="searchParams"></param>
        /// <param name="childSelectParams"></param>
        /// <param name="priorMoves">if null, the prior moves are taken from the passed store</param>
        /// <param name="searchLimit"></param>
        public MCTSManager(MCTSNodeStore store,
                           MCTSIterator reuseOtherContextForEvaluatedNodes,
                           PositionEvalCache reusePositionCache,
                           TranspositionRootsDict reuseTranspositionRoots,
                           NNEvaluatorSet nnEvaluators,
                           ParamsSearch searchParams,
                           ParamsSelect childSelectParams,
                           SearchLimit searchLimit,
                           ParamsSearchExecutionModifier paramsSearchExecutionPostprocessor,
                           IManagerGameLimit timeManager,
                           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;
                ManagerGameLimitInputs timeManagerInputs = 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 = timeManager.ComputeMoveAllocation(timeManagerInputs);
                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), 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       = timeManager;

            CeresEnvironment.LogInfo("MCTS", "Init", $"SearchManager created for store {store}", InstanceID);
        }
예제 #6
0
        // Algorithm
        //   priorEvaluateTask <- null
        //   priorNodesNN <- null
        //   do
        //   {
        //     // Select new nodes
        //     newNodes <- Select()
        //
        //     // Remove any which may have been already selected by alternate selector
        //     newNodes <- Deduplicate(newNodes, priorNodesNN)
        //
        //     // Check for those that can be immediately evaluated and split them out
        //     (newNodesNN, newNodesImm) <- TryEvalImmediateAndPartition(newNodes)
        //     if (OUT_OF_ORDER_ENABLED) BackupApply(newNodesImm)
        //
        //     // Launch evaluation of new nodes which need NN evaluation
        //     newEvaluateTask <- new Task(Evaluate(newNodesNN))
        //
        //     // Wait for prior NN evaluation to finish and apply nodes
        //     if (priorEvaluateTask != null)
        //     {
        //       priorNodesNN <- Wait(priorEvaluateTask)
        //       BackupApply(priorNodesNN)
        //     }
        //
        //     if (!OUT_OF_ORDER_ENABLED) BackupApply(newNodesImm)
        //
        //     // Prepare to cycle again
        //     priorEvaluateTask <- newEvaluateTask
        //   } until (end of search)
        //
        //   // Finalize last batch
        //   priorNodesNN <- Wait(priorEvaluateTask)
        //   BackupApply(priorNodesNN)


        public void ProcessDirectOverlapped(MCTSManager manager, int hardLimitNumNodes, int startingBatchSequenceNum, int?forceBatchSize)
        {
            Debug.Assert(!manager.Root.IsInFlight);
            if (hardLimitNumNodes == 0)
            {
                hardLimitNumNodes = 1;
            }

            bool overlappingAllowed = Context.ParamsSearch.Execution.FlowDirectOverlapped;
            int  initialRootN       = Context.Root.N;

            int guessMaxNumLeaves = MCTSParamsFixed.MAX_NN_BATCH_SIZE;

            ILeafSelector selector1;
            ILeafSelector selector2;

            selector1 = new LeafSelectorMulti(Context, 0, Context.StartPosAndPriorMoves, guessMaxNumLeaves);
            int secondSelectorID = Context.ParamsSearch.Execution.FlowDualSelectors ? 1 : 0;

            selector2 = overlappingAllowed ? new LeafSelectorMulti(Context, secondSelectorID, Context.StartPosAndPriorMoves, guessMaxNumLeaves) : null;

            MCTSNodesSelectedSet[] nodesSelectedSets = new MCTSNodesSelectedSet[overlappingAllowed ? 2 : 1];
            for (int i = 0; i < nodesSelectedSets.Length; i++)
            {
                nodesSelectedSets[i] = new MCTSNodesSelectedSet(Context,
                                                                i == 0 ? (LeafSelectorMulti)selector1
                                                               : (LeafSelectorMulti)selector2,
                                                                guessMaxNumLeaves, guessMaxNumLeaves, BlockApply,
                                                                Context.ParamsSearch.Execution.InFlightThisBatchLinkageEnabled,
                                                                Context.ParamsSearch.Execution.InFlightOtherBatchLinkageEnabled);
            }

            int selectorID       = 0;
            int batchSequenceNum = startingBatchSequenceNum;

            Task <MCTSNodesSelectedSet> overlappingTask        = null;
            MCTSNodesSelectedSet        pendingOverlappedNodes = null;
            int numOverlappedNodesImmediateApplied             = 0;

            int iterationCount = 0;
            int numSelected    = 0;
            int nodesLastSecondaryNetEvaluation = 0;

            while (true)
            {
                // Only start overlapping past 1000 nodes because
                // CPU latency will be very small at small tree sizes,
                // obviating the overlapping beneifts of hiding this latency
                bool overlapThisSet = overlappingAllowed && Context.Root.N > 2000;

                iterationCount++;

                ILeafSelector selector = selectorID == 0 ? selector1 : selector2;

                float thisBatchDynamicVLossBoost = batchingManagers[selectorID].VLossDynamicBoostForSelector();

                // Call progress callback and check if reached search limit
                Context.ProgressCallback?.Invoke(manager);
                Manager.UpdateSearchStopStatus();
                if (Manager.StopStatus != MCTSManager.SearchStopStatus.Continue)
                {
                    break;
                }

                int numCurrentlyOverlapped = Context.Root.NInFlight + Context.Root.NInFlight2;

                int numApplied = Context.Root.N - initialRootN;
                int hardLimitNumNodesThisBatch = int.MaxValue;
                if (hardLimitNumNodes > 0)
                {
                    // Subtract out number already applied or in flight
                    hardLimitNumNodesThisBatch = hardLimitNumNodes - (numApplied + numCurrentlyOverlapped);

                    // Stop search if we have already exceeded search limit
                    // or if remaining number is very small relative to full search
                    // (this avoids incurring latency with a few small batches at end of a search).
                    if (hardLimitNumNodesThisBatch <= numApplied / 1000)
                    {
                        break;
                    }
                }

                //          Console.WriteLine($"Remap {targetThisBatch} ==> {Context.Root.N} {TargetBatchSize(Context.EstimatedNumSearchNodes, Context.Root.N)}");
                int targetThisBatch = OptimalBatchSizeCalculator.CalcOptimalBatchSize(Manager.EstimatedNumSearchNodes, Context.Root.N,
                                                                                      overlapThisSet,
                                                                                      Context.ParamsSearch.Execution.FlowDualSelectors,
                                                                                      Context.ParamsSearch.Execution.MaxBatchSize,
                                                                                      Context.ParamsSearch.BatchSizeMultiplier);

                targetThisBatch = Math.Min(targetThisBatch, Manager.MaxBatchSizeDueToPossibleNearTimeExhaustion);
                if (forceBatchSize.HasValue)
                {
                    targetThisBatch = forceBatchSize.Value;
                }
                if (targetThisBatch > hardLimitNumNodesThisBatch)
                {
                    targetThisBatch = hardLimitNumNodesThisBatch;
                }

                int thisBatchTotalNumLeafsTargeted = 0;

                // Compute number of dynamic nodes to add (do not add any when tree is very small and impure child selection is particularly deleterious)
                int numNodesPadding = 0;
                if (manager.Root.N > 50 && manager.Context.ParamsSearch.PaddedBatchSizing)
                {
                    numNodesPadding = manager.Context.ParamsSearch.PaddedExtraNodesBase
                                      + (int)(targetThisBatch * manager.Context.ParamsSearch.PaddedExtraNodesMultiplier);
                }
                int numVisitsTryThisBatch = targetThisBatch + numNodesPadding;

                numVisitsTryThisBatch = (int)(numVisitsTryThisBatch * batchingManagers[selectorID].BatchSizeDynamicScaleForSelector());

                // Select a batch using this selector
                // It will select a set of Leafs completely independent of what a possibly other selector already selected
                // It may find some unevaluated leafs in the tree (extant but N = 0) due to action of the other selector
                // These leafs will nevertheless be recorded but specifically ignored later
                MCTSNodesSelectedSet nodesSelectedSet = nodesSelectedSets[selectorID];
                nodesSelectedSet.Reset(pendingOverlappedNodes);

                // Select the batch of nodes
                if (numVisitsTryThisBatch < 5 || !Context.ParamsSearch.Execution.FlowSplitSelects)
                {
                    thisBatchTotalNumLeafsTargeted += numVisitsTryThisBatch;
                    ListBounded <MCTSNode> selectedNodes = selector.SelectNewLeafBatchlet(Context.Root, numVisitsTryThisBatch, thisBatchDynamicVLossBoost);
                    nodesSelectedSet.AddSelectedNodes(selectedNodes, true);
                }
                else
                {
                    // Set default assumed max batch size
                    nodesSelectedSet.MaxNodesNN = numVisitsTryThisBatch;

                    // In first attempt try to get 60% of target
                    int numTry1 = Math.Max(1, (int)(numVisitsTryThisBatch * 0.60f));
                    int numTry2 = (int)(numVisitsTryThisBatch * 0.40f);
                    thisBatchTotalNumLeafsTargeted += numTry1;

                    ListBounded <MCTSNode> selectedNodes1 = selector.SelectNewLeafBatchlet(Context.Root, numTry1, thisBatchDynamicVLossBoost);
                    nodesSelectedSet.AddSelectedNodes(selectedNodes1, true);
                    int numGot1 = nodesSelectedSet.NumNewLeafsAddedNonDuplicates;
                    nodesSelectedSet.ApplyImmeditateNotYetApplied();

                    // In second try target remaining 40%
                    if (Context.ParamsSearch.Execution.SmartSizeBatches &&
                        Context.EvaluatorDef.NumDevices == 1 &&
                        Context.NNEvaluators.PerfStatsPrimary != null) // TODO: somehow handle this for multiple GPUs
                    {
                        int[] optimalBatchSizeBreaks;
                        if (Context.NNEvaluators.PerfStatsPrimary.Breaks != null)
                        {
                            optimalBatchSizeBreaks = Context.NNEvaluators.PerfStatsPrimary.Breaks;
                        }
                        else
                        {
                            optimalBatchSizeBreaks = Context.GetOptimalBatchSizeBreaks(Context.EvaluatorDef.DeviceIndices[0]);
                        }

                        // Make an educated guess about the total number of NN nodes that will be sent
                        // to the NN (resulting from both try1 and try2)
                        // We base this on the fraction of nodes in try1 which actually are going to NN
                        // then discounted by 0.8 because the yield on the second try is typically lower
                        const float TRY2_SUCCESS_DISCOUNT_FACTOR   = 0.8f;
                        float       fracNodesFirstTryGoingToNN     = (float)nodesSelectedSet.NodesNN.Count / (float)numTry1;
                        int         estimatedAdditionalNNNodesTry2 = (int)(numTry2 * fracNodesFirstTryGoingToNN * TRY2_SUCCESS_DISCOUNT_FACTOR);

                        int estimatedTotalNNNodes = nodesSelectedSet.NodesNN.Count + estimatedAdditionalNNNodesTry2;

                        const float NEARBY_BREAK_FRACTION = 0.20f;
                        int?        closeByBreak          = NearbyBreak(optimalBatchSizeBreaks, estimatedTotalNNNodes, NEARBY_BREAK_FRACTION);
                        if (closeByBreak is not null)
                        {
                            nodesSelectedSet.MaxNodesNN = closeByBreak.Value;
                        }
                    }

                    // Only try to collect the second half of the batch if the first one yielded
                    // a good fraction of desired nodes (otherwise too many collisions to profitably continue)
                    const float THRESHOLD_SUCCESS_TRY1 = 0.667f;
                    bool        shouldProcessTry2      = numTry1 < 10 || ((float)numGot1 / (float)numTry1) >= THRESHOLD_SUCCESS_TRY1;
                    if (shouldProcessTry2)
                    {
                        thisBatchTotalNumLeafsTargeted += numTry2;
                        ListBounded <MCTSNode> selectedNodes2 = selector.SelectNewLeafBatchlet(Context.Root, numTry2, thisBatchDynamicVLossBoost);

                        // TODO: clean this up
                        //  - Note that ideally we might not apply immeidate nodes here (i.e. pass false instead of true in next line)
                        //  - This is because once done selecting nodes for this batch, we want to get it launched as soon as possible,
                        //    we could defer and call ApplyImmeditateNotYetApplied only later (below)
                        // *** WARNING*** However, setting this to false causes NInFlight errors (seen when running test matches within 1 or 2 minutes)
                        nodesSelectedSet.AddSelectedNodes(selectedNodes2, true); // MUST BE true; see above
                    }
                }

                // Possibly pad with "preload nodes"
                if (rootPreloader != null && nodesSelectedSet.NodesNN.Count <= MCTSRootPreloader.PRELOAD_THRESHOLD_BATCH_SIZE)
                {
                    // TODO: do we need to update thisBatchTotalNumLeafsTargeted ?
                    TryAddRootPreloadNodes(manager, MAX_PRELOAD_NODES_PER_BATCH, nodesSelectedSet, selector);
                }

                // TODO: make flow private belows
                if (Context.EvaluatorDef.SECONDARY_NETWORK_ID != null && (manager.Root.N - nodesLastSecondaryNetEvaluation > 500))
                {
                    manager.RunSecondaryNetEvaluations(8, manager.flow.BlockNNEvalSecondaryNet);
                    nodesLastSecondaryNetEvaluation = manager.Root.N;
                }

                // Update statistics
                UpdateStatistics(selectorID, thisBatchTotalNumLeafsTargeted, nodesSelectedSet);

                // Convert any excess nodes to CacheOnly
                if (Context.ParamsSearch.PaddedBatchSizing)
                {
                    throw new Exception("Needs remediation");
                    // Mark nodes not eligible to be applied as "cache only"
                    //for (int i = numApplyThisBatch; i < selectedNodes.Count; i++)
                    //  selectedNodes[i].ActionType = MCTSNode.NodeActionType.CacheOnly;
                }

                CeresEnvironment.LogInfo("MCTS", "Batch", $"Batch Target={numVisitsTryThisBatch} "
                                         + $"yields NN={nodesSelectedSet.NodesNN.Count} Immediate= {nodesSelectedSet.NodesImmediateNotYetApplied.Count} "
                                         + $"[CacheOnly={nodesSelectedSet.NumCacheOnly} None={nodesSelectedSet.NumNotApply}]", manager.InstanceID);

                // Now launch NN evaluation on the non-immediate nodes
                bool isPrimary = selectorID == 0;
                if (overlapThisSet)
                {
                    Task <MCTSNodesSelectedSet> priorOverlappingTask = overlappingTask;

                    numOverlappedNodesImmediateApplied = nodesSelectedSet.NodesImmediateNotYetApplied.Count;

                    // Launch a new task to preprocess and evaluate these nodes
                    overlappingTask = Task.Run(() => LaunchEvaluate(manager, targetThisBatch, isPrimary, nodesSelectedSet));
                    nodesSelectedSet.ApplyImmeditateNotYetApplied();
                    pendingOverlappedNodes = nodesSelectedSet;

                    WaitEvaluationDoneAndApply(priorOverlappingTask, nodesSelectedSet.NodesNN.Count);
                }
                else
                {
                    LaunchEvaluate(manager, targetThisBatch, isPrimary, nodesSelectedSet);
                    nodesSelectedSet.ApplyAll();
                    //Console.WriteLine("applied " + selector.Leafs.Count + " " + manager.Root);
                }

                RunPeriodicMaintenance(manager, batchSequenceNum, iterationCount);

                // Advance (rotate) selector
                if (overlappingAllowed)
                {
                    selectorID = (selectorID + 1) % 2;
                }
                batchSequenceNum++;
            }

            WaitEvaluationDoneAndApply(overlappingTask);

            //      Debug.Assert(!manager.Root.IsInFlight);

            if ((manager.Root.NInFlight != 0 || manager.Root.NInFlight2 != 0) && !haveWarned)
            {
                Console.WriteLine($"Internal error: search ended with N={manager.Root.N} NInFlight={manager.Root.NInFlight} NInFlight2={manager.Root.NInFlight2} " + manager.Root);
                int count = 0;
                manager.Root.Ref.TraverseSequential(manager.Root.Context.Tree.Store, delegate(ref MCTSNodeStruct node, MCTSNodeStructIndex index)
                {
                    if (node.IsInFlight && node.NumChildrenVisited == 0 && count++ < 20)
                    {
                        Console.WriteLine("  " + index.Index + " " + node.Terminal + " " + node.N + " " + node.IsTranspositionLinked + " " + node.NumNodesTranspositionExtracted);
                    }
                    return(true);
                });
                haveWarned = true;
            }

            selector1.Shutdown();
            selector2?.Shutdown();
        }