Extending backends
July 15, 2026 · View on GitHub
This guide explains how to add a new training backend to Primus using the current runtime architecture. It complements the high-level picture in Primus overview: adapters sit under the unified CLI and configuration system (Configuration system), and each backend plugs in through the same lifecycle and hook points as Megatron-LM, TorchTitan, MaxText, and the other integrated stacks.
The runtime is built around:
BackendAdapter– integrates a backend frameworkBackendRegistry– discovers and instantiates adaptersBaseTrainer– defines the minimal training lifecycle that all backends followPrimusRuntime– orchestrates config loading, environment setup, patches, adapter, and trainer
The examples below use a minimal dummy backend as a template. The dummy files are illustrative and are not checked into this repository; existing backends such as Megatron, TorchTitan, MaxText, Megatron Bridge, and HummingbirdXT show the production pattern.
What happens when you run Primus?
When you run:
primus train pretrain --config <exp.yaml>
the runtime (PrimusRuntime) does roughly:
-
Load the experiment config—
load_primus_config()returns a lightweightSimpleNamespace(not aPrimusConfig)—and select themodule_config -
Apply CLI overrides to
module_config.params -
Initialize environment (HF, logging, distributed environment, data directory)
-
Resolve backend adapter via
BackendRegistry.get_adapter(framework) -
Call
adapter.setup_backend_path(...)to put the backend onsys.path -
Call
adapter.prepare_backend(module_config)(usually runs backend setup hooks) -
Build backend arguments:
backend_args = adapter.convert_config(module_config.params) # run "build_args" patches and merge back into module_config.params -
Load and construct the trainer:
TrainerClass = adapter.load_trainer_class(stage=module_config.params.stage or "pretrain") trainer = TrainerClass(backend_args=backend_args) -
Execute the trainer lifecycle (with patches around it). Backend version detection is lazy during patch handling through
adapter.detect_backend_version()rather than a separate pre-trainer step:# PrimusRuntime (the "build_args" patches from step 7 already ran # before the trainer was constructed): run_patches(phase="setup", backend_args=backend_args) trainer.setup() trainer.init() run_patches(phase="before_train", backend_args=backend_args) trainer.train() run_patches(phase="after_train", backend_args=backend_args) trainer.cleanup()
So a complete backend must provide:
- An adapter subclassing
BackendAdapter - A trainer subclassing
BaseTrainerand implementingsetup,init, andtrain(and optionally overridingcleanup, which has a default no-op implementation) - A small
primus.backends.<name>.__init__that callsBackendRegistry.register_adapter(...)
Minimal backend layout
Create a new backend folder under primus/backends/:
primus/backends/dummy/
├── __init__.py
├── dummy_adapter.py
└── dummy_pretrain_trainer.py
This mirrors the pattern used by existing backends (for example Megatron, TorchTitan).
Implement the adapter (BackendAdapter)
File: primus/backends/dummy/dummy_adapter.py
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from primus.core.backend.backend_adapter import BackendAdapter
from primus.core.backend.backend_registry import BackendRegistry
from primus.core.utils.module_utils import log_rank_0
class DummyAdapter(BackendAdapter):
"""Minimal adapter for a 'dummy' backend."""
def __init__(self, framework: str = "dummy"):
super().__init__(framework)
def setup_backend_path(self, backend_path=None) -> str:
"""
Dummy backend lives inside the Primus tree (no third_party submodule),
so we don't need to modify sys.path or resolve any external path here.
For real backends that live under third_party/<backend>, you can rely on
the default implementation in BackendAdapter instead.
"""
log_rank_0("[Primus:DummyAdapter] setup_backend_path: no-op for in-tree dummy backend")
return ""
def convert_config(self, params: Any) -> Any:
"""
Convert Primus module params → backend-specific args.
For a real backend you would build a structured args object. Here we
just wrap the incoming params in a SimpleNamespace.
"""
if isinstance(params, dict):
backend_args = SimpleNamespace(**params)
else:
backend_args = params
log_rank_0("[Primus:DummyAdapter] Converted Primus params -> dummy backend_args")
return backend_args
def detect_backend_version(self) -> str:
"""Return a version string used by patch filtering."""
return "dummy-0.1"
def load_trainer_class(self, stage: str = "pretrain"):
"""Return the Trainer class for the specified training stage."""
from primus.backends.dummy.dummy_pretrain_trainer import DummyPretrainTrainer
log_rank_0("[Primus:DummyAdapter] Loaded trainer class: DummyPretrainTrainer")
return DummyPretrainTrainer
Key points:
- Since the dummy backend is implemented directly under
primus.backends.dummy(not inthird_party/), it overridessetup_backend_path()as a no-op so that the default third-party path resolution is skipped. convert_config()returns whatever your trainer expects asbackend_args.load_trainer_class()imports and returnsDummyPretrainTrainerdirectly (similar toMegatronAdapter), without going through a registry lookup.
Implement a runnable trainer (BaseTrainer)
File: primus/backends/dummy/dummy_pretrain_trainer.py
from typing import Any
from primus.core.trainer.base_trainer import BaseTrainer
from primus.core.utils.module_utils import log_rank_0
class DummyPretrainTrainer(BaseTrainer):
"""Minimal runnable trainer for the dummy backend."""
def __init__(self, backend_args: Any):
# BaseTrainer stores backend_args and reads torchrun env (rank, world_size, etc.)
super().__init__(backend_args=backend_args)
self._initialized = False
def setup(self):
# Optional pre-init setup (e.g., logging, sanity checks)
log_rank_0(f"[DummyPretrainTrainer] setup() on rank={self.rank}")
def init(self):
# Build your model / optimizer / dataloader here in a real backend.
log_rank_0("[DummyPretrainTrainer] init()")
self._initialized = True
def train(self):
if not self._initialized:
raise RuntimeError("DummyPretrainTrainer.init() must be called before train().")
log_rank_0("[DummyPretrainTrainer] train()")
# Example: access a custom param (e.g., 'hello') from backend_args.
hello_value = getattr(self.backend_args, "hello", "<missing>")
log_rank_0(f"[DummyPretrainTrainer] hello={hello_value}")
# Real training loop would go here.
log_rank_0("[DummyPretrainTrainer] training finished successfully.")
def cleanup(self, on_error: bool = False):
# Optional cleanup logic (close files, finalize logging, etc.)
status = "error" if on_error else "success"
log_rank_0(f"[DummyPretrainTrainer] cleanup(on_error={status})")
Why this matches the core architecture:
BaseTrainer.__init__reads distributed environment fromget_torchrun_env().PrimusRuntimedrives the lifecycle:setup→init→train→cleanupand runs patch phases around these steps.- Your trainer only needs to implement
setup,init,train, andcleanupusingbackend_argsand the resolved environment information.
Register the adapter in BackendRegistry
File: primus/backends/dummy/__init__.py
from primus.backends.dummy.dummy_adapter import DummyAdapter
from primus.core.backend.backend_registry import BackendRegistry
# Register adapter (backend name → adapter class)
BackendRegistry.register_adapter("dummy", DummyAdapter)
At runtime, when framework: dummy is requested:
BackendRegistry.get_adapter("dummy")lazily importsprimus.backends.dummy(this file), which callsregister_adapter("dummy", DummyAdapter).- The adapter instance is created and used by
PrimusRuntimeto set up the backend path, run setup hooks, buildbackend_args, and load and construct the trainer.
Minimal config example
Create an experiment YAML (simplified; full template in the next section):
modules:
pre_trainer:
framework: dummy
config: dummy_trainer.yaml
model: dummy_8B.yaml
Run:
./primus-cli direct -- train pretrain --config examples/dummy/configs/dummy_8B-pretrain.yaml
Because this dummy backend is an in-tree template and setup_backend_path() is a no-op, you should see logs similar to:
[Primus:DummyAdapter] setup_backend_path: no-op for in-tree dummy backend[Primus:DummyAdapter] Converted Primus params -> dummy backend_args[DummyPretrainTrainer] setup()[DummyPretrainTrainer] init()[DummyPretrainTrainer] train()
Example end-to-end YAML configs
This template mirrors the Megatron pattern. Create these files only when you are actually adding a dummy backend for local development or tests:
- The top-level experiment config lives under
examples/<backend>/configs/... - The module config is resolved from
primus/configs/modules/{framework}/ - The model config is resolved from
primus/configs/models/{framework}/
Top-level experiment config
File 1: examples/dummy/configs/dummy_8B-pretrain.yaml
work_group: ${PRIMUS_TEAM:local}
user_name: ${PRIMUS_USER:local}
exp_name: ${PRIMUS_EXP_NAME:dummy_8B-pretrain}
workspace: ${PRIMUS_WORKSPACE:./output}
modules:
pre_trainer:
framework: dummy
config: dummy_trainer.yaml
# model to run
model: dummy_8B.yaml
overrides:
# log / debug
stderr_sink_level: DEBUG
# example training overrides (merged into module params)
train_iters: 100
global_batch_size: 32
micro_batch_size: 4
seq_length: 1024
hello: world
Module-level trainer config
File 2: primus/configs/modules/dummy/dummy_trainer.yaml
extends:
- trainer_base.yaml # optional, if you have a shared base; otherwise omit
train_iters: 1000
global_batch_size: 16
micro_batch_size: 1
seq_length: 512
log_interval: 1
save_interval: 100
This file defines default training hyperparameters for the dummy backend. Fields under modules.pre_trainer.overrides in the top-level config are deep-merged on top of these defaults.
Model-level config
File 3: primus/configs/models/dummy/dummy_8B.yaml
extends: []
model_name: dummy_8B
vocab_size: 32000
hidden_size: 4096
num_layers: 32
num_attention_heads: 32
This file plays the same role as Megatron model configs under primus/configs/models/megatron/. It is loaded via:
modules.pre_trainer.model: dummy_8B.yaml- resolved as
primus/configs/models/{framework}/dummy_8B.yaml
Running the example
Run:
./primus-cli direct -- train pretrain --config examples/dummy/configs/dummy_8B-pretrain.yaml
Primus will:
- load
examples/dummy/configs/dummy_8B-pretrain.yaml - resolve
modules.pre_trainer.config→primus/configs/modules/dummy/dummy_trainer.yaml - resolve
modules.pre_trainer.model→primus/configs/models/dummy/dummy_8B.yaml - build
module_config.paramsfrom these sources plusoverrides - call
DummyAdapter.convert_config(params)to buildbackend_args - construct
DummyPretrainTrainer(backend_args=...) - execute
setup→init→train→cleanup.
For adding model YAML for existing backends (Megatron, TorchTitan, and others), see Adding model configurations.
Checklist for a complete backend
Use this as a quick checklist when adding a new backend:
- Adapter subclass of
BackendAdapterimplements: -load_trainer_class(stage: str)-convert_config(params)-detect_backend_version()- (optionally) overridesprepare_backend()/third_party_dir_name - Trainer subclass of
BaseTrainerimplements: -setup(),init(),train(), and optionalcleanup(on_error: bool) -
BackendRegistry.register_adapter(backend, AdapterClass)is called inprimus.backends.<backend>.__init__ - At least one unit test is added under
tests/unit_tests/backends/
Once these are in place, your backend is fully integrated into the Primus runtime and follows the same lifecycle and patch phases as the built-in backends.
Advanced: Backend-specific setup with train hooks
For more advanced scenarios (for example installing extra Python packages or configuring backend-specific environment variables at runtime), you can use train hooks under runner/helpers/hooks.
- Hook locations for training:
- Global hooks (run for all commands):
runner/helpers/hooks/*.shandrunner/helpers/hooks/*.py. These are discovered withfind ... -maxdepth 1 \( -name "*.sh" -o -name "*.py" \)and executed in lexicographical order of their filenames (seerunner/helpers/execute_hooks.sh). - Command-specific hooks:
runner/helpers/hooks/train/pretrain/*.sh|*.py(and.../posttrain/...), discovered and ordered the same way. For pretrain, this directory contains the dispatcherprepare_experiment.sh. - Per-framework hooks:
runner/helpers/hooks/train/pretrain/<framework>/andrunner/helpers/hooks/train/posttrain/<framework>/, where<framework>ismegatron,torchtitan,dummy, and so on. These are not run directly byexecute_hooks; insteadprepare_experiment.shdetects the framework from the experiment config, runs that framework folder's*.shfiles in lexicographical order, and then invokes the framework'sprepare.pydispatcher.
- Global hooks (run for all commands):
When you run:
./primus-cli direct -- train pretrain --config <exp.yaml>
Primus will:
- Call
execute_hooks train pretrain ..., which:- Runs global hooks under
runner/helpers/hooks/(lexicographical order) - Then runs command-specific hooks under
runner/helpers/hooks/train/pretrain/, includingprepare_experiment.sh prepare_experiment.shresolves the framework from the config and runs the per-framework hooks underrunner/helpers/hooks/train/pretrain/<framework>/(its*.shfiles in lexicographical order, thenprepare.py)
- Runs global hooks under
Each hook script can emit control lines on stdout that Primus parses (the framework hooks' stdout is captured through prepare_experiment.sh):
-
env.*=value→ environment variables# inside runner/helpers/hooks/train/pretrain/<framework>/<something>.sh echo "env.MY_BACKEND_FLAG=1" # becomes: export MY_BACKEND_FLAG=1 echo "env.PYTHONPATH=/opt/mylib:$PYTHONPATH"These are exported into the environment of the
primus-cli directprocess, so downstream backend code and trainers see them. -
extra.*=value→ extra CLI arguments# inside the same hook echo "extra.backend_path=/opt/my-backend" # becomes: --backend_path /opt/my-backend echo "extra.train_data_path=/my/data" # becomes: --train_data_path /my/dataThese
extra.*pairs are appended to the Primus CLI invocation as--<name> <value>after hook execution.
Typical pattern to install or configure packages for a backend:
- Add a script under
runner/helpers/hooks/train/pretrain/<framework>/<NNN>-setup.sh(use a numeric prefix such as000-or010-to control ordering). - In that script:
- Optionally run
python -m pip install ...or other setup commands. - Emit
env.*=...lines to export any required environment variables. - Emit
extra.*=...lines if you need to pass additional CLI arguments (for examplebackend_path) into the Primus runtime for this run.
- Optionally run