composer-replication-framework / docs /INTEGRATION_RECIPES.md
Codeseys's picture
Wave 16: install ergonomics + gradient evidence + SDPO end-to-end example
c0a5ab7
# INTEGRATION_RECIPES.md — Wiring the 3-channel composer loss into your RL stack
> **Status:** Wave 14 release reference. Supersedes the historical
> [`docs/INTEGRATION_ARCHITECTURE.md`](INTEGRATION_ARCHITECTURE.md) (Recipes
> A–D), which is retained as background reading for the original
> mechanism-level diagrams.
>
> **Companion docs:**
> - [`docs/USER_GUIDE.md`](USER_GUIDE.md) — narrative walk-through, sections 1–8
> - [`docs/API_REFERENCE.md`](API_REFERENCE.md) — exact kwarg signatures
> - [`docs/TROUBLESHOOTING.md`](TROUBLESHOOTING.md) — error → fix index
> - [`docs/V3_SUBSTRATE_COVERAGE.md`](V3_SUBSTRATE_COVERAGE.md) — what each
> substrate covers
> - [`docs/adrs/ADR-006-rl-frameworks.md`](adrs/ADR-006-rl-frameworks.md) —
> why these five recipes and not others
This document is the canonical answer to **"how do I plug the 3-channel
composer loss into framework X?"** for the five frameworks the project
supports as of Wave 14:
1. [TRL `GRPOTrainer` subclass](#recipe-1--trl-grpotrainer-subclass)
2. [VeRL custom `adv_estimator` + DataProto extension](#recipe-2--verl-custom-adv_estimator--dataproto-extension)
3. [PRIME-RL custom-loss config](#recipe-3--prime-rl-customlossconfig)
4. [Serverless Decoupled DiLoCo (Modal / HF Jobs / SageMaker)](#recipe-4--serverless-decoupled-diloco)
5. [Monarch actor mesh (TorchForge-style topology)](#recipe-5--monarch-actor-mesh)
Each recipe follows the same seven-part template:
1. **When to use it** — decision criteria.
2. **Install command** — which optional extras of `composer-replication`.
3. **Minimum-viable Python script** — copy-pasteable, ≤ 60 lines.
4. **Decoupled DiLoCo wiring** — how `ServerlessExecutor` +
`ObjectStoreAllReduce` + `MockManager` layer on top.
5. **Distillation-loss wiring** — how to switch DPO → SimPO and add TAID
via `compose_loss(..., dpo_variant=..., sdpo_wrapper=...)` or the
recipe's own loss-config field.
6. **Cost ballpark** — GPU $/hr + API spend, sourced from
[`docs/research/DILOCO_SERVERLESS_RECONNAISSANCE.md`](research/DILOCO_SERVERLESS_RECONNAISSANCE.md).
7. **Known limitations as of Wave 14**.
A cross-recipe [comparison matrix](#comparison-matrix) closes the doc.
## TL;DR — the unified loss
For any of the five recipes, the v0.1 trainer step computes:
```
total_loss = grpo_loss
+ α * sdpo_kl_loss (channel 2 — Composer hint-distill;
optional TAID or Entropy-OPD wrapper)
+ β * trace_replay_loss (channel 3 — N-teacher DPO;
switchable to SimPO)
```
This is implemented once, in
[`composer_replication/loss.py::compose_loss`](../composer_replication/loss.py),
and re-used by every recipe via the kwargs documented in
[`API_REFERENCE.md`](API_REFERENCE.md). The full signature — including
all ADR-007 channel-2/3 knobs (`dpo_variant`, `sdpo_wrapper`, `taid_t`,
`simpo_beta`/`simpo_gamma`, `entropy_opd_h_max`, …) — is the
single source of truth in
[API_REFERENCE.md § `compose_loss`](API_REFERENCE.md#compose_loss).
The conceptual call shape is just:
```python
compose_loss(model, inputs, **kwargs) # see API_REFERENCE.md#compose_loss for full signature
```
All five recipes below either call `compose_loss` directly or call a
thin per-framework adapter that forwards these kwargs unchanged. Each
recipe's **§5 Distillation-loss wiring** documents the kwargs *that
recipe* uses by default and why; refer back to API_REFERENCE.md for
defaults, types, and which kwargs are mutually exclusive.
---
## Recipe 1 — TRL `GRPOTrainer` subclass
### 1. When to use it
This is the **default v0.0/v0.1 path** and the one we recommend for
~99% of users today. Pick TRL when:
- Your model fits on ≤ 32 GPUs (typically ≤ 70B-param FSDP).
- You already have a HuggingFace `model` + `tokenizer` + `datasets` flow.
- You want minimum integration cost — `ComposerReplicationTrainer` is a
single subclass override of `_compute_loss` over `trl.GRPOTrainer`,
no Ray, no actor mesh.
- You're doing single-host (one node, possibly multi-GPU FSDP) training.
Don't pick TRL when you need >100 B-param scale, when you must async-decouple
tool calls from the GPU loop, or when a Ray cluster is already in your stack
(in which case Recipe 2 is cheaper).
### 2. Install command
```bash
pip install -e ".[train,replaysim]"
```
The `train` extra pulls `trl>=0.12`, `peft`, `accelerate`, and `datasets`.
The `replaysim` extra pulls `data-juicer` for CPU-side DPO normalization
(channel 3 cleaning step). Add `[serverless]` if you also want Decoupled
DiLoCo (see step 4).
### 3. Minimum-viable Python script
```python
# train_trl.py — minimum viable Recipe 1
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from composer_replication import ComposerReplicationTrainer
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" # swap for 7B once it works
model = AutoModelForCausalLM.from_pretrained(MODEL_ID)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
dataset = load_dataset("trl-lib/tldr", split="train[:512]")
def reward_length(completions, **_):
return [-abs(len(c) - 64) for c in completions]
trainer = ComposerReplicationTrainer(
model = model,
processing_class = tokenizer,
reward_funcs = [reward_length],
train_dataset = dataset,
# Composer extras (defaults shown):
alpha_sdpo = 0.1,
beta_replay = 0.05,
sdpo_jsd_beta = 0.5,
sdpo_temperature = 1.0,
sdpo_token_clip = None,
replay_dpo_beta = 0.1,
)
trainer.train()
```
Channels 2 and 3 **auto-disable per step** when their inputs aren't
present in the batch (e.g. batches with no error sites get
`sdpo_kl=0`). Set `alpha_sdpo=0` / `beta_replay=0` to disable globally
for ablations.
### 4. Decoupled DiLoCo wiring
`ComposerReplicationTrainer` is a single-process trainer. To run N
replicas of it under Decoupled DiLoCo, layer the serverless stack on the
outside: each replica runs the script above; `MockManager` stands in for
`torchft.Manager` on the inner loop and `ObjectStoreAllReduce` runs the
outer-loop pseudo-gradient exchange:
```python
# diloco_replica.py — what each of the N replicas runs
import os
from composer_replication.diloco import make_diloco_outer_loop
from composer_replication.diloco.serverless import (
LocalProcessExecutor, ObjectStoreAllReduce, MockManager,
)
rendezvous = ObjectStoreAllReduce(
uri = "s3://my-bucket/diloco-runs/run42/",
world_size = 4,
rank = int(os.environ["REPLICA_RANK"]),
)
manager = MockManager(allreduce=rendezvous)
# trainer.optimizer is the *inner* optimizer; the outer is built here:
outer = make_diloco_outer_loop(
inner_optimizer = trainer.optimizer,
manager = manager,
sync_every_h = 500,
)
trainer.add_callback(outer.callback()) # syncs every H inner steps
trainer.train()
```
The driver process spins these up with any `ServerlessExecutor`:
```python
# Wave 14: ModalExecutor / HFJobsExecutor are skeletons (raise NotImplementedError);
# use LocalProcessExecutor for testing. Swap once the cloud backends land.
executor = LocalProcessExecutor()
handles = executor.launch_replicas(
n_replicas = 4,
entrypoint = "diloco_replica.py",
entrypoint_args = {"rendezvous": rendezvous.uri,
"rank_env": "REPLICA_RANK"},
)
result = executor.collect(handles, timeout=3600)
```
### 5. Distillation-loss wiring
`ComposerReplicationTrainer` exposes the new ADR-007 channels via the
shared `compose_loss` kwargs — pass them through `**kwargs` on the
trainer and they're forwarded to `compose_loss`:
```python
trainer = ComposerReplicationTrainer(
model = model, processing_class = tokenizer,
reward_funcs = [reward_length], train_dataset = dataset,
# SimPO instead of DPO for channel 3:
dpo_variant = "simpo",
simpo_beta = 2.0,
simpo_gamma = 1.0,
# TAID for channel 2 (SakanaAI port; logit-space mix + forward-KL):
sdpo_wrapper = "taid",
taid_t = 0.4, # current TAID coeff in [0, 1];
# drive from TAIDScheduler if you want
# the paper's adaptive scheme
)
```
Or, equivalently, drop `entropy_opd` in for `taid` if you want
per-token entropy-gated forward/reverse KL instead of the
linear-blend interpolation. SimPO does **not** require reference
log-probs (channel 3 batches with `dpo_chosen_ref_logprobs` /
`dpo_rejected_ref_logprobs` set are silently ignored).
### 6. Cost ballpark
- **GPU**: single host, `g5.12xlarge` ($5.67/hr) or RunPod 4×A100-80GB
(~$5–9/hr) gets you Qwen2.5-7B at moderate throughput. For Qwen2.5-72B
you'll want 2–4× H100 — `p5.48xlarge` (~$98/hr on AWS, ~$25–30/hr on
Lambda Cloud / RunPod community).
- **API**: channel 3 teacher replay via OpenRouter — verified
~$0.98/trace at 50 steps × 3 teachers (spike 001). For a 100-trace
curriculum that's ~$100 in teacher tokens.
- **Storage**: negligible until you turn on DiLoCo (then see Recipe 4).
### 7. Known limitations as of Wave 14
- **Tool calls block the GPU.** TRL's rollout is synchronous; long
tool-call latency idles the trainer. Async-decouple via Recipe 2/3/5
if this matters.
- **No native multi-node.** TRL is single-process; multi-host scaling is
via Decoupled DiLoCo (Recipe 4) on top, not via TRL itself.
- **vLLM weight sync is co-located** — no resharding between FSDP and TP.
At 70B+ this becomes the bottleneck and you should move to Recipe 2.
- **`reward_funcs` must be Python callables** that return `list[float]`;
shell-out reward graders need a wrapper.
---
## Recipe 2 — VeRL custom `adv_estimator` + DataProto extension
### 1. When to use it
Pick VeRL when:
- You need >70B-param scale or >32-GPU multi-host, *and* a Ray cluster
is acceptable in your stack.
- You're already using or willing to adopt **3D-HybridEngine** for
efficient FSDP↔TP weight resharding (verified ~5× weight-sync speed-up
vs co-located vLLM at 70B+).
- You need async multi-turn rollouts where tool-call latency must not
block the GPU loop. VeRL's `AsyncServer` + `AgentLoop` is the
best-in-class option here.
- You want extension points the framework's authors *expect* third
parties to use — the `@register_adv_est("...")` decorator and the
`DataProto` extension contract are first-class APIs.
Don't pick VeRL if you're <7B-param or single-host (overkill —
Recipe 1's Trainer subclass is one file, not a Ray cluster).
### 2. Install command
```bash
pip install -e ".[replaysim]"
pip install verl # not packaged as an extra; pinned at >=0.3
# Optional, for the Composer adapter:
pip install -e ".[serverless]" # for Decoupled DiLoCo on top
```
The framework's verl adapter lives at
`composer_replication.recipes.verl` (currently shape-only — see
[Limitations](#7-known-limitations-as-of-wave-14-2) below).
### 3. Minimum-viable Python script
VeRL's actual entry point is a Hydra/YAML config + `verl.trainer.main_ppo`
CLI; the pythonic surface looks like this:
```python
# train_verl.py — minimum viable Recipe 2 sketch
from verl.trainer.ppo import core_algos
from verl.trainer.ppo.ray_trainer import RayPPOTrainer
from composer_replication.loss import compose_loss
@core_algos.register_adv_est("grpo_composer")
def composer_advantage(data, **kwargs):
"""Custom adv-estimator that adds SDPO + DPO channels to GRPO.
Reads three extra DataProto keys (populated by the data prep step):
- data.batch["sdpo_teacher_logits"] (channel 2)
- data.non_tensor_batch["teacher_actions"] (channel 3)
and returns the standard (advantages, returns) tuple plus a stashed
composer-loss term consumed by the critic worker.
"""
advantages, returns = core_algos.compute_grpo_outcome_advantage(data, **kwargs)
composer_term = compose_loss(
model = kwargs["actor_module"],
inputs = data.batch,
alpha_sdpo = 0.1,
beta_replay = 0.05,
dpo_variant = "dpo",
sdpo_wrapper = "none",
)
data.meta_info["composer_loss"] = composer_term
return advantages, returns
# Then in your YAML:
# algorithm:
# adv_estimator: grpo_composer
# and run: python -m verl.trainer.main_ppo --config-name composer_grpo
```
The full driver wires `RayPPOTrainer` against your config; consult VeRL's
own quickstart for the Ray-cluster boilerplate. The composer-specific
piece is just the registered estimator above.
### 4. Decoupled DiLoCo wiring
VeRL's actor workers run in Ray; DiLoCo replicates the **whole VeRL job**.
Each "replica" is one Ray cluster running Recipe 2 end-to-end; the outer
loop is independent of Ray and just exchanges pseudo-gradients via the
object store between Ray-job invocations:
```python
from composer_replication.diloco.serverless import (
LocalProcessExecutor, ObjectStoreAllReduce,
)
rendezvous = ObjectStoreAllReduce(
uri = "s3://verl-diloco/run/",
world_size = 4,
)
executor = LocalProcessExecutor() # Wave 14: ModalExecutor is a skeleton (raises NotImplementedError) — keep LocalProcessExecutor for now
handles = executor.launch_replicas(
n_replicas = 4,
entrypoint = "verl.trainer.main_ppo",
entrypoint_args = {
"+algorithm.adv_estimator": "grpo_composer",
"+algorithm.diloco.rendezvous": rendezvous.uri,
"+algorithm.diloco.sync_every_h": 500,
},
)
executor.collect(handles, timeout=24 * 3600)
```
The Ray cluster inside each replica handles intra-replica scaling
(FSDP / TP / vLLM); the object-store exchange handles cross-replica
sync. Bandwidth is identical to Recipe 1 (~2 GB / 30 min per replica
for a 7B-param model in bf16) and well within S3 free-tier.
### 5. Distillation-loss wiring
The custom `adv_estimator` from step 3 already calls `compose_loss`;
flip the kwargs there to switch DPO → SimPO or add TAID:
```python
composer_term = compose_loss(
model = kwargs["actor_module"],
inputs = data.batch,
alpha_sdpo = 0.1,
beta_replay = 0.05,
dpo_variant = "simpo", # ← SimPO swap
simpo_beta = 2.0,
simpo_gamma = 1.0,
sdpo_wrapper = "taid", # ← TAID wrap
taid_schedule_step = data.meta_info.get("global_step", 0),
taid_total_steps = 10_000,
)
```
VeRL's `data.meta_info` carries the global step automatically, which is
exactly what TAID's interpolation schedule needs. Channel 2 batches
without `student_init_logits` / `student_init_input_ids` are auto-skipped
(returns 0 for that step).
### 6. Cost ballpark
- **GPU**: 8× H100 (`p5.48xlarge` ~$98/hr on AWS, ~$25/hr on Lambda or
RunPod community) is the entry point for 70B-class. Expect 32–256
H100 for full 671B (matches DeepSeek's reported VeRL config).
- **API**: same ~$0.98/trace as Recipe 1 (channel 3 is a Python helper,
not a VeRL primitive — costs are framework-independent).
- **Ray cluster overhead**: head node + redis + dashboard adds ~1
CPU-instance ($0.10–0.50/hr) per cluster, negligible at GPU scale.
### 7. Known limitations as of Wave 14
- **`composer_replication.recipes.verl` is shape-only.** The decorator
registration and DataProto extension are documented but not yet shipped
as a runnable adapter — Wave 14 release exposes the *contract*, not the
glue. Expect this to land in a v0.2 follow-up spike.
- **Ray dependency.** Adds a heavyweight runtime; debugging
cross-actor crashes can be painful. Use VeRL's `--debug` mode early.
- **Custom-`adv_estimator` LOC**: writing your own takes ~50–150 LOC
including DataProto plumbing. Not a one-liner.
- **No first-class TAID hook in VeRL itself** — we route TAID through
the meta_info channel; this works but means you can't use VeRL's
built-in checkpoint-replay tooling without re-stamping `taid_schedule_step`
on each replay.
---
## Recipe 3 — PRIME-RL `CustomLossConfig`
### 1. When to use it
Pick PRIME-RL when:
- You're operating in the **PRIME-Intellect / decentralized training**
universe and want INTELLECT-style scaling on a long-horizon training
run.
- You need **DPPO importance-ratio masking** (the rationale most users
arrive with) — PRIME-RL's headline contribution is the
out-of-band-token *mask* (not clip) on `log_ratio = trainer_lp -
inference_lp`, with defaults `low=-4.0, high=4.0`.
- You want a **first-class custom-loss surface**: PRIME-RL ships
`CustomLossConfig` that takes an importable Python function and a
`LossInputs` struct exposing exactly the tensors we need
(`trainer_logprobs`, `inference_logprobs`, `teacher_logprobs`,
`advantages`, `loss_mask`). No fork, no Trainer subclass, no monkey-patch.
- You have access to multi-node infrastructure that PRIME-RL's
trainer/inference/orchestrator split is designed for.
Don't pick PRIME-RL if you need full vocab logits (channel 2 SDPO
requires logits not log-probs — see Limitations).
### 2. Install command
```bash
pip install -e ".[prime-rl,replaysim]"
# pulls prime-rl>=0.5
```
### 3. Minimum-viable Python script
PRIME-RL drives via YAML config; the only Python you write is the
custom-loss function (already shipped at
`composer_replication/recipes/prime_rl/composer_loss.py`). Wire it in:
```yaml
# prime_rl_config.yaml — point at the framework's adapter
loss:
custom:
import_path: composer_replication.recipes.prime_rl.composer_loss:loss_fn
kwargs:
alpha_sdpo: 0.0 # channel 2 deferred in v0 (see below)
beta_dpo: 0.0 # channel 3 emits a warning if non-zero
dppo_mask_high: 4.0 # PRIME-RL DPPO mask bounds
dppo_mask_low: -4.0
epsilon: 1.0e-6
trainer:
model: Qwen/Qwen2.5-7B-Instruct
... # standard PRIME-RL fields
```
The shipped `loss_fn` signature is fixed by PRIME-RL's contract:
```python
def loss_fn(
inputs: LossInputs,
*,
alpha_sdpo: float = 0.0,
beta_dpo: float = 0.0,
dppo_mask_high: float = 4.0,
dppo_mask_low: float = -4.0,
epsilon: float = 1e-6,
) -> torch.Tensor:
log_ratio = inputs.trainer_logprobs - inputs.inference_logprobs
dppo_invalid = (log_ratio > dppo_mask_high) | (log_ratio < dppo_mask_low)
keep_mask = inputs.loss_mask & ~dppo_invalid
grpo = -(inputs.advantages * inputs.trainer_logprobs * keep_mask).sum() \
/ keep_mask.sum().clamp_min(epsilon)
if alpha_sdpo != 0.0:
raise NotImplementedError(
"Channel 2 SDPO requires full-vocab logits; PRIME-RL v0.5 "
"exposes only log-probs. Deferred to v0.2."
)
if beta_dpo != 0.0:
import warnings; warnings.warn(
"Channel 3 trace-replay DPO is out-of-scope for PRIME-RL recipe v0",
stacklevel=2,
)
return grpo
```
**Shape note** (caught in the Wave 13 cross-model review): PRIME-RL
calls the loss function **once per sample**; tensors are 1-D `(seq,)`,
*not* batched `(B, T)`. The 10 unit tests in
`composer_replication/recipes/prime_rl/tests/test_composer_loss.py`
cover this plus DPPO mask edges.
### 4. Decoupled DiLoCo wiring
PRIME-RL was designed for decentralized training and ships its own
weight-sync primitives. Stack DiLoCo on top via the
`ServerlessExecutor` Protocol — each replica runs an independent
PRIME-RL job pointing at the same `composer_loss:loss_fn`:
```python
from composer_replication.diloco.serverless import (
LocalProcessExecutor, ObjectStoreAllReduce,
)
rendezvous = ObjectStoreAllReduce(
uri = "s3://prime-rl-diloco/run/",
world_size = 4,
)
# Wave 14: ModalExecutor is a skeleton (raises NotImplementedError until v0.x).
# Use LocalProcessExecutor for the inner-replica wiring; swap to the cloud
# executor once it lands. The DiLoCo + rendezvous code below is identical.
executor = LocalProcessExecutor()
handles = executor.launch_replicas(
n_replicas = 4,
entrypoint = "prime_rl.cli:main",
entrypoint_args = {
"config": "prime_rl_config.yaml",
"+diloco.rendezvous": rendezvous.uri,
"+diloco.sync_every_h": 500,
},
)
executor.collect(handles, timeout=24 * 3600)
```
Note PRIME-RL's own multi-node story (the trainer / inference /
orchestrator split) is **orthogonal** to Decoupled DiLoCo: PRIME-RL
multi-node = single replica scaled across many GPUs; DiLoCo = N
independent replicas synchronizing via object store. Combine both for
"big PRIME-RL job × N replicas".
### 5. Distillation-loss wiring
Channel 2 (SDPO + TAID + Entropy-OPD) is **deferred** in v0 because
PRIME-RL's `LossInputs` exposes log-probs not full vocab logits. The
SimPO swap on channel 3 is also gated by the same shape constraint, but
DPPO-clip itself doesn't change. To get TAID/SimPO into a PRIME-RL job
today you must:
1. Switch to Recipe 1 or 2 for the SFT/distill phase.
2. Use PRIME-RL only for the on-policy GRPO+DPPO phase.
The v0.2 plan (per ADR-007) is to extend `LossInputs` with a
`teacher_logits` field; the loss adapter is already shape-ready.
### 6. Cost ballpark
- **GPU**: similar profile to Recipe 2 — 8–32 H100 typical, scales to
hundreds for INTELLECT-class runs. Lambda Cloud or RunPod community
H100 community pricing (~$2–4/hr per H100) is most cost-effective.
- **API**: channel 3 is gated, so the only OpenRouter spend is from the
*offline data-prep* spike (using the verifier harness in Recipe 1 to
pre-bake DPO pairs), not from the training loop itself. Order of
magnitude: $50–500 for a curriculum-bake one-time, then $0/run.
- **Network**: PRIME-RL's own decentralized weight sync uses substantial
bandwidth between training replicas (one of its design constraints);
this is *separate* from the Decoupled DiLoCo bandwidth and shows up
as a ceiling on cross-region replica placement.
### 7. Known limitations as of Wave 14
- **Channel 2 deferred** — see step 5. `alpha_sdpo > 0` raises
`NotImplementedError`.
- **Channel 3 emits a warning** if `beta_dpo != 0`; trace-replay DPO
pairs must be folded into the *training data* (offline) rather than
the *loss* (online) until v0.2.
- **PRIME-RL ≥ 0.5 required.** Earlier versions don't ship
`CustomLossConfig`.
- **Smoke test deferred.** Per `prime_rl_recipe.md`, the runtime smoke
test requires a CUDA box + `prime-rl >= 0.5` install and is gated
to a follow-up spike. The 10 unit tests run cleanly without GPU.
- **DPPO defaults are PRIME-RL's, not ours.** We pin `low=-4.0,
high=4.0` to match. If you change them, you're now diverging from
PRIME-RL's example configs.
---
## Recipe 4 — Serverless Decoupled DiLoCo
### 1. When to use it
Pick Decoupled DiLoCo when:
- You have **N independent training replicas** that should sync
occasionally but can't (or shouldn't) cross-talk on every step.
- The cost or operational burden of an always-on multi-node cluster is
unacceptable, but you're happy paying for 4× independent **serverless
jobs**.
- Your inner trainer is one of Recipes 1–3 — DiLoCo wraps any inner
optimizer; it's *purely outer-loop*.
- You need **failure isolation**: if one replica crashes, the others
keep training; on restart it picks up from the last outer round.
DiLoCo's design rests on two abstractions (per ADR-005):
1. **`ServerlessExecutor` Protocol** — uniform interface for spinning up
N replicas across cloud backends (Modal / HF Jobs / SageMaker / k8s).
2. **`ObjectStoreAllReduce`** — fsspec-backed pseudo-gradient exchange
that replaces the in-process `torchft.Manager.allreduce` call.
The communication pattern is `S3 PutObject + N GetObjects` once per
inner-H steps, matching DiLoCo paper §3.2 (arXiv:2311.08105). For
1B-param bf16 that's ~2 GB / 30 min per replica — well within S3
free-tier.
### 2. Install command
```bash
pip install -e ".[diloco,serverless]"
# also one of the inner-trainer extras:
pip install -e ".[train]" # if the inner trainer is Recipe 1
# OR pip install verl # if the inner trainer is Recipe 2
# OR pip install -e ".[prime-rl]" # if the inner trainer is Recipe 3
```
### 3. Minimum-viable Python script
This pattern is independent of the inner trainer — pick any of Recipes
1/2/3 and wrap it with a `ServerlessExecutor`. The replica entrypoint
runs the inner trainer; the driver launches N of them and waits.
```python
# diloco_driver.py — driver that launches N replicas
from composer_replication.diloco.serverless import (
LocalProcessExecutor, # for dev — runs replicas as local subprocesses
ObjectStoreAllReduce,
)
rendezvous = ObjectStoreAllReduce(
uri = "s3://my-bucket/diloco-runs/run42/", # or file:// for local
world_size = 4,
)
executor = LocalProcessExecutor() # Wave 14: ModalExecutor skeleton raises NotImplementedError; swap once cloud backend lands
handles = executor.launch_replicas(
n_replicas = 4,
entrypoint = "diloco_replica.py", # (script below)
entrypoint_args = {
"rendezvous": rendezvous.uri,
"rank_env": "REPLICA_RANK",
},
)
result = executor.collect(handles, timeout=3600)
print({h.replica_id: h.exit_code for h in result})
```
```python
# diloco_replica.py — runs inside each replica
import os
from composer_replication.diloco import make_diloco_outer_loop
from composer_replication.diloco.serverless import (
ObjectStoreAllReduce, MockManager,
)
# Build inner trainer (Recipe 1 example):
from train_trl import trainer
rendezvous = ObjectStoreAllReduce(
uri = os.environ["DILOCO_RENDEZVOUS"],
world_size = 4,
rank = int(os.environ["REPLICA_RANK"]),
)
manager = MockManager(allreduce=rendezvous)
outer = make_diloco_outer_loop(
inner_optimizer = trainer.optimizer,
manager = manager,
sync_every_h = 500,
)
trainer.add_callback(outer.callback())
trainer.train()
```
### 4. Decoupled DiLoCo wiring
This recipe **is** the DiLoCo wiring — see step 3. The available
executor adapters are:
| Executor | Status | Use case |
|---------------------------|-------------------------------|--------------------------------------|
| `LocalProcessExecutor` | Production-ready | Dev loop — N subprocesses on one box |
| `ModalExecutor` | Skeleton (modal-client gated) | Modal cloud, $/sec billing |
| `HFJobsExecutor` | Skeleton (hf-hub gated) | HuggingFace Jobs, transformer-shop |
| `SageMakerExecutor` | Roadmap (post-v0.2) | AWS, warm-pool ~10s cold start |
| `K8sExecutor` | Roadmap | KubeRay / Volcano gang scheduling |
Cross-cloud replica placement (e.g. 2× Modal + 2× HF Jobs) is supported
in principle — they all read/write the same S3 / GCS / HF rendezvous —
but treat as experimental.
### 5. Distillation-loss wiring
DiLoCo is loss-agnostic — it operates purely on inner-optimizer state.
Whichever inner trainer you're running (Recipe 1, 2, or 3) handles
distillation kwargs as documented in that recipe's step 5. The only
DiLoCo-specific knob worth knowing: TAID's `taid_schedule_step` is a
*global* counter, but each replica increments it independently. If you
care about replicas all reading the same α at outer-sync time, set
`taid_schedule_step = trainer.state.global_step + replica_offset` and
let the outer-loop sync average them out.
### 6. Cost ballpark
Pulled from
[`docs/research/DILOCO_SERVERLESS_RECONNAISSANCE.md`](research/DILOCO_SERVERLESS_RECONNAISSANCE.md):
| Backend | A100-80GB $/hr | H100 $/hr | Cold-start | Notes |
|---------------|----------------|-----------|------------|------------------------------------------|
| Modal | $1.39/sec → 4× ≈ $20/hr per A100 | ~$8/hr per H100 | 1–60s warm, 60–120s first-run | $/sec billing; no minimum |
| AWS SageMaker | $4.10/A100·hr | $12.29/hr | 2–5 min cold, ~10s warm pool | Min 60min on warm pool |
| GCP Vertex | $3.67/A100·hr | $11/hr | 2–6 min cold | 30–50% premium over raw GPU |
| Azure ML | ~$3.67/A100·hr | ~$12.25/hr | 3–8 min cold | Use curated env to cut cold-start |
| RunPod | $1.19/hr (community), $2.17 (secure) | $1.99/hr (community), $4.18 (secure) | seconds | No federation; same-DC only |
| HF Jobs | comparable to Modal | ~$8–12/hr | 30–90s | Best DX for HF-shop |
**Object-store cost.** ~$0.02/GB-month for S3 standard, ~$0/free-tier.
Pseudo-gradients are ~2 GB per replica per outer round; for a 24-hour
4-replica run at H=500 that's ~50 outer rounds × 2 GB × 4 replicas = ~400
GB written. Free-tier blows through fast — budget $10–20 in storage.
### 7. Known limitations as of Wave 14
- **`ModalExecutor` and `HFJobsExecutor` are skeletons.** They check
`import modal` / `import huggingface_hub` at *adapter init* time and
raise; the actual `launch_replicas` is shape-only until the relevant
spike lands. Use `LocalProcessExecutor` for dev.
- **`ObjectStoreAllReduce(world_size=1)`** must passthrough cleanly —
the unit test `test_object_store_allreduce_world_size_1_passthrough`
is the regression guard. Don't override unless you've read it.
- **Rank validation is mandatory.** Tests assert
`ObjectStoreAllReduce(rank=N, world_size=N)` raises (rank must be
`< world_size`); silent corruption otherwise.
- **`MockManager` is *not* feature-complete.** It implements the
`Manager.allreduce` surface that DiLoCo's outer-loop needs, but
not the full `torchft.Manager` API (no fault-tolerance, no
membership protocol). Don't use it as a drop-in for live torchft.
- **No native heterogeneous compute** — all replicas are assumed to
have the same compute shape. Mixed A100+H100 placements work but
the slow replica gates outer-loop progress.
---
## Recipe 5 — Monarch actor mesh
### 1. When to use it
Pick Monarch when:
- You're at **TorchForge-style topology scale**: trainer / generator /
rewarder / N-teachers all want to be independent, asynchronously
scheduled, fault-tolerant actors on a typed mesh.
- You want **heterogeneous executor support** — different actors run
in different clouds (e.g. `TrainerActor` on Modal A100s,
`GeneratorActor` on dedicated H100s, `TeacherPoolActor` as 0-GPU CPU
pods on k8s).
- You need **hot-swap of actor implementations** — replace
"OpenRouter teachers" with "local vLLM teachers" by changing one
Monarch binding, no trainer code change.
- You're prepared to track **upstream Monarch** (v0.4.1 stable, v0.5
dev daily); the API is moving and v0 of this recipe is intentionally
deferred per ADR-006.
Don't pick Monarch in Wave 14 unless you're explicitly scoping a
v0.2+ pilot. The framework ships *skeleton* actors that fail-fast on
instantiation; this is a reference-pattern reading exercise, not a
production target.
### 2. Install command
```bash
pip install -e ".[prime-rl,monarch]"
# pulls monarch>=0.4.1 plus the PRIME-RL trainer used inside actors
```
### 3. Minimum-viable Python script
The framework ships skeleton actor definitions at
`composer_replication/recipes/monarch/actors.py`; they raise
`NotImplementedError` on instantiation in Wave 14. The shape of the
final answer:
```python
# monarch_train.py — what v0.2+ usage will look like
from monarch import Actor, mesh, endpoint
from composer_replication.recipes.monarch.actors import (
TrainerActor, GeneratorActor, RewarderActor, TeacherPoolActor,
)
# Topology
trainers = mesh.spawn(TrainerActor, n=4, gpu="A100")
generator = mesh.spawn(GeneratorActor, n=1, gpu="A100")
rewarder = mesh.spawn(RewarderActor, n=1, gpu=None)
teachers = mesh.spawn(TeacherPoolActor, n=1, gpu=None)
# Wire endpoints
async def outer_step(batch_id: int):
prompts = await trainers[0].sample_prompts.call(batch_id)
rollouts = await generator.rollout.call(prompts)
rewards = await rewarder.score.call(rollouts)
teacher_acts = await teachers.replay.call([
{"state": r["state"]} for r in rollouts
])
await trainers.train_outer_step.call(
batch_id, rollouts=rollouts, rewards=rewards,
teacher_actions=teacher_acts,
)
# Run
import asyncio
for batch_id in range(1000):
asyncio.run(outer_step(batch_id))
```
The Composer 3-channel loss lives inside `TrainerActor.train_outer_step`,
which calls `compose_loss(...)` exactly as Recipe 1 does. The
*orchestration* changes; the *loss math* doesn't.
### 4. Decoupled DiLoCo wiring
Monarch + Decoupled DiLoCo compose naturally: each `TrainerActor` is a
DiLoCo replica, and Monarch's supervision tree handles the failure
recovery that ADR-005 lists as a DiLoCo design constraint. The wire-up
is identical to Recipe 4's `LocalProcessExecutor` pattern, just running
inside Monarch instead of `subprocess`:
```python
from composer_replication.diloco.serverless import (
ObjectStoreAllReduce, MockManager,
)
class TrainerActor(Actor):
def __init__(self, rendezvous_uri: str, rank: int, world_size: int):
self.rendezvous = ObjectStoreAllReduce(
uri=rendezvous_uri, rank=rank, world_size=world_size,
)
self.manager = MockManager(allreduce=self.rendezvous)
# ... build inner ComposerReplicationTrainer ...
@endpoint
async def train_outer_step(self, batch_id: int, **kw):
# Inner H steps locally, then sync via self.rendezvous
...
```
The "object store" is the cross-actor synchronization point that
*doesn't* go through Monarch's RDMA data plane — by design, slow
syncs (S3) and fast syncs (RDMA for in-actor weight broadcast) live on
different planes.
### 5. Distillation-loss wiring
Monarch sees the loss as opaque: it lives inside `TrainerActor` and
takes the same `compose_loss` kwargs as Recipe 1. The mesh-level
benefit is **swap-by-binding**: you can replace `TeacherPoolActor`
("OpenRouter") with a `LocalVLLMTeacherActor` to switch the
*supplier* of teacher log-probs without touching the loss config.
```python
# Original binding — channel 3 via OpenRouter
teachers = mesh.spawn(TeacherPoolActor, n=1, gpu=None)
# Swap binding — channel 3 via local vLLM
teachers = mesh.spawn(LocalVLLMTeacherActor, n=1, gpu="A100",
model_id="Qwen/Qwen2.5-72B-Instruct")
# Trainer config unchanged:
trainer.compose_loss_kwargs = dict(
dpo_variant = "simpo", # same as before
sdpo_wrapper = "taid",
taid_schedule_step = batch_id,
taid_total_steps = 10_000,
)
```
### 6. Cost ballpark
In Wave 14: $0 (skeleton fails fast; no compute used). Projected for v0.2+:
- **Mesh overhead**: Monarch's coordination plane is light — typically
<1% of total compute even at 4-actor scale. The dominant cost is
whatever the actors run.
- **Heterogeneous placement** is the cost lever: e.g. a 4-trainer mesh
with `TeacherPoolActor` on 0-GPU CPU pods can cut total $/hr by
~10–20% vs forcing all actors onto GPU nodes.
- **Cluster bring-up**: Monarch v0.5's Slurm backend is stable; k8s
backend is dev-track; bare-metal SSH backend is documented.
### 7. Known limitations as of Wave 14
- **Skeleton only, fails fast.** Importing `actors.py` is fine;
instantiating `TrainerActor(...)` raises `NotImplementedError("v0
skeleton; deferred to v0.2 per ADR-006")`. By design.
- **Upstream Monarch API is moving.** v0.4.1 stable + v0.5 dev daily
means breaking changes are expected. Pin to a Monarch hash if you
prototype.
- **TorchForge is paused.** Per its own repo banner — don't take
TorchForge's recipes as production patterns. Monarch alone is
active; Forge as a layered framework is reference reading.
- **Open question (deferred):** does Monarch v0.5's Slurm backend
hand-shake cleanly with HF Jobs lifecycle? See
`monarch_actor_layout.md` for the open-questions list.
- **Open question (deferred):** can `TrainerActor` host
`ComposerReplicationTrainer` unmodified, or does it need a
`step_init` / `step_compute` split for Monarch's async actor model?
---
## Comparison matrix
| Dimension | Recipe 1 — TRL | Recipe 2 — VeRL | Recipe 3 — PRIME-RL | Recipe 4 — Serverless DiLoCo | Recipe 5 — Monarch |
|------------------------------------|-----------------------------|----------------------------------|-----------------------------------|------------------------------------|-------------------------------------|
| **Maturity (Wave 14)** | Production-ready | Production-ready (adapter shape-only) | Recipe ready, runtime smoke deferred | `LocalProcessExecutor` ready; cloud adapters skeleton | Skeleton only; v0.2+ scope |
| **Supports DAPO / GRPO** | GRPO ✅; DAPO via TRL master | GRPO ✅; DAPO ✅ (built-in) | GRPO+DPPO ✅ (DAPO mask is the headline) | Inherits from inner trainer | Inherits from inner trainer |
| **Custom-loss extension cost (LOC)** | ~30 LOC (subclass override) | ~50–150 LOC (registered estimator) | ~20 LOC (single Python fn) | 0 (transparent wrapper) | ~30 LOC (loss inside actor) |
| **OpenEnv-compatible** | ✅ (HF datasets layer) | ✅ (DataProto extension) | ✅ (rollout JSONL contract) | ✅ (orthogonal) | ✅ (RewarderActor binding) |
| **Native multi-node** | ❌ (single-host FSDP only) | ✅ (Ray cluster + 3D-HybridEngine) | ✅ (trainer/inference/orchestrator split) | ✅ (the *whole point*) | ✅ (mesh of actors) |
| **Native Decoupled DiLoCo** | ❌ — wrap with Recipe 4 | ❌ — wrap with Recipe 4 | ❌ — wrap with Recipe 4 | ✅ (this *is* it) | ✅ (compose with Recipe 4 inside actor) |
| **License** | Apache 2.0 (TRL) | Apache 2.0 (VeRL) | Apache 2.0 (PRIME-RL) | Apache 2.0 (this repo) | BSD-3 (Monarch) |
| **Our recommendation (Wave 14)** | **Default for ≤ 70B / single-host** | Pick at >70B *if* Ray is acceptable | Pick if PRIME-Intellect / DPPO mask is required | Stack on top of 1/2/3 for N replicas | Reference pattern only — revisit v0.2 |
---
## Cross-recipe checklist
Regardless of which recipe you pick, these invariants are tested across
the 115-test suite (post-Wave-15) and should be true of your wired-up system:
- **`alpha_sdpo=0`** must reproduce the channel-1-only baseline
bit-exact (`test_compose_loss_integration.py`).
- **`beta_replay=0`** must reproduce the no-channel-3 baseline
bit-exact.
- **`sdpo_wrapper="taid"` without `taid_schedule_step`** must `ValueError`
at first step (`test_compose_loss_integration.py`).
- **`sdpo_wrapper="taid"` at `taid_schedule_step / taid_total_steps = 0`**
must ignore the teacher signal (`test_taid_loss_alpha_zero_ignores_teacher`).
- **`sdpo_wrapper="taid"` at `taid_schedule_step / taid_total_steps = 1`**
must equal plain SDPO (`test_taid_blended_logits_endpoints`).
- **`dpo_variant="simpo"`** must be differentiable through the
`loss-of-sigmoid` path (`test_simpo_loss_differentiable`).
- **`sdpo_wrapper="entropy_opd"`** must zero out when student ≡ teacher
(`test_entropy_aware_opd_zero_when_distributions_match`).
- **`ObjectStoreAllReduce(world_size=1)`** must passthrough cleanly
(`test_object_store_allreduce_world_size_1_passthrough`).
If any of these fail in your wired-up system, run the corresponding
unit test to localize: most break because a kwarg got dropped at the
adapter boundary, not because the loss math is wrong.
---
## Picking a recipe — decision flow
1. **Piloting Monarch (v0.2+)?** → Recipe 5.
2. **Else, need >70B / multi-host?** → Recipe 2 (VeRL) if Ray is OK,
Recipe 3 (PRIME-RL) if you're in the PRIME-Intellect / DPPO universe,
otherwise wait for Recipe 5.
3. **Else** → Recipe 1 (TRL) is the v0.0/v0.1 default.
4. **At any of 1–3, need N independent replicas / failure isolation?**
→ Stack Recipe 4 (Decoupled DiLoCo) on top.
---
## Pointers to source
- Loss core: [`composer_replication/loss.py`](../composer_replication/loss.py)
- TRL trainer: [`composer_replication/trainer/composer_trainer.py`](../composer_replication/trainer/composer_trainer.py)
- PRIME-RL adapter:
[`composer_replication/recipes/prime_rl/composer_loss.py`](../composer_replication/recipes/prime_rl/composer_loss.py),
recipe doc:
[`composer_replication/recipes/prime_rl/prime_rl_recipe.md`](../composer_replication/recipes/prime_rl/prime_rl_recipe.md)
- Monarch skeleton:
[`composer_replication/recipes/monarch/actors.py`](../composer_replication/recipes/monarch/actors.py),
layout doc:
[`composer_replication/recipes/monarch/monarch_actor_layout.md`](../composer_replication/recipes/monarch/monarch_actor_layout.md)
- Serverless DiLoCo:
[`composer_replication/diloco/serverless/`](../composer_replication/diloco/serverless/)
- VeRL adapter (shape-only): `composer_replication/recipes/verl/`
- ADRs:
[`docs/adrs/ADR-005-serverless-diloco.md`](adrs/ADR-005-serverless-diloco.md),
[`docs/adrs/ADR-006-rl-frameworks.md`](adrs/ADR-006-rl-frameworks.md),
[`docs/adrs/ADR-007-distillation-losses.md`](adrs/ADR-007-distillation-losses.md)
---
**File path:** `/mnt/e/CS/HF/composer-replication-framework/docs/INTEGRATION_RECIPES.md`