🇬🇧 English · 🇪🇸 Español
Backends de hardware: GPU, Apple MLX y jacobianos por lotes para comercio y GE espacial
puremacro._backend y puremacro.trade.gpu añaden aceleración por hardware opcional a los solucionadores de equilibrio general documentados en Economía espacial cuantitativa y GE de comercio con gravedad. Intervienen dos capas:
- La capa de espacio de nombres de arreglos (
puremacro._backend):numpy,mlx(Apple Silicon) ycupy(NVIDIA) comparten una API de arreglos al estilo de NumPy.AllenArkolakisModel.solve_equilibrium(..., backend=)/solve_counterfactual(..., backend=)ejecutan su aplicación de contracción a través de ella, con unRuntimeWarningy un respaldo en NumPy cuando el backend pedido falta o falla. El solucionador de comerciosolve_trade_equilibriumes solo NumPy:backend="numpy"es el único valor aceptado y cualquier otro lanzaNotImplementedError. - La capa de dispositivo (
puremacro.trade.gpu): un motor en PyTorch (CUDA / Apple MPS / CPU) y en Apple MLX nativo para el modelo de comercio multipaís y multisectorial de contabilidad consistente depuremacro.trade(insumos intermedios Cobb-Douglas, devolución de suma fija de la recaudación arancelaria), construido sobre operadores de Leontief preinvertidos, un jacobiano por lotes evaluado como un único GEMM con varios lados derechos, un paso de Levenberg-Marquardt (1944, 1963) con búsqueda lineal hacia atrás y continuación homotópica adaptativa (Allgower y Georg, 1990) en el esquema arancelario.
Ambas capas son opcionales. Nada de esta página es necesario para ejecutar los modelos: la ruta NumPy sigue siendo la implementación de referencia y el oráculo de corrección, y es la única disponible en Pyodide (iPad, navegador). Todo resultado acelerado de esta página se compara con ella.
1. Marco algorítmico
1.1 Qué backend ejecuta qué
puremacro._backend.SUPPORTED es ("numpy", "numba", "mlx", "cupy"). backend_available(name) consulta importlib.util.find_spec, available_backends() enumera los instalados (NumPy primero) y get_array_namespace(name) devuelve numpy, mlx.core o cupy ("numba" es un backend de núcleos compilados, no un espacio de nombres de arreglos, y aquí lanza ValueError). to_numpy(x) trae cualquier arreglo al anfitrión, pasando CuPy por cp.asnumpy porque CuPy bloquea la conversión implícita.
solve_trade_equilibrium(calib, ..., backend=)solo acepta"numpy". Las soluciones aceleradas de comercio pasan por la capa de dispositivo de abajo, que resuelve el mismo modelo y devuelve el mismo tipo de resultado.AllenArkolakisModel.solve_equilibrium(backend=): toda la aplicación de contracción amortiguada sobre \((w_i, L_i)\) (matriz núcleo \(\tau_{ji}^{-\theta}\), acceso al mercado, el softmax de igualación de utilidades) corre en el dispositivo enfloat32con MLX y enfloat64con CuPy. Un dispositivo float32 solo puede certificar el punto fijo hasta su propio piso, así que cuando ese piso es más grueso que latolpedida (siempre en MLX, donde los pisos del dispositivo son \(\max(\text{tol}, 10^{-6})\) en el residuo y \(10^{-5}\) en la dispersión de utilidades) el bucle del dispositivo se trata como arranque en caliente y la iteración se termina en el anfitrión en NumPy float64 con latolpedida. La tolerancia pedida se respeta yconvergedes veraz sea cual sea el backend; el ejemplo 4.4 muestra a MLX y NumPy coincidiendo a \(10^{-9}\) tras las mismas 27 iteraciones. Tras la convergencia la población se renormaliza a \(\bar L\), los salarios a media unitaria, y el índice de precios, los términos de acceso al mercado y los salarios reales se recalculan en NumPy float64, por lo que el residuo de conservación del trabajo es cero (al redondeo) en ambos backends.solve_counterfactualpasabackendtanto a la solución base como a la contrafactual.
1.2 Selección de dispositivo y DeviceInfo
puremacro.trade.gpu.select_compute_device(preferred) asigna a un nombre un par (backend, device), donde backend es "torch", "mlx" o "numpy" y device es "cuda", "mps", "gpu" o "cpu":
preferred |
Resultado | Si no está disponible |
|---|---|---|
None / "auto" |
el primero de: torch CUDA, torch MPS, MLX GPU, torch CPU, NumPy CPU | nunca falla |
"numpy" |
("numpy", "cpu"), el evaluador serial de NumPy |
nunca falla |
"cpu" |
("torch", "cpu") si PyTorch está instalado, ("numpy", "cpu") si no |
nunca falla |
"cuda", "cuda:N" |
ese dispositivo CUDA de PyTorch | RuntimeError (falta PyTorch, torch.cuda.is_available() es False o el índice N está fuera de rango) |
"mps" |
("torch", "mps") |
RuntimeError (falta PyTorch o torch.backends.mps.is_available() es False) |
"torch" / "pytorch" |
el mejor dispositivo PyTorch: CUDA, luego MPS, luego CPU | RuntimeError si falta PyTorch |
"mlx" / "apple_mlx" |
("mlx", "gpu") |
RuntimeError si falta mlx |
"gpu" |
la mejor GPU de cualquier backend: CUDA, luego MPS, luego MLX | RuntimeError si no hay ninguna de las tres |
Todas las cadenas aceptadas están en la tabla; cualquier otra lanza ValueError: Unknown device/backend ...; expected one of (...) en lugar de resolverse en silencio como "auto". "numpy" es un nombre real, así que se puede pedir explícitamente el evaluador serial, y "cpu" no exige PyTorch: se degrada a ("numpy", "cpu"), que es lo que obtiene todo kernel de Pyodide.
detect_device(preferred) envuelve la misma elección en una dataclass congelada:
Campo de DeviceInfo |
Significado |
|---|---|
backend |
"torch", "mlx" o "numpy" |
device |
"cuda", "mps", "gpu" (MLX) o "cpu" |
device_name |
nombre del adaptador CUDA, "Apple Metal (<cpu>)", "Apple MLX (<cpu>)" o el procesador anfitrión |
total_memory_gb |
CUDA: total_memory de las propiedades del dispositivo; Apple Silicon: sysctl hw.memsize (la reserva unificada; None si no se puede ejecutar sysctl); None en CPU simple |
supports_float64 |
True para CUDA y CPU, False para MPS, False para el flujo GPU de MLX |
is_uma |
True en Apple Silicon (memoria unificada: anfitrión y GPU comparten una reserva, sin copia al dispositivo) |
El repr resume la situación de precisión, p. ej. f32-only para MPS y f32-gpu/f64-cpu-stream para MLX.
1.3 Precisión: float64 frente a float32
Una diferencia finita hacia adelante divide el error de redondeo del residuo por el paso, de modo que un residuo en float32 produce un error relativo grande en el jacobiano y un paso de Newton divergente. La regla de la capa de dispositivo es: ensamblar el jacobiano en float64 dondequiera que el dispositivo lo permita, y usar un dispositivo float32 solo cuando se pide explícitamente, y solo para la fase gruesa.
- CUDA y la CPU de PyTorch ejecutan todo en float64.
- MPS (Apple Metal vía PyTorch) no tiene float64. Con
device="auto"el solucionador sustituye en silencio el evaluador float64 de PyTorch en CPU. Condevice="mps"pedido explícitamente emite unRuntimeWarning("... 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"), usa MPS mientras \(\max_i |F_i| \ge 5 \times 10^6\) y solo en las cinco primeras iteraciones, y pule en float64 después. - MLX guarda constantes float64 creadas bajo
mx.stream(mx.cpu), donde MLX ejecuta Accelerate/LAPACK (y dondemx.exp, que solo es exacto en float32, se sustituye por une ** xexacto), y copias float32 para el flujo GPU.BatchedJacobianEvaluatorusa por defecto el flujo CPU; el flujo GPU float32 solo se alcanza constream="gpu"o en la fase gruesa de un dispositivo explícito.solve_trade_equilibrium_mlxessolve_trade_equilibrium_gpu(device="mlx")y sigue exactamente la misma política.
Sea cual sea el dispositivo, result.metadata["jacobian_evaluations"] cuenta los jacobianos por backend:device:dtype, de modo que la ruta de precisión que siguió una ejecución queda en el resultado. El residuo puntual eval_macro_single, los puntos de prueba de la búsqueda lineal y la comprobación final del residuo son NumPy float64 en el anfitrión, así que max_residual y converged son siempre magnitudes float64; solo las columnas del jacobiano de la fase gruesa pueden llevar redondeo float32. El ejemplo 4.3 lo mide: el jacobiano float32 de MPS es inservible (su error supera la mayor entrada), mientras que los jacobianos de la CPU de PyTorch y del flujo CPU de MLX reproducen el de NumPy serial al redondeo.
1.4 El jacobiano por lotes
El sistema de contabilidad consistente de puremacro.trade (véase Contabilidad de comercio consistente) se condensa en un vector macro. Con \(N\) países, \(S\) sectores y \(M = SN\) pares país-sector, BatchedJacobianEvaluator trabaja con
donde \(w\) son los salarios, \(r\) las rentas del capital y \(T\) las transferencias de suma fija (presupuestos públicos). El ahorro externo es exógeno, como en el modelo completo, en unidades del numerario o como proporción fija del ingreso factorial mundial (foreign_saving_units), así que no es una incógnita. Los residuos macro son el primer precio de productor menos uno (el numerario), el vaciado del mercado de trabajo en todos los países salvo el primero (implicado por la ley de Walras una vez que vacían todos los mercados de bienes), el vaciado del mercado de capital (solo en el esquema completo) y los presupuestos públicos. Una solución del sistema macro resuelve todas las ecuaciones del sistema completo de contabilidad consistente.
El primer esquema impone \(r_c = w_c\), que solo es exacto cuando la participación del capital es la misma en todos los sectores de un país: el vaciado del mercado de capital se sigue entonces del de trabajo. El esquema se elige con el argumento solo por nombre factor_equivalence, y batch_size es la grafía antigua, validada contra la calibración. factor_equivalence=None (por defecto) elige el esquema reducido exactamente cuando es exacto para la calibración y el completo en otro caso; factor_equivalence=True en una calibración donde no es exacto lanza ValueError en lugar de eliminar en silencio la condición de vaciado del capital. Si se pasa batch_size, debe valer \(2N\) o \(3N\) para su \(N\): en la calibración de juguete de abajo, 4 o 6, y batch_size=5 lanza ValueError: batch_size=5 is inconsistent with a 2-country calibration: expected 4 (reduced layout, r == w) or 6 (full layout). Pasar ambos argumentos con implicaciones contradictorias también lanza un error.
Dado \(x_m\), los precios y las producciones brutas salen de dos sistemas de Leontief,
con \(B^\top = (A \circ \tau)^\top / (1 - \text{tax})\) el operador de participaciones de costo con aranceles, \(v\) el valor agregado unitario Cobb-Douglas y \(A\) los coeficientes de insumo-producto. Ambas inversas se forman una vez ((I - A)^{-1} en la construcción, (I - B^\top)^{-1} en update_tariffs, es decir, una vez por esquema arancelario). Un jacobiano por diferencias hacia adelante necesita \(F\) en los \(B\) puntos perturbados \(x_m + h_j e_j\), con \(h_j = \varepsilon\) para los precios factoriales en logaritmos y \(h_j = \varepsilon \max(|T_j|, s)\) para las transferencias, donde \(s\) es la mediana del ingreso factorial base (\(\varepsilon = 10^{-6}\) por defecto, eps_fd). Apilando los puntos perturbados como filas de \(X \in \mathbb{R}^{B \times B}\), las soluciones de precios y producción de todo el lote se convierten en dos productos matriciales,
y cada cuenta (demandas de factores, impuestos a la producción y al uso final, aranceles) se convierte en un producto matricial por lotes. Un único lanzamiento de núcleo sustituye a \(B\) evaluaciones seriales del residuo, y
ad_mode="forward" sustituye las diferencias por diferenciación automática en modo directo (torch.func.jacfwd en PyTorch, mx.jvp columna a columna en MLX) y ad_mode="vjp" por el modo inverso (torch.func.jacrev, mx.vjp); ambos eliminan la sensibilidad al tamaño del paso. Ante cualquier fallo el evaluador emite un RuntimeWarning y vuelve a las diferencias finitas (nunca en silencio), y en el backend numpy cualquier modo AD avisa y usa diferencias finitas. Un vector macro cuya longitud no coincide con el esquema lanza ValueError, y también un ad_mode desconocido. El método jvp(xm, v) devuelve la derivada direccional \(J v\) del mismo modo, con una diferencia centrada en float64 como último recurso.
1.5 Paso de Levenberg-Marquardt, equilibrado y búsqueda lineal
Cada iteración de solve_trade_equilibrium_gpu / solve_trade_equilibrium_mlx escala el jacobiano por ambos lados (equilibrado de Jacobi), \(\tilde J = D_L J D_R\) con \(D_R = \operatorname{diag}(1/\lVert J_{:,j}\rVert_2)\) y \(D_L = \operatorname{diag}(1/\lVert (J D_R)_{i,:}\rVert_2)\), y resuelve las ecuaciones normales regularizadas
con torch.linalg.solve en float64 cuando PyTorch es el backend evaluador (en el dispositivo CUDA cuando ese es el de pulido, en la CPU en otro caso), luego scipy.linalg.solve y luego scipy.linalg.lstsq; gana el primero que funciona. El paso se reescala para que su bloque de precios factoriales quede dentro de \(0.3\) en valor absoluto, y los precios factoriales en logaritmos se recortan a \([-5, 5]\). Una búsqueda lineal hacia atrás sobre la función de mérito \(\Phi(x) = \lVert D_L F(x) \rVert_2\) prueba \(\alpha = 1, \tfrac12, \tfrac14, \dots\) (como mucho 12 puntos) y acepta el primero con \(\max_i|F_i| \le \text{tol}\), \(\Phi(x + \alpha \Delta) < \Phi(x)\) o \(\max_i |F_i(x + \alpha\Delta)| < \max_i |F_i(x)|\): una regla de descenso simple al estilo del retroceso de Armijo (1966), sin constante de descenso suficiente. El amortiguamiento sigue la actualización clásica de Marquardt: \(\mu \leftarrow \max(0.3\mu, 10^{-8})\) tras un paso aceptado, \(\mu \leftarrow \max(0.5\mu, 10^{-8})\) cuando solo mejoró el mérito el mejor punto de prueba, y \(\mu \leftarrow \min(5\mu, 10^{2})\) en otro caso. La iteración solo se detiene cuando el residuo macro está dentro de tol y todas las ecuaciones del sistema completo en el estado reconstruido están dentro de tol; el resultado devuelto se posprocesa y audita exactamente como una solución NumPy.
1.6 Continuación homotópica en el esquema arancelario
Los choques arancelarios grandes y asimétricos pueden derrotar a un método tipo Newton iniciado en la base. solve_homotopy_continuation inserta el esquema objetivo en la trayectoria
y la recorre con la continuación sin predictor y con arranque en caliente de Allgower y Georg (1990, cap. 1): resuelve en \(\lambda_0 = 0\) (o acepta x0) y luego resuelve repetidamente en \(\min(\lambda + \Delta\lambda, 1)\) partiendo del último estado convergido. El control del paso es adaptativo: un paso que convergió en 4 iteraciones o menos hace crecer el siguiente \(1.5\times\) (con tope max_step), uno que necesitó al menos max_iter_per_step - 5 lo reduce a \(0.75\) (con piso min_step), y un paso fallido se descarta y se reintenta con la mitad de tamaño. Si el paso cae por debajo de min_step, si la propia base no converge o si ningún paso tiene éxito, la función lanza RuntimeError (no devuelve un resultado parcial); el gancho callback(lambda, result), llamado tras cada paso con éxito, es la forma de conservar los equilibrios intermedios. Los esquemas objetivo y base aceptan todas las formas que acepta solve_trade_equilibrium (multiplicadores o tasas por país importador).
2. Opciones metodológicas
| Opción | Capa de espacio de nombres (backend=, solucionador espacial) |
solve_trade_equilibrium_gpu |
solve_trade_equilibrium_mlx |
|---|---|---|---|
| Nombres aceptados | "numpy", "mlx", "cupy" ("numba" avisa y recurre a NumPy; cualquier otra cadena lanza ValueError) |
device en "auto", "numpy", "cpu", "cuda", "cuda:N", "mps", "mlx", "gpu"; backend en "torch", "mlx", "numpy" |
ninguno: solo MLX, lanza RuntimeError sin él |
| Precisión | contracción float32 en MLX, terminada en el anfitrión en float64 | jacobiano float64 dondequiera que el dispositivo lo permita; un dispositivo float32 solo si se pide explícitamente, y solo para la fase gruesa | la misma política, en el flujo CPU de MLX |
| Jacobiano | no aplica (iteración de punto fijo) | diferencias finitas por lotes, ad_mode="forward" (jacfwd) o "vjp" (jacrev) |
diferencias finitas por lotes, ad_mode="forward" (jvp) o "vjp" (mx.vjp) |
| Regla de parada | la tol pedida, terminada en el anfitrión cuando el piso del dispositivo es más grueso |
tol=2.5e-3 en todas las ecuaciones del sistema completo, absoluta en las unidades de valor de la calibración |
igual |
| Política ante fallos | RuntimeWarning y luego NumPy, para un backend ausente, una excepción y una solución acelerada no convergida |
las excepciones se propagan; los fallos de AD avisan y recurren a diferencias finitas | igual |
| Dónde se prueba | un modelo espacial de 5 regiones (tests/test_trade_solver_accelerated.py) |
una calibración sintética de 8 países y 5 sectores contra el solucionador NumPy (tests/test_gpu_acceleration.py), omitida cuando no hay acelerador importable |
igual |
La tolerancia de comercio por defecto es \(2.5 \times 10^{-3}\) tanto para el solucionador NumPy como para los acelerados. Es absoluta en las unidades de valor de la calibración y holgada; ajústela para trabajos de paridad, como hacen los ejemplos de abajo.
3. Instalación de los aceleradores
Ninguno de los aceleradores forma parte de la instalación base, y la instalación base ejecuta todo lo de esta página por la ruta NumPy.
| Acelerador | Instalación | Plataformas | Aporta |
|---|---|---|---|
| Apple MLX | pip install "puremacro[backend]" (el extra [accel] tiene el mismo contenido: numba más mlx en macOS) |
solo macOS en Apple Silicon (marcador sys_platform == 'darwin') |
backend="mlx" en el solucionador espacial, solve_trade_equilibrium_mlx, device="mlx" en la capa de dispositivo |
| NVIDIA CuPy | pip install "puremacro[cuda]" (cupy-cuda12x; elija la rueda acorde con su CUDA) |
GPU NVIDIA con CUDA | backend="cupy" en el solucionador espacial |
| PyTorch | pip install torch (no hay extra de puremacro; use el selector de pytorch.org para una compilación CUDA) |
CUDA, Apple MPS o CPU | solve_trade_equilibrium_gpu, solve_homotopy_continuation, device="cuda"/"mps"/"cpu" |
puremacro.trade.gpu importa torch y mlx de forma perezosa: import puremacro.trade, e incluso from puremacro.trade.gpu import solve_trade_equilibrium_gpu, los dejan fuera de sys.modules en una máquina donde están instalados y funcionan en una donde no lo están. Una llamada que de verdad necesita un acelerador ausente lanza RuntimeError cuando lo pide por nombre, mientras que device=None / "auto" se resuelve como ("numpy", "cpu") y ejecuta el evaluador serial. puremacro.runtime.capabilities().backends enumera los backends de espacio de nombres instalados (numpy, numba, mlx, cupy); PyTorch no forma parte de esa tupla aunque esté instalado.
Pyodide, iPad y el navegador. Ni PyTorch, ni MLX, ni CuPy pueden instalarse en un kernel de Pyodide, así que allí capabilities().backends == ("numpy",) (véase Ejecutar en cualquier sitio), detect_device() devuelve numpy:cpu por construcción y los solucionadores de dispositivo ejecutan el evaluador NumPy. Un script escrito contra la ruta NumPy corre sin cambios en una tableta.
4. Ejemplos ejecutables
Todos los ejemplos corren sin conexión. Se ejecutaron en una estación Apple Silicon con PyTorch (MPS) y MLX instalados y sin GPU NVIDIA; las líneas impresas reproducen esa ejecución. Los ejemplos se continúan unos a otros, como una sola sesión.
4.1 ¿Qué hardware tengo?
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']
Con PyTorch instalado, "auto" prefiere MPS a MLX en un Mac, y el DeviceInfo de MPS indica f32-only. El diccionario de memoria siempre lleva backend, device, allocated_mb y peak_mb; MPS añade driver_mb, CUDA y MLX añaden cache_mb, y el respaldo NumPy informa del tamaño máximo del conjunto residente del proceso según resource.getrusage.
4.2 La referencia NumPy en una calibración de juguete
La calibración es la tabla de 2 países, 2 sectores y 3 usos finales de tests/test_trade_solver_accelerated.py. En su base la tabla ya es un equilibrio, así que el ejemplo impone un arancel del 25% a todo lo que el país 1 importa del país 0 para que los solucionadores trabajen. solve_trade_equilibrium da la referencia y rechaza un backend distinto de NumPy.
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 El jacobiano por lotes en la misma calibración
El evaluador acepta directamente los tensores arancelarios 4-D y, con batch_size en None, elige el esquema reducido \([\log w; T]\) (exacto aquí: la participación del capital es uniforme) con \(B = 2 \cdot 2 = 4\) entradas. El punto macro es el equilibrio de referencia, donde el residuo macro se anula. La entrada MLX se pide como device="mlx", así que el evaluador usa el flujo CPU float64; stream="gpu" sería la forma de pedir 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
El jacobiano float64 de la CPU de PyTorch reproduce exactamente el de NumPy serial, y MLX en su flujo CPU float64 a \(3 \times 10^{-8}\) (los núcleos float64 propios de MLX). El único dispositivo float32 de la tabla, MPS, se desvía más que la mayor entrada: el redondeo float32 dividido por el paso relativo \(10^{-6}\) anula la derivada, que es justamente por lo que los solucionadores nunca pulen con él (sección 1.3). La diferencia de \(9 \times 10^{-6}\) entre la AD en modo directo y las diferencias finitas es el error de truncamiento de las diferencias; ad_mode="vjp" (modo inverso) reproduce el jacobiano AD. (ev.dtype solo se fija en la ruta PyTorch; MLX elige float32 o float64 según el flujo.)
4.4 Equilibrio espacial en NumPy y 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%
Ambos backends informan de las mismas 27 iteraciones y coinciden a \(10^{-9}\), porque el bucle float32 de MLX cede el paso al bucle float64 de NumPy en cuanto alcanza su propio piso (sección 1.1): la ejecución se certifica contra la tol=1e-8 pedida, no contra el \(10^{-6}\) del dispositivo. El efecto sobre el bienestar de una ganancia de productividad del 20% en la primera región coincide a cinco decimales. La población suma exactamente \(\bar L\) en ambos backends porque la renormalización ocurre en NumPy.
4.5 El solucionador de dispositivo y la homotopía en la calibración de juguete
Continuando desde 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]
El solucionador de dispositivo alcanza los salarios de NumPy a \(10^{-14}\). Los metadatos dicen qué ocurrió: "auto" eligió MPS, la política float64 de la sección 1.3 movió en silencio el jacobiano a torch:cpu, se eligió el esquema reducido porque aquí es exacto (\(B = 4\)) y todos los jacobianos fueron float64. La homotopía dio tres pasos: \(0.25\), luego \(0.625\) tras el crecimiento \(1.5\times\) de un paso que convergió en 4 iteraciones o menos, y luego \(1.0\).
4.6 Una tabla sintética mayor
El GEMM por lotes compensa a medida que crece \(M = SN\). Las mismas llamadas corren sobre cualquier calibración; aquí, una tabla sintética de 8 países y 5 sectores (synthetic_icio_table, flujos aleatorios genéricos) con un arancel del 20% de su primer país sobre todas sus importaciones:
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}")
Las tasas arancelarias por país importador se aceptan exactamente como en solve_trade_equilibrium; solve_trade_equilibrium_mlx(big, tau=rates, tau_fd=rates) y solve_homotopy_continuation(big, target_tau=rates, target_tau_fd=rates) toman los mismos argumentos.
5. Especificación completa de la API
# 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
Todos los nombres de arriba se importan desde puremacro.trade.gpu.
Parámetros comunes de los solucionadores acelerados
calib: unTradeCalibrationResultdecalibrate_trade_model.tau,tau_fd: aranceles en cualquier forma que aceptesolve_trade_equilibrium: multiplicadores ($1 + $ tasa) de forma(ns, nc, ns, nc)/(ns, nc, nfd, nc)o en el esquema interno(M, ns, nc)/(M, nfd, nc), o tasas por país importador (un escalar o un vector de longitudnc);Nonesignifica sin aranceles.tauf,tauf_fd(solo solucionadores directos): tasas arancelarias por país importador, usadas cuandotau/tau_fdesNone, exactamente como ensolve_trade_equilibrium.x0:None(rendimientos factoriales unitarios y las transferencias calibradas), el vector de estado completo de longitud \(2M + 4N - 1\), o un vector macro de cualquiera de los dos esquemas, \(2N\) o \(3N\); un vector reducido que inicia el esquema completo fija \(r = w\). Cualquier otra longitud lanzaValueErrornombrando las tres esperadas.device/backend: los valores explícitos se mantienen;select_compute_devicecompleta el que falte. El par que acaba evaluando el jacobiano puede diferir del pedido (la política float64 de la sección 1.3);metadata["device"]/["backend"]registran el par efectivo ymetadata["requested_device"]/["requested_backend"]el pedido.solve_trade_equilibrium_mlxno tiene ninguno de los dos.batch_size/factor_equivalence: el selector de esquema de la sección 1.4. Prefierafactor_equivalence(None= elegir por exactitud,True= reducido \([\log w; T]\),False= completo \([\log r; \log w; T]\));batch_sizedebe valer \(2N\) o \(3N\) para la calibración.foreign_saving_units:"numeraire"(por defecto) o"world_income", el cierre de ahorro externo desolve_trade_equilibrium.tol: tolerancia absoluta en norma infinito, en las unidades de valor de la calibración, sobre todas las ecuaciones del sistema completo paraconverged;max_iter: iteraciones de Levenberg-Marquardt (max_iter_per_steppor paso de continuación).damping: \(\mu\) inicial.ad_mode:"finite_diff"(diferencias por lotes, por defecto),"forward"(AD en modo directo:torch.func.jacfwd/mx.jvp) o"vjp"(modo inverso:torch.func.jacrev/mx.vjp). Un modo AD que falla en el dispositivo avisa y recurre a diferencias finitas; un valor desconocido lanzaValueError.base_result: equilibrio base que usa el posprocesamiento para el IPC y los índices de términos de intercambio.verbose: imprime por iteraciónmax_res, el paso y \(\mu\) (y, en la homotopía, el registro de pasos).callback(solo homotopía):callback(lambda_value, result)tras cada paso con éxito.
6. Interfaz de resultados
TradeEquilibriumResult (solucionadores acelerados)
Los solucionadores acelerados devuelven la misma dataclass congelada que solve_trade_equilibrium, posprocesada y auditada por el mismo código, así que x_sol, p_sol, y_sol, r_sol, w_sol, T_sol, XN_sol, los tensores de flujos bilaterales, gdp, imports, exports, tariffs, cpi y terms_of_trade se documentan una sola vez en la página de contabilidad del comercio. Los campos que fijan los solucionadores de esta página son:
converged,iterations,max_residual,diff/residual_norm(norma L1) yresiduals: evaluados sobre el residuo del sistema completo en float64.metadata:method("gpu_accelerated"o"mlx_accelerated"), eldevicey elbackendefectivos junto conrequested_deviceyrequested_backend,factor_equivalence,batch_size,jacobian_evaluations(un recuento de jacobianos porbackend:device:dtype),ad_mode,tol,max_iter,macro_residual_max,solve_duration_secondsymemory_usage(el diccionario deget_memory_usage).summary(detailed=False) -> pd.DataFrame: la tabla de diagnóstico del solucionador;detailed=Trueda la tabla por país (PIB, comercio, aranceles, IPC, términos de intercambio).to_frame(detailed=False),to_markdown(detailed=False),to_latex(detailed=False),to_typst(detailed=False): la misma tabla en cada formato.TradeEquilibriumResultno tiene métodoplot; las figuras salen depuremacro.trade.plot.
AllenArkolakisResult (solucionador espacial)
No cambia con el backend: wages, population, price_index, real_wages, welfare, consumer_market_access, firm_market_access, trade_shares y, en contrafactuales, w_hat, L_hat, welfare_hat, welfare_pct; diagnósticos converged, iterations, max_residual, labor_conservation_residual, spatial_utility_variance; presentación summary(), to_frame(), to_markdown(), to_latex(), to_typst() y plot(kind="spatial"). Nada en el resultado registra qué backend lo produjo.
DeviceInfo y el diccionario de memoria
DeviceInfo es la dataclass congelada de la sección 1.2. get_memory_usage() devuelve {"backend", "device", "allocated_mb", "peak_mb"} más cache_mb (CUDA, MLX) o driver_mb (MPS); reset_peak_memory() reinicia los contadores de pico (CUDA, MLX) y vacía las cachés del dispositivo (CUDA, MPS, MLX), y device_context(...) lo llama al salir y entrega el par (backend, device) sin contrastar ambos, repitiendo las cadenas pasadas.
7. Advertencias y limitaciones
- Alcance del modelo. La capa de dispositivo resuelve el modelo de contabilidad consistente con insumos intermedios Cobb-Douglas (
sigma = 0) y devolución de suma fija de la recaudación arancelaria. Para insumos CES, otros cierres fiscales o la extensión de capacidad, usesolve_trade_equilibrium. - Dos capas, dos vocabularios. La capa de espacio de nombres acepta
"numpy","mlx","cupy"("numba"avisa y recurre a NumPy, y cualquier otra cosa,"torch"incluido, lanzaValueError: Unknown backend). La capa de dispositivo acepta"auto","numpy","cpu","cuda","cuda:N","mps","mlx","gpu"y lanzaValueErrorcon cualquier otra cosa. - Precisión. MPS no tiene float64 y el flujo Metal de MLX es float32, así que un dispositivo float32 pedido explícitamente solo se usa en la fase gruesa y avisa cuando se usa. Espere coincidencia al redondeo en las rutas float64 y un jacobiano inservible en una float32 (ejemplo 4.3).
- Tolerancias.
tol=2.5e-3en los solucionadores de comercio NumPy y acelerados es absoluta y holgada; ajústela explícitamente para trabajos de paridad. - Costo de importación.
import puremacro.tradeno carga PyTorch ni MLX; ambos se importan de forma perezosa en la primera llamada que los necesita, que paga la importación del marco. - Rutas no verificadas. La rama CuPy del solucionador espacial y la rama CUDA de la capa de dispositivo no se ejecutaron al escribir esta página (no hay hardware NVIDIA); las cubren la lógica de respaldo y pruebas que se omiten cuando falta el acelerador.
- Sin aceleración en problemas pequeños. Todas las rutas de dispositivo añaden latencia de transferencia y lanzamiento; la calibración de juguete y el modelo de 5 regiones corren más lento en un acelerador que en NumPy. Rendimiento y benchmarks publica por ahora solo tiempos en CPU.
- Sin conexión y determinista. Nada aquí toca la red; las rutas aceleradas son tan deterministas como las reducciones del dispositivo, y la ruta NumPy es la referencia para las afirmaciones de reproducibilidad.
Referencias
- 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.