# Ray and Kubernetes Backend Source: https://miles.radixark.com/docs/advanced/cluster-backend Run the same training script on Ray, or let Kubernetes schedule every worker of the run. Miles runs a job on one of two cluster backends. Ray is the default and needs no configuration. Kubernetes installs the run as a helm release, so the cluster schedules every worker. The training script is the same either way. ## Ray Ray is the default backend, so `python scripts/run_*.py train` launches a run as is, with nothing to configure. Nothing in the Kubernetes chapter below applies to it. ## Kubernetes > [!WARNING] > > **Status.** Under active development: flags, chart values and failure semantics still change. Ray > also runs *inside* Kubernetes, and that is the well-trodden path — take this one only if you want > Miles to create the cluster's objects itself. ### Launch Run everything from the repository root, with `kubectl` and `helm` on your PATH. Install a workbench — the long-lived pod you launch from — once per namespace, from your cluster's [`infra.yaml`](#for-cluster-administrator): ```bash theme={null} export MILES_NS="miles-$USER" python -m miles.utils.external_utils.miles_workbench install -n "$MILES_NS" -f infra.yaml ``` It creates the namespace if missing, checks your rights, installs, and waits for Ready. Runs launched from the pod inherit those values. Launch from inside it: ```bash theme={null} python -m miles.utils.external_utils.miles_workbench exec -n "$MILES_NS" -- \ bash -lc "cd /root/miles && python scripts/run_qwen3_4b.py train" ``` The recipe defaults to `/root/models`, `/root/datasets` and `/root/shared_data`; the example [`infra.yaml`](#for-cluster-administrator) mounts all three from the shared volume. Fill them once with `python scripts/run_qwen3_4b.py prepare`, or pass `--model-dir`, `--data-dir` and `--output-dir` to point the recipe elsewhere. ### Observability **Built in** * The launcher follows every pod's logs, prints status changes and warning events, and ends with the run's exit code. * On a failure, collect the namespace's logs, describes and events into one directory — before cleaning up, and the whole namespace, because the explanation is usually the pod next to it: `python -m miles.utils.external_utils.miles_workbench collect-diagnosis -n "$MILES_NS" --output-dir ~/artifacts/miles` **External** * A run's pods are ordinary pods, so whatever the cluster already runs — a metrics stack, a log collector, the platform's own dashboards — sees them with no wiring from Miles. * Prefer it at scale. The built-in following is meant for watching one run, not hundreds of pods. ### Clean up ```bash theme={null} python -m miles.utils.external_utils.miles_workbench stop -n "$MILES_NS" 260811-143000-042 python -m miles.utils.external_utils.miles_workbench uninstall -n "$MILES_NS" ``` `stop` removes the run and frees its GPUs; `uninstall` removes the workbench. ### Folder convention A run is many pods on many machines, and they share nothing but the volumes `infra.yaml` mounts. * Every path your script names — `/root/models`, `/root/datasets`, `infra.paths.runsRoot` — has to be under one of those mounts. * To run your own branch, mount it at `/root/miles`, `/root/Megatron-LM` or `/sgl-workspace/sglang`; a copy anywhere else is not imported. ### For cluster administrator Everything above assumes this was done once. **Install LWS.** Miles deploys its worker pools as [LeaderWorkerSets](https://github.com/kubernetes-sigs/lws). Install the CRDs and controller, and grant users rights over them explicitly. **Give each user a namespace.** The namespace is the real boundary, not the Role: anything that may create workloads can name another ServiceAccount and read its token. Keep privileged accounts out of it. **Write one `infra.yaml`.** The same file drives every Miles chart: ```yaml theme={null} infra: image: repository: radixark/miles tag: dev volumes: - name: cluster-storage hostPath: {path: /cluster-storage, type: Directory} mounts: - {mountPath: /cluster-storage} - {mountPath: /root/miles, subPath: alice/miles} - {mountPath: /root/models, subPath: models} - {mountPath: /root/datasets, subPath: datasets} - {mountPath: /root/shared_data, subPath: alice/shared_data} paths: runsRoot: /cluster-storage/${NAMESPACE}/miles_data ``` The `hostPath` above stands for a cluster-wide shared filesystem (NFS, Lustre, a CSI mount) already mounted at `/cluster-storage` on every node: a per-node directory would lose the orchestrator state file and the shared checkpoints. Where Pod Security forbids `hostPath`, replace that key with an RWX `persistentVolumeClaim` and keep the mounts. Any path in `infra.yaml` — a `hostPath`, a `mountPath`, a `subPath`, `infra.paths.runsRoot` — may name `${NAMESPACE}`, which the chart replaces with the namespace it is installed into. `charts/miles-run/values.yaml` shows the full shape, and each chart's `values.schema.json` is the authoritative field list. # Disaggregated RL Rollout Source: https://miles.radixark.com/docs/advanced/disaggregated-rollout Scale rollout independently across clusters and regions while miles preserves policy publication and version attribution. Policy training and rollout inference have different resource and scaling profiles. A trainer is a long-lived, tightly coupled cluster whose ranks step together. Rollout is request-driven and bursty: long and agentic trajectories finish at different times, demand changes over a run, and inference capacity may be available in different clusters, regions, or compute providers. Keeping both roles inside one job ties rollout capacity to the trainer's placement, network, and lifecycle. Disaggregated RL rollout instead makes inference an independent service. The trainer remains on its stable cluster, while rollout replicas can scale with the live queue and draw on compute across the globe without joining the trainer's Ray placement group or NCCL fabric. For miles, this boundary brings: * **Independent scaling.** Add or remove rollout capacity without resizing or restarting the trainer. * **Flexible placement.** Run inference where GPUs are available, including other clusters, regions, and providers. * **Separate failure domains.** Replace rollout replicas without making their process lifecycle part of the training job. * **Better asynchronous utilization.** Keep a changing pool of rollout workers busy while training consumes completed trajectories. * **Policy correctness.** Publish immutable policy versions, constrain requests by version, and record which version generated every returned trajectory. The last property is what turns remote inference capacity into an RL rollout service rather than an ordinary model-serving endpoint. miles must update the policy without owning every engine and must preserve the behavior-policy identity required for staleness control and off-policy training. This page is about separating **RL training from rollout inference**. It is different from [PD disaggregation](/docs/advanced/pd-disaggregation), which separates the prefill and decode phases inside an SGLang deployment. ## Topologies | Topology | Engine lifecycle | What miles talks to | Policy updates | | - | - | - | - | | miles-managed rollout | Part of the miles job | A miles-managed router and engine handles | miles converts, transfers, pauses, updates, and resumes the engines | | Attached SGLang engines | Owned by the deployment | Fixed engine addresses supplied with `--rollout-external-engine-addrs` | miles still addresses and controls each engine through its engine handle | | External rollout service | Independent and dynamically scaled | One stable rollout endpoint | miles publishes versions; the rollout service materializes and activates them across its replicas | miles supports the first two topologies today. The single-endpoint external rollout service is coming soon and extends that separation from GPU placement to independent scaling, routing, and lifecycle. `--rollout-external-engine-addrs` is the second row, not the third. It prevents miles from launching SGLang, but miles still knows the individual engine addresses, checks their configuration, registers them with its router, and calls their weight-update lifecycle. An external rollout service is a narrower interface. miles sends rollout requests to one endpoint and publishes new policy versions without needing an engine handle for every replica. The service behind that endpoint can be implemented by any deployment or control-plane package that satisfies the request and policy-version contracts below. ## Separate trainer and rollout GPUs Disaggregated placement is the miles default. Give the trainer and rollout engines separate GPU counts and do not pass `--colocate`: ```bash theme={null} --actor-num-nodes 1 \ --actor-num-gpus-per-node 8 \ --rollout-num-gpus 8 \ --rollout-num-gpus-per-engine 2 ``` The trainer and engines remain resident on their own GPUs, so they can run at the same time under `train_async.py`. In a colocated job the two roles share GPUs and take turns; fully async rollout therefore requires the disaggregated layout. See [Training Backends: Choosing the GPU layout](/docs/user-guide/training-backend#3-choosing-the-gpu-layout) for placement and offload behavior. To attach SGLang engines launched outside the miles Ray job, provide their addresses explicitly: ```bash theme={null} --rollout-external-engine-addrs 10.0.1.10:30000 10.0.1.11:30000 ``` Setting the addresses is what makes the rollout external; miles discovers each engine's topology from its `/server_info`. If the attached fleet runs PD disaggregation, add `--rollout-external-router-pd`, because the router starts before the engines are discovered. The engines must be reachable from the miles job and must have server settings compatible with the rollout configuration. Because miles retains individual engine handles, attaching external engines does not hand off weight-update ownership: miles still runs the selected weight-update lifecycle. ## Weight synchronization Once training and rollout use different GPUs, updated weights have to cross the boundary between them. `--update-weight-transfer-mode` selects the current miles-managed path: | Mode | Data path | Use when | | - | - | - | | `broadcast` | Gather and convert trainer weights, then broadcast each tensor separately to the SGLang ranks over NCCL | Trainer and rollout ranks have NCCL connectivity; this remains the default | | `broadcast_packed` | Pack each weight bucket into one byte buffer and issue one NCCL broadcast | Non-colocated Megatron with SGLang mixed-dtype flattened-bucket support; trades contiguous bucket storage for fewer collectives | | `p2p` | Convert and re-shard weights, then write them directly to rollout-rank memory over RDMA | miles-managed, in-cluster jobs with direct rank-to-rank connectivity; see [P2P Weight Transfer](/docs/advanced/p2p-weight-transfer) | | `disk-delta` | Publish changed canonical checkpoint bytes to shared storage, let rollout hosts materialize them locally, then reload | Trainer and rollout cannot share an NCCL fabric, or model-sized full-weight transfer dominates the update | These are weight synchronization choices, not different rollout APIs. In the broadcast and P2P modes, miles transfers tensors into known engine ranks directly. P2P RDMA is an in-cluster transfer optimization; it is not the external rollout service mechanism described later in this page. Disk-delta instead establishes a versioned publication boundary that can also be consumed by an external rollout system. ### Disk-delta publication and activation Enable disk-delta on a non-colocated Megatron run with a publication directory visible to the trainer and rollout hosts, plus a host-local checkpoint directory for each rollout host: ```bash theme={null} --update-weight-transfer-mode disk-delta \ --update-weight-disk-dir /shared/miles/weight-updates \ --update-weight-local-checkpoint-dir /local-nvme/miles-rollout-checkpoint ``` The current lifecycle is: 1. On the first `update_weights()` call, miles captures a CPU snapshot from `--hf-checkpoint`. No delta version is published. Each rollout host also materializes the same base checkpoint in its local directory. 2. At the next update boundary, source trainer ranks gather Megatron tensors under their canonical Hugging Face names and compare their bytes with the previous snapshot. 3. miles publishes `weight_vNNNNNN/` with compressed changed bytes and an index containing the version, base version, delta encoding, and final-state checksums. Files are written atomically before the version is consumed. 4. Each rollout host pulls the version and patches its host-local checkpoint. Delta application verifies the checksum of the resulting tensor, not only the transferred delta. 5. miles pauses generation, reloads the materialized checkpoint into SGLang, advances the engine weight version, and resumes generation. Pull and local materialization happen before the engine pause and can overlap inference. Reloading the prepared weights is the activation boundary that requires the pause. The default delta encoding is XOR. It produces a compact byte-wise delta but must be applied exactly once to the declared base version. `overwrite` stores changed positions and their new values; it is larger but idempotent. Both encodings are byte-oriented: the base and exported policy must agree on tensor names, dtypes, shapes, and byte layout. Each published tensor carries a checksum of its complete target state. `xxh3-128` is the default; `blake3` and `adler32` are also accepted. A lineage, layout, or checksum mismatch fails the update instead of activating a partially updated policy. On a POSIX shared filesystem, the published version becomes visible through the normal filesystem contract. Object-store-backed mounts can use `--custom-update-weight-post-write-path` on the miles side and `--sglang-custom-pull-weights-pre-read-hook` on the SGLang side to make writes visible before a rollout host reads them. The maintained end-to-end coverage is [`tests/e2e/megatron/test_qwen3_4B_disk_delta.py`](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/test_qwen3_4B_disk_delta.py). It exercises a Qwen3-4B Megatron trainer (TP2 on two GPUs) and two TP1 SGLang rollout engines on the other two GPUs of a single 4-GPU node. The same storage contract supports separate hosts, but the registered test does not reproduce a cross-cluster deployment. Current `main` rejects disk-delta with `--colocate`, LoRA, or PD disaggregation. It also requires `--hf-checkpoint` to be a local checkpoint directory. The implementation is selected by the Megatron actor; it is not a general FSDP weight-update path. ## External rollout service contract The coming single-endpoint integration builds on the current disk-delta publication path and removes the need for miles to hold one handle per rollout engine. The commands above cover miles-managed and attached-engine deployments; this section defines the external-service boundary. The intended boundary has two independent data paths: ```mermaid theme={null} flowchart LR T[miles trainer] -->|publish policy version| S[(Version store)] W[miles rollout worker] -->|version-constrained request| G[External rollout endpoint] S -->|materialize and verify| E[Rollout replicas] G --> E E -->|trajectory and served version| W ``` miles does not need to know how many replicas are behind the endpoint, where they run, or how the service replaces them. The integration is defined by ownership: | miles owns | The external rollout service owns | | - | - | | Training state and optimizer steps | Replica placement, scaling, and health | | Conversion to canonical rollout-visible tensors | Materializing published versions on rollout hosts | | Policy version numbering and publication | Verifying lineage and final-state checksums before activation | | The policy-version requirement attached to each rollout request | Routing requests only to replicas that satisfy that requirement | | Recording the served version on returned samples | Staging, admission control, activation, and reporting the version that served a request | The rollout endpoint and the version store are separate interfaces. The request path should not have to carry model-sized weights, and publishing a new version should not require miles to enumerate the current replicas. One open-source package implementing the rollout-service side is [Stitch](https://github.com/modal-projects/stitch). It connects miles policy publication and version-constrained requests to an independently managed rollout fleet. The miles contract remains package-agnostic. Stitch pins the miles revision it integrates against; [Miles fork](https://github.com/modal-projects/stitch/blob/main/cookbook/miles_disagg/MILES_FORK.md) records the trainer-side commits that pin depends on, how the same ownership split lands in code, and how to move the pin forward. ### Policy-version requirements An external rollout request needs more than an inference payload. It must be able to express the policy version the caller can accept: * A **minimum version** permits any replica at or above the requested version. This bounds staleness while allowing the fleet to converge gradually. * An **exact version** requires the replica to serve one particular policy. It is useful for reproducibility, evaluation, and update validation. If no compatible replica is ready, the service returns a retryable response instead of silently serving an older policy. Retrying a stateful generation is safe only when the rollout integration defines an idempotency and session contract; the generic miles request path should not assume that every failed generation can be replayed. The response reports which version served the request. For long or agentic trajectories, recording both the version at generation start and at generation end also exposes whether an activation occurred while the request was in flight. miles can then attach the observed version to the sample rather than inferring it from the trainer's latest published version. ### Publication and replica convergence A policy version becomes eligible for rollout only after its complete artifact is visible. The publication contract therefore needs: 1. an immutable version identifier and its base lineage; 2. atomic visibility of the completed version; 3. final-state checksums for every changed tensor; 4. a way for a new or restarted replica to materialize a valid base and catch up through later versions; and 5. an activation acknowledgement before a replica advertises the new version. Disk-delta already provides version and base identity, atomic file writes, and final-state checksums. For an external consumer, the publication hook and version store must also expose the completed version as one commit boundary. Replica catch-up, admission, and activation belong to the external rollout system. Other publication formats can implement the same boundary without using disk-delta, as long as miles and the rollout system agree on version identity and rollout-visible weights. ## Fully async rollout Disaggregation and fully async rollout answer different questions: * Disaggregation decides where training and rollout run and who owns the rollout engines. * [Fully Async RL](/docs/user-guide/fully-async) decides how generation, buffering, and optimizer steps overlap. With miles-managed engines, fully async rollout keeps generation in flight while the trainer consumes completed groups and takes optimizer steps. At each configured update boundary, the current schedule still synchronizes the active generation call before invoking the weight updater. Disk-delta host materialization occurs before the subsequent engine pause, but current `main` does not overlap an opaque external publication with that active generation call. An external rollout service allows policy publication and replica preparation to proceed without draining the entire fleet. Only activation needs a short engine-local pause. Different replicas may converge at different times, so request constraints and served-version attribution become part of the RL data contract rather than optional serving metadata. miles already uses weight versions to measure fully async sample staleness. A service integration must preserve that information so `--max-weight-staleness` and custom data-buffer policies make decisions from the policy that generated each sample. ## What to measure End-to-end `update_weights` time is not enough for a disaggregated service. It mixes phases with different effects on rollout availability: | Measurement | What it isolates | | - | - | | Trainer encode and publish | Megatron-to-HF conversion, delta construction, compression, and publication | | Publication size and changed-byte density | Storage and network demand per version | | Replica materialization | Fetch, lineage verification, delta application, and runtime-layout preparation while serving | | Activation pause | Time the engine cannot admit or advance generation while prepared weights become active | | Version convergence | Time from completed publication until the required rollout capacity advertises the version | | Request version lag | Difference between the requested, served, and newest published policy versions | For current disk-delta runs, miles records `perf/update_weights_density` and `perf/update_weights_wire_bytes` in addition to the normal weight-update timing. The open-source Stitch integration reports the following reference rollout-side weight update timings: | Model | Preparation (incl. weight delta apply) | Engine pause | | - | -: | -: | | GLM-4.7-Flash | 15.8 s | 0.75 s | | Kimi K2.6 NVFP4 | 72.5 s | 2.82 s | Preparation does not pause the engine: rollout generation continues while the weight delta is applied and the next weights are prepared. This makes staged weight updates a natural fit for fully async training, because only activation requires a brief engine pause. ## Related guides * [Fully Async RL](/docs/user-guide/fully-async) * [Training Backends](/docs/user-guide/training-backend) * [P2P Weight Transfer](/docs/advanced/p2p-weight-transfer) * [PD Disaggregation](/docs/advanced/pd-disaggregation) * [Stitch: open-source disaggregated rollout service](https://github.com/modal-projects/stitch) ## Acknowledgment Thanks to [Nan Jiang](https://www.nanjiangwill.com/), [Jason Mancuso](https://github.com/jvmncs), and the Modal team for [Stitch](https://github.com/modal-projects/stitch) and for their support in putting this guide together. # Disk Offload Source: https://miles.radixark.com/docs/advanced/disk-offload Spill the paused training actor to node-local disk when host RAM cannot hold it. Colocated RL keeps a training actor and a rollout engine on the same GPUs, so the actor must get out of the way while the engine generates. miles offloads the actor during that window, and by default the backup lives in pinned host memory. For large models that copy does not fit, and disk offload is the alternative. ## Usage ```bash theme={null} --offload-train --offload-train-target disk \ --offload-train-disk-dir /scratch/miles_offload \ --offload-train-disk-chunk-mb 256 ``` Instead of a pinned host copy, the paused actor is streamed to per-rank files through a fixed-size pinned staging buffer, so host memory stays bounded by `--offload-train-disk-chunk-mb` regardless of how much is offloaded. Each rank writes to its own directory under `--offload-train-disk-dir` (defaults to `$SCRATCH/miles_train_offload_`), the files are overwritten in place every step, and they are removed when the actor exits. Point the directory at real node-local NVMe. A tmpfs mount (including `/tmp` on many systems) keeps the backup in RAM and defeats the purpose. ## How it works This runs on [torch\_memory\_saver](https://github.com/fzyzcjy/torch_memory_saver), which hooks the allocator, so it does not care what the memory holds — weights, gradient buffers and optimizer state all move as one block when the actor is paused, and come back on resume. miles' part is choosing the per-rank directory, launching each actor with the matching `TMS_DISK_BACKUP_*` environment, and reclaiming the files at startup and exit. Because pause and resume happen at phase boundaries, everything is resident again by the time the optimizer step runs. If the binding constraint is instead that the optimizer state does not fit the GPU *during* the step, actor offload cannot help; that case is what `--stream-optimizer-state-to-disk` addresses, and the two compose. ## Streaming the optimizer state ```bash theme={null} --offload-train --offload-train-target disk \ --stream-optimizer-state-to-disk \ --offload-train-disk-dir /scratch/miles_offload ``` The two together are what this is for. Actor offload gets the paused actor out of HBM for the whole rollout window; streaming keeps the largest part of it — 12 bytes per parameter of fp32 main params and Adam moments — from being in HBM in the first place, including while the step runs, which offload cannot do. Data parallelism divides the optimizer state, so a run with GPUs to spare shards it small enough to stay resident and needs neither; the runs that need both are the ones tight enough to sit at DP=1. GLM-5.2 744B on 8 GB300 nodes is the shape of it: 279 GB of state per rank against 276.6 GB of HBM, on half the nodes the run would otherwise need. The fp32 main params and Adam moments live in per-bucket files instead of on the GPU. Each step brings in one bucket, updates it, and writes it back, so peak residency is one bucket rather than the whole state. Buckets are capped at 200M elements independently of DDP's bucket sizes, which reach tens of GB at DP=1. Native-fp32 model params (a router's `expert_bias`, a GDN/Mamba `A_log`) stay GPU-resident under a small separate Adam: they are tiny, and unlike the bf16 path their optimizer shards alias the model params directly. The fp32 gradients the step reads are bounded the same way: instead of Megatron copying every reduced bf16 gradient into an fp32 `.grad` up front (4 more bytes per local parameter), each bucket's gradients are converted, times the clip coefficient, right before its Adam step and dropped after it. The grad norm reads the reduced gradients in the DDP grad buffer directly and matches Megatron's up to float summation order. For the Adam/DistributedOptimizer path, streaming also bounds initialization: Megatron releases each fp32 main shard's storage after creating its tensor handle, then Miles fills one existing runtime bucket at a time directly in the final files. There is no temporary state file. Peak initialization HBM is the largest individual shard during construction and one main-only bucket afterward (normally at most about 200M fp32 elements, except for an oversized entry). Each initialized range is synchronized and evicted from page cache before continuing. With `fp32` storage the state round-trips bit for bit and only the grad norm's summation order differs from keeping it on GPU, so turning this on changes results by rounding at most — it trades step time for memory. The step is I/O bound and the moments tolerate less precision than the master copy, so they can be stored narrower: ```bash theme={null} --stream-optimizer-state-moment-dtype bf16 ``` That cuts streaming volume by a third (12 bytes per param to 8). A checkpoint records the dtypes it was written with and a resume verifies them, so bytes written as bf16 can never be read back as fp32. The fp8 options work but are not recommended: `exp_avg_sq` needs per-block scaling to survive 8-bit storage, which this does not implement. Four limits to know about. Resume is same-topology only — the on-disk layout follows this rank's DP shard, so changing TP/PP/DP/EP fails the layout assert rather than resharding. A checkpoint written before streaming was enabled cannot be resumed with it: the streamed state is the only optimizer state read, so miles refuses rather than silently restarting Adam from zero — pass `--no-load-optim` to accept a fresh optimizer state. And the optimizer state is copied to the checkpoint directory synchronously, outside `--async-save`, so expect checkpoint saves to take noticeably longer. Finally, the streamed params have fp32 gradients only inside the step, so streaming refuses what reads them outside it: fp16 loss scaling, `--log-num-zeros-in-grad`, and `--enable-mtp-training`, whose detached MTP heads are clipped by their own grad norm. The two also help each other. With the optimizer state already on disk there is that much less to move when the actor is paused: on Qwen3-30B-A3B, sleep/wake went from 24s/8.9s to 5.2s/1.3s once the paused actor no longer carried the state. ## Choosing `--offload-train` is the base mechanism, and it is not tied to colocation: the actor is resident only during `train()` and sleeps for the whole rollout window either way. What changes is who takes the freed HBM. Colocated, it is the engine on the same GPUs, so offload is mandatory and defaults on. Disaggregated, it is whatever else you fit on the training GPUs, which is the point when you are sizing engines against a fixed cluster. * Paused actor fits in host RAM: keep `--offload-train-target=cpu` (default). Fastest, no scratch space. * It does not fit: `--offload-train-target=disk`. * On top of that, the optimizer state does not fit the GPU *during the step*, which offload cannot help with: add `--stream-optimizer-state-to-disk`, and consider `--stream-optimizer-state-moment-dtype bf16` to claw back some of the I/O cost. Streaming on its own is the disaggregated case: nothing else wants the training GPUs during rollout, so there is no actor to park and `--offload-train-target` is never read. Pass `--stream-optimizer-state-to-disk` alone there. Under `--offload-train`, though, the two go together — a run that cannot hold the optimizer state on the GPU for the step will not hold a pinned host copy of the whole actor either — and miles asserts that pairing. Both mechanisms share `--offload-train-disk-dir` and `--offload-train-disk-chunk-mb`. Point the directory at real node-local NVMe: a tmpfs mount, which `/tmp` is on many systems, keeps the data in RAM and defeats both. The chunk is a pinned host staging buffer and each mechanism allocates its own, so enabling both costs 2x that per rank. # Fault Tolerance Source: https://miles.radixark.com/docs/advanced/fault-tolerance Rollout-side health checks and engine recovery, gated by --use-fault-tolerance. > **Outdated.** This page describes the pre-refactor fault-tolerance stack; the new fault-tolerance machinery is still being merged and will be documented once it lands. The `--use-fault-tolerance` flag enables Miles's rollout-side fault-tolerance machinery. It gates two code paths: 1. A `RolloutHealthMonitor` thread per server group, started in `miles/ray/rollout/rollout_manager.py`, which periodically heart-beats each SGLang engine. 2. A recovery hook in the trainer's weight-update step (`miles/backends/megatron_utils/actor.py`), which restarts engines that the health monitor has killed. ```bash theme={null} --use-fault-tolerance ``` The flag is `action="store_true"`, default `False` (`miles/utils/arguments.py`). The rollout paths below additionally require `rollout` in `--ft-components`, which is what that flag selects when omitted. Rollout fault tolerance requires a partial-target weight update: `--update-weight-transfer-mode p2p` without `--colocate`. ## Health monitor `RolloutHealthMonitor` (`miles/utils/health_monitor.py`) runs in a daemon thread. Lifecycle: `start` (called once during init), `pause` and `resume` (called when engines offload / onload), `stop` (called during dispose). `pause` / `resume` are wired up in `miles/ray/rollout/rollout_manager.py` and called around offload / onload events. Each loop iteration does: 1. After a `resume`, wait `--rollout-health-check-first-wait` seconds before the first check (intended to cover model compilation and initialization). 2. For every active engine in the group, call `engine.health_generate.remote(timeout=self._check_timeout)`. 3. If the call raises, run `_kill_engine`: `engine.shutdown.remote()`, `ray.kill(engine)`, and the engine slot is set to `None` (`miles/utils/health_monitor.py`). 4. Sleep `--rollout-health-check-interval` seconds, then repeat. ### Flags | Flag | Default | Source help text | | - | - | - | | `--rollout-health-check-interval` | `30.0` | "Interval in seconds between rollout engine `/health_generate` checks during generate/eval." | | `--rollout-health-check-timeout` | `30.0` | "Timeout in seconds to wait for a rollout engine `/health_generate` response before killing it." | | `--rollout-health-check-first-wait` | `0` | "Initial grace period (in seconds) before starting health checks. This allows time for model compilation and initialization. Increase this value significantly when using deepgemm." | ## Engine recovery When `--use-fault-tolerance` is on, `MegatronActor.update_weights` calls `inference_controller.recover_updatable_engines` before each weight update (`miles/backends/megatron_utils/actor.py`). `recover_updatable_engines` (`miles/ray/rollout/inference_controller.py`): 1. Pauses health monitoring. 2. Calls `srv.recover()` on the updatable server. `srv.recover()` (`miles/ray/rollout/rollout_server.py`, which fans out to `ServerCell.init` in `miles/ray/rollout/server_cell.py`): 1. Finds engine slots set to `None` (killed by the health monitor). 2. Calls `start_engines` for each affected group. 3. Releases memory occupation on the new engines. After `recover_updatable_engines` returns, the weight updater connects to the new engines and the next weight transfer proceeds normally. ## P2P weight transfer timeouts When `--update-weight-transfer-mode p2p` is on, each write is bounded by Mooncake's own timeout (`MC_TRANSFER_TIMEOUT`, default 30 s, plus 1 ns per byte written). At the end of the update the sender waits up to `--p2p-transfer-timeout` (default `30.0`s) for writes still in flight; a write that failed or is still running fails the update, and the error names each such rollout engine rank and its session (`miles/backends/training_utils/weight_update/protocols/p2p.py`). There is no automatic retry or broadcast-mode fallback. ## Dumper-mode interaction In dumper mode (`miles/utils/arguments.py`), Miles forces `use_fault_tolerance = False` and `rollout_health_check_interval = 1e18` to keep heartbeats from firing. # Advanced Features Source: https://miles.radixark.com/docs/advanced/index Systems-level features for large-scale and long-running RL. This section covers the Miles features that the Core-features section of the homepage points at: low-precision training (FP8 / MXFP8 / NVFP4 / INT4 QAT), Rollout Routing Replay for MoE, fast weight updates over P2P RDMA, disaggregated RL rollout through an external service, fault tolerance, speculative decoding, sampling-support replay, and LoRA training and serving. > **[Low Precision RL](/docs/advanced/low-precision)** > > Unified block-wise FP8, MXFP8, and NVFP4 recipes with matched training and > rollout precision. > **[INT4 QAT](/docs/advanced/int4-qat)** > > W4A16 quantization-aware training for fitting large models on a single > 8-GPU node. > **[Rollout Routing Replay (R3)](/docs/advanced/miles-router)** > > Capture expert routing during inference and replay during training. The > mechanism that keeps MoE RL stable. > **[Speculative Decoding](/docs/advanced/speculative-decoding)** > > Draft + target speculative rollout, with online MTP-SFT for the draft. > **[Sampling-Support Replay](/docs/advanced/sampling-support-replay)** > > Preserve top-p and top-k rollout probabilities when the actor recomputes > token log probabilities for training. > **[On-Policy Distillation](/docs/advanced/on-policy-distillation)** > > Train a student on its own rollouts while matching teacher token > probabilities through SGLang or Megatron teacher modes. > **[Disaggregated RL Rollout](/docs/advanced/disaggregated-rollout)** > > Scale rollout across clusters and regions through an independent service, > with versioned policy publication and request attribution. > **[LoRA Training and Serving](/docs/advanced/lora)** > > Train LoRA adapters with SFT or RL and serve them through SGLang from the > same checkpoint. # INT4 Quantization-Aware Training Source: https://miles.radixark.com/docs/advanced/int4-qat Train MoE policies with fake-quantized expert weights in Megatron and packed W4A16 rollout weights in SGLang. miles INT4 QAT keeps actor parameters in Megatron's training dtype (BF16 by default) and applies symmetric INT4 fake quantization to routed MoE expert weights during each Megatron forward. SGLang serves the routed expert projections from a packed W4A16 checkpoint. The purpose is to expose training forwards to the weight quantization used by rollout. The hook does not store Megatron parameters, optimizer state, gradients, or activations in INT4, so it does not reduce their storage. INT4 QAT uses the Megatron backend and covers routed experts implemented by Transformer Engine `GroupedLinear`. Attention, embeddings, routers, shared experts, dense MLPs, and the LM head remain in their configured precision. ## Component roles | Component | Role | | - | - | | Hugging Face checkpoint | `--hf-checkpoint` initializes SGLang and defines the rollout storage format. The generic miles exporter reads its quantization config when packing live updates. | | Megatron | Keeps trainable weights in the configured training dtype. Before a grouped-expert GEMM, it quantizes each weight group to INT4 and dequantizes it back to the input weight's dtype. | | Megatron Bridge or the miles raw-mode weight exporter | Maps updated Megatron weights to Hugging Face names. For an INT4 rollout checkpoint, the Kimi Bridge returns packed tensors itself; the generic miles exporter packs eligible `.weight` tensors. | | SGLang | Loads the packed tensors with a compressed-tensors W4A16 MoE implementation and runs the expert GEMMs with 16-bit activations. | The `W4` in W4A16 describes the stored SGLang expert weights. On the trainer, INT4 is simulated in the forward pass; the underlying Megatron parameters stay in the configured training dtype. `A16` means the expert GEMM uses BF16 or FP16 activations rather than quantized activations. ## Weight lifecycle 1. SGLang loads the compressed Hugging Face checkpoint from `--hf-checkpoint`. 2. Megatron initializes the actor in its training dtype. Kimi-K2.5 can load the packed checkpoint directly through Megatron Bridge; the maintained Qwen3 raw-mode recipe initializes from a higher-precision Megatron checkpoint. 3. During training, Megatron fake-quantizes every routed-expert `GroupedLinear` weight immediately before the forward GEMM. Backward uses a straight-through estimator (STE), so the optimizer updates the original trainable weight. 4. At a weight-update boundary, Megatron Bridge or the miles raw-mode exporter exports Hugging Face-named tensors. The Kimi-K2.5 Bridge returns routed experts already packed as INT4; the generic miles path packs them from the checkpoint quantization config. 5. SGLang opens a weight-update session and loads the new packed tensors. On the CUDA/Marlin WNA16 path used by the registered H200 tests, the session restores the checkpoint-facing tensor shapes before loading and rebuilds the Marlin layout before rollout resumes. For a Megatron weight group `w`, fake quantization is: ```text theme={null} scale = max(max(abs(w)) / 7, 1e-5) fake_w = clamp(round(w / scale), -7, 7) * scale ``` ## Quantization settings The rollout checkpoint and live exporter use the compressed-tensors storage contract: * Format: compressed-tensors `pack-quantized` with `num_bits: 4`, `strategy: group`, no activation quantization, and `symmetric: true`. * Tensor scope: to match the current Megatron hook, pack the routed expert projections handled by `GroupedLinear` and keep other modules in their checkpoint dtype. * Shape: the generic miles packer and direct converter require the final dimension of each packed matrix to be divisible by the group size. Megatron's fake-QAT hook is configured separately through `OPEN_TRAINING_INT4_GROUP_SIZE`. Set it to the checkpoint's `group_size` when the training forward should use the same grouping as rollout. The two paths are separate implementations, so matching this value does not by itself establish bitwise-identical train and rollout weights. Changing the environment variable does not convert a checkpoint. Changing only the checkpoint config does not change the fake-quantization grid used by Megatron. ## Kimi-K2.5 [Kimi-K2.5](https://huggingface.co/moonshotai/Kimi-K2.5) publishes routed expert weights as symmetric group-size-32 INT4 tensors. The maintained Kimi launcher exercises this Bridge path. With `--megatron-to-hf-mode bridge`, the Kimi Bridge handles both directions: * On Hugging Face to Megatron load, it unpacks the INT4 expert tensors and maps them to the Megatron parameter dtype, BF16 in the maintained recipe. * On Megatron to Hugging Face export, it repacks the updated routed experts to group-size-32 INT4 and returns `weight_packed`, `weight_scale`, and `weight_shape` tensors for SGLang. The Bridge can therefore initialize a fresh actor directly from the published INT4 checkpoint: ```bash theme={null} --hf-checkpoint /root/models/Kimi-K2.5 --megatron-to-hf-mode bridge --model-name kimi_k25 ``` When a fresh run has no usable `--load` checkpoint and no `--ref-load`, miles falls back to `--hf-checkpoint` for Bridge initialization. The current `scripts/run_kimi_k25.py` launcher also materializes a BF16 copy and passes it through `--ref-load`; that is how the launcher is written today, not a requirement of the Kimi Bridge. Run the CI-sized two-layer recipe on one 4-GPU H200 node: ```bash theme={null} python scripts/run_kimi_k25.py full-train \ --model-name Kimi-K2.5-2layer \ --num-nodes 1 \ --num-gpus-per-node 4 ``` For the full 32-node recipe, start Ray on the cluster and then run on the head node: ```bash theme={null} python scripts/run_kimi_k25.py prepare \ --model-name Kimi-K2.5 \ --num-nodes 32 MILES_SCRIPT_EXTERNAL_RAY=1 python scripts/run_kimi_k25.py train \ --model-name Kimi-K2.5 \ --num-nodes 32 ``` See the [Kimi-K2.5 model guide](/docs/models/kimi/kimi-k2.5) for its parallelism and RL settings. ## Prepare another MoE checkpoint The miles direct converter creates a symmetric, group-wise INT4 checkpoint without a calibration dataset: ```bash theme={null} python tools/convert_hf_to_int4_direct.py \ --model-dir /root/models/MyMoE-BF16 \ --save-dir /root/models/MyMoE-INT4 \ --group-size 128 ``` The ignore rules are name-based rather than architecture-aware. By default they match `lm_head`, `norm`, `embed`, `self_attn`, `shared_experts`, `mlp.(gate|up|gate_up|down)_proj`, and `mlp.gate`, plus names beginning with `vision_tower` or `mm_projector`. Compare those patterns with a new model's actual Hugging Face tensor names before conversion. The converter requires CUDA and the `fake_int4_quant_cuda` extension installed by the miles image. The converter defaults to group size 32. The current `scripts/run_qwen3_30b_a3b.py --rollout-int4` path uses that default for the rollout checkpoint and sets Megatron fake QAT to group size 128. The path is therefore an example where the rollout storage group and training fake-QAT group are configured independently; it does not simulate the same grouping on both sides. In raw mode, use the INT4 checkpoint for SGLang and a higher-precision `torch_dist` checkpoint to initialize Megatron: ```bash theme={null} --hf-checkpoint /root/models/MyMoE-INT4 --ref-load /root/models/MyMoE-BF16_torch_dist ``` Bridge mode can load the packed checkpoint directly only when that model's Bridge implements the corresponding dequantization path, as Kimi-K2.5 does. ## Enable fake QAT Set both variables in the Ray runtime environment used by every Megatron worker: ```bash theme={null} RUNTIME_ENV_JSON='{ "env_vars": { "OPEN_TRAINING_INT4_FAKE_QAT_FLAG": "1", "OPEN_TRAINING_INT4_GROUP_SIZE": "128" } }' ray job submit --address="http://127.0.0.1:8265" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ -- python train.py ... ``` The Kimi launcher sets the same variables through `U.execute_train`, using group size 32. If the selected Megatron model does not build its routed experts with Transformer Engine `GroupedLinear`, the environment variables do not enable QAT for that model. ## Validate the setup Inspect the checkpoint config before launch. The expression handles both a top-level quantization config and Kimi-K2.5's nested text config: ```bash theme={null} jq '(.quantization_config // .text_config.quantization_config) | { format, quant_method, ignore, weights: .config_groups.group_0.weights }' /root/models/MyMoE-INT4/config.json ``` Confirm that: * `format` is `pack-quantized` and `quant_method` is `compressed-tensors`. * `num_bits` is `4`, `strategy` is `group`, and `symmetric` is `true`. * `group_size` matches `OPEN_TRAINING_INT4_GROUP_SIZE` if train and rollout should use the same grouping. * The safetensors index has `weight_packed`, `weight_scale`, and `weight_shape` entries for the intended routed expert projections, and that other modules remain unpacked. During a smoke test, confirm that SGLang selects a compressed-tensors W4A16 MoE scheme and that a live weight update completes. For a new model, also compare reward, KL, gradient norm, and train-versus-rollout log-probability differences with a BF16 rollout baseline. | Symptom | Check | | - | - | | SGLang fails at load or weight update | Check the packed tensor names, shapes, group size, and checkpoint ignore rules. | | Training runs but QAT has no effect | Check that the environment reached every Megatron worker and that routed experts use Transformer Engine `GroupedLinear`. | | Train and rollout log-probabilities diverge | Check the quantized tensor scope and group size first; MoE routing can be a separate source of mismatch. | | INT4 QAT does not reduce trainer parameter or optimizer storage | Expected. Those states are not stored in INT4. | | The converter cannot import `fake_int4_quant_cuda` | Use the miles CUDA image or build the repository's INT4 QAT extension. | ## Hardware and test coverage The direct converter and generic miles INT4 packer use the CUDA `fake_int4_quant_cuda` extension. SGLang's CUDA WNA16 MoE path requires NVIDIA compute capability 8.0 or newer. SGLang also contains a ROCm WNA16 MoE implementation. The repository registers two INT4 end-to-end tests, the Kimi-K2.5 two-layer Bridge recipe and the Qwen3-30B-A3B raw-mode recipe, both on 4-GPU H200. It does not register an INT4 QAT end-to-end test on ROCm. INT4 reduces storage and weight-update traffic for the packed routed experts. The whole-model reduction is smaller because scales, metadata, and unquantized tensors remain. Rollout throughput depends on the model, batch shape, expert parallelism, and the W4A16 backend SGLang selects. ## Related guides * [Low Precision RL](/docs/advanced/low-precision) * [Kimi-K2.5 model guide](/docs/models/kimi/kimi-k2.5) * [P2P weight transfer](/docs/advanced/p2p-weight-transfer) * [slime low-precision training and rollout](https://thudm.github.io/slime/advanced/low-precision.html) # LoRA Training and Serving Source: https://miles.radixark.com/docs/advanced/lora Train LoRA adapters with miles, synchronize them to SGLang, and run dense, MoE, quantized-rollout, multi-LoRA, and agentic recipes. miles trains the adapter matrices in Megatron and serves the same live adapter from SGLang. The base model stays frozen; at each configured weight-update boundary, miles exports and synchronizes the updated LoRA weights. The default boundary is once per rollout/train iteration. No merge-to-base or checkpoint conversion is required inside the training-rollout loop. ## Training and rollout lifecycle For a single adapter, miles exports the updated LoRA tensors at each configured publish boundary. Colocated jobs load them through the local tensor/IPC path; disaggregated Bridge jobs broadcast them to remote SGLang engines over NCCL. Rollout requests then select the live named adapter with `lora_path`. Multi-LoRA currently supports disaggregated rollout only and rejects `--colocate` at launch. Each SGLang engine keeps the base checkpoint resident; adapter weights reach the engines through explicit pushes: each push registers an immutable version under its own engine-side name, so in-flight sampling against an older version is never disturbed. Model support is therefore a three-way contract rather than a hard-coded allowlist: 1. Megatron-Bridge (or a model-specific provider) must build and wrap the training model. 2. miles must map and export the selected module names correctly. 3. SGLang must be able to allocate and apply the same adapter modules. A model can support attention-only LoRA while still having caveats for expert, linear-attention, or model-specific projections. Start from a validated recipe instead of assuming that any checkpoint with familiar module names will work. ## LoRA training implementations Both implementations use `--train-backend megatron`. The difference is how LoRA is attached to the Megatron model and converted for serving; this is separate from SGLang's serving-side `--sglang-lora-backend` choice. | Implementation | How the adapter is built | Model coverage on current `main` | Status | | - | - | - | - | | **Megatron-Bridge PEFT** | `AutoBridge` builds the provider, applies Bridge LoRA before DDP, and exports HF-named adapter tensors. Select it with `--megatron-to-hf-mode bridge`. | Qwen2.5, Qwen3, GPT-OSS, Kimi K2.5, GLM-5/5.1/5.2/5.3, and Qwen3.5/3.6, subject to the evidence and module caveats below. | General production path. Current multi-LoRA also requires this path. | | **Native / raw-mode LoRA** | Under `--megatron-to-hf-mode raw`, miles builds the model with its own Megatron provider and the `miles.backends.megatron_utils.lora.native` plugin attaches adapter modules directly before DDP. | Architectures registered in `miles/backends/megatron_utils/lora/native/registry.py`: fused-QKV GQA (Llama, Qwen2/3, GLM-4), Qwen3.5/3.6/Next hybrids (GQA layers), MLA (DeepSeek-V3/V3.2, Kimi K2/K2.5, GLM-4.7-Flash, GLM-5.x), Inkling, and Kimi K3. | Single-LoRA, colocated. Unregistered models fail at startup. | > [!NOTE] > > The planned maintenance direction is native-first: after the generalized native plugin > lands, new model enablement and ongoing LoRA maintenance will primarily target > the native path. Bridge remains the compatibility path for existing validated > recipes during the transition; this is not an immediate Bridge deprecation. ### Native LoRA Native LoRA attaches adapter modules directly to raw-mode Megatron models instead of relying on Bridge PEFT conversion. An architecture spec declares, once, which HF projections exist on which physical linear and how each is sharded; the same declaration drives attachment, HF export/import, and SGLang serving targets. The spec is selected from the checkpoint's `model_type`. Adapters cover attention projections (GQA Q/K/V/O, MLA Q-A/Q-B/KV-A/KV-B/O), dense MLPs, and shared experts. Routed experts carry shared-outer adapters only for Inkling and Kimi K3 (which require `--experts-shared-outer-loras`); Inkling's spec also covers its output head. Inkling and Kimi K3 accept only their verified adapter layout (Kimi K3 may omit the routed-expert down projection), so omit `--target-modules` for them. Selecting a projection the spec does not implement (for example routed experts or GDN input projections) fails at startup; use `--megatron-to-hf-mode bridge` for those. A partial fused selection (for example only `q_proj`) trains only the selected projection; SGLang serves its fused-buffer siblings as zero adapters. A shared expert's `up_proj` needs `gate_proj` selected too. ## Validated models and recipes The table distinguishes CI-tested configurations from maintained recipes or full-scale experiment evidence. It is not an exhaustive model whitelist. | Implementation | Model family | Architecture exercised | Evidence | Notes | | - | - | - | - | - | | Bridge | Qwen2.5 0.5B / 3B | Dense | [0.5B CUDA and ROCm E2E](https://github.com/radixark/miles/blob/main/tests/e2e/lora/test_lora_qwen2.5_0.5B.py), [3B disaggregated recipe](https://github.com/radixark/miles/blob/main/examples/lora/run-qwen2.5-3B-megatron-lora-disaggregated.sh) | The simplest starting point; `all-linear` works. | | Bridge | Qwen3 4B | Dense | [Single-LoRA recipe](https://github.com/radixark/miles/blob/main/examples/lora/run-qwen3-4B-megatron-lora.sh), [AMD launcher](https://github.com/radixark/miles/blob/main/scripts/amd/run_qwen3_4b_lora.py) | The AMD launcher uses Triton attention and LoRA backends. | | Bridge | Qwen3-30B-A3B | MoE | [Multi-LoRA gateway example](https://github.com/radixark/miles/tree/main/examples/multi_lora) | Disaggregated Tinker training and versioned sampling. | | Bridge | GPT-OSS 20B | MoE | [Recipe](https://github.com/radixark/miles/blob/main/examples/lora/run-gpt-oss-20B-megatron-moe-lora.sh), [AMD launcher](https://github.com/radixark/miles/blob/main/scripts/amd/run_gpt_oss_20b_lora.py), [MoE LoRA E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_gpt_oss_20b_moe_lora_ci.py) | Uses the SGLang `triton` LoRA backend; the AMD launcher also uses Triton attention and MoE backends. | | Bridge | Kimi K2.5 | Multimodal MoE + MLA | [16-node recipe](https://github.com/radixark/miles/blob/main/examples/lora/run-kimi-k25-megatron-lora.sh) | Demonstrates shared-outer expert LoRA and an INT4 rollout / fake-QAT setup. | | Bridge | GLM-5 / 5.1 / 5.2 744B-A40B | MoE + MLA + DSA | [GLM-5.1 launcher](https://github.com/radixark/miles/blob/main/scripts/run_glm5_1_744b_a40b_lora.py), [GLM-5.2 launcher](https://github.com/radixark/miles/blob/main/scripts/run_glm5_2_744b_a40b_lora.py) | CI covers reduced 6-layer / 5-layer checkpoints; historical full-744B results are described below. | | Bridge | Qwen3.5 / Qwen3.6 35B-A3B | Hybrid GDN + MoE | [Launcher](https://github.com/radixark/miles/blob/main/scripts/run_qwen3_5_35b_a3b_lora.py), [Qwen3.5 E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/test_qwen3_5_35b_a3b_lora_ci.py) | Uses explicit wildcard targets to exclude MTP and vision modules. | | Native / raw | Inkling / Inkling-Small | Native multimodal MoE | [Launcher](https://github.com/radixark/miles/blob/main/scripts/run_inkling.py), [Inkling-Small 6-layer E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_inkling_small_6layer_lora_ci.py) | Larger profiles are launcher/experiment evidence rather than LoRA CI. | | Native / raw | Qwen3 0.6B, Qwen3.5-35B-A3B | Dense GQA; gated-GQA + GDN hybrid MoE | [Launcher](https://github.com/radixark/miles/blob/main/examples/lora/run_qwen3_lora_native.py), [Qwen3 0.6B E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_qwen3_0_6b_lora_native_ci.py), [Qwen3.5 E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_qwen3_5_35b_a3b_lora_native_ci.py) | Attention (+ dense/shared MLP) adapters; GDN layers carry none. | | Native / raw | GLM-4.7-Flash, GLM-5.2, Kimi K2.5 | MLA MoE | [Launcher](https://github.com/radixark/miles/blob/main/examples/lora/run_lora_native.py), [GLM-5.2 5-layer E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_glm5_2_5layer_lora_native_ci.py), [Kimi K2.5 2-layer E2E](https://github.com/radixark/miles/blob/main/tests/e2e/megatron/model_scripts/test_kimi_k25_2layer_lora_native_ci.py) | MLA attention adapters; routed experts require Bridge. | | Native / raw | GLM-5.3-Flash | Hybrid KDA + DSA MoE + mHC | [Launcher](https://github.com/radixark/miles/blob/main/scripts/run_glm5_3_flash_lora.py), [model page](/docs/models/glm/glm5-3-flash) | KDA, DSA (except `kv_b_proj`) and dense/shared MLP adapters; routed experts stay frozen. Serving needs SGLang LoRA on the KDA projections. | ## Quick start Append the following to a Megatron dense-model recipe: ```bash theme={null} LORA_ARGS=( --lora-rank 32 --lora-alpha 32 --lora-dropout 0.0 --target-modules all-linear --megatron-to-hf-mode bridge ) ``` `--lora-rank 0` is the default and disables LoRA when no adapter path is provided. For RL, dropout is normally set to zero. Alpha is recipe-dependent: maintained recipes use both `alpha = rank` and `alpha = 2 * rank`. > [!WARNING] > > This quick start is the Bridge path. Native LoRA uses `--megatron-to-hf-mode raw` > with a torch\_dist base checkpoint and a registered architecture (see Native LoRA > above). FSDP does not currently implement LoRA training. Omitting `--target-modules` or passing `all-linear` uses the model defaults from `miles/utils/lora/hf_lora_targets.py`: attention + MLP, with model-specific exclusions and output-head defaults. Multi-LoRA without explicit targets selects all three training groups. Use `--target-modules attn,mlp,unembed` to select groups explicitly. Ordinary LoRA also accepts specific HF targets mixed with group names, such as `--target-modules attn,lm_head`; Tinker accepts only group names. ### Core arguments | Flag | Default | Purpose | | - | -: | - | | `--lora-rank` | `0` | Adapter rank; a positive value enables LoRA. Supplying an adapter path also marks LoRA enabled, but still requires matching positive-rank configuration. | | `--lora-alpha` | `16` | Adapter scaling factor. | | `--lora-dropout` | `0.0` | Dropout on the adapter path. | | `--lora-type` | `lora` | `lora` uses fused Megatron projections; `canonical_lora` uses split Q/K/V and gate/up projections. The canonical path is implemented and covered by fast name-mapping tests, but has no maintained recipe or E2E validation. | | `--target-modules` | none | Uses HF model defaults when omitted. Accepts `all-linear`, `attn/mlp/unembed` groups, HF leaf names or scoped HF paths; Bridge also accepts Megatron selectors. | | `--exclude-modules` | none | Comma-separated HF leaf names or scoped HF paths removed after selection; Bridge also accepts Megatron selectors. | | `--lora-adapter-path` | none | Warm-start/resume path. Also provide the matching positive rank, alpha, and target modules. Bridge training resume currently requires miles' per-rank adapter shards and the same parallel topology; an HF PEFT-only adapter cannot yet be loaded directly into the Bridge model. Inkling native has its own HF adapter loader. | | `--lora-base-cpu-backup` | off | Colocated mode only: keep a CPU mirror of the frozen SGLang base and avoid re-sending base weights. This trades host RAM for faster and more reliable pause/resume. | | `--lora-train-only` | off | Train the adapter while keeping ordinary rollout engines on the frozen base policy. | | `--experts-shared-outer-loras` | off | Use shared outer factors for grouped MoE experts. This layout is not checkpoint-compatible with per-expert LoRA. | | `--check-lora-weight-equal` | off | On the colocated path, verify each synchronized adapter tensor with SHA-256. | | `--update-weights-interval` | `1` | Publish new weights every N rollout/train iterations. This is not LoRA-specific, but it controls when the live adapter is synchronized. | ### HF target source of truth `miles/utils/lora/hf_lora_targets.py` owns **HF target groups and defaults** for all backends. It derives attention, MLP, and output-head paths from the model config, including nested text models, optional MLA projections, expert layouts, and hybrid attention. Vision towers, routers, norms, and GDN convolutions are excluded. | Model layout | Attention | MLP | Ordinary LoRA default | | - | - | - | - | | Llama, Qwen2/2.5, Qwen3 | Q/K/V/O | Dense gate/up/down | Attention + MLP | | Qwen3 MoE | Q/K/V/O | Routed experts; dense layers when configured | Attention + MLP | | Qwen3-Next | Q/K/V/O + GDN qkvz/ba/out | Routed + shared experts; configured dense layers | Attention + MLP | | Qwen3.5/3.6, text and multimodal | Q/K/V/O + GDN qkv/z/b/a/out | Dense or packed routed + shared experts | Attention + MLP | | GPT-OSS | Q/K/V/O | Packed gate\_up/down experts | Attention + MLP | | DeepSeek V2/V3, Kimi K2/K2.5 | MLA | Dense + routed + shared experts as configured | Attention + MLP | | DeepSeek V3.2, GLM-5/5.1/5.2 | MLA + DSA indexer | Dense + routed + shared experts as configured | Attention + MLP, excluding indexer | | GLM-4 MoE | Q/K/V/O | Dense + routed + shared experts as configured | Attention + MLP | | Inkling | Q/K/V/R/O | Dense + packed routed + shared expert projections | Attention + MLP + output head | `--target-modules` accepts comma-separated `attn`, `mlp`, and `unembed` groups, explicit HF targets, or a mix of both. Group names expand through the model's HF definition, so `mlp` includes dense, shared, and routed expert projections, including packed `gate_up_proj` weights. Only exact group names expand; full paths remain literal target patterns. Duplicates are removed, then `--exclude-modules` applies. Explicit module selections must not overlap exclusions; defaults and group selections may still be narrowed with exclusions. A matching pair of `gate_proj` and `up_proj` selectors also selects `gate_up_proj` when the HF model has packed projections, with a warning. Scoped pairs retain their path prefix; selecting only gate or only up does not expand to the packed projection. Split selectors remain where the model has split modules and are replaced where it has only packed modules. Without explicit targets, ordinary LoRA uses its model defaults and multi-LoRA selects all three groups. `all-linear` selects the ordinary model defaults and must be used alone. Explicit lists replace the defaults. Packed expert entries identify HF parameters rather than `nn.Linear` modules. Inkling entries follow the native HF model namespace. Its native Megatron LoRA implementation requires the complete fixed layout. SGLang discovers its adapter modules with `all`, and online weight-sync config uses `all-linear`; adapter checkpoints derive explicit names from exported tensors. Adapter factors, tensor packing, and checkpoint names are unchanged. The pinned Transformers version does not yet include native Inkling, so its HF structure is not covered by the native meta-model tests. A layout entry is not a backend support claim. Ordinary LoRA and Tinker share these HF target groups. HF targets retain their meaning throughout training and serving. `miles/utils/hf_utils/weight_mapping.py` uses Transformers conversion rules and a meta model's parameter names to relate checkpoint keys to the current HF model namespace, without loading base weights. This resolves renamed and packed parameter names. Custom models absent from native Transformers keep their existing checkpoint namespace. Bridge resolves mappings against parameters that actually exist across PP/EP ranks before checking fused selections. Explicit Megatron selectors retain a compatibility conversion at startup. Adapter factories receive only Megatron targets; they do not overwrite the HF selection. Arbitrary layer/expert subsets within a registry template are not implemented and are rejected. Standard LoRA requires all projections of a fused weight together; `canonical_lora` supports individual Q/K/V and dense gate/up selections. Grouped-expert FC1 and GDN input projections remain fused and require all of their HF projections even in canonical mode. Tinker uses `--target-modules attn,mlp,unembed` by default; for example, `--target-modules attn,mlp` disables output-head training. Client SDK flags must match the selected server groups. Tinker rejects explicit module names, `all-linear`, and `--exclude-modules` because the SDK describes only whole groups. For Bridge, SGLang receives the selected HF paths and normalizes them into buffer types (for example, Q/K/V become `qkv_proj`); it does not own the selection policy. Adapter checkpoints derive their concrete `target_modules` from exported tensor keys rather than a separate Megatron-to-HF name table. Online adapter registration uses the resolved HF selection for Bridge and native LoRA, and `all-linear` for native Inkling, without an extra tensor export. This requires [SGLang's HF-path normalization support](https://github.com/sgl-project/sglang/pull/40242), including GDN split names. FSDP LoRA injection remains unsupported. Native LoRA validates the resolved HF selection against the architecture spec before injection and matches each adapter by its full HF path, so layer-scoped selections work. Native Inkling uses a fixed adapter schema: defaults select that complete schema, and partial/custom layouts are rejected. `canonical_lora` is Bridge-only. ### Rollout topology | Topology | Adapter transport | Requirements | | - | - | - | | Colocated | Local tensor serialization / CUDA IPC | Add `--colocate`; large-model recipes generally also use `--lora-base-cpu-backup`. Pipeline parallel adapter shards are assembled before the load. | | Disaggregated, including multi-node | NCCL broadcast to remote SGLang engines | Use `--update-weight-transfer-mode broadcast`, Bridge mode, and `--pipeline-model-parallel-size 1`. | Native LoRA is colocated only. Remote/disaggregated LoRA sync requires Bridge. On SGLang attention DP (`--sglang-attn-dp-size N`, or the deprecated `--sglang-enable-dp-attention --sglang-dp-size N`), SGLang serves LoRA only from pinned adapters and requires the attention DP size to equal the engine's TP size (sgl-project/sglang#36389). miles pins the rollout adapter there and gives the engine a second LoRA slot, which SGLang keeps for the base model. Multi-LoRA cannot run on attention DP: pinned adapters are never evicted. LoRA is not supported by the P2P/RDMA or disk-delta weight-transfer modes. A hybrid job with both colocated and additional remote rollout engines also cannot send LoRA weights to the remote engines today. The legacy `--lora-sync-from-tensor` flag is still accepted by the parser but is not consumed by the implementation. The topology selects the synchronization path automatically; do not rely on this flag. ## MoE adapters This section describes the Bridge path; native Inkling uses its fixed custom expert layout. For grouped MoE experts, the training and serving layouts must agree. A typical Bridge configuration is: ```bash theme={null} LORA_ARGS=( --lora-rank 32 --lora-alpha 32 --lora-dropout 0.0 --target-modules "gate_up_proj,down_proj" --sglang-lora-backend triton --megatron-to-hf-mode bridge ) ``` The SGLang `triton` backend is required by the maintained MoE recipes; the default backend can skip MoE layers. Per-expert adapters are the default. `--experts-shared-outer-loras` selects the shared-outer layout, and miles enables SGLang virtual-expert serving for expert adapters by default. Use `--no-sglang-lora-use-virtual-experts` only when intentionally selecting the alternative aligned-expert path. ## Supported features * **Losses and algorithms.** LoRA wraps the actor model independently of the policy loss. The maintained RL recipes use critic-free estimators such as GRPO. The SFT loss path can use the same adapter hooks, although the repository does not yet have a dedicated LoRA-SFT E2E test. Shared actor/critic PPO with Bridge LoRA is untested: the critic is always built as a full model without adapters. * **Checkpoints.** miles saves native per-rank adapter shards and optimizer/scheduler state. Exact resume expects the same TP/PP topology. It also attempts a best-effort HF PEFT `adapter_model.safetensors` plus `adapter_config.json` export in Bridge mode, through the same snapshot publisher used by Tinker. HF export errors are logged while native checkpoint saving continues. Raw mode saves native shards and a rank-sharded adapter config without HF export. `--save-hf` exports a merged model and an HF adapter without native training shards; native LoRA merges each adapter into its host weights before the ordinary base-weight conversion, so the merged model uses the same converters as full-model exports. Direct HF PEFT-to-Bridge resume is not implemented yet; native LoRA imports HF adapters through `--lora-adapter-path`. * **Weight synchronization.** Colocated IPC and remote NCCL broadcast both ship adapter tensors at each configured update boundary without merging them into the base. A checksum checker is available for the colocated path. * **Model structures.** Dense attention/MLP, MLA, GDN, multimodal models, models containing DSA, and both per-expert and shared-outer MoE adapters have validated recipes or tests. DSA-indexer adapters themselves are not validated; see the model-specific caveats in the table above. * **Agentic sessions.** Session-server requests carry the active single-LoRA name, so multi-turn trajectories are generated by the updated policy rather than the frozen base. See the experimental example below. ### Quantized rollout Precision support is asymmetric: the validated FP8 configuration trains the LoRA actor in BF16 while SGLang serves a separately quantized FP8 base checkpoint. Adapter tensors remain unquantized and are synchronized at each configured weight-update boundary. The GLM-5.2 launcher exposes this as its `--fp8-rollout` option and writes an SGLang config with `update_weights: true`. See [Low Precision RL](/docs/advanced/low-precision) and [INT4 QAT](/docs/advanced/int4-qat) for the underlying precision features. Historical GLM-5.2 validation measured: | Configuration | Train/rollout abs diff | KL | | - | -: | -: | | 5-layer GLM-5.2, BF16 rollout | `0.0104` | `1.2e-4` | | 5-layer GLM-5.2, FP8 rollout | `0.0347` | `9.8e-4` | | Full GLM-5.2 744B, FP8 rollout, two 16-GPU engines | `0.0196` | `2.7e-3` | These values are configuration-specific rather than universal FP8 thresholds. The full-744B row is historical multi-node evidence and cannot be reproduced directly by the current single-node `main` launcher; multi-node launcher work is tracked in [PR #2033](https://github.com/radixark/miles/pull/2033). The same work also produced the following curves from a different, longer validation run. The exact configuration for these curves was not published, so their plotted levels should not be mapped to any one of the final point measurements in the table: ![GLM-5.2 LoRA FP8 rollout train-rollout log-prob difference](https://mintcdn.com/radixark/1Ix2r8uv4-Uc24OW/assets/images/lora-glm5-2-fp8-logprob.png?fit=max\&auto=format\&n=1Ix2r8uv4-Uc24OW\&q=85\&s=c43e2ce2ca7940207739b1bfed383be3) *Train/rollout log-prob difference in the longer FP8 rollout validation.* ![GLM-5.2 LoRA FP8 rollout reward](https://mintcdn.com/radixark/1Ix2r8uv4-Uc24OW/assets/images/lora-glm5-2-fp8-reward.png?fit=max\&auto=format\&n=1Ix2r8uv4-Uc24OW\&q=85\&s=b3aed42e101b9f90ef3df4c608ce8c37) *Raw reward in the longer FP8 rollout validation.* Kimi K2.5 provides a separate model-specific INT4 example: SGLang serves the INT4 checkpoint while the trainer uses BF16 fake-QAT and TIS correction. Do not generalize these two recipes into blanket support for FP8, MXFP8, or INT4 LoRA training on every model. In particular, multi-LoRA on MoE expert leaves rejects FP8/FP4 experts. ## GLM-5.2 744B BF16 validation Historical full-scale runs validated the GLM-5/5.1/5.2 Bridge LoRA path across MoE and MLA on models containing DSA; the DSA indexer itself was excluded from the adapter targets. The full GLM-5.2 744B run used 64 GPUs and completed more than 50 rollout -> train -> save steps. The validation also compared expert target selections, including a run that excluded `down_proj`. Treat this as historical implementation and scale evidence. The reported train/rollout log-prob gaps are configuration-specific and are not established as a portable acceptance threshold, so the corresponding curves are not reproduced here. > [!WARNING] > > This historical validation is not a claim that the current `main` launcher > reproduces the 744B run on one node. The checked-in launcher is single-node; the > repository's copy-paste validation path uses a reduced checkpoint. Multi-node > full-744B launcher work is tracked in > [PR #2033](https://github.com/radixark/miles/pull/2033). ## Agentic RL with LoRA (experimental) Agentic rollout has one extra correctness requirement: every turn sent through the session server must select the newly synchronized adapter. The session server therefore attaches `lora_path=miles_lora` to its requests; otherwise the trainer could update LoRA while the agent continues collecting trajectories from the frozen base policy. The current session integration selects the fixed single-adapter name; it is not multi-LoRA slot routing, and it should not be combined with `--lora-train-only`. [Draft PR #2280](https://github.com/radixark/miles/pull/2280) adds the first LoRA-specific agentic recipe: GLM-5.2 744B-A40B with synchronous GRPO on Terminal-Bench-2-style tasks, a Harbor agent server, and Daytona sandboxes. Its reference configuration uses: * 4 nodes x 8 H200, TP8 / EP32 / PP1 / CP1; * BF16 training with an FP8 rollout checkpoint; * rank 16, alpha 32, attention/MLA-only targets; * 64K sessions, TileLang DSA, full recompute, and disk actor offload; and * two 16-GPU rollout engines with `--lora-base-cpu-backup`. The author reports 30+ 64K-context GRPO rollouts with stable rewards and no NaN/OOM, but the PR does not publish a reward value, solve rate, or learning curve. It remains a draft and depends on an open memory-headroom companion change ([PR #2199](https://github.com/radixark/miles/pull/2199)), so treat it as stability evidence rather than a released benchmark. ## Multi-LoRA training Multi-LoRA training is served through the Tinker protocol: `serve_tinker.py` turns a miles deployment into a training service where each client drives its own adapter slot with explicit forward\_backward / optim\_step operations (the server owns no datasets or schedules). The gateway lives in `miles/tinker/`, the slot mechanics in `miles/backends/megatron_utils/lora/`. The dataset-driven multi-LoRA v1 backend (fully-async driver, adapter controller, per-adapter data sources) has been removed. Clients use the official `tinker` SDK with the gateway URL and a distinct API key per tenant. The key identifies ownership of models, futures, and checkpoints. Each adapter accumulates gradients until its client submits an optimizer step; saving weights for sampling publishes an immutable adapter version. `save_state` saves adapter parameters and optimizer state, including FP32 masters. It waits for preceding commands but neither steps nor saves pending gradients; call it after `optim_step` to save the effect of the accumulated training work. Sampler saves commit an immutable adapter directory on shared storage, without contacting inference engines. Engines load the same frozen base at startup and load adapter snapshots from disk on demand. A failed engine load fails sampling without invalidating the saved snapshot or model training. Futures, model leases, and sequence deduplication are in memory and are lost on gateway restart. Saved checkpoints retain their ownership and adapter metadata and can be used to create a new training or sampling client. See the [gateway example](/docs/examples/multi-lora) for the launcher and smoke client. # Low Precision RL Source: https://miles.radixark.com/docs/advanced/low-precision Unified low-precision pipelines for RL — block-wise FP8, MXFP8, and NVFP4 across rollout and training. A common failure mode in MoE RL is precision drift between training and inference. Pipelines that train in BF16 and serve in FP8 accumulate per-layer numerical disagreement, which compounds into divergent log-probabilities and gradients pointing in unintended directions. Miles supports a unified low-precision path where rollout and training share the same quantization logic on the forward pass. The same path is wired up for three formats today — **block-wise FP8**, **MXFP8**, and **NVFP4** — plus the lower-friction "BF16 train + FP8 inference" mode that's useful when standing up a new model architecture. ## Choose a precision | Format | Block layout | Hardware | Models tested | Maturity | | - | - | - | - | - | | **BF16** | — | All NVIDIA + AMD MI300X / MI325X / MI350X / MI355X | All | Baseline | | **FP8 block-wise** (DeepSeek-style) | 128×128, FP32 scales | Hopper (H100 / H200), Blackwell (B200+), AMD MI350X / MI355X | Qwen3-4B, Qwen3-30B-A3B, DeepSeek-V4-Flash | Generally available | | **MXFP8** | 1×32, UE8M0 scales | Blackwell only (B200, B300, GB200, GB300) | Qwen3-30B-A3B, DeepSeek-V3.2 | Beta | | **NVFP4** (E2M1) | 1×16, two-level (FP8 + FP32) scales | Blackwell only (B200, B300, GB200, GB300) | Qwen3-30B-A3B | Beta | ## Rollout × training compatibility Each row is a rollout (inference) precision; each column is the trainer's forward precision. ✅ = supported; ✗ = not supported. | Rollout \ Train | BF16 | FP8 block-wise | MXFP8 | NVFP4 | | - | - | - | - | - | | **BF16** | ✅ baseline | ✗ | ✗ | ✗ | | **FP8 block-wise** | ✅ | ✅ Hopper + Blackwell + MI350X / MI355X | ✗ | ✗ | | **MXFP8** | ✅ | ✗ | ✅ Blackwell | ✗ | | **NVFP4** | ✗ | ✗ | ✗ | ✅ Blackwell | The reference script (`scripts/run_qwen3_30b_a3b.py`) allows only one rollout precision and one training precision at a time. Use paired rollout and training flags for the end-to-end MXFP8 and NVFP4 recipes below. ## Unified training recipe | Stage | Typical pipeline | Miles unified low-precision | | - | - | - | | Rollout (forward) | FP8 / MXFP8 / NVFP4 GEMM | Matching low-precision GEMM | | Trainer (forward) | BF16 GEMM | Matching low-precision GEMM | | Trainer (backward) | BF16 grads | Recipe-specific precision | | Optimizer | BF16 master | Higher-precision master weights | The precision contract covers checkpoint conversion, the trainer forward pass, SGLang rollout, and live weight export. High-precision tensor exceptions must match across all four stages. ## Modes ### 1. BF16 train + FP8 inference The lowest-friction path. SGLang loads FP8 weights while the trainer keeps a BF16 `torch_dist` checkpoint. There is precision drift between the two paths; on MoE workloads, pair this with R3 (and optionally TIS). ```bash theme={null} hf download Qwen/Qwen3-30B-A3B-FP8 --local-dir /root/Qwen3-30B-A3B-FP8 CKPT_ARGS=( --hf-checkpoint /root/Qwen3-30B-A3B-FP8 # FP8 weights for SGLang --ref-load /root/Qwen3-30B-A3B_torch_dist # BF16 torch_dist for trainer ) ``` Reference recipe: [`examples/infra_features/low_precision/run-qwen3-4b-fp8.sh`](https://github.com/radixark/miles/blob/main/examples/infra_features/low_precision/run-qwen3-4b-fp8.sh) — single-node Qwen3-4B. It serves an FP8 checkpoint to SGLang and trains from a BF16 `torch_dist` checkpoint; it sets no `--fp8-recipe`, so the trainer forward stays BF16. ### 2. Unified block-wise FP8 (DeepSeek-style) Rollout and training share the same block-wise FP8 quantization. This is the recipe to use on Hopper, and the layout DeepSeek ships its FP8 checkpoints in. Block layout is 128×128 with FP32 scales. ```bash theme={null} --transformer-impl transformer_engine --bf16 --fp8-format e4m3 --fp8-recipe blockwise # Optional, for MoE numerical stability --use-tis ``` | Flag | Effect | | - | - | | `--transformer-impl transformer_engine` | Routes Megatron's forward through TransformerEngine so FP8 GEMM is engaged. | | `--fp8-format e4m3` | Forward FP8 format used by TransformerEngine. | | `--fp8-recipe blockwise` | 128×128 block-wise quantization; sglang must serve weights in the matching layout. | | `--use-tis` | Truncated Importance Sampling for residual precision drift. | `NVTE_FP8_BLOCK_SCALING_FP32_SCALES` is set for you in the actor env (`miles/ray/train/actor_factory.py`), defaulting by hardware: `1` on Hopper, and `0` on Blackwell, where TransformerEngine emulates the block-wise recipe with MXFP8 and needs power-of-two scales. Override it only if you know you want the non-default for your GPU. For models that already ship 128×128 block-wise FP8 weights (DeepSeek-V3.2, `Qwen/Qwen3-30B-A3B-FP8`), point `--hf-checkpoint` at the block-wise FP8 directory and let SGLang autodetect. Otherwise convert with `tools/convert_hf_to_fp8.py`. For MoE workloads, also consider `--use-rollout-routing-replay` (R3). The canonical recipe leaves it commented out by default but the flag is available. Reference recipe: [`examples/infra_features/low_precision/run-qwen3-30b-a3b-fp8-two-nodes.sh`](https://github.com/radixark/miles/blob/main/examples/infra_features/low_precision/run-qwen3-30b-a3b-fp8-two-nodes.sh) — two-node Qwen3-30B-A3B. ### 3. Unified MXFP8 (Blackwell) MXFP8 uses one-dimensional microscaling blocks: 32 consecutive E4M3 values share one UE8M0 scale. The end-to-end recipe uses MXFP8 for rollout, forward propagation, weight-gradient GEMMs, and data-gradient GEMMs while preserving configured tensors in BF16. ![End-to-end MXFP8 RL recipe](https://mintcdn.com/radixark/Cy4ImdPUZ2E-qOic/assets/images/low-precision/mxfp8-e2e.png?fit=max\&auto=format\&n=Cy4ImdPUZ2E-qOic\&q=85\&s=d60de262ad9dec1d60d192d2e719ff3a) **Hardware:** B200, B300, GB200, or GB300. The Qwen3-30B-A3B launcher prepares the MXFP8 Hugging Face checkpoint and configures both sides of the RL loop: ```bash theme={null} python scripts/run_qwen3_30b_a3b.py prepare \ --hardware B200 \ --rollout-mxfp8 \ --train-mxfp8 python scripts/run_qwen3_30b_a3b.py execute \ --hardware B200 \ --rollout-mxfp8 \ --train-mxfp8 ``` The training path uses: ```bash theme={null} --transformer-impl transformer_engine --bf16 --fp8-format e4m3 --fp8-recipe mxfp8 ``` SGLang selects the MoE and dense linear GEMM backends independently. Common MXFP8 choices are: | Workload | Miles flag | Backends | | - | - | - | | MoE | `--sglang-moe-runner-backend` | `flashinfer_trtllm_routed`, `flashinfer_trtllm`, `cutlass` | | Dense linear GEMM | `--sglang-fp8-gemm-backend` | `flashinfer_trtllm`, `flashinfer_cutlass`, `triton` | Choose the pair that matches the model, parallel layout, and installed SGLang stack. Convert a checkpoint outside the launcher with: ```bash theme={null} python tools/convert_hf_to_mxfp8.py \ --model-dir /root/models/Qwen3-30B-A3B \ --save-dir /root/models/Qwen3-30B-A3B-MXFP8 ``` The converter records a 1x32 weight block and UE8M0 scale layout. It excludes norms, embeddings, routers, and configured high-precision tensors. Low-precision parameter gather is not enabled in the reference recipe, so a higher-precision master weight copy remains present during training. ### 4. NVFP4 (Blackwell) NVFP4 stores E2M1 values in 16-value blocks with an E4M3 scale for each block and an outer FP32 scale. The Miles reference recipe combines NVFP4 and BF16 through tensor-level precision configuration. Activation scaling is computed per token. Gate and up projections are quantized together so the fused rollout GEMM uses the same outer weight scale. The trainer, checkpoint converter, and rollout kernels must use the same scaling contract. **Hardware:** B200, B300, GB200, or GB300. Run the Qwen3-30B-A3B reference recipe with paired rollout and training flags: ```bash theme={null} python scripts/run_qwen3_30b_a3b.py prepare \ --hardware B200 \ --rollout-nvfp4 \ --train-nvfp4 python scripts/run_qwen3_30b_a3b.py execute \ --hardware B200 \ --rollout-nvfp4 \ --train-nvfp4 ``` The launcher selects per-token activation scaling, disables the incompatible 2D quantization, RHT, and stochastic-rounding paths, and loads the matching tensor-level precision configuration. NVFP4 applies only to the routed-expert FC1 and FC2 GEMMs; shared experts and all other unmatched tensors stay in BF16 across checkpoint conversion, training, and live weight export. Rollout uses a BF16 KV cache. The training path uses: ```bash theme={null} --transformer-impl transformer_engine --bf16 --fp4-format e2m1 --fp4-recipe nvfp4 ``` Note the `--fp4-` prefix: NVFP4 has its own format and recipe flags rather than reusing the `--fp8-` pair. The launcher also adds `--optimizer-cpu-offload --overlap-cpu-optimizer-d2h-h2d --use-precision-aware-optimizer`. The base NVFP4 recipe uses **high-precision backward**: the forward pass uses NVFP4 while the BF16 backward GEMMs consume the original BF16 operands. ![NVFP4 with high-precision backward](https://mintcdn.com/radixark/Cy4ImdPUZ2E-qOic/assets/images/low-precision/nvfp4-high-precision-backward.png?fit=max\&auto=format\&n=Cy4ImdPUZ2E-qOic\&q=85\&s=4dee9789d03b2efe660b29d114490cc3) The base recipe settings used by the launcher are: ```bash theme={null} NVTE_NVFP4_DISABLE_2D_QUANTIZATION=1 NVTE_NVFP4_DISABLE_RHT=1 NVTE_NVFP4_DISABLE_STOCHASTIC_ROUNDING=1 NVTE_NVFP4_ROW_SCALED_ACTIVATION=1 NVTE_BACKWARD_OVERRIDE=high_precision NVTE_USE_FAST_MATH=0 SGLANG_FLASHINFER_NVFP4_PER_TOKEN_ACTIVATION=1 TRTLLM_DISABLE_FP4_QUANT_FAST_MATH=1 FLASHINFER_DISABLE_FP4_QUANT_FAST_MATH=1 ``` Convert a checkpoint outside the launcher with the same `NVTE_*` recipe environment: ```bash theme={null} python tools/convert_hf_to_nvfp4.py \ --model-dir /root/models/Qwen3-30B-A3B \ --save-dir /root/models/Qwen3-30B-A3B-NVFP4 ``` Checkpoint conversion and live weight export quantize only routed experts in the main language decoder. MTP weights (including appended decoder layers), vision/audio components, shared experts, and dense layers keep their source precision. The HF converter uses the language `num_hidden_layers` from `text_config` when present, otherwise the top-level config, and recognizes `model.layers`, `language_model.model.layers`, `model.language_model.layers`, and `language_model.layers`. First/last-layer BF16 controls apply to those language layers. Unsupported namespaces remain unquantized. Training-time precision is still controlled separately by `--te-precision-config-file`. #### Advanced: dequantized backward Dequantized backward keeps the backward GEMMs in BF16 but uses BF16 dequantizations of the NVFP4 operands produced during the forward pass. See the humans& discussion of [gradient stability](https://humansand.ai/blog/nvfp4-rl#improving-gradient-stability) for the motivation and ablations. ![NVFP4 with dequantized backward](https://mintcdn.com/radixark/Cy4ImdPUZ2E-qOic/assets/images/low-precision/nvfp4-dequantized-backward.png?fit=max\&auto=format\&n=Cy4ImdPUZ2E-qOic\&q=85\&s=5a03566ad1d59884b672c745faf712b7) Select this mode in the environment used to launch the job: ```bash theme={null} export NVTE_BACKWARD_OVERRIDE=dequantized ``` #### Advanced: four-over-six [Four Over Six: More Accurate NVFP4 Quantization with Adaptive Block Scaling](https://arxiv.org/abs/2512.02010) optionally chooses, for each NVFP4 block, whether mapping the largest FP4 magnitude to 4 or 6 produces less quantization error. See the humans& [four-over-six analysis](https://humansand.ai/blog/nvfp4-rl#four-over-six-for-rl-weights-and-activations) for the RL recipe discussion. An example setting is: ```bash theme={null} export NVTE_NVFP4_4OVER6=all export FLASHINFER_NVFP4_4OVER6=1 export NVTE_NVFP4_4OVER6_E4M3_USE_256=all export FLASHINFER_NVFP4_4OVER6_E4M3_USE_256=1 export NVTE_NVFP4_4OVER6_ERR_MODE=MSE export FLASHINFER_NVFP4_4OVER6_ERR_MODE=MSE export NVTE_NVFP4_4OVER6_ERR_USE_FAST_MATH=0 export FLASHINFER_NVFP4_4OVER6_ERR_USE_FAST_MATH=0 ``` ## Fine-grained BF16 exceptions Miles supports per-layer precision configuration across checkpoint conversion, Megatron training, SGLang rollout, and live weight export. | Control | Purpose | | - | - | | `--num-layers-at-start-in-bf16` | Keep an initial layer range in BF16. | | `--num-layers-at-end-in-bf16` | Keep a final layer range in BF16. | | `--first-last-layers-bf16` | Apply the corresponding Megatron training rule. | | `--extra-high-precision-layers-hf` | Exclude matching Hugging Face tensor names during conversion and rollout export. | | `--extra-high-precision-layers-megatron` | Exclude matching Megatron tensor names during training and live export. | | `--te-precision-config-file` | Select Transformer Engine recipes by Megatron tensor name. | Use equivalent Hugging Face and Megatron name matchers for exceptions beyond a recipe's default scope. NVFP4 checkpoint conversion and live export exclude shared experts, MTP, and non-language components automatically. Common additional exceptions include final transformer layers and MLA projections whose contraction axis does not match a one-dimensional scaling layout. ## Hardware support | GPU | BF16 | FP8 block-wise | MXFP8 | NVFP4 | | - | - | - | - | - | | NVIDIA H100 / H200 | ✅ | ✅ | ✗ | ✗ | | NVIDIA B200 / B300 / GB200 / GB300 | ✅ | ✅ | ✅ | ✅ | | NVIDIA A100 | ✅ | ✗ | ✗ | ✗ | | AMD MI350X / MI355X | ✅ | ✅ | ✗ | ✗ | | AMD MI300X / MI325X | ✅ | ✗ | ✗ | ✗ | ## When BF16 is enough * Dense models below \~30 B. * A100 hardware (no FP8 GEMM). * AMD MI300X / MI325X (no FP8 block-wise path). * Bring-up of a new model architecture, where clean BF16 numerics simplify debugging. ## Further reading * [Blackwell MXFP8 and NVFP4 RL roadmap](https://github.com/radixark/miles/issues/615) * [Towards Blackwell-Native 8-bit and 4-bit RL: End-to-End MXFP8 and NVFP4 RL in Miles](https://www.lmsys.org/blog/2026-07-29-mxfp8-nvfp4-rl) * [The 4-bitter Lesson](https://humansand.ai/blog/nvfp4-rl) * [Faster-than-light NVFP4 decode—with precision](https://humansand.ai/blog/faster-than-light-nvfp4-decode) # Rollout Routing Replay (R3) Source: https://miles.radixark.com/docs/advanced/miles-router Capture expert routing during inference and replay it during training to stabilize RL. Rollout Routing Replay (R3) records the expert routing decisions made during inference and replays them during training, producing bit-identical expert allocation between rollout and training. ## Why MoE RL is unstable without R3 For each token, an MoE router picks `top-k` experts. The choice depends on the input through a soft router and a top-k operation. In production the router is a learned `nn.Linear` with non-deterministic kernels and FP8 quantization, so tiny numerical differences flip routes at the per-layer, per-token level. An example without R3: * Rollout selects experts `{2, 7}` for token 314. * Training (with the same weights but slightly different precision and kernels) selects experts `{2, 8}` for token 314. * The gradient is computed against the wrong expert. Multiplied by hundreds of layers, tens of thousands of tokens, and thousands of training steps, the policy diverges. With R3, the trainer replays the rollout router's expert assignments verbatim, so numerical noise no longer flips routes. ## How R3 wires up **SGLang side.** Miles enables `enable_return_routed_experts` automatically when `--use-rollout-routing-replay` is on. SGLang then includes `routed_experts` in `meta_info` of each response, with shape `(seq_len - 1, num_layers, top_k)` and dtype `int32`. **Miles side.** Enable R3 with: ```bash theme={null} --use-rollout-routing-replay ``` Rollout sends `return_routed_experts=true` in each request and stores the results in `sample.rollout_routed_experts` (`miles/utils/types.py`). The trainer pushes the arrays through `RoutingReplayManager` (`miles/utils/replay_base.py`), and `replay_utils.py` plugs them into the forward pass so recorded routes are used instead of recomputed ones. ## Memory cost `(num_tokens - 1) × num_layers × top_k × 4 bytes` (int32 per element, see `miles/utils/types.py`). For a 32K-token sequence, 60 layers, and `top_k = 8`, that is roughly 60 MB per sample of routing metadata. ## When R3 is not required * The model is dense. * The `--advantage-estimator` is `reinforce_plus_plus` and `--use-tis` already masks the off-policy term. For MoE training with `--advantage-estimator grpo`, current recipes turn R3 on (for example `scripts/run_glm47_flash.py`). ## References * R3 paper: [arXiv 2510.11370](https://arxiv.org/pdf/2510.11370). * Routing replay: [arXiv 2507.18071](https://arxiv.org/abs/2507.18071). # Mooncake Rollout Data Transfer Source: https://miles.radixark.com/docs/advanced/mooncake-rollout-transfer Configure Mooncake Store as the rollout object-store backend in Miles. Miles uses Ray's object store by default to pass rollout data from rollout workers to trainers. Mooncake Store can replace Ray for this handoff. Both `train.py` and `train_async.py` support the Mooncake backend, and the trainer receives the same rollout dictionary with either backend. Rollout-data transfer and model-weight transfer are separate settings: * `--object-store-backend` selects the rollout object store. * `--update-weight-transfer-mode` selects the model-weight transfer path. ## Requirements Before starting a Miles job: * Run the same Miles revision and Mooncake version on every Ray node and on the host that runs `mooncake_master`. * Start `mooncake_master`, or provide a Mooncake HA endpoint, and make the endpoint reachable from every node. Miles connects as a client and does not manage the endpoint lifecycle. * Use routable data-network addresses for Ray and Mooncake clients. * Reserve enough host memory for `global_segment_size` and `local_buffer_size`. * For RDMA, expose the RDMA device to the Miles environment, allow memory locking, and set the local device name on each node. Leaving it to automatic selection can spread host-memory transfers across every HCA and run far slower ([kvcache-ai/Mooncake#4322](https://github.com/kvcache-ai/Mooncake/issues/4322)). This backend imports the structured-object API from `mooncake.structured_object_store` (`FieldSchema`, `MooncakeBundleTransfer`, `export_ref`, `import_ref`), which Mooncake provides from 0.3.12.post1 on; earlier releases ship the module without these names. The Miles CUDA images use the Mooncake package that ships in the SGLang base image, and the ROCm images add Mooncake's Python files from `mooncake-transfer-engine-non-cuda` (see `docker/Dockerfile.rocm`). For a custom environment, install a Mooncake package that matches the CUDA runtime and is compatible with the Miles revision. Follow the [Mooncake installation guide](https://kvcache-ai.github.io/Mooncake/getting_started/build.html) for current package names and supported platforms. See [Versions and Images](/docs/developer/versions) for how the images pick their Mooncake. ## Configure the backend Choose the transfer protocol before starting Ray. TCP works on any routable data network. RDMA also requires a local RDMA device on every node; device names may differ between nodes. If Ray and the Mooncake endpoint are already running, set the protocol and endpoint: ```bash theme={null} export MOONCAKE_PROTOCOL="" export MOONCAKE_MASTER_ADDR=":50051" # RDMA only. Set this to the device on the current node before starting Ray. # export MOONCAKE_DEVICE="" ``` Then add these options to an existing Miles training command: ```bash theme={null} --object-store-backend mooncake \ --mooncake-store-init-kwargs \ "{\"protocol\":\"${MOONCAKE_PROTOCOL}\",\"master_server_address\":\"${MOONCAKE_MASTER_ADDR}\"}" ``` Use a data-network address that every client can reach. The JSON value must remain one shell argument. `MOONCAKE_MASTER_ADDR` above is only a shell variable for these examples; the environment variable Miles itself falls back to is `MOONCAKE_MASTER`. Launch scripts and the Mooncake end-to-end tests assemble both pieces with `start_mooncake_master()` and `get_mooncake_object_store_args()` from `miles/utils/external_utils/command_utils.py`. The walkthrough below spells them out instead, so every setting stays visible. ## Two-node walkthrough The example below runs three synchronous rollout and training iterations with FSDP. It uses one eight-GPU node for rollout and one eight-GPU node for training. Set the variables to match your cluster, then complete the following steps in order. | Node | Address variable | Role | | - | - | - | | Head | `HEAD_IP` | Ray head, Mooncake master, rollout manager, rollout engines | | Worker | `WORKER_IP` | Training actors and Mooncake Store segment | Both nodes must use the same Python environment, Miles revision, and Mooncake version. The model and dataset paths referenced by the job must be available to the processes that use them. ### 1. Choose the protocol and set node addresses On the head node, select TCP or RDMA and set its data-network address: ```bash theme={null} export HEAD_IP="" export MOONCAKE_PROTOCOL="" # RDMA only. Use the device attached to HEAD_IP. # export MOONCAKE_DEVICE="" ``` On the worker node, select the same protocol and set both node addresses: ```bash theme={null} export HEAD_IP="" export WORKER_IP="" export MOONCAKE_PROTOCOL="" # RDMA only. Use the device attached to WORKER_IP. # export MOONCAKE_DEVICE="" ``` For RDMA, set `MOONCAKE_DEVICE` before starting Ray so that Ray workers inherit the node-local device setting. ### 2. Start Mooncake and Ray on the head Activate the Miles environment first, then run: ```bash theme={null} export MOONCAKE_LOG="${MOONCAKE_LOG:-mooncake_master.log}" mooncake_master --rpc_port 50051 --metrics_port 50052 \ >"${MOONCAKE_LOG}" 2>&1 & ray stop --force ray start --head \ --node-ip-address "${HEAD_IP}" \ --num-gpus 8 \ --disable-usage-stats \ --dashboard-host 0.0.0.0 \ --dashboard-port 8265 ``` ### 3. Join the worker Activate the same Miles environment on the worker, then run: ```bash theme={null} ray stop --force ray start \ --address="${HEAD_IP}:6379" \ --node-ip-address "${WORKER_IP}" \ --num-gpus 8 \ --disable-usage-stats ``` Mooncake uses each Ray node IP as `local_hostname` by default. Starting Ray with the data-network addresses above therefore keeps both systems on the same network. ### 4. Submit training from the head Set the Store configuration and the repository, model, and dataset paths before submitting the job. The 2 GiB values are suitable for this small example; production jobs should size them for live rollout data, replicas, and concurrent transfers. ```bash theme={null} export MOONCAKE_MASTER_ADDR="${HEAD_IP}:50051" export MOONCAKE_STORE_INIT_KWARGS="{\"protocol\":\"${MOONCAKE_PROTOCOL}\",\"master_server_address\":\"${MOONCAKE_MASTER_ADDR}\",\"global_segment_size\":\"2gb\",\"local_buffer_size\":\"2gb\",\"chunk_bytes\":67108864}" export MILES_HOME="" export MODEL_PATH="" export DATASET_PATH="" export RAY_DASHBOARD_ADDR="${RAY_DASHBOARD_ADDR:-http://127.0.0.1:8265}" RUNTIME_ENV_JSON='{ "env_vars": { "CUDA_DEVICE_MAX_CONNECTIONS": "1" } }' ray job submit --address="${RAY_DASHBOARD_ADDR}" \ --runtime-env-json="${RUNTIME_ENV_JSON}" \ -- python3 "${MILES_HOME}/train.py" \ --train-backend fsdp \ --hf-checkpoint "${MODEL_PATH}" \ --prompt-data "${DATASET_PATH}" \ --input-key messages \ --label-key label \ --apply-chat-template \ --rollout-shuffle \ --rm-type math \ --num-rollout 3 \ --rollout-batch-size 8 \ --n-samples-per-prompt 2 \ --rollout-max-response-len 64 \ --rollout-temperature 0.8 \ --global-batch-size 16 \ --advantage-estimator grpo \ --eps-clip 0.2 \ --optimizer adam \ --lr 1e-6 \ --lr-decay-style constant \ --weight-decay 0.1 \ --adam-beta1 0.9 \ --adam-beta2 0.98 \ --rollout-num-gpus 8 \ --rollout-num-gpus-per-engine 1 \ --num-gpus-per-node 8 \ --sglang-mem-fraction-static 0.7 \ --actor-num-nodes 1 \ --actor-num-gpus-per-node 8 \ --pin-rollout-manager-to-head \ --update-weight-transfer-mode broadcast \ --object-store-backend mooncake \ --mooncake-store-init-kwargs "${MOONCAKE_STORE_INIT_KWARGS}" \ --wandb-mode disabled ``` For fully asynchronous training, keep the same Mooncake options and use the normal Miles async entrypoint and flags: ```diff theme={null} - python3 "${MILES_HOME}/train.py" ... + python3 "${MILES_HOME}/train_async.py" ... + --fully-async ``` See [Fully Async Rollout](/docs/user-guide/fully-async) for the remaining async settings. ## Configuration reference `--mooncake-store-init-kwargs` accepts the following fields: | Field | Default source | Description | | - | - | - | | `master_server_address` | `MOONCAKE_MASTER` | Mooncake master address or HA endpoint. | | `local_hostname` | `MOONCAKE_LOCAL_HOSTNAME`, then Ray node IP | Data-network address advertised by this client. | | `metadata_server` | `MOONCAKE_TE_META_DATA_SERVER`, then `P2PHANDSHAKE` | Transfer Engine metadata endpoint. | | `protocol` | `MOONCAKE_PROTOCOL`, then `rdma` | Transfer protocol, normally `tcp` or `rdma`. | | `device_name` | `MOONCAKE_DEVICE` | Local RDMA device. Prefer the environment variable when device names differ by node. | | `global_segment_size` | `MOONCAKE_GLOBAL_SEGMENT_SIZE`, then `8gb` | Store capacity contributed by an eligible client. | | `local_buffer_size` | `MOONCAKE_LOCAL_BUFFER_SIZE`, then `32gb` | Local transfer and staging capacity. | | `chunk_bytes` | Mooncake default | Structured-object PUT chunk size. | Values supplied in `--mooncake-store-init-kwargs` take precedence over environment variables. Use data-network addresses in multi-node jobs; loopback addresses work only when all clients run on one node. Not every client contributes a segment: the rollout manager never does, and among training actors one per node does. Size `global_segment_size` for that per-node contribution rather than for the total client count. Mooncake stores one replica by default. Request additional replicas with: ```bash theme={null} --mooncake-replica-num 2 ``` Additional replicas require additional Store capacity. # On-Policy Distillation Source: https://miles.radixark.com/docs/advanced/on-policy-distillation Train a student on its own rollouts with a teacher's token-level probabilities as a reverse-KL signal, composable with GRPO, PPO, and other estimators. On-policy distillation (OPD) trains a student model on its own rollouts while using a teacher model's token-level probabilities as the distillation signal. In Miles, the teacher signal is converted into a per-token reverse-KL penalty and applied after the selected RL advantage estimator has produced token advantages. This lets the same OPD recipe compose with GRPO, PPO, REINFORCE++, GSPO, and other estimators. ## Key Arguments | Argument | Description | | - | - | | `--use-opd` | Enable on-policy distillation. Required flag to use OPD. | | `--opd-type` | Type of OPD: `sglang` or `megatron`. Required when `--use-opd` is set. | | `--opd-kl-coef` | OPD KL penalty coefficient (default: 1.0). Controls the weight of the distillation signal relative to the RL advantage. | | `--opd-log-prob-top-k` | Number of top-k tokens retained for the Rethinking OPD token reward. `0` uses sampled-token OPD; `16` matches the paper recipe default. | | `--opd-top-k-strategy` | Top-k token set strategy: `only-student`, `only-teacher`, `intersection`, `union`, or `xor`. | | `--opd-reward-weight-mode` | Weighting scheme for top-k rewards: `student_p`, `teacher_p`, or `none`. | | `--opd-teacher-urls` | Optional multi-teacher routing map (`NAME=URL` pairs, SGLang mode only). Routes each sample to a teacher by `sample.metadata[--opd-teacher-key]`; reserved name `default` is the fallback. Unset = single teacher at `--rm-url`. | | `--opd-teacher-key` | Metadata key holding the teacher name for routing (default: `opd_teacher`). | | `--opd-teacher-load` | Path to teacher Megatron checkpoint. **Required** when `--opd-type=megatron`, **must not be set** when `--opd-type=sglang`. | | `--opd-teacher-ckpt-step` | Optional checkpoint step for teacher model. | ## How It Works OPD modifies the advantage computation by subtracting a KL penalty term that encourages the student to match the teacher's output distribution: $$ \hat{A}_t = A_t - \lambda_{\text{opd}} \cdot D_{\text{KL}}(P_{\text{student}} \| P_{\text{teacher}})_t $$ Where $A_t$ is the original advantage from the base estimator (e.g., GRPO), $\lambda_{\text{opd}}$ is `--opd-kl-coef`, and $D_{\text{KL}}$ is the token-level reverse KL divergence. The implementation follows the additive OPD training recipe described in the [Thinking Machines OPD blog](https://thinkingmachines.ai/blog/on-policy-distillation/), with an additional SGLang top-k reward mode from [Rethinking On-Policy Distillation](https://arxiv.org/abs/2604.13016). ## Rethinking OPD Top-K Reward SGLang OPD supports the top-k token reward recipe from [Rethinking On-Policy Distillation](https://arxiv.org/abs/2604.13016). Set `--opd-log-prob-top-k` above zero to request student rollout top-logprobs, score the same sequence with the teacher, and aggregate a weighted reverse-KL estimate over a selected token set at each response position. The token set is controlled by `--opd-top-k-strategy`: | Strategy | Token set | | - | - | | `only-student` | Student top-k tokens, with teacher logprobs queried for those IDs. | | `only-teacher` | Teacher top-k tokens, with student logprobs queried for those IDs. | | `intersection` | Tokens appearing in both top-k sets. | | `union` | Tokens appearing in either top-k set, with duplicates removed. | | `xor` | Tokens appearing in exactly one top-k set. | `--opd-reward-weight-mode` controls whether each selected token is weighted by student probability, teacher probability, or uniformly. For compatibility, `--opd-log-prob-top-k=0` keeps the original sampled-token OPD path. ## Two Teacher Modes ### SGLang Mode (`--opd-type sglang`) The teacher runs on an external SGLang server. Teacher log-probs are obtained during the rollout phase. **When to use**: The teacher has a different architecture from the student, or the teacher is too large to load alongside the training model. **How it works**: 1. An external SGLang server runs the teacher model. 2. During rollout, the custom reward function (`miles.rollout.on_policy_distillation.reward_func`) sends each sample to the teacher server to obtain token-level log-probs. 3. With `--opd-log-prob-top-k=0`, the custom post-processing function trims sampled-token teacher log-probs to the response span and stores them in `sample.teacher_log_probs`. 4. With `--opd-log-prob-top-k>0`, it computes the Rethinking OPD weighted top-k reverse-KL estimate and stores it in `sample.opd_reverse_kl`. 5. During training, the stored OPD penalty is subtracted from the selected estimator's advantages. **Configuration**: ```bash theme={null} --use-opd --opd-type sglang --opd-kl-coef 1.0 --opd-log-prob-top-k 16 --opd-top-k-strategy only-student --opd-reward-weight-mode student_p --custom-rm-path miles.rollout.on_policy_distillation.reward_func --custom-reward-post-process-path miles.rollout.on_policy_distillation.post_process_rewards --rm-url http://:/generate ``` ### Multi-Teacher Routing (SGLang mode only) `--opd-teacher-urls` routes each sample to a task-specific teacher, e.g. a math specialist for math prompts and a code specialist for code prompts. Each sample is still scored by exactly one teacher, so scoring cost is identical to single-teacher OPD and the loss is unchanged — `teacher_log_probs` / `opd_reverse_kl` are per-sample and do not care which teacher produced them. **How it works**: 1. Tag each prompt with a teacher name in its dataset metadata column (read via `--metadata-key`, default `metadata`): ```json theme={null} {"prompt": "...", "metadata": {"opd_teacher": "math"}} {"prompt": "...", "metadata": {"opd_teacher": "code"}} ``` 2. Map names to teacher endpoints with `--opd-teacher-urls NAME=URL ...`. The reserved name `default` is the fallback for samples whose name is missing or unknown; without a `default` entry such samples raise an error (failing loudly beats silently distilling from the wrong teacher). 3. `reward_func` resolves the URL per sample; everything downstream is unchanged. **Configuration** (on top of the SGLang-mode flags above; `--rm-url` is ignored when the routing map is set): ```bash theme={null} --opd-teacher-urls math=http://

