Skip to content

Commit 0de6ca8

Browse files
authored
Merge pull request #957 from saivishal1999/spothula/nccl-alltoall-pre-hook
Add pre-test hook support for MegatronBridge workload
2 parents 2dd195d + 03e6e96 commit 0de6ca8

4 files changed

Lines changed: 251 additions & 40 deletions

File tree

src/cloudai/systems/slurm/slurm_command_gen_strategy.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -372,34 +372,55 @@ def _write_sbatch_script(self, srun_command: str) -> str:
372372

373373
return f"sbatch {batch_script_path}"
374374

375+
def _append_resource_directives(self, content: List[str], time_limit: Optional[str] = None) -> Optional[Path]:
376+
"""
377+
Append resource-related SBATCH directives shared between main and pre-hook scripts.
378+
379+
Covers: reservation, distribution, nodelist/exclude, gpus-per-node/gres,
380+
ntasks-per-node, time limit, and extra_sbatch_args. Does NOT write
381+
job-name, output/error paths, partition, or account — those differ
382+
between the main job and the pre-hook and are the caller's responsibility.
383+
384+
Args:
385+
content: The list of script lines to append to.
386+
time_limit: SBATCH time limit string; uses self.test_run.time_limit if None.
387+
388+
Returns:
389+
Path to the generated hostfile, or None if node-list mode was not used.
390+
"""
391+
self._add_reservation(content)
392+
hostfile = self._append_nodes_related_directives(content)
393+
394+
if self.system.gpus_per_node and self.system.supports_gpu_directives:
395+
content.append(f"#SBATCH --gpus-per-node={self.system.gpus_per_node}")
396+
content.append(f"#SBATCH --gres=gpu:{self.system.gpus_per_node}")
397+
398+
if self.system.ntasks_per_node:
399+
content.append(f"#SBATCH --ntasks-per-node={self.system.ntasks_per_node}")
400+
401+
limit = time_limit if time_limit is not None else self.test_run.time_limit
402+
if limit:
403+
content.append(f"#SBATCH --time={limit}")
404+
405+
for arg in self.system.extra_sbatch_args:
406+
content.append(f"#SBATCH {arg}")
407+
408+
return hostfile
409+
375410
def _append_sbatch_directives(self, batch_script_content: List[str]) -> None:
376411
"""
377412
Append SBATCH directives to the batch script content.
378413
379414
Args:
380415
batch_script_content (List[str]): The list of script lines to append to.
381416
"""
382-
batch_script_content = self._add_reservation(batch_script_content)
383-
384417
batch_script_content.append(f"#SBATCH --output={self.test_run.output_path.absolute() / 'stdout.txt'}")
385418
batch_script_content.append(f"#SBATCH --error={self.test_run.output_path.absolute() / 'stderr.txt'}")
386419
batch_script_content.append(f"#SBATCH --partition={self.system.default_partition}")
387420
if self.system.account:
388421
batch_script_content.append(f"#SBATCH --account={self.system.account}")
389422

390-
hostfile = self._append_nodes_related_directives(batch_script_content)
391-
392-
if self.system.gpus_per_node and self.system.supports_gpu_directives:
393-
batch_script_content.append(f"#SBATCH --gpus-per-node={self.system.gpus_per_node}")
394-
batch_script_content.append(f"#SBATCH --gres=gpu:{self.system.gpus_per_node}")
395-
396-
if self.system.ntasks_per_node:
397-
batch_script_content.append(f"#SBATCH --ntasks-per-node={self.system.ntasks_per_node}")
398-
if self.test_run.time_limit:
399-
batch_script_content.append(f"#SBATCH --time={self.test_run.time_limit}")
400-
401-
for arg in self.system.extra_sbatch_args:
402-
batch_script_content.append(f"#SBATCH {arg}")
423+
hostfile = self._append_resource_directives(batch_script_content)
403424

404425
if hostfile is not None:
405426
batch_script_content.append(f"export SLURM_HOSTFILE={hostfile}")

src/cloudai/workloads/megatron_bridge/slurm_command_gen_strategy.py

