Skip to article frontmatterSkip to article content
Contents
and

13. Numba

In addition to what’s in Anaconda, this lecture will need the following libraries:

!pip install quantecon
Output
Requirement already satisfied: quantecon in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (0.11.4)
Requirement already satisfied: numba>=0.49.0 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from quantecon) (0.66.0)
Requirement already satisfied: numpy>=1.17.0 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from quantecon) (2.4.6)
Requirement already satisfied: requests in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from quantecon) (2.34.2)
Requirement already satisfied: scipy>=1.5.0 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from quantecon) (1.18.0)
Requirement already satisfied: sympy in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from quantecon) (1.14.0)
Requirement already satisfied: llvmlite<0.49,>=0.48.0dev0 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from numba>=0.49.0->quantecon) (0.48.0)
Requirement already satisfied: charset_normalizer<4,>=2 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from requests->quantecon) (3.4.9)
Requirement already satisfied: idna<4,>=2.5 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from requests->quantecon) (3.18)
Requirement already satisfied: urllib3<3,>=1.26 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from requests->quantecon) (2.7.0)
Requirement already satisfied: certifi>=2023.5.7 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from requests->quantecon) (2026.7.22)
Requirement already satisfied: mpmath<1.4,>=1.1.0 in /opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages (from sympy->quantecon) (1.3.0)

Please also make sure that you have the latest version of Anaconda, since old versions are a common source of errors.

Let’s start with some imports:

import numpy as np
import quantecon as qe
import matplotlib.pyplot as plt

13.1Overview

In an earlier lecture we discussed vectorization, which can improve execution speed by sending array processing operations in batch to efficient low-level code.

However, as discussed in that lecture, traditional vectorization schemes have weaknesses:

  • Highly memory-intensive for compound array operations

  • Ineffective or impossible for some algorithms

One way to circumvent these problems is by using Numba, a just in time (JIT) compiler for Python.

Numba compiles functions to native machine code instructions at runtime.

When it succeeds, the result is performance comparable to compiled C or Fortran.

In addition, Numba can do useful tricks such as multithreading.

This lecture introduces the core ideas.

13.2.1An Example

Let’s consider a problem that’s difficult to vectorize (i.e., hand off to array processing operations).

The problem involves generating the trajectory via the quadratic map

xt+1=αxt(1xt)x_{t+1} = \alpha x_t (1 - x_t)

In what follows we set α=4\alpha = 4.

13.2.1.1Base Version

Here’s the plot of a typical trajectory, starting from x0=0.1x_0 = 0.1, with tt on the x-axis

def qm(x0, n, α=4.0):
    x = np.empty(n+1)
    x[0] = x0
    for t in range(n):
      x[t+1] = α * x[t] * (1 - x[t])
    return x

x = qm(0.1, 250)
fig, ax = plt.subplots()
ax.plot(x, 'b-', lw=2, alpha=0.8)
ax.set_xlabel('$t$', fontsize=12)
ax.set_ylabel('$x_{t}$', fontsize = 12)
plt.show()
<Figure size 640x480 with 1 Axes>

Let’s see how long this takes to run for large nn

n = 10_000_000

with qe.Timer() as timer1:
    # Time Python base version
    x = qm(0.1, n)
7.3859 seconds elapsed

13.2.1.2Acceleration via Numba

To speed the function qm up using Numba, we first import the jit function

from numba import jit

Now we apply it to qm, producing a new function:

qm_numba = jit(qm)

The function qm_numba is a version of qm that is “targeted” for JIT-compilation.

We will explain what this means momentarily.

Let’s time this new version:

with qe.Timer() as timer2:
    # Time jitted version
    x = qm_numba(0.1, n)
0.2736 seconds elapsed

This is a large speed gain.

In fact, the next time and all subsequent times it runs even faster as the function has been compiled and is in memory:

with qe.Timer() as timer3:
    # Second run
    x = qm_numba(0.1, n)
0.0940 seconds elapsed

Here’s the speed gain

timer1.elapsed /  timer3.elapsed
78.55213124730582

This is a big boost for a small modification to our original code.

