Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion flagscale/runner/backend/backend_megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
from omegaconf import DictConfig, OmegaConf

from flagscale.runner.backend.backend_base import BackendBase
from flagscale.runner.diagnostics import diagnostic_command_body
from flagscale.runner.heartbeat.config import prepare_heartbeat_launch_config
from flagscale.runner.runner_train import (
_get_args_megatron,
_update_config_train,
)
from flagscale.runner.tracing.config import prepare_trace_launch_config
from flagscale.runner.utils import get_pkg_dir, logger, parse_hostfile, resolve_path

PERF_MONITOR_RUNNER_KEYS = (
Expand Down Expand Up @@ -51,6 +53,9 @@ def _prepare(self):
self.user_args = _get_args_megatron(self.config)
self.rdzv_id = datetime.now().strftime("%Y%m%d_%H%M%S.%f")
self.heartbeat_config = prepare_heartbeat_launch_config(self.config, self.rdzv_id)
self.trace_config = prepare_trace_launch_config(
self.config, self.rdzv_id, self.heartbeat_config
)
self.user_envs = self.config.experiment.get("envs", {})
self.user_script = self.config.experiment.task.entrypoint
self.resources = parse_hostfile(self.config.experiment.runner.get("hostfile", None))
Expand Down Expand Up @@ -137,6 +142,10 @@ def generate_run_script(
f.write(f"{line}\n")
if self.heartbeat_config.enabled:
f.write("\n")
for line in self.trace_config.shell_setup_lines(node_rank):
f.write(f"{line}\n")
if self.trace_config.enabled:
f.write("\n")
f.write(f'cmd="{cmd}"\n')
f.write("\n")
if enable_monitoring:
Expand All @@ -161,7 +170,9 @@ def generate_run_script(
)
f.write("\n")

command_body = self.heartbeat_config.training_command_body(node_rank)
command_body = diagnostic_command_body(
node_rank, self.heartbeat_config, self.trace_config
)
if background:
f.write(
f'nohup bash -c "{command_body}" >> {host_output_file} 2>&1 & echo $! > {host_pid_file}\n'
Expand Down Expand Up @@ -206,6 +217,8 @@ def generate_stop_script(self, host, node_rank):
f.write("fi\n")
for line in self.heartbeat_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
for line in self.trace_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
f.write(f"{after_stop}\n")
f.flush()
os.fsync(f.fileno())
Expand Down
26 changes: 24 additions & 2 deletions flagscale/runner/backend/backend_native_train.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
from omegaconf import DictConfig, OmegaConf

from flagscale.runner.backend.backend_base import BackendBase
from flagscale.runner.diagnostics import diagnostic_command_body
from flagscale.runner.heartbeat.config import prepare_heartbeat_launch_config
from flagscale.runner.runner_train import _get_args_native, _update_config_train
from flagscale.runner.tracing.config import prepare_trace_launch_config
from flagscale.runner.utils import get_pkg_dir, logger, parse_hostfile, resolve_path


Expand All @@ -33,6 +36,10 @@ def _prepare(self):
_update_config_train(self.config)
self.user_args = _get_args_native(self.config)
self.rdzv_id = datetime.now().strftime("%Y%m%d_%H%M%S.%f")
self.heartbeat_config = prepare_heartbeat_launch_config(self.config, self.rdzv_id)
self.trace_config = prepare_trace_launch_config(
self.config, self.rdzv_id, self.heartbeat_config
)
self.user_envs = self.config.experiment.get("envs", {})
self.user_script = self.config.experiment.task.entrypoint
self.resources = parse_hostfile(self.config.experiment.runner.get("hostfile", None))
Expand Down Expand Up @@ -94,6 +101,14 @@ def generate_run_script(
f.write("\n")
f.write(f"export PYTHONPATH={pkg_dir}:{megatron_dir}:${{PYTHONPATH}}\n")
f.write("\n")
for line in self.heartbeat_config.shell_setup_lines(node_rank):
f.write(f"{line}\n")
if self.heartbeat_config.enabled:
f.write("\n")
for line in self.trace_config.shell_setup_lines(node_rank):
f.write(f"{line}\n")
if self.trace_config.enabled:
f.write("\n")
f.write(f'cmd="{cmd}"\n')
f.write("\n")
if enable_monitoring:
Expand All @@ -118,13 +133,16 @@ def generate_run_script(
)
f.write("\n")

command_body = diagnostic_command_body(
node_rank, self.heartbeat_config, self.trace_config
)
if background:
f.write(
f'nohup bash -c "$cmd; sync" >> {host_output_file} 2>&1 & echo $! > {host_pid_file}\n'
f'nohup bash -c "{command_body}" >> {host_output_file} 2>&1 & echo $! > {host_pid_file}\n'
)
else:
f.write("set -o pipefail\n")
f.write(f'bash -c "$cmd; sync" 2>&1 | tee -a {host_output_file}\n')
f.write(f'bash -c "{command_body}" 2>&1 | tee -a {host_output_file}\n')
f.write("\n")
f.flush()
os.fsync(f.fileno())
Expand Down Expand Up @@ -160,6 +178,10 @@ def generate_stop_script(self, host, node_rank):
# TODO: This is a temporary fix. We need to find a better way to stop the job.
f.write(" pkill -f 'torchrun'\n")
f.write("fi\n")
for line in self.heartbeat_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
for line in self.trace_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
f.write(f"{after_stop}\n")
f.flush()
os.fsync(f.fileno())
Expand Down
39 changes: 39 additions & 0 deletions flagscale/runner/diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright 2026 FlagOS 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.

"""Shared shell lifecycle helpers for opt-in runner diagnostics."""

from __future__ import annotations

from typing import Protocol


class DiagnosticLaunchConfig(Protocol):
enabled: bool

def command_exit_actions(self, node_rank: int) -> list[str]: ...


def diagnostic_command_body(node_rank: int, *configs: DiagnosticLaunchConfig) -> str:
"""Write every enabled monitor's completion marker without nesting shells."""

actions = [
action
for config in configs
if config.enabled
for action in config.command_exit_actions(node_rank)
]
if not actions:
return "$cmd; sync"
return f"$cmd; rc=\\$?; {'; '.join(dict.fromkeys(actions))}; sync; exit \\$rc"
13 changes: 9 additions & 4 deletions flagscale/runner/heartbeat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,15 @@ def hardware_health_log_file(self, node_rank: int) -> str:

def training_command_body(self, node_rank: int) -> str:
"""Run training and notify the node-zero heartbeat monitor on exit."""
if not self.enabled:
exit_actions = self.command_exit_actions(node_rank)
if not exit_actions:
return "$cmd; sync"
return "$cmd; rc=\\$?; " + "; ".join(exit_actions) + "; sync; exit \\$rc"

def command_exit_actions(self, node_rank: int) -> list[str]:
"""Return cleanup actions for a shared diagnostic command wrapper."""
if not self.enabled:
return []
exit_actions: list[str] = []
if node_rank == 0:
completion_file = shlex.quote(self.completion_file)
Expand All @@ -112,9 +119,7 @@ def training_command_body(self, node_rank: int) -> str:
f"if [ -f {health_pid_file} ]; then "
f'kill \\"\\$(cat {health_pid_file})\\" 2>/dev/null || true; fi'
)
if not exit_actions:
return "$cmd; sync"
return "$cmd; rc=\\$?; " + "; ".join(exit_actions) + "; sync; exit \\$rc"
return exit_actions

def shell_setup_lines(self, node_rank: int) -> list[str]:
if not self.enabled:
Expand Down
4 changes: 3 additions & 1 deletion flagscale/runner/launcher/launcher_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ def _get_runner_cmd_train(
del runner_args["perf_max_log_files"]
if "perf_model_type" in runner_args:
del runner_args["perf_model_type"]
# Heartbeat is consumed by FlagScale and is not a torchrun option.
# Diagnostics are consumed by FlagScale and are not torchrun options.
if "heartbeat" in runner_args:
del runner_args["heartbeat"]
if "tracing" in runner_args:
del runner_args["tracing"]
runner_args["rdzv_id"] = rdzv_id
# runner_args["master_addr"] = master_addr
# runner_args["master_port"] = master_port
Expand Down
26 changes: 24 additions & 2 deletions flagscale/runner/runner_train.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
from hydra.core.hydra_config import HydraConfig
from omegaconf import DictConfig, OmegaConf

from flagscale.runner.diagnostics import diagnostic_command_body
from flagscale.runner.elastic.monitor_service import MonitorService
from flagscale.runner.heartbeat.config import (
HeartbeatLaunchConfig,
prepare_heartbeat_launch_config,
)
from flagscale.runner.runner_base_legacy import JobStatus, RunnerBase
from flagscale.runner.tracing.config import TraceLaunchConfig, prepare_trace_launch_config
from flagscale.runner.utils import (
find_latest_stdout_log,
flatten_dict_to_args,
Expand Down Expand Up @@ -212,6 +214,8 @@ def _get_runner_cmd_train(
del runner_args["enable_monitoring"]
if "heartbeat" in runner_args:
del runner_args["heartbeat"]
if "tracing" in runner_args:
del runner_args["tracing"]
runner_args["rdzv_id"] = rdzv_id
# runner_args["master_addr"] = master_addr
# runner_args["master_port"] = master_port
Expand Down Expand Up @@ -245,8 +249,10 @@ def _generate_run_script_train(
pkg_dir=None,
enable_monitoring=False,
heartbeat_config=None,
trace_config=None,
):
heartbeat_config = heartbeat_config or HeartbeatLaunchConfig(enabled=False)
trace_config = trace_config or TraceLaunchConfig(enabled=False)
system_config = config.train.system
logging_config = config.train.system.logging

Expand Down Expand Up @@ -293,6 +299,10 @@ def _generate_run_script_train(
f.write(f"{line}\n")
if heartbeat_config.enabled:
f.write("\n")
for line in trace_config.shell_setup_lines(node_rank):
f.write(f"{line}\n")
if trace_config.enabled:
f.write("\n")
f.write(f'cmd="{cmd}"\n')
f.write("\n")
if enable_monitoring:
Expand All @@ -315,7 +325,7 @@ def _generate_run_script_train(
f.write(f'echo "Monitor service started in background for {host} (node {node_rank})"\n')
f.write("\n")

command_body = heartbeat_config.training_command_body(node_rank)
command_body = diagnostic_command_body(node_rank, heartbeat_config, trace_config)
if background:
f.write(
f'nohup bash -c "{command_body}" >> {host_output_file} 2>&1 & echo $! > {host_pid_file}\n'
Expand All @@ -331,8 +341,9 @@ def _generate_run_script_train(
return host_run_script_file


def _generate_stop_script_train(config, host, node_rank, heartbeat_config=None):
def _generate_stop_script_train(config, host, node_rank, heartbeat_config=None, trace_config=None):
heartbeat_config = heartbeat_config or HeartbeatLaunchConfig(enabled=False)
trace_config = trace_config or TraceLaunchConfig(enabled=False)
if getattr(config, "train", None):
logging_config = config.train.system.logging
else:
Expand Down Expand Up @@ -362,6 +373,8 @@ def _generate_stop_script_train(config, host, node_rank, heartbeat_config=None):
f.write("fi\n")
for line in heartbeat_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
for line in trace_config.stop_shell_lines(node_rank):
f.write(f"{line}\n")
f.write(f"{after_stop}\n")
f.flush()
os.fsync(f.fileno())
Expand Down Expand Up @@ -426,6 +439,9 @@ def _prepare(self):
raise ValueError(f"Unsupported backend: {self.config.experiment.task.backend}")
self.rdzv_id = datetime.now().strftime("%Y%m%d_%H%M%S.%f")
self.heartbeat_config = prepare_heartbeat_launch_config(self.config, self.rdzv_id)
self.trace_config = prepare_trace_launch_config(
self.config, self.rdzv_id, self.heartbeat_config
)
self.user_envs = self.config.experiment.get("envs", {})
self.user_script = self.config.experiment.task.entrypoint
self.resources = parse_hostfile(self.config.experiment.runner.get("hostfile", None))
Expand Down Expand Up @@ -484,6 +500,7 @@ def _run_each(
pkg_dir=node_specific_config.get("build_dir", None),
enable_monitoring=enable_monitoring,
heartbeat_config=self.heartbeat_config,
trace_config=self.trace_config,
)

if host != "localhost":
Expand Down Expand Up @@ -616,6 +633,7 @@ def _stop_each(self, host, node_rank):
host,
node_rank,
self.heartbeat_config,
self.trace_config,
)
logging_config = self.config.train.system.logging

Expand Down Expand Up @@ -889,6 +907,9 @@ def _prepare(self):
_update_config_train(self.config)
self.rdzv_id = datetime.now().strftime("%Y%m%d_%H%M%S.%f")
self.heartbeat_config = prepare_heartbeat_launch_config(self.config, self.rdzv_id)
self.trace_config = prepare_trace_launch_config(
self.config, self.rdzv_id, self.heartbeat_config
)
if self.config.experiment.task.backend == "megatron":
self.user_args = _get_args_megatron(self.config)
logger.info("\n************** configuration ***********")
Expand Down Expand Up @@ -922,6 +943,7 @@ def _run_each(
cmd,
background=background,
heartbeat_config=self.heartbeat_config,
trace_config=self.trace_config,
)

run_local_command(f"bash {host_run_script_file}", dryrun)
Expand Down
22 changes: 22 additions & 0 deletions flagscale/runner/tracing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2026 FlagOS 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.

"""CPU-side NCCL tracing and hang detection utilities."""

from .analyzer import Finding, TraceAnalyzer

__all__ = [
"Finding",
"TraceAnalyzer",
]
Loading
Loading