:/generate code=http://

:/generate default=http://

:/generate --opd-teacher-key opd_teacher # metadata key holding the teacher name (default) ``` > **Notes**: All teachers must share the student's tokenizer — scoring sends > `input_ids` and gathers per-token-id log-probs. Works with both the > sampled-token path and the top-k path (student-side scoring still goes to the > student router). For throughput, point multiple names (or one name backed by > an sglang router) at replicas; `--opd-teacher-urls` is for *different* > teachers, not load balancing. ### Megatron Mode (`--opd-type megatron`) The teacher model is loaded directly into Megatron via `--opd-teacher-load`. Teacher log-probs are computed during the training forward pass. **When to use**: The teacher has the same architecture as the student/reference model and fits in GPU memory. **How it works**: 1. The teacher model is loaded as an additional Megatron model during initialization. 2. During the training forward pass, the teacher model computes log-probs for each sample. 3. The KL penalty is computed inline and applied to advantages. **Configuration**: ```bash theme={null} --use-opd --opd-type megatron --opd-kl-coef 1.0 --opd-teacher-load /path/to/teacher_torch_dist ``` > **Note**: The teacher checkpoint must be in Megatron format (`torch_dist` or `torch`). You can convert from HuggingFace format using `tools/convert_hf_to_torch_dist.py`. ## Running the Examples Complete example scripts are provided in `examples/on_policy_distillation/`: ### SGLang Teacher ```bash theme={null} # 1. Download models and data hf download Qwen/Qwen3-32B --local-dir /root/Qwen3-32B hf download Qwen/Qwen3-8B --local-dir /root/Qwen3-8B hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /root/dapo-math-17k # 2. Convert student model cd /root/miles MODEL_ARGS_LINE="$(python3 miles/utils/external_utils/model_args_utils.py qwen3-8B)" || exit 1 read -ra MODEL_ARGS <<< "${MODEL_ARGS_LINE}" PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ ${MODEL_ARGS[@]} \ --hf-checkpoint /root/Qwen3-8B \ --save /root/Qwen3-8B_torch_dist # 3. Run bash examples/on_policy_distillation/run-qwen3-8B-opd.sh ``` ### Megatron Teacher ```bash theme={null} # 1. Convert both student and teacher models to Megatron format # 2. Run bash examples/on_policy_distillation/run-qwen3-8B-opd-megatron.sh ``` ## Preliminary Results Using Qwen3-8B-Base model SFT-ed on part of the [OpenThoughts3-1.2M](https://huggingface.co/datasets/open-thoughts/OpenThoughts3-1.2M) dataset, on-policy distillation with a Qwen3-32B teacher on the remaining data yields: | | Pass\@1 | | - | - | | Qwen3-8B-Base + SFT | 76% | | Qwen3-8B-Base + SFT + On-Policy Distillation | 94% | ## References * [Thinking Machines: On-Policy Distillation](https://thinkingmachines.ai/blog/on-policy-distillation/) * [Rethinking On-Policy Distillation](https://arxiv.org/abs/2604.13016) # P2P Weight Transfer Source: https://miles.radixark.com/docs/advanced/p2p-weight-transfer Direct rank-to-rank weight sync from actor to rollout via RDMA writes. miles supports P2P (point-to-point) weight transfer between training and rollout engines. By using `--update-weight-transfer-mode p2p`, miles enables more efficient weight transfer from training ranks to rollout engine ranks. More details on the design and implementation can be found in [this issue](https://github.com/radixark/miles/issues/755). ## Usage To enable P2P weight transfer, add the following flag to your training command: ``` --update-weight-transfer-mode p2p ``` ## How It Works The default weight transfer mode in miles is `broadcast`: after training, updated weights are broadcast via NCCL to all rollout engine ranks. This works but does not fully utilize the available bandwidth, as redundant copies of the same weights are transferred to multiple ranks. P2P mode addresses this by having each training rank transfer only the specific weight shards required by its target rollout engine rank(s), writing them directly to remote memory without redundant copies. The key steps are: 1. **Initialization**: Training ranks establish point-to-point connections (via RDMA) to their target rollout engine ranks. Including: * Create a transfer plan that maps each training rank to its target rollout rank(s) based on GPU counts and parallelism configuration. * Query remote rollout engines for their weight memory registration info (addresses and sizes for RDMA writes). * Query each target rank's parallelism config and server args, and build a model replica of its sharding layout without parameter storage, which converts HF weights into the exact bytes that rank's loader would write. 2. **Weight gather**: Megatron TP/EP shards are all-gathered and converted to HF format, same as the broadcast path. 3. **P2P transfer**: Instead of a collective broadcast, each source rank writes bucketed weight tensors directly to the destination rollout rank's memory, in a write-only fashion. 4. **Synchronization**: Once all RDMA writes are confirmed complete, rollout engines increment their weight version and resume generation for the next training step. ## Architecture Both broadcast and P2P modes share the same bucketed weight-update pipeline in `miles/backends/megatron_utils/update_weight/`. The diagram below shows which components are shared and which are P2P-specific. ### Shared components (broadcast & P2P) | Component | Description | | - | - | | **TP/EP all-gather** | Megatron TP shards are all-gathered within each PP stage; EP shards are gathered per-bucket when the accumulated expert data exceeds `buffer_size * ep_size`. Both modes perform this identically via `common.py`. | | **Bucketed update** | Weights are not transferred one parameter at a time. Instead, converted tensors are accumulated into a fixed-size buffer (`--update-weight-buffer-size`, default 512 MB). When the buffer is full, the entire bucket is flushed — via NCCL broadcast or RDMA write depending on the mode. This amortizes per-transfer overhead. Non-expert and expert weights use separate buckets. | | **PP independence** | Each pipeline-parallel stage updates its own weights independently. In broadcast mode, each PP rank has its own NCCL group (`miles-pp_{pp_rank}`). In P2P mode, each PP stage writes its own params to every engine rank (with Megatron Bridge, which gathers PP, each engine rank is written once). No cross-PP synchronization is needed during weight transfer, which is key to scaling. | | **HF format conversion** | After all-gather, Megatron-format tensors (with custom naming and sharding) are converted to HuggingFace-format names expected by the sglang rollout engine. | ### P2P-specific components | Component | File | Description | | - | - | - | | **Rollout engine rank assignment** | `protocols/utils/rollout_engine_rank_assignment.py` | Decides which rollout engine ranks each training rank sends weights to, for the rollout engines the rollout manager passes in (`engine_gpu_counts`). Senders are the training ranks that hold the same weights: those of one PP stage, or all ranks when PP is gathered (Megatron Bridge). Every (rollout engine, rollout engine rank) gets exactly one sender. First every sender gets one, so all of them send in parallel; the rest go to a sender that already sends the same rollout engine rank, so it prepares those weights once and sends them to several rollout engines. | | **Model replica** | `protocols/utils/model_replica.py` | An sglang model of the target rank's layout, built on `meta` with no parameter storage and brought, one module at a time on the GPU, to the state the engine loads updates into: its startup postprocess, then the restore every update session begins with. Its `weight_loader` functions re-shard all-gathered HF weights into the exact bytes the target rank expects, loaded into a transfer buffer. `ModelReplicas` keeps one replica per shard layout (the rank's parallelism and the sglang arguments that change its bytes and may differ between server groups, such as quantization and MoE and GEMM backends) for the whole trainer process, so a reconnect rebuilds only the connections to the new engines. Rollout engines whose expert placement a replica cannot reproduce (redundant experts, a non-trivial `init_expert_location`, EPLB, elastic EP, DWDP, KTransformers) are rejected at connect. | | **Transfer buffers** | `protocols/utils/transfer_buffers.py` | Two pinned host buffers per sender, sized to the larger of `--update-weight-buffer-size` and the largest complete parameter group, including alignment. Parameters sharing HF inputs stay in one group. Buffers are registered once per trainer process and reused only after every write reading them has returned; a write that failed or is still running after `--p2p-transfer-timeout` fails the update there. | | **Speculative draft** | `p2p.py`, `protocols/utils/model_replica.py` | With MTP training and same-model EAGLE, senders update both `target` and `draft` roles. Each runner receives every weight its own loader loads, as in the engine's own weight updates; a weight both loaders load into storage the draft shares with the target is written twice with the same bytes. Draft replicas use the engine's speculative MoE settings. Multi-layer EAGLE, separate draft checkpoints and a draft MoE runner or a2a backend other than the target's are rejected at connect, since sglang reloads a draft under the target's MoE backends. | | **Pipelined transfer** | `p2p.py`, `protocols/transports/mooncake.py` | While the writes reading one transfer buffer are in flight, the sender loads the next group of parameters into the other. Each rollout engine has its own write thread, so a stuck engine delays only its own writes. The end of the update waits for the remaining writes and fails it if any failed or is still running after `--p2p-transfer-timeout`. | The model replica runs the rollout engine's own sglang code in the trainer process: its model construction, startup postprocess and weight loaders. The trainer and the rollout engines therefore run the same sglang build, and their GPUs share an architecture: postprocess chooses kernels and layouts by architecture (TRT-LLM MoE layouts and UE8M0 scales on Blackwell, for example), so a replica on a Hopper trainer would lay out bytes for Hopper while Blackwell engines expect their own. ## Supported Model Architectures P2P derives HF-to-parameter mappings by probing each replica's `load_weights` at the first update (`protocols/utils/loader_probe.py`). The probe uses real GPU inputs and meta parameters, then caches the mapping for later updates. Models need no separate mapping implementation. P2P writes **parameters only**. The engine recomputes derived buffers, such as Gemma norm's `weight + 1`, after the update; `--check-weight-update-equal` checks those buffers too. The following sglang model classes are tested: | sglang Model Class | Model Family | Example Models | | - | - | - | | `Qwen2ForCausalLM` | Qwen2 (dense) | Qwen2.5-0.5B, Qwen2.5-7B | | `Qwen3ForCausalLM` | Qwen3 (dense) | Qwen3-4B, Qwen3-8B | | `Qwen3MoeForCausalLM` | Qwen3-MoE | Qwen3-30B-A3B, Qwen3-235B-A22B | | `Glm4MoeForCausalLM` | GLM4-MoE | GLM-4.5-Air | | `Glm4MoeLiteForCausalLM` | GLM4-MoE | GLM-4.7-9B-Flash | | `DeepseekV3ForCausalLM` | DeepSeek V3p2 | GLM-5 (744B-A40B) | | `DeepseekV3ForCausalLM` | DeepSeek V3 | Kimi-K2 (1T) \*, Kimi-K2.6 (1T) | > **Note:** All the above models are tested on H100-80GB clusters. > > For Kimi-K2, we set `training nodes = inference nodes = 32` to ensure sufficient memory. > The original Kimi-K2 checkpoint uses a block-quant size of `[128, 128]`, which will trigger errors if `sglang-tp-size = 32`. > To work around this, we re-quantize to `[64, 64]` and update all affected scale tensors accordingly. ## Validated Models All models below have been validated with `--check-weight-update-equal` in P2P mode. | Model | sglang Model Class | Nodes | | - | - | - | | Qwen3-4B | `Qwen3ForCausalLM` | 1 | | GLM-4.7-9B-Flash | `Glm4MoeLiteForCausalLM` | 2 | | GLM-5\_4layer | `DeepseekV3ForCausalLM` | 2 | | Qwen3-30B-A3B | `Qwen3MoeForCausalLM` | 4 | | GLM-4.5-Air | `Glm4MoeForCausalLM` | 8 | > Enabling `--check-weight-update-equal` for Kimi-K2 is non-trivial due to several issues: > > * The user must first dequantize the Kimi-K2 model to BF16 for training, then re-quantize the parameters for weight updating. Meanwhile, the rollout side snapshots the original Kimi-K2 tensors as the reference when enabling `--check-weight-update-equal`. This means the rollout SGLang engine should load the checkpoints processed by the `dequant-requant` pipeline. > * We use modified checkpoints with block-quant size `[64, 64]`, so any `quant/dequant` code that hard-codes the block-quant size as `[128, 128]` will break. > * Certain tensors that are only initialized on the rollout side (e.g., `k_scale` / `v_scale`) must be skipped during the weight-check process. > > We verified P2P correctness for Kimi-K2 by enabling `--check-weight-update-equal` with hard-coded workarounds for the issues above. The transferred weights were confirmed correct. These hard-coded workarounds are verification-only and will not be merged into the main branch to keep the codebase maintainable. ## Profiling Results For M source ranks and N target ranks, with source pp size `src_pp` and target ep size `sgl_ep`, the benefit of P2P transfer is approximately: 1. utilizes `M // src_pp` times more source transmission bandwidth. 2. each target rank receives `sgl_ep` times less data. Thus we expect our solution to scale better, especially on the MoE models. All profiling is run on H100-80GB clusters with 1GB transfer bucket. Timing measures after `pause_generation` call returns and before `update_weight` call exits to exclude request queue abortion time. Table includes steady-state steps 3–12 average. For Kimi-K2, the RDMA (ms) column includes GPU-side post-processing time (`post_load_weights` \~884ms) since this model requires GPU-side weight requantization after RDMA transfer. Models marked with ★ are MoE architectures, where P2P benefits are most pronounced due to expert-parallel sharding reducing per-target transfer volume. | Model Family | Model Name | Total Param | sglang Model Class | Train Config | Inference Config | NCCL (ms) | RDMA (ms) | Delta | | - | - | - | - | - | - | - | - | - | | GLM4-MoE ★ | GLM-4.7-9B-Flash | 30B(3B) | `Glm4MoeLiteForCausalLM` | TP=4, PP=1, CP=1, EP=8, ETP=1, 1 node | TP=4, EP=4, 1 node | 2,508.6 | 4,229.0 | +68.6% | | DeepSeek-V3 ★ | GLM-5\_4layer | 4-layer | `DeepseekV3ForCausalLM` | TP=4, PP=1, CP=1, EP=8, ETP=1, 1 node | TP=8, EP=8, 1 node | 732.2 | 1,260.8 | +72.2% | | Qwen3-MoE ★ | Qwen3-30B-A3B | 30B(3B) | `Qwen3MoeForCausalLM` | TP=4, PP=1, CP=1, EP=8, ETP=1, 2 nodes | TP=8, EP=8, 2 nodes | 2,670.0 | 2,160.2 | **−19.1%** | | GLM4-MoE ★ | GLM-4.5-Air | 106B(12B) | `Glm4MoeForCausalLM` | TP=1, PP=4, CP=1, EP=8, ETP=1, 4 nodes | TP=8, EP=8, 4 nodes | 5,001.1 | 2,637.2 | **−47.3%** | | Qwen3-MoE ★ | Qwen3-235B-A22B | 235B(22B) | `Qwen3MoeForCausalLM` | TP=4, PP=4, CP=2, EP=16, ETP=1, 8 nodes | TP=32, EP=32, 8 nodes | 10,753.6 | 3,162.0 | **−70.6%** | | DeepSeek-V3p2 ★ | GLM-5 | 744B(40B) | `DeepseekV3ForCausalLM` | TP=4, PP=8, CP=2, EP=16, ETP=1, 16 nodes | TP=64, EP=64, 16 nodes | 58,301.5 | 8,479.7 | **−85.5%** | | DeepSeek-V3 ★ | Kimi-K2 | 1T(64B) | `DeepseekV3ForCausalLM` | TP=8, PP=8, CP=4, EP=32, ETP=1, 32 nodes | TP=32, EP=32, 32 nodes | 53,279.1 | 7,227.3 | **−86.4%** | ![P2P vs NCCL Broadcast Scaling](https://mintcdn.com/radixark/hlz3_cm2V1iMNGwr/assets/images/p2p_vs_nccl_scaling.png?fit=max\&auto=format\&n=hlz3_cm2V1iMNGwr\&q=85\&s=09e21aa6a89d0f421afce2c6f7dfc45a) \* Kimi-K2 RDMA time includes \~884 ms GPU-side `post_load_weights` requantization on rollout engines. ## Examples ### CI Test (single-node, Qwen3-4B) The P2P weight transfer E2E test validates correctness on a single node using `Qwen3-4B`: ```python theme={null} # # Train: 4 GPUs (TP=2, CP=2) # Rollout: 4 GPUs (sglang, 2 engines × 2 GPUs each) # Flags: --update-weight-transfer-mode p2p --check-weight-update-equal ``` # PD Disaggregation Source: https://miles.radixark.com/docs/advanced/pd-disaggregation Separate prefill and decode pools so each is sized for its workload. In a typical SGLang deployment, every engine handles both **prefill** (the one-shot forward over the prompt) and **decode** (the per-token autoregressive loop). The two phases have different compute profiles: This page covers separation inside the rollout deployment. To separate policy training from rollout inference, see [Disaggregated RL Rollout](/docs/advanced/disaggregated-rollout). | Phase | Compute pattern | Bottleneck | | - | - | - | | Prefill | Long sequence × full batch | FLOPs | | Decode | One token × batch | Memory bandwidth | Mixing them in one engine means each engine is sized for the worse case. PD disaggregation splits them into two pools, each sized for its own workload. ## Enable ```bash theme={null} --prefill-num-servers 2 ``` `--prefill-num-servers` is a Miles-native flag added by `add_prefill_decode_disaggregation_arguments` in `miles/utils/arguments.py`. When set, `resolve_sglang_config` in `miles/backends/sglang_utils/sglang_config.py` dedicates that many SGLang servers to prefill, with the rest used for decode. `--prefill-num-servers` is mutually exclusive with the `sglang_config` attribute (the YAML `server_groups` config), and also cannot be combined with external rollout engines (`--rollout-external-engine-addrs`); a PD external fleet declares itself with `--rollout-external-router-pd` instead (`arguments.py`). ## When PD is worth it * Long prompts (≥ 4K). Prefill dominates total latency. * High decode batch sizes. Decode is memory-bandwidth bound. * MoE models. Decode benefits disproportionately from EP scaling that prefill does not need. For typical post-training with 1–2K prompts, the routing and KV-transfer overhead can outweigh the speedup. Measure first. ## How requests flow ```mermaid theme={null} flowchart LR P[Prompt] --> Router Router -->|prefill request| Prefill[Prefill pool] Prefill -->|KV cache + first token| Router Router -->|decode request + KV| Decode[Decode pool] Decode -->|stream tokens| Router Router --> Out[Response] ``` The KV cache produced by the prefill pool is migrated to the decode pool. SGLang handles the cache transfer when the [SGLang Model Gateway](https://docs.sglang.io/advanced_features/sgl_model_gateway.html) fronts the engines (PD support is a feature of the SGLang router). ## Sizing the pools A starting heuristic: ``` prefill_servers = ceil(rollout_qps × avg_prompt_tokens / single_engine_prefill_tps) decode_servers = N - prefill_servers ``` Without measurements, start at `prefill_num_servers = N / 4` and adjust based on observed queueing: | Symptom | Action | | - | - | | Prefill queue backing up | Increase `prefill_num_servers` | | Decode latency creeping up | Decrease `prefill_num_servers` | | Both queues growing | Scale up both pools, then revisit the ratio | ## Pairs with * [DeepSeek-V3.2 recipe](/docs/models/deepseek/deepseek-v3-2). PD is a clear win at 671B scale. * [Speculative decoding](/docs/advanced/speculative-decoding). Both are SGLang-side features; pool sizing should account for the verify-batch size when speculative is on. ## When PD is not useful * Short prompts (under \~1K tokens). Prefill is already cheap. * Single-node setups. Pool boundaries do not help. * Highly variable workloads. Fixed pool sizes are wasteful. # Sampling-Support Replay Source: https://miles.radixark.com/docs/advanced/sampling-support-replay Train against the same bounded sampling distribution used during rollout. Top-p and top-k change the distribution that generates a rollout token. They remove tokens from the vocabulary and renormalize the remaining probability mass. Recomputing a token's log probability over the full vocabulary therefore does not reproduce its behavior-policy probability, even when the rollout and trainer weights are identical. Sampling-support replay preserves that distribution. For every generated token, SGLang returns the realized set of token IDs that remained after sampling filters. Miles transports this ragged support with the sample and applies it to the actor logits before the softmax: $$ q_\theta(a_t \mid S_t) = \frac{\exp(z_\theta(a_t) / T)} {\sum_{j \in S_t} \exp(z_\theta(j) / T)}. $$ The support `S_t` is fixed rollout data; `z_θ` comes from the actor being trained. This gives PPO/GRPO a denominator and numerator in the same sampled probability space while retaining gradients through the current actor logits. ## Enable replay ```bash theme={null} ROLLOUT_ARGS+=( --rollout-temperature 1.0 --rollout-top-p 0.95 --rollout-top-k 64 ) ``` Replay is enabled whenever `--rollout-top-p` is below `1` or `--rollout-top-k` is positive. A finite top-p run must also set a positive top-k so that the captured support is bounded. Miles asks SGLang to capture its native sampling support with `return_sampling_mask` and uses the returned support-normalized log probability as the rollout log probability. `--rollout-top-k` is the default for rollout requests, not a global upper bound on request-specific top-k values. SGLang owns support capacity through `--sglang-sampling-mask-max-tokens` (or the matching server-group override) and rejects requests whose realized support cannot be represented. ## Correctness requirements Miles rejects configurations that it cannot replay faithfully: * Every resolved training request must have top-p, top-k, and temperature. Agentic sessions fill omitted values from the sampling defaults registered when the session is created. * Request temperature must match the registered training temperature (`--rollout-temperature` for the standard rollout paths). * Frequency, presence, and repetition penalties, `logit_bias`, and custom logit processors are not supported because the trainer does not replay those logit transformations. * `--recompute-logprobs-via-prefill` is incompatible because that path does not preserve the per-token support. * The selected router must preserve `return_sampling_mask`. The Miles router forwards raw request bodies. The native SGLang router requires typed chat and generate request schemas that declare the field. The SGLang backend must return one complete support and its normalized log probability for every sampled token. Miles validates that response contract but does not add separate restrictions for speculative decoding. Tool and environment tokens are recorded with singleton support. Evaluation requests do not capture this training-only metadata. ## Current objective limitation Replay currently exposes only the support-normalized actor score to the loss. Reference KL and on-policy distillation also need a full-vocabulary actor score, so Miles rejects `--use-kl-loss`, nonzero `--kl-coef`, and `--use-opd` with replay until the loss interface carries both scores. ## Monitor replay On the first update from identical weights, `train/train_rollout_logprob_abs_diff` should be near numerical tolerance. Afterward, interpret it with version-lag and clipping metrics because it also reflects policy updates and staleness. # Speculative Decoding Source: https://miles.radixark.com/docs/advanced/speculative-decoding Draft + target speculative rollout, with online SFT for MTP-style drafts. Speculative decoding accelerates rollout by letting a lightweight draft model generate ahead a few tokens and then verifying them with a single batched forward of the target model. When the draft is correct the target produces N tokens for the cost of one forward pass. ## Enabling speculative decoding For models with built-in MTP (Multi-Token Prediction) layers (GLM-4.7, DeepSeek-V3, DeepSeek-R1): ```bash theme={null} SGLANG_ARGS+=( --sglang-speculative-algorithm EAGLE --sglang-speculative-num-steps 3 --sglang-speculative-eagle-topk 1 --sglang-speculative-num-draft-tokens 4 ) ``` These are passthrough flags forwarded to SGLang. Miles auto-enables `enable_draft_weights_cpu_backup` so SGLang can run training without MTP weights resident on GPU (`miles/backends/sglang_utils/sglang_engine.py`). For an externally trained draft model (for example, trained with [SpecForge](https://docs.sglang.ai/SpecForge/)): ```bash theme={null} SGLANG_ARGS+=( --sglang-speculative-draft-model-path /data/draft_model/ ) ``` Full reference: [SGLang speculative decoding docs](https://docs.sglang.ai/advanced_features/speculative_decoding.html). ## Drift over a long RL run As RL training progresses, the target model's distribution shifts away from the draft. Fewer draft tokens pass verification, and over many steps speculative decoding can become a net negative because the wasted draft compute outweighs the verified speedup. Miles supports training the draft alongside the target through online MTP-SFT. ## Online SFT for MTP-style draft models ```bash theme={null} PERF_ARGS+=( --mtp-num-layers 1 --enable-mtp-training --mtp-loss-scaling-factor 0.2 ) ``` | Flag | Notes | | - | - | | `--mtp-num-layers` | MTP layers the model has (1 matches GLM/DeepSeek release defaults). Built only with `--enable-mtp-training`. | | `--enable-mtp-training` | Build and train the MTP layers alongside the policy (Megatron only). Without it no MTP layer is built, so exported checkpoints carry no MTP weights. | | `--mtp-loss-scaling-factor` | Weight of the MTP loss in the combined gradient (default `0.2`). | > [!NOTE] > > **Checkpoint must contain MTP weights.** Pass `--mtp-num-layers 1` when running `convert_hf_to_torch_dist.py`. > Without it the resulting `torch_dist` checkpoint will not contain the MTP > layer to train. ## External draft model SFT Training an external (non-MTP) draft model online is not yet supported in Miles. The current path is to retrain the external draft offline every N rollouts and reload it. ## Pairs with * [Low Precision RL](/docs/advanced/low-precision). Draft and target both quantized the same way. * [INT4 QAT](/docs/advanced/int4-qat). A quantized draft is cheaper to verify. * [R3](/docs/advanced/miles-router). R3 captures routing for the verified tokens emitted by the target. ## When to skip * Rollout-bound on dense models below \~13 B. The verification overhead can outweigh the benefit. * Already at high draft acceptance and the bottleneck is verification compute, not generation. ## Reading * SpecForge: [SGLang docs](https://docs.sglang.ai/SpecForge/). # Blog Source: https://miles.radixark.com/docs/blog/index Miles engineering write-ups on RL systems, post-training recipes, and performance results, published on the LMSYS blog. Check out Miles blog posts on the [LMSYS blog](https://www.lmsys.org/blog). # Adding a Sandbox Provider Source: https://miles.radixark.com/docs/developer/adding-a-sandbox-provider How a cloud sandbox provider plugs into Miles, and what it takes to add one. Connectors that run a task in a container (Harbor, OpenEnv) get that container from a sandbox provider. A provider is **one entry in `PROVIDER_CREDENTIALS`** (`miles/rollout/agentic/credentials.py`) — no connector code branches on it. The in-process Harbor path passes `HARBOR_ENV_TYPE` straight to Harbor; the golden test runs one case per registry entry, and the rollout e2e takes its provider from `HARBOR_ENV_TYPE`. Setting a provider up as a user is [Sandbox Providers](/docs/user-guide/sandbox-providers). ## Adding one 1. **The registry entry.** What credential a worker needs, the path-valued variable the launcher forwards in place of the secret, the address variables that are safe to forward by value, and the SDK. Field semantics are documented on `PROVIDER_CREDENTIALS` itself. 2. **A golden episode** on the real platform: `python scripts/sandbox_smoke/run.py --connector harbor --backend `. This is the only step that proves the platform round trip — image resolution, sandbox create, exec, verifier, teardown — and nothing below it is claimable without one. 3. **A GPU rollout**: `HARBOR_ENV_TYPE=` with `tests/e2e/agentic/test_harbor_rollout.py`. This is the bar for the [provider table](/docs/user-guide/sandbox-providers). 4. **A cell in that table**, plus a setup section on the same page if the provider needs anything beyond a credential. Nothing in step 1 needs a test change: the golden episode runs as one case per registry entry, and the rollout e2e reads the provider from the environment. ## The two tests | | `tests/e2e/agentic/test_sandbox_golden.py` | `tests/e2e/agentic/test_harbor_rollout.py` | | - | - | - | | Proves | the platform round trip | harness, session server, TITO recording, reward | | Needs | a provider credential | that, plus 2 GPUs and the model | | Provider axis | one case per registry entry | `HARBOR_ENV_TYPE` | | In CI | registered, skipped without a credential | registered, `disabled` | The golden test drives `scripts/sandbox_smoke/run.py`, which stays the entry point for the combinations CI does not fix — another agent harness, another task, another connector. Its README covers those axes. # Architecture Overview Source: https://miles.radixark.com/docs/developer/architecture The 30-minute tour of how Miles is organized internally. A reading guide before you start patching. ## The processes A Miles run is three kinds of processes wrapped in a Ray cluster: ```mermaid theme={null} flowchart TB subgraph Ray cluster subgraph "Trainer (1+ Megatron group)" T1[Actor rank 0] T2[Actor rank 1] T3[Actor rank ...] end subgraph "Rollout (N SGLang servers + Miles Router)" R1[SGLang server 1] R2[SGLang server 2] MR[Miles Router] R1 -. health, route .- MR R2 -. health, route .- MR end D[Data Source
RolloutDataSource] T1 <-- weight sync --> R1 T1 <-- weight sync --> R2 D --> MR MR --> D end ``` * **Trainer ranks** — Megatron processes that load `torch_dist` checkpoints and run the RL loop. * **SGLang servers** — independent HTTP services that produce rollouts. * **Miles Router** — FastAPI proxy that distributes rollout requests, preserves metadata (R3), and enforces health checks. * **Data Source** — Python object owned by the trainer; reads prompt JSONL and acts as a buffer between rollout and training. ## The package layout ```text theme={null} miles/ ├── backends/ # one directory per backend, plus what they share │ ├── megatron_utils/ # Megatron actor, update_weight/ (HF iterator), checkpointing, fp32 markers │ ├── fsdp_utils/ # FSDP2 actor, adaptations/ per architecture, hf_weight_iterator.py, │ │ # checkpoint.py (DCP save/resume) │ ├── torchtitan_utils/ # torchtitan actor, Trainer config tree, hf_weight_iterator.py │ ├── sglang_utils/ # SGLang engine wrapper + argument glue │ └── training_utils/ # what the backends share: loss.py / loss_hub/, ParallelState, │ # weight_update/ (engine session, transport protocols, WeightUpdater), │ # torch_native/ (the RL step FSDP and torchtitan share, the │ # StepRunner seam, the routing-replay surface), log + CI checkers ├── ray/ # Ray actors, placement groups, train/ and rollout/ groups ├── rollout/ │ ├── sglang_rollout.py # legacy v1 rollout function │ ├── data_source.py # buffer + JSONL loader │ ├── filter_hub/ # built-in filters │ ├── rm_hub/ # built-in reward types (`--rm-type` dispatch) │ ├── fully_async_*.py # queue-backed producer for train_async.py │ └── inference_rollout/# default class-based rollout ├── router/ # FastAPI proxy + worker load-balancer (router.py) ├── dashboard/ # run dashboard: collector, backend, dump reader ├── true_on_policy/ # true-on-policy contracts and per-model profiles └── utils/ # arguments.py, async / IO / distributed helpers, audit_utils/ ``` The `miles_plugins/` tree sits beside it. Nothing in `miles/` imports it directly: a plugin is loaded only when a run names its import path in a flag (`--spec`, or one of the `--custom-*-path` flags). `models/` holds Megatron specs and HF module wrappers, `mbridge/` per-architecture weight bridges, `megatron_bridge/` the `megatron.bridge` shims, and `optimizers/` optimizer plugins. `train.py` and `train_async.py` are the entry points. They are thin; most logic lives in the modules above. ## A request's life For a single GRPO iteration: ```mermaid theme={null} sequenceDiagram participant T as Trainer participant DS as DataSource participant MR as MilesRouter participant SG as SGLang participant RM as RewardFn T->>DS: get_samples(N) DS-->>T: prompts T->>MR: generate(prompts) MR->>SG: dispatch SG-->>MR: responses + meta_info MR-->>T: samples T->>RM: score(samples) RM-->>T: rewards T->>T: GRPO loss / step T->>SG: weight_sync(p2p) ``` This is the sync path. Fully async (`train_async.py --fully-async`) breaks the request from the trainer loop and uses a continuously-running worker. ## Where common changes go | You want to … | Edit | | - | - | | Add a new RL algorithm | `miles/backends/training_utils/loss/objective.py` and `loss/hub/`, plus the enum in `miles/utils/arguments.py` | | Add a new built-in reward type | `miles/rollout/rm_hub/` (the `rm_type` dispatch lives in its `__init__.py`) | | Add a new built-in filter | `miles/rollout/filter_hub/` | | Support a new architecture on Megatron | `miles_plugins/models/.py` + a bridge in `miles_plugins/mbridge/` | | Support a new architecture on FSDP | `miles/backends/fsdp_utils/adaptations/specs/.py` | | Add a new flag | `miles/utils/arguments.py` | | Change the engine handshake (pause / flush / announce / resume) | `miles/backends/training_utils/weight_update/session.py` — shared by all backends | | Change how weights are produced or transported | `miles/backends/training_utils/weight_update/` (transport protocols, shared), each backend's HF weight iterator beside it: `miles/backends/megatron_utils/update_weight/`, `miles/backends/fsdp_utils/hf_weight_iterator.py`, `miles/backends/torchtitan_utils/weight_bridge.py` | | Add a training backend | a new `miles/backends/_utils/` beside the existing three, a loader + validator in `miles/utils/arguments.py`, and an entry in `_TRAINER_ACTOR_CLASSES` in `miles/ray/specs/train.py` | | Change rollout buffer | `miles/rollout/data_source.py` | ## Extension points (the right way) The trainer is plug-in-friendly. Most extensions don't need a code change inside Miles — just pass a `--something-path my_pkg.thing`. See [Customization](/docs/user-guide/customization) for the full list. If you find yourself patching the trainer to make something work, that's a sign we're missing a hook. Open an issue. ## Tests ```text theme={null} tests/ ├── fast/ # CPU CI only — each test_*.py auto-registers as stage-a-cpu (register_cuda_ci is rejected here) ├── fast-gpu/ # GPU or CPU CI, registered explicitly (register_cuda_ci / register_cpu_ci) ├── ci/ # the suite runner + registry, with their own CPU CI ├── e2e/ # end-to-end (spins up Ray + SGLang); GPU or CPU CI, registered explicitly ├── manual/ # run on request, not discovered by the CI runner └── snapshots/ # recorded fixtures the launch-script and other snapshot tests assert against ``` CI discovery is location-based. The `tests/fast/` folder may hold **only CPU CI**: every `test_*.py` there auto-registers as `stage-a-cpu`, so no boilerplate is needed — write a literal `register_cpu_ci(...)` only to override the defaults, and a `register_cuda_ci` under `tests/fast/` is an error (move the file to `tests/fast-gpu/`). Every other folder may hold **GPU or CPU CI** and must register each test explicitly with `register_cpu_ci` / `register_cuda_ci`. The runner collects `tests/fast/`, `tests/fast-gpu/`, `tests/e2e/`, and `tests/ci/`. Run `pytest tests/fast` for a quick CPU check (`pytest tests/fast-gpu` if you have a GPU); run `tests/e2e` before landing anything that touches the train loop. ## Where to look first when reading the code If you have 30 minutes and want to understand Miles end-to-end: 1. `train.py` — the loop, top-to-bottom. 2. `miles/rollout/sglang_rollout.py:generate_rollout` — how prompts become samples. 3. `miles/backends/training_utils/loss/objective.py` — the loss and advantage computation. 4. `miles/router/router.py` — the FastAPI proxy. 5. `miles/backends/training_utils/weight_update/` — how trained weights reach the engines: the session handshake, the transport protocols, and the per-backend HF weight iterators. That's the spine. Everything else hangs off it. # Wheels rebuild Source: https://miles.radixark.com/docs/developer/ci/06-build-wheels Rebuild and publish router, int4_qat, and Transformer Engine wheels with the independent build-wheels workflow. `build-wheels.yml` builds prebuilt wheel releases independently of the [Docker image workflow](/docs/developer/ci/02-docker-build). The image workflow detects release changes and rebuilds the consuming image. ## Wheel sets Three `radixark/miles-wheels` asset sets follow a moving source. Each build writes a `-source.json` release asset recording the commit it came from, and `build-wheels.yml` rebuilds a set only when its source has moved past that commit: | set | source | x86\_64 | aarch64 | | - | - | - | - | | `sgl-router` (wheel + `sgl-model-gateway` binary) | `radixark/sgl-router-for-miles` `main` | GitHub-hosted `ubuntu-24.04` | GitHub-hosted `ubuntu-24.04-arm` | | `int4_qat` (`fake_int4_quant_cuda`) | last `main` commit touching `miles/kernels/quant/int4_fake` | `docker-build` runner, in the SGLang base image | by hand | | `te` (Transformer Engine triplet, `+miles`) | `radixark/TransformerEngine` `miles-main`: NVIDIA's release commit plus the fixes Miles carries | `docker-build` runner | by hand | ## Publish setup Use the shared `radixark-miles-ci` GitHub App installed on `radixark/miles` and `radixark/miles-wheels`. Grant `Contents: write` for wheel publishing and `Issues: write` plus `Pull requests: write` for [label commands](/docs/developer/ci/01-label). Store its client ID in the `radixark/miles` repository variable `CI_APP_CLIENT_ID` and its private key in the repository secret `CI_APP_PRIVATE_KEY`; both workflows use the same credentials. The publish job requests only `Contents: write` on `radixark/miles-wheels`. `CI_APP_ENABLED` controls label commands and does not gate wheel publishing. When migrating existing configuration, add the new names before deploying the workflows and retain the old entries until workflows on the default branch and active runs no longer reference them. ## Schedule and manual runs It runs hourly and by `workflow_dispatch` (`wheels`, `force`, `te_ref`, `keep_superseded`). `docker/Dockerfile` installs the `+miles` TE triplet, so scheduled runs rebuild TE like the other sets, and an upload replaces superseded wheels unless `keep_superseded` is true. There is no self-hosted ARM64 runner, so aarch64 `int4_qat` and `te` are rebuilt by hand with the miles-wheels README commands. ```bash theme={null} # Rebuild stale TE wheels. gh workflow run build-wheels.yml --repo radixark/miles -f wheels=te # Rebuild the router even when its release manifest already matches. gh workflow run build-wheels.yml --repo radixark/miles -f wheels=sgl-router -f force=true ``` The hourly schedule runs at minute 17. Manual dispatch defaults to `wheels=all`, `force=false`, `te_ref=miles-main`, and `keep_superseded=false`. `wheels` also accepts `sgl-router`, `int4_qat`, or `te` to select one set. `te_ref` selects the Transformer Engine branch or commit. The `check` job summary lists each selected source commit and which architectures need rebuilding. A matching release manifest leaves the build unselected unless `force=true`. ## Build and publish flow * **`check`** — resolves only the selected sources and reads the commit their manifests record in the `WHEELS_TAG_X86` / `WHEELS_TAG_ARM64` release. A lookup failure for a selected source or release fails the job. It takes the repository, tags and `SGLANG_IMAGE_TAG` from `docker/Dockerfile`, so assets land in the releases the image installs and compile against its torch. * **`build-router`**, **`build-int4-x86`**, **`build-te-x86`** — build only the stale sets. The router job also checks that the wheel and the binary forward a `/generate` request. `te` runs NVIDIA's manylinux recipe on the host, then compiles `transformer_engine_torch` inside the SGLang base image (`lmsysorg/sglang:`). * **`publish`** (GitHub-hosted) — when no selected build failed, syncs each arch's assets and manifests into its release with a CI App token (`vars.CI_APP_CLIENT_ID` / `secrets.CI_APP_PRIVATE_KEY`) minted with only `Contents: write` on `radixark/miles-wheels`. The release fingerprint in `check-upstream` then rebuilds the image. * **`notify-build-failure`** (GitHub-hosted) — after the check, build, and publish jobs finish, posts one failure card to the Miles CI Lark group if any failed. Scheduled and manual runs in `radixark/miles` use the existing repository secret `LARK_WEBHOOK`; the card names the failed jobs and steps and links to the run. Successful, skipped, or cancelled jobs alone do not trigger a notification. Runs are serialized across the whole workflow to prevent overlapping source snapshots from replacing newer assets. A long manual TE build can therefore delay the hourly router/int4\_qat runs. A failed selected build blocks publishing for both architectures. # Contributing Source: https://miles.radixark.com/docs/developer/contributor-guide Repository layout, the local loop, what enforces code style, what lives in .claude, and how to drive CI from a PR. Miles is open source under the LICENSE file in the repo. Contributions of every size are welcome: bug reports, doc fixes, new model recipes, full features. ## Repository layout ```text theme={null} miles/ ├── train.py # synchronous entry point ├── train_async.py # fully-async entry point ├── miles/ # the package │ ├── backends/ │ │ ├── megatron_utils/ # Megatron actor, weight sync, checkpointing, fp32 markers │ │ ├── fsdp_utils/ # FSDP2 actor, per-arch adaptations, MoE kernels │ │ ├── sglang_utils/ # SGLang engine + argument glue │ │ └── training_utils/ # loss / GRPO / PPO / GSPO / REINFORCE++, shared ParallelState │ ├── ray/ # Ray actors, placement groups, the train and rollout groups │ ├── rollout/ # rollout functions, data source, filters, fully-async buffer │ ├── router/ # Miles Router (FastAPI proxy in router.py) │ ├── dashboard/ # the run dashboard (collector + backend) │ ├── true_on_policy/ # true-on-policy contracts and model profiles │ └── utils/ # arguments.py, async / IO / distributed helpers, audit utils ├── miles_plugins/ # opt-in plugins, imported by name from flags │ ├── models/ # per-architecture Megatron specs and HF wrappers │ ├── mbridge/ # per-architecture weight bridges │ ├── megatron_bridge/ # megatron.bridge shims │ └── optimizers/ # optimizer plugins (NVMe streaming store) ├── scripts/ # launchers, one per recipe; scripts/models/ holds the architecture flags ├── tools/ # checkpoint converters, quantizers, profilers ├── tests/ # fast / fast-gpu / e2e / ci / manual (see Running CI) ├── docker/ # Dockerfile, Dockerfile.rocm, build.py, patches ├── docs/ # the source of this site, plus docs/developer/ci internals └── .claude/ # rules and skills (see What lives in .claude) ``` ## The local loop ```bash theme={null} git remote add me git@github.com:/miles.git git checkout -b feat/awesome pip install -e . --no-deps # editable install, deps come from the image pre-commit install # optional, runs the hooks on every commit pytest tests/fast # CPU suite, no GPU needed pre-commit run --all-files # what the pre-commit CI job runs git commit -m "feat(rollout): add partial-rollout buffer" git push me feat/awesome && gh pr create ``` ## Code style Formatting is not a matter of taste here, it is a hook. `.pre-commit-config.yaml` is the enforcement, the `pre-commit` workflow runs `pre-commit run --all-files` on every PR, and you can reproduce it exactly with the same command locally. | Hook | What it enforces | | - | - | | `ruff-check --fix` | Pycodestyle errors, Pyflakes, bugbear, pyupgrade. `E402` and `E501` are ignored on purpose | | `autoflake` | Removes unused imports in place | | `isort` | Import order, `--profile=black`, first-party is `miles` and `miles_plugins` | | `black` | Formatting at **line length 119** (`[tool.black]` in `pyproject.toml`) | | `check-yaml`, `check-case-conflict`, `detect-private-key`, `check-added-large-files` | Hygiene; files cap at 1000 KB | | `requirements-txt-fixer` | Keeps `requirements.txt` sorted | Three hooks are Miles-specific bans, each pointing at the API you should use instead: | Ban | Use instead | Why | | - | - | - | | `mpu.get_*` | `get_parallel_state()` from `miles.backends.training_utils.parallel` | The two backends share one `ParallelState`; reading Megatron's `mpu` directly does not work under FSDP | | `AutoConfig.from_pretrained` / `AutoTokenizer.from_pretrained` | `load_hf_config` / `load_tokenizer` from `miles.utils` | The wrappers centralize trust, caching, and the multi-node file-system race | | `huggingface-cli` | `hf` | The old CLI is deprecated upstream | If a commit legitimately needs an exception, the hooks carry `exclude` patterns; extend those in the same PR rather than disabling a hook. Beyond formatting, the conventions a reviewer will hold you to live in [`.claude/rules/general-code-style.md`](https://github.com/radixark/miles/blob/main/.claude/rules/general-code-style.md): prefer stateless and immutable, keep functions under roughly 100 lines and files under roughly 1000, initialize derived values once, keep imports at the top, use absolute imports, prefer keyword arguments where they add clarity. It applies to `miles/**/*.py`, `scripts/**/*.py`, `tools/**/*.py`, `train.py` and `train_async.py`. ## What lives in `.claude` The `.claude` directory is how the repo hands its conventions to coding agents, and it is worth reading even if you never run one, because it is where several rules are written down exactly once. **`.claude/rules/`** holds conventions. A file with a `paths:` front matter list applies to any file matching it; a file without one applies everywhere. `general-code-style.md` is the one described above, and `AGENTS.md` at the repo root points Codex at it too, so both agents and humans review against one document. `ci-test-registration.md` holds the rules for adding a CI test (see [Registering a test](#registering-a-test)), and `ci-failure-triage.md` what to do when a CI check goes red. **`.claude/skills/`** holds procedures, one directory per skill with a `SKILL.md`. They are workflows rather than style rules: | Skill | What it is for | | - | - | | `doc-dev` | Keeping a file and its governing document in sync (see below) | | `ci-fetch-log` | Pulling complete GitHub Actions logs and diagnosing a failed run from saved evidence | | `ci-e2e-time-tune` | Recalibrating `register_cuda_ci(est_time=...)` from real run times | | `pr-ci-targeted` | Running only the GPU test files a PR's diff reaches, with per-file `/rerun-test`, and iterating fixes until they pass | | `mechanical-refactor-verify` | Reviewing a file split or move by requiring a reproducible transform script | | `setup-ci-host`, `manage-gh-runners` | Provisioning a CI host and its self-hosted runners | ### The `doc-dev` sentinel Some files are bound to a document. A `# doc-dev:` line in a file's own comment syntax is the opt-in: bare, it binds the file's own header block; with a repo-relative path, it also binds that central document. Editing such a file means updating its documentation in the same change, and editing the document means finding the files that name it. ```python theme={null} # doc-dev: docs/developer/ci/02-docker-build.md ``` Current sentinels, so you know when you have walked into one: | File | Governing document | | - | - | | `.github/workflows/pr-test.yml`, `pr-test-rocm.yml` | `docs/developer/ci/00-stage.md`, `docs/developer/ci/01-label.md` | | `.github/workflows/bot-bump-miles-version.yml`, `bot-cherry-pick.yml`, `release-*.yml` | `docs/developer/ci/04-release.md` | | `docker/build.py` | `docs/developer/ci/02-docker-build.md` | | `tests/ci/metric_history/**` | `docs/developer/ci/03-metric-history-gate.md` | Grep for `doc-dev:` before editing anything under `.github/workflows/` or `docker/`. A change that lands the code and leaves the document stale is the failure mode this convention exists to prevent. ## Running CI ### What a PR runs Two things start automatically: the `pre-commit` workflow on every PR, and `PR Test` (`.github/workflows/pr-test.yml`) on every PR based on `main`. A PR based on another branch, such as a stacked PR, runs `PR Test` only with a `run-ci*` label (see [Labels](/docs/developer/ci/01-label)). `PR Test` resolves a policy and an image, runs the two CPU stages, and then the GPU stages, which are gated on `stage-a-cpu` succeeding so a formatting or import error does not burn GPU time. A PR that touches `docker/Dockerfile`, `docker/build.py`, `docker/verify_transformer_engine.py`, `docker/patch/**` or `requirements.txt` additionally builds the image first and runs every GPU suite inside it. ### Registering a test Selection is declared in the test file, never in the workflow YAML. Every `test_*.py` under `tests/fast/` is auto-registered as a CPU test in `stage-a-cpu`; a file under `tests/fast-gpu`, `tests/e2e` or `tests/ci` declares a top-level `register_*_ci(...)` call, or collection fails with `No CI registry found`. Where the file goes, which stage runs it, how it is declared, which PRs run it, how `est_time` is measured with `/rerun-test`, and when `disabled=` is allowed are rules, written once in [`.claude/rules/ci-test-registration.md`](https://github.com/radixark/miles/blob/main/.claude/rules/ci-test-registration.md). Telling your failure from an infra one, and reporting either, is in [`.claude/rules/ci-failure-triage.md`](https://github.com/radixark/miles/blob/main/.claude/rules/ci-failure-triage.md). ### Labels Labels are how a PR opts into the expensive matrix. A test's `labels=["megatron"]` is triggered by the GitHub label `run-ci-megatron`: the workflow forwards the labels, Python strips the `run-ci-` prefix and intersects with each test's list. The canonical set lives in `tests/ci/labels.py`, and a value outside it is a collection-time error. | Label | Effect | | - | - | | `run-ci-` | Selects tests declaring `` | | `run-ci-all` | Every enabled tag | | `nightly` | Nightly cadence: admits `nightly=True` tests, every tag except `long` and `ft-long`, fast-fail off | | `run-ci-image` | Every tag except `long`, `ft-short`, `ft-long`; for validating an image bump | | `bypass-fastfail` | Run GPU stages even if `stage-a-cpu` failed, and let each suite continue past the first failure | If your fork PR sits waiting for approval, that is GitHub holding first-time contributor runs; any maintainer-applied `run-ci-*` label doubles as the approval. ## PR-description CI tags Three directives are read out of the PR description itself, one per line, matched at the start of a line: | Line in the PR description | Effect | | - | - | | `ci-image-tag: ` | Run the GPU suites on `radixark/miles:` instead of `dev`. Must be a bare tag, not a full image reference | | `ci-sglang-pr: ` | Check the SGLang tree out at `` instead of the default `sglang-miles` branch | | `ci-megatron-pr: ` | Check Megatron-LM out at `` instead of the default `miles-main` branch | For the two ref directives, `` is a branch or commit, and the shorthand `#1234` resolves to `refs/pull/1234/head`, which is how you test against an unmerged SGLang or Megatron-LM PR: ```text theme={null} ci-sglang-pr: #12345 ci-megatron-pr: my-fix-branch ci-image-tag: dev-202608100600 ``` Precedence, when several sources disagree: a `workflow_dispatch` input wins, then the PR-description line, then the default. `ci-image-tag:` has one more rule: on a PR that built its own image, the fresh `pr-` tag outranks the directive, so a docker PR always tests what it just built. ## PR conventions Commit subjects follow conventional commits, under 70 characters, and the body explains *why*: ```text theme={null} feat(rollout): add partial-rollout buffer fix(megatron): correct fp32 marker on Qwen3.5 A_log docs: clarify FP8 rationale for MoE ``` Before marking a PR ready for review: * [ ] `pre-commit run --all-files` is clean. * [ ] `pytest tests/fast` passes, plus `tests/fast-gpu` if you have a GPU. * [ ] New behavior has a test. * [ ] Each new or moved CUDA test has its `CI timing:` line in the PR description, from a `/rerun-test` run (see `.claude/rules/ci-test-registration.md`). * [ ] A new flag appears in [CLI Reference](/docs/user-guide/cli-reference), and `python3 train.py --help` still parses. * [ ] A change to a `doc-dev:` governed file updates its document in the same PR. * [ ] The PR description carries the CI directives your change needs. Commenting `/run-lint` on a PR runs the hooks in CI and pushes the autofixes back to your branch, which is the quick way out of a red `pre-commit` job. ## Issue triage | Label | Meaning | | - | - | | `good first issue` | Self-contained, no system knowledge needed | | `help wanted` | Community PRs welcome | | `bug` | Reproducible breakage | | `enhancement` | Feature request | | `discussion` | Design conversation, not yet a task | | `needs-repro` | Not reproducible yet, please add a minimal example | Comment to claim an issue before you start. For an infra failure or a flake, file the issue with the fields `.claude/rules/ci-failure-triage.md` lists (job URL, runner name, suite, log line), so a maintainer can map it to a host. ## Where to ask * **Quick questions:** the `#miles-rl` channel of the [SGLang Slack](https://slack.sglang.ai). * **Design discussions:** a GitHub Discussion, or an Issue labeled `discussion`. * **CI internals:** [Stage](/docs/developer/ci/00-stage) (stages), [Labels](/docs/developer/ci/01-label) (label semantics), [Docker build](/docs/developer/ci/02-docker-build) (images), and [Metric history & regression gate](/docs/developer/ci/03-metric-history-gate) (metric gate). The CI rules for contributors are `.claude/rules/ci-test-registration.md` and `.claude/rules/ci-failure-triage.md`. # Debugging Source: https://miles.radixark.com/docs/developer/debug Split a misbehaving run into rollout and training halves, then debug the side that is actually wrong. When a Miles run misbehaves, the first question is always: **rollout or training?** The flags on this page exist to answer it by cutting the loop into pieces you can run one at a time. Once you know which side is wrong, it becomes an ordinary debugging session. ## Cut the loop in half | Flag | What it does | | - | - | | `--debug-rollout-only` | Run generation only. The training backend (Megatron or FSDP) is never initialized. | | `--debug-train-only` | Run training only. No rollout engines are started; a snapshot-eval fleet (`--eval-num-gpus`) still is when configured. | | `--save-debug-rollout-data ` | Pickle every rollout to `path.format(rollout_id)`. The template must contain `{rollout_id}`, which also carries the eval and policy prefixes. | | `--load-debug-rollout-data ` | Train from those recordings instead of generating. Implies `--debug-train-only`, since it does not start engines. | The two `--debug-*-only` flags are mutually exclusive and argument validation rejects setting both. The pattern worth internalizing: ```bash theme={null} # 1. capture a few well-formed rollouts, no trainer involved --debug-rollout-only --save-debug-rollout-data /tmp/rollout_{rollout_id}.pt # 2. iterate on training with those exact inputs, no engines involved --load-debug-rollout-data /tmp/rollout_{rollout_id}.pt ``` Step 2 removes sampling randomness from the loop, which is what makes a bisect or an A/B of a loss change trustworthy. ### Replaying without killing the engines `--load-debug-rollout-data` gives up the engines, so it cannot tell you whether the *weights that reached the engines* are right. `--ci-inject-rollout-data-path` is the variant that keeps them: ```bash theme={null} --ci-inject-rollout-data-path /tmp/rollout_{rollout_id}.pt \ --ci-inject-rollout-data-start-rollout-id 2 \ --ci-inject-rollout-data-min-match-ratio 0.9 ``` From the start rollout id onwards, generation still runs and its output is still compared, then discarded, and training consumes the recording instead. The comparison is the point: if the mean response-token match ratio between the fresh generation and the recording falls below `--ci-inject-rollout-data-min-match-ratio` (default `0.9`), the engines are holding the wrong weights. Legitimate ulp-level drift only flips the occasional sampled token, so the ratio stays high when the sync is correct. ## Cut a single step into pieces | Flag | What it removes | | - | - | | `--debug-skip-weight-update` | The actor-to-rollout weight update itself, while keeping the offload and onload schedule around it. Separates "the sync is wrong" from "the schedule around the sync is wrong". | | `--debug-disable-optimizer` | Optimizer and LR-scheduler construction, and the optimizer step. Rollout, log-prob forward and actor forward/backward still run, so this isolates optimizer-state memory and update behavior from the rest. | | `--debug-exit-after-rollout ` | Everything after rollout `n`. Built for exercising checkpoint resume with consistent scheduler state. | ## Make two runs comparable `--debug-deterministic-collective` runs the training world on the `det_nccl` backend from `miles.utils.test_utils.det_process_group`, which folds order-sensitive SUM and AVG reductions in a fixed tree order. That is what makes two different reduction topologies bitwise comparable, and it is how the Megatron-versus-FSDP alignment test can assert equality at all. It is slow; never enable it in production. For end-to-end reproducibility of a whole run, including the sampling path, see the [Reproducibility recipe](https://github.com/radixark/miles/tree/main/examples/experimental/reproducibility). ## The assertion harness CI uses An e2e test in Miles is mostly a normal training run with `--ci-test` added. That flag turns on a set of in-process assertions, so a violated invariant fails the run where it happens instead of showing up as a wrong number hours later. You can use the same flags on your own runs, and each checker has an escape hatch for the case where your change makes it legitimately not hold. | Checker | What it asserts | Turn off with | | - | - | - | | KL | At step 0, `abs(train/ppo_kl) < 1e-9` and `train/pg_clipfrac < 1e-10`, and `train/kl_loss` is zero. MLA relaxes to `1e-8`; LoRA relaxes because the Megatron-to-HF adapter conversion is not bit-exact | `--ci-disable-kl-checker` | | Log-probs | At rollout 0, trainer `rollout/log_probs` matches `rollout/ref_log_probs` within `1e-8` (`5e-3` under R3, whose reference does not replay routing); trainer versus engine log-probs within `0.03`; rollout entropy in `(0, 0.7)`. Under `--true-on-policy-mode` the two must be exactly equal | `--ci-disable-logprobs-checker` | | Weight update | Sets `check_weight_update_equal`, comparing trainer and engine weights after a sync. Skipped automatically under either `--debug-*-only` | `--ci-disable-weight-update-checker` | Three more take values rather than switching off: **Accuracy gate.** `--ci-metric-checker-key --ci-metric-checker-threshold ` checks eval metrics when the checker is disposed at the end of the run. By default, at least one eval must report `key >= x`. Set `--ci-metric-checker-expect-num ` to require exactly `n` eval checks and require all of them to succeed. A run with no eval always fails. **Gradient-norm comparison.** `--ci-save-grad-norm ` writes the grad norm, and `--ci-load-grad-norm ` asserts a later run matches within `rel_tol=abs_tol=0.03`. Both accept `{role}`, `{rollout_id}` and `{step_id}` in the path, and only rank 0 participates. This is how the Megatron-versus-FSDP alignment test compares two backends that cannot share a process. **Per-layer weight hashes.** `--ci-save-model-hash` and `--ci-check-model-hash` compute SHA256 over each layer's parameter bytes, including name, shape and dtype, and write one JSON per rank under `iter_/model_hash_tp*_pp*_dp*_cp*.json`. Layer granularity is deliberate: a mismatch names the layer instead of just saying the model differs. **Fault injection.** `--ci-fault-hooks` takes a JSON array of fault hook requests, such as `[{"request_id": "stop", "hook_name": "trainer_controller_step_end", "action": {"kind": "stop_cell", "cell_id": "trainer-engine-actor-00000"}, "rollout_id": 3}]`. A request waits at a named hook (`trainer_step_before_allreduce`, `trainer_controller_step_start`, `trainer_controller_step_end`, `orchestrator_step_end`, the weight-update hooks) and runs its action there (`kill_process`, `exit_process`, `segfault_process`, `freeze_process`, `stop_process`, `deadlock_thread`, `stop_cell`, `start_cell`, `sleep`, `sleep_forever`, `observe`, `api_server_fault`, `restart_rollout_cell`). The rollout restart runs immediately at `orchestrator_step_end` for its declared rollout, requires rollout fault tolerance and synchronous single-policy training, and waits for a fresh Running replacement before the next weight transaction. It is how the fault-tolerance suite kills things on purpose. See [Fault Tolerance](/docs/advanced/fault-tolerance). ## Aligning precision The most common class of bug. Walk these checks before anything else. ### Is the first rollout coherent? If the very first rollout is gibberish: * **Parameters didn't load.** Megatron logs a clear load line; if it is absent, fix `--load` / `--ref-load`. * **Parameter mapping is wrong.** With `pp_size > 1`, second-stage layer IDs are a common offset bug. Dump the parameters in the SGLang model's `load_weights` and compare against the checkpoint. * **SGLang dropped buffers.** Some buffers can be released during the parameter-release path; check they are re-loaded after weight sync. * **Pretrained versus instruct.** If the instruct model of the same architecture works, your base model plus chat template combination is wrong. ### Are `log_probs` and `ref_log_probs` equal at step 1? They should be, because the actor and the reference are the same weights. This is exactly what the log-probs checker above asserts, so running with `--ci-test` turns the question into a hard failure. If KL is non-zero: * **Non-deterministic kernels.** Some Transformer Engine versions need `--attention-backend flash` to force deterministic Flash Attention under context parallelism. * **KL below `1e-4`.** Kernel-level jitter, acceptable. * **KL above 1.** A configuration error; re-check parallelism and precision. * **About 0.8 per token on an instruct model.** Almost always a chat-template mismatch. Run the [chat template verifier](/docs/user-guide/agentic-rollout). ### Is `grad_norm` reasonable? Step 1 with `num_steps_per_rollout=1` should produce a tiny gradient. If it does not, look at MoE fusion first (`--moe-permute-fusion`), then at whether the reward is being computed from the right key (a `--label-key` typo is the usual cause). ### Does step 2 OOM under colocate? The trainer's offload and reload cycle is colliding with the engine's static memory. Lower `--sglang-mem-fraction-static` to 0.7 or 0.6. See [Training Backends](/docs/user-guide/training-backend) for the layout and offload knobs. ## Common kernel pitfalls | Symptom | Likely culprit | | - | - | | Garbled rollout, parameters loaded fine | Chat-template mismatch, or a dropped buffer in SGLang | | KL non-zero at step 1 | Non-deterministic fused attention; force `--attention-backend flash` | | MoE training collapses after tens of steps | Routing not preserved; check `--use-rollout-routing-replay` | | Gradient NaN or Inf | Bad chat template, or activation overflow in FP8 | | `illegal memory access` in SGLang | OOM in disguise; lower `--sglang-mem-fraction-static` | | `JSONDecodeError` from inductor | Corrupt compile cache; set `TORCHINDUCTOR_FORCE_DISABLE_CACHES=1` | | NCCL hang during weight sync | Raise the NCCL timeout and re-run with `NCCL_DEBUG=INFO` | ## Reading logs | Component | Where | | - | - | | Trainer stdout | Wherever you redirected `ray job submit` | | Ray workers | `~/.ray/session_latest/logs/worker-*.{out,err}` | | SGLang | Inside the Ray worker logs; verbosity via `--sglang-log-level` | | NCCL | `NCCL_DEBUG=INFO NCCL_DEBUG_FILE=/tmp/nccl_%h_%p.log` | Useful environment variables while debugging: ```bash theme={null} RAY_DEDUP_LOGS=0 # every rank's line, not one deduplicated line NCCL_DEBUG=INFO NCCL_DEBUG_SUBSYS=COLL,P2P TORCHINDUCTOR_FORCE_DISABLE_CACHES=1 ``` The per-step metrics are the real signal, and they are the same numbers the checkers above assert on: `train/ppo_kl`, `train/pg_clipfrac`, `train/kl_loss`, `rollout/log_probs`, `rollout/ref_log_probs`, `rollout/rollout_log_probs`, `rollout/entropy`. Trainer steps log as `step : {...}`, and the rollout side logs one reduced dict per rollout. See [Monitoring and Logging](/docs/user-guide/monitoring) for the full metric surface. ## When all else fails * Drop to a tiny model on a known-good recipe (the [Reproducibility](https://github.com/radixark/miles/tree/main/examples/experimental/reproducibility) one) to separate framework from model. * `git bisect` between a known-good commit and HEAD, with the record-and-replay pattern above pinning the inputs and `--debug-deterministic-collective` pinning the reductions. * Open a GitHub issue with the launch script, `pip freeze`, the first 200 lines of trainer stdout, and what you have already ruled out. # Developer Guide Source: https://miles.radixark.com/docs/developer/index Contribution conventions, internal architecture, dependency versions, and debugging. You're here because you want to change Miles, not just use it. This section is the short tour for new contributors. > **[Contributing](/docs/developer/contributor-guide)** > > Repo layout, what enforces code style, what lives in .claude, and how to drive CI from a PR. > **[Architecture Overview](/docs/developer/architecture)** > > The 30-minute tour of how Miles is organized internally. > **[Versions and Images](/docs/developer/versions)** > > How the miles, SGLang and Megatron-LM trees fit together, and how to bump one. > **[Debugging](/docs/developer/debug)** > > Isolating rollout from training, the debug and CI assertion flags, aligning precision. > **[Training Backends](/docs/user-guide/training-backend)** > > Megatron-LM and FSDP: what each backend owns and where its code lives. ## TL;DR for first-time contributors 1. Pick something small from `good first issue` on [GitHub](https://github.com/radixark/miles/issues). 2. Run the [Reproducibility recipe](https://github.com/radixark/miles/tree/main/examples/experimental/reproducibility) so you can be sure "I changed X and it broke" actually means that. 3. Use `--debug-train-only` or `--debug-rollout-only` to scope your changes. 4. Open a PR. We'll review within \~48h. # Reconcile Loop Source: https://miles.radixark.com/docs/developer/reconcile-loop A minimal, level-triggered controller runtime for Miles, and how it maps onto the Kubernetes Go stack. ## What this is * A deliberately small port of the [client-go informer stack](https://github.com/kubernetes/client-go/tree/master/tools/cache) plus [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime). * Tracks pools of processes — SGLang engine cells, trainer cells — that appear, disappear and restart mid-run. * The alignment record: **every deviation from Go must appear below with a reason.** ## Modules All under `miles/utils/workers/reconcile/`. | Ours | Does | Go | Not 1:1 | | - | - | - | - | | `k8s_types.py` | The pod fields the loop reads, as pydantic models that validate straight off whatever the client yields | [core/v1 `Pod` types](https://github.com/kubernetes/api/blob/master/core/v1/types.go) | A partial view: only the fields miles reads, and every other one is ignored | | `k8s_api.py` | LIST/WATCH calls; the only module importing `kubernetes_asyncio`, lazily so the loop stays importable without a Kubernetes backend | [typed client `List` / `Watch`](https://github.com/kubernetes/client-go/blob/master/kubernetes/typed/core/v1/pod.go) | | | `k8s_reflector.py` | Cursor bookkeeping, relist on cursor rejection | [`cache.Reflector`](https://github.com/kubernetes/client-go/blob/master/tools/cache/reflector.go) | | | `source_event.py` | Reflector-to-loop wire format: `UpsertEvent`, `DeleteEvent`, and a whole-world `ReplaceEvent` | [`watch.Event`](https://github.com/kubernetes/apimachinery/blob/master/pkg/watch/watch.go) + [`Store`'s write methods](https://github.com/kubernetes/client-go/blob/master/tools/cache/store.go) | Go writes to a store by method call; we send the same operations as values | | `object_store.py` | Cache, parent index, replace with deletion synthesis | [`cache.Store`](https://github.com/kubernetes/client-go/blob/master/tools/cache/store.go) + [`DeltaFIFO.Replace()`](https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go) | Absorbs `Replace()`, the only part of `DeltaFIFO` we keep | | `work_queue.py` | Insertion-ordered dedup with a wakeup, generic over the key type | [`workqueue`](https://github.com/kubernetes/client-go/blob/master/util/workqueue/queue.go) | | | `retry_scheduler.py` | Per-key exponential backoff, latest-wins deadlines swept by one poll loop, generic over the key type | [rate limiter](https://github.com/kubernetes/client-go/blob/master/util/workqueue/default_rate_limiters.go) + [delaying queue](https://github.com/kubernetes/client-go/blob/master/util/workqueue/delaying_queue.go) | Absorbs both, because latest-wins is one mechanism, not two | | `source_stream_driver.py` | Open, sync, reopen the stream; pump events into the store | [informer `Run` / `processLoop`](https://github.com/kubernetes/client-go/blob/master/tools/cache/controller.go) | | | `loop.py` | Lifecycle, the single worker, resync | [controller-runtime `Controller`](https://github.com/kubernetes-sigs/controller-runtime/blob/main/pkg/internal/controller/controller.go) | | An empty last column means 1:1. Each entry is the shadow of a **Dropped** / **Replaced** row below — the Go class died with its feature and the remainder landed on a neighbor — so the reason lives with that row. ## Decisions per module ### `k8s_types.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Generated core/v1 types | Name the fields of an object the apiserver owns | **Replaced** by hand-written pydantic models covering only the fields miles reads, with `extra="ignore"` | The store, the reflector and the pod views all hold `Pod`, so a typo is a validation error at the edge instead of an `AttributeError` deep in a view. Modelling the whole of `V1Pod` would only re-type what `kubernetes_asyncio` already ships | | Two wire spellings of the same field | One parser for both | **Kept** as `from_attributes=True` plus `AliasChoices("pod_ip", "podIP")` on every compound field | `kubernetes_asyncio` deserializes into a snake\_case model unless it has none, and only then leaves the raw JSON, which is camelCase. One model reading both is what keeps the conversion in pydantic rather than in a hand-written field walker | | An unset field | Tell "absent" from "empty" | **Dropped**; `None` becomes `{}` / `[]` before validation | The client spells an absent `labels` block as `None` and an empty one as `{}`, and no consumer here distinguishes them | ### `k8s_reflector.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Reflector | Move remote changes into a local cache reliably | **Kept**, Kubernetes only | Ray / external-URL backends emit in-process: no cursor, no replay window | | `watchHandler` per-event metadata failure | One malformed frame must not stop the watch | **Kept**: log, skip, advance past it | Tearing down reconnects at the same cursor, replays the same frame, wedges the watch until expiry | | Bookmarks | Keep an idle cursor fresh | **Kept** | Free server-side, avoids relists | | `BackoffManager` around reconnects | Survive a watch that keeps dropping | **Kept**, ours only | Always sending `timeout_seconds` is load-bearing: `kubernetes_asyncio` reads its absence as `watch_forever` and then reconnects and retries a 410 behind our back. Sending it keeps every reopen in `watch()` | | `IsTooLargeResourceVersion` | A cursor from the future is never satisfied | **Kept** as the code 504, **dropped** as a reason string | A rolled-back backend would otherwise freeze the store forever, and a plain gateway timeout costs one LIST. Go finds `ResourceVersionTooLarge` in `Status.Details.Causes[].Type` ([`isTooLargeResourceVersionError`](https://github.com/kubernetes/client-go/blob/master/tools/cache/reflector.go)), never in `Status.Reason`, so matching it as a reason string never fires | | Watch frame shape | Read a cursor out of whatever the client yields | **Kept**: an attribute, or a camelCase key when the frame is a dict, both through the `WatchFrame` model | `kubernetes_asyncio` deserializes into a model unless it has none, and only then leaves the raw JSON, which is camelCase. A dict spelling `resource_version` is one of the accepted aliases, since one model reads both shapes | | Frame payload type | Only some frames carry a pod | **Kept**: `PodWatchEvent.pod` is validated for `ADDED` / `MODIFIED` / `DELETED` only, and a pod frame that does not validate raises | `BOOKMARK` carries a bare `resourceVersion` and `ERROR` carries a `Status`, so validating either as a pod would fail on every frame. A pod frame miles cannot read is the apiserver breaking the contract, not an object to skip: skipping it would leave the store silently short of a pod. The envelope the reflector needs — the cursor, and a `Status`'s `code` and `reason` — is read from every frame by `WatchFrame` instead | | `ApiException` shape | Tell a dead cursor from a transient failure | **Kept**: `status` against 410 / 504 only | `ApiException.__init__` sets `status` from `http_resp.status` or the int the watch layer passes, so it is always an int and never carries a separate `code` | | An `ERROR` frame reaching the consumer | A watch that reports failure in-band | **Kept**, though `kubernetes_asyncio` cannot produce it | `Watch.unmarshal_event` raises `ApiException` on `type == "error"` rather than yielding, so the live path is the exception one. Kept because losing a cursor-death signal freezes the store forever and the dependency is unpinned | | `ListAndWatch` → `Run` → LIST again | Refresh after every watch ends | **Dropped**; reopen WATCH from the cursor | A LIST per timeout dominates an idle reflector's cost, and the cursor is still valid | | `BackoffUntil` | Keep a relist storm off the apiserver | **Replaced** by one flat `retry_delay` | One reflector, small label-scoped LIST | | LIST pagination | Huge collections | **Dropped** | Thousands of pods at most | ### `object_store.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Store | Read without hitting the apiserver | **Kept**, a plain `dict` | Single-threaded asyncio: no locks | | `Replace()` on relist | Deletions missed while disconnected | **Kept**, store-side | Ghost cells are forever. Store-side also survives a whole stream reopening, which a reflector-side diff cannot remember across. Costs one event type (`ReplaceEvent`) | | Indexer | Large-scale reverse lookup | **Dropped**; `dict[ObjectKey, ParentKey]` scanned | The parent map is already the index | | `EnqueueRequestForOwner` | Child event to parent key | **Kept** as `key_map`; an object it cannot map is evicted with an error, not stored | Cells are not Kubernetes objects, so the parent comes from labels, and one bad pod must not stall the pool. Go caches an object it cannot attribute and merely enqueues nothing, but this store is indexed by parent, so an object whose labels stop mapping leaves and re-drives the parent it left. Ignoring the update instead would serve it under that parent forever | | DeltaFIFO | Delta coalescing | **Dropped** | Reconcile reads a snapshot, so the queue needs parent-key dedup, never a delta chain | ### `work_queue.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | workqueue | The scheduling core | **Kept** as a dedup set; delayed retry lives in `retry_scheduler.py` | With one worker, the dirty/processing protocol collapses into the set | | `ShutDown` vs `ShutDownWithDrain` | Finish in-flight work first | **Dropped**; `stop()` cancels everything, then waits. Awaiting it inside reconcile asserts (use `asyncio.create_task`). It may only follow a `start()` call, but a `start()` that raised leaves no tasks and `stop()` is then a no-op that returns, so an aborted start unwinds carrying its own exception instead of an assertion | Drain exists for many Go workers. One worker means one in-flight key, and reconcile is idempotent, so abandoning it costs a re-derivation. A caller that wraps `start()` in `except: await stop()` is the normal shape, and it must not lose the failure it is unwinding | ### `retry_scheduler.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Delaying queue on earliest `readyAt` | Rate-limited retry | **Replaced** by latest-wins deadlines swept by one `POLL_INTERVAL` loop | A new failure overwrites a deadline instead of cancelling a task, so the delay always matches current state. Go sweeps a heap on an exact timer inside one `waitingLoop` goroutine; polling keeps the single loop and costs a retry up to one interval late, which a backoff heuristic absorbs. The poller is created in `__init__` and cancelled in `shutdown()`, so its lifetime is a straight line and it ticks even when idle, which costs one wakeup a second and buys having no state that decides when to run. Creating it there is why a `ReconcileLoop` must be constructed inside a running event loop | | Bucket rate limiter | Cap retry pressure | **Dropped** | In-memory accounting behind one worker: no backend to protect | | Delaying queue *is* a queue (`delayingType` embeds `Interface` and re-`Add`s to itself) | Retry without a second component | **Replaced** by an `on_retry` callback | Nothing here reads the queue, so a scheduler that only pushes needs no queue at all. `WorkQueue` and `RetryScheduler` then know no domain type, and `ReconcileLoop` names the key by instantiating them as `[ParentKey]` | | `Result{RequeueAfter}` | Timed re-check | **Dropped** | Backoff plus resync covers it | ### `source_stream_driver.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Cache-before-notify ordering | Handlers never read a stale state | **Kept** | Correctness, not volume | | WaitForCacheSync | Do not decide on a half-filled cache | **Kept**: `run()` + `wait_for_sync()`, the [`SyncingSource`](https://github.com/kubernetes-sigs/controller-runtime/blob/main/pkg/source/source.go) shape; `start()` awaits it | A partial engine list at step 0 would silently shrink the pool | | `source.Channel` | External event injection | **Dropped** | Miles-internal events are method calls | | Retry inside vs outside the stream | Recover without losing the cursor | **Kept** at both levels: `KubernetesReflector.retry_delay`, then `ReconcileLoop.source_retry_delay` | The inner one recovers a dropped watch, a failed LIST or an expired cursor without ending the stream. The outer one is the net for a stream that dies for good; in-process registries reach it | | Closing a stream during teardown | Do not hide a worse error | **Replaced**: every close is logged, none propagates | `run()`'s `finally` closes the stream and clears it before `stop()` gathers the driver, so `aclose()` only ever finds nothing left to close. That leaves `_aclose_logging_failure` as the single close path, and it must swallow: a close failure would otherwise mask an unwinding cancellation, or re-raise the very failure the driver exists to recover from | | Streaming a listing into the store | Sync a huge collection without holding it in memory | **Dropped**; a listing is one `ReplaceEvent`, and a stream that does not open with one is reopened | client-go buffers too: its watch-list path fills a `temporaryStore` until the `k8s.io/initial-events-end` bookmark, then calls `Replace` once. Ours materializes the page before yielding anyway, so bracketing it would only add a segment FSM and a half-applied state to police | ### `loop.py` | Upstream | Solves | Decision | Reason | | - | - | - | - | | Resync period | Level-triggered backstop | **Kept**, on by default at Go's 10 h, re-enqueues parents that still have members | Go defaults it on for the same reason we need it: insurance against this controller, or something it depends on, failing to requeue. Go's own limit is kept too, in that a parent which lost its last member is not re-driven. The 10 percent jitter is **dropped**: it spreads the LIST storm of many controllers, and there is one loop here whose resync only adds keys to a dedup set | | `MaxConcurrentReconciles` | Overlap reconcile I/O | **Dropped**; one worker | controller-runtime's default. No I/O to overlap | | Predicates | Save reconciles at scale | **Dropped** | Reconcile is cheap | | Lifecycle | Never leak a running loop | **`async with loop:`** | `start()` and `stop()` stay public, because `stop()` sometimes has to be scheduled from inside reconcile. Every caller that owns both ends writes the `async with` instead, and a caller that hands teardown back to someone else keeps the pairing with `AsyncExitStack.enter_async_context(loop)` | ### Dropped wholesale | Upstream | Reason | | - | - | | SharedInformer | One or two consumers per object type | | Manager, leader election, metrics, webhooks, multi-GVK cache, cached client | In-process objects, not a deployed operator | | kubebuilder / Operator SDK | One resource type, hand-written | | Thread-safe store / DeltaFIFO / workqueue | Under asyncio state mutates only between `await` points. Removes the data-race class, not the interleaving class: "check, await, mutate" still needs FSM discipline | ## Test layers The fake-based tests remain always-on. The real-apiserver and kind tests run in Stage B CPU when selected by `run-ci-k8s` or a broad CI scope such as `run-ci-all`, `run-ci-image`, or nightly. `pytest tests/e2e/k8s_apiserver tests/e2e/k8s_kind` self-provisions everything below from a Docker daemon, and the modules that provision it depend on nothing from the reconcile package, so an environment can be verified before anything uses it. | Layer | Where | Proves | Provisions | | - | - | - | - | | Fakes + fake clock | `tests/fast/utils/workers/reconcile/` | Control flow, timing, shutdown | Nothing | | Real apiserver, no kubelet | `tests/e2e/k8s_apiserver/` | API semantics: cursors, watch timeouts, real 410, relist | etcd and the apiserver as containers, plus a second apiserver with `--watch-cache=false` — with the cache on, a compacted `resourceVersion` is still served, so nothing can invalidate a live cursor | | kind cluster | `tests/e2e/k8s_kind/` | What only a kubelet produces: Running, restarts, graceful deletion, bookmarks | A pinned kind binary: a `kind` on `PATH` is used only when its version matches the pin, otherwise the pinned build is downloaded on demand and checked against its pinned SHA-256 | | Env var | Effect | | - | - | | `MILES_K8S_KEEP=1` | Leaves the environment up | | `MILES_K8S_KUBECONFIG=` | Reuses an existing cluster | | `MILES_K8S_REQUIRE=1` | Default in CI; missing Docker becomes a failure | # Versions and Images Source: https://miles.radixark.com/docs/developer/versions How the Miles, SGLang, and Megatron-LM trees fit together, which artifact owns each release identity, and what the Docker images pin. A Miles run is three Python source trees on one `PYTHONPATH`. Understanding which tree owns a given behavior, and which file pins its version, is most of what you need to debug a version problem or land a bump. ## The three trees | Tree | Comes from | Installed as | Lives at | | - | - | - | - | | Miles | this repository | `pip install -e . --no-deps` | `/root/miles` | | SGLang | the `sglang-miles` branch of [`sgl-project/sglang`](https://github.com/sgl-project/sglang), on top of the `lmsysorg/sglang:` base image | `pip install -e "python[all]" --no-deps` | `/sgl-workspace/sglang` | | Megatron-LM | the `miles-main` branch of [`radixark/Megatron-LM`](https://github.com/radixark/Megatron-LM) | `pip install -e .` | `/root/Megatron-LM` | Two things follow from that table. **The dependency branches are moving development sources.** Both carry patches Miles needs before they land upstream, which is why installing stock `sglang` or upstream Megatron-LM next to Miles does not work. Ordinary `dev` images follow those branches; a versioned release records their exact commits in `release-lock.json`. **All three are editable installs.** Nothing is copied into `site-packages`, so changing a file in any of the three trees changes the next run. That is also what lets CI move the SGLang and Megatron-LM checkouts to a different ref without reinstalling anything. The `version` in `setup.py` owns the base Miles release version `X.Y.Z`. Release candidates and post releases keep that base in `setup.py` and add `rcN` or `.postN` only to the exact Git and Docker tags. ## Where each pin is written ```text theme={null} docker/Dockerfile the image recipe and its default build-args docker/build.py the variant table: which build-args each variant overrides requirements.txt Miles' own Python dependencies release-lock.json generated on a release branch: dependency commits, CI image tag, and audit fingerprints ``` The default build-args are the version surface: | Build-arg | Default | What it selects | | - | - | - | | `SGLANG_IMAGE_TAG` | `v0.5.22` | The `lmsysorg/sglang` base image, which brings torch, CUDA, cuDNN and Mooncake | | `SGLANG_BRANCH` | `sglang-miles` | The branch fetched into the base image's SGLang checkout | | `SGLANG_COMMIT` | empty | Empty means the branch HEAD at build time; set it to freeze one commit | | `MEGATRON_REPO` / `MEGATRON_BRANCH` / `MEGATRON_COMMIT` | `radixark/Megatron-LM` / `miles-main` / empty | The Megatron-LM checkout; an empty commit follows branch HEAD, while a release build supplies the locked commit | | `MILES_COMMIT` | `main` | The Miles checkout baked into the image | | `WHEELS_REPO` | `radixark/miles-wheels` | The prebuilt-wheels repository | | `WHEELS_TAG_X86` / `WHEELS_TAG_ARM64` | `cu130-torch214-x86_64` / `cu130-torch214-aarch64` | Two complete wheels releases, selected by `TARGETARCH` and installed verbatim | Two design choices are worth naming. The Dockerfile holds the defaults and `build.py` owns the per-variant deltas, so there is exactly one place to look for what a variant changes. And a wheels release is installed as a whole release, with no tag assembled at build time, so the set of kernels in an image is one auditable name rather than a computed string. Everything else is pinned inline where it is installed: `mbridge` and `Megatron-Bridge` and `torch_memory_saver` at explicit commits, `flash-linear-attention`, `tilelang` and friends at explicit versions. Transformer Engine is special: the wheels release carries the `+miles` triplet built from `radixark/TransformerEngine` `miles-main` (NVIDIA's release commit plus the fixes Miles carries), and `docker/verify_transformer_engine.py` asserts the installed triplet is `2.17.0+miles`. Mooncake comes from the base image unchanged; Miles' Mooncake object-store backend needs 0.3.12.post1 or newer, the first release whose `mooncake.structured_object_store` has the API Miles imports. `requirements.txt` is Miles' own dependency list, and the convention there is that **a pin carries its reason inline**: `transformers==5.19.0` names the HF-native weight conversion and the SGLang release it matches, `blake3` / `xxhash` / `zstandard` name the disk-delta weight sync, `onnxscript` records that TE 2.17 imports it at import time. Follow that when you add one. ## Release identities and source ownership Release values represent different facts. Each row below names the source used by the supported automatic path. | Fact | Authoritative source | Derived or consumed values | | - | - | - | | Base Miles version | `setup.py` | Release branch `release/vX.Y.Z`; base-version check before tagging | | Exact published version | `version` input to `release-tag.yml`, persisted as annotated Git tag `v` | CUDA 13 image tag `v` | | Frozen dependency selection | `release-lock.json` committed on the release branch | SGLang commit, Megatron-LM commit, and the CUDA image tag used by release CI | For example, base version `0.3.0` owns branch `release/v0.3.0`; that branch can produce exact tags `v0.3.0rc0`, `v0.3.0`, and `v0.3.0.post1` without changing `setup.py` between tags. `release-lock.json` freezes the two dependency source commits and names the prune-exempt CUDA image used by release CI. Miles itself is frozen by the release commit and final Git tag. Wheels asset fingerprints are audit records, not content-addressed inputs. Use [Release a Version](/docs/developer/ci/04-release) for the maintainer procedure. That runbook owns workflow order, inputs, success signals, and recovery; this page owns the identity and pinning model. ## The images CUDA variants are published to [`radixark/miles`](https://hub.docker.com/r/radixark/miles) on Docker Hub, ROCm variants to `rocm/sgl-dev`. The [`dev` tag](https://hub.docker.com/r/radixark/miles?tag=dev) is what CI runs on unless a job says otherwise, so its last-pushed timestamp is the fastest way to see how current the fleet's image is. | Variant | Tag written by `--image-tag dev` | Platforms | | - | - | - | | `cu13` | `radixark/miles:dev` | `linux/amd64` + `linux/arm64`, one manifest. This is the daily image | | `cu13-x86` / `cu13-aarch64` | `radixark/miles:dev` | Single-arch rebuilds of the same image | | `rocm724-mi35x` / `rocm10-mi35x` | `rocm/sgl-dev:miles-rocm*-mi35x` | Native | | `rocm10-mi30x` | `rocm/sgl-dev:miles-rocm10-mi30x` | Native, MI300X / MI325X | `--image-tag dev` also publishes a timestamped sibling. Scheduled retention and manual tag behavior are documented in [Docker build](/docs/developer/ci/02-docker-build). Build one yourself with `docker/build.py`: ```bash theme={null} python docker/build.py --variant cu13-x86 --image-tag custom --custom-tag my-experiment --push ``` [Docker build](/docs/developer/ci/02-docker-build) is the full reference for the build script, the workflow and the tag rules. Official versioned releases add `radixark/miles:v` for the CUDA 13 multi-arch image. CUDA 12 image builds are retired, including rolling builds; previously published CUDA 12 tags remain available. Publishing a release does not move the rolling `dev` or `latest` families. ## What CI moves, and what it does not This is the part that decides whether your change needs a new image. A CUDA CI job starts from `radixark/miles:` and then: 1. Installs `examples/multi_lora/requirements.txt` before `requirements.txt` through `tests/ci/reconcile_dependencies.py`. It preserves installed CUDA 12/13 cuDNN versions with uv overrides so a resolve cannot move cuDNN off the image's version; images without cuDNN use pip. Before each install, a dry-run gate rejects replacement of installed GPU runtimes or packages with at least 100 MiB of recorded installed files, including same-version reinstalls. Update these packages in the image. 2. Resets both dependency checkouts and fetches the selected refs. Explicit dispatch or PR-body overrides win first, `release-lock.json` commits win when no override exists, and the moving `sglang-miles` / `miles-main` heads are the final defaults. 3. Sets `PYTHONPATH` to the Miles workspace plus both source roots. It never reinstalls the three source trees, because they are editable installs. So: | Your change | Needs a new image? | | - | - | | `requirements.txt` | Only when replacing an installed GPU runtime or a package with at least 100 MiB of recorded installed files; other changes install in the next CI run. | | A Dockerfile layer: a pinned wheel, an inline commit, a TE patch, the base image | Yes | | SGLang or Megatron-LM code | No. Point CI at a ref instead | | Miles code | No | The ROCm stage is the exception: it takes SGLang and Megatron-LM from `rocm/sgl-dev` unless the run names a ref for one, and never reads `release-lock.json`. ROCm CI runs independently and is not part of the versioned release gate. ## Bumping principle **Bump where the pin lives.** Python dependency requirements live in `requirements.txt`; image layers live in `docker/Dockerfile`; variant-only differences live in `docker/build.py`. A requirement change that replaces an installed GPU runtime or a package with at least 100 MiB of recorded installed files also needs an image rebuild so the dependency gate can retain the new image version. **Prefer moving the branch to pinning a commit during rolling development.** `SGLANG_COMMIT` and `MEGATRON_COMMIT` are empty by default, so ordinary images follow `sglang-miles` and `miles-main` together. A versioned release is the deliberate exception: its lockfile supplies both exact commits to CI and the final image build. **Validate an image-affecting bump on the PR that makes it.** A PR touching `docker/Dockerfile`, `docker/build.py`, `docker/verify_transformer_engine.py`, `docker/patch/**` or `requirements.txt` builds a `pr-` image first and runs every GPU suite inside it, and that fresh tag outranks a `ci-image-tag:` directive so the PR cannot accidentally test the old image. Add the `run-ci-image` label to widen the selection to every tag except the long-running and fault-tolerance ones. **Validate a dependency-ref bump without building anything.** Put the ref in the PR description and CI checks out that instead: ```text theme={null} ci-sglang-pr: #12345 ci-megatron-pr: my-fix-branch ``` `#12345` resolves to `refs/pull/12345/head`, so an unmerged upstream fix can be tested before it lands on `sglang-miles`. See [Contributing](/docs/developer/contributor-guide#pr-description-ci-tags) for all three directives. **Expect `dev` to move on its own, within a bound.** The scheduled check (every 10 minutes) polls the SGLang and Megatron-LM branch heads plus a fingerprint of the wheels release, and starts a rebuild within about 30 minutes of any of them moving. It deliberately does not poll Miles, which would rebuild constantly, and instead forces a build once the last one is 12 hours old. So `dev` follows its dependencies closely and trails Miles `main` by at most half a day. When you need that to stop moving underneath you, pin `ci-image-tag:` to a timestamped tag; the scheduled prune keeps every timestamped tag for at least 14 days. **The ROCm images move daily too.** The sgl-project/sglang nightlies rebuild the undated `rocm/sgl-dev:miles-rocm*-mi35x` and `miles-rocm10-mi30x` tags from Miles `main` every day and publish a dated `-YYYYMMDD` sibling; an out-of-band rebuild is a `workflow_dispatch` on the variant you want. ## After a bump, the usual suspects | Symptom | Look at | | - | - | | `CUDNN_STATUS_BAD_PARAM` in a fused-attention backward | Something re-resolved cuDNN below the image's version | | Build fails with "expected exactly one TransformerEngine 2.17.0+miles wheel" | The wheels release lacks the TE triplet for that arch, or `miles-main` moved off `2.17.0`; rebuild it (`build-wheels.yml` on x86\_64, by hand on aarch64) and update the Dockerfile globs together with `docker/verify_transformer_engine.py` | | `#error C++20 or later compatible compiler is required` while building a wheel | The new torch requires C++20 and the extension hard-codes `-std=c++17`; raise it in the extension's build flags, patching `setup.py` for a PyPI sdist | | `cannot import name 'FieldSchema' from 'mooncake.structured_object_store'` | The base image's Mooncake predates 0.3.12.post1, typically because `SGLANG_IMAGE_TAG` points at an older base | | A test passes locally but fails in CI, or the reverse | Compare the image tag and the two dependency refs or commits CI resolved. Every job logs all three | ## Related * [Installation](/docs/getting-started/installation) for pulling and running the image. * [Release a Version](/docs/developer/ci/04-release) for the maintainer release runbook. * [Contributing](/docs/developer/contributor-guide) for the CI labels and PR-description directives. * [Docker build](/docs/developer/ci/02-docker-build) for the build script, workflow triggers and tag mechanics. # Deterministic Training Source: https://miles.radixark.com/docs/diffusion/advanced/deterministic What --deterministic-mode covers, which attention backends it accepts, and what it deliberately does not fix. `--deterministic-mode` configures the **training actor's** forward and backward for repeatable execution with the same hardware, topology, software stack, and inputs. However, some argument gates are still incomplete, so configurations that do not support deterministic execution may still pass validation. ## What it turns on ### At actor spawn (`miles/ray/actor_group.py`) ```bash theme={null} NCCL_DETERMINISTIC=1 CUBLAS_WORKSPACE_CONFIG=:4096:8 ``` NCCL reads its variable at `init_process_group`, which runs in the base actor's `init` before the deterministic setup below, and cuBLAS at the first matmul — so both go into the spawn environment rather than being set from Python. Both are `setdefault`, so an explicit value in `--train-env-vars` wins. `:4096:8` rather than `:16:8`: both are deterministic, but the larger workspace keeps cuBLASLt from being workspace-limited. Costs roughly 32 MiB per handle. ### In the actor (`miles/backends/fsdp_utils/actor.py`) ```python theme={null} torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False torch.use_deterministic_algorithms(True, warn_only=False) ``` `warn_only=False` is load-bearing: SDPA's deterministic backward is gated on `!warnOnly` inside `aten`'s `attention_backward.cu`, so `warn_only=True` would silently be a no-op on the native backend — the most common one. ## Attention is the hard part `torch.use_deterministic_algorithms` only governs torch-native ops. A custom attention kernel is opaque to it and would run nondeterministically **without any warning**, so backends are classified explicitly: | `--fsdp-attention-backend` | How determinism is obtained | | - | - | | unset, `*native*`, `*math*` (SDPA) | torch's global flag covers it. `math` backends are deterministic by construction. | | `*flash*` (flash-attn, FA3) | torch's flag cannot reach it — miles patches `deterministic=True` onto the kernel entry points. | | `sage`, `xformers`, `flex`, `aiter`, … | No hook exists. **Rejected.** | The check runs **driver-side, before any actor launches** (`validate_attention_args`), so a misconfiguration fails in seconds instead of after a multi-node startup. A flash backend is also rejected when no diffusers flash entry point exposes a `deterministic` parameter (driver-side, same check). Native model packages (LTX) additionally check their own kernel at actor init and fail with a message listing which kernels were patched. ### How the flash patch works For diffusers-backed families, miles wraps the dispatch functions diffusers routes flash through: ``` flash_attn_func flash_attn_varlen_func flash_attn_3_func flash_attn_3_varlen_func ``` Each entry point with a `deterministic` parameter is replaced by `functools.partial(fn, deterministic=True)`; the rest are skipped. ## What it does not cover * **Rollout determinism.** Guaranteed by sglang-d and its post-training support. * **Train/rollout agreement.** Determinism removes run-to-run variance; it does not make the two forwards equal. That is a precision problem — see [Dtype Control](/docs/diffusion/advanced/dtype-control). ## Cost Real but usually acceptable: `cudnn.benchmark=False` gives up kernel autotuning, deterministic kernels are chosen over faster nondeterministic ones, and cuBLAS reserves \~32 MiB per handle. Recipes that need reproducibility more than throughput keep it on permanently. ## In CI The e2e suite is built on this flag. Each test under `tests/e2e/short/` runs a real recipe with `--deterministic-mode` for its registered short run (currently two or four rollouts) and compares every registered metric series — reward statistics, old/new log-probs, grad norm — **bit for bit** against a standard committed under `tests/ci/fixtures/e2e_standards/` (strict unless the test registers a per-metric tolerance). Determinism is what makes strict comparison viable: a tolerance wide enough to absorb run-to-run noise would also absorb small regressions. Standards are re-recorded by the PR author (`python tests/ci/e2e_metrics_registry.py record --test `, or the `record-e2e-standards` workflow, which uploads the result as an artifact) and never committed by CI. Because reward metrics are compared too, the CI recipes also pin the engine side — e.g. `--sglang-dit-precision fp16` — matching the "what it does not cover" list: bit-stable curves need both halves. ## Related * [Dtype Control](/docs/diffusion/advanced/dtype-control) — the other half of train/rollout numeric agreement. # Dtype Control Source: https://miles.radixark.com/docs/diffusion/advanced/dtype-control The global dtype flags, the model-boundary input cast policy, and per-parameter dtype overrides under FSDP2. Diffusion RL is unusually sensitive to precision. The PPO ratio is `exp(log_prob_new − log_prob_old)`, where `log_prob_old` came from the **rollout engine** (without recompute flag) and `log_prob_new` from the **trainer**. Any numeric difference between the two forwards shows up as a ratio drifting off 1.0 — an update signal made of nothing but rounding. ## 1. Global dtype control flags | Flag | Default | Applies to | | - | - | - | | `--fsdp-master-dtype` | `fp32` | The FSDP-sharded master copy: load dtype, shard dtype, optimizer state. | | `--fsdp-reduce-dtype` | `fp32` | `MixedPrecisionPolicy.reduce_dtype` — the gradient reduce-scatter. | | `--diffusion-forward-dtype` | `bf16` | The DiT forward compute on the training side. Must match `--sglang-dit-precision`. | | `--sglang-dit-precision` | `bf16` | The DiT forward compute in the sglang-d rollout engine. | It is **strongly not recommended** to modify any of these dtype settings unless you have a thorough understanding of what the change does. The defaults are what keeps `log_prob_new` comparable to `log_prob_old`; in particular, `--diffusion-forward-dtype` must be set to the same value as `--sglang-dit-precision`, and `--fsdp-reduce-dtype bf16` trades multi-rank gradient stability for nothing you want. ## 2. Input dtype control Autocast is not enough on its own. It governs op *interiors* — matmuls, convolutions — but element-wise ops run at whatever dtype their inputs already carry. So the dtype of the tensors handed to the DiT is itself a semantic choice, and it has to match what the family's sglang-d pipeline feeds its DiT. Each family therefore declares an `input_dtype_policy` over three boundary inputs: ```python theme={null} input_dtype_policy = {"latents": ..., "cond": ..., "timestep": ...} ``` | Value | Meaning | | - | - | | `"default"` | Cast to the run's `--diffusion-forward-dtype`. | | `"fp32"` / `"bf16"` / `"fp16"` | Cast to that dtype specifically. | | `None` | Passthrough — keep whatever dtype rollout handed us. | The base class defaults to passthrough on all three, but the model family dtypes in the `TrainPipelineConfig` are aligned with sglang-d by default. LTX-2 and Cosmos3 declare their own policies; every other family inherits passthrough: ```python theme={null} # miles/backends/fsdp_utils/configs/ltx.py input_dtype_policy = {"latents": "default", "cond": "default", "timestep": None} # miles/backends/fsdp_utils/configs/cosmos3.py input_dtype_policy = {"latents": "default", "cond": None, "timestep": "fp32"} ``` **Division of labour:** `input_dtype_policy` owns the boundary; autocast owns the interior. That split also keeps gradient-checkpointing recompute consistent, since the recomputed forward sees the same ambient autocast as the original. Autocast is off when `--diffusion-forward-dtype` is `fp32` and for families that set `enable_autocast = False` (MiniMax H3). ## 3. Per-parameter dtype overrides Some parameters must stay fp32 even in a bf16 forward — timestep embedders, `scale_shift_table`-style modulation parameters. The patterns match parameters only; buffers (such as RoPE frequencies) are outside this mechanism. FSDP2's stock `MixedPrecisionPolicy` is per-wrap, not per-parameter, so miles-diffusion adds a targeted patch. ### Declaring the rule A model's `FSDPParallelPlan` carries FQN glob patterns, matched against **root-relative** names: ```python theme={null} # miles/backends/fsdp_utils/models/diffusers/wan2_2/parallel_plan.py FSDP_PARALLEL_PLAN = FSDPParallelPlan( param_dtype_patterns={ "*.norm2.*": "fp32", }, ) ``` Semantics: * Patterns apply **in declaration order**, and a later pattern overrides an earlier one — a narrow rule can carve a parameter back out of a broad one. * A pattern matching **nothing** is an error, not a no-op. Rules do not rot silently when a model is renamed. * An assignment equal to the group default compiles to nothing. ### How it is compiled `compile_param_dtype_maps` (`miles/backends/fsdp_utils/mixed_precision.py`) does two passes: ```mermaid theme={null} flowchart LR A["param_dtype_patterns
root-relative globs"] B["one dtype per
matched parameter"] C["per-wrap map
wrap-local FQNs"] D["root_map
root FQNs"] E["mp_policy of that
fully_shard(block)"] F["mp_policy of the root
fully_shard(model)"] A -- "pass 1: expand" --> B B -- "pass 2: claimed
by a child wrap" --> C B -- "claimed by
no wrap" --> D C --> E D --> F ``` Put simply: the rules name parameters from the model root, but at runtime each `fully_shard` call looks its parameters up by wrap-local name — the compile step bridges the two. A parameter belongs to the first `fully_shard` call that reaches it: the block wraps first, the final root call takes whatever is left, exactly the order in which FSDP2 itself claims parameters. Each call then gets its own small map in its own namespace; at cast time a hit uses the override dtype, a miss falls back to the group `param_dtype`. The one thing this cannot express — two parameters in one wrap group sharing a local name but wanting different dtypes — is rejected at compile time. ### Expand FSDP2 with param-level dtype control When any override exists, the actor swaps in `ParamDtypeMixedPrecisionPolicy` — a `MixedPrecisionPolicy` extended with a per-parameter `param_dtype_map` — and patches FSDP2's casting path to honour it. Because the patch reaches into FSDP2 internals, it is version-gated on `torch==2.11.0` and raises on any other version; `requirements.txt` pins the matching torch. ## 4. Verifying the result To measure any of this, run with: ```bash theme={null} --diffusion-debug-mode --debug-skip-optimizer-step ``` The engine then returns per-step debug tensors and the trainer never updates weights, so any divergence is pure forward-path difference. Watch these metrics: | Metric | What it tells you | | - | - | | `train/model_output_mean_abs_diff` | Average trainer-vs-rollout `noise_pred` gap. | | `train/model_output_max_abs_diff` | Worst element. | | `train/model_output_rel_max` | The worst element relative to the rollout output's scale. | | `train/log_prob_mean_abs_diff` | The gap that actually feeds the PPO ratio. | | `train/ratio_abs_minus_1` | How far the ratio sits from 1.0 in a live run. | For a sense of scale: the Qwen-Image RoPE cache bug — CPU-built vs CUDA-built frequency tables differing by fp32 ULPs — produced a frozen-weight `noise_pred` mean |Δ| of about **2e-2**. Small absolute numbers here are not automatically fine; compare against a known-good run. With CFG enabled, `noise_pred` is the guided combination of two forwards weighted by the CFG scale (`true_cfg_scale` when set, else `--diffusion-guidance-scale`; Qwen-Image may also rescale it to the conditional branch's norm, see its [model page](/docs/diffusion/models/qwen-image/qwen-image)), so these metrics depend on the CFG settings — compare runs only at the same ones. # LoRA Training and Weight Sync Source: https://miles.radixark.com/docs/diffusion/advanced/lora PEFT LoRA on FSDP diffusion actors and weight sync to sglang-diffusion rollout engines over CUDA IPC or NCCL. Miles-diffusion trains LoRA adapters on the FSDP actor and syncs them to sglang-diffusion rollout engines each iteration. The rollout engine has no PEFT layers — weights arrive either as merged base tensors or as raw `lora_A` / `lora_B` pairs for local merge. ## 1. Recommended flags For colocated LoRA training, prefer **IPC merge** — push only `lora_A` / `lora_B` and let the rollout engine merge locally: ```bash theme={null} --use-lora \ --lora-ipc-weight-sync \ --lora-rank 32 \ --lora-alpha 64 \ --colocate ``` `--lora-rank` / `--lora-alpha` vary by recipe (e.g. SD3 uses 32/64; some others use 64/128). Without `--lora-ipc-weight-sync`, LoRA still trains but merges on the train side and pushes full merged weights (§3). IPC merge requires `--colocate`. Without `--colocate` (separate train and rollout GPU pools, e.g. async training), leave out `--lora-ipc-weight-sync`: LoRA always pushes only `lora_A` / `lora_B` over NCCL, and the rollout engine merges them the same way. ## 2. Key flags | Flag | Purpose | | - | - | | `--use-lora` | Enable PEFT LoRA on the FSDP actor | | `--lora-ipc-weight-sync` | Push only `lora_A`/`lora_B` over CUDA IPC; rollout merges locally (requires `--colocate`) | | `--lora-rank` | LoRA rank (recipe-specific; often 32 or 64) | | `--lora-alpha` | LoRA alpha (typically 2× rank) | | `--lora-target-modules` | Override family defaults (optional) | | `--lora-init-weights` | Init scheme, e.g. `gaussian` | | `--update-weight-buffer-size` | Sync bucket size in bytes (most recipes use 2 GB) | | `--update-weight-target-module` | Component to sync (SD3 default: `transformer`) | | `--colocate` | Train and rollout share GPU visibility and sync over CUDA IPC; without it, weights sync over NCCL | > [!WARNING] > > `--lora-ipc-weight-sync` requires `--use-lora`, and `--colocate` whenever weights > are synced (any run without `--train-only` or `--debug-rollout-only`). Without > colocation, CUDA IPC handles cannot cross the train/rollout process boundary; > LoRA then always syncs `lora_A` / `lora_B` over NCCL, and the flag is rejected. LoRA target modules default from the model family's `TrainPipelineConfig.lora_target_modules`. For SD3, see [SD3 model guide](/docs/diffusion/models/sd3/sd3). ## 3. Weight-sync strategies Selection logic in `miles/backends/fsdp_utils/actor.py`: | Condition | Updater class | Behavior | | - | - | - | | No LoRA, `--colocate` | `DiffusionUpdateWeightFromTensor` | Full base-weight IPC | | No LoRA, no `--colocate` | `DiffusionUpdateWeightFromDistributed` | Full base weights over NCCL | | `--use-lora --colocate`, no IPC | `DiffusionUpdateWeightFromTensorLoRA` | Merge `W + αBA/r` on train side, push merged weights | | `--use-lora --colocate --lora-ipc-weight-sync` | `DiffusionUpdateWeightFromTensorLoRAIPC` | Push only `lora_A`/`lora_B` over IPC (`weight_update_mode=lora_merge`); the rollout merges them or keeps them unmerged per `--sglang-lora-merge-mode` | | `--use-lora`, no `--colocate` | `DiffusionUpdateWeightLoRADistributed` | Push only `lora_A`/`lora_B` over NCCL (`weight_update_mode=lora_merge`); the rollout merges them or keeps them unmerged per `--sglang-lora-merge-mode` | `rollout_merges_lora(args)` in `miles/utils/arguments.py` is the single check for the last two rows; the engine's LoRA target modules, the merge precision and the adapter checks (§5) all follow it. Implementation: `miles/backends/fsdp_utils/diffusion_update_weight_utils.py`. ### Full-weight sync (no LoRA) FSDP shards are all-gathered into `FlattenedTensorBucket` objects, serialized via CUDA IPC, and sent to the rollout engine's `update_weights_from_tensor(load_format="flattened_bucket")`. ### Train-side merge (LoRA, no IPC) For each base layer with adapters, the updater computes: ``` W_merged = W_base + (B @ A) * scaling ``` on the fly, strips PEFT key prefixes, and pushes standard weight names that sglang-d expects (e.g. `transformer_blocks.0.attn.to_q.weight`). ### NCCL sync (no `--colocate`) Trainer rank 0 and every rollout engine GPU join one NCCL group (trainer rank 0, engine *i* from rank `1 + i × --rollout-num-gpus-per-engine`). For each bucket, rank 0 broadcasts the tensors while the engines receive them through `update_weights_from_distributed`. This needs an sglang build with distributed weight updates for diffusion engines. ### Rollout-side LoRA merge (IPC or NCCL) `DiffusionUpdateWeightFromTensorLoRAIPC` (IPC) and `DiffusionUpdateWeightLoRADistributed` (NCCL) share this path: 1. `FSDPHfWeightIterator.iter_hf_adapter_weights()` turns each LoRA module into one unit, its **lora\_A and lora\_B**, named by `to_hf_name()` (e.g. `transformer_blocks.0.attn.to_q.lora_A`). Fused families such as H3 keep these diffusers names; sglang-d `lora_merge` applies `param_names_mapping` and the disk-load FFN swap. 2. FSDP shard all-gather → `pack_units_by_size()` packs units into buckets of up to **`--update-weight-buffer-size`** (most recipes use 2 GB; a unit is never split, so one larger than that ships as its own bucket) → CUDA IPC or NCCL broadcast. 3. Rollout engine receives `weight_update_mode="lora_merge"` with `lora_alpha` and `lora_rank`. **Bucket packing:** every updater packs **units**, not individual tensors. When adding the next unit would reach or exceed `--update-weight-buffer-size`, the current bucket is flushed first; the whole unit then starts the next bucket. A unit is never split across buckets, so a `lora_A` / `lora_B` pair always arrives together. For a family whose rollout fuses projections into one layer (H3 `to_q/k/v`, Qwen-Image and Cosmos3 `add_q/k/v_proj`), the adapters of those projections form one **atomic update group** and share a bucket too: sglang-d's `lora_merge` replaces a fused layer's LoRA with the sections one request carries. Constant: `LORA_WEIGHT_UPDATE_MODE = "lora_merge"`. Rollout-side merge precision is controlled by environment variable `SGLANG_DIFFUSION_LORA_MERGE_FP32`: * `"1"` when `--diffusion-forward-dtype fp32` * `"0"` otherwise (no fp32 upcast: the engine merges in its own weight dtype, e.g. bf16) Set automatically in `RolloutManager` when spawning engines that merge LoRA. On the first few syncs, rank 0 logs lines like: ```text theme={null} LoRA weight sync v1 [transformer]: pushed N lora tensors in K buckets ``` After FSDP all-gather, IPC buckets are collected on the **gather-src rank** only, and that rank calls the rollout engine; over NCCL, trainer rank 0 broadcasts. If sync stalls or VRAM grows across rollouts, check trainer logs for `LoRA weight sync` lines and Ray worker stderr under `~/.ray/session_latest/logs/`. ## 4. Internals | File | Role | | - | - | | `miles/backends/fsdp_utils/diffusion_update_weight_utils.py` | Updater classes: send buckets over CUDA IPC or NCCL | | `miles/backends/fsdp_utils/adaptations/weight_bridge.py` | `ParamTransform` registry (train -> rollout name/shape), as in miles | | `miles/backends/fsdp_utils/hf_weight_iterator.py` | `FSDPHfWeightIterator`: FSDP shards -> HF-named units (full, LoRA-merged, LoRA adapters) | | `miles/backends/training_utils/weight_update/hf_weight_iterator/` | `HfWeightIteratorBase` and bucketing (atomic groups, size-bounded packing), as in miles | | `miles/backends/training_utils/weight_update/hf_weight_iterator/atomic_groups.py` | Per-family atomic groups for weights and adapters (today: LoRA adapters the rollout fuses into one layer) | | `miles/backends/fsdp_utils/actor.py` | Updater selection | | `miles/backends/fsdp_utils/model_loader.py` | LoRA apply via PEFT (`apply_lora`, `load_lora_adapter`) | | `miles/backends/sglang_diffusion_utils/sglang_diffusion_engine.py` | HTTP `update_weights_from_tensor` (IPC) / `update_weights_from_distributed` (NCCL) to rollout | | `miles/utils/arguments.py` | `rollout_merges_lora(args)` and LoRA weight-sync validation | | `miles/ray/rollout.py` | Engine env vars (`SGLANG_DIFFUSION_LORA_MERGE_FP32`) | ## 5. Limitations * **Rollout-side merge expresses only uniform standard LoRA** — adapters from `--lora-adapter-path` with rsLoRA or per-layer rank/alpha are rejected for it. They need `--colocate` without `--lora-ipc-weight-sync` (train-side merge), which has no NCCL path yet. * **One adapter per trained component** — every component in `--update-weight-target-module` gets its own adapter, all from one set of `--lora-*` flags per job. * **FSDP backend** — LoRA weight sync is implemented for the FSDP diffusion actor; Megatron LLM LoRA (in upstream Miles) uses a separate path. # SDE Step Backend Source: https://miles.radixark.com/docs/diffusion/advanced/sde-backend Train-side SDE dynamics for Flow-GRPO — when it applies, which flags to set, and how to check train/rollout alignment. Flow-matching RL algorithms fall into two paradigms: * **Coupled (Flow-GRPO)** — training re-scores the same `(x_t → x_{t+1})` transitions that rollout produced, so it needs tractable log-probs via `SdeStepBackend`. * **Decoupled (DiffusionNFT, …)** — training re-noises the final image at the rollout's sampling sigmas; rollout dynamics are irrelevant, so this backend is unused. ## 1. When it applies Flow-GRPO (`--loss-type policy_loss`, the default) calls `SdeStepBackend.sde_step_logprob` on each recorded transition. The backend mirrors sglang-diffusion's rollout stepping so train-side log-probs match rollout-side values. | Objective | Miles flag | Uses `SdeStepBackend`? | Paper | | - | - | - | - | | Flow-GRPO | `--loss-type policy_loss` | **Yes** | [Flow-GRPO](https://arxiv.org/abs/2505.05470) | | DiffusionNFT | `--loss-type nft` | **No** | [DiffusionNFT](https://arxiv.org/abs/2509.16117) | Canonical Flow-GRPO recipe: `scripts/run_diffusion_grpo_sd3_ocr_sglang.py`. Implementation: `miles/backends/fsdp_utils/sde_step_backend.py`. Override with `--sde-step-backend-path` if you need a custom kernel. ## 2. Dynamics types | Formulation | `--diffusion-sde-type` | Backend | Notes | | - | - | - | - | | Flow-SDE | `sde` (default) | `FlowSdeStepBackend` | Transition noise std η·√(σ/(1−σ))·√(−dt) with the rollout's recorded σ (the grid's second σ replaces the denominator's σ at σ = 1); [Flow-GRPO](https://arxiv.org/abs/2505.05470) | | CPS | `cps` | `CpsSdeStepBackend` | Transition noise std σ\_next·sin(η·π/2); log-prob is −(x−μ)², without the Gaussian constants or the 1/(2σ²) scale (forces `--diffusion-log-prob-no-const`); [FlowCPS](https://arxiv.org/abs/2509.05952) | | ODE | `ode` | `FlowSdeStepBackend` (placeholder; NFT never calls it) | Deterministic; NFT (or a custom loss formula) only — with the Flow-GRPO loss it is rejected at parse time | `--diffusion-noise-level` (η) sets the SDE noise scale. DanceGRPO-style constant-η schedules use the same `sde` backend with a tuned η ([DanceGRPO](https://arxiv.org/abs/2505.07818)). ## 3. Key flags These CLI flags configure sglang-diffusion rollout stepping and must stay consistent with the train-side backend: | CLI flag | Default | Role | | - | - | - | | `--diffusion-sde-type` | `sde` | Dynamics family (auto-selects backend) | | `--diffusion-noise-level` | `0.7` | η in the SDE kernel | | `--diffusion-num-steps` | `10` | Denoise schedule length | | `--diffusion-num-sde-steps` | `0` | Steps scored by the step strategy | | `--diffusion-sde-window-range` | None | e.g. `0,10` for a full window | | `--diffusion-step-strategy-path` | None | Which steps enter training | Example (SD3 Flow-GRPO OCR — full-window SDE): ```bash theme={null} --diffusion-step-strategy-path miles.rollout.step_strategy_hub.sde_window \ --diffusion-num-sde-steps 10 \ --diffusion-sde-window-range 0,10 \ --diffusion-noise-level 0.7 ``` Step strategies live in `miles/rollout/step_strategy_hub.py`. `sde_window` picks a contiguous window; `epoch_global_random_choice` picks a subset of `--diffusion-sde-candidate-steps`, shared by each block of `--rollout-batch-size` consecutive prompt groups (one rollout, unless a dynamic-sampling filter drops groups). Partial windows follow the MixGRPO / TempFlow-GRPO idea ([MixGRPO](https://arxiv.org/abs/2507.21802), [TempFlow-GRPO](https://arxiv.org/abs/2508.04324)). ## 4. Train / rollout alignment The train-side kernel must reproduce rollout dynamics. Mismatched σ resolution or noise level shows up as a rising `train/log_prob_mean_abs_diff`. Checklist: 1. Both sides read `--diffusion-sde-type` and `--diffusion-noise-level`, and the default backend is derived from the former, so only a custom `--sde-step-backend-path` can drift from the engine's dynamics. 2. Train-side scoring takes σ from the trajectory the rollout recorded (`sigma`, `next_sigma`, `sigma_max` per transition), not from a scheduler, so `--diffusion-num-steps` needs no train-side copy. 3. Match input dtypes between FSDP forward and the rollout engine for fp32 runs. 4. Watch `train/log_prob_mean_abs_diff` — near zero before the first optimizer step. ## 5. Pairs well with * [Customization](/docs/diffusion/user-guide/customization) — `--diffusion-step-strategy-path` and `--sde-step-backend-path`. * [SD3 model guide](/docs/diffusion/models/sd3/sd3) — Flow-GRPO vs NFT recipe flags. * [Quick Start](/docs/diffusion/getting-started/quick-start) — SD3.5 Flow-GRPO OCR walkthrough. # Single-Prompt Multi-Generation Source: https://miles.radixark.com/docs/diffusion/advanced/single-prompt-multi-gen One rollout request, N outputs — engine-side conditioning expansion, microgroup mechanics, and seed layout. GRPO needs `n_samples_per_prompt` outputs per prompt to normalize advantages within a group. The naive way is N separate requests, which encodes the same prompt N times and denoises N batches of size 1. Miles-diffusion instead sends **one request per microgroup** of up to M of those N samples: sglang-diffusion encodes the conditioning once, expands it engine-side, and denoises the microgroup's outputs as a batch. Batched expansion is sglang-diffusion's default; Krea-2 and FLUX 3 Image Klein validate a microgroup of 1 instead. ## 1. Microgroups `--rollout-microgroup-size M` splits each prompt group of `n_samples_per_prompt` samples into requests of at most M (`generate_and_rm_group` in `miles/rollout/sglang_diffusion_rollout.py`). Each request carries `num_outputs_per_prompt` = its microgroup's size: M, or less for the last microgroup when M does not divide `n_samples_per_prompt`: ``` group (1 prompt × n_samples_per_prompt samples) ├─ microgroup 0 → POST /rollout/generate num_outputs_per_prompt=M ├─ microgroup 1 → POST /rollout/generate num_outputs_per_prompt=M └─ ... (the last one may be smaller) ``` Microgroups of one group run as concurrent asyncio tasks, each routed to the least-loaded engine; in-flight requests across all engines are capped at `--sglang-server-concurrency` × the number of engines. Canonical values on `main`: | Recipe | `n_samples_per_prompt` | microgroup size | | - | - | - | | SD3.5 GRPO + OCR | 16 | 8 | | Qwen-Image GRPO + PickScore | 16 | 8 | | Wan2.2 GRPO + PickScore (5-GPU) | 16 | 8 | | Wan2.2 GRPO + PickScore (17-GPU) | 16 | 4 | | LTX-2.3 GRPO + PickScore | 8 | 1 — multi-gen works but single-gen is faster | | Cosmos3 GRPO | 16 | 1 — packed forward is single-sample | LTX-2.3 supports engine-side expansion, but the recipe generates one output per request: a multi-output video response is large enough that deserializing and scoring it stops overlapping with other in-flight requests, so single-output requests pipeline better end to end. ## 2. Seed layout Rollout stays deterministic and collision-free per sample: * sgl-d expands one request's `seed` into `seed, seed+1, …, seed+M−1` — one RNG stream per output. * The rollout (`generate_and_rm_group`) spaces request seeds so streams never overlap: ```python theme={null} seed_base = (rollout_seed + group_index * n_samples_per_prompt) % 2**31 microgroup_seed = seed_base + idx # idx = index of the microgroup's first sample: 0, M, 2M, … ``` `group_index` is monotonic across the run, so every `(rollout, prompt-group, sample)` triple gets a distinct seed (until `seed_base` wraps at 2^31). This is what makes rollout replay and [deterministic mode](/docs/diffusion/advanced/deterministic) possible at microgroup granularity. ## 3. Pairs well with * [Streaming Reward and Deserialization](/docs/diffusion/advanced/streaming-reward) — what happens to a microgroup response after generation. * [Deterministic Training](/docs/diffusion/advanced/deterministic) — seed layout is half of run reproducibility. * [Core Concepts](/docs/diffusion/user-guide/concepts) — where microgroup size sits among the batch knobs. # Streaming Reward and Deserialization Source: https://miles.radixark.com/docs/diffusion/advanced/streaming-reward How rollout responses stream through msgpack transport, a Ray parser-actor pool, and per-microgroup async reward scoring. Rollout in miles-diffusion is not "generate everything, then score everything". Each microgroup is generated, deserialized, and scored as an independent asyncio task, so reward computation and tensor decoding overlap with generation that is still in flight. This page covers the three stages of that pipeline and the knobs that keep each one off the critical path. ## 1. The per-microgroup pipeline `miles/rollout/sglang_diffusion_rollout.py` runs, per microgroup: ```mermaid theme={null} flowchart LR G[generate
POST /rollout/generate] --> D[deserialize
parser actor pool] D --> R[reward
rm_hub actor pool] ``` The rollout pipeline decomposes into `generate` / `deserialize` / `reward` components. Because every microgroup is its own `asyncio` task, the engine starts the next request while previous responses are still being unpacked and scored. Nothing waits for the full batch. ## 2. Deserialization: msgpack + parser-actor pool Trajectory tensors are large — for video models the response for one microgroup can be gigabytes, and encoding tensors into base64 further increases the size. In a naive implementation, tensors were base64-encoded inside a JSON body and parsed on the main asyncio event loop, one sample at a time. The current path: * **msgpack raw-bytes transport.** The engine responds with `application/msgpack`; `post(..., raw=True)` returns the body untouched, and tensors decode directly from safetensors raw bytes — no base64. * **Unpacking runs inside Ray actors, not the event loop.** `RolloutImageResponseParserActor.apply_raw(samples, raw)` does `msgpack.unpackb` + tensor decode in a separate process (`miles/utils/diffusion_rollout_response.py`), so the rollout event loop never blocks on a multi-GB unpack. * **One call per microgroup.** A whole microgroup is parsed in a single `apply_raw` call — fewer Ray RPCs, one unpack per response. * **A pool, round-robin dispatched.** `--rollout-parser-num-workers N` spins up N parser actors so multiple microgroups deserialize in parallel. * **Optionally, the parser fetches too.** With `--rollout-fetch-in-parser` (requires `--use-miles-router`; six shipped recipes set it, the LTX-2.3 one included) the parser actor also makes the request: it asks the router for the least-loaded engine, posts `/rollout/generate` itself, and parses the response in place (`fetch_and_apply`), so the response never passes through the rollout process. A parser then stays busy for the whole generation, so `perf/parser_max_queue_depth` counts queued generations, not just queued unpacks. Measured on the LTX-2.3 recipe (H200, same config, averaged over two consecutive RL steps, base64/JSON vs the current path): | Metric | base64/JSON | msgpack + pool | Speedup | | - | - | - | - | | `perf/rollout_time` | 157.4 s | 87.6 s | **\~1.8× (−44 %)** | | `perf/step_time` | 321.9 s | 252.1 s | **\~1.28× (−22 %)** | ## 3. Reward: scored as soon as a microgroup lands `generate_and_rm_microgroup` calls `batched_async_rm` immediately after parsing, per microgroup — not once at the end of the rollout: ```python theme={null} microgroup = await generate_microgroup(...) # generate + deserialize rewards = await batched_async_rm(args, microgroup) # score right away ``` Reward workers are Ray actor pools (see [Rewards](/docs/diffusion/user-guide/rewards) for per-reward flags), so scoring one microgroup overlaps with generation and deserialization of the others. ## 4. Diagnosing the pipeline Both actor pools report how deep their backlog was, which is what tells you whether a stage is keeping up. When a microgroup is dispatched, the pool records how many calls that worker already had in flight and keeps the largest value seen over the rollout. A reward pool keeps counting a scoring call's batches until the last of them returns: | Metric | Meaning | | - | - | | `perf/parser_max_queue_depth` | Deepest backlog on a parser actor when a response was handed to it | | `perf/reward_max_queue_depth_` | Deepest backlog on that reward pool's actors when a batch was handed to them, one metric per pool (`hps`, `pickscore`, `ocr`, `openai_api`, `custom_api`) | `0` means every dispatch found its worker idle — that stage never made anything wait. A number that climbs with the number of concurrent microgroups means the stage is saturated and requests are lining up behind it. The API pools (`openai_api`, `custom_api`) are one actor that runs `max_concurrency` requests at once, so for them a depth below `max_concurrency` means nothing waited; at or above it, requests may have waited. Read them together with `perf/rollout_time`: | Symptom | Look at | Likely fix | | - | - | - | | `perf/rollout_time` high | `perf/parser_max_queue_depth` > 0 | Responses are queuing on the parsers (deserialization, or with `--rollout-fetch-in-parser` whole generations); raise `--rollout-parser-num-workers` | | `perf/rollout_time` high | `perf/reward_max_queue_depth_` > 0 (API pools: ≥ `max_concurrency`) | That pool's scoring is the bottleneck; add its workers (API pools: raise `max_concurrency`), or give it a dedicated GPU | | `perf/rollout_time` high, both depths `0` | Nothing queued at dispatch | Usually the engines — check `--rollout-microgroup-size` and `--sglang-server-concurrency`. Parsing also runs inside a `--sglang-server-concurrency` slot, so a slow parse can hold back generation without a parser backlog | Both metrics are emitted only when the corresponding pool ran, so a reward with no actor pool leaves its `perf/reward_max_queue_depth_` absent rather than zero. # Contributing Source: https://miles.radixark.com/docs/diffusion/developer/contributor-guide Repository layout, the test suites, CI labels, and PR conventions for miles-diffusion. miles-diffusion is the diffusion-model sibling of [miles](https://github.com/radixark/miles). It shares miles' conventions — conventional commits, `pre-commit`, English-only PRs — but has its own test topology, because almost everything meaningful needs GPUs. ## Repository layout ```text theme={null} miles_diffusion/ ├── train_diffusion.py # the entry point — the whole train loop, ~90 lines ├── train_sft.py # train-only entry point: generates rollout i+1 while i trains ├── miles/ │ ├── backends/ │ │ ├── fsdp_utils/ # the training half │ │ │ ├── actor.py # FSDPTrainRayActor: wrap, forward, loss, step │ │ │ ├── configs/ # TrainPipelineConfig per model family │ │ │ ├── models/ # native model packages (ltx) + diffusers parallel plans │ │ │ ├── loss_hub/ # flow_grpo / nft / sft objectives │ │ │ ├── sequence_parallel/ # USP = Ulysses × Ring │ │ │ ├── monkey_patches/ # FSDP2 param-dtype patch (torch 2.11 gated) │ │ │ ├── mixed_precision.py # per-parameter dtype map compilation │ │ │ └── sde_step_backend.py# SDE dynamics + log-prob scoring │ │ └── sglang_diffusion_utils/# the rollout half: engine wrapper + parity patches │ ├── ray/ # RolloutManager, train actor group, placement groups │ │ └── data_conversion_hub/ # samples → train pairs │ ├── rollout/ # rollout fn, data source, rm_hub, filters, step strategies │ ├── router/ # miles router │ ├── dashboard/ # offline telemetry │ └── utils/ # arguments.py, types, metrics, debug tooling ├── scripts/ # run_*.py launchers — Typer CLIs, also the e2e entry points ├── tests/ # fast / fast-gpu / e2e / ci ├── docker/ # Dockerfile + pinned version tags └── docs/ # this site ``` If you are adding a **model family**, you will touch: `miles/backends/fsdp_utils/configs/.py` (register with `@register_train_pipeline_config`), optionally `miles/backends/fsdp_utils/models/...` for a native package or FSDP plan, a launcher in `scripts/`, and a page under `docs/models/`. ## Local dev loop ```bash theme={null} cd /root/miles_diffusion git remote add me git@github.com:/miles_diffusion.git git checkout -b feat/awesome pip install -e . --no-deps # editable install picks up changes pytest tests/fast -x -q # CPU suite: no GPU, seconds pytest tests/fast/backends/fsdp_utils/test_mixed_precision.py -xvs git add -p && git commit -m "feat(fsdp): short imperative description" git push me feat/awesome gh pr create ``` ## Tests Four tiers, by what they need: | Directory | Needs | What it is | | - | - | - | | `tests/fast/` | CPU only | Argument validation, config registry, dtype-map compilation, loss math, data splitting. The bulk of the suite. | | `tests/fast-gpu/` | 1-4 GPUs | FSDP behaviour that only exists on device: param-dtype maps, hybrid shard, SP attention parity, LoRA weight sync. Includes ported upstream PyTorch FSDP tests. | | `tests/e2e/short/` | 2-5 GPUs | Runs an actual launch script for a few rollouts and compares its **metric series** against a recorded standard. | | `tests/ci/` | — | The harness itself: registry, label filter, suite runner, e2e standards. | Run the CPU tier before every push; it is fast and catches most regressions. On a CPU-only box without sglang's GPU kernels, install the stubs first the way CI does: `uv pip install tests/ci/cpu_stubs`. CI does not call `pytest` directly — it goes through the suite runner, which is also the way to reproduce a CI failure locally: ```bash theme={null} python tests/ci/run_suite.py --hw cpu --suite stage-a-cpu --labels run-ci-fsdp python tests/ci/run_suite.py --hw cuda --suite stage-b-3-gpu-h200 --match-all-labels ``` Stage A (CPU) gates every GPU stage: a broken import should not burn GPU runner slots. ### Registering a test with CI CI discovers tests by **AST-parsing** a module-level `register_*_ci(...)` call — scheduling never imports the file, and `register_cpu_ci` / `register_cuda_ci` are runtime no-ops (`register_e2e_ci`, below, also runs the recipe when the file executes). ```python theme={null} from tests.ci.ci_register import register_cpu_ci register_cpu_ci(est_time=5, suite="stage-a-cpu", labels=["fsdp"]) ``` | Parameter | Meaning | | - | - | | `est_time` | Seconds. Used to pack the suite. | | `suite` | Which runner it lands on (below). | | `labels` | Domain gate. Empty/omitted = runs on **every** PR. | | `nightly` | Nightly only. | | `disabled` | A string reason; the test is skipped. | ### Suites | Suite | Runner | | - | - | | `stage-a-cpu`, `stage-b-cpu` | CPU | | `stage-b-3-gpu-h200`, `stage-b-5-gpu-h200` | H200, split into 3-GPU and 5-GPU runners via per-runner `CUDA_VISIBLE_DEVICES` | | `stage-c-3-gpu-h200`, `stage-c-5-gpu-h200` | Same hardware; stage C holds the long e2e runs, plus the 4-GPU sequence-parallel attention parity test | ### Labels A test with a non-empty `labels` list runs only when the PR carries `run-ci-