diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index f39bf888af..34e15bcbeb 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -595,7 +595,15 @@ jobs: }, { "num_gpus": 0, - "test_file": "test_train_dump.py" + "test_file": "test_train_data_utils.py" + }, + { + "num_gpus": 0, + "test_file": "test_rollout_data_utils.py" + }, + { + "num_gpus": 0, + "test_file": "test_rollout_metrics.py" }, { "num_gpus": 0, @@ -617,10 +625,18 @@ jobs: "num_gpus": 0, "test_file": "test_logprob_response_spans.py" }, + { + "num_gpus": 0, + "test_file": "observability/test_trace_utils.py" + }, { "num_gpus": 0, "test_file": "test_value_temperature.py" }, + { + "num_gpus": 0, + "test_file": "test_ppo_kl_metric.py" + }, { "num_gpus": 0, "test_file": "test_cispo_loss.py" @@ -697,6 +713,10 @@ jobs: "num_gpus": 0, "test_file": "test_qwen3_5_vl_native.py" }, + { + "num_gpus": 0, + "test_file": "test_accelerator.py" + }, { "num_gpus": 0, "test_file": "test_reloadable_process_group_world.py" @@ -782,7 +802,7 @@ jobs: pip install torch --index-url https://download.pytorch.org/whl/cpu pip install pytest numpy packaging pyyaml omegaconf tqdm httpx requests ray pybase64 pylatexenc sympy aiohttp pillow safetensors psutil - pip install transformers + pip install transformers wandb - name: Install diff --git a/.github/workflows/pr-test.yml.j2 b/.github/workflows/pr-test.yml.j2 index ce2425e39b..6ed6147126 100644 --- a/.github/workflows/pr-test.yml.j2 +++ b/.github/workflows/pr-test.yml.j2 @@ -66,7 +66,7 @@ 'label': 'run-ci-cpu-unittest', 'always': True, 'cpu': True, - 'extra_pip_deps': 'transformers', + 'extra_pip_deps': 'transformers wandb', 'tests': [ {'test_file': 'test_megatron_argument_validation.py', 'num_gpus': 0}, {'test_file': 'test_deep_ep_tms_patch.py', 'num_gpus': 0}, @@ -74,13 +74,17 @@ {'test_file': 'utils/test_megatron_server_arguments.py', 'num_gpus': 0}, {'test_file': 'test_dp_schedule.py', 'num_gpus': 0}, {'test_file': 'test_cp_utils.py', 'num_gpus': 0}, - {'test_file': 'test_train_dump.py', 'num_gpus': 0}, + {'test_file': 'test_train_data_utils.py', 'num_gpus': 0}, + {'test_file': 'test_rollout_data_utils.py', 'num_gpus': 0}, + {'test_file': 'test_rollout_metrics.py', 'num_gpus': 0}, {'test_file': 'test_metric_report.py', 'num_gpus': 0}, {'test_file': 'test_metric_report_dist.py', 'num_gpus': 0}, {'test_file': 'test_loss_cp_invariance.py', 'num_gpus': 0}, {'test_file': 'test_advantage_whiten_cp.py', 'num_gpus': 0}, {'test_file': 'test_logprob_response_spans.py', 'num_gpus': 0}, + {'test_file': 'observability/test_trace_utils.py', 'num_gpus': 0}, {'test_file': 'test_value_temperature.py', 'num_gpus': 0}, + {'test_file': 'test_ppo_kl_metric.py', 'num_gpus': 0}, {'test_file': 'test_cispo_loss.py', 'num_gpus': 0}, {'test_file': 'test_policy_loss.py', 'num_gpus': 0}, {'test_file': 'test_ppo_logprob_entropy.py', 'num_gpus': 0}, @@ -100,6 +104,7 @@ {'test_file': 'test_rollout_sample_hooks.py', 'num_gpus': 0}, {'test_file': 'test_hf_to_megatron.py', 'num_gpus': 0}, {'test_file': 'test_qwen3_5_vl_native.py', 'num_gpus': 0}, + {'test_file': 'test_accelerator.py', 'num_gpus': 0}, {'test_file': 'test_reloadable_process_group_world.py', 'num_gpus': 0}, {'test_file': 'test_placement_group.py', 'num_gpus': 0}, {'test_file': 'test_external_sglang_engines.py', 'num_gpus': 0}, diff --git a/docker/NOTES_GB10.md b/docker/NOTES_GB10.md index d5417321f5..4901b5a790 100644 --- a/docker/NOTES_GB10.md +++ b/docker/NOTES_GB10.md @@ -52,7 +52,7 @@ https://catalog.ngc.nvidia.com/orgs/nvidia/containers/vllm?version=26.03-py3 | 9 | CMake 3.31 rejects `CMAKE_CUDA_ARCHITECTURES=120f;121f` | `f` suffix for CUDA 13 Blackwell family is only supported in CMake ≥4.0 | Upgrade to `cmake==4.3.1` via pip (must override NGC `/etc/pip/constraint.txt` with `PIP_CONSTRAINT=`) and set `CMAKE_POLICY_VERSION_MINIMUM=3.5` for old bundled deps | | 10 | TE 2.10 build: `cuda_profiler_api.h: No such file` | CUDA 13 removed the public header for `cudaProfilerStart/Stop`; the symbols still exist in `libcudart.so.13`. TE's 3 `fused_softmax` TUs `#include` the header but don't call the APIs | Install a 20-line shim header at `/usr/local/cuda/include/cuda_profiler_api.h` declaring the two functions extern. Stored as `docker/patch/gb10/cuda_profiler_api.h` | | 11 | slime `train.py --help`: `'tuple' object has no attribute 'strip'` | Typo in `slime/utils/arguments.py:1073`: `help=("string",)` (trailing comma → tuple) instead of `help=("string")` | Remove trailing comma — simple one-line slime fix, upstream-able | -| 12 | `sglang_router` x86_64-only wheel from `zhuzilin/sgl-router` fork | slime Dockerfile pins `zhuzilin/sgl-router` release (no arm64 builds); slime's `'slime' in version` assertion is only in `wandb_utils.py` (non-critical path) | Install upstream `sglang-router==0.3.2` from PyPI (has arm64 wheel). Accept wandb path fallback | +| 12 | `sglang_router` x86_64-only wheel from `zhuzilin/sgl-router` fork | slime Dockerfile pins `zhuzilin/sgl-router` release (no arm64 builds); slime's `'slime' in version` assertion is only in `slime/observability/wandb_utils.py` (non-critical path) | Install upstream `sglang-router==0.3.2` from PyPI (has arm64 wheel). Accept wandb path fallback | | 13 | `antlr4-python3-runtime==4.13.2` → `Could not deserialize ATN with version 3` | Omegaconf's bundled grammar was generated with antlr 4.9 serialized format; runtime 4.13 only reads format v4 | Pin `antlr4-python3-runtime==4.9.3` | | 14 | `megatron.training` not importable after `pip install -e Megatron-LM` | Megatron-LM's setup.py only packages `megatron-core`; `megatron.training`, `megatron.rl`, `megatron.legacy` are sibling dirs meant to be on `PYTHONPATH` | `export PYTHONPATH=/root/src/Megatron-LM:$PYTHONPATH` (slime docs confirm this) | | 15 | `libz3.so` missing for tilelang | tilelang uses Z3 SMT solver for autoscheduling; NGC vllm base doesn't include libz3 | `apt-get update && apt-get install -y libz3-dev` (libz3-4 package alias needs update first) | diff --git a/docker/npu_patch/slime.patch b/docker/npu_patch/slime.patch index fee99b00b3..769eb4783d 100644 --- a/docker/npu_patch/slime.patch +++ b/docker/npu_patch/slime.patch @@ -84,11 +84,11 @@ index 7bc4f910..a00c83c51 100644 from ray.actor import ActorHandle from torch_memory_saver import torch_memory_saver @@ -17,6 +21,7 @@ from slime.ray.train_actor import TrainRayActor - from slime.utils import train_dump_utils + from slime.observability import train_data_utils, train_metric_utils from slime.utils.data import process_rollout_data from slime.utils.distributed_utils import get_gloo_group, init_process_group +from slime.utils.http_utils import _wrap_ipv6 - from slime.utils.logging_utils import init_tracking + from slime.observability.logging_utils import init_tracking from slime.utils.memory_utils import clear_memory, print_memory from slime.utils.misc import Box @@ -55,6 +60,8 @@ class MegatronTrainRayActor(TrainRayActor): @@ -586,13 +586,13 @@ diff --git a/slime/ray/rollout.py b/slime/ray/rollout.py index 75cb053c..c54a3854 100644 --- a/slime/ray/rollout.py +++ b/slime/ray/rollout.py -@@ -28,6 +28,7 @@ from slime.utils.metric_utils import ( +@@ -28,6 +28,7 @@ from slime.observability.metric_utils import ( from slime.utils.misc import Box, group_by, load_function from slime.utils.seqlen_balancing import get_seqlen_balanced_partitions from slime.utils.types import Sample +from slime.utils.common import is_npu - from ..utils.metric_utils import has_repetition + from slime.observability.metric_utils import has_repetition from .utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, Lock @@ -76,7 +77,8 @@ class RolloutManager: self.all_rollout_engines = [None] * num_engines @@ -631,7 +631,7 @@ index 2e900ca5..d0a25583 100644 +++ b/slime/ray/train_actor.py @@ -13,16 +13,23 @@ from slime.ray.ray_actor import RayActor from slime.utils.distributed_utils import init_gloo_group - from slime.utils.logging_utils import configure_logger + from slime.observability.logging_utils import configure_logger from slime.utils.memory_utils import clear_memory, print_memory +from slime.utils.common import is_npu @@ -966,4 +966,4 @@ index 01883c47..18faa6d2 100644 + import mindspeed.megatron_adaptor from slime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models from slime.utils.arguments import parse_args - from slime.utils.logging_utils import configure_logger, init_tracking + from slime.observability.logging_utils import configure_logger, init_tracking diff --git a/docs/en/developer_guide/trace.md b/docs/en/developer_guide/trace.md index d866537298..604efa6564 100644 --- a/docs/en/developer_guide/trace.md +++ b/docs/en/developer_guide/trace.md @@ -41,7 +41,7 @@ By default it also starts a local static server so you can open the generated HT ## Instrument custom code -For custom rollout or reward code — including custom agent steps, tool calls, sandbox execution, and verifier calls in agentic workflows — reuse helpers from `slime.utils.trace_utils`: +For custom rollout or reward code — including custom agent steps, tool calls, sandbox execution, and verifier calls in agentic workflows — reuse helpers from `slime.observability.trace_utils`: - `trace_span(target, name, attrs=...)`: record a duration span. - `trace_event(target, name, attrs=...)`: record an instant event. @@ -57,7 +57,7 @@ Use `trace_function(...)` when the whole function should be represented as one s The decorator is what slime uses for the main rollout pipeline. For example, `generate_and_rm(...)` is traced per sample and `generate_and_rm_group(...)` is traced per sample group: ```python -from slime.utils.trace_utils import trace_function +from slime.observability.trace_utils import trace_function @trace_function("generate_and_rm", target="sample") @@ -104,7 +104,7 @@ If you need to add attrs after part of the function has executed, use an inner ` If you want to record SGLang generation metadata in a consistent way, reuse `build_sglang_meta_trace_attrs`: ```python -from slime.utils.trace_utils import build_sglang_meta_trace_attrs, trace_span +from slime.observability.trace_utils import build_sglang_meta_trace_attrs, trace_span with trace_span(sample, "sglang_generate") as span: output = await post(url, payload) @@ -116,4 +116,3 @@ with trace_span(sample, "sglang_generate") as span: - Save a small number of rollouts first; the viewer is easiest to read when each dump contains a manageable number of samples. - The viewer is built from the saved `.pt` dump, so traces can be inspected offline on another machine. - For GPU/kernel-level SGLang profiling traces, see [Profiling](./profiling.md). - diff --git a/docs/en/get_started/customization.md b/docs/en/get_started/customization.md index 476b2ffed9..6a9cfb6a55 100644 --- a/docs/en/get_started/customization.md +++ b/docs/en/get_started/customization.md @@ -42,7 +42,7 @@ For most agentic use cases, **start with `--custom-generate-function-path` plus | Replace the entire rollout orchestration (only when per-sample customization is not enough) | [`--rollout-function-path`](#1-rollout-function---rollout-function-path) | | Control task sampling, buffering, requeueing, or custom prompt/task sources | [`--data-source-path`](#15-data-source---data-source-path) | | Attach custom loss masks, metadata, or convert agentic outputs into training data | [`--rollout-data-postprocess-path`](#8-rollout-data-postprocess---rollout-data-postprocess-path), [`--custom-convert-samples-to-train-data-path`](#13-samples-to-train-data-conversion---custom-convert-samples-to-train-data-path) | -| Debug long-running custom generation, verifier calls, tool calls, or sandbox steps | trace utilities in [`slime.utils.trace_utils`](../developer_guide/trace.md) | +| Debug long-running custom generation, verifier calls, tool calls, or sandbox steps | trace utilities in [`slime.observability.trace_utils`](../developer_guide/trace.md) | A native example of this pattern is [`examples/search-r1`](../../../examples/search-r1/), which adds search-augmented multi-turn generation via `--custom-generate-function-path` while keeping slime's default `sglang_rollout` outer loop. See also [`examples/multi_agent`](../../../examples/multi_agent/README.md) for a `--rollout-function-path`-based multi-agent pattern and [`examples/fully_async`](../../../examples/fully_async/README.md) for long-tail agentic generation. diff --git a/docs/en/get_started/quick_start.md b/docs/en/get_started/quick_start.md index d0bd3fb780..0eb848ac93 100644 --- a/docs/en/get_started/quick_start.md +++ b/docs/en/get_started/quick_start.md @@ -584,6 +584,8 @@ export NCCL_SOCKET_IFNAME=$(ip -o -4 addr show | awk '$4 ~ /^10\\./ {print $2}') export NVSHMEM_BOOTSTRAP_UID_SOCK_IFNAME=$(ip -o -4 addr show | awk '$4 ~ /^10\./ {print $2}') ``` +For launching the same multi-node setup on Kubernetes or cloud instances with one command, see the [SkyPilot tutorial](../platform_support/skypilot_tutorial.md). + slime has been deeply optimized for distributed training of large-scale Mixture of Experts (MoE) models. We provide some end-to-end training cases for reference: - [Example: 8xH100 Training GLM-4.7-Flash](../examples/glm4.7-30B-A3B.md) diff --git a/docs/en/index.rst b/docs/en/index.rst index 83230af1fa..d7a1c5df0e 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -110,9 +110,10 @@ Start by Use Case .. toctree:: :maxdepth: 1 - :caption: Hardware Platforms + :caption: Platforms platform_support/amd_tutorial.md + platform_support/skypilot_tutorial.md .. toctree:: :maxdepth: 1 diff --git a/docs/en/platform_support/skypilot_tutorial.md b/docs/en/platform_support/skypilot_tutorial.md new file mode 100644 index 0000000000..746d88b666 --- /dev/null +++ b/docs/en/platform_support/skypilot_tutorial.md @@ -0,0 +1,319 @@ +# SkyPilot + +[SkyPilot](https://github.com/skypilot-org/skypilot) is an open-source framework for running workloads on Kubernetes or any cloud. This tutorial shows how to launch multi-node slime training with SkyPilot: node provisioning, Ray cluster startup, and job submission are described in a single YAML, replacing the per-node `ray start` steps from the [Quick Start](../get_started/quick_start.md). + +It covers two setups, both running the Quick Start's Qwen3-4B GRPO recipe (`scripts/run-qwen3-4B.sh`) on the DAPO-math dataset: + +- **Multi-node training on one cluster** — the standard setup from the Quick Start's multi-node section. +- **Disaggregated training and inference** — the trainer and SGLang engines run as separate, gang-scheduled jobs that scale independently. + +This page is maintained by the SkyPilot maintainers. + +## Prerequisites + +Install SkyPilot with the extras for your infrastructure and confirm it can reach it: + +```bash +pip install "skypilot[kubernetes]" # or [aws], [gcp], ... — see SkyPilot docs +sky check +``` + +The examples below use the `slimerl/slime:latest` Docker image from the Quick Start, so no additional environment setup is needed inside the nodes. + +## Multi-Node Training on One Cluster + +The Quick Start starts a Ray cluster by running `ray start` on every node, then submits training with `ray job submit` from node 0. The following SkyPilot task performs the same steps: it provisions `num_nodes` nodes with GPUs, downloads and converts the model on each node, starts the Ray head and workers, and submits the job. Environment variables like `SKYPILOT_NODE_RANK` and `SKYPILOT_NODE_IPS` are injected by SkyPilot on every node. + +
+slime-multinode.yaml + +```yaml +# slime-multinode.yaml +resources: + infra: kubernetes # or aws / gcp / any infra configured in `sky check` + accelerators: H100:4 + image_id: docker:slimerl/slime:latest + +num_nodes: 2 + +setup: | + pip install -q -U "huggingface_hub[cli]" + [ -d /root/Qwen3-4B ] || hf download Qwen/Qwen3-4B --local-dir /root/Qwen3-4B + [ -d /root/dapo-math-17k ] || hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /root/dapo-math-17k + [ -d /root/aime-2024 ] || hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /root/aime-2024 + # Convert the HF checkpoint to Megatron torch_dist format (each node needs a local copy). + if [ ! -d /root/Qwen3-4B_torch_dist ]; then + cd /root/slime + source scripts/models/qwen3-4B.sh + PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ + ${MODEL_ARGS[@]} --hf-checkpoint /root/Qwen3-4B --save /root/Qwen3-4B_torch_dist + fi + +run: | + MASTER_ADDR=$(echo "$SKYPILOT_NODE_IPS" | head -n1) + if [ "$SKYPILOT_NODE_RANK" != "0" ]; then + # Worker nodes join the Ray cluster. --block keeps the worker's Ray daemons in the + # foreground for the whole run (an exiting run command would get them reaped) and + # returns once the head shuts down at the end of training. + sleep 10 + ray start --address=${MASTER_ADDR}:6379 --num-gpus ${SKYPILOT_NUM_GPUS_PER_NODE} --disable-usage-stats \ + --dashboard-agent-listen-port 52366 --metrics-export-port 8091 --block + exit 0 + fi + + # Start Ray from /root/slime: job entrypoints run in the head's working directory. + cd /root/slime + source scripts/models/qwen3-4B.sh + + # Non-default agent/metrics ports: SkyPilot's runtime on the node runs its own Ray. + ray start --head --node-ip-address ${MASTER_ADDR} \ + --num-gpus ${SKYPILOT_NUM_GPUS_PER_NODE} --disable-usage-stats \ + --dashboard-host=0.0.0.0 --dashboard-port=8265 \ + --dashboard-agent-listen-port 52366 --metrics-export-port 8091 + + # Wait until every node has joined the Ray cluster. + until python3 -c "import ray, sys; ray.init(address='${MASTER_ADDR}:6379', logging_level='error'); sys.exit(0 if len([n for n in ray.nodes() if n['Alive']]) >= ${SKYPILOT_NUM_NODES} else 1)"; do sleep 5; done + + # Wait for Ray's job agent to be ready to accept submissions. + until ray job submit --address="http://127.0.0.1:8265" --no-wait -- true >/dev/null 2>&1; do + echo "waiting for the Ray job agent..."; sleep 5 + done + ray job submit --address="http://127.0.0.1:8265" \ + --runtime-env-json='{"env_vars": {"PYTHONPATH": "/root/Megatron-LM/", "CUDA_DEVICE_MAX_CONNECTIONS": "1"}}' \ + -- python3 /root/slime/train.py \ + --actor-num-nodes ${SKYPILOT_NUM_NODES} \ + --actor-num-gpus-per-node ${SKYPILOT_NUM_GPUS_PER_NODE} \ + --num-gpus-per-node ${SKYPILOT_NUM_GPUS_PER_NODE} \ + --colocate \ + ${MODEL_ARGS[@]} \ + --hf-checkpoint /root/Qwen3-4B \ + --ref-load /root/Qwen3-4B_torch_dist \ + --load /root/Qwen3-4B_slime/ \ + --save /root/Qwen3-4B_slime/ \ + --save-interval 20 \ + --prompt-data /root/dapo-math-17k/dapo-math-17k.jsonl \ + --input-key prompt \ + --label-key label \ + --apply-chat-template \ + --rollout-shuffle \ + --rm-type deepscaler \ + --num-rollout 3000 \ + --rollout-batch-size 32 \ + --n-samples-per-prompt 8 \ + --rollout-max-response-len 8192 \ + --rollout-temperature 1 \ + --global-batch-size 256 \ + --balance-data \ + --eval-interval 20 \ + --eval-prompt-data aime /root/aime-2024/aime-2024.jsonl \ + --n-samples-per-eval-prompt 16 \ + --eval-max-response-len 16384 \ + --eval-top-p 1 \ + --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 1e-6 \ + --lr-decay-style constant \ + --weight-decay 0.1 \ + --adam-beta1 0.9 \ + --adam-beta2 0.98 \ + --tensor-model-parallel-size 2 \ + --sequence-parallel \ + --pipeline-model-parallel-size 1 \ + --context-parallel-size 1 \ + --expert-model-parallel-size 1 \ + --expert-tensor-parallel-size 1 \ + --recompute-granularity full \ + --recompute-method uniform \ + --recompute-num-layers 1 \ + --use-dynamic-batch-size \ + --max-tokens-per-gpu 9216 \ + --rollout-num-gpus-per-engine 2 \ + --sglang-mem-fraction-static 0.7 \ + --attention-dropout 0.0 \ + --hidden-dropout 0.0 \ + --accumulate-allreduce-grads-in-fp32 \ + --attention-softmax-in-fp32 \ + --attention-backend flash +``` + +
+ +The training arguments are the Quick Start's Qwen3-4B recipe; the only adjustments are the topology flags (`--actor-num-nodes`, `--actor-num-gpus-per-node`, and `--num-gpus-per-node`), whose values come from the SkyPilot-injected environment. `--num-gpus-per-node` matters on nodes with fewer than 8 GPUs: slime's colocated engine mapping assumes 8 per node unless told otherwise. Launch it with: + +```bash +sky launch -c slime-train slime-multinode.yaml +``` + +SkyPilot provisions the nodes (creating them if needed), runs `setup` and `run` on each node, and streams the logs. `sky down slime-train` tears the cluster down. The task assumes a fresh cluster: to re-run training, recreate the cluster (`sky down slime-train && sky launch -c slime-train ...`) rather than re-launching onto one whose Ray daemons are still running. The same YAML can be launched as a managed job with `sky jobs launch`, which adds automatic recovery from node failures. + +## Disaggregated Training and Inference + +slime supports connecting the trainer to SGLang engines launched by an external system (`--rollout-external-engine-addrs`, see [External Rollout Engines](../advanced/external-rollout-engines.md)). With a SkyPilot **Job Group**, the trainer and each engine are separate jobs in one YAML that are gang-scheduled together and reach each other by stable hostname (`-0.`), so the fleet of engines can be sized independently of the trainer. + +The trainer publishes updated weights after each optimizer step and the engines reload them from a shared `ReadWriteMany` volume (`--update-weight-transport disk`). Create the volume once: + +```yaml +# policy-volume.yaml +name: slime-policy +type: k8s-pvc +size: 100Gi +infra: kubernetes +config: + access_mode: ReadWriteMany +``` + +```bash +sky volumes apply policy-volume.yaml +``` + +Then launch the Job Group: + +
+slime-jobgroup.yaml + +```yaml +# slime-jobgroup.yaml +--- +name: slime-rl +execution: parallel +primary_tasks: [trainer] # the group succeeds/fails with the trainer +inter_connection: true # place all jobs on one cluster so they can reach each other +termination_delay: 60s +--- +name: sglang +resources: + infra: kubernetes + accelerators: H100:1 + image_id: docker:slimerl/slime:latest +volumes: + /shared/policy: slime-policy +setup: | + pip install -q -U "huggingface_hub[cli]" + [ -d /root/Qwen3-4B ] || hf download Qwen/Qwen3-4B --local-dir /root/Qwen3-4B +run: | + # One SGLang server; the trainer reaches it at sglang-0.:30000. + python -m sglang.launch_server --model-path /root/Qwen3-4B --tp 1 \ + --host 0.0.0.0 --port 30000 --mem-fraction-static 0.7 +--- +name: trainer +resources: + infra: kubernetes + accelerators: H100:2 + image_id: docker:slimerl/slime:latest +volumes: + /shared/policy: slime-policy +setup: | + pip install -q -U "huggingface_hub[cli]" + [ -d /root/Qwen3-4B ] || hf download Qwen/Qwen3-4B --local-dir /root/Qwen3-4B + [ -d /root/dapo-math-17k ] || hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /root/dapo-math-17k + [ -d /root/aime-2024 ] || hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /root/aime-2024 + # Convert the HF checkpoint to Megatron torch_dist format. + if [ ! -d /root/Qwen3-4B_torch_dist ]; then + cd /root/slime + source scripts/models/qwen3-4B.sh + PYTHONPATH=/root/Megatron-LM python tools/convert_hf_to_torch_dist.py \ + ${MODEL_ARGS[@]} --hf-checkpoint /root/Qwen3-4B --save /root/Qwen3-4B_torch_dist + fi +run: | + # Wait for the engine job to serve (jobs in a group provision independently). + ENGINE_ADDR="sglang-0.${SKYPILOT_JOBGROUP_NAME}:30000" + until curl -sf "http://${ENGINE_ADDR}/health" >/dev/null; do + echo "waiting for engine ${ENGINE_ADDR}..."; sleep 10 + done + echo "engine healthy: ${ENGINE_ADDR}" + + # Start Ray from /root/slime: job entrypoints run in the head's working directory. + cd /root/slime + source scripts/models/qwen3-4B.sh + + # Non-default agent/metrics ports: SkyPilot's runtime on the node runs its own Ray. + ray start --head --node-ip-address 127.0.0.1 --num-gpus 2 --disable-usage-stats \ + --dashboard-host=0.0.0.0 --dashboard-port=8265 \ + --dashboard-agent-listen-port 52366 --metrics-export-port 8091 + + # Wait for Ray's job agent to be ready to accept submissions. + until ray job submit --address="http://127.0.0.1:8265" --no-wait -- true >/dev/null 2>&1; do + echo "waiting for the Ray job agent..."; sleep 5 + done + ray job submit --address="http://127.0.0.1:8265" \ + --runtime-env-json='{"env_vars": {"PYTHONPATH": "/root/Megatron-LM/", "CUDA_DEVICE_MAX_CONNECTIONS": "1"}}' \ + -- python3 /root/slime/train.py \ + --actor-num-nodes 1 \ + --actor-num-gpus-per-node 2 \ + --rollout-external-engine-addrs ${ENGINE_ADDR} \ + --update-weight-mode full \ + --update-weight-transport disk \ + --update-weight-disk-dir /shared/policy \ + ${MODEL_ARGS[@]} \ + --hf-checkpoint /root/Qwen3-4B \ + --ref-load /root/Qwen3-4B_torch_dist \ + --load /root/Qwen3-4B_slime/ \ + --save /root/Qwen3-4B_slime/ \ + --save-interval 20 \ + --prompt-data /root/dapo-math-17k/dapo-math-17k.jsonl \ + --input-key prompt \ + --label-key label \ + --apply-chat-template \ + --rollout-shuffle \ + --rm-type deepscaler \ + --num-rollout 3000 \ + --rollout-batch-size 32 \ + --n-samples-per-prompt 8 \ + --rollout-max-response-len 8192 \ + --rollout-temperature 1 \ + --global-batch-size 256 \ + --balance-data \ + --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 1e-6 \ + --lr-decay-style constant \ + --weight-decay 0.1 \ + --adam-beta1 0.9 \ + --adam-beta2 0.98 \ + --tensor-model-parallel-size 2 \ + --sequence-parallel \ + --pipeline-model-parallel-size 1 \ + --context-parallel-size 1 \ + --expert-model-parallel-size 1 \ + --expert-tensor-parallel-size 1 \ + --recompute-granularity full \ + --recompute-method uniform \ + --recompute-num-layers 1 \ + --use-dynamic-batch-size \ + --max-tokens-per-gpu 9216 \ + --rollout-num-gpus-per-engine 1 \ + --sglang-mem-fraction-static 0.7 \ + --attention-dropout 0.0 \ + --hidden-dropout 0.0 \ + --accumulate-allreduce-grads-in-fp32 \ + --attention-softmax-in-fp32 \ + --attention-backend flash +``` + +
+ +```bash +sky jobs launch -n slime-rl slime-jobgroup.yaml +``` + +To scale the inference fleet, add more engine jobs (`sglang-2`, `sglang-3`, ...) to the YAML and append their addresses to `--rollout-external-engine-addrs`. For large models, `--update-weight-mode delta` ships only the changed bytes ([Delta Weight Sync](../advanced/delta-weight-sync.md)); NCCL transport (`--update-weight-transport nccl`) avoids the shared volume entirely. + +## End-to-End Example: Agentic Coding RL + +A complete agentic RL version of the disaggregated setup lives in the SkyPilot repository: + +**[slime on SkyPilot Job Groups](https://github.com/skypilot-org/skypilot/tree/master/llm/slime)** — trains a coding agent (Qwen3-14B) on SWE-smith with slime: a Megatron trainer job plus 1–3 SGLang engine jobs in one Job Group, agent rollouts executing untrusted code in sandboxed pods, and disk-based delta weight sync between the jobs. The example includes launch YAMLs, all setup/run scripts, and benchmark results for scaling the inference fleet (1 → 3 engines cuts async step time from about 1200 s to about 660 s on the example workload). + +Issues with the SkyPilot setups on this page can be reported to the [SkyPilot repository](https://github.com/skypilot-org/skypilot/issues). diff --git a/docs/zh/developer_guide/trace.md b/docs/zh/developer_guide/trace.md index dfd2a812cc..ab2afe016d 100644 --- a/docs/zh/developer_guide/trace.md +++ b/docs/zh/developer_guide/trace.md @@ -41,7 +41,7 @@ python tools/trace_timeline_viewer.py /path/to/debug/rollout_0.pt ## 给自定义代码打点 -在自定义 rollout 或 reward 逻辑中——包括 agentic workflow 里的 agent step、tool call、sandbox 执行、verifier 调用等——可以直接复用 `slime.utils.trace_utils` 里的工具: +在自定义 rollout 或 reward 逻辑中——包括 agentic workflow 里的 agent step、tool call、sandbox 执行、verifier 调用等——可以直接复用 `slime.observability.trace_utils` 里的工具: - `trace_span(target, name, attrs=...)`:记录一段持续时间。 - `trace_event(target, name, attrs=...)`:记录一个瞬时事件。 @@ -57,7 +57,7 @@ python tools/trace_timeline_viewer.py /path/to/debug/rollout_0.pt slime 主 rollout 流程里就是这样用的。例如 `generate_and_rm(...)` 按 sample 打点,而 `generate_and_rm_group(...)` 按 group 打点: ```python -from slime.utils.trace_utils import trace_function +from slime.observability.trace_utils import trace_function @trace_function("generate_and_rm", target="sample") @@ -104,7 +104,7 @@ async def custom_rollout_batch(samples, **kwargs): 如果想统一记录 SGLang 返回的 generation 元信息,可以复用 `build_sglang_meta_trace_attrs`: ```python -from slime.utils.trace_utils import build_sglang_meta_trace_attrs, trace_span +from slime.observability.trace_utils import build_sglang_meta_trace_attrs, trace_span with trace_span(sample, "sglang_generate") as span: output = await post(url, payload) @@ -116,4 +116,3 @@ with trace_span(sample, "sglang_generate") as span: - 先保存少量 rollout;单个 dump 的 sample 数量适中时,viewer 会更容易阅读。 - viewer 直接基于保存下来的 `.pt` dump 工作,因此可以把文件拷到别的机器离线分析。 - 如果你想看的是 SGLang 自身的 GPU / kernel 级 profiling trace,请参考 [性能分析](./profiling.md)。 - diff --git a/docs/zh/get_started/customization.md b/docs/zh/get_started/customization.md index c3cc65e8a3..992a1bfc2d 100644 --- a/docs/zh/get_started/customization.md +++ b/docs/zh/get_started/customization.md @@ -42,7 +42,7 @@ agentic workflow——multi-turn tool use、sandbox interaction、environment fe | 替换整个 rollout 编排(只在 per-sample 自定义不够用时使用) | [`--rollout-function-path`](#1-rollout-函数---rollout-function-path) | | 控制任务采样、缓冲、回填,或自定义 prompt / task 数据源 | [`--data-source-path`](#15-数据源---data-source-path) | | 给 agentic 输出附加自定义 loss mask、metadata,或转换成训练数据 | [`--rollout-data-postprocess-path`](#8-rollout-数据后处理---rollout-data-postprocess-path)、[`--custom-convert-samples-to-train-data-path`](#13-样本转训练数据---custom-convert-samples-to-train-data-path) | -| 调试长耗时的 custom generation、verifier、tool call 或 sandbox 调用 | [`slime.utils.trace_utils`](../developer_guide/trace.md) 中的 trace 工具 | +| 调试长耗时的 custom generation、verifier、tool call 或 sandbox 调用 | [`slime.observability.trace_utils`](../developer_guide/trace.md) 中的 trace 工具 | 这一模式的原生示例是 [`examples/search-r1`](../../../examples/search-r1/):通过 `--custom-generate-function-path` 接入搜索增强的多轮生成,外层仍然走 slime 默认的 `sglang_rollout`。互补的示例可参考 [`examples/multi_agent`](../../../examples/multi_agent/README.md) 中基于 `--rollout-function-path` 的多 agent 模式,以及 [`examples/fully_async`](../../../examples/fully_async/README.md) 中适合 long-tail agentic 场景的 fully-async rollout。 diff --git a/requirements.txt b/requirements.txt index 79a708f949..5acae2c7d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,6 @@ pylatexenc pyyaml qwen_vl_utils # for VLM ray[default] -ring_flash_attn safetensors sglang-router>=0.3.0 tensorboard diff --git a/slime/backends/megatron_utils/__init__.py b/slime/backends/megatron_utils/__init__.py index e1315d6633..7703fa2913 100644 --- a/slime/backends/megatron_utils/__init__.py +++ b/slime/backends/megatron_utils/__init__.py @@ -2,6 +2,10 @@ import torch +from slime.utils import accelerator + +accelerator.initialize_accelerator() + try: import deep_ep from torch_memory_saver import torch_memory_saver @@ -21,7 +25,18 @@ def new_init(self, *args, **kwargs): # DeepEP owns persistent buffers and may initialize them on its # internal streams. Make their lifetime independent of the TMS # disabled region before restoring allocation tracking. - torch.cuda.synchronize() + # CPU-only imports intentionally have no selected device; explicit + # accelerator requests still fail fast in initialize_accelerator(). + selected_accelerator = accelerator.initialize_accelerator() + if selected_accelerator is not None: + selected_accelerator.synchronize() + else: + # Keep the historical CUDA hook observable for CPU test + # doubles, while ignoring the expected no-CUDA runtime error. + try: + torch.cuda.synchronize() + except RuntimeError: + pass finally: cdll.tms_set_interesting_region(original_interesting_region) diff --git a/slime/backends/megatron_utils/actor.py b/slime/backends/megatron_utils/actor.py index ea2601a9d4..1d20abc96f 100644 --- a/slime/backends/megatron_utils/actor.py +++ b/slime/backends/megatron_utils/actor.py @@ -12,10 +12,14 @@ from torch_memory_saver import torch_memory_saver from transformers import AutoConfig, AutoTokenizer +from slime.observability import train_data_utils, train_metric_utils +from slime.observability.logging_utils import init_tracking +from slime.observability.profile_utils import TrainProfiler +from slime.observability.timer import Timer, inverse_timer, timer, with_defer from slime.ray.train_actor import TrainRayActor +from slime.utils import accelerator from slime.utils.data import process_rollout_data from slime.utils.distributed_utils import get_gloo_group -from slime.utils.logging_utils import init_tracking from slime.utils.memory_utils import clear_memory, print_memory from slime.utils.misc import Box from slime.utils.reloadable_process_group import ( @@ -25,15 +29,12 @@ reload_process_groups, ) from slime.utils.routing_replay import RoutingReplay -from slime.utils.timer import Timer, inverse_timer, timer, with_defer from slime.utils.types import RolloutBatch -from ...utils.profile_utils import TrainProfiler from ...utils.tensor_backper import TensorBackuper -from . import train_dump_utils from .checkpoint import load_checkpoint from .cp_utils import prepare_routed_experts_for_routing_replay, slice_log_prob_with_cp -from .data import DataIterator, get_data_iterator, log_perf_data, log_rollout_data +from .data import DataIterator, get_data_iterator from .hf_checkpoint_saver import save_hf_model_to_path from .initialize import init, is_megatron_main_rank from .loss import ( @@ -237,7 +238,7 @@ def wake_up(self) -> None: # that is the first NCCL operation on a group. Prime WORLD here, # after the memory saver is resumed, so later stages cannot miss its # lazy initialization. Sleep still destroys it completely. - dist.barrier(device_ids=[torch.cuda.current_device()]) + dist.barrier(device_ids=[accelerator.current_device()]) if self.role == "actor": self._switch_model("actor") print_memory("after wake_up model") @@ -253,7 +254,7 @@ def _get_rollout_data(self, rollout_data_ref: Box) -> RolloutBatch: ) # TODO: this is ugly, move to somewhere else? # move tokens to GPU in advance - device = torch.cuda.current_device() + device = accelerator.current_device() rollout_data["tokens"] = [ t.to(device=device, dtype=torch.long, non_blocking=True) for t in rollout_data["tokens"] ] @@ -359,7 +360,6 @@ def compute_log_prob( num_microbatches: list[int], store_prefix: str = "", ) -> dict[str, list[torch.Tensor]]: - with timer(f"{store_prefix}log_probs"): return forward_only( get_log_probs_and_entropy, @@ -505,7 +505,7 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data if self.rollout_data_postprocess is not None: self.rollout_data_postprocess(self.args, rollout_id, rollout_data) - log_rollout_data( + train_metric_utils.log_rollout_data( rollout_id, self.args, rollout_data, @@ -542,7 +542,7 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data self.prof.step(rollout_id=rollout_id) - train_dump_utils.save_debug_train_data(self.args, rollout_id=rollout_id, rollout_data=rollout_data) + train_data_utils.save_debug_train_data(self.args, rollout_id=rollout_id, rollout_data=rollout_data) if self.args.use_routing_replay: RoutingReplay.clear_all() @@ -561,7 +561,11 @@ def train_actor(self, rollout_id: int, rollout_data: RolloutBatch, external_data logger.info(f"Updating ref model at rollout_id {rollout_id}") self.weights_backuper.backup("ref") - log_perf_data(rollout_id, self.args, extra_metrics=self.weight_updater.pop_metrics()) + train_metric_utils.log_perf_data( + rollout_id, + self.args, + extra_metrics=self.weight_updater.pop_metrics(), + ) @timer def save_model(self, rollout_id: int, force_sync: bool = False) -> None: diff --git a/slime/backends/megatron_utils/cp_utils.py b/slime/backends/megatron_utils/cp_utils.py index 96c97df0e2..a54da3a7ef 100644 --- a/slime/backends/megatron_utils/cp_utils.py +++ b/slime/backends/megatron_utils/cp_utils.py @@ -124,114 +124,6 @@ def sum_of_token(x: torch.Tensor) -> torch.Tensor: return sum_of_sample_mean if not calculate_per_token_loss else sum_of_token -def reduce_train_step_metrics( - losses_reduced: list[dict], - *, - calculate_per_token_loss: bool, - step_global_batch_size: int, - cp_size: int, - dp_with_cp_group, -) -> dict[str, float]: - """Aggregate per-mb log dicts into the dict ``train_one_step`` reports. - - Pipeline (1:1 with what the train loop used to do inline): - 1. Sum each metric's per-mb ``values`` tensor locally on this rank. - 2. All-reduce across the DP*CP group (``dp_with_cp_group``). - 3. Apply the per-mode divisor / cp_factor: - - per-token-loss: divisor = ``values[0]`` = all-reduced ``num_tokens``, - CP-inflated by ``cp_size`` because every CP rank computes the same - num_tokens off the FULL (not chunked) masks; the - ``cp_factor = cp_size`` multiplier cancels that inflation, leaving - the genuine per-token average. - - per-rollout-mean: divisor = constant ``step_global_batch_size`` from - the rollout side, never all-reduced, so no CP inflation to cancel - and ``cp_factor = 1``. - - Tests pass a mock ``dp_with_cp_group`` and monkeypatch ``dist.all_reduce`` - to a no-op, then pre-aggregate virtual ranks themselves — this exercises - the same call shape as production while staying single-process. - """ - keys = losses_reduced[0]["keys"] - values = None - for x in losses_reduced: - values = x["values"] if values is None else values + x["values"] - assert len(keys) + 1 == values.numel() - dist.all_reduce(values, group=dp_with_cp_group) - values = values.tolist() - - if calculate_per_token_loss: - num_samples_or_tokens = values[0] - cp_factor = cp_size - else: - num_samples_or_tokens = step_global_batch_size - cp_factor = 1 - return {key: value * cp_factor / num_samples_or_tokens for key, value in zip(keys, values[1:], strict=False)} - - -def rollout_log_metric_contribution( - per_rank_reducer_sum: float, - *, - cp_size: int, - num_rollouts_in_rollout: int, - dp_size: int, -) -> tuple[float, float]: - """``(sum, count)`` tuple to hand the gather step for a per-rollout-mean - metric on the rollout side (``log_rollout_data``). - - Sum across DP*CP ranks of ``count`` lands on ``num_rollouts_in_rollout`` - (``dp_size`` here is the no-CP DP width; the gather covers ``dp_size * - cp_size`` ranks, and each rank emits the same ``count``, so the totals - cancel out the ``cp_size`` in the sum). Result: ``Σsum / Σcount = - sum_DP_full / num_rollouts`` — the same number ``train_one_step`` reports - for the same samples (when ``num_steps_per_rollout == 1``). - - Pair with :func:`gather_and_reduce_log_dict` to do the full end-to-end - in tests (single helper call per rank, returns the reduced number on - the source rank). - """ - sum_value = cp_size * per_rank_reducer_sum - count = num_rollouts_in_rollout / dp_size - return sum_value, count - - -def gather_and_reduce_log_dict( - log_dict: dict, - *, - dp_size: int, - dp_src_rank: int, - dp_group, -) -> dict | None: - """``dist.gather_object`` per-rank log_dicts + per-key reduction. - - Per key in the gathered dicts: - - ``(sum, count)`` tuple → ``Σsum / Σcount`` (per-rollout-mean shape; - pair with :func:`rollout_log_metric_contribution`). - - plain value → ``Σ / dp_size`` (legacy mean-across-ranks; the only - correct answer when ranks hold the same data). - - Returns the reduced dict on ``dp_src_rank``, ``None`` elsewhere. The - caller adds whatever metric-name prefix / wandb plumbing it wants — - this helper stays free of side effects so CPU multi-process unit tests - can drive it directly with real ``torch.distributed``. - """ - if dist.get_rank() == dp_src_rank: - gathered = [None] * dp_size - dist.gather_object(log_dict, gathered, dst=dp_src_rank, group=dp_group) - reduced: dict = {} - for key in log_dict: - values = [d[key] for d in gathered] - first = values[0] - if isinstance(first, tuple) and len(first) == 2: - total_sum = sum(v[0] for v in values) - total_count = sum(v[1] for v in values) - reduced[key] = total_sum / total_count if total_count else 0.0 - else: - reduced[key] = sum(values) / dp_size - return reduced - dist.gather_object(log_dict, None, dst=dp_src_rank, group=dp_group) - return None - - def all_gather_with_cp(tensor: torch.Tensor, total_length: int, response_length: int) -> torch.Tensor: """ Gather tensors across all ranks in the context parallel group. diff --git a/slime/backends/megatron_utils/data.py b/slime/backends/megatron_utils/data.py index 8ab7235da9..6ae48a9613 100644 --- a/slime/backends/megatron_utils/data.py +++ b/slime/backends/megatron_utils/data.py @@ -1,28 +1,14 @@ -import logging -from argparse import Namespace from collections.abc import Sequence -import numpy as np import torch -import torch.distributed as dist import torch.nn.functional as F from megatron.core import mpu from megatron.core.packed_seq_params import PackedSeqParams -from slime.utils import train_metric_utils -from slime.utils.flops_utils import calculate_fwd_flops -from slime.utils.metric_utils import compute_pass_rate, compute_rollout_step +from slime.utils import accelerator from slime.utils.types import RolloutBatch -from ...utils import logging_utils -from .cp_utils import ( - gather_and_reduce_log_dict, - get_sum_of_sample_mean, - rollout_log_metric_contribution, - slice_with_cp, -) - -logger = logging.getLogger(__name__) +from .cp_utils import slice_with_cp def get_batch( @@ -83,7 +69,7 @@ def get_batch( tokens = F.pad(tokens, (0, pad), value=pad_token_id) cu_seqlens_list.append(cu_seqlens_list[-1] + pad) - cu_seqlens = torch.tensor(cu_seqlens_list, dtype=torch.int, device=torch.cuda.current_device()) + cu_seqlens = torch.tensor(cu_seqlens_list, dtype=torch.int, device=accelerator.current_device()) tokens = tokens.chunk(cp_size, dim=0)[cp_rank] else: tokens = [slice_with_cp(t, pad_token_id) for t in tokens] @@ -101,7 +87,7 @@ def get_batch( cu_seqlens.append(cu_seqlens[-1] + pad) # thd requires the cu_seqlens to be of the origin length - cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int).cuda() * cp_size + cu_seqlens = torch.tensor(cu_seqlens, dtype=torch.int, device=accelerator.device()) * cp_size max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max().item() packed_seq_params = PackedSeqParams( @@ -163,41 +149,6 @@ def get_batch( return batch -def gather_log_data( - metric_name: str, - args: Namespace, - rollout_id: int, - log_dict: dict[str, "float | tuple[float, float]"], -) -> dict[str, float] | None: - """ - Gather per-rank metrics, reduce on the DP source rank, and log to W&B / TB. - - Each value in ``log_dict`` is either: - * a ``(sum, count)`` tuple → reduced as ``Σsum / Σcount``; - * a plain scalar → reduced as ``Σ / dp_size`` (mean across ranks). - - The gather + reduce step is delegated to - :func:`cp_utils.gather_and_reduce_log_dict` so it can be exercised by - CPU multi-process unit tests directly. This function adds the - ``metric_name`` prefix and the W&B / TB logging side effects. - """ - reduced = gather_and_reduce_log_dict( - log_dict, - dp_size=mpu.get_data_parallel_world_size(with_context_parallel=True), - dp_src_rank=mpu.get_data_parallel_src_rank(with_context_parallel=True), - dp_group=mpu.get_data_parallel_group_gloo(with_context_parallel=True), - ) - if reduced is None: - return None - reduced_log_dict = {f"{metric_name}/{k}": v for k, v in reduced.items()} - logger.info(f"{metric_name} {rollout_id}: {reduced_log_dict}") - # Calculate step once to avoid duplication - step = compute_rollout_step(args, rollout_id) - reduced_log_dict["rollout/step"] = step - logging_utils.log(args, reduced_log_dict, step_key="rollout/step") - return reduced_log_dict - - class DataIterator: """Iterator over a rollout dict following an explicit micro-batch index schedule.""" @@ -245,277 +196,6 @@ def get_data_iterator(rollout_data: RolloutBatch) -> list[DataIterator]: return [DataIterator(rollout_data, micro_batch_indices) for _ in range(vpp_size)] -def log_rollout_data( - rollout_id: int, - args: Namespace, - rollout_data: RolloutBatch, -) -> None: - """ - Summarize rollout fields and log reduced metrics on PP last stage, TP rank 0. - - - Tensor-valued lists are concatenated and averaged. For token-level metrics - like log-probs/returns/advantages/values, computes a CP-correct sample mean - using `loss_masks` and total/response lengths. - - Non-tensor lists are averaged elementwise. - - Scalars are converted to Python numbers. - """ - if mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): - cp_size = mpu.get_context_parallel_world_size() - log_dict = {} - response_lengths = rollout_data["response_lengths"] - loss_masks = rollout_data["loss_masks"] - total_lengths = rollout_data["total_lengths"] - # Same per-rollout denominators the training loss uses, so reported - # log_probs / returns / advantages / etc. live in the same per-rollout - # mean space (rather than per-sample) as the gradient signal. - rollout_mask_sums = rollout_data.get("rollout_mask_sums", None) - # For per-rollout-mean metrics: ``rollout_log_metric_contribution`` - # produces the ``(sum, count)`` tuple so gather_log_data's - # ``Σsum / Σcount`` lands on ``sum_DP_full / num_rollouts`` — the - # same number train_one_step reports for the same samples. - dp_world = mpu.get_data_parallel_world_size(with_context_parallel=False) - num_rollouts_in_rollout = sum(rollout_data["global_batch_sizes"]) - - for key, val in rollout_data.items(): - if key in [ - "tokens", - "multimodal_train_inputs", - "loss_masks", - "sample_indices", - "rollout_ids", - "rollout_mask_sums", - "rollout_top_p_token_ids", - "rollout_top_p_token_offsets", - "rollout_routed_experts", - "global_batch_sizes", - "num_microbatches", - "micro_batch_indices", - "source_names", - # DP-local view of `raw_reward`, which this loop already logs; - # both reduce to the same mean, so skip the duplicate metric. - "local_raw_reward", - ]: - continue - # Emit (sum, count) so gather_log_data can do a weighted average across - # DP ranks. This stops the legacy "every rank has the same N samples" - # assumption from biasing means once uneven-DP partitioning lands. - if isinstance(val, (list, tuple)): - count = len(val) - if isinstance(val[0], torch.Tensor): - # NOTE: Here we have to do the clone().detach(), otherwise the tensor will be - # modified in place and will cause problem for the next rollout. - if key in [ - "log_probs", - "ref_log_probs", - "rollout_log_probs", - "returns", - "advantages", - "values", - "teacher_log_probs", - "opd_reverse_kl", - ]: - tensor = torch.cat(val).clone().detach() - sum_of_sample_mean = get_sum_of_sample_mean( - total_lengths, - response_lengths, - loss_masks, - rollout_mask_sums, - ) - # Compute (sum, count) via the shared helper so this - # path and the unit tests stay in sync. - sum_value, count = rollout_log_metric_contribution( - sum_of_sample_mean(tensor).item(), - cp_size=cp_size, - num_rollouts_in_rollout=num_rollouts_in_rollout, - dp_size=dp_world, - ) - log_dict[key] = (sum_value, count) - continue - tensor = torch.cat(val).clone().detach() - # val.mean() * cp_size is the per-sample mean for one rank; - # multiply by count to get the per-rank sum. - per_rank_sum = tensor.mean() * cp_size * count - sum_value = per_rank_sum.item() - else: - sum_value = sum(val) - log_dict[key] = (sum_value, count) - elif isinstance(val, torch.Tensor): - # Scalar tensor (one per rank): treat as count=1. - log_dict[key] = (val.float().mean().item(), 1) - else: - raise ValueError(f"Unsupported type: {type(val)} for key: {key}") - - reduced_log_dict = gather_log_data("rollout", args, rollout_id, log_dict) - if args.ci_test and reduced_log_dict is not None: - # This is an initial actor/ref zero-KL check. R3 replays rollout - # routing for the actor forward, while the reference forward - # intentionally falls through to normal routing, so their - # log-probs are not expected to match bit-for-bit in CI. - if ( - rollout_id == 0 - and not getattr(args, "ci_disable_kl_checker", False) - and not getattr(args, "use_rollout_routing_replay", False) - and "rollout/log_probs" in reduced_log_dict - and "rollout/ref_log_probs" in reduced_log_dict - ): - # TODO: figure out why there is a small numerical difference in log_probs and ref_log_probs in CI test, and whether it's expected or not. - # assert reduced_log_dict["rollout/log_probs"] == reduced_log_dict["rollout/ref_log_probs"] - assert abs(reduced_log_dict["rollout/log_probs"] - reduced_log_dict["rollout/ref_log_probs"]) < 1e-8 - if "rollout/log_probs" in reduced_log_dict: - assert -1 < reduced_log_dict["rollout/log_probs"] < 0 - if "rollout/entropy" in reduced_log_dict: - assert 0 < reduced_log_dict["rollout/entropy"] < 1 - - if args.log_multi_turn: - log_multi_turn_data(rollout_id, args, rollout_data) - if args.log_passrate: - log_passrate(rollout_id, args, rollout_data) - - if args.log_correct_samples: - if mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): - cp_size = mpu.get_context_parallel_world_size() - log_dict = {} - response_lengths = rollout_data["response_lengths"] - loss_masks = rollout_data["loss_masks"] - total_lengths = rollout_data["total_lengths"] - - def quantile(total_value, n_quantiles, data) -> dict: - import math - - assert n_quantiles > 1, f"n_quantiles({n_quantiles}) must be greater than 1." - - quantiles = [((i + 1) / n_quantiles) for i in range(n_quantiles)] - cut_points = [total_value * q for q in quantiles] - cut_points[-1] = total_value - - count = [0] * n_quantiles - for d in data: - for i, point in enumerate(cut_points): - if d <= point: - count[i] += 1 - break - - total = sum(count) + 1e-9 - percentile = [c / total for c in count] - - percentile = {f"p{min(math.ceil(q*100),100)}": p for q, p in zip(quantiles, percentile, strict=True)} - return percentile - - # DP-local, so it lines up positionally with response_lengths / - # total_lengths / loss_masks / log_probs below. `raw_reward` itself - # is the whole rollout batch (log_passrate needs the full grouping). - raw_rewards = rollout_data["local_raw_reward"] - # Additional metrics for correct cases are calculated separately below. - correct_response_lengths = [] - correct_total_lengths = [] - correct_loss_masks = [] - correct_entropy = [] - for i, raw_reward in enumerate(raw_rewards): - if raw_reward == 1: - correct_response_lengths.append(response_lengths[i]) - correct_total_lengths.append(total_lengths[i]) - correct_loss_masks.append(loss_masks[i]) - correct_entropy.append(-rollout_data["log_probs"][i]) - num_correct_responses = len(correct_total_lengths) - rollout_data["correct_response_lengths"] = correct_response_lengths - correct_response_length_percentile = quantile( - args.rollout_max_response_len, 4, rollout_data["correct_response_lengths"] - ) - for p, val in correct_response_length_percentile.items(): - rollout_data[f"correct_length/{p}"] = [val] * num_correct_responses - if len(correct_entropy) > 0: - # NOTE: per-sample-mean over the correct subset, not per-rollout. - # A rollout's siblings may not all be correct, and slicing - # ``rollout_mask_sums`` here would leave a denom that still - # includes incorrect siblings — meaningless for a "correct-only" - # entropy report. Per-sample-mean over the filtered subset is - # the cleanest semantic. - sum_of_sample_mean = get_sum_of_sample_mean( - correct_total_lengths, correct_response_lengths, correct_loss_masks, sample_denoms=None - ) - correct_entropy = sum_of_sample_mean(torch.cat(correct_entropy, dim=0)) - rollout_data["correct_entropy"] = [correct_entropy.item()] * num_correct_responses - else: - rollout_data["correct_entropy"] = [0] * num_correct_responses - - -def log_multi_turn_data(rollout_id: int, args: Namespace, rollout_data: RolloutBatch) -> None: - """ - Log multi-turn auxiliary metrics such as raw/observed response lengths and rounds. - - Operates only on PP last stage and TP rank 0. Uses GPU tensors when available - to compute statistics without host transfers. - """ - if mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): - log_dict = {} - for key, val in rollout_data.items(): - if key == "loss_masks": - if val: # Check if val is not empty - device = val[0].device # Get device from first tensor - - # Vectorized length calculation using torch - raw_response_lengths = torch.tensor([v.shape[0] for v in val], dtype=torch.float32, device=device) - log_dict["raw_response_length/response_length_mean"] = raw_response_lengths.mean().item() - log_dict["raw_response_length/response_length_max"] = raw_response_lengths.max().item() - log_dict["raw_response_length/response_length_min"] = raw_response_lengths.min().item() - log_dict["raw_response_length/response_length_clip_ratio"] = ( - (raw_response_lengths >= args.rollout_max_response_len).float().mean().item() - ) - - # Vectorized sum calculation using torch - stay on GPU - wo_obs_response_lengths = torch.tensor( - [v.sum().item() for v in val], dtype=torch.float32, device=device - ) - log_dict["wo_obs_response_length/response_length_mean"] = wo_obs_response_lengths.mean().item() - log_dict["wo_obs_response_length/response_length_max"] = wo_obs_response_lengths.max().item() - log_dict["wo_obs_response_length/response_length_min"] = wo_obs_response_lengths.min().item() - if key == "round_number": - # Use numpy for vectorized round number statistics - round_number_array = np.array(val) - log_dict["multi_turn_metric/round_number_mean"] = np.mean(round_number_array) - log_dict["multi_turn_metric/round_number_max"] = np.max(round_number_array) - log_dict["multi_turn_metric/round_number_min"] = np.min(round_number_array) - gather_log_data("multi_turn", args, rollout_id, log_dict) - - -def log_passrate(rollout_id: int, args: Namespace, rollout_data: RolloutBatch) -> None: - """ - Compute pass@k metrics from `raw_reward` groups and log the results. - - `raw_reward` is reshaped to `[group_number, group_size]`, then pass@k is - estimated per problem and averaged. - """ - if mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): - log_dict = {} - for key, val in rollout_data.items(): - if key != "raw_reward": - continue - - log_dict |= compute_pass_rate( - flat_rewards=val, - group_size=args.n_samples_per_prompt, - num_groups=args.rollout_batch_size, - ) - - gather_log_data("passrate", args, rollout_id, log_dict) - - -def log_perf_data(rollout_id: int, args: Namespace, extra_metrics: dict | None = None) -> None: - train_metric_utils.log_perf_data_raw( - rollout_id=rollout_id, - args=args, - is_primary_rank=( - mpu.get_tensor_model_parallel_rank() == 0 - and mpu.is_pipeline_last_stage() - and mpu.get_data_parallel_rank(with_context_parallel=True) == 0 - ), - compute_total_fwd_flops=lambda seq_lens: calculate_fwd_flops(seqlens=seq_lens, args=args) - / dist.get_world_size() - / 1e12, - extra_metrics=extra_metrics, - ) - - def tensors_to_cpu(tensor_list): """Move a list of GPU tensors to CPU for Ray object store transfer. @@ -543,5 +223,5 @@ def tensors_to_gpu(tensor_list, device=None): if tensor_list is None: return None if device is None: - device = torch.cuda.current_device() + device = accelerator.current_device() return [t.to(device=device, dtype=torch.float32) for t in tensor_list] diff --git a/slime/backends/megatron_utils/hf_checkpoint_saver.py b/slime/backends/megatron_utils/hf_checkpoint_saver.py index f45f10e3e8..a574d34f1c 100644 --- a/slime/backends/megatron_utils/hf_checkpoint_saver.py +++ b/slime/backends/megatron_utils/hf_checkpoint_saver.py @@ -8,6 +8,8 @@ import torch +from slime.utils import accelerator + logger = logging.getLogger(__name__) _HF_WEIGHT_FILE_NAMES = { @@ -223,9 +225,10 @@ def _write_pending_chunk( if pending_write is not None: shard_idx, named_tensors = pending_write writer.write(named_tensors, shard_idx=shard_idx) - if torch.cuda.is_available(): - torch.cuda.ipc_collect() - torch.cuda.empty_cache() + selected_accelerator = accelerator.initialize_accelerator() + if selected_accelerator is not None: + selected_accelerator.ipc_collect() + selected_accelerator.empty_cache() return None diff --git a/slime/backends/megatron_utils/loss.py b/slime/backends/megatron_utils/loss.py index a19c014868..e7ac97c19e 100644 --- a/slime/backends/megatron_utils/loss.py +++ b/slime/backends/megatron_utils/loss.py @@ -771,11 +771,11 @@ def compute_advantages_and_returns(args: Namespace, rollout_data: RolloutBatch) rewards = [] kl_coef = -args.kl_coef cp_rank = mpu.get_context_parallel_rank() - for reward, k in zip(old_rewards, kl, strict=False): - k *= kl_coef + for reward, per_token_kl in zip(old_rewards, kl, strict=False): + token_level_rewards = per_token_kl * kl_coef if cp_rank == 0: - k[-1] += reward - rewards.append(k) + token_level_rewards[-1] += reward + rewards.append(token_level_rewards) advantages, returns = get_advantages_and_returns_batch( total_lengths, response_lengths, values, rewards, args.gamma, args.lambd ) diff --git a/slime/backends/megatron_utils/megatron_to_hf/processors/quantizer_compressed_tensors.py b/slime/backends/megatron_utils/megatron_to_hf/processors/quantizer_compressed_tensors.py index ca69df8e65..5f953f9179 100644 --- a/slime/backends/megatron_utils/megatron_to_hf/processors/quantizer_compressed_tensors.py +++ b/slime/backends/megatron_utils/megatron_to_hf/processors/quantizer_compressed_tensors.py @@ -5,6 +5,8 @@ import torch import torch.nn as nn +from slime.utils import accelerator + try: import fake_int4_quant_cuda except ImportError: @@ -90,7 +92,7 @@ def from_linear(cls, linear, w_bit, group_size, init_only=False, scales=None, ze awq_linear.bias = linear.bias.clone().half() pack_num = 32 // awq_linear.w_bit - device = torch.device(f"cuda:{torch.cuda.current_device()}") + device = accelerator.current_device() repeat_scales = scales.to(device).t().repeat_interleave(group_size, 1) if isinstance(zeros, torch.Tensor): @@ -283,7 +285,7 @@ def quantize_params_compressed_tensors(converted_named_params, quantization_conf qw, s, zp = pack_layer(param, group_size, is_symmetric) qweight_name = name.replace(".weight", ".weight_packed") scale_name = name.replace(".weight", ".weight_scale") - weight_shape = torch.tensor(param.shape, dtype=torch.int32, device="cuda") + weight_shape = torch.tensor(param.shape, dtype=torch.int32, device=accelerator.device()) weight_shape_name = name.replace(".weight", ".weight_shape") if zp is not None: zp_name = name.replace(".weight", ".weight_zero_point") diff --git a/slime/backends/megatron_utils/model.py b/slime/backends/megatron_utils/model.py index 1e6a53ef8a..1bb73d6b5e 100644 --- a/slime/backends/megatron_utils/model.py +++ b/slime/backends/megatron_utils/model.py @@ -28,11 +28,10 @@ from megatron.core.pipeline_parallel.utils import unwrap_model except ImportError: from megatron.core.utils import unwrap_model -from slime.utils import logging_utils +from slime.observability import logging_utils, train_metric_utils from slime.utils.memory_utils import clear_memory from .checkpoint import load_checkpoint, save_checkpoint -from .cp_utils import reduce_train_step_metrics from .data import DataIterator, get_batch from .loss import ROLLOUT_TOP_P_TOKEN_KEYS, get_rollout_top_p_logprob_kwargs, loss_function from .model_provider import get_model_provider_func @@ -270,7 +269,7 @@ def _patch_megatron_adam(adam_cls): def setup_model_and_optimizer( args: Namespace, role: str = "actor", -) -> tuple[list[DDP], MegatronOptimizer, OptimizerParamScheduler]: +) -> tuple[list[DDP], MegatronOptimizer | None, OptimizerParamScheduler | None]: """Build model(s), wrap with DDP, and construct optimizer and scheduler. Args: @@ -283,7 +282,7 @@ def setup_model_and_optimizer( lr_mult (float): Global learning-rate multiplier for the optimizer. Returns: - tuple[list[DDP], MegatronOptimizer, OptimizerParamScheduler]: + tuple[list[DDP], MegatronOptimizer | None, OptimizerParamScheduler | None]: - List of model chunks wrapped by ``DDP``. - The constructed ``MegatronOptimizer`` instance. - The learning-rate/weight-decay scheduler tied to the optimizer. @@ -293,6 +292,10 @@ def setup_model_and_optimizer( model = get_model(get_model_provider_func(args, role), ModelType.encoder_or_decoder) + if args.num_rollout == 0: + args.no_load_optim = True + return model, None, None + # Optimizer kwargs = {} for f in dataclasses.fields(OptimizerConfig): @@ -688,7 +691,7 @@ def forward_step(data_iterator: DataIterator, model: GPTModel, return_schedule_p optimizer.zero_grad() if mpu.is_pipeline_last_stage(ignore_virtual=True): - loss_reduced = reduce_train_step_metrics( + loss_reduced = train_metric_utils.reduce_train_step_metrics( losses_reduced, calculate_per_token_loss=args.calculate_per_token_loss, step_global_batch_size=step_global_batch_size, @@ -973,7 +976,7 @@ def save( def initialize_model_and_optimizer( args: Namespace, role: str = "actor" -) -> tuple[list[DDP], MegatronOptimizer, OptimizerParamScheduler, int]: +) -> tuple[list[DDP], MegatronOptimizer | None, OptimizerParamScheduler | None, int]: """Initialize model(s), optimizer, scheduler, and load from checkpoint. Args: @@ -981,7 +984,7 @@ def initialize_model_and_optimizer( role (str): Logical role of the model (e.g., "actor", "critic"). Returns: - tuple[list[DDP], MegatronOptimizer, OptimizerParamScheduler, int]: + tuple[list[DDP], MegatronOptimizer | None, OptimizerParamScheduler | None, int]: DDP-wrapped model chunks, optimizer, scheduler, and iteration index. """ diff --git a/slime/backends/megatron_utils/server/logprob_utils.py b/slime/backends/megatron_utils/server/logprob_utils.py index 61c3fca5f2..3772056dbd 100644 --- a/slime/backends/megatron_utils/server/logprob_utils.py +++ b/slime/backends/megatron_utils/server/logprob_utils.py @@ -12,6 +12,7 @@ from slime.backends.megatron_utils.data import get_data_iterator from slime.backends.megatron_utils.loss import get_log_probs_and_entropy, get_responses from slime.backends.megatron_utils.model import forward_only +from slime.utils import accelerator logging.getLogger().setLevel(logging.WARNING) @@ -222,17 +223,17 @@ def get_label_token_log_probs_from_vocab_parallel_logits( return (local_selected_logits - global_max.to(reduction_dtype) - log_denom).to(logits_dtype) -def _to_cuda_tensors(values, dtype: torch.dtype) -> list[torch.Tensor]: - return [torch.as_tensor(value, dtype=dtype, device=torch.cuda.current_device()) for value in values] +def _to_accelerator_tensors(values, dtype: torch.dtype) -> list[torch.Tensor]: + return [torch.as_tensor(value, dtype=dtype, device=accelerator.current_device()) for value in values] def _prepare_rollout_data(rollout_data_ref): rollout_data = ray.get(rollout_data_ref[0].inner) - rollout_data["tokens"] = _to_cuda_tensors(rollout_data["tokens"], torch.long) - rollout_data["loss_masks"] = _to_cuda_tensors(rollout_data["loss_masks"], torch.int) + rollout_data["tokens"] = _to_accelerator_tensors(rollout_data["tokens"], torch.long) + rollout_data["loss_masks"] = _to_accelerator_tensors(rollout_data["loss_masks"], torch.int) if rollout_data.get("label_token_ids") is not None: - rollout_data["label_token_ids"] = _to_cuda_tensors(rollout_data["label_token_ids"], torch.long) + rollout_data["label_token_ids"] = _to_accelerator_tensors(rollout_data["label_token_ids"], torch.long) for idx, tensor in enumerate(rollout_data["label_token_ids"]): if tensor.dim() == 1 and tensor.numel() == 0: rollout_data["label_token_ids"][idx] = tensor.reshape(0, 0) diff --git a/slime/backends/megatron_utils/update_weight/hf_weight_iterator_direct.py b/slime/backends/megatron_utils/update_weight/hf_weight_iterator_direct.py index ae3083b1f4..67f663d276 100644 --- a/slime/backends/megatron_utils/update_weight/hf_weight_iterator_direct.py +++ b/slime/backends/megatron_utils/update_weight/hf_weight_iterator_direct.py @@ -7,6 +7,7 @@ from megatron.core import mpu from tqdm import tqdm +from slime.utils import accelerator from slime.utils.distributed_utils import get_gloo_group from slime.utils.types import ParamInfo @@ -80,13 +81,13 @@ def _get_megatron_full_params( if dist.get_rank() == info.src_rank: params.append( torch.nn.Parameter( - megatron_local_weights[info.name].to(device=torch.cuda.current_device(), non_blocking=True), + megatron_local_weights[info.name].to(device=accelerator.current_device(), non_blocking=True), requires_grad=False, ) ) else: - params.append(torch.empty(info.shape, dtype=info.dtype, device=torch.cuda.current_device())) - torch.cuda.synchronize() + params.append(torch.empty(info.shape, dtype=info.dtype, device=accelerator.current_device())) + accelerator.synchronize() # broadcast params across pp ranks if pp_size > 1: diff --git a/slime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py b/slime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py index 2632835096..9be3bfabb9 100644 --- a/slime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py +++ b/slime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py @@ -19,6 +19,7 @@ from megatron.core import mpu from ray.actor import ActorHandle +from slime.utils import accelerator from slime.utils.disk_delta import NUM_WORKERS, checksum, make_tensor_reader, overwrite_encode from slime.utils.distributed_utils import get_gloo_group @@ -259,7 +260,7 @@ def collect(fut): if use_pinned and nbytes <= max_bytes: buf = free_q.get() # blocks when all buffers are in flight -> backpressures the gather buf[:nbytes].copy_(flat, non_blocking=True) - torch.cuda.current_stream().synchronize() + accelerator.current_stream().synchronize() payload, pinned = buf, True else: payload, pinned = flat.cpu().numpy(), False @@ -278,7 +279,7 @@ def _record_metrics(self) -> None: counts = torch.tensor( [self.changed_bytes, self.total_bytes, self.wire_bytes], dtype=torch.int64, - device=torch.cuda.current_device(), + device=accelerator.current_device(), ) dist.all_reduce(counts) changed, total, wire = counts.tolist() diff --git a/slime/backends/megatron_utils/update_weight/update_weight_from_distributed.py b/slime/backends/megatron_utils/update_weight/update_weight_from_distributed.py index 1ba987f06b..cd3169e38f 100644 --- a/slime/backends/megatron_utils/update_weight/update_weight_from_distributed.py +++ b/slime/backends/megatron_utils/update_weight/update_weight_from_distributed.py @@ -13,6 +13,7 @@ from ray.actor import ActorHandle from tqdm import tqdm +from slime.utils import accelerator from slime.utils.distributed_utils import get_gloo_group, init_process_group from slime.utils.http_utils import _wrap_ipv6 @@ -22,8 +23,8 @@ class UpdateWeightFromDistributed: """ - Update distributed engines via NCCL. Each PP rank: group "slime-pp_{pp_rank}", - only DP=TP=0 broadcasts. Non-expert (TP) and expert (EP) params separate. + Update distributed engines through a device process group. Each PP rank: group "slime-pp_{pp_rank}", + only DP=TP=0 transfers. Non-expert (TP) and expert (EP) params separate. Subclasses override ``_send_weights`` / ``_on_chunk`` to inject per-mode behaviour. """ @@ -63,7 +64,7 @@ def connect_rollout_engines( engine_parallel_configs: Sequence[Mapping[str, object]] | None = None, ) -> None: """ - Create NCCL "slime-pp_{pp_rank}" if PP source (DP=TP=0). Lock prevents concurrent broadcasts. + Create "slime-pp_{pp_rank}" if PP source (DP=TP=0). Lock prevents concurrent transfers. """ self.rollout_engines = rollout_engines self.rollout_engine_lock = rollout_engine_lock @@ -217,7 +218,7 @@ def _ep_gather_and_convert(self, named_tensors: list[tuple[str, torch.Tensor]]) handles = [] for i, (_name, param) in enumerate(named_tensors): params = [ - torch.empty_like(param.data, device=torch.cuda.current_device()) + torch.empty_like(param.data, device=accelerator.current_device()) for _ in range(mpu.get_expert_model_parallel_world_size()) ] handle = dist.all_gather(params, param.data, group=mpu.get_expert_model_parallel_group(), async_op=True) @@ -244,9 +245,9 @@ def _update_bucket_weights_from_distributed( load_format: str | None = None, ) -> None: """ - Lock → broadcast → clear → unlock → pbar++. Lock prevents NCCL deadlock. + Lock → transfer → clear → unlock → pbar++. Lock prevents communication deadlock. """ - # lock the rollout engines to prevent dead lock on broadcast. + # Lock the rollout engines to prevent communication deadlock. while not ray.get(self.rollout_engine_lock.acquire.remote()): time.sleep(0.1) @@ -272,11 +273,11 @@ def connect_rollout_engines_from_distributed( engine_gpu_counts: Sequence[int] | None = None, ) -> dist.ProcessGroup: """ - Create NCCL group: training rank 0 + all engine GPUs. Blocks until joined. + Create a device process group: training rank 0 + all engine GPUs. Blocks until joined. ``engine_gpu_counts`` gives the number of GPUs per engine. When engines have heterogeneous TP sizes (e.g. prefill TP=2, decode TP=4), each engine - occupies a different number of ranks in the NCCL group. + occupies a different number of ranks in the process group. """ if engine_gpu_counts is None: engine_gpu_counts = [args.rollout_num_gpus_per_engine] * len(rollout_engines) @@ -292,6 +293,7 @@ def connect_rollout_engines_from_distributed( for c in engine_gpu_counts: cumulative.append(cumulative[-1] + c) + backend = accelerator.weight_update_backend() refs = [ engine.init_weights_update_group.remote( master_address=master_address, @@ -299,12 +301,12 @@ def connect_rollout_engines_from_distributed( rank_offset=cumulative[i] + 1, world_size=world_size, group_name=group_name, - backend="nccl", + backend=backend, ) for i, engine in enumerate(rollout_engines) ] model_update_groups = init_process_group( - backend="nccl", + backend=backend, init_method=f"tcp://{_wrap_ipv6(master_address)}:{master_port}", world_size=world_size, rank=0, @@ -316,7 +318,7 @@ def connect_rollout_engines_from_distributed( def disconnect_rollout_engines_from_distributed(args, group_name, model_update_groups, rollout_engines): """ - Destroy NCCL on training and engines. + Destroy the weight-update process group on training and engines. """ refs = [engine.destroy_weights_update_group.remote(group_name) for engine in rollout_engines] dist.destroy_process_group(model_update_groups) @@ -332,7 +334,7 @@ def update_weights_from_distributed( load_format: str | None = None, ) -> list[ObjectRef]: """ - Send metadata (Ray), broadcast tensors (NCCL rank 0 → engines). + Send metadata through Ray and tensors through the configured transport. """ refs = [ engine.update_weights_from_distributed.remote( @@ -345,7 +347,6 @@ def update_weights_from_distributed( ) for engine in rollout_engines ] - handles = [] for _, param in converted_named_tensors: handles.append(dist.broadcast(param.data, 0, group=group, async_op=True)) diff --git a/slime/backends/megatron_utils/update_weight/update_weight_from_tensor.py b/slime/backends/megatron_utils/update_weight/update_weight_from_tensor.py index 877febb7ca..b656781edb 100644 --- a/slime/backends/megatron_utils/update_weight/update_weight_from_tensor.py +++ b/slime/backends/megatron_utils/update_weight/update_weight_from_tensor.py @@ -11,6 +11,7 @@ from ray.actor import ActorHandle from tqdm import tqdm +from slime.utils import accelerator from slime.utils.distributed_utils import get_gloo_group from slime.utils.types import ParamInfo @@ -31,7 +32,7 @@ def _build_flattened_tensor_data( ) -> dict[str, Any]: if not named_tensors: return { - "flattened_tensor": torch.empty(0, dtype=torch.uint8, device=torch.cuda.current_device()), + "flattened_tensor": torch.empty(0, dtype=torch.uint8, device=accelerator.current_device()), "metadata": [], } @@ -209,7 +210,7 @@ def _prepare_expert_weight_batch( offset = buffer_offsets[key] buffer_offsets[key] = offset + 1 if offset == len(pool): - pool.append(torch.empty(info.shape, dtype=info.dtype, device="cuda")) + pool.append(torch.empty(info.shape, dtype=info.dtype, device=accelerator.device())) tensor = pool[offset] if self.rank == transfer.source_rank: source = megatron_local_weights[info.name] @@ -266,12 +267,12 @@ def _update_expert_weights( refs, long_lived_tensors = self._send_hf_params(hf_named_tensors) ray.get(refs) dist.barrier(group=get_gloo_group()) - torch.cuda.synchronize() + accelerator.synchronize() del refs, long_lived_tensors, hf_named_tensors - torch.cuda.ipc_collect() - torch.cuda.empty_cache() + accelerator.ipc_collect() + accelerator.empty_cache() del staging_buffers - torch.cuda.empty_cache() + accelerator.empty_cache() @torch.no_grad() def update_weights(self) -> None: @@ -306,8 +307,8 @@ def update_weights(self) -> None: # then release CUDA IPC cache entries whose consumers (sglang engines) # have already closed their IPC handles. del refs, long_lived_tensors, hf_named_tensors - torch.cuda.ipc_collect() - torch.cuda.empty_cache() + accelerator.ipc_collect() + accelerator.empty_cache() if self._expert_transfer_plan: self._update_expert_weights(megatron_local_weights) @@ -316,8 +317,8 @@ def update_weights(self) -> None: dist.barrier(group=get_gloo_group()) # After the barrier all engines have returned, so every rank's last-chunk # IPC handles are now released by the consumers. Clean them up. - torch.cuda.ipc_collect() - torch.cuda.empty_cache() + accelerator.ipc_collect() + accelerator.empty_cache() # int4/fp4 post_process if self.rank == 0: @@ -426,6 +427,6 @@ def _send_to_colocated_engine( def _empty_flattened_tensor_data(): return { - "flattened_tensor": torch.empty(0, dtype=torch.uint8, device=torch.cuda.current_device()), + "flattened_tensor": torch.empty(0, dtype=torch.uint8, device=accelerator.current_device()), "metadata": [], } diff --git a/slime/backends/sglang_utils/__init__.py b/slime/backends/sglang_utils/__init__.py index e69de29bb2..1a15621488 100644 --- a/slime/backends/sglang_utils/__init__.py +++ b/slime/backends/sglang_utils/__init__.py @@ -0,0 +1,5 @@ +from slime.utils import accelerator + +# Finalize the backend before importing any SGLang submodule. In a MUSA +# runtime this loads musa_patch only after MUSA wins backend selection. +accelerator.initialize_accelerator() diff --git a/slime/backends/sglang_utils/arguments.py b/slime/backends/sglang_utils/arguments.py index 7b04fe4a43..87ea018825 100644 --- a/slime/backends/sglang_utils/arguments.py +++ b/slime/backends/sglang_utils/arguments.py @@ -1,9 +1,12 @@ import argparse +import logging from sglang.srt.server_args import ServerArgs from sglang_router.launch_router import RouterArgs from slime.utils.http_utils import _wrap_ipv6 +logger = logging.getLogger(__name__) + # TODO: use all sglang router arguments with `--sglang-router` prefix def add_sglang_router_arguments(parser): @@ -152,7 +155,18 @@ def validate_args(args): ("sglang_ep_size", "sglang_expert_parallel_size"), ("sglang_moe_dp_size", "sglang_moe_data_parallel_size"), ): - value = getattr(args, current_name) if hasattr(args, current_name) else getattr(args, legacy_name) + if hasattr(args, current_name): + value = getattr(args, current_name) + elif hasattr(args, legacy_name): + value = getattr(args, legacy_name) + else: + logger.warning( + "The installed SGLang registered neither %s nor %s; " + "skipping compatibility alias normalization for this parameter.", + current_name, + legacy_name, + ) + continue setattr(args, current_name, value) setattr(args, legacy_name, value) diff --git a/slime/backends/sglang_utils/sglang_engine.py b/slime/backends/sglang_utils/sglang_engine.py index 3e562a7e38..66e1828baa 100644 --- a/slime/backends/sglang_utils/sglang_engine.py +++ b/slime/backends/sglang_utils/sglang_engine.py @@ -12,6 +12,7 @@ from slime.backends.sglang_utils.external import get_server_info from slime.ray.ray_actor import RayActor +from slime.utils import accelerator from slime.utils.http_utils import get_host_info logger = logging.getLogger(__name__) @@ -27,24 +28,6 @@ def get_base_gpu_id(args, rank): return start_index -def _to_local_gpu_id(physical_gpu_id: int) -> int: - cvd = os.environ.get("CUDA_VISIBLE_DEVICES") - if not cvd: - return physical_gpu_id # no remapping - # CUDA_VISIBLE_DEVICES can be like "4,5,6,7" - visible = [int(x) for x in cvd.split(",") if x.strip() != ""] - # In a remapped process, valid torch device indices are 0..len(visible)-1 - if physical_gpu_id in visible: - return visible.index(physical_gpu_id) - # If we're already getting local IDs, allow them - if 0 <= physical_gpu_id < len(visible): - return physical_gpu_id - raise RuntimeError( - f"GPU id {physical_gpu_id} is not valid under CUDA_VISIBLE_DEVICES={cvd}. " - f"Expected one of {visible} (physical) or 0..{len(visible)-1} (local)." - ) - - def launch_server_process(server_args: ServerArgs) -> multiprocessing.Process: # Expandable segments help the colocated training actor tolerate repeated # cache releases, but SGLang's allocator/sleep path does not support them. @@ -540,7 +523,7 @@ def _compute_server_args( nnodes = max(1, _gpus_per_engine // args.num_gpus_per_node) node_rank = rank % nnodes base = base_gpu_id if base_gpu_id is not None else get_base_gpu_id(args, rank) - base = _to_local_gpu_id(base) + base = accelerator.resolve_visible_device_id(base) kwargs = { "model_path": args.hf_checkpoint, "trust_remote_code": True, diff --git a/slime/observability/__init__.py b/slime/observability/__init__.py new file mode 100644 index 0000000000..3dab479228 --- /dev/null +++ b/slime/observability/__init__.py @@ -0,0 +1 @@ +"""Training and rollout observability utilities.""" diff --git a/slime/utils/logging_utils.py b/slime/observability/logging_utils.py similarity index 91% rename from slime/utils/logging_utils.py rename to slime/observability/logging_utils.py index 1fc3d94b23..2ae2a222ce 100644 --- a/slime/utils/logging_utils.py +++ b/slime/observability/logging_utils.py @@ -2,8 +2,8 @@ import wandb -from . import wandb_utils -from .tensorboard_utils import _TensorboardAdapter +from slime.observability import wandb_utils +from slime.observability.tensorboard_utils import _TensorboardAdapter _LOGGER_CONFIGURED = False diff --git a/slime/utils/metric_utils.py b/slime/observability/metric_utils.py similarity index 100% rename from slime/utils/metric_utils.py rename to slime/observability/metric_utils.py diff --git a/slime/utils/profile_utils.py b/slime/observability/profile_utils.py similarity index 70% rename from slime/utils/profile_utils.py rename to slime/observability/profile_utils.py index 504d1ce868..a2f8dc0316 100644 --- a/slime/utils/profile_utils.py +++ b/slime/observability/profile_utils.py @@ -5,6 +5,7 @@ import torch +from slime.utils import accelerator from slime.utils.memory_utils import print_memory logger = logging.getLogger(__name__) @@ -58,7 +59,13 @@ def _profile_simple_loop(iterator, args, name): def _create_torch_profiler(args, name): + activities = [torch.profiler.ProfilerActivity.CPU] + activity_name = accelerator.device_type().upper() + if hasattr(torch.profiler.ProfilerActivity, activity_name): + activities.append(getattr(torch.profiler.ProfilerActivity, activity_name)) + return torch.profiler.profile( + activities=activities, schedule=torch.profiler.schedule( # TODO the train_actor and train_log_probs ones may need to have different args to control step wait=max(args.profile_step_start - 1, 0), @@ -101,30 +108,55 @@ def stop(self): class _TorchMemoryProfiler(_BaseMemoryProfiler): + def __init__(self, args): + super().__init__(args) + self._recording = False + + @staticmethod + def _memory_module(): + return accelerator.memory_module() + def start(self): logger.info("Attach OOM dump memory history.") - - torch.cuda.memory._record_memory_history( + memory_module = self._memory_module() + if memory_module is None or not hasattr(memory_module, "_record_memory_history"): + logger.warning("Accelerator memory history is unavailable; skip torch memory profiler.") + return + if not hasattr(memory_module, "_dump_snapshot"): + logger.warning("Accelerator memory snapshot is unavailable; skip torch memory profiler.") + return + + memory_module._record_memory_history( max_entries=1000000, - # record stack information for the trace events - # trace_alloc_record_context=True, stacks="all", ) + self._recording = True def oom_observer(device, alloc, device_alloc, device_free): logger.info( f"Observe OOM, will dump snapshot to {self._path_dump}. ({device=} {alloc=} {device_alloc=} {device_free=}; stacktrace is as follows)" ) traceback.print_stack() - torch.cuda.memory._dump_snapshot(self._path_dump) + memory_module._dump_snapshot(str(self._path_dump)) print_memory("when oom") - torch._C._cuda_attach_out_of_memory_observer(oom_observer) + attach_oom_observer = getattr(torch._C, "_cuda_attach_out_of_memory_observer", None) + if attach_oom_observer is not None: + attach_oom_observer(oom_observer) + else: + logger.warning("Accelerator OOM observer is unavailable; memory snapshot on OOM is disabled.") def stop(self): + if not self._recording: + return logger.info(f"Dump memory snapshot to: {self._path_dump}") - torch.cuda.memory._dump_snapshot(self._path_dump) - torch.cuda.memory._record_memory_history(enabled=None) + memory_module = self._memory_module() + if memory_module is None or not hasattr(memory_module, "_dump_snapshot"): + logger.warning("Accelerator memory snapshot is unavailable; skip dump.") + return + memory_module._dump_snapshot(str(self._path_dump)) + memory_module._record_memory_history(enabled=None) + self._recording = False class _MemrayMemoryProfiler(_BaseMemoryProfiler): diff --git a/slime/observability/rollout_data_utils.py b/slime/observability/rollout_data_utils.py new file mode 100644 index 0000000000..de2c7db136 --- /dev/null +++ b/slime/observability/rollout_data_utils.py @@ -0,0 +1,153 @@ +import logging +from pathlib import Path +from typing import Any + +import numpy as np +import torch + +from slime.utils.types import Sample + +logger = logging.getLogger(__name__) + +_ROLLOUT_DATA_TENSOR_DTYPES = { + "tokens": torch.long, + "loss_masks": torch.int, + "rollout_log_probs": torch.float32, + "rollout_top_p_token_ids": torch.int32, + "rollout_top_p_token_offsets": torch.int32, + "teacher_log_probs": torch.float32, + "rollout_routed_experts": None, +} + + +def _cpu_tensor(value, dtype: torch.dtype | None = None) -> torch.Tensor: + if isinstance(value, np.ndarray) and not value.flags.writeable: + value = value.copy() + tensor = torch.as_tensor(value, dtype=dtype) if dtype is not None else torch.as_tensor(value) + return tensor.detach().cpu().contiguous() + + +def tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: + for key, dtype in _ROLLOUT_DATA_TENSOR_DTYPES.items(): + if key in rollout_data: + rollout_data[key] = [_cpu_tensor(value, dtype=dtype) for value in rollout_data[key]] + + if "multimodal_train_inputs" in rollout_data: + rollout_data["multimodal_train_inputs"] = [ + ( + { + key: _cpu_tensor(value) if isinstance(value, (np.ndarray, torch.Tensor)) else value + for key, value in mm_dict.items() + } + if mm_dict is not None + else None + ) + for mm_dict in rollout_data["multimodal_train_inputs"] + ] + + if "rollout_mask_sums" in rollout_data: + rollout_data["rollout_mask_sums"] = _cpu_tensor( + rollout_data["rollout_mask_sums"], + dtype=torch.float32, + ) + + +def validate_rollout_routed_experts_for_replay( + routed_experts: list[torch.Tensor], + args, +) -> None: + """Reject incomplete PP routing captures before R3 consumes them.""" + if not routed_experts: + raise ValueError("R3 is enabled but no rollout routed-experts tensors were returned.") + + num_layers = int(args.num_layers) + topk = int(args.moe_router_topk) + moe_layer_freq = getattr(args, "moe_layer_freq", None) + if isinstance(moe_layer_freq, (list, tuple)): + moe_layers = [layer_id for layer_id, freq in enumerate(moe_layer_freq[:num_layers]) if int(freq) != 0] + else: + moe_layers = list(range(num_layers)) + + for sample_idx, experts in enumerate(routed_experts): + experts = torch.as_tensor(experts) + if experts.ndim != 3 or tuple(experts.shape[1:]) != (num_layers, topk): + raise ValueError( + "Invalid rollout routed-experts shape for R3: " + f"sample={sample_idx}, got={tuple(experts.shape)}, " + f"expected=(*, {num_layers}, {topk})." + ) + if experts.shape[0] == 0: + raise ValueError(f"R3 sample {sample_idx} has no routed-experts rows.") + if topk > 1: + missing_layers = [layer_id for layer_id in moe_layers if not torch.count_nonzero(experts[:, layer_id, :])] + if missing_layers: + raise ValueError( + "R3 routed-experts capture is all zero for MoE layers " + f"{missing_layers} in sample {sample_idx}. This usually means " + "SGLang pipeline stages did not aggregate their disjoint routing " + "captures; refusing to replay expert 0 everywhere." + ) + + +def validate_rollout_id_annotated(node, depth=0): + """Walk the rollout function's nested output and validate ``rollout_id`` only + when a compact / subagent pattern is detected. + + "Compact" = the rollout function wraps multiple training samples from one + rollout execution into a ``list[Sample]``. In slime's convention the + default rollout shape is ``list[list[Sample]]`` (depth-2: prompt × rollout) + so its leaf ``list[Sample]`` lands at depth 1 and we skip validation, + preserving backward compatibility. A compact rollout adds a third level: + ``list[list[list[Sample]]]`` (prompt × rollout × samples-from-one-rollout), + so the leaf ``list[Sample]`` lands at depth ≥ 2. At that point we require + every sibling to carry a non-None ``rollout_id`` and to share the same + value, so the loss reducer counts the rollout once instead of N times. + """ + if isinstance(node, Sample): + return + assert isinstance(node, list), f"unexpected rollout output node type: {type(node).__name__}" + if node and isinstance(node[0], Sample): + if depth >= 2 and len(node) > 1: + rids = [sample.rollout_id for sample in node] + missing = [i for i, rollout_id in enumerate(rids) if rollout_id is None] + assert not missing, ( + f"Compact rollout returned {len(node)} samples but rollout_id is unset on " + f"positions {missing}. Set Sample.rollout_id on every sibling so the loss " + "reducer can aggregate them as one rollout instead of N." + ) + assert len(set(rids)) == 1, f"Sibling samples from one compact rollout must share rollout_id; got {rids}." + return + for item in node: + validate_rollout_id_annotated(item, depth + 1) + + +def load_debug_rollout_data(path_template, *, rollout_id: int, subsample_ratio=None) -> list[Sample]: + data = torch.load(path_template.format(rollout_id=rollout_id), weights_only=False)["samples"] + data = [Sample.from_dict(sample) for sample in data] + if subsample_ratio is not None: + original_num_rows = len(data) + rough_subsample_num_rows = int(original_num_rows * subsample_ratio) + data = data[: rough_subsample_num_rows // 2] + data[-rough_subsample_num_rows // 2 :] + logger.info( + "Subsample loaded debug rollout data using ratio=%s and change num rows %s -> %s", + subsample_ratio, + original_num_rows, + len(data), + ) + return data + + +def save_debug_rollout_data(path_template, data, *, rollout_id: int, evaluation: bool) -> None: + if path_template is None: + return + + path = Path(path_template.format(rollout_id=("eval_" if evaluation else "") + str(rollout_id))) + logger.info(f"Save debug rollout data to {path}") + path.parent.mkdir(parents=True, exist_ok=True) + + if evaluation: + samples = [sample.to_dict() for info in data.values() for sample in info["samples"]] + else: + samples = [sample.to_dict() for sample in data] + + torch.save({"rollout_id": rollout_id, "samples": samples}, path) diff --git a/slime/observability/rollout_metrics.py b/slime/observability/rollout_metrics.py new file mode 100644 index 0000000000..e7e9ca2bb6 --- /dev/null +++ b/slime/observability/rollout_metrics.py @@ -0,0 +1,271 @@ +import logging +from typing import Any + +import numpy as np +import torch + +from slime.observability import logging_utils +from slime.observability.metric_utils import ( + compute_pass_rate, + compute_rollout_step, + compute_statistics, + dict_add_prefix, + has_repetition, +) +from slime.utils.misc import group_by, load_function +from slime.utils.types import Sample + +logger = logging.getLogger(__name__) + +_SGLANG_REQUEST_PERF_FIELDS = ( + ("request/e2e_latency", "e2e_latency"), + ("request/queue_time", "queue_time"), + ("decode/throughput", "decode_throughput"), +) +_SGLANG_PREFILL_PERF_FIELDS = ( + ("prefill/bootstrap_queue_duration", "pd_prefill_bootstrap_queue_duration"), + ("prefill/bootstrap_duration", "pd_prefill_bootstrap_duration"), + ("prefill/alloc_wait_duration", "pd_prefill_alloc_wait_duration"), + ("prefill/forward_duration", "pd_prefill_forward_duration"), + ("prefill/transfer_queue_duration", "pd_prefill_transfer_queue_duration"), + ("prefill/transfer_speed_gb_s", "pd_transfer_speed_gb_s"), + ("prefill/transfer_total_mb", "pd_transfer_total_mb"), + ("prefill/retry_count", "pd_prefill_retry_count"), +) +_SGLANG_DECODE_PERF_FIELDS = ( + ("decode/prealloc_duration", "pd_decode_prealloc_duration"), + ("decode/bootstrap_duration", "pd_decode_bootstrap_duration"), + ("decode/alloc_wait_duration", "pd_decode_alloc_wait_duration"), + ("decode/transfer_duration", "pd_decode_transfer_duration"), + ("decode/forward_duration", "pd_decode_forward_duration"), +) + + +def compute_metrics_from_samples(args, samples): + response_lengths = [sample.effective_response_length for sample in samples] + + log_dict = {} + log_dict |= dict_add_prefix(compute_statistics(response_lengths), "response_len/") + log_dict |= _compute_zero_std_metrics(args, samples) + log_dict |= _compute_spec_metrics(args, samples) + log_dict |= _compute_prefix_cache_metrics(args, samples) + log_dict |= _compute_reward_cat_metrics(args, samples) + log_dict |= _compute_top_p_kept_vocab_metrics(args, samples) + log_dict["repetition_frac"] = np.mean([int(has_repetition(s.response)) for s in samples]).item() + log_dict["truncated_ratio"] = np.mean([int(s.status == Sample.Status.TRUNCATED) for s in samples]).item() + return log_dict + + +def compute_perf_metrics_from_samples(args, samples, rollout_time): + non_generation_time = [sample.non_generation_time for sample in samples] + + log_dict = {} + log_dict["rollout_time"] = rollout_time + if max(non_generation_time) > 0: + log_dict |= dict_add_prefix(compute_statistics(non_generation_time), "non_generation_time/") + + def token_perf(response_lengths, non_generation_time, key=""): + max_response_length = max(response_lengths) + if args.rollout_num_gpus: + log_dict[f"{key}tokens_per_gpu_per_sec"] = sum(response_lengths) / rollout_time / args.rollout_num_gpus + log_dict[f"longest_{key}sample_tokens_per_sec"] = max_response_length / rollout_time + + if max(non_generation_time) == 0: + return + + non_generation_time = [ + t for t, length in zip(non_generation_time, response_lengths, strict=True) if length == max_response_length + ] + mean_non_generation_time = sum(non_generation_time) / len(non_generation_time) + + log_dict[f"longest_{key}sample_non_generation_time"] = mean_non_generation_time + log_dict[f"longest_{key}sample_tokens_per_sec_without_non_generation"] = max_response_length / ( + rollout_time - mean_non_generation_time + ) + + token_perf([sample.response_length for sample in samples], non_generation_time, key="") + token_perf([sample.effective_response_length for sample in samples], non_generation_time, key="effective_") + log_dict |= _compute_sglang_request_perf_metrics(samples) + + return log_dict + + +def _compute_sglang_request_perf_metrics(all_samples: list[Sample]): + attrs_by_request = list(_iter_sglang_generate_attrs(all_samples)) + if not attrs_by_request: + return {} + + values_by_metric: dict[str, list[float]] = {} + profiled_request_count = 0 + + def add_value(metric_key: str, source_key: str, attrs: dict) -> bool: + value = attrs.get(source_key) + if not isinstance(value, (int, float)) or isinstance(value, bool) or not np.isfinite(value): + return False + values_by_metric.setdefault(metric_key, []).append(float(value)) + return True + + for attrs in attrs_by_request: + request_has_perf = False + + for metric_key, source_key in _SGLANG_REQUEST_PERF_FIELDS: + request_has_perf |= add_value(metric_key, source_key, attrs) + + for metric_key, source_key in _SGLANG_PREFILL_PERF_FIELDS: + request_has_perf |= add_value(metric_key, source_key, attrs) + + for metric_key, source_key in _SGLANG_DECODE_PERF_FIELDS: + request_has_perf |= add_value(metric_key, source_key, attrs) + + if request_has_perf: + profiled_request_count += 1 + + metrics: dict[str, float] = {} + for key, values in values_by_metric.items(): + if not values: + continue + metrics |= dict_add_prefix(compute_statistics(values), f"{key}/") + + return metrics + + +def _iter_sglang_generate_attrs(all_samples: list[Sample]): + for sample in all_samples: + trace = getattr(sample, "trace", None) + if not isinstance(trace, dict): + continue + for event in trace.get("events") or []: + if event.get("type") != "span_end" or event.get("name") != "sglang_generate": + continue + attrs = event.get("attrs") + if isinstance(attrs, dict): + yield attrs + + +def _compute_zero_std_metrics(args, all_samples: list[Sample]): + # only compute in GRPO-like algorithms where one prompt has multiple responses + if args.advantage_estimator == "ppo": + return {} + + def _is_zero_std(samples: list[Sample]): + rewards = [sample.get_reward_value(args) for sample in samples] + return len(rewards) == 0 or all(rewards[0] == r for r in rewards) + + all_sample_groups = group_by(all_samples, lambda s: s.group_index) + interesting_sample_groups = [g for g in all_sample_groups.values() if _is_zero_std(g)] + + interesting_rewards = [str(round(g[0].get_reward_value(args), 1)) for g in interesting_sample_groups] + + return {f"zero_std/count_{reward}": len(items) for reward, items in group_by(interesting_rewards).items()} + + +def _compute_top_p_kept_vocab_metrics(args, all_samples: list[Sample]): + total_kept = 0 + total_tokens = 0 + for sample in all_samples: + offsets = sample.rollout_top_p_token_offsets + if offsets is None or sample.response_length == 0: + continue + offsets = torch.as_tensor(offsets, dtype=torch.int64) + if offsets.numel() == 0: + continue + assert ( + offsets.numel() == sample.response_length + 1 + ), f"top-p token offsets length {offsets.numel()} != response length + 1 {sample.response_length + 1}" + if sample.remove_sample: + continue + if sample.loss_mask is None: + total_kept += int(offsets[-1] - offsets[0]) + total_tokens += sample.response_length + continue + loss_mask = torch.as_tensor(sample.loss_mask, dtype=torch.bool, device=offsets.device) + assert ( + loss_mask.numel() == sample.response_length + ), f"loss mask length {loss_mask.numel()} != response length {sample.response_length}" + total_kept += int(torch.diff(offsets)[loss_mask].sum()) + total_tokens += int(loss_mask.sum()) + if total_tokens == 0: + return {} + return {"top_p_kept_vocab_per_token": total_kept / total_tokens} + + +def _compute_spec_metrics(args, all_samples: list[Sample]): + if getattr(args, "sglang_speculative_algorithm", None) is None: + return {} + num_samples = len(all_samples) + metrics = {} + metrics["spec_accept_rate"] = sum(sample.spec_info.spec_accept_rate for sample in all_samples) / num_samples + metrics["spec_accept_length"] = sum(sample.spec_info.spec_accept_length for sample in all_samples) / num_samples + return metrics + + +def _compute_prefix_cache_metrics(args, all_samples: list[Sample]): + num_samples = len(all_samples) + metrics = {} + total_cached_tokens = sum(sample.prefix_cache_info.cached_tokens for sample in all_samples) + total_prompt_tokens = sum(sample.prefix_cache_info.total_prompt_tokens for sample in all_samples) + + metrics["prefix_cache_hit_rate"] = total_cached_tokens / total_prompt_tokens if total_prompt_tokens > 0 else 0.0 + metrics["avg_cached_tokens_per_sample"] = total_cached_tokens / num_samples + return metrics + + +def _compute_reward_cat_metrics(args, all_samples: list[Sample]): + reward_cat_key = args.log_reward_category + if reward_cat_key is None: + return {} + + samples_of_reward_cat = group_by(all_samples, lambda s: s.reward[reward_cat_key]) + + return {f"error_cat/{reward_cat}": len(s) / len(all_samples) for reward_cat, s in samples_of_reward_cat.items()} + + +def log_eval_rollout_data(rollout_id, args, data, extra_metrics: dict[str, Any] | None = None): + if args.custom_eval_rollout_log_function_path is not None: + custom_log_func = load_function(args.custom_eval_rollout_log_function_path) + if custom_log_func(rollout_id, args, data, extra_metrics): + return + + log_dict = extra_metrics or {} + for key in data.keys(): + rewards = data[key]["rewards"] + log_dict[f"eval/{key}"] = sum(rewards) / len(rewards) + if (samples := data[key].get("samples")) is not None: + log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), f"eval/{key}/") + if "truncated" in data[key]: + truncated = data[key]["truncated"] + log_dict[f"eval/{key}-truncated_ratio"] = sum(truncated) / len(truncated) + if args.log_passrate: + log_dict |= dict_add_prefix( + compute_pass_rate( + flat_rewards=rewards, + group_size=args.n_samples_per_eval_prompt, + ), + f"eval/{key}-", + ) + + logger.info(f"eval {rollout_id}: {log_dict}") + + step = compute_rollout_step(args, rollout_id) + log_dict["eval/step"] = step + logging_utils.log(args, log_dict, step_key="eval/step") + + return log_dict + + +def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_time): + if args.custom_rollout_log_function_path is not None: + custom_log_func = load_function(args.custom_rollout_log_function_path) + if custom_log_func(rollout_id, args, samples, rollout_extra_metrics, rollout_time): + return + + if args.load_debug_rollout_data: + return + + log_dict = {**(rollout_extra_metrics or {})} + log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), "rollout/") + log_dict |= dict_add_prefix(compute_perf_metrics_from_samples(args, samples, rollout_time), "perf/") + logger.info(f"perf {rollout_id}: {log_dict}") + step = compute_rollout_step(args, rollout_id) + log_dict["rollout/step"] = step + logging_utils.log(args, log_dict, step_key="rollout/step") diff --git a/slime/utils/tensorboard_utils.py b/slime/observability/tensorboard_utils.py similarity index 96% rename from slime/utils/tensorboard_utils.py rename to slime/observability/tensorboard_utils.py index 3c384ec12a..250864636c 100644 --- a/slime/utils/tensorboard_utils.py +++ b/slime/observability/tensorboard_utils.py @@ -22,7 +22,7 @@ class _TensorboardAdapter(metaclass=SingletonMeta): # tb.log({"Loss": 0.1}, step=1) # In other files: - # from tensorboard_utils import _TensorboardAdapter + # from slime.observability.tensorboard_utils import _TensorboardAdapter # tb = _TensorboardAdapter(args) # No parameters needed to get existing instance # tb.log({"Accuracy": 0.9}, step=1) """ diff --git a/slime/utils/timer.py b/slime/observability/timer.py similarity index 98% rename from slime/utils/timer.py rename to slime/observability/timer.py index ec1bdf767d..b0a89a5389 100644 --- a/slime/utils/timer.py +++ b/slime/observability/timer.py @@ -5,7 +5,7 @@ import torch.distributed -from .misc import SingletonMeta +from slime.utils.misc import SingletonMeta __all__ = ["Timer", "timer"] diff --git a/slime/utils/trace_utils.py b/slime/observability/trace_utils.py similarity index 100% rename from slime/utils/trace_utils.py rename to slime/observability/trace_utils.py diff --git a/slime/backends/megatron_utils/train_dump_utils.py b/slime/observability/train_data_utils.py similarity index 100% rename from slime/backends/megatron_utils/train_dump_utils.py rename to slime/observability/train_data_utils.py diff --git a/slime/observability/train_metric_utils.py b/slime/observability/train_metric_utils.py new file mode 100644 index 0000000000..6a5a0f6874 --- /dev/null +++ b/slime/observability/train_metric_utils.py @@ -0,0 +1,405 @@ +import logging +from argparse import Namespace +from copy import deepcopy + +import numpy as np +import torch +import torch.distributed as dist + +from slime.observability import logging_utils +from slime.observability.metric_utils import compute_pass_rate, compute_rollout_step +from slime.observability.timer import Timer +from slime.utils.flops_utils import calculate_fwd_flops +from slime.utils.types import RolloutBatch + +logger = logging.getLogger(__name__) + + +def reduce_train_step_metrics( + losses_reduced: list[dict], + *, + calculate_per_token_loss: bool, + step_global_batch_size: int, + cp_size: int, + dp_with_cp_group, +) -> dict[str, float]: + """Aggregate per-mb log dicts into the dict ``train_one_step`` reports. + + Pipeline (1:1 with what the train loop used to do inline): + 1. Sum each metric's per-mb ``values`` tensor locally on this rank. + 2. All-reduce across the DP*CP group (``dp_with_cp_group``). + 3. Apply the per-mode divisor / cp_factor: + - per-token-loss: divisor = ``values[0]`` = all-reduced ``num_tokens``, + CP-inflated by ``cp_size`` because every CP rank computes the same + num_tokens off the FULL (not chunked) masks; the + ``cp_factor = cp_size`` multiplier cancels that inflation, leaving + the genuine per-token average. + - per-rollout-mean: divisor = constant ``step_global_batch_size`` from + the rollout side, never all-reduced, so no CP inflation to cancel + and ``cp_factor = 1``. + + Tests pass a mock ``dp_with_cp_group`` and monkeypatch ``dist.all_reduce`` + to a no-op, then pre-aggregate virtual ranks themselves — this exercises + the same call shape as production while staying single-process. + """ + keys = losses_reduced[0]["keys"] + values = None + for item in losses_reduced: + values = item["values"] if values is None else values + item["values"] + assert len(keys) + 1 == values.numel() + dist.all_reduce(values, group=dp_with_cp_group) + values = values.tolist() + + if calculate_per_token_loss: + num_samples_or_tokens = values[0] + cp_factor = cp_size + else: + num_samples_or_tokens = step_global_batch_size + cp_factor = 1 + return {key: value * cp_factor / num_samples_or_tokens for key, value in zip(keys, values[1:], strict=False)} + + +def rollout_log_metric_contribution( + per_rank_reducer_sum: float, + *, + cp_size: int, + num_rollouts_in_rollout: int, + dp_size: int, +) -> tuple[float, float]: + """``(sum, count)`` tuple for a per-rollout-mean metric. + + Sum across DP*CP ranks of ``count`` lands on ``num_rollouts_in_rollout`` + (``dp_size`` here is the no-CP DP width; the gather covers ``dp_size * + cp_size`` ranks, and each rank emits the same ``count``, so the totals + cancel out the ``cp_size`` in the sum). Result: ``Σsum / Σcount = + sum_DP_full / num_rollouts`` — the same number ``train_one_step`` reports + for the same samples (when ``num_steps_per_rollout == 1``). + + Pair with :func:`gather_and_reduce_log_dict` to do the full end-to-end + in tests. + """ + sum_value = cp_size * per_rank_reducer_sum + count = num_rollouts_in_rollout / dp_size + return sum_value, count + + +def gather_and_reduce_log_dict( + log_dict: dict, + *, + dp_size: int, + dp_src_rank: int, + dp_group, +) -> dict | None: + """Gather per-rank log dicts and reduce each metric on ``dp_src_rank``. + + ``(sum, count)`` tuples reduce to ``Σsum / Σcount``; plain values reduce + to a mean across ranks. The helper stays free of reporting side effects so + CPU multi-process tests can exercise it with real ``torch.distributed``. + """ + if dist.get_rank() == dp_src_rank: + gathered = [None] * dp_size + dist.gather_object(log_dict, gathered, dst=dp_src_rank, group=dp_group) + reduced: dict = {} + for key in log_dict: + values = [item[key] for item in gathered] + first = values[0] + if isinstance(first, tuple) and len(first) == 2: + total_sum = sum(value[0] for value in values) + total_count = sum(value[1] for value in values) + reduced[key] = total_sum / total_count if total_count else 0.0 + else: + reduced[key] = sum(values) / dp_size + return reduced + dist.gather_object(log_dict, None, dst=dp_src_rank, group=dp_group) + return None + + +def gather_log_data( + metric_name: str, + args: Namespace, + rollout_id: int, + log_dict: dict[str, "float | tuple[float, float]"], +) -> dict[str, float] | None: + """Gather per-rank metrics and report them through the configured trackers.""" + from megatron.core import mpu + + reduced = gather_and_reduce_log_dict( + log_dict, + dp_size=mpu.get_data_parallel_world_size(with_context_parallel=True), + dp_src_rank=mpu.get_data_parallel_src_rank(with_context_parallel=True), + dp_group=mpu.get_data_parallel_group_gloo(with_context_parallel=True), + ) + if reduced is None: + return None + reduced_log_dict = {f"{metric_name}/{key}": value for key, value in reduced.items()} + logger.info(f"{metric_name} {rollout_id}: {reduced_log_dict}") + step = compute_rollout_step(args, rollout_id) + reduced_log_dict["rollout/step"] = step + logging_utils.log(args, reduced_log_dict, step_key="rollout/step") + return reduced_log_dict + + +def log_rollout_data( + rollout_id: int, + args: Namespace, + rollout_data: RolloutBatch, +) -> None: + """Summarize and report Megatron-side rollout fields.""" + from megatron.core import mpu + + from slime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + + if mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): + cp_size = mpu.get_context_parallel_world_size() + log_dict = {} + response_lengths = rollout_data["response_lengths"] + loss_masks = rollout_data["loss_masks"] + total_lengths = rollout_data["total_lengths"] + rollout_mask_sums = rollout_data.get("rollout_mask_sums", None) + dp_world = mpu.get_data_parallel_world_size(with_context_parallel=False) + num_rollouts_in_rollout = sum(rollout_data["global_batch_sizes"]) + + ignored_keys = { + "tokens", + "multimodal_train_inputs", + "loss_masks", + "sample_indices", + "rollout_ids", + "rollout_mask_sums", + "rollout_top_p_token_ids", + "rollout_top_p_token_offsets", + "rollout_routed_experts", + "global_batch_sizes", + "num_microbatches", + "micro_batch_indices", + "source_names", + "local_raw_reward", + } + per_rollout_mean_keys = { + "log_probs", + "ref_log_probs", + "rollout_log_probs", + "returns", + "advantages", + "values", + "teacher_log_probs", + "opd_reverse_kl", + } + + for key, value in rollout_data.items(): + if key in ignored_keys: + continue + if isinstance(value, (list, tuple)): + count = len(value) + if isinstance(value[0], torch.Tensor): + tensor = torch.cat(value).clone().detach() + if key in per_rollout_mean_keys: + sum_of_sample_mean = get_sum_of_sample_mean( + total_lengths, + response_lengths, + loss_masks, + rollout_mask_sums, + ) + sum_value, count = rollout_log_metric_contribution( + sum_of_sample_mean(tensor).item(), + cp_size=cp_size, + num_rollouts_in_rollout=num_rollouts_in_rollout, + dp_size=dp_world, + ) + log_dict[key] = (sum_value, count) + continue + per_rank_sum = tensor.mean() * cp_size * count + sum_value = per_rank_sum.item() + else: + sum_value = sum(value) + log_dict[key] = (sum_value, count) + elif isinstance(value, torch.Tensor): + log_dict[key] = (value.float().mean().item(), 1) + else: + raise ValueError(f"Unsupported type: {type(value)} for key: {key}") + + reduced_log_dict = gather_log_data("rollout", args, rollout_id, log_dict) + if args.ci_test and reduced_log_dict is not None: + if ( + rollout_id == 0 + and not getattr(args, "ci_disable_kl_checker", False) + and not getattr(args, "use_rollout_routing_replay", False) + and "rollout/log_probs" in reduced_log_dict + and "rollout/ref_log_probs" in reduced_log_dict + ): + assert abs(reduced_log_dict["rollout/log_probs"] - reduced_log_dict["rollout/ref_log_probs"]) < 1e-8 + if "rollout/log_probs" in reduced_log_dict: + assert -1 < reduced_log_dict["rollout/log_probs"] < 0 + if "rollout/entropy" in reduced_log_dict: + assert 0 < reduced_log_dict["rollout/entropy"] < 1 + + if args.log_multi_turn: + log_multi_turn_data(rollout_id, args, rollout_data) + if args.log_passrate: + log_passrate(rollout_id, args, rollout_data) + + if args.log_correct_samples and mpu.get_tensor_model_parallel_rank() == 0 and mpu.is_pipeline_last_stage(): + response_lengths = rollout_data["response_lengths"] + loss_masks = rollout_data["loss_masks"] + total_lengths = rollout_data["total_lengths"] + + def quantile(total_value, n_quantiles, data) -> dict: + import math + + assert n_quantiles > 1, f"n_quantiles({n_quantiles}) must be greater than 1." + quantiles = [(i + 1) / n_quantiles for i in range(n_quantiles)] + cut_points = [total_value * quantile for quantile in quantiles] + cut_points[-1] = total_value + + count = [0] * n_quantiles + for value in data: + for i, point in enumerate(cut_points): + if value <= point: + count[i] += 1 + break + + total = sum(count) + 1e-9 + percentile = [value / total for value in count] + return { + f"p{min(math.ceil(quantile * 100), 100)}": value + for quantile, value in zip(quantiles, percentile, strict=True) + } + + raw_rewards = rollout_data["local_raw_reward"] + correct_response_lengths = [] + correct_total_lengths = [] + correct_loss_masks = [] + correct_entropy = [] + for i, raw_reward in enumerate(raw_rewards): + if raw_reward == 1: + correct_response_lengths.append(response_lengths[i]) + correct_total_lengths.append(total_lengths[i]) + correct_loss_masks.append(loss_masks[i]) + correct_entropy.append(-rollout_data["log_probs"][i]) + num_correct_responses = len(correct_total_lengths) + rollout_data["correct_response_lengths"] = correct_response_lengths + correct_response_length_percentile = quantile( + args.rollout_max_response_len, + 4, + rollout_data["correct_response_lengths"], + ) + for percentile, value in correct_response_length_percentile.items(): + rollout_data[f"correct_length/{percentile}"] = [value] * num_correct_responses + if correct_entropy: + sum_of_sample_mean = get_sum_of_sample_mean( + correct_total_lengths, + correct_response_lengths, + correct_loss_masks, + sample_denoms=None, + ) + correct_entropy_value = sum_of_sample_mean(torch.cat(correct_entropy, dim=0)) + rollout_data["correct_entropy"] = [correct_entropy_value.item()] * num_correct_responses + else: + rollout_data["correct_entropy"] = [0] * num_correct_responses + + +def log_multi_turn_data(rollout_id: int, args: Namespace, rollout_data: RolloutBatch) -> None: + """Report multi-turn response-length and round-count metrics.""" + from megatron.core import mpu + + if mpu.get_tensor_model_parallel_rank() != 0 or not mpu.is_pipeline_last_stage(): + return + + log_dict = {} + for key, value in rollout_data.items(): + if key == "loss_masks" and value: + device = value[0].device + raw_response_lengths = torch.tensor( + [item.shape[0] for item in value], + dtype=torch.float32, + device=device, + ) + log_dict["raw_response_length/response_length_mean"] = raw_response_lengths.mean().item() + log_dict["raw_response_length/response_length_max"] = raw_response_lengths.max().item() + log_dict["raw_response_length/response_length_min"] = raw_response_lengths.min().item() + log_dict["raw_response_length/response_length_clip_ratio"] = ( + (raw_response_lengths >= args.rollout_max_response_len).float().mean().item() + ) + + wo_obs_response_lengths = torch.tensor( + [item.sum().item() for item in value], + dtype=torch.float32, + device=device, + ) + log_dict["wo_obs_response_length/response_length_mean"] = wo_obs_response_lengths.mean().item() + log_dict["wo_obs_response_length/response_length_max"] = wo_obs_response_lengths.max().item() + log_dict["wo_obs_response_length/response_length_min"] = wo_obs_response_lengths.min().item() + if key == "round_number": + round_number_array = np.array(value) + log_dict["multi_turn_metric/round_number_mean"] = np.mean(round_number_array) + log_dict["multi_turn_metric/round_number_max"] = np.max(round_number_array) + log_dict["multi_turn_metric/round_number_min"] = np.min(round_number_array) + gather_log_data("multi_turn", args, rollout_id, log_dict) + + +def log_passrate(rollout_id: int, args: Namespace, rollout_data: RolloutBatch) -> None: + """Compute and report pass@k metrics from grouped ``raw_reward`` values.""" + from megatron.core import mpu + + if mpu.get_tensor_model_parallel_rank() != 0 or not mpu.is_pipeline_last_stage(): + return + + log_dict = {} + for key, value in rollout_data.items(): + if key == "raw_reward": + log_dict |= compute_pass_rate( + flat_rewards=value, + group_size=args.n_samples_per_prompt, + num_groups=args.rollout_batch_size, + ) + gather_log_data("passrate", args, rollout_id, log_dict) + + +def log_perf_data( + rollout_id: int, + args: Namespace, + extra_metrics: dict | None = None, +) -> None: + from megatron.core import mpu + + timer_instance = Timer() + log_dict_raw = deepcopy(timer_instance.log_dict()) + timer_instance.reset() + + if not ( + mpu.get_tensor_model_parallel_rank() == 0 + and mpu.is_pipeline_last_stage() + and mpu.get_data_parallel_rank(with_context_parallel=True) == 0 + ): + return + + log_dict = {f"perf/{key}_time": val for key, val in log_dict_raw.items()} + if extra_metrics: + log_dict.update(extra_metrics) + + if "perf/actor_train_time" in log_dict: + total_fwd_flops = ( + calculate_fwd_flops(seqlens=timer_instance.seq_lens, args=args) / dist.get_world_size() / 1e12 + ) + + if "perf/log_probs_time" in log_dict: + log_dict["perf/log_probs_tflops"] = total_fwd_flops / log_dict["perf/log_probs_time"] + + if "perf/ref_log_probs_time" in log_dict: + log_dict["perf/ref_log_probs_tflops"] = total_fwd_flops / log_dict["perf/ref_log_probs_time"] + + if log_dict["perf/actor_train_time"] > 0: + log_dict["perf/actor_train_tflops"] = 3 * total_fwd_flops / log_dict["perf/actor_train_time"] + log_dict["perf/actor_train_tok_per_s"] = sum(timer_instance.seq_lens) / log_dict["perf/actor_train_time"] + + if "perf/train_wait_time" in log_dict and "perf/train_time" in log_dict: + total_time = log_dict["perf/train_wait_time"] + log_dict["perf/train_time"] + if total_time > 0: + log_dict["perf/step_time"] = total_time + log_dict["perf/wait_time_ratio"] = log_dict["perf/train_wait_time"] / total_time + + logger.info(f"perf {rollout_id}: {log_dict}") + + step = compute_rollout_step(args, rollout_id) + log_dict["rollout/step"] = step + logging_utils.log(args, log_dict, step_key="rollout/step") diff --git a/slime/utils/wandb_utils.py b/slime/observability/wandb_utils.py similarity index 100% rename from slime/utils/wandb_utils.py rename to slime/observability/wandb_utils.py diff --git a/slime/ray/placement_group.py b/slime/ray/placement_group.py index eb652c3af9..2617b1f62a 100644 --- a/slime/ray/placement_group.py +++ b/slime/ray/placement_group.py @@ -187,7 +187,7 @@ def create_training_models(args, pgs, rollout_manager, actor_cls=None): actor_model, actor_start_rollout_ids = create_actor_model(args, pgs, rollout_manager, actor_cls=actor_cls) critic_model = None - if args.use_critic: + if args.use_critic and args.num_rollout != 0: from slime.utils.arguments import parse_megatron_role_args critic_args = ( @@ -208,7 +208,7 @@ def create_training_models(args, pgs, rollout_manager, actor_cls=None): critic_start_rollout_ids = critic_model.create(rollout_manager=rollout_manager) # TODO how to decide rollout start id when critic is involved? For now we just require user to specify it via args. - if args.use_critic: + if critic_model is not None: start_rollout_ids = critic_start_rollout_ids else: start_rollout_ids = actor_start_rollout_ids diff --git a/slime/ray/rollout.py b/slime/ray/rollout.py index abd8e35d70..246e828fb1 100644 --- a/slime/ray/rollout.py +++ b/slime/ray/rollout.py @@ -5,10 +5,8 @@ import os import random import time -from pathlib import Path from typing import Any -import numpy as np import ray import torch from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy @@ -17,19 +15,25 @@ from slime.backends.sglang_utils.external import start_external_rollout_servers from slime.backends.sglang_utils.sglang_config import ModelConfig, ServerGroupConfig, SglangConfig from slime.backends.sglang_utils.sglang_engine import SGLangEngine +from slime.observability import logging_utils +from slime.observability.logging_utils import configure_logger, init_tracking +from slime.observability.rollout_data_utils import ( + load_debug_rollout_data, + save_debug_rollout_data, + tensorize_rollout_data_for_training, + validate_rollout_id_annotated, + validate_rollout_routed_experts_for_replay, +) +from slime.observability.rollout_metrics import log_eval_rollout_data, log_rollout_data from slime.rollout.base_types import call_rollout_fn from slime.rollout.sample_hooks import set_current_rollout_id -from slime.utils import logging_utils from slime.utils.data import get_source from slime.utils.dp_schedule import build_dp_schedule from slime.utils.health_monitor import RolloutHealthMonitor from slime.utils.http_utils import _wrap_ipv6, find_available_port, get_host_info, init_http_client -from slime.utils.logging_utils import configure_logger, init_tracking -from slime.utils.metric_utils import compute_pass_rate, compute_rollout_step, compute_statistics, dict_add_prefix -from slime.utils.misc import Box, group_by, load_function +from slime.utils.misc import Box, load_function from slime.utils.types import Sample -from ..utils.metric_utils import has_repetition from .rollout_validation import validate_server_group_gpu_indices from .utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, Lock, add_default_ray_env_vars @@ -38,108 +42,6 @@ logger = logging.getLogger(__name__) -_ROLLOUT_DATA_TENSOR_DTYPES = { - "tokens": torch.long, - "loss_masks": torch.int, - "rollout_log_probs": torch.float32, - "rollout_top_p_token_ids": torch.int32, - "rollout_top_p_token_offsets": torch.int32, - "teacher_log_probs": torch.float32, - "rollout_routed_experts": None, -} - -_SGLANG_REQUEST_PERF_FIELDS = ( - ("request/e2e_latency", "e2e_latency"), - ("request/queue_time", "queue_time"), - ("decode/throughput", "decode_throughput"), -) -_SGLANG_PREFILL_PERF_FIELDS = ( - ("prefill/bootstrap_queue_duration", "pd_prefill_bootstrap_queue_duration"), - ("prefill/bootstrap_duration", "pd_prefill_bootstrap_duration"), - ("prefill/alloc_wait_duration", "pd_prefill_alloc_wait_duration"), - ("prefill/forward_duration", "pd_prefill_forward_duration"), - ("prefill/transfer_queue_duration", "pd_prefill_transfer_queue_duration"), - ("prefill/transfer_speed_gb_s", "pd_transfer_speed_gb_s"), - ("prefill/transfer_total_mb", "pd_transfer_total_mb"), - ("prefill/retry_count", "pd_prefill_retry_count"), -) -_SGLANG_DECODE_PERF_FIELDS = ( - ("decode/prealloc_duration", "pd_decode_prealloc_duration"), - ("decode/bootstrap_duration", "pd_decode_bootstrap_duration"), - ("decode/alloc_wait_duration", "pd_decode_alloc_wait_duration"), - ("decode/transfer_duration", "pd_decode_transfer_duration"), - ("decode/forward_duration", "pd_decode_forward_duration"), -) - - -def _cpu_tensor(value, dtype: torch.dtype | None = None) -> torch.Tensor: - if isinstance(value, np.ndarray) and not value.flags.writeable: - value = value.copy() - tensor = torch.as_tensor(value, dtype=dtype) if dtype is not None else torch.as_tensor(value) - return tensor.detach().cpu().contiguous() - - -def _tensorize_rollout_data_for_training(rollout_data: dict[str, Any]) -> None: - for key, dtype in _ROLLOUT_DATA_TENSOR_DTYPES.items(): - if key in rollout_data: - rollout_data[key] = [_cpu_tensor(value, dtype=dtype) for value in rollout_data[key]] - - if "multimodal_train_inputs" in rollout_data: - rollout_data["multimodal_train_inputs"] = [ - ( - { - key: _cpu_tensor(value) if isinstance(value, (np.ndarray, torch.Tensor)) else value - for key, value in mm_dict.items() - } - if mm_dict is not None - else None - ) - for mm_dict in rollout_data["multimodal_train_inputs"] - ] - - if "rollout_mask_sums" in rollout_data: - rollout_data["rollout_mask_sums"] = _cpu_tensor( - rollout_data["rollout_mask_sums"], - dtype=torch.float32, - ) - - -def _validate_rollout_routed_experts_for_replay( - routed_experts: list[torch.Tensor], - args, -) -> None: - """Reject incomplete PP routing captures before R3 consumes them.""" - if not routed_experts: - raise ValueError("R3 is enabled but no rollout routed-experts tensors were returned.") - - num_layers = int(args.num_layers) - topk = int(args.moe_router_topk) - moe_layer_freq = getattr(args, "moe_layer_freq", None) - if isinstance(moe_layer_freq, (list, tuple)): - moe_layers = [layer_id for layer_id, freq in enumerate(moe_layer_freq[:num_layers]) if int(freq) != 0] - else: - moe_layers = list(range(num_layers)) - - for sample_idx, experts in enumerate(routed_experts): - experts = torch.as_tensor(experts) - if experts.ndim != 3 or tuple(experts.shape[1:]) != (num_layers, topk): - raise ValueError( - "Invalid rollout routed-experts shape for R3: " - f"sample={sample_idx}, got={tuple(experts.shape)}, " - f"expected=(*, {num_layers}, {topk})." - ) - if experts.shape[0] == 0: - raise ValueError(f"R3 sample {sample_idx} has no routed-experts rows.") - if topk > 1: - missing_layers = [layer_id for layer_id in moe_layers if not torch.count_nonzero(experts[:, layer_id, :])] - if missing_layers: - raise ValueError( - "R3 routed-experts capture is all zero for MoE layers " - f"{missing_layers} in sample {sample_idx}. This usually means " - "SGLang pipeline stages did not aggregate their disjoint routing " - "captures; refusing to replay expert 0 everywhere." - ) - @dataclasses.dataclass class ServerGroup: @@ -595,8 +497,13 @@ def generate(self, rollout_id): if self.args.ci_test and self.args.use_fault_tolerance and rollout_id >= 2: self._try_ci_fault_injection() data, metrics = self._get_rollout_data(rollout_id=rollout_id) - self._save_debug_rollout_data(data, rollout_id=rollout_id, evaluation=False) - _log_rollout_data(rollout_id, self.args, data, metrics, time.time() - start_time) + save_debug_rollout_data( + self.args.save_debug_rollout_data, + data, + rollout_id=rollout_id, + evaluation=False, + ) + log_rollout_data(rollout_id, self.args, data, metrics, time.time() - start_time) if self.args.debug_rollout_only: # if debug rollout only, we don't convert samples to train data and directly return return @@ -612,8 +519,13 @@ def eval(self, rollout_id): result = call_rollout_fn(self.eval_generate_rollout, self.args, rollout_id, self.data_source, evaluation=True) data = result.data - self._save_debug_rollout_data(data, rollout_id=rollout_id, evaluation=True) - _log_eval_rollout_data(rollout_id, self.args, data, result.metrics) + save_debug_rollout_data( + self.args.save_debug_rollout_data, + data, + rollout_id=rollout_id, + evaluation=True, + ) + log_eval_rollout_data(rollout_id, self.args, data, result.metrics) def save(self, rollout_id): self.data_source.save(rollout_id) @@ -670,18 +582,11 @@ def check_weights(self, action: str): def _get_rollout_data(self, rollout_id): if self.args.load_debug_rollout_data: - data = torch.load( - self.args.load_debug_rollout_data.format(rollout_id=rollout_id), - weights_only=False, - )["samples"] - data = [Sample.from_dict(sample) for sample in data] - if (ratio := self.args.load_debug_rollout_data_subsample) is not None: - original_num_rows = len(data) - rough_subsample_num_rows = int(original_num_rows * ratio) - data = data[: rough_subsample_num_rows // 2] + data[-rough_subsample_num_rows // 2 :] - logger.info( - f"Subsample loaded debug rollout data using {ratio=} and change num rows {original_num_rows} -> {len(data)}" - ) + data = load_debug_rollout_data( + self.args.load_debug_rollout_data, + rollout_id=rollout_id, + subsample_ratio=self.args.load_debug_rollout_data_subsample, + ) metrics = None else: data = call_rollout_fn(self.generate_rollout, self.args, rollout_id, self.data_source, evaluation=False) @@ -693,32 +598,13 @@ def _get_rollout_data(self, rollout_id): # subagent paths that split one rollout into N training samples must # set the same rollout_id on every sibling so the loss reducer counts # the rollout once instead of N times. - _validate_rollout_id_annotated(data) + validate_rollout_id_annotated(data) # flatten the data if it is a list of lists while isinstance(data[0], list): data = list(itertools.chain.from_iterable(data)) return data, metrics - def _save_debug_rollout_data(self, data, rollout_id, evaluation: bool): - # TODO to be refactored (originally Buffer._set_data) - if (path_template := self.args.save_debug_rollout_data) is not None: - path = Path(path_template.format(rollout_id=("eval_" if evaluation else "") + str(rollout_id))) - logger.info(f"Save debug rollout data to {path}") - path.parent.mkdir(parents=True, exist_ok=True) - - # TODO may improve the format - if evaluation: - dump_data = dict( - samples=[sample.to_dict() for dataset_name, info in data.items() for sample in info["samples"]] - ) - else: - dump_data = dict( - samples=[sample.to_dict() for sample in data], - ) - - torch.save(dict(rollout_id=rollout_id, **dump_data), path) - def _post_process_rewards(self, samples: list[Sample] | list[list[Sample]]): if self.custom_reward_post_process_func is not None: return self.custom_reward_post_process_func(self.args, samples) @@ -848,7 +734,7 @@ def _convert_samples_to_train_data(self, samples: list[Sample] | list[list[Sampl if samples[0].rollout_routed_experts is not None: routed_experts = [torch.as_tensor(sample.rollout_routed_experts) for sample in samples] if getattr(self.args, "use_rollout_routing_replay", False): - _validate_rollout_routed_experts_for_replay(routed_experts, self.args) + validate_rollout_routed_experts_for_replay(routed_experts, self.args) train_data["rollout_routed_experts"] = routed_experts if samples[0].train_metadata is not None: @@ -927,7 +813,7 @@ def _split_train_data_by_dp(self, data): rollout_data["global_batch_sizes"] = global_batch_sizes rollout_data["num_microbatches"] = num_microbatches rollout_data["micro_batch_indices"] = micro_batch_indices[r] - _tensorize_rollout_data_for_training(rollout_data) + tensorize_rollout_data_for_training(rollout_data) transport = getattr(self.args, "rollout_data_transport", "object-store") if transport == "nixl": rollout_data_refs.append(Box(ray.put(rollout_data, _tensor_transport="nixl"))) @@ -938,38 +824,6 @@ def _split_train_data_by_dp(self, data): return rollout_data_refs -def _validate_rollout_id_annotated(node, depth=0): - """Walk the rollout function's nested output and validate ``rollout_id`` only - when a compact / subagent pattern is detected. - - "Compact" = the rollout function wraps multiple training samples from one - rollout execution into a ``list[Sample]``. In slime's convention the - default rollout shape is ``list[list[Sample]]`` (depth-2: prompt × rollout) - so its leaf ``list[Sample]`` lands at depth 1 and we skip validation, - preserving backward compatibility. A compact rollout adds a third level: - ``list[list[list[Sample]]]`` (prompt × rollout × samples-from-one-rollout), - so the leaf ``list[Sample]`` lands at depth ≥ 2. At that point we require - every sibling to carry a non-None ``rollout_id`` and to share the same - value, so the loss reducer counts the rollout once instead of N times. - """ - if isinstance(node, Sample): - return - assert isinstance(node, list), f"unexpected rollout output node type: {type(node).__name__}" - if node and isinstance(node[0], Sample): - if depth >= 2 and len(node) > 1: - rids = [s.rollout_id for s in node] - missing = [i for i, r in enumerate(rids) if r is None] - assert not missing, ( - f"Compact rollout returned {len(node)} samples but rollout_id is unset on " - f"positions {missing}. Set Sample.rollout_id on every sibling so the loss " - "reducer can aggregate them as one rollout instead of N." - ) - assert len(set(rids)) == 1, f"Sibling samples from one compact rollout must share rollout_id; got {rids}." - return - for item in node: - _validate_rollout_id_annotated(item, depth + 1) - - def _allocate_rollout_engine_addr_and_ports_normal( *, args, @@ -1096,7 +950,8 @@ def _start_router(args, *, has_pd_disaggregation: bool = False, force_new: bool router_args.disable_circuit_breaker = True # We will not use the health check from router. - router_args.disable_health_check = True + if hasattr(router_args, "disable_health_check"): + router_args.disable_health_check = True logger.info(f"Launch router with args: {router_args}") @@ -1296,233 +1151,3 @@ def _resolve_sglang_config(args) -> SglangConfig: ) ] ) - - -def _log_eval_rollout_data(rollout_id, args, data, extra_metrics: dict[str, Any] | None = None): - if args.custom_eval_rollout_log_function_path is not None: - custom_log_func = load_function(args.custom_eval_rollout_log_function_path) - if custom_log_func(rollout_id, args, data, extra_metrics): - return - - log_dict = extra_metrics or {} - for key in data.keys(): - rewards = data[key]["rewards"] - log_dict[f"eval/{key}"] = sum(rewards) / len(rewards) - if (samples := data[key].get("samples")) is not None: - log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), f"eval/{key}/") - if "truncated" in data[key]: - truncated = data[key]["truncated"] - log_dict[f"eval/{key}-truncated_ratio"] = sum(truncated) / len(truncated) - if args.log_passrate: - log_dict |= dict_add_prefix( - compute_pass_rate( - flat_rewards=rewards, - group_size=args.n_samples_per_eval_prompt, - ), - f"eval/{key}-", - ) - - logger.info(f"eval {rollout_id}: {log_dict}") - - step = compute_rollout_step(args, rollout_id) - log_dict["eval/step"] = step - logging_utils.log(args, log_dict, step_key="eval/step") - - return log_dict - - -def _log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_time): - if args.custom_rollout_log_function_path is not None: - custom_log_func = load_function(args.custom_rollout_log_function_path) - if custom_log_func(rollout_id, args, samples, rollout_extra_metrics, rollout_time): - return - - if args.load_debug_rollout_data: - return - - log_dict = {**(rollout_extra_metrics or {})} - log_dict |= dict_add_prefix(compute_metrics_from_samples(args, samples), "rollout/") - log_dict |= dict_add_prefix(compute_perf_metrics_from_samples(args, samples, rollout_time), "perf/") - logger.info(f"perf {rollout_id}: {log_dict}") - step = compute_rollout_step(args, rollout_id) - log_dict["rollout/step"] = step - logging_utils.log(args, log_dict, step_key="rollout/step") - - -def compute_metrics_from_samples(args, samples): - response_lengths = [sample.effective_response_length for sample in samples] - - log_dict = {} - log_dict |= dict_add_prefix(compute_statistics(response_lengths), "response_len/") - log_dict |= _compute_zero_std_metrics(args, samples) - log_dict |= _compute_spec_metrics(args, samples) - log_dict |= _compute_prefix_cache_metrics(args, samples) - log_dict |= _compute_reward_cat_metrics(args, samples) - log_dict |= _compute_top_p_kept_vocab_metrics(args, samples) - log_dict["repetition_frac"] = np.mean([int(has_repetition(s.response)) for s in samples]).item() - log_dict["truncated_ratio"] = np.mean([int(s.status == Sample.Status.TRUNCATED) for s in samples]).item() - return log_dict - - -def compute_perf_metrics_from_samples(args, samples, rollout_time): - non_generation_time = [sample.non_generation_time for sample in samples] - - log_dict = {} - log_dict["rollout_time"] = rollout_time - if max(non_generation_time) > 0: - log_dict |= dict_add_prefix(compute_statistics(non_generation_time), "non_generation_time/") - - def token_perf(response_lengths, non_generation_time, key=""): - max_response_length = max(response_lengths) - if args.rollout_num_gpus: - log_dict[f"{key}tokens_per_gpu_per_sec"] = sum(response_lengths) / rollout_time / args.rollout_num_gpus - log_dict[f"longest_{key}sample_tokens_per_sec"] = max_response_length / rollout_time - - if max(non_generation_time) == 0: - return - - non_generation_time = [ - t for t, length in zip(non_generation_time, response_lengths, strict=True) if length == max_response_length - ] - mean_non_generation_time = sum(non_generation_time) / len(non_generation_time) - - log_dict[f"longest_{key}sample_non_generation_time"] = mean_non_generation_time - log_dict[f"longest_{key}sample_tokens_per_sec_without_non_generation"] = max_response_length / ( - rollout_time - mean_non_generation_time - ) - - token_perf([sample.response_length for sample in samples], non_generation_time, key="") - token_perf([sample.effective_response_length for sample in samples], non_generation_time, key="effective_") - log_dict |= _compute_sglang_request_perf_metrics(samples) - - return log_dict - - -def _compute_sglang_request_perf_metrics(all_samples: list[Sample]): - attrs_by_request = list(_iter_sglang_generate_attrs(all_samples)) - if not attrs_by_request: - return {} - - values_by_metric: dict[str, list[float]] = {} - profiled_request_count = 0 - - def add_value(metric_key: str, source_key: str, attrs: dict) -> bool: - value = attrs.get(source_key) - if not isinstance(value, (int, float)) or isinstance(value, bool) or not np.isfinite(value): - return False - values_by_metric.setdefault(metric_key, []).append(float(value)) - return True - - for attrs in attrs_by_request: - request_has_perf = False - - for metric_key, source_key in _SGLANG_REQUEST_PERF_FIELDS: - request_has_perf |= add_value(metric_key, source_key, attrs) - - for metric_key, source_key in _SGLANG_PREFILL_PERF_FIELDS: - request_has_perf |= add_value(metric_key, source_key, attrs) - - for metric_key, source_key in _SGLANG_DECODE_PERF_FIELDS: - request_has_perf |= add_value(metric_key, source_key, attrs) - - if request_has_perf: - profiled_request_count += 1 - - metrics: dict[str, float] = {} - for key, values in values_by_metric.items(): - if not values: - continue - metrics |= dict_add_prefix(compute_statistics(values), f"{key}/") - - return metrics - - -def _iter_sglang_generate_attrs(all_samples: list[Sample]): - for sample in all_samples: - trace = getattr(sample, "trace", None) - if not isinstance(trace, dict): - continue - for event in trace.get("events") or []: - if event.get("type") != "span_end" or event.get("name") != "sglang_generate": - continue - attrs = event.get("attrs") - if isinstance(attrs, dict): - yield attrs - - -def _compute_zero_std_metrics(args, all_samples: list[Sample]): - # only compute in GRPO-like algorithms where one prompt has multiple responses - if args.advantage_estimator == "ppo": - return {} - - def _is_zero_std(samples: list[Sample]): - rewards = [sample.get_reward_value(args) for sample in samples] - return len(rewards) == 0 or all(rewards[0] == r for r in rewards) - - all_sample_groups = group_by(all_samples, lambda s: s.group_index) - interesting_sample_groups = [g for g in all_sample_groups.values() if _is_zero_std(g)] - - interesting_rewards = [str(round(g[0].get_reward_value(args), 1)) for g in interesting_sample_groups] - - return {f"zero_std/count_{reward}": len(items) for reward, items in group_by(interesting_rewards).items()} - - -def _compute_top_p_kept_vocab_metrics(args, all_samples: list[Sample]): - total_kept = 0 - total_tokens = 0 - for sample in all_samples: - offsets = sample.rollout_top_p_token_offsets - if offsets is None or sample.response_length == 0: - continue - offsets = torch.as_tensor(offsets, dtype=torch.int64) - if offsets.numel() == 0: - continue - assert ( - offsets.numel() == sample.response_length + 1 - ), f"top-p token offsets length {offsets.numel()} != response length + 1 {sample.response_length + 1}" - if sample.remove_sample: - continue - if sample.loss_mask is None: - total_kept += int(offsets[-1] - offsets[0]) - total_tokens += sample.response_length - continue - loss_mask = torch.as_tensor(sample.loss_mask, dtype=torch.bool, device=offsets.device) - assert ( - loss_mask.numel() == sample.response_length - ), f"loss mask length {loss_mask.numel()} != response length {sample.response_length}" - total_kept += int(torch.diff(offsets)[loss_mask].sum()) - total_tokens += int(loss_mask.sum()) - if total_tokens == 0: - return {} - return {"top_p_kept_vocab_per_token": total_kept / total_tokens} - - -def _compute_spec_metrics(args, all_samples: list[Sample]): - if getattr(args, "sglang_speculative_algorithm", None) is None: - return {} - num_samples = len(all_samples) - metrics = {} - metrics["spec_accept_rate"] = sum(sample.spec_info.spec_accept_rate for sample in all_samples) / num_samples - metrics["spec_accept_length"] = sum(sample.spec_info.spec_accept_length for sample in all_samples) / num_samples - return metrics - - -def _compute_prefix_cache_metrics(args, all_samples: list[Sample]): - num_samples = len(all_samples) - metrics = {} - total_cached_tokens = sum(sample.prefix_cache_info.cached_tokens for sample in all_samples) - total_prompt_tokens = sum(sample.prefix_cache_info.total_prompt_tokens for sample in all_samples) - - metrics["prefix_cache_hit_rate"] = total_cached_tokens / total_prompt_tokens if total_prompt_tokens > 0 else 0.0 - metrics["avg_cached_tokens_per_sample"] = total_cached_tokens / num_samples - return metrics - - -def _compute_reward_cat_metrics(args, all_samples: list[Sample]): - reward_cat_key = args.log_reward_category - if reward_cat_key is None: - return {} - - samples_of_reward_cat = group_by(all_samples, lambda s: s.reward[reward_cat_key]) - - return {f"error_cat/{reward_cat}": len(s) / len(all_samples) for reward_cat, s in samples_of_reward_cat.items()} diff --git a/slime/ray/train_actor.py b/slime/ray/train_actor.py index a8ba6ddc64..42fb0ee0e3 100644 --- a/slime/ray/train_actor.py +++ b/slime/ray/train_actor.py @@ -9,20 +9,17 @@ import torch.distributed as dist import slime.utils.eval_config +from slime.observability.logging_utils import configure_logger from slime.ray.ray_actor import RayActor +from slime.utils import accelerator from slime.utils.distributed_utils import init_gloo_group -from slime.utils.logging_utils import configure_logger from slime.utils.memory_utils import clear_memory, print_memory logger = logging.getLogger(__name__) def get_local_gpu_id(): - cvd = os.environ.get("CUDA_VISIBLE_DEVICES", None) - if cvd is None: - return ray.get_gpu_ids()[0] - else: - return cvd.split(",").index(str(ray.get_gpu_ids()[0])) + return accelerator.resolve_visible_device_id(ray.get_gpu_ids()[0]) class TrainRayActor(RayActor): @@ -56,9 +53,14 @@ def init(self, args, role, with_ref=False, with_opd_teacher=False): torch.serialization.add_safe_globals([slime.utils.eval_config.EvalDatasetConfig]) local_rank = int(os.environ.get("LOCAL_RANK", 0)) - torch.cuda.set_device(f"cuda:{local_rank}") + accelerator.set_device(local_rank) + if accelerator.set_allocator_expandable_segments(): + logger.info( + f"[Rank {self._rank}] Enabled {accelerator.device_type().upper()} memory allocator " + "expandable_segments for train actor" + ) - backend = args.distributed_backend + backend = accelerator.process_group_backend(args.distributed_backend) dist.init_process_group( backend=backend, diff --git a/slime/ray/utils.py b/slime/ray/utils.py index decdbdec2e..2575a96f04 100644 --- a/slime/ray/utils.py +++ b/slime/ray/utils.py @@ -2,8 +2,9 @@ import os import ray -import torch + from slime.ray.ray_actor import RayActor +from slime.utils import accelerator # Refer to # https://github.com/ray-project/ray/blob/161849364a784442cc659fb9780f1a6adee85fce/python/ray/_private/accelerators/nvidia_gpu.py#L95-L96 @@ -15,6 +16,7 @@ # https://github.com/ray-project/ray/blob/161849364a784442cc659fb9780f1a6adee85fce/python/ray/_private/accelerators/intel_gpu.py#L97-L98 NOSET_VISIBLE_DEVICES_ENV_VARS_LIST = [ "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_MUSA_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES", "RAY_EXPERIMENTAL_NOSET_HABANA_VISIBLE_MODULES", @@ -47,9 +49,9 @@ def ray_noset_visible_devices(env_vars=os.environ): def get_physical_gpu_id(): - device = torch.cuda.current_device() - props = torch.cuda.get_device_properties(device) - return str(props.uuid) + device = accelerator.current_device() + props = accelerator.get_device_properties(device) + return str(getattr(props, "uuid", device)) @ray.remote diff --git a/slime/rollout/_fanout_test_helpers.py b/slime/rollout/_fanout_test_helpers.py index e065dd13dd..d989972c2d 100644 --- a/slime/rollout/_fanout_test_helpers.py +++ b/slime/rollout/_fanout_test_helpers.py @@ -12,7 +12,7 @@ - ``compact_generate``: fans one input sample out to N siblings sharing the same ``rollout_id``. That's the contract the rest of the framework (per-rollout step splitter, per-rollout-mean reducer, - ``_validate_rollout_id_annotated`` validator) is built around. + ``validate_rollout_id_annotated`` validator) is built around. - ``grpo_normalize_by_group_index``: replaces the default ``_post_process_rewards`` reshape-by-shape logic with a proper diff --git a/slime/rollout/on_policy_distillation.py b/slime/rollout/on_policy_distillation.py index 9190974345..eb52a0821d 100644 --- a/slime/rollout/on_policy_distillation.py +++ b/slime/rollout/on_policy_distillation.py @@ -10,7 +10,7 @@ async def reward_func(args, sample, **kwargs): # "text": sample.prompt + sample.response, "input_ids": sample.tokens, "sampling_params": { - "temperature": 0, + "temperature": args.rollout_temperature, "max_new_tokens": 0, "skip_special_tokens": False, }, diff --git a/slime/rollout/sglang_rollout.py b/slime/rollout/sglang_rollout.py index 7615f20e7a..2cbcb89662 100644 --- a/slime/rollout/sglang_rollout.py +++ b/slime/rollout/sglang_rollout.py @@ -13,6 +13,7 @@ from tqdm import tqdm from slime.backends.sglang_utils.server_control import abort_servers_until_idle +from slime.observability.trace_utils import build_sglang_meta_trace_attrs, trace_function, trace_span from slime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput from slime.rollout.filter_hub.base_types import MetricGatherer, call_dynamic_filter, should_drop_dynamic_filter_output from slime.rollout.sample_hooks import apply_rollout_sample_hooks @@ -27,7 +28,6 @@ load_processor, load_tokenizer, ) -from slime.utils.trace_utils import build_sglang_meta_trace_attrs, trace_function, trace_span from slime.utils.types import Sample from .rm_hub import async_rm, batched_async_rm diff --git a/slime/rollout/sglang_streaming_rollout.py b/slime/rollout/sglang_streaming_rollout.py index 195da071bd..bcc6d176fb 100644 --- a/slime/rollout/sglang_streaming_rollout.py +++ b/slime/rollout/sglang_streaming_rollout.py @@ -29,10 +29,10 @@ from argparse import Namespace from typing import Any +from slime.observability.trace_utils import build_sglang_meta_trace_attrs, trace_span from slime.rollout.sglang_rollout import GenerateState, _prepare_prompt_ids from slime.utils import http_utils from slime.utils.processing_utils import encode_image_for_rollout_engine -from slime.utils.trace_utils import build_sglang_meta_trace_attrs, trace_span from slime.utils.types import Sample __all__ = ["generate_streaming"] diff --git a/slime/utils/accelerator/__init__.py b/slime/utils/accelerator/__init__.py new file mode 100644 index 0000000000..4ece4d01c0 --- /dev/null +++ b/slime/utils/accelerator/__init__.py @@ -0,0 +1,394 @@ +"""Runtime-selectable, backend-neutral accelerator API for Slime. + +The module-level functions are compatibility shims for the historical +``slime.utils.accelerator`` API. New code can use :func:`get_accelerator` +when it needs capability inspection or dependency injection. +""" + +from __future__ import annotations + +import importlib +import logging +import os +import sys +import threading +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import torch + +from .base import Accelerator +from .cuda import CUDAAccelerator +from .musa import MUSAAccelerator +from .musa import is_musa_available as _is_musa_available + +logger = logging.getLogger(__name__) + +_MUSA_PATCH_IMPORTED = False +_MUSA_BOOTSTRAP_CHECKED = False +_ACCELERATOR: Accelerator | None = None +_SELECTION_LOCK = threading.RLock() + + +@dataclass(frozen=True) +class _BackendRegistration: + factory: Callable[[], Accelerator] + is_available: Callable[[], bool] + priority: int + communication_backends: tuple[str, ...] + + +_REGISTRY: dict[str, _BackendRegistration] = {} + + +def register_accelerator( + name: str, + factory: Callable[[], Accelerator], + is_available: Callable[[], bool] | None = None, + priority: int = 0, + communication_backends: tuple[str, ...] = (), +) -> None: + """Register a lazily constructed backend without changing Slime core.""" + normalized = name.strip().lower() + if not normalized or normalized in {"auto", "none"}: + raise ValueError("Accelerator name must be a non-empty backend name") + with _SELECTION_LOCK: + _REGISTRY[normalized] = _BackendRegistration( + factory=factory, + is_available=is_available or (lambda: factory().is_available()), + priority=priority, + communication_backends=tuple(name.lower() for name in communication_backends), + ) + + +def _append_musa_patch_path() -> None: + patch_path = os.environ.get("MUSA_PATCH_PATH") + if patch_path and patch_path not in sys.path: + sys.path.append(patch_path) + + +def _import_musa_patch() -> bool: + _append_musa_patch_path() + try: + importlib.import_module("musa_patch") + except ModuleNotFoundError as exc: + if exc.name == "musa_patch": + return False + raise RuntimeError(f"musa_patch failed because dependency {exc.name!r} is missing") from exc + except Exception as exc: + raise RuntimeError(f"musa_patch initialization failed: {exc}") from exc + return True + + +def is_musa_available() -> bool: + return _is_musa_available() + + +def is_musa_environment() -> bool: + return ( + is_musa_available() + or os.environ.get("SLIME_ACCELERATOR", "").lower() == "musa" + or "MUSA_VISIBLE_DEVICES" in os.environ + or bool(os.environ.get("MUSA_PATCH_PATH")) + ) + + +def _try_import_musa_patch() -> bool: + global _MUSA_PATCH_IMPORTED + if _MUSA_PATCH_IMPORTED: + return True + if not is_musa_environment(): + return False + _MUSA_PATCH_IMPORTED = _import_musa_patch() + if not _MUSA_PATCH_IMPORTED and is_musa_environment(): + logger.warning("musa_patch is not importable; continuing without it") + return _MUSA_PATCH_IMPORTED + + +def _musa_requested() -> bool: + configured = os.environ.get("SLIME_ACCELERATOR", "").lower() + if configured and configured != "auto": + return configured == "musa" + return "MUSA_VISIBLE_DEVICES" in os.environ or bool(os.environ.get("MUSA_PATCH_PATH")) + + +def _bootstrap_musa_patch_if_needed() -> bool: + """Bootstrap the patch for an already chosen MUSA backend at most once.""" + global _MUSA_BOOTSTRAP_CHECKED + if _MUSA_BOOTSTRAP_CHECKED: + return _MUSA_PATCH_IMPORTED + _MUSA_BOOTSTRAP_CHECKED = True + return _try_import_musa_patch() + + +def _cuda_available() -> bool: + try: + return bool(torch.cuda.is_available() and torch.cuda.device_count() > 0) + except (ImportError, RuntimeError): + return False + + +def _register_builtin_backends() -> None: + if "cuda" not in _REGISTRY: + register_accelerator("cuda", CUDAAccelerator, _cuda_available, priority=100, communication_backends=("nccl",)) + if "musa" not in _REGISTRY: + register_accelerator( + "musa", MUSAAccelerator, is_musa_available, priority=200, communication_backends=("mccl",) + ) + + +def _requested_name() -> str | None: + value = os.environ.get("SLIME_ACCELERATOR") + if value and value.lower() != "auto": + return value.strip().lower() + if _musa_requested(): + return "musa" + return None + + +def _make_selected(name: str, explicit: bool) -> Accelerator: + _register_builtin_backends() + entry = _REGISTRY.get(name) + if entry is None: + available = ", ".join(sorted(_REGISTRY)) + raise ValueError(f"Unknown accelerator {name!r}; registered backends: {available}") + if name == "musa": + # musa_patch may expose torch.musa, so bootstrap after MUSA has been + # chosen but before validating and constructing its backend. + _bootstrap_musa_patch_if_needed() + if explicit and not entry.is_available(): + if name == "musa": + detail = ( + "torch.musa is unavailable; install a MUSA-enabled PyTorch runtime and set MUSA_PATCH_PATH if required" + ) + elif name == "cuda": + detail = "torch.cuda.is_available() is false or no CUDA device is visible" + else: + detail = "the backend availability check returned false" + raise RuntimeError(f"Requested accelerator {name!r} is unavailable: {detail}") + backend = entry.factory() + if not backend.is_available(): + raise RuntimeError(f"Accelerator backend {name!r} was selected but is unavailable at runtime") + return backend + + +def get_accelerator() -> Accelerator: + global _ACCELERATOR + if _ACCELERATOR is not None: + return _ACCELERATOR + with _SELECTION_LOCK: + if _ACCELERATOR is not None: + return _ACCELERATOR + _register_builtin_backends() + requested = _requested_name() + if requested is not None: + _ACCELERATOR = _make_selected(requested, explicit=True) + logger.info("Selected accelerator %s (explicit)", _ACCELERATOR.name) + return _ACCELERATOR + + # Highest priority wins; names break priority ties deterministically. + candidates = sorted(_REGISTRY.items(), key=lambda item: (-item[1].priority, item[0])) + for name, registration in candidates: + if registration.is_available(): + _ACCELERATOR = _make_selected(name, explicit=False) + logger.info("Selected accelerator %s (auto)", _ACCELERATOR.name) + return _ACCELERATOR + registered = ", ".join(sorted(_REGISTRY)) + raise RuntimeError( + "No usable accelerator was detected. " + f"Registered backends: {registered}. " + "Set SLIME_ACCELERATOR explicitly or install a supported accelerator runtime." + ) + + +def initialize_accelerator() -> Accelerator | None: + """Finalize runtime selection when a backend is requested or available. + + Explicit requests retain ``get_accelerator``'s fail-fast behavior. An + environment without accelerator hardware remains importable for CPU-only + tooling and documentation. + """ + if _ACCELERATOR is not None: + return _ACCELERATOR + with _SELECTION_LOCK: + _register_builtin_backends() + if _requested_name() is not None or any(entry.is_available() for entry in _REGISTRY.values()): + return get_accelerator() + return None + + +def set_accelerator(accelerator: Accelerator) -> None: + global _ACCELERATOR + if not isinstance(accelerator, Accelerator): + raise TypeError(f"Expected Accelerator, got {type(accelerator).__name__}") + if not accelerator.is_available(): + raise RuntimeError(f"Cannot install unavailable accelerator backend {accelerator.name!r}") + with _SELECTION_LOCK: + _ACCELERATOR = accelerator + + +def reset_accelerator() -> None: + """Reset the singleton; intended for tests and process initialization.""" + global _ACCELERATOR + with _SELECTION_LOCK: + _ACCELERATOR = None + + +def _backend() -> Accelerator: + return get_accelerator() + + +def device_type() -> str: + return _backend().device_type + + +def accelerator_module() -> Any: + return _backend().accelerator_module() + + +def device(index: int | str | torch.device | None = None) -> torch.device: + return _backend().device(index) + + +def device_name(index: int | str | torch.device | None = None) -> str: + return _backend().device_name(index) + + +def set_device(index: int | str | torch.device) -> None: + return _backend().set_device(index) + + +def current_device() -> int | str: + return _backend().current_device() + + +def device_count() -> int: + return _backend().device_count() + + +def synchronize(device_arg: int | str | torch.device | None = None) -> None: + return _backend().synchronize(device_arg) + + +def current_stream(device_arg: int | str | torch.device | None = None) -> Any: + return _backend().current_stream(device_arg) + + +def default_stream(device_arg: int | str | torch.device | None = None) -> Any: + return _backend().default_stream(device_arg) + + +def stream(stream_arg: Any): + return _backend().stream(stream_arg) + + +def new_stream(*args, **kwargs) -> Any: + stream_type = _backend().Stream + if stream_type is None: + raise NotImplementedError(f"Accelerator {_backend().name!r} does not support streams") + return stream_type(*args, **kwargs) + + +def new_event(*args, **kwargs) -> Any: + event_type = _backend().Event + if event_type is None: + raise NotImplementedError(f"Accelerator {_backend().name!r} does not support events") + return event_type(*args, **kwargs) + + +def empty_cache() -> None: + return _backend().empty_cache() + + +def ipc_collect() -> None: + return _backend().ipc_collect() + + +def set_allocator_expandable_segments() -> bool: + return _backend().set_allocator_expandable_segments() + + +def mem_get_info(device_arg: int | str | torch.device | None = None) -> tuple[int, int]: + return _backend().mem_get_info(device_arg) + + +def memory_allocated(device_arg: int | str | torch.device | None = None) -> int: + return _backend().memory_allocated(device_arg) + + +def memory_reserved(device_arg: int | str | torch.device | None = None) -> int: + return _backend().memory_reserved(device_arg) + + +def get_device_properties(device_arg: int | str | torch.device | None = None) -> Any: + return _backend().get_device_properties(device_arg) + + +def memory_module() -> Any: + return _backend().memory_module() + + +def attach_oom_observer(callback) -> bool: + return _backend().attach_oom_observer(callback) + + +def supports(capability: str) -> bool: + return _backend().supports(capability) + + +def autocast(*args, **kwargs): + return _backend().autocast(*args, **kwargs) + + +def manual_seed(seed: int) -> None: + return _backend().manual_seed(seed) + + +def manual_seed_all(seed: int) -> None: + return _backend().manual_seed_all(seed) + + +def get_rng_state(device_arg: int | str | torch.device | None = None) -> torch.Tensor: + return _backend().get_rng_state(device_arg) + + +def set_rng_state(state: torch.Tensor, device_arg: int | str | torch.device | None = None) -> None: + return _backend().set_rng_state(state, device_arg) + + +def initial_seed() -> int: + return _backend().initial_seed() + + +def distributed_device_id(index: int | str | torch.device | None = None) -> torch.device | None: + return _backend().distributed_device_id(index) + + +def post_import_torch() -> None: + return _backend().post_import_torch() + + +def is_accelerator_backend(backend: str) -> bool: + """Return whether a distributed backend belongs to a registered device accelerator.""" + _register_builtin_backends() + normalized = backend.lower() + return any( + name in normalized for registration in _REGISTRY.values() for name in registration.communication_backends + ) + + +def process_group_backend(default: str = "nccl") -> str: + return _backend().communication_backend(default) + + +def weight_update_backend(default: str = "nccl") -> str: + return _backend().weight_update_backend(default) + + +def visible_devices_env_key() -> str: + return _backend().visible_devices_env + + +def resolve_visible_device_id(physical_device_id: int | float | str) -> int: + return _backend().resolve_visible_device_id(physical_device_id) diff --git a/slime/utils/accelerator/base.py b/slime/utils/accelerator/base.py new file mode 100644 index 0000000000..9d760c619e --- /dev/null +++ b/slime/utils/accelerator/base.py @@ -0,0 +1,179 @@ +"""Small, backend-neutral accelerator contract used by Slime. + +The contract intentionally contains only operations that Slime uses in its +runtime. Vendor modules are supplied by concrete implementations and are +never imported by this module. +""" + +from __future__ import annotations + +import abc +import os +from typing import Any + +import torch + + +class Accelerator(abc.ABC): + """Common device/runtime surface exposed to Slime code.""" + + name: str + device_type: str + communication_backend_name: str + + @abc.abstractmethod + def is_available(self) -> bool: + """Return whether this backend can actually execute on this host.""" + + @abc.abstractmethod + def device(self, index: int | str | torch.device | None = None) -> torch.device: + """Return a :class:`torch.device` for a local device index.""" + + @abc.abstractmethod + def device_name(self, index: int | str | torch.device | None = None) -> str: + """Return the canonical device string used by PyTorch APIs.""" + + @abc.abstractmethod + def set_device(self, index: int | str | torch.device) -> None: + """Select the current local device.""" + + @abc.abstractmethod + def current_device(self) -> int | str: + """Return the current local device index, or ``cpu`` for CPU.""" + + @abc.abstractmethod + def device_count(self) -> int: + """Return the number of visible devices.""" + + @abc.abstractmethod + def synchronize(self, device: int | str | torch.device | None = None) -> None: + """Synchronize work on one device or the current device.""" + + @abc.abstractmethod + def current_stream(self, device: int | str | torch.device | None = None) -> Any: + """Return the current stream, or ``None`` when streams are unsupported.""" + + def default_stream(self, device: int | str | torch.device | None = None) -> Any: + """Return the default stream, or ``None`` when streams are unsupported.""" + return None + + def stream(self, stream: Any): + """Return a context manager for a stream when the backend supports it.""" + raise NotImplementedError(f"Accelerator {self.name!r} does not support streams") + + @property + def Stream(self) -> Any: + return None + + @property + def Event(self) -> Any: + return None + + @abc.abstractmethod + def empty_cache(self) -> None: + """Release allocator-held, currently unused memory.""" + + def ipc_collect(self) -> None: + """Collect inter-process allocator state when supported.""" + return None + + def set_allocator_expandable_segments(self) -> bool: + """Configure expandable allocator segments when supported.""" + return False + + @abc.abstractmethod + def mem_get_info(self, device: int | str | torch.device | None = None) -> tuple[int, int]: + """Return ``(free_bytes, total_bytes)`` for the selected device.""" + + @abc.abstractmethod + def memory_allocated(self, device: int | str | torch.device | None = None) -> int: + """Return currently allocated device memory in bytes.""" + + @abc.abstractmethod + def memory_reserved(self, device: int | str | torch.device | None = None) -> int: + """Return allocator-reserved device memory in bytes.""" + + def get_device_properties(self, device: int | str | torch.device | None = None) -> Any: + return None + + def memory_module(self) -> Any: + """Return the backend memory namespace, if it exposes one.""" + return None + + def attach_oom_observer(self, callback) -> bool: + """Attach an OOM callback; return ``False`` when unsupported.""" + return False + + def supports(self, capability: str) -> bool: + """Return whether a named optional capability is implemented.""" + return False + + def autocast(self, *args, **kwargs): + """Return an autocast context for this backend.""" + raise NotImplementedError(f"Accelerator {self.name!r} does not support autocast") + + def manual_seed(self, seed: int) -> None: + """Seed the current device generator when supported.""" + return None + + def manual_seed_all(self, seed: int) -> None: + """Seed all device generators when supported.""" + return None + + def get_rng_state(self, device: int | str | torch.device | None = None) -> torch.Tensor: + """Return the current generator state.""" + return torch.get_rng_state() + + def set_rng_state(self, state: torch.Tensor, device: int | str | torch.device | None = None) -> None: + """Restore the current generator state.""" + torch.set_rng_state(state) + + def initial_seed(self) -> int: + return int(torch.initial_seed()) + + def distributed_device_id(self, index: int | str | torch.device | None = None) -> torch.device | None: + """Return the device id accepted by ``dist.init_process_group``.""" + return self.device(index) + + def post_import_torch(self) -> None: + """Apply an optional backend hook after third-party torch imports.""" + return None + + def communication_backend(self, default: str = "nccl") -> str: + """Map a logical default backend to this accelerator's transport.""" + return self.communication_backend_name if default == "nccl" else default + + def weight_update_backend(self, default: str = "nccl") -> str: + return self.communication_backend(default) + + @property + def visible_devices_env(self) -> str: + return "CUDA_VISIBLE_DEVICES" + + def resolve_visible_device_id(self, physical_device_id: int | float | str) -> int: + """Map a physical id to a local id under this backend's visibility env.""" + raw_value = str(physical_device_id).strip() + visible = os.environ.get(self.visible_devices_env) + if not visible: + return int(float(raw_value)) + + ids = [item.strip() for item in visible.split(",") if item.strip()] + if raw_value in ids: + return ids.index(raw_value) + + try: + value = int(float(raw_value)) + except ValueError: + value = None + if value is not None and str(value) in ids: + return ids.index(str(value)) + if value is not None and 0 <= value < len(ids): + return value + raise RuntimeError( + f"Device id {raw_value} is not valid under {self.visible_devices_env}={visible}. " + f"Expected one of {ids} (physical) or 0..{len(ids) - 1} (local)." + ) + + def accelerator_module(self) -> Any: + """Return the torch backend namespace, or ``None`` for CPU.""" + return None diff --git a/slime/utils/accelerator/cuda.py b/slime/utils/accelerator/cuda.py new file mode 100644 index 0000000000..c9fca2afe0 --- /dev/null +++ b/slime/utils/accelerator/cuda.py @@ -0,0 +1,40 @@ +"""CUDA/ROCm accelerator implementation.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from .torch_accelerator import TorchAccelerator + + +class CUDAAccelerator(TorchAccelerator): + name = "cuda" + device_type = "cuda" + communication_backend_name = "nccl" + + def _module(self) -> Any: + return torch.cuda + + def is_available(self) -> bool: + return bool(torch.cuda.is_available() and torch.cuda.device_count() > 0) + + def attach_oom_observer(self, callback) -> bool: + attach = getattr(torch._C, "_cuda_attach_out_of_memory_observer", None) + if attach is None: + return False + attach(callback) + return True + + def supports(self, capability: str) -> bool: + if capability == "nvml_affinity": + return torch.version.hip is None + if capability == "bf16": + checker = getattr(torch.cuda, "is_bf16_supported", None) + return bool(checker and checker()) + if capability in {"cuda_int4_extension", "sglang_fp8_utils", "strict_fp32_logits", "triton_kernels"}: + return True + if capability == "requires_cpu_initialization": + return torch.version.hip is not None + return super().supports(capability) diff --git a/slime/utils/accelerator/musa.py b/slime/utils/accelerator/musa.py new file mode 100644 index 0000000000..62b398dfaa --- /dev/null +++ b/slime/utils/accelerator/musa.py @@ -0,0 +1,88 @@ +"""MUSA accelerator implementation. + +Importing this module never imports ``torch_musa``. A MUSA runtime or the +optional ``musa_patch`` bootstrap may attach ``torch.musa`` before selection. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +import torch + +from .torch_accelerator import TorchAccelerator + + +def musa_module() -> Any: + return getattr(torch, "musa", None) + + +def is_musa_available() -> bool: + module = musa_module() + checker = getattr(module, "is_available", None) + return bool(module is not None and checker is not None and checker()) + + +class MUSAAccelerator(TorchAccelerator): + name = "musa" + device_type = "musa" + communication_backend_name = "mccl" + + @property + def visible_devices_env(self) -> str: + return "MUSA_VISIBLE_DEVICES" + + def _module(self) -> Any: + module = musa_module() + if module is None: + raise RuntimeError("MUSA backend requires a runtime that exposes torch.musa") + return module + + def is_available(self) -> bool: + return is_musa_available() + + def weight_update_backend(self, default: str = "nccl") -> str: + return "cpu:gloo,musa:mccl" if default == "nccl" else default + + def distributed_device_id(self, index: int | str | torch.device | None = None) -> None: + return None + + def post_import_torch(self) -> None: + try: + module = importlib.import_module("musa_patch") + except ModuleNotFoundError as exc: + if exc.name == "musa_patch": + return + raise RuntimeError(f"musa_patch failed because dependency {exc.name!r} is missing") from exc + callback = getattr(module, "patch_after_import_torch", None) + if callback is not None: + callback() + + def attach_oom_observer(self, callback) -> bool: + musa_c = getattr(self._module(), "_MUSAC", None) + attach = getattr(musa_c, "_musa_attach_out_of_memory_observer", None) + if attach is None: + return False + attach(callback) + return True + + def autocast(self, *args, **kwargs): + amp = getattr(self._module(), "amp", None) + autocast = getattr(amp, "autocast", None) + if autocast is None: + raise NotImplementedError("MUSA runtime does not expose torch.musa.amp.autocast") + return autocast(*args, **kwargs) + + def supports(self, capability: str) -> bool: + if capability in {"nvml_affinity", "sglang_fp8_utils", "strict_fp32_logits"}: + return False + if capability == "requires_cpu_initialization": + return True + if capability == "amp": + amp = getattr(self._module(), "amp", None) + return callable(getattr(amp, "autocast", None)) + if capability == "bf16": + checker = getattr(self._module(), "is_bf16_supported", None) + return bool(checker and checker()) + return super().supports(capability) diff --git a/slime/utils/accelerator/torch_accelerator.py b/slime/utils/accelerator/torch_accelerator.py new file mode 100644 index 0000000000..d961ebe3d8 --- /dev/null +++ b/slime/utils/accelerator/torch_accelerator.py @@ -0,0 +1,163 @@ +"""Shared adapter for PyTorch accelerator namespaces.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import torch + +from .base import Accelerator + +logger = logging.getLogger(__name__) + + +class TorchAccelerator(Accelerator): + """Delegate common CUDA-like APIs to a vendor torch namespace.""" + + def _module(self) -> Any: + raise NotImplementedError + + def accelerator_module(self) -> Any: + return self._module() + + def is_available(self) -> bool: + module = self._module() + checker = getattr(module, "is_available", None) + return bool(module is not None and checker is not None and checker()) + + def device(self, index: int | str | torch.device | None = None) -> torch.device: + return torch.device(self.device_name(index)) + + def device_name(self, index: int | str | torch.device | None = None) -> str: + if isinstance(index, torch.device): + return str(index) + if isinstance(index, str): + return index if ":" in index else f"{self.device_type}:{index}" + if index is None: + index = self.current_device() + return f"{self.device_type}:{index}" + + def set_device(self, index: int | str | torch.device) -> None: + self._module().set_device(index) + + def current_device(self) -> int: + return int(self._module().current_device()) + + def device_count(self) -> int: + return int(self._module().device_count()) + + def synchronize(self, device: int | str | torch.device | None = None) -> None: + if device is None: + self._module().synchronize() + else: + self._module().synchronize(device) + + def current_stream(self, device: int | str | torch.device | None = None) -> Any: + if device is None: + return self._module().current_stream() + return self._module().current_stream(device) + + def default_stream(self, device: int | str | torch.device | None = None) -> Any: + default_stream = getattr(self._module(), "default_stream", None) + if default_stream is None: + raise NotImplementedError(f"Accelerator {self.name!r} does not expose a default stream") + if device is None: + return default_stream() + return default_stream(device) + + def stream(self, stream: Any): + stream_context = getattr(self._module(), "stream", None) + if stream_context is None: + raise NotImplementedError(f"Accelerator {self.name!r} does not expose stream contexts") + return stream_context(stream) + + @property + def Stream(self) -> Any: + return getattr(self._module(), "Stream", None) + + @property + def Event(self) -> Any: + return getattr(self._module(), "Event", None) + + def empty_cache(self) -> None: + self._module().empty_cache() + + def ipc_collect(self) -> None: + collect = getattr(self._module(), "ipc_collect", None) + if collect is not None: + collect() + + def set_allocator_expandable_segments(self) -> bool: + value = os.getenv("SLIME_ENABLE_EXPANDABLE_SEGMENTS", "0") + if value not in {"0", "1"}: + raise ValueError(f"SLIME_ENABLE_EXPANDABLE_SEGMENTS must be 0 or 1, got {value!r}") + if value == "0": + return False + + memory = self.memory_module() + setter = getattr(memory, "_set_allocator_settings", None) + if setter is None: + logger.warning( + "%s memory allocator settings API is unavailable; skip expandable_segments:True", + self.name.upper(), + ) + return False + setter("expandable_segments:True") + return True + + def mem_get_info(self, device: int | str | torch.device | None = None) -> tuple[int, int]: + if device is None: + device = self.current_device() + free, total = self._module().mem_get_info(device) + return int(free), int(total) + + def memory_allocated(self, device: int | str | torch.device | None = None) -> int: + return int(self._module().memory_allocated(device)) + + def memory_reserved(self, device: int | str | torch.device | None = None) -> int: + return int(self._module().memory_reserved(device)) + + def get_device_properties(self, device: int | str | torch.device | None = None) -> Any: + if device is None: + device = self.current_device() + return self._module().get_device_properties(device) + + def memory_module(self) -> Any: + return getattr(self._module(), "memory", None) + + def autocast(self, *args, **kwargs): + return torch.autocast(self.device_type, *args, **kwargs) + + def manual_seed(self, seed: int) -> None: + self._module().manual_seed(seed) + + def manual_seed_all(self, seed: int) -> None: + self._module().manual_seed_all(seed) + + def get_rng_state(self, device: int | str | torch.device | None = None) -> torch.Tensor: + if device is None: + return self._module().get_rng_state() + return self._module().get_rng_state(device) + + def set_rng_state(self, state: torch.Tensor, device: int | str | torch.device | None = None) -> None: + if device is None: + self._module().set_rng_state(state) + else: + self._module().set_rng_state(state, device) + + def initial_seed(self) -> int: + return int(self._module().initial_seed()) + + def supports(self, capability: str) -> bool: + module = self._module() + if capability == "device_memory": + return all(hasattr(module, name) for name in ("empty_cache", "mem_get_info", "memory_allocated")) + if capability == "events": + return hasattr(module, "Event") + if capability == "rng": + return all(hasattr(module, name) for name in ("get_rng_state", "set_rng_state", "manual_seed")) + if capability == "streams": + return all(hasattr(module, name) for name in ("Stream", "current_stream", "stream")) + return capability in {"amp", "fp16"} diff --git a/slime/utils/arguments.py b/slime/utils/arguments.py index 2ef7dad59b..33381cf0a3 100644 --- a/slime/utils/arguments.py +++ b/slime/utils/arguments.py @@ -10,8 +10,8 @@ from slime.backends.sglang_utils.arguments import sglang_parse_args from slime.backends.sglang_utils.arguments import validate_args as sglang_validate_args from slime.backends.sglang_utils.external import apply_external_engine_info_to_args +from slime.observability.logging_utils import configure_logger from slime.utils.eval_config import EvalDatasetConfig, build_eval_dataset_configs, ensure_dataset_list -from slime.utils.logging_utils import configure_logger logger = logging.getLogger(__name__) @@ -343,7 +343,7 @@ def add_rollout_arguments(parser): "--rollout-temperature", type=float, default=1.0, - help="the temperature for the inference engine during rollout.", + help="the temperature for the inference engine during rollout. Must be > 0.", ) parser.add_argument( "--rollout-top-p", type=float, default=1.0, help="the top-p for the inference engine during rollout." @@ -1767,6 +1767,11 @@ def _resolve_eval_datasets(args) -> list[EvalDatasetConfig]: def slime_validate_args(args): args.eval_datasets = _resolve_eval_datasets(args) + if args.rollout_temperature <= 0: + raise ValueError( + "--rollout-temperature must be > 0; temperature 0 is greedy decoding and is not a valid RL policy." + ) + if args.kl_coef != 0 or args.use_kl_loss: if not os.path.exists(args.ref_load): raise FileNotFoundError(f"ref_load {args.ref_load} does not exist, please check the path.") diff --git a/slime/utils/data.py b/slime/utils/data.py index adac4b95a6..275cd3efa8 100644 --- a/slime/utils/data.py +++ b/slime/utils/data.py @@ -13,10 +13,9 @@ except ImportError: pq = None +from slime.observability.timer import Timer from slime.utils.types import MultimodalTypes, Sample -from .timer import Timer - __all__ = ["Dataset", "get_source"] logger = logging.getLogger(__name__) diff --git a/slime/utils/memory_utils.py b/slime/utils/memory_utils.py index d4b2d89321..4bad36b51f 100644 --- a/slime/utils/memory_utils.py +++ b/slime/utils/memory_utils.py @@ -5,28 +5,30 @@ import torch import torch.distributed as dist +from slime.utils import accelerator + logger = logging.getLogger(__name__) def clear_memory(clear_host_memory: bool = False): - torch.cuda.synchronize() + accelerator.synchronize() gc.collect() - torch.cuda.empty_cache() + accelerator.empty_cache() if clear_host_memory: torch._C._host_emptyCache() def available_memory(): - device = torch.cuda.current_device() - free, total = torch.cuda.mem_get_info(device) + device = accelerator.current_device() + free, total = accelerator.mem_get_info(device) vm = psutil.virtual_memory() return { "gpu": str(device), "total_GB": _byte_to_gb(total), "free_GB": _byte_to_gb(free), "used_GB": _byte_to_gb(total - free), - "allocated_GB": _byte_to_gb(torch.cuda.memory_allocated(device)), - "reserved_GB": _byte_to_gb(torch.cuda.memory_reserved(device)), + "allocated_GB": _byte_to_gb(accelerator.memory_allocated(device)), + "reserved_GB": _byte_to_gb(accelerator.memory_reserved(device)), "host_total_GB": _byte_to_gb(vm.total), "host_available_GB": _byte_to_gb(vm.available), "host_used_GB": _byte_to_gb(vm.used), diff --git a/slime/utils/reloadable_process_group.py b/slime/utils/reloadable_process_group.py index 3e79281d4d..bef0dc5e7b 100644 --- a/slime/utils/reloadable_process_group.py +++ b/slime/utils/reloadable_process_group.py @@ -9,6 +9,7 @@ import torch.distributed as dist from torch.distributed.distributed_c10d import PrefixStore, _get_default_group, _get_default_store +from slime.utils import accelerator from slime.utils.distributed_utils import get_gloo_group, init_gloo_group, set_gloo_group from slime.utils.memory_utils import available_memory, clear_memory, print_memory @@ -26,11 +27,11 @@ class _DefaultProcessGroupState: rank: int world_size: int generation: int = 0 - nccl_world_destroyed: bool = False + accelerator_world_destroyed: bool = False def register_default_process_group(timeout: timedelta) -> None: - """Register the NCCL WORLD group so it can be destroyed and rebuilt. + """Register the accelerator WORLD group so it can be destroyed and rebuilt. Keeping a reference to the rendezvous store is intentional. It keeps the rank-0 TCPStore alive after ``destroy_process_group()`` and lets every @@ -58,8 +59,8 @@ def register_default_process_group(timeout: timedelta) -> None: ) -def _uses_nccl(backend: str) -> bool: - return "nccl" in backend.lower() +def _uses_accelerator_backend(backend: str) -> bool: + return accelerator.is_accelerator_backend(backend) def _new_default_process_group(state: _DefaultProcessGroupState, backend: str) -> None: @@ -74,9 +75,9 @@ def _new_default_process_group(state: _DefaultProcessGroupState, backend: str) - ) -def _destroy_default_nccl_process_group() -> None: +def _destroy_default_accelerator_process_group() -> None: state = default_process_group_states.get(os.getpid()) - if state is None or state.nccl_world_destroyed or not _uses_nccl(state.backend): + if state is None or state.accelerator_world_destroyed or not _uses_accelerator_backend(state.backend): return # Pure PP=4 exposed a teardown ordering deadlock here. Pipeline ranks own @@ -96,7 +97,7 @@ def _destroy_default_nccl_process_group() -> None: _new_default_process_group(state, backend="gloo") set_gloo_group(_get_default_group()) - state.nccl_world_destroyed = True + state.accelerator_world_destroyed = True logger.info( "Destroyed default %s WORLD process group and initialized a temporary Gloo WORLD (generation %s)", state.backend, @@ -106,18 +107,18 @@ def _destroy_default_nccl_process_group() -> None: def _reload_default_process_group() -> None: state = default_process_group_states.get(os.getpid()) - if state is None or not state.nccl_world_destroyed: + if state is None or not state.accelerator_world_destroyed: return - # WORLD uses Gloo while the NCCL WORLD is destroyed, so this barrier does - # not allocate CUDA or recreate an NCCL communicator before all ranks are ready. + # WORLD uses Gloo while the accelerator WORLD is destroyed, so this barrier + # does not recreate an accelerator communicator before all ranks are ready. dist.barrier() dist.destroy_process_group() set_gloo_group(None) _new_default_process_group(state, backend=state.backend) init_gloo_group() - state.nccl_world_destroyed = False + state.accelerator_world_destroyed = False logger.info( "Reloaded default WORLD process group with backend %s (generation %s)", state.backend, @@ -154,9 +155,17 @@ def monkey_patch_torch_dist(): dist.old_new_group = old_new_group def new_group(*args, **kwargs): - group = old_new_group(*args, **kwargs) explicit_backend = args[2] if len(args) >= 3 else kwargs.get("backend") backend = str(explicit_backend) if explicit_backend is not None else str(dist.get_backend()) + normalized_backend = accelerator.process_group_backend(backend) if backend == "nccl" else backend + if normalized_backend != backend: + if len(args) >= 3: + args = (*args[:2], normalized_backend, *args[3:]) + else: + kwargs = {**kwargs, "backend": normalized_backend} + backend = normalized_backend + + group = old_new_group(*args, **kwargs) # Before WORLD is registered, preserve the historical behavior of # leaving CPU groups and singleton groups untouched. Afterwards every @@ -457,16 +466,16 @@ def bound_device_id(self, dev): def destroy_process_groups(): - """Destroy registered subgroups and replace NCCL WORLD with a temporary Gloo WORLD.""" + """Destroy registered subgroups and replace accelerator WORLD with a temporary Gloo WORLD.""" state = default_process_group_states.get(os.getpid()) - if state is not None and not state.nccl_world_destroyed and _uses_nccl(state.backend): - _destroy_default_nccl_process_group() + if state is not None and not state.accelerator_world_destroyed and _uses_accelerator_backend(state.backend): + _destroy_default_accelerator_process_group() else: ReloadableProcessGroup.destroy_process_groups() def reload_process_groups(): - """Restore NCCL WORLD and recreate all registered subgroups.""" + """Restore accelerator WORLD and recreate all registered subgroups.""" _reload_default_process_group() ReloadableProcessGroup.reload_process_groups() diff --git a/slime/utils/routing_replay.py b/slime/utils/routing_replay.py index c26a9e192e..b9759ea72c 100644 --- a/slime/utils/routing_replay.py +++ b/slime/utils/routing_replay.py @@ -1,6 +1,9 @@ import os import torch +from slime.utils import accelerator + + ROUTING_REPLAY = None ORDERED_TOPK_CAPTURE_ROUTER = None @@ -118,7 +121,7 @@ def pop_forward(self): if hasattr(top_indices, "materialize_for_routing_replay"): return top_indices.materialize_for_routing_replay("forward") return top_indices.to( - torch.cuda.current_device(), + accelerator.current_device(), dtype=torch.int32, non_blocking=top_indices.is_pinned(), ) @@ -129,7 +132,7 @@ def pop_backward(self): if hasattr(top_indices, "materialize_for_routing_replay"): return top_indices.materialize_for_routing_replay("backward") return top_indices.to( - torch.cuda.current_device(), + accelerator.current_device(), dtype=torch.int32, non_blocking=top_indices.is_pinned(), ) diff --git a/slime/utils/tensor_backper.py b/slime/utils/tensor_backper.py index 2fc2a6359d..7d876a4be5 100644 --- a/slime/utils/tensor_backper.py +++ b/slime/utils/tensor_backper.py @@ -4,6 +4,8 @@ import torch +from slime.utils import accelerator + _SourceGetter = Callable[[], Iterable[tuple[str, torch.Tensor]]] @@ -58,7 +60,7 @@ def backup(self, tag: str) -> None: if name not in backup_dict: backup_dict[name] = torch.empty_like(param, device=torch.device("cpu"), pin_memory=True) backup_dict[name].copy_(param.detach(), non_blocking=True) - torch.cuda.synchronize() + accelerator.synchronize() @torch.no_grad() def copy(self, *, src_tag: str, dst_tag: str): @@ -71,7 +73,7 @@ def restore(self, tag: str) -> None: for name, param in self._source_getter(): assert name in backup_dict param.copy_(backup_dict[name], non_blocking=True) - torch.cuda.synchronize() + accelerator.synchronize() class _TensorBackuperNoop(TensorBackuper): @@ -94,12 +96,12 @@ def get(self, tag: str): def backup(self, tag: str) -> None: assert tag == self._single_tag self._backup_hash_dict = _compute_hash_dict(dict(self._source_getter())) - torch.cuda.synchronize() + accelerator.synchronize() def restore(self, tag: str) -> None: assert tag == self._single_tag assert _compute_hash_dict(dict(self._source_getter())) == self._backup_hash_dict - torch.cuda.synchronize() + accelerator.synchronize() def _compute_hash_dict(tensors: dict[str, torch.Tensor]): diff --git a/slime/utils/train_metric_utils.py b/slime/utils/train_metric_utils.py deleted file mode 100644 index 8ecefcfbf3..0000000000 --- a/slime/utils/train_metric_utils.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging -from argparse import Namespace -from collections.abc import Callable -from copy import deepcopy - -from slime.utils import logging_utils -from slime.utils.metric_utils import compute_rollout_step -from slime.utils.timer import Timer - -logger = logging.getLogger(__name__) - - -def log_perf_data_raw( - rollout_id: int, - args: Namespace, - is_primary_rank: bool, - compute_total_fwd_flops: Callable, - extra_metrics: dict | None = None, -) -> None: - timer_instance = Timer() - log_dict_raw = deepcopy(timer_instance.log_dict()) - timer_instance.reset() - - if not is_primary_rank: - return - - log_dict = {f"perf/{key}_time": val for key, val in log_dict_raw.items()} - if extra_metrics: - log_dict.update(extra_metrics) - - if ("perf/actor_train_time" in log_dict) and (compute_total_fwd_flops is not None): - total_fwd_flops = compute_total_fwd_flops(seq_lens=timer_instance.seq_lens) - - if "perf/log_probs_time" in log_dict: - log_dict["perf/log_probs_tflops"] = total_fwd_flops / log_dict["perf/log_probs_time"] - - if "perf/ref_log_probs_time" in log_dict: - log_dict["perf/ref_log_probs_tflops"] = total_fwd_flops / log_dict["perf/ref_log_probs_time"] - - if log_dict["perf/actor_train_time"] > 0: - log_dict["perf/actor_train_tflops"] = 3 * total_fwd_flops / log_dict["perf/actor_train_time"] - log_dict["perf/actor_train_tok_per_s"] = sum(timer_instance.seq_lens) / log_dict["perf/actor_train_time"] - - if "perf/train_wait_time" in log_dict and "perf/train_time" in log_dict: - total_time = log_dict["perf/train_wait_time"] + log_dict["perf/train_time"] - if total_time > 0: - log_dict["perf/step_time"] = total_time - log_dict["perf/wait_time_ratio"] = log_dict["perf/train_wait_time"] / total_time - - logger.info(f"perf {rollout_id}: {log_dict}") - - step = compute_rollout_step(args, rollout_id) - log_dict["rollout/step"] = step - logging_utils.log(args, log_dict, step_key="rollout/step") diff --git a/slime_plugins/models/flash_dot_product_attention.py b/slime_plugins/models/flash_dot_product_attention.py index 06aef6dc3a..fd89bdc464 100644 --- a/slime_plugins/models/flash_dot_product_attention.py +++ b/slime_plugins/models/flash_dot_product_attention.py @@ -18,6 +18,7 @@ from megatron.core.utils import divide from torch import Tensor +from slime.utils import accelerator from slime_plugins.models.learnable_softmax_attention import learnable_softmax_flash_attn_varlen @@ -75,7 +76,7 @@ def __init__( elif config.softmax_type == "off-by-one": self.softmax_offset = torch.zeros( num_heads_per_partition, - device=torch.cuda.current_device(), + device=accelerator.current_device(), dtype=config.params_dtype, ) elif config.softmax_type == "learnable": @@ -84,7 +85,7 @@ def __init__( torch.nn.Parameter( torch.empty( num_heads_per_partition, - device=torch.cuda.current_device(), + device=accelerator.current_device(), dtype=config.params_dtype, ) ), diff --git a/slime_plugins/models/qwen3_5.py b/slime_plugins/models/qwen3_5.py index a53fb15dc9..d7551f9a94 100644 --- a/slime_plugins/models/qwen3_5.py +++ b/slime_plugins/models/qwen3_5.py @@ -9,6 +9,8 @@ from megatron.core.transformer.transformer_layer import get_transformer_layer_offset from transformers.activations import ACT2FN +from slime.utils import accelerator + try: from fla.modules import FusedRMSNormGated, ShortConvolution except ImportError: @@ -77,7 +79,7 @@ def __init__(self, config, layer_idx: int, args=None): self.head_v_dim, eps=self.layer_norm_epsilon, activation=self.activation, - device=torch.cuda.current_device(), + device=accelerator.current_device(), dtype=config.dtype if config.dtype is not None else torch.get_default_dtype(), ) diff --git a/slime_plugins/models/qwen3_5_vl.py b/slime_plugins/models/qwen3_5_vl.py index 2403091bc4..cdc1ad7622 100644 --- a/slime_plugins/models/qwen3_5_vl.py +++ b/slime_plugins/models/qwen3_5_vl.py @@ -9,6 +9,8 @@ from megatron.core.transformer.module import MegatronModule from transformers import AutoConfig +from slime.utils import accelerator + from .qwen3_5 import get_qwen3_5_spec from .qwen3_5_vl_utils import build_packed_mrope_position_ids, gather_packed_input_ids, get_packed_cp_local_indices @@ -56,8 +58,8 @@ def _load_vision_model(hf_config, dtype: torch.dtype, use_cpu_initialization: bo vision_model_cls = Qwen3_5VisionModel - device = torch.device("cpu") if use_cpu_initialization else torch.device("cuda", torch.cuda.current_device()) - with device: + device = torch.device("cpu") if use_cpu_initialization else accelerator.current_device() + with torch.device(device): vision_model = vision_model_cls._from_config(hf_config.vision_config) vision_model.to(dtype=dtype) diff --git a/slime_plugins/models/qwen3_next.py b/slime_plugins/models/qwen3_next.py index f73d57bcf7..cc2937228e 100644 --- a/slime_plugins/models/qwen3_next.py +++ b/slime_plugins/models/qwen3_next.py @@ -9,6 +9,8 @@ from megatron.core.transformer.transformer_layer import get_transformer_layer_offset from transformers.activations import ACT2FN +from slime.utils import accelerator + from .hf_attention import _load_hf_config try: @@ -70,7 +72,7 @@ def __init__(self, config, layer_idx: int, args=None): self.head_v_dim, eps=self.layer_norm_epsilon, activation=self.activation, - device=torch.cuda.current_device(), + device=accelerator.current_device(), dtype=config.dtype if config.dtype is not None else torch.get_default_dtype(), ) diff --git a/slime_plugins/rollout_buffer/rollout_buffer_example.py b/slime_plugins/rollout_buffer/rollout_buffer_example.py index 74b4b4c46c..ac1fb6998f 100644 --- a/slime_plugins/rollout_buffer/rollout_buffer_example.py +++ b/slime_plugins/rollout_buffer/rollout_buffer_example.py @@ -123,7 +123,7 @@ def log_raw_info(args, all_meta_info, rollout_id): wandb.log(log_dict) if args.use_tensorboard: - from slime.utils.tensorboard_utils import _TensorboardAdapter + from slime.observability.tensorboard_utils import _TensorboardAdapter tb = _TensorboardAdapter(args) tb.log(data=log_dict, step=step) diff --git a/tests/_cp_dist_helpers.py b/tests/_cp_dist_helpers.py index f5fa39ab2a..351dadfc28 100644 --- a/tests/_cp_dist_helpers.py +++ b/tests/_cp_dist_helpers.py @@ -41,7 +41,6 @@ import sys import types - # --- Stub ``megatron.core.mpu`` (must run before cp_utils is imported) --- # # Both this module and any test file that imports it should *import this diff --git a/slime/utils/compare_glm52_layerwise.py b/tests/glm52_layerwise_comparator.py similarity index 99% rename from slime/utils/compare_glm52_layerwise.py rename to tests/glm52_layerwise_comparator.py index 7d59a763a5..93227f1208 100644 --- a/slime/utils/compare_glm52_layerwise.py +++ b/tests/glm52_layerwise_comparator.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compare matching Megatron and SGLang decoder-layer outputs.""" +"""Compare matching Megatron and SGLang decoder-layer outputs for tests.""" from __future__ import annotations diff --git a/tests/utils/test_trace_utils.py b/tests/observability/test_trace_utils.py similarity index 93% rename from tests/utils/test_trace_utils.py rename to tests/observability/test_trace_utils.py index e8337d6cbe..dc19f1f36d 100644 --- a/tests/utils/test_trace_utils.py +++ b/tests/observability/test_trace_utils.py @@ -5,9 +5,11 @@ import pytest import torch -from slime.utils.trace_utils import TRACE_CHILDREN_KEY, build_sglang_meta_trace_attrs, trace_span +from slime.observability.trace_utils import TRACE_CHILDREN_KEY, build_sglang_meta_trace_attrs, trace_span from slime.utils.types import Sample +NUM_GPUS = 0 + def _load_trace_timeline_viewer_module(): module_path = Path(__file__).resolve().parents[2] / "tools" / "trace_timeline_viewer.py" @@ -91,3 +93,7 @@ def test_trace_timeline_viewer_omits_virtual_pd_lanes_without_pd_attrs(tmp_path: } assert "[P]" not in item["name"] assert "[D]" not in item["name"] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py index a8380feecc..d4192e10a7 100644 --- a/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py +++ b/tests/plugin_contracts/test_plugin_runtime_hook_contracts.py @@ -133,7 +133,7 @@ def invoke_rollout_data_postprocess(fn): "custom_rollout_log", "CUSTOM_ROLLOUT_LOG_FUNCTION_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_custom_rollout_log", - "slime/ray/rollout.py", + "slime/observability/rollout_metrics.py", "custom_log_func(rollout_id, args, samples, rollout_extra_metrics, rollout_time)", ("rollout_id", "args", "samples", "rollout_extra_metrics", "rollout_time"), invoke_custom_rollout_log, @@ -142,7 +142,7 @@ def invoke_rollout_data_postprocess(fn): "custom_eval_rollout_log", "CUSTOM_EVAL_ROLLOUT_LOG_FUNCTION_PATH", "plugin_contracts.test_plugin_runtime_hook_contracts.reference_custom_eval_rollout_log", - "slime/ray/rollout.py", + "slime/observability/rollout_metrics.py", "custom_log_func(rollout_id, args, data, extra_metrics)", ("rollout_id", "args", "data", "extra_metrics"), invoke_custom_eval_rollout_log, diff --git a/tests/test_accelerator.py b/tests/test_accelerator.py new file mode 100644 index 0000000000..301737550e --- /dev/null +++ b/tests/test_accelerator.py @@ -0,0 +1,203 @@ +from types import SimpleNamespace + +import pytest + +from slime.utils import accelerator + +NUM_GPUS = 0 + + +class FakeAccelerator(accelerator.Accelerator): + name = "fake" + device_type = "fake" + communication_backend_name = "fake" + + def is_available(self): + return True + + def device(self, index=None): + return accelerator.torch.device("cpu") + + def device_name(self, index=None): + return "cpu" + + def set_device(self, index): + return None + + def current_device(self): + return "cpu" + + def device_count(self): + return 0 + + def synchronize(self, device=None): + return None + + def current_stream(self, device=None): + return None + + def empty_cache(self): + return None + + def mem_get_info(self, device=None): + return 0, 0 + + def memory_allocated(self, device=None): + return 0 + + def memory_reserved(self, device=None): + return 0 + + +@pytest.fixture(autouse=True) +def reset_accelerator_selection(monkeypatch): + registry = accelerator._REGISTRY.copy() + selected = accelerator._ACCELERATOR + patch_imported = accelerator._MUSA_PATCH_IMPORTED + bootstrap_checked = accelerator._MUSA_BOOTSTRAP_CHECKED + for name in ("SLIME_ACCELERATOR", "MUSA_VISIBLE_DEVICES", "MUSA_PATCH_PATH", "CUDA_VISIBLE_DEVICES"): + monkeypatch.delenv(name, raising=False) + accelerator._REGISTRY.clear() + accelerator.reset_accelerator() + accelerator._MUSA_PATCH_IMPORTED = False + accelerator._MUSA_BOOTSTRAP_CHECKED = False + yield + accelerator._REGISTRY.clear() + accelerator._REGISTRY.update(registry) + accelerator._ACCELERATOR = selected + accelerator._MUSA_PATCH_IMPORTED = patch_imported + accelerator._MUSA_BOOTSTRAP_CHECKED = bootstrap_checked + + +@pytest.mark.unit +def test_cuda_selection_does_not_bootstrap_musa(monkeypatch): + monkeypatch.setenv("SLIME_ACCELERATOR", "cuda") + monkeypatch.setenv("MUSA_VISIBLE_DEVICES", "0") + monkeypatch.setattr(accelerator, "_cuda_available", lambda: True) + monkeypatch.setattr(accelerator.CUDAAccelerator, "is_available", lambda self: True) + monkeypatch.setattr( + accelerator, + "_import_musa_patch", + lambda: pytest.fail("CUDA selection must not import musa_patch"), + ) + + assert accelerator.get_accelerator().name == "cuda" + assert accelerator.process_group_backend() == "nccl" + assert accelerator.visible_devices_env_key() == "CUDA_VISIBLE_DEVICES" + + +@pytest.mark.unit +def test_selected_musa_bootstraps_patch_once(monkeypatch): + imports = [] + fake_musa = SimpleNamespace(is_available=lambda: True) + + def import_musa_patch(): + imports.append("musa_patch") + monkeypatch.setattr(accelerator.torch, "musa", fake_musa, raising=False) + return True + + monkeypatch.setenv("MUSA_VISIBLE_DEVICES", "0") + monkeypatch.setattr(accelerator, "_import_musa_patch", import_musa_patch) + + assert imports == [] + assert accelerator.initialize_accelerator().name == "musa" + assert accelerator.initialize_accelerator().name == "musa" + assert imports == ["musa_patch"] + + +@pytest.mark.unit +def test_cpu_only_initialization_does_not_require_an_accelerator(monkeypatch): + monkeypatch.setattr(accelerator, "is_musa_available", lambda: False) + monkeypatch.setattr(accelerator, "_cuda_available", lambda: False) + + assert accelerator.initialize_accelerator() is None + + +@pytest.mark.unit +def test_musa_backend_maps_devices_and_process_groups(monkeypatch): + monkeypatch.setattr(accelerator.MUSAAccelerator, "is_available", lambda self: True) + monkeypatch.setenv("MUSA_VISIBLE_DEVICES", "2,5") + accelerator.set_accelerator(accelerator.MUSAAccelerator()) + + assert accelerator.visible_devices_env_key() == "MUSA_VISIBLE_DEVICES" + assert accelerator.resolve_visible_device_id("5") == 1 + assert accelerator.process_group_backend() == "mccl" + assert accelerator.weight_update_backend() == "cpu:gloo,musa:mccl" + assert accelerator.process_group_backend("gloo") == "gloo" + + +@pytest.mark.unit +def test_cuda_visible_device_mapping(monkeypatch): + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "4,6") + accelerator.set_accelerator(FakeAccelerator()) + + assert accelerator.resolve_visible_device_id(4) == 0 + assert accelerator.resolve_visible_device_id(1) == 1 + with pytest.raises(RuntimeError, match="CUDA_VISIBLE_DEVICES=4,6"): + accelerator.resolve_visible_device_id(7) + + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "GPU-aaaa,GPU-bbbb") + assert accelerator.resolve_visible_device_id("GPU-bbbb") == 1 + + +@pytest.mark.unit +def test_registered_backend_can_be_selected(monkeypatch): + class RegisteredAccelerator(FakeAccelerator): + name = "registered" + + monkeypatch.setattr(accelerator, "is_musa_available", lambda: False) + monkeypatch.setattr(accelerator, "_cuda_available", lambda: False) + accelerator.register_accelerator("registered", RegisteredAccelerator, lambda: True, priority=300) + + assert accelerator.get_accelerator().name == "registered" + + +@pytest.mark.unit +def test_cuda_backend_uses_torch_cuda_namespace(monkeypatch): + monkeypatch.setattr(accelerator.torch.cuda, "is_available", lambda: True) + monkeypatch.setattr(accelerator.torch.cuda, "device_count", lambda: 2) + monkeypatch.setattr(accelerator.torch.cuda, "current_device", lambda: 1) + monkeypatch.setattr(accelerator.torch.cuda, "memory_allocated", lambda device=None: 123) + backend = accelerator.CUDAAccelerator() + + assert backend.is_available() + assert backend.device_name() == "cuda:1" + assert backend.memory_allocated() == 123 + + +@pytest.mark.unit +def test_routing_replay_uses_selected_backend_current_device(monkeypatch): + from slime.utils import routing_replay + + transfers = [] + + class FakeTopIndices: + def is_pinned(self): + return False + + def to(self, device, *, dtype, non_blocking): + transfers.append((device, dtype, non_blocking)) + return self + + accelerator.set_accelerator(FakeAccelerator()) + monkeypatch.setattr(routing_replay.RoutingReplay, "all_routing_replays", []) + replay = routing_replay.RoutingReplay() + replay.top_indices_list.append(FakeTopIndices()) + + replay.pop_forward() + replay.pop_backward() + assert transfers == [ + ("cpu", accelerator.torch.int32, False), + ("cpu", accelerator.torch.int32, False), + ] + + +@pytest.mark.unit +def test_musa_availability_handles_missing_torch_namespace(monkeypatch): + monkeypatch.delattr(accelerator.torch, "musa", raising=False) + + assert accelerator.is_musa_available() is False + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_empty_colocated_weight_bucket.py b/tests/test_empty_colocated_weight_bucket.py index 7e5db7e0db..4b5217cd2c 100644 --- a/tests/test_empty_colocated_weight_bucket.py +++ b/tests/test_empty_colocated_weight_bucket.py @@ -67,6 +67,10 @@ def _install_fake_deps(monkeypatch): update_weight_pkg.__path__ = [str(REPO_ROOT / "slime" / "backends" / "megatron_utils" / "update_weight")] slime_utils_pkg = types.ModuleType("slime.utils") slime_utils_pkg.__path__ = [str(REPO_ROOT / "slime" / "utils")] + accelerator_mod = types.ModuleType("slime.utils.accelerator") + accelerator_mod.device = lambda: "cuda:0" + accelerator_mod.current_device = lambda: "cuda:0" + accelerator_mod.ipc_collect = lambda: None dist_mod = types.ModuleType("torch.distributed") @@ -133,6 +137,7 @@ def gather_object(obj, object_gather_list, dst, group): monkeypatch.setitem(sys.modules, "slime.backends.megatron_utils", megatron_utils_pkg) monkeypatch.setitem(sys.modules, "slime.backends.megatron_utils.update_weight", update_weight_pkg) monkeypatch.setitem(sys.modules, "slime.utils", slime_utils_pkg) + monkeypatch.setitem(sys.modules, "slime.utils.accelerator", accelerator_mod) monkeypatch.setitem(sys.modules, "torch", torch_mod) monkeypatch.setitem(sys.modules, "torch.distributed", dist_mod) monkeypatch.setitem(sys.modules, "ray", ray_mod) diff --git a/tests/test_glm52_6layer_deterministic_e2e.py b/tests/test_glm52_6layer_deterministic_e2e.py index 1f94cc0732..241ec4f1c8 100644 --- a/tests/test_glm52_6layer_deterministic_e2e.py +++ b/tests/test_glm52_6layer_deterministic_e2e.py @@ -365,8 +365,7 @@ def run_gate(*, layerwise_zero: bool = False, rollout_max_response_len: int = 40 _run( [ sys.executable, - "-m", - "slime.utils.compare_glm52_layerwise", + str(REPO_ROOT / "tests/glm52_layerwise_comparator.py"), "--megatron-dir", megatron_layerwise_dump, "--sglang-dir", diff --git a/tests/test_glm52_layerwise_comparison.py b/tests/test_glm52_layerwise_comparison.py index e1d528ebb2..9228646461 100644 --- a/tests/test_glm52_layerwise_comparison.py +++ b/tests/test_glm52_layerwise_comparison.py @@ -1,7 +1,7 @@ import pytest import torch -from slime.utils.compare_glm52_layerwise import ( +from glm52_layerwise_comparator import ( TrainSequence, _sglang_layer_token_rows, compare_layer_outputs, diff --git a/tests/test_megatron_argument_validation.py b/tests/test_megatron_argument_validation.py index ba3304d79c..20c133ed95 100644 --- a/tests/test_megatron_argument_validation.py +++ b/tests/test_megatron_argument_validation.py @@ -43,7 +43,7 @@ def load_slime_arguments_module(monkeypatch): router_launch_mod = types.ModuleType("sglang_router.launch_router") sglang_arguments_mod = types.ModuleType("slime.backends.sglang_utils.arguments") sglang_external_mod = types.ModuleType("slime.backends.sglang_utils.external") - logging_utils_mod = types.ModuleType("slime.utils.logging_utils") + logging_utils_mod = types.ModuleType("slime.observability.logging_utils") router_launch_mod.RouterArgs = object sglang_arguments_mod.sglang_parse_args = lambda *args, **kwargs: None @@ -55,7 +55,7 @@ def load_slime_arguments_module(monkeypatch): monkeypatch.setitem(sys.modules, "sglang_router.launch_router", router_launch_mod) monkeypatch.setitem(sys.modules, "slime.backends.sglang_utils.arguments", sglang_arguments_mod) monkeypatch.setitem(sys.modules, "slime.backends.sglang_utils.external", sglang_external_mod) - monkeypatch.setitem(sys.modules, "slime.utils.logging_utils", logging_utils_mod) + monkeypatch.setitem(sys.modules, "slime.observability.logging_utils", logging_utils_mod) module_path = Path(__file__).resolve().parents[1] / "slime" / "utils" / "arguments.py" module_name = "test_slime_argument_validation_module" @@ -255,6 +255,7 @@ def make_slime_validate_args(**overrides): update_weight_disk_dir=None, update_weight_local_checkpoint_dir=None, update_weight_mode="full", + rollout_temperature=1.0, ) values.update(overrides) return types.SimpleNamespace(**values) @@ -299,6 +300,16 @@ def test_slime_validate_args_rejects_equal_debug_data_paths(monkeypatch): module.slime_validate_args(args) +@pytest.mark.unit +@pytest.mark.parametrize("temperature", [0.0, -0.1]) +def test_slime_validate_args_rejects_non_positive_rollout_temperature(monkeypatch, temperature): + module = load_slime_arguments_module(monkeypatch) + args = make_slime_validate_args(rollout_temperature=temperature) + + with pytest.raises(ValueError, match="--rollout-temperature must be > 0"): + module.slime_validate_args(args) + + @pytest.mark.unit def test_slime_validate_args_preserves_zero_rollout_gpus_under_colocate(monkeypatch): module = load_slime_arguments_module(monkeypatch) diff --git a/tests/test_metric_report.py b/tests/test_metric_report.py index d908597c5c..a83f7fda1b 100644 --- a/tests/test_metric_report.py +++ b/tests/test_metric_report.py @@ -1,8 +1,7 @@ """Single-process metric-report invariance tests. Pins train-side / rollout-side report formulas implemented in -``slime.backends.megatron_utils.cp_utils.reduce_train_step_metrics`` and -``rollout_log_metric_contribution``: the reported number for a given set +``slime.observability.train_metric_utils``: the reported number for a given set of samples must be the same regardless of - how samples are distributed across micro-batches / DP ranks @@ -28,11 +27,12 @@ from slime.backends.megatron_utils.cp_utils import ( # noqa: E402 get_logits_and_tokens_offset_with_cp, get_sum_of_sample_mean, +) +from slime.observability.train_metric_utils import ( # noqa: E402 reduce_train_step_metrics, rollout_log_metric_contribution, ) - NUM_GPUS = 0 @@ -143,7 +143,7 @@ def _simulate_rollout_report(samples_per_rank): per-token metric branch. Each "rank" applies the reducer once over its full sample subset, then - ``rollout_log_metric_contribution`` (the same helper data.py uses) emits + ``rollout_log_metric_contribution`` (the same helper the reporter uses) emits the ``(per_rank_sum, count)`` tuple. We aggregate via ``Σsum / Σcount`` — the same shape ``gather_log_data`` uses. """ diff --git a/tests/test_metric_report_dist.py b/tests/test_metric_report_dist.py index 614720d5ed..466ad3b729 100644 --- a/tests/test_metric_report_dist.py +++ b/tests/test_metric_report_dist.py @@ -1,4 +1,4 @@ -"""Multi-process distributed tests for the cp_utils report helpers. +"""Multi-process distributed tests for the train metric report helpers. Spawn ``dp_size * cp_size`` workers with real ``torch.distributed`` (gloo backend) and exercise the actual production helpers end-to-end. The @@ -42,7 +42,6 @@ stub_megatron_in_worker, ) - NUM_GPUS = 0 @@ -68,7 +67,8 @@ def _train_step_distributed_worker( # Import AFTER the megatron stub override so cp_utils still binds # against the pre-installed stub (which we've now pinned for this # worker's CP rank). - from slime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean, reduce_train_step_metrics + from slime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from slime.observability.train_metric_utils import reduce_train_step_metrics all_total_lengths = FOUR_ROLLOUT_TOTAL_LENGTHS all_response_lengths = FOUR_ROLLOUT_RESPONSE_LENGTHS @@ -197,11 +197,8 @@ def _rollout_log_distributed_worker( dp_group = init_worker_process_group(rank, world_size, master_port) try: - from slime.backends.megatron_utils.cp_utils import ( - gather_and_reduce_log_dict, - get_sum_of_sample_mean, - rollout_log_metric_contribution, - ) + from slime.backends.megatron_utils.cp_utils import get_sum_of_sample_mean + from slime.observability.train_metric_utils import gather_and_reduce_log_dict, rollout_log_metric_contribution all_total_lengths = FOUR_ROLLOUT_TOTAL_LENGTHS all_response_lengths = FOUR_ROLLOUT_RESPONSE_LENGTHS diff --git a/tests/test_ppo_kl_metric.py b/tests/test_ppo_kl_metric.py new file mode 100644 index 0000000000..64d85aa479 --- /dev/null +++ b/tests/test_ppo_kl_metric.py @@ -0,0 +1,63 @@ +import sys +import types +from argparse import Namespace + +import torch + +from slime.utils.ppo_utils import compute_approx_kl + +NUM_GPUS = 0 + + +def test_ppo_estimator_does_not_corrupt_logged_kl(monkeypatch): + previous_loss = sys.modules.pop("slime.backends.megatron_utils.loss", None) + previous_cp_utils = sys.modules.pop("slime.backends.megatron_utils.cp_utils", None) + + mpu_stub = types.SimpleNamespace( + get_context_parallel_world_size=lambda: 1, + get_context_parallel_rank=lambda: 0, + is_pipeline_last_stage=lambda: True, + ) + megatron_mod = types.ModuleType("megatron") + core_mod = types.ModuleType("megatron.core") + core_mod.mpu = mpu_stub + monkeypatch.setitem(sys.modules, "megatron", megatron_mod) + monkeypatch.setitem(sys.modules, "megatron.core", core_mod) + + try: + from slime.backends.megatron_utils.loss import compute_advantages_and_returns + + log_probs = [torch.tensor([0.5, 0.7, 0.9])] + ref_log_probs = [torch.tensor([0.4, 0.5, 0.6])] + expected_kl = compute_approx_kl(log_probs[0], ref_log_probs[0], kl_loss_type="k1") + rollout_data = { + "log_probs": log_probs, + "ref_log_probs": ref_log_probs, + "rewards": [1.0], + "values": [torch.zeros(3)], + "response_lengths": [3], + "total_lengths": [5], + "loss_masks": [torch.ones(3)], + } + args = Namespace( + advantage_estimator="ppo", + kl_coef=0.05, + kl_loss_type="k1", + use_rollout_logprobs=False, + custom_advantage_function_path=None, + normalize_advantages=False, + use_opd=False, + gamma=1.0, + lambd=1.0, + ) + compute_advantages_and_returns(args, rollout_data) + torch.testing.assert_close(rollout_data["kl"][0], expected_kl) + finally: + if previous_loss is None: + sys.modules.pop("slime.backends.megatron_utils.loss", None) + else: + sys.modules["slime.backends.megatron_utils.loss"] = previous_loss + if previous_cp_utils is None: + sys.modules.pop("slime.backends.megatron_utils.cp_utils", None) + else: + sys.modules["slime.backends.megatron_utils.cp_utils"] = previous_cp_utils diff --git a/tests/test_qwen2.5_0.5B_fanout_short.py b/tests/test_qwen2.5_0.5B_fanout_short.py index 289a6e725c..5a90954b53 100644 --- a/tests/test_qwen2.5_0.5B_fanout_short.py +++ b/tests/test_qwen2.5_0.5B_fanout_short.py @@ -10,7 +10,7 @@ this test, **no e2e training run had ever exercised the full chain**: custom_generate returns list[Sample] sharing rollout_id - → _validate_rollout_id_annotated at depth ≥ 2 passes + → validate_rollout_id_annotated at depth ≥ 2 passes → _split_train_data_by_dp groups by rollout_id and trims to N steps using ``rollout_batch_size * n_samples_per_prompt / global_batch_size`` (NOT total sample count, which would inflate steps once N>1) diff --git a/tests/test_reloadable_process_group_world.py b/tests/test_reloadable_process_group_world.py index 38822a2748..289b0bebf0 100644 --- a/tests/test_reloadable_process_group_world.py +++ b/tests/test_reloadable_process_group_world.py @@ -26,10 +26,10 @@ def _run_pp_group_reload_worker(rank: int, world_size: int, rendezvous_path: str distributed_utils.init_gloo_group() rpg.register_default_process_group(timeout=timeout) - # Exercise the NCCL lifecycle with Gloo so this remains a CPU test. The - # relevant contract is the global ordering of WORLD and subgroup teardown, - # not the backend implementation. - rpg._uses_nccl = lambda _backend: True + # Exercise the accelerator lifecycle with Gloo so this remains a CPU test. + # The contract under test is WORLD/subgroup teardown ordering, not a vendor + # communication backend. + rpg._uses_accelerator_backend = lambda _backend: True group_specs = [ ([0], "TP_0"), @@ -65,6 +65,49 @@ def _run_pp_group_reload_worker(rank: int, world_size: int, rendezvous_path: str dist.destroy_process_group() +def _run_backend_normalization_worker(_rank: int) -> None: + calls = [] + mapped_backends = [] + + def old_new_group(*args, **kwargs): + calls.append((args, kwargs)) + return f"group-{len(calls)}" + + def process_group_backend(backend): + mapped_backends.append(backend) + return "mccl" if backend == "nccl" else backend + + rpg.old_new_group_dict.clear() + rpg.default_process_group_states.clear() + rpg.dist.new_group = old_new_group + rpg.dist.get_backend = lambda: "gloo" + rpg.accelerator.process_group_backend = process_group_backend + rpg.monkey_patch_torch_dist() + + gloo_group = rpg.dist.new_group(ranks=[0], backend="gloo") + assert gloo_group == "group-1" + assert calls[-1][1]["backend"] == "gloo" + assert mapped_backends == [] + + mccl_group = rpg.dist.new_group([0], None, "nccl") + assert mccl_group == "group-2" + assert calls[-1][0][2] == "mccl" + assert mapped_backends == ["nccl"] + + +@pytest.mark.unit +@pytest.mark.parametrize("backend", ["nccl", "mccl", "cpu:gloo,musa:mccl"]) +def test_accelerator_backend_detection(backend): + assert rpg._uses_accelerator_backend(backend) + + +@pytest.mark.unit +def test_new_group_normalizes_only_logical_nccl_backend(): + # monkey_patch_torch_dist replaces process-wide torch.distributed symbols, + # so isolate this behavior check in a spawned process. + mp.spawn(_run_backend_normalization_worker, nprocs=1, join=True) + + @pytest.mark.unit def test_register_default_process_group_captures_rendezvous_state(monkeypatch): timeout = timedelta(minutes=7) @@ -83,7 +126,7 @@ def test_register_default_process_group_captures_rendezvous_state(monkeypatch): assert state.store == "rendezvous-store" assert state.rank == 3 assert state.world_size == 8 - assert not state.nccl_world_destroyed + assert not state.accelerator_world_destroyed @pytest.mark.unit @@ -127,7 +170,7 @@ def init_process_group(**kwargs): rpg.destroy_process_groups() - assert state.nccl_world_destroyed + assert state.accelerator_world_destroyed assert state.generation == 1 assert events == [ ("barrier", "canonical-gloo"), @@ -150,7 +193,7 @@ def init_process_group(**kwargs): events.clear() rpg.reload_process_groups() - assert not state.nccl_world_destroyed + assert not state.accelerator_world_destroyed assert state.generation == 2 assert events == [ ("barrier", "WORLD"), diff --git a/tests/test_rollout_data_utils.py b/tests/test_rollout_data_utils.py new file mode 100644 index 0000000000..82a72de105 --- /dev/null +++ b/tests/test_rollout_data_utils.py @@ -0,0 +1,102 @@ +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from slime.observability.rollout_data_utils import ( + load_debug_rollout_data, + save_debug_rollout_data, + tensorize_rollout_data_for_training, + validate_rollout_routed_experts_for_replay, +) +from slime.utils.types import Sample + +NUM_GPUS = 0 + + +def _args(): + return SimpleNamespace( + num_layers=6, + moe_router_topk=2, + moe_layer_freq=[0, 0, 0, 1, 1, 1], + ) + + +def test_r3_validation_accepts_dense_zeros_and_complete_moe_routes(): + routes = torch.zeros((4, 6, 2), dtype=torch.uint8) + routes[:, 3:, 1] = 7 + validate_rollout_routed_experts_for_replay([routes], _args()) + + +def test_r3_validation_rejects_missing_pipeline_layers(): + routes = torch.zeros((4, 6, 2), dtype=torch.uint8) + routes[:, 3, 1] = 7 + + with pytest.raises(ValueError, match=r"all zero.*\[4, 5\]"): + validate_rollout_routed_experts_for_replay([routes], _args()) + + +def test_r3_validation_rejects_wrong_shape(): + routes = torch.zeros((4, 5, 2), dtype=torch.uint8) + + with pytest.raises(ValueError, match="Invalid rollout routed-experts shape"): + validate_rollout_routed_experts_for_replay([routes], _args()) + + +def test_tensorize_rollout_data_for_training_normalizes_cpu_tensors(): + readonly_tokens = np.array([1, 2, 3]) + readonly_tokens.flags.writeable = False + rollout_data = { + "tokens": [readonly_tokens], + "loss_masks": [[1, 0]], + "multimodal_train_inputs": [ + { + "pixel_values": torch.tensor([1.0], requires_grad=True), + "metadata": "unchanged", + } + ], + "rollout_mask_sums": [2], + } + + tensorize_rollout_data_for_training(rollout_data) + + assert rollout_data["tokens"][0].dtype == torch.long + assert rollout_data["loss_masks"][0].dtype == torch.int + assert rollout_data["multimodal_train_inputs"][0]["metadata"] == "unchanged" + assert not rollout_data["multimodal_train_inputs"][0]["pixel_values"].requires_grad + assert rollout_data["rollout_mask_sums"].dtype == torch.float32 + + +def test_save_and_load_debug_rollout_data_round_trip(tmp_path): + path_template = str(tmp_path / "rollout_{rollout_id}.pt") + samples = [ + Sample(index=1, rollout_id=3, prompt="question", response="answer", response_length=1), + ] + + save_debug_rollout_data(path_template, samples, rollout_id=3, evaluation=False) + loaded = load_debug_rollout_data(path_template, rollout_id=3) + + assert len(loaded) == 1 + assert loaded[0].index == 1 + assert loaded[0].rollout_id == 3 + assert loaded[0].prompt == "question" + assert loaded[0].response == "answer" + + +def test_save_debug_eval_rollout_data_flattens_datasets(tmp_path): + path_template = str(tmp_path / "rollout_{rollout_id}.pt") + data = { + "math": {"samples": [Sample(index=1)]}, + "code": {"samples": [Sample(index=2)]}, + } + + save_debug_rollout_data(path_template, data, rollout_id=4, evaluation=True) + + saved = torch.load(tmp_path / "rollout_eval_4.pt", weights_only=False) + assert saved["rollout_id"] == 4 + assert [sample["index"] for sample in saved["samples"]] == [1, 2] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_rollout_metrics.py b/tests/test_rollout_metrics.py index 02fc92bce4..60faef145f 100644 --- a/tests/test_rollout_metrics.py +++ b/tests/test_rollout_metrics.py @@ -5,7 +5,7 @@ import pytest import torch -from slime.ray.rollout import _compute_top_p_kept_vocab_metrics +from slime.observability.rollout_metrics import _compute_top_p_kept_vocab_metrics from slime.utils.misc import decode_int32_meta_array from slime.utils.types import Sample @@ -215,3 +215,7 @@ def test_append_response_tokens_rejects_non_trainable_log_probs(): with pytest.raises(ValueError, match="non-trainable response tokens should not pass rollout log probabilities"): sample.append_response_tokens(tokens=[10], log_probs=[-0.1], trainable=False) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_rollout_routing_replay_validation.py b/tests/test_rollout_routing_replay_validation.py deleted file mode 100644 index 55c7a4fa6b..0000000000 --- a/tests/test_rollout_routing_replay_validation.py +++ /dev/null @@ -1,41 +0,0 @@ -from types import SimpleNamespace - -import pytest -import torch - -from slime.ray.rollout import _validate_rollout_routed_experts_for_replay - -NUM_GPUS = 0 - - -def _args(): - return SimpleNamespace( - num_layers=6, - moe_router_topk=2, - moe_layer_freq=[0, 0, 0, 1, 1, 1], - ) - - -def test_r3_validation_accepts_dense_zeros_and_complete_moe_routes(): - routes = torch.zeros((4, 6, 2), dtype=torch.uint8) - routes[:, 3:, 1] = 7 - _validate_rollout_routed_experts_for_replay([routes], _args()) - - -def test_r3_validation_rejects_missing_pipeline_layers(): - routes = torch.zeros((4, 6, 2), dtype=torch.uint8) - routes[:, 3, 1] = 7 - - with pytest.raises(ValueError, match=r"all zero.*\[4, 5\]"): - _validate_rollout_routed_experts_for_replay([routes], _args()) - - -def test_r3_validation_rejects_wrong_shape(): - routes = torch.zeros((4, 5, 2), dtype=torch.uint8) - - with pytest.raises(ValueError, match="Invalid rollout routed-experts shape"): - _validate_rollout_routed_experts_for_replay([routes], _args()) - - -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__])) diff --git a/tests/test_train_dump.py b/tests/test_train_data_utils.py similarity index 99% rename from tests/test_train_dump.py rename to tests/test_train_data_utils.py index 694673736a..6b94ff29ac 100644 --- a/tests/test_train_dump.py +++ b/tests/test_train_data_utils.py @@ -5,7 +5,7 @@ import torch from _cp_dist_helpers import cp_chunk_response_tensor, free_port, init_worker_process_group, stub_megatron_in_worker -from slime.backends.megatron_utils.train_dump_utils import ( +from slime.observability.train_data_utils import ( _build_dump_payload, restore_context_parallel_fields_to_cpu, save_debug_train_data, diff --git a/tools/convert_hf_to_fp8.py b/tools/convert_hf_to_fp8.py index e98e750e22..bcc4306b9d 100644 --- a/tools/convert_hf_to_fp8.py +++ b/tools/convert_hf_to_fp8.py @@ -28,6 +28,8 @@ import torch.nn.functional as F from tqdm import tqdm +from slime.utils import accelerator + FP8_INFO = torch.finfo(torch.float8_e4m3fn) FP8_MAX, FP8_MIN = FP8_INFO.max, FP8_INFO.min @@ -117,11 +119,13 @@ def process_file(input_path, output_path, filename, strategy, block_size, result if not filename.endswith(".safetensors"): return - print(f"Processing {filename}, memory usage: {torch.cuda.memory_allocated()}") + print(f"Processing {filename}, memory usage: {accelerator.memory_allocated()}") weights = {} q_weights = {} - with safetensors.safe_open(os.path.join(input_path, filename), framework="pt", device="cuda") as f: + with safetensors.safe_open( + os.path.join(input_path, filename), framework="pt", device=accelerator.device_name() + ) as f: for k in f.keys(): weights[k] = f.get_tensor(k) @@ -243,7 +247,7 @@ def convert_fp8(input_path, output_path, strategy, block_size=None, max_workers= json.dump(index_dict, open(os.path.join(output_path, "model.safetensors.index.json"), "w"), indent=2) gc.collect() - torch.cuda.empty_cache() + accelerator.empty_cache() if __name__ == "__main__": diff --git a/tools/convert_hf_to_int4_direct.py b/tools/convert_hf_to_int4_direct.py index 613f65595d..7eaffc7272 100644 --- a/tools/convert_hf_to_int4_direct.py +++ b/tools/convert_hf_to_int4_direct.py @@ -21,6 +21,8 @@ import torch from tqdm import tqdm +from slime.utils import accelerator + try: import fake_int4_quant_cuda except ImportError: @@ -166,11 +168,13 @@ def add_result(self, filename, q_weights): def process_file(input_path, output_path, filename, group_size, is_symmetric, ignore_rules, result_collector): - print(f"Processing {filename}, memory usage: {torch.cuda.memory_allocated()}") + print(f"Processing {filename}, memory usage: {accelerator.memory_allocated()}") weights = {} q_weights = {} - with safetensors.safe_open(os.path.join(input_path, filename), framework="pt", device="cuda") as f: + with safetensors.safe_open( + os.path.join(input_path, filename), framework="pt", device=accelerator.device_name() + ) as f: for k in f.keys(): weights[k] = f.get_tensor(k) @@ -180,15 +184,15 @@ def process_file(input_path, output_path, filename, group_size, is_symmetric, ig ) if is_ignored or not name.endswith(".weight") or weight.dim() < 2: - print(f"Ignoring {name}, memory usage: {torch.cuda.memory_allocated()}") + print(f"Ignoring {name}, memory usage: {accelerator.memory_allocated()}") q_weights[name] = weight continue - print(f"Packing {name}, memory usage: {torch.cuda.memory_allocated()}") + print(f"Packing {name}, memory usage: {accelerator.memory_allocated()}") qw, s, zp = pack_layer(weight, group_size, is_symmetric) qweight_name = name.replace(".weight", ".weight_packed") scale_name = name.replace(".weight", ".weight_scale") - weight_shape = torch.tensor(weight.shape, dtype=torch.int32, device="cuda") + weight_shape = torch.tensor(weight.shape, dtype=torch.int32, device=accelerator.device()) weight_shape_name = name.replace(".weight", ".weight_shape") if zp is not None: zp_name = name.replace(".weight", ".weight_zero_point") @@ -273,7 +277,7 @@ def convert_int4(input_path, output_path, group_size, is_symmetric, ignore_rules json.dump(index_dict, open(os.path.join(output_path, "model.safetensors.index.json"), "w"), indent=2) gc.collect() - torch.cuda.empty_cache() + accelerator.empty_cache() return output_path diff --git a/tools/convert_hf_to_torch_dist.py b/tools/convert_hf_to_torch_dist.py index 4ef5034b10..8cd72b26c3 100644 --- a/tools/convert_hf_to_torch_dist.py +++ b/tools/convert_hf_to_torch_dist.py @@ -13,7 +13,8 @@ from slime.backends.megatron_utils.hf_to_megatron import load_hf_weights from slime.backends.megatron_utils.initialize import init from slime.backends.megatron_utils.model_provider import get_model_provider_func -from slime.utils.logging_utils import configure_logger +from slime.observability.logging_utils import configure_logger +from slime.utils import accelerator from slime.utils.memory_utils import print_memory @@ -27,7 +28,6 @@ def add_convertion_args(parser): help="Path to a custom model provider function.", ) parser.add_argument("--allgather-cp", action="store_true", default=False) - parser.add_argument("--use-gated-attention", action="store_true", default=False) try: parser.add_argument("--padded-vocab-size", type=int, default=None) except Exception: @@ -81,6 +81,7 @@ def ceildiv(a, b): def main(): if torch.version.hip: import megatron.core.dist_checkpointing.strategies.filesystem_async as filesystem_async_module + from slime.utils.rocm_checkpoint_writer import ROCmFileSystemWriterAsync filesystem_async_module.FileSystemWriterAsync = ROCmFileSystemWriterAsync @@ -93,17 +94,17 @@ def main(): local_rank = int(os.getenv("LOCAL_RANK") or os.getenv("SLURM_LOCALID") or 0) global_rank = int(os.getenv("RANK") or os.getenv("SLURM_PROCID") or 0) - torch.cuda.set_device(local_rank) + accelerator.set_device(local_rank) os.environ.setdefault("WORLD_SIZE", str(world_size)) os.environ.setdefault("RANK", str(global_rank)) os.environ.setdefault("LOCAL_RANK", str(local_rank)) os.environ.setdefault("MASTER_ADDR", "localhost") os.environ.setdefault("MASTER_PORT", "12355") dist.init_process_group( - backend="nccl", + backend=accelerator.process_group_backend(), world_size=world_size, rank=global_rank, - device_id=torch.device(f"cuda:{local_rank}"), + device_id=accelerator.distributed_device_id(local_rank), ) args = get_args() init(args) @@ -123,9 +124,9 @@ def main(): model[0] = model[0].cpu() print_memory("after loading model") - torch.cuda.synchronize() + accelerator.synchronize() gc.collect() - torch.cuda.empty_cache() + accelerator.empty_cache() save_checkpoint(1, model, None, None, 0) diff --git a/tools/convert_to_hf.py b/tools/convert_to_hf.py index 9f16850f0c..201e35bf9c 100644 --- a/tools/convert_to_hf.py +++ b/tools/convert_to_hf.py @@ -5,6 +5,7 @@ import slime.backends.megatron_utils as megatron_utils from slime.backends.megatron_utils import update_weight_utils +from slime.utils import accelerator from slime.utils.arguments import parse_args @@ -57,7 +58,7 @@ def main(args): param = param_ break else: - param = torch.empty(info.shape, dtype=info.dtype, device=torch.cuda.current_device()) + param = torch.empty(info.shape, dtype=info.dtype, device=accelerator.current_device()) if pp_size > 1: if info.src_rank in dist.get_process_group_ranks(mpu.get_pipeline_model_parallel_group()): diff --git a/tools/fp8_cast_bf16.py b/tools/fp8_cast_bf16.py index c227c300f2..edc8485d90 100644 --- a/tools/fp8_cast_bf16.py +++ b/tools/fp8_cast_bf16.py @@ -10,6 +10,8 @@ from safetensors.torch import load_file, save_file from tqdm import tqdm +from slime.utils import accelerator + @triton.jit def weight_dequant_kernel(x_ptr, s_ptr, y_ptr, M, N, BLOCK_SIZE: tl.constexpr): @@ -60,7 +62,7 @@ def get_tensor(tensor_name): file_name = weight_map[tensor_name] if file_name not in loaded_files: file_path = os.path.join(fp8_path, file_name) - loaded_files[file_name] = load_file(file_path, device="cuda") + loaded_files[file_name] = load_file(file_path, device=accelerator.device_name()) return loaded_files[file_name][tensor_name] safetensor_files = list(glob(os.path.join(fp8_path, "*.safetensors"))) @@ -68,7 +70,7 @@ def get_tensor(tensor_name): for safetensor_file in tqdm(safetensor_files): print(f"Handling file: {safetensor_file}") file_name = os.path.basename(safetensor_file) - current_state_dict = load_file(safetensor_file, device="cuda") + current_state_dict = load_file(safetensor_file, device=accelerator.device_name()) loaded_files[file_name] = current_state_dict new_state_dict = {} @@ -95,7 +97,7 @@ def get_tensor(tensor_name): if len(loaded_files) > 2: oldest_file = next(iter(loaded_files)) del loaded_files[oldest_file] - torch.cuda.empty_cache() + accelerator.empty_cache() # Update model index new_model_index_file = os.path.join(bf16_path, "model.safetensors.index.json") diff --git a/train.py b/train.py index 9cb2968866..051e5ec35d 100644 --- a/train.py +++ b/train.py @@ -1,8 +1,8 @@ import ray +from slime.observability.logging_utils import configure_logger, finish_tracking, init_tracking from slime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models from slime.utils.arguments import parse_args -from slime.utils.logging_utils import configure_logger, finish_tracking, init_tracking from slime.utils.misc import should_run_periodic_action diff --git a/train_async.py b/train_async.py index 9141b612fe..1f74a9a5a2 100644 --- a/train_async.py +++ b/train_async.py @@ -1,8 +1,8 @@ import ray +from slime.observability.logging_utils import configure_logger, finish_tracking, init_tracking from slime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models from slime.utils.arguments import parse_args -from slime.utils.logging_utils import configure_logger, finish_tracking, init_tracking from slime.utils.misc import should_run_periodic_action