Optimizers

Every training loop in this tutorial ends in the same line:

(model', optim') <- runStep model optim loss learningRate

This chapter is about what stands behind that line. Hasktorch ships two optimizer families with the same interface: purely functional optimizers written in Haskell, whose state is an ordinary value, and bindings to libtorch's C++ optimizers, which update parameters in place. They are interchangeable in the loop — we will run the exact same code with both — but they make opposite trade-offs.

import Data.Default.Class (def)
import GHC.Generics (Generic)
import Torch
import Torch.Optim.CppOptim

The interface

class Optimizer optimizer where
  step :: LearningRate -> Gradients -> [Tensor] -> optimizer -> ([Tensor], optimizer)
  runStep :: Parameterized model
          => model -> optimizer -> Loss -> LearningRate -> IO (model, optimizer)

step is the mathematical content: given gradients and parameters, produce new parameters and the optimizer's next state. runStep wraps it with the autograd call (grad') and the flattening of the model record into a parameter list — that is the line the training loops use.

A test problem hard enough to tell optimizers apart — the Rosenbrock banana function, whose minimum at ((1, 1)) hides at the end of a long, curved, nearly-flat valley:

data XY = XY {x :: Parameter, y :: Parameter}
  deriving (Generic, Show, Parameterized)

rosenbrock :: XY -> Tensor
rosenbrock XY {..} = (1 - x') ^ 2 + 100 * (y' - x' * x') ^ 2
  where
    x' = toDependent x
    y' = toDependent y

optimize :: Optimizer o => o -> LearningRate -> Int -> XY -> IO (XY, o)
optimize opt lr steps p0 = foldLoop (p0, opt) steps $
  \(p, o) _ -> runStep p o (rosenbrock p) lr

result :: XY -> Tensor
result XY {..} = stack (Dim 0) [toDependent x, toDependent y]

Optimizer state is a value

The Haskell-side optimizers live in Torch.Optim: GD, GDM (momentum), Adagrad, Adam, AdamW. Their state is a plain record — GD has none, GDM carries momentum tensors, Adam carries its two moment estimates and a step counter (built with mkAdam, which needs the parameter list to size those tensors):

p0 <- XY <$> makeIndependent (asTensor (-1.2 :: Float))
         <*> makeIndependent (asTensor (1.0 :: Float))
(gdP, _) <- optimize GD 1e-3 2000 p0
(adamP, adamState) <- optimize (mkAdam 0 0.9 0.999 (flattenParameters p0)) 2e-2 2000 p0

Starting from the classic ((-1.2, 1)), after 2000 steps each — gradient descent is still crawling along the valley floor:

result gdP => 
Tensor Float [2] [ 0.7207   ,  0.5180   ]

while Adam's per-coordinate step sizes have carried it to within a few percent of the minimum:

result adamP => 
Tensor Float [2] [ 0.9720   ,  0.9447   ]

Because the optimizer state is a value, there is nothing hidden to manage: checkpointing an optimizer is saving a record of tensors, inspecting it is pattern matching. Adam's internal step counter, for instance, is right there:

iter adamState => 
2000

The price is allocation: every step builds fresh parameter and state tensors and the old ones become garbage.

The C++ optimizers update in place

Torch.Optim.CppOptim binds libtorch's native optimizers — SGD, Adagrad, RMSprop, Adam, AdamW, and L-BFGS, which has no Haskell-side counterpart. Each is configured by an options record with a Data.Default instance, and initOptimizer registers the model's parameters with a C++ optimizer object:

p1 <- XY <$> makeIndependent (asTensor (-1.2 :: Float))
         <*> makeIndependent (asTensor (1.0 :: Float))
cppAdam <- initOptimizer (def {adamLr = 2e-2} :: AdamOptions) p1
(cppP, _) <- optimize cppAdam 0 2000 p1
result cppP => 
Tensor Float [2] [ 0.9719   ,  0.9445   ]

It lands on the same numbers as the pure Adam run above, up to floating-point noise — same algorithm, same trajectory.

Note what did not change: optimize is the same function, because CppOptimizerState has an Optimizer instance. Two differences hide in that call, though. The learning rate argument is ignored — the 0 above is a dummy, because a C++ optimizer's hyperparameters live in its options (change them mid-run with cppOptimizerSetLr). And the step is genuinely in-place: the parameter tensors now live inside the C++ optimizer, which overwrites them on every step. The model returned by runStep is rebuilt around those same tensors, so the old model value silently changes too — the underlying binding is honestly named unsafeStep. Keep only the newest model and this never bites; keep a history of models for comparison and it will. In exchange, a step allocates nothing new for parameters or state, which for large models means less garbage-collector pressure and a smaller resident set. (runStep also nudges the collector each step — performGC plus a malloc_trim — so that memory held by dead foreign tensors actually returns to the OS during training.)

Parameter groups

PyTorch training recipes routinely split parameters into groups — the classic case is AdamW with weight decay on the weights but not on biases and normalization parameters. The AdamW binding exposes this: initAdamwWithGroups takes the decayed and undecayed parameter lists separately, and per-group learning rates can be scheduled afterwards:

optim <- initAdamwWithGroups (def {adamwPgWeightDecay = 1e-2}) decayed undecayed
cppOptimizerSetGroupLr optim 0 (lr * warmupFactor)

This is the piece you need to port a modern transformer training recipe faithfully; the pure optimizers have no equivalent yet.

Choosing

Use the pure optimizers when you want the state visible — for teaching, for reproducibility experiments, for checkpointing as ordinary values, or with the typed API, where Torch.Typed.Optim provides typed GD/GDM/Adam. Use the C++ optimizers when throughput and memory matter, when you need L-BFGS or parameter groups, or when you are matching a PyTorch training run step-for-step (Torch.Typed.Optim.CppOptim wraps them for the typed API too). The training loop does not care which you picked — which is the point of runStep being a typeclass method.