4

Details:

I have a game with two independent AIs playing agains each other. Each AI has its own task. Both tasks need to start at the same time, need to take some parameters and return a value. Now I want to run 100-200 games (with each two tasks) in parallel.

The problem that I now have is that the two tasks are not started together. They are started completely random, whenever there are some free resources.

Code:

My current approach is like following.

  • I have a list of inputobjects which include some of the parameter.
  • With Parallel.ForEach I create for each inputobject a game and two AIs for the game.
  • Whichever AI finishes the game first stops the other AI, playing the same game, with a CancellationToken.
  • All returned values are saved in a ConcurrentBag.

Because with just that the two AI-Tasks for each game are not started together, I added an AutoResetEvent. I hoped that I could wait with one task until the second task has started but instead the AutoResetEvent.WaitOne blocks all resources. So the result with AutoResetEvent is that the first AI-Tasks are starting and waiting for the second task to start, but since they do not free the threads again they wait forever.

        private ConcurrentBag<Individual> TrainKis(List<Individual> population) {
            ConcurrentBag<Individual> resultCollection = new ConcurrentBag<Individual>();
            ConcurrentBag<Individual> referenceCollection = new ConcurrentBag<Individual>();

            Parallel.ForEach(population, individual =>
            {
                GameManager gm = new GameManager();

                CancellationTokenSource c = new CancellationTokenSource();
                CancellationToken token = c.Token;
                AutoResetEvent waitHandle = new AutoResetEvent(false);

                KI_base eaKI = new KI_Stupid(gm, individual.number, "KI-" + individual.number, Color.FromArgb(255, 255, 255));
                KI_base referenceKI = new KI_Stupid(gm, 999, "REF-" + individual.number, Color.FromArgb(0, 0, 0));
                Individual referenceIndividual = CreateIndividual(individual.number, 400, 2000);

                var t1 = referenceKI.Start(token, waitHandle, referenceIndividual).ContinueWith(taskInfo => {
                    c.Cancel();
                    return taskInfo.Result;
                }).Result;
                var t2 = eaKI.Start(token, waitHandle, individual).ContinueWith(taskInfo => { 
                    c.Cancel(); 
                    return taskInfo.Result; 
                }).Result;

                referenceCollection.Add(t1);
                resultCollection.Add(t2);
            });

            return resultCollection;
        }

This is the start method of the AI where I wait for the second AI to play:

            public Task<Individual> Start(CancellationToken _ct, AutoResetEvent _are, Individual _i) {
                i = _i;
                gm.game.kis.Add(this);
                if (gm.game.kis.Count > 1) {
                    _are.Set();
                    return Task.Run(() => Play(_ct));
                }
                else {
                    _are.WaitOne();
                    return Task.Run(() => Play(_ct));
                }
            }

And the simplified play method

public override Individual Play(CancellationToken ct) {
            Console.WriteLine($"{player.username} started.");
            while (Constants.TOWN_NUMBER*0.8 > player.towns.Count || player.towns.Count == 0) {
                try {
                    Thread.Sleep((int)(Constants.TOWN_GROTH_SECONDS * 1000 + 10));
                }
                catch (Exception _ex) {
                    Console.WriteLine($"{player.username} error: {_ex}");
                }
                
                //here are the actions of the AI (I removed them for better overview)

                if (ct.IsCancellationRequested) {
                    return i;
                }
            }
            if (Constants.TOWN_NUMBER * 0.8 <= player.towns.Count) {
                winner = true;
                return i;
            }
            return i;
        }

Is there a better way of doing this, keeping all things but ensure that the two KI-Tasks in each game are started at the same time?

