Skip to content

Commit c5e72bc

Browse files
albopclaude
andcommitted
feat(jax): GMRES on the device as Dolo.jl's gmres!
One cycle of at most maxit Krylov vectors from x0 = b, modified Gram-Schmidt, Givens rotations, absolute tolerance on the 2-norm of the residual; basis and products on the device (the operator applied with its staged kernels), the small Hessenberg problem on the host. Same speed as jax.scipy's gmres here (~2 ms per iteration besides L.v), with Dolo.jl's iterations; replaces it. PolicyLinearOperator.solve records the number of iterations (last_iterations), shown by newton. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3e357a9 commit c5e72bc

4 files changed

Lines changed: 132 additions & 37 deletions

File tree

‎src/dolo/algos/newton.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ def residual(u):
358358
dx = L_prec.solve(
359359
rhs, linsolve=linsolve_cfg["method"], tol=linsolve_cfg["tol"], maxit=linsolve_cfg["maxit"]
360360
)
361+
n_linear = getattr(L_prec, "last_iterations", linsolve_cfg["maxit"])
361362
L_last = L_prec
362363
del L_prec, rhs
363364
t3 = time.time()
@@ -408,7 +409,7 @@ def residual(u):
408409
Time_inversion=t3 - t2,
409410
Time_search=t4 - t3,
410411
Lambda_0=0.0,
411-
N_invert=linsolve_cfg["maxit"],
412+
N_invert=n_linear,
412413
N_search=k,
413414
)
414415
if stop or (tol_eta is not None and eta < tol_eta):

‎src/dolo/numeric/policy_operator_jax.py‎

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ def densify(Fc, cols, n_x, scale=1.0):
198198
MAX_BLOCKS = 2**20
199199

200200

201+
@jax.jit
202+
def axmy(x, y):
203+
"""x - y."""
204+
return x - y
205+
206+
201207
def apply(v, Fc, base, weights, scale, grid_n, cols, order):
202208
"""L.v for v (n_m, N, n_x)."""
203209
if len(cols) == 0:
@@ -207,24 +213,91 @@ def apply(v, Fc, base, weights, scale, grid_n, cols, order):
207213
return contract(Fc, X, scale)
208214

209215

210-
@partial(
211-
jax.jit, static_argnames=("grid_n", "cols", "order", "restart", "maxiter")
212-
)
213-
def gmres_I_minus_L(b, Fc, base, weights, scale, tol, grid_n, cols, order, restart, maxiter):
214-
"""Solve (I - L) x = b with GMRES on the device, starting from x0 = b. Stops when
215-
|b - (I - L) x|_2 <= tol (absolute, as Dolo.jl's gmres!)."""
216-
217-
def matvec(x):
218-
return x - apply(x, Fc, base, weights, scale, grid_n, cols, order)
219-
220-
x, _ = jax.scipy.sparse.linalg.gmres(
221-
matvec,
222-
b,
223-
x0=b,
224-
tol=0.0,
225-
atol=tol,
226-
restart=restart,
227-
maxiter=maxiter,
228-
solve_method="incremental",
229-
)
230-
return x
216+
# --------------------------------------------------------------------------------------------
217+
# GMRES as Dolo.jl's `gmres!` (src/algos/linsolve.jl): one cycle of at most m Krylov vectors,
218+
# modified Gram-Schmidt, Givens rotations, absolute tolerance on the 2-norm of the residual.
219+
# The Krylov basis and the matrix-vector products stay on the device; the small (m+1) x m
220+
# Hessenberg problem is solved on the host (one transfer of a column per iteration). As fast
221+
# as `jax.scipy.sparse.linalg.gmres` here (whose tolerance is relative, with
222+
# reorthogonalization), with the iterations and the stopping rule of Dolo.jl.
223+
224+
225+
@partial(jax.jit, donate_argnums=(0,))
226+
def _arnoldi_step(V, w, j):
227+
"""Orthogonalize w against V[0..j] (modified Gram-Schmidt), store w / |w| in V[j+1] (when
228+
j + 1 < len(V)). Returns V and the column h[0..j+1] (zeros beyond)."""
229+
230+
def body(i, carry):
231+
w, h = carry
232+
v_i = jax.lax.dynamic_index_in_dim(V, i, keepdims=False)
233+
h_i = jnp.vdot(v_i, w)
234+
return w - h_i * v_i, h.at[i].set(h_i)
235+
236+
h = jnp.zeros(V.shape[0], dtype=w.dtype)
237+
w, h = jax.lax.fori_loop(0, j + 1, body, (w, h))
238+
h_norm = jnp.linalg.norm(w)
239+
h = h.at[j + 1].set(h_norm)
240+
V = jax.lax.dynamic_update_index_in_dim(V, w / h_norm, jnp.minimum(j + 1, V.shape[0] - 1), 0)
241+
return V, h
242+
243+
244+
@partial(jax.jit, donate_argnums=(0,))
245+
def _krylov_init(V, r, beta):
246+
return V.at[0].set(r / beta)
247+
248+
249+
@jax.jit
250+
def _krylov_vector(V, j):
251+
return jax.lax.dynamic_index_in_dim(V, j, keepdims=False)
252+
253+
254+
@jax.jit
255+
def _krylov_update(x, V, y):
256+
"""x + sum_i y_i V[i] (y padded with zeros)."""
257+
return x + jnp.tensordot(y, V, axes=1)
258+
259+
260+
def gmres(matvec, b, x0, tol, m):
261+
"""Solve A x = b, A given by `matvec` on flat device vectors, starting from x0: one cycle of
262+
at most m Krylov vectors, until |b - A x|_2 < tol. Returns x and the number of iterations."""
263+
n = b.shape[0]
264+
x = x0
265+
r = b - matvec(x)
266+
beta = float(jnp.linalg.norm(r))
267+
if beta < tol:
268+
return x, 0
269+
V = _krylov_init(jnp.zeros((m + 1, n), dtype=b.dtype), r, beta)
270+
H = np.zeros((m + 1, m))
271+
cs = np.zeros(m)
272+
sn = np.zeros(m)
273+
g = np.zeros(m + 1)
274+
g[0] = beta
275+
eps = np.finfo(np.float64).eps
276+
k = 0
277+
for j in range(m):
278+
w = matvec(_krylov_vector(V, j))
279+
V, h_col = _arnoldi_step(V, w, j)
280+
h_col = np.asarray(h_col)
281+
H[: j + 2, j] = h_col[: j + 2]
282+
h = H[j + 1, j]
283+
# previous rotations on the new column
284+
for i in range(j):
285+
t = cs[i] * H[i, j] + sn[i] * H[i + 1, j]
286+
H[i + 1, j] = -sn[i] * H[i, j] + cs[i] * H[i + 1, j]
287+
H[i, j] = t
288+
# new rotation eliminating H[j+1, j]
289+
rho = np.hypot(H[j, j], H[j + 1, j])
290+
cs[j] = H[j, j] / rho
291+
sn[j] = H[j + 1, j] / rho
292+
H[j, j] = rho
293+
H[j + 1, j] = 0.0
294+
g[j + 1] = -sn[j] * g[j]
295+
g[j] = cs[j] * g[j]
296+
k = j + 1
297+
# converged, or lucky breakdown (the Krylov space is invariant)
298+
if abs(g[j + 1]) < tol or h <= eps * beta:
299+
break
300+
y = np.zeros(m + 1)
301+
for i in range(k - 1, -1, -1):
302+
y[i] = (g[i] - H[i, i + 1 : k] @ y[i + 1 : k]) / H[i, i]
303+
return _krylov_update(x, V, jnp.asarray(y, dtype=b.dtype)), k

