GitHub

@@ -135,18 +135,36 @@ for x in grid:

135135136136

Let's switch to NumPy and use a larger grid

137137138-

Here we use `np.meshgrid` to create two-dimensional input grids `x` and `y` such

139-

that `f(x, y)` generates all evaluations on the product grid.

138+

```{code-cell} ipython3

139+

grid = np.linspace(-3, 3, 3_000) # Large grid

140+

```

141+142+

As a first pass of vectorization we might try something like this

143+144+

```{code-cell} ipython3

145+

# Large grid

146+

z = np.max(f(grid, grid)) # This is wrong!

147+

```

148+149+

The problem here is that `f(grid, grid)` doesn't obey the nested loop.

150+151+

In terms of the figure above, it only computes the values of `f` along the

152+

diagonal.

153+154+

To trick NumPy into calculating `f(x,y)` on every `x,y` pair, we need to use `np.meshgrid`.

155+156+

Here we use `np.meshgrid` to create two-dimensional input grids `x` and `y`

157+

such that `f(x, y)` generates all evaluations on the product grid.

140158141159142160

```{code-cell} ipython3

143161

# Large grid

144162

grid = np.linspace(-3, 3, 3_000)

145163146-

x, y = np.meshgrid(grid, grid) # MATLAB style meshgrid

164+

x_mesh, y_mesh = np.meshgrid(grid, grid) # MATLAB style meshgrid

147165148166

with qe.Timer():

149-

z_max_numpy = np.max(f(x, y))

167+

z_max_numpy = np.max(f(x_mesh, y_mesh)) # This works

150168

```

151169152170

In the vectorized version, all the looping takes place in compiled code.

@@ -159,11 +177,30 @@ The output should be close to one:

159177

print(f"NumPy result: {z_max_numpy:.6f}")

160178

```

161179180+

### Memory Issues

181+182+

So we have the right solution in reasonable time --- but memory usage is huge.

183+184+

While the flat arrays are low-memory

185+186+

```{code-cell} ipython3

187+

grid.nbytes

188+

```

189+190+

the mesh grids are two-dimensional and hence very memory intensive

191+192+

```{code-cell} ipython3

193+

x_mesh.nbytes + y_mesh.nbytes

194+

```

195+196+

Moreover, NumPy's eager execution creates many intermediate arrays of the same size!

197+198+

This kind of memory usage can be a big problem in actual research calculations.

162199163200164201

### A Comparison with Numba

165202166-

Now let's see if we can achieve better performance using Numba with a simple loop.

203+

Let's see if we can achieve better performance using Numba with a simple loop.

167204168205

```{code-cell} ipython3

169206

@numba.jit

@@ -194,15 +231,13 @@ with qe.Timer():

194231

compute_max_numba(grid)

195232

```

196233197-

Depending on your machine, the Numba version might be either slower or faster than NumPy.

234+

Notice how we are using almost no memory --- we just need the one-dimensional `grid`

198235199-

In most cases we find that Numba is slightly better.

236+

Moreover, execution speed is good.

200237201-

On the one hand, NumPy combines efficient arithmetic with some

202-

multithreading, which provides an advantage.

238+

On most machines, the Numba version will be somewhat faster than NumPy.

203239204-

On the other hand, the Numba routine uses much less memory, since we are only

205-

working with a single one-dimensional grid.

240+

The reason is efficient machine code plus less memory read-write.

206241207242208243

### Parallelized Numba

@@ -301,27 +336,11 @@ The compilation overhead is a one-time cost that pays off when the function is c

301336302337

### JAX plus vmap

303338304-

There is one problem with both the NumPy code and the JAX code above:

305-306-

While the flat arrays are low-memory

307-308-

```{code-cell} ipython3

309-

grid.nbytes

310-

```

311-312-

the mesh grids are memory intensive

313-314-

```{code-cell} ipython3

315-

x_mesh.nbytes + y_mesh.nbytes

316-