Lines changed: 195 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,135 @@ def gen_exec_command(self) -> str:
7373

7474
launcher_py = (mbridge_repo_path / "scripts" / "performance" / "setup_experiment.py").absolute()
7575

76-
parts = self._build_launcher_parts(args, tdef, mbridge_repo_path, launcher_py)
76+
pre_hook_sbatch_path: Optional[Path] = None
77+
base_slurm_params: str = ""
78+
capture_nodelist: bool = False
79+
if self.test_run.pre_test:
80+
pre_hook_sbatch_path = self._gen_pre_hook_sbatch()
81+
parts = self._build_launcher_parts(args, tdef, mbridge_repo_path, launcher_py, include_slurm_params=False)
82+
base_slurm_params = ";".join(self._collect_additional_slurm_params())
83+
_, node_list = self.get_cached_nodes_spec()
84+
capture_nodelist = not node_list
85+
else:
86+
parts = self._build_launcher_parts(args, tdef, mbridge_repo_path, launcher_py)
87+
7788
launcher_python = str((venv_path / "bin" / "python").absolute())
78-
full_cmd = self._wrap_launcher_for_job_id_and_quiet_output(" ".join(parts), launcher_python)
89+
full_cmd = self._wrap_launcher_for_job_id_and_quiet_output(
90+
" ".join(parts),
91+
launcher_python,
92+
pre_hook_sbatch_path=pre_hook_sbatch_path,
93+
base_slurm_params=base_slurm_params,
94+
capture_nodelist=capture_nodelist,
95+
)
7996

8097
self._write_command_to_file(full_cmd, self.test_run.output_path)
8198
return full_cmd
8299

