Skip to content

Commit 08364b7

Browse files
committed
updates
1 parent f3fdcbc commit 08364b7

2 files changed

Lines changed: 219 additions & 7 deletions

File tree

lectures/_static/quant-econ.bib

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5225,3 +5225,13 @@ @article{PhanEtAl2019
52255225
journal = {arXiv preprint arXiv:1912.11554},
52265226
year = {2019}
52275227
}
5228+
5229+
@article{SarkkaGarcia2021,
5230+
author = {S{\"a}rkk{\"a}, Simo and Garc{\'i}a-Fern{\'a}ndez, {\'A}ngel F.},
5231+
title = {Temporal Parallelization of {Bayesian} Smoothers},
5232+
journal = {IEEE Transactions on Automatic Control},
5233+
volume = {66},
5234+
number = {1},
5235+
pages = {299--306},
5236+
year = {2021}
5237+
}

lectures/sargent_surico.md

Lines changed: 209 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ kernelspec:
2929
:depth: 2
3030
```
3131

32+
```{include} _admonition/gpu.md
33+
```
34+
3235
In addition to what's in Anaconda, this lecture uses `pandas_datareader` to download
3336
macroeconomic data and `jax`, `numpyro` and `arviz` for the Hamiltonian Monte Carlo
3437
section at the end:
@@ -63,7 +66,7 @@ We do all of this from scratch in Python.
6366

6467
We write our own solver for linear rational expectations models, our own Kalman filter, and our own Metropolis-Hastings sampler, so that every step is visible.
6568

66-
A final section then uses the estimated model as a test bed for Hamiltonian Monte Carlo, which turns out to require replacing the model solver with a differentiable one.
69+
A final section then uses the estimated model as a test bed for Hamiltonian Monte Carlo, which turns out to require replacing the model solver with a differentiable one and, if a GPU is to be worth using, the Kalman filter with one that works on all dates at once.
6770

6871
Along the way we flag several places where the published paper's statement or implementation of its model needs care, and we check each of them numerically.
6972

@@ -1384,6 +1387,10 @@ $$ (eq:ss_fixedpoint)
13841387
Iterating {eq}`eq:ss_fixedpoint` from $G = 0$ involves nothing but matrix products
13851388
and linear solves, every one of them differentiable.
13861389
1390+
We enable 64-bit precision, which Kalman filtering needs, and we let JAX use
1391+
whatever hardware it finds; a later subsection reorganizes the filter so that a
1392+
GPU, if one is present, is actually worth using.
1393+
13871394
```{code-cell} ipython3
13881395
import jax
13891396
import jax.numpy as jnp
@@ -1393,7 +1400,7 @@ from jax import lax
13931400
from numpyro.infer import MCMC, NUTS
13941401
13951402
jax.config.update('jax_enable_x64', True)
1396-
jax.config.update('jax_platform_name', 'cpu')
1403+
print(f'JAX backend: {jax.default_backend()}')
13971404
13981405
U, NJ = 8, 9 # y = [pi, x, dm, R, e, a, chi, z, u]
13991406
@@ -1565,12 +1572,191 @@ whole point of reverse-mode differentiation.
15651572
15661573
A finite-difference gradient would need at least nineteen likelihood evaluations.
15671574
1575+
### Parallelizing the filter over time
1576+
1577+
On a CPU the story could end here, but on a GPU the filter above is very slow, and
1578+
the reason is worth understanding because it has nothing to do with arithmetic
1579+
speed.
1580+
1581+
A GPU is thousands of arithmetic units that want a few large array operations; what
1582+
our `lax.scan` gives it is a chain of about a thousand tiny dependent ones per
1583+
likelihood, since every date's handful of $11 \times 11$ products must wait for the
1584+
date before it, and each little operation pays a fixed kernel launch overhead that
1585+
dwarfs the arithmetic inside it.
1586+
1587+
Multiply that chain by the hundreds of thousands of leapfrog steps in a NUTS run
1588+
and the GPU spends nearly all of its time waiting rather than computing.
1589+
1590+
Kalman filtering looks irreducibly sequential, but it is not.
1591+
1592+
{cite:t}`SarkkaGarcia2021` showed that the filtering recursion is the repeated
1593+
application of an *associative* binary operation, and any associative operation
1594+
over $T$ items can be evaluated in a balanced tree of depth $\log_2 T$ rather than
1595+
a chain of length $T$, which is the same observation that lets parallel hardware
1596+
compute cumulative sums.
1597+
1598+
`jax.lax.associative_scan` supplies the tree; our job is to supply the elements
1599+
and the binary operation.
1600+
1601+
The element for date $k$ packages what observation $Y_k$ says about the state
1602+
given the previous state, as five arrays $(A_k, b_k, C_k, \eta_k, J_k)$ that
1603+
encode the two densities
1604+
1605+
$$
1606+
p(S_k \mid S_{k-1}, Y_k) = \mathcal N\big( A_k S_{k-1} + b_k,\ C_k \big),
1607+
\qquad
1608+
p(Y_k \mid S_{k-1}) \propto
1609+
\exp\big( \eta_k^\top S_{k-1} - \tfrac{1}{2} S_{k-1}^\top J_k S_{k-1} \big) .
1610+
$$
1611+
1612+
With $V = CQC^\top$ the innovation covariance and $K = QC^\top V^{-1}$ the gain,
1613+
one Kalman update starting from a known $S_{k-1}$ gives, for every date $k \ge 2$,
1614+
1615+
$$
1616+
A_k = (I - KC)A, \quad b_k = K Y_k, \quad C_k = (I - KC)Q,
1617+
\quad
1618+
\eta_k = A^\top C^\top V^{-1} Y_k, \quad J_k = A^\top C^\top V^{-1} C A ,
1619+
$$ (eq:ss_element)
1620+
1621+
while the first element instead absorbs the prior $\mathcal N(0, P_0)$, storing
1622+
the date-one filtered moments in $(b_1, C_1)$ with $A_1 = 0$, $\eta_1 = 0$ and
1623+
$J_1 = 0$.
1624+
1625+
Composing the elements of two adjacent blocks of dates means marginalizing out the
1626+
state that joins them, and for Gaussians that has a closed form,
1627+
1628+
$$
1629+
\begin{aligned}
1630+
A_{ij} &= A_j (I + C_i J_j)^{-1} A_i \\
1631+
b_{ij} &= A_j (I + C_i J_j)^{-1} (b_i + C_i \eta_j) + b_j \\
1632+
C_{ij} &= A_j (I + C_i J_j)^{-1} C_i A_j^\top + C_j \\
1633+
\eta_{ij} &= A_i^\top (I + J_j C_i)^{-1} (\eta_j - J_j b_i) + \eta_i \\
1634+
J_{ij} &= A_i^\top (I + J_j C_i)^{-1} J_j A_i + J_i .
1635+
\end{aligned}
1636+
$$ (eq:ss_combine)
1637+
1638+
This operation is associative, and composing elements $1$ through $k$ delivers the
1639+
filtered mean and covariance at date $k$ in the slots $b$ and $C$, for every $k$
1640+
simultaneously.
1641+
1642+
The likelihood then needs one more batched pass: given the filtered moments at
1643+
$k-1$, the date-$k$ innovation and its covariance are one prediction away, and all
1644+
$T$ Gaussian densities can be evaluated together with `vmap`.
1645+
1646+
```{code-cell} ipython3
1647+
def mv(M, v):
1648+
"""Batched matrix-vector product."""
1649+
return (M @ v[..., None])[..., 0]
1650+
1651+
1652+
def solve_vec(M, v):
1653+
"""Batched linear solve with a vector right-hand side."""
1654+
return jnp.linalg.solve(M, v[..., None])[..., 0]
1655+
1656+
1657+
def combine(elem_i, elem_j):
1658+
"""The composition rule (SS-combine) of Sarkka and Garcia-Fernandez."""
1659+
A_i, b_i, C_i, eta_i, J_i = elem_i
1660+
A_j, b_j, C_j, eta_j, J_j = elem_j
1661+
I = jnp.eye(A_i.shape[-1])
1662+
M = I + C_i @ J_j
1663+
A_ij = A_j @ jnp.linalg.solve(M, A_i)
1664+
b_ij = mv(A_j, solve_vec(M, b_i + mv(C_i, eta_j))) + b_j
1665+
C_ij = A_j @ jnp.linalg.solve(M, C_i) @ jnp.swapaxes(A_j, -1, -2) + C_j
1666+
Mt = I + J_j @ C_i
1667+
A_iT = jnp.swapaxes(A_i, -1, -2)
1668+
eta_ij = mv(A_iT, solve_vec(Mt, eta_j - mv(J_j, b_i))) + eta_i
1669+
J_ij = A_iT @ jnp.linalg.solve(Mt, J_j) @ A_i + J_i
1670+
return A_ij, b_ij, C_ij, eta_ij, J_ij
1671+
1672+
1673+
def loglik_parallel(p, Y):
1674+
"""The same log likelihood, with the time recursion replaced by an
1675+
associative scan of depth log2(T)."""
1676+
A, B, C = state_space_jax(p)
1677+
n, T = A.shape[0], Y.shape[0]
1678+
Q = B @ B.T
1679+
P0 = jnp.linalg.solve(jnp.eye(n * n) - jnp.kron(A, A),
1680+
Q.reshape(-1)).reshape(n, n)
1681+
1682+
# the generic element (SS-element) is the same at every date
1683+
V = C @ Q @ C.T
1684+
K = jnp.linalg.solve(V, C @ Q).T # Q C' V^{-1}
1685+
W = jnp.linalg.solve(V, C @ A).T # A' C' V^{-1}
1686+
A_g, C_g = (jnp.eye(n) - K @ C) @ A, (jnp.eye(n) - K @ C) @ Q
1687+
1688+
# the first element instead carries the prior N(0, P0)
1689+
P1 = A @ P0 @ A.T + Q
1690+
K1 = jnp.linalg.solve(C @ P1 @ C.T, C @ P1).T # P1 C' V1^{-1}
1691+
1692+
elems = (
1693+
jnp.concatenate([jnp.zeros((1, n, n)),
1694+
jnp.broadcast_to(A_g, (T - 1, n, n))]),
1695+
jnp.concatenate([(K1 @ Y[0])[None], Y[1:] @ K.T]),
1696+
jnp.concatenate([((jnp.eye(n) - K1 @ C) @ P1)[None],
1697+
jnp.broadcast_to(C_g, (T - 1, n, n))]),
1698+
jnp.concatenate([jnp.zeros((1, n)), Y[1:] @ W.T]),
1699+
jnp.concatenate([jnp.zeros((1, n, n)),
1700+
jnp.broadcast_to(W @ C @ A, (T - 1, n, n))]))
1701+
1702+
_, m_f, P_f, _, _ = lax.associative_scan(combine, elems)
1703+
1704+
# one-step-ahead predictive densities, all dates at once
1705+
m_prev = jnp.vstack([jnp.zeros((1, n)), m_f[:-1]])
1706+
P_prev = jnp.concatenate([P0[None], P_f[:-1]])
1707+
const = Y.shape[1] * jnp.log(2 * jnp.pi)
1708+
1709+
def predictive_ll(y, m, P):
1710+
F = C @ (A @ P @ A.T + Q) @ C.T
1711+
v = y - C @ (A @ m)
1712+
L = jnp.linalg.cholesky(F)
1713+
u = jax.scipy.linalg.solve_triangular(L, v, lower=True)
1714+
return -0.5 * (const + 2 * jnp.sum(jnp.log(jnp.diag(L))) + u @ u)
1715+
1716+
return jnp.sum(jax.vmap(predictive_ll)(Y, m_prev, P_prev))
1717+
```
1718+
1719+
The parallel filter has to agree with the sequential one, in value and in
1720+
gradient, and it does.
1721+
1722+
```{code-cell} ipython3
1723+
print(f'log likelihood, sequential scan {float(loglik_jax(p_check, Y_jax)):.8f}')
1724+
print(f'log likelihood, associative scan {float(loglik_parallel(p_check, Y_jax)):.8f}')
1725+
1726+
grad_par = jax.jit(jax.grad(lambda q: loglik_parallel(q, Y_jax)))
1727+
g_par = grad_par(p_check)
1728+
print('largest difference across the 18 gradients:',
1729+
f'{max(abs(float(g[n] - g_par[n])) for n in FREE):.2e}')
1730+
```
1731+
1732+
```{code-cell} ipython3
1733+
for name, fun in [('sequential ', grad_ll), ('associative', grad_par)]:
1734+
fun(p_check) # ensure compiled
1735+
t0 = time.time()
1736+
for _ in range(20):
1737+
out = fun(p_check)
1738+
jax.block_until_ready(out)
1739+
print(f'one gradient, {name} filter {1000 * (time.time() - t0) / 20:7.2f} ms')
1740+
```
1741+
1742+
Depth, not total work, is what the reorganization buys: ninety-six dependent steps
1743+
have become about seven rounds of batched linear algebra.
1744+
1745+
On a CPU, which executes one operation at a time anyway, the tree only adds
1746+
arithmetic, and the timings above will show the sequential filter winning.
1747+
1748+
On a GPU each round is a single wide launch, the ordering reverses, and the gap is
1749+
large; the timings above reflect whichever machine built this lecture.
1750+
15681751
### Sampling with NUTS
15691752
15701753
We hand the same priors to NumPyro {cite}`PhanEtAl2019`, which supplies NUTS and
15711754
handles the transformations to unconstrained space that Hamiltonian dynamics
15721755
require.
15731756
1757+
The likelihood inside the model is the parallel filter of the previous
1758+
subsection.
1759+
15741760
```{code-cell} ipython3
15751761
def beta_np(m, s):
15761762
nu = m * (1 - m) / s ** 2 - 1
@@ -1601,36 +1787,44 @@ PRIORS_NP = {
16011787
16021788
def ss_model(Y):
16031789
p = {n: numpyro.sample(n, PRIORS_NP[n]) for n in FREE}
1604-
numpyro.factor('loglik', loglik_jax(p, Y))
1790+
numpyro.factor('loglik', loglik_parallel(p, Y))
16051791
```
16061792
1607-
Three settings matter.
1793+
Four settings matter.
16081794
16091795
We ask for a **dense mass matrix**, because the condition number reported above says
16101796
the posterior has correlations that a diagonal preconditioner cannot absorb.
16111797
16121798
We cap the trajectory length, since without a cap NUTS spends most of its time on very
16131799
long trajectories in the flattest directions.
16141800
1801+
On a GPU we run the chains **vectorized**, batched into one program, so that every
1802+
kernel launch carries every chain's arithmetic and the launch overhead is paid once
1803+
rather than once per chain; on a CPU there is no launch overhead to amortize, and we
1804+
run them one after another.
1805+
16151806
And we run **four chains** rather than one, all started from the posterior mode.
16161807
16171808
That last choice is the one that earns its keep.
16181809
16191810
```{code-cell} ipython3
1811+
chain_method = 'vectorized' if jax.default_backend() == 'gpu' else 'sequential'
1812+
16201813
kernel = NUTS(ss_model, target_accept_prob=0.8, dense_mass=True,
16211814
max_tree_depth=8,
16221815
init_strategy=numpyro.infer.init_to_value(
16231816
values={n: float(v) for n, v in zip(FREE, v_mode)}))
16241817
mcmc = MCMC(kernel, num_warmup=400, num_samples=400, num_chains=4,
1625-
chain_method='sequential', progress_bar=False)
1818+
chain_method=chain_method, progress_bar=False)
16261819
16271820
t0 = time.time()
16281821
mcmc.run(jax.random.PRNGKey(1), Y_jax, extra_fields=('num_steps', 'diverging'))
16291822
jax.block_until_ready(mcmc.get_samples())
16301823
nuts_seconds = time.time() - t0
16311824
16321825
extra = mcmc.get_extra_fields()
1633-
print(f'{nuts_seconds:.0f} seconds for 4 chains of 400 draws')
1826+
print(f'{nuts_seconds:.0f} seconds for 4 {chain_method} chains of 400 draws '
1827+
f'on the {jax.default_backend()}')
16341828
print(f'mean leapfrog steps per iteration '
16351829
f'{np.asarray(extra["num_steps"]).mean():.0f}')
16361830
print(f'divergences '
@@ -1892,6 +2086,12 @@ Replacing an eigenvalue-sorting solver by a fixed point that uses only linear al
18922086
buys exact gradients for about the cost of one extra likelihood evaluation, and that
18932087
is enough to put NUTS within reach.
18942088
2089+
A second obstacle appears on a GPU, and it too is algorithmic rather than
2090+
statistical: a sequential filter over tiny matrices leaves massively parallel
2091+
hardware idle, and the cure is again to reorganize the computation, across time by
2092+
the associative scan of {cite:t}`SarkkaGarcia2021` and across chains by
2093+
vectorizing them, so that every kernel launch carries real work.
2094+
18952095
The payoff was not only speed.
18962096
18972097
Cheap chains made it cheap to run several of them and inspect their diagnostics, and
@@ -1934,7 +2134,9 @@ On the computational side, the model turned out to be a useful test bed for Hami
19342134
19352135
The barrier to using it on DSGE models is not statistical but algorithmic: the standard solvers sort eigenvalues, and sorting has no derivative.
19362136
1937-
Swapping in a fixed-point solver that uses only linear algebra restores exact gradients, and the sampler that becomes available is far more efficient per unit of computing time on a posterior as badly scaled as this one.
2137+
Swapping in a fixed-point solver that uses only linear algebra restores exact gradients, and a second swap, of the sequential Kalman recursion for an associative scan that a GPU can evaluate in $\log_2 T$ rounds, lets modern parallel hardware carry the sampler.
2138+
2139+
The sampler that becomes available is far more efficient per unit of computing time on a posterior as badly scaled as this one.
19382140
19392141
The larger dividend was a diagnostic one.
19402142

0 commit comments

Comments
 (0)