Solving sudoku with multiprocessing

In this module, we're going to implement three functions. The first one simply solves a batch of sudoku puzzles, with no multiprocessing involved. We will use the results for benchmarking. The second and the third ones will use multiprocessing, with and without batch-solving, so we can appreciate the differences. Let's start:

# sudoku/process_solver.py
import os
from functools import reduce
from operator import concat
from math import ceil
from time import time
from contextlib import contextmanager
from concurrent.futures import ProcessPoolExecutor, as_completed
from unittest import TestCase
from algo.solver import solve

@contextmanager
def timer():
t = time()
yield
tot = time() - t
print(f'Elapsed time: {tot:.3f}s')

After a long list of imports, we define a context manager that we're going to use as a timer device. It takes a reference to the current time (t), and then it yields. After having yielded, that's when the body of the managed context is executed. Finally, on exiting the managed context, we calculate tot, which is the total amount of time elapsed, and print it. It's a simple and elegant context manager written with the decoration technique, and it's super fun. Let's now see the three functions I mentioned earlier:

def batch_solve(puzzles):
# Single thread batch solve.
return [solve(puzzle) for puzzle in puzzles]

This one is a single-threaded simple batch solver, which will give us a time to compare against. It simply returns a list of all solved grids. Boring. Now, check out the following code:

def parallel_single_solver(puzzles, workers=4):
# Parallel solve - 1 process per each puzzle
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = (
executor.submit(solve, puzzle) for puzzle in puzzles
)
return [
future.result() for future in as_completed(futures)
]

This one is much better. It uses ProcessPoolExecutor to use a pool of workers, each of which is used to solve roughly one-fourth of the puzzles. This is because we are spawning one future object per puzzle. The logic is extremely similar to any multiprocessing example we have already seen in the chapter. Let's see the third function:  

def parallel_batch_solver(puzzles, workers=4):
# Parallel batch solve - Puzzles are chunked into `workers`
# chunks. A process for each chunk.
assert len(puzzles) >= workers
dim = ceil(len(puzzles) / workers)
chunks = (
puzzles[k: k + dim] for k in range(0, len(puzzles), dim)
)
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = (
executor.submit(batch_solve, chunk) for chunk in chunks
)
results = (
future.result() for future in as_completed(futures)
)
return reduce(concat, results)

This last function is slightly different. Instead of spawning one future object per puzzle, it splits the total list of puzzles into workers chunks, and then creates one future object per chunk. This means that if workers is eight, we're going to spawn eight future objects. Notice that instead of passing solve to executor.submit, we're passing batch_solve, which does the trick. The reason why I coded the last two functions so differently is because I was curious to see the severity of the impact of the overhead we incur into when we recycle processes from a pool a non-negligible amount of times.

Now that we have the functions defined, let's use them:

puzzles_file = os.path.join('puzzles', 'sudoku-topn234.txt')
with open(puzzles_file) as stream:
puzzles = [puzzle.strip() for puzzle in stream]

# single thread solve
with timer():
res_batch = batch_solve(puzzles)

# parallel solve, 1 process per puzzle
with timer():
res_parallel_single = parallel_single_solver(puzzles)

# parallel batch solve, 1 batch per process
with timer():
res_parallel_batch = parallel_batch_solver(puzzles)

# Quick way to verify that the results are the same, but
# possibly in a different order, as they depend on how the
# processes have been scheduled.
assert_items_equal = TestCase().assertCountEqual
assert_items_equal(res_batch, res_parallel_single)
assert_items_equal(res_batch, res_parallel_batch)
print('Done.')

We use a set of 234 very hard sudoku puzzles for this benchmarking session. As you can see, we simply run the three functions, batch_solve, parallel_single_solver, and parallel_batch_solver, all within a timed context. We collect the results, and, just to make sure, we verify that all the runs have produced the same results.

Of course, in the second and third runs, we have used multiprocessing, so we cannot guarantee that the order in the results will be the same as that of the single-threaded batch_solve. This minor issue is brilliantly solved with the aid of assertCountEqual, one of the worst-named methods in the Python standard library. We find it in the TestCase class, which we can instantiate just to take a reference to the method we need. We're not actually running unit tests, but this is a cool trick, and I wanted to show it to you. Let's see the output of running this module:

$ python process_solver.py
Elapsed time: 5.368s
Elapsed time: 2.856s
Elapsed time: 2.818s
Done.

Wow. That is quite interesting. First of all, you can once again see that my machine has a two-core processor, as the time elapsed for the multiprocessing runs is about half the time taken by the single-threaded solver. However, what is actually much more interesting is the fact that there is basically no difference in the time taken by the two multiprocessing functions. Multiple runs sometimes end in favor of one approach, and sometimes in favor of the other. Understanding why requires a deep understanding of all the components that are taking part in the game, not just the processes, and therefore is not something we can discuss here. It is fairly safe to say though, that the two approaches are comparable in terms of performance.

In the source code for the book, you can find tests in the sudoku folder, with instructions on how to run them. Take the time to check them out!

And now, let's get to the final example.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset