Exercise in modular programming: the Mandelbrot set
This exercise uses Numpy, Matplotlib, Numba and Dask.
«imports»
from matplotlib import pyplot as plt
import numpy as np
import numba
from dask import array as da
from dask import delayed
from typing import Callable
= Callable[(complex,), int]
ComplexToInt
from dataclasses import dataclass
We will be computing the famous Mandelbrot fractal. The Mandelbrot set is the set of complex numbers \(c \in \mathbb{C}\) for which the iteration,
\[z_{n+1} = z_n^2 + c,\]
converges, starting iteration at \(z_0 = 0\). We can visualize the Mandelbrot set by plotting the number of iterations needed for the absolute value \(|z_n|\) to exceed 2 (for which it can be shown that the iteration always diverges).
We may compute the Mandelbrot as follows.
= 256
max_iter = 256
width = 256
height = -0.8+0.0j
center = 3.0+3.0j
extent = max((extent / width).real, (extent / height).imag)
scale
= np.zeros((height, width), int)
result for j in range(height):
for i in range(width):
= center + (i - width // 2 + (j - height // 2)*1j) * scale
c = 0
z for k in range(max_iter):
= z**2 + c
z if (z * z.conjugate()).real > 4.0:
break
= k result[j, i]
Then we can plot with the following code:
= plt.subplots(1, 1, figsize=(10, 10))
fig, ax = (width + 1j * height) * scale
plot_extent = center - plot_extent / 2
z1 = z1 + plot_extent
z2 **(1/3), origin='lower', extent=(z1.real, z2.real, z1.imag, z2.imag))
ax.imshow(result"$\Re(c)$")
ax.set_xlabel("$\Im(c)$") ax.set_ylabel(
Things become really loads of fun when we start to zoom in. We can play around with the center
and extent
values (and necessarily max_iter
) to control our window.
= 1024
max_iter = -1.1195+0.2718j
center = 0.005+0.005j extent
When we zoom in on the Mandelbrot fractal, we get smaller copies of the larger set!
Exercise 1: modularize this code
This is not even the worst code. Variables are aptly named and the code is nicely parametrized. However, this code utterly lacks in modularity. The author has never heard of classes or functions! Try to improve this code to get less repetition and better isolation of concerns, while at the same time bundling things that belong together. To achieve this you should consider using functions to isolate functionality, while using classes to bundle things that are related.
Create a BoundingBox
class
One piece of information that we might want to bundle is the BoundingBox
of the fractal. Then we can think about the actions that we perform on the bounding-box that are generic enough so that we may put them in the BoundingBox
class as a method. We collect the information on the width
and height
of the resulting image, together with the center
coordinate and the extent
in terms of the complex number plane.
«bounding-box»
@dataclass
class BoundingBox:
int
width: int
height: complex
center: complex
extent:
<<bounding-box-methods>>
We will do some testing and benchmarking on the following box:
«define-test-box»
= BoundingBox(1024, 1024, -1.1195+0.2718j, 0.005+0.005j) box
Mapping
In the initial code, we had the following loop structure:
«mapping»
= np.zeros((height, width), int)
result for j in range(height):
for i in range(width):
= center + (i - width // 2 + (j - height // 2)*1j) * scale
c
<<compute-k>>
= k result[j, i]
Then for <<compute-k>>
we had the explicit computation of a pixel value for the Mandelbrot fractal. It would be much better if that is done through a generic method.
«bounding-box-methods»
def map(self, f) -> np.array:
= self.width
width = self.height
height = self.center
center = self.scale
scale
<<mapping>>
return result
Now, for <<compute-k>>
we have the very generic:
«compute-k»
= f(c) k
Notice that I used the member BoundingBox.scale
, which we haven’t defined yet:
«bounding-box-methods»
@property
def scale(self) -> float:
return max(self.extent.real / self.width,
self.extent.imag / self.height)
The Mandelbrot iteration
Now we need to put the inner-most loop in a separate function. From the first code we had the following:
«compute-mandelbrot»
= 0
z for k in range(max_iter):
= z**2 + c
z if (z * z.conjugate()).real > 4.0:
break
To create a function that takes a complex
and returns an int
, we need to capture the max_iter
variable in some way. In one function, this would be something like:
def mandelbrot(max_iter: int, c: complex) -> int:
<<compute-mandelbrot>>
return k
To create a function that only takes a single complex
argument, we need to write a function that captures max_iter
and returns a function of only c
:
«mandelbrot-iteration»
def mandelbrot(max_iter: int):
def mandelbrot_iter(c: complex) -> int:
<<compute-mandelbrot>>
return k
return mandelbrot_iter
This pattern is so common that it has a name: currying, after the famous mathematician Haskell Curry.
Write a plot function
Now that we have a generic BoundingBox
class, we can write a plotting function. We could make this a method of BoundingBox
, but there is a good reason not to: again, modularity. Not everyone may like our plotting function, by having it separate from the class, we can swap out the plotting function for another one more easily.
«plot-fractal»
def plot_fractal(box: BoundingBox, result: np.array):
= plt.subplots(1, 1, figsize=(10, 10))
fig, ax = (box.width + 1j * box.height) * box.scale
plot_extent = box.center - plot_extent / 2
z1 = z1 + plot_extent
z2 **(1/3), origin='lower', extent=(z1.real, z2.real, z1.imag, z2.imag))
ax.imshow(result"$\Re(c)$")
ax.set_xlabel("$\Im(c)$")
ax.set_ylabel(return fig, ax
Choosing wether to bundle things into a class or keep them separate is sometimes a hard choice. Don’t worry about getting it right from the get go.
Conclusion
What have we gained by refactoring our code like this? We now have separated the outer-loop structure from inner-loop functionality. We can now do several things:
- optimize the different components
- swap out the mapping function for a parallel version
- swap out the function to compute a different fractal
As an added bonus: our code has become more readable and thus better maintainable.
Exercise 2: optimise using Numba
The code we have is terribly slow. We can make it faster by optimizing with Numba. It is often a good idea to start optimization with the inner-most loop.
«using-vectorize»
= numba.vectorize()(mandelbrot(1024)) mandelbrot_vectorized
By using the vectorize
decorator, we immediately have the nice functionality that we can call this function with numpy
arrays and everything works as expected. We actually don’t need the mapping function now:
«using-vectorize»
"vectorized", lambda: mandelbrot_vectorized(box.grid())) benchmark(
Here, the grid
method returns a 2-dimensional array of complex values that give the values of the complex plane for each pixel in the bounding box.
«bounding-box-methods»
def grid(self) -> np.array:
= self.center - self.extent / 2
x0 = self.center + self.extent / 2
x1 = np.indices([self.height, self.width])
g return self.center + (g[1] - self.width // 2 + (g[0] - self.height // 2)*1j) \
* self.scale
Optimise the outer loop
We have no optimized the inner loop. We could also optimise the outer loop using numba. Since we want to create a function that does not live in the GIL, it cannot be a class method: all arguments must be primitive types. The advantages of doing this over using Numpy are:
- The entire computation now lives outside the GIL
- We save a bit of memory
The downside is: we need to specialize for mapping complex to integer functions.
«numba-int-map»
@numba.jit(nopython=True, nogil=True)
def _int_map(f: ComplexToInt, width: int, height: int, center: complex, scale: complex):
= np.zeros((height, width), np.int64)
result for j in range(height):
for i in range(width):
= center + (i - width // 2 + (j - height // 2) * 1j) * scale
z = f(z)
result[j, i] return result
«bounding-box-methods»
def map_numba(self, f) -> np.array:
return _int_map(f, self.width, self.height, self.center, self.scale)
We’ll be benchmarking all our solutions (except the native Python one):
«using-numba»
= numba.njit(nogil=True)(mandelbrot(1024))
mb 0) # run once to compile
mb("numba-single", lambda: box.map_numba(mb)) benchmark(
Exercise 3: parallelize
For further speed-up, we need to run our fractal renderer in parallel. Since we already have a vectorised function, we can try to plug a dask.array
directly in, and see if we can get any faster.
«using-dask-array»
= numba.vectorize([numba.int64(numba.complex128)])(mandelbrot(1024))
mb 0) # run once to compile
mb("dask-array", lambda: mb(da.from_array(box.grid(), chunks=(128, 128))).compute()) benchmark(
Domain splitting
We could do better by splitting our domain manually and using the map_numba
method to do the mapping, then numpy.block
to rejoin each part. The following method splits the domain into n x n
subdomains, returing a nested list of BoundingBox
objects.
«bounding-box-methods»
def split(self, n):
= self.extent / n
sd = self.center - self.extent / 2 + sd / 2
x0 return [[BoundingBox(width=self.width//n, height=self.height//n,
=x0 + i * sd.real + j * sd.imag * 1j,
center=sd) for i in range(n)]
extentfor j in range(n)]
We can now chunk the domain, and build a workflow using the dask.delayed
interface. One function that I keep defining for its infinite usefulness is gather
: it converts a list of promises into promise of a list.
«gather»
@delayed
def gather(*args):
return list(args)
We add a benchmark to the list:
«using-manual-splitting»
<<gather>>
def split_domain_map(box, f, n=8, num_workers=None):
= gather(*[gather(*[delayed(sub_domain.map_numba)(f)
wf for sub_domain in row])
for row in box.split(n)])
return np.block(wf.compute(num_workers=num_workers))
= numba.njit(nogil=True)(mandelbrot(1024))
mb 0) # run once to compile
mb("manual-splitting", lambda: split_domain_map(box, mb)) benchmark(
Visualizing this workflow:
That was a bit of extra work compared to using vectorize
and dask.array
. Let’s see if it pays off.
Benchmark results
Considering the amount of work needed to implement the dask.array
version, it parallelizes rather well. It is no match however for our hand coded domain splitting solution. For the following results, we ran the above codes on subdomains of 8x8 tiles (so 128x128 each). The hand coded solution runs nearly ten times faster on an 8-core Intel machine, and twice as fast as the dask.array
version.
Bonus: Julia sets
For each value \(c\) we can compute the Julia set, namely the set of starting values \(z_1\) for which the iteration over \(z_{n+1}=z_n^2 + c\) converges. Every location on the Mandelbrot image corresponds to its own unique Julia set.
= 256
max_iter = 0.0+0.0j
center = 4.0+3.0j
extent = max((extent / width).real, (extent / height).imag)
scale
= np.zeros((height, width), int)
result = -1.1193+0.2718j
c
for j in range(height):
for i in range(width):
= center + (i - width // 2 + (j - height // 2)*1j) * scale
z for k in range(max_iter):
= z**2 + c
z if (z * z.conjugate()).real > 4.0:
break
= k result[j, i]
If we take the center of the last image, we get the following rendering of the Julia set:
It is not too hard to adapt the above code for the Mandelbrot fractal to the Julia fractal.