Fast computers have several million cores, which need to be used efficiently & simultaneously.
Remember:
| Prefix | Factor |
|---|---|
| Kilo | $10^3$ |
| Mega | $10^6$ |
| Giga | $10^9$ |
| Tera | $10^{12}$ |
| Peta | $10^{15}$ |
| Exa | $10^{18}$ |
What is theoretical peak performance¶
A rough theoretical peak multiplies the number of cores, the clock frequency, and the number of floating-point operations completed per cycle. This is an upper bound for performance, not a prediction of application performance. The actual performance is obtained by running LINPACK benchmark.
# my laptop
cores = 12
clock_GHz = 2.4
flops_per_cycle = 8
number_of_nodes = 10**7
peak_GFLOPS = cores * clock_GHz * flops_per_cycle * number_of_nodes
print(f'Theoretical peak: {peak_GFLOPS:.1f} GFLOP/s')
print(f' {peak_GFLOPS/10**6:.4f} PFLOP/s')
Theoretical peak: 2304000000.0 GFLOP/s
2304.0000 PFLOP/s
Python example: serial and parallel Mandelbrot calculations¶
Each point in the complex plane is independent. We therefore parallelize the outer image loop with Numba's prange. The inner loop remains an ordinary serial loop within each worker.
import time
import numpy as np
import matplotlib.pyplot as plt
from numba import njit, prange, get_num_threads, set_num_threads
%matplotlib inline
@njit
def mandelbrot_serial(data, ext, max_steps):
Nx, Ny = data.shape
for i in range(Nx):
x = ext[0] + (ext[1] - ext[0])*i/(Nx - 1.0)
for j in range(Ny):
y = ext[2] + (ext[3] - ext[2])*j/(Ny - 1.0)
c = x + 1j*y
z = 0j
iteration = max_steps
for k in range(max_steps):
z = z*z + c
if z.real*z.real + z.imag*z.imag > 4.0:
iteration = k
break
data[i, j] = iteration
@njit(parallel=True)
def mandelbrot_parallel(data, ext, max_steps):
Nx, Ny = data.shape
for i in prange(Nx):
x = ext[0] + (ext[1] - ext[0])*i/(Nx - 1.0)
for j in range(Ny):
y = ext[2] + (ext[3] - ext[2])*j/(Ny - 1.0)
c = x + 1j*y
z = 0j
iteration = max_steps
for k in range(max_steps):
z = z*z + c
if z.real*z.real + z.imag*z.imag > 4.0:
iteration = k
break
data[i, j] = iteration
ext = np.array([-2.0, 1.0, -1.0, 1.0])
Nxy = 1000
max_steps = 1000
serial_data = np.empty((Nxy, Nxy), dtype=np.int32)
parallel_data = np.empty_like(serial_data)
# Warm-up calls compile both functions. Do not include them in the timing.
mandelbrot_serial(serial_data[:10, :10], ext, 10)
mandelbrot_parallel(parallel_data[:10, :10], ext, 10)
mandelbrot_serial(serial_data, ext, max_steps)
mandelbrot_parallel(parallel_data, ext, max_steps)
print('The implementations agree:', np.array_equal(serial_data, parallel_data))
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
The implementations agree: True
fig, ax = plt.subplots(figsize=(9, 6))
im = ax.imshow(parallel_data.T, extent=ext, origin='lower', cmap='magma')
ax.set_xlabel(r'$\operatorname{Re}(c)$')
ax.set_ylabel(r'$\operatorname{Im}(c)$')
ax.set_title('Mandelbrot escape iterations')
fig.colorbar(im, ax=ax, label='iteration')
fig.tight_layout()
plt.show()
get_num_threads() # gives number of threads on your computer
12
thread_counts = range(1,get_num_threads()+1)
times = []
for p in thread_counts:
set_num_threads(p) # using only limited number of cores after that line
start = time.perf_counter()
mandelbrot_parallel(parallel_data, ext, max_steps)
elapsed = time.perf_counter() - start
times.append(elapsed)
print(f'{p:3d} threads: {elapsed:.4f} s')
set_num_threads(get_num_threads())
times = np.array(times)
speedup = times[0]/times
fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(thread_counts, speedup, 'o-', label='measured')
ax.plot(thread_counts, thread_counts, '--', color='0.5', label='ideal')
ax.set_xlabel('number of threads')
ax.set_ylabel('speedup')
ax.grid()
ax.legend()
fig.tight_layout()
plt.show()
1 threads: 0.6866 s 2 threads: 0.4794 s 3 threads: 0.4373 s 4 threads: 0.4328 s 5 threads: 0.3276 s 6 threads: 0.3141 s 7 threads: 0.2772 s 8 threads: 0.2285 s 9 threads: 0.2315 s 10 threads: 0.2066 s 11 threads: 0.1901 s 12 threads: 0.1897 s
%%file pi_examp.cc
#include <iostream>
#include <ctime>
#include <cmath>
#include <omp.h>
using namespace std;
double f(double x){
return 4.0/(1.0+x*x);
}
double calcPi(int n)
{
const double dx = 1.0/n;
double fSum = 0.0;
#pragma omp parallel for reduction(+:fSum)
for (int i=0; i<n; ++i){
double x = (i+0.5)*dx;
fSum += f(x);
}
return fSum*dx;
}
double calcPi_bad(int n)
{
const double dx = 1.0/n;
double fSum = 0.0;
#pragma omp parallel for
for (int i=0; i<n; ++i){
double x = (i+0.5)*dx;
double df = f(x);
#pragma omp critical
fSum += df;
}
return fSum*dx;
}
int main()
{
int n=1000000;
clock_t startTimec = clock();
double start = omp_get_wtime();
double fpi;
for (int j=0; j<100; ++j)
fpi = calcPi(n);
//fpi = calcPi_bad(n);
clock_t endTimec = clock();
double diffc = double(endTimec-startTimec)/CLOCKS_PER_SEC;
double diff = omp_get_wtime()-start;
clog<<"clock time : "<<diffc<<"s"<<" with wall time="<<diff<<"s "<<endl;
std::cout << "At n=" << n << " approximation for pi= " << fpi << " with error= " << std::abs(fpi-M_PI) << std::endl; //<< " " << gpi << std::endl;
}
Overwriting pi_examp.cc
from pathlib import Path
# Homebrew uses versioned names for GNU C++, for example g++-15.
candidates = [p for root in ('/opt/homebrew/bin', '/usr/local/bin')
for p in Path(root).glob('g++-[0-9]*')]
if not candidates:
raise FileNotFoundError('GNU C++ was not found. On macOS, install it with: brew install gcc')
gxx = str(max(candidates, key=lambda p: int(p.name.rsplit('-', 1)[1])))
print('Using', gxx)
cmd=f'"{gxx}" -fopenmp -O3 -o pi_examp pi_examp.cc'
!{cmd}
!{export OMP_NUM_THREADS=1; pi_examp}
!{export OMP_NUM_THREADS=6; pi_examp}
!{export OMP_NUM_THREADS=12; pi_examp}
Using /opt/homebrew/bin/g++-15 clock time : 0.082733s with wall time=0.082731s At n=1000000 approximation for pi= 3.14159 with error= 2.88658e-14 clock time : 0.073281s with wall time=0.014555s At n=1000000 approximation for pi= 3.14159 with error= 8.9706e-14 clock time : 0.11407s with wall time=0.0168s At n=1000000 approximation for pi= 3.14159 with error= 8.30447e-14
Python example: a parallel reduction¶
Numba recognizes total += value as a reduction inside a prange loop. Each thread accumulates a private partial sum, and Numba combines the partial results after the loop.
@njit(parallel=True)
def midpoint_pi(N):
dx = 1.0/N
total = 0.0
for i in prange(N):
x = (i + 0.5)*dx
total += 4.0/(1.0 + x*x)
return total*dx
midpoint_pi(10) # compile before examining the timing
for N in (1_000, 100_000, 10_000_000):
estimate = midpoint_pi(N)
error = abs(estimate - np.pi)
print(f'N={N:>10,d}: pi={estimate:.15f}, error={error:.3e}')
N= 1,000: pi=3.141592736923126, error=8.333e-08 N= 100,000: pi=3.141592653598126, error=8.333e-12 N=10,000,000: pi=3.141592653589811, error=1.776e-14
set_num_threads(12)
N = 100_000_000
calls_per_trial = 5
original_threads = get_num_threads()
thread_counts = np.arange(1, original_threads + 1)
thread_counts = range(1,get_num_threads()+1)
times = []
for p in thread_counts:
set_num_threads(p) # using only limited number of cores after that line
start = time.perf_counter()
for _ in range(calls_per_trial):
estimate = midpoint_pi(N)
elapsed = time.perf_counter() - start
times.append(elapsed)
print(f'{p:3d} threads: {elapsed:.4f} s')
set_num_threads(get_num_threads())
times = np.array(times)
speedup = times[0]/times
fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(thread_counts, speedup, 'o-', label='measured')
ax.plot(thread_counts, thread_counts, '--', color='0.5', label='ideal')
ax.set_xlabel('number of threads')
ax.set_ylabel('speedup')
ax.grid()
ax.legend()
fig.tight_layout()
1 threads: 0.3852 s 2 threads: 0.1788 s 3 threads: 0.1302 s 4 threads: 0.0974 s 5 threads: 0.0790 s 6 threads: 0.0665 s 7 threads: 0.0703 s 8 threads: 0.0634 s 9 threads: 0.0579 s 10 threads: 0.0546 s 11 threads: 0.0493 s 12 threads: 0.0502 s
Python example: contiguous and strided access¶
NumPy arrays use C order by default, so the last index is contiguous. These functions perform identical arithmetic but visit memory in different orders.
@njit
def transform_contiguous(a, result):
for i in range(a.shape[0]):
for j in range(a.shape[1]):
result[i, j] = 2.0*a[i, j] + 1.0
@njit
def transform_strided(a, result):
for j in range(a.shape[1]):
for i in range(a.shape[0]):
result[i, j] = 2.0*a[i, j] + 1.0
def typical_time(function, *args, repeat=7, number=5):
"""
Return the median execution time per function call.
"""
measurements = []
for _ in range(repeat):
start = time.perf_counter()
for _ in range(number):
function(*args)
elapsed = time.perf_counter() - start
measurements.append(elapsed/number)
return np.median(measurements)
a = np.random.default_rng(509).random((3000, 3000))
result = np.empty_like(a)
# Compile both functions.
transform_contiguous(a, result)
transform_strided(a, result)
contiguous_time = typical_time(transform_contiguous, a, result)
strided_time = typical_time(transform_strided, a, result)
print(f'contiguous access: {contiguous_time:.6f} s')
print(f'strided access: {strided_time:.6f} s')
print(
f'slowdown: '
f'{strided_time/contiguous_time:.2f} times'
)
contiguous access: 0.001399 s strided access: 0.035034 s slowdown: 25.04 times