Skip to content

Commit 3ef4d9b

Browse files
committed
Merge main to fix GB200 config-lock permissions
Signed-off-by: Chen Cui <chcui@nvidia.com>
2 parents ec13f2e + d8fe0b5 commit 3ef4d9b

8 files changed

Lines changed: 267 additions & 26 deletions

File tree

‎.github/actions/test-template/action.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ runs:
122122
echo "::group::Start test container"
123123
if [[ "$MOUNT_FS" == "true" ]]; then
124124
VOLUME_ARGS="--volume ${{ inputs.test-data-path }}:/home/TestData --env HF_HUB_OFFLINE=1"
125+
# The shared offline cache may be read-only to the container user.
126+
# All ranks in these single-container jobs share this writable lock directory.
127+
VOLUME_ARGS+=" --env MEGATRON_CONFIG_LOCK_DIR=/home/ubuntu/.cache/huggingface"
125128
HF_HOME=/home/TestData/HF_HOME
126129
NEMO_HOME=/home/TestData/nemo_home
127130
else

‎docs/fern/versions/0.4.2/pages/index.mdx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,22 +159,22 @@ Megatron Bridge provides out-of-the-box bridges and training recipes for a wide
159159

160160
### Nemotron (NVIDIA)
161161

162-
- [Nemotron Nano v2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotronh) — [recipes (9B/12B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/nemotronh/nemotron_nano_v2.py)
162+
- [Nemotron Nano v2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotronh) — [recipes (9B/12B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/v0.4.2/src/megatron/bridge/recipes/nemotronh/nemotron_nano_v2.py)
163163
- [Nemotron Nano v3](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotronh) — [recipes (30B-A3B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/nemotronh/nemotron_3_nano.py)
164164
- [Nemotron Super v3](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotronh) — [recipes (120B-A12B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/nemotronh/nemotron_3_super.py)
165-
- [Llama Nemotron](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/llama_nemotron)
166-
- [Nemotron Nano v2 VL](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotron_vl) — [recipes (9B/12B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/nemotron_vl/nemotron_nano_v2_vl.py)
165+
- [Llama Nemotron](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/v0.4.2/src/megatron/bridge/models/llama_nemotron)
166+
- [Nemotron Nano v2 VL](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/nemotron_vl) — [recipes (9B/12B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/v0.4.2/src/megatron/bridge/recipes/nemotron_vl/nemotron_nano_v2_vl.py)
167167

168168
### Large Language Models (LLM)
169169

170-
- [DeepSeek V2 / V2 Lite](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/deepseek) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/deepseek/deepseek_v2.py)
170+
- [DeepSeek V2 / V2 Lite](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/deepseek) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/v0.4.2/src/megatron/bridge/recipes/deepseek/deepseek_v2.py)
171171
- [DeepSeek V3](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/deepseek) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/deepseek/deepseek_v3.py)
172-
- [Gemma / Gemma 2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/gemma) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/gemma/gemma2.py)
172+
- [Gemma / Gemma 2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/gemma) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/v0.4.2/src/megatron/bridge/recipes/gemma/gemma2.py)
173173
- [Gemma 3](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/gemma) — [recipes (1B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/gemma/gemma3.py)
174174
- [GLM-4.5](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/glm) — [recipes (106B-Air/355B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/glm/glm45.py)
175175
- [GPT-oss](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/gpt_oss) — [recipes (20B/120B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/gpt_oss/gpt_oss.py)
176176
- [Kimi K2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/kimi) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/kimi/kimi_k2.py)
177-
- [Llama 2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/llama) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/llama/llama2.py)
177+
- [Llama 2](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/llama) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/v0.4.2/src/megatron/bridge/recipes/llama/llama2.py)
178178
- [Llama 3 / 3.1 / 3.2 / 3.3](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/llama) — [recipes](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/llama/llama3.py)
179179
- [Mamba](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/mamba)
180180
- [Ministral](https://github.com/NVIDIA-NeMo/Megatron-Bridge/tree/main/src/megatron/bridge/models/ministral3) — [recipes (3B/8B/14B)](https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/main/src/megatron/bridge/recipes/ministral3/ministral3.py)

‎examples/models/deepseek_v4/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ required storage and validate checkpoint resume explicitly.
249249
[`inference.sh`](inference.sh) example uses legacy full-prefix generation and
250250
is not a verified KV-cache path. HF-native inference is the target after a
251251
verified Megatron-to-HF export.
252+
- [`inference.sh`](inference.sh) needs `PP * EP` GPUs (32 for the Pro
253+
defaults `PP=4`, `EP=8`) and, like the other examples, runs from the
254+
repository root. On 4-GPU nodes such as GB200 NVL72 spread the ranks over
255+
several nodes with one task per node:
256+
`srun --ntasks-per-node=1 bash -c 'NNODES=$SLURM_JOB_NUM_NODES NODE_RANK=$SLURM_NODEID MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1) examples/models/deepseek_v4/inference.sh'`.
257+
With the variables unset the script launches all ranks on the current node.
252258
- Fused mHC requires Blackwell (`sm_100`). Hopper uses the unfused path.
253259
- CPU import and export require enough RAM for the full BF16 model plus
254260
conversion workspace and remain unverified in the current card.

‎examples/models/deepseek_v4/inference.sh‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
# EP: expert-parallel size (default: 4 for Flash, 8 for Pro)
2323
# PP: pipeline-parallel size (default: 1 for Flash, 4 for Pro)
2424
# PROMPT: prompt string (default: "Explain hyper-connections in transformer models.")
25+
# NNODES / NODE_RANK / MASTER_ADDR / MASTER_PORT: multi-node launch (defaults 1 / 0 / 127.0.0.1 / 29500).
26+
# The PP*EP ranks are spread over NNODES nodes; GPUS_PER_NODE defaults to PP*EP/NNODES.
27+
# Run from the repository root (the Python entrypoint below is a root-relative path).
28+
# Under Slurm run one task per node, for example:
29+
# srun --ntasks-per-node=1 bash -c 'NNODES=$SLURM_JOB_NUM_NODES NODE_RANK=$SLURM_NODEID \
30+
# MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1) \
31+
# examples/models/deepseek_v4/inference.sh'
2532
# NUM_OF_HYBRID_EP_RANKS_PER_NVLINK_DOMAIN: optional HybridEP topology override
2633
# NVLINK_DOMAIN_SIZE: optional physical NVLink-domain size override
2734
# USE_MNNVL: optional HybridEP fabric override (0 or 1)
@@ -51,9 +58,26 @@ if [[ -z "${PP:-}" ]]; then
5158
fi
5259
TP=1
5360

61+
# Multi-node launch: PP*EP ranks over NNODES nodes (default: everything on this node).
62+
NNODES=${NNODES:-1}
63+
NODE_RANK=${NODE_RANK:-0}
64+
MASTER_ADDR=${MASTER_ADDR:-127.0.0.1}
65+
MASTER_PORT=${MASTER_PORT:-29500}
66+
WORLD=$((PP * EP))
67+
if (( WORLD % NNODES != 0 )); then
68+
echo "ERROR: PP*EP=${WORLD} is not divisible by NNODES=${NNODES}" >&2
69+
exit 1
70+
fi
71+
GPUS_PER_NODE=${GPUS_PER_NODE:-$((WORLD / NNODES))}
72+
TORCHRUN_ARGS=(--nproc_per_node="${GPUS_PER_NODE}")
73+
if (( NNODES > 1 )); then
74+
TORCHRUN_ARGS+=(--nnodes="${NNODES}" --node_rank="${NODE_RANK}"
75+
--master_addr="${MASTER_ADDR}" --master_port="${MASTER_PORT}")
76+
fi
77+
5478
# DSv4 hybrid attention does not support inference contexts yet, so use full-prefix generation.
5579
# Inference directly from the HF checkpoint (Bridge dequantises in-flight).
56-
uv run python -m torch.distributed.run --nproc_per_node=$((PP * EP)) \
80+
uv run python -m torch.distributed.run "${TORCHRUN_ARGS[@]}" \
5781
examples/conversion/hf_to_megatron_generate_text.py \
5882
--hf_model_path "${HF_MODEL_ID}" \
5983
--prompt "${PROMPT}" \
@@ -65,7 +89,7 @@ uv run python -m torch.distributed.run --nproc_per_node=$((PP * EP)) \
6589
# Inference from a previously-imported Megatron checkpoint (faster cold start).
6690
MEGATRON_DIR="${WORKSPACE}/models/${MODEL_VARIANT}"
6791
if [[ -d "${MEGATRON_DIR}/iter_0000000" ]]; then
68-
uv run python -m torch.distributed.run --nproc_per_node=$((PP * EP)) \
92+
uv run python -m torch.distributed.run "${TORCHRUN_ARGS[@]}" \
6993
examples/conversion/hf_to_megatron_generate_text.py \
7094
--hf_model_path "${HF_MODEL_ID}" \
7195
--megatron_model_path "${MEGATRON_DIR}" \

‎src/megatron/bridge/models/hf_pretrained/safe_config_loader.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from typing import Union
2828

2929
import filelock
30+
from huggingface_hub import constants as hf_constants
3031
from transformers import AutoConfig
3132
from transformers.configuration_utils import PretrainedConfig
3233

@@ -59,8 +60,10 @@ def safe_load_config_with_retry(
5960
6061
Environment Variables:
6162
MEGATRON_CONFIG_LOCK_DIR: Override the directory where lock files are created.
62-
Default: ~/.cache/huggingface/
63-
Useful for multi-node setups where a shared lock directory is needed.
63+
Default: the Hugging Face home directory (``HF_HOME``, normally
64+
``~/.cache/huggingface``), so the lock lives next to the cache it protects.
65+
Set it to a shared directory when ``HF_HOME`` is not shared across the ranks
66+
of a multi-node job.
6467
6568
Example:
6669
>>> config = safe_load_config_with_retry("meta-llama/Meta-Llama-3-8B")
@@ -88,12 +91,12 @@ def safe_load_config_with_retry(
8891
path_hash = hashlib.md5(str(path).encode()).hexdigest()
8992

9093
# Allow override of lock directory via environment variable
91-
# This is useful for multi-node setups where a shared lock directory is needed
92-
lock_dir = os.getenv("MEGATRON_CONFIG_LOCK_DIR")
93-
if lock_dir:
94-
lock_file = Path(lock_dir) / f".megatron_config_lock_{path_hash}"
95-
else:
96-
lock_file = Path.home() / ".cache" / "huggingface" / f".megatron_config_lock_{path_hash}"
94+
# (useful for multi-node setups where a shared lock directory is needed).
95+
# Otherwise follow HF_HOME: that is where the config cache being protected
96+
# lives, and on containerised clusters $HOME is often an ephemeral,
97+
# per-container layer that is neither shared nor persistent.
98+
lock_dir = os.getenv("MEGATRON_CONFIG_LOCK_DIR") or hf_constants.HF_HOME
99+
lock_file = Path(lock_dir) / f".megatron_config_lock_{path_hash}"
97100

98101
lock_file.parent.mkdir(parents=True, exist_ok=True)
99102

@@ -131,8 +134,14 @@ def safe_load_config_with_retry(
131134
break
132135

133136
# All retries exhausted
137+
hint = ""
138+
if isinstance(last_exception, filelock.Timeout):
139+
hint = (
140+
" The config file lock could not be acquired; on multi-node jobs set "
141+
"MEGATRON_CONFIG_LOCK_DIR to a directory on a shared filesystem."
142+
)
134143
raise ValueError(
135144
f"Failed to load configuration from {path} after {max_retries + 1} attempts. "
136-
f"This might be due to network issues or concurrent access conflicts. "
145+
f"This might be due to network issues or concurrent access conflicts.{hint} "
137146
f"Last error: {last_exception}"
138147
) from last_exception

‎tests/unit_tests/ci/test_test_template.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,35 @@ def test_container_startup_cannot_override_gcp_test_environment(
8585
"NVTE_FLASH_ATTN_V3": "0",
8686
"NVTE_FLASH_ATTN_V4": "0",
8787
}
88+
89+
90+
@pytest.mark.parametrize("mount_fs", [True, False])
91+
def test_mounted_cache_uses_container_local_config_locks(tmp_path: Path, mount_fs: bool) -> None:
92+
action = yaml.safe_load(ACTION.read_text())
93+
start = next(step["run"] for step in action["runs"]["steps"] if step.get("name") == "Start container")
94+
expressions = {
95+
"inputs.test-data-path": "/shared/test-data",
96+
"github.run_id": "123",
97+
"inputs.runner": "nemo-ci-gcp-gpu-x2",
98+
"inputs.shard-id": "",
99+
"inputs.num-shards": "",
100+
"inputs.container-image": "test-image",
101+
"inputs.timeout": "1",
102+
}
103+
start = re.sub(r"\$\{\{\s*(.*?)\s*\}\}", lambda match: expressions[match[1]], start)
104+
docker = tmp_path / "docker"
105+
docker.write_text('#!/bin/sh\n[ "$1" != run ] || printf "%s\\n" "$@" > docker-args\n')
106+
docker.chmod(0o755)
107+
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}", "MOUNT_FS": str(mount_fs).lower()}
108+
subprocess.run(["bash", "-c", start], cwd=tmp_path, env=env, check=True, capture_output=True, text=True)
109+
args = (tmp_path / "docker-args").read_text().splitlines()
110+
container_env = dict(args[i + 1].split("=", 1) for i, arg in enumerate(args) if arg == "--env")
111+
if mount_fs:
112+
assert container_env["HF_HOME"] == "/home/TestData/HF_HOME"
113+
assert container_env["HF_HUB_OFFLINE"] == "1"
114+
assert container_env["MEGATRON_CONFIG_LOCK_DIR"] == "/home/ubuntu/.cache/huggingface"
115+
assert args[args.index("--volume") + 1] == "/shared/test-data:/home/TestData"
116+
else:
117+
assert "MEGATRON_CONFIG_LOCK_DIR" not in container_env
118+
assert "HF_HUB_OFFLINE" not in container_env
119+
assert "--volume" not in args
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""CPU-only checks for examples/models/deepseek_v4/inference.sh.
16+
17+
The launcher (``uv``) and the Slurm tools (``srun``, ``scontrol``) are replaced by stubs, so the
18+
tests only exercise the shell logic: default parallelism per variant, the multi-node torchrun
19+
arguments, the divisibility guard and the Slurm command documented in the README.
20+
"""
21+
22+
import os
23+
import re
24+
import subprocess
25+
from pathlib import Path
26+
27+
import pytest
28+
29+
30+
pytestmark = pytest.mark.unit
31+
32+
REPO_ROOT = Path(__file__).resolve().parents[3]
33+
SCRIPT = "examples/models/deepseek_v4/inference.sh"
34+
README = REPO_ROOT / "examples/models/deepseek_v4/README.md"
35+
ENTRYPOINT = "examples/conversion/hf_to_megatron_generate_text.py"
36+
MULTINODE_ARGS = {"--nproc_per_node=4", "--nnodes=8", "--node_rank=3", "--master_addr=node0"}
37+
38+
39+
def _write_stub(path: Path, body: str) -> None:
40+
path.write_text("#!/bin/bash\n" + body)
41+
path.chmod(0o755)
42+
43+
44+
@pytest.fixture
45+
def launcher_env(tmp_path):
46+
"""Environment whose uv/srun/scontrol are stubs; every uv call appends its arguments to UV_LOG."""
47+
fake_bin = tmp_path / "bin"
48+
fake_bin.mkdir()
49+
_write_stub(fake_bin / "uv", 'printf "%s\\n" "$*" >> "$UV_LOG"\n')
50+
# srun: drop the srun options and run the payload once, as a single task would.
51+
_write_stub(fake_bin / "srun", 'while [[ "${1:-}" == --* ]]; do shift; done\nexec "$@"\n')
52+
_write_stub(fake_bin / "scontrol", 'printf "node0\\nnode1\\n"\n')
53+
54+
env = os.environ.copy()
55+
for key in ("NNODES", "NODE_RANK", "MASTER_ADDR", "MASTER_PORT", "GPUS_PER_NODE", "EP", "PP", "MODEL_VARIANT"):
56+
env.pop(key, None)
57+
env["PATH"] = f"{fake_bin}:{env['PATH']}"
58+
env["UV_LOG"] = str(tmp_path / "uv.log")
59+
env["WORKSPACE"] = str(tmp_path)
60+
return env
61+
62+
63+
def _run(args, env):
64+
return subprocess.run(args, cwd=REPO_ROOT, env=env, capture_output=True, text=True)
65+
66+
67+
def _uv_calls(env):
68+
log = Path(env["UV_LOG"])
69+
return [line.split() for line in log.read_text().splitlines()] if log.exists() else []
70+
71+
72+
def _documented_slurm_command() -> str:
73+
match = re.search(r"`(srun --ntasks-per-node=1 bash -c '[^`]*')`", README.read_text())
74+
assert match, "README no longer documents the multi-node srun command"
75+
return match.group(1)
76+
77+
78+
@pytest.mark.parametrize(("variant", "nproc"), [("DeepSeek-V4-Flash", 4), ("DeepSeek-V4-Pro", 32)])
79+
def test_single_node_defaults(launcher_env, variant, nproc):
80+
result = _run(["bash", SCRIPT], {**launcher_env, "MODEL_VARIANT": variant})
81+
82+
assert result.returncode == 0, result.stderr
83+
calls = _uv_calls(launcher_env)
84+
assert len(calls) == 1, "no imported checkpoint, so only the HF path should launch"
85+
args = calls[0]
86+
assert args[:4] == ["run", "python", "-m", "torch.distributed.run"]
87+
assert f"--nproc_per_node={nproc}" in args
88+
assert not any(a.startswith(("--nnodes", "--node_rank", "--master_addr", "--master_port")) for a in args)
89+
assert ENTRYPOINT in args
90+
assert (REPO_ROOT / ENTRYPOINT).is_file()
91+
92+
93+
def test_multinode_both_loading_paths(launcher_env, tmp_path):
94+
(tmp_path / "models" / "DeepSeek-V4-Pro" / "iter_0000000").mkdir(parents=True)
95+
env = {**launcher_env, "MODEL_VARIANT": "DeepSeek-V4-Pro", "NNODES": "8", "NODE_RANK": "3", "MASTER_ADDR": "node0"}
96+
97+
result = _run(["bash", SCRIPT], env)
98+
99+
assert result.returncode == 0, result.stderr
100+
calls = _uv_calls(env)
101+
assert len(calls) == 2, "HF path and imported-checkpoint path must both launch"
102+
for args in calls:
103+
assert MULTINODE_ARGS | {"--master_port=29500", "--legacy-full-prefix", ENTRYPOINT} <= set(args)
104+
assert "--megatron_model_path" not in calls[0]
105+
assert "--megatron_model_path" in calls[1]
106+
107+
108+
def test_nondivisible_nodes_exit_before_launch(launcher_env):
109+
result = _run(["bash", SCRIPT], {**launcher_env, "PP": "4", "EP": "8", "NNODES": "3"})
110+
111+
assert result.returncode != 0
112+
assert "not divisible by NNODES=3" in result.stderr
113+
assert _uv_calls(launcher_env) == []
114+
115+
116+
def test_documented_slurm_command_runs_from_repo_root(launcher_env):
117+
command = _documented_slurm_command()
118+
assert SCRIPT in command, "the documented command must use the root-relative script path"
119+
assert "./inference.sh" not in (REPO_ROOT / SCRIPT).read_text(), "header example must match the README"
120+
env = {
121+
**launcher_env,
122+
"MODEL_VARIANT": "DeepSeek-V4-Pro",
123+
"SLURM_JOB_NUM_NODES": "8",
124+
"SLURM_NODEID": "3",
125+
"SLURM_JOB_NODELIST": "node[0-7]",
126+
}
127+
128+
result = _run(["bash", "-c", command], env)
129+
130+
assert result.returncode == 0, result.stderr
131+
calls = _uv_calls(env)
132+
assert len(calls) == 1
133+
assert MULTINODE_ARGS | {ENTRYPOINT} <= set(calls[0])

0 commit comments

Comments
 (0)