The reduction post ended with a scaling curve that bent at four threads because half the cores are efficiency cores. Amdahl's and Gustafson's laws model the other reason curves bend: the part of a run that does not get faster with more workers. They answer two different questions, and the clearest way to see what each says is to fit both to a measured curve, here from Python's multiprocessing on an Apple-silicon laptop (Apple M1, hw.ncpu = 8).
Two laws, two questions
Strong scaling fixes the problem and adds processes: how much sooner does this job finish? If a fraction
That is Amdahl's law (1967), and the ceiling
Weak scaling fixes the work per process and grows the problem with
a straight line with no ceiling. The two
Solving Amdahl's law for
The experiment
The workload is the midpoint rule for
import multiprocessing as mp
import time
import numpy as np
def integrate(lo, hi, n, block=1 << 14):
h, s = 1.0 / n, 0.0
for a in range(lo, hi, block):
x = (np.arange(a, min(a + block, hi), dtype=np.float64) + 0.5) * h
s += float(np.sum(4.0 / (1.0 + x * x)))
return s * h
def task(args):
return integrate(*args)
def ready(barrier):
barrier.wait() # this worker has started and imported NumPy
def run(p, n, ntasks):
step = n // ntasks
tasks = [(i * step, (i + 1) * step, n) for i in range(ntasks)]
barrier = mp.Barrier(p + 1)
t0 = time.perf_counter()
pool = mp.Pool(p, initializer=ready, initargs=(barrier,))
barrier.wait() # all p workers are up
t1 = time.perf_counter()
pi = sum(pool.imap_unordered(task, tasks, chunksize=1))
t2 = time.perf_counter()
pool.close(); pool.join()
return time.perf_counter() - t0, t1 - t0, t2 - t1, pi
if __name__ == "__main__":
for p in range(1, 9):
print("strong", p, run(p, 1 << 30, 256)) # fixed total: 2^30 points
print("weak ", p, run(p, p << 27, 32 * p)) # fixed per process: 2^27 points
imap_unordered with chunksize=1 hands out tasks one at a time, so workers on fast cores take more of them. The eight-process weak run is exactly the strong problem. Software: Python 3.14.0 (standard build, with a GIL), NumPy 2.3.4, SciPy 1.16.2, and the spawn start method, the macOS default since Python 3.8. Each number is the median of seven runs with process counts interleaved; the laptop was in ordinary use, so the min–max bars are part of the result. All 119 runs returned
Strong scaling and the fit
Calling integrate directly in the parent takes 1.90 s. A pool of one worker takes 2.16 s, of which 0.26 s (12 %) is start-up.
| p | strong | S(p) | E(p) | start-up (s) | e(p) | weak | pT_w(1)/T_w(p) | weak |
|---|---|---|---|---|---|---|---|---|
| 1 | 2.156 | 1.00 | 1.00 | 0.256 | – | 0.501 | 1.00 | 1.00 |
| 2 | 1.260 | 1.71 | 0.86 | 0.266 | 0.169 | 0.524 | 1.91 | 0.95 |
| 3 | 0.994 | 2.17 | 0.72 | 0.282 | 0.191 | 0.558 | 2.69 | 0.90 |
| 4 | 0.866 | 2.49 | 0.62 | 0.300 | 0.202 | 0.592 | 3.38 | 0.84 |
| 5 | 0.865 | 2.49 | 0.50 | 0.353 | 0.252 | 0.672 | 3.73 | 0.75 |
| 6 | 0.863 | 2.50 | 0.42 | 0.357 | 0.280 | 0.793 | 3.79 | 0.63 |
| 7 | 0.884 | 2.44 | 0.35 | 0.401 | 0.312 | 0.885 | 3.96 | 0.57 |
| 8 | 0.998 | 2.16 | 0.27 | 0.449 | 0.386 | 0.985 | 4.07 | 0.51 |
The speed-up reaches 2.5 at four processes, stays near it through seven and drops to 2.16 at eight. The compute phase alone still speeds up 4.33 times on eight processes (short of 8 because processes five to eight run on efficiency cores), but start-up grows from 0.26 s to 0.45 s as more interpreters start at once, and at eight processes it is 45 % of the run.
Least squares on all eight speed-ups gives
The fitting code
from scipy.optimize import curve_fit
p = np.arange(1, 9)
S = T[0] / T # T: median strong-scaling times
amdahl = lambda p, f: 1.0 / (f + (1.0 - f) / p)
(f_A,), cov = curve_fit(amdahl, p, S, p0=[0.05], bounds=(0, 1)) # nonlinear least squares on S
Sw = p * Tw[0] / Tw # Tw: median weak-scaling times
f_G = np.sum((p - 1) * (p - Sw)) / np.sum((p - 1) ** 2) # S = p - f(p-1) is linear in f
e = (1 / S[1:] - 1 / p[1:]) / (1 - 1 / p[1:]) # Karp-Flatt, p >= 2
Weak scaling and Gustafson
With
The most useful line in the table is the last one. The eight-process weak and strong runs are the same job,
Where the overhead comes from
Process start-up. With spawn, every worker is a new interpreter that imports the main module and NumPy before it takes a task. Python's documentation calls spawn slow next to fork and forkserver, but fork is unsafe on macOS because system libraries may have started threads, and since Python 3.14 the Linux default is forkserver. The fix is the same everywhere: create the pool once and reuse it.
Pickling. A task description pickles to about 30 bytes, and a trivial round trip through pool.apply costs 0.66 ms. Data is different: sending a 128 MiB array to a worker and summing it there took 0.111 s, against 0.010 s for np.sum in the parent. Send instructions to generate data, or use multiprocessing.shared_memory.
The GIL is irrelevant here. Each process has its own interpreter and its own GIL, so no lock is shared. With eight threads instead, a pure-Python version of the integral (
Memory bandwidth. The right panel repeats the strong-scaling run with
TIP
Before fitting anything, split the wall time the way the barrier does here: start-up, compute, teardown. A serial fraction measured that way is a number you can act on (reuse the pool, ship less data, block for the cache); a fitted
only tells you that something did not scale.
Further reading
- Gene Amdahl, Validity of the single processor approach to achieving large scale computing capabilities, AFIPS 1967.
- John Gustafson, Reevaluating Amdahl's law, Communications of the ACM, 1988.
- Alan Karp and Horace Flatt, Measuring parallel processor performance, Communications of the ACM, 1990, which introduces the experimentally determined serial fraction.
- The
multiprocessingdocumentation, especially the section on start methods, and thepickledocumentation. - NumPy's notes on thread safety and when it releases the GIL.