Skip to content

Commit b21e9c6

Browse files
wanglei19991004gemini-code-assist[bot]zhaoyinglia
authored
[Elastic] Add the log collection and diagnosis function (flagos-ai#766)
- Record training status changes in file: `outputs/logs/monitor/status.log` - incrementally collect log content in file: `outputs/logs/monitor/host_*_temp_*.log` - automatically analyze training errors and generate report in file: `outputs/logs/monitor/host_*_diagnostic_*.txt` diagnostic report examples: ``` Diagnostic Report for localhost (node 0) Generated at 2025-09-11 01:53:17 Analysis: - OutOfMemoryError: The training process ran out of GPU memory. Check host_0_localhost_temp_20250918_163815.log line:32-128 - RendezvousConnectionError: Connection to rendezvous backend failed. Check host_0_localhost_temp_20250918_163815.log line:33-97 - CodeError: Python exception occurred during execution. Check host_0_localhost_temp_20250918_163815.log line:29-125 ``` - Add hang detection. If there is no update in the log within 30 minutes, it is determined to be hung - The log monitoring and diagnosis module is disabled by default. Set the switch control: ``` python run.py action=run +experiment.runner.enable_monitoring=true ``` or modify `train.yaml` ``` experiment: runner: enable_monitoring: true ``` --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: zhaoyingli <86812880+zhaoyinglia@users.noreply.github.com>
1 parent 8707a29 commit b21e9c6

12 files changed

Lines changed: 1601 additions & 11 deletions

File tree

flagscale/runner/auto_tuner/tuner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ def tune(self):
239239
best_task = self.generator.gen_best_task(best_strategy, self.orig_config)
240240
best_task.action = "run"
241241
runner = SSHTrainRunner(best_task)
242-
runner.run(monitor=True, interval=60)
242+
enable_monitoring = best_task.experiment.runner.get("enable_monitoring", False)
243+
runner.run(monitor=True, interval=60, enable_monitoring=enable_monitoring)
243244

244245
def need_stop(self):
245246
"""Judge whether need to stop tuning."""
@@ -296,7 +297,8 @@ def run(self, task=None):
296297
if task is None:
297298
task = self.cur_task
298299
self.runner = SSHTrainRunner(task)
299-
self.runner.run()
300+
enable_monitoring = task.experiment.runner.get("enable_monitoring", False)
301+
self.runner.run(enable_monitoring=enable_monitoring)
300302
# set start time
301303
self.task_start_time = time.time()
302304

flagscale/runner/elastic/__init__.py

Whitespace-only changes.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import os
2+
import time
3+
4+
from flagscale.runner.utils import logger
5+
6+
# Record the number of rows last diagnosed and analyzed by each host
7+
_diagnostic_offsets = {}
8+
9+
error_types = {
10+
# Success indicators
11+
"completed": "Completed: The task finished successfully with no errors.",
12+
# Memory errors
13+
"out of memory": "OutOfMemoryError: The training process ran out of GPU memory.",
14+
"outofmemoryerror": "OutOfMemoryError: The training process ran out of GPU memory.",
15+
"cuda out of memory": "OutOfMemoryError: CUDA out of memory error occurred.",
16+
# Connection and network errors
17+
"rendezvousconnectionerror": "RendezvousConnectionError: Connection to rendezvous backend failed.",
18+
"rendezvous": "RendezvousError: Rendezvous coordination failed between nodes.",
19+
"connection refused": "ConnectionError: Network connection refused.",
20+
"connection timeout": "ConnectionTimeout: Network connection timeout.",
21+
# Import and code errors
22+
"importerror:": "ImportError: Failed to import required modules.",
23+
"modulenotfounderror:": "ModuleNotFoundError: Required Python module not found.",
24+
"traceback (most recent call last)": "CodeError: Python exception occurred during execution.",
25+
"fatal error": "FatalError: Fatal error occurred during training.",
26+
"exception": "Exception: An exception occurred during training.",
27+
# Process errors
28+
"process killed": "ProcessKilled: Training process was killed.",
29+
"killed by signal": "ProcessKilled: Process was killed by signal.",
30+
"terminated by signal": "ProcessKilled: Process was terminated by signal.",
31+
"keyboardinterrupt": "ProcessKilled: MANUAL KILL - Keyboard interrupt detected",
32+
"sigint": "ProcessKilled: MANUAL KILL - SIGINT signal received",
33+
"sigterm": "ProcessKilled: MANUAL KILL - SIGTERM signal received",
34+
"segmentation fault": "SegmentationFault: Process crashed due to memory access error.",
35+
"core dumped": "CoreDump: Process crashed and dumped core.",
36+
# CUDA errors
37+
"cuda error": "CUDAError: CUDA-related error occurred.",
38+
"cudnn error": "CUDNNError: CuDNN library error occurred.",
39+
"gpu error": "GPUError: GPU-related error occurred.",
40+
# File and storage errors
41+
"no such file or directory": "FileNotFound: Required file or directory not found.",
42+
"permission denied": "PermissionError: File permission denied.",
43+
"no space left on device": "StorageError: Insufficient disk space.",
44+
# Timeout errors
45+
"operation timed out": "TimeoutError: Operation timed out.",
46+
"connection timeout": "TimeoutError: Connection timed out.",
47+
"hanging": "HangError: Process appears to be hanging.",
48+
}
49+
50+
51+
def find_error_lines(lines, error_key, start_line=0):
52+
"""
53+
Search for the error keyword in the log line and return a list of line numbers
54+
Args:
55+
lines (list): All lines of the log file
56+
error_key (str): errors keyword
57+
start_line (int): The line number where the search began
58+
Returns:
59+
list: A list of errors line numbers
60+
"""
61+
matches = []
62+
for i in range(start_line, len(lines)):
63+
if error_key.lower() in lines[i].lower():
64+
matches.append(i + 1) # Line numbers start from 1
65+
return matches
66+
67+
68+
def format_line_range(line_numbers):
69+
"""
70+
Format the line number range display
71+
Args:
72+
line_numbers (list): List of line numbers
73+
Returns:
74+
str: Formatted line number range, such as "111-112" or "111"
75+
"""
76+
if not line_numbers:
77+
return "unknown"
78+
elif len(line_numbers) == 1:
79+
return str(line_numbers[0])
80+
else:
81+
return f"{min(line_numbers)}-{max(line_numbers)}"
82+
83+
84+
def generate_diagnostic_report(config, host, node_rank, log_file, return_content=False):
85+
"""
86+
Generate an incremental diagnostic report from a log file.
87+
Args:
88+
config (DictConfig): Configuration object.
89+
host (str): Hostname or IP.
90+
node_rank (int): Node rank.
91+
log_file (str): Path to the log file.
92+
return_content (bool): If True, return report as string instead of writing to file.
93+
Returns:
94+
str: Diagnostic report content if return_content=True, else diagnostic file path.
95+
"""
96+
global _diagnostic_offsets
97+
98+
# Generate the path of the diagnostic file
99+
log_dir = os.path.dirname(log_file)
100+
diagnostic_file = os.path.join(log_dir, f"host_{node_rank}_{host}_diagnostic.txt")
101+
host_key = f"{host}_{node_rank}"
102+
103+
try:
104+
if not os.path.exists(log_file) or os.path.getsize(log_file) == 0:
105+
logger.debug(f"Log file {log_file} is empty or does not exist")
106+
return diagnostic_file if not return_content else ""
107+
# Read all lines of the log file
108+
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
109+
all_lines = f.readlines()
110+
111+
# Get the number of rows analyzed last time
112+
last_analyzed_line = _diagnostic_offsets.get(host_key, 0)
113+
114+
# If it is the first analysis, create the header of the diagnostic file
115+
if last_analyzed_line == 0 and not os.path.exists(diagnostic_file):
116+
os.makedirs(os.path.dirname(diagnostic_file), exist_ok=True)
117+
header_content = f"Diagnostic Report for {host} (node {node_rank})\n"
118+
header_content += (
119+
f"Generated at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}\n"
120+
)
121+
header_content += "Analysis:\n"
122+
123+
with open(diagnostic_file, 'w', encoding='utf-8') as f:
124+
f.write(header_content)
125+
126+
# Only analyze the newly added rows
127+
new_lines = all_lines[last_analyzed_line:]
128+
if not new_lines:
129+
logger.debug(f"No new lines to analyze in {log_file}")
130+
return diagnostic_file if not return_content else ""
131+
132+
# Analyze the errors in the new lines
133+
current_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
134+
new_errors = []
135+
error_lines_by_type = {key: [] for key in error_types}
136+
137+
# Iterate through new lines once and check against all error types
138+
for i, line in enumerate(all_lines[last_analyzed_line:], start=last_analyzed_line):
139+
for key, desc in error_types.items():
140+
if find_error_lines([line], key, 0): # Check single line
141+
error_lines_by_type[key].append(i)
142+
143+
# Process error lines for each type
144+
for key, desc in error_types.items():
145+
error_lines = error_lines_by_type[key]
146+
if error_lines:
147+
line_range = format_line_range(error_lines)
148+
log_filename = os.path.basename(log_file)
149+
error_entry = f"[{current_time}] {desc} Check {log_filename} line:{line_range}"
150+
new_errors.append(error_entry)
151+
152+
# Update Analysis Location
153+
_diagnostic_offsets[host_key] = len(all_lines)
154+
155+
# If there are new errors, append them to the diagnostic file
156+
if new_errors:
157+
os.makedirs(os.path.dirname(diagnostic_file), exist_ok=True)
158+
with open(diagnostic_file, 'a', encoding='utf-8') as f:
159+
for error in new_errors:
160+
f.write(f"{error}\n")
161+
162+
logger.debug(
163+
f"Added {len(new_errors)} new errors to diagnostic report for {host} (node {node_rank})"
164+
)
165+
166+
if return_content:
167+
return '\n'.join(new_errors) if new_errors else ""
168+
else:
169+
return diagnostic_file
170+
except Exception as e:
171+
logger.error(f"Failed to generate diagnostic report for {host} (node {node_rank}): {e}")
172+
if return_content:
173+
return f"Error analyzing log file: {e}"
174+
else:
175+
return None
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import glob
2+
import os
3+
import shlex
4+
import subprocess
5+
6+
from datetime import datetime
7+
8+
from flagscale.runner.utils import logger, run_local_command
9+
10+
_log_offsets = {}
11+
12+
13+
def get_remote_file_size(host, filepath):
14+
"""
15+
Retrieve the size of a file on a remote host (in bytes).
16+
17+
Parameters:
18+
host (str): The address of the remote host (e.g., 'user@hostname').
19+
filepath (str): The path to the file on the remote host.
20+
21+
Returns:
22+
int: The size of the file in bytes if successful; returns -1 if an error occurs.
23+
24+
Exception Handling:
25+
subprocess.CalledProcessError: Caught when the SSH command fails.
26+
"""
27+
try:
28+
result = subprocess.run(
29+
["ssh", host, f"stat -c%s {filepath}"],
30+
stdout=subprocess.PIPE,
31+
stderr=subprocess.PIPE,
32+
text=True,
33+
check=True,
34+
)
35+
return int(result.stdout.strip())
36+
except subprocess.CalledProcessError:
37+
return -1
38+
39+
40+
def get_file_size(host, filepath):
41+
"""
42+
Retrieve the size of a file, either locally or on a remote host (in bytes).
43+
44+
Parameters:
45+
host (str): The address of the host (e.g., 'localhost' or 'user@hostname').
46+
filepath (str): The path to the file, either local or on the remote host.
47+
48+
Returns:
49+
int: The size of the file in bytes if successful; returns -1 if the file does not exist
50+
or an error occurs during remote access.
51+
52+
Notes:
53+
- For local files ('localhost'), uses os.path to check existence and get size.
54+
"""
55+
if host == "localhost":
56+
if os.path.exists(filepath):
57+
return os.path.getsize(filepath)
58+
return -1
59+
else:
60+
return get_remote_file_size(host, filepath)
61+
62+
63+
def find_actual_log_file(log_dir, node_rank, host, no_shared_fs=False):
64+
"""
65+
Smart file discovery for log files that handles hostname/IP mismatches.
66+
67+
Args:
68+
log_dir (str): Directory containing log files
69+
node_rank (int): Rank of the node
70+
host (str): Expected hostname or IP
71+
no_shared_fs (bool): Whether shared filesystem is disabled
72+
73+
Returns:
74+
str: Path to the actual log file found, or expected path if not found
75+
"""
76+
# Construct expected filename based on original logic
77+
if no_shared_fs:
78+
expected_file = os.path.join(log_dir, "host.output")
79+
else:
80+
expected_file = os.path.join(log_dir, f"host_{node_rank}_{host}.output")
81+
82+
# Try exact match first
83+
if os.path.exists(expected_file):
84+
return expected_file
85+
86+
# Use glob pattern to find any matching node_rank file
87+
if not no_shared_fs:
88+
pattern = os.path.join(log_dir, f"host_{node_rank}_*.output")
89+
matches = glob.glob(pattern)
90+
91+
if matches:
92+
# Return the first match found
93+
logger.debug(
94+
f"Smart discovery found log file: {matches[0]} (expected: {expected_file})"
95+
)
96+
return matches[0]
97+
98+
# Return original expected path for error handling
99+
return expected_file
100+
101+
102+
def collect_logs(config, host, node_rank, destination_dir, dryrun=False):
103+
"""
104+
Collect logs incrementally from a specified host and node rank, saving to destination_dir.
105+
Args:
106+
config (DictConfig): Configuration object containing experiment and logging details.
107+
host (str): Hostname or IP of the node.
108+
node_rank (int): Rank of the node.
109+
destination_dir (str): Directory to store collected logs.
110+
dryrun (bool): If True, simulate the collection without executing commands.
111+
Returns:
112+
str: Path to the collected log file.
113+
"""
114+
logging_config = config.train.system.logging
115+
no_shared_fs = config.experiment.runner.get("no_shared_fs", False)
116+
log_dir = logging_config.log_dir
117+
src_log_file = find_actual_log_file(log_dir, node_rank, host, no_shared_fs)
118+
dest_log_file = os.path.join(
119+
destination_dir,
120+
f"host_{node_rank}_{host}_temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log",
121+
)
122+
123+
os.makedirs(destination_dir, exist_ok=True)
124+
125+
log_key = f"{host}_{node_rank}"
126+
offset = _log_offsets.get(log_key, 0)
127+
128+
try:
129+
if host != "localhost":
130+
ssh_port = config.experiment.runner.get("ssh_port", 22)
131+
command = f"ssh -p {ssh_port} {host} 'tail -c +{offset + 1} {shlex.quote(src_log_file)}' > {shlex.quote(dest_log_file)}"
132+
run_local_command(command, dryrun)
133+
logger.debug(
134+
f"Collected incremental log from {host} (node {node_rank}) to {dest_log_file}"
135+
)
136+
else:
137+
command = (
138+
f"tail -c +{offset + 1} {shlex.quote(src_log_file)} > {shlex.quote(dest_log_file)}"
139+
)
140+
run_local_command(command, dryrun)
141+
logger.debug(f"Collected incremental local log to {dest_log_file}")
142+
143+
# Check if the source file exists and update the offset
144+
if os.path.exists(src_log_file):
145+
current_src_size = get_file_size(host, src_log_file)
146+
if current_src_size > 0:
147+
if current_src_size > offset: # There is new content in the source file
148+
_log_offsets[log_key] = current_src_size
149+
150+
if os.path.exists(dest_log_file) and os.path.getsize(dest_log_file) > 0:
151+
logger.debug(
152+
f"Collected {current_src_size - offset} bytes from {src_log_file}"
153+
)
154+
return dest_log_file
155+
else:
156+
logger.debug(f"No new content extracted from {src_log_file}")
157+
if os.path.exists(dest_log_file):
158+
os.remove(dest_log_file)
159+
return None
160+
else:
161+
logger.debug(f"No new content in source file {src_log_file}")
162+
if os.path.exists(dest_log_file):
163+
os.remove(dest_log_file)
164+
return None
165+
else:
166+
logger.debug(f"Source log file {src_log_file} not found")
167+
if os.path.exists(dest_log_file):
168+
os.remove(dest_log_file)
169+
return None
170+
171+
except Exception as e:
172+
logger.error(f"Failed to collect logs from {host} (node {node_rank}): {e}")
173+
if os.path.exists(dest_log_file):
174+
os.remove(dest_log_file)
175+
return None

0 commit comments

Comments
 (0)