100+
def _collect_additional_slurm_params(self) -> list[str]:
101+
"""Return the additional_slurm_params list (without dependency)."""
102+
params: list[str] = []
103+
if self.system.gpus_per_node and self.system.supports_gpu_directives:
104+
params.append(f"gpus-per-node={self.system.gpus_per_node}")
105+
params.append(f"gres=gpu:{self.system.gpus_per_node}")
106+
_, node_list = self.get_cached_nodes_spec()
107+
if node_list:
108+
params.append(f"nodelist={','.join(node_list)}")
109+
elif self.test_run.exclude_nodes:
110+
params.append(f"exclude={','.join(self.test_run.exclude_nodes)}")
111+
for source in (self.system.extra_srun_args, self.test_run.extra_srun_args):
112+
if source:
113+
params.extend(self._parse_srun_args_as_slurm_params(source))
114+
return params
115+
116+
def _gen_pre_hook_sbatch(self) -> Path:
117+
"""
118+
Generate a standalone sbatch script running per-node independent pre-hook tests.
119+
120+
Each node runs its own alltoall among its local GPUs (1 srun per node in parallel),
121+
so the tests are truly independent — no cross-node NCCL communicator is formed.
122+
"""
123+
pre_hook_output = self.test_run.output_path / "pre_hook"
124+
pre_hook_output.mkdir(parents=True, exist_ok=True)
125+
126+
sbatch_lines = [
127+
"#!/bin/bash",
128+
f"#SBATCH --job-name=pre_hook_{self.job_name()}",
129+
f"#SBATCH --output={pre_hook_output.absolute() / 'stdout.txt'}",
130+
f"#SBATCH --error={pre_hook_output.absolute() / 'stderr.txt'}",
131+
f"#SBATCH --partition={self.system.default_partition}",
132+
]
133+
if self.system.account:
134+
sbatch_lines.append(f"#SBATCH --account={self.system.account}")
135+
self._append_resource_directives(sbatch_lines, self.test_run.time_limit)
136+
if self.test_run.extra_srun_args:
137+
for param in self._parse_srun_args_as_slurm_params(self.test_run.extra_srun_args):
138+
key, _, val = param.partition("=")
139+
sbatch_lines.append(f"#SBATCH --{key}={val}" if val else f"#SBATCH --{key}")
140+
sbatch_lines.append("")
141+
142+
assert self.test_run.pre_test is not None
143+
success_vars = []
144+
for idx, tr in enumerate(self.test_run.pre_test.test_runs):
145+
tr.num_nodes = 1
146+
strategy = self._get_cmd_gen_strategy(tr)
147+
self._set_hook_output_path(tr, self.test_run.output_path / "pre_test")
148+
tr.output_path.mkdir(parents=True, exist_ok=True)
149+
150+
node_out = tr.output_path.absolute()
151+
srun_cmd = strategy.gen_srun_command()
152+
# Inject per-node output paths and --nodelist so each node runs independently
153+
node_srun = srun_cmd.replace(
154+
"srun ",
155+
f"srun --nodelist=$_node --output={node_out}/stdout_$_node.txt --error={node_out}/stderr_$_node.txt ",
156+
1,
157+
)
158+
159+
success_var = f"SUCCESS_{idx}"
160+
success_vars.append(success_var)
161+
162+
min_busbw = getattr(tr.test.cmd_args, "min_busbw", None)
163+
if min_busbw is not None:
164+
check_cmd = (
165+
f"awk '/Avg bus bandwidth/ {{ if ($NF+0 >= {min_busbw}) found=1 }}"
166+
f" END {{ exit !found }}' {node_out}/stdout_$_node.txt 2>/dev/null"
167+
)
168+
else:
169+
check_cmd = f'grep -q "Avg bus bandwidth" {node_out}/stdout_$_node.txt 2>/dev/null'
170+
171+
sbatch_lines.extend(
172+
[
173+
f"# {tr.test.name}",
174+
f"mkdir -p {node_out}",
175+
"for _node in $(scontrol show hostnames $SLURM_JOB_NODELIST); do",
176+
f" {node_srun} &",
177+
"done",
178+
"wait",
179+
f"{success_var}=1",
180+
"for _node in $(scontrol show hostnames $SLURM_JOB_NODELIST); do",
181+
f" if ! {check_cmd}; then",
182+
f" {success_var}=0",
183+
" fi",
184+
"done",
185+
"",
186+
]
187+
)
188+
189+
combined = " && ".join([f"[ ${v} -eq 1 ]" for v in success_vars])
190+
sbatch_lines.extend(
191+
[
192+
f"PRE_TEST_SUCCESS=$( {combined} && echo 1 || echo 0 )",
193+
'if [ "$PRE_TEST_SUCCESS" -ne 1 ]; then',
194+
' echo "Pre-hook tests failed. Blocking training job." >&2',
195+
" exit 1",
196+
"fi",
197+
]
198+
)
199+
200+
sbatch_path = self.test_run.output_path / "pre_hook_sbatch_script.sh"
201+
sbatch_path.write_text("\n".join(sbatch_lines))
202+
sbatch_path.chmod(sbatch_path.stat().st_mode | stat.S_IXUSR)
203+
return sbatch_path
204+
83205
def store_test_run(self) -> None:
84206
test_cmd = self.gen_exec_command()
85207
trd = TestRunDetails.from_test_run(self.test_run, test_cmd=test_cmd, full_cmd=test_cmd)
@@ -166,12 +288,22 @@ def _normalize_cuda_graph_scope_arg(self, val: Any) -> str:
166288
parts = [p.strip().strip("\"'") for p in s.split(",") if p.strip()]
167289
return ",".join(parts)
168290

