Implementing parallel genetic algorithms with `std::jthread`

Genetic algorithms are optimization algorithms inspired by the process of natural selection. They are commonly used to solve complex optimization problems. By using the concept of evolution, genetic algorithms evolve a population of candidate solutions over multiple generations to find the best possible solution.

In this blog post, we will explore how to implement parallel genetic algorithms using the std::jthread library introduced in C++20. std::jthread provides an abstraction for working with threads, making it easy to parallelize computationally intensive tasks like the evaluation and selection processes of a genetic algorithm.

What is std::jthread?

std::jthread is a new class introduced in C++20 that provides a higher-level interface for managing threads. It is similar to std::thread, but with an additional benefit of RAII (Resource Acquisition Is Initialization), which means that the thread will automatically be joined when the std::jthread object is destructed.

Parallelizing Genetic Algorithm Evaluation

The evaluation step of a genetic algorithm involves assigning fitness values to each candidate solution in the population. This is typically a computationally intensive task and can benefit from parallelization. Let’s take a look at how we can parallelize this step using std::jthread:

void evaluatePopulation(std::vector<CandidateSolution>& population) {
  std::atomic<size_t> index{ 0 };  // Current population index

  auto worker = [&](std::stop_token stopToken) {
    while (!stopToken.stop_requested()) {
      size_t i = index.fetch_add(1);
      if (i >= population.size()) {
        break;
      }

      // Evaluate fitness for candidate solution at index i
      evaluateFitness(population[i]);
    }
  };

  std::vector<std::jthread> threads;
  for (int i = 0; i < std::thread::hardware_concurrency(); ++i) {
    threads.emplace_back(worker);
  }

  for (auto& thread : threads) {
    thread.join();
  }
}

In the example above, we create a lambda function worker that will be executed concurrently by multiple threads. The std::atomic<size_t> index is used to synchronize access to the population vector. Each thread fetches the next unprocessed candidate solution from the population vector and evaluates its fitness.

We create a vector of std::jthread objects to manage the worker threads. The number of threads created is based on std::thread::hardware_concurrency(), which returns the number of concurrent threads supported by the implementation.

Parallelizing Genetic Algorithm Selection

The selection step of a genetic algorithm involves selecting the fittest candidate solutions from the population to generate the next generation. This step can also benefit from parallelization. Here’s an example of how to parallelize the selection step:

std::vector<CandidateSolution> selectParents(const std::vector<CandidateSolution>& population) {
  constexpr size_t tournamentSize = 5; // Number of candidates compared in each tournament
  constexpr size_t numParents = 2;     // Number of parents to select

  std::vector<CandidateSolution> parents(numParents);

  auto worker = [&](std::stop_token stopToken) {
    for (auto& parent : parents) {
      while (!stopToken.stop_requested()) {
        std::vector<CandidateSolution> tournament;
        for (size_t i = 0; i < tournamentSize; ++i) {
          size_t randomIndex = getRandomIndex(population.size());
          tournament.push_back(population[randomIndex]);
        }

        parent = selectFittest(tournament);
        if (parent.isValid()) {
          break;
        }
      }
    }
  };

  std::vector<std::jthread> threads;
  for (int i = 0; i < std::thread::hardware_concurrency(); ++i) {
    threads.emplace_back(worker);
  }

  for (auto& thread : threads) {
    thread.join();
  }

  return parents;
}

In the example above, we use a lambda function worker to perform the selection process concurrently. Each thread selects numParents parents by repeatedly performing a tournament selection. In each tournament, tournamentSize random candidate solutions are selected, and the fittest candidate is chosen as a parent.

Like in the previous example, we create std::jthread objects to manage the worker threads and join them at the end.

Conclusion

With the introduction of std::jthread in C++20, implementing parallel genetic algorithms has become significantly easier. By parallelizing the evaluation and selection steps, we can achieve faster convergence and better performance.

By utilizing the power of parallel computing, we can tackle complex optimization problems more efficiently. std::jthread simplifies the process of implementing parallel algorithms, allowing us to leverage the full potential of modern hardware.

#geneticalgorithm #parallelprogramming