‎src/dolo/numeric/residuals.py‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -903,22 +903,18 @@ def _solve_device(self, b: Any, linsolve: str, tol: float, maxit: int) -> Any:
903903
x = x + du
904904
if float(jnp.abs(du).max()) < tol:
905905
break
906+
self.last_iterations = it + 1
906907
elif linsolve == "gmres":
907-
# one cycle of at most `maxit` Krylov vectors, as the scipy version
908-
jx = self._jx
909-
x = poj.gmres_I_minus_L(
910-
b_dev,
911-
jx["Fc"],
912-
jx["base"],
913-
jx["weights"],
914-
jx["scale"],
915-
tol,
916-
self.grid_shape,
917-
jx["cols"],
918-
jx["order"],
919-
int(maxit),
920-
1,
921-
)
908+
# as Dolo.jl: from x0 = b, one cycle of at most `maxit` Krylov vectors
909+
shape = b_dev.shape
910+
911+
def matvec(v):
912+
u = v.reshape(shape)
913+
return poj.axmy(u, self._apply_device(u)).reshape(-1)
914+
915+
b_flat = b_dev.reshape(-1)
916+
x, self.last_iterations = poj.gmres(matvec, b_flat, b_flat, float(tol), int(maxit))
917+
x = x.reshape(shape)
922918
else:
923919
raise ValueError(
924920
f"Unknown linear solver: '{linsolve}'. Use 'neumann' or 'gmres'."

‎tests/test_solvers_device.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,28 @@ def test_device_loops_are_used(dmodel, monkeypatch):
100100
dolo.time_iteration(dmodel, engine="jax", ignore_constraints=True, maxit=3, verbose=False)
101101
dolo.newton(dmodel, engine="jax", ignore_constraints=True, maxit=2, verbose=False)
102102
assert calls == ["_time_iteration_device", "_newton_device"]
103+
104+
105+
def test_gmres_as_dolo_jl():
106+
"""One cycle of at most m Krylov vectors, absolute tolerance on the 2-norm of the residual."""
107+
from dolo.numeric.policy_operator_jax import gmres
108+
109+
rng = np.random.default_rng(0)
110+
n = 40
111+
A = np.eye(n) - 0.4 * rng.standard_normal((n, n)) / np.sqrt(n)
112+
b = rng.standard_normal(n)
113+
A_dev, b_dev = jnp.asarray(A), jnp.asarray(b)
114+
matvec = lambda v: A_dev @ v
115+
116+
x, k = gmres(matvec, b_dev, b_dev, 1e-10, 40)
117+
assert np.linalg.norm(b - A @ np.asarray(x)) < 1e-10
118+
np.testing.assert_allclose(np.asarray(x), np.linalg.solve(A, b), rtol=1e-8, atol=1e-9)
119+
assert 0 < k < 40
120+
121+
# at most m iterations; the residual decreases
122+
x5, k5 = gmres(matvec, b_dev, b_dev, 1e-14, 5)
123+
assert k5 == 5
124+
assert np.linalg.norm(b - A @ np.asarray(x5)) < np.linalg.norm(b - A @ b)
125+
# already solved: no iteration
126+
_, k0 = gmres(matvec, b_dev, jnp.asarray(np.linalg.solve(A, b)), 1e-8, 10)
127+
assert k0 == 0

0 commit comments

Comments
 (0)