Lesson 06: Performance & Parallelism

Python is slow for explicit loops but can be as fast as C when you use the right tools. This lesson explains why, how to find bottlenecks, and which technique to reach for first.


Why Python loops are slow

Python executes one bytecode instruction at a time, with type checking and memory management overhead on every operation. A loop over a million elements that runs in milliseconds in C can take seconds in Python.

The Global Interpreter Lock (GIL) adds a second constraint: the CPython interpreter allows only one thread to execute Python bytecode at a time. Pure-Python threading does not give you CPU parallelism.

The solution is not to rewrite in C. It is to push loops into libraries that run in compiled code.


Vectorisation first

Replacing Python for loops with NumPy operations is called vectorisation. It should be your first instinct whenever you find yourself looping over array elements.

import numpy as np

# Slow: explicit Python loop
def slow_flux(energies, gamma):
    result = []
    for E in energies:
        result.append(E ** (-gamma))
    return result

# Fast: vectorised
def fast_flux(energies: np.ndarray, gamma: float) -> np.ndarray:
    return energies ** (-gamma)

The vectorised version runs the same arithmetic in compiled C code inside NumPy. For arrays with a million elements the difference is typically 100–1000×.

SciPy, Pandas, and most of the scientific Python stack are already vectorised internally. The rule: if you are writing a for loop over a NumPy array, ask whether a NumPy or SciPy function already does what you need.


Profiling: find the bottleneck first

Optimising the wrong function wastes time and makes code harder to read. Always profile before optimising.

cProfile

python -m cProfile -s cumtime myscript.py | head -20

This prints the 20 functions where your program spends the most cumulative time.

line_profiler

For line-by-line timing within a specific function:

pip install line-profiler

Decorate the function you want to examine:

@profile
def compute_spectrum(energies, gamma):
    ...

Run with:

kernprof -l -v myscript.py

Multiprocessing

When you have many independent tasks, such as running the same simulation with different seeds or processing different files, multiprocessing gives you true CPU parallelism by spawning separate processes, each on its own core, bypassing the GIL entirely.

from multiprocessing import Pool
import numpy as np

def run_simulation(seed: int) -> float:
    rng = np.random.default_rng(seed)
    events = rng.exponential(scale=1e5, size=10000)
    return float(events.mean())

seeds = list(range(500))

with Pool(processes=8) as pool:
    results = pool.map(run_simulation, seeds)

concurrent.futures.ProcessPoolExecutor is a cleaner modern API for the same thing:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=8) as executor:
    results = list(executor.map(run_simulation, seeds))

Threading vs multiprocessing

Use threading for I/O-bound work (downloading files, reading from disk) where the bottleneck is waiting, not computing. Threads release the GIL while waiting. Use multiprocessing for CPU-bound work where you want to use multiple cores. For most physics computation, multiprocessing is the right choice.


Numba

Numba compiles Python functions to native machine code using LLVM. It is most useful when your computation is genuinely loop-heavy and cannot be vectorised, for example when each step depends on the result of the previous one.

pip install numba
from numba import njit
import numpy as np

@njit
def track_particle(positions: np.ndarray, momenta: np.ndarray,
                   dt: float, n_steps: int) -> np.ndarray:
    for step in range(n_steps):
        for i in range(len(positions)):
            positions[i] += momenta[i] * dt
    return positions

The @njit decorator compiles the function the first time it is called. Subsequent calls run at near-C speed. The compilation happens once per session; after that you pay only the cost of the computation.

When to use Numba: loops that cannot be vectorised, recurrence relations, particle tracking with branching logic.


JAX

JAX is a NumPy replacement that runs on CPUs, GPUs, and TPUs, and supports automatic differentiation (autodiff). It is used in machine learning (including physics applications with PyTorch-adjacent workflows) and increasingly in physics simulation.

pip install jax
import jax.numpy as jnp
from jax import jit, grad

@jit
def chi_squared(params: jnp.ndarray, E: jnp.ndarray, observed: jnp.ndarray) -> float:
    gamma, norm = params
    predicted = norm * E ** (-gamma)
    return jnp.sum((predicted - observed) ** 2)

# Compute the gradient exactly — no finite differences
gradient = grad(chi_squared)(params, E, observed)

@jit compiles the function to XLA code. grad computes its exact gradient via backpropagation. This is very powerful for fitting problems where you optimise over many parameters at once.

JAX enforces functional programming: no in-place mutation of arrays. This is the main learning curve; once you are comfortable with it, the performance and autodiff capabilities are hard to give up.


GPU computing

Why GPUs are fast