theoretisch
  • 1,718
  • 5
  • 24
  • 34
  • How do the two AI players interact with each other? Do they read and write to some shared state? Are these read/write operations synchronized using a `lock` or other synchronization primitive, or they are lock-less? – Theodor Zoulias Jan 01 '21 at 20:07
  • Both AIs interact with the same game manager. Some parts (like the quadtree which contains the current state of the game) of the game manager are locked to prevent errors. – theoretisch Jan 01 '21 at 20:25
  • Is it an option to use [coroutines](https://stackoverflow.com/questions/35816905/c-sharp-why-shouldnt-i-ever-use-coroutines) instead of `Task`s to coordinate each pair of AI players? The idea is to expose each AI player as an [iterator](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/iterators) (a method that returns `IEnumerable` and contains `yield` statements) instead of `Task`, and have a single `Task` for each game that "unwinds" both iterators one step at a time. – Theodor Zoulias Jan 01 '21 at 20:34
  • Would the two players then always take turns making a move? So KI1 does an aktion and then KI2, KI1... and so on? Both KIs need to play completely free... – theoretisch Jan 01 '21 at 20:39
  • Yes, the players would take turns in this case. But the coordinator could take arbitrary desicions about how many moves would allow one player to make before switching to the other player. It could include calls to `Random.Next()` for example. Btw *"completely free"* includes the possibility that a player makes no move at all, which is what you are currently experiencing. :-) – Theodor Zoulias Jan 01 '21 at 20:45
  • Ah ok. So the "playstyle" of each AI depends on the parameter which are passed to the task. When some of the AI does no move at all it would be ok. But at the moment none of the AIs does anything and this is not because of the AI but because of the AutoResetEvent. When I remove this, then every AI is playing. The problem then is that maybe some AI starts playing earlier than others which gives it an unintended advantage. – theoretisch Jan 01 '21 at 20:52
  • 1
    Oh, I see. Could you include in your question a simplified example of the asynchronous `Start` method of an AI player, so that we are able to offer alternative suggestions? (instead of coroutines) – Theodor Zoulias Jan 01 '21 at 20:57
  • Does this currently work as intended when you don't start 100 games in parallel, but just one? – Doc Brown Jan 01 '21 at 21:13
  • When you say that you want to run 100-200 games in parallel, you mean that all 100-200 should progress concurrently, or that you want to parallelize them with a small [degree of parallelism](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.paralleloptions.maxdegreeofparallelism), like 4, 8 or 16? – Theodor Zoulias Jan 01 '21 at 21:14
  • I am not sure it is possible to ensure that two tasks start at exactly the same time-- the O/S still has to schedule things-- and there could be other processes running on the machine that steal processing from one task but not the other. So even if you could do it, it wouldn't necessarily be "fair' to both AIs. You need to provide for the same number of clock cycles or iterations or something of that nature instead. You'd have to keep track of how many cycles one AI gets, then throttle it until the other AI can catch up. – John Wu Jan 01 '21 at 21:15
  • @Doc Brown When I remove the AutoResetEvent and the ContinueWith CancellationToken it works fine with one game with two AIs. Just with to many games at the same time (4 and more) it seperates the AIs. – theoretisch Jan 01 '21 at 21:28
  • @Theodor Zoulias No they do not need to run at the same time. I just want as much as parallelization as possible to speed up the process. – theoretisch Jan 01 '21 at 21:28
  • @JohnWu They don't need to start at the exact same time. One second delay is completely ok. But at the moment sometimes there are 30 seconds and more between. When a task is started, could it be then interruped again from some other task? I hoped when the AI task is running it runs until the end. The cycles option you mentioned sounds very complicated. – theoretisch Jan 01 '21 at 21:35

1 Answers1

3

My suggestion is to change the signature of the Play method so that it returns a Task<Individual> instead of Individual, and replace the calls to Thread.Sleep with await Task.Delay. This small change should have a significant positive effect to the responsiveness of the AI players, because no threads will be blocked by them, and the small pool of ThreadPool threads will be optimally utilized.

public override async Task<Individual> Play(CancellationToken ct)
{
    Console.WriteLine($"{player.username} started.");
    while (Constants.TOWN_NUMBER * 0.8 > player.towns.Count || player.towns.Count == 0)
    {
        //...
        await Task.Delay((int)(Constants.TOWN_GROTH_SECONDS * 1000 + 10));
        //...
    }
}

You could also consider changing the name of the method from Play to PlayAsync, to comply with the guidelines.

Then you should scrape the Parallel.ForEach method, since it is not async-friendly, and instead project each individual to a Task, bundle all tasks in an array, and wait them all to complete with the Task.WaitAll method (or with the await Task.WhenAll if you want to go async-all-the-way).

Task[] tasks = population.Select(async individual =>
{
    GameManager gm = new GameManager();
    CancellationTokenSource c = new CancellationTokenSource();

    //...

    var task1 = referenceKI.Start(token, waitHandle, referenceIndividual);
    var task2 = eaKI.Start(token, waitHandle, individual);

    await Task.WhenAny(task1, task2);
    c.Cancel();
    await Task.WhenAll(task1, task2);
    var result1 = await task1;
    var result2 = await task2;
    referenceCollection.Add(result1);
    resultCollection.Add(result2);
}).ToArray();

Task.WaitAll(tasks);

This way you'll have maximum concurrency, which may not be ideal, because the CPU or the RAM or the CPU <=> RAM bandwidth of your machine may become saturated. You can look here for ways of limiting the amount of concurrent asynchronous operations.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    @theoretisch in short, locate all `.Wait()` and `.Result` invocations anywhere in your program, and eliminate as many of them as you can (ideally all of them). In most cases these are harmful for the responsiveness or scalability of a program. – Theodor Zoulias Jan 01 '21 at 22:03
  • 1
    Just implemented it and it works perfectly. Even with 200 and more games at the same time. Thank you so much. All the links are also very helpful! – theoretisch Jan 01 '21 at 22:10
  • 1
    @theoretisch the quick and dirty fix would be to increase the number of threads that the `ThreadPool` creates instantly on demand, by calling `ThreadPool.SetMinThreads(1000, 1000)` at the start of your program. Although this would probably fix your problem, it would be quite wasteful because each thread allocates [1 MB](https://stackoverflow.com/questions/28656872/why-is-stack-size-in-c-sharp-exactly-1-mb) of RAM by default, just for its stack. So with, say, ~400 created threads you would have ~400MB of wasted RAM, dedicated to threads that would spend most of their time sleeping. :-) – Theodor Zoulias Jan 01 '21 at 22:17