Work · Scale & deploy
Scale & deploy¶
A ResDAG model is a standard torch.nn.Module: it moves to the GPU,
checkpoints, and embeds in a larger network like any other module.
The reservoir-specific part is knowing when each of those is worth doing.
GPU¶
import resdag as rd
model = rd.ott_esn(reservoir_size=3000, feedback_size=3, output_size=3).to("cuda")
x = x.to("cuda")
rd.ESNTrainer(model).fit((warmup.cuda(),), (train.cuda(),), {"output": target.cuda()})
preds = model.forecast(f_warmup.cuda(), horizon=1000) # stays on cuda
.to("cuda") moves parameters and buffers as usual. The live reservoir
state needs no handling of its own — it follows the data, re-initializing
on the next forward pass whenever the incoming batch's size, device, or
dtype changes. One consequence: a warmed-up state does not survive a
device change, so move the model first and warm up afterward.
When the GPU helps. The reservoir loop is sequential in time, so each timestep is one small kernel launch; the GPU only wins once those kernels carry real work. In practice:
- Wins from ~2k units or batched trajectories. Speedups grow with
batch and reservoir size —
fit()reaches up to ~20× at batch 16 with 3000-unit reservoirs, where the readout's Gram formation becomes heavy GPU work. - Tiny configs are launch-bound. A single trajectory through a 1000-unit reservoir runs at CPU parity or slower; the GPU mostly waits.
- As a rule of thumb, large batches, large reservoirs, or many models
favor the GPU; a single small model runs as fast or faster on CPU.
Measure your own configuration with
examples/11_gpu_benchmark.py.
Why fit() stays fast on CUDA
The direct ridge readouts (RidgeReadoutLayer, the default) and
CGReadoutLayer share a gram_dtype policy that is automatic: the heavy
Gram-matrix matmuls run in float64 on CPU (cheap there) but in the input
dtype on CUDA — consumer GPUs run float64 at 1/32–1/64 throughput, which is
why ESN training has often measured slower on GPU. Pass
gram_dtype=torch.float64 only for badly scaled states (e.g.
unnormalized inputs concatenated into the readout); the better fix is
normalizing the data.
Long horizons: compile the step¶
The autoregressive forecast loop is sequential in time — one small kernel
per step — so on the GPU a long rollout spends most of its wall-clock in
Python and kernel-launch overhead, not arithmetic. forecast(compile=True)
wraps the flat single-step engine in torch.compile (mode="reduce-overhead",
fullgraph=True), fusing the per-step work into one graph and cutting that
overhead:
The default eager path is already flat and hook-free (bit-for-bit identical
whether or not you compile), so compile=True is a pure opt-in speedup and
transparently falls back to eager on torch < 2.10 or a compilation failure
(emitting a RuntimeWarning). It pays off for long horizons on the GPU;
for short rollouts or on CPU the compile warmup can cost more than it saves, so
leave it off. It is mutually exclusive with the differentiable
no_grad=False rollout (Train · Differentiable
forecast) —
requesting both raises.
Compiling the teacher-forced pass too
Warmup and training run the teacher-forced sequence loop, which also
accepts an opt-in compile=True: reservoir(feedback, compile=True)
routes it through a compiled chunked scan (same reduce-overhead mode,
same eager fallback). Worth it for very long teacher-forced passes on the
GPU; the default is byte-for-byte the eager loop.
Persistence¶
There are two ways to persist a model. State-dict (save/load)
stores weights only — compact and the safe choice for long-term archival
— but the architecture is not serialized, so you keep a build
function and load into a fresh instance:
def build(): # same factory call = same architecture
return rd.ott_esn(reservoir_size=500, feedback_size=3, output_size=3)
model.save("model.pt") # weights + metadata
restored = rd.ESNModel.load_from_file("model.pt", model=build())
model.save("ckpt.pt", include_states=True, epoch=10, val_mse=0.012)
restored.load("ckpt.pt", load_states=True) # weights + live states
include_states=True makes checkpoints resumable mid-sequence: warm up,
save, and a later process can load(..., load_states=True) and call
forecast(..., reset=False) to continue exactly where this one stopped.
Metadata is a plain torch.load payload — inspectable without building
the model.
Whole-model (save_full/load_full) serializes everything — the
graph, weights, and reservoir states — in one file, so there is no build
function to keep in sync:
model.save_full("model_full.pt", epoch=10) # everything
restored = rd.ESNModel.load_full("model_full.pt") # no rebuild needed
restored.forecast(f_warmup, horizon=1000)
This rides on the pickling support added in pytorch-symbolic 1.2.
load_full unpickles arbitrary objects (weights_only=False), so only
open files you trust — for sharing weights publicly, prefer the state-dict
form. One constraint: any custom callable you pass as a topology,
*_initializer, or activation spec must be importable (a module-level
def, not a lambda) for the model to pickle; string, tuple, and
registered-object specs always work. CoupledEnsembleESNModel exposes the
same save_full/load_full pair.
Inside a larger pipeline¶
A frozen reservoir is a feature extractor with zero trainable parameters
— sum(p.numel() for p in model.parameters() if p.requires_grad) is 0 by
design — so optimizers, gradient bookkeeping, and checkpoint diffs all
treat it as a constant. Wrap it like any frozen backbone:
import torch
import torch.nn as nn
import resdag as rd
class ReservoirClassifier(nn.Module):
"""Frozen reservoir features, gradient-trained head."""
def __init__(self, n_classes: int):
super().__init__()
self.features = rd.headless_esn(reservoir_size=500, feedback_size=3)
self.head = nn.Sequential(
nn.Linear(500, 128), nn.ReLU(), nn.Linear(128, n_classes)
)
def forward(self, x: torch.Tensor) -> torch.Tensor: # (batch, time, 3)
self.features.reset_reservoirs() # independent sequences
states = self.features(x) # (batch, time, 500)
return self.head(states[:, -1]) # classify on last state
clf = ReservoirClassifier(n_classes=4)
opt = torch.optim.Adam(clf.head.parameters(), lr=1e-3)
for xb, yb in loader: # (B, T, 3), (B,)
loss = nn.functional.cross_entropy(clf(xb), yb)
opt.zero_grad()
loss.backward()
opt.step()
Gradients flow through the reservoir to anything upstream; nothing
inside it moves. Between forward calls the stored state is detached, so
this loop needs no special handling for autograd. To fine-tune a readout
instead of adding a separate head, use the combination pattern from
Train: build it with trainable=True, solve algebraically,
then continue with a small learning rate.
One operational note: reservoir layers carry mutable state, so a model
instance is not thread-safe — give each thread its own model.
CoupledEnsembleESNModel.fit(n_workers=...) parallelizes exactly this
way, one sub-model per thread.
See also¶
- Build · Architectures — premade model factories
- Reference · Core —
save,load,load_from_filein full