```

339+

Because we used `jax.jit` above, we avoided creating many intermediate arrays.

317340318-

This extra memory usage can be a big problem in actual research calculations.

341+

But we still create the big arrays `z_max`, `x_mesh`, and `y_mesh`.

319342320-

Fortunately, JAX admits a different approach

321-

using [jax.vmap](https://docs.jax.dev/en/latest/_autosummary/jax.vmap.html).

322-323-

The idea of `vmap` is to break vectorization into stages, transforming a

324-

function that operates on single values into one that operates on arrays.

343+

Fortunately, we can avoid this by using [jax.vmap](https://docs.jax.dev/en/latest/_autosummary/jax.vmap.html).

325344326345

Here's how we can apply it to our problem.

327346

@@ -330,13 +349,13 @@ Here's how we can apply it to our problem.

330349

@jax.jit

331350

def compute_max_vmap(grid):

332351

# Construct a function that takes the max over all x for given y

333-

f_vec_x_max = lambda y: jnp.max(f(grid, y))

352+

compute_column_max = lambda y: jnp.max(f(grid, y))

334353

# Vectorize the function so we can call on all y simultaneously

335-

f_vec_max = jax.vmap(f_vec_x_max)

336-

# Compute the max across x at every y

337-

maxes = f_vec_max(grid)

338-

# Compute the max of the maxes and return

339-

return jnp.max(maxes)

354+

vectorized_compute_column_max = jax.vmap(compute_column_max)

355+

# Compute the column max at every row

356+

column_maxes = vectorized_compute_column_max(grid)

357+

# Compute the max of the column maxes and return

358+

return jnp.max(column_maxes)

340359

```

341360342361

Note that we never create

@@ -345,6 +364,8 @@ Note that we never create

345364

* the two-dimensional grid `y_mesh` or

346365

* the two-dimensional array `f(x,y)`

347366367+

Like Numba, we just use the flat array `grid`.

368+348369

And because everything is under a single `@jax.jit`, the compiler can fuse

349370

all operations into one optimized kernel.

350371

@@ -378,18 +399,14 @@ In our view, JAX is the winner for vectorized operations.

378399

It dominates NumPy both in terms of speed (via JIT-compilation and

379400

parallelization) and memory efficiency (via vmap).

380401381-

Moreover, the `vmap` approach can sometimes lead to significantly clearer code.

382-383-

While Numba is impressive, the beauty of JAX is that, with fully vectorized

384-

operations, we can run exactly the same code on machines with hardware

385-

accelerators and reap all the benefits without extra effort.

402+

It also dominates Numba when run on the GPU.

386403387-

Moreover, JAX already knows how to effectively parallelize many common array

388-

operations, which is key to fast execution.

389-390-

For most cases encountered in economics, econometrics, and finance, it is

404+

```{note}

405+

Numba can support GPU programming through `numba.cuda` but then we need to

406+

parallelize by hand. For most cases encountered in economics, econometrics, and finance, it is

391407

far better to hand over to the JAX compiler for efficient parallelization than to

392-

try to hand code these routines ourselves.

408+

try to hand-code these routines ourselves.

409+

```

393410394411395412

## Sequential operations

@@ -554,8 +571,6 @@ The JAX versions, on the other hand, require either `lax.fori_loop` or

554571

While JAX's `at[t].set` syntax does allow element-wise updates, the overall code

555572

remains harder to read than the Numba equivalent.

556573557-

For this type of sequential operation, Numba is the clear winner in terms of

558-

code clarity and ease of implementation.

559574560575561576

## Overall recommendations

@@ -573,25 +588,17 @@ than traditional meshgrid-based vectorization.

573588

In addition, JAX functions are automatically differentiable, as we explore in

574589

{doc}`autodiff`.

575590576-

For **sequential operations**, Numba has clear advantages.

591+

For **sequential operations**, Numba has nicer syntax.

577592578593

The code is natural and readable --- just a Python loop with a decorator ---

579594

and performance is excellent.

580595581596

JAX can handle sequential problems via `lax.fori_loop` or `lax.scan`, but

582597

the syntax is less intuitive.

583598584-

```{note}

585-

One important advantage of `lax.fori_loop` and `lax.scan` is that they

586-

support automatic differentiation through the loop, which Numba cannot do.

587-

If you need to differentiate through a sequential computation (e.g., computing

588-

sensitivities of a trajectory to model parameters), JAX is the better choice

589-

despite the less natural syntax.

590-

```

599+

On the other hand, the JAX versions support automatic differentiation.

591600592-

In practice, many problems involve a mix of both patterns.

601+

That might be of interest if, say, we want to compute sensitivities of a

602+

trajectory to model parameters

593603594-

A good rule of thumb: default to JAX for new projects, especially when

595-

hardware acceleration or differentiability might be useful, and reach for Numba

596-

when you have a tight sequential loop that needs to be fast and readable.

597604

Read the original on github.com ↗