Run GRPO on one GPU with the radixark/miles image
One container, one small model, and one launcher script give you a logged GRPO step on a single GPU in about ten minutes.
We start from an empty folder and end with one finished GRPO step, logged to a file. The model is Qwen3-0.6B and the data is GSM8K, so the run fits on one GPU and each step takes a few minutes.
When you finish this section, you can start a GRPO run on one GPU. You can also find the log lines that prove the first step finished.
Our result: in a 12-step run of Qwen3-0.6B on one GB10, the first step took 233 s and the median step 167 s.
Check what you need
You need one NVIDIA GPU, Docker, and the NVIDIA container toolkit. Run this check first:
docker run --rm --gpus all radixark/miles:latest nvidia-smi --query-gpu=name,memory.total --format=csv
You should see your GPU name. If Docker says it cannot select a device driver, install the NVIDIA container toolkit and restart Docker.
name, memory.total [MiB]
NVIDIA GB10, [N/A]Our GPU was a DGX Spark GB10 with 128 GB of shared memory. We did not test a smaller card. Section 2.1 covers model size and memory.
Pull the image and record what you pulled
The latest tag moves. Record the image digest and the Miles commit before the run, so you can compare runs later.
docker pull radixark/miles:latest
docker image inspect --format "{{index .RepoDigests 0}} {{.Architecture}}" radixark/miles:latest
docker run --rm --entrypoint git radixark/miles:latest -C /root/miles log -1 --format="%h %cs %s"
docker run --rm --entrypoint /opt/sglang/bin/python3 radixark/miles:latest -c "import sglang;print(sglang.__version__)"
radixark/miles@sha256:c64fa344e3a86715f03c0a1b6a422c474e0035355591a2f6da87bf52a74c52da arm64
08752a1283 2026-10-15 fix(ci): keep host networks out of the builder disk cleanup (#4130)
0.5.22.dev46+g7b6993aThe image is built for arm64 and amd64. It holds its own build of SGLang, which is the rollout engine. Use that build and do not install another SGLang into the image.
Make a lab folder
The container reads the launcher script, the model, and the data from one folder that you mount at /root/lab. Create it and download the launcher script of this manual into it:
mkdir -p ~/miles-lab/runs ~/.cache/huggingface
curl -fsSL -o ~/miles-lab/miles_grpo.py https://raw.githubusercontent.com/rohitg00/ai-engineering-from-scratch/main/manuals/miles-101/capture/gpu/miles_grpo.py
If you have a clone of this repository, run cp manuals/miles-101/capture/gpu/miles_grpo.py ~/miles-lab/ from its root instead. The second folder is the model cache that the run mounts. If it does not exist, Docker creates it as root, and later downloads on the host fail.
The launcher downloads Qwen/Qwen3-0.6B and the zhuzilin/gsm8k dataset into the folder on its first run. Then it builds the Miles command and starts it.
Read the settings the launcher uses
The launcher passes 36 flags to Miles. Most of them keep their defaults. These are the ones that shape the run:
| Flag | Value | What it decides |
|---|---|---|
--colocate | on | the trainer and the rollout engine share the one GPU and take turns |
--train-backend | fsdp | the trainer uses PyTorch FSDP. Megatron, the default trainer of Miles, needs a checkpoint conversion first |
--rollout-batch-size | 32 | prompts per step |
--n-samples-per-prompt | 8 | answers per prompt, which form one group |
--global-batch-size | 256 | samples per optimizer step, which is 32 times 8 |
--rollout-max-response-len | 1024 | the longest answer, in tokens |
--rm-type | math | the reward is 1 for a correct final answer and 0 otherwise |
--sglang-mem-fraction-static | 0.4 | the share of GPU memory the rollout engine keeps for weights and the KV cache |
Group: the answers that one prompt gets in one step. GRPO scores each answer against the mean reward of its group.
The rollout batch size counts prompts, and the global batch size counts samples. With 32 prompts and 8 answers each, one rollout gives 256 samples, so each step makes one optimizer update.
Add flags to a run
The launcher reads its settings from environment variables. Set each one with -e on the docker run line:
| Variable | Default | What it sets |
|---|---|---|
MILES_NUM_ROLLOUT | 12 | the number of steps |
MILES_RUN | plain | the run name, which names the folder of the step events under runs/ |
MILES_SEED | not set | the trainer seed and the rollout seed |
MILES_MODEL | Qwen3-0.6B | the Qwen model that the launcher downloads |
MILES_EXTRA_ARGS | empty | more Miles flags, added after the 36 flags of the launcher |
Every recipe in this manual adds its flags through MILES_EXTRA_ARGS. For example, this line adds the top-p and top-k settings of section 4.4:
-e MILES_EXTRA_ARGS="--rollout-top-p 0.95 --rollout-top-k 64"
When a flag is in the launcher and also in MILES_EXTRA_ARGS, the value in MILES_EXTRA_ARGS wins, because Miles keeps the last value. Our runs with an engine memory fraction of 0.25 set it that way. There is one exception. With MILES_TRUE_ON_POLICY=1, the launcher adds its true on-policy flags after MILES_EXTRA_ARGS, so those flags win. Set the attention backends of that mode with MILES_SGLANG_ATTENTION and MILES_FSDP_ATTENTION instead.
Start the run
Run the container with the GPU, the lab folder, and your model cache mounted:
cd ~/miles-lab
docker run --rm --name miles-plain --gpus all --ipc=host --shm-size 32g --ulimit memlock=-1 \
-v "$HOME/miles-lab:/root/lab" -v "$HOME/.cache/huggingface:/root/.cache/huggingface" \
-e MILES_NUM_ROLLOUT=12 -e MILES_RUN=plain \
radixark/miles:latest python /root/lab/miles_grpo.py > runs/plain.log 2>&1
Our 12-step run took 35 minutes on the GB10, from the first log line to the last. Start the watchdog from section 1.2 in a second terminal before you leave the run alone.
To end a run early, stop its container from another terminal:
docker stop miles-plain
Give each run its own container name, MILES_RUN, and log file, so that two runs never share a log.
Read the startup lines
Open the log in another terminal with tail -f runs/plain.log. The run starts Ray, then the rollout engine, then the trainer. These lines tell you each part came up:
[2026-10-17 15:53:16.726 launcher] common.py:290 - EXEC: export PYTHONUNBUFFERED=1 && ray start --head --node-ip-address 127.0.0.1 --num-gpus 1 …
(CommandActor pid=2546) [2026-10-17 15:54:38] Load weight end. elapsed=5.85 s, type=Qwen3ForCausalLM, avail mem=94.82 GB, mem usage=1.40 GB.
(CommandActor pid=2546) [2026-10-17 15:54:40] KV Cache is allocated. dtype: torch.bfloat16, #tokens: 345645, K size: 18.46 GB, V size: 18.46 GBThe engine loaded 1.40 GB of weights and kept room for 345,645 tokens of KV cache. One step needs at most 256 answers of 1,024 tokens each, plus their prompts.
If no KV Cache is allocated line appears within five minutes, the rollout engine did not start. Read the lines above it for an Error or an AssertionError, and see section 7.1.
Find the first finished step
A step ends when the trainer logs a step 0 line and a perf 0 line. Search for them:
grep -c " - step [0-9]*: {" runs/plain.log
The count is the number of finished steps. Our first step logged these lines:
(FSDPTrainRayActor pid=2551) [2026-10-17 15:56:48.271 actor_cell00000_rank00000] log_utils.py:125 - rollout 0: {'rollout/response_lengths': 739.92578125, … 'rollout/raw_reward': 0.62890625, …
(FSDPTrainRayActor pid=2551) [2026-10-17 15:58:15.687 actor_cell00000_rank00000] log_utils.py:554 - step 0: { … 'train/train_rollout_logprob_abs_diff': 0.015336457639932632, …
(FSDPTrainRayActor pid=2551) [2026-10-17 15:58:15.688 actor_cell00000_rank00000] perf.py:56 - perf 0: {'perf/sleep_time': 2.1116080284118652, … 'perf/update_weights_time': 0.6647534370422363, … 'perf/step_time': 233.29494333267212, …In this 12-step run, the first step took 233 s and the median step took 167 s. In a typical step, the rollout took 57 s and the training phase took 105 s, and 25 s of that computed log probabilities. The weight update from the trainer to the engine took 0.65 s.
The two figures on this page use our 48-step run of the same setup. Its median step was 152.6 s and its median rollout 47.5 s. Its later steps wrote shorter answers, so its medians are lower.
Section 1.3 explains how to read these numbers over the first ten steps.
Miles radixark/miles:latest, Miles main of 2026-10-15 · 08752a1 · 2026-10-15 · Edition 2026.10