Miles RL post-training¶
srt-slurm launches a Miles training run as a services-only job: a ray service brings up the cluster, and a custom benchmark step runs the bundled launcher benchmarks/rl/miles/launch.sh, which hands the cluster to Miles's own launch script. Miles pairs SGLang rollout engines with a Megatron-LM (or FSDP) trainer under Ray and owns every GPU. srt-slurm owns everything around it: the SLURM allocation, the container and mounts, Ray bring-up with readiness, the launcher step, per-job logs, tachometer telemetry, status reporting, and teardown.
Nothing in srt-slurm's core knows about Miles. The launcher is a script in the repo-root benchmarks/rl/ folder, written against the environment every custom benchmark receives.
Table of Contents¶
- What runs where
- Recipe
- Environment the launcher reads
- Prerequisites
- Agentic environments and sandboxes
- Gotchas
What runs where¶
The benchmark step is only the launcher and the log tail of the Ray job. Everything RL happens inside that job, on the raylets the ray service started.
sbatch: N nodes, one task per node
1. services[train] (type: ray, nodes: N, placement: every worker node)
node 0 ray start --head --node-ip-address <fabric ip> --port 6379 --dashboard-host 0.0.0.0 --num-gpus G --block
node 1..N-1 ray start --address <node0 ip>:6379 --num-gpus G --block (after the head answers /api/version)
gate /nodes?view=summary lists N raylets ALIVE
2. exporters, tachometer (when enabled)
3. benchmark (type: custom) on node 0, same image and mounts, no GPUs of its own:
srtctl injects SRT_SERVICE_TRAIN_IPS=<node0 ip>,... SRT_SERVICE_TRAIN_NODE_COUNT=N SRT_GPUS_PER_NODE=G
launch.sh sets MILES_SCRIPT_EXTERNAL_RAY=1 MASTER_ADDR=<node0 ip> RAY_ADDRESS=http://<node0 ip>:8265
MILES_SCRIPT_NUM_NODES=N MILES_SCRIPT_NUM_GPUS_PER_NODE=G
and execs cd /root/miles && python3 scripts/run_qwen3_dense.py (every option comes from MILES_SCRIPT_* env)
Miles runs `ray job submit -- python3 train.py ...` and streams the job's stdout into benchmark.out
4. inside the Ray job (in the ray service's containers):
MegatronTrainRayActor x (nodes x GPUs) the trainer ranks
CommandActor per engine each execs sglang.launch_server: the rollout engines
router spreads /generate over the engines
RolloutExecutor samples prompts, generates, scores rewards: the rollout
loop: rollout -> reward -> GRPO step -> update_weights into the engines -> repeat
5. the launcher exits with the Ray job's status -> postprocess -> teardown: launcher, then the raylets (SIGTERM stops Ray cleanly)
Two consequences shape the recipe. The ray service carries the job's container, mounts and environment because that is where the trainer and engines actually run; the benchmark step is only a client of the dashboard port. And the Miles launcher runs pkill -9 sglang before it starts, which is harmless here because every SGLang engine is Miles's own and starts afterwards.
Recipe¶
schema: 2
name: "qwen3-4b-grpo"
model: { path: "qwen3-4b", container: "miles", precision: "bf16" }
resources: { gpu_type: "b200", gpus_per_node: 8 }
frontend: { type: none }
services:
- name: train # the Ray cluster; owns the job's two nodes
type: ray
nodes: 2
preamble: "cd /root/miles"
metrics: # both on the Ray head; see Engine metrics
- { name: miles, port: 9090, nodes: first }
- { name: engines, port: 31000, path: /engine_metrics, nodes: first }
benchmark:
type: custom
command: /benchmarks/rl/miles/launch.sh
env:
MILES_RECIPE: scripts/run_qwen3_dense.py
MILES_SCRIPT_MODEL_NAME: Qwen3-4B
MILES_SCRIPT_MODEL_DIR: /data/models
MILES_SCRIPT_DATA_DIR: /data/datasets
MILES_SCRIPT_OUTPUT_DIR: /data/miles-runs/qwen3-4b-grpo
MILES_SCRIPT_ENABLE_EVAL: "true"
MILES_SCRIPT_EXTRA_ARGS: >-
--num-rollout 20 --colocate --actor-num-nodes 2 --actor-num-gpus-per-node 8 --rollout-num-gpus-per-engine 2
--sglang-router-port 31000 --use-prometheus --prometheus-port 9090 --prometheus-run-name qwen3-4b-grpo
extra_mount:
- "/data/models:/data/models"
- "/data/datasets:/data/datasets"
- "/data/miles-runs:/data/miles-runs"
environment: { NCCL_SOCKET_IFNAME: bond0 }
observability: { tachometer: { enabled: true } }
frontend.type: none says there is no router and no worker-count health gate; the services' own probes are the only gate. services[].nodes on the ray service is the node count of the job: with no engine roles, the service that owns the nodes sets the allocation size. The directories Miles reads and writes are host paths, so they are mounted at the same path into every step with extra_mount; the raylets need them because the trainer and engines run there. The metrics list on the ray service is what puts Miles's own rewards and timers and the engines' KV usage into tachometer's parquet next to the GPU telemetry; see Engine metrics.
examples/miles/qwen3-4b-grpo.yaml is a runnable version.
Environment the launcher reads¶
Injected by srtctl for every custom benchmark command (see Benchmarks, custom):
| Variable | Meaning |
|---|---|
SRT_SERVICE_TRAIN_IPS, SRT_SERVICE_TRAIN_NODE_COUNT |
The ray service's nodes; the first IP is the head. TRAIN is the service name upper-cased |
SRT_GPUS_PER_NODE |
resources.gpus_per_node |
Set in benchmark.env:
| Variable | Default | Meaning |
|---|---|---|
MILES_RECIPE |
required | Launch script, relative to MILES_ROOT or absolute in the container |
MILES_RAY_SERVICE |
train |
Name of the services[] entry that is the Ray cluster |
MILES_ROOT |
/root/miles |
The Miles checkout in the container |
MILES_SUBCOMMAND |
none | Positional for the multi-step launchers (train, prepare, full-train); the single-command recipes such as run_qwen3_dense.py take none |
MILES_RAY_PORT, MILES_RAY_DASHBOARD_PORT |
6379, 8265 | Must match the ray service's options |
MILES_LAUNCH_DRY_RUN |
0 | 1 prints the resolved environment and command and exits |
MILES_SCRIPT_* |
Miles's own recipe options, passed through untouched. Every ScriptArgs field of a launch script is settable as MILES_SCRIPT_<FIELD>: MODEL_NAME, MODEL_DIR, DATA_DIR, OUTPUT_DIR, ENABLE_EVAL, EXTRA_ARGS (appended to train.py), EXTRA_ENV_VARS (JSON, reaches Ray's runtime env), MEGATRON_PATH, ... |
The launcher exports, unless already set, MILES_SCRIPT_EXTERNAL_RAY=1, MASTER_ADDR, RAY_ADDRESS, MILES_SCRIPT_NUM_NODES and MILES_SCRIPT_NUM_GPUS_PER_NODE, all derived from the ray service. It exits with the Ray job's status.
Give MILES_SCRIPT_OUTPUT_DIR a stable shared path: Miles resumes from the last checkpoint there when the same recipe is resubmitted. Global environment: reaches every step, including the raylets, so raylet-spawned processes inherit it; put NCCL and GLOO interface names there.
Prerequisites¶
The image is radixark/miles (about 43 GB as a squashfs). It pins patched SGLang, Megatron-LM and sgl-router branches; use it for the whole job. Import once and alias it in srtslurm.yaml:
ENROOT_CACHE_PATH=/data/$USER/.cache/enroot enroot import -o /data/$USER/squash/miles-latest.sqsh docker://radixark/miles:latest
The recipe expects, on the shared filesystem, the HF weights, the converted Megatron checkpoint next to them, and the datasets:
hf download Qwen/Qwen3-4B --local-dir <model_dir>/Qwen3-4B
hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir <data_dir>/dapo-math-17k
hf download --repo-type dataset zhuzilin/aime-2024 --local-dir <data_dir>/aime-2024
# once, on a GPU node, inside the image:
cd /root/miles
read -ra MODEL_ARGS <<< "$(python3 miles/utils/external_utils/model_args_utils.py qwen3-4B)"
PYTHONPATH=/root/Megatron-LM python3 tools/convert_hf_to_torch_dist.py "${MODEL_ARGS[@]}" \
--hf-checkpoint <model_dir>/Qwen3-4B --save <model_dir>/Qwen3-4B_torch_dist
Agentic environments and sandboxes¶
The run above scores answers with a verifier inside the rollout executor. Miles's agentic recipes instead drive an environment: an OpenEnv, NeMo Gym or Verifiers server the agent loop talks to over HTTP, or a per-task sandbox on E2B, Modal, Daytona or a Kubernetes-hosted AgentENV, orchestrated through Harbor or HUD. srt-slurm needs no code for any of these. They are Miles connectors configured through environment, and three things in the recipe carry that environment.
services:
- name: train
type: ray
nodes: 2
# Credentials reach every process Miles spawns, because they inherit the raylet's
# environment. Keep them in a mode-600 file on the shared filesystem, not in the recipe.
preamble: "set -a; . /data/secrets/sandbox.env; set +a; cd /root/miles"
benchmark:
type: custom
command: /benchmarks/rl/miles/launch.sh
env:
MILES_RECIPE: examples/experimental/openenv/glm52_tbench2/run_glm5_2_744b_a40b_daytona.py
MILES_SUBCOMMAND: train # the agentic launchers are multi-step; single-command recipes take none
MILES_SCRIPT_MODEL_NAME: ...
MILES_SCRIPT_EXTRA_ENV_VARS: '{"OPENENV_RUN_ID": "..."}' # anything a recipe wants in Ray's runtime env
- Credentials (
E2B_API_KEY,MODAL_TOKEN_ID,DAYTONA_API_KEY, an AgentENV token) go in the ray servicepreambleas a sourced file. Miles's own SLURM launcher does the same with aDAYTONA_ENV_FILE. Globalenvironment:also works but puts the value in the recipe and in every log of the sbatch script. - An environment server you run yourself (OpenEnv, NeMo Gym, Verifiers) is a
services[]entry with its owncontainer:; its address reaches the launcher asSRT_SERVICE_<NAME>_IPS, and a few lines in a copy oflaunch.shturn that into the URL the recipe expects. - Outbound network from the compute nodes to the provider is required; SaaS providers need internet, a Kubernetes AgentENV needs a route to its service.
Whether a provider works from a SLURM cluster depends on which direction the traffic flows:
| Mode | Traffic | Works from a private compute fabric |
|---|---|---|
| In-process trials: the agent loop runs inside Miles's rollout executor and the sandbox only executes commands | compute node to provider, outbound only | Yes, with outbound network and a key |
| Sandbox-hosted agent: a coding agent runs inside the sandbox and calls the policy through Miles's session server or router | sandbox to a compute node's fabric IP, inbound | Only if that endpoint is reachable from the sandbox: a route back from a Kubernetes AgentENV, or an ingress or tunnel for SaaS sandboxes. Miles's --pin-rollout-manager-to-head exists for the Kubernetes version of this |
Before calling a recipe ready, check that its connector's Python dependencies are in the image. Miles's Harbor connector imports harbor, which its own README installs separately, so it may not be in radixark/miles:latest; steps run read-only containers, so the fix is a derived squashfs with the connector installed, or a venv on the shared filesystem added to PYTHONPATH in the ray service preamble. Per-task sandboxes hosted on the SLURM cluster itself are a different matter: pyxis containers have no Docker daemon, so that needs a daemonless provider such as an enroot-based sandbox service or rootless podman, which nothing here provides yet.
Engine metrics¶
Miles's telemetry surfaces are all fixed-port endpoints on the Ray head once the recipe pins them, so a metrics list on the ray service is the whole integration; tachometer scrapes them like any other service (see services.md).
- The engines, through the router. Miles picks its SGLang engines' ports at runtime, but its default router, the sgl-model-gateway, serves
GET /engine_metrics: the metrics of every registered engine, each sample labeledworker_addr. Pin the router with--sglang-router-portand scrape that. Names arrive normalized by the router (sglang_num_running_reqsrather thansglang:num_running_reqs). - Miles's training collector. With
--use-prometheusMiles runs a collector actor pinned to the driver node, the Ray head, that servesmiles_metric_*gauges (rollout rewards, response lengths, actor and rollout timers, MFU) on--prometheus-port, 9090 by default, labeledrun_name.
services:
- name: train
type: ray
nodes: 2
metrics:
- { name: miles, port: 9090, nodes: first } # miles_metric_*
- { name: engines, port: 31000, path: /engine_metrics, nodes: first } # sglang_* per engine, worker_addr label
benchmark:
env:
MILES_SCRIPT_EXTRA_ARGS: "... --sglang-router-port 31000 --use-prometheus --prometheus-port 9090 --prometheus-run-name qwen3-4b-grpo"
nodes: first scrapes only the service's first node, the head; the rows land as endpoints miles_<head> and engines_<head> with service=train. Both endpoints appear a few minutes into the job, once Miles has started the router and the collector, so the scraper logs failures until then. On sa-b200 (job 15881, Qwen3-4B GSM8K smoke, four TP2 engines) the parquet held 83 miles_metric_* families and 71 sglang_* families per engine at one-second resolution; KV usage peaked at about a quarter of the pool per engine and dropped to zero during training steps.
Gotchas¶
- Layout is Miles's to validate.
--actor-num-nodes,--rollout-num-gpusand friends live inMILES_SCRIPT_EXTRA_ARGS; srt-slurm does not check them against the allocation, Miles does when the job starts. Colocated: actor nodes equal the ray service'snodes. Most shipped recipes hardcode--colocate, and a store-true flag cannot be negated fromextra_args. - Health timeouts. The ray service allows ten minutes per node for the first
ray start, which imports torch. Megatron checkpoint load happens inside the Miles job and is not gated by srt-slurm. - Fault tolerance is Miles's. The raylets are
critical, so a raylet dying fails the run. Engine restarts inside Miles (--use-fault-tolerance) do not involve srt-slurm. - Benign NCCL warnings.
transport/p2p.cc NCCL WARN Cuda failure 1 'invalid argument'during CUDA graph capture appeared in every successful run. - Live logs.
tail -F outputs/<job>/logs/{sweep_<job>.log,benchmark.out}; the driver log carries the Ray job's stdout, engines and actors included.perf Nlines are the rollout side of iteration N,step Nthe trainer's.