Layer 4 — Backend Adapters¶
One module per backend under pipekit_train.adapters.*. Each
exports a single run(loop) → (trained_model_op, backend_info)
function. v0.1 ships the Equinox adapter as the reference; Lightning
and Keras are scaffolded.
Equinox (v0.1 reference)¶
pipekit_train.adapters.equinox. Built on the eqx_trainer design:
Grain DataLoader, Orbax CheckpointManager, Optax optimiser,
eqx.filter_jit train step, eqx.partition checkpoint bridge.
Extras: pipekit-train[equinox].
Module surface¶
# pipekit_train/adapters/equinox.py
class TrainState(eqx.Module):
"""Equinox-side training state. Stored inside TrainerCarryState.opt_state.
A nested-state design: TrainerCarryState (the pipekit-side carry
that everyone sees) holds an opaque opt_state field; for the
Equinox adapter, that field is itself a TrainState. The split
keeps the JAX-specific PyTree concerns inside the adapter and
the carrier-agnostic concerns inside the loop.
"""
model: eqx.Module
opt_state: optax.OptState
step: int
@staticmethod
def create(model: eqx.Module, optimizer: optax.GradientTransformation) -> "TrainState":
opt_state = optimizer.init(eqx.filter(model, eqx.is_array))
return TrainState(model=model, opt_state=opt_state, step=0)
@runtime_checkable
class TrainTask(Protocol):
"""User-defined Equinox training target. Mirrors eqx_trainer D4.
Provide `loss_fn`; optionally provide `eval_fn`. The adapter
synthesises a TrainTask from `loop.loss` if none is supplied.
"""
def loss_fn(
self,
model: eqx.Module,
batch: Any,
key: jax.Array,
) -> tuple[jax.Array, dict[str, jax.Array]]: ...
def eval_fn(
self,
model: eqx.Module,
batch: Any,
key: jax.Array,
) -> dict[str, jax.Array]: ...
@eqx.filter_jit
def train_step(
state: TrainState,
batch: Any,
key: jax.Array,
task: TrainTask,
optimizer: optax.GradientTransformation,
) -> tuple[TrainState, dict[str, jax.Array]]:
"""One Equinox optimisation step (see api/primitives.md)."""
def save_state(mngr: ocp.CheckpointManager, state: TrainState, step: int) -> None: ...
def restore_state(mngr: ocp.CheckpointManager, template: TrainState, step: int) -> TrainState: ...
def run(loop: TrainingLoop) -> tuple[Operator, dict[str, Any]]:
"""Train `loop` end-to-end with the Equinox+Optax+Grain+Orbax stack.
Returns:
trained_model_op: A pipekit_jax.JaxModelOp (or domain
subclass) wrapping the trained eqx.Module.
backend_info: Dict with keys: backend ("equinox"), jax_version,
equinox_version, optax_version, devices, total_seconds.
"""
What run does¶
- Resolve the optimizer.
optimizer_config = {"name": "adamw", "lr": 3e-4, ...}→optax.chain(optax.adamw(**kwargs), ...). Schedule support: iflris a dict like{"schedule": "cosine_decay", "init_value": 3e-4, "decay_steps": loop.max_steps}, build anoptax.cosine_decay_scheduleand nest the optimiser insideoptax.inject_hyperparams. - Resolve the task. If
loop.taskis set, use it. Otherwise synthesise:class SynthTask: def loss_fn(self, model, batch, key): pred = jax.vmap(model)(batch["x"]); l = loop.loss(pred, batch["y"]); return l, {"loss": l}. - Build the data iterator. Wrap
loop.datasetas agrain.MapDatasetsource if it isn't already one; add agrain.Batch(loop.batch_size)operation; wrap in agrain.DataLoader(worker_count=2, ...). Same forloop.val_dataset. - Set up the checkpoint manager. If
loop.checkpoint_dir, build anocp.CheckpointManagerwithsave_interval_steps=loop.callbacks[Checkpoint].every_n_stepsandmax_to_keep=loop.callbacks[Checkpoint].keep_last, using aCompositeCheckpointHandlerso the Grain iterator state checkpoints alongside the model. - Initialise
TrainStateandTrainerCarryState. Pull the model fromloop.model_op(it must be aJaxModelOpor a subclass exposing.modulereturning the bareeqx.Module). Build the carry-state with the right RNG split. - Resume. If checkpoint manager has a
latest_step, restoreTrainState+ Grain iterator state. - Run the per-step loop. For each step up to
loop.max_steps: calltrain_step; periodically log (log_every_n_steps), evaluate (eval_every_n_steps), checkpoint, run callbacks. - Build the trained
model_op. Wrap the finaleqx.Moduleback into aJaxModelOp(or the originalmodel_op's class) and return it together withbackend_info.
Sharding (multi-device / multi-host) — v0.2¶
Pass an optional ShardingSpec to enable data-parallel (and uniform
model-parallel) training (issue #15, ADR D12):
import jax
from jax.sharding import Mesh, PartitionSpec
from pipekit_train import TrainingLoop
from pipekit_train.adapters.equinox import ShardingSpec
mesh = Mesh(jax.devices(), axis_names=("data",))
spec = ShardingSpec(
mesh=mesh,
model_pspec=PartitionSpec(), # replicate the model on every device
data_pspec=PartitionSpec("data"), # shard each batch along its leading axis
)
loop = TrainingLoop(
model_op=model_op,
dataset=dataset,
loss=loss,
batch_size=256, # must be divisible by the "data" axis size
sharding=spec, # None => single-device (the default)
)
trained_op, artifact = loop.run()
How it threads through run:
TrainState— model + optimiser array leaves are placed onspec.model_sharding; the step counter is replicated.train_step— unchanged;eqx.filter_jitcompiles sharding-aware once its inputs are sharded (replicated model + adata-sharded batch give data-parallel SPMD, XLA inserting the collectives).- Data iterator — each batch is placed on
spec.data_sharding. For multi-host (an explicit spec andprocess_count > 1), each process reads a disjoint shard of thegrainMapDataset(map_ds[process_index::process_count], the modern equivalent ofgrain.ShardByJaxProcess) and batches the local sizebatch_size // process_count, so the per-process batches assemble viajax.make_array_from_process_local_datainto a global batch of exactlybatch_size. Without a spec the single-device path is preserved even under a distributed-initialised JAX. (batch_sizemust divideprocess_count; multi-host needs an indexable dataset, not a streaming one.) - Checkpointing — Orbax restore is sharding-aware automatically: the
restore template is the already-sharded
TrainState. backend_info["sharding"]records the mesh shape and partition specs for the artifact.
Verified on a CPU-simulated 2-device mesh (tests/adapters/
test_equinox_sharding.py) and a scripted 2-process jax.distributed smoke
(scripts/multihost_sharding_smoke.py).
Notes¶
sharding=None(the default) is the original single-host, single-device path, unchanged.- The adapter does not require the user to install
pipekit-jax. If it isn't installed, the adapter returns a minimalJaxModelOp-like wrapper defined inline (the wrapper just calls theeqx.Module's__call__). The fullpipekit-jax.JaxModelOpadds shape inference and weight-blob round-trip. - The Grain iterator is the only JAX-side data-loading dependency. PyTorch DataLoader is explicitly not used.
Lightning (v0.2 scaffold)¶
pipekit_train.adapters.lightning. Extras:
pipekit-train[lightning].
v0.1 ships the module with a single function that raises:
def run(loop: TrainingLoop) -> tuple[Operator, dict[str, Any]]:
raise NotImplementedError(
"The Lightning adapter is scheduled for v0.2. Use "
"backend='equinox' for v0.1, or pin pipekit-train when "
"v0.2 lands."
)
Planned design¶
When v0.2 lands, the adapter will:
- Synthesise a
LightningModulefromloop.model_op+loop.loss(or accept a user-suppliedtaskthat is itself aLightningModulefactory). - Synthesise a
LightningDataModulefromloop.dataset/loop.val_dataset. - Build a
Trainerwithmax_steps=loop.max_steps, mapping pipekit callbacks to Lightning'spl.Callbacksubclass surface (e.g.LogToExperiment → MLFlowLoggerorWandbLogger). - Call
trainer.fit(module, datamodule). - Wrap the final module's
nn.Moduleinpipekit-array.ModelOpand return.
The Lightning adapter inherits all of Lightning's distributed,
mixed-precision, and accumulation features without pipekit-train
adding any code — they're configured via optimizer_config or
extra_trainer_kwargs.
Keras (v0.3 scaffold)¶
pipekit_train.adapters.keras. Extras: pipekit-train[keras].
v0.1 ships with the same NotImplementedError stub.
Planned design¶
When v0.3 lands, the adapter will:
- Translate
loop.lossinto akeras.losses.Losssubclass (or accept the user's pre-builttf.keras.lossesinstance vialoop.task). - Configure the optimiser via
keras.optimizers.getusingoptimizer_config. - Wrap
loop.datasetas atf.data.Dataset.from_generator(...). - Call
model.compile(...)thenmodel.fit(...). - Wrap the trained
keras.Modelinpipekit-array.ModelOpand return.
Keras 3's multi-backend (TF / JAX / torch) means the Keras adapter gives a second JAX path orthogonal to the Equinox one — useful for projects that already standardise on Keras layers.
NumPyro (implemented) — two Bayesian backends: numpyro-svi + numpyro-mcmc¶
pipekit_train.adapters.numpyro_svi and …numpyro_mcmc. Extras:
pipekit-train[numpyro] (numpyro>=0.15, optax, grain; JAX via
NumPyro). See ADR D13 and the full design in
../numpyro_adapter.md.
"Training" here is Bayesian inference. NumPyro is split into two backends by paradigm (the per-step vs blocking loop shapes are too different to method-dispatch):
numpyro-svi(variational, per-step) —svi.updatereturns(state, loss)per step, so it is the per-step loop: callbacks /metric_writer/ eval / checkpoint /max_steps/_BatchSourceminibatching all apply, andSVI(optim=...)takes an optax transformation sooptimizer_configis reused.numpyro-mcmc(sampling, single call) — onemcmc.run(NUTS) over the full dataset (begin/end callbacks only; the per-step fields don't apply).
Both are task-first (D9): loop.task is one shared NumpyroTask (no
method field — the backend selects the paradigm, so the same task works
under either); the carrier-agnostic Loss (D4) does not apply. The artifact
is a NumpyroPredictiveOp (posterior-predictive Operator) from the shared
adapters.bayes seam (D7). They fill the benchmark ladder's rung-2
(numpyro-mcmc) and rung-4 (numpyro-svi); numpyro-mcmc is a sibling of
the BlackJAX sampler backend (below). Full design — user story, math, CS
background, API, examples, build order, references:
../numpyro_adapter.md.
BlackJAX (planned) — sampler-library backend¶
pipekit_train.adapters.blackjax. Extras: pipekit-train[blackjax]
(blackjax>=1.2, optax, grain; numpyro only for the NumPyro-model
task path). See ADR D14 and the full design in
../blackjax_adapter.md.
Separate from NumPyro, by design. BlackJAX is a sampler library, not a
PPL: it operates on a logdensity_fn, and is PPL-agnostic. So it is its own
backend (different tool / extra / contract) — but it interoperates: a
BlackjaxTask accepts a raw logdensity_fn or a NumpyroTask (bridged
via numpyro.infer.util.initialize_model), and both adapters share the
model→logdensity + Predictive seam (adapters.bayes). Net UX: write a
NumPyro model once, flip backend="numpyro-mcmc" ↔ "blackjax".
Why a separate adapter pays off: BlackJAX's kernel.step(rng, state) ->
(state, info) is one sample per step, so it reuses the per-step loop
(per-sample diagnostics / checkpoint / early-stop) — a cleaner fit than
NumPyro's blocking mcmc.run. It also unlocks an algorithm zoo NumPyro
lacks: MCLMC, stochastic-gradient MCMC (sgld/sghmc — minibatch MCMC
via _BatchSource), tempered SMC, and pathfinder/svgd VI. Full design:
../blackjax_adapter.md.
Adapter selection¶
TrainingLoop.run() selects the adapter by string:
loop = TrainingLoop(..., backend="equinox") # uses adapters.equinox
loop = TrainingLoop(..., backend="lightning") # uses adapters.lightning
loop = TrainingLoop(..., backend="keras") # uses adapters.keras
loop = TrainingLoop(..., backend="numpyro-svi") # adapters.numpyro_svi (planned)
loop = TrainingLoop(..., backend="numpyro-mcmc") # adapters.numpyro_mcmc (planned)
loop = TrainingLoop(..., backend="blackjax") # adapters.blackjax (planned)
The selection happens at run() time, not at construction, so a
TrainingLoop YAML can be authored with backend="lightning" even
on a machine that hasn't installed [lightning] — the missing extra
is reported at run time only.