169-
def _wrap_launcher_for_job_id_and_quiet_output(self, launcher_cmd: str, launcher_python: str) -> str:
291+
def _wrap_launcher_for_job_id_and_quiet_output(
292+
self,
293+
launcher_cmd: str,
294+
launcher_python: str,
295+
pre_hook_sbatch_path: Optional[Path] = None,
296+
base_slurm_params: str = "",
297+
capture_nodelist: bool = False,
298+
) -> str:
170299
"""
171300
Run the Megatron-Bridge launcher quietly and ensure CloudAI can parse a job ID.
172301
173302
CloudAI's SlurmRunner expects stdout to include "Submitted batch job <id>".
174303
This writes a readable wrapper script (with section breaks) into the test output directory, then runs it.
304+
305+
If pre_hook_sbatch_path is provided, the pre-hook sbatch is submitted first and its job ID is used as
306+
a Slurm dependency (afterok) for the main training job, so training only starts if the pre-hook passed.
175307
"""
176308
output_dir = self.test_run.output_path.absolute()
177309
output_dir.mkdir(parents=True, exist_ok=True)
@@ -181,6 +313,54 @@ def _wrap_launcher_for_job_id_and_quiet_output(self, launcher_cmd: str, launcher
181313

182314
container_runtime_exports = self._container_runtime_env_exports()
183315

316+
pre_hook_lines: list[str] = []
317+
launch_line: str
318+
if pre_hook_sbatch_path is not None:
319+
nodelist_lines: list[str] = []
320+
if capture_nodelist:
321+
nodelist_lines = [
322+
"# Wait for pre-hook to reach RUNNING; exit on terminal state or timeout (1 h)",
323+
"PRE_HOOK_NODES=''",
324+
"_NODELIST_WAIT=0",
325+
"_NODELIST_TIMEOUT=3600",
326+
"while true; do",
327+
' _state=$(squeue -j "$PRE_HOOK_JOB_ID" -h -o "%T" 2>/dev/null || true)',
328+
' if [ "$_state" = "RUNNING" ]; then',
329+
' PRE_HOOK_NODES=$(squeue -j "$PRE_HOOK_JOB_ID" -h -o "%N" 2>/dev/null | head -1 || true)',
330+
" break",
331+
' elif [ -z "$_state" ] || [ "$_state" = "FAILED" ] || [ "$_state" = "CANCELLED" ] || [ "$_state" = "COMPLETED" ] || [ "$_state" = "TIMEOUT" ]; then', # noqa: E501
332+
" echo \"Pre-hook job $PRE_HOOK_JOB_ID ended in state '${_state:-gone}' before reaching RUNNING.\" >&2", # noqa: E501
333+
" exit 1",
334+
' elif [ "$_NODELIST_WAIT" -ge "$_NODELIST_TIMEOUT" ]; then',
335+
' echo "Timed out after ${_NODELIST_TIMEOUT}s waiting for pre-hook job $PRE_HOOK_JOB_ID to reach RUNNING (last state: ${_state})." >&2', # noqa: E501
336+
" exit 1",
337+
" fi",
338+
" sleep 10",
339+
" _NODELIST_WAIT=$((_NODELIST_WAIT + 10))",
340+
"done",
341+
'ADDITIONAL_SLURM_PARAMS="${ADDITIONAL_SLURM_PARAMS};nodelist=${PRE_HOOK_NODES}"',
342+
"",
343+
]
344+
pre_hook_lines = [
345+
f'PRE_HOOK_SBATCH="{pre_hook_sbatch_path.absolute()}"',
346+
'PRE_HOOK_OUTPUT=$(sbatch "$PRE_HOOK_SBATCH" 2>&1)',
347+
'PRE_HOOK_JOB_ID=$(echo "$PRE_HOOK_OUTPUT" | grep -Eo "Submitted batch job [0-9]+" | grep -Eo "[0-9]+" | tail -n1 || true)', # noqa: E501
348+
'if [ -z "$PRE_HOOK_JOB_ID" ]; then',
349+
' echo "Failed to submit pre-hook job: $PRE_HOOK_OUTPUT" >&2',
350+
" exit 1",
351+
"fi",
352+
'echo "Submitted pre-hook batch job $PRE_HOOK_JOB_ID"',
353+
f'ADDITIONAL_SLURM_PARAMS="{base_slurm_params}"',
354+
'ADDITIONAL_SLURM_PARAMS="${ADDITIONAL_SLURM_PARAMS};dependency=afterok:${PRE_HOOK_JOB_ID}"',
355+
"",
356+
*nodelist_lines,
357+
]
358+
launch_line = (
359+
f'{launcher_cmd} --additional_slurm_params "$ADDITIONAL_SLURM_PARAMS" >>"$LOG" 2>&1 || LAUNCH_RC=$?'
360+
)
361+
else:
362+
launch_line = f'{launcher_cmd} >>"$LOG" 2>&1 || LAUNCH_RC=$?'
363+
184364
script_lines = [
185365
"#!/usr/bin/env bash",
186366
"set -o pipefail",
@@ -195,6 +375,7 @@ def _wrap_launcher_for_job_id_and_quiet_output(self, launcher_cmd: str, launcher
195375
"",
196376
*container_runtime_exports,
197377
"",
378+
*pre_hook_lines,
198379
': >"$LOG"',
199380
"WANDB_INSTALL_RC=0",
200381
f'{shlex.quote(launcher_python)} -m pip install wandb numpy==1.26.4 >>"$LOG" 2>&1 || WANDB_INSTALL_RC=$?',
@@ -205,7 +386,7 @@ def _wrap_launcher_for_job_id_and_quiet_output(self, launcher_cmd: str, launcher
205386
"fi",
206387
"",
207388
"LAUNCH_RC=0",
208-
f'{launcher_cmd} >>"$LOG" 2>&1 || LAUNCH_RC=$?',
389+
launch_line,
209390
"",
210391
# Parse job id from Megatron-Bridge output (multiple possible formats)
211392
# Patterns: "Submitted batch job 694112", "Job id: 694112", "- Job id: 694112", "Job ID: 694112"
@@ -257,7 +438,12 @@ def _add_extra_cmd_args(self, extra_cmd_args: dict[str, str]) -> list[str]:
257438
return [shlex.quote(f"{key}={value}" if value else key) for key, value in overrides.items()]
258439

259440
def _build_launcher_parts( # noqa: C901
260-
self, args: MegatronBridgeCmdArgs, tdef: MegatronBridgeTestDefinition, repo_path: Path, launcher_py: Path
441+
self,
442+
args: MegatronBridgeCmdArgs,
443+
tdef: MegatronBridgeTestDefinition,
444+
repo_path: Path,
445+
launcher_py: Path,
446+
include_slurm_params: bool = True,
261447
) -> list[str]:
262448
fields_set = args.model_fields_set
263449
force_fields = {
@@ -462,25 +648,10 @@ def add_field(field: str, flag: str, value: Any) -> None:
462648
add_field("nsys_trace", "--nsys_trace", self._list_or_comma_str(args.nsys_trace))
463649
add_field("nsys_extra_args", "--nsys_extra_args", self._list_or_comma_str(args.nsys_extra_args))
464650

465-
additional_slurm_params: list[str] = []
466-
467-
if self.system.gpus_per_node and self.system.supports_gpu_directives:
468-
additional_slurm_params.append(f"gpus-per-node={self.system.gpus_per_node}")
469-
additional_slurm_params.append(f"gres=gpu:{self.system.gpus_per_node}")
470-
471-
_, node_list = self.get_cached_nodes_spec()
472-
if node_list:
473-
nodelist_str = ",".join(node_list)
474-
additional_slurm_params.append(f"nodelist={nodelist_str}")
475-
elif self.test_run.exclude_nodes:
476-
additional_slurm_params.append(f"exclude={','.join(self.test_run.exclude_nodes)}")
477-
478-
for source in (self.system.extra_srun_args, self.test_run.extra_srun_args):
479-
if source:
480-
additional_slurm_params.extend(self._parse_srun_args_as_slurm_params(source))
481-
482-
if additional_slurm_params:
483-
parts.extend(["--additional_slurm_params", shlex.quote(";".join(additional_slurm_params))])
651+
if include_slurm_params:
652+
additional_slurm_params = self._collect_additional_slurm_params()
653+
if additional_slurm_params:
654+
parts.extend(["--additional_slurm_params", shlex.quote(";".join(additional_slurm_params))])
484655

485656
# Config variant
486657
add_field("config_variant", "-cv", args.config_variant)

src/cloudai/workloads/nccl_test/nccl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class NCCLCmdArgs(CmdArgs):
103103
stepfactor: Optional[Union[int, list[int]]] = None
104104
use_deepep_matrix: bool = False
105105
alltoallv_matrix_container_path: str = "/tmp/traffic_matrix.txt"
106+
min_busbw: Optional[float] = None
106107

107108

108109
class NCCLTestDefinition(TestDefinition):

0 commit comments

Comments
 (0)