diff --git a/.buildkite/README-NPU-CI.md b/.buildkite/README-NPU-CI.md
new file mode 100644
index 000000000..6cd2e7a21
--- /dev/null
+++ b/.buildkite/README-NPU-CI.md
@@ -0,0 +1,111 @@
+# vime NPU CI on Buildkite
+
+The NPU pipeline lives in [`pipeline-npu.yaml`](./pipeline-npu.yaml). It validates
+vime on Ascend NPU hardware.
+
+The NPU suites are behind a **block step** (`:rocket: Run NPU test suites?`):
+click it in the Buildkite UI like GPU CI test suites.
+
+## Pipeline steps
+
+Three steps run in order:
+
+**`pre-commit-npu`** runs the pre-commit gate on all files, always.
+
+**`npu-gate`** is a block step that pauses the pipeline for manual interaction.
+It appears only on PR triggers (`build.source != "schedule"`). You select which
+NPU suites to run via a multi-select field:
+
+- **`image-build`** — triggers a fresh image build in the `image-build-npu` step;
+ when selected, `smk` is automatically included and all test steps use the newly
+ built image instead of the pre-built default.
+- **`smk`** — runs the smoke test suite.
+- **`nightly`** — runs the nightly test suite.
+
+**`image-build-npu`** the `image-build-npu` step will build and
+push a fresh NPU test image tagged with the current commit. When `image-build`
+is selected, all test steps generated by `upload-npu-suites` use the newly built
+image instead of the pre-built default image.
+
+This allows testing code changes that require an updated NPU environment
+(e.g., modifications to `docker/Dockerfile.npu`) before they are merged.
+
+**`upload-npu-suites`** reads the `NPU_SUITES` environment variable (or
+`buildkite-agent meta-data get npu-suites` for PR triggers) and generates
+individual test jobs via [`npu_suites.py`](./npu_suites.py).
+
+## Triggers
+
+The pipeline supports two trigger modes:
+
+**PR trigger.** The `npu-gate` block step appears for manual suite selection.
+Suites not selected in the block step are skipped. The `image-build-npu` step
+builds a new image only when `image-build` is chosen in the block.
+
+**Schedule trigger.** There is no block step — the `npu-gate` step is omitted
+entirely. A new image is **always** built, and the suites are determined by the
+`NPU_SUITES` environment variable (set in the scheduled build's pipeline
+configuration), which lists suite names from the `SUITES` dict.
+
+## Adding a test
+
+Suites and test mappings are defined in [`npu_suites.py`](./npu_suites.py). Two
+suites are predefined — `smk` (runs with the `run-ci-npu-smk` label) and `nightly`
+(runs on schedule or with the `run-ci-npu-nightly` label).
+
+Each entry is a 4-tuple:
+
+```python
+("test-qwen3-4B-npu.py", "npu-8", "", {})
+# (test_name, resource_class, extra_args, env_overrides)
+```
+
+- **`test_name`** — the test script under `tests/`, e.g. `test-qwen3-4B-npu.py`.
+- **`resource_class`** — NPU count for the pod (`npu-2`, `npu-4`, `npu-8`,
+ `npu-16`).
+- **`extra_args`** — extra CLI arguments passed to the test script.
+- **`env_overrides`** — extra environment variables for the test step, e.g.
+ `{"USE_DEEPEP": "1"}`.
+
+A test script should:
+
+1. **Download models and datasets to `HF_HOME`** before training so subsequent
+ steps reuse the cache. The CI sets `HF_HOME=/root/.cache/huggingface`.
+
+2. **Run training** via `train.py` or `train_async.py` with the appropriate
+ NPU-specific arguments.
+
+To register a new suite, add a key to the `SUITES` dict and update
+`selected_suites()` if it should run conditionally. Also add a corresponding
+option in the block step's `fields[].options` list in
+[`pipeline-npu.yaml`](./pipeline-npu.yaml) so it can be selected in the
+Buildkite UI.
+
+To add a test to an existing suite, simply append a new entry tuple to its
+list in `SUITES`.
+
+## Adding or removing a patch
+
+Patches are applied explicitly by [`docker/Dockerfile.npu`](../docker/Dockerfile.npu)
+and reconciled at runtime in the order declared by
+[`docker/npu_patch/series.conf`](../docker/npu_patch/series.conf).
+Each entry declares `target_worktree|image_patch|source_patch`: the image keeps
+the flat `image_patch` under `/opt/npu_patch`, while runtime CI reads
+`source_patch` relative to the current VIME checkout.
+
+When adding a patch file, add the matching Dockerfile COPY/apply operations and
+the `series.conf` entry in the same order. When deleting a patch, remove the
+Dockerfile operation and `series.conf` entry before deleting the patch file;
+the previous image retains the OLD bytes required for runtime revert. Rename
+and reorder changes must likewise update both declarations. Ordinary patch
+changes must not add patch-specific branches to the CI script.
+
+Patch-only changes reuse the default image and are reconciled when the test
+container starts. Changes that add, remove, or upgrade external repositories,
+modify installed dependencies, or otherwise change the Docker image must be
+validated with `image-build`.
+
+The initial rollout of this mechanism requires publishing a new default image
+that contains `/opt/npu_patch/series.conf`, then updating
+`DEFAULT_CI_IMAGE` in `npu_suites.py`. The legacy default image contains patch
+bytes under `/tmp/npu_patch` but not the OLD series required by this reconciler.
diff --git a/.buildkite/npu_suites.py b/.buildkite/npu_suites.py
new file mode 100644
index 000000000..97517f9dd
--- /dev/null
+++ b/.buildkite/npu_suites.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""Emit Buildkite steps for the NPU suites.
+
+Piped into `buildkite-agent pipeline upload` by the npu step in pipeline.yml.
+The suites and their configurations are defined here.
+
+NPU jobs run on the ascend-a3 queue with resource_class determining NPU count
+(e.g., "npu-8" means 8 NPUs).
+
+The selection is read from the NPU_SUITES environment variable. For local
+testing, set NPU_SUITES=smk,nightly instead of having a buildkite-agent on PATH.
+
+stdlib only — runs with the agent host's python3.
+"""
+
+import json
+import os
+import subprocess
+import sys
+
+NPU_QUEUE = "ascend-a3"
+DEFAULT_CI_IMAGE = "quay.io/ascend/vime:vime-latest"
+IMAGE_REGISTRY = "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry"
+IMAGE_NAME = "vime-ci-npu"
+VIME_IMAGE_TAG = os.environ.get("BUILDKITE_COMMIT", "latest")
+BUILDKITE_SOURCE = os.environ.get("BUILDKITE_SOURCE", "")
+
+# (test_name, resource_class, extra_args, env_overrides)
+SUITES = {
+ "smk": [
+ ("test_qwen3_4B_npu.py", "npu-8", "", {}),
+ ("test_qwen3_30B_A3B_npu.py", "npu-16", "", {}),
+ ("test_qwen3_vl_8B_npu.py", "npu-8", "", {}),
+ ("test_glm4.7_30B_A3B_npu.py", "npu-16", "", {}),
+ ],
+ "nightly": [],
+}
+
+
+def _read_suite_values() -> list[str]:
+ raw = os.environ.get("NPU_SUITES")
+ if raw is None:
+ try:
+ raw = subprocess.run(
+ ["buildkite-agent", "meta-data", "get", "npu-suites"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout
+ except subprocess.CalledProcessError:
+ raw = ""
+ return [v.strip() for v in raw.replace(",", "\n").splitlines()]
+
+
+def _ci_image() -> str:
+ values = _read_suite_values()
+ if ("image-build" in values) or (BUILDKITE_SOURCE == "schedule"):
+ return f"{IMAGE_REGISTRY}/{IMAGE_NAME}:{VIME_IMAGE_TAG}"
+ return DEFAULT_CI_IMAGE
+
+
+def selected_suites() -> list:
+ values = _read_suite_values()
+ unknown = [v for v in values if v and v not in SUITES and v != "image-build"]
+ if unknown:
+ raise SystemExit(f"unknown suite(s) {unknown}; expected {sorted(SUITES)}")
+ if "image-build" in values:
+ # image-build auto-includes smk tests
+ values.append("smk")
+ return [s for s in SUITES if s in values]
+
+
+def npu_step(suite: str, test_name: str, resource_class: str, extra_args: str, env: dict) -> dict:
+ step_env = {
+ "VIME_TEST_ENABLE_INFINITE_RUN": "false",
+ "BUILDKITE_PULL_REQUEST": os.environ.get("BUILDKITE_PULL_REQUEST", "false"),
+ "BUILDKITE_COMMIT": os.environ.get("BUILDKITE_COMMIT", ""),
+ "HF_TOKEN": "${HF_TOKEN}",
+ "HF_ENDPOINT": "https://hf-mirror.com",
+ "ASCEND_RT_VISIBLE_DEVICES": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15",
+ "IMAGE_REGISTRY": IMAGE_REGISTRY,
+ "IMAGE_NAME": IMAGE_NAME,
+ "VIME_IMAGE_TAG": VIME_IMAGE_TAG,
+ "HF_HOME": "/root/.cache/huggingface",
+ **env,
+ }
+
+ commands = "\n".join(
+ [
+ 'echo "INFO: update NPU environment"',
+ 'if [ -n "${BUILDKITE_COMMIT}" ]; then',
+ " source /workspace/build/buildkite/.buildkite/scripts/update-npu-environment.sh",
+ "fi",
+ "export HF_HUB_OFFLINE=0",
+ f"python tests/{test_name}{' ' + extra_args if extra_args else ''}",
+ ]
+ )
+ command = f"bash -c '{commands}'"
+
+ label = f":fire: {suite}: {test_name}{' ' + extra_args if extra_args else ''}"
+ step = {
+ "label": label,
+ "depends_on": "image-build-npu",
+ "command": command,
+ "agents": {
+ "queue": NPU_QUEUE,
+ "resource_class": resource_class,
+ },
+ "timeout_in_minutes": 180,
+ "image": _ci_image(),
+ "plugins": [
+ {
+ "kubernetes": {
+ "podSpecPatch": {
+ "imagePullSecrets": [{"name": "swr-secret"}],
+ },
+ }
+ }
+ ],
+ "env": step_env,
+ }
+ return step
+
+
+def main() -> None:
+ steps = [npu_step(suite, *entry) for suite in selected_suites() for entry in SUITES[suite]]
+ json_str = json.dumps({"steps": steps}, indent=2)
+
+ print("--- Generated Pipeline JSON:", file=sys.stderr)
+ print(json_str, file=sys.stderr)
+
+ print(json_str)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.buildkite/pipeline-npu-image.yaml b/.buildkite/pipeline-npu-image.yaml
new file mode 100644
index 000000000..22b9ca82e
--- /dev/null
+++ b/.buildkite/pipeline-npu-image.yaml
@@ -0,0 +1,88 @@
+steps:
+ - label: ":docker: Build and Push NPU Test Image - A3"
+ key: image-build-npu
+ depends_on:
+ - pre-commit-npu
+ - npu-gate
+ timeout_in_minutes: 240
+ skip: __SKIP_IMAGE_BUILD__
+ agents:
+ queue: "ascend-a3"
+ resource_class: "npu-2"
+ plugins:
+ - kubernetes:
+ metadata:
+ annotations:
+ vault.hashicorp.com/agent-init-first: "true"
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/agent-inject-perms-ca.pem: "0400"
+ vault.hashicorp.com/agent-inject-perms-cert.pem: "0400"
+ vault.hashicorp.com/agent-inject-perms-config.json: "0400"
+ vault.hashicorp.com/agent-inject-perms-key.pem: "0400"
+ vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd
+ vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd
+ vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd
+ vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd
+ vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}"
+ vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}"
+ vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}"
+ vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/agent-run-as-group: "1000"
+ vault.hashicorp.com/agent-run-as-user: "1000"
+ vault.hashicorp.com/agent-service-account-token-volume-name: token-vol
+ vault.hashicorp.com/role: ascend-gha-runners
+ vault.hashicorp.com/secret-volume-path: /home/user/.docker/
+ vault.hashicorp.com/tls-skip-verify: "true"
+ podSpecPatch:
+ volumes:
+ - name: token-vol
+ projected:
+ defaultMode: 420
+ sources:
+ - serviceAccountToken:
+ audience: api
+ expirationSeconds: 600
+ path: token
+ env:
+ VIME_IMAGE_TAG: "${BUILDKITE_COMMIT}"
+ IMAGE_NAME: "vime-ci-npu"
+ IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry"
+ BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234"
+ command: |
+ set -ex
+ echo "--- Building and pushing NPU Test Image"
+ echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VIME_IMAGE_TAG}"
+ echo "buildkitd address: $${BUILDKITD_ADDR}"
+
+ if ! command -v buildctl &> /dev/null; then
+ echo "Installing buildctl..."
+ mkdir -p /tmp/buildkit
+ BUILDKIT_VERSION="v0.29.0"
+ wget -q "https://gh-proxy.test.osinfra.cn/https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz
+ tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit
+ cp /tmp/buildkit/bin/buildctl /usr/local/bin/
+ fi
+
+ sed -i '/^RUN git config --global http.sslVerify false/i RUN git config --global url."https://gh-proxy.test.osinfra.cn/https://github.com/".insteadOf "https://github.com/"' docker/Dockerfile.npu
+ sed -i '/^# syntax=docker\/dockerfile:1\.7$$/d' docker/Dockerfile.npu
+
+ export DOCKER_CONFIG=/home/user/.docker
+ buildctl \
+ --addr="$${BUILDKITD_ADDR}" \
+ --tlscacert=/home/user/.docker/ca.pem \
+ --tlscert=/home/user/.docker/cert.pem \
+ --tlskey=/home/user/.docker/key.pem \
+ build \
+ --frontend dockerfile.v0 \
+ --local context=. \
+ --local dockerfile=./docker \
+ --opt filename=Dockerfile.npu \
+ --opt build-arg:APTMIRROR=http://cache-service.nginx-pypi-cache.svc.cluster.local:8081 \
+ --opt build-arg:PIP_INDEX_URL=http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
+ --secret id=dockerconfig,src=/home/user/.docker/config.json \
+ --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VIME_IMAGE_TAG},push=true \
+ --progress=plain
+
+ echo "--- Image pushed successfully"
+ echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VIME_IMAGE_TAG}"
\ No newline at end of file
diff --git a/.buildkite/pipeline-npu.yaml b/.buildkite/pipeline-npu.yaml
new file mode 100644
index 000000000..244e860c6
--- /dev/null
+++ b/.buildkite/pipeline-npu.yaml
@@ -0,0 +1,73 @@
+# Usage: buildkite-agent pipeline upload .buildkite/pipeline-npu.yaml
+#
+# This pipeline is for running NPU tests on PRs.
+# Scheduled builds always build a fresh image. PR builds reuse DEFAULT_CI_IMAGE
+# unless image-build is selected; patch-only changes are reconciled at runtime.
+
+steps:
+ - group: ":pipeline: Run NPU test"
+ key: npu-tests
+ steps:
+ # Before run any test, check pre-commit
+ - label: ":lint-roller: pre-commit-npu"
+ key: pre-commit-npu
+ agents:
+ queue: small_cpu_queue_premerge
+ timeout_in_minutes: 15
+ retry:
+ automatic:
+ - exit_status: -1 # agent lost (fresh instance failed to boot)
+ limit: 2
+ command: |
+ docker run --rm \
+ -e GIT_CONFIG_PARAMETERS="'safe.directory=/workspace'" \
+ -v "$$PWD:/workspace" -w /workspace \
+ python:3.11 bash -c '
+ set -euo pipefail
+ pip install -q pre-commit
+ pre-commit run --all-files --show-diff-on-failure --color=always
+ '
+
+ - block: ":rocket: Run NPU test suites?"
+ key: npu-gate
+ depends_on: pre-commit-npu
+ if: build.source != "schedule"
+ blocked_state: passed
+ prompt: "Select the npu suites to run."
+ fields:
+ - select: "NPU suites"
+ key: npu-suites
+ multiple: true
+ required: true
+ options:
+ - label: "run-npu-ci-image-build (test new image, auto include smk)"
+ value: image-build
+ - label: "run-npu-ci-smk"
+ value: smk
+ - label: "run-npu-ci-nightly"
+ value: nightly
+
+ - label: ":pipeline: upload NPU suites"
+ key: upload-npu-suites
+ depends_on:
+ - npu-gate
+ agents:
+ queue: "ascend-a3"
+ resource_class: "npu-2"
+ timeout_in_minutes: 180
+ command: |
+ NPU_SUITES=$$(buildkite-agent meta-data get "npu-suites" --default "")
+ if [[ "$$BUILDKITE_SOURCE" == "schedule" ]]; then
+ echo "Scheduled build — building the image."
+ echo "BUILDKITE_SOURCE: $$BUILDKITE_SOURCE"
+ SKIP_IMAGE_BUILD=false
+ elif [[ "$$NPU_SUITES" == *"image-build"* ]]; then
+ echo "image-build selected — building the image."
+ echo "NPU_SUITES: $$NPU_SUITES"
+ SKIP_IMAGE_BUILD=false
+ else
+ echo "Skipping image build because image-build is not present in the npu-suites."
+ SKIP_IMAGE_BUILD=true
+ fi
+ sed -e "s/__SKIP_IMAGE_BUILD__/$${SKIP_IMAGE_BUILD}/g" .buildkite/pipeline-npu-image.yaml | buildkite-agent pipeline upload
+ python .buildkite/npu_suites.py | buildkite-agent pipeline upload
diff --git a/.buildkite/scripts/update-npu-environment.sh b/.buildkite/scripts/update-npu-environment.sh
new file mode 100644
index 000000000..761f2406d
--- /dev/null
+++ b/.buildkite/scripts/update-npu-environment.sh
@@ -0,0 +1,238 @@
+#!/bin/bash
+# Purpose: Updates an NPU test container to match the requested VIME commit.
+# - Reads the image's persisted OLD patch series and exact patch bytes
+# - Updates VIME, then reconciles OLD -> NEW in declared series order
+# - Installs the current VIME checkout and normalizes visible devices
+# Usage: Called by Buildkite pipeline during NPU test runs
+set -e -o pipefail
+
+VIME_DIR="${VIME_DIR:-/root/vime}"
+VIME_NPU_PATCH_STATE_DIR="${VIME_NPU_PATCH_STATE_DIR:-/opt/npu_patch}"
+VIME_NPU_PATCH_SOURCE_ROOT="${VIME_NPU_PATCH_SOURCE_ROOT:-${VIME_DIR}}"
+PATCH_SERIES_RELATIVE_PATH="docker/npu_patch/series.conf"
+
+sha256_stdin() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum | awk '{print $1}'
+ else
+ shasum -a 256 | awk '{print $1}'
+ fi
+}
+
+sha256_file() {
+ local path="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$path" | awk '{print $1}'
+ else
+ shasum -a 256 "$path" | awk '{print $1}'
+ fi
+}
+
+SERIES_ENTRIES=()
+
+load_series() {
+ local series_file="$1"
+ local line target image_patch source_patch extra
+
+ SERIES_ENTRIES=()
+ if [ ! -f "$series_file" ]; then
+ echo "ERROR: Patch series not found: $series_file" >&2
+ return 1
+ fi
+
+ while IFS= read -r line || [ -n "$line" ]; do
+ if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
+ continue
+ fi
+
+ IFS='|' read -r target image_patch source_patch extra <<< "$line"
+ if [ -z "$target" ] || [ -z "$image_patch" ] || [ -z "$source_patch" ] || [ -n "$extra" ]; then
+ echo "ERROR: Invalid patch series entry: $line" >&2
+ return 1
+ fi
+ if [[ "$image_patch" = */* ]]; then
+ echo "ERROR: Image patch must be a flat file name: $image_patch" >&2
+ return 1
+ fi
+ if [[ "$source_patch" = /* || "/$source_patch/" = *"/../"* ]]; then
+ echo "ERROR: Source patch must stay under the VIME root: $source_patch" >&2
+ return 1
+ fi
+ SERIES_ENTRIES+=("${target}|${image_patch}|${source_patch}")
+ done < "$series_file"
+}
+
+validate_series_entry() {
+ local target="$1"
+ local patch_path="$2"
+
+ if ! git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "ERROR: Patch target is not a Git worktree: $target" >&2
+ return 1
+ fi
+ if [ ! -f "$patch_path" ]; then
+ echo "ERROR: Patch file not found: $patch_path" >&2
+ return 1
+ fi
+}
+
+series_digest() {
+ local series_file="$1"
+ local patch_root="$2"
+ local path_kind="$3"
+ local entry target image_patch source_patch patch_ref patch_path patch_sha
+
+ load_series "$series_file"
+ for entry in "${SERIES_ENTRIES[@]}"; do
+ IFS='|' read -r target image_patch source_patch <<< "$entry"
+ case "$path_kind" in
+ image) patch_ref="$image_patch" ;;
+ source) patch_ref="$source_patch" ;;
+ *) echo "ERROR: Unknown patch path kind: $path_kind" >&2; return 1 ;;
+ esac
+ patch_path="${patch_root}/${patch_ref}"
+ if [ ! -f "$patch_path" ]; then
+ echo "ERROR: Patch file not found: $patch_path" >&2
+ return 1
+ fi
+ done
+
+ {
+ printf 'vime-npu-patch-series-v1\0'
+ for entry in "${SERIES_ENTRIES[@]}"; do
+ IFS='|' read -r target image_patch source_patch <<< "$entry"
+ if [ "$path_kind" = "image" ]; then
+ patch_ref="$image_patch"
+ else
+ patch_ref="$source_patch"
+ fi
+ patch_path="${patch_root}/${patch_ref}"
+ patch_sha=$(sha256_file "$patch_path")
+ printf '%s\0%s\0%s\0%s\0' "$target" "$image_patch" "$source_patch" "$patch_sha"
+ done
+ } | sha256_stdin
+}
+
+apply_series() {
+ local series_file="$1"
+ local source_root="$2"
+ local entry target image_patch source_patch patch_path
+
+ load_series "$series_file"
+ for entry in "${SERIES_ENTRIES[@]}"; do
+ IFS='|' read -r target image_patch source_patch <<< "$entry"
+ patch_path="${source_root}/${source_patch}"
+ validate_series_entry "$target" "$patch_path"
+ echo "INFO: Applying $source_patch to $target"
+ git -C "$target" apply --whitespace=nowarn "$patch_path"
+ done
+}
+
+revert_series() {
+ local series_file="$1"
+ local image_root="$2"
+ local i entry target image_patch source_patch patch_path
+
+ load_series "$series_file"
+ for ((i=${#SERIES_ENTRIES[@]}-1; i>=0; i--)); do
+ entry="${SERIES_ENTRIES[$i]}"
+ IFS='|' read -r target image_patch source_patch <<< "$entry"
+ patch_path="${image_root}/${image_patch}"
+ validate_series_entry "$target" "$patch_path"
+ echo "INFO: Reverting $image_patch from $target"
+ git -C "$target" apply --reverse --whitespace=nowarn "$patch_path"
+ done
+}
+
+reconcile_series() {
+ local old_series="$1"
+ local old_root="$2"
+ local new_series="$3"
+ local new_root="$4"
+ local old_digest new_digest
+
+ old_digest=$(series_digest "$old_series" "$old_root" image)
+ new_digest=$(series_digest "$new_series" "$new_root" source)
+ if [ "$old_digest" = "$new_digest" ]; then
+ echo "INFO: Patch series is unchanged"
+ return
+ fi
+
+ revert_series "$old_series" "$old_root"
+ apply_series "$new_series" "$new_root"
+}
+
+update_vime_code() {
+ echo "INFO: Updating VIME code..."
+
+ if [ -n "${BUILDKITE_COMMIT:-}" ]; then
+ echo "INFO: Fetching and checking out commit ${BUILDKITE_COMMIT}"
+ git -C "$VIME_DIR" fetch origin "${BUILDKITE_COMMIT}"
+ git -C "$VIME_DIR" checkout "${BUILDKITE_COMMIT}"
+ else
+ echo "INFO: BUILDKITE_COMMIT not set, skipping code update"
+ fi
+}
+
+install_vime_code() {
+ pip install -e "$VIME_DIR" --no-deps --break-system-packages || pip install -e "$VIME_DIR" --no-deps
+}
+
+sort_ascend_visible_devices() {
+ export ASCEND_VISIBLE_DEVICES="${ASCEND_VISIBLE_DEVICES:-${ASCEND_RT_VISIBLE_DEVICES:-}}"
+ echo "Value: ${ASCEND_VISIBLE_DEVICES}"
+ if [ -n "${ASCEND_VISIBLE_DEVICES}" ]; then
+ SORTED_DEVICES=$(echo "${ASCEND_VISIBLE_DEVICES}" | tr ',' '\n' | sort -n | tr '\n' ',')
+ SORTED_DEVICES=${SORTED_DEVICES%,}
+ export ASCEND_VISIBLE_DEVICES=$SORTED_DEVICES
+ echo "Sorted ASCEND_VISIBLE_DEVICES: $ASCEND_VISIBLE_DEVICES"
+ fi
+}
+
+main() {
+ local old_series="${VIME_NPU_PATCH_STATE_DIR}/series.conf"
+ local new_series="${VIME_NPU_PATCH_SOURCE_ROOT}/${PATCH_SERIES_RELATIVE_PATH}"
+
+ echo "=== Step 1: Sort ASCEND_VISIBLE_DEVICES ==="
+ sort_ascend_visible_devices
+
+ echo "=== Step 2: Update VIME code ==="
+ update_vime_code
+
+ if [ ! -f "$old_series" ]; then
+ echo "ERROR: The selected image does not contain NPU patch state: $old_series" >&2
+ echo "ERROR: Build a patch-state-enabled NPU image before running this commit." >&2
+ return 1
+ fi
+
+ echo "=== Step 3: Reconcile image patches with current VIME patches ==="
+ reconcile_series "$old_series" "$VIME_NPU_PATCH_STATE_DIR" "$new_series" "$VIME_NPU_PATCH_SOURCE_ROOT"
+
+ echo "=== Step 4: Install current VIME code ==="
+ install_vime_code
+
+ echo "INFO: NPU environment update completed successfully"
+}
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ case "${1:-}" in
+ series-digest)
+ if [ "$#" -ne 4 ]; then
+ echo "Usage: $0 series-digest SERIES_FILE PATCH_ROOT image|source" >&2
+ exit 2
+ fi
+ series_digest "$2" "$3" "$4"
+ exit
+ ;;
+ reconcile)
+ if [ "$#" -ne 5 ]; then
+ echo "Usage: $0 reconcile OLD_SERIES OLD_ROOT NEW_SERIES NEW_ROOT" >&2
+ exit 2
+ fi
+ reconcile_series "$2" "$3" "$4" "$5"
+ exit
+ ;;
+ esac
+fi
+
+main
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..9812ceb1f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.patch text eol=lf
diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu
new file mode 100644
index 000000000..f9e42c9aa
--- /dev/null
+++ b/docker/Dockerfile.npu
@@ -0,0 +1,148 @@
+# syntax=docker/dockerfile:1.7
+
+ARG BASE_IMAGE=quay.io/atlas-ci/vllm-ascend
+ARG BASE_IMAGE_TAG=v0.28.0-fd81546-a3
+FROM ${BASE_IMAGE}:${BASE_IMAGE_TAG}
+
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+WORKDIR /root
+
+ARG MEGATRON_COMMIT=1dcf0dafa884ad52ffb243625717a3471643e087
+ARG MEGATRON_BRIDGE_COMMIT=3fd3768045422d0aa5c97e90a4e6c659aea9acb9
+ARG MINDSPEED_COMMIT=fc63de5c48426dd019c3b3f39e65f5bdf56e4086
+ARG MEGATRON_ADAPTOR_COMMIT=15582addff3f3d4680e350826fa70d012b475509
+ARG TRANSFORMER_ENGINE_NPU_COMMIT=d743c83d060d5edc48867ecb9e93ec80d81860e4
+ARG MBRIDGE_COMMIT=89eb10887887bc74853f89a4de258c0702932a1c
+ARG SOC_VERSION="ascend910_9391"
+ARG PIP_INDEX_URL="https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
+ARG APTMIRROR=""
+
+ENV SOC_VERSION=$SOC_VERSION \
+ DEBIAN_FRONTEND=noninteractive \
+ PIP_NO_CACHE_DIR=1 \
+ ASCEND_TOOLKIT_HOME=/usr/local/Ascend/ascend-toolkit/latest \
+ ASCEND_OPP_PATH=/usr/local/Ascend/ascend-toolkit/latest/opp \
+ ASCEND_AICPU_PATH=/usr/local/Ascend/ascend-toolkit/latest \
+ ASCEND_HOME_PATH=/usr/local/Ascend/ascend-toolkit/latest \
+ VLLM_ASCEND_ENABLE_NZ=0 \
+ RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1 \
+ HCCL_HOST_SOCKET_PORT_RANGE=60000-60050 \
+ HCCL_NPU_SOCKET_PORT_RANGE=61000-61050 \
+ PYTORCH_NPU_ALLOC_CONF=expandable_segments:True \
+ HYDRA_FULL_ERROR=1 \
+ PYTHONPATH=/root/Megatron-Bridge/src:/root/Megatron-LM:/root/MegatronAdaptor:/root/TransformerEngineNPU:/root/vime
+
+# PATCH MAINTENANCE: keep patch COPY/apply operations and
+# docker/npu_patch/series.conf synchronized.
+COPY docker/npu_patch /opt/npu_patch
+COPY docker/patch/latest/megatron.patch /opt/npu_patch/megatron-common.patch
+
+RUN git config --global http.sslVerify false
+
+# System and pip dependencies.
+RUN if [ -n "$APTMIRROR" ];then sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1$APTMIRROR@g" /etc/apt/sources.list; \
+ else sed -i '/ports\.ubuntu\.com/ {h;s|ports\.ubuntu\.com|mirrors.tuna.tsinghua.edu.cn|g;G}' /etc/apt/sources.list; fi && \
+ apt-get update && \
+ apt-get install -y --no-install-recommends \
+ build-essential cmake curl git libnuma-dev ninja-build patch rsync wget && \
+ rm -rf /var/lib/apt/lists/* && \
+ pip config set global.index-url ${PIP_INDEX_URL} && \
+ if [ "${PIP_INDEX_URL#http://}" != "${PIP_INDEX_URL}" ]; then pip config set global.trusted-host "$(echo ${PIP_INDEX_URL} | awk -F/ '{print $3}')"; fi && \
+ pip config set global.extra-index-url \
+ "https://download.pytorch.org/whl/cpu/ https://mirrors.huaweicloud.com/ascend/repos/pypi https://mirrors.aliyun.com/pypi/simple/" && \
+ git config --global http.version HTTP/1.1
+
+# vllm and vllm-ascend are installed editable (-e) in the base image; apply the
+# NPU colocate patches directly to their source trees.
+RUN git -C /vllm-workspace/vllm apply --check --whitespace=nowarn \
+ /opt/npu_patch/vllm.patch && \
+ git -C /vllm-workspace/vllm apply --whitespace=nowarn \
+ /opt/npu_patch/vllm.patch
+
+RUN git -C /vllm-workspace/vllm-ascend apply --check --whitespace=nowarn \
+ /opt/npu_patch/vllm-ascend.patch && \
+ git -C /vllm-workspace/vllm-ascend apply --whitespace=nowarn \
+ /opt/npu_patch/vllm-ascend.patch
+
+# Protect the serving stack while installing Vime dependencies.
+RUN python3 -c 'import importlib.metadata as m; names = ["numpy", "ray", "torch", "torch-npu", "torchvision", "transformers", "triton-ascend", "vllm", "vllm-ascend"]; open("/tmp/vime-npu-constraints.txt", "w").write("\n".join(f"{name}=={m.version(name)}" for name in names) + "\n")'
+
+# Training source dependencies.
+RUN git clone https://github.com/NVIDIA/Megatron-LM.git /root/Megatron-LM && \
+ git -C /root/Megatron-LM checkout "${MEGATRON_COMMIT}"
+
+RUN git clone --branch bridge https://github.com/radixark/Megatron-Bridge.git \
+ /root/Megatron-Bridge && \
+ git -C /root/Megatron-Bridge checkout "${MEGATRON_BRIDGE_COMMIT}"
+
+RUN git clone https://gitcode.com/Ascend/MindSpeed.git /root/MindSpeed && \
+ git -C /root/MindSpeed checkout "${MINDSPEED_COMMIT}"
+
+RUN git clone https://gitcode.com/Ascend/MegatronAdaptor.git /root/MegatronAdaptor && \
+ git -C /root/MegatronAdaptor checkout "${MEGATRON_ADAPTOR_COMMIT}" && \
+ git clone https://gitcode.com/Ascend/TransformerEngineNPU.git /root/TransformerEngineNPU && \
+ git -C /root/TransformerEngineNPU checkout "${TRANSFORMER_ENGINE_NPU_COMMIT}"
+
+RUN git clone https://github.com/ISEEKYAN/mbridge.git /root/mbridge && \
+ git -C /root/mbridge checkout "${MBRIDGE_COMMIT}"
+
+# Apply NPU training-stack patches from the build-context snapshot.
+# The NPU Megatron patch is based on the common Vime Megatron patch, so the
+# common patch must be applied first.
+RUN git -C /root/Megatron-LM apply --check --whitespace=nowarn \
+ /opt/npu_patch/megatron-common.patch && \
+ git -C /root/Megatron-LM apply --whitespace=nowarn \
+ /opt/npu_patch/megatron-common.patch && \
+ git -C /root/Megatron-LM apply --check --whitespace=nowarn \
+ /opt/npu_patch/megatron.patch && \
+ git -C /root/Megatron-LM apply --whitespace=nowarn \
+ /opt/npu_patch/megatron.patch && \
+ git -C /root/Megatron-Bridge apply --check --whitespace=nowarn \
+ /opt/npu_patch/megatron-bridge.patch && \
+ git -C /root/Megatron-Bridge apply --whitespace=nowarn \
+ /opt/npu_patch/megatron-bridge.patch && \
+ git -C /root/MindSpeed apply --whitespace=nowarn \
+ /opt/npu_patch/mindspeed.patch
+
+# Megatron-Bridge is used directly from PYTHONPATH. Installing its package
+# metadata would pull CUDA-only dependencies into the Ascend environment.
+RUN pip install --constraint /tmp/vime-npu-constraints.txt --no-build-isolation \
+ "nvidia-modelopt==0.46.0" "nvdlfw-inspect==0.2.2" && \
+ pip install --no-deps --no-build-isolation -e /root/mbridge && \
+ pip install --no-deps --no-build-isolation -e /root/Megatron-LM && \
+ pip install --no-deps --no-build-isolation -e /root/TransformerEngineNPU && \
+ pip install --no-deps --no-build-isolation -e /root/MegatronAdaptor && \
+ pip install --no-deps --no-build-isolation -e /root/MindSpeed
+
+# Defaults to the ascend branch for local builds. Release workflows should
+# pass an immutable commit SHA for reproducible images.
+ARG VIME_COMMIT=ascend
+RUN test -n "${VIME_COMMIT}" && \
+ git init /root/vime && \
+ git -C /root/vime remote add origin https://github.com/vllm-project/vime.git && \
+ git -C /root/vime fetch --depth 1 origin "${VIME_COMMIT}" && \
+ git -C /root/vime checkout --detach FETCH_HEAD
+
+# ring_flash_attn is a CUDA extension and is not used on Ascend.
+RUN pip install \
+ --constraint /tmp/vime-npu-constraints.txt \
+ --requirement /root/vime/requirements.txt && \
+ pip install --no-deps --no-build-isolation -e /root/vime
+
+# Vime currently imports torch_memory_saver from its training actor.
+RUN git clone --depth 1 --branch 2026.6.0 \
+ https://github.com/sgl-project/sgl-kernel-npu.git /root/sgl-kernel-npu && \
+ cd /root/sgl-kernel-npu/contrib/torch_memory_saver/python && \
+ python3 setup.py bdist_wheel && \
+ python3 -m pip install --no-deps \
+ dist/torch_memory_saver-*.whl && \
+ cd /root && \
+ rm -rf /root/sgl-kernel-npu
+
+# Minimal import check.
+RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
+ python3 -c 'import megatron, mindspeed, megatron_adaptor, transformer_engine, torch_memory_saver, vime, vllm, vllm_ascend;'
+
+WORKDIR /root/vime
+ENTRYPOINT []
+CMD ["/bin/bash"]
diff --git a/docker/npu_patch/README.md b/docker/npu_patch/README.md
new file mode 100644
index 000000000..e81b1daaf
--- /dev/null
+++ b/docker/npu_patch/README.md
@@ -0,0 +1,155 @@
+# Vime NPU Patch Installation Guide
+
+This guide provides instructions for installing Vime with NPU support, including all required dependencies and patches.
+
+> S7 closeout (2026-09-09): retain Ascend #385 (training stack) and #396
+> (torch_dist/ref-load), with native HF loading and main's shared orchestration.
+> Revert #409 (`f5b84916`) and its follow-up Qwen3.5 NPU adaptations; defer that
+> model to the next stage in a fresh, matched environment. Main's Qwen3.5 model
+> code is retained. Existing Qwen3-4B, Qwen3-30B-A3B, Qwen3-VL-8B and the 30B
+> torch_dist/ref-load run passed before the Qwen3.5 environment changes; this
+> does not certify a fresh image or a post-revert E2E run. No installed packages
+> or vendor source trees are rolled back as part of this source-only closeout.
+> Post-revert checks: 183 grouped CPU tests passed. Common → NPU Megatron
+> patches and the reverted Bridge patch pass apply checks on their pinned
+> clean source revisions. Serving patches and `docker/patch/latest` are unchanged.
+
+## Component Version Mapping
+
+| Component | Version/Commit | Source |
+| --------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| vime | main | [GitHub](https://github.com/vllm-project/vime/tree/main) |
+| vLLM | e6bfe03ad73a3330cb427885aa90d97a12e1c704 + NPU patch | S6 serving baseline, retained for S7 |
+| vLLM-Ascend | fd815467c221ee600137f6bdd53fe354d5e7c999 + NPU patch | S6 serving baseline, retained for S7 |
+| Megatron-Bridge | 3fd3768045422d0aa5c97e90a4e6c659aea9acb9 | [GitHub](https://github.com/radixark/Megatron-Bridge) |
+| Megatron-LM | 1dcf0dafa884ad52ffb243625717a3471643e087 | [GitHub](https://github.com/NVIDIA/Megatron-LM) |
+| MegatronAdaptor | 15582addff3f3d4680e350826fa70d012b475509 | [GitCode](https://gitcode.com/Ascend/MegatronAdaptor) |
+| TransformerEngineNPU | d743c83d060d5edc48867ecb9e93ec80d81860e4 | [GitCode](https://gitcode.com/Ascend/TransformerEngineNPU) |
+| MindSpeed | fc63de5c48426dd019c3b3f39e65f5bdf56e4086 | [GitCode](https://gitcode.com/Ascend/MindSpeed) |
+| HDK | 25.3.RC1 | [Ascend](https://www.hiascend.com/hardware/firmware-drivers/commercial?product=7\&model=33) |
+| CANN | 9.0.0 | [Ascend](https://www.hiascend.com/developer/download/community/result?module=cann\&cann=9.0.0\&product=7\&model=33) |
+
+## Preparing the Running Environment
+
+Run the steps below in a Python 3.12 environment with CANN 9.0.0. A
+`quay.io/ascend/vllm-ascend:nightly-main-a3` container can be used as the base.
+
+```bash
+export WORKSPACE=/root
+cd "${WORKSPACE}"
+```
+
+Vime's Ascend NPU adaptation lives on the **`ascend`** branch, so clone that
+branch (not `main`):
+
+```bash
+git clone --branch ascend https://github.com/vllm-project/vime.git "${WORKSPACE}/vime"
+export PATCH_DIR="${WORKSPACE}/vime/docker/npu_patch"
+```
+
+#### 1. Megatron-Bridge (legacy build dependency, not the native loader)
+
+The source PR used this via `PYTHONPATH` (no editable install) and required
+`nvidia-modelopt`. This is not a prerequisite for Vime's native HF loader;
+whether to retain it in the S7 image remains under review.
+
+```bash
+export MEGATRON_BRIDGE_COMMIT=3fd3768045422d0aa5c97e90a4e6c659aea9acb9
+export MBRIDGE_COMMIT=89eb10887887bc74853f89a4de258c0702932a1c
+pip install "git+https://github.com/ISEEKYAN/mbridge.git@${MBRIDGE_COMMIT}" --no-deps
+git clone --branch bridge https://github.com/radixark/Megatron-Bridge.git "${WORKSPACE}/Megatron-Bridge"
+git -C "${WORKSPACE}/Megatron-Bridge" checkout "${MEGATRON_BRIDGE_COMMIT}"
+
+git -C "${WORKSPACE}/Megatron-Bridge" apply --whitespace=nowarn "${PATCH_DIR}/megatron-bridge.patch"
+
+pip install --no-build-isolation "nvidia-modelopt[torch]>=0.37.0"
+```
+
+#### 2. Megatron-LM
+
+```bash
+export MEGATRON_COMMIT=1dcf0dafa884ad52ffb243625717a3471643e087
+git clone https://github.com/NVIDIA/Megatron-LM.git "${WORKSPACE}/Megatron-LM"
+git -C "${WORKSPACE}/Megatron-LM" checkout "${MEGATRON_COMMIT}"
+
+git -C "${WORKSPACE}/Megatron-LM" apply --whitespace=nowarn "${WORKSPACE}/vime/docker/patch/latest/megatron.patch"
+git -C "${WORKSPACE}/Megatron-LM" apply --whitespace=nowarn "${PATCH_DIR}/megatron.patch"
+
+pip install --no-deps --no-build-isolation -e "${WORKSPACE}/Megatron-LM"
+```
+
+#### 3. MegatronAdaptor and TransformerEngineNPU
+
+The NPU training stack now uses the two source repositories directly. The mainline Megatron patch is applied first; `docker/npu_patch/megatron.patch` contains only the NPU-specific changes rebased onto that mainline patch:
+
+pip install --no-deps --no-build-isolation -e ${WORKSPACE}/MegatronAdaptor
+pip install --no-deps --no-build-isolation -e ${WORKSPACE}/TransformerEngineNPU
+
+Do not install the CUDA TransformerEngine package in the same environment.
+
+#### 4. MindSpeed
+
+```bash
+export MINDSPEED_COMMIT=fc63de5c48426dd019c3b3f39e65f5bdf56e4086
+git clone https://gitcode.com/Ascend/MindSpeed.git "${WORKSPACE}/MindSpeed"
+git -C "${WORKSPACE}/MindSpeed" checkout "${MINDSPEED_COMMIT}"
+
+git -C "${WORKSPACE}/MindSpeed" apply --whitespace=nowarn "${PATCH_DIR}/mindspeed.patch"
+
+pip install --no-deps --no-build-isolation -e "${WORKSPACE}/MindSpeed"
+```
+
+#### 5. Vime
+
+```bash
+pip install -r "${WORKSPACE}/vime/requirements.txt"
+pip install "vllm-router>=0.1.14"
+pip install --no-deps --no-build-isolation -e "${WORKSPACE}/vime"
+```
+
+The NPU training region and optimizer state use Ascend `torch_memory_saver`.
+Retain the working build in an existing environment; the source build recipe is:
+
+```bash
+git clone --branch 2026.6.0 https://github.com/sgl-project/sgl-kernel-npu.git "${WORKSPACE}/sgl-kernel-npu"
+cd "${WORKSPACE}/sgl-kernel-npu"
+bash build.sh -a kernels
+bash build.sh -a memory-saver
+pip install --no-deps output/torch_memory_saver-0.0.8-cp312-cp312-linux_aarch64.whl
+```
+
+#### 5. Install vLLM and vLLM Ascend
+
+```bash
+export VLLM_COMMIT=e6bfe03ad73a3330cb427885aa90d97a12e1c704
+export VLLM_ASCEND_COMMIT=fd815467c221ee600137f6bdd53fe354d5e7c999
+
+git clone https://github.com/vllm-project/vllm.git "${WORKSPACE}/vllm"
+git -C "${WORKSPACE}/vllm" checkout "${VLLM_COMMIT}"
+VLLM_TARGET_DEVICE=empty pip install -v -e "${WORKSPACE}/vllm"
+
+git clone https://github.com/vllm-project/vllm-ascend.git "${WORKSPACE}/vllm-ascend"
+git -C "${WORKSPACE}/vllm-ascend" checkout "${VLLM_ASCEND_COMMIT}"
+git -C "${WORKSPACE}/vllm-ascend" submodule update --init --recursive
+pip install -v -e "${WORKSPACE}/vllm-ascend"
+```
+
+Apply `vllm.patch` and `vllm-ascend.patch` to those exact revisions before
+validation. Do not replace the existing source trees during conflict resolution.
+
+For image patch reconciliation, persist the common Megatron patch as
+`/opt/npu_patch/megatron-common.patch`. `series.conf` applies common → NPU and
+reverts in reverse order. This reconciles patch bytes, not repository versions;
+an old Megatron checkout cannot be upgraded by the patch reconciler alone.
+
+## Additional Dependencies
+
+The source PR specified the following versions. They are not an instruction to
+upgrade the existing S6 environment; in particular, validate the new NPU kernel
+requirements before changing torch-npu:
+
+```shell
+pip install torch-npu==2.10.0
+pip install torchvision==0.25.0
+pip install numpy==1.26.4
+```
diff --git a/docker/npu_patch/megatron-bridge.patch b/docker/npu_patch/megatron-bridge.patch
new file mode 100644
index 000000000..adb77c908
--- /dev/null
+++ b/docker/npu_patch/megatron-bridge.patch
@@ -0,0 +1,375 @@
+diff --git a/src/megatron/bridge/models/conversion/param_mapping.py b/src/megatron/bridge/models/conversion/param_mapping.py
+index a0421273..7f9203ab 100644
+--- a/src/megatron/bridge/models/conversion/param_mapping.py
++++ b/src/megatron/bridge/models/conversion/param_mapping.py
+@@ -1097,15 +1097,20 @@ class AutoMapping(MegatronParamMapping[torch.Tensor]):
+ "LinearCrossEntropyModule",
+ "TEColumnParallelLinear",
++ "MindSpeedTEColumnParallelLinear",
+ "TELayerNormColumnParallelLinear",
++ "MindSpeedTELayerNormColumnParallelLinear",
+ "TEColumnParallelGroupedLinear",
++ "MindSpeedTEColumnParallelGroupedLinear",
+ "VocabParallelEmbedding",
+ "DotProductAttention", # for attention sink only
+ "TEDotProductAttention", # for attention sink only
++ "MindSpeedTEDotProductAttention",
+ },
+ "row": {
+ "RowParallelLinear",
+ "TERowParallelLinear",
+ "TERowParallelGroupedLinear",
++ "MindSpeedTERowParallelGroupedLinear",
+ },
+ "replicated": {
+ # Normalization layers
+@@ -1176,7 +1180,7 @@ class AutoMapping(MegatronParamMapping[torch.Tensor]):
+ # Handle fused modules like TELayerNormColumnParallelLinear
+ # These modules have both column-parallel weights (weight, bias)
+ # and replicated layer norm weights (layer_norm_weight, layer_norm_bias)
+- if module_type == "TELayerNormColumnParallelLinear":
++ if module_type == "TELayerNormColumnParallelLinear" or module_type == "MindSpeedTELayerNormColumnParallelLinear":
+ # Check the actual parameter name to determine the correct parallelism type
+ if self.megatron_param and (
+ self.megatron_param.endswith("layer_norm_weight") or self.megatron_param.endswith("layer_norm_bias")
+@@ -1207,7 +1211,7 @@ class AutoMapping(MegatronParamMapping[torch.Tensor]):
+ return "replicated"
+
+ # Check parallel_mode for TELinear
+- if module_type == "TELinear":
++ if module_type == "TELinear" or module_type == "MindSpeedTELinear":
+ if module.parallel_mode == "column":
+ return "column"
+ elif module.parallel_mode == "row":
+diff --git a/src/megatron/bridge/models/qwen/__init__.py b/src/megatron/bridge/models/qwen/__init__.py
+index b3656b6d..382845cc 100644
+--- a/src/megatron/bridge/models/qwen/__init__.py
++++ b/src/megatron/bridge/models/qwen/__init__.py
+@@ -15,7 +15,7 @@
+ from megatron.bridge.models.qwen.qwen2_bridge import Qwen2Bridge # noqa: F401
+ from megatron.bridge.models.qwen.qwen3_bridge import Qwen3Bridge # noqa: F401
+ from megatron.bridge.models.qwen.qwen3_moe_bridge import Qwen3MoEBridge # noqa: F401
+-from megatron.bridge.models.qwen.qwen3_next_bridge import Qwen3NextBridge
++# from megatron.bridge.models.qwen.qwen3_next_bridge import Qwen3NextBridge
+ from megatron.bridge.models.qwen.qwen_provider import (
+ Qwen2ModelProvider,
+ Qwen2ModelProvider1P5B,
+@@ -32,8 +32,8 @@ from megatron.bridge.models.qwen.qwen_provider import (
+ Qwen3MoEModelProvider,
+ Qwen3MoEModelProvider30B_A3B,
+ Qwen3MoEModelProvider235B_A22B,
+- Qwen3NextModelProvider,
+- Qwen3NextModelProvider80B_A3B,
++ # Qwen3NextModelProvider,
++ # Qwen3NextModelProvider80B_A3B,
+ Qwen25ModelProvider1P5B,
+ Qwen25ModelProvider3B,
+ Qwen25ModelProvider7B,
+@@ -67,6 +67,6 @@ __all__ = [
+ "Qwen3MoEModelProvider",
+ "Qwen3MoEModelProvider30B_A3B",
+ "Qwen3MoEModelProvider235B_A22B",
+- "Qwen3NextModelProvider",
+- "Qwen3NextModelProvider80B_A3B",
++ # "Qwen3NextModelProvider",
++ # "Qwen3NextModelProvider80B_A3B",
+ ]
+diff --git a/src/megatron/bridge/models/qwen/qwen_provider.py b/src/megatron/bridge/models/qwen/qwen_provider.py
+index 775b9765..6200103c 100644
+--- a/src/megatron/bridge/models/qwen/qwen_provider.py
++++ b/src/megatron/bridge/models/qwen/qwen_provider.py
+@@ -18,9 +18,9 @@ from typing import TYPE_CHECKING, Callable, Optional
+
+ import torch
+ import torch.nn.functional as F
+-from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
+- get_transformer_block_with_experimental_attention_variant_spec,
+-)
++# from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
++# get_transformer_block_with_experimental_attention_variant_spec,
++# )
+ from megatron.core.transformer.spec_utils import ModuleSpec
+
+ from megatron.bridge.models.gpt_provider import GPTModelProvider
+@@ -430,53 +430,53 @@ class Qwen3MoEModelProvider235B_A22B(Qwen3MoEModelProvider):
+ # =============================================================================
+
+
+-@dataclass
+-class Qwen3NextModelProvider(Qwen3MoEModelProvider):
+- """Base provider for Qwen 3 Next Models."""
+-
+- transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
+- get_transformer_block_with_experimental_attention_variant_spec
+- )
+-
+- layernorm_zero_centered_gamma: bool = True # Zero-centered RMSNorm
+- kv_channels: int | None = 256
+- num_query_groups: int = 2
+- seq_length: int = 262144 # 256k tokens
+- rotary_base: float = 10000000.0
+- rotary_percent: float = 0.25 # 25% of the hidden size is used for RoPE
+- attention_output_gate: bool = True # Gated Attention
+-
+- # MoE specific parameters
+- num_moe_experts: int = 512
+- moe_router_topk: int = 10 # 10 routed experts per token
+- moe_shared_expert_gate: bool = True # Qwen3-Next uses a gate for the shared expert
+- moe_router_dtype: str = "fp32"
+- moe_router_load_balancing_type: str = "global_aux_loss" # Qwen3-Next uses global aux loss for load balancing
+-
+- # Linear Attention specific parameters
+- experimental_attention_variant: str = "gated_delta_net" # Gated Delta Net used in 75% of the model layers
+- linear_attention_freq: int | list[int] = 4 # 1 gated standard attention layer per 4 layers
+- linear_conv_kernel_dim: int = 4
+- linear_key_head_dim: int = 128
+- linear_value_head_dim: int = 128
+- linear_num_key_heads: int = 16
+- linear_num_value_heads: int = 32
+-
+- # Checkpointing
+- hetereogenous_dist_checkpoint: bool = True
+-
+-
+-@dataclass
+-class Qwen3NextModelProvider80B_A3B(Qwen3NextModelProvider):
+- """
+- Provider for Qwen 3 Next 80B-A3B: https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct and https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Thinking
+- """
+-
+- num_layers: int = 48
+- hidden_size: int = 2048
+- num_attention_heads: int = 16
+- num_query_groups: int = 2
+- ffn_hidden_size: int = 5120
+- moe_ffn_hidden_size: int = 512
+- moe_shared_expert_intermediate_size: int = 512
+- mtp_num_layers: Optional[int] = None
++# @dataclass
++# class Qwen3NextModelProvider(Qwen3MoEModelProvider):
++# """Base provider for Qwen 3 Next Models."""
++
++# transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
++# get_transformer_block_with_experimental_attention_variant_spec
++# )
++
++# layernorm_zero_centered_gamma: bool = True # Zero-centered RMSNorm
++# kv_channels: int | None = 256
++# num_query_groups: int = 2
++# seq_length: int = 262144 # 256k tokens
++# rotary_base: float = 10000000.0
++# rotary_percent: float = 0.25 # 25% of the hidden size is used for RoPE
++# attention_output_gate: bool = True # Gated Attention
++
++# # MoE specific parameters
++# num_moe_experts: int = 512
++# moe_router_topk: int = 10 # 10 routed experts per token
++# moe_shared_expert_gate: bool = True # Qwen3-Next uses a gate for the shared expert
++# moe_router_dtype: str = "fp32"
++# moe_router_load_balancing_type: str = "global_aux_loss" # Qwen3-Next uses global aux loss for load balancing
++
++# # Linear Attention specific parameters
++# experimental_attention_variant: str = "gated_delta_net" # Gated Delta Net used in 75% of the model layers
++# linear_attention_freq: int | list[int] = 4 # 1 gated standard attention layer per 4 layers
++# linear_conv_kernel_dim: int = 4
++# linear_key_head_dim: int = 128
++# linear_value_head_dim: int = 128
++# linear_num_key_heads: int = 16
++# linear_num_value_heads: int = 32
++
++# # Checkpointing
++# hetereogenous_dist_checkpoint: bool = True
++
++
++# @dataclass
++# class Qwen3NextModelProvider80B_A3B(Qwen3NextModelProvider):
++# """
++# Provider for Qwen 3 Next 80B-A3B: https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct and https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Thinking
++# """
++
++# num_layers: int = 48
++# hidden_size: int = 2048
++# num_attention_heads: int = 16
++# num_query_groups: int = 2
++# ffn_hidden_size: int = 5120
++# moe_ffn_hidden_size: int = 512
++# moe_shared_expert_intermediate_size: int = 512
++# mtp_num_layers: Optional[int] = None
+diff --git a/src/megatron/bridge/models/qwen_vl/qwen35_vl_provider.py b/src/megatron/bridge/models/qwen_vl/qwen35_vl_provider.py
+index 6c74827c..75973903 100644
+--- a/src/megatron/bridge/models/qwen_vl/qwen35_vl_provider.py
++++ b/src/megatron/bridge/models/qwen_vl/qwen35_vl_provider.py
+@@ -36,9 +36,9 @@ from typing import Any, Callable, List, Optional
+
+ import transformers
+ from megatron.core.models.gpt import GPTModel as MCoreGPTModel
+-from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
+- get_transformer_block_with_experimental_attention_variant_spec,
+-)
++# from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
++# get_transformer_block_with_experimental_attention_variant_spec,
++# )
+ from megatron.core.transformer.spec_utils import ModuleSpec
+ from megatron.core.transformer.transformer_block import TransformerBlockSubmodules
+ from packaging.version import Version as PkgVersion
+@@ -105,9 +105,10 @@ class Qwen35VLModelProvider(GPTModelProvider):
+ # =========================================================================
+ # Hybrid Architecture (Qwen3-Next style)
+ # =========================================================================
+- transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
+- get_transformer_block_with_experimental_attention_variant_spec
+- )
++ # transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
++ # get_transformer_block_with_experimental_attention_variant_spec
++ # )
++ transformer_layer_spec: ModuleSpec = None
+ layernorm_zero_centered_gamma: bool = True
+ attention_output_gate: bool = True
+ experimental_attention_variant: str = "gated_delta_net"
+@@ -261,9 +262,10 @@ class Qwen35VLMoEModelProvider(GPTModelProvider):
+ # =========================================================================
+ # Hybrid Architecture (Qwen3-Next style)
+ # =========================================================================
+- transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
+- get_transformer_block_with_experimental_attention_variant_spec
+- )
++ # transformer_layer_spec: ModuleSpec | Callable[["GPTModelProvider"], ModuleSpec] = (
++ # get_transformer_block_with_experimental_attention_variant_spec
++ # )
++ transformer_layer_spec: ModuleSpec = None
+ layernorm_zero_centered_gamma: bool = True
+ attention_output_gate: bool = True
+ experimental_attention_variant: str = "gated_delta_net"
+diff --git a/src/megatron/bridge/models/transformer_config.py b/src/megatron/bridge/models/transformer_config.py
+index 618700ed..3133f4ea 100644
+--- a/src/megatron/bridge/models/transformer_config.py
++++ b/src/megatron/bridge/models/transformer_config.py
+@@ -47,6 +47,10 @@ def _safe_asdict(obj, skip_keys: set[str]) -> dict:
+ return obj.__class__((_safe_asdict(k, skip_keys), _safe_asdict(v, skip_keys)) for k, v in obj.items())
+ return obj
+
++from dataclasses import dataclass, field
++
++class MyConfig:
++ pass
+
+ @dataclass
+ class TransformerConfig(MCoreTransformerConfig):
+@@ -70,6 +74,13 @@ class TransformerConfig(MCoreTransformerConfig):
+ """
+
+ _NO_COPY_KEYS = {"_pg_collection"}
++ # vllm_eplb_config: MyConfig = field(default_factory=MyConfig)
++ # vllm_ir_op_priority: MyConfig = field(default_factory=MyConfig)
++ # vllm_kernel_config: MyConfig = field(default_factory=MyConfig)
++ # vllm_profiler_config: MyConfig = field(default_factory=MyConfig)
++ # vllm_structured_outputs_config: MyConfig = field(default_factory=MyConfig)
++ # vllm_compilation_config: MyConfig = field(default_factory=MyConfig)
++ # vllm_attention_config: MyConfig = field(default_factory=MyConfig)
+
+ def __post_init__(self) -> None:
+ """Skip MCore post_init during initial construction.
+diff --git a/src/megatron/bridge/peft/utils.py b/src/megatron/bridge/peft/utils.py
+index 1ca5b18b..3d8ec12a 100644
+--- a/src/megatron/bridge/peft/utils.py
++++ b/src/megatron/bridge/peft/utils.py
+@@ -62,7 +62,7 @@ HAVE_TE = all(
+ )
+ )
+
+-MixedFusedLayerNorm, HAVE_APEX = safe_import_from("apex.normalization.fused_layer_norm", "MixedFusedLayerNorm")
++# MixedFusedLayerNorm, HAVE_APEX = safe_import_from("apex.normalization.fused_layer_norm", "MixedFusedLayerNorm")
+
+ TECL = (TEColumnParallelLinear, TELayerNormColumnParallelLinear, TEColumnParallelGroupedLinear)
+ TERL = (TERowParallelLinear, TERowParallelGroupedLinear)
+diff --git a/src/megatron/bridge/training/mlm_compat/model.py b/src/megatron/bridge/training/mlm_compat/model.py
+index 60cc091c..1dd5c808 100644
+--- a/src/megatron/bridge/training/mlm_compat/model.py
++++ b/src/megatron/bridge/training/mlm_compat/model.py
+@@ -21,9 +21,9 @@ from megatron.core import tensor_parallel
+ from megatron.core.enums import ModelType
+ from megatron.core.fp8_utils import correct_amax_history_if_needed
+ from megatron.core.models.gpt import GPTModel
+-from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
+- get_transformer_block_with_experimental_attention_variant_spec,
+-)
++# from megatron.core.models.gpt.experimental_attention_variant_module_specs import (
++# get_transformer_block_with_experimental_attention_variant_spec,
++# )
+ from megatron.core.models.gpt.gpt_layer_specs import (
+ get_gpt_decoder_block_spec,
+ get_gpt_layer_local_spec,
+diff --git a/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/model.py b/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/model.py
+index 7775c11c..c7b094cf 100644
+--- a/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/model.py
++++ b/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/model.py
+@@ -26,6 +26,7 @@ from megatron.core.packed_seq_params import PackedSeqParams
+ from megatron.core.process_groups_config import ProcessGroupCollection
+ from megatron.core.transformer import MegatronModule
+ from megatron.core.transformer.spec_utils import ModuleSpec
++from megatron.core.utils import nvtx_range_pop, nvtx_range_push
+ from transformers.models.qwen3_vl.configuration_qwen3_vl import Qwen3VLConfig as Qwen3VLConfigHF
+
+ from megatron.bridge.models.qwen_vl.modelling_qwen3_vl.attention import Qwen3VLSelfAttention
+@@ -294,7 +295,7 @@ class Qwen3VLModel(MegatronModule):
+ # position ids is computed within the model
+ position_ids = None
+
+- torch.cuda.nvtx.range_push("Qwen3VLModel.forward.pre_process")
++ nvtx_range_push(msg="Qwen3VLModel.forward.pre_process")
+
+ cp_rank = self.pg_collection.cp.rank()
+ cp_size = self.pg_collection.cp.size()
+@@ -387,7 +388,7 @@ class Qwen3VLModel(MegatronModule):
+ combined_embeddings = split_data_cp_rank(combined_embeddings, cp_size, 0, cp_rank)
+ if packed_seq_params is not None:
+ if attention_mask is None:
+- attention_mask = torch.ones_like(input_ids, dtype=torch.int32, device=input_ids.device)
++ attention_mask = torch.ones_like(input_ids, dtype=torch.bool, device=input_ids.device)
+ input_ids_thd, _ = preprocess_packed_seqs(
+ input_ids, attention_mask, pre_process=True, pg_collection=self.pg_collection
+ )
+@@ -443,7 +444,7 @@ class Qwen3VLModel(MegatronModule):
+ # convert lm_input_ids to THD format so it matches position_ids.
+ if packed_seq_params is not None:
+ if attention_mask is None:
+- attention_mask = torch.ones_like(input_ids, dtype=torch.int32, device=input_ids.device)
++ attention_mask = torch.ones_like(input_ids, dtype=torch.bool, device=input_ids.device)
+ lm_input_ids, _ = preprocess_packed_seqs(
+ input_ids, attention_mask, pre_process=True, pg_collection=self.pg_collection
+ )
+@@ -499,8 +500,8 @@ class Qwen3VLModel(MegatronModule):
+ attention_mask = None
+ self.language_model.rotary_pos_emb.is_thd_format = True
+
+- torch.cuda.nvtx.range_pop()
+- torch.cuda.nvtx.range_push("Qwen3VLModel.forward.language_model")
++ nvtx_range_pop(msg="Qwen3VLModel.forward.pre_process")
++ nvtx_range_push(msg="Qwen3VLModel.forward.language_model")
+
+ output = self.language_model(
+ input_ids=lm_input_ids,
+@@ -516,6 +517,6 @@ class Qwen3VLModel(MegatronModule):
+ **(extra_block_kwargs or {}),
+ **kwargs,
+ )
+- torch.cuda.nvtx.range_pop()
++ nvtx_range_pop(msg="Qwen3VLModel.forward.language_model")
+
+ return output
+diff --git a/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/utils.py b/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/utils.py
+index b714d0f7..42fcab74 100644
+--- a/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/utils.py
++++ b/src/megatron/bridge/models/qwen_vl/modelling_qwen3_vl/utils.py
+@@ -668,6 +668,11 @@ def preprocess_packed_seqs(
+ """
+ batch_size = input_ids.shape[0]
+
++ # Ensure boolean dtype for correct advanced indexing (bool → mask select,
++ # int → fancy index which silently corrupts data when values are 0/1).
++ if attention_mask.dtype != torch.bool:
++ attention_mask = attention_mask.bool()
++
+ seqlens_in_batch = attention_mask.sum(dim=-1, dtype=torch.int32)
+ if pg_collection is not None:
+ tp_size = pg_collection.tp.size()
diff --git a/docker/npu_patch/megatron.patch b/docker/npu_patch/megatron.patch
new file mode 100644
index 000000000..e676ac3f6
--- /dev/null
+++ b/docker/npu_patch/megatron.patch
@@ -0,0 +1,661 @@
+diff --git a/megatron/core/activations.py b/megatron/core/activations.py
+index 8b422d73a..58fba4667 100644
+--- a/megatron/core/activations.py
++++ b/megatron/core/activations.py
+@@ -5,19 +5,19 @@ import torch.nn.functional as F
+ from megatron.core.jit import jit_fuser
+
+
+-@jit_fuser
++
+ def squared_relu(x: torch.Tensor) -> torch.Tensor:
+ """Squared ReLU activation"""
+ return torch.pow(F.relu(x), 2)
+
+
+-@jit_fuser
++
+ def quick_gelu(x: torch.Tensor) -> torch.Tensor:
+ """Quick GELU activation"""
+ return x * torch.sigmoid(1.702 * x)
+
+
+-@jit_fuser
++
+ def fast_gelu(x: torch.Tensor) -> torch.Tensor:
+ """Fast GELU activation"""
+ return 0.5 * x * (1.0 + torch.tanh(x * 0.7978845608 * (1.0 + 0.044715 * x * x)))
+diff --git a/megatron/core/distributed/param_and_grad_buffer.py b/megatron/core/distributed/param_and_grad_buffer.py
+index a9982e176..807c4a88d 100644
+--- a/megatron/core/distributed/param_and_grad_buffer.py
++++ b/megatron/core/distributed/param_and_grad_buffer.py
+@@ -787,45 +787,19 @@ class _ParamAndGradBuffer:
+ # Individual param/grad contexts below handle TMS regions separately.
+ mem_alloc_context = nullcontext
+
+- def _make_no_backup_context(tag, disable, flag_name="disable_grad_buffers_cpu_backup"):
+- if disable:
+- try:
+- from torch_memory_saver import torch_memory_saver
+- except ImportError as e:
+- raise ImportError(
+- f"{flag_name}=True requires torch_memory_saver. "
+- "Install with: pip install torch-memory-saver"
+- ) from e
+- return partial(
+- torch_memory_saver.region,
+- tag=tag,
+- enable_cpu_backup=False,
+- )
+- return nullcontext
+- grad_mem_alloc_context = _make_no_backup_context(
+- "grad_buffer", disable_grad_buffers_cpu_backup
+- )
+- param_mem_alloc_context = _make_no_backup_context(
+- "param_buffer", disable_param_buffers_cpu_backup, "disable_param_buffers_cpu_backup"
+- )
+-
++ # NPU already wraps model construction in the outer VIME training
++ # torch_memory_saver region. Do not open nested mem-pool regions here.
+ with mem_alloc_context():
+ # For MXFP8 param: Create a shared buffer for param AG and grad RS for memory efficiency
+ # The buffer is mapped to weight gradients whose dtype is either bf16 or FP32.
+ # It can be temporarily reused by param AG.
+ if self.ddp_config.use_distributed_optimizer and any(is_mxfp8tensor(p) for p in params):
+- shared_mem_alloc_context = (
+- param_mem_alloc_context
+- if disable_param_buffers_cpu_backup
+- else grad_mem_alloc_context
++ self.shared_buffer = torch.zeros(
++ self.numel,
++ dtype=self.grad_dtype,
++ device=torch.cuda.current_device(),
++ requires_grad=False,
+ )
+- with shared_mem_alloc_context():
+- self.shared_buffer = torch.zeros(
+- self.numel,
+- dtype=self.grad_dtype,
+- device=torch.cuda.current_device(),
+- requires_grad=False,
+- )
+ # For FP32 weight grads, only half of the buffer is used to store params in bf16.
+ if self.grad_dtype == torch.float32:
+ self.param_data = self.shared_buffer[: math.ceil(self.numel / 2)].view(
+@@ -837,20 +811,18 @@ class _ParamAndGradBuffer:
+ else:
+ # Only re-map param tensors if using distributed optimizer.
+ if self.ddp_config.use_distributed_optimizer:
+- with param_mem_alloc_context():
+- self.param_data = torch.zeros(
+- self.numel,
+- dtype=self.param_dtype,
+- device=torch.cuda.current_device(),
+- requires_grad=False,
+- )
+- with grad_mem_alloc_context():
+- self.grad_data = torch.zeros(
++ self.param_data = torch.zeros(
+ self.numel,
+- dtype=self.grad_dtype,
++ dtype=self.param_dtype,
+ device=torch.cuda.current_device(),
+ requires_grad=False,
+ )
++ self.grad_data = torch.zeros(
++ self.numel,
++ dtype=self.grad_dtype,
++ device=torch.cuda.current_device(),
++ requires_grad=False,
++ )
+
+ self.grad_data_size = 0
+ self.param_data_size = 0
+diff --git a/megatron/core/fusions/fused_bias_dropout.py b/megatron/core/fusions/fused_bias_dropout.py
+index 336452562..614ee1a48 100644
+--- a/megatron/core/fusions/fused_bias_dropout.py
++++ b/megatron/core/fusions/fused_bias_dropout.py
+@@ -64,14 +64,14 @@ def bias_dropout_add_unfused(training):
+ return _bias_dropout_add
+
+
+-@jit_fuser
++
+ def bias_dropout_add_fused_train(
+ x_with_bias: Tuple[torch.Tensor, Optional[torch.Tensor]], residual: torch.Tensor, prob: float
+ ) -> torch.Tensor:
+ return _bias_dropout_add_func(x_with_bias, residual, prob, True)
+
+
+-@jit_fuser
++
+ def bias_dropout_add_fused_inference(
+ x_with_bias: Tuple[torch.Tensor, Optional[torch.Tensor]], residual: torch.Tensor, prob: float
+ ) -> torch.Tensor:
+diff --git a/megatron/core/fusions/fused_bias_geglu.py b/megatron/core/fusions/fused_bias_geglu.py
+index 7a7fbe7f9..f9a438953 100644
+--- a/megatron/core/fusions/fused_bias_geglu.py
++++ b/megatron/core/fusions/fused_bias_geglu.py
+@@ -13,7 +13,7 @@ from megatron.core.jit import jit_fuser
+ # x * 0.5 * (1.0 + torch.erf(x * 0.70710678))
+
+
+-@jit_fuser
++
+ def geglu(y):
+ """Performs GEGLU (GELU-Gated Linear Unit) activation.
+
+@@ -27,7 +27,7 @@ def geglu(y):
+ return (y_1 * 0.5 * (1.0 + torch.tanh(0.79788456 * y_1 * (1 + 0.044715 * y_1 * y_1)))) * y_2
+
+
+-@jit_fuser
++
+ def bias_geglu(bias, y):
+ """Performs GEGLU activation with bias addition.
+
+@@ -45,7 +45,7 @@ def bias_geglu(bias, y):
+ # gradient of tanh approximation of gelu
+ # gradient of actual gelu is:
+ # 0.5 * (1. + torch.erf(x * 0.70710678)) + 0.3989423 * x * torch.exp(-0.5 * x * x)
+-@jit_fuser
++
+ def geglu_back(g, y):
+ """Computes the gradient for the GEGLU activation.
+
+@@ -65,7 +65,7 @@ def geglu_back(g, y):
+ return torch.cat(((g * y_2) * ff, g * (y_1 * 0.5 * (1.0 + tanh_out))), -1)
+
+
+-@jit_fuser
++
+ def bias_geglu_back(g, y, bias):
+ """Computes the gradient for the biased GEGLU activation.
+
+@@ -181,13 +181,13 @@ def bias_geglu_impl(input, bias):
+ # ------------------------- QUICK GEGLU FUSION --------------------------
+
+
+-@jit_fuser
++
+ def quick_gelu(y: torch.Tensor) -> torch.Tensor:
+ """Sigmoid approximation of gelu"""
+ return y * torch.sigmoid(1.702 * y)
+
+
+-@jit_fuser
++
+ def quick_geglu(y: torch.Tensor, linear_offset: float = 0.0) -> torch.Tensor:
+ """Performs Quick-GELU-based GEGLU activation : quick_gelu(y1) * (y2 + offset).
+
+@@ -202,7 +202,7 @@ def quick_geglu(y: torch.Tensor, linear_offset: float = 0.0) -> torch.Tensor:
+ return quick_gelu(y_1) * (y_2 + linear_offset)
+
+
+-@jit_fuser
++
+ def weighted_quick_geglu(
+ y: torch.Tensor, weights: torch.Tensor, linear_offset: float = 0.0
+ ) -> torch.Tensor:
+@@ -217,7 +217,7 @@ def weighted_quick_geglu(
+
+
+ # gradient of sigmoid approximation of gelu
+-@jit_fuser
++
+ def quick_geglu_back(g, y, linear_offset: float = 0.0) -> torch.Tensor:
+ """Backward helper for Quick-GEGLU.
+
+@@ -236,7 +236,7 @@ def quick_geglu_back(g, y, linear_offset: float = 0.0) -> torch.Tensor:
+ return torch.cat((dy_1, dy_2), -1)
+
+
+-@jit_fuser
++
+ def weighted_quick_geglu_back(g, y, weights, linear_offset: float = 0.0):
+ """Backward helper for weighted Quick-GEGLU.
+ Returns gradient w.r.t input `y` and `weights`.
+@@ -255,7 +255,7 @@ def weighted_quick_geglu_back(g, y, weights, linear_offset: float = 0.0):
+ # ---------------- Weighted Bias Quick-GEGLU helpers -----------------
+
+
+-@jit_fuser
++
+ def weighted_bias_quick_geglu(
+ y: torch.Tensor, bias: torch.Tensor, weights: torch.Tensor, linear_offset: float = 0.0
+ ) -> torch.Tensor:
+@@ -275,7 +275,7 @@ def weighted_bias_quick_geglu(
+ return res.to(dtype)
+
+
+-@jit_fuser
++
+ def weighted_bias_quick_geglu_back(g, y, bias, weights, linear_offset: float = 0.0):
+ """Backward helper for weighted Quick-GEGLU with bias.
+
+diff --git a/megatron/core/fusions/fused_bias_gelu.py b/megatron/core/fusions/fused_bias_gelu.py
+index 8cc90f617..fda8f2f5f 100644
+--- a/megatron/core/fusions/fused_bias_gelu.py
++++ b/megatron/core/fusions/fused_bias_gelu.py
+@@ -13,7 +13,7 @@ from megatron.core.jit import jit_fuser
+ # x * 0.5 * (1.0 + torch.erf(x * 0.70710678))
+
+
+-@jit_fuser
++
+ def bias_gelu(bias, y):
+ x = bias + y
+ return x * 0.5 * (1.0 + torch.tanh(0.79788456 * x * (1 + 0.044715 * x * x)))
+@@ -22,7 +22,7 @@ def bias_gelu(bias, y):
+ # gradient of tanh approximation of gelu
+ # gradient of actual gelu is:
+ # 0.5 * (1. + torch.erf(x * 0.70710678)) + 0.3989423 * x * torch.exp(-0.5 * x * x)
+-@jit_fuser
++
+ def bias_gelu_back(g, bias, y):
+ x = bias + y
+ tanh_out = torch.tanh(0.79788456 * x * (1 + 0.044715 * x * x))
+diff --git a/megatron/core/fusions/fused_bias_swiglu.py b/megatron/core/fusions/fused_bias_swiglu.py
+index 632470876..105936786 100644
+--- a/megatron/core/fusions/fused_bias_swiglu.py
++++ b/megatron/core/fusions/fused_bias_swiglu.py
+@@ -12,7 +12,7 @@ from megatron.core.utils import nvtx_decorator
+ ###### BIAS SWIGLU FUSION/ NO AUTOGRAD ################
+
+
+-@jit_fuser
++
+ def swiglu(y):
+ """Performs SwiGLU (Swish-Gated Linear Unit) activation function.
+
+@@ -26,7 +26,7 @@ def swiglu(y):
+ return F.silu(y_1) * y_2
+
+
+-@jit_fuser
++
+ def bias_swiglu(y, bias):
+ """Performs SwiGLU activation with bias addition.
+
+@@ -41,7 +41,7 @@ def bias_swiglu(y, bias):
+ return swiglu(y)
+
+
+-@jit_fuser
++
+ def weighted_swiglu(y, weights):
+ dtype = y.dtype
+ res = swiglu(y) * weights
+@@ -51,7 +51,7 @@ def weighted_swiglu(y, weights):
+ # gradient of tanh approximation of gelu
+ # gradient of actual gelu is:
+ # 0.5 * (1. + torch.erf(x * 0.70710678)) + 0.3989423 * x * torch.exp(-0.5 * x * x)
+-@jit_fuser
++
+ def swiglu_back(g, y):
+ """Computes the gradient for the SwiGLU activation function.
+
+@@ -69,7 +69,7 @@ def swiglu_back(g, y):
+ )
+
+
+-@jit_fuser
++
+ def bias_swiglu_back(g, y, bias):
+ """Computes the gradient for the biased SwiGLU activation function.
+
+@@ -86,7 +86,7 @@ def bias_swiglu_back(g, y, bias):
+ return swiglu_back(g, y)
+
+
+-@jit_fuser
++
+ def weighted_swiglu_back(g, y, weights):
+ input_dtype = y.dtype
+ w_dtype = weights.dtype
+diff --git a/megatron/core/fusions/fused_cross_entropy.py b/megatron/core/fusions/fused_cross_entropy.py
+index 23e4b6031..4d447e0e0 100644
+--- a/megatron/core/fusions/fused_cross_entropy.py
++++ b/megatron/core/fusions/fused_cross_entropy.py
+@@ -9,7 +9,7 @@ from megatron.core.tensor_parallel.cross_entropy import VocabParallelCrossEntrop
+ from megatron.core.tensor_parallel.utils import VocabUtility
+
+
+-@jit_fuser
++
+ def calculate_logits_max(vocab_parallel_logits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
+ """
+ Calculates the maximum logits of the predicted tokens.
+@@ -22,7 +22,7 @@ def calculate_logits_max(vocab_parallel_logits: torch.Tensor) -> Tuple[torch.Ten
+ return vocab_parallel_logits, logits_max
+
+
+-@jit_fuser
++
+ def calculate_predicted_logits(
+ vocab_parallel_logits: torch.Tensor,
+ target: torch.Tensor,
+@@ -44,7 +44,7 @@ def calculate_predicted_logits(
+ return target_mask, masked_target_1d, predicted_logits_sum_exp_logits, exp_logits
+
+
+-@jit_fuser
++
+ def calculate_cross_entropy_loss(
+ exp_logits: torch.Tensor, predicted_logits_sum_exp_logits: torch.Tensor
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+@@ -61,7 +61,7 @@ def calculate_cross_entropy_loss(
+ return exp_logits, loss
+
+
+-@jit_fuser
++
+ def calculate_gradients(
+ softmax: torch.Tensor,
+ grad_output: torch.Tensor,
+diff --git a/megatron/core/fusions/fused_pad_routing_map.py b/megatron/core/fusions/fused_pad_routing_map.py
+index c382178b6..563279edd 100644
+--- a/megatron/core/fusions/fused_pad_routing_map.py
++++ b/megatron/core/fusions/fused_pad_routing_map.py
+@@ -70,7 +70,7 @@ def _pad_routing_map_kernel(
+ tl.store(output_row_ptr + token_indices, output_row, mask=token_mask)
+
+
+-@jit_fuser
++
+ def fused_pad_routing_map(routing_map: torch.Tensor, pad_multiple: int) -> torch.Tensor:
+ """Fused version of pad_routing_map.
+ Args:
+diff --git a/megatron/core/fusions/fused_weighted_squared_relu.py b/megatron/core/fusions/fused_weighted_squared_relu.py
+index 02dabc14c..137022386 100644
+--- a/megatron/core/fusions/fused_weighted_squared_relu.py
++++ b/megatron/core/fusions/fused_weighted_squared_relu.py
+@@ -10,7 +10,7 @@ from megatron.core.utils import nvtx_decorator
+ ###################### WEIGHTED SQUARED ReLU FUSION ######################
+
+
+-@jit_fuser
++
+ def weighted_squared_relu(x: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
+ """Element-wise weight applied after Squared-ReLU.
+
+@@ -28,7 +28,7 @@ def weighted_squared_relu(x: torch.Tensor, weights: torch.Tensor) -> torch.Tenso
+ return res.to(out_dtype)
+
+
+-@jit_fuser
++
+ def _squared_relu_back(g: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
+ """Gradient of Squared-ReLU.
+
+@@ -37,7 +37,7 @@ def _squared_relu_back(g: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
+ return g * 2 * F.relu(x)
+
+
+-@jit_fuser
++
+ def weighted_squared_relu_back(g: torch.Tensor, x: torch.Tensor, weights: torch.Tensor):
+ """Backward for weighted Squared-ReLU.
+
+diff --git a/megatron/core/inference/contexts/dynamic_context.py b/megatron/core/inference/contexts/dynamic_context.py
+index 2b241c86d..cca4503d5 100644
+--- a/megatron/core/inference/contexts/dynamic_context.py
++++ b/megatron/core/inference/contexts/dynamic_context.py
+@@ -51,12 +51,8 @@ try:
+ except:
+ HAVE_PACKAGING = False
+
+-try:
+- import flashinfer # pylint: disable=unused-import
+
+- HAVE_FLASHINFER = True
+-except ImportError:
+- HAVE_FLASHINFER = False
++HAVE_FLASHINFER = False
+
+ try:
+ from torch_memory_saver import torch_memory_saver
+diff --git a/megatron/core/models/gpt/gpt_layer_specs.py b/megatron/core/models/gpt/gpt_layer_specs.py
+index a9d3583e5..f2adaceb2 100755
+--- a/megatron/core/models/gpt/gpt_layer_specs.py
++++ b/megatron/core/models/gpt/gpt_layer_specs.py
+@@ -209,7 +209,7 @@ def get_gpt_layer_with_transformer_engine_spec(
+ 'The fp8 argument in "get_gpt_layer_with_transformer_engine_spec" has been deprecated'
+ " and will be removed soon. Please update your code accordingly."
+ )
+-
++ from megatron.core.extensions.transformer_engine_spec_provider import TESpecProvider
+ if use_kitchen:
+ assert HAVE_KITCHEN
+ backend: BackendSpecProvider = KitchenSpecProvider(
+diff --git a/megatron/core/optimizer/distrib_optimizer.py b/megatron/core/optimizer/distrib_optimizer.py
+index dd8590a79..3736b0e31 100644
+--- a/megatron/core/optimizer/distrib_optimizer.py
++++ b/megatron/core/optimizer/distrib_optimizer.py
+@@ -659,10 +659,12 @@ class DistributedOptimizer(MixedPrecisionOptimizer):
+ break
+ elif USING_TE_OPTIMIZER or USING_APEX_OPTIMIZER:
+ # Extract 'step', for TE FusedAdam support.
++ # Use int() to convert tensors to Python scalars so set() deduplicates
++ # by value (PyTorch tensor hashing is identity-based).
+ steps = list(
+ set(
+ [
+- g["step"]
++ int(g["step"])
+ for g in inner_state_dict["param_groups"]
+ if len(g["params"]) > 0 and "step" in g
+ ]
+@@ -824,7 +826,7 @@ class DistributedOptimizer(MixedPrecisionOptimizer):
+
+ # Extract 'step', for non-Apex/TE support.
+ if not HAVE_APEX_OR_TE:
+- steps = list(set([g["step"] for g in state_dict["optimizer"]["param_groups"]]))
++ steps = list(set([int(g["step"]) for g in state_dict["optimizer"]["param_groups"]]))
+ assert len(steps) == 1
+ step = torch.tensor(steps[0], dtype=torch.float)
+
+diff --git a/megatron/core/ssm/gated_delta_net.py b/megatron/core/ssm/gated_delta_net.py
+index 601a72a43..ec114cf38 100644
+--- a/megatron/core/ssm/gated_delta_net.py
++++ b/megatron/core/ssm/gated_delta_net.py
+@@ -465,7 +465,7 @@ class GatedDeltaNet(MegatronModule):
+
+ return out, out_bias
+
+- @jit_fuser
++
+ def _apply_gated_norm(self, x, gate):
+ # Output Norm
+ x_dtype = x.dtype
+diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py
+index bc5e4e2ee..d223e774e 100644
+--- a/megatron/core/transformer/attention.py
++++ b/megatron/core/transformer/attention.py
+@@ -1219,7 +1219,7 @@ class Attention(MegatronModule, ABC):
+
+ return output, bias
+
+- @jit_fuser
++
+ def _apply_output_gate(self, x, gate):
+ x_dtype = x.dtype
+ gate = gate.contiguous()
+diff --git a/megatron/core/transformer/module.py b/megatron/core/transformer/module.py
+index c30c107e7..f4340f4d1 100644
+--- a/megatron/core/transformer/module.py
++++ b/megatron/core/transformer/module.py
+@@ -17,9 +17,9 @@ from megatron.core.transformer.utils import (
+ sharded_state_dict_default,
+ )
+
+-_FLOAT_TYPES = (torch.FloatTensor, torch.cuda.FloatTensor)
+-_HALF_TYPES = (torch.HalfTensor, torch.cuda.HalfTensor)
+-_BF16_TYPES = (torch.BFloat16Tensor, torch.cuda.BFloat16Tensor)
++_FLOAT_TYPES = (torch.FloatTensor, torch.cuda.FloatTensor, torch.npu.FloatTensor)
++_HALF_TYPES = (torch.HalfTensor, torch.cuda.HalfTensor, torch.npu.HalfTensor)
++_BF16_TYPES = (torch.BFloat16Tensor, torch.cuda.BFloat16Tensor, torch.npu.BFloat16Tensor)
+
+
+ def param_is_not_shared(param): # pylint: disable=missing-function-docstring
+diff --git a/megatron/core/transformer/moe/experts.py b/megatron/core/transformer/moe/experts.py
+index d8e753422..aff929985 100644
+--- a/megatron/core/transformer/moe/experts.py
++++ b/megatron/core/transformer/moe/experts.py
+@@ -92,7 +92,7 @@ class GroupedMLP(MegatronModule):
+ if self.config.activation_func not in (F.silu, F.gelu):
+ raise ValueError("Activation function must be silu or gelu when using GroupedMLP.")
+
+- @jit_fuser
++
+ def glu(x):
+ x = torch.chunk(x, 2, dim=-1)
+ return self.config.activation_func(x[0]) * x[1]
+@@ -109,7 +109,7 @@ class GroupedMLP(MegatronModule):
+ "moe_act recompute for fp8 or fp4 cannot work with the legacy GroupedMLP."
+ )
+
+- @jit_fuser
++
+ def activation_func_with_probs(x, probs):
+ dtype = x.dtype
+ res = self.activation_func(x) * probs
+diff --git a/megatron/core/transformer/moe/token_dispatcher.py b/megatron/core/transformer/moe/token_dispatcher.py
+index 327dbc8a3..b007d6662 100644
+--- a/megatron/core/transformer/moe/token_dispatcher.py
++++ b/megatron/core/transformer/moe/token_dispatcher.py
+@@ -1402,7 +1402,7 @@ class MoEFlexTokenDispatcher(MoETokenDispatcher):
+ ).contiguous()
+ return routing_map, probs
+
+- @jit_fuser
++
+ def dispatch_preprocess(
+ self, hidden_states: torch.Tensor, routing_map: torch.Tensor, probs: torch.Tensor
+ ):
+diff --git a/megatron/core/transformer/multi_token_prediction.py b/megatron/core/transformer/multi_token_prediction.py
+index 63f81465d..c47baeaea 100755
+--- a/megatron/core/transformer/multi_token_prediction.py
++++ b/megatron/core/transformer/multi_token_prediction.py
+@@ -7,6 +7,7 @@ from typing import Callable, List, Optional, Union
+
+ import torch
+ from torch import Tensor
++import warnings
+
+ from megatron.core import InferenceParams, parallel_state, tensor_parallel
+ from megatron.core.dist_checkpointing.mapping import ShardedStateDict
+diff --git a/megatron/core/transformer/torch_norm.py b/megatron/core/transformer/torch_norm.py
+index d0ceca7af..f16796680 100644
+--- a/megatron/core/transformer/torch_norm.py
++++ b/megatron/core/transformer/torch_norm.py
+@@ -69,7 +69,7 @@ class L2Norm(torch.nn.Module):
+ self.hidden_size = hidden_size
+ self.eps = eps
+
+- @jit_fuser
++
+ def _norm(self, x):
+ """
+ Performs the actual L2 normalization.
+diff --git a/megatron/core/transformer/utils.py b/megatron/core/transformer/utils.py
+index 880c53099..dbc95736e 100644
+--- a/megatron/core/transformer/utils.py
++++ b/megatron/core/transformer/utils.py
+@@ -51,7 +51,7 @@ def attention_mask_func(attention_scores, attention_mask):
+ return attention_scores
+
+
+-@jit_fuser
++
+ def gelu_impl(x):
+ """OpenAI's gelu implementation."""
+ return 0.5 * x * (1.0 + torch.tanh(0.7978845608028654 * x * (1.0 + 0.044715 * x * x)))
+@@ -65,7 +65,7 @@ def openai_gelu(x):
+ # This is actually Python equivalent of torch.nn.functional.gelu(), also with
+ # type hints for ONNX exporter
+ # pylint: disable=missing-function-docstring
+-@jit_fuser
++
+ def erf_gelu(x):
+ return (
+ x * 0.5 * (torch.erf(x / 1.41421).to(dtype=x.dtype) + torch.ones_like(x).to(dtype=x.dtype))
+diff --git a/megatron/legacy/model/fused_bias_gelu.py b/megatron/legacy/model/fused_bias_gelu.py
+index e00e63148..ffe4b7ec6 100644
+--- a/megatron/legacy/model/fused_bias_gelu.py
++++ b/megatron/legacy/model/fused_bias_gelu.py
+@@ -12,7 +12,7 @@ from megatron.core.jit import jit_fuser
+ # actual gelu is:
+ # x * 0.5 * (1.0 + torch.erf(x * 0.70710678))
+
+-@jit_fuser
++
+ def bias_gelu(bias, y):
+ x = bias + y
+ return x * 0.5 * (1.0 + torch.tanh(0.79788456 * x * (1 + 0.044715 * x * x)))
+@@ -20,7 +20,7 @@ def bias_gelu(bias, y):
+ # gradient of tanh approximation of gelu
+ # gradient of actual gelu is:
+ # 0.5 * (1. + torch.erf(x * 0.70710678)) + 0.3989423 * x * torch.exp(-0.5 * x * x)
+-@jit_fuser
++
+ def bias_gelu_back(g, bias, y):
+ x = bias + y
+ tanh_out = torch.tanh(0.79788456 * x * (1 + 0.044715 * x * x))
+diff --git a/megatron/legacy/model/transformer.py b/megatron/legacy/model/transformer.py
+index ca3414eec..5dd9e4798 100644
+--- a/megatron/legacy/model/transformer.py
++++ b/megatron/legacy/model/transformer.py
+@@ -856,7 +856,7 @@ def get_bias_dropout_add(training):
+ return _bias_dropout_add
+
+
+-@jit_fuser
++
+ def bias_dropout_add_fused_train(x: torch.Tensor,
+ bias: Optional[torch.Tensor],
+ residual: torch.Tensor,
+@@ -864,7 +864,7 @@ def bias_dropout_add_fused_train(x: torch.Tensor,
+ return bias_dropout_add(x, bias, residual, prob, True)
+
+
+-@jit_fuser
++
+ def bias_dropout_add_fused_inference(x: torch.Tensor,
+ bias: Optional[torch.Tensor],
+ residual: torch.Tensor,
+diff --git a/megatron/legacy/model/utils.py b/megatron/legacy/model/utils.py
+index 5762000d5..534858df7 100644
+--- a/megatron/legacy/model/utils.py
++++ b/megatron/legacy/model/utils.py
+@@ -43,7 +43,7 @@ def get_linear_layer(rows, columns, init_method):
+ return layer
+
+
+-@jit_fuser
++
+ def gelu_impl(x):
+ """OpenAI's gelu implementation."""
+ return 0.5 * x * (1.0 + torch.tanh(0.7978845608028654 * x *
+@@ -54,7 +54,7 @@ def openai_gelu(x):
+
+
+ #This is actually Python equivalent of torch.nn.functional.gelu(), also with type hints for ONNX exporter
+-@jit_fuser
++
+ def erf_gelu(x):
+ return x * 0.5 * (torch.erf(x / 1.41421).to(dtype=x.dtype)+torch.ones_like(x).to(dtype=x.dtype))
+
+diff --git a/megatron/training/utils.py b/megatron/training/utils.py
+index 7709f6513..65b8f7fca 100644
+--- a/megatron/training/utils.py
++++ b/megatron/training/utils.py
+@@ -446,6 +446,8 @@ def is_first_or_last_pipeline_stage(vp_stage):
+
+ def get_device_arch_version():
+ """Returns GPU arch version (8: Ampere, 9: Hopper, 10: Blackwell, ...)"""
++ if hasattr(torch, 'npu') and torch.npu.is_available():
++ return 10 # NPU: treat as Blackwell to avoid CUDA restrictions
+ return torch.cuda.get_device_properties(torch.device("cuda:0")).major
+
+
diff --git a/docker/npu_patch/mindspeed.patch b/docker/npu_patch/mindspeed.patch
new file mode 100644
index 000000000..8a017c8e2
--- /dev/null
+++ b/docker/npu_patch/mindspeed.patch
@@ -0,0 +1,299 @@
+diff --git a/mindspeed/core/fusions/fused_moe_permute.py b/mindspeed/core/fusions/fused_moe_permute.py
+index bb007b44..98708a5b 100644
+--- a/mindspeed/core/fusions/fused_moe_permute.py
++++ b/mindspeed/core/fusions/fused_moe_permute.py
+@@ -100,9 +100,9 @@ def sort_chunks_by_idxs_wrapper(fn):
+ def moe_alltoall_token_dispatcher_init_wrapper(fn):
+ @wraps(fn)
+ def wrapper(
+- self, num_local_experts, local_expert_indices, config, model_comm_pgs=None
++ self, num_local_experts, local_expert_indices, config, pg_collection=None
+ ) -> None:
+- fn(self, num_local_experts, local_expert_indices, config, model_comm_pgs)
++ fn(self, num_local_experts, local_expert_indices, config, pg_collection)
+ # Since fused_sort_chunks_by_index is not currently supported, set self.permute_idx_device to None
+ self.permute_idx_device = None
+ input_chunk_idxs = torch.arange(
+diff --git a/mindspeed/core/megatron_basic/arguments_basic.py b/mindspeed/core/megatron_basic/arguments_basic.py
+index 8ea25b9f..054313cb 100644
+--- a/mindspeed/core/megatron_basic/arguments_basic.py
++++ b/mindspeed/core/megatron_basic/arguments_basic.py
+@@ -91,5 +91,11 @@ def transformer_config_init_wrapper(fn):
+ known_config = {}
+ unknown_config = {}
+ ignore_config = ['rope_type']
+- full_args = vars(get_full_args()).copy()
++ # vLLM serving arguments share the process-level Namespace in Vime,
++ # but they are not Megatron TransformerConfig fields.
++ full_args = {
++ key: value
++ for key, value in vars(get_full_args()).items()
++ if not key.startswith("vllm_")
++ }
+ full_args.update(dict(kwargs))
+@@ -113,3 +119,41 @@ def transformer_config_init_wrapper(fn):
+ fn(self, *args, **known_config)
+
+ return wrapper
++
++
++def transformer_config_getattr(self, name):
++ """Resolve MindSpeed extension fields for configs created before patching."""
++ full_args = vars(get_full_args())
++ if name in full_args:
++ return full_args[name]
++ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
++
++
++def transformer_config_init_subclass(cls, **kwargs):
++ mutable_types = (list, dict, set, bytearray)
++ unknown_config = {}
++ # Keep serving-only vLLM values out of dynamically created Megatron
++ # dataclass fields. They remain available on the original Vime args.
++ full_args = {
++ key: value
++ for key, value in vars(get_full_args()).items()
++ if not key.startswith("vllm_")
++ }
++ full_args.update(kwargs)
++
++ config_key = inspect.signature(cls).parameters
++ for key, value in full_args.items():
++ if key not in config_key:
++ unknown_config[key] = value
++
++ for key, value in unknown_config.items():
++ if not hasattr(cls, key):
++ cls.__annotations__[key] = type(value)
++ value = field(default_factory=value) if callable(value) and not isinstance(value, type) else value
++ if callable(value) and not isinstance(value, type):
++ value = field(default_factory=value)
++ elif type(value) in mutable_types:
++ value = field(default_factory=lambda: value)
++ else:
++ value = value
++ setattr(cls, key, value)
+diff --git a/mindspeed/features_manager/megatron_basic/megatron_basic.py b/mindspeed/features_manager/megatron_basic/megatron_basic.py
+index 355913e1..41678a03 100644
+--- a/mindspeed/features_manager/megatron_basic/megatron_basic.py
++++ b/mindspeed/features_manager/megatron_basic/megatron_basic.py
+@@ -43,8 +43,13 @@ class MegatronBasicFeature(MindSpeedFeature):
+
+ def register_mcore_basic_patches(self, pm, args):
+ # configuration patches
+- from mindspeed.core.megatron_basic.arguments_basic import transformer_config_init_wrapper, transformer_config_post_init_wrapper
++ from mindspeed.core.megatron_basic.arguments_basic import (transformer_config_init_wrapper,
++ transformer_config_getattr,
++ transformer_config_post_init_wrapper,
++ transformer_config_init_subclass)
+ pm.register_patch("megatron.core.transformer.transformer_config.TransformerConfig.__init__", transformer_config_init_wrapper)
++ pm.register_patch("megatron.core.transformer.transformer_config.TransformerConfig.__init_subclass__", classmethod(transformer_config_init_subclass))
++ pm.register_patch("megatron.core.transformer.transformer_config.TransformerConfig.__getattr__", transformer_config_getattr, create_dummy=True)
+ pm.register_patch("megatron.core.transformer.transformer_config.MLATransformerConfig.__init__", transformer_config_init_wrapper)
+ pm.register_patch("megatron.core.transformer.transformer_config.TransformerConfig.__post_init__", transformer_config_post_init_wrapper)
+
+diff --git a/mindspeed/patch_utils.py b/mindspeed/patch_utils.py
+index a489d58c..d9328555 100644
+--- a/mindspeed/patch_utils.py
++++ b/mindspeed/patch_utils.py
+@@ -2,7 +2,7 @@ import importlib
+ import sys
+ import types
+ from typing import List, Dict, Union
+-
++import inspect
+ _MEGATRON_TRAINING_AVAILABLE = None
+
+
+@@ -93,8 +93,10 @@ class Patch:
+
+ def remove_patch(self):
+ for key, value in sys.modules.copy().items():
+- if 'mindspeed' in key:
++ if 'mindspeed' in key or 'torch.classes' == key:
+ continue
++ if inspect.isclass(self.orig_module) and hasattr(value, self.orig_module_name.split('.')[-1]):
++ value = getattr(value, self.orig_module_name.split('.')[-1])
+ if self.orig_func_name is not None and hasattr(value, self.orig_func_name) \
+ and id(getattr(value, self.orig_func_name)) == id(self.final_patch_func):
+ setattr(value, self.orig_func_name, self.orig_func)
+diff --git a/mindspeed/core/fusions/fused_rope.py b/mindspeed/core/fusions/fused_rope.py
+index a6f02e07..70f7cb08 100644
+--- a/mindspeed/core/fusions/fused_rope.py
++++ b/mindspeed/core/fusions/fused_rope.py
+@@ -126,5 +126,6 @@ def apply_rotary_pos_emb(
+ freqs,
+ rotary_interleaved=config.rotary_interleaved,
+ multi_latent_attention=config.multi_latent_attention,
+- mscale=mscale
++ mscale=mscale,
++ cp_group=cp_group
+ )
+diff --git a/mindspeed/megatron_adaptor.py b/mindspeed/megatron_adaptor.py
+index f14b231d..4615590e 100644
+--- a/mindspeed/megatron_adaptor.py
++++ b/mindspeed/megatron_adaptor.py
+@@ -57,6 +57,7 @@ def delete_lock_file():
+ def repatch(args):
+ MindSpeedFeaturesManager.remove_patches()
+ full_args = get_full_args()
++ args = vars(args)
+ for k, v in args.items():
+ setattr(full_args, k, v)
+ MindSpeedFeaturesManager.apply_features_pre_patches(full_args)
+diff --git a/mindspeed/te/pytorch/attention/dot_product_attention/dot_product_attention.py b/mindspeed/te/pytorch/attention/dot_product_attention/dot_product_attention.py
+index ac4eabe5..f82a581b 100644
+--- a/mindspeed/te/pytorch/attention/dot_product_attention/dot_product_attention.py
++++ b/mindspeed/te/pytorch/attention/dot_product_attention/dot_product_attention.py
+@@ -330,6 +330,8 @@ class DotProductAttention(torch.nn.Module):
+ inference_params: Any = None,
+ pad_between_seqs: Optional[bool] = None,
+ fp8_output: Optional[bool] = False,
++ local_cp_size=None,
++ cp_group=None,
+ ) -> torch.Tensor:
+ """
+ Dot Product Attention Layer.
+@@ -653,6 +655,30 @@ class MindSpeedTEDotProductAttention(DotProductAttention):
+ packed_seq_params: PackedSeqParams = None,
+ ):
+ """Forward."""
++ packed_seq_kwargs = (
++ {key: getattr(packed_seq_params, key) for key in self.kept_packed_seq_params}
++ if packed_seq_params is not None
++ else {}
++ )
++
++ # Honor the per-call attn_mask_type. Megatron passes AttnMaskType.no_mask for the
++ # (bidirectional) vision tower and AttnMaskType.causal for the causal LM decoder.
++ # The previous code unconditionally built a triu(2048) causal mask and forwarded
++ # config.attention_mask_type, forcing CAUSAL attention even for no_mask -> the
++ # Qwen3-VL vision tower's full attention was computed causally (wrong image embeds).
++ if attn_mask_type == AttnMaskType.no_mask:
++ # Full (bidirectional) attention: no causal mask; per-image segmentation is
++ # handled by cu_seqlens in packed_seq_params (sparse_mode=0 via 'no_mask').
++ core_attn_out = super().forward(
++ query,
++ key,
++ value,
++ None,
++ attn_mask_type='no_mask',
++ **packed_seq_kwargs,
++ )
++ return core_attn_out
++
+ if (
+ attention_mask is None and
+ self.attn_mask_type == AttnMaskType.causal
+@@ -660,12 +686,6 @@ class MindSpeedTEDotProductAttention(DotProductAttention):
+ self.config.sparse_mode = 2
+ attention_mask = get_attention_mask(self.config)
+
+- packed_seq_kwargs = (
+- {key: getattr(packed_seq_params, key) for key in self.kept_packed_seq_params}
+- if packed_seq_params is not None
+- else {}
+- )
+-
+ core_attn_out = super().forward(
+ query,
+ key,
+diff --git a/mindspeed/core/fusions/fused_rope.py b/mindspeed/core/fusions/fused_rope.py
+index 70f7cb08..15189c2d 100644
+--- a/mindspeed/core/fusions/fused_rope.py
++++ b/mindspeed/core/fusions/fused_rope.py
+@@ -65,7 +65,7 @@ def apply_rotary_pos_emb_bshd(
+ cos_ = (torch.cos(freqs) * _mscale).to(t.dtype)
+ sin_ = (torch.sin(freqs) * _mscale).to(t.dtype)
+
+- if getattr(args, "use_fused_rotary_pos_emb"):
++ if getattr(args, "use_fused_rotary_pos_emb", False):
+ mode = 1 if rotary_interleaved else 0
+ t = npu_rotary_position_embedding(t.contiguous(), cos_, sin_, mode).to(t.dtype)
+ else:
+@@ -82,7 +82,7 @@ def transformer_config_post_init_wrapper(fn):
+ self.apply_rope_fusion = False
+ fn(self)
+ self.apply_rope_fusion = ori_apply_rope_fusion
+- if ((getattr(self, "multi_head_latent_attention") or getattr(self, "multi_latent_attention"))
++ if ((getattr(self, "multi_head_latent_attention", False) or getattr(self, "multi_latent_attention", False))
+ and self.rope_type == "yarn"):
+ self.apply_rope_fusion = False
+ del ori_apply_rope_fusion
+diff --git a/mindspeed/core/models/common/embeddings/rotary_pos_embedding.py b/mindspeed/core/models/common/embeddings/rotary_pos_embedding.py
+index c0319906..c0741691 100644
+--- a/mindspeed/core/models/common/embeddings/rotary_pos_embedding.py
++++ b/mindspeed/core/models/common/embeddings/rotary_pos_embedding.py
+@@ -79,7 +79,7 @@ def apply_rotary_pos_emb_bshd(t: Tensor, freqs: Tensor, rotary_interleaved: bool
+ cos_ = (torch.cos(freqs) * _mscale).to(t.dtype)
+ sin_ = (torch.sin(freqs) * _mscale).to(t.dtype)
+
+- if getattr(args, "use_fused_rotary_pos_emb"):
++ if getattr(args, "use_fused_rotary_pos_emb", False):
+ mode = 1 if rotary_interleaved else 0
+ t = npu_rotary_position_embedding(t.contiguous(), cos_, sin_, mode).to(t.dtype)
+ else:
+diff --git a/mindspeed/features_manager/features_manager.py b/mindspeed/features_manager/features_manager.py
+index ba476adc..f5924b98 100644
+--- a/mindspeed/features_manager/features_manager.py
++++ b/mindspeed/features_manager/features_manager.py
+@@ -43,7 +43,10 @@ class MindSpeedFeaturesManager:
+ post_validate_features_args(args=args) # args.x = old_x
+ """
+ for feature in cls.FEATURES_LIST:
+- feature.pre_validate_args(args)
++ try:
++ feature.pre_validate_args(args)
++ except AttributeError:
++ pass
+
+ @classmethod
+ def post_validate_features_args(cls, args):
+@@ -54,13 +57,19 @@ class MindSpeedFeaturesManager:
+ post_validate_features_args(args=args) # args.x = old_x
+ """
+ for feature in cls.FEATURES_LIST:
+- feature.post_validate_args(args)
++ try:
++ feature.post_validate_args(args)
++ except AttributeError:
++ pass
+
+ @classmethod
+ def validate_features_args(cls, args):
+ """Validate arguments of all features."""
+ for feature in cls.FEATURES_LIST:
+- feature.validate_args(args)
++ try:
++ feature.validate_args(args)
++ except AttributeError:
++ pass
+
+ @classmethod
+ def remove_patches(cls):
+diff --git a/mindspeed/features_manager/moe/fb_overlap.py b/mindspeed/features_manager/moe/fb_overlap.py
+index 6c76d891..e099c51d 100644
+--- a/mindspeed/features_manager/moe/fb_overlap.py
++++ b/mindspeed/features_manager/moe/fb_overlap.py
+@@ -16,6 +16,8 @@ class MoEFwdBwdOverlapFeature(MindSpeedFeature):
+ group.add_argument('--moe-unperm2-mem-optim-swap', action='store_true')
+
+ def validate_args(self, args):
++ if not hasattr(args, 'moe_fb_overlap'):
++ return
+ self.incompatible_check(args, 'moe_alltoall_overlap_comm')
+ self.incompatible_check(args, 'overlap_grad_reduce')
+ self.incompatible_check(args, 'moe_hierarchical_alltoallv')
+diff --git a/mindspeed/features_manager/moe/moe_zero_memory.py b/mindspeed/features_manager/moe/moe_zero_memory.py
+index 4d95e64e..94f26109 100644
+--- a/mindspeed/features_manager/moe/moe_zero_memory.py
++++ b/mindspeed/features_manager/moe/moe_zero_memory.py
+@@ -23,6 +23,8 @@ class MoEZeroMemoryFeature(MindSpeedFeature):
+ 'in each pp stage.')
+
+ def pre_validate_args(self, args):
++ if not hasattr(args, "moe_zero_memory_num_layers") or not hasattr(args, "moe_zero_memory"):
++ return
+ #Zero Memory check.
+ if args.moe_zero_memory_num_layers is not None:
+ num_layers_per_pipeline_stage = args.num_layers // args.pipeline_model_parallel_size
diff --git a/docker/npu_patch/series.conf b/docker/npu_patch/series.conf
new file mode 100644
index 000000000..10d543dee
--- /dev/null
+++ b/docker/npu_patch/series.conf
@@ -0,0 +1,17 @@
+# Ordered NPU patch series used by the runtime CI reconciler.
+# Format: target_worktree|image_patch|source_patch.
+# Apply top-to-bottom from source_patch; revert bottom-to-top from image_patch.
+#
+# PATCH MAINTENANCE:
+# - Add: add the required COPY/apply operations to docker/Dockerfile.npu, then
+# add this entry in the same application order.
+# - Delete: remove the explicit Dockerfile operation and this entry, then delete
+# the patch file. The previous image keeps the OLD bytes needed for revert.
+# - Rename/reorder: update both Dockerfile.npu and this file in the same change.
+# Ordinary patch changes must not add patch-specific logic to the CI script.
+/vllm-workspace/vllm|vllm.patch|docker/npu_patch/vllm.patch
+/vllm-workspace/vllm-ascend|vllm-ascend.patch|docker/npu_patch/vllm-ascend.patch
+/root/Megatron-LM|megatron-common.patch|docker/patch/latest/megatron.patch
+/root/Megatron-LM|megatron.patch|docker/npu_patch/megatron.patch
+/root/Megatron-Bridge|megatron-bridge.patch|docker/npu_patch/megatron-bridge.patch
+/root/MindSpeed|mindspeed.patch|docker/npu_patch/mindspeed.patch
diff --git a/docker/npu_patch/vllm-ascend.patch b/docker/npu_patch/vllm-ascend.patch
new file mode 100644
index 000000000..768852a59
--- /dev/null
+++ b/docker/npu_patch/vllm-ascend.patch
@@ -0,0 +1,472 @@
+diff --git a/vllm_ascend/distributed/weight_transfer/__init__.py b/vllm_ascend/distributed/weight_transfer/__init__.py
+index d6434f05f..fd7223836 100644
+--- a/vllm_ascend/distributed/weight_transfer/__init__.py
++++ b/vllm_ascend/distributed/weight_transfer/__init__.py
+@@ -33,6 +33,11 @@ def register_engine():
+ "vllm_ascend.distributed.weight_transfer.npu_ipc_engine",
+ "NPUIPCWeightTransferEngine",
+ )
++ WeightTransferTrainerFactory.register_engine(
++ "hccl",
++ "vllm_ascend.distributed.weight_transfer.hccl_engine",
++ "HCCLTrainerWeightTransferEngine",
++ )
+ WeightTransferTrainerFactory.register_engine(
+ "npu_ipc",
+ "vllm_ascend.distributed.weight_transfer.npu_ipc_engine",
+diff --git a/vllm_ascend/distributed/weight_transfer/hccl_engine.py b/vllm_ascend/distributed/weight_transfer/hccl_engine.py
+index 023ffd1ae..7b184e279 100644
+--- a/vllm_ascend/distributed/weight_transfer/hccl_engine.py
++++ b/vllm_ascend/distributed/weight_transfer/hccl_engine.py
+@@ -3,8 +3,9 @@
+ """HCCL-based weight transfer engine."""
+
+ from collections.abc import Callable, Iterator
+-from dataclasses import dataclass
+-from typing import TYPE_CHECKING, Any
++from concurrent.futures import ThreadPoolExecutor
++from dataclasses import asdict, dataclass
++from typing import TYPE_CHECKING, Any, ClassVar
+
+ import torch
+
+@@ -14,6 +15,11 @@ if TYPE_CHECKING:
+ from vllm.config import VllmConfig
+ from vllm.config.weight_transfer import WeightTransferConfig
+ from vllm.distributed.weight_transfer.base import (
++ ParamMeta,
++ TrainerInitInfo,
++ TrainerWeightTransferEngine,
++ VLLMWeightSyncClient,
++ WeightSource,
+ WeightTransferEngine,
+ WeightTransferInitInfo,
+ WeightTransferUpdateInfo,
+@@ -26,6 +32,19 @@ from vllm_ascend.distributed.weight_transfer.packed_tensor import (
+ )
+
+
++@dataclass
++class HCCLTrainerInitInfo(TrainerInitInfo):
++ """Stateful trainer configuration; rank 0 owns the HCCL endpoint."""
++
++ backend: ClassVar[str] = "hccl"
++ master_address: str
++ master_port: int
++ world_size: int
++ packed: bool = True
++ packed_buffer_size_bytes: int = DEFAULT_PACKED_BUFFER_SIZE_BYTES
++ packed_num_buffers: int = DEFAULT_PACKED_NUM_BUFFERS
++
++
+ @dataclass
+ class HCCLWeightTransferInitInfo(WeightTransferInitInfo):
+ """Initialization info for HCCL weight transfer backend."""
+@@ -338,3 +357,114 @@ class HCCLWeightTransferEngine(WeightTransferEngine[HCCLWeightTransferInitInfo,
+ pg = StatelessProcessGroup.create(host=master_address, port=master_port, rank=rank, world_size=world_size)
+ pyhccl = PyHcclCommunicator(pg, device=device)
+ return pyhccl
++
++
++class HCCLTrainerWeightTransferEngine(TrainerWeightTransferEngine[HCCLTrainerInitInfo]):
++ """Stateful control plane over the existing HCCL broadcast transport.
++
++ Every trainer rank replays the source collectives. Only rank 0 opens the
++ transfer communicator and drives worker RPCs. The frozen receiver expects
++ packed geometry on each update, so derive it from the trainer init info.
++ """
++
++ init_info_cls = HCCLTrainerInitInfo
++
++ def __init__(self, *, init_info: HCCLTrainerInitInfo, client: VLLMWeightSyncClient, source: WeightSource) -> None:
++ super().__init__(client=client, source=source, is_sender=init_info.is_sender)
++ self.init_info = init_info
++ self.model_update_group: PyHcclCommunicator | None = None
++
++ @classmethod
++ def trainer_init(
++ cls,
++ init_info: HCCLTrainerInitInfo,
++ *,
++ client: VLLMWeightSyncClient,
++ source: WeightSource,
++ ) -> "HCCLTrainerWeightTransferEngine":
++ engine = cls(init_info=init_info, client=client, source=source)
++ if not engine.is_sender:
++ return engine
++
++ worker_info = HCCLWeightTransferInitInfo(
++ master_address=init_info.master_address,
++ master_port=init_info.master_port,
++ rank_offset=1,
++ world_size=init_info.world_size,
++ )
++ # Both endpoints must rendezvous concurrently.
++ executor = ThreadPoolExecutor(max_workers=1)
++ try:
++ future = executor.submit(client.init_weight_transfer_engine, asdict(worker_info))
++ if future.done():
++ future.result()
++ engine.model_update_group = HCCLWeightTransferEngine.trainer_init(worker_info)
++ future.result()
++ finally:
++ executor.shutdown(wait=False)
++ return engine
++
++ def send_weights(self) -> None:
++ # Metadata export can itself contain Megatron collectives.
++ meta = self.source.metadata()
++ if not self.is_sender:
++ for _ in self.source:
++ pass
++ torch.npu.current_stream().synchronize()
++ return
++
++ assert self.model_update_group is not None, "HCCL trainer has been shut down."
++ info = self.init_info
++ update_info = HCCLWeightTransferUpdateInfo(
++ names=[m.name for m in meta],
++ dtype_names=[str(m.dtype).split(".")[-1] for m in meta],
++ shapes=[list(m.shape) for m in meta],
++ packed=info.packed,
++ packed_buffer_size_bytes=info.packed_buffer_size_bytes,
++ packed_num_buffers=info.packed_num_buffers,
++ )
++ self.client.start_weight_update()
++ executor = ThreadPoolExecutor(max_workers=1)
++ try:
++ future = executor.submit(self.client.update_weights, asdict(update_info))
++ if future.done():
++ future.result()
++ HCCLWeightTransferEngine.trainer_send_weights(
++ self._checked_iter(self.source, meta),
++ HCCLTrainerSendWeightsArgs(
++ group=self.model_update_group,
++ packed=info.packed,
++ packed_buffer_size_bytes=info.packed_buffer_size_bytes,
++ packed_num_buffers=info.packed_num_buffers,
++ ),
++ )
++ future.result()
++ finally:
++ # A failed broadcast can leave the worker RPC waiting in HCCL.
++ # Surface the error; joining that thread here would hide it forever.
++ executor.shutdown(wait=False)
++ self.client.finish_weight_update()
++ torch.npu.current_stream().synchronize()
++
++ @staticmethod
++ def _checked_iter(source: WeightSource, meta: list[ParamMeta]) -> Iterator[tuple[str, torch.Tensor]]:
++ # Receive buffer sizes and packed chunk boundaries come from metadata.
++ sent = 0
++ for name, tensor in source:
++ if sent >= len(meta):
++ raise ValueError(f"WeightSource yielded more parameters than metadata(): {name!r}.")
++ expected = meta[sent]
++ if name != expected.name or tensor.dtype != expected.dtype or tuple(tensor.shape) != expected.shape:
++ raise ValueError(
++ f"WeightSource metadata() disagrees with iteration at index {sent}: "
++ f"expected {expected.name!r} {expected.dtype} {expected.shape}, "
++ f"got {name!r} {tensor.dtype} {tuple(tensor.shape)}."
++ )
++ sent += 1
++ # Unpacked HCCL reads contiguous storage from data_ptr().
++ yield name, tensor if tensor.is_contiguous() else tensor.contiguous()
++ if sent != len(meta):
++ raise ValueError(f"WeightSource yielded {sent} parameters but metadata() declared {len(meta)}.")
++
++ def shutdown(self) -> None:
++ self.model_update_group = None
+diff --git a/vllm_ascend/distributed/weight_transfer/npu_ipc_engine.py b/vllm_ascend/distributed/weight_transfer/npu_ipc_engine.py
+index 7a63adf7b..d3e3a7fc4 100644
+--- a/vllm_ascend/distributed/weight_transfer/npu_ipc_engine.py
++++ b/vllm_ascend/distributed/weight_transfer/npu_ipc_engine.py
+@@ -156,12 +156,14 @@ class NPUIPCWeightTransferEngine( # type: ignore[no-redef]
+ self.packed = init_info.packed
+
+ def start_weight_update(self) -> None:
+- """No-op for NPU IPC engine (no layerwise reloading)."""
+- pass
++ from vllm.model_executor.model_loader.reload import initialize_layerwise_reload
++
++ initialize_layerwise_reload(self.model)
+
+ def finish_weight_update(self) -> None:
+- """No-op for NPU IPC engine (no layerwise reloading)."""
+- pass
++ from vllm.model_executor.model_loader.reload import finalize_layerwise_reload
++
++ finalize_layerwise_reload(self.model, self.model_config)
+
+ def receive_weights(self, update_info: NPUIPCWeightTransferUpdateInfo) -> None:
+ """Receive weights from the trainer via NPU IPC handles.
+@@ -170,6 +172,10 @@ class NPUIPCWeightTransferEngine( # type: ignore[no-redef]
+ update_info: NPU IPC update info containing parameter names,
+ dtypes, shapes, and IPC handles.
+ """
++ # A rank-local slot may have no weights in this chunk (and no handle).
++ if not update_info.names:
++ return
++
+ # Use the worker's assigned device rather than the ambient current
+ # device: the receive path is no longer wrapped in
+ # ``with torch.device(self.device)`` by the caller, so the current
+@@ -219,7 +225,7 @@ class NPUIPCWeightTransferEngine( # type: ignore[no-redef]
+ weight = rebuild_npu_tensor(*list_args)
+ weights.append((name, weight))
+
+- self.model.load_weights(weights)
++ self.model.load_weights(weights)
+
+ def shutdown(self) -> None:
+ pass
+@@ -288,6 +294,8 @@ class NPUIPCTrainerWeightTransferEngine(IPCTrainerWeightTransferEngine):
+ self.client.finish_weight_update()
+ self._post_send_sync()
+ del weight_refs
++ torch.npu.ipc_collect()
++ torch.npu.empty_cache()
+
+ def _send(self, source: "WeightSource") -> list[torch.Tensor] | None:
+ if self.packed:
+diff --git a/vllm_ascend/distributed/weight_transfer/packed_tensor.py b/vllm_ascend/distributed/weight_transfer/packed_tensor.py
+index a35d9af8d..d55c988c3 100644
+--- a/vllm_ascend/distributed/weight_transfer/packed_tensor.py
++++ b/vllm_ascend/distributed/weight_transfer/packed_tensor.py
+@@ -39,6 +39,7 @@ def packed_broadcast_producer(
+ target_packed_tensor_size = buffer_size_bytes
+
+ streams = [torch.npu.Stream() for _ in range(num_buffers)]
++ source_stream = torch.npu.current_stream()
+ buffer_idx = 0
+
+ packing_tensor_list: list[list[torch.Tensor]] = [[] for _ in range(num_buffers)]
+@@ -50,6 +51,8 @@ def packed_broadcast_producer(
+ # Synchronize the current stream (waits for previous
+ # iteration's work on this buffer to finish)
+ streams[buffer_idx].synchronize()
++ # Wait for tensors produced on the caller's stream before packing.
++ streams[buffer_idx].wait_stream(source_stream)
+ # Start tasks for the new buffer in a new stream
+ with torch.npu.stream(streams[buffer_idx]):
+ # Initialize the packing tensor list and sizes
+@@ -206,10 +209,10 @@ def packed_npu_ipc_producer(
+ ) -> Iterator[dict[str, Any]]:
+ """Pack tensors into a reusable NPU IPC buffer and yield chunks.
+
+- Allocates a single NPU buffer of ``buffer_size_bytes`` and registers
+- it for IPC once via ``reduce_tensor``. Each chunk's packed data is
+- copied into this buffer before yielding, so only one IPC-shared
+- allocation exists for the lifetime of the transfer.
++ Allocates a single NPU buffer of ``buffer_size_bytes``. Each chunk
++ publishes a fresh IPC reference via ``reduce_tensor`` so its consumer
++ releases that reference exactly once. The underlying buffer is reused
++ for the lifetime of the transfer.
+
+ Args:
+ iterator: Iterator of (name, tensor) pairs.
+@@ -218,9 +221,6 @@ def packed_npu_ipc_producer(
+ buffer_size_bytes: Exact capacity of the reusable IPC buffer.
+ """
+ ipc_buffer = torch.empty(buffer_size_bytes, dtype=torch.uint8, device="npu")
+- # Store only the rebuild args (drop the func); the consumer rebuilds with
+- # the well-known ``rebuild_npu_tensor``, mirroring upstream's CUDA IPC engine.
+- _, ipc_args = reduce_tensor(ipc_buffer)
+
+ names: list[str] = []
+ shapes: list[list[int]] = []
+@@ -240,6 +240,7 @@ def packed_npu_ipc_producer(
+
+ if total_bytes and total_bytes + flat.numel() > buffer_size_bytes:
+ torch.npu.current_stream().synchronize()
++ _, ipc_args = reduce_tensor(ipc_buffer)
+ yield {
+ "names": names,
+ "shapes": shapes,
+@@ -259,6 +260,7 @@ def packed_npu_ipc_producer(
+
+ if total_bytes:
+ torch.npu.current_stream().synchronize()
++ _, ipc_args = reduce_tensor(ipc_buffer)
+ yield {
+ "names": names,
+ "shapes": shapes,
+diff --git a/vllm_ascend/worker/v2/model_runner.py b/vllm_ascend/worker/v2/model_runner.py
+index c388619f2..38a7d2fb7 100644
+--- a/vllm_ascend/worker/v2/model_runner.py
++++ b/vllm_ascend/worker/v2/model_runner.py
+@@ -17,7 +17,7 @@
+ # This file is a part of the vllm-ascend project.
+ #
+
+-from contextlib import contextmanager
++from contextlib import AbstractContextManager, contextmanager
+
+ import numpy as np
+ import torch
+@@ -239,9 +239,13 @@ class NPUModelRunner(GPUModelRunner):
+ self.pp_handler.broadcast_draft_tokens()
+ return output
+
+- def initialize_kv_cache(self, kv_cache_config: KVCacheConfig) -> None:
++ def initialize_kv_cache(
++ self,
++ kv_cache_config: KVCacheConfig,
++ kv_cache_allocation_context: AbstractContextManager | None = None,
++ ) -> None:
+ with graph_manager_wrapper(self):
+- super().initialize_kv_cache(kv_cache_config)
++ super().initialize_kv_cache(kv_cache_config, kv_cache_allocation_context=kv_cache_allocation_context)
+ if self.pcp_manager is not None:
+ assert isinstance(self.pcp_manager, AscendPCPManager)
+ self.pcp_manager.vllm_config = self.vllm_config
+diff --git a/vllm_ascend/worker/worker.py b/vllm_ascend/worker/worker.py
+index 6d99ac76a..b7df4df22 100644
+--- a/vllm_ascend/worker/worker.py
++++ b/vllm_ascend/worker/worker.py
+@@ -321,7 +321,48 @@ class NPUWorker(WorkerBase):
+
+ def start_weight_update(self) -> None:
+ """Begin a new weight update; prepares the model for layerwise reload."""
++ with set_current_vllm_config(self.vllm_config):
++ self._start_weight_update()
++
++ def get_draft_model(self) -> nn.Module | None:
++ return self.model_runner.get_draft_model()
++
++ def supports_draft_weight_updates(self) -> bool:
++ engine = self.weight_transfer_engine
++ speculative_config = self.speculative_config
++ get_draft_model = getattr(self.model_runner, "get_draft_model", None)
++ return (
++ engine is not None
++ and engine.supports_draft_weight_update
++ and callable(get_draft_model)
++ and get_draft_model() is not None
++ and speculative_config is not None
++ and speculative_config.draft_model_config is not None
++ )
++
++ def _set_draft_weight_update_target(self) -> None:
++ assert self.weight_transfer_engine is not None
++ draft_model = self.get_draft_model()
++ if draft_model is None:
++ raise RuntimeError("Draft model weight update requested, but no draft model is configured.")
++ speculative_config = self.speculative_config
++ if speculative_config is None or speculative_config.draft_model_config is None:
++ raise RuntimeError("Draft model weight update requested, but no draft model config is configured.")
++ self.weight_transfer_engine.set_weight_update_target(draft_model, speculative_config.draft_model_config)
++
++ def start_draft_weight_update(self) -> None:
++ """Retarget the native engine at the draft model for this session."""
++ with set_current_vllm_config(self.vllm_config):
++ self._start_weight_update(is_draft=True)
++
++ def _start_weight_update(self, is_draft: bool = False) -> None:
+ self._check_weight_transfer_engine()
++ assert self.weight_transfer_engine is not None
++
++ if is_draft and not self.weight_transfer_engine.supports_draft_weight_update:
++ raise RuntimeError(
++ f"{type(self.weight_transfer_engine).__name__} does not support draft model weight updates."
++ )
+
+ if self._weight_update_active:
+ raise RuntimeError(
+@@ -330,11 +371,16 @@ class NPUWorker(WorkerBase):
+
+ self._check_nz_disabled()
+
+- assert self.weight_transfer_engine is not None
+- self.weight_transfer_engine.start_weight_update()
++ try:
++ if is_draft:
++ self._set_draft_weight_update_target()
++ self.weight_transfer_engine.start_weight_update()
++ except BaseException:
++ self.weight_transfer_engine.reset_weight_update_target()
++ raise
+ self._weight_update_active = True
+
+- def update_weights(self, update_info: dict) -> None:
++ def update_weights(self, update_info: dict | list[dict]) -> None:
+ """Receive a chunk of weights from the trainer and load them in place."""
+ self._check_weight_transfer_engine()
+ assert self.weight_transfer_engine is not None
+@@ -343,11 +389,19 @@ class NPUWorker(WorkerBase):
+ if not self._weight_update_active:
+ raise RuntimeError("start_weight_update must be called before update_weights.")
+
+- try:
+- self.weight_transfer_engine.update_weights(update_info)
+- except BaseException:
+- self._weight_update_active = False
+- raise
++ with set_current_vllm_config(self.vllm_config):
++ try:
++ if isinstance(update_info, list):
++ parallel_config = self.vllm_config.parallel_config
++ worker_rank = parallel_config.data_parallel_rank * parallel_config.world_size + self.rank
++ local_update_info = update_info[worker_rank]
++ else:
++ local_update_info = update_info
++ self.weight_transfer_engine.update_weights(local_update_info)
++ except BaseException:
++ self._weight_update_active = False
++ self.weight_transfer_engine.reset_weight_update_target()
++ raise
+
+ def finish_weight_update(self) -> None:
+ """Finish the current weight update; runs layerwise postprocessing."""
+@@ -357,8 +411,12 @@ class NPUWorker(WorkerBase):
+ raise RuntimeError("start_weight_update must be called before finish_weight_update.")
+
+ assert self.weight_transfer_engine is not None
+- self.weight_transfer_engine.finish_weight_update()
+- self._weight_update_active = False
++ with set_current_vllm_config(self.vllm_config):
++ try:
++ self.weight_transfer_engine.finish_weight_update()
++ finally:
++ self._weight_update_active = False
++ self.weight_transfer_engine.reset_weight_update_target()
+
+ def shutdown(self) -> None:
+ if ensure_kv_transfer_shutdown is not None:
+@@ -448,7 +506,9 @@ class NPUWorker(WorkerBase):
+ # take current memory snapshot
+ self.init_snapshot = MemorySnapshot(device=device)
+ self.requested_memory = self.init_snapshot.total_memory * self.cache_config.gpu_memory_utilization
+- if self.init_snapshot.free_memory < self.requested_memory:
++ weight_transfer_config = self.vllm_config.weight_transfer_config
++ uses_ipc_weight_transfer = weight_transfer_config is not None and weight_transfer_config.backend == "npu_ipc"
++ if not uses_ipc_weight_transfer and self.init_snapshot.free_memory < self.requested_memory:
+ GiB = lambda b: round(b / GiB_bytes, 2)
+ raise ValueError(
+ f"Free memory on device "
+@@ -597,7 +657,9 @@ class NPUWorker(WorkerBase):
+ self.non_torch_memory = profile_result.non_torch_increase
+
+ free_gpu_memory = profile_result.after_profile.free_memory
+- assert self.init_snapshot.free_memory > free_gpu_memory, (
++ weight_transfer_config = self.vllm_config.weight_transfer_config
++ uses_ipc_weight_transfer = weight_transfer_config is not None and weight_transfer_config.backend == "npu_ipc"
++ assert uses_ipc_weight_transfer or self.init_snapshot.free_memory > free_gpu_memory, (
+ "Error in memory profiling. "
+ f"Initial free memory {GiB(self.init_snapshot.free_memory)} GiB, "
+ f"current free memory {GiB(free_gpu_memory)} GiB. "
+@@ -1118,8 +1180,12 @@ class NPUWorker(WorkerBase):
+ from contextlib import nullcontext
+
+ context = nullcontext() # type: ignore
+- with context:
+- self.model_runner.initialize_kv_cache(kv_cache_config)
++ if self.use_v2_model_runner:
++ # MRV2 bookkeeping must survive sleep; pool only the KV data.
++ self.model_runner.initialize_kv_cache(kv_cache_config, kv_cache_allocation_context=context)
++ else:
++ with context:
++ self.model_runner.initialize_kv_cache(kv_cache_config)
+
+ # MRV2's scheduler emits new_block_ids_to_zero whenever this flag is
+ # set, so its worker-side consumer must use the same condition. Keep the
diff --git a/docker/npu_patch/vllm.patch b/docker/npu_patch/vllm.patch
new file mode 100644
index 000000000..142941525
--- /dev/null
+++ b/docker/npu_patch/vllm.patch
@@ -0,0 +1,158 @@
+diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+index f304bf677b..71a17e9363 100644
+--- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
++++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+@@ -240,6 +240,8 @@ class GenerateStreamResponse(BaseModel):
+ )
+ choices: list[GenerateResponseStreamChoice]
+ usage: UsageInfo | None = Field(default=None)
++ weight_version: str | None = None
++ request_spec_decode_stats: dict[str, Any] | None = Field(default=None)
+
+
+ class GenerateResponse(BaseModel):
+@@ -255,6 +257,8 @@ class GenerateResponse(BaseModel):
+ created: int | None = None
+ choices: list[GenerateResponseChoice]
+ usage: UsageInfo | None = Field(default=None)
++ weight_version: str | None = None
++ request_spec_decode_stats: dict[str, Any] | None = Field(default=None)
+ prompt_logprobs: list[dict[int, Logprob] | None] | None = None
+
+ kv_transfer_params: dict[str, Any] | None = Field(
+diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py
+index bbbd85137c..809f1a66ea 100644
+--- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py
++++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py
+@@ -14,6 +14,7 @@ from vllm.engine.protocol import EngineClient
+ from vllm.entrypoints.chat_utils import AsyncMultiModalItemTracker
+ from vllm.entrypoints.generate.base.serving import (
+ GenerateBaseServing,
++ build_spec_decoding_metrics,
+ clamp_prompt_logprobs,
+ )
+ from vllm.entrypoints.openai.chat_completion.protocol import (
+@@ -261,6 +262,7 @@ class ServingTokens(GenerateBaseServing):
+ )
+
+ assert result_generator is not None
++ weight_version = await self.engine_client.get_weight_version()
+
+ if request.stream:
+ return self.serve_tokens_stream_generator(
+@@ -269,10 +271,16 @@ class ServingTokens(GenerateBaseServing):
+ request_id,
+ model_name,
+ request_metadata,
++ weight_version,
+ )
+
+ return await self.serve_tokens_full_generator(
+- request, result_generator, request_id, model_name, request_metadata
++ request,
++ result_generator,
++ request_id,
++ model_name,
++ request_metadata,
++ weight_version,
+ )
+
+ async def serve_tokens_full_generator(
+@@ -282,6 +290,7 @@ class ServingTokens(GenerateBaseServing):
+ request_id: str,
+ model_name: str,
+ request_metadata: RequestResponseMetadata,
++ weight_version: str | None,
+ ) -> ErrorResponse | GenerateResponse:
+ created_time = int(time.time())
+ final_res: RequestOutput | None = None
+@@ -355,6 +364,11 @@ class ServingTokens(GenerateBaseServing):
+ cached_tokens=final_res.num_cached_tokens
+ )
+
++ spec_decode_metrics = build_spec_decoding_metrics(final_res)
++ request_spec_decode_stats = (
++ spec_decode_metrics.model_dump() if spec_decode_metrics else None
++ )
++
+ request_metadata.final_usage_info = usage
+
+ response = GenerateResponse(
+@@ -363,6 +377,8 @@ class ServingTokens(GenerateBaseServing):
+ model=model_name,
+ choices=choices,
+ usage=usage,
++ weight_version=weight_version,
++ request_spec_decode_stats=request_spec_decode_stats,
+ prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs),
+ kv_transfer_params=final_res.kv_transfer_params,
+ ec_transfer_params=final_res.ec_transfer_params,
+@@ -396,11 +412,13 @@ class ServingTokens(GenerateBaseServing):
+ request_id: str,
+ model_name: str,
+ request_metadata: RequestResponseMetadata,
++ weight_version: str | None,
+ ) -> AsyncGenerator[str, None]:
+ num_prompt_tokens = 0
+ num_generated_tokens: list[int] = []
+ first_iteration = True
+ num_cached_tokens = None
++ request_spec_decode_stats: dict[str, object] | None = None
+ sampling_params: SamplingParams = request.sampling_params
+
+ include_usage, include_continuous_usage = should_include_usage(
+@@ -409,6 +427,9 @@ class ServingTokens(GenerateBaseServing):
+
+ try:
+ async for res in result_generator:
++ spec_decode_metrics = build_spec_decoding_metrics(res)
++ if spec_decode_metrics is not None:
++ request_spec_decode_stats = spec_decode_metrics.model_dump()
+ if first_iteration:
+ if res.prompt_token_ids is not None:
+ num_prompt_tokens = len(res.prompt_token_ids)
+@@ -448,6 +469,8 @@ class ServingTokens(GenerateBaseServing):
+
+ chunk = GenerateStreamResponse(
+ request_id=request_id,
++ weight_version=weight_version,
++ request_spec_decode_stats=request_spec_decode_stats,
+ choices=[
+ GenerateResponseStreamChoice(
+ index=i,
+@@ -482,6 +505,8 @@ class ServingTokens(GenerateBaseServing):
+ if include_usage:
+ final_chunk = GenerateStreamResponse(
+ request_id=request_id,
++ weight_version=weight_version,
++ request_spec_decode_stats=request_spec_decode_stats,
+ choices=[],
+ usage=final_usage_info,
+ )
+diff --git a/vllm/model_executor/model_loader/reload/meta.py b/vllm/model_executor/model_loader/reload/meta.py
+index a8f8023cf2..d62f39a2b8 100644
+--- a/vllm/model_executor/model_loader/reload/meta.py
++++ b/vllm/model_executor/model_loader/reload/meta.py
+@@ -30,5 +30,6 @@ SKIP_LOAD_TENSORS: set[str] = {
+ "expert_global_to_physical",
+ "expert_physical_to_global",
+ "expert_local_to_global",
++ "expert_ids_per_ep_rank",
+ "e_score_correction_bias",
+ }
+diff --git a/vllm/model_executor/models/glm4_moe_lite_mtp.py b/vllm/model_executor/models/glm4_moe_lite_mtp.py
+index 1d30a0eaf6..37239f7f62 100644
+--- a/vllm/model_executor/models/glm4_moe_lite_mtp.py
++++ b/vllm/model_executor/models/glm4_moe_lite_mtp.py
+@@ -129,6 +129,10 @@ class Glm4MoeLiteMultiTokenPredictorLayer(nn.Module):
+ ) -> torch.Tensor:
+ assert inputs_embeds is not None
+ # masking inputs at position 0, as not needed by MTP
+- inputs_embeds[positions == 0] = 0
++ # Avoid dynamic bool indexing during NPU graph capture.
++ mask = (positions == 0).unsqueeze(-1)
++ inputs_embeds = torch.where(
++ mask, torch.zeros_like(inputs_embeds), inputs_embeds
++ )
+ inputs_embeds = self.enorm(inputs_embeds)
+ previous_hidden_states = self.hnorm(previous_hidden_states)
diff --git a/docs/en/get_started/NPU.md b/docs/en/get_started/NPU.md
new file mode 100644
index 000000000..b6084fd3e
--- /dev/null
+++ b/docs/en/get_started/NPU.md
@@ -0,0 +1,205 @@
+# NPU
+
+⚠️ If you encounter problems running vime on Ascend NPU, feel free to open an
+issue on [vllm-project/vime](https://github.com/vllm-project/vime/issues).
+
+## Introduction
+
+If you are running vime on Ascend NPU, please refer to the following materials.
+This tutorial explains how to set up the runtime environment and provides an
+end-to-end example for running GRPO training. It uses the **Megatron** training
+backend together with the **vLLM Ascend** rollout backend, synchronizing actor
+weights to vLLM through the native HCCL weight-sync path.
+
+The current NPU support targets Ascend **Atlas A2 / A3** (aarch64) hosts with the
+Ascend driver and **CANN 9.0.0** (Toolkit, Kernels, and NNAL/ATB) installed.
+Only `python==3.12` is supported.
+
+## Docker
+
+The recommended path for validation is the published vime NPU image.
+
+```bash
+export IMAGE=quay.io/ascend/vime:vime-latest
+# A2: export IMAGE=quay.io/ascend/vime:vime-a2-latest
+
+docker pull "${IMAGE}"
+```
+
+For source builds and dependency debugging, the patch list and pinned commits are
+documented in [`docker/npu_patch/README.md`](https://github.com/vllm-project/vime/blob/npu/docker/npu_patch/README.md).
+
+## Quick Start
+
+### Environment Setup
+
+Start the container, mounting the Ascend devices and driver files. Device names
+and driver mount paths vary by host; reuse the mounts from a known working vLLM
+Ascend container if the layout differs.
+
+```bash
+docker run -d --name vime-npu -it --net=host --shm-size=1024g \
+ --privileged=true \
+ --cap-add=SYS_PTRACE \
+ --device=/dev/davinci_manager \
+ --device=/dev/hisi_hdc \
+ --device=/dev/devmm_svm \
+ -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
+ -v /usr/local/dcmi:/usr/local/dcmi \
+ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
+ -v /usr/local/sbin:/usr/local/sbin \
+ -v /home:/home \
+ -v /mnt:/mnt \
+ -v /tmp:/tmp \
+ -v /data:/data \
+ -v /path/to:/path/to \
+ -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
+ "${IMAGE}"
+
+docker exec -it vime-npu bash
+```
+
+Inside the container, initialize the CANN environment before training:
+
+```bash
+source /usr/local/Ascend/ascend-toolkit/set_env.sh
+source /usr/local/Ascend/nnal/atb/set_env.sh
+```
+
+### Prepare Model and Data
+
+Set `MODEL_ROOT` to a host-visible directory that will hold both the checkpoint
+and the dataset, then download the Qwen3-4B checkpoint and the DAPO Math 17K
+dataset:
+
+```bash
+export MODEL_ROOT=/root
+mkdir -p ${MODEL_ROOT}/models ${MODEL_ROOT}/datasets
+
+# hf checkpoint
+hf download Qwen/Qwen3-4B \
+ --local-dir ${MODEL_ROOT}/models/Qwen3-4B
+
+# train data
+hf download --repo-type dataset zhuzilin/dapo-math-17k \
+ --local-dir ${MODEL_ROOT}/datasets/dapo-math-17k
+```
+
+### Example: Qwen3-4B
+
+We provide an example to run GRPO training with
+[Qwen3-4B](https://huggingface.co/Qwen/Qwen3-4B) on 8 NPUs (4 for the actor,
+4 for rollout), please refer to:
+[scripts/run-qwen3-4B-npu.sh](../../../scripts/run-qwen3-4B-npu.sh).
+Just run:
+
+```bash
+cd /root/vime
+
+# Source these explicitly if not already initialized by the image.
+source /usr/local/Ascend/ascend-toolkit/set_env.sh
+source /usr/local/Ascend/nnal/atb/set_env.sh
+
+DATA_ROOT="${MODEL_ROOT:-/root}" bash scripts/run-qwen3-4B-npu.sh \
+ 2>&1 | tee /root/vime/train_qwen3_4b_vllm.log
+```
+
+The full log is written to `/root/vime/train_qwen3_4b_vllm.log`.
+
+⚠️ Note: The main difference between the NPU training script and the NVIDIA one
+is the Ascend-specific environment variables — `ASCEND_RT_VISIBLE_DEVICES`
+selects the NPUs, and `RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1` lets
+Ray schedule them correctly. The reference target is an Atlas A3 host with 16
+visible NPUs; on an 8-NPU host, set
+`ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7`.
+
+We show the training script below:
+
+```bash
+export SLIME_SCRIPT_TRAIN_BACKEND=megatron
+export PYTHONPATH="/root/Megatron-Bridge/src:/root/Megatron-LM/:$PYTHONPATH"
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export MASTER_PORT=$(shuf -i 20000-65000 -n 1) # or any free port
+export DISABLE_L2_CACHE=1
+export VLLM_ASCEND_ENABLE_NZ=0
+
+SCRIPT_DIR="/root/vime/scripts/"
+source "${SCRIPT_DIR}/models/qwen3-4B.sh"
+LOG_FILE="/root/vime/train_qwen3_4b_vllm.log"
+MODEL_ROOT="${MODEL_ROOT:-/root}"
+
+python /root/vime/train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 4 \
+ --rollout-num-gpus 4 \
+ --rollout-num-gpus-per-engine 4 \
+ ${MODEL_ARGS[@]} \
+ \
+ --hf-checkpoint ${MODEL_ROOT}/models/Qwen3-4B/ \
+ \
+ --prompt-data ${MODEL_ROOT}/datasets/dapo-math-17k/dapo-math-17k.jsonl \
+ --input-key prompt \
+ --label-key label \
+ --apply-chat-template \
+ --rollout-shuffle \
+ --rm-type math \
+ \
+ --rollout-backend vllm \
+ --vllm-weight-sync-mode native \
+ --vllm-gpu-memory-utilization 0.6 \
+ --vllm-enable-sleep-mode \
+ --vllm-max-model-len 4096 \
+ \
+ --num-rollout 200 \
+ --rollout-batch-size 32 \
+ --n-samples-per-prompt 8 \
+ --rollout-max-response-len 2048 \
+ --rollout-temperature 1.0 \
+ --global-batch-size 256 \
+ --balance-data \
+ \
+ --advantage-estimator grpo \
+ --kl-loss-coef 0.0 \
+ --kl-loss-type low_var_kl \
+ --kl-coef 0.00 \
+ --entropy-coef 0.0 \
+ --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 4 \
+ --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 8192 \
+ --load ${MODEL_ROOT}/models/Qwen3-4B \
+ --megatron-to-hf-mode bridge \
+ \
+ --attention-dropout 0.0 \
+ --hidden-dropout 0.0 \
+ --accumulate-allreduce-grads-in-fp32 \
+ --attention-softmax-in-fp32 \
+ --attention-backend flash \
+ --micro-batch-size 1 \
+ --use-flash-attn \
+ \
+ --train-memory-margin-bytes 2147483648 \
+ 2>&1 | tee -a "$LOG_FILE"
+```
diff --git a/examples/fully_async/run-qwen3-4B-fully_async-npu.sh b/examples/fully_async/run-qwen3-4B-fully_async-npu.sh
new file mode 100755
index 000000000..9c05eff36
--- /dev/null
+++ b/examples/fully_async/run-qwen3-4B-fully_async-npu.sh
@@ -0,0 +1,140 @@
+#!/bin/bash
+# Tiny end-to-end fully-async GRPO example using Qwen3-4B-Instruct on the
+# dapo-math-17k dataset. Designed to run on a single 4-GPU node in a few minutes
+#
+# Prerequisites:
+# /root/models/Qwen3-4B/ (HF checkpoint)
+# /root/datasets/dapo-math-17k/dapo-math-17k.jsonl
+
+# clean any leftover ray/vllm
+pkill -9 -f '[v]llm serve|VLL[M]::'
+pkill -9 -f VLLM
+sleep 3
+ray stop --force
+pkill -9 ray
+pkill -9 python
+sleep 3
+pkill -9 ray
+pkill -9 python
+pkill -9 redis
+
+set -ex
+
+export PYTHONUNBUFFERED=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export RAY_USE_UVLOOP=0 # upstream: Ray's uvloop integration has caused intermittent async actor issues.
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export DISABLE_L2_CACHE=1
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+export PYTHONPATH="/root/Megatron-Bridge/src:/root/Megatron-LM/:${PYTHONPATH:-}"
+
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+source "${SCRIPT_DIR}/../../scripts/models/qwen3-4B.sh"
+
+MODEL_DIR=${MODEL_DIR:-/root/models/Qwen3-4B}
+DATA_PATH=${DATA_PATH:-/root/datasets/dapo-math-17k/dapo-math-17k.jsonl}
+
+CKPT_ARGS=(
+ --hf-checkpoint "${MODEL_DIR}"
+ --load "${MODEL_DIR}"
+ --ref-load "${MODEL_DIR}"
+ --megatron-to-hf-mode bridge
+ --save /tmp/vime_fully_async_demo/
+ --save-interval 9999
+)
+
+ROLLOUT_ARGS=(
+ # ↓↓↓ This is the only knob you need to flip to go fully-async ↓↓↓
+ --rollout-function-path vime.rollout.fully_async_rollout.generate_rollout_fully_async
+
+ --prompt-data "${DATA_PATH}"
+ --input-key prompt
+ --label-key label
+ --apply-chat-template
+ --rollout-shuffle
+ --rm-type math
+ --num-rollout 200
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 2048
+ --rollout-temperature 1
+ --global-batch-size 256
+ --balance-data
+)
+
+PERF_ARGS=(
+ --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 8192
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --kl-loss-coef 0.00
+ --kl-loss-type low_var_kl
+ --kl-coef 0.00
+ --entropy-coef 0.00
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 2
+ --vllm-gpu-memory-utilization 0.6
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+)
+
+ray start --head --node-ip-address 127.0.0.1 --disable-usage-stats
+
+# fully-async splits actor / rollout onto disjoint GPUs (no colocation).
+ACTOR_GPUS=2
+ROLLOUT_GPUS=2
+
+ray job submit --address="http://127.0.0.1:8265" \
+ -- python3 train_async.py \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node "${ACTOR_GPUS}" \
+ --rollout-num-gpus "${ROLLOUT_GPUS}" \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${ROLLOUT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${GRPO_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${VLLM_ARGS[@]} \
+ ${MISC_ARGS[@]}
diff --git a/examples/geo3k_vlm/run_geo3k_vlm_npu.sh b/examples/geo3k_vlm/run_geo3k_vlm_npu.sh
new file mode 100644
index 000000000..911035b22
--- /dev/null
+++ b/examples/geo3k_vlm/run_geo3k_vlm_npu.sh
@@ -0,0 +1,216 @@
+#!/bin/bash
+
+# Single-turn Qwen3-VL GRPO on geo3k for Ascend NPU.
+set -eo pipefail
+
+MODEL_NAME=${VIME_SCRIPT_MODEL_NAME:-Qwen3-VL-8B-Instruct}
+DATASET_NAME=${VIME_SCRIPT_DATASET_NAME:-chenhegu/geo3k_imgurl}
+NUM_GPUS=${VIME_SCRIPT_NUM_GPUS:-8}
+NUM_ROLLOUT=${VIME_SCRIPT_NUM_ROLLOUT:-3000}
+DATA_ROOT="/root/datasets/$(basename "$DATASET_NAME")"
+MODEL_ROOT="/root/models/${MODEL_NAME}"
+
+if ! [[ "$NUM_GPUS" =~ ^[1-9][0-9]*$ && "$NUM_ROLLOUT" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Error: VIME_SCRIPT_NUM_GPUS and VIME_SCRIPT_NUM_ROLLOUT must be positive integers"
+ exit 1
+fi
+
+VALID_MODELS="
+ Qwen3-VL-2B-Instruct
+ Qwen3-VL-4B-Instruct
+ Qwen3-VL-8B-Instruct
+ Qwen3-VL-2B-Thinking
+ Qwen3-VL-4B-Thinking
+ Qwen3-VL-8B-Thinking
+"
+if ! echo "$VALID_MODELS" | grep -qw "$MODEL_NAME"; then
+ echo "Error: unsupported MODEL_NAME=${MODEL_NAME}"
+ exit 1
+fi
+
+if [ -z "${VIME_SCRIPT_EXTERNAL_RAY:-}" ] || [ "$VIME_SCRIPT_EXTERNAL_RAY" = "0" ]; then
+ USE_EXTERNAL_RAY=0
+else
+ USE_EXTERNAL_RAY=1
+fi
+
+export PYTHONUNBUFFERED=1
+export PYTORCH_ALLOC_CONF=expandable_segments:True
+export PYTORCH_NPU_ALLOC_CONF=expandable_segments:True
+export PYTHONPATH="/root/Megatron-Bridge/src:/root/Megatron-LM/:$PYTHONPATH"
+export ASCEND_RT_VISIBLE_DEVICES=${ASCEND_RT_VISIBLE_DEVICES:-0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export MASTER_PORT=${MASTER_PORT:-$(shuf -i 20000-65000 -n 1)}
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+export ASCEND_TOOLKIT_HOME=/usr/local/Ascend/ascend-toolkit/latest/
+export ASCEND_OPP_PATH=/usr/local/Ascend/ascend-toolkit/latest/opp/
+export ASCEND_AICPU_PATH=/usr/local/Ascend/ascend-toolkit/latest/
+export ASCEND_HOME_PATH=/usr/local/Ascend/ascend-toolkit/latest/
+export set_env_path=/usr/local/Ascend/nnal/atb/set_env.sh
+
+IFS=',' read -r -a VISIBLE_NPUS <<<"${ASCEND_RT_VISIBLE_DEVICES}"
+REQUIRED_NPUS=$((NUM_GPUS * 2))
+if [ "${#VISIBLE_NPUS[@]}" -lt "$REQUIRED_NPUS" ]; then
+ echo "Error: actor and rollout require ${REQUIRED_NPUS} visible NPUs"
+ exit 1
+fi
+
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY all_proxy
+
+mkdir -p /root/models /root/datasets
+if [ ! -d "$MODEL_ROOT" ]; then
+ hf download "Qwen/${MODEL_NAME}" --local-dir "$MODEL_ROOT"
+fi
+if [ ! -d "$DATA_ROOT" ]; then
+ hf download --repo-type dataset "$DATASET_NAME" --local-dir "$DATA_ROOT"
+fi
+
+MODEL_LOAD_ARGS=(
+ --hf-checkpoint "$MODEL_ROOT"
+ --rotary-base 5000000
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data "${DATA_ROOT}/train.parquet"
+ --input-key problem
+ --label-key answer
+ --apply-chat-template
+ --rollout-shuffle
+ --rm-type math
+ --num-rollout "$NUM_ROLLOUT"
+ --rollout-batch-size 64
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 4096
+ --rollout-temperature 1.0
+ --global-batch-size 512
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --kl-loss-coef 0.00
+ --kl-loss-type low_var_kl
+ --kl-coef 0.00
+ --entropy-coef 0.00
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 1
+ --vllm-gpu-memory-utilization "${VLLM_GPU_MEMORY_UTILIZATION:-0.8}"
+ --vllm-max-model-len 16384
+ --vllm-generation-config auto
+ --vllm-logprobs-mode processed_logprobs
+)
+
+if [ -n "${WANDB_API_KEY:-}" ]; then
+ WANDB_ARGS=(
+ --use-wandb
+ --wandb-project vime-geo3k-vlm
+ --wandb-group "${MODEL_NAME,,}-megatron-vllm-${NUM_GPUS}npu"
+ --wandb-key "$WANDB_API_KEY"
+ --disable-wandb-random-suffix
+ )
+else
+ WANDB_ARGS=()
+fi
+
+BACKEND_ARGS=(
+ --train-backend megatron
+ --load "$MODEL_ROOT"
+ --tensor-model-parallel-size 4
+ --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 4096
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --megatron-to-hf-mode bridge
+)
+
+VIME_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." &>/dev/null && pwd)"
+MODEL_ARGS_FILE=$(echo "$MODEL_NAME" | sed 's/-Instruct//g; s/-Thinking//g; s/Qwen3-VL-/qwen3-/g; s/-2B/-1.7B/g')
+MODEL_ARGS_ROTARY_BASE=5000000 source "${VIME_DIR}/scripts/models/${MODEL_ARGS_FILE}.sh"
+
+pkill -9 -f '[v]llm serve|VLL[M]::' || true
+if [ "$USE_EXTERNAL_RAY" = "0" ]; then
+ ray stop --force || true
+ pkill -9 ray || true
+fi
+pkill -9 vime || true
+pkill -9 redis || true
+
+export MASTER_ADDR=${MASTER_ADDR:-127.0.0.1}
+export no_proxy="127.0.0.1,${MASTER_ADDR}"
+if [ "$USE_EXTERNAL_RAY" = "0" ]; then
+ ray start --head --node-ip-address "$MASTER_ADDR" --disable-usage-stats \
+ --dashboard-host=0.0.0.0 --dashboard-port=8265
+fi
+
+RUNTIME_ENV_KEYS=(
+ CUDA_DEVICE_MAX_CONNECTIONS
+ RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES
+ PYTHONPATH
+ PYTORCH_ALLOC_CONF
+ PYTORCH_NPU_ALLOC_CONF
+ VLLM_ASCEND_ENABLE_NZ
+ ASCEND_TOOLKIT_HOME
+ ASCEND_OPP_PATH
+ ASCEND_AICPU_PATH
+ ASCEND_HOME_PATH
+ set_env_path
+ HYDRA_FULL_ERROR
+ HCCL_HOST_SOCKET_PORT_RANGE
+ HCCL_NPU_SOCKET_PORT_RANGE
+ no_proxy
+ MASTER_ADDR
+)
+RUNTIME_ENV_JSON=$(python - "${RUNTIME_ENV_KEYS[@]}" <<'PY'
+import json
+import os
+import sys
+
+print(json.dumps({"env_vars": {key: os.environ[key] for key in sys.argv[1:]}}))
+PY
+)
+
+ray job submit --address=http://127.0.0.1:8265 \
+ --runtime-env-json="$RUNTIME_ENV_JSON" \
+ -- "$(command -v python)" train.py \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node "$NUM_GPUS" \
+ --rollout-num-gpus "$NUM_GPUS" \
+ --multimodal-keys '{"image": "images"}' \
+ "${MODEL_ARGS[@]}" \
+ "${MODEL_LOAD_ARGS[@]}" \
+ "${ROLLOUT_ARGS[@]}" \
+ "${GRPO_ARGS[@]}" \
+ "${OPTIMIZER_ARGS[@]}" \
+ "${VLLM_ARGS[@]}" \
+ "${WANDB_ARGS[@]}" \
+ "${BACKEND_ARGS[@]}" \
+ --no-gradient-accumulation-fusion \
+ --use-flash-attn
diff --git a/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_grpo_npu.py b/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_grpo_npu.py
new file mode 100644
index 000000000..35124a53a
--- /dev/null
+++ b/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_grpo_npu.py
@@ -0,0 +1,154 @@
+import os
+
+from vime.utils.external_utils.command_utils import execute_train
+
+MODEL_NAME = os.environ.get("VIME_SCRIPT_MODEL_NAME", "Qwen3-VL-8B-Instruct")
+SUPPORTED_MODELS = {
+ "Qwen3-VL-2B-Instruct",
+ "Qwen3-VL-4B-Instruct",
+ "Qwen3-VL-8B-Instruct",
+ "Qwen3-VL-2B-Thinking",
+ "Qwen3-VL-4B-Thinking",
+ "Qwen3-VL-8B-Thinking",
+}
+if MODEL_NAME not in SUPPORTED_MODELS:
+ raise ValueError(f"Unsupported VIME_SCRIPT_MODEL_NAME={MODEL_NAME}")
+
+NUM_ROLLOUT = int(os.environ.get("VIME_SCRIPT_NUM_ROLLOUT", "3000"))
+if NUM_ROLLOUT <= 0:
+ raise ValueError("VIME_SCRIPT_NUM_ROLLOUT must be positive")
+
+MODEL_ROOT = os.environ.get("VIME_SCRIPT_MODEL_ROOT", "/root/models")
+DATA_ROOT = os.environ.get("VIME_SCRIPT_DATA_ROOT", "/root/datasets/geo3k_imgurl_processed")
+TRAIN_DATA_PATH = os.path.join(DATA_ROOT, "train.parquet")
+
+
+def get_megatron_model_type(model_name: str) -> str:
+ model_type = model_name.replace("-Instruct", "").replace("-Thinking", "")
+ model_type = model_type.replace("Qwen3-VL-", "qwen3-")
+ return model_type.replace("-2B", "-1.7B")
+
+
+def execute():
+ model_path = os.path.join(MODEL_ROOT, MODEL_NAME)
+ if not os.path.isdir(model_path):
+ raise FileNotFoundError(f"Model not found: {model_path}")
+ if not os.path.isfile(TRAIN_DATA_PATH):
+ raise FileNotFoundError(f"Dataset not found: {TRAIN_DATA_PATH}")
+
+ wandb_api_key = os.environ.get("WANDB_API_KEY")
+ wandb_args = (
+ (
+ "--use-wandb "
+ "--wandb-project vime-dev "
+ "--wandb-group geo3k_vlm_multi_turn "
+ f"--wandb-key '{wandb_api_key}' "
+ )
+ if wandb_api_key
+ else ""
+ )
+
+ rollout_args = (
+ f"--prompt-data {TRAIN_DATA_PATH} "
+ "--input-key problem "
+ "--label-key answer "
+ '--multimodal-keys \'{"image": "images"}\' '
+ "--rm-type math "
+ "--custom-generate-function-path examples.geo3k_vlm_multi_turn.rollout.generate "
+ "--custom-config-path examples/geo3k_vlm_multi_turn/geo3k_vlm_multi_turn_config.yaml "
+ "--rollout-shuffle "
+ f"--num-rollout {NUM_ROLLOUT} "
+ "--rollout-batch-size 32 "
+ "--n-samples-per-prompt 8 "
+ "--rollout-max-response-len 4096 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 256 "
+ )
+
+ grpo_args = (
+ "--advantage-estimator grpo "
+ "--kl-loss-coef 0.00 "
+ "--kl-loss-type low_var_kl "
+ "--kl-coef 0.00 "
+ "--entropy-coef 0.00 "
+ "--eps-clip 0.2 "
+ "--eps-clip-high 0.28 "
+ "--use-kl-loss "
+ )
+
+ optimizer_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ "--optimizer-cpu-offload "
+ "--overlap-cpu-optimizer-d2h-h2d "
+ "--use-precision-aware-optimizer "
+ )
+
+ vllm_args = (
+ "--rollout-num-gpus-per-engine 1 "
+ "--vllm-gpu-memory-utilization 0.6 "
+ "--vllm-max-model-len 16384 "
+ "--vllm-generation-config auto "
+ "--vllm-logprobs-mode processed_logprobs "
+ )
+
+ megatron_args = (
+ "--train-backend megatron "
+ f"--load {model_path} "
+ f"--ref-load {model_path} "
+ "--tensor-model-parallel-size 4 "
+ "--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 16384 "
+ "--balance-data "
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--megatron-to-hf-mode bridge "
+ )
+
+ misc_args = (
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 8 "
+ "--rollout-num-gpus 8 "
+ "--no-gradient-accumulation-fusion "
+ "--use-flash-attn "
+ )
+
+ megatron_model_type = get_megatron_model_type(MODEL_NAME)
+ os.environ["MODEL_ARGS_ROTARY_BASE"] = "5000000"
+
+ train_args = (
+ f"--hf-checkpoint {model_path} "
+ f"{rollout_args} "
+ f"{optimizer_args} "
+ f"{grpo_args} "
+ f"{vllm_args} "
+ f"{megatron_args} "
+ f"{misc_args} "
+ f"{wandb_args} "
+ )
+
+ execute_train(
+ train_args=train_args,
+ num_gpus_per_node=16, # actor 8 + rollout 8 (non-colocate)
+ megatron_model_type=megatron_model_type,
+ extra_env_vars={"WANDB_API_KEY": wandb_api_key} if wandb_api_key else {},
+ )
+
+
+if __name__ == "__main__":
+ execute()
diff --git a/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_ppo_npu.py b/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_ppo_npu.py
new file mode 100644
index 000000000..43c04ba4e
--- /dev/null
+++ b/examples/geo3k_vlm_multi_turn/run_geo3k_vlm_multi_turn_ppo_npu.py
@@ -0,0 +1,160 @@
+import os
+import tempfile
+
+from vime.utils.external_utils.command_utils import execute_train
+
+MODEL_NAME = os.environ.get("VIME_SCRIPT_MODEL_NAME", "Qwen3-VL-2B-Instruct")
+assert MODEL_NAME in {
+ "Qwen3-VL-2B-Instruct",
+ "Qwen3-VL-4B-Instruct",
+ "Qwen3-VL-8B-Instruct",
+ "Qwen3-VL-2B-Thinking",
+ "Qwen3-VL-4B-Thinking",
+ "Qwen3-VL-8B-Thinking",
+}
+
+EXTERNAL_RAY = int(os.environ.get("VIME_SCRIPT_EXTERNAL_RAY", "0"))
+TRAIN_BACKEND = os.environ.get("VIME_SCRIPT_TRAIN_BACKEND", "fsdp").lower()
+assert TRAIN_BACKEND in {"fsdp", "megatron"}
+
+DATASET_NAME = "VeraIsHere/geo3k_imgurl_processed"
+DATA_ROOT = "/path/to/datasets/geo3k_imgurl_processed"
+TRAIN_DATA_PATH = os.path.join(DATA_ROOT, "train.parquet")
+
+
+def get_megatron_model_type(model_name: str) -> str:
+ model_type = model_name.replace("-Instruct", "").replace("-Thinking", "")
+ model_type = model_type.replace("Qwen3-VL-", "qwen3-")
+ return model_type.replace("-2B", "-1.7B")
+
+
+def execute():
+ megatron_config = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False)
+ megatron_config.write(
+ """
+megatron:
+ - name: default
+ role: critic
+ overrides:
+ lr: 1e-5
+"""
+ )
+ megatron_config.close()
+
+ ckpt_args = f"--hf-checkpoint /path/to/model/checkpoints/{MODEL_NAME} "
+
+ wandb_args = (
+ (
+ "--use-wandb "
+ "--wandb-project vime-dev "
+ "--wandb-group geo3k_vlm_multi_turn "
+ f"--wandb-key '{wandb_api_key}' "
+ )
+ if (wandb_api_key := os.environ.get("WANDB_API_KEY"))
+ else ""
+ )
+
+ rollout_args = (
+ f"--prompt-data {TRAIN_DATA_PATH} "
+ "--input-key problem "
+ "--label-key answer "
+ '--multimodal-keys \'{"image": "images"}\' '
+ "--rm-type math "
+ "--apply-chat-template "
+ "--custom-generate-function-path examples.geo3k_vlm_multi_turn.rollout.generate "
+ "--custom-config-path examples/geo3k_vlm_multi_turn/geo3k_vlm_multi_turn_config.yaml "
+ "--rollout-shuffle "
+ "--num-rollout 3000 "
+ "--rollout-batch-size 32 "
+ "--n-samples-per-prompt 8 "
+ "--rollout-max-response-len 4096 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 256 "
+ )
+
+ ppo_args = (
+ "--advantage-estimator ppo "
+ "--kl-loss-coef 0.00 "
+ "--kl-loss-type k1 "
+ "--kl-coef 0.00 "
+ "--entropy-coef 0.00 "
+ "--eps-clip 4e-4 "
+ "--num-critic-only-steps 1 "
+ "--normalize-advantages "
+ )
+
+ optimizer_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ "--optimizer-cpu-offload "
+ "--overlap-cpu-optimizer-d2h-h2d "
+ "--use-precision-aware-optimizer "
+ )
+
+ vllm_args = "--rollout-num-gpus-per-engine 1 " "--vllm-gpu-memory-utilization 0.6 "
+
+ megatron_args = (
+ "--train-backend megatron "
+ f"--load /path/to/model/checkpoints/{MODEL_NAME} "
+ f"--ref-load /path/to/model/checkpoints/{MODEL_NAME} "
+ "--tensor-model-parallel-size 4 "
+ "--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 16384 "
+ "--balance-data "
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--megatron-to-hf-mode bridge "
+ )
+
+ misc_args = (
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 8 "
+ "--rollout-num-gpus 8 "
+ "--no-gradient-accumulation-fusion "
+ "--use-flash-attn "
+ )
+
+ if TRAIN_BACKEND == "megatron":
+ backend_args = megatron_args
+ megatron_model_type = get_megatron_model_type(MODEL_NAME)
+ os.environ["MODEL_ARGS_ROTARY_BASE"] = "5000000"
+ else:
+ exit()
+
+ train_args = (
+ f"--megatron-config-path {megatron_config.name} "
+ f"{ckpt_args} "
+ f"{rollout_args} "
+ f"{optimizer_args} "
+ f"{ppo_args} "
+ f"{vllm_args} "
+ f"{backend_args} "
+ f"{misc_args} "
+ f"{wandb_args} "
+ )
+
+ execute_train(
+ train_args=train_args,
+ num_gpus_per_node=16, # actor 8 + rollout 8 (non-colocate)
+ megatron_model_type=megatron_model_type,
+ extra_env_vars=({"WANDB_API_KEY": os.environ["WANDB_API_KEY"]} if os.environ.get("WANDB_API_KEY") else {}),
+ )
+
+
+if __name__ == "__main__":
+ execute()
diff --git a/examples/multi_agent/agent_system.py b/examples/multi_agent/agent_system.py
index ebd521900..c04640e6f 100644
--- a/examples/multi_agent/agent_system.py
+++ b/examples/multi_agent/agent_system.py
@@ -12,12 +12,18 @@
from .prompts import SOLVER_PROMPT_TEMPLATE, generate_rewriter_template, generate_select_template
-async def generate_response(args, prompt, key):
+async def generate_response(args, prompt, key, worker_id: int | None = None):
try:
sampling_params = args.sampling_params
tokenizer = args.tokenizer
max_context_length = args.rollout_max_context_len
sample = deepcopy(args.sample)
+ sample.metadata = dict(sample.metadata or {})
+ sample.metadata["multi_agent_role"] = key
+ sample.metadata["multi_agent_parent_group_index"] = sample.group_index
+ sample.metadata["multi_agent_parent_index"] = sample.index
+ if worker_id is not None:
+ sample.metadata["multi_agent_worker_id"] = worker_id
url = f"http://{args.vllm_router_ip}:{args.vllm_router_port}/inference/v1/generate"
@@ -90,11 +96,11 @@ class Agent:
def __init__(self):
pass
- async def run(self, args, prompt, max_retries: int = 1, key: str = None) -> str:
+ async def run(self, args, prompt, max_retries: int = 1, key: str = None, worker_id: int | None = None) -> str:
"""Runs the agent by sending a prompt to the LLM."""
for _i in range(max_retries):
try:
- response = await generate_response(args, prompt, key=key)
+ response = await generate_response(args, prompt, key=key, worker_id=worker_id)
return response
except Exception as e:
print(f"Error querying LLM: {e}")
@@ -109,10 +115,10 @@ class SolverAgent(Agent):
def __init__(self):
super().__init__()
- async def generate_initial_solution(self, args, problem_statement) -> str:
+ async def generate_initial_solution(self, args, problem_statement, worker_id: int) -> str:
"""Generates the first solution attempt."""
prompt = SOLVER_PROMPT_TEMPLATE.format(problem_statement=problem_statement)
- return await self.run(args, prompt, max_retries=3, key="solver")
+ return await self.run(args, prompt, max_retries=3, key="solver", worker_id=worker_id)
class RewriterAgent(Agent):
@@ -121,7 +127,7 @@ class RewriterAgent(Agent):
def __init__(self):
super().__init__()
- async def rewrite(self, args, problem_statement, previous_solutions: list[str]) -> str:
+ async def rewrite(self, args, problem_statement, previous_solutions: list[str], worker_id: int) -> str:
"""Generates the rewrited solution."""
# Build the prompt template dynamically.
@@ -133,7 +139,7 @@ async def rewrite(self, args, problem_statement, previous_solutions: list[str])
format_params[f"solution{i+1}"] = solution
prompt = template.format(**format_params)
- return await self.run(args, prompt, max_retries=1, key="rewriter")
+ return await self.run(args, prompt, max_retries=1, key="rewriter", worker_id=worker_id)
class SelectorAgent(Agent):
@@ -142,7 +148,7 @@ class SelectorAgent(Agent):
def __init__(self):
super().__init__()
- async def select(self, args, problem_statement, candidate_solutions: list[str]) -> str:
+ async def select(self, args, problem_statement, candidate_solutions: list[str], worker_id: int = 0) -> str:
"""Generates the rewrited solution."""
# Build the prompt template dynamically.
@@ -154,7 +160,7 @@ async def select(self, args, problem_statement, candidate_solutions: list[str])
format_params[f"solution{i+1}"] = solution
prompt = template.format(**format_params)
- return await self.run(args, prompt, max_retries=10, key="selector")
+ return await self.run(args, prompt, max_retries=10, key="selector", worker_id=worker_id)
def extract_selected_solution_idx(self, response: str, candidate_solutions: list[str]) -> int:
"""Extracts the selected solution ID from the response."""
@@ -173,7 +179,7 @@ def extract_selected_solution_idx(self, response: str, candidate_solutions: list
async def rewrite_worker(args, previous_solutions, problem_statement, worker_id):
rewriter = RewriterAgent()
- new_solution = await rewriter.rewrite(args, problem_statement, previous_solutions)
+ new_solution = await rewriter.rewrite(args, problem_statement, previous_solutions, worker_id)
return new_solution
@@ -184,7 +190,7 @@ async def solver_worker(args, problem_statement, worker_id):
try:
solver = SolverAgent()
- current_solution = await solver.generate_initial_solution(args, problem_statement)
+ current_solution = await solver.generate_initial_solution(args, problem_statement, worker_id)
return current_solution
except Exception as e:
@@ -245,7 +251,7 @@ def reward_adjustment(samples, reward_weight):
# Selection
selector = SelectorAgent()
- response = await selector.select(args, problem_statement, rewrited_solutions)
+ response = await selector.select(args, problem_statement, rewrited_solutions, worker_id=0)
if len(args.results_dict["selector"]) == 0:
reward_adjustment(args.results_dict["solver"], args.incorrect_reward_weight)
reward_adjustment(args.results_dict["rewriter"], args.incorrect_reward_weight)
@@ -254,13 +260,18 @@ def reward_adjustment(samples, reward_weight):
assert (
len(args.results_dict["selector"]) == 1
), f"selector should only return one solution, but got {len(args.results_dict['selector'])}"
+ selector_sample = args.results_dict["selector"][0]
if response is None:
- args.results_dict["selector"][0].reward = 0
+ selector_sample.reward = 0
+ selector_sample.metadata["selector_parse_success"] = False
else:
selected_solution_idx = selector.extract_selected_solution_idx(response, rewrited_solutions)
if selected_solution_idx is None:
- args.results_dict["selector"][0].reward = 0
+ selector_sample.reward = 0
+ selector_sample.metadata["selector_parse_success"] = False
else:
+ selector_sample.metadata["selector_parse_success"] = True
+ selector_sample.metadata["selector_choice"] = selected_solution_idx + 1
selected_solution = rewrited_solutions[selected_solution_idx]
for sample in args.results_dict["rewriter"]:
if sample.response_content is not None and selected_solution in sample.response_content:
diff --git a/examples/multi_agent/rollout_with_multi_agents.py b/examples/multi_agent/rollout_with_multi_agents.py
index d8a843c37..b3e4725cb 100644
--- a/examples/multi_agent/rollout_with_multi_agents.py
+++ b/examples/multi_agent/rollout_with_multi_agents.py
@@ -28,6 +28,21 @@ async def generate_with_multi_agents(args, sample: Sample, sampling_params, eval
custom_multi_agent_func = load_function(args.custom_multi_agent_function_path)
samples = await custom_multi_agent_func(args, sample)
+ # VIME compact rollouts return multiple training samples from one source
+ # sample. Newer VIME requires all siblings to share a rollout_id so loss
+ # reduction counts the source rollout once instead of over-counting agents.
+ compact_rollout_id = (
+ sample.rollout_id
+ if sample.rollout_id is not None
+ else (
+ sample.index
+ if sample.index is not None
+ else sample.group_index if sample.group_index is not None else id(sample)
+ )
+ )
+ for sibling in samples:
+ sibling.rollout_id = compact_rollout_id
+
random.shuffle(samples)
return samples
diff --git a/examples/multi_agent/run-qwen3-4B-multi-agent-npu.sh b/examples/multi_agent/run-qwen3-4B-multi-agent-npu.sh
new file mode 100755
index 000000000..9b8424be3
--- /dev/null
+++ b/examples/multi_agent/run-qwen3-4B-multi-agent-npu.sh
@@ -0,0 +1,150 @@
+#!/bin/bash
+
+# for rerun the task
+pkill -9 -f '[v]llm serve|VLL[M]::'
+pkill -9 -f VLLM
+sleep 3
+ray stop --force
+pkill -9 ray
+pkill -9 python
+sleep 3
+pkill -9 ray
+pkill -9 python
+pkill -9 redis
+
+set -ex
+
+export PYTHONUNBUFFERED=1
+
+export SLIME_SCRIPT_TRAIN_BACKEND=megatron
+export PYTHONPATH="/workspace/wky/Megatron-Bridge/src:/workspace/wky/Megatron-LM/:${PYTHONPATH:-}"
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export DISABLE_L2_CACHE=1
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+VIME_ROOT="${VIME_ROOT:-/workspace/wky/vime-ascend}"
+SCRIPT_DIR="${VIME_ROOT}/scripts"
+WEIGHT_DIR="${WEIGHT_DIR:-/home/data/weights/Qwen3-4B}"
+DATA_FILE="${DATA_FILE:-/home/w00893744/dataset/dapo-math-17k.jsonl}"
+RUN_TS="${RUN_TS:-$(date +%Y%m%d_%H%M%S)}"
+LOG_FILE="${LOG_FILE:-/home/w00893744/train_qwen3_4b_multi_agent_vllm_${RUN_TS}.log}"
+MASTER_ADDR="${MASTER_ADDR:-127.0.0.1}"
+
+cd "${VIME_ROOT}"
+source "${SCRIPT_DIR}/models/qwen3-4B.sh"
+
+CKPT_ARGS=(
+ --hf-checkpoint "${WEIGHT_DIR}"
+ --load "${WEIGHT_DIR}"
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --custom-generate-function-path examples.multi_agent.rollout_with_multi_agents.generate_with_multi_agents
+ --prompt-data "${DATA_FILE}"
+ --input-key prompt
+ --label-key label
+ --apply-chat-template
+ --rollout-shuffle
+ --rm-type math
+
+ --rollout-backend vllm
+ --vllm-weight-sync-mode native
+ --vllm-gpu-memory-utilization 0.6
+ --vllm-enable-sleep-mode
+ --vllm-max-model-len 4096
+ --vllm-enforce-eager
+
+ --num-rollout 200
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-context-len 4096
+ --rollout-max-response-len 2048
+ --rollout-temperature 1.0
+
+ --global-batch-size 256
+ --balance-data
+)
+
+EVAL_ARGS=(
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --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 8192
+ --micro-batch-size 1
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --kl-loss-coef 0.0
+ --kl-loss-type low_var_kl
+ --kl-coef 0.00
+ --entropy-coef 0.0
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+)
+
+WANDB_ARGS=(
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 4
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+ --train-memory-margin-bytes 2147483648
+)
+
+export MASTER_ADDR=${MASTER_ADDR:-"127.0.0.1"}
+ray start --head --node-ip-address ${MASTER_ADDR} --disable-usage-stats --dashboard-host=0.0.0.0 --dashboard-port=8265
+
+ray job submit --address="http://127.0.0.1:8265" \
+ -- python3 train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 4 \
+ --rollout-num-gpus 4 \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${ROLLOUT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${GRPO_ARGS[@]} \
+ ${WANDB_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${EVAL_ARGS[@]} \
+ ${VLLM_ARGS[@]} \
+ ${MISC_ARGS[@]}
diff --git a/examples/retool/README.md b/examples/retool/README.md
new file mode 100644
index 000000000..cab318ded
--- /dev/null
+++ b/examples/retool/README.md
@@ -0,0 +1,88 @@
+# Retool: from SFT to RL
+This example _**(Retool)**_ demonstrates how to use the retool functionality for tool-enabled language model generation.
+
+## Overview
+The retool example provides:
+
+- Safe Python code execution in a sandbox environment
+- Tool registry for managing available tools
+- Integration with language model generation
+- Reward calculation for tool usage
+
+## Files
+- `generate_with_retool.py`: Main generation function with tool support
+- `tool_sandbox.py`: Tool execution and safety management
+- `sft_data_processing.py`: Process SFT dataset
+
+## Reward Design
+The RL reward function (`generate_with_retool.reward_func`) uses a **tool-aware reward shaping** strategy on top of the math accuracy reward:
+
+| Answer | Tool Used | Reward | Rationale |
+|--------|-----------|--------|-----------|
+| ✅ Correct | No | 1.0 | Pure reasoning — the ideal case |
+| ✅ Correct | Yes | 1.0 + min(0.2, turns × 0.05) | Bonus for effective tool use (capped at 0.2) |
+| ❌ Wrong | No | 0.0 | Neutral — model didn't attempt tools |
+| ❌ Wrong | Yes | min(0.1, turns × 0.02) | Small positive to encourage exploration |
+
+This design encourages the model to **explore tool calling** during early RL training without letting it reward-hack by preferring tool calls over correct answers. As training progresses and accuracy improves, the accuracy reward dominates and the tool bonus becomes a tiebreaker.
+
+## Usage
+### 1. Setup
+```
+git clone -b ascend https://github.com/vllm-project/vime.git
+cd vime
+docker build -f docker/Dockerfile.npu -t vime-ascend:latest .
+```
+```
+# Update the vime image
+export IMAGE=vime-ascend:latest
+
+docker run -d --name vime-npu -it --net=host --shm-size=1024g \
+ --privileged=true \
+ --cap-add=SYS_PTRACE \
+ --device=/dev/davinci_manager \
+ --device=/dev/hisi_hdc \
+ --device=/dev/devmm_svm \
+ -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
+ -v /usr/local/dcmi:/usr/local/dcmi \
+ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
+ -v /usr/local/sbin:/usr/local/sbin \
+ -v /home:/home \
+ -v /mnt:/mnt \
+ -v /tmp:/tmp \
+ -v /data:/data \
+ -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
+ $IMAGE
+
+docker exec -it vime-npu bash
+```
+### 2. Download
+```
+# For SFT part, you can use later model to RL directly and skip SFT.
+hf download --repo-type dataset JoeYing/ReTool-SFT --local-dir /path/to/ReTool-SFT
+hf download Qwen/Qwen3-4B-Instruct-2507 --local-dir /path/to/Qwen3-4B-Instruct-2507
+
+# For RL part
+hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /path/to/dapo-math-17k
+hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /path/to/aime-2024
+# download our SFT model if you want to skip SFT
+hf download font-info/qwen3-4b-sft-SGLang-RL --local-dir /path/to/qwen3-4b-sft
+```
+### 3. Preprocessing
+```
+cd /root/vime
+# Replace the save path with /path/to/ReTool-SFT.parquet
+python examples/retool/sft_data_processing.py
+```
+### 4. SFT
+```
+cd /root/vime
+# Replace the model and data loading/saving paths
+python examples/retool/retool_qwen3_4b_sft.sh
+```
+### 5. RL
+```
+cd /root/vime
+# Replace the model and data loading/saving paths
+python examples/retool/retool_qwen3_4b_rl.sh
+```
diff --git a/examples/retool/README_zh.md b/examples/retool/README_zh.md
new file mode 100644
index 000000000..4d933e42d
--- /dev/null
+++ b/examples/retool/README_zh.md
@@ -0,0 +1,93 @@
+# Retool:从 SFT 到 RL
+本示例 **(Retool)** 演示了如何使用 retool 功能进行带工具调用的语言模型生成。
+
+## 概述
+Retool 示例提供了:
+
+- 沙盒环境中的安全 Python 代码执行
+- 工具注册表,用于管理可用工具
+- 与语言模型生成的集成
+- 工具使用的奖励计算
+
+## 文件说明
+- `generate_with_retool.py`:主生成函数,支持工具调用
+- `tool_sandbox.py`:工具执行与安全管理
+- `sft_data_processing.py`:处理 SFT 数据集
+
+## 奖励设计
+RL 奖励函数(`generate_with_retool.reward_func`)在数学正确性奖励的基础上采用了**工具感知的奖励塑形**策略:
+
+| 答案 | 是否使用工具 | 奖励值 | 说明 |
+|------|-------------|--------|------|
+| ✅ 正确 | 否 | 1.0 | 纯推理——理想情况 |
+| ✅ 正确 | 是 | 1.0 + min(0.2, 轮次 × 0.05) | 有效工具使用的额外奖励(上限 0.2) |
+| ❌ 错误 | 否 | 0.0 | 中性——模型未尝试使用工具 |
+| ❌ 错误 | 是 | min(0.1, 轮次 × 0.02) | 小额正奖励以鼓励探索 |
+
+该设计鼓励模型在 RL 训练早期**探索工具调用**,同时避免模型通过偏好工具调用而非正确答案来进行奖励投机。随着训练推进和准确率提升,正确性奖励将占主导,工具奖励变为辅助项。
+
+## 使用方法
+### 1. 环境搭建
+```bash
+git clone -b ascend https://github.com/vllm-project/vime.git
+cd vime
+docker build -f docker/Dockerfile.npu -t vime-ascend:latest .
+```
+
+```bash
+# 更新 vime 镜像
+export IMAGE=vime-ascend:latest
+
+docker run -d --name vime-npu -it --net=host --shm-size=1024g \
+ --privileged=true \
+ --cap-add=SYS_PTRACE \
+ --device=/dev/davinci_manager \
+ --device=/dev/hisi_hdc \
+ --device=/dev/devmm_svm \
+ -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
+ -v /usr/local/dcmi:/usr/local/dcmi \
+ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
+ -v /usr/local/sbin:/usr/local/sbin \
+ -v /home:/home \
+ -v /mnt:/mnt \
+ -v /tmp:/tmp \
+ -v /data:/data \
+ -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
+ $IMAGE
+
+docker exec -it vime-npu bash
+```
+
+### 2. 下载
+```bash
+# SFT 部分,你也可以直接使用后续模型进行 RL 而跳过 SFT。
+hf download --repo-type dataset JoeYing/ReTool-SFT --local-dir /path/to/ReTool-SFT
+hf download Qwen/Qwen3-4B-Instruct-2507 --local-dir /path/to/Qwen3-4B-Instruct-2507
+
+# RL 部分
+hf download --repo-type dataset zhuzilin/dapo-math-17k --local-dir /path/to/dapo-math-17k
+hf download --repo-type dataset zhuzilin/aime-2024 --local-dir /path/to/aime-2024
+# 如果你想跳过 SFT,可下载我们的 SFT 模型
+hf download font-info/qwen3-4b-sft-SGLang-RL --local-dir /path/to/qwen3-4b-sft
+```
+
+### 3. 数据预处理
+```bash
+cd /root/vime
+# 将保存路径替换为 /path/to/ReTool-SFT.parquet
+python examples/retool/sft_data_processing.py
+```
+
+### 4. SFT
+```bash
+cd /root/vime
+# 替换模型和数据加载/保存路径
+python examples/retool/retool_qwen3_4b_sft.sh
+```
+
+### 5. RL
+```bash
+cd /root/vime
+# 替换模型和数据加载/保存路径
+python examples/retool/retool_qwen3_4b_rl.sh
+```
diff --git a/examples/retool/generate_with_retool.py b/examples/retool/generate_with_retool.py
new file mode 100644
index 000000000..e5b748324
--- /dev/null
+++ b/examples/retool/generate_with_retool.py
@@ -0,0 +1,571 @@
+# Adapted from https://github.com/volcengine/verl/blob/cb809d66e46dfd3342d008628891a14a054fa424/recipe/retool/retool.py
+# Adapted for vLLM /inference/v1/generate endpoint (disagg API)
+import logging
+import random
+import re
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+try:
+ from jinja2 import Template
+except ImportError as e:
+ raise ImportError("Jinja2 is required. Please install it with: pip install jinja2") from e
+
+from vime.rollout.vllm_rollout import GenerateState, _build_inference_sampling_params
+from vime.utils.http_utils import post
+from vime.utils.types import Sample
+
+# Import reward models
+try:
+ from vime.rollout.rm_hub.math_dapo_utils import compute_score as math_dapo_compute_score
+except ImportError as e:
+ raise ImportError("MathDapo is not installed") from e
+
+# Import tool sandbox functionality
+from tool_sandbox import SEMAPHORE, TOOL_CONFIGS, tool_registry
+
+# ── Sample-level verbose logging ──────────────────────────────────────────
+# Roughly 1/20 samples are logged so the output stays readable.
+_LOG_SAMPLE_PROB = 0.05
+_LOG_WIDTH = 300
+
+# ── Log probability collection ───────────────────────────────────────────
+# When True, collect log probabilities for TIS (Trajectory Importance Sampling).
+# When True, we CANNOT postprocess the decoded text because that would break
+# token/logp alignment. Instead, we rely on stop strings to ensure the engine
+# stops at tool/answer boundaries.
+RETURN_LOGPROB = True
+
+
+def _trunc(s: str, n: int = 300) -> str:
+ """Truncate *s* to at most *n* characters for display."""
+ if len(s) <= n:
+ return s
+ return s[:n] + f"…[+{len(s) - n}]"
+
+
+# Jinja2 template for tool-enabled conversations
+TOOL_TEMPLATE = """<|im_start|>system
+{%- if messages[0]['role'] == 'system' %}
+{{- messages[0]['content'] }}
+{%- else %}
+You are a helpful assistant.
+{%- endif %}
+{%- if tools %}
+# Tools
+
+You can write Python code to solve problems. Put your code between and tags. The code will be executed and you will see the output.
+When you have the final answer, write it as: Answer: \boxed{your_answer}
+{%- endif %}
+<|im_end|>
+{%- for message in messages %}
+{%- if message['role'] == 'user' %}
+<|im_start|>user
+{{- message['content'] }}<|im_end|>
+{%- elif message['role'] == 'assistant' %}
+<|im_start|>assistant
+{{- message['content'] }}<|im_end|>
+{%- endif %}
+{%- endfor %}
+<|im_start|>assistant
+"""
+
+
+def format_conversation_with_tools(
+ prompt: str, tools: list[dict[str, Any]] = None, system_prompt: str = None, messages: list[dict[str, Any]] = None
+) -> str:
+ """Format conversation using Jinja2 template with tool support"""
+ template = Template(TOOL_TEMPLATE)
+
+ # Prepare messages
+ messages_to_render = []
+
+ # Always add system message - use provided one or default
+ if system_prompt:
+ system_content = system_prompt
+ else:
+ system_content = "You are a helpful assistant that solves " "mathematical problems step by step."
+
+ messages_to_render.append({"role": "system", "content": system_content})
+
+ # Add user message if provided
+ if prompt:
+ messages_to_render.append({"role": "user", "content": prompt})
+
+ # Add assistant responses from previous turns if provided
+ if messages:
+ messages_to_render.extend(messages)
+
+ # Render template
+ formatted_text = template.render(messages=messages_to_render, tools=tools or [])
+
+ return formatted_text
+
+
+def postprocess_predictions(prediction: str):
+ """Extract action and content from prediction string"""
+ # Check for Answer: \boxed{...} format (only format we need for math_dapo)
+ # Use a more robust regex that handles nested braces
+ answer_pattern = r"Answer:\s*\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}"
+ answer_match = re.search(answer_pattern, prediction, re.DOTALL)
+ if answer_match:
+ content = answer_match.group(1).strip()
+ return "answer", content
+
+ # Then check for tags (new format from Jinja2 template)
+ tool_call_pattern = r"\s*(\{.*?\})\s*"
+ tool_call_match = re.search(tool_call_pattern, prediction, re.DOTALL)
+ if tool_call_match:
+ try:
+ import json
+
+ # Clean up the JSON string by removing newlines and extra
+ # whitespace
+ json_str = tool_call_match.group(1)
+ # Replace newlines in string values with \n
+ json_str = json_str.replace("\n", "\\n")
+ tool_call_data = json.loads(json_str)
+ tool_name = tool_call_data.get("name")
+ arguments = tool_call_data.get("arguments", {})
+
+ if tool_name == "code_interpreter":
+ code = arguments.get("code", "")
+ if code.strip():
+ return "code", code
+ except (json.JSONDecodeError, KeyError, AttributeError):
+ pass
+
+ # Then check for tags
+ code_pattern = r"(.*?)"
+ code_match = re.search(code_pattern, prediction, re.DOTALL)
+ if code_match:
+ content = code_match.group(1).strip()
+ return "code", content
+
+ # Finally check for ```python code blocks (lowest priority)
+ python_code_pattern = r"```python\s*(.*?)\s*```"
+ python_code_match = re.search(python_code_pattern, prediction, re.DOTALL)
+ if python_code_match:
+ content = python_code_match.group(1).strip()
+ return "code", content
+
+ return None, ""
+
+
+def postprocess_responses(resp: str) -> str:
+ """Post-process response to ensure tag completeness.
+
+ IMPORTANT: Only used when RETURN_LOGPROB is False. When log prob collection
+ is enabled, we cannot postprocess the decoded text because that would break
+ token/logp alignment. Instead, we rely on stop strings to ensure the engine
+ stops at tool/answer boundaries.
+ """
+ # Handle tags (new format from Jinja2 template)
+ if "" in resp:
+ # Find the last occurrence of ...
+ tool_call_pattern = r"\s*\{.*?\}\s*"
+ matches = list(re.finditer(tool_call_pattern, resp, re.DOTALL))
+ if matches:
+ last_match = matches[-1]
+ return resp[: last_match.end()]
+
+ # Handle tags
+ if "" in resp:
+ return resp.split("")[0] + ""
+
+ # Handle ```python code blocks
+ if "```python" in resp:
+ # Find the last occurrence of ```python...```
+ python_pattern = r"```python\s*.*?```"
+ matches = list(re.finditer(python_pattern, resp, re.DOTALL))
+ if matches:
+ last_match = matches[-1]
+ return resp[: last_match.end()]
+
+ # Handle Answer: \boxed{...} format (only format we need for math_dapo)
+ if "Answer:" in resp and "\\boxed{" in resp:
+ # Find the last occurrence of Answer: \boxed{...} with nested braces support
+ answer_pattern = r"Answer:\s*\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}"
+ matches = list(re.finditer(answer_pattern, resp, re.DOTALL))
+ if matches:
+ last_match = matches[-1]
+ return resp[: last_match.end()]
+
+ return resp
+
+
+async def execute_predictions(prediction: str) -> str:
+ """Execute predictions and return results"""
+ action, content = postprocess_predictions(prediction)
+
+ if action == "code":
+ # Content is already the Python code (extracted by
+ # postprocess_predictions)
+ code = content.strip()
+ if code:
+ async with SEMAPHORE:
+ result = await tool_registry.execute_tool("code_interpreter", {"code": code})
+ next_obs = f"\n\n\n{result}\n\n\n"
+ done = False
+ else:
+ next_obs = "\n\n\nError: No Python code found\n\n\n"
+ done = False
+ elif action == "answer":
+ next_obs = ""
+ done = True
+ else:
+ next_obs = (
+ "\nMy previous action is invalid. "
+ "If I want to execute code, I should put the code between "
+ " and . "
+ "If I want to give the final answer, I should write "
+ "'Answer: ' followed by a boxed expression. Let me try again.\n"
+ )
+ done = False
+
+ return next_obs, done
+
+
+# Stop tags: make the inference engine STOP at the tool boundary.
+# "Answer:" was previously included, but it caused the engine to stop
+# immediately after "Answer:" before the model could generate "\boxed{...}",
+# resulting in invalid actions and reward=-1 every time. The model now
+# completes its answer naturally; execute_predictions detects the
+# Answer: \boxed{...} pattern and sets done=True, ending the turn cleanly.
+_STOP_TAGS = [""]
+
+
+async def generate(args, sample: Sample, sampling_params) -> Sample:
+ """Custom generation function supporting tool calls (vLLM version)"""
+ assert not args.partial_rollout, "Partial rollout is not supported for " "this function at the moment."
+
+ state = GenerateState(args)
+ router_ip = args.vllm_router_ip
+ router_port = args.vllm_router_port
+ url = f"http://{router_ip}:{router_port}/inference/v1/generate"
+
+ # Set up the initial prompt with system prompt and tools (outside the loop)
+ tool_specs = tool_registry.get_tool_specs()
+
+ # Extract the raw user content from sample.prompt to avoid duplicate
+ # <|im_start|>user tags — the Jinja2 template adds them itself.
+ _raw_prompt = sample.prompt
+ if isinstance(_raw_prompt, list):
+ # List of message dicts, e.g. [{"role": "user", "content": "..."}]
+ _raw_prompt = "".join(m.get("content", "") for m in _raw_prompt)
+ # If the prompt string already contains chat-format tags, strip them so
+ # the template can re-add them consistently.
+ if isinstance(_raw_prompt, str) and "<|im_start|>" in _raw_prompt:
+ _raw_prompt = re.sub(r"<\|im_start\|>(?:system|user|assistant)\n?", "", _raw_prompt)
+ _raw_prompt = _raw_prompt.replace("<|im_end|>", "")
+ _raw_prompt = _raw_prompt.strip()
+
+ prompt = format_conversation_with_tools(prompt=_raw_prompt, tools=tool_specs)
+
+ prompt_tokens_ids = state.tokenizer(prompt, add_special_tokens=False)["input_ids"]
+ response = ""
+ response_token_ids = []
+ loss_masks = []
+ rollout_log_probs = [] if RETURN_LOGPROB else None
+ tool_call_count = 0 # Track actual tool call rounds
+ consecutive_memory_errors = 0 # Track consecutive "Memory usage too high" errors
+ obs_truncated = False # Flag: obs caused total length to exceed max_context_length
+ # Calculate max context length once at the beginning
+ max_context_length = len(prompt_tokens_ids) + args.rollout_max_response_len
+ logger.debug("max_context_length is set to %d", max_context_length)
+
+ # Randomly select a small fraction of samples for detailed turn-by-turn logging.
+ verbose = random.random() < _LOG_SAMPLE_PROB
+ if verbose:
+ _sep = "═" * _LOG_WIDTH
+ _prompt_display = (
+ "".join(m.get("content", "") for m in sample.prompt) if isinstance(sample.prompt, list) else sample.prompt
+ )
+ logger.debug(
+ "\n%s\n[ReTool LOG] prompt (%d tokens): %s\n%s",
+ _sep,
+ len(prompt_tokens_ids),
+ _trunc(_prompt_display, 200),
+ _sep,
+ )
+
+ # Add stop tags to sampling_params so the engine stops at tool/answer boundaries.
+ # This is critical for keeping token/logp alignment when RETURN_LOGPROB is enabled.
+ _existing_stop = sampling_params.get("stop") or []
+ if isinstance(_existing_stop, str):
+ _existing_stop = [_existing_stop]
+ sampling_params = {**sampling_params, "stop": list(dict.fromkeys([*_existing_stop, *_STOP_TAGS]))}
+
+ # Build vLLM-style sampling params (maps max_new_tokens -> max_tokens, adds logprobs, etc.)
+ inference_sampling_params = _build_inference_sampling_params(sampling_params)
+
+ for turn in range(TOOL_CONFIGS["max_turns"]):
+ # vLLM /inference/v1/generate requires token_ids instead of text.
+ # For multi-turn, re-tokenize the full context each time.
+ full_text = prompt + response
+ current_token_ids = state.tokenizer(full_text, add_special_tokens=False)["input_ids"]
+
+ # Check if total length exceeds max context length
+ total_length = len(current_token_ids)
+ if total_length >= max_context_length:
+ sample.status = Sample.Status.TRUNCATED
+ break
+
+ # Dynamically calculate remaining token budget for this turn
+ remaining_tokens = max_context_length - total_length
+
+ # Update max_tokens for this turn to respect the remaining budget
+ current_inference_params = dict(inference_sampling_params)
+ current_inference_params["max_tokens"] = min(
+ inference_sampling_params["max_tokens"],
+ remaining_tokens,
+ )
+
+ # Check if we have budget for more tokens
+ if current_inference_params["max_tokens"] <= 0:
+ sample.status = Sample.Status.TRUNCATED
+ break
+
+ payload = {
+ "token_ids": current_token_ids,
+ "sampling_params": current_inference_params,
+ }
+ if hasattr(args, "hf_checkpoint"):
+ payload["model"] = args.hf_checkpoint
+
+ # Log payload to wandb for debugging
+ try:
+ import wandb
+
+ if wandb.run is not None:
+ # Count available tools (from tool_specs)
+ available_tools = len(tool_specs)
+ # Count tools used in the current response
+ tools_used = response.count("")
+
+ wandb.log(
+ {
+ "debug/payload_length": len(prompt + response),
+ "debug/available_tools": available_tools,
+ "debug/tools_used": tools_used,
+ "debug/turn": turn,
+ }
+ )
+ except ImportError:
+ pass # wandb not available
+
+ output = await post(url, payload)
+
+ # Parse vLLM GenerateResponse: {"choices": [{"token_ids": [...], "logprobs": ..., "finish_reason": "stop"}]}
+ choice = output["choices"][0]
+ finish_reason = choice.get("finish_reason", "stop")
+
+ # Handle abort
+ if finish_reason == "abort":
+ sample.status = Sample.Status.ABORTED
+ return sample
+
+ # Extract token IDs from vLLM response
+ cur_response_token_ids = choice.get("token_ids") or []
+
+ # Decode text from token_ids
+ skip_sp = current_inference_params.get("skip_special_tokens")
+ skip_decode = True if skip_sp is None else bool(skip_sp)
+ cur_response = (
+ state.tokenizer.decode(cur_response_token_ids, skip_special_tokens=skip_decode)
+ if cur_response_token_ids
+ else ""
+ )
+
+ # Extract log probs if enabled
+ if RETURN_LOGPROB:
+ cur_response_log_probs: list[float] = []
+ lp = choice.get("logprobs")
+ if isinstance(lp, dict):
+ content_items = lp.get("content") or []
+ cur_response_log_probs = [
+ float(item.get("logprob", 0.0)) if isinstance(item, dict) else 0.0 for item in content_items
+ ]
+ if not cur_response_log_probs:
+ cur_response_log_probs = [0.0] * len(cur_response_token_ids)
+ else:
+ # When not collecting log probs, we can safely postprocess the response
+ cur_response = postprocess_responses(cur_response)
+ # Re-tokenize after postprocessing
+ cur_response_token_ids = state.tokenizer(cur_response, add_special_tokens=False)["input_ids"]
+ cur_response_log_probs = None
+
+ response += cur_response
+ response_token_ids += cur_response_token_ids
+ loss_masks += [1] * len(cur_response_token_ids)
+
+ # Add log probs if enabled
+ if RETURN_LOGPROB:
+ rollout_log_probs += cur_response_log_probs
+
+ # verbose: show what the model generated this turn
+ if verbose:
+ n_tok = len(cur_response_token_ids)
+ logger.debug(
+ "\n%s\n[Turn %d] model output (%d tok, finish=%s):\n %s",
+ "─" * _LOG_WIDTH,
+ turn + 1,
+ n_tok,
+ finish_reason,
+ _trunc(cur_response).replace("\n", "\n "),
+ )
+
+ # Check length limit
+ if finish_reason == "length":
+ if verbose:
+ logger.debug("[Turn %d] → length limit reached, stopping.", turn + 1)
+ break
+
+ next_obs, done = await execute_predictions(cur_response)
+
+ # Track consecutive memory errors to break the vicious retry cycle.
+ # When sandbox keeps returning "Memory usage too high", the model
+ # generates another code attempt which also fails, consuming more
+ # tokens and memory. Force done=True after N consecutive failures.
+ if "Memory usage too high" in next_obs:
+ consecutive_memory_errors += 1
+ if consecutive_memory_errors >= TOOL_CONFIGS.get("max_consecutive_memory_errors", 3):
+ done = True
+ else:
+ consecutive_memory_errors = 0
+
+ # verbose: show action and observation
+ if verbose:
+ if done:
+ logger.debug("[Turn %d] → answer detected (DONE)", turn + 1)
+ elif "" in next_obs:
+ obs_display = " " + _trunc(next_obs, 300).replace("\n", "\n ")
+ logger.debug("[Turn %d] → code executed, observation:\n%s", turn + 1, obs_display)
+ else:
+ logger.debug("[Turn %d] → invalid action (no recognized code or answer)", turn + 1)
+
+ if done:
+ break
+
+ # Count tool calls (when we get interpreter output, it means a tool
+ # was called)
+ if "" in next_obs:
+ tool_call_count += 1
+
+ assert next_obs != "", "Next observation should not be empty."
+ obs_tokens_ids = state.tokenizer(next_obs, add_special_tokens=False)["input_ids"]
+ response += next_obs
+ response_token_ids += obs_tokens_ids
+ loss_masks += [0] * len(obs_tokens_ids)
+
+ # Add dummy log probs for observation tokens if enabled (they won't be used due to loss_mask=0)
+ if RETURN_LOGPROB:
+ rollout_log_probs += [0.0] * len(obs_tokens_ids)
+
+ # Verify alignment when collecting log probs
+ assert len(response_token_ids) == len(
+ rollout_log_probs
+ ), f"Token/logp length mismatch at turn {turn}: {len(response_token_ids)} tokens vs {len(rollout_log_probs)} logps"
+
+ # Truncate if obs pushed total response tokens beyond max_context_length
+ max_response_tokens = max_context_length - len(prompt_tokens_ids)
+ if len(response_token_ids) > max_response_tokens:
+ response_token_ids = response_token_ids[:max_response_tokens]
+ loss_masks = loss_masks[:max_response_tokens]
+ if RETURN_LOGPROB:
+ rollout_log_probs = rollout_log_probs[:max_response_tokens]
+ obs_truncated = True
+ break
+
+ if tool_call_count >= TOOL_CONFIGS["max_tool_calls"]:
+ break
+
+ if verbose:
+ logger.debug(
+ "\n%s\n[ReTool LOG] finished | tool_calls=%d | " "response_tokens=%d | finish=%s\n%s",
+ "═" * _LOG_WIDTH,
+ tool_call_count,
+ len(response_token_ids),
+ finish_reason,
+ "═" * _LOG_WIDTH,
+ )
+
+ # Set sample attributes
+ sample.tokens = prompt_tokens_ids + response_token_ids
+ sample.response_length = len(response_token_ids)
+ sample.response = response
+ sample.loss_mask = loss_masks
+ sample.prompt = prompt
+
+ # Store log probs if enabled
+ if RETURN_LOGPROB:
+ sample.rollout_log_probs = rollout_log_probs if rollout_log_probs else None
+
+ # Store payload information for wandb logging
+ sample.payload_text = prompt + response
+ sample.payload_has_system = "<|im_start|>system" in prompt + response
+ sample.payload_has_tools = "# Tools" in prompt + response
+
+ # Store tool call count for reward calculation
+ sample.tool_call_count = tool_call_count
+
+ # Set status
+ # vLLM finish_reason is a string: "stop", "length", or "abort"
+ match finish_reason:
+ case "length":
+ sample.status = Sample.Status.TRUNCATED
+ case "abort":
+ sample.status = Sample.Status.ABORTED
+ case "stop":
+ sample.status = Sample.Status.COMPLETED
+
+ if obs_truncated:
+ sample.status = Sample.Status.TRUNCATED
+
+ return sample
+
+
+async def reward_func(args, sample, **kwargs):
+ """Tool call reward function using math_dapo as primary reward model"""
+ if not isinstance(sample, Sample):
+ raise TypeError("Sample must be an instance of Sample class.")
+
+ # Truncated samples: neutral reward — not right, not wrong, just incomplete.
+ if sample.status == Sample.Status.TRUNCATED:
+ return {"score": 0.0, "acc": False, "pred": ""}
+
+ # Build complete solution string.
+ # sample.prompt may be a list of message dicts; flatten to plain text.
+ if isinstance(sample.prompt, list):
+ prompt_str = "".join(m.get("content", "") for m in sample.prompt)
+ else:
+ prompt_str = sample.prompt
+ solution_str = prompt_str + sample.response
+
+ # Get ground truth answer - label is a string, not a dict
+ ground_truth = sample.label if sample.label is not None else ""
+
+ # Get tool call count as num_turns
+ num_turns = getattr(sample, "tool_call_count", 0)
+
+ # use \\boxed{...} answer
+ result = math_dapo_compute_score(solution_str, ground_truth, strict_box_verify=True)
+
+ # Reward shaping:
+ # Correct + no tools → 1.0 (pure reasoning, best)
+ # Correct + tools → 1.0 + bonus (tools helped, still good)
+ # Wrong + no tools → 0.0 (neutral — model didn't even try tools)
+ # Wrong + tools → small positive (encourages exploration,
+ # but capped low to avoid reward hacking:
+ # the model should not prefer tool-calling
+ # over getting the right answer)
+ if result["score"] > 0:
+ result["score"] = 1.0 + min(0.2, num_turns * 0.05)
+ else:
+ result["score"] = min(0.1, num_turns * 0.02)
+
+ if result["pred"] is None:
+ result["pred"] = ""
+
+ return result
diff --git a/examples/retool/requirements.txt b/examples/retool/requirements.txt
new file mode 100644
index 000000000..dd93aaab7
--- /dev/null
+++ b/examples/retool/requirements.txt
@@ -0,0 +1,3 @@
+jinja2>=3.0.0
+psutil>=5.8.0
+pytest>=7.0.0
diff --git a/examples/retool/retool_qwen3_4b_rl.sh b/examples/retool/retool_qwen3_4b_rl.sh
new file mode 100644
index 000000000..42d4eb2da
--- /dev/null
+++ b/examples/retool/retool_qwen3_4b_rl.sh
@@ -0,0 +1,206 @@
+#!/bin/bash
+set -ex
+ulimit -u 65535
+
+# cleanup
+pkill -9 -f "vllm serve" 2>/dev/null || true
+sleep 2
+npu-smi info 2>/dev/null | grep rayWorker | awk '{print $4}' | xargs -r kill -9 2>/dev/null || true
+sleep 3
+
+# Ray isolation: independent temp-dir, ports, and cleanup
+export RAY_TMPDIR=/tmp/ray_vime_npu_retool
+export RAY_PORT=6379
+export RAY_DASHBOARD_PORT=8265
+export RAY_AGENT_PORT=52378
+unset RAY_ADDRESS RAY_REDIS_ADDRESS
+
+ray stop --force 2>/dev/null || true
+rm -rf "${RAY_TMPDIR}"
+sleep 2
+
+project_name="vime"
+exp_name="qwen3-4b-retool-rl"
+RAY_DATA_HOME=${RAY_DATA_HOME:-"/root/logs"}
+start_time=$(date +"%Y%m%d_%H%M%S")
+LOG_DIR=${LOG_DIR:-"${RAY_DATA_HOME}/${project_name}/${exp_name}"}
+mkdir -p "${LOG_DIR}"
+LOG_FILE="${LOG_DIR}/${start_time}.log"
+
+echo "Experiment Log will be saved to: ${LOG_FILE}"
+VIME_DIR="/root/vime"
+
+# NPU environment
+source /usr/local/Ascend/driver/bin/setenv.bash
+source /usr/local/Ascend/ascend-toolkit/set_env.sh
+source /usr/local/Ascend/nnal/atb/set_env.sh
+export PYTHONPATH="${VIME_DIR}:${VIME_DIR}/examples/retool:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge:${PYTHONPATH}"
+export PYTHONUNBUFFERED=1
+export PYTORCH_NPU_ALLOC_CONF=expandable_segments:False
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HCCL_CONNECT_TIMEOUT=7200
+export HCCL_DETERMINISTIC=true
+export VLLM_ASCEND_ENABLE_NZ=0
+export ASCEND_COREDUMP_SIGNAL=None
+export ATB_MATMUL_SHUFFLE_K_ENABLE=0
+export ATB_LLM_LCOC_ENABLE=0
+export TASK_QUEUE_ENABLE=1
+export RAY_DISABLE_SIGINT_OVERRIDE=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export LD_LIBRARY_PATH=/usr/local/Ascend/driver/lib64:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:${LD_LIBRARY_PATH}
+export VLLM_DISABLE_COMPILE_CACHE=1
+export TRANSFORMERS_VERBOSITY=error
+export RUST_LOG=vllm_router_rs=warn
+
+NUM_NPUS=16
+source "${VIME_DIR}/scripts/models/qwen3-4B-Instruct-2507.sh"
+
+CKPT_ARGS=(
+ --hf-checkpoint /path/to/Qwen3-4B_sft_vime_hf
+ --ref-load /path/to/Qwen3-4B_sft_vime_hf
+ --load /path/to/Qwen3-4B_vime_npu/
+ --save /path/to/Qwen3-4B_vime_npu/
+ --save-interval 20
+ --no-load-optim
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data /path/to/dapo-math-17k/dapo-math-17k.jsonl
+ --input-key prompt
+ --label-key label
+ --apply-chat-template
+ --rollout-shuffle
+ --reward-key score
+ --num-rollout 200
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 8192
+ --rollout-temperature 1
+ --global-batch-size 256
+ --balance-data
+)
+
+EVAL_ARGS=(
+ --eval-interval 50
+ --eval-prompt-data aime /path/to/aime-2024/aime-2024.jsonl
+ --n-samples-per-eval-prompt 16
+ --eval-max-response-len 16384
+ --eval-top-p 1
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --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
+
+ --micro-batch-size 1
+ --use-dynamic-batch-size
+ --max-tokens-per-gpu 9216
+)
+
+GRPO_ARGS=(
+ --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_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 4
+ --vllm-gpu-memory-utilization 0.7
+ --vllm-enable-sleep-mode
+ --vllm-weight-sync-mode native
+ --vllm-max-model-len 16384
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+)
+
+CUSTOM_ARGS=(
+ --custom-generate-function-path generate_with_retool.generate
+ --custom-rm-path generate_with_retool.reward_func
+)
+
+# launch the master node of ray in container
+unset https_proxy http_proxy proxy
+ray start --head \
+ --temp-dir="${RAY_TMPDIR}" \
+ --port="${RAY_PORT}" \
+ --dashboard-port="${RAY_DASHBOARD_PORT}" \
+ --dashboard-agent-listen-port="${RAY_AGENT_PORT}" \
+ --node-ip-address 127.0.0.1 \
+ --num-gpus 0 \
+ --resources "{\"NPU\": $NUM_NPUS}" \
+ --disable-usage-stats \
+ --dashboard-host=0.0.0.0
+
+# Build the runtime environment JSON with proper variable substitution
+RUNTIME_ENV_JSON=$(cat << 'EOF'
+{
+ "env_vars": {
+ "PYTHONPATH": "${VIME_DIR}:${VIME_DIR}/examples/retool:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge",
+ "CUDA_DEVICE_MAX_CONNECTIONS": "1",
+ "HCCL_HOST_SOCKET_PORT_RANGE": "60000-60050",
+ "HCCL_NPU_SOCKET_PORT_RANGE": "61000-61050",
+ "HCCL_CONNECT_TIMEOUT": "7200",
+ "PYTORCH_NPU_ALLOC_CONF": "expandable_segments:False",
+ "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1",
+ "VLLM_DISABLE_COMPILE_CACHE": "1",
+ "TRANSFORMERS_VERBOSITY": "error",
+ "RUST_LOG": "vllm_router_rs=warn",
+ "LD_LIBRARY_PATH": "/usr/local/Ascend/driver/lib64:/usr/local/Ascend/driver/lib64/driver:/usr/local/Ascend/driver/lib64/common:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/opskernel:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/nnengine:/usr/local/Ascend/ascend-toolkit/latest/opp/built-in/op_impl/ai_core/tbe/op_tiling/lib/:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:/usr/local/Ascend/cann/aarch64-linux/devlib"
+ }
+}
+EOF
+)
+
+ray job submit --address="http://127.0.0.1:${RAY_DASHBOARD_PORT}" \
+ --runtime-env-json="${RUNTIME_ENV_JSON}" \
+ --working-dir="${VIME_DIR}" \
+ -- python3 -u train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 8 \
+ --rollout-num-gpus 8 \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${ROLLOUT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${GRPO_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${EVAL_ARGS[@]} \
+ ${VLLM_ARGS[@]} \
+ ${MISC_ARGS[@]} \
+ ${CUSTOM_ARGS[@]} \
+ 2>&1 | tee "${LOG_FILE}"
diff --git a/examples/retool/retool_qwen3_4b_sft.sh b/examples/retool/retool_qwen3_4b_sft.sh
new file mode 100644
index 000000000..cf067ab80
--- /dev/null
+++ b/examples/retool/retool_qwen3_4b_sft.sh
@@ -0,0 +1,170 @@
+#!/bin/bash
+set -ex
+ulimit -u 65535
+
+# cleanup
+pkill -9 -f "vllm serve" 2>/dev/null || true
+sleep 2
+npu-smi info 2>/dev/null | grep rayWorker | awk '{print $4}' | xargs -r kill -9 2>/dev/null || true
+sleep 3
+
+# Ray isolation: independent temp-dir, ports, and cleanup
+export RAY_TMPDIR=/tmp/ray_vime_npu_retool
+export RAY_PORT=6379
+export RAY_DASHBOARD_PORT=8265
+export RAY_AGENT_PORT=52378
+unset RAY_ADDRESS RAY_REDIS_ADDRESS
+
+ray stop --force 2>/dev/null || true
+rm -rf "${RAY_TMPDIR}"
+sleep 2
+
+project_name="vime"
+exp_name="qwen3-4b-retool-sft"
+RAY_DATA_HOME=${RAY_DATA_HOME:-"/root/logs"}
+start_time=$(date +"%Y%m%d_%H%M%S")
+LOG_DIR=${LOG_DIR:-"${RAY_DATA_HOME}/${project_name}/${exp_name}"}
+mkdir -p "${LOG_DIR}"
+LOG_FILE="${LOG_DIR}/${start_time}.log"
+
+echo "Experiment Log will be saved to: ${LOG_FILE}"
+VIME_DIR="/root/vime"
+
+# NPU environment
+source /usr/local/Ascend/driver/bin/setenv.bash
+source /usr/local/Ascend/ascend-toolkit/set_env.sh
+source /usr/local/Ascend/nnal/atb/set_env.sh
+export PYTHONPATH="${VIME_DIR}:${VIME_DIR}/examples/retool:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge:${PYTHONPATH}"
+export PYTHONUNBUFFERED=1
+export PYTORCH_NPU_ALLOC_CONF=expandable_segments:False
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HCCL_CONNECT_TIMEOUT=7200
+export HCCL_DETERMINISTIC=true
+export VLLM_ASCEND_ENABLE_NZ=0
+export ASCEND_COREDUMP_SIGNAL=None
+export ATB_MATMUL_SHUFFLE_K_ENABLE=0
+export ATB_LLM_LCOC_ENABLE=0
+export TASK_QUEUE_ENABLE=1
+export RAY_DISABLE_SIGINT_OVERRIDE=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export LD_LIBRARY_PATH=/usr/local/Ascend/driver/lib64:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:${LD_LIBRARY_PATH}
+export VLLM_DISABLE_COMPILE_CACHE=1
+export RUST_LOG=vllm_router_rs=warn
+export TRANSFORMERS_VERBOSITY=error
+
+NUM_NPUS=16
+source "${VIME_DIR}/scripts/models/qwen3-4B-Instruct-2507.sh"
+
+CKPT_ARGS=(
+ --hf-checkpoint /path/to/Qwen3-4B-Instruct-2507
+ --ref-load /path/to/Qwen3-4B-Instruct-2507
+ --save /path/to/Qwen3-4B_sft_vime/
+ --save-interval 1000
+ --save-hf /path/to/Qwen3-4B_sft_vime_hf/
+ --no-load-optim
+ --megatron-to-hf-mode bridge
+)
+
+SFT_ARGS=(
+ --rollout-function-path vime.rollout.sft_rollout.generate_rollout
+ --prompt-data /path/to/ReTool-SFT/ReTool-SFT.parquet
+ --input-key messages
+ --rollout-shuffle
+ --num-epoch 3
+ --rollout-batch-size 128
+ --global-batch-size 128
+ --loss-type sft_loss
+ --calculate-per-token-loss
+ --disable-compute-advantages-and-returns
+ --debug-train-only
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --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
+
+ --micro-batch-size 1
+ --use-dynamic-batch-size
+ --max-tokens-per-gpu 9216
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-5
+ --lr-decay-style cosine
+ --min-lr 1e-6
+ --lr-warmup-fraction 0.1
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.95
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+)
+
+# launch the master node of ray in container
+unset https_proxy http_proxy proxy
+ray start --head \
+ --temp-dir="${RAY_TMPDIR}" \
+ --port="${RAY_PORT}" \
+ --dashboard-port="${RAY_DASHBOARD_PORT}" \
+ --dashboard-agent-listen-port="${RAY_AGENT_PORT}" \
+ --node-ip-address 127.0.0.1 \
+ --num-gpus 0 \
+ --resources "{\"NPU\": $NUM_NPUS}" \
+ --disable-usage-stats \
+ --dashboard-host=0.0.0.0
+
+# Build the runtime environment JSON with proper variable substitution
+RUNTIME_ENV_JSON=$(cat << 'EOF'
+{
+ "env_vars": {
+ "PYTHONPATH": "${VIME_DIR}:${VIME_DIR}/examples/retool:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge",
+ "CUDA_DEVICE_MAX_CONNECTIONS": "1",
+ "HCCL_HOST_SOCKET_PORT_RANGE": "60000-60050",
+ "HCCL_NPU_SOCKET_PORT_RANGE": "61000-61050",
+ "HCCL_CONNECT_TIMEOUT": "7200",
+ "PYTORCH_NPU_ALLOC_CONF": "expandable_segments:False",
+ "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1",
+ "VLLM_DISABLE_COMPILE_CACHE": "1",
+ "TRANSFORMERS_VERBOSITY": "error",
+ "RUST_LOG": "vllm_router_rs=warn",
+ "LD_LIBRARY_PATH": "/usr/local/Ascend/driver/lib64:/usr/local/Ascend/driver/lib64/driver:/usr/local/Ascend/driver/lib64/common:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/opskernel:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/nnengine:/usr/local/Ascend/ascend-toolkit/latest/opp/built-in/op_impl/ai_core/tbe/op_tiling/lib/:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:/usr/local/Ascend/cann/aarch64-linux/devlib"
+ }
+}
+EOF
+)
+
+ray job submit --address="http://127.0.0.1:${RAY_DASHBOARD_PORT}" \
+ --runtime-env-json="${RUNTIME_ENV_JSON}" \
+ --working-dir="${VIME_DIR}" \
+ -- python3 -u train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 16 \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${SFT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${MISC_ARGS[@]} \
+ 2>&1 | tee "${LOG_FILE}"
diff --git a/examples/retool/rl_data_preprocess.py b/examples/retool/rl_data_preprocess.py
new file mode 100644
index 000000000..da4f75631
--- /dev/null
+++ b/examples/retool/rl_data_preprocess.py
@@ -0,0 +1,21 @@
+from datasets import load_dataset
+
+# Load the original dataset
+ds = load_dataset("BytedTsinghua-SIA/DAPO-Math-17k", split="train")
+
+
+# Map to extract the ground_truth from the reward_model dict and create a new 'label' field
+def transform(example):
+ return {
+ "prompt": example["prompt"][0]["content"] if example["prompt"] else None,
+ "label": example["reward_model"]["ground_truth"],
+ }
+
+
+ds2 = ds.map(transform, remove_columns=ds.column_names)
+
+# Optionally, verify the first few entries
+print(ds2[0])
+
+# save to jsonl
+ds2.to_json("/path/to/dapo-math-17k/dapo-math-17k.jsonl", orient="records", lines=True)
diff --git a/examples/retool/sft_data_processing.py b/examples/retool/sft_data_processing.py
new file mode 100644
index 000000000..38bbccd29
--- /dev/null
+++ b/examples/retool/sft_data_processing.py
@@ -0,0 +1,31 @@
+from datasets import load_dataset
+
+ds = load_dataset("/path/to/ReTool-SFT")["train"]
+
+
+def convert(sample):
+ conversations = sample["messages"]
+
+ def convert_role(role):
+ if role == "user":
+ return "user"
+ elif role == "assistant":
+ return "assistant"
+ elif role == "system":
+ return "system"
+ else:
+ raise ValueError(f"Unknown role: {role}")
+
+ messages = [
+ {
+ "role": convert_role(turn["role"]),
+ "content": turn["content"],
+ }
+ for turn in conversations
+ ]
+
+ return {"messages": messages}
+
+
+ds = ds.map(convert)
+ds.to_parquet("/path/to/ReTool-SFT/ReTool-SFT.parquet")
diff --git a/examples/retool/tool_sandbox.py b/examples/retool/tool_sandbox.py
new file mode 100644
index 000000000..5ccf48006
--- /dev/null
+++ b/examples/retool/tool_sandbox.py
@@ -0,0 +1,367 @@
+"""
+Tool sandbox module for safe code execution and tool management.
+
+This module provides:
+- PythonSandbox: Safe Python code execution environment
+- ToolRegistry: Tool registration and execution management
+- Memory management utilities
+"""
+
+import asyncio
+import gc
+import os
+import re
+import subprocess
+import tempfile
+from contextlib import contextmanager
+from typing import Any
+
+import psutil
+
+# Configuration for tool execution
+TOOL_CONFIGS = {
+ "max_turns": 4,
+ "max_tool_calls": 4,
+ "max_consecutive_memory_errors": 3, # Break retry cycle after N consecutive memory errors
+ "tool_concurrency": 8, # Conservative: avoid RSS pressure on RolloutManager
+ # Python interpreter settings
+ "python_timeout": 120, # 2 minutes for complex calculations
+ "python_memory_limit": "4GB", # 4GB per Python process
+ "python_cpu_limit": 1,
+ # Memory management settings
+ "max_memory_usage": 12288, # 12GB total (75% of 16GB)
+ "cleanup_threshold": 6144, # 6GB
+ "aggressive_cleanup_threshold": 3072, # 3GB
+ "force_cleanup_threshold": 9216, # 9GB
+}
+
+# Global semaphore for controlling concurrent tool executions
+SEMAPHORE = asyncio.Semaphore(TOOL_CONFIGS["tool_concurrency"])
+
+
+def get_memory_usage() -> float:
+ """Get current memory usage in MB"""
+ process = psutil.Process()
+ return process.memory_info().rss / 1024 / 1024
+
+
+def cleanup_memory():
+ """Force garbage collection to free memory"""
+ gc.collect()
+
+
+def aggressive_cleanup_memory():
+ """More aggressive memory cleanup"""
+ # Force multiple garbage collection cycles
+ for _ in range(3):
+ gc.collect()
+
+ # Force glibc to return freed memory to the OS.
+ # Without this, Python's allocator holds onto freed pages due to
+ # heap fragmentation, causing RSS to stay high even after gc.collect().
+ # Try multiple libc paths for compatibility across different Linux distros
+ # (including Ascend NPU servers).
+ for libc_path in ("libc.so.6", "libc.musl.so.1", "libc.so"):
+ try:
+ import ctypes
+
+ ctypes.CDLL(libc_path).malloc_trim(0)
+ break
+ except OSError:
+ continue
+
+
+def check_and_cleanup_memory():
+ """Check memory usage and perform appropriate cleanup"""
+ current_memory = get_memory_usage()
+
+ if current_memory > TOOL_CONFIGS["force_cleanup_threshold"]:
+ # Force aggressive cleanup
+ aggressive_cleanup_memory()
+ return f"Warning: High memory usage ({current_memory:.1f}MB), performed aggressive cleanup"
+ elif current_memory > TOOL_CONFIGS["cleanup_threshold"]:
+ # Normal cleanup
+ cleanup_memory()
+ return f"Info: Memory usage ({current_memory:.1f}MB), performed cleanup"
+ elif current_memory > TOOL_CONFIGS["aggressive_cleanup_threshold"]:
+ # Light cleanup
+ gc.collect()
+ return f"Info: Memory usage ({current_memory:.1f}MB), performed light cleanup"
+
+ return None
+
+
+class PythonSandbox:
+ """Python code sandbox, provides safe code execution environment"""
+
+ def __init__(self, timeout: int = 10, memory_limit: str = "100MB"):
+ self.timeout = timeout
+ self.memory_limit = memory_limit
+ self.allowed_modules = {
+ "math",
+ "random",
+ "datetime",
+ "collections",
+ "itertools",
+ "functools",
+ "operator",
+ "statistics",
+ "decimal",
+ "fractions",
+ }
+
+ def _check_code_safety(self, code: str) -> tuple[bool, str]:
+ """Check code safety by scanning for dangerous patterns"""
+ # Check for dangerous operations
+ dangerous_patterns = [
+ r"import\s+os",
+ r"import\s+sys",
+ r"import\s+subprocess",
+ r"import\s+shutil",
+ r"import\s+glob",
+ r"import\s+pathlib",
+ r"__import__",
+ r"eval\s*\(",
+ r"exec\s*\(",
+ r"open\s*\(",
+ r"file\s*\(",
+ r"input\s*\(",
+ r"raw_input\s*\(",
+ r"compile\s*\(",
+ r"execfile\s*\(",
+ r"getattr\s*\(",
+ r"setattr\s*\(",
+ r"delattr\s*\(",
+ r"hasattr\s*\(",
+ r"globals\s*\(",
+ r"locals\s*\(",
+ r"vars\s*\(",
+ r"dir\s*\(",
+ r"type\s*\(",
+ r"isinstance\s*\(",
+ r"issubclass\s*\(",
+ r"super\s*\(",
+ r"property\s*\(",
+ r"staticmethod\s*\(",
+ r"classmethod\s*\(",
+ r"__\w+__", # double underscore methods
+ ]
+
+ for pattern in dangerous_patterns:
+ if re.search(pattern, code, re.IGNORECASE):
+ return False, f"Code contains dangerous pattern: {pattern}"
+
+ # Check imported modules
+ import_pattern = r"import\s+(\w+)"
+ from_pattern = r"from\s+(\w+)"
+
+ imports = re.findall(import_pattern, code)
+ froms = re.findall(from_pattern, code)
+
+ all_imports = set(imports + froms)
+ for imp in all_imports:
+ if imp not in self.allowed_modules:
+ return False, f"Import of '{imp}' is not allowed"
+
+ return True, "Code is safe"
+
+ @contextmanager
+ def _create_safe_environment(self):
+ """Create safe execution environment with temporary directory"""
+ # Create temporary directory
+ temp_dir = tempfile.mkdtemp(prefix="python_sandbox_")
+
+ try:
+ # Create safe Python script
+ script_path = os.path.join(temp_dir, "code.py")
+
+ # Set environment variables
+ env = os.environ.copy()
+ env["PYTHONPATH"] = temp_dir
+ env["PYTHONUNBUFFERED"] = "1"
+
+ yield script_path, env, temp_dir
+
+ finally:
+ # Clean up temporary directory
+ try:
+ import shutil
+
+ shutil.rmtree(temp_dir)
+ except Exception:
+ pass
+
+ async def execute_code(self, code: str) -> str:
+ """Execute Python code in sandbox with safety checks"""
+ # Check memory usage before execution
+ current_memory = get_memory_usage()
+ if current_memory > TOOL_CONFIGS["max_memory_usage"]:
+ aggressive_cleanup_memory()
+ # Re-check after cleanup — gc.collect + malloc_trim may free enough
+ current_memory = get_memory_usage()
+ if current_memory > TOOL_CONFIGS["max_memory_usage"]:
+ return "Error: Memory usage too high, please try again"
+
+ # Check code safety
+ is_safe, message = self._check_code_safety(code)
+ if not is_safe:
+ return f"Error: {message}"
+
+ # Add necessary wrapper code with memory limits
+ # Properly indent the user code within the try block
+ # Handle indentation properly by adding 4 spaces to each line
+ indented_code = "\n".join(" " + line for line in code.split("\n"))
+
+ wrapped_code = f"""import sys
+import traceback
+from io import StringIO
+import resource
+
+# Set memory limit (4GB)
+try:
+ resource.setrlimit(resource.RLIMIT_AS, (4 * 1024 * 1024 * 1024, -1))
+except Exception:
+ pass
+
+# Redirect stdout and stderr
+old_stdout = sys.stdout
+old_stderr = sys.stderr
+stdout_capture = StringIO()
+stderr_capture = StringIO()
+sys.stdout = stdout_capture
+sys.stderr = stderr_capture
+
+try:
+ # User code
+{indented_code}
+
+ # Get output
+ stdout_output = stdout_capture.getvalue()
+ stderr_output = stderr_capture.getvalue()
+
+ # Restore standard output
+ sys.stdout = old_stdout
+ sys.stderr = old_stderr
+
+ # Return result
+ result = ""
+ if stdout_output:
+ result += f"Output:\\n{{stdout_output}}"
+ if stderr_output:
+ result += f"\\nErrors:\\n{{stderr_output}}"
+
+ print(result)
+
+except Exception as e:
+ # Restore standard output
+ sys.stdout = old_stdout
+ sys.stderr = old_stderr
+
+ # Return error information
+ error_msg = f"Error: {{str(e)}}\\nTraceback:\\n{{traceback.format_exc()}}"
+ print(error_msg)"""
+
+ with self._create_safe_environment() as (script_path, env, temp_dir):
+ # Write code to file
+ with open(script_path, "w") as f:
+ f.write(wrapped_code)
+
+ try:
+ # Use subprocess to run code
+ process = subprocess.Popen(
+ ["python3", script_path],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ cwd=temp_dir,
+ text=True,
+ )
+
+ # Set timeout
+ try:
+ stdout, stderr = process.communicate(timeout=self.timeout)
+
+ if process.returncode == 0:
+ result = stdout.strip()
+ else:
+ result = f"Error: Process exited with code {process.returncode}\n{stderr}"
+
+ except subprocess.TimeoutExpired:
+ process.kill()
+ result = f"Error: Code execution timed out after {self.timeout} seconds"
+
+ # Explicitly release subprocess resources to reduce RSS
+ del process
+
+ except Exception as e:
+ result = f"Error: Failed to execute code: {str(e)}"
+
+ # Check memory usage after execution and cleanup if needed
+ cleanup_message = check_and_cleanup_memory()
+ if cleanup_message:
+ print(f"Memory cleanup: {cleanup_message}")
+
+ return result
+
+
+class ToolRegistry:
+ """Tool registry, manages available tools and their execution"""
+
+ def __init__(self):
+ self.tools = {}
+ self.python_sandbox = PythonSandbox(
+ timeout=TOOL_CONFIGS["python_timeout"], memory_limit=TOOL_CONFIGS["python_memory_limit"]
+ )
+ self._register_default_tools()
+
+ def _register_default_tools(self):
+ """Register default tools in the registry"""
+ # Python code interpreter
+ self.register_tool(
+ "code_interpreter",
+ {
+ "type": "function",
+ "function": {
+ "name": "code_interpreter",
+ "description": "A tool for executing Python code in a safe sandbox environment.",
+ "parameters": {
+ "type": "object",
+ "properties": {"code": {"type": "string", "description": "The Python code to execute"}},
+ "required": ["code"],
+ },
+ },
+ },
+ )
+
+ def register_tool(self, name: str, tool_spec: dict[str, Any]):
+ """Register a new tool in the registry"""
+ self.tools[name] = tool_spec
+
+ def get_tool_specs(self) -> list[dict[str, Any]]:
+ """Get all tool specifications as a list"""
+ return list(self.tools.values())
+
+ async def execute_tool(self, tool_name: str, arguments: dict[str, Any]) -> str:
+ """Execute a tool call with the given arguments"""
+ if tool_name not in self.tools:
+ return f"Error: Tool '{tool_name}' not found"
+
+ async with SEMAPHORE:
+ if tool_name == "code_interpreter":
+ return await self._execute_python(arguments)
+ else:
+ return f"Error: Tool '{tool_name}' not implemented"
+
+ async def _execute_python(self, arguments: dict[str, Any]) -> str:
+ """Execute Python code using the sandbox"""
+ code = arguments.get("code", "")
+ if not code.strip():
+ return "Error: No code provided"
+
+ # Execute code in sandbox
+ result = await self.python_sandbox.execute_code(code)
+ return result
+
+
+# Global tool registry instance
+tool_registry = ToolRegistry()
diff --git a/examples/search-r1/README.md b/examples/search-r1/README.md
new file mode 100644
index 000000000..05228154f
--- /dev/null
+++ b/examples/search-r1/README.md
@@ -0,0 +1,236 @@
+# Search-R1 lite
+
+This example **(Search-R1 lite)** demonstrates how to use vime for tool-enabled language model generation with search/retrieval capabilities, based on a minimal reproduction of [Search-R1](https://github.com/PeterGriffinJin/Search-R1).
+
+## Overview
+
+The Search-R1 example provides:
+
+- **Multi-turn conversation** with tool-calling (search/answer actions)
+- **Dual search backend support**: local dense retriever (FAISS + E5) or Google Search (serper.dev)
+- **GRPO-based RL training** with exact-match (EM) reward for QA tasks
+- **TIS (Trajectory Importance Sampling)** support for handling train/inference mismatch
+- **Format-aware reward** that evaluates both answer correctness and output structure
+
+## Files
+
+| File | Description |
+|---------------------------------------------|------------------------------------------------------------------------------------|
+| `generate_with_search.py` | Main generation function with multi-turn search + answer tool-calling, and reward function |
+| `google_search_server.py` | Google Search backend via serper.dev API |
+| `local_search_server.py` | Local search backend that wraps the retrieval server |
+| `qa_em_format.py` | QA exact-match scoring with format validation and retrieval correctness check |
+| `run_qwen3_4b_npu.sh` | Training launch script (NPU 8-card, Qwen3-4B-Instruct-2507, GRPO) |
+| `local_dense_retriever/retrieval_server.py` | Dense retriever server (FAISS + E5 model) |
+| `local_dense_retriever/download.py` | Download wiki-18 index and corpus from HuggingFace |
+
+---
+
+## Usage
+
+### 1. Setup
+
+```bash
+git clone -b ascend https://github.com/vllm-project/vime.git
+cd vime
+docker build -f docker/Dockerfile.npu -t vime-ascend:latest .
+```
+
+```bash
+# Update the vime image
+export IMAGE=vime-ascend:latest
+
+docker run -d --name vime-npu -it --net=host --shm-size=1024g \
+ --privileged=true \
+ --cap-add=SYS_PTRACE \
+ --device=/dev/davinci_manager \
+ --device=/dev/hisi_hdc \
+ --device=/dev/devmm_svm \
+ -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
+ -v /usr/local/dcmi:/usr/local/dcmi \
+ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
+ -v /usr/local/sbin:/usr/local/sbin \
+ -v /home:/home \
+ -v /mnt:/mnt \
+ -v /tmp:/tmp \
+ -v /data:/data \
+ -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
+ $IMAGE
+
+docker exec -it vime-npu bash
+```
+
+```bash
+# For retriever
+pip install faiss-cpu==1.13.2
+```
+
+### 2. Download
+
+#### 2.1 Data
+
+**Option A: Online Auto Download**
+
+```bash
+# Training data (NQ + HotpotQA)
+cd /root
+git clone https://github.com/PeterGriffinJin/Search-R1.git
+cd Search-R1
+pip install -e . --no-deps
+pip install tensordict
+pip install chardet
+
+# Set your working directory
+WORK_DIR=/root/Search-R1
+LOCAL_DIR=/path/to/nq_hotpotqa_train
+
+# Process multiple dataset search format train file
+DATA=nq,hotpotqa
+python $WORK_DIR/scripts/data_process/qa_search_train_merge.py \
+ --local_dir $LOCAL_DIR \
+ --data_sources $DATA
+
+# (Optional) Process multiple dataset search format test file
+DATA=nq,triviaqa,popqa,hotpotqa,2wikimultihopqa,musique,bamboogle
+python $WORK_DIR/scripts/data_process/qa_search_test_merge.py \
+ --local_dir $LOCAL_DIR \
+ --data_sources $DATA
+```
+
+**Option B: Offline Manual Download**
+
+- Download full dataset assets from Hugging Face repo: [PeterJinGo/nq_hotpotqa_train](https://huggingface.co/datasets/PeterJinGo/nq_hotpotqa_train)
+- Upload all downloaded files to your target directory `LOCAL_DIR=/path/to/nq_hotpotqa_train`
+
+#### 2.2 Model
+
+**Option A: Online Auto Download**
+
+```bash
+hf download Qwen/Qwen3-4B-Instruct- --local-dir /path/to/Qwen3-4B-Instruct-2507
+```
+
+**Option B: Offline Manual Download**
+
+- Download full model weights from Hugging Face repo: [Qwen/Qwen3-4B-Instruct-2507](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507)
+- Upload all downloaded model files to your target directory `MODEL_DIR=/path/to/Qwen3-4B-Instruct-2507`
+
+#### 2.3 Local Retrieval Server (Optional)
+
+> Only needed if using the local search backend instead of Google Search.
+
+**(1) Data**
+
+**Option A: Online Auto Download**
+
+```bash
+# Download index and corpus (~60-70 GB download)
+SAVE_PATH=/path/to/Index
+python /root/vime/examples/search-r1/local_dense_retriever/download.py --save_path $SAVE_PATH
+```
+
+**Option B: Offline Manual Download**
+
+- Download full index assets from Hugging Face repo: [PeterJinGo/wiki-18-e5-index](https://huggingface.co/datasets/PeterJinGo/wiki-18-e5-index) and [PeterJinGo/wiki-18-corpus](https://huggingface.co/datasets/PeterJinGo/wiki-18-corpus)
+- Upload all downloaded files to your target directory `SAVE_PATH=/path/to/Index`
+
+> No matter which download option you use (Option A or B), you must run the following commands after the download completes to merge the index shards and unpack the corpus dataset.
+
+```bash
+SAVE_PATH=/path/to/Index
+cat $SAVE_PATH/part_* > $SAVE_PATH/e5_Flat.index
+gzip -d $SAVE_PATH/wiki-18.jsonl.gz
+```
+
+**(2) Model**
+
+**Option A: Online Auto Download**
+
+```bash
+hf download intfloat/e5-base-v2 --local-dir /path/to/e5-base-v2
+```
+
+**Option B: Offline Manual Download**
+
+- Download full model weights from Hugging Face repo: [intfloat/e5-base-v2](https://huggingface.co/intfloat/e5-base-v2)
+- Upload all downloaded model files to your target directory `MODEL_DIR=/path/to/e5-base-v2`
+
+### 3. Configure Search Backend
+
+The `generate_with_search.py` file supports both **local** search and **Google** search backends. Configure via the `SEARCH_R1_CONFIGS` dictionary:
+
+```python
+SEARCH_R1_CONFIGS = {
+ # ============== General Configuration ==============
+ "max_turns": 2,
+ "topk": 3,
+ "search_concurrency": 8,
+
+ # ============== Search Backend Selection ==============
+ "search_backend": "local", # Options: "local" or "google"
+
+ # ============== Local Search Configuration ==============
+ # (Only used when search_backend="local")
+ "local": {
+ "search_url": "http://127.0.0.1:8000/retrieve", # URL of your local retrieval server
+ "proxy": None,
+ },
+
+ # ============== Google Search Configuration ==============
+ # (Only used when search_backend="google")
+ "google": {
+ "api_key": "your_api_key_here", # Replace with your actual serper.dev API key
+ "snippet_only": True,
+ "proxy": None,
+ },
+
+ # ============== Log Probability Collection ==============
+ "return_logprob": True, # Set to True to collect log probabilities (required for TIS)
+
+ # ============== Reward Model Configuration ==============
+ "format_score": 0.2,
+}
+```
+
+#### Using Local Search
+
+- Set `"search_backend": "local"`
+- Configure `"local"` section with your local retrieval server URL
+- Start your local search server before running the training script
+
+```bash
+# Set paths
+SAVE_PATH=/path/to/Index
+INDEX_FILE=$SAVE_PATH/e5_Flat.index
+CORPUS_FILE=$SAVE_PATH/wiki-18.jsonl
+RETRIEVER_NAME=e5
+RETRIEVER_PATH=/path/to/e5-base-v2
+
+# Start the retrieval server
+python /root/vime/examples/search-r1/local_dense_retriever/retrieval_server.py \
+ --index_path $INDEX_FILE\
+ --corpus_path $CORPUS_FILE\
+ --topk 3 \
+ --retriever_name $RETRIEVER_NAME \
+ --retriever_model $RETRIEVER_PATH
+```
+
+> **Note:**
+> - First startup will download the model and load the index, which may take a few minutes
+> - Normal startup time (excluding downloads): 1-2 minutes
+> - The local search engine's Python process will not terminate when the shell closes
+> - To restart the server: `lsof -i :8000` to find the PID, then kill it and restart
+
+#### Using Google Search
+
+- Set `"search_backend": "google"`
+- Configure `"google"` section with your serper.dev API key
+- Get your API key from [serper.dev](https://serper.dev/)
+
+### 4. Run Training
+
+```bash
+cd /root/vime
+# Replace the model and data loading/saving paths
+bash examples/search-r1/run_qwen3_4b_npu.sh
+```
diff --git a/examples/search-r1/README_zh.md b/examples/search-r1/README_zh.md
new file mode 100644
index 000000000..7d196ea0e
--- /dev/null
+++ b/examples/search-r1/README_zh.md
@@ -0,0 +1,234 @@
+# Search-R1 lite
+
+本示例 **(Search-R1 lite)** 演示了如何使用 vime 进行带工具调用的语言模型生成,具备搜索/检索能力,是基于 [Search-R1](https://github.com/PeterGriffinJin/Search-R1) 的最小复现。
+
+## 概述
+
+Search-R1 示例提供了:
+
+- **多轮对话**:支持工具调用(搜索/回答)
+- **双搜索后端支持**:本地稠密检索器(FAISS + E5)或 Google 搜索(serper.dev)
+- **基于 GRPO 的强化学习训练**:使用精确匹配(EM)计算问答任务奖励
+- **TIS(轨迹重要性采样)**:用于处理训推不一致问题
+- **格式感知奖励**:同时评估答案正确性和输出结构
+
+## 文件说明
+
+| 文件 | 描述 |
+|---------------------------------------------|------|
+| `generate_with_search.py` | 主生成函数,支持多轮搜索 + 回答工具调用,以及奖励函数 |
+| `google_search_server.py` | 通过 serper.dev API 实现 Google 搜索后端 |
+| `local_search_server.py` | 本地搜索后端,封装检索服务 |
+| `qa_em_format.py` | 问答精确匹配评分,包含格式验证和检索正确性检查 |
+| `run_qwen3_4b_npu.sh` | 训练启动脚本(NPU 8卡,Qwen3-4B-Instruct-2507,GRPO) |
+| `local_dense_retriever/retrieval_server.py` | 稠密检索服务器(FAISS + E5 模型) |
+| `local_dense_retriever/download.py` | 从 HuggingFace 下载 wiki-18 索引和语料库 |
+
+---
+
+## 使用方法
+
+### 1. 环境搭建
+
+```bash
+git clone -b ascend https://github.com/vllm-project/vime.git
+cd vime
+docker build -f docker/Dockerfile.npu -t vime-ascend:latest .
+```
+
+```bash
+export IMAGE=vime-ascend:latest
+
+docker run -d --name vime-npu -it --net=host --shm-size=1024g \
+ --privileged=true \
+ --cap-add=SYS_PTRACE \
+ --device=/dev/davinci_manager \
+ --device=/dev/hisi_hdc \
+ --device=/dev/devmm_svm \
+ -v /usr/local/Ascend/driver:/usr/local/Ascend/driver \
+ -v /usr/local/dcmi:/usr/local/dcmi \
+ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \
+ -v /usr/local/sbin:/usr/local/sbin \
+ -v /home:/home \
+ -v /mnt:/mnt \
+ -v /tmp:/tmp \
+ -v /data:/data \
+ -v /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime \
+ $IMAGE
+
+docker exec -it vime-npu bash
+```
+
+```bash
+# 检索依赖
+pip install faiss-cpu==1.13.2
+```
+
+### 2. 下载
+
+#### 2.1 数据
+
+**方式 A:在线自动下载**
+
+```bash
+cd /root
+git clone https://github.com/PeterGriffinJin/Search-R1.git
+cd Search-R1
+pip install -e . --no-deps
+pip install tensordict
+pip install chardet
+
+# 设置工作目录
+WORK_DIR=/root/Search-R1
+LOCAL_DIR=/path/to/nq_hotpotqa_train
+
+# 处理多数据集搜索格式训练文件
+DATA=nq,hotpotqa
+python $WORK_DIR/scripts/data_process/qa_search_train_merge.py \
+ --local_dir $LOCAL_DIR \
+ --data_sources $DATA
+
+# (可选)处理多数据集搜索格式测试文件
+DATA=nq,triviaqa,popqa,hotpotqa,2wikimultihopqa,musique,bamboogle
+python $WORK_DIR/scripts/data_process/qa_search_test_merge.py \
+ --local_dir $LOCAL_DIR \
+ --data_sources $DATA
+```
+
+**方式 B:离线手动下载**
+
+- 从 Hugging Face 仓库下载完整数据集资源:[PeterJinGo/nq_hotpotqa_train](https://huggingface.co/datasets/PeterJinGo/nq_hotpotqa_train)
+- 将所有下载的文件上传到目标目录 `LOCAL_DIR=/path/to/nq_hotpotqa_train`
+
+#### 2.2 模型
+
+**方式 A:在线自动下载**
+
+```bash
+hf download Qwen/Qwen3-4B-Instruct-2507 --local-dir /path/to/Qwen3-4B-Instruct-2507
+```
+
+**方式 B:离线手动下载**
+
+- 从 Hugging Face 仓库下载完整模型权重:[Qwen/Qwen3-4B-Instruct-2507](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507)
+- 将所有下载的模型文件上传到目标目录 `MODEL_DIR=/path/to/Qwen3-4B-Instruct-2507`
+
+#### 2.3 本地检索服务器(可选)
+
+> 仅在使用本地搜索后端而非 Google 搜索时需要。
+
+**(1) 数据**
+
+**方式 A:在线自动下载**
+
+```bash
+# 下载索引和语料库(约 60-70 GB 下载量)
+SAVE_PATH=/path/to/Index
+python /root/vime/examples/search-r1/local_dense_retriever/download.py --save_path $SAVE_PATH
+```
+
+**方式 B:离线手动下载**
+
+- 从 Hugging Face 仓库下载完整索引资源:[PeterJinGo/wiki-18-e5-index](https://huggingface.co/datasets/PeterJinGo/wiki-18-e5-index) 和 [PeterJinGo/wiki-18-corpus](https://huggingface.co/datasets/PeterJinGo/wiki-18-corpus)
+- 将所有下载的文件上传到目标目录 `SAVE_PATH=/path/to/Index`
+
+> **注意:**无论采用上述哪种下载方式(方案 A 或方案 B),在数据下载完成后,均须执行以下命令以完成索引分片的合并以及语料库的解压。
+
+```bash
+SAVE_PATH=/path/to/Index
+cat $SAVE_PATH/part_* > $SAVE_PATH/e5_Flat.index
+gzip -d $SAVE_PATH/wiki-18.jsonl.gz
+```
+
+**(2) 模型**
+
+**方式 A:在线自动下载**
+
+```bash
+hf download intfloat/e5-base-v2 --local-dir /path/to/e5-base-v2
+```
+
+**方式 B:离线手动下载**
+
+- 从 Hugging Face 仓库下载完整模型权重:[intfloat/e5-base-v2](https://huggingface.co/intfloat/e5-base-v2)
+- 将所有下载的模型文件上传到目标目录 `MODEL_DIR=/path/to/e5-base-v2`
+
+### 3. 配置搜索后端
+
+`generate_with_search.py` 文件同时支持**本地**搜索和 **Google** 搜索后端。通过 `SEARCH_R1_CONFIGS` 字典进行配置:
+
+```python
+SEARCH_R1_CONFIGS = {
+ # ============== 通用配置 ==============
+ "max_turns": 2,
+ "topk": 3,
+ "search_concurrency": 8,
+
+ # ============== 搜索后端选择 ==============
+ "search_backend": "local", # 选项:"local" 或 "google"
+
+ # ============== 本地搜索配置 ==============
+ # (仅在 search_backend="local" 时使用)
+ "local": {
+ "search_url": "http://127.0.0.1:8000/retrieve", # 本地检索服务器 URL
+ "proxy": None,
+ },
+
+ # ============== Google 搜索配置 ==============
+ # (仅在 search_backend="google" 时使用)
+ "google": {
+ "api_key": "your_api_key_here", # 替换为你的 serper.dev API 密钥
+ "snippet_only": True,
+ "proxy": None,
+ },
+
+ # ============== 对数概率收集 ==============
+ "return_logprob": True, # 设为 True 以收集对数概率(TIS 所需)
+
+ # ============== 奖励模型配置 ==============
+ "format_score": 0.2,
+}
+```
+
+#### 使用本地搜索
+
+- 设置 `"search_backend": "local"`
+- 在 `"local"` 部分配置本地检索服务器 URL
+- 在运行训练脚本之前启动本地搜索服务器
+
+```bash
+# 设置路径
+SAVE_PATH=/path/to/Index
+INDEX_FILE=$SAVE_PATH/e5_Flat.index
+CORPUS_FILE=$SAVE_PATH/wiki-18.jsonl
+RETRIEVER_NAME=e5
+RETRIEVER_PATH=/path/to/e5-base-v2
+
+# 启动检索服务器
+python /root/vime/examples/search-r1/local_dense_retriever/retrieval_server.py \
+ --index_path $INDEX_FILE\
+ --corpus_path $CORPUS_FILE\
+ --topk 3 \
+ --retriever_name $RETRIEVER_NAME \
+ --retriever_model $RETRIEVER_PATH
+```
+
+> **注意:**
+> - 首次启动将下载模型并加载索引,可能需要几分钟
+> - 正常启动时间(不含下载):1-2 分钟
+> - 本地搜索引擎的 Python 进程不会在 Shell 关闭时终止
+> - 重启服务器:使用 `lsof -i :8000` 查找进程 PID,然后终止并重启
+
+#### 使用 Google 搜索
+
+- 设置 `"search_backend": "google"`
+- 在 `"google"` 部分配置你的 serper.dev API 密钥
+- 从 [serper.dev](https://serper.dev/) 获取你的 API 密钥
+
+### 4. 运行训练
+
+```bash
+cd /root/vime
+# 替换模型和数据加载/保存路径
+bash examples/search-r1/run_qwen3_4b_npu.sh
+```
diff --git a/examples/search-r1/generate_with_search.py b/examples/search-r1/generate_with_search.py
new file mode 100644
index 000000000..e2ac4aac2
--- /dev/null
+++ b/examples/search-r1/generate_with_search.py
@@ -0,0 +1,307 @@
+# Adapted from https://github.com/PeterGriffinJin/Search-R1/blob/ceee7b89655ed52f205b9beb98e1190c3eedcfb0/search_r1/llm_agent/generation.py
+# This is a unified version supporting both local search and Google search, with optional log probability collection
+# Adapted for vLLM /inference/v1/generate endpoint (disagg API)
+
+import asyncio
+import re
+
+from qa_em_format import compute_score_em
+
+from vime.rollout.vllm_rollout import GenerateState, _build_inference_sampling_params
+from vime.utils.http_utils import post
+from vime.utils.types import Sample
+
+# Configuration for Search-R1
+SEARCH_R1_CONFIGS = {
+ # ============== General Configuration ==============
+ "max_turns": 2,
+ "topk": 3,
+ "search_concurrency": 8, # TODO: temporarily lowered for CPU-based Faiss retrieval; restore to 256 once NPU-accelerated Faiss is available
+ # ============== Search Backend Selection ==============
+ "search_backend": "local", # Options: "local" or "google"
+ # ============== Local Search Configuration ==============
+ # (Only used when search_backend="local")
+ "local": {
+ "search_url": "http://127.0.0.1:8000/retrieve", # URL of your local retrieval server
+ "proxy": None, # Set to your proxy if needed
+ },
+ # ============== Google Search Configuration ==============
+ # (Only used when search_backend="google")
+ "google": {
+ "api_key": "your_api_key_here", # Replace with your actual API key
+ "snippet_only": True, # Set to True to only return snippets
+ "proxy": None, # Set to your proxy if needed
+ },
+ # ============== Log Probability Collection ==============
+ "return_logprob": True, # Set to True to collect log probabilities for TIS metrics
+ # ============== Reward Model Configuration ==============
+ "format_score": 0.2,
+}
+
+
+SEMAPHORE = asyncio.Semaphore(SEARCH_R1_CONFIGS["search_concurrency"])
+
+
+def _passages2string(retrieval_result):
+ """
+ Convert retrieval results to a formatted string.
+ This function works with both google_search and local_search results.
+ """
+ format_reference = ""
+ for idx, doc_item in enumerate(retrieval_result):
+ content = doc_item["document"]["contents"]
+ title = content.split("\n")[0]
+ text = "\n".join(content.split("\n")[1:])
+ format_reference += f"Doc {idx+1}(Title: {title}) {text}\n"
+
+ return format_reference
+
+
+async def search(query: str) -> str:
+ """
+ Perform search using either local search engine or Google search.
+ The search backend is determined by SEARCH_R1_CONFIGS["search_backend"].
+ """
+ backend = SEARCH_R1_CONFIGS["search_backend"]
+
+ if backend == "local":
+ from local_search_server import local_search
+
+ local_config = SEARCH_R1_CONFIGS["local"]
+ result = await local_search(
+ local_config["search_url"],
+ query,
+ SEARCH_R1_CONFIGS["topk"],
+ proxy=local_config["proxy"],
+ )
+ elif backend == "google":
+ from google_search_server import google_search
+
+ google_config = SEARCH_R1_CONFIGS["google"]
+ result = await google_search(
+ google_config["api_key"],
+ query,
+ SEARCH_R1_CONFIGS["topk"],
+ snippet_only=google_config["snippet_only"],
+ proxy=google_config["proxy"],
+ )
+ else:
+ raise ValueError(f"Unknown search backend: {backend}. " f"Must be either 'local' or 'google'.")
+
+ return _passages2string(result)
+
+
+# IMPORTANT: When we need to collect log probabilities (logp), we CANNOT do any postprocessing
+# on the strings decoded from token_ids. This is because:
+# 1. We don't know how to truncate the corresponding tokens/logp arrays to match the modified string
+# 2. Re-tokenizing the postprocessed string may produce different tokens than what the engine generated,
+# leading to misalignment between tokens and their log probabilities
+# Therefore, postprocess_responses is only used when return_logprob=False.
+def postprocess_responses(resp: str) -> str:
+ """
+ Post-process response to ensure tag completeness.
+ Only used when SEARCH_R1_CONFIGS["return_logprob"] is False.
+ """
+ return (
+ resp.split("")[0] + ""
+ if "" in resp
+ else resp.split("")[0] + "" if "" in resp else resp
+ )
+
+
+def postprocess_predictions(prediction: str):
+ pattern = r"<(search|answer)>(.*?)\1>"
+ match = re.search(pattern, prediction, re.DOTALL)
+ if match:
+ content = match.group(2).strip() # Return only the content inside the tags
+ action = match.group(1)
+ else:
+ content = ""
+ action = None
+
+ return action, content
+
+
+async def execute_predictions(prediction: str) -> tuple[str, bool]:
+ action, content = postprocess_predictions(prediction)
+
+ if action == "search":
+ search_query = content
+ async with SEMAPHORE:
+ search_results = await search(search_query)
+ next_obs = f"\n\n{search_results.strip()}\n\n"
+ done = False
+ elif action == "answer":
+ next_obs = ""
+ done = True
+ else:
+ next_obs = "\nMy previous action is invalid. \
+If I want to search, I should put the query between and . \
+If I want to give the final answer, I should put the answer between and . Let me try again.\n"
+ done = False
+
+ return next_obs, done
+
+
+async def generate(args, sample: Sample, sampling_params) -> Sample:
+ assert not args.partial_rollout, "Partial rollout is not supported for this function at the moment."
+
+ state = GenerateState(args)
+
+ router_ip = args.vllm_router_ip
+ router_port = args.vllm_router_port
+ url = f"http://{router_ip}:{router_port}/inference/v1/generate"
+
+ # Handle partial rollout samples: continue generation from existing response
+ prompt_text = sample.prompt
+ prompt_tokens_ids = state.tokenizer(prompt_text, add_special_tokens=False)["input_ids"]
+ sample.tokens = list(prompt_tokens_ids)
+ sample.loss_mask = []
+ response = ""
+ response_token_ids = []
+ loss_mask = []
+ rollout_log_probs = [] if SEARCH_R1_CONFIGS["return_logprob"] else None
+ sample.rollout_top_p_token_ids = None
+ sample.rollout_top_p_token_offsets = None
+
+ # BUGFIX: make the inference engine STOP at the tool/answer boundary.
+ # Without a stop, the engine keeps emitting tokens after /
+ # (junk, even fabricated new "Question:"s). The example only trimmed that junk
+ # via postprocess_responses when return_logprob=False; with return_logprob=True
+ # (TIS) trimming is disabled to keep token/logp aligned, so the junk stayed in
+ # the trajectory and got trained on (loss_mask=1) AND broke is_valid_sequence
+ # (trailing content after -> format invalid -> lower reward).
+ # Stopping at the tag avoids all of that and keeps token/logp aligned natively.
+ _stop_tags = ["", ""]
+ _existing_stop = sampling_params.get("stop") or []
+ if isinstance(_existing_stop, str):
+ _existing_stop = [_existing_stop]
+ sampling_params = {**sampling_params, "stop": list(dict.fromkeys([*_existing_stop, *_stop_tags]))}
+
+ # Build vLLM-style sampling params (maps max_new_tokens -> max_tokens, adds logprobs, etc.)
+ inference_sampling_params = _build_inference_sampling_params(sampling_params)
+
+ for _turn_idx in range(SEARCH_R1_CONFIGS["max_turns"]):
+ # vLLM /inference/v1/generate requires token_ids instead of text.
+ # For multi-turn, re-tokenize the full context each time.
+ full_text = prompt_text + response
+ full_token_ids = state.tokenizer(full_text, add_special_tokens=False)["input_ids"]
+
+ payload = {
+ "token_ids": full_token_ids,
+ "sampling_params": inference_sampling_params,
+ }
+ if hasattr(args, "hf_checkpoint"):
+ payload["model"] = args.hf_checkpoint
+
+ output = await post(url, payload)
+
+ # Parse vLLM GenerateResponse: {"choices": [{"token_ids": [...], "logprobs": ..., "finish_reason": "stop"}]}
+ choice = output["choices"][0]
+ finish_reason = choice.get("finish_reason", "stop")
+
+ # abort
+ if finish_reason == "abort":
+ sample.status = Sample.Status.ABORTED
+ return sample
+
+ # Extract token IDs from vLLM response
+ cur_response_token_ids = choice.get("token_ids") or []
+
+ # Decode text from token_ids
+ skip_sp = inference_sampling_params.get("skip_special_tokens")
+ skip_decode = True if skip_sp is None else bool(skip_sp)
+ cur_response = (
+ state.tokenizer.decode(cur_response_token_ids, skip_special_tokens=skip_decode)
+ if cur_response_token_ids
+ else ""
+ )
+
+ # Extract log probs if enabled
+ if SEARCH_R1_CONFIGS["return_logprob"]:
+ cur_response_log_probs: list[float] = []
+ lp = choice.get("logprobs")
+ if isinstance(lp, dict):
+ content_items = lp.get("content") or []
+ cur_response_log_probs = [
+ float(item.get("logprob", 0.0)) if isinstance(item, dict) else 0.0 for item in content_items
+ ]
+ if not cur_response_log_probs:
+ cur_response_log_probs = [0.0] * len(cur_response_token_ids)
+ else:
+ # When not collecting log probs, we can safely postprocess the response
+ cur_response = postprocess_responses(cur_response)
+ # Re-tokenize after postprocessing
+ cur_response_token_ids = state.tokenizer(cur_response, add_special_tokens=False)["input_ids"]
+ cur_response_log_probs = None
+
+ response += cur_response
+ response_token_ids += cur_response_token_ids
+ loss_mask += [1] * len(cur_response_token_ids)
+
+ # Add log probs if enabled
+ if SEARCH_R1_CONFIGS["return_logprob"]:
+ rollout_log_probs += cur_response_log_probs
+
+ if finish_reason == "length":
+ break
+
+ next_obs, done = await execute_predictions(cur_response)
+ if done:
+ break
+
+ assert next_obs != "", "Next observation should not be empty."
+ obs_tokens_ids = state.tokenizer(next_obs, add_special_tokens=False)["input_ids"]
+ response += next_obs
+ response_token_ids += obs_tokens_ids
+ loss_mask += [0] * len(obs_tokens_ids)
+
+ # Add dummy log probs for observation tokens if enabled (they won't be used due to loss_mask=0)
+ if SEARCH_R1_CONFIGS["return_logprob"]:
+ rollout_log_probs += [0.0] * len(obs_tokens_ids)
+
+ # Verify alignment when collecting log probs
+ assert len(response_token_ids) == len(
+ rollout_log_probs
+ ), f"Token/logp length mismatch: {len(response_token_ids)} tokens vs {len(rollout_log_probs)} logps"
+
+ # Store statistics for wandb logging
+ sample.tokens = prompt_tokens_ids + response_token_ids
+ sample.response_length = len(response_token_ids)
+ sample.response = response
+ sample.loss_mask = loss_mask
+ sample.prompt = prompt_text
+
+ # Store log probs if enabled
+ if SEARCH_R1_CONFIGS["return_logprob"]:
+ sample.rollout_log_probs = rollout_log_probs if rollout_log_probs else None
+
+ # vLLM finish_reason is a string: "stop", "length", or "abort"
+ match finish_reason:
+ case "length":
+ sample.status = Sample.Status.TRUNCATED
+ case "abort":
+ sample.status = Sample.Status.ABORTED
+ case "stop":
+ sample.status = Sample.Status.COMPLETED
+
+ return sample
+
+
+async def reward_func(args, sample, **kwargs):
+ """The reward function for retrieval-based question answering.
+
+ Args:
+ args: the arguments
+ sample: the sample to evaluate
+ """
+ if not isinstance(sample, Sample):
+ raise TypeError("Sample must be an instance of Sample class.")
+
+ score = compute_score_em(
+ solution_str=sample.prompt + sample.response,
+ ground_truth=sample.label["ground_truth"],
+ format_score=SEARCH_R1_CONFIGS["format_score"],
+ )
+
+ return score
diff --git a/examples/search-r1/google_search_server.py b/examples/search-r1/google_search_server.py
new file mode 100644
index 000000000..394315ddb
--- /dev/null
+++ b/examples/search-r1/google_search_server.py
@@ -0,0 +1,149 @@
+import asyncio
+import os
+import random
+import re
+
+import aiohttp
+import chardet
+
+
+# --- Utilities ---
+def parse_snippet(snippet: str) -> list[str]:
+ segments = snippet.split("...")
+ return [s.strip() for s in segments if len(s.strip().split()) > 5]
+
+
+def sanitize_search_query(query: str) -> str:
+ # Remove or replace special characters that might cause issues.
+ # This is a basic example; you might need to add more characters or patterns.
+ sanitized_query = re.sub(r"[^\w\s]", " ", query) # Replace non-alphanumeric and non-whitespace with spaces.
+ sanitized_query = re.sub(
+ r"[\t\r\f\v\n]", " ", sanitized_query
+ ) # replace tab, return, formfeed, vertical tab with spaces.
+ sanitized_query = re.sub(
+ r"\s+", " ", sanitized_query
+ ).strip() # remove duplicate spaces, and trailing/leading spaces.
+
+ return sanitized_query
+
+
+def filter_links(search_results: list[dict]) -> list[str]:
+ links = []
+ for result in search_results:
+ for item in result.get("items", []):
+ if "mime" in item:
+ continue
+ ext = os.path.splitext(item["link"])[1]
+ if ext in ["", ".html", ".htm", ".shtml"]:
+ links.append(item["link"])
+ return links
+
+
+async def fetch(session: aiohttp.ClientSession, url: str, semaphore: asyncio.Semaphore) -> str:
+ if url == "":
+ return ""
+ user_agents = [
+ "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P)...",
+ "Mozilla/5.0 AppleWebKit/537.36...",
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +https://www.google.com/bot.html)",
+ ]
+ headers = {"User-Agent": random.choice(user_agents)}
+
+ async with semaphore:
+ try:
+ async with session.get(url, headers=headers) as response:
+ raw = await response.read()
+ detected = chardet.detect(raw)
+ encoding = detected["encoding"] or "utf-8"
+ return raw.decode(encoding, errors="ignore")
+ except (aiohttp.ClientError, asyncio.TimeoutError):
+ return ""
+
+
+async def fetch_all(urls: list[str], limit: int = 8) -> list[str]:
+ semaphore = asyncio.Semaphore(limit)
+ timeout = aiohttp.ClientTimeout(total=5)
+ connector = aiohttp.TCPConnector(limit_per_host=limit, force_close=True)
+
+ async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
+ tasks = [fetch(session, url, semaphore) for url in urls]
+ return await asyncio.gather(*tasks)
+
+
+def collect_context(snippet: str, doc: str) -> str:
+ snippets = parse_snippet(snippet)
+ ctx_paras = []
+
+ for s in snippets:
+ pos = doc.replace("\n", " ").find(s)
+ if pos == -1:
+ continue
+ sta = pos
+ while sta > 0 and doc[sta] != "\n":
+ sta -= 1
+ end = pos + len(s)
+ while end < len(doc) and doc[end] != "\n":
+ end += 1
+ para = doc[sta:end].strip()
+ if para not in ctx_paras:
+ ctx_paras.append(para)
+
+ return "\n".join(ctx_paras)
+
+
+async def google_search(api_key, query, top_k=5, timeout: int = 60, proxy=None, snippet_only=False) -> list[dict]:
+ timeout_obj = aiohttp.ClientTimeout(total=timeout)
+ session_kwargs = {}
+ if proxy:
+ session_kwargs["proxy"] = proxy
+ async with aiohttp.ClientSession(**session_kwargs) as session:
+ async with session.post(
+ "https://google.serper.dev/search",
+ json={
+ "q": query,
+ "num": top_k,
+ "gl": "us",
+ "hl": "en",
+ },
+ headers={
+ "Content-Type": "application/json",
+ "X-API-KEY": api_key,
+ },
+ timeout=timeout_obj,
+ ) as resp:
+ resp.raise_for_status()
+ response = await resp.json()
+ items = response.get("organic", [])
+
+ contexts = []
+ if snippet_only:
+ for item in items:
+ title = item.get("title", "")
+ context = " ".join(parse_snippet(item.get("snippet", "")))
+ if title != "" or context != "":
+ title = "No title." if not title else title
+ context = "No snippet available." if not context else context
+ contexts.append(
+ {
+ "document": {"contents": f'"{title}"\n{context}'},
+ }
+ )
+ else:
+ links = [item.get("link", "") for item in items]
+ web_contents = await fetch_all(links)
+ contexts = []
+ for i, item in enumerate(items):
+ title = item.get("title", "")
+ snippet = item.get("snippet", "")
+
+ context = collect_context(snippet, web_contents[i])
+ if title != "" or context != "":
+ title = "No title." if not title else title
+ context = "No snippet available." if not context else context
+ contexts.append(
+ {
+ "document": {"contents": f'"{title}"\n{context}'},
+ }
+ )
+
+ return contexts
diff --git a/examples/search-r1/local_dense_retriever/download.py b/examples/search-r1/local_dense_retriever/download.py
new file mode 100644
index 000000000..6fe554936
--- /dev/null
+++ b/examples/search-r1/local_dense_retriever/download.py
@@ -0,0 +1,44 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2023-2024 SGLang Team
+# Copyright 2025 Search-R1 Contributors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# Adapted from https://github.com/PeterGriffinJin/Search-R1/blob/main/scripts/download.py
+
+
+import argparse
+
+from huggingface_hub import hf_hub_download
+
+parser = argparse.ArgumentParser(description="Download files from a Hugging Face dataset repository.")
+parser.add_argument("--repo_id", type=str, default="PeterJinGo/wiki-18-e5-index", help="Hugging Face repository ID")
+parser.add_argument("--save_path", type=str, required=True, help="Local directory to save files")
+
+args = parser.parse_args()
+
+repo_id = "PeterJinGo/wiki-18-e5-index"
+for file in ["part_aa", "part_ab"]:
+ hf_hub_download(
+ repo_id=repo_id,
+ filename=file, # e.g., "e5_Flat.index"
+ repo_type="dataset",
+ local_dir=args.save_path,
+ )
+
+repo_id = "PeterJinGo/wiki-18-corpus"
+hf_hub_download(
+ repo_id=repo_id,
+ filename="wiki-18.jsonl.gz",
+ repo_type="dataset",
+ local_dir=args.save_path,
+)
diff --git a/examples/search-r1/local_dense_retriever/retrieval_server.py b/examples/search-r1/local_dense_retriever/retrieval_server.py
new file mode 100644
index 000000000..5604bba29
--- /dev/null
+++ b/examples/search-r1/local_dense_retriever/retrieval_server.py
@@ -0,0 +1,435 @@
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+# Copyright 2023-2024 SGLang Team
+# Copyright 2025 Search-R1 Contributors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# Adapted from https://github.com/PeterGriffinJin/Search-R1/blob/main/search_r1/search/retrieval_server.py
+
+import argparse
+import json
+import warnings
+
+import datasets
+import faiss
+import numpy as np
+import torch
+import uvicorn
+from fastapi import FastAPI
+from pydantic import BaseModel
+from tqdm import tqdm
+from transformers import AutoModel, AutoTokenizer
+
+
+def load_corpus(corpus_path: str):
+ corpus = datasets.load_dataset("json", data_files=corpus_path, split="train", num_proc=4)
+ return corpus
+
+
+def load_docs(corpus, doc_idxs):
+ results = [corpus[int(idx)] for idx in doc_idxs]
+ return results
+
+
+def load_model(model_path: str, use_fp16: bool = False, device: torch.device | None = None):
+ """Load transformer model and tokenizer."""
+ if device is None:
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+ model = AutoModel.from_pretrained(model_path, trust_remote_code=True)
+ model.eval()
+ model.to(device)
+
+ if use_fp16 and device.type == "cuda":
+ model = model.half()
+ elif use_fp16 and device.type != "cuda":
+ warnings.warn("FP16 requested but CUDA is not available; running in FP32 on CPU.", stacklevel=2)
+
+ tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=True, trust_remote_code=True)
+ return model, tokenizer
+
+
+def pooling(pooler_output, last_hidden_state, attention_mask=None, pooling_method="mean"):
+ if pooling_method == "mean":
+ last_hidden = last_hidden_state.masked_fill(~attention_mask[..., None].bool(), 0.0)
+ return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]
+ elif pooling_method == "cls":
+ return last_hidden_state[:, 0]
+ elif pooling_method == "pooler":
+ return pooler_output
+ else:
+ raise NotImplementedError("Pooling method not implemented!")
+
+
+class Encoder:
+ def __init__(self, model_name, model_path, pooling_method, max_length, use_fp16):
+ self.model_name = model_name
+ self.model_path = model_path
+ self.pooling_method = pooling_method
+ self.max_length = max_length
+ self.use_fp16 = use_fp16
+ # Set the device
+ if torch.cuda.is_available():
+ self.device = torch.device("cuda")
+ else:
+ self.device = torch.device("cpu")
+ self.model, self.tokenizer = load_model(model_path=model_path, use_fp16=use_fp16, device=self.device)
+ self.model.eval()
+
+ @torch.no_grad()
+ def encode(self, query_list: list[str], is_query=True) -> np.ndarray:
+ # processing query for different encoders
+ if isinstance(query_list, str):
+ query_list = [query_list]
+
+ if "e5" in self.model_name.lower():
+ if is_query:
+ query_list = [f"query: {query}" for query in query_list]
+ else:
+ query_list = [f"passage: {query}" for query in query_list]
+
+ if "bge" in self.model_name.lower():
+ if is_query:
+ query_list = [
+ f"Represent this sentence for searching relevant passages: {query}" for query in query_list
+ ]
+
+ inputs = self.tokenizer(
+ query_list, max_length=self.max_length, padding=True, truncation=True, return_tensors="pt"
+ )
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
+
+ if "T5" in type(self.model).__name__:
+ # T5-based retrieval model
+ decoder_input_ids = torch.zeros((inputs["input_ids"].shape[0], 1), dtype=torch.long).to(
+ inputs["input_ids"].device
+ )
+ output = self.model(**inputs, decoder_input_ids=decoder_input_ids, return_dict=True)
+ query_emb = output.last_hidden_state[:, 0, :]
+ else:
+ output = self.model(**inputs, return_dict=True)
+ query_emb = pooling(
+ output.pooler_output, output.last_hidden_state, inputs["attention_mask"], self.pooling_method
+ )
+ if "dpr" not in self.model_name.lower():
+ query_emb = torch.nn.functional.normalize(query_emb, dim=-1)
+
+ query_emb = query_emb.detach().cpu().numpy()
+ query_emb = query_emb.astype(np.float32, order="C")
+
+ del inputs, output
+ if self.device.type == "cuda":
+ torch.cuda.empty_cache()
+
+ return query_emb
+
+
+class BaseRetriever:
+ def __init__(self, config):
+ self.config = config
+ self.retrieval_method = config.retrieval_method
+ self.topk = config.retrieval_topk
+
+ self.index_path = config.index_path
+ self.corpus_path = config.corpus_path
+
+ def _search(self, query: str, num: int, return_score: bool):
+ raise NotImplementedError
+
+ def _batch_search(self, query_list: list[str], num: int, return_score: bool):
+ raise NotImplementedError
+
+ def search(self, query: str, num: int = None, return_score: bool = False):
+ return self._search(query, num, return_score)
+
+ def batch_search(self, query_list: list[str], num: int = None, return_score: bool = False):
+ return self._batch_search(query_list, num, return_score)
+
+
+class BM25Retriever(BaseRetriever):
+ def __init__(self, config):
+ super().__init__(config)
+ from pyserini.search.lucene import LuceneSearcher
+
+ self.searcher = LuceneSearcher(self.index_path)
+ self.contain_doc = self._check_contain_doc()
+ if not self.contain_doc:
+ self.corpus = load_corpus(self.corpus_path)
+ self.max_process_num = 8
+
+ def _check_contain_doc(self):
+ return self.searcher.doc(0).raw() is not None
+
+ def _search(self, query: str, num: int = None, return_score: bool = False):
+ if num is None:
+ num = self.topk
+ hits = self.searcher.search(query, num)
+ if len(hits) < 1:
+ if return_score:
+ return [], []
+ else:
+ return []
+ scores = [hit.score for hit in hits]
+ if len(hits) < num:
+ warnings.warn("Not enough documents retrieved!", stacklevel=2)
+ else:
+ hits = hits[:num]
+
+ if self.contain_doc:
+ all_contents = [json.loads(self.searcher.doc(hit.docid).raw())["contents"] for hit in hits]
+ results = [
+ {
+ "title": content.split("\n")[0].strip('"'),
+ "text": "\n".join(content.split("\n")[1:]),
+ "contents": content,
+ }
+ for content in all_contents
+ ]
+ else:
+ results = load_docs(self.corpus, [hit.docid for hit in hits])
+
+ if return_score:
+ return results, scores
+ else:
+ return results
+
+ def _batch_search(self, query_list: list[str], num: int = None, return_score: bool = False):
+ results = []
+ scores = []
+ for query in query_list:
+ item_result, item_score = self._search(query, num, True)
+ results.append(item_result)
+ scores.append(item_score)
+ if return_score:
+ return results, scores
+ else:
+ return results
+
+
+class DenseRetriever(BaseRetriever):
+ def __init__(self, config):
+ super().__init__(config)
+ self.index = faiss.read_index(self.index_path)
+ if config.faiss_gpu:
+ co = faiss.GpuMultipleClonerOptions()
+ co.useFloat16 = True
+ co.shard = True
+ self.index = faiss.index_cpu_to_all_gpus(self.index, co=co)
+
+ self.corpus = load_corpus(self.corpus_path)
+ self.encoder = Encoder(
+ model_name=self.retrieval_method,
+ model_path=config.retrieval_model_path,
+ pooling_method=config.retrieval_pooling_method,
+ max_length=config.retrieval_query_max_length,
+ use_fp16=config.retrieval_use_fp16,
+ )
+ self.topk = config.retrieval_topk
+ self.batch_size = config.retrieval_batch_size
+
+ def _search(self, query: str, num: int = None, return_score: bool = False):
+ if num is None:
+ num = self.topk
+ query_emb = self.encoder.encode(query)
+ scores, idxs = self.index.search(query_emb, k=num)
+ idxs = idxs[0]
+ scores = scores[0]
+ results = load_docs(self.corpus, idxs)
+ if return_score:
+ return results, scores.tolist()
+ else:
+ return results
+
+ def _batch_search(self, query_list: list[str], num: int = None, return_score: bool = False):
+ if isinstance(query_list, str):
+ query_list = [query_list]
+ if num is None:
+ num = self.topk
+
+ results = []
+ scores = []
+ for start_idx in tqdm(range(0, len(query_list), self.batch_size), desc="Retrieval process: "):
+ query_batch = query_list[start_idx : start_idx + self.batch_size]
+ batch_emb = self.encoder.encode(query_batch)
+ batch_scores, batch_idxs = self.index.search(batch_emb, k=num)
+ batch_scores = batch_scores.tolist()
+ batch_idxs = batch_idxs.tolist()
+
+ # load_docs is not vectorized, but is a python list approach
+ flat_idxs = sum(batch_idxs, [])
+ batch_results = load_docs(self.corpus, flat_idxs)
+ # chunk them back
+ batch_results = [batch_results[i * num : (i + 1) * num] for i in range(len(batch_idxs))]
+
+ results.extend(batch_results)
+ scores.extend(batch_scores)
+
+ del batch_emb, batch_scores, batch_idxs, query_batch, flat_idxs, batch_results
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+
+ if return_score:
+ return results, scores
+ else:
+ return results
+
+
+def get_retriever(config):
+ if config.retrieval_method == "bm25":
+ return BM25Retriever(config)
+ else:
+ return DenseRetriever(config)
+
+
+#####################################
+# FastAPI server below
+#####################################
+
+
+class Config:
+ """
+ Minimal config class (simulating your argparse)
+ Replace this with your real arguments or load them dynamically.
+ """
+
+ def __init__(
+ self,
+ retrieval_method: str = "bm25",
+ retrieval_topk: int = 10,
+ index_path: str = "./index/bm25",
+ corpus_path: str = "./data/corpus.jsonl",
+ dataset_path: str = "./data",
+ data_split: str = "train",
+ faiss_gpu: bool = True,
+ retrieval_model_path: str = "./model",
+ retrieval_pooling_method: str = "mean",
+ retrieval_query_max_length: int = 256,
+ retrieval_use_fp16: bool = False,
+ retrieval_batch_size: int = 128,
+ ):
+ self.retrieval_method = retrieval_method
+ self.retrieval_topk = retrieval_topk
+ self.index_path = index_path
+ self.corpus_path = corpus_path
+ self.dataset_path = dataset_path
+ self.data_split = data_split
+ self.faiss_gpu = faiss_gpu
+ self.retrieval_model_path = retrieval_model_path
+ self.retrieval_pooling_method = retrieval_pooling_method
+ self.retrieval_query_max_length = retrieval_query_max_length
+ self.retrieval_use_fp16 = retrieval_use_fp16
+ self.retrieval_batch_size = retrieval_batch_size
+
+
+class QueryRequest(BaseModel):
+ queries: list[str]
+ topk: int | None = None
+ return_scores: bool = False
+
+
+app = FastAPI()
+
+
+@app.post("/retrieve")
+def retrieve_endpoint(request: QueryRequest):
+ """
+ Endpoint that accepts queries and performs retrieval.
+
+ Input format:
+ {
+ "queries": ["What is Python?", "Tell me about neural networks."],
+ "topk": 3,
+ "return_scores": true
+ }
+
+ Output format (when return_scores=True,similarity scores are returned):
+ {
+ "result": [
+ [ # Results for each query
+ {
+ {"document": doc, "score": score}
+ },
+ # ... more documents
+ ],
+ # ... results for other queries
+ ]
+ }
+ """
+ if not request.topk:
+ request.topk = config.retrieval_topk # fallback to default
+
+ # Perform batch retrieval
+ tmp = retriever.batch_search(query_list=request.queries, num=request.topk, return_score=request.return_scores)
+
+ scores = []
+ try:
+ results, scores = tmp
+ except ValueError:
+ results = tmp
+
+ # Format response
+ resp = []
+ for i, single_result in enumerate(results):
+ if scores:
+ # If scores are returned, combine them with results
+ combined = []
+ for doc, score in zip(single_result, scores[i], strict=True):
+ combined.append({"document": doc, "score": score})
+ resp.append(combined)
+ else:
+ resp.append(single_result)
+ return {"result": resp}
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Launch the local faiss retriever.")
+ parser.add_argument(
+ "--index_path",
+ type=str,
+ default="./e5_Flat.index",
+ help="Corpus indexing file.",
+ )
+ parser.add_argument(
+ "--corpus_path",
+ type=str,
+ default="./wiki-18.jsonl",
+ help="Local corpus file.",
+ )
+ parser.add_argument("--topk", type=int, default=3, help="Number of retrieved passages for one query.")
+ parser.add_argument("--retriever_name", type=str, default="e5", help="Name of the retriever model.")
+ parser.add_argument(
+ "--retriever_model", type=str, default="intfloat/e5-base-v2", help="Path of the retriever model."
+ )
+ parser.add_argument("--faiss_gpu", action="store_true", help="Use GPU for computation")
+
+ args = parser.parse_args()
+
+ # 1) Build a config (could also parse from arguments).
+ # In real usage, you'd parse your CLI arguments or environment variables.
+ config = Config(
+ retrieval_method=args.retriever_name, # or "dense"
+ index_path=args.index_path,
+ corpus_path=args.corpus_path,
+ retrieval_topk=args.topk,
+ faiss_gpu=args.faiss_gpu,
+ retrieval_model_path=args.retriever_model,
+ retrieval_pooling_method="mean",
+ retrieval_query_max_length=256,
+ retrieval_use_fp16=True,
+ retrieval_batch_size=512,
+ )
+
+ # 2) Instantiate a global retriever so it is loaded once and reused.
+ retriever = get_retriever(config)
+
+ # 3) Launch the server. By default, it listens on http://127.0.0.1:8000
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/examples/search-r1/local_search_server.py b/examples/search-r1/local_search_server.py
new file mode 100644
index 000000000..4771556a0
--- /dev/null
+++ b/examples/search-r1/local_search_server.py
@@ -0,0 +1,97 @@
+"""
+Local Search Server for Search-R1
+
+This module provides a local search engine interface that mimics the google_search_server.py API.
+It sends requests to a local retrieval server (e.g., running retrieval_server.py from Search-R1)
+and formats the results to match the expected output format.
+
+Usage:
+ In your generate_with_search.py, replace:
+ from google_search_server import google_search
+ with:
+ from local_search_server import local_search as google_search
+
+ And update SEARCH_R1_CONFIGS:
+ SEARCH_R1_CONFIGS = {
+ "search_url": "http://127.0.0.1:8000/retrieve", # URL of local retrieval server
+ "topk": 3,
+ ...
+ }
+"""
+
+import aiohttp
+
+
+async def local_search(
+ search_url: str,
+ query: str,
+ top_k: int = 5,
+ timeout: int = 1200,
+ proxy: str | None = None,
+) -> list[dict]:
+ """
+ Call local search engine server and format results to match google_search_server.py output.
+
+ This function provides the same interface as google_search() from google_search_server.py,
+ making it a drop-in replacement. The only difference is that instead of using an API key,
+ it uses a search_url parameter.
+
+ Args:
+ search_url: URL of the local retrieval server (e.g., "http://127.0.0.1:8000/retrieve")
+ query: Search query string
+ top_k: Number of results to retrieve
+ timeout: Request timeout in seconds (default: 60)
+ proxy: Proxy URL if needed (not used for local retrieval, kept for API compatibility)
+ snippet_only: If True, only return snippet (kept for API compatibility with google_search)
+
+ Returns:
+ List of dictionaries with format: [{"document": {"contents": '""\n'}}]
+ This matches the output format of google_search() from google_search_server.py
+ """
+ # Prepare request payload for local retrieval server
+ payload = {
+ "queries": [query],
+ "topk": top_k,
+ "return_scores": False, # We don't need scores for compatibility with google_search_server
+ }
+
+ # Send async request to local retrieval server
+ timeout_obj = aiohttp.ClientTimeout(total=timeout)
+ session_kwargs = {}
+ # Note: proxy parameter is kept for API compatibility but typically not needed for local server
+ if proxy:
+ session_kwargs["proxy"] = proxy
+
+ try:
+ async with aiohttp.ClientSession(**session_kwargs) as session:
+ async with session.post(search_url, json=payload, timeout=timeout_obj) as resp:
+ resp.raise_for_status()
+ result = await resp.json()
+ except Exception as e:
+ print(f"Error calling local search engine at {search_url}: {repr(e)}")
+ return []
+
+ # Parse retrieval results
+ # Format from retrieval_server.py: {"result": [[{"document": {"id": "...", "contents": "..."}}]]}
+ retrieval_results = result.get("result", [[]])[0]
+ # Format to match google_search_server.py output
+ # Google format: [{"document": {"contents": '""\n'}}]
+ contexts = []
+
+ for item in retrieval_results:
+ # Extract contents from retrieval result
+ # retrieval_server returns: {"document": {"id": "...", "contents": '"Title"\nText...'}}
+ if isinstance(item, dict):
+ # Access the document dict first, then get contents
+ content = item.get("contents", "")
+
+ if content:
+ # The contents are already in the correct format: '"Title"\nText content...'
+ # Just pass through as-is to match google_search format
+ contexts.append({"document": {"contents": content}})
+ else:
+ # Empty content case - provide default values
+ contexts.append({"document": {"contents": '"No title."\nNo snippet available.'}})
+
+ # If no results found, return empty list (consistent with google_search_server.py)
+ return contexts
diff --git a/examples/search-r1/qa_em_format.py b/examples/search-r1/qa_em_format.py
new file mode 100644
index 000000000..afd9049b9
--- /dev/null
+++ b/examples/search-r1/qa_em_format.py
@@ -0,0 +1,208 @@
+# Adapt from https://github.com/PeterGriffinJin/Search-R1/blob/ceee7b89655ed52f205b9beb98e1190c3eedcfb0/verl/utils/reward_score/qa_em_format.py
+# Copyright 2024 Bytedance Ltd. and/or its affiliates
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import random
+import re
+import string
+
+
+def normalize_answer(s):
+ def remove_articles(text):
+ return re.sub(r"\b(a|an|the)\b", " ", text)
+
+ def white_space_fix(text):
+ return " ".join(text.split())
+
+ def remove_punc(text):
+ exclude = set(string.punctuation)
+ return "".join(ch for ch in text if ch not in exclude)
+
+ def lower(text):
+ return text.lower()
+
+ return white_space_fix(remove_articles(remove_punc(lower(s))))
+
+
+def em_check(prediction, golden_answers):
+ if isinstance(golden_answers, str):
+ golden_answers = [golden_answers]
+ normalized_prediction = normalize_answer(prediction)
+ score = 0
+ for golden_answer in golden_answers:
+ golden_answer = normalize_answer(golden_answer)
+ if golden_answer == normalized_prediction:
+ score = 1
+ break
+ return score
+
+
+def is_valid_sequence(text):
+ # Find the position of "<|im_start|>assistant" with potential whitespace
+ assistant_pattern = r"<\|im_start\|>assistant\s*"
+ assistant_match = re.search(assistant_pattern, text)
+
+ if not assistant_match:
+ return False, "Missing assistant marker"
+
+ # Extract the content after the assistant marker
+ start_pos = assistant_match.end()
+ content = text[start_pos:]
+
+ # Check for balanced tags
+ tags_to_check = ["think", "search", "information", "answer"]
+ for tag in tags_to_check:
+ opening_count = len(re.findall(f"<{tag}>", content))
+ closing_count = len(re.findall(f"{tag}>", content))
+ if opening_count != closing_count:
+ return False, f"Mismatch in {tag} tags: {opening_count} opening vs {closing_count} closing tags"
+
+ # Now check for proper sequence pattern and no extraneous content
+
+ # 1. First split the content by any tags we recognize
+ split_pattern = r"(?(?:think|search|information|answer)>)"
+ parts = re.split(split_pattern, content)
+
+ # 2. Keep track of the current position in the expected sequence
+ state = "start" # start -> think -> search -> information -> think -> ... -> answer -> end
+
+ # 3. Check each part
+ for _i, part in enumerate(parts):
+ # Skip empty parts
+ if not part.strip():
+ continue
+
+ # Check if this is a tag
+ if re.match(r"?(?:think|search|information|answer)>", part):
+ # This is a tag, check if it's valid in the current state
+ if part == "" and state in ["start", "information"]:
+ state = "in_think"
+ elif part == "" and state == "in_think":
+ state = "after_think"
+ elif part == "" and state == "after_think":
+ state = "in_search"
+ elif part == "" and state == "in_search":
+ state = "after_search"
+ elif part == "" and state == "after_search":
+ state = "in_information"
+ elif part == "" and state == "in_information":
+ state = "information"
+ elif part == "" and state == "after_think":
+ state = "in_answer"
+ elif part == "" and state == "in_answer":
+ state = "end"
+ else:
+ return False, f"Unexpected tag {part} in state {state}"
+ else:
+ # This is content, check if it's valid in the current state
+ if state in ["in_think", "in_search", "in_information", "in_answer"]:
+ # Content is allowed inside tags
+ pass
+ elif state in ["start", "after_think", "after_search", "information"]:
+ # Only whitespace is allowed between tags
+ if part.strip():
+ return False, f"Unexpected content '{part.strip()}' between tags (state: {state})"
+ else:
+ return False, f"Unexpected content in state {state}"
+
+ # Check final state
+ if state != "end":
+ return False, f"Incomplete sequence, ended in state {state}"
+
+ return True, "Valid sequence format"
+
+
+def extract_solution(solution_str):
+ """Extract the equation from the solution string."""
+
+ answer_pattern = r"(.*?)"
+ match = re.finditer(answer_pattern, solution_str, re.DOTALL)
+ matches = list(match)
+
+ # If there are 0 or exactly 1 matches, return None
+ if len(matches) <= 1:
+ return None
+
+ # If there are 2 or more matches, return the last one
+ return matches[-1].group(1).strip()
+
+
+def extract_information_blocks(text: str) -> list[str]:
+ pattern = r"(.*?)"
+ matches = re.findall(pattern, text, re.DOTALL)
+ return [match.strip() for match in matches]
+
+
+def is_retrieval_correct(text: str, golden_answers: list[str]) -> bool:
+ seqs = extract_information_blocks(text)
+ for seq in seqs:
+ for golden_answer in golden_answers:
+ if normalize_answer(golden_answer) in normalize_answer(seq):
+ return True
+ return False
+
+
+def compute_score_em(
+ solution_str,
+ ground_truth,
+ method="strict",
+ structure_format_score=0,
+ final_format_score=0,
+ retrieval_score=0,
+ format_score=0,
+ score=1.0,
+):
+ """The scoring function for exact match (EM).
+
+ Args:
+ solution_str: the solution text
+ ground_truth: the ground truth
+ method: the method to extract the solution, choices are 'strict' and 'flexible'
+ format_score: the score for the format
+ score: the score for the correct answer
+ """
+ is_valid_format, _ = is_valid_sequence(solution_str)
+ retrieval_correct = False
+ if is_valid_format:
+ retrieval_correct = is_retrieval_correct(solution_str, ground_truth["target"])
+ answer = extract_solution(solution_str=solution_str)
+ do_print = random.randint(1, 64) == 1
+
+ if do_print:
+ print("--------------------------------")
+ print(f"Golden answers: {ground_truth['target']}")
+ print(f"Extracted answer: {answer}")
+ print(f"Solution string: {solution_str}")
+
+ if answer is None:
+ if is_valid_format:
+ if retrieval_correct:
+ return structure_format_score + retrieval_score # 0.3
+ else:
+ return structure_format_score # 0.2
+ else:
+ return 0
+ else:
+ if em_check(answer, ground_truth["target"]):
+ if is_valid_format:
+ return score # 1
+ else:
+ return score - structure_format_score # 0.8
+ elif is_valid_format:
+ if retrieval_correct:
+ return structure_format_score + retrieval_score # 0.3
+ else:
+ return structure_format_score # 0.2
+ else:
+ return final_format_score # 0.1
diff --git a/examples/search-r1/run_qwen3_4b_npu.sh b/examples/search-r1/run_qwen3_4b_npu.sh
new file mode 100644
index 000000000..8f2aa6dea
--- /dev/null
+++ b/examples/search-r1/run_qwen3_4b_npu.sh
@@ -0,0 +1,213 @@
+#!/bin/bash
+set -ex
+ulimit -u 65535
+
+# cleanup
+pkill -9 -f "vllm serve" 2>/dev/null || true
+sleep 2
+npu-smi info 2>/dev/null | grep rayWorker | awk '{print $4}' | xargs -r kill -9 2>/dev/null || true
+sleep 3
+
+# Ray isolation: independent temp-dir, ports, and cleanup
+export RAY_TMPDIR=/tmp/ray_vime_npu_search_r1
+export RAY_PORT=6379
+export RAY_DASHBOARD_PORT=8265
+export RAY_AGENT_PORT=52378
+unset RAY_ADDRESS RAY_REDIS_ADDRESS
+
+ray stop --force 2>/dev/null || true
+rm -rf "${RAY_TMPDIR}"
+sleep 2
+
+project_name="vime"
+exp_name="qwen3-4b-search-r1"
+RAY_DATA_HOME=${RAY_DATA_HOME:-"/root/logs"}
+start_time=$(date +"%Y%m%d_%H%M%S")
+LOG_DIR=${LOG_DIR:-"${RAY_DATA_HOME}/${project_name}/${exp_name}"}
+mkdir -p "${LOG_DIR}"
+LOG_FILE="${LOG_DIR}/${start_time}.log"
+
+echo "Experiment Log will be saved to: ${LOG_FILE}"
+VIME_DIR="/root/vime"
+
+# NPU environment
+source /usr/local/Ascend/driver/bin/setenv.bash
+source /usr/local/Ascend/ascend-toolkit/set_env.sh
+source /usr/local/Ascend/nnal/atb/set_env.sh
+export PYTHONPATH="${VIME_DIR}/examples/search-r1:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:${VIME_DIR}:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge:${PYTHONPATH}"
+export PYTHONUNBUFFERED=1
+export PYTORCH_NPU_ALLOC_CONF=expandable_segments:False
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HCCL_CONNECT_TIMEOUT=7200
+export HCCL_DETERMINISTIC=true
+export VLLM_ASCEND_ENABLE_NZ=0
+export ASCEND_COREDUMP_SIGNAL=None
+export ATB_MATMUL_SHUFFLE_K_ENABLE=0
+export ATB_LLM_LCOC_ENABLE=0
+export TASK_QUEUE_ENABLE=1
+export RAY_DISABLE_SIGINT_OVERRIDE=1
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export LD_LIBRARY_PATH=/usr/local/Ascend/driver/lib64:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:${LD_LIBRARY_PATH}
+export VLLM_USE_AOT_COMPILE=0
+export VLLM_DISABLE_COMPILE_CACHE=1
+export TRANSFORMERS_VERBOSITY=error
+export RUST_LOG=vllm_router_rs=warn
+
+NUM_NPUS=16
+source "${VIME_DIR}/scripts/models/qwen3-4B-Instruct-2507.sh"
+
+CKPT_ARGS=(
+ --hf-checkpoint /path/to/Qwen3-4B-Instruct-2507
+ --ref-load /path/to/Qwen3-4B-Instruct-2507
+ --load /path/to/Qwen3-4B-Instruct-2507_vime_npu/
+ --save /path/to/Qwen3-4B-Instruct-2507_vime_npu/
+ --save-interval 100
+ --no-load-optim
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data /path/to/nq_hotpotqa_train/train.parquet
+ --input-key prompt
+ --label-key reward_model
+ --apply-chat-template
+ --rollout-shuffle
+ --num-rollout 200
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 512
+ --rollout-temperature 1
+ --global-batch-size 256
+ --balance-data
+)
+
+EVAL_ARGS=(
+ --eval-interval 50
+ --eval-prompt-data nq_test /path/to/nq_hotpotqa_train/test.parquet@[0:500]
+ --n-samples-per-eval-prompt 1
+ --eval-input-key prompt
+ --eval-label-key reward_model
+ --eval-top-p 1
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --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
+
+ --micro-batch-size 1
+ --use-dynamic-batch-size
+ --max-tokens-per-gpu 9216
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --use-kl-loss
+ --kl-loss-coef 0.001
+ --kl-loss-type low_var_kl
+ --entropy-coef 0.00
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+
+ # TIS (Trajectory Importance Sampling)
+ # --use-tis
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 4
+ --vllm-gpu-memory-utilization 0.7
+ --vllm-enable-sleep-mode
+ --vllm-weight-sync-mode native
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+)
+
+CUSTOM_ARGS=(
+ --custom-generate-function-path generate_with_search.generate
+ --custom-rm-path generate_with_search.reward_func
+
+ # TIS (Trajectory Importance Sampling)
+ # --custom-config-path examples/train_infer_mismatch_helper/mis.yaml
+ # --custom-tis-function-path examples.train_infer_mismatch_helper.mis.compute_mis_weights_with_cp
+)
+
+# launch the master node of ray in container
+unset https_proxy http_proxy proxy
+ray start --head \
+ --temp-dir="${RAY_TMPDIR}" \
+ --port="${RAY_PORT}" \
+ --dashboard-port="${RAY_DASHBOARD_PORT}" \
+ --dashboard-agent-listen-port="${RAY_AGENT_PORT}" \
+ --node-ip-address 127.0.0.1 \
+ --num-gpus 0 \
+ --resources "{\"NPU\": $NUM_NPUS}" \
+ --disable-usage-stats \
+ --dashboard-host=0.0.0.0
+
+# Build the runtime environment JSON with proper variable substitution
+RUNTIME_ENV_JSON=$(cat << 'EOF'
+{
+ "env_vars": {
+ "PYTHONPATH": "${VIME_DIR}/examples/search-r1:/root/Megatron-LM:/root/vllm:/root/vllm-ascend:${VIME_DIR}:/root/Megatron-Bridge:/root/mbridge:/root/MegatronAdaptor:/root/TransformerEngineNPU:/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge",
+ "CUDA_DEVICE_MAX_CONNECTIONS": "1",
+ "HCCL_HOST_SOCKET_PORT_RANGE": "60000-60050",
+ "HCCL_NPU_SOCKET_PORT_RANGE": "61000-61050",
+ "HCCL_CONNECT_TIMEOUT": "7200",
+ "PYTORCH_NPU_ALLOC_CONF": "expandable_segments:False",
+ "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1",
+ "VLLM_DISABLE_COMPILE_CACHE": "1",
+ "TRANSFORMERS_VERBOSITY": "error",
+ "RUST_LOG": "vllm_router_rs=warn",
+ "LD_LIBRARY_PATH": "/usr/local/Ascend/driver/lib64:/usr/local/Ascend/driver/lib64/driver:/usr/local/Ascend/driver/lib64/common:/usr/local/Ascend/ascend-toolkit/latest/lib64:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/opskernel:/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/nnengine:/usr/local/Ascend/ascend-toolkit/latest/opp/built-in/op_impl/ai_core/tbe/op_tiling/lib/:/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:/usr/local/Ascend/cann/lib64:/usr/local/Ascend/cann/aarch64-linux/devlib"
+ }
+}
+EOF
+)
+
+ray job submit --address="http://127.0.0.1:${RAY_DASHBOARD_PORT}" \
+ --runtime-env-json="${RUNTIME_ENV_JSON}" \
+ --working-dir="${VIME_DIR}" \
+ -- python3 -u train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 8 \
+ --rollout-num-gpus 8 \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${ROLLOUT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${GRPO_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${EVAL_ARGS[@]} \
+ ${VLLM_ARGS[@]} \
+ ${MISC_ARGS[@]} \
+ ${CUSTOM_ARGS[@]} \
+ 2>&1 | tee "${LOG_FILE}"
diff --git a/examples/tau-bench/run_qwen3_4B_npu.sh b/examples/tau-bench/run_qwen3_4B_npu.sh
new file mode 100644
index 000000000..c5bad23fc
--- /dev/null
+++ b/examples/tau-bench/run_qwen3_4B_npu.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+
+if grep -q $'\r' "$0" 2>/dev/null; then
+ exec bash <(sed 's/\r$//' "$0") "$@"
+fi
+
+# for rerun the task
+pkill -9 vllm 2>/dev/null || true
+pkill -9 VLLM 2>/dev/null || true
+sleep 3
+ray stop --force 2>/dev/null || true
+pkill -9 ray 2>/dev/null || true
+pkill -9 -f 'python3 train.py' 2>/dev/null || true
+sleep 3
+
+set -ex
+
+export PYTHONUNBUFFERED=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+export VIME_VLLM_SERVER_HEALTH_TIMEOUT_SEC=900
+
+unset PYTORCH_CUDA_ALLOC_CONF PYTORCH_ALLOC_CONF
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+source "${SCRIPT_DIR}/../../scripts/models/qwen3-4B-Instruct-2507.sh"
+
+export PYTHONPATH="${SCRIPT_DIR}:/root/Megatron-Bridge/src:/root/Megatron-LM:${PYTHONPATH:-}"
+
+DATA_ROOT="${DATA_ROOT:-/root}"
+TAU_BENCH_ROOT="${TAU_BENCH_ROOT:-/root/tau-bench}"
+
+CKPT_ARGS=(
+ --hf-checkpoint ${DATA_ROOT}/weights/Qwen3-4B-Instruct-2507/
+ --load ${DATA_ROOT}/weights/Qwen3-4B-Instruct-2507/
+ --ref-load ${DATA_ROOT}/weights/Qwen3-4B-Instruct-2507/
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data ${TAU_BENCH_ROOT}/retail_train_tasks.jsonl
+ --input-key index
+ --rollout-shuffle
+ --num-rollout 500
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 4096
+ --rollout-max-context-len 16384
+ --rollout-temperature 0.7
+ --global-batch-size 256
+ --dynamic-sampling-filter-path vime.rollout.filter_hub.dynamic_sampling_filters.check_reward_nonzero_std
+ --balance-data
+)
+
+EVAL_ARGS=(
+ --eval-interval 5
+ --eval-prompt-data retail-dev ${TAU_BENCH_ROOT}/retail_dev_tasks.jsonl
+ --n-samples-per-eval-prompt 1
+ --eval-max-response-len 4096
+ --eval-top-k 1
+)
+
+PERF_ARGS=(
+ --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
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --use-kl-loss
+ --kl-loss-coef 0.001
+ --kl-loss-type low_var_kl
+ --entropy-coef 0.01
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 5e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 1
+ --vllm-gpu-memory-utilization 0.7
+ --vllm-max-model-len 16384
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --use-flash-attn
+ --no-gradient-accumulation-fusion
+)
+
+CUSTOM_ARGS=(
+ --custom-generate-function-path generate_with_tau.generate
+ --custom-rm-path generate_with_tau.batched_tau_bench_rm
+)
+
+export MASTER_ADDR=${MASTER_ADDR:-"127.0.0.1"}
+
+ray start --head \
+ --node-ip-address "${MASTER_ADDR}" \
+ --disable-usage-stats \
+ --dashboard-host=0.0.0.0 \
+ --dashboard-port=8265
+
+ray job submit --address="http://127.0.0.1:8265" \
+ -- python3 train.py \
+ --train-backend megatron \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 4 \
+ --rollout-num-gpus 4 \
+ "${MODEL_ARGS[@]}" \
+ "${CKPT_ARGS[@]}" \
+ "${ROLLOUT_ARGS[@]}" \
+ "${EVAL_ARGS[@]}" \
+ "${OPTIMIZER_ARGS[@]}" \
+ "${GRPO_ARGS[@]}" \
+ "${PERF_ARGS[@]}" \
+ "${VLLM_ARGS[@]}" \
+ "${CUSTOM_ARGS[@]}" \
+ "${MISC_ARGS[@]}"
+
diff --git a/scripts/models/qwen3-30B-A3B-npu.sh b/scripts/models/qwen3-30B-A3B-npu.sh
new file mode 100644
index 000000000..c4512cd4e
--- /dev/null
+++ b/scripts/models/qwen3-30B-A3B-npu.sh
@@ -0,0 +1,48 @@
+NLAYERS=48
+FIRST_K_DENSE_REPLACE=0
+
+arr=()
+for ((i=0; i/dev/null && pwd)"
+source "${SCRIPT_DIR}/models/glm4.7-30B-A3B.sh"
+
+DATA_ROOT="${DATA_ROOT:-/root}"
+MODEL_DIR="${DATA_ROOT}/weights/GLM-4.7-Flash"
+DATASET_DIR="${DATA_ROOT}/datasets/dapo-math-17k"
+
+CKPT_ARGS=(
+ --hf-checkpoint "${MODEL_DIR}"
+ --load "${MODEL_DIR}"
+ --ref-load "${MODEL_DIR}"
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data "${DATASET_DIR}/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_ARGS=(
+ --eval-interval 20
+ --eval-prompt-data aime ${DATA_ROOT}/datasets/aime-2024/aime-2024.jsonl
+ --n-samples-per-eval-prompt 16
+ --eval-max-response-len 16384
+ --eval-top-p 1
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --sequence-parallel
+ --pipeline-model-parallel-size 1
+ --context-parallel-size 1
+ --expert-model-parallel-size 8
+ --expert-tensor-parallel-size 1
+
+ --recompute-granularity full
+ --recompute-method uniform
+ --recompute-num-layers 1
+
+ --use-dynamic-batch-size
+ --max-tokens-per-gpu 20480
+ --seq-length 24576
+)
+
+GRPO_ARGS=(
+ --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_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+
+VLLM_ARGS=(
+ --vllm-additional-config '{"weight_nz_mode":0}'
+ --rollout-num-gpus-per-engine 4
+ --vllm-gpu-memory-utilization 0.7
+ --vllm-enable-expert-parallel
+ --vllm-cudagraph-capture-sizes 1 2 4 8 $(seq 16 8 256)
+)
+
+MISC_ARGS=(
+ # Match GLM's unscaled RoPE without changing main's shared model script.
+ --rope-type rope
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+
+ --use-flash-attn
+ --no-gradient-accumulation-fusion
+)
+
+# launch the master node of ray in container
+export MASTER_ADDR=${MASTER_ADDR:-"127.0.0.1"}
+ray start --head --node-ip-address ${MASTER_ADDR} --disable-usage-stats --dashboard-host=0.0.0.0 --dashboard-port=8265
+
+ray job submit --address="http://127.0.0.1:8265" \
+ -- python3 train.py \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 8 \
+ --rollout-num-gpus 8 \
+ "${MODEL_ARGS[@]}" \
+ "${CKPT_ARGS[@]}" \
+ "${ROLLOUT_ARGS[@]}" \
+ "${OPTIMIZER_ARGS[@]}" \
+ "${GRPO_ARGS[@]}" \
+ "${PERF_ARGS[@]}" \
+ "${EVAL_ARGS[@]}" \
+ "${VLLM_ARGS[@]}" \
+ "${MISC_ARGS[@]}"
diff --git a/scripts/run-qwen3-30B-A3B-npu.sh b/scripts/run-qwen3-30B-A3B-npu.sh
new file mode 100644
index 000000000..915b4c8bd
--- /dev/null
+++ b/scripts/run-qwen3-30B-A3B-npu.sh
@@ -0,0 +1,137 @@
+#!/bin/bash
+
+# for rerun the task
+pkill -9 -f '[v]llm serve|VLL[M]::'
+sleep 3
+ray stop --force
+pkill -9 ray
+pkill -9 python
+sleep 3
+pkill -9 ray
+pkill -9 python
+pkill -9 redis
+
+set -ex
+
+export PYTHONUNBUFFERED=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export DISABLE_L2_CACHE=1
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+export PYTHONPATH="/root/Megatron-Bridge/src:/root/Megatron-LM/:${PYTHONPATH:-}"
+
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+source "${SCRIPT_DIR}/models/qwen3-30B-A3B.sh"
+
+DATA_ROOT="${DATA_ROOT:-/root}"
+
+CKPT_ARGS=(
+ --hf-checkpoint ${DATA_ROOT}/weights/Qwen3-30B-A3B/
+ --load ${DATA_ROOT}/weights/Qwen3-30B-A3B/
+ --ref-load ${DATA_ROOT}/weights/Qwen3-30B-A3B/
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data ${DATA_ROOT}/datasets/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 $((1024 * 8))
+ --rollout-temperature 1
+ --global-batch-size 256
+ --balance-data
+)
+
+EVAL_ARGS=(
+ --eval-interval 20
+ --eval-prompt-data aime ${DATA_ROOT}/datasets/aime-2024/aime-2024.jsonl
+ --n-samples-per-eval-prompt 16
+ --eval-max-response-len 16384
+ --eval-top-p 1
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --sequence-parallel
+ --pipeline-model-parallel-size 1
+ --context-parallel-size 1
+ --expert-model-parallel-size 8
+ --expert-tensor-parallel-size 1
+
+ --recompute-granularity full
+ --recompute-method uniform
+ --recompute-num-layers 1
+
+ --use-dynamic-batch-size
+ --max-tokens-per-gpu 20480
+)
+
+GRPO_ARGS=(
+ --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_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 4
+ --vllm-gpu-memory-utilization 0.7
+ --vllm-cudagraph-capture-sizes 1 2 4 8 $(seq 16 8 256)
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+
+ --use-flash-attn
+ --no-gradient-accumulation-fusion
+)
+
+ray start --head --node-ip-address 127.0.0.1 --disable-usage-stats --dashboard-host=0.0.0.0 --dashboard-port=8265
+
+ray job submit --address="http://127.0.0.1:8265" \
+ -- python3 train.py \
+ --actor-num-nodes 1 \
+ --actor-num-gpus-per-node 8 \
+ --rollout-num-gpus 8 \
+ ${MODEL_ARGS[@]} \
+ ${CKPT_ARGS[@]} \
+ ${ROLLOUT_ARGS[@]} \
+ ${OPTIMIZER_ARGS[@]} \
+ ${GRPO_ARGS[@]} \
+ ${PERF_ARGS[@]} \
+ ${EVAL_ARGS[@]} \
+ ${VLLM_ARGS[@]} \
+ ${MISC_ARGS[@]}
diff --git a/scripts/run-qwen3-4B-npu.sh b/scripts/run-qwen3-4B-npu.sh
new file mode 100644
index 000000000..73b672866
--- /dev/null
+++ b/scripts/run-qwen3-4B-npu.sh
@@ -0,0 +1,124 @@
+#!/bin/bash
+
+# for rerun the task
+pkill -9 -f '[v]llm serve|VLL[M]::'
+pkill -9 -f VLLM
+sleep 3
+ray stop --force
+pkill -9 ray
+pkill -9 python
+sleep 3
+pkill -9 ray
+pkill -9 python
+pkill -9 redis
+
+set -ex
+
+export PYTHONUNBUFFERED=1
+export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
+export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1
+export CUDA_DEVICE_MAX_CONNECTIONS=1
+export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050
+export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050
+export HYDRA_FULL_ERROR=1
+export DISABLE_L2_CACHE=1
+export VLLM_ASCEND_ENABLE_NZ=0
+export VLLM_USE_AOT_COMPILE=0
+export PYTHONPATH="/root/Megatron-Bridge/src:/root/Megatron-LM/:${PYTHONPATH:-}"
+
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+source "${SCRIPT_DIR}/models/qwen3-4B.sh"
+
+DATA_ROOT="${DATA_ROOT:-/root}"
+
+CKPT_ARGS=(
+ --hf-checkpoint ${DATA_ROOT}/models/Qwen3-4B/
+ --load ${DATA_ROOT}/models/Qwen3-4B/
+ --ref-load ${DATA_ROOT}/models/Qwen3-4B/
+ --megatron-to-hf-mode bridge
+)
+
+ROLLOUT_ARGS=(
+ --prompt-data ${DATA_ROOT}/datasets/dapo-math-17k/dapo-math-17k.jsonl
+ --input-key prompt
+ --label-key label
+ --apply-chat-template
+ --rollout-shuffle
+ --rm-type math
+ --num-rollout 200
+ --rollout-batch-size 32
+ --n-samples-per-prompt 8
+ --rollout-max-response-len 2048
+ --rollout-temperature 1
+ --global-batch-size 256
+ --balance-data
+)
+
+PERF_ARGS=(
+ --tensor-model-parallel-size 4
+ --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 8192
+ --megatron-to-hf-mode bridge
+)
+
+GRPO_ARGS=(
+ --advantage-estimator grpo
+ --kl-loss-coef 0.0
+ --kl-loss-type low_var_kl
+ --kl-coef 0.00
+ --entropy-coef 0.0
+ --eps-clip 0.2
+ --eps-clip-high 0.28
+)
+
+OPTIMIZER_ARGS=(
+ --optimizer adam
+ --lr 1e-6
+ --lr-decay-style constant
+ --weight-decay 0.1
+ --adam-beta1 0.9
+ --adam-beta2 0.98
+ --optimizer-cpu-offload
+ --overlap-cpu-optimizer-d2h-h2d
+ --use-precision-aware-optimizer
+)
+
+VLLM_ARGS=(
+ --rollout-num-gpus-per-engine 4
+ --vllm-gpu-memory-utilization 0.6
+)
+
+MISC_ARGS=(
+ --attention-dropout 0.0
+ --hidden-dropout 0.0
+ --accumulate-allreduce-grads-in-fp32
+ --attention-softmax-in-fp32
+ --attention-backend flash
+ --micro-batch-size 1
+ --use-flash-attn
+)
+
+ray start --head --node-ip-address 127.0.0.1 --disable-usage-stats --dashboard-host=0.0.0.0 --dashboard-port=8265
+
+ray job submit --address="http://127.0.0.1:8265" \
+-- python3 train.py \
+--actor-num-nodes 1 \
+--actor-num-gpus-per-node 4 \
+--rollout-num-gpus 4 \
+${MODEL_ARGS[@]} \
+${CKPT_ARGS[@]} \
+${ROLLOUT_ARGS[@]} \
+${OPTIMIZER_ARGS[@]} \
+${GRPO_ARGS[@]} \
+${PERF_ARGS[@]} \
+${VLLM_ARGS[@]} \
+${MISC_ARGS[@]}
diff --git a/tests/_unit_stubs.py b/tests/_unit_stubs.py
index 2f0bb62ba..827c615d2 100644
--- a/tests/_unit_stubs.py
+++ b/tests/_unit_stubs.py
@@ -35,9 +35,8 @@
def real_module_available(name: str) -> bool:
"""True when the real package is importable and should not be shadowed."""
- if name in sys.modules:
- return True
try:
+ # Earlier test modules may have installed a stub with no import spec.
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False
diff --git a/tests/test_empty_colocated_weight_bucket.py b/tests/test_empty_colocated_weight_bucket.py
index 14c8589dd..4162d3003 100644
--- a/tests/test_empty_colocated_weight_bucket.py
+++ b/tests/test_empty_colocated_weight_bucket.py
@@ -44,6 +44,9 @@ def _install_fake_deps(monkeypatch):
accelerator_mod.current_device = lambda: "cuda:0"
accelerator_mod.ipc_collect = lambda: None
+ platform_mod = types.ModuleType("vime.platforms")
+ platform_mod.current_platform = lambda: types.SimpleNamespace(is_npu=False)
+
dist_mod = types.ModuleType("torch.distributed")
def gather_object(obj, object_gather_list, dst, group):
@@ -110,6 +113,7 @@ def gather_object(obj, object_gather_list, dst, group):
update_from_distributed_mod.update_weights_from_distributed = lambda *args, **kwargs: []
monkeypatch.setitem(sys.modules, "vime", vime_pkg)
+ monkeypatch.setitem(sys.modules, "vime.platforms", platform_mod)
monkeypatch.setitem(sys.modules, "vime.backends", vime_backends_pkg)
monkeypatch.setitem(sys.modules, "vime.backends.megatron_utils", megatron_utils_pkg)
monkeypatch.setitem(sys.modules, "vime.backends.megatron_utils.update_weight", update_weight_pkg)
diff --git a/tests/test_glm4.7_30B_A3B_npu.py b/tests/test_glm4.7_30B_A3B_npu.py
new file mode 100644
index 000000000..b333e849e
--- /dev/null
+++ b/tests/test_glm4.7_30B_A3B_npu.py
@@ -0,0 +1,156 @@
+import os
+import shlex
+
+import vime.utils.external_utils.command_utils as U
+
+
+TEST_ROOT = os.environ.get("HF_HOME") or "/root"
+MODEL_DIR = f"{TEST_ROOT}/models/GLM-4.7-Flash"
+DATASET_DIR = f"{TEST_ROOT}/datasets/dapo-math-17k"
+
+
+def prepare():
+ models_dir = shlex.quote(f"{TEST_ROOT}/models")
+ datasets_dir = shlex.quote(f"{TEST_ROOT}/datasets")
+ model_dir = shlex.quote(MODEL_DIR)
+ dataset_dir = shlex.quote(DATASET_DIR)
+
+ U.exec_command(f"mkdir -p {models_dir} {datasets_dir}")
+ U.exec_command(f"hf download zai-org/GLM-4.7-Flash --local-dir {model_dir}")
+ U.exec_command("hf download --repo-type dataset zhuzilin/dapo-math-17k " f"--local-dir {dataset_dir}")
+
+
+def execute():
+ # Default to G1; G2 and eager diagnostics are explicit, independent opt-ins.
+ enable_mtp = os.environ.get("VIME_TEST_GLM_MTP", "0") == "1"
+ enforce_eager = os.environ.get("VIME_TEST_GLM_EAGER", "0") == "1"
+ model_dir = shlex.quote(MODEL_DIR)
+ prompt_data = shlex.quote(f"{DATASET_DIR}/dapo-math-17k.jsonl")
+
+ # G1 loads HF weights through the native loader, without torch_dist conversion.
+ checkpoint_args = f"--hf-checkpoint {model_dir} --load {model_dir} --ref-load {model_dir} --no-load-optim "
+
+ # Smoke-scaled rollout (num-rollout/batch/n-samples trimmed like test_qwen3_30B_A3B_npu).
+ rollout_args = (
+ f"--prompt-data {prompt_data} "
+ "--input-key prompt "
+ "--label-key label "
+ "--apply-chat-template "
+ "--rollout-shuffle "
+ "--rm-type deepscaler "
+ "--num-rollout 2 "
+ "--rollout-batch-size 4 "
+ "--n-samples-per-prompt 4 "
+ "--rollout-max-response-len 128 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 16 "
+ "--balance-data "
+ )
+
+ # TP=4/EP=8 mirrors scripts/run-glm4.7-30B-A3B-npu.sh.
+ parallel_args = (
+ "--tensor-model-parallel-size 4 "
+ "--sequence-parallel "
+ "--pipeline-model-parallel-size 1 "
+ "--context-parallel-size 1 "
+ "--expert-model-parallel-size 8 "
+ "--expert-tensor-parallel-size 1 "
+ "--moe-token-dispatcher-type alltoall "
+ "--recompute-granularity full "
+ "--recompute-method uniform "
+ "--recompute-num-layers 1 "
+ "--use-dynamic-batch-size "
+ "--max-tokens-per-gpu 20480 "
+ "--micro-batch-size 1 "
+ )
+
+ grpo_args = (
+ "--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_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ "--optimizer-cpu-offload "
+ "--overlap-cpu-optimizer-d2h-h2d "
+ "--use-precision-aware-optimizer "
+ )
+
+ vllm_args = (
+ "--vllm-additional-config '{\"weight_nz_mode\":0}' "
+ "--rollout-num-gpus-per-engine 4 "
+ "--vllm-gpu-memory-utilization 0.7 "
+ "--vllm-enable-expert-parallel "
+ "--vllm-cudagraph-capture-sizes 1 2 4 8 "
+ )
+ mtp_args = ""
+ if enable_mtp:
+ mtp_args = "--mtp-num-layers 1 --enable-mtp-training --mtp-loss-scaling-factor 0.2 "
+ vllm_args += '--vllm-speculative-config \'{"method":"mtp","num_speculative_tokens":1}\' '
+ if enforce_eager:
+ vllm_args += "--vllm-enforce-eager "
+
+ model_args = (
+ # GLM-4.7-Flash has no HF rope_scaling; MLA otherwise defaults to YaRN.
+ "--rope-type rope "
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--use-flash-attn "
+ "--no-gradient-accumulation-fusion "
+ )
+
+ runtime_args = (
+ "--train-backend megatron "
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 8 "
+ "--rollout-num-gpus 8 "
+ "--ci-test "
+ )
+
+ train_args = (
+ checkpoint_args
+ + rollout_args
+ + parallel_args
+ + grpo_args
+ + optimizer_args
+ + mtp_args
+ + vllm_args
+ + model_args
+ + runtime_args
+ )
+ # Model architecture (num-experts, moe-*, multi-latent-attention, q-lora-rank,
+ # kv-lora-rank, ...) is injected by sourcing scripts/models/glm4.7-30B-A3B.sh
+ # via ${MODEL_ARGS[@]}, so only runtime/training args are passed here.
+ U.execute_train(
+ train_args=train_args,
+ num_gpus_per_node=16,
+ megatron_model_type="glm4.7-30B-A3B",
+ extra_env_vars={
+ "DISABLE_L2_CACHE": "1",
+ "VLLM_USE_AOT_COMPILE": "0",
+ },
+ )
+
+
+def main():
+ prepare()
+ for proxy_var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ os.environ.pop(proxy_var, None)
+ execute()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_hf_to_megatron.py b/tests/test_hf_to_megatron.py
index 07c0d8a52..412fab08a 100644
--- a/tests/test_hf_to_megatron.py
+++ b/tests/test_hf_to_megatron.py
@@ -22,7 +22,7 @@
from vime.backends.megatron_utils import megatron_to_hf as megatron_to_hf_module
from vime.backends.megatron_utils.hf_to_megatron import _LOADERS
-from vime.backends.megatron_utils.hf_to_megatron.common import SafetensorReader
+from vime.backends.megatron_utils.hf_to_megatron.common import SafetensorReader, shard_mcore_tensor
from vime.backends.megatron_utils.hf_to_megatron.deepseek import deepseek_hf_tensor
from vime.backends.megatron_utils.hf_to_megatron.glm import glm4_hf_tensor, glm4_moe_hf_tensor
from vime.backends.megatron_utils.hf_to_megatron.qwen import (
@@ -33,6 +33,7 @@
)
from vime.backends.megatron_utils.hf_to_megatron.qwen3_next import qwen3_next_hf_tensor
from vime.backends.megatron_utils.hf_to_megatron.qwen3_omni import qwen3_omni_hf_tensor
+from vime.backends.megatron_utils.hf_to_megatron.qwen3_vl import qwen3_vl_hf_tensor
from vime.backends.megatron_utils.megatron_to_hf import _convert_to_hf_core, convert_to_hf
from vime.backends.megatron_utils.megatron_to_hf.deepseekv3 import convert_deepseekv3_to_hf
from vime.backends.megatron_utils.megatron_to_hf.glm4 import convert_glm4_to_hf
@@ -42,6 +43,7 @@
from vime.backends.megatron_utils.megatron_to_hf.qwen2 import convert_qwen2_to_hf
from vime.backends.megatron_utils.megatron_to_hf.qwen3_next import convert_qwen3_next_to_hf
from vime.backends.megatron_utils.megatron_to_hf.qwen3_omni import convert_qwen3_omni_to_hf
+from vime.backends.megatron_utils.megatron_to_hf.qwen3_vl import convert_qwen3vl_to_hf
from vime.backends.megatron_utils.megatron_to_hf.qwen3moe import convert_qwen3moe_to_hf
NUM_GPUS = 0
@@ -222,7 +224,7 @@ def test_qwen3_omni_encoder_mapping_is_replicated():
@pytest.mark.unit
-@pytest.mark.parametrize("model_name", ["deepseekv32config", "kimik2config"])
+@pytest.mark.parametrize("model_name", ["deepseekv32config", "kimik2config", "glm4moeliteconfig"])
def test_deepseek_family_parameter_updates_use_the_direct_exporter(model_name):
parameter = torch.randn(8, 8)
@@ -257,6 +259,58 @@ def test_qwen2_moe_parameter_updates_use_the_moe_exporter():
assert torch.equal(converted[1][1], parameter[6:])
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "rest,shape",
+ [
+ ("input_layernorm.weight", (8,)),
+ ("self_attention.linear_q_down_proj.weight", (4, 8)),
+ ("self_attention.linear_q_up_proj.weight", (8, 4)),
+ ("self_attention.linear_q_up_proj.layer_norm_weight", (4,)),
+ ("self_attention.linear_kv_down_proj.weight", (6, 8)),
+ ("self_attention.linear_kv_up_proj.weight", (8, 4)),
+ ("self_attention.linear_kv_up_proj.layer_norm_weight", (4,)),
+ ("self_attention.linear_proj.weight", (8, 8)),
+ ("pre_mlp_layernorm.weight", (8,)),
+ ("mlp.linear_fc1.weight", (12, 8)),
+ ("mlp.linear_fc2.weight", (8, 6)),
+ ("mlp.shared_experts.linear_fc1.weight", (12, 8)),
+ ("mlp.shared_experts.linear_fc2.weight", (8, 6)),
+ ("mlp.experts.linear_fc1.weight3", (12, 8)),
+ ("mlp.experts.linear_fc2.weight3", (8, 6)),
+ ("mlp.router.weight", (4, 8)),
+ ("mlp.router.expert_bias", (4,)),
+ ],
+)
+@pytest.mark.parametrize("mtp", [False, True])
+def test_glm_lite_native_mla_and_moe_round_trip(rest, shape, mtp):
+ prefix = "mtp.layers.0.transformer_layer" if mtp else "decoder.layers.1"
+ name = f"module.module.{prefix}.{rest}"
+ parameter = torch.randn(shape)
+ exported = _convert_to_hf_core(_EXPORT_ARGS, "glm4moeliteconfig", name, parameter)
+ loaded = _LOADERS["glm4_moe_lite"](name, Reader(**dict(exported)), _config("glm4_moe_lite"))
+ assert torch.equal(loaded, parameter)
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "rest,hf_rest,shape",
+ [
+ ("eh_proj.weight", "eh_proj.weight", (8, 16)),
+ ("enorm.weight", "enorm.weight", (8,)),
+ ("hnorm.weight", "hnorm.weight", (8,)),
+ ("final_layernorm.weight", "shared_head.norm.weight", (8,)),
+ ],
+)
+def test_glm_lite_native_mtp_projection_and_norm_round_trip(rest, hf_rest, shape):
+ name = f"module.module.mtp.layers.0.{rest}"
+ parameter = torch.randn(shape)
+ exported = _convert_to_hf_core(_EXPORT_ARGS, "glm4moeliteconfig", name, parameter)
+ assert [key for key, _ in exported] == [f"model.layers.{_EXPORT_ARGS.num_layers}.{hf_rest}"]
+ loaded = _LOADERS["glm4_moe_lite"](name, Reader(**dict(exported)), _config("glm4_moe_lite"))
+ assert torch.equal(loaded, parameter)
+
+
@pytest.mark.unit
def test_qwen_and_llama_share_the_basic_qkv_mapping():
q = torch.arange(16).view(4, 4)
@@ -398,9 +452,108 @@ def test_loader_scope_stays_explicit():
"qwen3_moe",
"qwen3_next",
"qwen3_omni_moe",
+ "qwen3_vl",
}
+@pytest.mark.unit
+@pytest.mark.parametrize(("model_name", "prefix"), [("qwen3", ""), ("qwen3_vl", "language_model.")])
+@pytest.mark.parametrize(
+ ("name", "shape", "is_vocab"),
+ [
+ ("embedding.word_embeddings.weight", (16, 8), True),
+ ("output_layer.weight", (16, 8), True),
+ ("decoder.final_layernorm.weight", (16,), False),
+ ],
+)
+def test_native_export_removes_only_vocab_padding(model_name, prefix, name, shape, is_vocab):
+ args = types.SimpleNamespace(vocab_size=8)
+ weight = torch.randn(shape)
+ [(_, exported)] = convert_to_hf(args, model_name, "module.module." + prefix + name, weight)
+ expected = weight[: args.vocab_size] if is_vocab else weight
+ assert torch.equal(exported, expected)
+ if not is_vocab:
+ assert exported is weight
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ ("name", "shape"),
+ [
+ ("embedding.word_embeddings.weight", (16, 8)),
+ ("output_layer.weight", (16, 8)),
+ ("decoder.final_layernorm.weight", (8,)),
+ ("decoder.layers.0.self_attention.linear_qkv.weight", (16, 8)),
+ ("decoder.layers.0.self_attention.linear_qkv.bias", (16,)),
+ ("decoder.layers.0.self_attention.linear_proj.weight", (8, 8)),
+ ("decoder.layers.0.self_attention.linear_qkv.layer_norm_weight", (8,)),
+ ("decoder.layers.0.self_attention.q_layernorm.weight", (2,)),
+ ("decoder.layers.0.self_attention.k_layernorm.weight", (2,)),
+ ("decoder.layers.0.mlp.linear_fc1.weight", (24, 8)),
+ ("decoder.layers.0.mlp.linear_fc2.weight", (8, 12)),
+ ("decoder.layers.0.mlp.linear_fc1.layer_norm_weight", (8,)),
+ ],
+)
+def test_qwen3_vl_language_round_trip(name, shape):
+ name = "module.module.language_model." + name
+ weight = torch.randn(shape)
+ config = types.SimpleNamespace(
+ tie_word_embeddings=False,
+ text_config=types.SimpleNamespace(
+ hidden_size=8, num_attention_heads=4, num_key_value_heads=2, head_dim=2, tie_word_embeddings=False
+ ),
+ )
+ reader = Reader(**dict(convert_qwen3vl_to_hf(_EXPORT_ARGS, name, weight)))
+ assert torch.equal(qwen3_vl_hf_tensor(name, reader, config), weight)
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ ("top_tied", "text_tied", "has_head"), [(True, False, True), (False, True, True), (False, False, False)]
+)
+def test_qwen3_vl_tied_output_uses_language_embedding(top_tied, text_tied, has_head):
+ embedding = torch.randn(8, 4)
+ tensors = {"model.language_model.embed_tokens.weight": embedding}
+ if has_head:
+ tensors["lm_head.weight"] = torch.zeros_like(embedding)
+ config = types.SimpleNamespace(
+ tie_word_embeddings=top_tied, text_config=types.SimpleNamespace(tie_word_embeddings=text_tied)
+ )
+ assert qwen3_vl_hf_tensor("output_layer.weight", Reader(**tensors), config) is embedding
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize("platform", ["cuda", "npu"])
+@pytest.mark.parametrize("expert", [False, True])
+@pytest.mark.parametrize("parallel_size", [1, 2, 4])
+def test_native_fc1_loading_uses_platform_partition_metadata(monkeypatch, platform, expert, parallel_size):
+ mpu = pytest.importorskip("megatron.core").mpu
+ monkeypatch.setenv("VIME_PLATFORM", platform)
+ monkeypatch.setattr(mpu, "get_tensor_model_parallel_world_size", lambda: 8 if expert else parallel_size)
+ monkeypatch.setattr(mpu, "get_tensor_model_parallel_rank", lambda: 0 if expert else parallel_size - 1)
+ monkeypatch.setattr(mpu, "get_expert_tensor_parallel_world_size", lambda: parallel_size if expert else 8)
+ monkeypatch.setattr(mpu, "get_expert_tensor_parallel_rank", lambda: parallel_size - 1 if expert else 0)
+ name = "decoder.layers.0.mlp." + ("experts.linear_fc1.weight0" if expert else "linear_fc1.weight")
+ weight = torch.arange(16 * 8).reshape(16, 8)
+ # MindSpeed grouped column-parallel weights carry dim=1, but store [out, in].
+ parameter = types.SimpleNamespace(
+ tensor_model_parallel=True, partition_dim=1 if platform == "npu" and expert else 0, partition_stride=1
+ )
+ shard = shard_mcore_tensor(name, weight, parameter)
+ gate, up = weight.chunk(2)
+ assert torch.equal(shard, torch.cat((gate.chunk(parallel_size)[-1], up.chunk(parallel_size)[-1])))
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize("parallel_mode", [None, "duplicated"])
+def test_native_loading_does_not_shard_replicated_parameters(monkeypatch, parallel_mode):
+ pytest.importorskip("megatron.core")
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+ parameter = types.SimpleNamespace(tensor_model_parallel=parallel_mode is not None, parallel_mode=parallel_mode)
+ weight = torch.randn(4, 8)
+ assert shard_mcore_tensor("model.visual.blocks.0.mlp.linear_fc1.weight", weight, parameter) is weight
+
+
@pytest.mark.unit
def test_reader_dequantizes_block_scaled_fp8(tmp_path):
weight = torch.linspace(-2, 2, 128 * 128).view(128, 128).to(torch.float8_e4m3fn)
diff --git a/tests/test_qwen3_30B_A3B_npu.py b/tests/test_qwen3_30B_A3B_npu.py
new file mode 100644
index 000000000..e3310096a
--- /dev/null
+++ b/tests/test_qwen3_30B_A3B_npu.py
@@ -0,0 +1,171 @@
+import os
+import shlex
+import sys
+import tempfile
+from pathlib import Path
+
+import vime.utils.external_utils.command_utils as U
+
+
+TEST_ROOT = os.environ.get("HF_HOME") or "/root"
+MODEL_DIR = f"{TEST_ROOT}/models/Qwen3-30B-A3B"
+DATASET_DIR = f"{TEST_ROOT}/datasets/dapo-math-17k"
+
+
+def prepare(torch_dist_ref_load=True):
+ models_dir = shlex.quote(f"{TEST_ROOT}/models")
+ datasets_dir = shlex.quote(f"{TEST_ROOT}/datasets")
+ model_dir = shlex.quote(MODEL_DIR)
+ dataset_dir = shlex.quote(DATASET_DIR)
+
+ U.exec_command(f"mkdir -p {models_dir} {datasets_dir}")
+ U.exec_command(f"hf download Qwen/Qwen3-30B-A3B --local-dir {model_dir}")
+ U.exec_command("hf download --repo-type dataset zhuzilin/dapo-math-17k " f"--local-dir {dataset_dir}")
+ if not torch_dist_ref_load:
+ return None
+
+ # Retain conversion artifacts for inspection; never delete an existing checkpoint.
+ checkpoint_path = Path(tempfile.mkdtemp(prefix="Qwen3-30B-A3B_torch_dist_", dir=f"{TEST_ROOT}/models"))
+ checkpoint_dir = shlex.quote(str(checkpoint_path))
+ U.exec_command(
+ "source scripts/models/qwen3-30B-A3B.sh && "
+ "TRANSFORMERS_VERBOSITY=error "
+ f"VIME_PLATFORM=npu PYTHONPATH={shlex.quote(str(U.repo_base_dir))}:/root/Megatron-LM:${{PYTHONPATH:-}} "
+ f"{shlex.quote(sys.executable)} -m torch.distributed.run --nproc-per-node 8 "
+ "tools/convert_hf_to_torch_dist.py "
+ "${MODEL_ARGS[@]} "
+ f"--hf-checkpoint {model_dir} --save {checkpoint_dir}"
+ )
+
+ tracker = checkpoint_path / "latest_checkpointed_iteration.txt"
+ assert tracker.read_text().strip() == "release"
+ weight_files = [
+ path
+ for path in checkpoint_path.rglob("*")
+ if path.is_file() and path.name != "latest_checkpointed_iteration.txt"
+ ]
+ assert weight_files, f"No checkpoint weights found under {checkpoint_path}"
+ return str(checkpoint_path)
+
+
+def execute(torch_dist_checkpoint=None):
+ model_dir = shlex.quote(MODEL_DIR)
+ prompt_data = shlex.quote(f"{DATASET_DIR}/dapo-math-17k.jsonl")
+
+ checkpoint_args = f"--hf-checkpoint {model_dir} --load {model_dir} --ref-load {model_dir} --no-load-optim "
+ if torch_dist_checkpoint is not None:
+ checkpoint_args = (
+ f"--hf-checkpoint {model_dir} --ref-load {shlex.quote(torch_dist_checkpoint)} --no-load-optim "
+ )
+
+ rollout_args = (
+ f"--prompt-data {prompt_data} "
+ "--input-key prompt "
+ "--label-key label "
+ "--apply-chat-template "
+ "--rollout-shuffle "
+ "--rm-type deepscaler "
+ "--num-rollout 2 "
+ "--rollout-batch-size 4 "
+ "--n-samples-per-prompt 4 "
+ "--rollout-max-response-len 8192 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 16 "
+ "--balance-data "
+ )
+
+ parallel_args = (
+ "--tensor-model-parallel-size 4 "
+ "--sequence-parallel "
+ "--pipeline-model-parallel-size 1 "
+ "--context-parallel-size 1 "
+ "--expert-model-parallel-size 8 "
+ "--expert-tensor-parallel-size 1 "
+ "--moe-token-dispatcher-type alltoall "
+ "--recompute-granularity full "
+ "--recompute-method uniform "
+ "--recompute-num-layers 1 "
+ "--use-dynamic-batch-size "
+ "--max-tokens-per-gpu 20480 "
+ "--micro-batch-size 1 "
+ )
+
+ grpo_args = (
+ "--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_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ "--optimizer-cpu-offload "
+ "--overlap-cpu-optimizer-d2h-h2d "
+ "--use-precision-aware-optimizer "
+ )
+
+ vllm_args = (
+ "--vllm-additional-config '{\"weight_nz_mode\":0}' "
+ "--rollout-num-gpus-per-engine 4 "
+ "--vllm-enable-sleep-mode "
+ "--vllm-enable-expert-parallel "
+ "--vllm-gpu-memory-utilization 0.7 "
+ )
+
+ model_args = (
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--use-flash-attn "
+ "--no-gradient-accumulation-fusion "
+ )
+
+ runtime_args = (
+ "--train-backend megatron "
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 8 "
+ "--rollout-num-gpus 8 "
+ "--ci-test "
+ "--colocate "
+ )
+
+ train_args = (
+ checkpoint_args
+ + rollout_args
+ + parallel_args
+ + grpo_args
+ + optimizer_args
+ + vllm_args
+ + model_args
+ + runtime_args
+ )
+ U.execute_train(
+ train_args=train_args,
+ num_gpus_per_node=16,
+ megatron_model_type="qwen3-30B-A3B",
+ extra_env_vars={
+ "DISABLE_L2_CACHE": "1",
+ "VLLM_USE_AOT_COMPILE": "0",
+ },
+ )
+
+
+def main():
+ checkpoint = prepare(torch_dist_ref_load=os.environ.get("VIME_TEST_TORCH_DIST_REF_LOAD", "1") == "1")
+ for proxy_var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ os.environ.pop(proxy_var, None)
+ execute(checkpoint)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_qwen3_4B_npu.py b/tests/test_qwen3_4B_npu.py
new file mode 100644
index 000000000..ea94cf6d1
--- /dev/null
+++ b/tests/test_qwen3_4B_npu.py
@@ -0,0 +1,132 @@
+import os
+import shlex
+
+import vime.utils.external_utils.command_utils as U
+
+
+TEST_ROOT = os.environ.get("HF_HOME") or "/root"
+MODEL_DIR = f"{TEST_ROOT}/models/Qwen3-4B"
+DATASET_DIR = f"{TEST_ROOT}/datasets/dapo-math-17k"
+
+
+def prepare():
+ models_dir = shlex.quote(f"{TEST_ROOT}/models")
+ datasets_dir = shlex.quote(f"{TEST_ROOT}/datasets")
+ model_dir = shlex.quote(MODEL_DIR)
+ dataset_dir = shlex.quote(DATASET_DIR)
+
+ U.exec_command(f"mkdir -p {models_dir} {datasets_dir}")
+ U.exec_command(f"hf download Qwen/Qwen3-4B --local-dir {model_dir}")
+ U.exec_command("hf download --repo-type dataset zhuzilin/dapo-math-17k " f"--local-dir {dataset_dir}")
+
+
+def execute():
+ model_dir = shlex.quote(MODEL_DIR)
+ prompt_data = shlex.quote(f"{DATASET_DIR}/dapo-math-17k.jsonl")
+
+ checkpoint_args = f"--hf-checkpoint {model_dir} --ref-load {model_dir} --load {model_dir} --no-load-optim "
+
+ rollout_args = (
+ f"--prompt-data {prompt_data} "
+ "--input-key prompt "
+ "--label-key label "
+ "--apply-chat-template "
+ "--rollout-shuffle "
+ "--rm-type math "
+ "--num-rollout 3 "
+ "--rollout-batch-size 4 "
+ "--n-samples-per-prompt 4 "
+ "--rollout-max-response-len 2048 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 16 "
+ "--balance-data "
+ )
+
+ parallel_args = (
+ "--tensor-model-parallel-size 4 "
+ "--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 8192 "
+ )
+
+ grpo_args = (
+ "--advantage-estimator grpo "
+ "--kl-loss-coef 0.0 "
+ "--kl-loss-type low_var_kl "
+ "--kl-coef 0.00 "
+ "--entropy-coef 0.0 "
+ "--eps-clip 0.2 "
+ "--eps-clip-high 0.28 "
+ )
+
+ optimizer_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ "--optimizer-cpu-offload "
+ "--overlap-cpu-optimizer-d2h-h2d "
+ "--use-precision-aware-optimizer "
+ )
+
+ vllm_args = (
+ "--vllm-additional-config '{\"weight_nz_mode\":0}' "
+ "--rollout-num-gpus-per-engine 4 "
+ "--vllm-enable-sleep-mode "
+ "--vllm-gpu-memory-utilization 0.6 "
+ "--vllm-max-model-len 4096 "
+ )
+
+ model_args = (
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--micro-batch-size 1 "
+ "--use-flash-attn "
+ )
+
+ runtime_args = (
+ "--train-backend megatron "
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 4 "
+ "--rollout-num-gpus 4 "
+ "--ci-test "
+ )
+
+ train_args = (
+ checkpoint_args
+ + rollout_args
+ + parallel_args
+ + grpo_args
+ + optimizer_args
+ + vllm_args
+ + model_args
+ + runtime_args
+ )
+ U.execute_train(
+ train_args=train_args,
+ num_gpus_per_node=8,
+ megatron_model_type="qwen3-4B",
+ extra_env_vars={},
+ )
+
+
+def main():
+ prepare()
+ for proxy_var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ os.environ.pop(proxy_var, None)
+ execute()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_qwen3_vl_8B_npu.py b/tests/test_qwen3_vl_8B_npu.py
new file mode 100644
index 000000000..c117a6744
--- /dev/null
+++ b/tests/test_qwen3_vl_8B_npu.py
@@ -0,0 +1,139 @@
+import os
+import shlex
+
+import vime.utils.external_utils.command_utils as U
+
+
+# Single-turn Qwen3-VL GRPO on geo3k (mirrors examples/geo3k_vlm/run_geo3k_vlm_npu.sh).
+# Qwen3-VL-8B uses the qwen3-8B language config and the native VL provider.
+MODEL_NAME = "Qwen3-VL-8B-Instruct"
+MODEL_TYPE = "qwen3-8B"
+TEST_ROOT = os.environ.get("HF_HOME") or "/root"
+MODEL_DIR = f"{TEST_ROOT}/models/{MODEL_NAME}"
+DATASET_DIR = f"{TEST_ROOT}/datasets/geo3k_imgurl"
+
+
+def prepare():
+ models_dir = shlex.quote(f"{TEST_ROOT}/models")
+ datasets_dir = shlex.quote(f"{TEST_ROOT}/datasets")
+ model_dir = shlex.quote(MODEL_DIR)
+ dataset_dir = shlex.quote(DATASET_DIR)
+
+ U.exec_command(f"mkdir -p {models_dir} {datasets_dir}")
+ U.exec_command(f"hf download Qwen/{MODEL_NAME} --local-dir {model_dir}")
+ U.exec_command(f"hf download --repo-type dataset chenhegu/geo3k_imgurl --local-dir {dataset_dir}")
+
+
+def execute():
+ model_dir = shlex.quote(MODEL_DIR)
+ prompt_data = shlex.quote(f"{DATASET_DIR}/train.parquet")
+
+ checkpoint_args = f"--hf-checkpoint {model_dir} --load {model_dir} --no-load-optim "
+
+ rollout_args = (
+ f"--prompt-data {prompt_data} "
+ "--input-key problem "
+ "--label-key answer "
+ '--multimodal-keys \'{"image": "images"}\' '
+ "--apply-chat-template "
+ "--rollout-shuffle "
+ "--rm-type math "
+ "--num-rollout 2 "
+ "--rollout-batch-size 4 "
+ "--n-samples-per-prompt 4 "
+ "--rollout-max-response-len 4096 "
+ "--rollout-temperature 1 "
+ "--global-batch-size 16 "
+ )
+
+ parallel_args = (
+ "--tensor-model-parallel-size 4 "
+ "--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 4096 "
+ )
+
+ grpo_args = (
+ "--advantage-estimator grpo "
+ "--kl-loss-coef 0.00 "
+ "--kl-loss-type low_var_kl "
+ "--kl-coef 0.00 "
+ "--entropy-coef 0.00 "
+ "--eps-clip 0.2 "
+ "--eps-clip-high 0.28 "
+ )
+
+ optimizer_args = (
+ "--optimizer adam "
+ "--lr 1e-6 "
+ "--lr-decay-style constant "
+ "--weight-decay 0.1 "
+ "--adam-beta1 0.9 "
+ "--adam-beta2 0.98 "
+ )
+
+ vllm_args = (
+ "--vllm-additional-config '{\"weight_nz_mode\":0}' "
+ "--rollout-num-gpus-per-engine 1 "
+ "--vllm-gpu-memory-utilization 0.7 "
+ "--vllm-max-model-len 16384 "
+ "--vllm-generation-config auto "
+ "--vllm-logprobs-mode processed_logprobs "
+ )
+
+ model_args = (
+ "--spec vime_plugins.models.qwen3_vl get_qwen3_vl_model_provider "
+ "--attention-dropout 0.0 "
+ "--hidden-dropout 0.0 "
+ "--accumulate-allreduce-grads-in-fp32 "
+ "--attention-softmax-in-fp32 "
+ "--attention-backend flash "
+ "--use-flash-attn "
+ "--no-gradient-accumulation-fusion "
+ )
+
+ runtime_args = (
+ "--train-backend megatron "
+ "--actor-num-nodes 1 "
+ "--actor-num-gpus-per-node 8 "
+ "--rollout-num-gpus 8 "
+ "--colocate "
+ "--ci-test "
+ )
+
+ train_args = (
+ checkpoint_args
+ + rollout_args
+ + parallel_args
+ + grpo_args
+ + optimizer_args
+ + vllm_args
+ + model_args
+ + runtime_args
+ )
+ # qwen3-8B.sh builds MODEL_ARGS with --rotary-base ${MODEL_ARGS_ROTARY_BASE}; Qwen3-VL needs 5e6.
+ os.environ["MODEL_ARGS_ROTARY_BASE"] = "5000000"
+ U.execute_train(
+ train_args=train_args,
+ num_gpus_per_node=8,
+ megatron_model_type=MODEL_TYPE,
+ extra_env_vars={},
+ )
+
+
+def main():
+ prepare()
+ for proxy_var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ os.environ.pop(proxy_var, None)
+ execute()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_qwen3_vl_native.py b/tests/test_qwen3_vl_native.py
new file mode 100644
index 000000000..135fcd5b5
--- /dev/null
+++ b/tests/test_qwen3_vl_native.py
@@ -0,0 +1,318 @@
+from types import SimpleNamespace
+
+import pytest
+import torch
+from safetensors.torch import save_file
+
+NUM_GPUS = 0
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def native(monkeypatch):
+ monkeypatch.setenv("VIME_PLATFORM", "cuda")
+ pytest.importorskip("megatron.core")
+ from vime_plugins.models import qwen3_vl
+
+ return qwen3_vl
+
+
+@pytest.fixture
+def hf_config():
+ from transformers import Qwen3VLConfig
+
+ return Qwen3VLConfig(
+ image_token_id=10,
+ video_token_id=20,
+ vision_start_token_id=30,
+ text_config={
+ "hidden_size": 8,
+ "intermediate_size": 16,
+ "num_hidden_layers": 2,
+ "num_attention_heads": 2,
+ "num_key_value_heads": 1,
+ "head_dim": 4,
+ "vocab_size": 32,
+ "rope_scaling": {"rope_type": "default", "mrope_section": [1, 1, 0], "mrope_interleaved": True},
+ "rope_theta": 5000000,
+ },
+ vision_config={
+ "hidden_size": 8,
+ "intermediate_size": 16,
+ "depth": 2,
+ "num_heads": 2,
+ "out_hidden_size": 8,
+ "patch_size": 2,
+ "temporal_patch_size": 2,
+ "spatial_merge_size": 2,
+ "num_position_embeddings": 4,
+ "deepstack_visual_indexes": [0],
+ },
+ )
+
+
+def test_vision_native_load_export_and_backward(native, hf_config, tmp_path, monkeypatch):
+ from vime.backends.megatron_utils.hf_to_megatron.common import load_model_hf_weights
+ from vime.backends.megatron_utils.hf_to_megatron.qwen3_vl import qwen3_vl_hf_tensor
+ from vime.backends.megatron_utils.megatron_to_hf import convert_to_hf
+ from vime.backends.megatron_utils.update_weight import common
+
+ config = SimpleNamespace(use_cpu_initialization=True, params_dtype=torch.float32, recompute_granularity="full")
+ vision = native._load_vision_model(hf_config, config)
+ tensors = {"model.visual." + name: param.detach().clone() for name, param in vision.named_parameters()}
+ save_file(tensors, tmp_path / "model.safetensors")
+ with torch.no_grad():
+ for parameter in vision.parameters():
+ parameter.zero_()
+ named = [("module.module.model.visual." + name, param) for name, param in vision.named_parameters()]
+ monkeypatch.setattr(common, "named_params_and_buffers", lambda args, model: iter(named))
+ load_model_hf_weights(SimpleNamespace(), [vision], tmp_path, hf_config, qwen3_vl_hf_tensor)
+
+ for name, parameter in named:
+ [(hf_name, exported)] = convert_to_hf(None, "qwen3_vl", name, parameter)
+ assert torch.equal(exported, tensors[hf_name])
+ assert parameter.requires_grad
+ assert not parameter.tensor_model_parallel
+ assert parameter.partition_dim == -1
+ output = vision(torch.randn(4, 24), grid_thw=torch.tensor([[1, 2, 2]]))
+ features, deepstack = (
+ (output.pooler_output, output.deepstack_features) if hasattr(output, "pooler_output") else output
+ )
+ (features.square().sum() + deepstack[0].square().sum()).backward()
+ assert vision.patch_embed.proj.weight.grad.abs().sum() > 0
+ assert vision.deepstack_merger_list[0].linear_fc2.weight.grad.abs().sum() > 0
+
+
+def _injection_model(native, *, sequence_parallel=False):
+ class Embedding:
+ def __call__(self, input_ids, position_ids):
+ return input_ids.T[..., None].float().expand(-1, -1, 2).clone()
+
+ class Vision:
+ dtype = torch.float32
+
+ def __call__(self, values, grid_thw):
+ return SimpleNamespace(pooler_output=values, deepstack_features=[values * 2, values * 3])
+
+ return SimpleNamespace(
+ config=SimpleNamespace(sequence_parallel=sequence_parallel),
+ language_model=SimpleNamespace(embedding=Embedding()),
+ model=SimpleNamespace(visual=Vision()),
+ image_token_id=10,
+ video_token_id=20,
+ )
+
+
+def test_vision_and_deepstack_follow_token_order_and_keep_gradients(native):
+ model = _injection_model(native)
+ image = torch.tensor([[100.0, 101.0]], requires_grad=True)
+ video = torch.tensor([[200.0, 201.0]], requires_grad=True)
+ embeddings, mask, deepstack = native.Qwen3VLModel._inject_vision_embeddings(
+ model, torch.tensor([[7, 20, 8, 10]]), image, video, torch.tensor([[1, 2, 2]]), torch.tensor([[1, 2, 2]])
+ )
+ expected = torch.cat((video, image))
+ assert torch.equal(embeddings[:, 0][mask[0]], expected)
+ assert torch.equal(deepstack[0], expected * 2)
+ (embeddings.sum() + sum(t.sum() for t in deepstack)).backward()
+ assert torch.equal(image.grad, torch.full_like(image, 6))
+ assert torch.equal(video.grad, torch.full_like(video, 6))
+
+
+def test_deepstack_sp_routes_gradients_through_tp_copy(native, monkeypatch):
+ copied = []
+ monkeypatch.setattr(
+ native.tensor_parallel, "copy_to_tensor_model_parallel_region", lambda value: copied.append(value) or value
+ )
+ monkeypatch.setattr(native.tensor_parallel, "scatter_to_sequence_parallel_region", lambda value: value.chunk(4)[1])
+ monkeypatch.setattr(native.mpu, "get_tensor_model_parallel_world_size", lambda: 4)
+ monkeypatch.setattr(native.mpu, "get_tensor_model_parallel_rank", lambda: 1)
+ model = _injection_model(native, sequence_parallel=True)
+ image = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
+ _, mask, deepstack = native.Qwen3VLModel._inject_vision_embeddings(
+ model, torch.tensor([[10, 7, 10, 7, 7, 7, 7, 7]]), image, None, torch.tensor([[1, 2, 4]]), None
+ )
+ assert len(copied) == 2
+ assert mask.tolist() == [[True, False]]
+ assert torch.equal(deepstack[0], image[1:] * 2)
+
+
+def test_missing_vision_is_not_silently_treated_as_text(native):
+ with pytest.raises(ValueError, match="pixel values"):
+ native.Qwen3VLModel._inject_vision_embeddings(
+ _injection_model(native), torch.tensor([[10]]), None, None, None, None
+ )
+
+
+def _vision_tp_worker(rank, rendezvous):
+ from datetime import timedelta
+ from unittest.mock import patch
+
+ import torch.distributed as dist
+
+ from vime_plugins.models import qwen3_vl as native
+
+ torch.set_num_threads(1)
+ dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=4, timeout=timedelta(seconds=45))
+ try:
+ copy = native.tensor_parallel.copy_to_tensor_model_parallel_region
+ scatter = native.tensor_parallel.scatter_to_sequence_parallel_region
+ with (
+ patch.object(torch.cuda, "current_device", lambda: torch.device("cpu")),
+ patch.object(
+ native.tensor_parallel,
+ "copy_to_tensor_model_parallel_region",
+ lambda x: copy(x, group=dist.group.WORLD),
+ ),
+ patch.object(
+ native.tensor_parallel,
+ "scatter_to_sequence_parallel_region",
+ lambda x: scatter(x, group=dist.group.WORLD),
+ ),
+ patch.object(native.mpu, "get_tensor_model_parallel_world_size", lambda: 4),
+ patch.object(native.mpu, "get_tensor_model_parallel_rank", lambda: rank),
+ ):
+ image = torch.arange(8, dtype=torch.float32).view(4, 2).requires_grad_()
+ embeddings, _, deepstack = native.Qwen3VLModel._inject_vision_embeddings(
+ _injection_model(native, sequence_parallel=True),
+ torch.tensor([[10, 7, 10, 7, 10, 7, 10, 7]]),
+ image,
+ None,
+ torch.tensor([[1, 4, 4]]),
+ None,
+ )
+ (embeddings.sum() + sum(t.sum() for t in deepstack)).backward()
+ # All four replicas receive the same complete gradient, including
+ # image features consumed by other SP ranks (1 + 2 + 3 = 6).
+ torch.testing.assert_close(image.grad, torch.full_like(image, 6))
+ finally:
+ dist.destroy_process_group()
+
+
+@pytest.mark.integration
+def test_trainable_vision_tp4_gradients_on_cpu(native, tmp_path):
+ torch.multiprocessing.spawn(_vision_tp_worker, args=((tmp_path / "gloo").as_uri(),), nprocs=4)
+
+
+def test_deepstack_gradients_survive_main_recompute(native, monkeypatch):
+ from torch.utils.checkpoint import checkpoint
+
+ from vime_plugins.models.qwen3_omni_transformer import Qwen3OmniTransformerBlock
+
+ class Layer(torch.nn.Module):
+ def __init__(self, number):
+ super().__init__()
+ self.layer_number = number
+
+ def forward(self, hidden_states, **kwargs):
+ return hidden_states * 2, None
+
+ block = Qwen3OmniTransformerBlock.__new__(Qwen3OmniTransformerBlock)
+ torch.nn.Module.__init__(block)
+ block.config = SimpleNamespace(
+ fp8=False, distribute_saved_activations=False, recompute_method="uniform", recompute_num_layers=1
+ )
+ block.pre_process = True
+ block.layers = torch.nn.ModuleList([Layer(1), Layer(2)])
+ block.num_layers_per_pipeline_rank = 2
+ monkeypatch.setattr(
+ native.tensor_parallel, "checkpoint", lambda fn, distribute, *args: checkpoint(fn, *args, use_reentrant=True)
+ )
+ hidden = torch.ones(4, 1, 2, requires_grad=True)
+ features = [torch.ones(1, 2, requires_grad=True), torch.ones(1, 2, requires_grad=True)]
+ output = block._checkpointed_forward(
+ hidden,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ False,
+ visual_pos_masks=torch.tensor([[False, True, False, False]]),
+ deepstack_visual_embeds=features,
+ )
+ output.sum().backward()
+ torch.testing.assert_close(hidden.grad, torch.full_like(hidden, 4))
+ torch.testing.assert_close(features[0].grad, torch.full_like(features[0], 2))
+ torch.testing.assert_close(features[1].grad, torch.ones_like(features[1]))
+
+
+def test_interleaved_mrope_matches_hf_for_packed_images(native, hf_config):
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextRotaryEmbedding
+
+ from vime_plugins.models.qwen3_omni_moe import Qwen3OmniMultimodalRotaryEmbedding
+
+ positions = native.build_packed_mrope_position_ids(
+ torch.tensor([[30, 10, 10, 10, 10, 7, 30, 10, 8]]),
+ [0, 6, 9],
+ torch.tensor([[1, 4, 4], [1, 2, 2]]),
+ None,
+ image_token_id=10,
+ video_token_id=20,
+ vision_start_token_id=30,
+ spatial_merge_size=2,
+ )
+ assert positions[:, 0, 6].tolist() == [0, 0, 0]
+ hf_rope = Qwen3VLTextRotaryEmbedding(hf_config.text_config)
+ # Exercise the real main RoPE arithmetic without allocating a CUDA buffer.
+ rope = Qwen3OmniMultimodalRotaryEmbedding.__new__(Qwen3OmniMultimodalRotaryEmbedding)
+ torch.nn.Module.__init__(rope)
+ rope.inv_freq = hf_rope.inv_freq
+ rope.seq_len_interpolation_factor = None
+ rope.cp_group = None
+ rope.is_thd_format = True
+ freqs = rope(positions, [1, 1, 0])[:, 0, 0].unsqueeze(0)
+ cos, sin = hf_rope(torch.zeros(1, 9, 4), positions)
+ torch.testing.assert_close(freqs.cos(), cos)
+ torch.testing.assert_close(freqs.sin(), sin)
+
+
+@pytest.mark.parametrize(("pp", "cp", "mtp"), [(2, 1, None), (1, 2, None), (1, 1, 1)])
+def test_provider_rejects_unimplemented_topologies(native, pp, cp, mtp):
+ with pytest.raises(ValueError, match="PP=1 and CP=1|MTP"):
+ native.get_qwen3_vl_model_provider(
+ SimpleNamespace(mtp_num_layers=mtp),
+ SimpleNamespace(pipeline_model_parallel_size=pp, context_parallel_size=cp),
+ None,
+ )
+
+
+def test_provider_uses_main_dense_deepstack_gpt(native, hf_config, monkeypatch):
+ calls = {}
+
+ def gpt(**kwargs):
+ calls.update(kwargs)
+ return SimpleNamespace(share_embeddings_and_output_weights=False)
+
+ monkeypatch.setattr(native, "Qwen3OmniMoeGPTModel", gpt)
+ monkeypatch.setattr(native, "_load_vision_model", lambda *args: torch.nn.Linear(8, 8))
+ monkeypatch.setattr(native.AutoConfig, "from_pretrained", lambda *args, **kwargs: hf_config)
+ monkeypatch.setattr(
+ native, "get_gpt_layer_with_transformer_engine_spec", lambda *, qk_layernorm: {"qk_layernorm": qk_layernorm}
+ )
+ args = SimpleNamespace(
+ hf_checkpoint="unused",
+ mtp_num_layers=None,
+ transformer_impl="transformer_engine",
+ normalization="RMSNorm",
+ padded_vocab_size=32,
+ max_position_embeddings=128,
+ fp16_lm_cross_entropy=False,
+ untie_embeddings_and_output_weights=True,
+ rotary_percent=1.0,
+ rotary_base=1000000,
+ )
+ config = SimpleNamespace(pipeline_model_parallel_size=1, context_parallel_size=1, normalization="RMSNorm")
+ model = native.get_qwen3_vl_model_provider(args, config, None)()
+ assert calls["transformer_layer_spec"] == {"qk_layernorm": True}
+ assert calls["config"] is config
+ assert calls["config"].normalization == "RMSNorm"
+ assert calls["position_embedding_type"] == "mrope"
+ assert calls["rotary_base"] == 5000000
+ assert calls["scatter_embedding_sequence_parallel"] is False
+ assert config.mrope_section == [1, 1, 0]
+ assert all(parameter.requires_grad for parameter in model.model.visual.parameters())
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__]))
diff --git a/tests/utils/test_megatron_role_config.py b/tests/utils/test_megatron_role_config.py
index fe1c847db..f6ebc5f4b 100644
--- a/tests/utils/test_megatron_role_config.py
+++ b/tests/utils/test_megatron_role_config.py
@@ -15,6 +15,7 @@
import _unit_stubs
_unit_stubs.install_rollout_optional_stubs()
+_unit_stubs.install_vllm_cli_stubs()
def _write_yaml(data: dict) -> str:
diff --git a/tests/utils/test_npu_accelerator.py b/tests/utils/test_npu_accelerator.py
new file mode 100644
index 000000000..709d62955
--- /dev/null
+++ b/tests/utils/test_npu_accelerator.py
@@ -0,0 +1,170 @@
+"""CPU contracts for NPU selection and pre-Megatron bootstrap ordering."""
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from vime.platforms import current_platform, get_platform, npu, reset_platform_cache
+from vime.platforms.npu import NPUAccelerator
+from vime.utils import accelerator
+
+
+@pytest.fixture(autouse=True)
+def npu_runtime(monkeypatch):
+ reset_platform_cache()
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+ monkeypatch.delenv("VIME_ACCELERATOR", raising=False)
+ monkeypatch.delenv("MUSA_VISIBLE_DEVICES", raising=False)
+ monkeypatch.delenv("MUSA_PATCH_PATH", raising=False)
+ monkeypatch.setattr(accelerator, "_REGISTRY", {})
+ monkeypatch.setattr(accelerator, "_ACCELERATOR", None)
+ monkeypatch.setattr(accelerator, "_cuda_available", lambda: False)
+ monkeypatch.setattr(accelerator, "is_musa_available", lambda: False)
+ calls = []
+ fake_npu = SimpleNamespace(
+ is_available=lambda: True,
+ current_device=lambda: 1,
+ device_count=lambda: 2,
+ set_device=lambda index: calls.append(("set_device", index)),
+ synchronize=lambda: calls.append("synchronize"),
+ empty_cache=lambda: calls.append("empty_cache"),
+ ipc_collect=lambda: calls.append("ipc_collect"),
+ )
+ monkeypatch.setattr(torch, "npu", fake_npu, raising=False)
+ yield calls
+ reset_platform_cache()
+
+
+def test_platform_registers_npu_before_main_auto_selection(monkeypatch, npu_runtime):
+ # MindSpeed may also make CUDA's availability probe return true.
+ monkeypatch.setattr(accelerator, "_cuda_available", lambda: True)
+ assert current_platform().is_npu
+ assert isinstance(accelerator.initialize_accelerator(), NPUAccelerator)
+ assert accelerator.device_type() == "npu"
+ assert accelerator.process_group_backend() == "hccl"
+ assert accelerator.process_group_backend("gloo") == "gloo"
+ assert accelerator.is_accelerator_backend("cpu:gloo,npu:hccl")
+ assert not accelerator.is_accelerator_backend("gloo")
+ assert accelerator.distributed_device_id() is None
+ assert accelerator.visible_devices_env_key() == "ASCEND_RT_VISIBLE_DEVICES"
+ monkeypatch.setenv("ASCEND_RT_VISIBLE_DEVICES", "4,7")
+ assert accelerator.resolve_visible_device_id("7") == 1
+ accelerator.set_device(1)
+ accelerator.synchronize()
+ accelerator.ipc_collect()
+ accelerator.empty_cache()
+ assert npu_runtime == [("set_device", 1), "synchronize", "ipc_collect", "empty_cache"]
+
+
+def test_npu_allocator_remains_owned_by_existing_runtime_hooks(monkeypatch):
+ monkeypatch.setenv("VIME_ENABLE_EXPANDABLE_SEGMENTS", "1")
+ assert NPUAccelerator().set_allocator_expandable_segments() is False
+
+
+def test_main_npu_override_selects_matching_platform(monkeypatch):
+ monkeypatch.delenv("VIME_PLATFORM")
+ monkeypatch.setenv("VIME_ACCELERATOR", "npu")
+ assert current_platform().is_npu
+ assert accelerator.get_accelerator().name == "npu"
+
+
+@pytest.mark.parametrize("platform,backend", [("npu", "cuda"), ("cuda", "npu"), ("npu", "musa")])
+def test_conflicting_overrides_fail_before_bootstrap(monkeypatch, platform, backend):
+ monkeypatch.setenv("VIME_PLATFORM", platform)
+ monkeypatch.setenv("VIME_ACCELERATOR", backend)
+ with pytest.raises(ValueError, match="Conflicting VIME_PLATFORM"):
+ current_platform()
+
+
+def test_registered_npu_does_not_override_explicit_cuda_platform(monkeypatch):
+ get_platform("npu")
+ monkeypatch.setenv("VIME_PLATFORM", "cuda")
+ monkeypatch.setattr(accelerator, "_cuda_available", lambda: True)
+ monkeypatch.setattr(accelerator.CUDAAccelerator, "is_available", lambda self: True)
+ assert current_platform().name == "cuda"
+ assert accelerator.initialize_accelerator().name == "cuda"
+
+
+def test_bootstrap_selects_npu_before_adaptor_and_attention(monkeypatch):
+ events = []
+ bootstrap = current_platform().megatron
+ monkeypatch.setattr(npu, "_ensure_torch_npu", lambda: events.append("torch_npu"))
+ monkeypatch.setattr(npu, "_install_safe_empty_cache", lambda: events.append("empty_cache_guard"))
+ original_import = npu.importlib.import_module
+
+ def import_module(name, *args, **kwargs):
+ if name in {"megatron_adaptor", "vime.backends.megatron_utils.npu_attention_patch"}:
+ assert accelerator.get_accelerator().name == "npu"
+ events.append(name)
+ bootstrap.bootstrap() # Recursive imports must not repeat initialization.
+ return SimpleNamespace()
+ return original_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(npu.importlib, "import_module", import_module)
+ bootstrap.bootstrap()
+ bootstrap.bootstrap()
+ assert events == [
+ "torch_npu",
+ "empty_cache_guard",
+ "megatron_adaptor",
+ "vime.backends.megatron_utils.npu_attention_patch",
+ ]
+
+
+def test_bootstrap_rejects_preselected_cuda_without_replacing_it(monkeypatch):
+ selected = accelerator.CUDAAccelerator()
+ monkeypatch.setattr(accelerator, "_ACCELERATOR", selected)
+ monkeypatch.setattr(npu, "_ensure_torch_npu", lambda: None)
+ bootstrap = current_platform().megatron
+ with pytest.raises(RuntimeError, match="already selected 'cuda'"):
+ bootstrap.bootstrap()
+ assert accelerator._ACCELERATOR is selected
+ assert not bootstrap._bootstrapping
+ assert not bootstrap._bootstrapped
+
+
+def test_repatch_passes_typed_args_and_restores_attention(monkeypatch):
+ events = []
+ full_args = SimpleNamespace(adaptor_default=True)
+ typed_config = {"weight_nz_mode": 0}
+ args = SimpleNamespace(vllm_additional_config=typed_config, tensor_model_parallel_size=4)
+ original_forward = object()
+ vime_forward = object()
+ attention_class = SimpleNamespace(forward=vime_forward)
+
+ def apply_features(config):
+ assert config is full_args
+ assert config.vllm_additional_config is typed_config
+ assert config.tensor_model_parallel_size == 4
+ assert config.adaptor_default
+ attention_class.forward = original_forward
+ events.append("features")
+
+ modules = {
+ "megatron_adaptor.features_manager.features_manager": SimpleNamespace(
+ FeaturesManager=SimpleNamespace(
+ remove_patches=lambda: events.append("remove"),
+ apply_features_pre_patches=lambda config: events.append(("pre", config)),
+ apply_features_patches=apply_features,
+ )
+ ),
+ "megatron_adaptor.utils.args_utils": SimpleNamespace(get_full_args=lambda: full_args),
+ "vime.backends.megatron_utils.npu_attention_patch": SimpleNamespace(
+ DotProductAttention=attention_class,
+ npu_dot_product_attention_forward=vime_forward,
+ ),
+ }
+ bootstrap = current_platform().megatron
+ original_import = npu.importlib.import_module
+
+ def import_module(name, *args, **kwargs):
+ if name in modules:
+ return modules[name]
+ return original_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(npu.importlib, "import_module", import_module)
+ bootstrap.repatch(args)
+ assert events == ["remove", ("pre", full_args), "features"]
+ assert attention_class.forward is vime_forward
+ assert args.vllm_additional_config is typed_config
diff --git a/tests/utils/test_npu_sync_scripts.py b/tests/utils/test_npu_sync_scripts.py
new file mode 100644
index 000000000..10f6ccaac
--- /dev/null
+++ b/tests/utils/test_npu_sync_scripts.py
@@ -0,0 +1,252 @@
+"""CPU contracts for the S7 checkpoint test modes and patch ordering."""
+
+import ast
+import importlib.util
+import shlex
+import textwrap
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[2]
+
+
+@pytest.fixture
+def glm_loader(monkeypatch, tmp_path):
+ monkeypatch.setenv("HF_HOME", str(tmp_path))
+ monkeypatch.delenv("VIME_TEST_GLM_MTP", raising=False)
+ monkeypatch.delenv("VIME_TEST_GLM_EAGER", raising=False)
+
+ def load():
+ spec = importlib.util.spec_from_file_location("glm_npu_case", REPO / "tests/test_glm4.7_30B_A3B_npu.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+ return load
+
+
+def test_glm_g1_uses_native_non_colocate_without_mtp(glm_loader, monkeypatch):
+ glm = glm_loader()
+ launches = []
+ monkeypatch.setattr(glm.U, "execute_train", lambda **kwargs: launches.append(kwargs))
+ glm.execute()
+ launch = launches[0]
+ tokens = shlex.split(launch["train_args"])
+ expected = {
+ "--hf-checkpoint": glm.MODEL_DIR,
+ "--ref-load": glm.MODEL_DIR,
+ "--actor-num-gpus-per-node": "8",
+ "--rollout-num-gpus": "8",
+ "--rollout-num-gpus-per-engine": "4",
+ "--tensor-model-parallel-size": "4",
+ "--expert-model-parallel-size": "8",
+ "--rope-type": "rope",
+ "--vllm-additional-config": '{"weight_nz_mode":0}',
+ "--num-rollout": "2",
+ }
+ for flag, value in expected.items():
+ assert tokens[tokens.index(flag) + 1] == value
+ assert "--vllm-enable-expert-parallel" in tokens
+ assert "--ci-test" in tokens
+ assert "--vllm-enforce-eager" not in tokens
+ assert not {
+ "--colocate",
+ "--megatron-to-hf-mode",
+ "--mtp-num-layers",
+ "--enable-mtp-training",
+ "--vllm-speculative-config",
+ "--dspark-enabled",
+ }.intersection(tokens)
+ assert launch["num_gpus_per_node"] == 16
+ assert launch["megatron_model_type"] == "glm4.7-30B-A3B"
+
+
+@pytest.mark.parametrize("mtp,eager", [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1")])
+def test_glm_explicit_mtp_and_eager_modes(glm_loader, monkeypatch, mtp, eager):
+ monkeypatch.setenv("VIME_TEST_GLM_MTP", mtp)
+ monkeypatch.setenv("VIME_TEST_GLM_EAGER", eager)
+ glm = glm_loader()
+ launches = []
+ monkeypatch.setattr(glm.U, "execute_train", lambda **kwargs: launches.append(kwargs))
+ glm.execute()
+ tokens = shlex.split(launches[0]["train_args"])
+ assert ("--vllm-enforce-eager" in tokens) == (eager == "1")
+ for flag in (
+ "--mtp-num-layers",
+ "--enable-mtp-training",
+ "--mtp-loss-scaling-factor",
+ "--vllm-speculative-config",
+ ):
+ assert (flag in tokens) == (mtp == "1")
+ if mtp == "1":
+ assert tokens[tokens.index("--mtp-num-layers") + 1] == "1"
+ assert tokens[tokens.index("--mtp-loss-scaling-factor") + 1] == "0.2"
+ assert tokens[tokens.index("--vllm-speculative-config") + 1] == '{"method":"mtp","num_speculative_tokens":1}'
+ assert "--colocate" not in tokens
+ assert "--dspark-enabled" not in tokens
+ assert tokens[tokens.index("--actor-num-gpus-per-node") + 1] == "8"
+ assert tokens[tokens.index("--rollout-num-gpus") + 1] == "8"
+ assert tokens[tokens.index("--rollout-num-gpus-per-engine") + 1] == "4"
+
+
+def test_glm_prepare_preserves_ci_download_defaults(glm_loader, monkeypatch):
+ glm = glm_loader()
+ commands = []
+ monkeypatch.setattr(glm.U, "exec_command", commands.append)
+ glm.prepare()
+ assert commands == [
+ f"mkdir -p {shlex.quote(f'{glm.TEST_ROOT}/models')} {shlex.quote(f'{glm.TEST_ROOT}/datasets')}",
+ f"hf download zai-org/GLM-4.7-Flash --local-dir {shlex.quote(glm.MODEL_DIR)}",
+ "hf download --repo-type dataset zhuzilin/dapo-math-17k " f"--local-dir {shlex.quote(glm.DATASET_DIR)}",
+ ]
+
+
+def test_glm_shell_keeps_g1_model_and_serving_configuration():
+ script = (REPO / "scripts/run-glm4.7-30B-A3B-npu.sh").read_text()
+ assert 'source "${SCRIPT_DIR}/models/glm4.7-30B-A3B.sh"' in script
+ assert "--rope-type rope" in script
+ assert "--vllm-additional-config '{\"weight_nz_mode\":0}'" in script
+ assert "--vllm-enable-expert-parallel" in script
+ for flag in (
+ "--colocate",
+ "--megatron-to-hf-mode",
+ "--mtp-num-layers",
+ "--enable-mtp-training",
+ "--vllm-speculative-config",
+ ):
+ assert flag not in script
+
+
+@pytest.fixture
+def qwen30(monkeypatch, tmp_path):
+ monkeypatch.setenv("HF_HOME", str(tmp_path))
+ (tmp_path / "models").mkdir()
+ spec = importlib.util.spec_from_file_location("qwen30_npu_case", REPO / "tests/test_qwen3_30B_A3B_npu.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_explicit_30b_hf_mode_skips_conversion(qwen30, monkeypatch):
+ commands = []
+ launches = []
+ monkeypatch.setattr(qwen30.U, "exec_command", commands.append)
+ monkeypatch.setattr(qwen30.U, "execute_train", lambda **kwargs: launches.append(kwargs))
+ assert qwen30.prepare(torch_dist_ref_load=False) is None
+ qwen30.execute()
+ assert not any("torch.distributed.run" in cmd or "rm -rf" in cmd for cmd in commands)
+ args = launches[0]["train_args"]
+ assert f"--ref-load {shlex.quote(qwen30.MODEL_DIR)} " in args
+ assert "--colocate " in args
+ assert "--tensor-model-parallel-size 4 " in args
+ assert "--expert-model-parallel-size 8 " in args
+ assert "weight_nz_mode" in args
+
+
+def test_default_torch_dist_mode_uses_new_output_and_ref_load(qwen30, monkeypatch, tmp_path):
+ commands = []
+ launches = []
+ existing = tmp_path / "models/Qwen3-30B-A3B_torch_dist"
+ existing.mkdir()
+ sentinel = existing / "keep"
+ sentinel.write_text("existing checkpoint")
+
+ def execute(command):
+ commands.append(command)
+ if "torch.distributed.run" in command:
+ tokens = shlex.split(command)
+ target = Path(tokens[tokens.index("--save") + 1])
+ (target / "latest_checkpointed_iteration.txt").write_text("release")
+ (target / ".metadata").write_bytes(b"test fixture")
+
+ monkeypatch.setattr(qwen30.U, "exec_command", execute)
+ monkeypatch.setattr(qwen30.U, "execute_train", lambda **kwargs: launches.append(kwargs))
+ checkpoint = qwen30.prepare()
+ qwen30.execute(checkpoint)
+ assert Path(checkpoint) != existing
+ assert sentinel.read_text() == "existing checkpoint"
+ assert not any("rm -rf" in command for command in commands)
+ conversion = next(command for command in commands if "torch.distributed.run" in command)
+ assert "VIME_PLATFORM=npu" in conversion
+ assert "--nproc-per-node 8 " in conversion
+ args = launches[0]["train_args"]
+ assert f"--ref-load {shlex.quote(checkpoint)} " in args
+ assert "--load " not in args
+ assert "--colocate " in args
+ assert "weight_nz_mode" in args
+
+
+@pytest.mark.parametrize("override,enabled", [(None, True), ("1", True), ("0", False)])
+def test_30b_main_checkpoint_mode(qwen30, monkeypatch, override, enabled):
+ monkeypatch.delenv("VIME_TEST_TORCH_DIST_REF_LOAD", raising=False)
+ if override is not None:
+ monkeypatch.setenv("VIME_TEST_TORCH_DIST_REF_LOAD", override)
+ for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ monkeypatch.delenv(key, raising=False)
+ checkpoint = object()
+ launches = []
+
+ def prepare(*, torch_dist_ref_load):
+ assert torch_dist_ref_load is enabled
+ return checkpoint if enabled else None
+
+ monkeypatch.setattr(qwen30, "prepare", prepare)
+ monkeypatch.setattr(qwen30, "execute", launches.append)
+ qwen30.main()
+ assert launches == [checkpoint if enabled else None]
+
+
+def test_converter_bootstraps_before_first_megatron_import():
+ tree = ast.parse((REPO / "tools/convert_hf_to_torch_dist.py").read_text())
+ first_megatron = next(
+ node.lineno
+ for node in tree.body
+ if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("megatron.")
+ )
+ bootstrap = next(
+ node.lineno
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Import) and any(alias.name == "vime.backends.megatron_utils" for alias in node.names)
+ )
+ assert bootstrap < first_megatron
+ assert "vime.utils.common" not in ast.unparse(tree)
+
+
+def test_common_megatron_patch_is_snapshotted_before_npu_patch():
+ entries = [
+ line.split("|")
+ for line in (REPO / "docker/npu_patch/series.conf").read_text().splitlines()
+ if line and not line.startswith("#")
+ ]
+ megatron = [entry for entry in entries if entry[0] == "/root/Megatron-LM"]
+ assert megatron == [
+ ["/root/Megatron-LM", "megatron-common.patch", "docker/patch/latest/megatron.patch"],
+ ["/root/Megatron-LM", "megatron.patch", "docker/npu_patch/megatron.patch"],
+ ]
+ dockerfile = (REPO / "docker/Dockerfile.npu").read_text()
+ assert "COPY docker/patch/latest/megatron.patch /opt/npu_patch/megatron-common.patch" in dockerfile
+ assert "/opt/vime_patch/megatron.patch" not in dockerfile
+
+
+def _megatron_patch_additions(path, patch_path="docker/npu_patch/megatron.patch"):
+ patch = (REPO / patch_path).read_text()
+ section = patch.split(f"diff --git a/{path} b/{path}\n", 1)[1].split("diff --git ", 1)[0]
+ return "\n".join(line[1:] for line in section.splitlines() if line.startswith("+") and not line.startswith("+++"))
+
+
+def test_npu_patch_keeps_public_transformer_layer():
+ patch = (REPO / "docker/npu_patch/megatron.patch").read_text()
+ assert "diff --git a/megatron/core/transformer/transformer_layer.py " not in patch
+
+
+def test_post_layernorm_flags_remain_dataclass_generated():
+ additions = _megatron_patch_additions(
+ "megatron/core/transformer/transformer_config.py", "docker/patch/latest/megatron.patch"
+ )
+ fields = ast.parse(textwrap.dedent(additions)).body
+ defaults = {node.target.id: ast.literal_eval(node.value) for node in fields if isinstance(node, ast.AnnAssign)}
+ npu_patch = (REPO / "docker/npu_patch/megatron.patch").read_text()
+ for name in ("post_self_attn_layernorm", "post_mlp_layernorm"):
+ assert defaults[name] is False
+ assert f"--{name.replace('_', '-')}" not in npu_patch
diff --git a/tests/utils/test_platform_contract.py b/tests/utils/test_platform_contract.py
new file mode 100644
index 000000000..c930fed82
--- /dev/null
+++ b/tests/utils/test_platform_contract.py
@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import sys
+from argparse import Namespace
+from types import ModuleType, SimpleNamespace
+
+import pytest
+
+from vime.platforms import current_platform, reset_platform_cache
+
+
+@pytest.fixture(autouse=True)
+def _reset_platform_selection():
+ reset_platform_cache()
+ yield
+ reset_platform_cache()
+
+
+def test_vime_platform_override_selects_cuda(monkeypatch):
+ monkeypatch.setenv("VIME_PLATFORM", "cuda")
+
+ platform = current_platform()
+
+ assert platform.name == "cuda"
+ assert platform.ray.resource_name == "GPU"
+ assert platform.ray.visible_devices_env == "CUDA_VISIBLE_DEVICES"
+ assert platform.checkpoint.default_megatron_to_hf_mode == "raw"
+
+
+def test_vime_platform_override_selects_npu_without_vendor_import(monkeypatch):
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+ before = {name for name in ("torch_npu", "vllm_ascend", "mindspeed") if name in sys.modules}
+
+ platform = current_platform()
+
+ after = {name for name in ("torch_npu", "vllm_ascend", "mindspeed") if name in sys.modules}
+ assert platform.name == "npu"
+ assert platform.ray.resource_name == "NPU"
+ assert platform.checkpoint.default_megatron_to_hf_mode == "bridge"
+ assert before == after
+
+
+def test_unknown_explicit_platform_has_clear_error(monkeypatch):
+ monkeypatch.setenv("VIME_PLATFORM", "not-registered")
+
+ with pytest.raises(ValueError, match="Unknown Vime platform"):
+ current_platform()
+
+
+def test_npu_auto_detection_does_not_import_vendor_without_device_nodes(monkeypatch):
+ from vime.platforms import npu
+
+ monkeypatch.setattr(npu.os.path, "exists", lambda path: False)
+ monkeypatch.setattr(npu, "glob", lambda pattern: [])
+
+ def fail_import(name):
+ raise AssertionError(f"unexpected import during negative NPU detection: {name}")
+
+ monkeypatch.setattr(npu.importlib, "import_module", fail_import)
+
+ assert npu.detect_npu() is False
+
+
+def test_npu_safe_empty_cache_wraps_original_once(monkeypatch):
+ from vime.platforms import npu
+
+ calls = []
+
+ def original_empty_cache():
+ calls.append("original")
+ raise RuntimeError("allocator is between offload states")
+
+ fake_torch = SimpleNamespace(
+ npu=SimpleNamespace(empty_cache=original_empty_cache),
+ cuda=SimpleNamespace(empty_cache=lambda: None),
+ )
+ monkeypatch.setattr(npu.importlib, "import_module", lambda name: fake_torch if name == "torch" else None)
+
+ npu._install_safe_empty_cache()
+ wrapped = fake_torch.npu.empty_cache
+ wrapped()
+ npu._install_safe_empty_cache()
+
+ assert calls == ["original"]
+ assert fake_torch.npu.empty_cache is wrapped
+ assert fake_torch.cuda.empty_cache is wrapped
+
+
+@pytest.mark.parametrize(
+ ("name", "bundle", "actor_options"),
+ [
+ ("cuda", {"GPU": 2, "CPU": 3}, {"num_gpus": 0.4}),
+ ("npu", {"NPU": 2, "CPU": 3}, {"resources": {"NPU": 0.4}}),
+ ],
+)
+def test_ray_resource_contract(monkeypatch, name, bundle, actor_options):
+ monkeypatch.setenv("VIME_PLATFORM", name)
+ ray_spec = current_platform().ray
+
+ assert ray_spec.bundle_resources(device_count=2, cpu_count=3) == bundle
+ assert ray_spec.actor_options(0.4) == actor_options
+
+
+def test_npu_runtime_env_is_scoped_to_npu_provider(monkeypatch, tmp_path):
+ toolkit = tmp_path / "toolkit"
+ (toolkit / "python" / "site-packages" / "acl").mkdir(parents=True)
+ monkeypatch.setenv("ASCEND_TOOLKIT_HOME", str(toolkit))
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+ args = Namespace(offload_train=True, train_backend="megatron", colocate=True)
+
+ train_env = current_platform().ray.train_runtime_env(args, {"BASE": "1"})
+ rollout_env = current_platform().ray.rollout_runtime_env(args, {"BASE": "1"})
+
+ assert train_env["TMS_HOOK_MODE"] == "torch"
+ assert train_env["TMS_REGION_TAG"] == "training"
+ assert train_env["TMS_ENABLE_CPU_BACKUP"] == "1"
+ assert train_env["PYTORCH_NPU_ALLOC_CONF"] == "expandable_segments:False"
+ assert str(toolkit / "python" / "site-packages") in train_env["PYTHONPATH"]
+ assert rollout_env["VLLM_USE_AOT_COMPILE"] == "0"
+ assert rollout_env["PYTORCH_NPU_ALLOC_CONF"] == "expandable_segments:False"
+
+
+def test_npu_vllm_env_replaces_cuda_and_rocm_visibility(monkeypatch):
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+ platform = current_platform()
+
+ env = platform.vllm.subprocess_env(
+ {
+ "KEEP": "1",
+ "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
+ "CUDA_VISIBLE_DEVICES": "0,1",
+ "HIP_VISIBLE_DEVICES": "0,1",
+ },
+ visible_devices="4,5",
+ colocate=True,
+ )
+
+ assert env["KEEP"] == "1"
+ assert "PYTORCH_CUDA_ALLOC_CONF" not in env
+ assert "CUDA_VISIBLE_DEVICES" not in env
+ assert "HIP_VISIBLE_DEVICES" not in env
+ assert env["ASCEND_RT_VISIBLE_DEVICES"] == "4,5"
+ assert env["PYTORCH_NPU_ALLOC_CONF"] == "expandable_segments:False"
+
+
+@pytest.mark.parametrize("name,backends", [("cuda", ("nccl", "ipc")), ("npu", ("hccl", "npu_ipc"))])
+def test_weight_transfer_provider_selects_matching_backend_and_init_info(monkeypatch, name, backends):
+ from vime.platforms import get_platform
+
+ plugin_calls = []
+ for colocate, backend in zip((False, True), backends, strict=True):
+ if name == "cuda":
+ module_name = f"vllm.distributed.weight_transfer.{backend}_engine"
+ cls_name = "IPCTrainerInitInfo" if colocate else "NCCLTrainerInitInfo"
+ else:
+ module_name = f"vllm_ascend.distributed.weight_transfer.{backend}_engine"
+ cls_name = "NPUIPCTrainerInitInfo" if colocate else "HCCLTrainerInitInfo"
+ module = ModuleType(module_name)
+ info_cls = type(cls_name, (SimpleNamespace,), {"backend": backend})
+ setattr(module, cls_name, info_cls)
+ monkeypatch.setitem(sys.modules, module_name, module)
+
+ plugins = ModuleType("vllm.plugins")
+ plugins.load_general_plugins = lambda: plugin_calls.append("load")
+ monkeypatch.setitem(sys.modules, "vllm.plugins", plugins)
+ monkeypatch.setitem(sys.modules, "torch_npu", ModuleType("torch_npu"))
+
+ ops = get_platform(name).weight_transfer
+ info = ops.trainer_init_info(colocate=colocate, rank=3, packed=True)
+ assert ops.backend("ipc" if colocate else "nccl") == info.backend == backend
+ assert isinstance(info, info_cls)
+ assert (info.rank, info.packed) == (3, True)
+
+ assert plugin_calls == (["load", "load"] if name == "npu" else [])
+ for backend in ("npu_ipc", "hccl", "custom"):
+ assert ops.backend(backend) == backend
+
+
+@pytest.mark.parametrize("platform", ["cuda", "npu"])
+@pytest.mark.parametrize("chained", [False, True])
+def test_optimizer_state_initialization_reuses_megatron_callback(monkeypatch, platform, chained):
+ monkeypatch.setenv("VIME_PLATFORM", platform)
+ calls = []
+
+ def init_state(optimizer, config):
+ calls.append((optimizer, config))
+
+ optimizers = [
+ SimpleNamespace(optimizer=object(), config=object(), init_state_fn=init_state)
+ for _ in range(2 if chained else 1)
+ ]
+ optimizer = SimpleNamespace(chained_optimizers=optimizers) if chained else optimizers[0]
+
+ current_platform().megatron.initialize_optimizer_state(optimizer)
+
+ expected = [(opt.optimizer, opt.config) for opt in optimizers] if platform == "npu" else []
+ assert calls == expected
+
+
+@pytest.mark.parametrize("empty_optimizer", [False, True])
+def test_npu_optimizer_state_initialization_skips_missing_state_or_optimizer(monkeypatch, empty_optimizer):
+ monkeypatch.setenv("VIME_PLATFORM", "npu")
+
+ def unexpected_init(*args):
+ raise AssertionError("an empty optimizer must not initialize state")
+
+ optimizer = SimpleNamespace(
+ optimizer=None if empty_optimizer else object(),
+ config=object(),
+ init_state_fn=unexpected_init if empty_optimizer else None,
+ )
+
+ current_platform().megatron.initialize_optimizer_state(optimizer)
+
+
+@pytest.mark.parametrize("platform", ["cuda", "npu"])
+def test_eval_only_has_no_optimizer_state_to_initialize(monkeypatch, platform):
+ monkeypatch.setenv("VIME_PLATFORM", platform)
+ current_platform().megatron.initialize_optimizer_state(None)
+
+
+def test_memory_utils_keep_main_accelerator_surface(monkeypatch):
+ from vime.utils import memory_utils
+
+ calls = []
+ fake_accelerator = SimpleNamespace(
+ synchronize=lambda: calls.append("synchronize"),
+ empty_cache=lambda: calls.append("empty_cache"),
+ )
+ fake_torch = SimpleNamespace(_C=SimpleNamespace(_host_emptyCache=lambda: calls.append("empty_host_cache")))
+ monkeypatch.setattr(memory_utils, "accelerator", fake_accelerator)
+ monkeypatch.setattr(memory_utils, "torch", fake_torch)
+ monkeypatch.setattr(memory_utils.gc, "collect", lambda: calls.append("gc"))
+
+ memory_utils.clear_memory(clear_host_memory=True)
+
+ assert calls == ["synchronize", "gc", "empty_cache", "empty_host_cache"]
diff --git a/tests/utils/test_ray_platform_integration.py b/tests/utils/test_ray_platform_integration.py
new file mode 100644
index 000000000..e5d19dcf4
--- /dev/null
+++ b/tests/utils/test_ray_platform_integration.py
@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import sys
+import types
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+
+def _fake_platform(ray_ops, *, is_npu=False):
+ return SimpleNamespace(ray=ray_ops, is_npu=is_npu)
+
+
+@pytest.mark.parametrize(
+ ("platform_name", "visible_env", "assigned_id", "expected_resource"),
+ [
+ ("cuda", "CUDA_VISIBLE_DEVICES", "7", "GPU"),
+ ("npu", "ASCEND_RT_VISIBLE_DEVICES", "9", "NPU"),
+ ],
+)
+def test_platform_ray_accelerator_ids_and_local_mapping(
+ monkeypatch,
+ platform_name,
+ visible_env,
+ assigned_id,
+ expected_resource,
+):
+ import ray
+
+ from vime.platforms import get_platform
+
+ platform = get_platform(platform_name)
+ monkeypatch.setenv(visible_env, f"unused,{assigned_id}")
+ if platform_name == "cuda":
+ monkeypatch.setattr(ray, "get_gpu_ids", lambda: [assigned_id])
+ else:
+ context = SimpleNamespace(get_accelerator_ids=lambda: {"NPU": [assigned_id]})
+ monkeypatch.setattr(ray, "get_runtime_context", lambda: context)
+
+ assert platform.ray.resource_name == expected_resource
+ assert platform.ray.accelerator_ids() == [assigned_id]
+ assert platform.ray.local_device_id() == 1
+
+
+def test_placement_group_uses_platform_ray_resource_contract(monkeypatch):
+ from vime.ray import placement_group as placement_group_module
+
+ ray_ops = SimpleNamespace(
+ resource_name="ACCEL",
+ bundle_resources=Mock(side_effect=lambda: {"ACCEL": 1, "CPU": 1}),
+ actor_options=Mock(side_effect=lambda fraction: {"resources": {"ACCEL": fraction}}),
+ )
+ monkeypatch.setattr(placement_group_module, "current_platform", lambda: _fake_platform(ray_ops, is_npu=True))
+
+ created = {}
+
+ class FakePlacementGroup:
+ def ready(self):
+ return "ready"
+
+ def fake_placement_group(bundles, strategy):
+ created["bundles"] = bundles
+ created["strategy"] = strategy
+ return FakePlacementGroup()
+
+ actor_options = []
+
+ class FakeInfoActor:
+ def __init__(self, result):
+ self.get_ip_and_gpu_id = SimpleNamespace(remote=lambda: result)
+
+ class FakeInfoActorClass:
+ results = iter([("10.0.0.1", "3"), ("10.0.0.1", "1")])
+
+ @classmethod
+ def options(cls, **options):
+ actor_options.append(options)
+ result = next(cls.results)
+ return SimpleNamespace(remote=lambda: FakeInfoActor(result))
+
+ monkeypatch.setattr(placement_group_module, "placement_group", fake_placement_group)
+ monkeypatch.setattr(placement_group_module, "InfoActor", FakeInfoActorClass)
+ wait_results = iter([([], ["ready"]), (["ready"], [])])
+ monkeypatch.setattr(placement_group_module.ray, "wait", lambda *_args, **_kwargs: next(wait_results))
+ monkeypatch.setattr(placement_group_module.ray, "cluster_resources", lambda: {"ACCEL": 2})
+ monkeypatch.setattr(placement_group_module.ray, "available_resources", lambda: {"ACCEL": 2})
+ monkeypatch.setattr(placement_group_module.ray, "get", lambda value: value)
+ monkeypatch.setattr(placement_group_module.ray, "kill", lambda _actor: None)
+
+ pg, reordered_indices, reordered_ids = placement_group_module._create_placement_group(2)
+
+ assert isinstance(pg, FakePlacementGroup)
+ assert created == {
+ "bundles": [{"ACCEL": 1, "CPU": 1}, {"ACCEL": 1, "CPU": 1}],
+ "strategy": "PACK",
+ }
+ assert reordered_indices == [1, 0]
+ assert reordered_ids == ["1", "3"]
+ assert [options["resources"] for options in actor_options] == [{"ACCEL": 1}, {"ACCEL": 1}]
+ assert [options["num_gpus"] for options in actor_options] == [0, 0]
+ assert ray_ops.bundle_resources.call_count == 2
+ assert [entry.args for entry in ray_ops.actor_options.call_args_list] == [(1,), (1,)]
+
+
+def test_ray_noset_visible_devices_keeps_ascend_entry():
+ from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST
+
+ assert "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES" in NOSET_VISIBLE_DEVICES_ENV_VARS_LIST
+
+
+def test_train_group_uses_npu_runtime_env_and_actor_resources(monkeypatch):
+ from vime.ray import actor_group as actor_group_module
+
+ runtime_env_inputs = []
+ ray_ops = SimpleNamespace(
+ train_runtime_env=lambda args, env: runtime_env_inputs.append((args, dict(env)))
+ or {**env, "PLATFORM_ENV": "1"},
+ actor_options=Mock(side_effect=lambda fraction: {"resources": {"ACCEL": fraction}}),
+ )
+ monkeypatch.setattr(actor_group_module, "current_platform", lambda: _fake_platform(ray_ops, is_npu=True))
+
+ actor_module = types.ModuleType("vime.backends.megatron_utils.actor")
+ actor_module.MegatronTrainRayActor = object
+ monkeypatch.setitem(sys.modules, "vime.backends.megatron_utils.actor", actor_module)
+
+ remote_declarations = []
+ actor_allocations = []
+
+ class FakeActorHandle:
+ get_master_addr_and_port = SimpleNamespace(remote=lambda: ("127.0.0.1", 20000))
+ init = SimpleNamespace(remote=lambda *_args, **_kwargs: 0)
+
+ class FakeRemoteActor:
+ def options(self, **options):
+ actor_allocations.append(options)
+ return self
+
+ def remote(self, *_args):
+ return FakeActorHandle()
+
+ def fake_remote(**options):
+ remote_declarations.append(options)
+ return lambda _actor_impl: FakeRemoteActor()
+
+ monkeypatch.setattr(actor_group_module.ray, "remote", fake_remote)
+ monkeypatch.setattr(actor_group_module.ray, "get", lambda value: value)
+
+ args = SimpleNamespace(
+ train_env_vars={"USER_ENV": "yes"},
+ offload_train=True,
+ train_backend="megatron",
+ colocate=False,
+ use_routing_replay=False,
+ )
+ group = actor_group_module.RayTrainGroup(
+ args=args,
+ num_nodes=1,
+ num_gpus_per_node=1,
+ pg=(object(), [0], [7]),
+ num_gpus_per_actor=0.4,
+ )
+ assert group.create() == [0]
+
+ assert runtime_env_inputs[0][0] is args
+ assert runtime_env_inputs[0][1]["USER_ENV"] == "yes"
+ assert remote_declarations[0]["runtime_env"]["env_vars"]["PLATFORM_ENV"] == "1"
+ assert remote_declarations[0]["num_gpus"] == 1
+ assert actor_allocations[0]["resources"] == {"ACCEL": 0.4}
+ assert actor_allocations[0]["num_gpus"] == 0
+ ray_ops.actor_options.assert_called_once_with(0.4)
+
+
+@pytest.mark.parametrize("is_npu", [False, True])
+def test_rollout_engine_delegates_runtime_env_and_actor_options(monkeypatch, is_npu):
+ from vime.backends.vllm_utils import engine_group as rollout_module
+
+ runtime_env_inputs = []
+ ray_ops = SimpleNamespace(
+ rollout_runtime_env=lambda args, env: runtime_env_inputs.append((args, dict(env)))
+ or {**env, "PLATFORM_ENV": "rollout"},
+ actor_options=Mock(side_effect=lambda fraction: {"resources": {"ACCEL": fraction}}),
+ )
+ monkeypatch.setattr(rollout_module, "current_platform", lambda: _fake_platform(ray_ops, is_npu=is_npu))
+
+ actor_options = []
+
+ class FakeEngine:
+ init = SimpleNamespace(remote=lambda **_kwargs: "init-ref")
+
+ class FakeRemoteActor:
+ def options(self, **options):
+ actor_options.append(options)
+ return self
+
+ def remote(self, *_args, **_kwargs):
+ return FakeEngine()
+
+ monkeypatch.setattr(rollout_module.ray, "remote", lambda _actor_impl: FakeRemoteActor())
+ monkeypatch.setattr(
+ rollout_module,
+ "_allocate_rollout_engine_addr_and_ports_normal",
+ lambda **_kwargs: ({0: {}}, {0: 15001}),
+ )
+
+ args = SimpleNamespace(
+ debug_train_only=False,
+ num_gpus_per_node=8,
+ rollout_num_gpus=1,
+ rollout_num_gpus_per_engine=1,
+ rollout_external=False,
+ colocate=True,
+ )
+ group = rollout_module.ServerGroup(
+ args=args,
+ pg=(object(), [0], [7]),
+ all_engines=[None],
+ num_gpus_per_engine=1,
+ num_new_engines=1,
+ )
+
+ handles, cursors = group.start_engines()
+
+ assert handles == ["init-ref"]
+ assert cursors == {0: 15001}
+ if is_npu:
+ assert runtime_env_inputs[0][0] is args
+ assert actor_options[0]["runtime_env"]["env_vars"]["PLATFORM_ENV"] == "rollout"
+ assert actor_options[0]["resources"] == {"ACCEL": 0.2}
+ assert actor_options[0]["num_gpus"] == 0
+ ray_ops.actor_options.assert_called_once_with(0.2)
+ else:
+ assert runtime_env_inputs == []
+ assert "resources" not in actor_options[0]
+ assert "PLATFORM_ENV" not in actor_options[0]["runtime_env"]["env_vars"]
+ assert actor_options[0]["num_gpus"] == 0.2
+ ray_ops.actor_options.assert_not_called()
+
+ # The main placement check must still run before an invalid slot is used.
+ group.all_engines = [None]
+ group.gpu_offset = 1
+ with pytest.raises(ValueError, match="Invalid rollout server group GPU placement"):
+ group.start_engines()
+
+
+def test_train_actor_uses_npu_local_device_mapping(monkeypatch):
+ from vime.ray import train_actor as train_actor_module
+
+ local_device_id = Mock(return_value=5)
+ monkeypatch.setattr(
+ train_actor_module,
+ "current_platform",
+ lambda: _fake_platform(SimpleNamespace(local_device_id=local_device_id), is_npu=True),
+ )
+
+ assert train_actor_module.get_local_gpu_id() == 5
+ local_device_id.assert_called_once_with()
+
+
+def test_train_actor_keeps_main_cuda_local_device_mapping(monkeypatch):
+ from vime.ray import train_actor as train_actor_module
+
+ monkeypatch.setattr(
+ train_actor_module,
+ "current_platform",
+ lambda: _fake_platform(SimpleNamespace(), is_npu=False),
+ )
+ monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "7,3")
+ monkeypatch.setattr(
+ train_actor_module.accelerator, "_ACCELERATOR", train_actor_module.accelerator.CUDAAccelerator()
+ )
+ monkeypatch.setattr(train_actor_module.ray, "get_gpu_ids", lambda: ["3"])
+
+ assert train_actor_module.get_local_gpu_id() == 1
diff --git a/tests/utils/test_update_weight_from_distributed.py b/tests/utils/test_update_weight_from_distributed.py
index d456cc226..f63a72b2b 100644
--- a/tests/utils/test_update_weight_from_distributed.py
+++ b/tests/utils/test_update_weight_from_distributed.py
@@ -187,7 +187,10 @@ def get_hf_weight_chunks(self, weights):
@pytest.mark.unit
def test_nccl_trainer_uses_single_packed_buffer(update_module, monkeypatch):
+ from vime.platforms import get_platform
+
adapter = sys.modules[update_module.create_nccl_trainer.__module__]
+ monkeypatch.setattr(adapter, "current_platform", lambda: get_platform("cuda"))
created = []
class NCCLTrainerInitInfo:
diff --git a/tests/utils/test_update_weight_from_tensor.py b/tests/utils/test_update_weight_from_tensor.py
index 815c9dd35..0e8ac0d61 100644
--- a/tests/utils/test_update_weight_from_tensor.py
+++ b/tests/utils/test_update_weight_from_tensor.py
@@ -188,6 +188,9 @@ def trainer_init(init_info, *, client, source):
@pytest.mark.unit
def test_connect_uses_native_ipc_and_nccl_trainers(update_module, monkeypatch):
+ from vime.platforms import get_platform
+
+ monkeypatch.setattr(update_module, "current_platform", lambda: get_platform("cuda"))
updater = _updater(update_module)
old_trainer = RecordingTrainer(object())
updater._native_trainers = [old_trainer]
diff --git a/tests/utils/test_vllm_arguments.py b/tests/utils/test_vllm_arguments.py
index 743f73831..6eeb10424 100644
--- a/tests/utils/test_vllm_arguments.py
+++ b/tests/utils/test_vllm_arguments.py
@@ -5,7 +5,7 @@
import argparse
import sys
from pathlib import Path
-from types import SimpleNamespace
+from types import ModuleType, SimpleNamespace
_tests_root = Path(__file__).resolve().parents[1]
if str(_tests_root) not in sys.path:
@@ -22,6 +22,20 @@
NUM_GPUS = 0
+@pytest.mark.unit
+@pytest.mark.parametrize("preloaded", [False, True])
+def test_real_module_available_rejects_missing_package_and_stub(monkeypatch, preloaded):
+ name = "_vime_missing_optional_dependency"
+ if preloaded:
+ monkeypatch.setitem(sys.modules, name, ModuleType(name))
+ assert not _unit_stubs.real_module_available(name)
+
+
+@pytest.mark.unit
+def test_real_module_available_accepts_loaded_real_module():
+ assert _unit_stubs.real_module_available("sys")
+
+
@pytest.fixture(scope="module")
def args_mod():
from vime.backends.vllm_utils import arguments as mod # noqa: PLC0415
@@ -172,11 +186,13 @@ def test_add_vllm_arguments_overrides_router_balance_threshold_defaults(args_mod
def _patch_device_config(monkeypatch):
- """Patch DeviceConfig.__post_init__ to avoid GPU device detection on CPU CI."""
+ """Avoid device detection and third-party plugin loading in parser tests."""
try:
from vllm.config.device import DeviceConfig
+ from vllm.engine import arg_utils
monkeypatch.setattr(DeviceConfig, "__post_init__", lambda self: setattr(self, "device_type", "cpu"))
+ monkeypatch.setattr(arg_utils, "load_general_plugins", lambda: None)
except ImportError:
pass
diff --git a/tests/utils/test_vllm_engine.py b/tests/utils/test_vllm_engine.py
index 5f599b397..bd540b0a5 100644
--- a/tests/utils/test_vllm_engine.py
+++ b/tests/utils/test_vllm_engine.py
@@ -349,6 +349,38 @@ def test_compute_server_args_adds_sleep_mode_for_offload_rollout(vllm_args):
assert vllm_args.vllm_enable_sleep_mode is True
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "platform_name,colocate,backend",
+ [("cuda", False, "nccl"), ("cuda", True, "ipc"), ("npu", False, "hccl"), ("npu", True, "npu_ipc")],
+)
+def test_compute_server_args_uses_native_platform_backend(vllm_args, monkeypatch, platform_name, colocate, backend):
+ from vime.platforms import get_platform
+
+ monkeypatch.setattr(mod, "_VLLM_SERVER_FIELDS", frozenset({"worker_extension_cls"}))
+ vllm_args.vllm_worker_extension_cls = ""
+ vllm_args.colocate = colocate
+ monkeypatch.setattr(mod, "current_platform", lambda: get_platform(platform_name))
+
+ sa, _ = mod._compute_server_args(vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000)
+
+ assert not sa.get("worker_extension_cls")
+ assert sa["weight_transfer_config"] == {"backend": backend}
+
+
+@pytest.mark.unit
+def test_compute_server_args_keeps_user_worker_extension(vllm_args, monkeypatch):
+ from vime.platforms import get_platform
+
+ monkeypatch.setattr(mod, "_VLLM_SERVER_FIELDS", frozenset({"worker_extension_cls"}))
+ vllm_args.vllm_worker_extension_cls = "example.UserWorkerExtension"
+ monkeypatch.setattr(mod, "current_platform", lambda: get_platform("npu"))
+
+ sa, _ = mod._compute_server_args(vllm_args, rank=0, dist_init_addr=None, host="127.0.0.1", port=8000)
+
+ assert sa["worker_extension_cls"] == "example.UserWorkerExtension"
+
+
@pytest.mark.unit
def test_compute_server_args_no_sleep_mode_from_colocate(vllm_args):
vllm_args.colocate = True
@@ -454,6 +486,7 @@ def fake_post(endpoint: str, payload: dict):
assert posted[0][0] == "update_weights"
sent = posted[0][1]["update_info"]
+ # ipc_handles are pickled for native vLLM/vLLM-Ascend parse_update_info.
# ipc_handles got cloudpickle'd into ipc_handles_pickled
assert "ipc_handles" not in sent
assert isinstance(sent["ipc_handles_pickled"], str)
diff --git a/tools/convert_hf_to_torch_dist.py b/tools/convert_hf_to_torch_dist.py
index 798e79ef6..45c7360ac 100644
--- a/tools/convert_hf_to_torch_dist.py
+++ b/tools/convert_hf_to_torch_dist.py
@@ -5,6 +5,11 @@
import torch
import torch.distributed as dist
+from vime.platforms import current_platform
+
+if current_platform().is_npu:
+ import vime.backends.megatron_utils # noqa: F401
+
from megatron.core.enums import ModelType
from megatron.training.arguments import parse_args, validate_args
from megatron.training.checkpointing import get_checkpoint_name, get_checkpoint_tracker_filename, save_checkpoint
diff --git a/train.py b/train.py
index 2130030f5..9b17f2ff4 100644
--- a/train.py
+++ b/train.py
@@ -1,5 +1,10 @@
import ray
+from vime.platforms import current_platform
+
+if current_platform().is_npu:
+ import vime.backends.megatron_utils # noqa: F401
+
from vime.observability.logging_utils import configure_logger, finish_tracking, init_tracking
from vime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models
from vime.utils.arguments import parse_args
diff --git a/train_async.py b/train_async.py
index 4cbc55bbc..5d58ecfb6 100644
--- a/train_async.py
+++ b/train_async.py
@@ -1,5 +1,10 @@
import ray
+from vime.platforms import current_platform
+
+if current_platform().is_npu:
+ import vime.backends.megatron_utils # noqa: F401
+
from vime.observability.logging_utils import configure_logger, finish_tracking, init_tracking
from vime.ray.placement_group import create_placement_groups, create_rollout_manager, create_training_models
from vime.utils.arguments import parse_args
diff --git a/vime/backends/megatron_utils/__init__.py b/vime/backends/megatron_utils/__init__.py
index 471d92043..feb890366 100644
--- a/vime/backends/megatron_utils/__init__.py
+++ b/vime/backends/megatron_utils/__init__.py
@@ -2,6 +2,11 @@
import torch
+from vime.platforms import current_platform
+
+# Load NPU prerequisites before the shared Megatron patches.
+current_platform().megatron.bootstrap()
+
from vime.utils import accelerator
accelerator.initialize_accelerator()
diff --git a/vime/backends/megatron_utils/actor.py b/vime/backends/megatron_utils/actor.py
index 13ded4186..e14682b22 100644
--- a/vime/backends/megatron_utils/actor.py
+++ b/vime/backends/megatron_utils/actor.py
@@ -16,6 +16,7 @@
from vime.observability.logging_utils import init_tracking
from vime.observability.profile_utils import TrainProfiler
from vime.observability.timer import Timer, inverse_timer, timer, with_defer
+from vime.platforms import current_platform
from vime.ray.train_actor import TrainRayActor
from vime.utils import accelerator
from vime.utils.data import process_rollout_data
@@ -77,6 +78,7 @@ def init(
init(args)
+ current_platform().megatron.repatch(args)
if is_megatron_main_rank():
init_tracking(args, primary=False, role=role)
@@ -91,9 +93,12 @@ def init(
dist.barrier(group=get_gloo_group())
- self.model, self.optimizer, self.opt_param_scheduler, loaded_rollout_id = initialize_model_and_optimizer(
- args, role
- )
+ with current_platform().megatron.training_context(args.offload_train):
+ self.model, self.optimizer, self.opt_param_scheduler, loaded_rollout_id = initialize_model_and_optimizer(
+ args, role
+ )
+ if args.offload_train:
+ current_platform().megatron.initialize_optimizer_state(self.optimizer)
vpp_size = mpu.get_virtual_pipeline_model_parallel_world_size() or 1
if vpp_size > 1:
@@ -602,7 +607,11 @@ def update_weights(self) -> None:
if dist.get_rank() == 0:
ray.get(self.rollout_manager.clear_updatable_num_new_engines.remote())
- with torch_memory_saver.disable() if self.args.offload_train else nullcontext():
+ with (
+ torch_memory_saver.disable()
+ if (self.args.offload_train and not current_platform().is_npu)
+ else nullcontext()
+ ):
if self.args.dspark_enabled and self.args.offload_train:
backup = self.weights_backuper.get("actor")
for name, param in named_params_and_buffers(self.args, self.model):
diff --git a/vime/backends/megatron_utils/checkpoint.py b/vime/backends/megatron_utils/checkpoint.py
index 122e97232..5ee3c7de1 100644
--- a/vime/backends/megatron_utils/checkpoint.py
+++ b/vime/backends/megatron_utils/checkpoint.py
@@ -8,6 +8,8 @@
from megatron.training.checkpointing import save_checkpoint
from megatron.training.global_vars import get_args
+from vime.platforms import current_platform
+
try:
# Here we patch out the `validate_non_overlapping_shards_metadata` in both functions
# because it is really slow for large models with many shards.
@@ -19,6 +21,7 @@
from torch.distributed._shard.sharded_tensor.shard import Shard
from torch.distributed._shard.sharded_tensor.utils import _parse_and_validate_remote_device
from torch.distributed._shard.sharding_spec.api import EnumerableShardingSpec
+ from torch.distributed.checkpoint import default_planner
def __post_init__(self):
pass
@@ -84,6 +87,8 @@ def _init_from_local_shards_and_global_metadata( # type: ignore[override]
ShardedTensor._init_from_local_shards_and_global_metadata = _init_from_local_shards_and_global_metadata
+ current_platform().checkpoint.patch_default_planner(default_planner)
+
except ImportError:
pass
diff --git a/vime/backends/megatron_utils/hf_to_megatron/__init__.py b/vime/backends/megatron_utils/hf_to_megatron/__init__.py
index 5276163f6..a5398ae97 100644
--- a/vime/backends/megatron_utils/hf_to_megatron/__init__.py
+++ b/vime/backends/megatron_utils/hf_to_megatron/__init__.py
@@ -9,6 +9,7 @@
from .qwen3_5 import qwen3_5_hf_tensor
from .qwen3_next import qwen3_next_hf_tensor
from .qwen3_omni import qwen3_omni_hf_tensor
+from .qwen3_vl import qwen3_vl_hf_tensor
_LOADERS = {
"deepseek_v3": deepseek_hf_tensor,
@@ -29,6 +30,7 @@
"qwen3_moe": qwen_moe_hf_tensor,
"qwen3_next": qwen3_next_hf_tensor,
"qwen3_omni_moe": qwen3_omni_hf_tensor,
+ "qwen3_vl": qwen3_vl_hf_tensor,
}
diff --git a/vime/backends/megatron_utils/hf_to_megatron/common.py b/vime/backends/megatron_utils/hf_to_megatron/common.py
index 06b874b20..8adff4198 100644
--- a/vime/backends/megatron_utils/hf_to_megatron/common.py
+++ b/vime/backends/megatron_utils/hf_to_megatron/common.py
@@ -9,6 +9,8 @@
import torch.nn.functional as F
from safetensors import safe_open
+from vime.platforms import current_platform
+
class SafetensorReader:
def __init__(self, path: str | Path):
@@ -129,7 +131,7 @@ def shard_mcore_tensor(name: str, tensor: torch.Tensor, parameter: torch.Tensor)
tensor,
parallel_size=parallel_size,
parallel_rank=parallel_rank,
- partition_dim=parameter.partition_dim,
+ partition_dim=current_platform().megatron.adjust_tp_partition_dim(name, parameter.partition_dim),
partition_stride=parameter.partition_stride,
)
diff --git a/vime/backends/megatron_utils/hf_to_megatron/qwen3_vl.py b/vime/backends/megatron_utils/hf_to_megatron/qwen3_vl.py
new file mode 100644
index 000000000..a5b350628
--- /dev/null
+++ b/vime/backends/megatron_utils/hf_to_megatron/qwen3_vl.py
@@ -0,0 +1,26 @@
+from .common import strip_mcore_wrappers
+from .qwen import qwen_hf_tensor
+
+
+class _LanguageReader:
+ def __init__(self, reader):
+ self.reader = reader
+
+ @staticmethod
+ def _key(name):
+ return name.replace("model.", "model.language_model.", 1) if name.startswith("model.") else name
+
+ def __contains__(self, name):
+ return self._key(name) in self.reader
+
+ def get_tensor(self, name):
+ return self.reader.get_tensor(self._key(name))
+
+
+def qwen3_vl_hf_tensor(name, reader, config):
+ name = strip_mcore_wrappers(name)
+ if name.startswith("model.visual."):
+ return reader.get_tensor(name)
+ if name == "output_layer.weight" and getattr(config, "tie_word_embeddings", False):
+ return reader.get_tensor("model.language_model.embed_tokens.weight")
+ return qwen_hf_tensor(name, _LanguageReader(reader), config.text_config)
diff --git a/vime/backends/megatron_utils/megatron_to_hf/processors/padding_remover.py b/vime/backends/megatron_utils/megatron_to_hf/processors/padding_remover.py
index 741584fa4..4e548adc9 100644
--- a/vime/backends/megatron_utils/megatron_to_hf/processors/padding_remover.py
+++ b/vime/backends/megatron_utils/megatron_to_hf/processors/padding_remover.py
@@ -7,6 +7,7 @@ def remove_padding(name: str, param: torch.Tensor, vocab_size: int) -> torch.Ten
"""
Remove vocab padding: param[:vocab_size] for embedding/output layers, else unchanged.
"""
- if strip_param_name_prefix(name) in {"embedding.word_embeddings.weight", "output_layer.weight"}:
+ name = strip_param_name_prefix(name).removeprefix("language_model.")
+ if name in {"embedding.word_embeddings.weight", "output_layer.weight"}:
return param[:vocab_size]
return param
diff --git a/vime/backends/megatron_utils/npu_attention_patch.py b/vime/backends/megatron_utils/npu_attention_patch.py
new file mode 100644
index 000000000..6cfc23222
--- /dev/null
+++ b/vime/backends/megatron_utils/npu_attention_patch.py
@@ -0,0 +1,81 @@
+import torch_npu
+from megatron.core.packed_seq_params import PackedSeqParams
+from megatron.core.transformer.enums import AttnMaskType
+from torch import Tensor
+
+try:
+ from einops import rearrange
+except ImportError:
+ rearrange = None
+
+
+def npu_dot_product_attention_forward(
+ self,
+ query: Tensor,
+ key: Tensor,
+ value: Tensor,
+ attention_mask: Tensor,
+ attn_mask_type: AttnMaskType = None,
+ attention_bias: Tensor = None,
+ packed_seq_params: PackedSeqParams | None = None,
+):
+ assert attention_bias is None, "Attention bias is not supported for DotProductAttention."
+
+ if packed_seq_params is None:
+ n_head = query.shape[2]
+ else:
+ n_head = query.shape[1]
+
+ sparse_mode = getattr(self.config, "sparse_mode", 0)
+ if attn_mask_type == AttnMaskType.no_mask:
+ sparse_mode = 0
+
+ scale = self.softmax_scale
+
+ pre_tockens = getattr(self.config, "pre_tockens", 65536)
+ next_tockens = getattr(self.config, "next_tockens", 0)
+
+ if packed_seq_params is not None:
+ if isinstance(packed_seq_params.cu_seqlens_q, list):
+ actual_seq_qlen = packed_seq_params.cu_seqlens_q
+ actual_seq_kvlen = packed_seq_params.cu_seqlens_kv
+ else:
+ actual_seq_qlen = packed_seq_params.cu_seqlens_q.tolist()
+ actual_seq_kvlen = packed_seq_params.cu_seqlens_kv.tolist()
+ shape_order = "TND"
+ else:
+ actual_seq_qlen = None
+ actual_seq_kvlen = None
+ if rearrange is not None:
+ query, key, value = [rearrange(x, "s b h d -> s b (h d)") for x in [query, key, value]]
+ else:
+ query = query.reshape(query.shape[0], query.shape[1], -1)
+ key = key.reshape(key.shape[0], key.shape[1], -1)
+ value = value.reshape(value.shape[0], value.shape[1], -1)
+ shape_order = "SBH"
+
+ output = torch_npu.npu_fusion_attention(
+ query,
+ key,
+ value,
+ n_head,
+ shape_order,
+ pse=None,
+ padding_mask=None,
+ atten_mask=attention_mask,
+ scale=scale,
+ pre_tockens=pre_tockens,
+ next_tockens=next_tockens,
+ keep_prob=1 - self.attention_dropout.p,
+ inner_precise=0,
+ sparse_mode=sparse_mode,
+ actual_seq_qlen=actual_seq_qlen,
+ actual_seq_kvlen=actual_seq_kvlen,
+ )[0]
+
+ return output
+
+
+from megatron.core.transformer.dot_product_attention import DotProductAttention
+
+DotProductAttention.forward = npu_dot_product_attention_forward
diff --git a/vime/backends/megatron_utils/update_weight/common.py b/vime/backends/megatron_utils/update_weight/common.py
index 14a2560f8..548f148c9 100644
--- a/vime/backends/megatron_utils/update_weight/common.py
+++ b/vime/backends/megatron_utils/update_weight/common.py
@@ -10,6 +10,7 @@
from megatron.core import mpu
from megatron.core.transformer.transformer_layer import get_transformer_layer_offset
+from vime.platforms import current_platform
from vime.utils.distributed_utils import get_gloo_group
from vime.utils.types import ParamInfo
@@ -51,6 +52,7 @@ def all_gather_param(name: str, param: torch.nn.Parameter) -> torch.Tensor:
if "linear_fc1.weight" in name or "linear_fc1.bias" in name:
param_partitions = [p.chunk(2, dim=0) for p in param_partitions]
param_partitions = [p[0] for p in param_partitions] + [p[1] for p in param_partitions]
+ partition_dim = current_platform().megatron.adjust_tp_partition_dim(name, partition_dim)
# this is bug in megatron's grouped moe.
if "linear_fc2.weight" in name:
if partition_dim == 0:
@@ -118,6 +120,7 @@ def all_gather_params_async(
if "linear_fc1.weight" in info.name or "linear_fc1.bias" in info.name:
param_partitions = [p.chunk(2, dim=0) for p in param_partitions]
param_partitions = [p[0] for p in param_partitions] + [p[1] for p in param_partitions]
+ partition_dim = current_platform().megatron.adjust_tp_partition_dim(info.name, partition_dim)
# this is bug in megatron's grouped moe.
if "linear_fc2.weight" in info.name:
if partition_dim == 0:
@@ -289,7 +292,6 @@ def create_nccl_trainer(
):
import ray
from vllm.distributed.weight_transfer.factory import WeightTransferTrainerFactory
- from vllm.distributed.weight_transfer.nccl_engine import NCCLTrainerInitInfo
rendezvous = [None]
if dist.get_rank() == 0:
@@ -299,7 +301,8 @@ def create_nccl_trainer(
dist.broadcast_object_list(rendezvous, src=0, group=get_gloo_group())
master_address, master_port = rendezvous[0]
return WeightTransferTrainerFactory.trainer_init(
- NCCLTrainerInitInfo(
+ current_platform().weight_transfer.trainer_init_info(
+ colocate=False,
master_address=master_address,
master_port=master_port,
world_size=sum(engine_gpu_counts) + 1,
diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py
index d3725dffb..c98997aa4 100644
--- a/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py
+++ b/vime/backends/megatron_utils/update_weight/update_weight_from_disk_delta.py
@@ -208,20 +208,16 @@ def _encode_delta(self) -> None:
# Pinned host-buffer pool: a pinned non_blocking GPU->CPU copy is far faster than .cpu().
max_bytes = max((int(v.nbytes) for v in snapshot.values()), default=0)
- free_q: queue.Queue = queue.Queue()
- use_pinned = True
- try:
- for _ in range(max(4, min(2 * NUM_WORKERS, (32 << 30) // max(max_bytes, 1)))):
- free_q.put(torch.empty(max_bytes, dtype=torch.uint8, pin_memory=True))
- except RuntimeError as e: # low memlock limit
- logger.warning("pinned host buffers unavailable (%s); using pageable .cpu()", e)
- use_pinned = False
+ free_q = _make_pinned_pool(max_bytes)
+ use_pinned = not free_q.empty()
def diff_and_compress(name, buf, nbytes, pinned):
if pinned: # copy out and free the pinned buffer before the heavy diff/compress
- new = np.empty(nbytes, dtype=np.uint8)
- np.copyto(new, buf.numpy()[:nbytes])
- free_q.put(buf)
+ try:
+ new = np.empty(nbytes, dtype=np.uint8)
+ np.copyto(new, buf.numpy()[:nbytes])
+ finally:
+ free_q.put(buf)
else:
new = buf
old = snapshot[name]
@@ -253,15 +249,23 @@ def collect(fut):
for name, tensor in self._iter_hf_tensors():
flat = tensor.detach().contiguous().view(torch.uint8).reshape(-1)
nbytes = int(flat.numel())
- 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)
- accelerator.current_stream().synchronize()
- payload, pinned = buf, True
- else:
- payload, pinned = flat.cpu().numpy(), False
- self.total_bytes += nbytes
- inflight.append(pool.submit(diff_and_compress, name, payload, nbytes, pinned))
+ buf = None
+ submitted = False
+ try:
+ if use_pinned and nbytes <= max_bytes:
+ buf = free_q.get() # backpressure until a worker returns a buffer
+ buf[:nbytes].copy_(flat, non_blocking=True)
+ accelerator.current_stream().synchronize()
+ payload, pinned = buf, True
+ else:
+ payload, pinned = flat.cpu().numpy().copy(), False
+ self.total_bytes += nbytes
+ future = pool.submit(diff_and_compress, name, payload, nbytes, pinned)
+ submitted = True # the worker now owns returning the buffer
+ inflight.append(future)
+ finally:
+ if buf is not None and not submitted:
+ free_q.put(buf)
if len(inflight) >= 2 * NUM_WORKERS:
collect(inflight.popleft())
while inflight:
@@ -291,6 +295,20 @@ def _record_metrics(self) -> None:
)
+def _make_pinned_pool(max_bytes: int) -> queue.Queue:
+ """Limit this pool's requested pinned storage to 8 GiB, excluding other CPU state."""
+ free_q: queue.Queue = queue.Queue()
+ num_buffers = min(2 * NUM_WORKERS, (8 << 30) // max_bytes) if max_bytes > 0 else 0
+ try:
+ for _ in range(num_buffers):
+ free_q.put(torch.empty(max_bytes, dtype=torch.uint8, pin_memory=True))
+ except (RuntimeError, MemoryError) as e:
+ while not free_q.empty():
+ free_q.get_nowait()
+ logger.warning("pinned host buffers unavailable (%s); using pageable .cpu()", e)
+ return free_q
+
+
def _atomic_write(path: str, data: bytes) -> None:
tmp = path + ".tmp"
with open(tmp, "wb") as f:
diff --git a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py
index c88da13d0..22c7ad8ef 100644
--- a/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py
+++ b/vime/backends/megatron_utils/update_weight/update_weight_from_tensor.py
@@ -13,6 +13,7 @@
from ray.actor import ActorHandle
from tqdm import tqdm
+from vime.platforms import current_platform
from vime.utils import accelerator
from vime.utils.distributed_utils import get_gloo_group
from vime.utils.types import ParamInfo
@@ -25,6 +26,16 @@
from .update_weight_from_distributed import post_process_weights
+def _current_gpu_uuid() -> str:
+ platform = current_platform()
+ if platform.is_npu:
+ return platform.weight_transfer.current_device_uuid()
+
+ device_index = torch.cuda.current_device()
+ props = torch.cuda.get_device_properties(device_index)
+ return str(props.uuid)
+
+
def _native_ipc_buffer_size(args: Namespace, param_info_buckets: Sequence[Sequence[ParamInfo]] | None) -> int:
buffer_size = args.update_weight_buffer_size
if not param_info_buckets:
@@ -64,7 +75,7 @@ def _build_packed_ipc_update_info(
)
assert chunk is not None
_, ipc_args = reduce_tensor(chunk.packed_tensor)
- gpu_uuid = str(torch.cuda.get_device_properties(torch.cuda.current_device()).uuid)
+ gpu_uuid = _current_gpu_uuid()
return (
{
"names": chunk.names,
@@ -188,11 +199,11 @@ def connect_rollout_engines(
if not self._expert_transfer_plan:
if self.rollout_engines:
from vllm.distributed.weight_transfer.factory import WeightTransferTrainerFactory
- from vllm.distributed.weight_transfer.ipc_engine import IPCTrainerInitInfo
client = VimeRayWeightSyncClient(self.rollout_engines, lambda: self.weight_version)
trainer = WeightTransferTrainerFactory.trainer_init(
- IPCTrainerInitInfo(
+ current_platform().weight_transfer.trainer_init_info(
+ colocate=True,
rank=dist.get_rank(),
packed=True,
packed_buffer_size_bytes=_native_ipc_buffer_size(
diff --git a/vime/backends/vllm_utils/engine_group.py b/vime/backends/vllm_utils/engine_group.py
index 324208aa3..4142c7859 100644
--- a/vime/backends/vllm_utils/engine_group.py
+++ b/vime/backends/vllm_utils/engine_group.py
@@ -10,6 +10,7 @@
from vime.backends.vllm_utils.vllm_config import ServerGroupConfig
from vime.backends.vllm_utils.vllm_engine import VLLMEngine, _resolve_parallel_sizes
+from vime.platforms import current_platform
from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, add_default_ray_env_vars
GPU_MEMORY_TYPE_KV_CACHE = "kv_cache"
@@ -117,6 +118,7 @@ def start_engines(self, port_cursors: dict[int, int] | None = None) -> tuple[lis
RolloutRayActor = ray.remote(VLLMEngine)
rollout_engines = []
+ platform = current_platform()
for i in range(len(self.all_engines)):
if self.all_engines[i] is not None:
continue
@@ -142,13 +144,18 @@ def start_engines(self, port_cursors: dict[int, int] | None = None) -> tuple[lis
env_vars["PYTORCH_CUDA_ALLOC_CONF"] = ",".join(
kv for kv in _alloc.split(",") if kv and not kv.strip().startswith("expandable_segments")
)
+ if platform.is_npu:
+ env_vars = platform.ray.rollout_runtime_env(self.args, env_vars)
+ resource_options = {"num_gpus": num_gpus}
+ if platform.is_npu:
+ resource_options = {"num_gpus": 0, **platform.ray.actor_options(num_gpus)}
rollout_engine = RolloutRayActor.options(
num_cpus=num_cpus,
- num_gpus=num_gpus,
scheduling_strategy=scheduling_strategy,
runtime_env={
"env_vars": add_default_ray_env_vars(env_vars),
},
+ **resource_options,
).remote(
self.args,
rank=global_rank,
diff --git a/vime/backends/vllm_utils/vllm_engine.py b/vime/backends/vllm_utils/vllm_engine.py
index 82c07e6ff..eaa4efddc 100644
--- a/vime/backends/vllm_utils/vllm_engine.py
+++ b/vime/backends/vllm_utils/vllm_engine.py
@@ -13,6 +13,7 @@
from vllm.utils.system_utils import kill_process_tree
from vime.backends.vllm_utils.external import get_server_info
+from vime.platforms import current_platform
from vime.ray.ray_actor import RayActor
from vime.utils.http_utils import _wrap_ipv6, get_host_info
@@ -67,6 +68,11 @@ def _build_subprocess_env(server_args_dict: dict[str, Any]) -> dict[str, str]:
env["CUDA_VISIBLE_DEVICES"] = server_args_dict["_visible_devices"]
# ROCm: keep HIP visibility in sync with CUDA (no-op on CUDA).
env["HIP_VISIBLE_DEVICES"] = server_args_dict["_visible_devices"]
+ env = current_platform().vllm.subprocess_env(
+ env,
+ visible_devices=server_args_dict["_visible_devices"],
+ colocate=getattr(args, "colocate", False),
+ )
env.setdefault("VLLM_SERVER_DEV_MODE", "1")
env["VLLM_USE_V2_MODEL_RUNNER"] = "1"
if getattr(args, "vllm_enable_deterministic_inference", False):
@@ -690,6 +696,9 @@ def _compute_server_args(
kwargs["weight_transfer_config"] = {"backend": "ipc"}
else:
kwargs["weight_transfer_config"] = {"backend": "nccl"}
+ kwargs["weight_transfer_config"]["backend"] = current_platform().weight_transfer.backend(
+ kwargs["weight_transfer_config"]["backend"]
+ )
if worker_type == "encoder":
# vLLM EPD producers have no language-model KV cache groups. Prefix
diff --git a/vime/platforms/__init__.py b/vime/platforms/__init__.py
new file mode 100644
index 000000000..8cec41787
--- /dev/null
+++ b/vime/platforms/__init__.py
@@ -0,0 +1,83 @@
+"""Accelerator platform discovery and narrow capability providers.
+
+``VIME_PLATFORM`` is the explicit override. When it is not set, NPU detection
+is lazy and failure-safe; CUDA is the default.
+"""
+
+from __future__ import annotations
+
+import os
+from functools import cache
+
+from .base import (
+ CheckpointCapabilities,
+ Platform,
+ RayResourceSpec,
+ TrainingBootstrap,
+ VLLMLaunchPlatformOps,
+ WeightTransferPlatformOps,
+)
+from .cuda import create_cuda_platform
+from .npu import create_npu_platform, detect_npu
+
+_PLATFORM_FACTORIES = {
+ "cuda": create_cuda_platform,
+ "npu": create_npu_platform,
+}
+
+
+@cache
+def get_platform(name: str) -> Platform:
+ normalized = name.strip().lower()
+ try:
+ factory = _PLATFORM_FACTORIES[normalized]
+ except KeyError as exc:
+ available = ", ".join(_PLATFORM_FACTORIES)
+ raise ValueError(f"Unknown Vime platform {name!r}; registered platforms: {available}") from exc
+ return factory()
+
+
+@cache
+def _resolve_platform(override: str | None) -> Platform:
+ if override:
+ return get_platform(override)
+
+ try:
+ if detect_npu():
+ return get_platform("npu")
+ except Exception: # noqa: BLE001 - a failed detector must not break imports
+ pass
+ return get_platform("cuda")
+
+
+def current_platform() -> Platform:
+ """Resolve the active platform without probing hardware at module import."""
+ raw_override = os.environ.get("VIME_PLATFORM")
+ override = raw_override.strip().lower() if raw_override and raw_override.strip() else None
+ accelerator_override = os.environ.get("VIME_ACCELERATOR", "").strip().lower()
+ if accelerator_override in {"npu", "cuda", "musa"}:
+ accelerator_platform = "npu" if accelerator_override == "npu" else "cuda"
+ if override in _PLATFORM_FACTORIES and override != accelerator_platform:
+ raise ValueError(f"Conflicting VIME_PLATFORM={override!r} and VIME_ACCELERATOR={accelerator_override!r}")
+ if override is None:
+ override = accelerator_platform
+ return _resolve_platform(override)
+
+
+def reset_platform_cache() -> None:
+ """Clear resolver/factory caches (primarily for tests and plugin registration)."""
+ get_platform.cache_clear()
+ _resolve_platform.cache_clear()
+
+
+__all__ = [
+ "CheckpointCapabilities",
+ "Platform",
+ "RayResourceSpec",
+ "TrainingBootstrap",
+ "VLLMLaunchPlatformOps",
+ "WeightTransferPlatformOps",
+ "current_platform",
+ "get_platform",
+ "reset_platform_cache",
+]
diff --git a/vime/platforms/base.py b/vime/platforms/base.py
new file mode 100644
index 000000000..c0775e5db
--- /dev/null
+++ b/vime/platforms/base.py
@@ -0,0 +1,143 @@
+"""Small contracts for behavior that genuinely differs by platform."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Mapping
+from contextlib import nullcontext
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class RayResourceSpec:
+ """How one accelerator is represented and addressed by Ray."""
+
+ resource_name: str
+ visible_devices_env: str
+ uses_ray_gpu_resource: bool
+
+ def bundle_resources(self, device_count: float = 1, cpu_count: float = 1) -> dict[str, float]:
+ return {self.resource_name: device_count, "CPU": cpu_count}
+
+ def actor_options(self, fraction: float) -> dict[str, object]:
+ if self.uses_ray_gpu_resource:
+ return {"num_gpus": fraction}
+ return {"resources": {self.resource_name: fraction}}
+
+ def accelerator_ids(self) -> list[str]:
+ import ray
+
+ if self.uses_ray_gpu_resource:
+ ids = ray.get_gpu_ids()
+ else:
+ ids = ray.get_runtime_context().get_accelerator_ids().get(self.resource_name, [])
+ return [str(device_id) for device_id in ids]
+
+ def local_device_id(self) -> int | str:
+ device_ids = self.accelerator_ids()
+ if not device_ids:
+ raise RuntimeError(f"No {self.resource_name} accelerator IDs are assigned to this Ray actor")
+
+ assigned_id = device_ids[0]
+ visible_devices = os.environ.get(self.visible_devices_env)
+ if visible_devices is None:
+ try:
+ return int(assigned_id)
+ except ValueError:
+ return assigned_id
+
+ visible_ids = [value.strip() for value in visible_devices.split(",") if value.strip()]
+ try:
+ return visible_ids.index(assigned_id)
+ except ValueError as exc:
+ raise RuntimeError(
+ f"Ray assigned {self.resource_name} id {assigned_id}, but it is absent from "
+ f"{self.visible_devices_env}={visible_devices!r}"
+ ) from exc
+
+ def train_runtime_env(
+ self,
+ args: Any,
+ env_vars: Mapping[str, str] | None = None,
+ ) -> dict[str, str]:
+ return dict(env_vars or {})
+
+ def rollout_runtime_env(
+ self,
+ args: Any,
+ env_vars: Mapping[str, str] | None = None,
+ ) -> dict[str, str]:
+ return dict(env_vars or {})
+
+
+class WeightTransferPlatformOps:
+ """Select vendor backends without changing the shared trainer lifecycle."""
+
+ def backend(self, backend: str) -> str:
+ return backend
+
+ def trainer_init_info(self, *, colocate: bool, **kwargs):
+ if colocate:
+ from vllm.distributed.weight_transfer.ipc_engine import IPCTrainerInitInfo
+
+ return IPCTrainerInitInfo(**kwargs)
+ from vllm.distributed.weight_transfer.nccl_engine import NCCLTrainerInitInfo
+
+ return NCCLTrainerInitInfo(**kwargs)
+
+
+class VLLMLaunchPlatformOps:
+ """Platform additions to the common vLLM launch command and environment."""
+
+ def subprocess_env(
+ self,
+ base_env: Mapping[str, str],
+ *,
+ visible_devices: str,
+ colocate: bool,
+ ) -> dict[str, str]:
+ return dict(base_env)
+
+
+class TrainingBootstrap:
+ """Lazy Megatron/vendor initialization hooks."""
+
+ def bootstrap(self) -> None:
+ return None
+
+ def repatch(self, args: Any) -> None:
+ return None
+
+ def adjust_tp_partition_dim(self, name: str, partition_dim: int) -> int:
+ return partition_dim
+
+ def training_context(self, offload_train: bool):
+ return nullcontext()
+
+ def initialize_optimizer_state(self, optimizer: Any) -> None:
+ return None
+
+
+@dataclass(frozen=True)
+class CheckpointCapabilities:
+ default_megatron_to_hf_mode: str = "raw"
+
+ def patch_default_planner(self, default_planner: Any) -> None:
+ return None
+
+
+@dataclass(frozen=True)
+class Platform:
+ """Aggregate only the providers whose semantics differ on Ascend."""
+
+ name: str
+ ray: RayResourceSpec
+ weight_transfer: WeightTransferPlatformOps
+ vllm: VLLMLaunchPlatformOps
+ megatron: TrainingBootstrap
+ checkpoint: CheckpointCapabilities
+
+ @property
+ def is_npu(self) -> bool:
+ return self.name == "npu"
diff --git a/vime/platforms/cuda.py b/vime/platforms/cuda.py
new file mode 100644
index 000000000..b068eebab
--- /dev/null
+++ b/vime/platforms/cuda.py
@@ -0,0 +1,25 @@
+"""Default CUDA platform assembled from the shared CUDA-compatible behavior."""
+
+from __future__ import annotations
+
+from .base import (
+ CheckpointCapabilities,
+ Platform,
+ RayResourceSpec,
+ TrainingBootstrap,
+ VLLMLaunchPlatformOps,
+ WeightTransferPlatformOps,
+)
+
+
+def create_cuda_platform() -> Platform:
+ return Platform(
+ name="cuda",
+ ray=RayResourceSpec(
+ resource_name="GPU", visible_devices_env="CUDA_VISIBLE_DEVICES", uses_ray_gpu_resource=True
+ ),
+ weight_transfer=WeightTransferPlatformOps(),
+ vllm=VLLMLaunchPlatformOps(),
+ megatron=TrainingBootstrap(),
+ checkpoint=CheckpointCapabilities(),
+ )
diff --git a/vime/platforms/npu.py b/vime/platforms/npu.py
new file mode 100644
index 000000000..94d2fea2a
--- /dev/null
+++ b/vime/platforms/npu.py
@@ -0,0 +1,292 @@
+"""Ascend NPU implementation of the Vime platform contracts."""
+
+from __future__ import annotations
+
+import importlib
+import logging
+import os
+from contextlib import nullcontext
+from glob import glob
+from typing import Any
+
+from vime.utils import accelerator
+from vime.utils.accelerator.torch_accelerator import TorchAccelerator
+
+from .base import (
+ CheckpointCapabilities,
+ Platform,
+ RayResourceSpec,
+ TrainingBootstrap,
+ VLLMLaunchPlatformOps,
+ WeightTransferPlatformOps,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class NPUAccelerator(TorchAccelerator):
+ name = "npu"
+ device_type = "npu"
+ communication_backend_name = "hccl"
+
+ def _module(self):
+ return getattr(importlib.import_module("torch"), "npu", None)
+
+ @property
+ def visible_devices_env(self) -> str:
+ return "ASCEND_RT_VISIBLE_DEVICES"
+
+ def distributed_device_id(self, index=None):
+ # Preserve lazy HCCL initialization instead of CUDA's eager device binding.
+ return None
+
+ def set_allocator_expandable_segments(self) -> bool:
+ # NPU allocator policy belongs to the existing TMS/runtime-env hooks.
+ return False
+
+
+def register_npu_accelerator() -> None:
+ accelerator.register_accelerator(
+ "npu",
+ NPUAccelerator,
+ is_available=lambda: os.environ.get("VIME_PLATFORM", "").strip().lower() != "cuda"
+ and NPUAccelerator().is_available(),
+ priority=300,
+ communication_backends=("hccl",),
+ )
+
+
+def detect_npu() -> bool:
+ """Return whether a usable NPU is visible, without leaking probe errors."""
+ # Do not import torch/torch_npu on an unselected CUDA host merely because
+ # torch_npu happens to be installed. Its import has process-wide monkey
+ # patch side effects. An explicit VIME_PLATFORM=npu override bypasses
+ # detection, while automatic selection first requires an exposed device.
+ if not (os.path.exists("/dev/davinci_manager") or glob("/dev/davinci[0-9]*")):
+ return False
+ try:
+ torch = importlib.import_module("torch")
+ if getattr(torch, "npu", None) is None:
+ importlib.import_module("torch_npu")
+ npu = getattr(torch, "npu", None)
+ return bool(npu is not None and npu.is_available())
+ except Exception: # noqa: BLE001 - detection must be safe on non-NPU hosts
+ return False
+
+
+def _ensure_torch_npu() -> None:
+ importlib.import_module("torch_npu")
+
+
+def _install_safe_empty_cache() -> None:
+ """Preserve the Ascend allocator guard required by MindSpeed/TMS callers."""
+ torch = importlib.import_module("torch")
+ original_empty_cache = torch.npu.empty_cache
+ if not getattr(original_empty_cache, "_vime_safe_empty_cache", False):
+
+ def _safe_empty_cache(_original=original_empty_cache) -> None:
+ try:
+ _original()
+ except RuntimeError:
+ pass
+
+ _safe_empty_cache._vime_safe_empty_cache = True
+ torch.npu.empty_cache = _safe_empty_cache
+
+ # Some shared dependencies still call the CUDA spelling after torch_npu's
+ # compatibility patching. Keep that alias local to NPU-bootstrapped jobs.
+ torch.cuda.empty_cache = torch.npu.empty_cache
+
+
+def _cann_python_site_packages() -> str | None:
+ candidates: list[str] = []
+ for env_key in ("ASCEND_TOOLKIT_HOME", "ASCEND_HOME_PATH"):
+ base = os.environ.get(env_key)
+ if not base:
+ continue
+ candidates.extend(
+ [
+ os.path.join(base, "python", "site-packages"),
+ os.path.normpath(os.path.join(base, "..", "python", "site-packages")),
+ ]
+ )
+ candidates.append("/usr/local/Ascend/ascend-toolkit/latest/python/site-packages")
+ for path in candidates:
+ if os.path.isdir(os.path.join(path, "acl")):
+ return path
+ return None
+
+
+def _prepend_pythonpath(env: dict[str, str], *paths: str) -> None:
+ existing = env.get("PYTHONPATH", os.environ.get("PYTHONPATH", ""))
+ existing_parts = {part for part in existing.split(os.pathsep) if part}
+ prefix_parts = [path for path in paths if path and path not in existing_parts]
+ if prefix_parts:
+ env["PYTHONPATH"] = os.pathsep.join([*prefix_parts, existing] if existing else prefix_parts)
+
+
+class NpuRayResourceSpec(RayResourceSpec):
+ def __init__(self) -> None:
+ super().__init__(
+ resource_name="NPU",
+ visible_devices_env="ASCEND_RT_VISIBLE_DEVICES",
+ uses_ray_gpu_resource=False,
+ )
+
+ def train_runtime_env(self, args: Any, env_vars=None) -> dict[str, str]:
+ env = dict(env_vars or {})
+ if not (getattr(args, "offload_train", False) and getattr(args, "train_backend", None) == "megatron"):
+ return env
+
+ env["TMS_HOOK_MODE"] = "torch"
+ env["TMS_REGION_TAG"] = "training"
+ env["TMS_ENABLE_CPU_BACKUP"] = "1"
+ if getattr(args, "colocate", False):
+ env["PYTORCH_NPU_ALLOC_CONF"] = "expandable_segments:False"
+ cann_python_path = _cann_python_site_packages()
+ if cann_python_path is not None:
+ _prepend_pythonpath(env, cann_python_path)
+ return env
+
+ def rollout_runtime_env(self, args: Any, env_vars=None) -> dict[str, str]:
+ env = dict(env_vars or {})
+ cann_python_path = _cann_python_site_packages()
+ if cann_python_path is not None:
+ _prepend_pythonpath(env, cann_python_path)
+ if getattr(args, "colocate", False):
+ env["PYTORCH_NPU_ALLOC_CONF"] = "expandable_segments:False"
+ env["VLLM_USE_AOT_COMPILE"] = "0"
+ return env
+
+
+class NpuWeightTransferPlatformOps(WeightTransferPlatformOps):
+ def current_device_uuid(self) -> str:
+ # Reuse vLLM Ascend's canonical host-IP/physical-chip identifier so
+ # trainer and receiver always use the exact same mapping.
+ from vllm_ascend.distributed.weight_transfer.npu_ipc_engine import npu_generate_uuid
+
+ return npu_generate_uuid()
+
+ def backend(self, backend: str) -> str:
+ return {"ipc": "npu_ipc", "nccl": "hccl"}.get(backend, backend)
+
+ def trainer_init_info(self, *, colocate: bool, **kwargs):
+ _ensure_torch_npu()
+ from vllm.plugins import load_general_plugins
+
+ # Trainers, unlike vLLM workers, may not have loaded general plugins yet.
+ load_general_plugins()
+ if colocate:
+ from vllm_ascend.distributed.weight_transfer.npu_ipc_engine import NPUIPCTrainerInitInfo
+
+ return NPUIPCTrainerInitInfo(**kwargs)
+ from vllm_ascend.distributed.weight_transfer.hccl_engine import HCCLTrainerInitInfo
+
+ return HCCLTrainerInitInfo(**kwargs)
+
+
+class NpuVLLMLaunchPlatformOps(VLLMLaunchPlatformOps):
+ def subprocess_env(self, base_env, *, visible_devices: str, colocate: bool) -> dict[str, str]:
+ env = dict(base_env)
+ env.pop("PYTORCH_CUDA_ALLOC_CONF", None)
+ env.pop("CUDA_VISIBLE_DEVICES", None)
+ env.pop("HIP_VISIBLE_DEVICES", None)
+ env["ASCEND_RT_VISIBLE_DEVICES"] = visible_devices
+ env["VLLM_USE_AOT_COMPILE"] = "0"
+ cann_python_path = _cann_python_site_packages()
+ if cann_python_path is not None:
+ _prepend_pythonpath(env, cann_python_path)
+ if colocate:
+ env["PYTORCH_NPU_ALLOC_CONF"] = "expandable_segments:False"
+ return env
+
+
+class NpuTrainingBootstrap(TrainingBootstrap):
+ def __init__(self) -> None:
+ self._bootstrapping = False
+ self._bootstrapped = False
+
+ def bootstrap(self) -> None:
+ if self._bootstrapped or self._bootstrapping:
+ return
+ self._bootstrapping = True
+ try:
+ _ensure_torch_npu()
+ # Select NPU before MegatronAdaptor can make torch.cuda appear available.
+ register_npu_accelerator()
+ selected = accelerator.get_accelerator()
+ if selected.name != "npu":
+ raise RuntimeError(f"NPU bootstrap cannot use an already selected {selected.name!r} accelerator")
+ _install_safe_empty_cache()
+ # MegatronAdaptor must install its pre-patches before any Megatron module
+ # is imported. Apply the NPU attention override afterwards.
+ importlib.import_module("megatron_adaptor")
+ importlib.import_module("vime.backends.megatron_utils.npu_attention_patch")
+ except Exception:
+ # A failed bootstrap may be retried after the runtime environment is
+ # corrected; never leave a partially initialized success marker.
+ raise
+ else:
+ self._bootstrapped = True
+ finally:
+ self._bootstrapping = False
+
+ def repatch(self, args: Any) -> None:
+ features_manager = importlib.import_module(
+ "megatron_adaptor.features_manager.features_manager"
+ ).FeaturesManager
+ full_args = importlib.import_module("megatron_adaptor.utils.args_utils").get_full_args()
+ for key, value in vars(args).items():
+ setattr(full_args, key, value)
+ features_manager.remove_patches()
+ features_manager.apply_features_pre_patches(full_args)
+ features_manager.apply_features_patches(full_args)
+ # Repatch may replace attention again; importing a cached module alone
+ # does not reinstall Vime's existing override.
+ attention = importlib.import_module("vime.backends.megatron_utils.npu_attention_patch")
+ attention.DotProductAttention.forward = attention.npu_dot_product_attention_forward
+
+ def adjust_tp_partition_dim(self, name: str, partition_dim: int) -> int:
+ if "linear_fc1.weight" in name or "linear_fc1.bias" in name:
+ return 0
+ return partition_dim
+
+ def training_context(self, offload_train: bool):
+ if not offload_train:
+ return nullcontext()
+ from torch_memory_saver import torch_memory_saver
+
+ return torch_memory_saver.region(tag="training", enable_cpu_backup=True)
+
+ def initialize_optimizer_state(self, optimizer: Any) -> None:
+ """Create lazy optimizer state before leaving the training memory pool."""
+ if optimizer is None:
+ return
+ for opt in getattr(optimizer, "chained_optimizers", [optimizer]):
+ if opt.optimizer is not None and opt.init_state_fn is not None:
+ opt.init_state_fn(opt.optimizer, opt.config)
+
+
+class NpuCheckpointCapabilities(CheckpointCapabilities):
+ def patch_default_planner(self, default_planner: Any) -> None:
+ if not hasattr(default_planner, "_validate_global_plan"):
+ return
+
+ def _validate_global_plan(global_plan, metadata):
+ logger.info("[NPU checkpoint] Skipping validate_access_integrity")
+ return True
+
+ default_planner._validate_global_plan = _validate_global_plan
+
+
+def create_npu_platform() -> Platform:
+ # Ray workers also resolve the platform without importing Megatron.
+ register_npu_accelerator()
+ return Platform(
+ name="npu",
+ ray=NpuRayResourceSpec(),
+ weight_transfer=NpuWeightTransferPlatformOps(),
+ vllm=NpuVLLMLaunchPlatformOps(),
+ megatron=NpuTrainingBootstrap(),
+ checkpoint=NpuCheckpointCapabilities(default_megatron_to_hf_mode="bridge"),
+ )
diff --git a/vime/ray/actor_group.py b/vime/ray/actor_group.py
index c3725d14c..2bb8ef5d6 100644
--- a/vime/ray/actor_group.py
+++ b/vime/ray/actor_group.py
@@ -7,6 +7,7 @@
from ray.util.placement_group import PlacementGroup
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
+from vime.platforms import current_platform
from vime.ray.utils import NOSET_VISIBLE_DEVICES_ENV_VARS_LIST, add_default_ray_env_vars
@@ -70,28 +71,24 @@ def _allocate_gpus_for_actor(self, pg, num_gpus_per_actor):
**self.args.train_env_vars,
}
- if self.args.offload_train:
- import torch_memory_saver
-
- for path in [
- "torch_memory_saver_hook_mode_preload_cu13.abi3.so",
- "torch_memory_saver_hook_mode_preload_cu12.abi3.so",
- "torch_memory_saver_hook_mode_preload.abi3.so",
- ]:
- dynlib_path = os.path.join(
- os.path.dirname(os.path.dirname(torch_memory_saver.__file__)),
- path,
- )
- if os.path.exists(dynlib_path):
- break
+ platform = current_platform()
+ if self.args.offload_train and self.args.train_backend == "megatron":
+ if platform.is_npu:
+ env_vars = platform.ray.train_runtime_env(self.args, env_vars)
else:
- raise FileNotFoundError(
- "Cannot find torch_memory_saver dynamic library. Please make sure torch_memory_saver is properly installed."
- )
-
- env_vars["LD_PRELOAD"] = dynlib_path
- env_vars["TMS_INIT_ENABLE"] = "1"
- env_vars["TMS_INIT_ENABLE_CPU_BACKUP"] = "1"
+ import torch_memory_saver
+
+ for path in [
+ "torch_memory_saver_hook_mode_preload_cu13.abi3.so",
+ "torch_memory_saver_hook_mode_preload_cu12.abi3.so",
+ "torch_memory_saver_hook_mode_preload.abi3.so",
+ ]:
+ dynlib_path = os.path.join(
+ os.path.dirname(os.path.dirname(torch_memory_saver.__file__)),
+ path,
+ )
+ if os.path.exists(dynlib_path):
+ break
# We cannot do routing replay for critic.
if self.args.use_routing_replay and self.role == "actor":
@@ -116,13 +113,16 @@ def _allocate_gpus_for_actor(self, pg, num_gpus_per_actor):
self._actor_handlers = []
master_addr, master_port = None, None
for rank in range(world_size):
+ resource_options = {"num_gpus": num_gpus_per_actor}
+ if platform.is_npu:
+ resource_options = {"num_gpus": 0, **platform.ray.actor_options(num_gpus_per_actor)}
actor = TrainRayActor.options(
num_cpus=num_gpus_per_actor,
- num_gpus=num_gpus_per_actor,
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=reordered_bundle_indices[rank],
),
+ **resource_options,
).remote(world_size, rank, master_addr, master_port)
if rank == 0:
master_addr, master_port = ray.get(actor.get_master_addr_and_port.remote())
diff --git a/vime/ray/placement_group.py b/vime/ray/placement_group.py
index 145677b62..a08886e81 100644
--- a/vime/ray/placement_group.py
+++ b/vime/ray/placement_group.py
@@ -6,6 +6,8 @@
from ray.util.placement_group import placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
+from vime.platforms import current_platform
+
from .actor_group import RayTrainGroup
from .utils import add_default_ray_env_vars
@@ -15,6 +17,17 @@
@ray.remote(num_gpus=1)
class InfoActor:
def get_ip_and_gpu_id(self):
+ platform = current_platform()
+ if platform.is_npu:
+ accelerator_ids = platform.ray.accelerator_ids()
+ if accelerator_ids:
+ return ray.util.get_node_ip_address(), accelerator_ids[0]
+
+ raise RuntimeError(
+ f"No {platform.ray.resource_name} accelerator IDs found. "
+ f"Accelerator IDs: {ray.get_runtime_context().get_accelerator_ids()}"
+ )
+
return ray.util.get_node_ip_address(), ray.get_gpu_ids()[0]
@@ -44,7 +57,11 @@ def _create_placement_group(num_gpus):
if num_gpus == 0:
return None, [], []
+ platform = current_platform()
+ resource_name = platform.ray.resource_name
bundles = [{"GPU": 1, "CPU": 1} for _ in range(num_gpus)]
+ if platform.is_npu:
+ bundles = [platform.ray.bundle_resources() for _ in range(num_gpus)]
pg = placement_group(bundles, strategy="PACK")
num_bundles = len(bundles)
@@ -59,22 +76,24 @@ def _create_placement_group(num_gpus):
log_interval = 30
while not ray.wait([ready_ref], timeout=log_interval)[0]:
elapsed += log_interval
- total = ray.cluster_resources().get("GPU", 0)
- available = ray.available_resources().get("GPU", 0)
+ total = ray.cluster_resources().get(resource_name, 0)
+ available = ray.available_resources().get(resource_name, 0)
logger.info(
- f"Waiting for placement group of {num_gpus} GPUs (elapsed {elapsed}s): "
- f"{total:g} GPUs registered with Ray, {available:g} available."
+ f"Waiting for placement group of {num_gpus} {resource_name} devices (elapsed {elapsed}s): "
+ f"{total:g} registered with Ray, {available:g} available."
)
# use info actor to get the GPU id
info_actors = []
for i in range(num_bundles):
+ resource_options = {"num_gpus": 0, **platform.ray.actor_options(1)} if platform.is_npu else {}
info_actors.append(
InfoActor.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=i,
),
+ **resource_options,
).remote()
)
gpu_ids = ray.get([actor.get_ip_and_gpu_id.remote() for actor in info_actors])
diff --git a/vime/ray/train_actor.py b/vime/ray/train_actor.py
index 72fea191c..a9da1b065 100644
--- a/vime/ray/train_actor.py
+++ b/vime/ray/train_actor.py
@@ -10,6 +10,7 @@
import vime.utils.eval_config
from vime.observability.logging_utils import configure_logger
+from vime.platforms import current_platform
from vime.ray.ray_actor import RayActor
from vime.utils import accelerator
from vime.utils.distributed_utils import init_gloo_group
@@ -19,6 +20,10 @@
def get_local_gpu_id():
+ platform = current_platform()
+ if platform.is_npu:
+ return platform.ray.local_device_id()
+
return accelerator.resolve_visible_device_id(ray.get_gpu_ids()[0])
diff --git a/vime/utils/disk_delta.py b/vime/utils/disk_delta.py
index c3abc65b1..e6631b794 100644
--- a/vime/utils/disk_delta.py
+++ b/vime/utils/disk_delta.py
@@ -59,15 +59,41 @@ def checksum(algorithm: str, buf) -> str:
def _tensor_locations(ckpt_dir: str) -> dict[str, tuple[str, int, int]]:
"""Map each tensor name to (file, byte offset, nbytes) by reading every safetensors header."""
+ paths = sorted(glob.glob(os.path.join(ckpt_dir, "*.safetensors")))
+ if not paths:
+ raise FileNotFoundError(f"No .safetensors files found in checkpoint directory: {ckpt_dir}")
locations: dict[str, tuple[str, int, int]] = {}
- for path in glob.glob(os.path.join(ckpt_dir, "*.safetensors")):
- with open(path, "rb") as f:
- (header_len,) = struct.unpack(" file_size - 8:
+ raise ValueError("declared header length exceeds file size")
+ header_bytes = f.read(header_len)
+ if len(header_bytes) != header_len:
+ raise ValueError("truncated header")
+ header = json.loads(header_bytes)
+ if not isinstance(header, dict):
+ raise ValueError("header must be a JSON object")
+ except (ValueError, UnicodeError, struct.error) as e:
+ raise RuntimeError(f"Failed to parse safetensors header from {path}: {e}") from e
+ data_size = file_size - 8 - header_len
for name, info in header.items():
if name == "__metadata__":
continue
- begin, end = info["data_offsets"]
+ offsets = info.get("data_offsets") if isinstance(info, dict) else None
+ if (
+ not isinstance(offsets, list)
+ or len(offsets) != 2
+ or any(type(value) is not int for value in offsets)
+ or not 0 <= offsets[0] <= offsets[1] <= data_size
+ ):
+ raise RuntimeError(f"Invalid data_offsets for tensor {name!r} in {path}: {offsets!r}")
+ begin, end = offsets
locations[name] = (path, 8 + header_len + begin, end - begin)
return locations
@@ -81,6 +107,9 @@ def read(name: str) -> np.ndarray:
path, offset, nbytes = locations[name]
with open(path, "rb") as f:
f.seek(offset)
- return np.frombuffer(f.read(nbytes), dtype=np.uint8)
+ data = f.read(nbytes)
+ if len(data) != nbytes:
+ raise RuntimeError(f"Truncated tensor {name!r} in {path}: expected {nbytes} bytes, read {len(data)}")
+ return np.frombuffer(data, dtype=np.uint8)
return read
diff --git a/vime/utils/external_utils/command_utils.py b/vime/utils/external_utils/command_utils.py
index bcfacd4b8..504772da5 100644
--- a/vime/utils/external_utils/command_utils.py
+++ b/vime/utils/external_utils/command_utils.py
@@ -10,6 +10,7 @@
from dataclasses import dataclass
from pathlib import Path
+from vime.utils.external_utils.launch import current_platform, launch_commands
from vime.utils.external_utils.typer_utils import dataclass_cli
from vime.utils.misc import exec_command
@@ -27,6 +28,11 @@ def convert_checkpoint(
dir_dst: str = "/root",
hf_checkpoint: str | None = None,
):
+ # Platforms without automatic conversion use native HF loading by default.
+ if not current_platform().torch_dist_convert:
+ print(f"convert_checkpoint skip on {current_platform().name} (native HF load)")
+ return
+
hf_checkpoint = hf_checkpoint or f"/root/models/{model_name}"
normalized_extra_args = f" {extra_args.strip()}" if extra_args.strip() else ""
@@ -104,6 +110,29 @@ def execute_train(
extra_env_vars = {}
if config is None:
config = ExecuteTrainConfig()
+
+ # Platform seam: non-CUDA delegates to the launcher; the CUDA path below stays
+ # byte-identical to upstream.
+ platform = current_platform()
+ if platform.name != "cuda":
+ cmds = launch_commands(
+ platform,
+ train_args=train_args,
+ num_devices=num_gpus_per_node,
+ megatron_model_type=megatron_model_type,
+ repo_base_dir=str(repo_base_dir),
+ train_script=train_script,
+ extra_env={**extra_env_vars, **_parse_extra_env_vars(config.extra_env_vars)},
+ external_ray=get_bool_env_var("VIME_SCRIPT_EXTERNAL_RAY"),
+ master_addr=os.environ.get("MASTER_ADDR", "127.0.0.1"),
+ )
+ for cmd in cmds[:-1]:
+ exec_command(cmd)
+ if before_ray_job_submit is not None:
+ before_ray_job_submit()
+ exec_command(cmds[-1])
+ return
+
external_ray = get_bool_env_var("VIME_SCRIPT_EXTERNAL_RAY")
master_addr = os.environ.get("MASTER_ADDR", "127.0.0.1")
diff --git a/vime/utils/external_utils/launch.py b/vime/utils/external_utils/launch.py
new file mode 100644
index 000000000..34368ea60
--- /dev/null
+++ b/vime/utils/external_utils/launch.py
@@ -0,0 +1,143 @@
+"""Device-specific launch policy for the vime test/example harness.
+
+Used only by the test and example launch utilities (`command_utils.execute_train`), not by
+vime core: a `Platform` describes one accelerator for the purpose of *launching a job* — how
+Ray advertises its devices, the device runtime env, whether torch_dist checkpoint conversion
+works, and how to construct the launch command. It adapts the core ``vime.platforms``
+selection to shell commands.
+
+Imports stay stdlib-only (torch imports are lazy) so the module is unit-testable in isolation;
+the actual `exec_command` calls live in `command_utils`.
+"""
+
+import json
+import shlex
+from dataclasses import dataclass, field
+
+
+# ── Platform contract ──────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class Platform:
+ name: str
+ ray_args: str # ray-start resource flags, "{n}"-templated with the device count
+ env: dict = field(default_factory=dict) # device runtime env (into runtime_env + raylet)
+ torch_dist_convert: bool = True # False -> use native HF loading without automatic conversion
+
+ def ray_start_args(self, num_devices: int) -> str:
+ return self.ray_args.format(n=num_devices)
+
+
+# ── Registry ────────────────────────────────────────────────────────────────
+
+PLATFORMS: dict[str, Platform] = {}
+
+
+def register(platform: Platform) -> None:
+ PLATFORMS[platform.name] = platform
+
+
+# ── Registered platforms (add a new accelerator here) ─────────────────────────
+
+
+register(Platform(name="cuda", ray_args="--num-gpus {n}")) # default; other fields unused for cuda
+
+register(
+ Platform(
+ name="npu",
+ # vime requests NPU bundles, not GPU (see ray/placement_group.py), so advertise
+ # the custom NPU resource rather than Ray GPU capacity.
+ ray_args="--num-gpus 0 --resources '{{\"NPU\": {n}}}'",
+ torch_dist_convert=False, # Keep HF tests unchanged; torch_dist has a separate opt-in test.
+ env={
+ "PYTHONPATH": (
+ "/root/Megatron-LM:/root/vime:"
+ "/root/Megatron-Bridge/src:/root/mbridge:"
+ "/root/MegatronAdaptor:/root/TransformerEngineNPU:"
+ "/usr/local/Ascend/ascend-toolkit/latest/python/site-packages:"
+ "/usr/local/Ascend/ascend-toolkit/latest/tools/ms_fmk_transplt/torch_npu_bridge"
+ ),
+ "LD_LIBRARY_PATH": (
+ "/usr/local/Ascend/driver/lib64:/usr/local/Ascend/driver/lib64/driver:"
+ "/usr/local/Ascend/driver/lib64/common:/usr/local/Ascend/ascend-toolkit/latest/lib64:"
+ "/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/opskernel:"
+ "/usr/local/Ascend/ascend-toolkit/latest/compiler/lib64/plugin/nnengine:"
+ "/usr/local/Ascend/ascend-toolkit/latest/opp/built-in/op_impl/ai_core/tbe/op_tiling/lib/:"
+ "/usr/local/Ascend/nnal/atb/latest/atb/cxx_abi_1/lib:"
+ "/usr/local/Ascend/cann/lib64:/usr/local/Ascend/cann/aarch64-linux/devlib"
+ ),
+ "CUDA_DEVICE_MAX_CONNECTIONS": "1",
+ "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES": "1",
+ "HCCL_HOST_SOCKET_PORT_RANGE": "60000-60050",
+ "HCCL_NPU_SOCKET_PORT_RANGE": "61000-61050",
+ "HCCL_CONNECT_TIMEOUT": "7200",
+ "PYTORCH_NPU_ALLOC_CONF": "expandable_segments:False",
+ "VLLM_DISABLE_COMPILE_CACHE": "1",
+ "VLLM_ASCEND_ENABLE_NZ": "0",
+ "HYDRA_FULL_ERROR": "1",
+ "TRANSFORMERS_VERBOSITY": "error", # silence transformers image-processing log spam
+ },
+ )
+)
+
+
+# ── Resolver + launcher (platform-agnostic; unchanged when adding a platform) ──
+
+
+def current_platform() -> Platform:
+ """Adapt the selected core runtime platform to the launch contract."""
+ from vime.platforms import current_platform as current_runtime_platform
+
+ selected = current_runtime_platform().name
+ try:
+ return PLATFORMS[selected]
+ except KeyError as exc:
+ raise ValueError(f"No launcher adapter is registered for platform {selected!r}") from exc
+
+
+def launch_commands(
+ platform: Platform,
+ train_args: str,
+ num_devices: int,
+ megatron_model_type: str | None,
+ repo_base_dir: str,
+ train_script: str = "train.py",
+ extra_env: dict | None = None,
+ external_ray: bool = False,
+ master_addr: str = "127.0.0.1",
+) -> list:
+ """Ordered shell commands to launch a training job on `platform` (pure; caller execs).
+
+ Driven entirely by platform data (ray resources + device env), so any non-CUDA platform
+ works with no change here. Lives outside `execute_train` so its CUDA body stays
+ byte-identical to upstream and only adds a one-line seam.
+ """
+ extra_env = extra_env or {}
+ all_env = {**platform.env, **extra_env}
+ all_env["VIME_PLATFORM"] = platform.name
+ cmds: list = []
+ cmds.append(
+ "pkill -9 -f '[v]llm serve|VLL[M]::'; sleep 3; "
+ + ("" if external_ray else "ray stop --force; pkill -9 ray; ")
+ + "pkill -9 vime; sleep 3; true; "
+ )
+ if not external_ray:
+ # Export the device env BEFORE `ray start` so the raylet and the actors it spawns
+ # (e.g. vLLM engines) inherit it; a job's runtime_env does not reach those actors.
+ exports = "".join(f"export {k}={shlex.quote(str(v))} && " for k, v in all_env.items())
+ cmds.append(
+ f"{exports}export PYTHONUNBUFFERED=1 && ray start --head --node-ip-address {master_addr} "
+ f"{platform.ray_start_args(num_devices)} --disable-usage-stats"
+ )
+
+ runtime_env = {"env_vars": {"no_proxy": f"127.0.0.1,{master_addr}", "MASTER_ADDR": master_addr, **all_env}}
+ src = f'source "{repo_base_dir}/scripts/models/{megatron_model_type}.sh" && ' if megatron_model_type else ""
+ model_args = "${MODEL_ARGS[@]}" if megatron_model_type else ""
+ cmds.append(
+ f"export no_proxy=127.0.0.1 && export PYTHONUNBUFFERED=1 && {src}"
+ f'ray job submit --address="http://127.0.0.1:8265" '
+ f"--runtime-env-json={shlex.quote(json.dumps(runtime_env))} "
+ f"-- python3 {train_script} {model_args} {train_args}"
+ )
+ return cmds
diff --git a/vime_plugins/models/qwen3_vl.py b/vime_plugins/models/qwen3_vl.py
new file mode 100644
index 000000000..de5782c6f
--- /dev/null
+++ b/vime_plugins/models/qwen3_vl.py
@@ -0,0 +1,201 @@
+"""Native Qwen3-VL: Megatron language model and a replicated HF vision tower."""
+
+from __future__ import annotations
+
+import torch
+from megatron.core import mpu, tensor_parallel
+from megatron.core.models.gpt.gpt_layer_specs import get_gpt_layer_with_transformer_engine_spec
+from megatron.core.transformer.module import MegatronModule
+from transformers import AutoConfig
+
+from .qwen3_5_vl_utils import build_packed_mrope_position_ids
+from .qwen3_omni_moe import Qwen3OmniMoeGPTModel
+from .qwen3_omni_transformer import split_deepstack_embeddings
+
+
+def _load_vision_model(hf_config, config):
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionModel
+
+ device = (
+ torch.device("cpu") if config.use_cpu_initialization else torch.device("cuda", torch.cuda.current_device())
+ )
+ with device:
+ vision_model = Qwen3VLVisionModel._from_config(hf_config.vision_config, attn_implementation="sdpa")
+ vision_model.to(dtype=config.params_dtype)
+ if config.recompute_granularity == "full":
+ vision_model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
+ for parameter in vision_model.parameters():
+ parameter.tensor_model_parallel = False
+ parameter.partition_dim = -1
+ parameter.partition_stride = 1
+ return vision_model
+
+
+class Qwen3VLModel(MegatronModule):
+ def __init__(self, config, language_layer_spec, hf_config, args, *, pre_process, post_process, vp_stage):
+ super().__init__(config=config)
+ self.pre_process = pre_process
+ self.post_process = post_process
+ self.image_token_id = hf_config.image_token_id
+ self.video_token_id = hf_config.video_token_id
+ self.vision_start_token_id = hf_config.vision_start_token_id
+ self.spatial_merge_size = hf_config.vision_config.spatial_merge_size
+
+ text_config = hf_config.text_config
+ rope = getattr(text_config, "rope_parameters", None) or text_config.rope_scaling
+ config.mrope_section = list(rope["mrope_section"])
+ config.position_embedding_type = "mrope"
+ config.rotary_base = rope.get("rope_theta", getattr(text_config, "rope_theta", args.rotary_base))
+ config.apply_rope_fusion = False
+ # The main Omni GPT class is also usable with a dense Qwen layer spec:
+ # its additions are interleaved MRoPE and checkpoint-aware DeepStack.
+ self.language_model = Qwen3OmniMoeGPTModel(
+ config=config,
+ transformer_layer_spec=language_layer_spec,
+ vocab_size=args.padded_vocab_size,
+ max_sequence_length=args.max_position_embeddings,
+ pre_process=pre_process,
+ post_process=post_process,
+ fp16_lm_cross_entropy=args.fp16_lm_cross_entropy,
+ parallel_output=True,
+ share_embeddings_and_output_weights=not args.untie_embeddings_and_output_weights,
+ position_embedding_type="mrope",
+ rotary_percent=args.rotary_percent,
+ rotary_base=config.rotary_base,
+ scatter_embedding_sequence_parallel=False,
+ vp_stage=vp_stage,
+ )
+ self.model = torch.nn.Module()
+ self.model.visual = _load_vision_model(hf_config, config) if pre_process else None
+ self.share_embeddings_and_output_weights = self.language_model.share_embeddings_and_output_weights
+
+ @property
+ def decoder(self):
+ return self.language_model.decoder
+
+ def shared_embedding_or_output_weight(self):
+ return self.language_model.shared_embedding_or_output_weight()
+
+ def set_input_tensor(self, input_tensor):
+ self.language_model.set_input_tensor(input_tensor)
+
+ def _inject_vision_embeddings(self, input_ids, pixel_values, pixel_values_videos, image_grid_thw, video_grid_thw):
+ embeddings = self.language_model.embedding(input_ids=input_ids, position_ids=None).transpose(0, 1).clone()
+ visual_mask = torch.zeros_like(input_ids, dtype=torch.bool)
+ positions, deepstack = [], []
+ for values, grids, token_id in (
+ (pixel_values, image_grid_thw, self.image_token_id),
+ (pixel_values_videos, video_grid_thw, self.video_token_id),
+ ):
+ mask = input_ids == token_id
+ if values is None:
+ if grids is not None or mask.any():
+ raise ValueError("Qwen3-VL vision tokens/grids require matching pixel values")
+ continue
+ if grids is None:
+ raise ValueError("Qwen3-VL pixel values require matching grid_thw")
+ output = self.model.visual(values.to(dtype=self.model.visual.dtype), grid_thw=grids)
+ if hasattr(output, "pooler_output"):
+ features, layer_features = output.pooler_output, output.deepstack_features
+ else:
+ features, layer_features = output
+ if mask.sum().item() != features.shape[0]:
+ raise ValueError("Qwen3-VL token/features count mismatch")
+ embeddings[mask] = features.to(embeddings)
+ visual_mask |= mask
+ positions.append(mask.flatten().nonzero(as_tuple=False).flatten())
+ deepstack.append(layer_features)
+
+ deepstack_features = None
+ if deepstack:
+ # Images and videos are encoded separately but can interleave in a sample.
+ order = torch.cat(positions).argsort()
+ deepstack_features = [
+ torch.cat(features)[order].to(embeddings) for features in zip(*deepstack, strict=True)
+ ]
+ embeddings = embeddings.transpose(0, 1).contiguous()
+ if self.config.sequence_parallel:
+ embeddings = tensor_parallel.scatter_to_sequence_parallel_region(embeddings).contiguous()
+ if deepstack_features is not None:
+ # Unlike Omni's frozen tower, this tower remains trainable. Each
+ # SP rank uses only part of DeepStack; sum its feature gradients
+ # before backpropagating through the replicated vision tower.
+ deepstack_features = [
+ tensor_parallel.copy_to_tensor_model_parallel_region(features) for features in deepstack_features
+ ]
+ visual_mask, deepstack_features = split_deepstack_embeddings(
+ visual_mask,
+ deepstack_features,
+ tp_size=mpu.get_tensor_model_parallel_world_size(),
+ tp_rank=mpu.get_tensor_model_parallel_rank(),
+ sequence_parallel=True,
+ )
+ return embeddings, visual_mask if deepstack_features is not None else None, deepstack_features
+
+ def forward(
+ self,
+ input_ids,
+ position_ids=None,
+ attention_mask=None,
+ labels=None,
+ packed_seq_params=None,
+ loss_mask=None,
+ pixel_values=None,
+ pixel_values_videos=None,
+ image_grid_thw=None,
+ video_grid_thw=None,
+ **kwargs,
+ ):
+ if packed_seq_params is None or packed_seq_params.qkv_format != "thd":
+ raise ValueError("Qwen3-VL native training requires THD packed sequences")
+ if position_ids is None:
+ cu_seqlens = packed_seq_params.cu_seqlens_q_padded
+ if cu_seqlens is None:
+ cu_seqlens = packed_seq_params.cu_seqlens_q
+ position_ids = build_packed_mrope_position_ids(
+ input_ids,
+ cu_seqlens,
+ image_grid_thw,
+ video_grid_thw,
+ image_token_id=self.image_token_id,
+ video_token_id=self.video_token_id,
+ vision_start_token_id=self.vision_start_token_id,
+ spatial_merge_size=self.spatial_merge_size,
+ )
+ embeddings, visual_mask, deepstack = self._inject_vision_embeddings(
+ input_ids, pixel_values, pixel_values_videos, image_grid_thw, video_grid_thw
+ )
+ self.language_model.rotary_pos_emb.is_thd_format = True
+ return self.language_model(
+ input_ids=input_ids,
+ position_ids=position_ids,
+ attention_mask=attention_mask,
+ decoder_input=embeddings,
+ labels=labels,
+ packed_seq_params=packed_seq_params,
+ loss_mask=loss_mask,
+ visual_pos_masks=visual_mask,
+ deepstack_visual_embeds=deepstack,
+ **kwargs,
+ )
+
+
+def get_qwen3_vl_model_provider(args, config, vp_stage):
+ """Use main's --spec provider interface without adding a Bridge branch."""
+ if config.pipeline_model_parallel_size != 1 or config.context_parallel_size != 1:
+ raise ValueError("Qwen3-VL native training currently supports PP=1 and CP=1")
+ if args.mtp_num_layers:
+ raise ValueError("Qwen3-VL native MTP is not supported")
+ if args.transformer_impl != "transformer_engine":
+ raise ValueError("Qwen3-VL native training requires the TE/MindSpeed layer spec")
+ hf_config = AutoConfig.from_pretrained(args.hf_checkpoint, trust_remote_code=True)
+ if hf_config.model_type != "qwen3_vl":
+ raise ValueError(f"{args.hf_checkpoint} is not a Qwen3-VL checkpoint")
+ layer_spec = get_gpt_layer_with_transformer_engine_spec(qk_layernorm=True)
+
+ def model_provider(pre_process=True, post_process=True, vp_stage=None):
+ return Qwen3VLModel(
+ config, layer_spec, hf_config, args, pre_process=pre_process, post_process=post_process, vp_stage=vp_stage
+ )
+
+ return model_provider