🇬🇧 English · 🇪🇸 Español
Hardware Backends: GPU, Apple MLX & Batched Jacobians for Trade and Spatial GE
puremacro._backend and puremacro.trade.gpu add optional hardware acceleration to the general equilibrium solvers documented in Quantitative Spatial Economics & Gravity Trade GE. Two layers are involved:
- The array-namespace layer (
puremacro._backend):numpy,mlx(Apple Silicon) andcupy(NVIDIA) share one NumPy-like array API.AllenArkolakisModel.solve_equilibrium(..., backend=)/solve_counterfactual(..., backend=)run their contraction mapping through it, with aRuntimeWarningand a NumPy fallback whenever the requested backend is missing or fails. The trade solversolve_trade_equilibriumis NumPy only:backend="numpy"is the one accepted value and any other raisesNotImplementedError. - The device layer (
puremacro.trade.gpu): a PyTorch (CUDA / Apple MPS / CPU) and native Apple MLX engine for the consistent-accounting multi-country, multi-sector trade model ofpuremacro.trade(Cobb-Douglas intermediates, lump-sum rebates of tariff revenue), built around pre-inverted Leontief operators, a batched Jacobian evaluated as one multi-right-hand-side GEMM, a Levenberg-Marquardt (1944, 1963) step with a backtracking line search, and adaptive homotopy continuation (Allgower & Georg, 1990) in the tariff schedule.
Both layers are optional. Nothing on this page is required to run the models: the NumPy path remains the reference implementation and the correctness oracle, and it is the only path available in Pyodide (iPad, browser). Every accelerated result on this page is compared against it.
1. Algorithmic Framework
1.1 Which backend runs what
puremacro._backend.SUPPORTED is ("numpy", "numba", "mlx", "cupy"). backend_available(name) checks importlib.util.find_spec, available_backends() lists the installed ones (NumPy first), and get_array_namespace(name) returns numpy, mlx.core or cupy ("numba" is a compiled-kernel backend, not an array namespace, and raises ValueError here). to_numpy(x) brings any array home, routing CuPy through cp.asnumpy because CuPy blocks implicit host conversion.
solve_trade_equilibrium(calib, ..., backend=)accepts"numpy"only. Accelerated trade solves go through the device layer below, which solves the same model and returns the same result type.AllenArkolakisModel.solve_equilibrium(backend=): the whole damped contraction mapping on \((w_i, L_i)\) (kernel matrix \(\tau_{ji}^{-\theta}\), market access, the utility-equalization softmax) runs on the device infloat32for MLX andfloat64for CuPy. A float32 device can only certify the fixed point to its own floor, so whenever that floor is coarser than thetolyou asked for (always on MLX, where the device floors are \(\max(\text{tol}, 10^{-6})\) on the residual and \(10^{-5}\) on the utility dispersion) the device loop is treated as a warm start and the iteration is finished on the host in NumPy float64 at the requestedtol. The requested tolerance is therefore honored andconvergedis truthful whatever the backend; Example 4.4 shows MLX and NumPy agreeing to \(10^{-9}\) after the same 27 iterations. After convergence the population is renormalized to \(\bar L\), the wages to unit mean, and the price index, market-access terms and real wages are recomputed in NumPy float64, which is why the labor-conservation residual is zero (to rounding) on both backends.solve_counterfactualpassesbackendto both the baseline and the counterfactual solve.
1.2 Device selection and DeviceInfo
puremacro.trade.gpu.select_compute_device(preferred) maps a name to a (backend, device) pair, where backend is "torch", "mlx" or "numpy" and device is "cuda", "mps", "gpu" or "cpu":
preferred |
Result | If unavailable |
|---|---|---|
None / "auto" |
first of: torch CUDA, torch MPS, MLX GPU, torch CPU, NumPy CPU | never fails |
"numpy" |
("numpy", "cpu"), the serial NumPy evaluator |
never fails |
"cpu" |
("torch", "cpu") when PyTorch is installed, ("numpy", "cpu") otherwise |
never fails |
"cuda", "cuda:N" |
that PyTorch CUDA device | RuntimeError (PyTorch missing, torch.cuda.is_available() is False, or index N out of range) |
"mps" |
("torch", "mps") |
RuntimeError (PyTorch missing or torch.backends.mps.is_available() is False) |
"torch" / "pytorch" |
best PyTorch device: CUDA, then MPS, then CPU | RuntimeError if PyTorch is missing |
"mlx" / "apple_mlx" |
("mlx", "gpu") |
RuntimeError if mlx is missing |
"gpu" |
best GPU of any backend: CUDA, then MPS, then MLX | RuntimeError if none of the three is available |
Every accepted string is listed above; anything else raises ValueError: Unknown device/backend ...; expected one of (...) rather than quietly resolving to "auto". "numpy" is a real name, so you can ask for the serial evaluator explicitly, and "cpu" does not demand PyTorch: it degrades to ("numpy", "cpu"), which is what every Pyodide kernel gets.
detect_device(preferred) wraps the same choice in a frozen dataclass:
DeviceInfo field |
Meaning |
|---|---|
backend |
"torch", "mlx" or "numpy" |
device |
"cuda", "mps", "gpu" (MLX) or "cpu" |
device_name |
CUDA adapter name, "Apple Metal (<cpu>)", "Apple MLX (<cpu>)" or the host processor |
total_memory_gb |
CUDA: total_memory of the device properties; Apple Silicon: sysctl hw.memsize (the unified pool; None if sysctl cannot be run); None on plain CPU |
supports_float64 |
True for CUDA and CPU, False for MPS, False for the MLX GPU stream |
is_uma |
True on Apple Silicon (unified memory: host and GPU share one pool, so there is no device copy) |
The repr summarizes the precision situation, e.g. f32-only for MPS and f32-gpu/f64-cpu-stream for MLX.
1.3 Precision: float64 versus float32
A one-sided finite difference divides the rounding error of the residual by the step, so a float32 residual produces a large relative Jacobian error and a diverging Newton step. The rule the device layer follows is therefore: assemble the Jacobian in float64 wherever the device can, and use a float32 device only when you ask for one explicitly, and only for the coarse phase.
- CUDA and PyTorch CPU execute in float64 throughout.
- MPS (Apple Metal through PyTorch) has no float64. With
device="auto"the solver silently substitutes the float64 PyTorch CPU evaluator. Withdevice="mps"requested explicitly it emits aRuntimeWarning("... only supports float32; finite-difference Jacobians are unusable in float32, so it is used for the coarse phase only and the Jacobian is polished in float64 on torch:cpu"), uses MPS while \(\max_i |F_i| \ge 5 \times 10^6\) and only in the first five iterations, and polishes in float64 afterwards. - MLX keeps float64 constants created under
mx.stream(mx.cpu), where MLX runs Accelerate/LAPACK (and wheremx.exp, which is only float32-accurate, is replaced by an exacte ** x), and float32 copies for the GPU stream.BatchedJacobianEvaluatordefaults to the CPU stream; the float32 GPU stream is reached only throughstream="gpu"or the explicit-device coarse phase.solve_trade_equilibrium_mlxissolve_trade_equilibrium_gpu(device="mlx")and follows exactly the same policy.
Whichever device is used, result.metadata["jacobian_evaluations"] counts the Jacobians by backend:device:dtype, so the precision path a run actually took is on the result. The single-point residual eval_macro_single, the line-search trial points and the final residual check are NumPy float64 on the host, so max_residual and converged are always float64 quantities; only coarse-phase Jacobian columns can carry float32 rounding. Example 4.3 measures it: the float32 MPS Jacobian is unusable (its error exceeds the largest entry), while the PyTorch CPU and MLX CPU-stream Jacobians reproduce the serial NumPy one to rounding.
1.4 The batched Jacobian
The consistent-accounting system of puremacro.trade (see Consistent trade accounting) is condensed onto a macro vector. With \(N\) countries, \(S\) sectors and \(M = SN\) country-sector pairs, BatchedJacobianEvaluator works on
where \(w\) are wages, \(r\) capital rentals and \(T\) the lump-sum transfers (government budgets). Foreign saving is exogenous, as in the full model, in numeraire units or as a fixed share of world factor income (foreign_saving_units), so it is not an unknown. The macro residuals are the first producer price minus one (the numeraire), labour-market clearing in every country but the first (implied by Walras' law once all goods markets clear), capital-market clearing (full layout only) and the government budgets. A solution of the macro system solves every equation of the full consistent-accounting system.
The first layout imposes \(r_c = w_c\), which is exact only when the capital share is the same in every sector of a country: capital-market clearing then follows from labour-market clearing. The layout is chosen by the keyword-only factor_equivalence, and batch_size is the older spelling, validated against the calibration. factor_equivalence=None (the default) selects the reduced layout exactly when it is exact for the calibration and the full layout otherwise; factor_equivalence=True on a calibration where it is not exact raises ValueError instead of quietly dropping the capital-market clearing condition. If you pass batch_size, it must equal \(2N\) or \(3N\) for your \(N\): on the 2-country toy calibration below that is 4 or 6, and batch_size=5 raises ValueError: batch_size=5 is inconsistent with a 2-country calibration: expected 4 (reduced layout, r == w) or 6 (full layout). Passing both keywords with conflicting implications also raises.
Given \(x_m\), prices and gross outputs follow from two Leontief systems,
with \(B^\top = (A \circ \tau)^\top / (1 - \text{tax})\) the tariff-inclusive cost-share operator, \(v\) the Cobb-Douglas unit value added and \(A\) the input-output coefficients. Both inverses are formed once ((I - A)^{-1} at construction, (I - B^\top)^{-1} in update_tariffs, i.e. once per tariff schedule). A forward-difference Jacobian needs \(F\) at the \(B\) perturbed points \(x_m + h_j e_j\), with \(h_j = \varepsilon\) for the log factor prices and \(h_j = \varepsilon \max(|T_j|, s)\) for the transfers, where \(s\) is the median baseline factor income (\(\varepsilon = 10^{-6}\) by default, eps_fd). Stacking the perturbed points as rows of \(X \in \mathbb{R}^{B \times B}\), the price and output solves for the whole batch become two matrix products,
and every account (factor demands, production and final-use taxes, duties) becomes a batched matrix product. One kernel launch replaces \(B\) serial residual evaluations, and
ad_mode="forward" replaces the differences by forward-mode automatic differentiation (torch.func.jacfwd on PyTorch, mx.jvp column by column on MLX) and ad_mode="vjp" by reverse mode (torch.func.jacrev, mx.vjp); both remove the step-size sensitivity. On any failure the evaluator emits a RuntimeWarning and falls back to the finite-difference path (never silently), and on the numpy backend either AD mode warns and uses finite differences. A macro vector whose length does not match the layout raises ValueError, and so does an unknown ad_mode. The jvp(xm, v) method returns the directional derivative \(J v\) the same way, with a float64 central difference as last resort.
1.5 Levenberg-Marquardt step, equilibration and line search
Each iteration of solve_trade_equilibrium_gpu / solve_trade_equilibrium_mlx scales the Jacobian on both sides (Jacobi equilibration), \(\tilde J = D_L J D_R\) with \(D_R = \operatorname{diag}(1/\lVert J_{:,j}\rVert_2)\) and \(D_L = \operatorname{diag}(1/\lVert (J D_R)_{i,:}\rVert_2)\), and solves the regularized normal equations
with torch.linalg.solve in float64 whenever PyTorch is the evaluating backend (on the CUDA device when that is the polish device, on the CPU otherwise), then scipy.linalg.solve, then scipy.linalg.lstsq; the first that succeeds wins. The step is rescaled so that its factor-price block stays within \(0.3\) in absolute value, and the log factor prices are clipped to \([-5, 5]\). A backtracking line search on the merit function \(\Phi(x) = \lVert D_L F(x) \rVert_2\) tries \(\alpha = 1, \tfrac12, \tfrac14, \dots\) (at most 12 trial points) and accepts the first trial with \(\max_i|F_i| \le \text{tol}\), \(\Phi(x + \alpha \Delta) < \Phi(x)\) or \(\max_i |F_i(x + \alpha\Delta)| < \max_i |F_i(x)|\): a simple-decrease rule in the Armijo (1966) backtracking pattern, without a sufficient-decrease constant. The damping follows the classical Marquardt update: \(\mu \leftarrow \max(0.3\mu, 10^{-8})\) after an accepted step, \(\mu \leftarrow \max(0.5\mu, 10^{-8})\) when only the best trial improved the merit, and \(\mu \leftarrow \min(5\mu, 10^{2})\) otherwise. The iteration stops only when the macro residual is within tol and every equation of the full system at the reconstructed state is within tol; the returned result is post-processed and audited exactly like a NumPy solve.
1.6 Homotopy continuation in the tariff schedule
Large asymmetric tariff shocks can defeat a Newton-type method started from the baseline. solve_homotopy_continuation embeds the target schedule in the path
and traces it with the predictor-free, warm-started continuation of Allgower & Georg (1990, ch. 1): solve at \(\lambda_0 = 0\) (or accept x0), then repeatedly solve at \(\min(\lambda + \Delta\lambda, 1)\) starting from the last converged state. Step control is adaptive: a step that converged in at most 4 iterations grows the next one by \(1.5\times\) (capped at max_step), one that needed at least max_iter_per_step - 5 shrinks it by \(0.75\) (floored at min_step), and a failed step is discarded and retried with half the size. If the step falls below min_step, if the baseline itself does not converge, or if no step ever succeeds, the function raises RuntimeError (it does not return a partial result); the callback(lambda, result) hook, called after every successful step, is the way to keep intermediate equilibria. The target and base schedules take every form solve_trade_equilibrium accepts (multipliers or rates by importing country).
2. Methodological Options
| Option | Namespace layer (backend=, spatial solver) |
solve_trade_equilibrium_gpu |
solve_trade_equilibrium_mlx |
|---|---|---|---|
| Accepted names | "numpy", "mlx", "cupy" ("numba" warns and falls back; any other string raises ValueError) |
device in "auto", "numpy", "cpu", "cuda", "cuda:N", "mps", "mlx", "gpu"; backend in "torch", "mlx", "numpy" |
none: MLX only, raises RuntimeError without it |
| Precision | float32 contraction on MLX, finished on the host in float64 | float64 Jacobian wherever the device allows; a float32 device only when requested explicitly, and only for the coarse phase | same policy, on the MLX CPU stream |
| Jacobian | not applicable (fixed-point iteration) | batched finite differences, ad_mode="forward" (jacfwd) or "vjp" (jacrev) |
batched finite differences, ad_mode="forward" (jvp) or "vjp" (mx.vjp) |
| Stopping rule | the tol you asked for, finished on the host when the device floor is coarser |
tol=2.5e-3 on every equation of the full system, absolute in the calibration's value units |
same |
| Failure policy | RuntimeWarning, then NumPy, for a missing backend, a raised exception and a non-converged accelerated solve |
exceptions propagate; AD failures warn and fall back to finite differences | same |
| Where tested | a 5-region spatial model (tests/test_trade_solver_accelerated.py) |
an 8-country, 5-sector synthetic calibration against the NumPy solver (tests/test_gpu_acceleration.py), skipped when no accelerator is importable |
same |
The default trade tolerance is \(2.5 \times 10^{-3}\) for the NumPy solver and the accelerated ones. It is absolute in the calibration's value units and loose; tighten it for parity work, as the examples below do.
3. Installing the Accelerators
None of the accelerators is part of the base install, and the base install runs everything on this page through the NumPy path.
| Accelerator | Install | Platforms | Provides |
|---|---|---|---|
| Apple MLX | pip install "puremacro[backend]" (the [accel] extra has the same contents: numba plus mlx on macOS) |
Apple Silicon macOS only (sys_platform == 'darwin' marker) |
backend="mlx" on the spatial solver, solve_trade_equilibrium_mlx, device="mlx" in the device layer |
| NVIDIA CuPy | pip install "puremacro[cuda]" (cupy-cuda12x; choose the wheel matching your CUDA toolkit) |
NVIDIA GPUs with CUDA | backend="cupy" on the spatial solver |
| PyTorch | pip install torch (there is no puremacro extra for it; use the selector at pytorch.org for a CUDA build) |
CUDA, Apple MPS, or CPU | solve_trade_equilibrium_gpu, solve_homotopy_continuation, device="cuda"/"mps"/"cpu" |
puremacro.trade.gpu imports torch and mlx lazily: import puremacro.trade, and even from puremacro.trade.gpu import solve_trade_equilibrium_gpu, leave both out of sys.modules on a machine where they are installed, and succeed on a machine where they are not. A call that genuinely needs an absent accelerator raises RuntimeError when it asks for it by name, while device=None / "auto" resolves to ("numpy", "cpu") and runs the serial evaluator. puremacro.runtime.capabilities().backends lists the installed namespace backends (numpy, numba, mlx, cupy); PyTorch is not part of that tuple even when installed.
Pyodide, iPad and the browser. Neither PyTorch, MLX nor CuPy can be installed in a Pyodide kernel, so capabilities().backends == ("numpy",) there (see Running anywhere), detect_device() returns numpy:cpu by construction, and the device solvers run the NumPy evaluator. A script written against the NumPy path runs unchanged on a tablet.
4. Runnable Worked Examples
All examples run offline. They were executed on an Apple Silicon workstation with PyTorch (MPS) and MLX installed and no NVIDIA GPU; the printed lines reproduce that run. The examples continue from each other, as one session.
4.1 What hardware do I have?
from puremacro._backend import available_backends
from puremacro.trade.gpu import detect_device, get_memory_usage, has_mlx, has_torch, select_compute_device
print("namespace backends:", available_backends())
print("torch installed:", has_torch(), "| mlx installed:", has_mlx())
print("auto selection :", select_compute_device())
print(detect_device())
if has_mlx():
print(detect_device("mlx"))
if has_torch():
print(detect_device("cpu"))
try:
select_compute_device("cuda")
except RuntimeError as exc:
print("cuda:", exc)
print(sorted(get_memory_usage()))
namespace backends: ('numpy', 'numba', 'mlx')
torch installed: True | mlx installed: True
auto selection : ('torch', 'mps')
<DeviceInfo: torch:mps (Apple Metal (arm), 36.0 GB, f32-only, UMA)>
<DeviceInfo: mlx:gpu (Apple MLX (arm), 36.0 GB, f32-gpu/f64-cpu-stream, UMA)>
<DeviceInfo: torch:cpu (arm, f64)>
cuda: CUDA requested but torch.cuda.is_available() is False.
['allocated_mb', 'backend', 'device', 'driver_mb', 'peak_mb']
With PyTorch installed, "auto" prefers MPS over MLX on a Mac, and the MPS DeviceInfo reports f32-only. The memory dictionary always carries backend, device, allocated_mb and peak_mb; MPS adds driver_mb, CUDA and MLX add cache_mb, and the NumPy fallback reports the peak resident set size of the process from resource.getrusage.
4.2 The NumPy reference on a toy calibration
The calibration is the 2-country, 2-sector, 3-final-use table of tests/test_trade_solver_accelerated.py. At its baseline the table is already an equilibrium, so the example imposes a 25% tariff on everything country 1 imports from country 0 to make the solvers work. solve_trade_equilibrium gives the reference, and refuses a non-NumPy backend.
import numpy as np
from puremacro.trade import calibrate_trade_model, solve_trade_equilibrium
def toy_calibration():
"""2-country, 2-sector, 3-final-use table (tests/test_trade_solver_accelerated.py)."""
nc, ns, nfd = 2, 2, 3
data = np.zeros((7, 10), dtype=float)
data[:4, :4] = np.array([
[20.0, 15.0, 5.0, 2.0],
[10.0, 25.0, 2.0, 8.0],
[5.0, 5.0, 12.0, 18.0],
[10.0, 10.0, 18.0, 22.0],
])
inter_col_sums = data[:4, :4].sum(axis=0)
y = np.array([100.0, 150.0, 120.0, 180.0])
va = y - inter_col_sums
taxes = 0.05 * y
va_fac = va - taxes
data[4, :4] = taxes
data[5, :4] = (2.0 / 3.0) * va_fac
data[6, :4] = (1.0 / 3.0) * va_fac
fd_row_sums = y - data[:4, :4].sum(axis=1)
for i in range(4):
shares = [0.50, 0.25, 0.05, 0.10, 0.08, 0.02] if i < 2 else [0.10, 0.08, 0.02, 0.50, 0.25, 0.05]
data[i, 4:] = fd_row_sums[i] * np.array(shares)
data[4, 4:] = 0.02 * data[:4, 4:].sum(axis=0)
return calibrate_trade_model(data, ns=ns, nc=nc, nfd=nfd, validate=True)
calib = toy_calibration()
ns, nc, nfd = calib.n_sectors, calib.n_countries, calib.n_final_demand
# 25% tariff on everything country 1 imports from country 0 (intermediates and final demand)
tau = np.ones((ns, nc, ns, nc))
tau[:, 0, :, 1] = 1.25
tau_fd = np.ones((ns, nc, nfd, nc))
tau_fd[:, 0, :, 1] = 1.25
ref = solve_trade_equilibrium(calib, tau=tau, tau_fd=tau_fd, tol=1e-9)
print(f"numpy reference: converged={ref.converged} iterations={ref.iterations} w={np.round(ref.w_sol.ravel(), 6)}")
try:
solve_trade_equilibrium(calib, tau=tau, tau_fd=tau_fd, backend="mlx")
except NotImplementedError as exc:
print("NotImplementedError:", exc)
numpy reference: converged=True iterations=6 w=[1.000255 0.977642]
NotImplementedError: solve_trade_equilibrium supports the NumPy backend only
4.3 The batched Jacobian on the same calibration
The evaluator accepts the 4-D tariff tensors directly and, with batch_size left at None, picks the reduced layout \([\log w; T]\) (exact here: the capital share is uniform) with \(B = 2 \cdot 2 = 4\) entries. The macro point is the reference equilibrium, where the macro residual vanishes. The MLX entry is requested as device="mlx", so the evaluator takes the float64 CPU stream; stream="gpu" would be the way to ask for float32.
from puremacro.trade.gpu import BatchedJacobianEvaluator
x_m = np.concatenate([np.log(ref.w_sol.ravel()), ref.T_sol.ravel()]) # [log w; T] at the reference
ev_np = BatchedJacobianEvaluator(calib, tau, tau_fd, device="cpu", backend="numpy")
J_np = ev_np.evaluate_batched_jacobian(x_m)
print("layout: B =", ev_np.batch_size, "| factor_equivalence =", ev_np.factor_equivalence,
"| max|J| =", f"{np.max(np.abs(J_np)):.2f}")
print("macro residual at the reference:", f"{np.max(np.abs(ev_np.eval_macro_single(x_m)[0])):.1e}")
for backend, device in [("torch", "cpu"), ("torch", "mps"), ("mlx", "mlx")]:
try:
ev = BatchedJacobianEvaluator(calib, tau, tau_fd, device=device, backend=backend)
except RuntimeError as exc: # accelerator absent on this machine
print(f"{backend}:{device} unavailable ({exc})")
continue
J = ev.evaluate_batched_jacobian(x_m)
print(f"{backend}:{device:4s} dtype={ev.dtype} max|J - J_numpy| = {np.max(np.abs(J - J_np)):.1e}")
ev_t = BatchedJacobianEvaluator(calib, tau, tau_fd, device="cpu", backend="torch")
J_ad = ev_t.evaluate_batched_jacobian(x_m, ad_mode="forward") # torch.func.jacfwd, no step size
print("forward-AD vs finite differences:", f"{np.max(np.abs(J_ad - J_np)):.1e}")
layout: B = 4 | factor_equivalence = True | max|J| = 21.95
macro residual at the reference: 5.7e-14
torch:cpu dtype=torch.float64 max|J - J_numpy| = 0.0e+00
torch:mps dtype=torch.float32 max|J - J_numpy| = 3.5e+01
mlx:mlx dtype=None max|J - J_numpy| = 2.8e-08
forward-AD vs finite differences: 9.3e-06
The float64 PyTorch CPU Jacobian reproduces the serial NumPy one exactly, and MLX on its float64 CPU stream to \(3 \times 10^{-8}\) (MLX's own float64 kernels). The one float32 device in the table, MPS, is off by more than the largest entry: float32 rounding divided by the relative step \(10^{-6}\) swamps the derivative, which is exactly why the solvers never polish on it (section 1.3). The \(9 \times 10^{-6}\) gap between forward-mode AD and finite differences is the truncation error of the differences; ad_mode="vjp" (reverse mode) reproduces the AD Jacobian. (ev.dtype is only set on the PyTorch path; MLX picks float32 or float64 per stream.)
4.4 Spatial equilibrium on NumPy and MLX
import numpy as np
from puremacro.spatial import AllenArkolakisModel
coords = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5]])
model = AllenArkolakisModel.from_coordinates(coords)
res_np = model.solve_equilibrium(backend="numpy")
res_mlx = model.solve_equilibrium(backend="mlx")
print("iterations: numpy", res_np.iterations, "| mlx", res_mlx.iterations)
print("population (numpy):", np.round(res_np.population, 6))
print("max |dw| =", f"{np.max(np.abs(res_np.wages - res_mlx.wages)):.1e}",
"| max |dL| =", f"{np.max(np.abs(res_np.population - res_mlx.population)):.1e}",
"| max |dP| =", f"{np.max(np.abs(res_np.price_index - res_mlx.price_index)):.1e}")
print("labor conservation:", res_np.labor_conservation_residual, res_mlx.labor_conservation_residual)
cf_np = model.solve_counterfactual(productivity_new=np.array([1.2, 1.0, 1.0, 1.0, 1.0]), backend="numpy")
cf_mlx = model.solve_counterfactual(productivity_new=np.array([1.2, 1.0, 1.0, 1.0, 1.0]), backend="mlx")
print(f"welfare change: numpy {cf_np.welfare_pct:+.5f}% | mlx {cf_mlx.welfare_pct:+.5f}%")
iterations: numpy 27 | mlx 27
population (numpy): [0.199763 0.199763 0.199763 0.199763 0.200947]
max |dw| = 5.4e-10 | max |dL| = 2.7e-10 | max |dP| = 2.7e-12
labor conservation: 0.0 0.0
welfare change: numpy +4.39312% | mlx +4.39312%
Both backends report the same 27 iterations and agree to \(10^{-9}\), because the MLX float32 loop hands over to the NumPy float64 loop as soon as it reaches its own floor (section 1.1): the run is certified against the tol=1e-8 you asked for, not against the device's \(10^{-6}\). The welfare effect of a 20% productivity gain in the first region agrees to five decimals. The population sums to exactly \(\bar L\) on both backends because the renormalization happens in NumPy.
4.5 The device solver and the homotopy on the toy calibration
Continuing from 4.2:
from puremacro.trade.gpu import solve_homotopy_continuation, solve_trade_equilibrium_gpu
res_gpu = solve_trade_equilibrium_gpu(calib, tau=tau, tau_fd=tau_fd, tol=1e-9)
print(f"gpu converged={res_gpu.converged} iterations={res_gpu.iterations} "
f"max_residual={res_gpu.max_residual:.1e} w={np.round(res_gpu.w_sol.ravel(), 6)}")
print("max |w_gpu - w_numpy| =", f"{np.max(np.abs(res_gpu.w_sol - ref.w_sol)):.1e}")
meta = res_gpu.metadata
print("device used :", f"{meta['backend']}:{meta['device']}",
"| requested:", f"{meta['requested_backend']}:{meta['requested_device']}")
print("layout :", "B =", meta["batch_size"], "factor_equivalence =", meta["factor_equivalence"])
print("jacobians :", dict(meta["jacobian_evaluations"]))
steps = []
hom = solve_homotopy_continuation(calib, target_tau=tau, target_tau_fd=tau_fd, tol=1e-9,
initial_step=0.25, max_step=0.5,
callback=lambda lam, r: steps.append((round(lam, 3), r.iterations)))
print("homotopy steps:", steps)
print(f"homotopy converged={hom.converged} max_residual={hom.max_residual:.1e} "
f"w={np.round(hom.w_sol.ravel(), 6)}")
gpu converged=True iterations=5 max_residual=8.5e-14 w=[1.000255 0.977642]
max |w_gpu - w_numpy| = 1.2e-14
device used : torch:cpu | requested: torch:mps
layout : B = 4 factor_equivalence = True
jacobians : {'torch:cpu:float64': 4}
homotopy steps: [(0.25, 4), (0.625, 4), (1.0, 4)]
homotopy converged=True max_residual=1.8e-10 w=[1.000255 0.977642]
The device solver reaches the NumPy wages to \(10^{-14}\). The metadata says what happened: "auto" picked MPS, the float64 policy of section 1.3 silently moved the Jacobian to torch:cpu, the reduced layout was chosen because it is exact here (\(B = 4\)), and every Jacobian was float64. The homotopy took three steps: \(0.25\), then \(0.625\) after the \(1.5\times\) growth of a step that converged in at most 4 iterations, then \(1.0\).
4.6 A larger synthetic table
The batched GEMM pays off as \(M = SN\) grows. The same calls run on any calibration; here an 8-country, 5-sector synthetic table (synthetic_icio_table, generic random flows) with a 20% tariff by its first country on all imports:
from puremacro.trade.data import synthetic_icio_table
nc, ns = 8, 5
big = calibrate_trade_model(synthetic_icio_table(nc, ns, seed=3), ns=ns, nc=nc, nfd=3)
rates = np.zeros(nc)
rates[0] = 0.20 # country C01 taxes all its imports at 20 percent
ref_big = solve_trade_equilibrium(big, tau=rates, tau_fd=rates, tol=1e-6)
gpu_big = solve_trade_equilibrium_gpu(big, tau=rates, tau_fd=rates, tol=1e-6)
print("B =", gpu_big.metadata["batch_size"], "| converged:", ref_big.converged, gpu_big.converged,
"| iterations:", ref_big.iterations, gpu_big.iterations)
print("max |w_gpu - w_numpy| =", f"{np.max(np.abs(gpu_big.w_sol - ref_big.w_sol)):.1e}")
Tariff rates by importing country are accepted exactly as by solve_trade_equilibrium; solve_trade_equilibrium_mlx(big, tau=rates, tau_fd=rates) and solve_homotopy_continuation(big, target_tau=rates, target_tau_fd=rates) take the same arguments.
5. Full API Specification
# puremacro._backend
SUPPORTED = ("numpy", "numba", "mlx", "cupy")
backend_available(name: str) -> bool # ValueError for unknown names
available_backends() -> tuple[str, ...] # installed, numpy first
get_array_namespace(name: str) # numpy | mlx.core | cupy; ImportError with the pip hint
to_numpy(x) -> np.ndarray
# backend= on the model solvers
solve_trade_equilibrium(calib, ..., *, backend="numpy", ...) # NumPy only; other values raise NotImplementedError
AllenArkolakisModel.solve_equilibrium(tol=1e-8, max_iter=2500, damping=0.35,
backend="numpy") -> AllenArkolakisResult
AllenArkolakisModel.solve_counterfactual(trade_costs_new=None, productivity_new=None, amenity_new=None,
tol=1e-8, max_iter=2500, damping=0.35,
backend="numpy") -> AllenArkolakisResult
# puremacro.trade.gpu.backend
DeviceInfo(backend: Literal["torch", "mlx", "numpy"], device: str, device_name: str,
total_memory_gb: float | None = None, supports_float64: bool = True, is_uma: bool = False)
has_torch() -> bool
has_mlx() -> bool
select_compute_device(preferred: str | None = None) -> tuple[str, str]
detect_device(preferred: str | None = None) -> DeviceInfo
device_context(device: str | None = None, backend: str | None = None) # context manager -> (backend, device)
get_memory_usage(device: str | None = None, backend: str | None = None) -> dict[str, float | str]
reset_peak_memory(device: str | None = None, backend: str | None = None) -> None
to_tensor(array, device: str | None = None, dtype=None, backend: str = "torch")
to_numpy(tensor) -> np.ndarray
# puremacro.trade.gpu.batched_jacobian
BatchedJacobianEvaluator(calib: TradeCalibrationResult, tau_a, taufd_a,
device: str | None = None, backend: str | None = None,
batch_size: int | None = None, factor_equivalence: bool | None = None,
*, foreign_saving_units: str = "numeraire")
.update_tariffs(tau_a, taufd_a) -> None
.eval_macro_single(xm, compute_full=False)
-> (f_macro, f_full | None, p_vec, p, y_vec)
.full_residuals(x_full) -> np.ndarray
.evaluate_batched_jacobian(xm, f_base=None, eps_fd=1e-6,
ad_mode="finite_diff" | "forward" | "vjp", stream=None) -> np.ndarray (B, B)
.jvp(xm, v) -> np.ndarray
.eval_macro_batched_torch(X_batch) / .eval_macro_batched_mlx(X_batch, stream=None)
# puremacro.trade.gpu.solver_gpu
solve_trade_equilibrium_gpu(calib, tau=None, tau_fd=None, tauf=None, tauf_fd=None, x0=None,
device=None, backend=None, batch_size=None, max_iter=50, tol=2.5e-3,
damping=1e-4, ad_mode="finite_diff", base_result=None, verbose=False, *,
factor_equivalence=None, foreign_saving_units="numeraire") -> TradeEquilibriumResult
# puremacro.trade.gpu.mlx_solver
solve_trade_equilibrium_mlx(calib, tau=None, tau_fd=None, tauf=None, tauf_fd=None, x0=None,
batch_size=None, max_iter=50, tol=2.5e-3, damping=1e-4,
ad_mode="finite_diff", base_result=None, verbose=False, *,
factor_equivalence=None, foreign_saving_units="numeraire") -> TradeEquilibriumResult
# puremacro.trade.gpu.homotopy
solve_homotopy_continuation(calib, target_tau, target_tau_fd, base_tau=None, base_tau_fd=None, x0=None,
device=None, backend=None, batch_size=None,
initial_step=0.1, min_step=1e-4, max_step=0.5, tol=2.5e-3, damping=1e-4,
max_iter_per_step=30, base_result=None, verbose=False, callback=None, *,
factor_equivalence=None, foreign_saving_units="numeraire") -> TradeEquilibriumResult
Every name above is importable from puremacro.trade.gpu.
Parameters shared by the accelerated solvers
calib: aTradeCalibrationResultfromcalibrate_trade_model.tau,tau_fd: tariffs in any formsolve_trade_equilibriumaccepts: multipliers ($1 + $ rate) of shape(ns, nc, ns, nc)/(ns, nc, nfd, nc)or the internal(M, ns, nc)/(M, nfd, nc)layout, or rates by importing country (a scalar or a vector of lengthnc);Nonemeans no tariffs.tauf,tauf_fd(direct solvers only): tariff rates by importing country, used whentau/tau_fdisNone, exactly as onsolve_trade_equilibrium.x0:None(unit factor returns and the calibrated transfers), the full state vector of length \(2M + 4N - 1\), or a macro vector of either layout, \(2N\) or \(3N\); a reduced vector seeding the full layout sets \(r = w\). Any other length raisesValueErrornaming the three it expects.device/backend: explicit values are kept as given;select_compute_devicefills in whichever of the two is missing. The pair that ends up evaluating the Jacobian may differ from the pair you asked for (the float64 policy of section 1.3);metadata["device"]/["backend"]record the effective pair andmetadata["requested_device"]/["requested_backend"]the requested one.solve_trade_equilibrium_mlxhas neither argument.batch_size/factor_equivalence: the layout switch of section 1.4. Preferfactor_equivalence(None= choose by exactness,True= reduced \([\log w; T]\),False= full \([\log r; \log w; T]\));batch_sizemust equal \(2N\) or \(3N\) for the calibration.foreign_saving_units:"numeraire"(default) or"world_income", the foreign-saving closure ofsolve_trade_equilibrium.tol: absolute infinity-norm tolerance in the calibration's value units, on every equation of the full system forconverged;max_iter: Levenberg-Marquardt iterations (max_iter_per_stepper continuation step).damping: initial \(\mu\).ad_mode:"finite_diff"(batched differences, default),"forward"(forward-mode AD:torch.func.jacfwd/mx.jvp) or"vjp"(reverse mode:torch.func.jacrev/mx.vjp). An AD mode that fails on the device warns and falls back to finite differences; an unknown value raisesValueError.base_result: baseline equilibrium used by the post-processing for CPI and terms-of-trade indices.verbose: print per-iterationmax_res, step and \(\mu\) (and, in the homotopy, the step log).callback(homotopy only):callback(lambda_value, result)after each successful step.
6. Result Interface
TradeEquilibriumResult (accelerated solvers)
The accelerated solvers return the same frozen dataclass as solve_trade_equilibrium, post-processed and audited by the same code, so x_sol, p_sol, y_sol, r_sol, w_sol, T_sol, XN_sol, the bilateral flow tensors, gdp, imports, exports, tariffs, cpi and terms_of_trade are documented once in the trade accounting page. The fields the solvers on this page set themselves are:
converged,iterations,max_residual,diff/residual_norm(L1 norm) andresiduals: evaluated on the full system residual in float64.metadata:method("gpu_accelerated"or"mlx_accelerated"), the effectivedeviceandbackendtogether withrequested_deviceandrequested_backend,factor_equivalence,batch_size,jacobian_evaluations(a count of Jacobians bybackend:device:dtype),ad_mode,tol,max_iter,macro_residual_max,solve_duration_secondsandmemory_usage(the dictionary ofget_memory_usage).summary(detailed=False) -> pd.DataFrame: the solver diagnostic table;detailed=Truegives the country table (GDP, trade, tariffs, CPI, terms of trade).to_frame(detailed=False),to_markdown(detailed=False),to_latex(detailed=False),to_typst(detailed=False): the same table in each format.- There is no
plotmethod onTradeEquilibriumResult; figures come frompuremacro.trade.plot.
AllenArkolakisResult (spatial solver)
Unchanged by the backend: wages, population, price_index, real_wages, welfare, consumer_market_access, firm_market_access, trade_shares, and for counterfactuals w_hat, L_hat, welfare_hat, welfare_pct; diagnostics converged, iterations, max_residual, labor_conservation_residual, spatial_utility_variance; presentation summary(), to_frame(), to_markdown(), to_latex(), to_typst() and plot(kind="spatial"). Nothing in the result records which backend produced it.
DeviceInfo and the memory dictionary
DeviceInfo is the frozen dataclass of section 1.2. get_memory_usage() returns {"backend", "device", "allocated_mb", "peak_mb"} plus cache_mb (CUDA, MLX) or driver_mb (MPS); reset_peak_memory() resets the peak counters (CUDA, MLX) and empties the device caches (CUDA, MPS, MLX), and device_context(...) calls it on exit and yields the (backend, device) pair without cross-checking the two, echoing the strings you passed.
7. Caveats and Limitations
- Model scope. The device layer solves the consistent-accounting model with Cobb-Douglas intermediates (
sigma = 0) and lump-sum rebates of tariff revenue. For CES intermediates, other fiscal closures or the capacity extension usesolve_trade_equilibrium. - Two layers, two vocabularies. The namespace layer takes
"numpy","mlx","cupy"("numba"warns and falls back, and anything else,"torch"included, raisesValueError: Unknown backend). The device layer takes"auto","numpy","cpu","cuda","cuda:N","mps","mlx","gpu"and raisesValueErroron anything else. - Precision. MPS has no float64 and MLX's Metal stream is float32, so an explicitly requested float32 device is used for the coarse phase only and warns when it is. Expect agreement to rounding on the float64 paths and an unusable Jacobian on a float32 one (Example 4.3).
- Tolerances.
tol=2.5e-3on both the NumPy and the accelerated trade solvers is absolute and loose; tighten it explicitly for parity work. - Import cost.
import puremacro.tradedoes not pull in PyTorch or MLX; both are imported lazily at the first call that needs them, which pays the framework import. - Unverified paths. The CuPy branch of the spatial solver and the CUDA branch of the device layer were not executed while writing this page (no NVIDIA hardware); they are covered by the fallback logic and by tests that skip when the accelerator is absent.
- No speedup on small problems. All device paths add transfer and launch latency; the toy calibration and the 5-region model run slower on an accelerator than on NumPy. Performance & Benchmarks currently publishes CPU timings only.
- Offline and deterministic. Nothing here touches the network; the accelerated paths are only as deterministic as the device reductions, and the NumPy path is the reference for reproducibility claims.
References
- Allen, T., & Arkolakis, C. (2014). "Trade and the Topography of the Spatial Economy." The Quarterly Journal of Economics, 129(3), 1085–1140.
- Allgower, E. L., & Georg, K. (1990). Numerical Continuation Methods: An Introduction. Springer Series in Computational Mathematics 13. Berlin: Springer.
- Armijo, L. (1966). "Minimization of functions having Lipschitz continuous first partial derivatives." Pacific Journal of Mathematics, 16(1), 1–3.
- Caliendo, L., & Parro, F. (2015). "Estimates of the Trade and Welfare Effects of NAFTA." The Review of Economic Studies, 82(1), 1–44.
- Levenberg, K. (1944). "A method for the solution of certain non-linear problems in least squares." Quarterly of Applied Mathematics, 2(2), 164–168.
- Marquardt, D. W. (1963). "An algorithm for least-squares estimation of nonlinear parameters." Journal of the Society for Industrial and Applied Mathematics, 11(2), 431–441.