Skip to content
Open
95 changes: 95 additions & 0 deletions examples/dynamo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Dynamo native-gRPC integration

Prime-RL uses Dynamo's OpenAI frontend for generation and `/v1/rl/workers` to
discover the direct vLLM admin endpoints used for weight updates.

## Pinned researcher stack

The currently validated source set is:

- vLLM `biswapanda/vllm@e74fc3f`
- Dynamo `ai-dynamo/dynamo@fc556d9`
- Prime-RL changes ported from Biswa's combined integration PR #3181 onto current `main`

These features are not all present in the public vLLM 0.26 and Dynamo 1.3.0
wheels. Use the build/install scripts in [`scripts/`](scripts/) or an internally
published image containing those exact revisions. The scripts use seven-character
revision pins as the repository policy requires.

For a two-GPU researcher smoke test, follow [`local/README.md`](local/README.md).
The other recipes describe larger externally deployed topologies.

## Frontend

Set these variables on the Dynamo frontend and expose both container ports:

```yaml
env:
- name: DYN_ENABLE_RL
value: "true"
- name: DYN_RL_PORT
value: "8001"
ports:
- name: http
containerPort: 8000
- name: rl-discovery
containerPort: 8001
```

The frontend Kubernetes Service must also map ports 8000 and 8001. Prime's
`base_url` targets 8000; `dynamo_discovery_url` targets 8001.

## Every vLLM engine and sidecar pair

The engine HTTP address published by discovery must be reachable from the
trainer, so bind vLLM to the pod network rather than loopback:

```text
vllm-rs serve <model> --host 0.0.0.0 --port 8000 --grpc-port 50051 -- \
--worker-extension-cls prime_rl.inference.vllm.worker.nccl.NCCLWeightUpdateWorker \
<other Python EngineCore arguments>
```

Install the matching Prime source in the engine image so Python can import the
worker extension. Set this environment variable on the `vllm-engine`
container to expose `/pause`, `/resume`, and `/collective_rpc`:

```yaml
env:
- name: VLLM_SERVER_DEV_MODE
value: "1"
```

Set the following variables on each `dynamo-vllm-sidecar` container. `POD_IP`
must appear before `VLLM_HTTP_ENDPOINT` so Kubernetes expands it:

```yaml
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: VLLM_HTTP_ENDPOINT
value: "http://$(POD_IP):8000"
- name: DYN_ENABLE_RL
value: "true"
```

Keep the existing `--grpc-endpoint 127.0.0.1:50051`: gRPC stays pod-local,
while discovery publishes the pod-reachable HTTP admin address. No per-worker
Kubernetes Service is required when trainer-to-pod networking is routable.

After startup, `/v1/rl/workers` must return every expected worker with a
non-null `admin_base_url`, positive `world_size`, and no `error` before Prime is
launched.

## Recipes

- [`local`](local): single-node 2-GPU smoke test with a real Dynamo stack
(etcd + frontend + sidecar + vLLM engine).
- [`qwen3_06b_math`](qwen3_06b_math): single-GPU trainer and aggregate Dynamo
inference smoke test.
- [`qwen3_30b_Thinking`](qwen3_30b_Thinking): Qwen3-30B Thinking math with an
external prefill/decode deployment.
- [`glm52_fp8_r2e`](glm52_fp8_r2e): multi-node GLM-5.2 FP8 R2E training with a
separately managed DGD.
128 changes: 128 additions & 0 deletions examples/dynamo/glm52_fp8_r2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# GLM-5.2 FP8 R2E with external Dynamo inference

This three-step smoke recipe runs a distributed Prime trainer and orchestrator
against a separately managed Dynamo deployment serving
`zai-org/GLM-5.2-FP8`. Dynamo owns the frontend, vLLM engines, and one native
gRPC sidecar per engine group; Prime discovers the mutable engine control
surface through `/v1/rl/workers`.

The recipe is split into trainer and orchestrator files because the external
Dynamo DGD and the multi-node trainer have independent lifecycles. It does not
add a Prime inference configuration or require Prime's launcher to manage the
DGD.

## Reference topology