Let’s discuss how this works.

13.2.2How and When it Works

Numba attempts to generate fast machine code using the infrastructure provided by the LLVM Project.

It does this by inferring type information on the fly.

(See our earlier lecture on scientific computing for a discussion of types.)

The basic idea is this:

  • Python is very flexible and hence we could call the function qm with many types.

    • e.g., x0 could be a NumPy array or a list, n could be an integer or a float, etc.

  • This makes it very difficult to generate efficient machine code ahead of time (i.e., before runtime).

  • However, when we do actually call the function, say by running qm(0.5, 10), the types of x0, α and n are determined.

  • Moreover, the types of other variables in qm can be inferred once the input types are known.

  • So the strategy of Numba and other JIT compilers is to wait until the function is called, and then compile.

That is called “just-in-time” compilation.

Note that, if you make the call qm_numba(0.5, 10) and then follow it with qm_numba(0.9, 20), compilation only takes place on the first call.

This is because compiled code is cached and reused as required.

This is why, in the code above, the second run of qm_numba is faster.

13.3Sharp Bits

Numba is relatively easy to use but not always seamless.

Let’s review some of the issues users run into.

13.3.1Typing

Successful type inference is the key to JIT compilation.

In an ideal setting, Numba can infer all necessary type information.

When Numba cannot infer all type information, it will raise an error.

For example, in the setting below, Numba is unable to determine the type of the function g when compiling iterate

@jit
def iterate(f, x0, n):
    x = x0
    for t in range(n):
        x = f(x)
    return x

# Not jitted
def g(x):
    return np.cos(x) - 2 * np.sin(x)

# This code throws an error
try:
    iterate(g, 0.5, 100)
except Exception as e:
    print(e)
Failed in nopython mode pipeline (step: nopython frontend)
non-precise type pyobject
During: typing of argument at /tmp/ipykernel_4272/946716698.py (1)

File "../../../../../../tmp/ipykernel_4272/946716698.py", line 1:
<source missing, REPL/exec in use?>

During: Pass nopython_type_inference 

This error may have been caused by the following argument(s):
- argument 0: Cannot determine Numba type of <class 'function'>

In the present case, we can fix this easily by compiling g.

@jit
def g(x):
    return np.cos(x) - 2 * np.sin(x)

iterate(g, 0.5, 100)
2.223875299559663

In other cases, such as when we want to use functions from external libaries such as SciPy, there might not be any easy workaround.

13.3.2Global Variables

Another thing to be careful about when using Numba is handling of global variables.

For example, consider the following code

a = 1

@jit
def add_a(x):
    return a + x

print(add_a(10))
11
a = 2

print(add_a(10))
11

Notice that changing the global had no effect on the value returned by the function 😱.

When Numba compiles machine code for functions, it treats global variables as constants to ensure type stability.

To avoid this, pass values as function arguments rather than relying on globals.

13.4Multithreaded Loops in Numba

In addition to JIT compilation, Numba provides support for parallel computing on CPUs and GPUs.

The key tool for parallelization on CPUs in Numba is the prange function, which tells Numba to execute loop iterations in parallel across available cores.

To illustrate, let’s look first at a simple, single-threaded (i.e., non-parallelized) piece of code.

The code simulates updating the wealth wtw_t of a household via the rule

wt+1=Rt+1swt+yt+1w_{t+1} = R_{t+1} s w_t + y_{t+1}

Here

  • RR is the gross rate of return on assets

  • ss is the savings rate of the household and

  • yy is labor income.

We model both RR and yy as independent draws from a lognormal distribution.

Here’s the code:

@jit
def update(w, r=0.1, s=0.3, v1=0.1, v2=1.0):
    " Updates household wealth. "
    # Draw shocks
    R = np.exp(v1 * np.random.randn()) * (1 + r)
    y = np.exp(v2 * np.random.randn())
    # Update wealth
    w = R * s * w + y
    return w

Let’s have a look at how wealth evolves under this rule.

fig, ax = plt.subplots()

