Mandelbrot Set Visualization#
The Mandelbrot set is a classic example of a fractal - a mathematical structure characterized by self-similarity and spatial complexity.
One way to compute a visualization of the set requires looping over coordinates in the complex plane corresponding to the centers of pixels in an image. Whether or not a point in the complex plane is in the mandelbrot set is a function only of the point's coordinates, so visualizing the set is very amenable to parallel speedups by breaking up the work into chunks of pixels.
Different algorithms for set visualization range in complexity and sophistication. Here is
a very basic version that calculates whether a given complex number, z = x + y*1j
, is in the mandelbrot set. The function returns 0 for points inside the
set and returns the number of iterations executed for points outside the set:
def mandelbrot(x, y, max_iterations=500):
z = x + y * 1j
p = 2
c = z
for iteration_number in range(max_iterations):
if abs(z) >= 2:
return iteration_number
else:
z = z**p + c
else:
return 0
We can create an image of the Mandelbrot set by creating an array of pixels and assigning x and y coordinates to the center of each pixel. We can then loop over the array and call the mandelbrot function for each pixel. Let's make use of a 2D NumPy array to represent the image:
import numpy as np
shape = (1000, 1000)
iteration_array = np.zeros(shape)
for i, x in enumerate(x_domain):
for j, y in enumerate(y_domain):
iteration_array[j, i] = mandelbrot(x, y)
This sort of "map-reduce" workflow,
where a problem reduces to looping over a batch of calculations, is particularly
amenable to parallel computation. On free-threaded Python, we can transform the
simple single-threaded for
loop above into a multithreaded parallel loop by
making use of a worker function that processes a chunk of pixels:
def worker(j_y):
for i, x in enumerate(x_domain):
for j, y in j_y:
iteration_array[j, i] = mandelbrot(x, y)
The worker
function can be called by a concurrent.futures.ThreadPoolExecutor
and
operate on a chunk of columns in the image:
def run_thread_pool(num_workers):
with ThreadPoolExecutor(max_workers=num_workers) as tpe:
chunks = itertools.batched(enumerate(y_domain), 4, strict=True)
try:
futures = [tpe.submit(worker, arg) for arg in chunks]
# block until all work finishes
concurrent.futures.wait(futures)
finally:
# check for exceptions in worker threads
[f.result() for f in futures]
In the notebook accompanying this page you can see
how executing run_thread_pool
scales well as a function of the number of
worker threads up to 32 threads, the number of CPU cores available on the test
machine.
The parallel throughput of a mandelbrot visualization algorithm is limited by
how much CPU power can be used to compute whether or not a point is in the
set. This is an example of a "CPU-bound" task. Since the algorithm is
implemented as a Python function that doesn't call into any libraries that
release the GIL, the GIL prevents parallel execution of the mandelbrot
function on the GIL-enabled build, and you will not see any parallel speedups
running the example notebook using the GIL-enabled interpreter.