School of Economics, Peking University
2026-01-28
Specifications
Tutorials
Books
Introduction to High Performance Computing for Scientists and Engineers
Georg Hager, Gerhard Wellein
Using Advanced MPI: Modern Features of the Message-Passing Interface
(Scientific and Engineering Computation) MIT Press, 2014
Gropp, W.; Höfler, T.; Thakur, R.; Lusk, E.
High-performance computing (HPC) enables us to:
Handle huge data sets
Tackle complex problems
Leverage specialized solutions for:
Transmission speeds:
The speed of a serial computer is limited by how fast data can move through hardware. Fundamental limits include:
Limits of miniaturization:
While processor technology can place more transistors on a chip, even atomic-scale components will eventually reach their limit on miniaturization.
Economic limits:
Making a single processor faster becomes increasingly expensive. Using many commodity processors in parallel is often more cost-effective and can provide better performance.
A computer is a “stupid” device; it only understands “on” and “off”.
Early programmers communicated directly in 0s and 1s.
Later, programs were developed to translate from symbolic notation to binary.
Advanced programming languages improve upon assembly:
Virtually all computers have followed this basic design, which is comprised of four main components:
For a long time, speeding up computations was considered a “free lunch”:
However, this free lunch has ended in recent years:
We used to focus only on floating point operations per second. Now, we must also consider floating point operations per Watt.
Serial and Parallel Computing
\(T(p, N)\): Time to solve a problem of total size N on p processors.
Parallel Speedup: Let \(S(p, N) = \frac{T(1, N)}{T(p, N)}\) be the parallel speedup.
Parallel Efficiency: Let \(E(p, N) = \frac{S(p, N)}{p}\) be the parallel efficiency.
Theorem 1 (Amdahl’s Law) \[T(p, N) = f \cdot T(1, N) + (1 - f)\frac{T(1, N)}{p}\] where: \(f\) is the fraction of the computation that is sequential (cannot be parallelized), \((1-f)\) is the fraction that can be parallelized.
Resulting speedup: \(S(p, N) = \frac{1}{f + \frac{1-f}{p}}\)
Limitation: As \(p \to \infty\), the maximum speedup approaches: \(S(p, N) < \frac{1}{f}\)
That is, the speedup is limited by the sequential portion of the code.
Strong scaling: Is defined as how the solution time varies with the number of processors for a fixed total problem size.
Weak scaling: Is defined as how the solution time varies with the number of processors for a fixed problem size per processor.
Scenario:
You need to price 10,000 exotic options using Monte Carlo simulation.
The Reality:
Your laptop has 14 CPU cores, but Python is only using one of them!
The Goal:
Use all cores → Reduce time to ≈ 1.5 minutes
Sequential and Parallel Execution
Tip
Key Insight: If tasks are independent, we can run them simultaneously on different cores.
| Layer | Modules/Concepts | Description |
|---|---|---|
| High-level | concurrent.futures |
Start here! Clean, simple API for both threading & multiprocessing |
| Mid-level | threading, multiprocessing |
Lower-level modules to manage OS threads/processes directly |
| Low-level | OS Threads/Processes | Managed by the operating system |
Our Focus:
We’ll use concurrent.futures — a clean, high-level API that supports both threading and multiprocessing.
Sequential (what you’re used to):
Parallel (what we’ll learn):
That’s it! Two extra lines of code can give you 4–8× speedup on multi-core machines.
Note
The Python Catch: Due to the Global Interpreter Lock (GIL), Python threads do not execute bytecode in true parallel for CPU-bound work.
What is the GIL?
Implications:
# Timeline illustration:
# Time →
# T1: |---Python code---|.......I/O wait.......|
# T2: |---Python code---|...I/O wait...|
Note
Key Insight: Threads take turns using the CPU while others are waiting for I/O, enabling efficient concurrency for I/O-bound workloads.
| Characteristic | I/O-Bound | CPU-Bound |
|---|---|---|
| Bottleneck | Waiting for data | Calculations |
| CPU usage | Low (lots of idle) | High (near 100%) |
| Solution | Threading | Multiprocessing |
Tip
How to Tell?
Run your code and check CPU usage. If it’s low while code runs slowly → you have an I/O-bound problem.
CPU times: user 440 μs, sys: 465 μs, total: 905 μs
Wall time: 505 ms
CPU times: user 230 μs, sys: 178 μs, total: 408 μs
Wall time: 2.01 s
Tip
Sequential: 4 × 0.5s = 2.0s
Parallel: ≈ 0.5s (4x faster!)
executor.map()Use when:
executor.submit() + as_completed()Use when:
from concurrent.futures import as_completed
tickers = ["AAPL", "GOOGL", "MSFT", "AMZN"]
with ThreadPoolExecutor() as executor:
# Submit tasks
futures = {executor.submit(fetch_stock_data, t): t for t in tickers}
# Process results as they complete
for future in as_completed(futures):
ticker = futures[future]
result = future.result()
print(f"{ticker}: got data!")AAPL: got data!
AMZN: got data!
GOOGL: got data!
MSFT: got data!
def risky_fetch(ticker):
if ticker == "BAD":
raise ValueError(f"Invalid ticker: {ticker}")
return {"ticker": ticker, "price": 100.0}
tickers = ["AAPL", "BAD", "MSFT"]
with ThreadPoolExecutor() as executor:
futures = {executor.submit(risky_fetch, t): t for t in tickers}
for future in as_completed(futures):
ticker = futures[future]
try:
result = future.result()
print(f"{ticker}: {result}")
except Exception as e:
print(f"{ticker}: ERROR - {e}")AAPL: {'ticker': 'AAPL', 'price': 100.0}
BAD: ERROR - Invalid ticker: BAD
MSFT: {'ticker': 'MSFT', 'price': 100.0}
When to Use Threading
When NOT to Use Threading
Best Practices
ThreadPoolExecutor (not raw threads)with statement)The GIL Problem:
The Solution: Multiprocessing
The Tool: joblib
Sequential Loop (List Comprehension):
Parallel Loop (Joblib):
Tip
Mental Model: Think of delayed(func)(args) as wrapping your function call in a “package” to be sent to another CPU core.
Let’s estimate using all your laptop’s cores.
Run in Parallel:
Estimated pi: 3.14160
CPU times: user 68.7 ms, sys: 62.6 ms, total: 131 ms
Wall time: 879 ms
| Task Type | Examples | Recommended Tool |
|---|---|---|
| I/O Bound | Web scraping, API calls, File reading | ThreadPoolExecutor (Standard Lib) |
| CPU Bound | Simulation, Optimization, Estimation | joblib (Easier & Robust) |
Note
Pro Tip: joblib can also handle threading!
Just use: Parallel(n_jobs=4, prefer="threads")(...)
Parallel bugs can be hard to find because of:
Strategies for Debugging
max_workers=1.future.result() (or similar) in try/except.Tip
Golden Rule:
max_workers=1, it should work with more.Python is dynamic and flexible, but this comes at a cost: loops are slow. Every iteration requires type-checking and memory allocation.
Numba is a Just-In-Time (JIT) compiler that translates a subset of Python and NumPy code into fast machine code (using LLVM).
@njit (nopython mode): The core decorator. It attempts to compile the decorated function entirely without the Python interpreter. If it fails, it raises an error.parallel=True: Enables automatic parallelization of array operations and explicit parallel loops.prange: “Parallel Range”. A replacement for range that tells Numba: “It is safe to run iterations of this loop in any order, on different CPU cores.”Imagine an economy with 3 major sectors (e.g., Manufacturing, Services, Tech). We want to estimate the “Tail Risk” (5th percentile outcome) of Aggregate GDP growth.
def simulate_gdp_tail_risk(n_sims, weights, mu, sigma):
"""
Simulates aggregate GDP growth scenarios to find the 5th percentile (Tail Risk).
"""
gdp_growth = np.empty(n_sims)
# --- The Bottleneck ---
# Python loops have high overhead for simple math
for i in range(n_sims):
sector_shocks = np.random.randn(len(weights))
sector_growth = sector_shocks * sigma + mu
gdp_growth[i] = np.dot(weights, sector_growth)
# ----------------------
gdp_growth.sort()
# Return the 5th percentile (Left-tail risk)
return gdp_growth[int(n_sims * 0.05)]We now run the simulation.
# Parameters
n_sims = int(1e6)
sector_weights = np.array([0.2, 0.5, 0.3]) # Manuf, Services, Tech
mu = np.array([0.01, 0.02, 0.03]) # Trend growth
sigma = np.array([0.05, 0.04, 0.08]) # Volatility
# Timing
start_time = time.time()
risk_result = simulate_gdp_tail_risk(n_sims, sector_weights, mu, sigma)
pure_time = time.time() - start_time
print(f"5% Tail Risk (GDP Growth): {risk_result:.4f}")
print(f"Python Time: {pure_time:.4f} seconds")5% Tail Risk (GDP Growth): -0.0329
Python Time: 1.0558 seconds
@njit)We add the @njit decorator. This compiles the loop into machine code, eliminating Python’s interpreter overhead.
@numba.njit
def simulate_gdp_numba(n_sims, weights, mu, sigma):
gdp_growth = np.empty(n_sims)
for i in range(n_sims):
sector_shocks = np.random.randn(len(weights))
sector_growth = sector_shocks * sigma + mu
gdp_growth[i] = np.dot(weights, sector_growth)
gdp_growth.sort()
return gdp_growth[int(n_sims * 0.05)]# Warmup (Compile the function)
_ = simulate_gdp_numba(100, sector_weights, mu, sigma)
# Timing
start_time = time.time()
risk_numba = simulate_gdp_numba(n_sims, sector_weights, mu, sigma)
numba_time = time.time() - start_time
print(f"Numba Time: {numba_time:.4f} seconds")
print(f"Speedup: {pure_time / numba_time:.1f}x")Numba Time: 0.1667 seconds
Speedup: 6.3x
prange)Now we use all CPU cores.
parallel=True to the decorator.range with numba.prange.@numba.njit(parallel=True)
def simulate_gdp_parallel(n_sims, weights, mu, sigma):
gdp_growth = np.empty(n_sims)
# prange tells Numba: "You can split this loop across cores"
for i in numba.prange(n_sims):
sector_shocks = np.random.randn(len(weights))
sector_growth = sector_shocks * sigma + mu
gdp_growth[i] = np.dot(weights, sector_growth)
gdp_growth.sort()
return gdp_growth[int(n_sims * 0.05)]# Warmup
_ = simulate_gdp_parallel(100, sector_weights, mu, sigma)
start_time = time.time()
risk_parallel = simulate_gdp_parallel(n_sims, sector_weights, mu, sigma)
parallel_time = time.time() - start_time
print(f"Parallel Time: {parallel_time:.4f} seconds")
print(f"Total Speedup vs Python: {pure_time / parallel_time:.1f}x")Parallel Time: 0.0749 seconds
Total Speedup vs Python: 14.1x