| Component | Shape | GPUs |
|---|---|---:|
| Dynamo prefill | 2 nodes, DP4 x TP2 x PP1 x EP8 | 8 |
| Dynamo decode | 2 nodes, DP4 x TP2 x PP1 x EP8 | 8 |
| Prime trainer | 4 nodes, FSDP16 x CP4 x EP8 | 16 |

The checked-in configuration targets this topology; it is not a claim that
every cluster can use these parallelism dimensions unchanged. The two
discovery records must report `prefill` and `backend` components with a
combined `world_size` of 16. If the DGD topology changes, update
`weight_broadcast.inference_world_size` in both TOML files to the atomic sum
returned by the same `/v1/rl/workers` response.

The inference engines must load
`prime_rl.inference.vllm.worker.nccl.NCCLWeightUpdateWorker`, expose vLLM's
admin routes, and run the version-matched `vllm-rs` and
`dynamo-vllm-sidecar` binaries described in [`../README.md`](../README.md).
For mutable GLM weight reloads, launch every vLLM rank with `--enforce-eager`.
When serving a snapshot path, set `--served-model-name
zai-org/GLM-5.2-FP8`. The tested GLM entrypoint also uses the `glm47` tool
parser, `glm45` reasoning parser, the model chat template, and complementary
NIXL `kv_producer`/`kv_consumer` roles for prefill/decode.
The trainer and every inference engine must also use a compatible NCCL
transport. Apply cluster-specific settings such as `NCCL_IB_DISABLE`,
`NCCL_SOCKET_IFNAME`, and the NCCL network plugin consistently on both sides;
do not force Socket on the trainer while allowing inference to select IB.
The filesystem rollout transport requires the orchestrator and all trainer
nodes to mount the same read-write shared output root.

## Configure

Initialize the R2E environment submodule and install its workspace package:

```bash
git submodule update --init -- deps/prime-envs
uv sync --package prime-rl --package r2e-gym
```

The taskset intentionally uses the current `r2e-gym` default,
`PrimeIntellect/R2E-Gym-Subset-Verified`, instead of pinning an older dataset
override in this recipe.

Replace the checked-in service names when the DGD and trainer use different
DNS names:

- `model.client.base_url`: Dynamo OpenAI frontend on port 8000;
- `model.client.dynamo_discovery_url`: Dynamo RL discovery on port 8001;
- `weight_broadcast.host`: trainer rank zero, reachable from every inference
engine.

The R2E harness uses Prime sandboxes. Configure the normal Prime credentials,
or replace the runtime with the sandbox backend used by your cluster. Model
and dataset caches should be shared across the trainer and inference nodes.

Multi-turn affinity is not enabled merely by sending a session header. Start
the Dynamo frontend with `--router-session-affinity-ttl-secs <seconds>` (or
`DYN_ROUTER_SESSION_AFFINITY_TTL_SECS`) and choose an idle TTL longer than the
longest expected R2E turn gap. Prime maps each trajectory ID to the canonical
`X-Dynamo-Session-ID` header in `orchestrator.toml`.

Verify both the model and the complete atomic worker snapshot before starting
Prime. A successful HTTP status alone is insufficient:

```bash
MODEL=zai-org/GLM-5.2-FP8
curl -fsS http://dynamo-frontend:8000/v1/models |
jq -e --arg model "$MODEL" '.data | any(.id == $model)'
curl -fsS http://dynamo-frontend:8001/v1/rl/workers |
jq -e --arg model "$MODEL" '
.protocol_version == 1 and
(.workers | length == 2) and
(all(.workers[]; .model == $model and
((.error // "") == "") and
(.instance_id != null) and
((.admin_base_url // "") != ""))) and
([.workers[].instance_id] | unique | length == 2) and
([.workers[].admin_base_url] | unique | length == 2) and
([.workers[] | select(.model == $model) | .component] | sort == ["backend", "prefill"]) and
([.workers[] | select(.model == $model) | .world_size] | add == 16)
'
```

## Run

Launch the trainer on four 4-GPU nodes with the cluster's distributed runner.
For example, rank zero's rendezvous address can be passed to `torchrun` while
all ranks consume the same trainer file:

