@@ -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 )
0 commit comments