From fb58e3ca93fe47bd430be5dc143a882baf66fb7f Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 13:45:03 +0900 Subject: [PATCH 01/10] fix(slime): use lowercase sglang log level so the rollout HTTP server starts The GRPO recipes passed --sglang-log-level WARN (uppercase). SLIME forwards the value verbatim into SGLang's ServerArgs, and SGLang hands it to uvicorn, whose LOG_LEVELS dict is keyed by lowercase names only (no 'warn' key). uvicorn raises KeyError while building its Config, before the socket binds, so the rollout HTTP server never comes up: the engine core idles, the port never listens, and the RolloutManager waits on an unreachable /health_generate forever (GPU idle). Use lowercase 'info' in both run_grpo_qwen3_4b.sh and run_grpo_qwen3_30b_a3b.sh. The proper normalization belongs upstream in SGLang (it accepts WARN for its own stdlib logger but not for uvicorn); this recipe change is the immediate unblock. --- 3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh | 6 +++++- 3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh index 4e35e9fce..94b2ae4a0 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh @@ -138,7 +138,11 @@ TRAIN_ARGS=( --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" --sglang-mem-fraction-static 0.85 - --sglang-log-level WARN + # SGLang forwards this to uvicorn's log_level, which only accepts lowercase + # (critical/error/warning/info/debug/trace). Uppercase "WARN" raises a + # KeyError and uvicorn dies before the rollout HTTP server binds, so the + # rollout health check never passes and training hangs before it starts. + --sglang-log-level info --sglang-enable-ep-moe ) diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh index 1367c34f9..2082622b9 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh @@ -137,7 +137,11 @@ TRAIN_ARGS=( --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" --sglang-mem-fraction-static 0.8 - --sglang-log-level WARN + # SGLang forwards this to uvicorn's log_level, which only accepts lowercase + # (critical/error/warning/info/debug/trace). Uppercase "WARN" raises a + # KeyError and uvicorn dies before the rollout HTTP server binds, so the + # rollout health check never passes and training hangs before it starts. + --sglang-log-level info ) # Submit via Ray job API. From 5a3777fdd18b748adade9363ec09afcf2b062fad Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 11:39:11 +0900 Subject: [PATCH 02/10] fix(slime): pass MODEL_ARGS to train.py via a launcher script instead of bash -c The GRPO recipes built the whole train.py invocation into a single string and submitted it as `ray job submit ... -- bash -c "${TRAIN_CMD}"`, with the model config deferred as `\${MODEL_ARGS[@]}`. ray job submit re-joins the tokens after `--` with subprocess.list2cmdline and runs them through an outer `/bin/sh -c` (Popen(shell=True)), so that outer shell expands ${MODEL_ARGS[@]} to zero elements before the inner bash sources the model script. train.py then receives no model args and aborts at hf_validate_args ("hidden_size ... None"). Move the launch into recipe/launcher/grpo_launch.sh, which sources the model script and expands "${MODEL_ARGS[@]}" in the same shell, and submit it as `-- bash grpo_launch.sh ` with --working-dir. No bash array ever crosses the ray-submit boundary, matching how SLIME's own scripts/run-*.sh launch. The recipes now assemble train.py flags as a bash array (argv tokens); the README file tree lists the new launcher. Applies to both run_grpo_qwen3_4b.sh and run_grpo_qwen3_30b_a3b.sh. --- 3.test_cases/pytorch/slime/README.md | 6 +- .../slime/recipe/launcher/grpo_launch.sh | 67 ++++++++ .../slime/recipe/run_grpo_qwen3_30b_a3b.sh | 160 ++++++++++-------- .../pytorch/slime/recipe/run_grpo_qwen3_4b.sh | 160 ++++++++++-------- 4 files changed, 246 insertions(+), 147 deletions(-) create mode 100755 3.test_cases/pytorch/slime/recipe/launcher/grpo_launch.sh diff --git a/3.test_cases/pytorch/slime/README.md b/3.test_cases/pytorch/slime/README.md index 72abf202c..88d438dea 100644 --- a/3.test_cases/pytorch/slime/README.md +++ b/3.test_cases/pytorch/slime/README.md @@ -532,8 +532,10 @@ slime/ # 3.test_cases/pytorch/slime │ ├── reward-service.yaml # CPU reward service Deployment + Service │ └── data-prep-pod.yaml # Utility pod for data preparation ├── recipe/ -│ ├── run_grpo_qwen3_4b.sh # GRPO training launcher (Qwen3-4B, colocated) -│ └── run_grpo_qwen3_30b_a3b.sh # GRPO training launcher (Qwen3-30B-A3B MoE, disaggregated) +│ ├── run_grpo_qwen3_4b.sh # GRPO submit script (Qwen3-4B, colocated) +│ ├── run_grpo_qwen3_30b_a3b.sh # GRPO submit script (Qwen3-30B-A3B MoE, disaggregated) +│ └── launcher/ +│ └── grpo_launch.sh # Ray job entrypoint: sources the model script, expands MODEL_ARGS, execs train.py └── scripts/ ├── convert_checkpoint.sh # HF <-> Megatron conversion helper └── evaluate.sh # Evaluation launcher diff --git a/3.test_cases/pytorch/slime/recipe/launcher/grpo_launch.sh b/3.test_cases/pytorch/slime/recipe/launcher/grpo_launch.sh new file mode 100755 index 000000000..1400b0fc6 --- /dev/null +++ b/3.test_cases/pytorch/slime/recipe/launcher/grpo_launch.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +# ============================================================ +# SLIME GRPO training entrypoint (runs on the Ray worker). +# +# This is the single shell that the Ray job entrypoint executes. It is +# uploaded to the Ray cluster via `ray job submit --working-dir ` +# and invoked as: ... -- bash grpo_launch.sh +# +# WHY A DEDICATED LAUNCHER (design note): +# SLIME's per-model scripts (scripts/models/*.sh) define MODEL_ARGS as a bash +# ARRAY. `ray job submit` re-joins everything after `--` with +# subprocess.list2cmdline and runs it through an outer `/bin/sh -c` +# (Popen(shell=True)). If the array were referenced in the submitted string +# (e.g. `-- bash -c "... ${MODEL_ARGS[@]} ..."`), that outer shell would +# expand ${MODEL_ARGS[@]} BEFORE this script sources the definition, so it +# would expand to zero elements and train.py would receive no model config. +# Keeping the `source` and the array expansion inside this one launcher — +# which the outer shell never expands, because the entrypoint tokens are just +# `bash grpo_launch.sh ` — makes the whole class of shell +# escaping bug impossible. This matches how SLIME's own upstream launch +# scripts (scripts/run-*.sh) expand ${MODEL_ARGS[@]} in the same shell that +# sourced it. +# +# Inputs: +# $@ : all train.py flags assembled by the recipe (scalar +# argv tokens, safely quoted by Ray). +# env MODEL_SCRIPT : the SLIME model script to source (e.g. qwen3-4B.sh), +# passed through the Ray runtime env by the recipe. +# env SLIME_DIR : SLIME install dir in the image (default /opt/slime). +# ============================================================ + +set -euo pipefail + +SLIME_DIR="${SLIME_DIR:-/opt/slime}" + +if [[ -z "${MODEL_SCRIPT:-}" ]]; then + echo "[grpo_launch] ERROR: MODEL_SCRIPT env var is not set." >&2 + exit 1 +fi + +cd "${SLIME_DIR}" + +MODEL_SCRIPT_PATH="scripts/models/${MODEL_SCRIPT}" +if [[ ! -f "${MODEL_SCRIPT_PATH}" ]]; then + echo "[grpo_launch] ERROR: model script ${SLIME_DIR}/${MODEL_SCRIPT_PATH} not found." >&2 + exit 1 +fi + +# Sourcing defines the MODEL_ARGS bash array in THIS shell. +# shellcheck disable=SC1090 +source "${MODEL_SCRIPT_PATH}" + +# Fail fast if the model script did not populate MODEL_ARGS. This is the exact +# condition that previously slipped through to train.py as "hidden_size None". +if [[ "${#MODEL_ARGS[@]}" -eq 0 ]]; then + echo "[grpo_launch] ERROR: MODEL_ARGS is empty after sourcing ${MODEL_SCRIPT_PATH}." >&2 + exit 1 +fi + +echo "[grpo_launch] MODEL_SCRIPT=${MODEL_SCRIPT} MODEL_ARGS count=${#MODEL_ARGS[@]}" +echo "[grpo_launch] launching: python3 train.py <${#MODEL_ARGS[@]} model args> $# recipe args" + +# MODEL_ARGS is expanded here (same shell that sourced it); the recipe-provided +# flags arrive as "$@". Quote both so values containing spaces stay single tokens. +exec python3 train.py "${MODEL_ARGS[@]}" "$@" diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh index 574c5ca0f..4e35e9fce 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh @@ -53,102 +53,118 @@ echo " Rollout BS: ${ROLLOUT_BATCH_SIZE} x ${N_SAMPLES_PER_PROMPT}" echo " Global BS: ${GLOBAL_BATCH_SIZE}" echo "============================================================" +# Build the train.py flags as a bash ARRAY (not a single string). Each element +# is one argv token, so values are never re-split by a shell. The array is +# expanded into the `ray job submit -- ...` argv below; MODEL_ARGS itself is +# expanded inside recipe/launcher/grpo_launch.sh, in the same shell that sources +# the SLIME model script. See that launcher for why this avoids the shell +# escaping trap that a `-- bash -c "...${MODEL_ARGS[@]}..."` string would hit. +# # When RM_TYPE=remote_rm, point SLIME at the CPU-hosted reward Service via # --rm-url (see kubernetes/reward-service.yaml). Otherwise scoring is in-process. -RM_ARGS="--rm-type ${RM_TYPE}" +RM_ARGS=(--rm-type "${RM_TYPE}") if [ "${RM_TYPE}" = "remote_rm" ]; then if [ -z "${RM_URL}" ]; then echo "[ERROR] RM_TYPE=remote_rm but RM_URL is not set. Configure it in env_vars." exit 1 fi - RM_ARGS="${RM_ARGS} --rm-url ${RM_URL}" + RM_ARGS+=(--rm-url "${RM_URL}") echo " Reward: remote_rm @ ${RM_URL}" fi -TRAIN_CMD="cd /opt/slime && source scripts/models/${MODEL_SCRIPT} && python3 train.py \ - \${MODEL_ARGS[@]} \ - --hf-checkpoint ${MODEL_LOCAL} \ - --ref-load ${MODEL_DIST} \ - --load ${CHECKPOINT_DIR}/qwen3-30b-a3b-grpo/ \ - --save ${CHECKPOINT_DIR}/qwen3-30b-a3b-grpo/ \ - --save-interval ${SAVE_INTERVAL} \ - \ - --prompt-data ${PROMPT_DATA} \ - --input-key prompt \ - --label-key label \ - --apply-chat-template \ - --rollout-shuffle \ - \ - ${RM_ARGS} \ - \ - --num-rollout ${NUM_ROLLOUT} \ - --rollout-batch-size ${ROLLOUT_BATCH_SIZE} \ - --n-samples-per-prompt ${N_SAMPLES_PER_PROMPT} \ - --num-steps-per-rollout ${NUM_STEPS_PER_ROLLOUT:-1} \ - --global-batch-size ${GLOBAL_BATCH_SIZE} \ - \ - --rollout-max-response-len ${ROLLOUT_MAX_RESPONSE_LEN} \ - --rollout-temperature ${ROLLOUT_TEMPERATURE} \ - --balance-data \ - \ - --eval-interval 10 \ - --eval-prompt-data aime ${EVAL_DATA} \ - --n-samples-per-eval-prompt 4 \ - --eval-max-response-len 16384 \ - --eval-top-p 1 \ - \ - --tensor-model-parallel-size ${TP_SIZE} \ - --pipeline-model-parallel-size ${PP_SIZE} \ - --context-parallel-size ${CP_SIZE} \ - --expert-model-parallel-size ${EP_SIZE} \ - --expert-tensor-parallel-size 1 \ - --sequence-parallel \ - \ - --recompute-granularity full \ - --recompute-method uniform \ - --recompute-num-layers 1 \ - \ - --use-dynamic-batch-size \ - --max-tokens-per-gpu ${MAX_TOKENS_PER_GPU} \ - \ - --advantage-estimator grpo \ - --use-kl-loss \ - --kl-loss-coef 0.00 \ - --kl-loss-type low_var_kl \ - --entropy-coef 0.00 \ - --eps-clip 0.2 \ - --eps-clip-high 0.28 \ - \ - --optimizer adam \ - --lr ${LEARNING_RATE} \ - --lr-decay-style constant \ - --weight-decay 0.1 \ - --adam-beta1 0.9 \ - --adam-beta2 0.98 \ - \ - --actor-num-nodes ${ACTOR_NUM_NODES} \ - --actor-num-gpus-per-node ${ACTOR_GPUS_PER_NODE} \ - --rollout-num-gpus ${ROLLOUT_NUM_GPUS} \ - --rollout-num-gpus-per-engine ${ROLLOUT_GPUS_PER_ENGINE} \ - \ - --sglang-mem-fraction-static 0.85 \ - --sglang-log-level WARN \ - --sglang-enable-ep-moe" +TRAIN_ARGS=( + --hf-checkpoint "${MODEL_LOCAL}" + --ref-load "${MODEL_DIST}" + --load "${CHECKPOINT_DIR}/qwen3-30b-a3b-grpo/" + --save "${CHECKPOINT_DIR}/qwen3-30b-a3b-grpo/" + --save-interval "${SAVE_INTERVAL}" + + --prompt-data "${PROMPT_DATA}" + --input-key prompt + --label-key label + --apply-chat-template + --rollout-shuffle + + "${RM_ARGS[@]}" + + --num-rollout "${NUM_ROLLOUT}" + --rollout-batch-size "${ROLLOUT_BATCH_SIZE}" + --n-samples-per-prompt "${N_SAMPLES_PER_PROMPT}" + --num-steps-per-rollout "${NUM_STEPS_PER_ROLLOUT:-1}" + --global-batch-size "${GLOBAL_BATCH_SIZE}" + + --rollout-max-response-len "${ROLLOUT_MAX_RESPONSE_LEN}" + --rollout-temperature "${ROLLOUT_TEMPERATURE}" + --balance-data + + --eval-interval 10 + --eval-prompt-data aime "${EVAL_DATA}" + --n-samples-per-eval-prompt 4 + --eval-max-response-len 16384 + --eval-top-p 1 + + --tensor-model-parallel-size "${TP_SIZE}" + --pipeline-model-parallel-size "${PP_SIZE}" + --context-parallel-size "${CP_SIZE}" + --expert-model-parallel-size "${EP_SIZE}" + --expert-tensor-parallel-size 1 + --sequence-parallel + --recompute-granularity full + --recompute-method uniform + --recompute-num-layers 1 + + --use-dynamic-batch-size + --max-tokens-per-gpu "${MAX_TOKENS_PER_GPU}" + + --advantage-estimator grpo + --use-kl-loss + --kl-loss-coef 0.00 + --kl-loss-type low_var_kl + --entropy-coef 0.00 + --eps-clip 0.2 + --eps-clip-high 0.28 + + --optimizer adam + --lr "${LEARNING_RATE}" + --lr-decay-style constant + --weight-decay 0.1 + --adam-beta1 0.9 + --adam-beta2 0.98 + + --actor-num-nodes "${ACTOR_NUM_NODES}" + --actor-num-gpus-per-node "${ACTOR_GPUS_PER_NODE}" + --rollout-num-gpus "${ROLLOUT_NUM_GPUS}" + --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" + + --sglang-mem-fraction-static 0.85 + --sglang-log-level WARN + --sglang-enable-ep-moe +) + +# Submit via Ray job API. +# +# The entrypoint after `--` is `bash grpo_launch.sh ` (plain argv tokens, +# no shell array crosses the ray boundary). --working-dir uploads the launcher +# to the Ray workers; the SLIME code itself is already in the image at +# /opt/slime. MODEL_SCRIPT is forwarded so the launcher can source the right +# model definition. echo "[INFO] Submitting Ray job for MoE GRPO training..." ray job submit \ --address="http://127.0.0.1:8265" \ + --working-dir "${SCRIPT_DIR}/launcher" \ --runtime-env-json="{ \"env_vars\": { \"PYTHONPATH\": \"/opt/Megatron-LM\", \"HF_TOKEN\": \"${HF_TOKEN}\", + \"MODEL_SCRIPT\": \"${MODEL_SCRIPT}\", \"TOKENIZERS_PARALLELISM\": \"false\", \"NCCL_DEBUG\": \"WARN\", \"FI_PROVIDER\": \"efa\", \"FI_EFA_USE_DEVICE_RDMA\": \"1\" } }" \ - -- bash -c "${TRAIN_CMD}" + -- bash grpo_launch.sh "${TRAIN_ARGS[@]}" echo "[INFO] Job submitted. Monitor at http://localhost:8265" diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh index c2cdb635a..1367c34f9 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh @@ -53,102 +53,116 @@ echo " Global BS: ${GLOBAL_BATCH_SIZE}" echo " Num rollouts: ${NUM_ROLLOUT}" echo "============================================================" -# Build the training command +# Build the train.py flags as a bash ARRAY (not a single string). Each element +# is one argv token, so values are never re-split by a shell. The array is +# expanded into the `ray job submit -- ...` argv below; MODEL_ARGS itself is +# expanded inside recipe/launcher/grpo_launch.sh, in the same shell that sources +# the SLIME model script. See that launcher for why this avoids the shell +# escaping trap that a `-- bash -c "...${MODEL_ARGS[@]}..."` string would hit. +# # When RM_TYPE=remote_rm, point SLIME at the CPU-hosted reward Service via # --rm-url (see kubernetes/reward-service.yaml). Otherwise scoring is in-process. -RM_ARGS="--rm-type ${RM_TYPE}" +RM_ARGS=(--rm-type "${RM_TYPE}") if [ "${RM_TYPE}" = "remote_rm" ]; then if [ -z "${RM_URL}" ]; then echo "[ERROR] RM_TYPE=remote_rm but RM_URL is not set. Configure it in env_vars." exit 1 fi - RM_ARGS="${RM_ARGS} --rm-url ${RM_URL}" + RM_ARGS+=(--rm-url "${RM_URL}") echo " Reward: remote_rm @ ${RM_URL}" fi -TRAIN_CMD="cd /opt/slime && source scripts/models/${MODEL_SCRIPT} && python3 train.py \ - \${MODEL_ARGS[@]} \ - --hf-checkpoint ${MODEL_LOCAL} \ - --ref-load ${MODEL_DIST} \ - --load ${CHECKPOINT_DIR}/qwen3-4b-grpo/ \ - --save ${CHECKPOINT_DIR}/qwen3-4b-grpo/ \ - --save-interval ${SAVE_INTERVAL} \ - \ - --prompt-data ${PROMPT_DATA} \ - --input-key prompt \ - --label-key label \ - --apply-chat-template \ - --rollout-shuffle \ - \ - ${RM_ARGS} \ - \ - --num-rollout ${NUM_ROLLOUT} \ - --rollout-batch-size ${ROLLOUT_BATCH_SIZE} \ - --n-samples-per-prompt ${N_SAMPLES_PER_PROMPT} \ - --num-steps-per-rollout ${NUM_STEPS_PER_ROLLOUT:-1} \ - --global-batch-size ${GLOBAL_BATCH_SIZE} \ - \ - --rollout-max-response-len ${ROLLOUT_MAX_RESPONSE_LEN} \ - --rollout-temperature ${ROLLOUT_TEMPERATURE} \ - --balance-data \ - \ - --eval-interval 10 \ - --eval-prompt-data aime ${EVAL_DATA} \ - --n-samples-per-eval-prompt 8 \ - --eval-max-response-len 16384 \ - --eval-top-p 1 \ - \ - --tensor-model-parallel-size ${TP_SIZE} \ - --pipeline-model-parallel-size ${PP_SIZE} \ - --context-parallel-size ${CP_SIZE} \ - --expert-model-parallel-size ${EP_SIZE} \ - --sequence-parallel \ - \ - --recompute-granularity full \ - --recompute-method uniform \ - --recompute-num-layers 1 \ - \ - --use-dynamic-batch-size \ - --max-tokens-per-gpu ${MAX_TOKENS_PER_GPU} \ - \ - --advantage-estimator grpo \ - --use-kl-loss \ - --kl-loss-coef 0.00 \ - --kl-loss-type low_var_kl \ - --entropy-coef 0.00 \ - --eps-clip 0.2 \ - --eps-clip-high 0.28 \ - \ - --optimizer adam \ - --lr ${LEARNING_RATE} \ - --lr-decay-style constant \ - --weight-decay 0.1 \ - --adam-beta1 0.9 \ - --adam-beta2 0.98 \ - \ - --actor-num-nodes ${ACTOR_NUM_NODES} \ - --actor-num-gpus-per-node ${ACTOR_GPUS_PER_NODE} \ - --colocate \ - --rollout-num-gpus-per-engine ${ROLLOUT_GPUS_PER_ENGINE} \ - \ - --sglang-mem-fraction-static 0.8 \ - --sglang-log-level WARN" - -# Submit via Ray job API +TRAIN_ARGS=( + --hf-checkpoint "${MODEL_LOCAL}" + --ref-load "${MODEL_DIST}" + --load "${CHECKPOINT_DIR}/qwen3-4b-grpo/" + --save "${CHECKPOINT_DIR}/qwen3-4b-grpo/" + --save-interval "${SAVE_INTERVAL}" + + --prompt-data "${PROMPT_DATA}" + --input-key prompt + --label-key label + --apply-chat-template + --rollout-shuffle + + "${RM_ARGS[@]}" + + --num-rollout "${NUM_ROLLOUT}" + --rollout-batch-size "${ROLLOUT_BATCH_SIZE}" + --n-samples-per-prompt "${N_SAMPLES_PER_PROMPT}" + --num-steps-per-rollout "${NUM_STEPS_PER_ROLLOUT:-1}" + --global-batch-size "${GLOBAL_BATCH_SIZE}" + + --rollout-max-response-len "${ROLLOUT_MAX_RESPONSE_LEN}" + --rollout-temperature "${ROLLOUT_TEMPERATURE}" + --balance-data + + --eval-interval 10 + --eval-prompt-data aime "${EVAL_DATA}" + --n-samples-per-eval-prompt 8 + --eval-max-response-len 16384 + --eval-top-p 1 + + --tensor-model-parallel-size "${TP_SIZE}" + --pipeline-model-parallel-size "${PP_SIZE}" + --context-parallel-size "${CP_SIZE}" + --expert-model-parallel-size "${EP_SIZE}" + --sequence-parallel + + --recompute-granularity full + --recompute-method uniform + --recompute-num-layers 1 + + --use-dynamic-batch-size + --max-tokens-per-gpu "${MAX_TOKENS_PER_GPU}" + + --advantage-estimator grpo + --use-kl-loss + --kl-loss-coef 0.00 + --kl-loss-type low_var_kl + --entropy-coef 0.00 + --eps-clip 0.2 + --eps-clip-high 0.28 + + --optimizer adam + --lr "${LEARNING_RATE}" + --lr-decay-style constant + --weight-decay 0.1 + --adam-beta1 0.9 + --adam-beta2 0.98 + + --actor-num-nodes "${ACTOR_NUM_NODES}" + --actor-num-gpus-per-node "${ACTOR_GPUS_PER_NODE}" + --colocate + --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" + + --sglang-mem-fraction-static 0.8 + --sglang-log-level WARN +) + +# Submit via Ray job API. +# +# The entrypoint after `--` is `bash grpo_launch.sh ` (plain argv tokens, +# no shell array crosses the ray boundary). --working-dir uploads the launcher +# to the Ray workers; the SLIME code itself is already in the image at +# /opt/slime. MODEL_SCRIPT is forwarded so the launcher can source the right +# model definition. echo "[INFO] Submitting Ray job..." ray job submit \ --address="http://127.0.0.1:8265" \ + --working-dir "${SCRIPT_DIR}/launcher" \ --runtime-env-json="{ \"env_vars\": { \"PYTHONPATH\": \"/opt/Megatron-LM\", \"HF_TOKEN\": \"${HF_TOKEN}\", + \"MODEL_SCRIPT\": \"${MODEL_SCRIPT}\", \"TOKENIZERS_PARALLELISM\": \"false\", \"NCCL_DEBUG\": \"WARN\", \"FI_PROVIDER\": \"efa\", \"FI_EFA_USE_DEVICE_RDMA\": \"1\" } }" \ - -- bash -c "${TRAIN_CMD}" + -- bash grpo_launch.sh "${TRAIN_ARGS[@]}" echo "[INFO] Job submitted. Monitor at http://localhost:8265" From f3d5dbea077afcb5dab63da514f09fd2f01649a8 Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 15:49:57 +0900 Subject: [PATCH 03/10] fix(slime): use lowercase 'warning' log level (preserve original intent) Verified that uvicorn accepts lowercase 'warning' (its LOG_LEVELS has a 'warning' key but no 'warn' key). The original recipe intent was WARN-level verbosity, so use lowercase 'warning' rather than 'info' to keep that intent while avoiding the uppercase KeyError that hangs the rollout HTTP server. --- .../pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh | 12 +++++++----- .../pytorch/slime/recipe/run_grpo_qwen3_4b.sh | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh index 94b2ae4a0..896993994 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh @@ -138,11 +138,13 @@ TRAIN_ARGS=( --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" --sglang-mem-fraction-static 0.85 - # SGLang forwards this to uvicorn's log_level, which only accepts lowercase - # (critical/error/warning/info/debug/trace). Uppercase "WARN" raises a - # KeyError and uvicorn dies before the rollout HTTP server binds, so the - # rollout health check never passes and training hangs before it starts. - --sglang-log-level info + # SGLang forwards this to uvicorn's log_level, whose LOG_LEVELS dict is keyed + # by lowercase names only (critical/error/warning/info/debug/trace) with no + # "warn" key. Uppercase "WARN" (the original value) raises a KeyError and + # uvicorn dies before the rollout HTTP server binds, so the rollout health + # check never passes and training hangs before it starts. Use lowercase + # "warning" to preserve the original intended verbosity. + --sglang-log-level warning --sglang-enable-ep-moe ) diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh index 2082622b9..f8202e23f 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_4b.sh @@ -137,11 +137,13 @@ TRAIN_ARGS=( --rollout-num-gpus-per-engine "${ROLLOUT_GPUS_PER_ENGINE}" --sglang-mem-fraction-static 0.8 - # SGLang forwards this to uvicorn's log_level, which only accepts lowercase - # (critical/error/warning/info/debug/trace). Uppercase "WARN" raises a - # KeyError and uvicorn dies before the rollout HTTP server binds, so the - # rollout health check never passes and training hangs before it starts. - --sglang-log-level info + # SGLang forwards this to uvicorn's log_level, whose LOG_LEVELS dict is keyed + # by lowercase names only (critical/error/warning/info/debug/trace) with no + # "warn" key. Uppercase "WARN" (the original value) raises a KeyError and + # uvicorn dies before the rollout HTTP server binds, so the rollout health + # check never passes and training hangs before it starts. Use lowercase + # "warning" to preserve the original intended verbosity. + --sglang-log-level warning ) # Submit via Ray job API. From 63320097dd74fd52d383e5935b05a24605f8d29a Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 19:08:07 +0900 Subject: [PATCH 04/10] fix(slime): heal the CUDA-13 torch_memory_saver LD_PRELOAD selection in-place On CUDA 13 the upstream SLIME actor_group.py picks the torch_memory_saver preload .so by filename existence (cu12, then unsuffixed), not by CUDA runtime. Both are cu12-linked, so LD_PRELOAD makes the train worker die with 'libcudart.so.12: cannot open shared object file'; the correct cu13 build ships in the wheel but is never enumerated. Rather than fork SLIME or hard-code a fork URL, install upstream SLIME as-is and run a self-neutralizing patch step that rewrites the .so selection to delegate to torch_memory_saver's own CUDA-aware resolver (get_binary_path_from_package), with a loadability-based fallback for older torch_memory_saver. The patch only touches the file when the unfixed pattern is present, so once the equivalent fix lands upstream in THUDM/slime it becomes a no-op automatically. Verified on H200 (CUDA 13): LD_PRELOAD resolves to the cu13 build and the train worker starts (no libcudart.so.12 error), reaching Megatron init. --- 3.test_cases/pytorch/slime/README.md | 2 + .../slime/patches/apply_slime_patches.py | 230 ++++++++++++++++++ 3.test_cases/pytorch/slime/slime.Dockerfile | 17 ++ 3 files changed, 249 insertions(+) create mode 100644 3.test_cases/pytorch/slime/patches/apply_slime_patches.py diff --git a/3.test_cases/pytorch/slime/README.md b/3.test_cases/pytorch/slime/README.md index 88d438dea..60ed1348f 100644 --- a/3.test_cases/pytorch/slime/README.md +++ b/3.test_cases/pytorch/slime/README.md @@ -522,6 +522,8 @@ slime/ # 3.test_cases/pytorch/slime ├── env_vars.colocated.example # Base config: colocated train+rollout, built-in reward ├── env_vars.disaggregated.example # Overlay: reward model on a CPU pool + heavier GRPO ├── slime.Dockerfile # SLIME + SGLang + Megatron + EFA image +├── patches/ +│ └── apply_slime_patches.py # Self-neutralizing in-place fixes for the pinned upstream SLIME checkout (no-op once upstream merges them) ├── requirements.txt # Pinned Python RL dependencies ├── reward_service.Dockerfile # CPU-only image for the remote reward service ├── reward_service/ diff --git a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py new file mode 100644 index 000000000..1a06a1304 --- /dev/null +++ b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Self-neutralizing patches for the pinned upstream SLIME checkout. + +This test case installs SLIME straight from upstream (``THUDM/slime`` at the +pinned ``SLIME_VERSION`` -- no fork, no forked URL). A handful of upstream bugs +still block a clean end-to-end run on B300 / H200 (CUDA 13). Rather than fork +SLIME or hard-code a fork URL, this script applies each fix *in place* against +the upstream checkout, and only when the unfixed pattern is actually present. + +Design goals (why this shape): + * Default is upstream. The image installs upstream SLIME as-is; this runs + afterwards as a thin, auditable layer. + * Self-neutralizing. Every patch first checks whether the upstream code still + exhibits the bug. Once upstream merges the corresponding fix, the pattern is + gone and the patch becomes a no-op automatically -- nothing to remember, no + version pin to bump, no fork to track. Upstream simply wins. + * Idempotent. Re-running (or running against an already-patched tree) is safe; + an applied patch is detected and skipped. + * Minimal blast radius. Each patch is scoped to the smallest possible edit and + is a no-op unless its exact precondition matches, so an unexpected upstream + refactor makes the patch skip (and say so) rather than corrupt the file. + +Each patch links to the upstream issue/PR that will make it unnecessary. When +all patches report "already fixed upstream", this file can be deleted. + +Usage (from the Dockerfile, right after the upstream SLIME install): + python3 patches/apply_slime_patches.py --slime-root /opt/slime + +Exit code is 0 whenever every patch ends in a known-good state (applied, +already-applied, or already-fixed-upstream). It is non-zero only if a patch's +target file is missing or a patch is genuinely unable to reach a good state, so +a broken image fails the build loudly instead of silently shipping the bug. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +# --- helper injected into slime/ray/actor_group.py -------------------------- +# Identical in behavior to the upstream fix proposed in the accompanying PR: +# delegate .so selection to torch_memory_saver's own CUDA-aware resolver, with a +# loadability-based fallback for older torch_memory_saver builds that predate it. +_TMS_HELPER = ''' + +def _resolve_tms_preload_lib(torch_memory_saver): + """Path to the torch_memory_saver preload .so that matches the CUDA runtime. + + Prefer the library's own CUDA-aware resolver. Fall back -- for older + torch_memory_saver builds that lack it -- to a candidate list that includes + the cu variant for the *detected* CUDA major and picks the first that + actually loads (existence is not loadability: a cu12 .so exists on a CUDA 13 + box but cannot be dlopen'd, which is what makes this fail on CUDA 13). + + Injected by awsome-distributed-training patches/apply_slime_patches.py as a + stopgap until the equivalent fix lands upstream in THUDM/slime. + """ + import os as _os + + stem = "torch_memory_saver_hook_mode_preload" + + try: + from torch_memory_saver.utils import get_binary_path_from_package + + return str(get_binary_path_from_package(stem)) + except Exception: + pass + + import ctypes + + base = _os.path.dirname(_os.path.dirname(torch_memory_saver.__file__)) + try: + import torch + + cuda = getattr(torch.version, "cuda", None) + major = cuda.split(".", 1)[0] if cuda else None + except Exception: + major = None + + candidates = [] + if major: + candidates.append(f"{stem}_cu{major}.abi3.so") + candidates += [f"{stem}.abi3.so", f"{stem}_cu12.abi3.so", f"{stem}_cu13.abi3.so"] + + tried = [] + for name in candidates: + path = _os.path.join(base, name) + if not _os.path.exists(path): + continue + try: + ctypes.CDLL(path) + except OSError as exc: + tried.append(f"{name}: {exc}") + continue + return path + + raise FileNotFoundError( + "Could not find a loadable torch_memory_saver preload library for the " + f"current CUDA runtime under {base}. Tried: {tried or candidates}" + ) +''' + +_HELPER_MARKER = "def _resolve_tms_preload_lib(" + +# The unfixed upstream pattern hard-codes the preload .so filename(s) and selects +# by existence. We match on the filename token that only appears in that unfixed +# path; once upstream selects by CUDA runtime, this token is gone and the patch +# self-neutralizes. +_BROKEN_TOKEN = '"torch_memory_saver_hook_mode_preload.abi3.so"' +_ENV_ASSIGN = 'env_vars["LD_PRELOAD"] = dynlib_path' +_ENV_ASSIGN_REPLACEMENT = ( + "dynlib_path = _resolve_tms_preload_lib(torch_memory_saver)\n\n" + ' env_vars["LD_PRELOAD"] = dynlib_path' +) + + +class PatchResult: + """Outcome of one patch: (status, message). status in known-good set => ok.""" + + GOOD = {"applied", "already-applied", "already-fixed-upstream"} + + def __init__(self, name: str, status: str, message: str): + self.name = name + self.status = status + self.message = message + + @property + def ok(self) -> bool: + return self.status in self.GOOD + + def __str__(self) -> str: + flag = "OK " if self.ok else "ERR" + return f"[{flag}] {self.name}: {self.status} -- {self.message}" + + +def patch_tms_preload_selection(slime_root: Path) -> PatchResult: + """Fix: pick the torch_memory_saver LD_PRELOAD .so by CUDA runtime, not by + filename existence (upstream issue/PR: torch_memory_saver cu13 selection). + + On CUDA 13, upstream selects a cu12-linked .so and every child dies with + 'libcudart.so.12: cannot open shared object file'. See the accompanying + SLIME PR for the full analysis. + """ + name = "tms-preload-cuda-aware" + target = slime_root / "slime" / "ray" / "actor_group.py" + if not target.is_file(): + return PatchResult(name, "error", f"target not found: {target}") + + src = target.read_text() + + # (a) already patched by us? + if _HELPER_MARKER in src: + return PatchResult(name, "already-applied", f"{target} already has the helper") + + # (b) upstream already fixed it? The unfixed path is identified by the + # hard-coded preload filename token; if it is gone, there is nothing to fix. + if _BROKEN_TOKEN not in src: + return PatchResult( + name, + "already-fixed-upstream", + "unfixed pattern absent; leaving upstream code untouched", + ) + + # (c) the assignment site we hang the helper call on must be present and + # unique, or we refuse to edit (an unexpected refactor -> skip, do not guess). + if src.count(_ENV_ASSIGN) != 1: + return PatchResult( + name, + "error", + f'expected exactly one occurrence of `{_ENV_ASSIGN}`, ' + f"found {src.count(_ENV_ASSIGN)}; refusing to edit", + ) + + # Insert the helper after the import block (after the last top-level import), + # and route the existing assignment through it. + lines = src.splitlines(keepends=True) + insert_at = 0 + for i, line in enumerate(lines): + if line.startswith(("import ", "from ")): + insert_at = i + 1 + patched = "".join(lines[:insert_at]) + _TMS_HELPER + "".join(lines[insert_at:]) + patched = patched.replace(_ENV_ASSIGN, _ENV_ASSIGN_REPLACEMENT, 1) + + # Byte-compile to guarantee we did not produce invalid Python. + try: + compile(patched, str(target), "exec") + except SyntaxError as exc: + return PatchResult(name, "error", f"patched file fails to compile: {exc}") + + target.write_text(patched) + return PatchResult(name, "applied", f"routed LD_PRELOAD through {_HELPER_MARKER[:-1]}()") + + +PATCHES = [ + patch_tms_preload_selection, +] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--slime-root", + default="/opt/slime", + type=Path, + help="Path to the upstream SLIME checkout (default: /opt/slime).", + ) + args = ap.parse_args() + + if not args.slime_root.is_dir(): + print(f"[patch] SLIME root not found: {args.slime_root}", file=sys.stderr) + return 2 + + print(f"[patch] applying self-neutralizing SLIME patches under {args.slime_root}") + results = [patch(args.slime_root) for patch in PATCHES] + for r in results: + print(f"[patch] {r}") + + failed = [r for r in results if not r.ok] + if failed: + print(f"[patch] {len(failed)} patch(es) failed", file=sys.stderr) + return 1 + print("[patch] all patches in a known-good state") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/3.test_cases/pytorch/slime/slime.Dockerfile b/3.test_cases/pytorch/slime/slime.Dockerfile index cd28cebc7..94af27d9e 100644 --- a/3.test_cases/pytorch/slime/slime.Dockerfile +++ b/3.test_cases/pytorch/slime/slime.Dockerfile @@ -180,12 +180,29 @@ RUN pip install --no-cache-dir --force-reinstall --no-deps "${SGL_ROUTER_WHEEL}" ##################### # SLIME (RL post-training framework) +# +# Installed straight from upstream THUDM/slime at the pinned SLIME_VERSION -- +# no fork, no forked URL. A few upstream bugs still block a clean end-to-end run +# on CUDA 13; they are healed in the next step against this upstream checkout. ##################### RUN cd /opt && \ git clone --depth 1 --branch ${SLIME_VERSION} https://github.com/THUDM/slime.git && \ cd slime && \ pip install --no-cache-dir -e . --no-deps +##################### +# Self-neutralizing upstream patches. +# +# Applies small, in-place fixes to the upstream SLIME checkout above -- only +# where the unfixed pattern is actually present. Each patch checks upstream +# first, so once the corresponding fix lands in THUDM/slime the patch becomes a +# no-op automatically (no fork to track, no URL to bump; upstream simply wins). +# The build fails loudly if a patch cannot reach a known-good state. See +# patches/apply_slime_patches.py for the per-patch rationale and upstream links. +##################### +COPY patches/apply_slime_patches.py /opt/slime-patches/apply_slime_patches.py +RUN python3 /opt/slime-patches/apply_slime_patches.py --slime-root /opt/slime + ## Set Open MPI variables to exclude network interface and conduit. ENV OMPI_MCA_pml=^ucx \ OMPI_MCA_btl=tcp,self \ From 9d833c8167f973af0c8a71499f87bfd57d61b669 Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 19:35:47 +0900 Subject: [PATCH 05/10] fix(slime): pin numpy < 2 so Megatron init does not abort the train worker Megatron-LM asserts numpy 1.x at init (per NVIDIA/Megatron-LM#1563), but sglang[all] -- installed just before requirements.txt in slime.Dockerfile -- pulls numpy 2.x transitively, so MegatronTrainRayActor dies during init with 'AssertionError: Megatron does not support numpy 2.x'. Pin numpy<2 in requirements.txt. Because the requirements install runs after sglang[all] and the later slime/sgl-router installs use --no-deps, this downgrades numpy back to 1.26.4 and nothing reintroduces 2.x. Verified on H200 (CUDA 13): numpy 1.26.4, torch/megatron import cleanly, Megatron init passes and the train worker proceeds into the rollout/train cycle. --- 3.test_cases/pytorch/slime/requirements.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/3.test_cases/pytorch/slime/requirements.txt b/3.test_cases/pytorch/slime/requirements.txt index b602ef3cf..d1085ba19 100644 --- a/3.test_cases/pytorch/slime/requirements.txt +++ b/3.test_cases/pytorch/slime/requirements.txt @@ -2,6 +2,16 @@ # SPDX-License-Identifier: MIT-0 # Reinforcement-learning Python dependencies for the SLIME image. + +# Megatron-LM asserts numpy 1.x at init (slime .../megatron_utils/initialize.py: +# `assert np.__version__.startswith("1.")`, per NVIDIA/Megatron-LM#1563), but +# sglang[all] (installed just before this file) pulls in numpy 2.x transitively. +# Pin numpy < 2 here so this requirements install downgrades it back to 1.x; the +# later slime/sgl-router installs use --no-deps and do not touch it. Without this +# the train worker aborts during Megatron init with "Megatron does not support +# numpy 2.x". +numpy<2 + nltk==3.9.4 awscli==1.45.26 pynvml==13.0.1 From 3c9789706b9f1f51e76116bb2445664c7da84851 Mon Sep 17 00:00:00 2001 From: littlemex Date: Fri, 3 Jul 2026 19:54:28 +0900 Subject: [PATCH 06/10] docs(slime): correct the numpy<2 pin comment and add a removal trigger Fixes an inaccurate comment (the later ring_flash_attn install is not --no-deps) and records why numpy 1.x survives to runtime: the only post-pin pip steps are slime/sgl-router (--no-deps) and ring_flash_attn 0.1.8 (declares no deps), so none reintroduce numpy 2.x. Notes that upstream SLIME's own Dockerfile pins numpy<2 the same way, and adds a TODO tying the pin's removal to a future MEGATRON_LM_VERSION that drops the numpy 1.x assert. No functional change. --- 3.test_cases/pytorch/slime/requirements.txt | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/3.test_cases/pytorch/slime/requirements.txt b/3.test_cases/pytorch/slime/requirements.txt index d1085ba19..cc0ea374b 100644 --- a/3.test_cases/pytorch/slime/requirements.txt +++ b/3.test_cases/pytorch/slime/requirements.txt @@ -5,11 +5,21 @@ # Megatron-LM asserts numpy 1.x at init (slime .../megatron_utils/initialize.py: # `assert np.__version__.startswith("1.")`, per NVIDIA/Megatron-LM#1563), but -# sglang[all] (installed just before this file) pulls in numpy 2.x transitively. -# Pin numpy < 2 here so this requirements install downgrades it back to 1.x; the -# later slime/sgl-router installs use --no-deps and do not touch it. Without this -# the train worker aborts during Megatron init with "Megatron does not support -# numpy 2.x". +# sglang[all] (installed just before this file in slime.Dockerfile) pulls in +# numpy 2.x transitively. Pin numpy < 2 so this requirements install downgrades +# it back to 1.x; without it the train worker aborts during Megatron init with +# "Megatron does not support numpy 2.x". Upstream SLIME's own docker/Dockerfile +# does the same (`pip install "numpy<2"`), so this mirrors the sanctioned pin. +# +# Why 1.x survives to runtime: the only pip steps after this one in the +# Dockerfile are the slime and sgl-router installs (--no-deps) and +# ring_flash_attn==0.1.8 (which declares no dependencies), so none of them +# reintroduce numpy 2.x. If a later step is added that pulls numpy WITHOUT +# --no-deps, re-pin numpy after it. +# +# TODO(numpy<2): remove this pin once MEGATRON_LM_VERSION is bumped to a commit +# that no longer asserts numpy 1.x (drops the numpy 2.x assert); until then the +# pin is required. numpy<2 nltk==3.9.4 From 97bb3eb3cc85176db17b6a1d2bcbd74f5eda5a51 Mon Sep 17 00:00:00 2001 From: littlemex Date: Sat, 4 Jul 2026 16:38:19 +0900 Subject: [PATCH 07/10] fix(slime): enable the 30B MoE disaggregated recipe to run end-to-end Bring up recipe/run_grpo_qwen3_30b_a3b.sh (Qwen3-30B-A3B, disaggregated) to a full GRPO training step. The 4B colocated path already works; the 30B MoE recipe hit several MoE/parallelism-specific walls that 4B does not: - Checkpoint conversion needs mbridge (convert_hf_to_torch_dist.py imports it), not pulled by the --no-deps slime install. Install it in the Dockerfile with --no-deps so it cannot drag numpy 2.x back in. - SGLang 0.5.12 removed --enable-ep-moe; use --sglang-moe-runner-backend triton + --sglang-expert-parallel-size instead so the rollout engine starts. - Megatron validate_args eagerly probes the CUDA device (get_device_capability for moe_grouped_gemm, get_device_arch_version for the TP/CP note) on the Ray driver, which is intentionally GPU-less (head num-gpus:0), crashing with 'Found no NVIDIA driver'. Guard both probes in SLIME's validate_args wrapper only when torch.cuda.is_available() is False (the driver); GPU actors probe the real device unchanged. get_device_arch_version returns 10 on the driver so it defers the CUDA_DEVICE_MAX_CONNECTIONS decision to the real actors rather than faking a pre-Blackwell arch (which would wrongly force it on B300). - TP/CP>1 requires CUDA_DEVICE_MAX_CONNECTIONS=1; add it to the recipe runtime-env. - Cap --sglang-cuda-graph-max-bs 8 so large-HBM capture is not pathologically slow. The driver-probe guard is added to the existing self-neutralizing patch mechanism (patches/apply_slime_patches.py): it edits the upstream checkout only when the un-guarded probe is present, is idempotent, byte-compiles, and no-ops once Megatron guards the probes upstream (issue/PR to be filed there). Verified on H200 (2x p5en.48xlarge, CUDA 13): the 30B MoE recipe reaches a full GRPO loop (rollout -> ref/actor log-probs -> Timer train end -> weight sync), with no wall regressions and no MoE weight-sync 400. --- .../slime/patches/apply_slime_patches.py | 110 ++++++++++++++++++ .../slime/recipe/run_grpo_qwen3_30b_a3b.sh | 13 ++- 3.test_cases/pytorch/slime/slime.Dockerfile | 8 ++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py index 1a06a1304..7c02e25bc 100644 --- a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py +++ b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py @@ -194,8 +194,118 @@ def patch_tms_preload_selection(slime_root: Path) -> PatchResult: return PatchResult(name, "applied", f"routed LD_PRELOAD through {_HELPER_MARKER[:-1]}()") +# --- wall: Megatron validate_args probes the GPU on a GPU-less Ray driver ----- +# Megatron's validate_args eagerly probes the CUDA device during pure argument +# validation (which SLIME runs on the Ray driver -- intentionally GPU-less in +# this test case: the head is num-gpus:0, GCS/dashboard only). Two probes are +# only reached by MoE models with tensor/context parallelism (the 30B recipe: +# --moe-grouped-gemm, TP=2, CP=2), so the 4B path never hits them: +# * torch.cuda.get_device_capability() (moe_grouped_gemm compute-cap assert) +# * megatron.training.utils.get_device_arch_version() (imported by name into +# megatron.training.arguments; used for the CUDA_DEVICE_MAX_CONNECTIONS note) +# On the GPU-less driver both raise "Found no NVIDIA driver". The real GPU actors +# (torch.cuda.is_available() == True) probe the real device unchanged. +# +# This injects a guard at the top of SLIME's own validate_args wrapper +# (slime/backends/megatron_utils/arguments.py) that, ONLY when no CUDA device is +# present (i.e. the driver), makes those two probes return safe values so +# argument validation can complete: +# * get_device_capability -> (8, 0): satisfies the moe_grouped_gemm assert +# (dc[0] >= 8) without a device; does not affect any arch-dependent branch. +# * get_device_arch_version -> 10: makes the driver treat itself as Blackwell+ +# and SKIP the arch<10 CUDA_DEVICE_MAX_CONNECTIONS branch, deferring that +# decision to the real GPU actors. This avoids faking a specific pre-Blackwell +# arch (which would wrongly force CUDA_DEVICE_MAX_CONNECTIONS=1 on B300); the +# actual requirement is still enforced on the actors on their real arch (H200 +# = sm_90 requires it, satisfied via the recipe env; B300 = sm_100 does not). +# Guarded so substitution happens only while is_available() is False, and +# idempotent via a _slime_cpu_guard marker. +_VALIDATE_ANCHOR = '''def validate_args(args): + """Run megatron\'s own validate_args plus slime-specific megatron validations.""" + _megatron_validate_args(args)''' + +_VALIDATE_INJECT = '''def validate_args(args): + """Run megatron\'s own validate_args plus slime-specific megatron validations.""" + # Megatron validate_args eagerly probes the CUDA device (get_device_capability + # for moe_grouped_gemm; get_device_arch_version for the TP/CP + # CUDA_DEVICE_MAX_CONNECTIONS note). SLIME runs validate_args on the Ray + # driver, which is intentionally GPU-less in this test case, so those probes + # raise "Found no NVIDIA driver". Guard them for the GPU-less driver only; the + # real GPU actors probe the real device unchanged. Injected as a stopgap until + # Megatron guards these probes with torch.cuda.is_available() upstream. + import torch as _torch + + if not _torch.cuda.is_available(): + import megatron.training.arguments as _ma + + if not getattr(_torch.cuda.get_device_capability, "_slime_cpu_guard", False): + _orig_cap = _torch.cuda.get_device_capability + + def _cap(*a, **k): + return (8, 0) if not _torch.cuda.is_available() else _orig_cap(*a, **k) + + _cap._slime_cpu_guard = True + _torch.cuda.get_device_capability = _cap + + if not getattr(_ma.get_device_arch_version, "_slime_cpu_guard", False): + _orig_arch = _ma.get_device_arch_version + + def _arch(*a, **k): + return 10 if not _torch.cuda.is_available() else _orig_arch(*a, **k) + + _arch._slime_cpu_guard = True + _ma.get_device_arch_version = _arch + + _megatron_validate_args(args)''' + + +def patch_gpuless_driver_validate(slime_root: Path) -> PatchResult: + """Fix: let Megatron validate_args run on the GPU-less Ray driver. + + Guards the two eager CUDA device probes (get_device_capability, + get_device_arch_version) inside SLIME's validate_args wrapper so that on a + GPU-less driver they return safe values instead of crashing with + "Found no NVIDIA driver" (upstream Megatron issue/PR filed separately). + """ + name = "gpuless-driver-validate" + target = slime_root / "slime" / "backends" / "megatron_utils" / "arguments.py" + if not target.is_file(): + return PatchResult(name, "error", f"target not found: {target}") + + src = target.read_text() + + if "_slime_cpu_guard" in src: + return PatchResult(name, "already-applied", f"{target} already guards the driver probes") + + # If upstream (or SLIME) has guarded the probes with is_available(), the + # bare validate_args anchor may still be present but the fix is unnecessary; + # keep this conservative -- only skip when we truly cannot find the anchor. + if _VALIDATE_ANCHOR not in src: + return PatchResult( + name, + "already-fixed-upstream", + "validate_args wrapper not in the expected shape; leaving it untouched", + ) + if src.count(_VALIDATE_ANCHOR) != 1: + return PatchResult( + name, + "error", + f"expected exactly one validate_args wrapper, found {src.count(_VALIDATE_ANCHOR)}", + ) + + patched = src.replace(_VALIDATE_ANCHOR, _VALIDATE_INJECT, 1) + try: + compile(patched, str(target), "exec") + except SyntaxError as exc: + return PatchResult(name, "error", f"patched file fails to compile: {exc}") + + target.write_text(patched) + return PatchResult(name, "applied", "guarded get_device_capability / get_device_arch_version on the GPU-less driver") + + PATCHES = [ patch_tms_preload_selection, + patch_gpuless_driver_validate, ] diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh index 896993994..3e2b58cd4 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh @@ -145,7 +145,17 @@ TRAIN_ARGS=( # check never passes and training hangs before it starts. Use lowercase # "warning" to preserve the original intended verbosity. --sglang-log-level warning - --sglang-enable-ep-moe + # SGLang 0.5.12 removed the `--enable-ep-moe` flag (expert-parallel MoE is now + # selected via the MoE runner backend). Passing the old flag makes SGLang's + # argparse reject it, so the rollout engine never starts. Select the triton + # MoE runner and set expert parallelism explicitly instead. + --sglang-moe-runner-backend triton + --sglang-expert-parallel-size "${EP_SIZE}" + # On large-HBM GPUs (e.g. B300) SGLang auto-picks a high cuda_graph_max_bs, + # which makes CUDA-graph capture extremely slow at startup. Cap it so the + # rollout engine comes up in a reasonable time; H200 tolerates the default + # but the cap is harmless there. + --sglang-cuda-graph-max-bs 8 ) # Submit via Ray job API. @@ -163,6 +173,7 @@ ray job submit \ --runtime-env-json="{ \"env_vars\": { \"PYTHONPATH\": \"/opt/Megatron-LM\", + \"CUDA_DEVICE_MAX_CONNECTIONS\": \"1\", \"HF_TOKEN\": \"${HF_TOKEN}\", \"MODEL_SCRIPT\": \"${MODEL_SCRIPT}\", \"TOKENIZERS_PARALLELISM\": \"false\", diff --git a/3.test_cases/pytorch/slime/slime.Dockerfile b/3.test_cases/pytorch/slime/slime.Dockerfile index 94af27d9e..48ef014be 100644 --- a/3.test_cases/pytorch/slime/slime.Dockerfile +++ b/3.test_cases/pytorch/slime/slime.Dockerfile @@ -190,6 +190,14 @@ RUN cd /opt && \ cd slime && \ pip install --no-cache-dir -e . --no-deps +# mbridge: required by SLIME's HF->torch_dist converter +# (tools/convert_hf_to_torch_dist.py imports `slime_plugins.mbridge` and +# `from mbridge import AutoBridge`). It is NOT pulled by the `--no-deps` slime +# install above, so the 30B MoE checkpoint conversion fails with +# `ModuleNotFoundError: No module named 'mbridge'` without it. Installed with +# --no-deps so it cannot drag numpy 2.x (or any other pinned dep) back in. +RUN pip install --no-cache-dir --no-deps mbridge + ##################### # Self-neutralizing upstream patches. # From 6a30bcd348c5604593335c97d6cd252c8aa74824 Mon Sep 17 00:00:00 2001 From: littlemex Date: Sat, 4 Jul 2026 16:57:48 +0900 Subject: [PATCH 08/10] fix(slime): make the GPU-less driver validate guard truly self-neutralizing The wall-9 guard was keyed only on SLIME's validate_args wrapper shape, so it would keep applying (harmlessly, since it is is_available()-gated) even after Megatron guards the probe upstream. Make it detect the actual defect: inspect the installed Megatron's validate_args and apply the SLIME-side guard only while an eager get_device_capability() probe remains unguarded (no torch.cuda.is_available() on or just before its line). Once Megatron guards or removes the probe, the patch reports already-fixed-upstream and leaves SLIME untouched -- matching the self-neutralizing behavior of the tms-preload patch. Verified all states on H200: applied / already-applied / already-fixed-upstream (guarded same-line, guarded enclosing-if, and probe-removed). --- .../slime/patches/apply_slime_patches.py | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py index 7c02e25bc..17246349f 100644 --- a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py +++ b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py @@ -259,6 +259,48 @@ def _arch(*a, **k): _megatron_validate_args(args)''' +def _megatron_probe_is_unguarded() -> bool: + """True if the installed Megatron's validate_args still probes the CUDA + device eagerly without a torch.cuda.is_available() guard. + + This is the real defect this patch works around. When Megatron guards the + probe upstream (the permanent fix), this returns False and the SLIME-side + patch self-neutralizes. Locating Megatron via import keeps this robust to + the install path. If Megatron cannot be located or its arguments module has + been restructured beyond recognition, we conservatively assume the probe is + still unguarded (better a harmless is_available()-gated guard than a crash). + """ + try: + import importlib.util + + spec = importlib.util.find_spec("megatron.training.arguments") + if spec is None or not spec.origin: + return True + src = Path(spec.origin).read_text() + except Exception: + return True + + if "get_device_capability()" not in src and "get_device_arch_version()" not in src: + # Probe removed/renamed entirely -> upstream changed it; nothing to guard. + return False + + # A probe is considered guarded if a torch.cuda.is_available() check appears + # on the probe's own line OR within the few lines immediately preceding it + # (upstream's likely fix is `if ...is_available(): dc = get_device_capability()` + # -- the guard sits on the enclosing `if`, not the probe line itself). If any + # eager get_device_capability() call has no is_available() nearby, the probe + # is still unguarded and the SLIME-side workaround is needed. + lines = src.splitlines() + WINDOW = 3 + for i, line in enumerate(lines): + if "get_device_capability()" not in line: + continue + context = lines[max(0, i - WINDOW): i + 1] + if not any("is_available" in c for c in context): + return True + return False + + def patch_gpuless_driver_validate(slime_root: Path) -> PatchResult: """Fix: let Megatron validate_args run on the GPU-less Ray driver. @@ -277,14 +319,25 @@ def patch_gpuless_driver_validate(slime_root: Path) -> PatchResult: if "_slime_cpu_guard" in src: return PatchResult(name, "already-applied", f"{target} already guards the driver probes") - # If upstream (or SLIME) has guarded the probes with is_available(), the - # bare validate_args anchor may still be present but the fix is unnecessary; - # keep this conservative -- only skip when we truly cannot find the anchor. - if _VALIDATE_ANCHOR not in src: + # True self-neutralization: the real defect is Megatron's *unguarded* eager + # device probe in validate_args. If Megatron has guarded it upstream (the + # permanent fix), this patch is unnecessary and must not touch SLIME. Detect + # the unguarded probe in the installed Megatron; if it is gone, no-op. + if not _megatron_probe_is_unguarded(): return PatchResult( name, "already-fixed-upstream", - "validate_args wrapper not in the expected shape; leaving it untouched", + "Megatron validate_args no longer has an unguarded CUDA device probe; leaving SLIME untouched", + ) + + # The probe is still unguarded upstream, so the SLIME-side guard is needed. + # It must be injectable into SLIME's validate_args wrapper in its known shape. + if _VALIDATE_ANCHOR not in src: + return PatchResult( + name, + "error", + "Megatron probe is unguarded but SLIME validate_args is not in the expected shape to inject a guard; " + "refusing to edit (needs a refreshed anchor)", ) if src.count(_VALIDATE_ANCHOR) != 1: return PatchResult( From 68d1aec0c5522b2bb0d17733c4be62b3174fd20b Mon Sep 17 00:00:00 2001 From: littlemex Date: Sun, 5 Jul 2026 04:47:32 +0900 Subject: [PATCH 09/10] fix(slime): drop unnecessary SGLang MoE flags from the 30B recipe An audit + end-to-end retest showed two of the SGLang flags added while bringing up the 30B MoE recipe are not needed, so remove them and keep the recipe minimal: - --sglang-moe-runner-backend triton: redundant. On SGLang 0.5.12 the default moe_runner_backend=auto resolves to the same triton runner for an unquantized bf16 MoE on H200 (sm_90); forcing triton changes nothing. - --sglang-expert-parallel-size (sglang ep_size): not required. The rollout engine's expert parallelism is independent of Megatron's training EP, and ep_size=1 (the SGLang default) is a valid serving mode for Qwen3-30B-A3B. Also correct the comment about --sglang-enable-ep-moe: SGLang 0.5.12 removed the flag, but SLIME v0.2.4 parses --sglang-* leniently (parse_known_args / ignore_unknown_args), so the dead flag is silently ignored, not rejected. It is dropped because it configures nothing, not because it errors. Verified on H200: the 30B MoE disaggregated recipe reaches a full GRPO loop (Timer train end) with none of these flags set. --- .../slime/recipe/run_grpo_qwen3_30b_a3b.sh | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh index 3e2b58cd4..f8ca6cdba 100644 --- a/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh +++ b/3.test_cases/pytorch/slime/recipe/run_grpo_qwen3_30b_a3b.sh @@ -145,17 +145,15 @@ TRAIN_ARGS=( # check never passes and training hangs before it starts. Use lowercase # "warning" to preserve the original intended verbosity. --sglang-log-level warning - # SGLang 0.5.12 removed the `--enable-ep-moe` flag (expert-parallel MoE is now - # selected via the MoE runner backend). Passing the old flag makes SGLang's - # argparse reject it, so the rollout engine never starts. Select the triton - # MoE runner and set expert parallelism explicitly instead. - --sglang-moe-runner-backend triton - --sglang-expert-parallel-size "${EP_SIZE}" - # On large-HBM GPUs (e.g. B300) SGLang auto-picks a high cuda_graph_max_bs, - # which makes CUDA-graph capture extremely slow at startup. Cap it so the - # rollout engine comes up in a reasonable time; H200 tolerates the default - # but the cap is harmless there. - --sglang-cuda-graph-max-bs 8 + # NOTE: the original recipe passed `--sglang-enable-ep-moe`, which SGLang + # 0.5.12 removed. SLIME v0.2.4 registers --sglang-* flags from SGLang's live + # ServerArgs (parse_known_args / ignore_unknown_args), so the dead flag is + # silently ignored rather than erroring -- but it configures nothing, so it + # is dropped here. No replacement flag is needed for this recipe: the rollout + # engine serves the Qwen3-30B-A3B MoE correctly with the SGLang defaults + # (moe_runner_backend=auto resolves to the triton runner for bf16 on H200; + # ep_size defaults to 1, which is a valid serving mode). Both were verified + # unnecessary by a full end-to-end run with neither flag set. ) # Submit via Ray job API. From b975633d86bbb6b96ab03c80ebadf13d0cda620d Mon Sep 17 00:00:00 2001 From: littlemex Date: Sat, 4 Jul 2026 20:23:53 +0000 Subject: [PATCH 10/10] fix(slime): use a non-GPU sentinel (9999) for get_device_arch_version on the GPU-less driver Return _ARCH_UNKNOWN_ON_GPULESS_DRIVER = 9999 instead of 10 from the GPU-less-driver guard. 9999 is deliberately not any real GPU generation (Ampere=8, Hopper=9, Blackwell=10), so the driver never mislabels the hardware; being >= 10 it still skips the arch<10 CUDA_DEVICE_MAX_CONNECTIONS branch and defers that decision to the real GPU actors. Matches the image already verified end-to-end on H200 (get_device_arch_version = 9999). --- .../slime/patches/apply_slime_patches.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py index 17246349f..760d8c36f 100644 --- a/3.test_cases/pytorch/slime/patches/apply_slime_patches.py +++ b/3.test_cases/pytorch/slime/patches/apply_slime_patches.py @@ -212,12 +212,14 @@ def patch_tms_preload_selection(slime_root: Path) -> PatchResult: # argument validation can complete: # * get_device_capability -> (8, 0): satisfies the moe_grouped_gemm assert # (dc[0] >= 8) without a device; does not affect any arch-dependent branch. -# * get_device_arch_version -> 10: makes the driver treat itself as Blackwell+ -# and SKIP the arch<10 CUDA_DEVICE_MAX_CONNECTIONS branch, deferring that -# decision to the real GPU actors. This avoids faking a specific pre-Blackwell -# arch (which would wrongly force CUDA_DEVICE_MAX_CONNECTIONS=1 on B300); the -# actual requirement is still enforced on the actors on their real arch (H200 -# = sm_90 requires it, satisfied via the recipe env; B300 = sm_100 does not). +# * get_device_arch_version -> a sentinel (9999) that is NOT any real GPU +# generation (Ampere=8, Hopper=9, Blackwell=10, ...), so it never mislabels +# the hardware -- on a GPU-less driver the arch is genuinely unknown. Being +# >= 10, it makes the driver SKIP the arch<10 CUDA_DEVICE_MAX_CONNECTIONS +# branch, deferring that decision to the real GPU actors. Returning a real +# generation (9 or 10) would falsely claim a specific arch; the sentinel does +# not. The actual requirement is enforced on the actors from their real arch +# (H200 = sm_90 requires it, satisfied via the recipe env; B300 = sm_100 does not). # Guarded so substitution happens only while is_available() is False, and # idempotent via a _slime_cpu_guard marker. _VALIDATE_ANCHOR = '''def validate_args(args): @@ -249,9 +251,20 @@ def _cap(*a, **k): if not getattr(_ma.get_device_arch_version, "_slime_cpu_guard", False): _orig_arch = _ma.get_device_arch_version + # A GPU-less driver has no GPU arch to report. Return a value that is + # deliberately NOT any real GPU generation (Ampere=8, Hopper=9, + # Blackwell=10, ...) so we never mislabel the hardware; it just has to + # be >= 10 so the arch-gated CUDA_DEVICE_MAX_CONNECTIONS branch is + # skipped on the driver. The real requirement is decided on the GPU + # actors from their real arch (they never re-run validate_args). + _ARCH_UNKNOWN_ON_GPULESS_DRIVER = 9999 def _arch(*a, **k): - return 10 if not _torch.cuda.is_available() else _orig_arch(*a, **k) + return ( + _ARCH_UNKNOWN_ON_GPULESS_DRIVER + if not _torch.cuda.is_available() + else _orig_arch(*a, **k) + ) _arch._slime_cpu_guard = True _ma.get_device_arch_version = _arch