T = 100
w = np.empty(T)
w[0] = 5
for t in range(T-1):
    w[t+1] = update(w[t])

ax.plot(w)
ax.set_xlabel('$t$', fontsize=12)
ax.set_ylabel('$w_{t}$', fontsize=12)
plt.show()
<Figure size 640x480 with 1 Axes>

Now let’s suppose that we have a large population of households and we want to know what median wealth will be.

This is not easy to solve with pencil and paper, so we will use simulation instead:

  1. Simulate a large number of households forward in time

  2. Calculate median wealth

Here’s the code:

@jit
def compute_long_run_median(w0=1, T=1000, num_reps=50_000):
    obs = np.empty(num_reps)
    # For each household
    for i in range(num_reps):
        # Set the initial condition and run forward in time
        w = w0
        for t in range(T):
            w = update(w)
        # Record the final value
        obs[i] = w
    # Take the median of all final values
    return np.median(obs)

Let’s see how fast this runs:

with qe.Timer():
    # Warm up
    compute_long_run_median()
6.9735 seconds elapsed
with qe.Timer():
    # Second run
    compute_long_run_median()
4.4901 seconds elapsed

To speed this up, we’re going to parallelize it via multithreading.

To do so, we add the parallel=True flag and change range to prange:

from numba import prange

@jit(parallel=True)
def compute_long_run_median_parallel(
        w0=1, T=1000, num_reps=50_000
    ):
    obs = np.empty(num_reps)
    for i in prange(num_reps):  # Parallelize over households
        w = w0
        for t in range(T):
            w = update(w)
        obs[i] = w
    return np.median(obs)

Let’s look at the timing:

with qe.Timer():
    # Warm up
    compute_long_run_median_parallel()
2.6145 seconds elapsed
with qe.Timer():
    # Second run
    compute_long_run_median_parallel()
1.8104 seconds elapsed

The speed-up is significant.

Notice that we parallelize across households rather than over time -- updates of an individual household across time periods are inherently sequential.

For GPU-based parallelization, see our lectures on JAX.

13.5Exercises

Exercise 1 and Exercise 3 both estimate π\pi by Monte Carlo from random samples in the unit square.

We generate them here and store them in u_draws and v_draws so that we can use them in both exercises and compare results.

n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)
Solution to Exercise 1

Here is one solution:

@jit
def calculate_pi(u_draws, v_draws):
    n = len(u_draws)
    count = 0
    for i in range(n):
        u, v = u_draws[i], v_draws[i]
        d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
        if d < 0.5:
            count += 1

    area_estimate = count / n
    return area_estimate * 4  # dividing by radius**2

Now let’s see how fast it runs:

with qe.Timer():
    calculate_pi(u_draws, v_draws)
0.3152 seconds elapsed
with qe.Timer():
    calculate_pi(u_draws, v_draws)
0.0015 seconds elapsed

If we switch off JIT compilation by removing @jit, the code takes considerably longer on our machine.

So we get a large speed gain by adding four characters.

The solution above takes one of two natural approaches: it draws all the random points first, stores them in u_draws and v_draws, and then lets the jitted function loop over them.

The other approach is to draw each point inside the loop.

To do this with a NumPy Generator, we pass rng in as an argument and call rng.uniform() inside the loop body

@jit
def calculate_pi_in_loop(rng, n):
    count = 0
    for i in range(n):
        u, v = rng.uniform(), rng.uniform()
        d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
        if d < 0.5:
            count += 1
    return (count / n) * 4
with qe.Timer():
    calculate_pi_in_loop(rng, n)
0.4286 seconds elapsed
with qe.Timer():
    calculate_pi_in_loop(rng, n)
0.0160 seconds elapsed

The two cells timing the first approach measure only the loop --- its random points are drawn once in the shared setup block above and are never timed, while the second approach pays for its draws inside the timed function.

To compare the two approaches fairly, we time the first approach end-to-end, including the cost of generating the arrays:

with qe.Timer():
    u2 = rng.uniform(size=n)
    v2 = rng.uniform(size=n)
    calculate_pi(u2, v2)
0.0234 seconds elapsed

