Thanks for sharing, that's a big improvement! The details are easier to see, for me, in grayscale, that is using: Image.fromarray(255 - red).show()
I'm learning as I go here, using numpy functions I've never used before, but we can use more in-place modifying operations like this (np.multiply, np.add with where= and out=) and it decreases runtime of the benchmark by 25%.
Then, after that, this problem is embarassingly parallel and thanks to numpy we can parallelize your code very well, using all cores (improves runtime for me from 3.5 to 0.92 seconds) Using Python3.8.:
import concurrent.futures
import time
from PIL import Image
import numpy as np
def compute_mandelbrot(cs):
"""
cs: complex grid subset
"""
z = np.zeros_like(cs)
red = np.zeros_like(cs, dtype=np.uint8)
mask = np.ones_like(cs, dtype=bool)
for _ in range(199):
# z² + c -> z
np.multiply(z, z, where=mask, out=z)
np.add(z, cs, where=mask, out=z)
np.add(red, 1, where=mask, out=red)
mask &= np.abs(z) < 2
return red
def py_mandelbrot(size):
t1 = time.time()
yPts, xPts = np.ogrid[-1j:1j:size * 1j, -1.5:0.5:size * 1j]
cs = xPts + yPts
# over-split because chunks are not equal in time taken
nsplit = 32
nthreads = 8
css = np.array_split(cs, nsplit)
with concurrent.futures.ThreadPoolExecutor(max_workers=nthreads) as executor:
reds_out = list(executor.map(compute_mandelbrot, css))
red = np.concatenate(reds_out)
dt = time.time() - t1
print(f"dt={dt:.2f}")
Image.fromarray(255 - red).show()
if __name__ == '__main__':
py_mandelbrot(1280)
Python makes it easy to get computed chunks in the order they are submitted, using executor.map. I still tried out what it would look like if I concatenated the chunks in the order they were received. That's when I noticed that emptier tiles were computed faster. So then, we can split the problem into more fine-grained chunks and submit more of them while keeping the number of threads the same, for a small speedup since some chunks are faster to compute.