```bash
uv run torchrun \
--nnodes=4 --nproc-per-node=4 \
--rdzv-backend=c10d --rdzv-endpoint="$TRAINER_RANK_ZERO:29501" \
--node-rank="$NODE_RANK" \
-m prime_rl.trainer.rl.train \
@ examples/dynamo/glm52_fp8_r2e/trainer.toml \
--output-dir /shared/glm52-dynamo-r2e/train
```

After trainer rank zero opens port 29500, launch the orchestrator once:

```bash
uv run orchestrator \
@ examples/dynamo/glm52_fp8_r2e/orchestrator.toml \
--output-dir /shared/glm52-dynamo-r2e/train/run_0
```

The gate succeeds when the first optimizer step completes, policy version 1
settles on all 16 inference ranks through NCCL, and a later multi-turn rollout
completes without changing its Dynamo session assignment. Three steps are
required because finite NCCL runs skip broadcasts once
`step >= max_steps - 1`; this leaves step 1 as the first non-final broadcast
slot. The disabled post-batch zero-advantage filter keeps this small smoke run
from stalling on a homogeneous batch; enable it for a production training run.
60 changes: 60 additions & 0 deletions examples/dynamo/glm52_fp8_r2e/orchestrator.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
max_steps = 3
batch_size = 2
group_size = 2
seq_len = 32768
max_inflight_episodes = 2
max_off_policy_steps = 1
tasks_per_minute = 1

[model]
name = "zai-org/GLM-5.2-FP8"

[model.client]
base_url = "http://dynamo-frontend:8000/v1"
dynamo_discovery_url = "http://dynamo-frontend:8001"
wait_for_ready_timeout = 7200

[model.client.extra_headers_from_state]
X-Dynamo-Session-ID = "trajectory_id"

[tokenizer]
name = "zai-org/GLM-5.2-FP8"

[renderer]
name = "glm-5.1"
clear_thinking = false

[train.sampling]
temperature = 1.0
max_completion_tokens = 2048
extra_body = { chat_template_kwargs = { clear_thinking = false } }

[[train.source]]
name = "r2e"
group_size = 2
serve.pool = { type = "static", num_workers = 2 }
env.taskset = { id = "r2e-gym" }
env.agent.max_turns = 64
env.agent.max_input_tokens = 30720
env.agent.max_output_tokens = 16384
env.agent.max_total_tokens = 32768
env.agent.timeout = { setup = 600, rollout = 1800, finalize = 300, scoring = 600 }
env.agent.harness = { id = "bash", edit = true }
env.agent.runtime = { type = "prime", labels = ["glm52-dynamo"], cpu = 4, creates_per_min = 2 }

[[post_batch_filters]]
type = "zero_advantage"
enforce = false

[weight_broadcast]
type = "nccl"
host = "trainer-0.trainer-headless"
port = 29500
timeout = 12000
inference_world_size = 16

[rollout_transport]
type = "filesystem"

[log]
level = "debug"
46 changes: 46 additions & 0 deletions examples/dynamo/glm52_fp8_r2e/trainer.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
max_steps = 3
dist_timeout_seconds = 12000

[model]
name = "zai-org/GLM-5.2-FP8"
seq_len = 32768
impl = "custom"
attn = "flash_attention_2"
dp_replicate = 1
cp = 4
ep = 8
optimization_dtype = "bfloat16"
reduce_dtype = "bfloat16"
moe_router_dtype = "float32"
optim_cpu_offload = true
fused_lm_head_token_chunk_size = 1024

[model.ac]
freq = 1

[model.ac_offloading]
max_inflight_activations = 1

[tokenizer]
name = "zai-org/GLM-5.2-FP8"

[optim]
type = "sign_sgd"
lr = 1e-6
weight_decay = 0.0

[scheduler]
type = "constant"

[weight_broadcast]
type = "nccl"
host = "0.0.0.0"
port = 29500
timeout = 12000
inference_world_size = 16

[rollout_transport]
type = "filesystem"

[log]
level = "debug"
Loading