In this serial setting the two approaches give equally good estimates and run at similar speed, but they are not equivalent in memory use.

The first approach must hold all 2n2n draws in memory at once --- two arrays of n floating point numbers, or about 16n bytes (around 1.6 GB when n = 100_000_000).

The second draws each point on demand and discards it, so its memory footprint does not grow with n.

This might suggest that drawing inside the loop is the better default.

But as we will see in Exercise 4, drawing inside the loop interacts badly with parallelization.

Solution to Exercise 2

We let

  • 0 represent “low”

  • 1 represent “high”

p, q = 0.1, 0.2  # Prob of leaving low and high state respectively

Here’s a pure Python version of the function

n = 1_000_000
rng = np.random.default_rng()
U = rng.uniform(0, 1, size=n)

def compute_series(n, U):
    x = np.empty(n, dtype=np.int64)
    x[0] = 1  # Start in state 1
    for t in range(1, n):
        current_x = x[t-1]
        if current_x == 0:
            x[t] = U[t] < p
        else:
            x[t] = U[t] > q
    return x

Let’s run this code and check that the fraction of time spent in the low state is about 0.666

x = compute_series(n, U)
print(np.mean(x == 0))  # Fraction of time x is in state 0
0.666329

This is (approximately) the right output.

Now let’s time it:

with qe.Timer():
    compute_series(n, U)
1.2750 seconds elapsed

Next let’s implement a Numba version, which is easy

compute_series_numba = jit(compute_series)

Let’s check we still get the right numbers

x = compute_series_numba(n, U)
print(np.mean(x == 0))
0.666329

Let’s see the time

with qe.Timer():
    compute_series_numba(n, U)
0.0097 seconds elapsed

This is a nice speed improvement for one line of code!

Solution to Exercise 3

Here is one solution:

@jit(parallel=True)
def calculate_pi_parallel(u_draws, v_draws):
    n = len(u_draws)
    count = 0
    for i in prange(n):
        u, v = u_draws[i], v_draws[i]
        d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
        if d < 0.5:
            count += 1

    area_estimate = count / n
    return area_estimate * 4  # dividing by radius**2

Now let’s see how fast it runs:

with qe.Timer():
    calculate_pi_parallel(u_draws, v_draws)
0.8822 seconds elapsed
with qe.Timer():
    calculate_pi_parallel(u_draws, v_draws)
0.0062 seconds elapsed

By switching parallelization on and off (selecting True or False in the @jit annotation), we can test the speed gain that multithreading provides on top of JIT compilation.

On our workstation, we find that parallelization provides a modest but worthwhile speed gain here.

(If you are executing locally, you will get different results, depending mainly on the number of CPUs on your machine.)

Notice that we drew all of the random points before the loop and passed them in as arrays, so the parallel loop only reads from memory.

Drawing the points inside the parallel loop instead is surprisingly delicate.

We investigate why, and how to do it safely, in Exercise 4.

Solution to Exercise 4

Here is the tempting version.

We pass rng in as an argument and call it inside the prange loop.

n = 1_000_000
rng = np.random.default_rng()

@jit(parallel=True)
def calculate_pi_in_loop_parallel(rng, n):
    count = 0
    for i in prange(n):
        u, v = rng.uniform(), rng.uniform()
        d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
        if d < 0.5:
            count += 1
    return (count / n) * 4

calculate_pi_in_loop_parallel(rng, n)
3.13284

The code runs without error and returns something close to π\pi.

But something is silently wrong with the results.

Here, every thread draws from the same generator rng.

A generator produces each number by updating an internal state.

Under prange, many threads read and update that single state at once, with no coordination between them.

This is a data race.

It creates correlations across the draws and can even cause some draws to be duplicated in an unpredictable way.

Two symptoms reveal the problem.

Symptom 1: the result is no longer reproducible.

A correct generator returns the same answer whenever it is given the same seed.

Because of the data race, the order in which threads happen to touch the shared state affects the stream of draws, so the answer is not reproducible even when the seed is fixed.

for seed in (1, 1, 1):
    print(calculate_pi_in_loop_parallel(np.random.default_rng(seed), n))