A modern CPU has 8–64 powerful cores designed to execute complex, branchy code as fast as possible. A modern GPU has thousands of simpler cores designed to execute the same instruction on thousands of data elements simultaneously. This is called SIMT (Single Instruction, Multiple Threads).

For physics work this matters when:

  • You are training or running a neural network (matrix multiplications on millions of weights)
  • You are running many independent simulations (particle showers, MC sampling)
  • You are computing large FFTs or solving dense linear systems

It does not help when:

  • The computation is sequential (each step depends on the previous)
  • The array fits comfortably in CPU cache and the operation is simple (vectorised NumPy is already close to optimal)
  • The time to transfer data to GPU memory (VRAM) exceeds the time saved by faster compute

The memory bottleneck

Data lives in CPU RAM by default. Before the GPU can operate on it, it must be copied to GPU VRAM. For small problems this transfer cost can dominate entirely. The GPU is faster per FLOP but you spend more time moving data than computing. Rule of thumb: only use GPU if the computation takes at least 10× longer than the transfer would on its own.

PyTorch on GPU

PyTorch makes GPU programming straightforward. The core pattern is selecting a device and moving tensors to it; all subsequent operations run on that device automatically.

import torch

# Select the best available device
if torch.cuda.is_available():
    device = "cuda"          # NVIDIA GPU
elif hasattr(torch, "xpu") and torch.xpu.is_available():
    device = "xpu"           # Intel Arc GPU (requires intel-extension-for-pytorch)
elif torch.backends.mps.is_available():
    device = "mps"           # Apple Silicon
else:
    device = "cpu"

print(f"Using: {device}")

# Create tensors directly on the device
a = torch.randn(10_000, 10_000, device=device)
b = torch.randn(10_000, 10_000, device=device)

# This matrix multiplication runs on the GPU — no code change needed
c = a @ b

For accurate GPU timing you must call torch.cuda.synchronize() before stopping the clock, because GPU kernels are dispatched asynchronously. The Python line returns immediately while the GPU is still computing.

import time

# CPU timing — straightforward
t0 = time.perf_counter()
c_cpu = a_cpu @ b_cpu
t_cpu = time.perf_counter() - t0

# GPU timing — must synchronise first
t0 = time.perf_counter()
c_gpu = a_gpu @ b_gpu
torch.cuda.synchronize()   # wait for GPU to finish
t_gpu = time.perf_counter() - t0

Forgetting synchronize() produces meaningless GPU timings that appear impossibly fast.

Moving data between CPU and GPU

# CPU → GPU
tensor_gpu = tensor_cpu.to(device)          # or .cuda()

# GPU → CPU (needed before converting to NumPy)
tensor_cpu = tensor_gpu.cpu()
arr = tensor_cpu.numpy()

Keep transfers to a minimum. Load your data once onto the GPU at the start of a loop, do all the compute there, and transfer back only the final result.

JAX on GPU

JAX selects the GPU automatically, no device management needed. The same @jit-decorated function runs on CPU or GPU depending on what is installed.

import jax
import jax.numpy as jnp
from jax import jit

print(jax.devices())   # shows [CudaDevice(id=0)] on a GPU machine

@jit
def matmul(a, b):
    return a @ b

a = jnp.ones((10_000, 10_000))
b = jnp.ones((10_000, 10_000))
c = matmul(a, b)   # runs on GPU if available, CPU otherwise

Hardware options

Hardware Framework Device string
NVIDIA GPU PyTorch (standard) "cuda"
NVIDIA GPU JAX automatic
Intel Arc GPU intel-extension-for-pytorch "xpu"
AMD GPU PyTorch (ROCm build) "cuda" (ROCm maps to the CUDA API)
Apple Silicon PyTorch (standard) "mps"

WSL2 and Intel Arc

Intel Arc GPU compute (OpenCL / Level Zero / IPEX XPU) requires the /dev/dri render node provided by Intel's DRM kernel driver. The Microsoft WSL2 kernel does not include this driver, so Intel GPU compute is not accessible from WSL2 without a custom kernel. Run Linux natively for full Intel Arc support.

HPC clusters

Research computing clusters almost universally provide NVIDIA GPUs. Check with nvidia-smi on the login node or in the job script to confirm availability and see which CUDA version is loaded.


Decision guide

Situation Recommended tool
Loop over array elements NumPy vectorisation (always try this first)
Many independent tasks in parallel multiprocessing / ProcessPoolExecutor
Loop-heavy code that cannot be vectorised Numba @njit
Large matrix ops, neural networks, many-particle simulation GPU via PyTorch or JAX
Autodiff / differentiable programming JAX or PyTorch autograd
I/O-bound parallel work threading

Lesson 07 introduces machine learning with PyTorch, where the performance ideas from this lesson (GPU arrays, differentiable computation) become central.