3.133476
3.13112
3.141124

Each call uses the same seed, yet the answers differ.

Symptom 2: the estimator is far noisier than it should be.

The duplicated and correlated draws carry less information than nn independent draws, so the effective sample size is much smaller than nn.

The fix is to give each thread its own random state, which NumPy’s legacy functions such as np.random.uniform() do automatically under Numba.

@jit(parallel=True)
def calculate_pi_legacy(n):
    count = 0
    for i in prange(n):
        u, v = np.random.uniform(0, 1), np.random.uniform(0, 1)
        d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
        if d < 0.5:
            count += 1
    return (count / n) * 4

To see the cost of the race, we repeat each estimate many times and plot its spread against the correct version as the sample size grows.

sample_sizes = np.logspace(3, 6, 10).astype(int)
num_reps = 20

methods = [("per-thread state (correct)",
            lambda n: calculate_pi_legacy(n), 'C0'),
           ("shared generator in prange (data race)",
            lambda n: calculate_pi_in_loop_parallel(np.random.default_rng(), n), 'C1')]

fig, ax = plt.subplots()
for label, estimate, color in methods:
    draws = np.array([[estimate(int(m)) for _ in range(num_reps)]
                      for m in sample_sizes])
    means, stds = draws.mean(axis=1), draws.std(axis=1)
    ax.plot(sample_sizes, means, color=color, marker='o', ms=3, label=label)
    ax.fill_between(sample_sizes, means - 2 * stds, means + 2 * stds,
                    color=color, alpha=0.2)
ax.axhline(np.pi, color='k', lw=0.8, ls='--', label=r'$\pi$')
ax.set_xscale('log')
ax.set_xlabel('number of samples')
ax.set_ylabel(r'estimate of $\pi$')
ax.legend()
plt.show()
<Figure size 640x480 with 1 Axes>

Both bands are centered on π\pi, but the band associated with the data race is wider than the other one and narrows slowly as the sample size grows.

The other safe option is the one from Exercise 3: draw the points before the loop so that the parallel loop only reads from memory.

Solution to Exercise 5

We time each approach from start to finish, so the pre-draw version pays for building its arrays.

n = 100_000_000
rng = np.random.default_rng()

with qe.Timer():
    u_draws = rng.uniform(size=n)
    v_draws = rng.uniform(size=n)
    calculate_pi_parallel(u_draws, v_draws)
2.0656 seconds elapsed
with qe.Timer():
    calculate_pi_legacy(n)
1.6213 seconds elapsed

Drawing inside the loop is much faster.

The pre-draw version generates its two arrays on a single thread before the loop begins.

The in-loop version spreads the random number generation across all threads instead.

It also avoids allocating two arrays of n numbers, so it saves both time and memory.

Solution to Exercise 6

With st:=lnSts_t := \ln S_t, the price dynamics become

st+1=st+μ+exp(ht)ξt+1s_{t+1} = s_t + \mu + \exp(h_t) \xi_{t+1}

Using this fact, the solution can be written as follows.

M = 10_000_000

n, β, K = 20, 0.99, 100
μ, ρ, ν, S0, h0 = 0.0001, 0.1, 0.001, 10, 0

@jit(parallel=True)
def compute_call_price_parallel(β=β,
                                μ=μ,
                                S0=S0,
                                h0=h0,
                                K=K,
                                n=n,
                                ρ=ρ,
                                ν=ν,
                                M=M):
    current_sum = 0.0
    # For each sample path
    for m in prange(M):
        s = np.log(S0)
        h = h0
        # Simulate forward in time
        for t in range(n):
            s = s + μ + np.exp(h) * np.random.randn()
            h = ρ * h + ν * np.random.randn()
        # And add the value max{S_n - K, 0} to current_sum
        current_sum += max(np.exp(s) - K, 0)

    return β**n * current_sum / M

Try swapping between parallel=True and parallel=False and noting the run time.

If you are on a machine with many CPUs, the difference should be significant.

CC-BY-SA-4.0

Creative Commons License – This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International.