Skip to content

Commit 4f9fd83

Browse files
Add the corresponding log line to the diagnostic file, and fix some bugs
1 parent 03c4ebf commit 4f9fd83

8 files changed

Lines changed: 468 additions & 222 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
name: Elastic Module Tests
2+
3+
on:
4+
push:
5+
branches: [ main, develop ]
6+
paths:
7+
- 'flagscale/runner/elastic/**'
8+
- 'tests/unit_tests/runner/elastic/**'
9+
- '.github/workflows/unit-tests-elastic.yml'
10+
pull_request:
11+
branches: [ main, develop ]
12+
paths:
13+
- 'flagscale/runner/elastic/**'
14+
- 'tests/unit_tests/runner/elastic/**'
15+
- '.github/workflows/unit-tests-elastic.yml'
16+
17+
jobs:
18+
lint-and-test:
19+
runs-on: [self-hosted, Linux, X64, nvidia-0, gpus-8]
20+
container:
21+
image: flagscale/flagscale:dev-megatron
22+
ports:
23+
- 80
24+
volumes:
25+
- /home/flagscale_cicd/flask/static:/workspace/report
26+
- /home/flagscale_cicd/flask/config:/workspace/config
27+
- /home/flagscale_cicd/docker/docker_build/docker_data:/home/gitlab-runner/data
28+
- /home/flagscale_cicd/docker/docker_build/docker_tokenizers:/home/gitlab-runner/tokenizers
29+
options: --gpus all --shm-size=500g --hostname flagscale_cicd --user root --ulimit nofile=65535:65535
30+
31+
steps:
32+
- name: Checkout Code
33+
uses: actions/checkout@v4
34+
with:
35+
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
36+
ref: ${{ github.event.pull_request.head.ref || github.ref }}
37+
ssh-strict: true
38+
ssh-user: git
39+
persist-credentials: true
40+
clean: true
41+
sparse-checkout-cone-mode: true
42+
fetch-tags: false
43+
show-progress: true
44+
lfs: false
45+
submodules: false
46+
set-safe-directory: true
47+
48+
- name: Setup Environment
49+
run: |
50+
echo "USER: $USER"
51+
echo "UID: $(id -u)"
52+
echo "GID: $(id -g)"
53+
echo "Home: $HOME"
54+
whoami
55+
git config --global --add safe.directory /__w/FlagScale/FlagScale || true
56+
57+
- name: Install Dependencies
58+
run: |
59+
source /root/miniconda3/etc/profile.d/conda.sh
60+
conda activate flagscale-train
61+
pip install pylint pytest pytest-cov
62+
python tools/patch/unpatch.py --backend Megatron-LM || true
63+
export PYTHONPATH=./third_party/Megatron-LM:$PYTHONPATH
64+
export NVTE_FLASH_ATTN=0
65+
export NVTE_FUSED_ATTN=0
66+
ulimit -n 65535
67+
68+
- name: Pylint Check - Elastic Module
69+
run: |
70+
source /root/miniconda3/etc/profile.d/conda.sh
71+
conda activate flagscale-train
72+
export PYTHONPATH=./third_party/Megatron-LM:$PYTHONPATH
73+
74+
echo "Running pylint on elastic module..."
75+
pylint flagscale/runner/elastic/ --output-format=text --reports=yes --exit-zero > pylint_report.txt
76+
77+
# Display pylint results
78+
cat pylint_report.txt
79+
80+
# Extract pylint score
81+
PYLINT_SCORE=$(grep "Your code has been rated at" pylint_report.txt | sed 's/.*rated at \([0-9.]*\).*/\1/' || echo "0")
82+
echo "Pylint Score: $PYLINT_SCORE"
83+
84+
# Fail if score is below 8.0
85+
if (( $(echo "$PYLINT_SCORE < 8.0" | bc -l) )); then
86+
echo "Pylint score $PYLINT_SCORE is below required threshold of 8.0"
87+
exit 1
88+
fi
89+
90+
- name: Run Elastic Unit Tests
91+
run: |
92+
source /root/miniconda3/etc/profile.d/conda.sh
93+
conda activate flagscale-train
94+
export PYTHONPATH=./third_party/Megatron-LM:$PYTHONPATH
95+
export NVTE_FLASH_ATTN=0
96+
export NVTE_FUSED_ATTN=0
97+
ulimit -n 65535
98+
99+
echo "Running elastic module unit tests..."
100+
pytest tests/unit_tests/runner/elastic/ \
101+
--cov=flagscale/runner/elastic \
102+
--cov-report=xml:coverage_elastic.xml \
103+
--cov-report=html:coverage_elastic_html \
104+
--cov-report=term \
105+
-v \
106+
--tb=short \
107+
-x
108+
109+
- name: Coverage Report
110+
run: |
111+
source /root/miniconda3/etc/profile.d/conda.sh
112+
conda activate flagscale-train
113+
114+
echo "Coverage Summary for Elastic Module:"
115+
if [ -f coverage_elastic.xml ]; then
116+
echo "Coverage XML report generated successfully"
117+
python -c "
118+
import xml.etree.ElementTree as ET
119+
try:
120+
tree = ET.parse('coverage_elastic.xml')
121+
root = tree.getroot()
122+
coverage = root.attrib.get('line-rate', '0')
123+
percentage = float(coverage) * 100
124+
print(f'Line Coverage: {percentage:.1f}%')
125+
if percentage < 80:
126+
print(f'Warning: Coverage {percentage:.1f}% is below recommended 80%')
127+
exit(1)
128+
except Exception as e:
129+
print(f'Error parsing coverage: {e}')
130+
exit(1)
131+
"
132+
else
133+
echo "Coverage XML report not found"
134+
exit 1
135+
fi
136+
137+
- name: Archive Test Results
138+
uses: actions/upload-artifact@v3
139+
if: always()
140+
with:
141+
name: elastic-test-results
142+
path: |
143+
pylint_report.txt
144+
coverage_elastic.xml
145+
coverage_elastic_html/
146+
retention-days: 30
147+
148+
- name: Summary
149+
if: always()
150+
run: |
151+
echo "=== Elastic Module CI/CD Summary ==="
152+
echo "[INFO] Pylint check completed"
153+
echo "[INFO] Unit tests executed"
154+
echo "[INFO] Coverage report generated"
155+
echo "=== Test artifacts uploaded to GitHub Actions ==="
Lines changed: 108 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
2-
3-
from datetime import datetime
2+
import time
43

54
from flagscale.runner.utils import logger
65

6+
# Record the number of rows last diagnosed and analyzed by each host
7+
_diagnostic_offsets = {}
8+
79
error_types = {
810
# Success indicators
911
"completed": "Completed: The task finished successfully with no errors.",
@@ -23,6 +25,12 @@
2325
"error": "GeneralError: An error occurred during training.",
2426
# Process errors
2527
"killed": "ProcessKilled: Training process was killed.",
28+
"kill": "ProcessKilled: Process was manually killed.",
29+
"sigterm": "ProcessKilled: Process received SIGTERM signal.",
30+
"sigkill": "ProcessKilled: Process received SIGKILL signal.",
31+
"signal": "ProcessKilled: Process received termination signal.",
32+
"terminated": "ProcessKilled: Process was terminated.",
33+
"sigint": "ProcessKilled: Process received SIGINT signal (Ctrl+C).",
2634
"segmentation fault": "SegmentationFault: Process crashed due to memory access error.",
2735
"core dumped": "CoreDump: Process crashed and dumped core.",
2836
# CUDA errors
@@ -39,59 +47,120 @@
3947
}
4048

4149

50+
def find_error_lines(lines, error_key, start_line=0):
51+
"""
52+
Search for the error keyword in the log line and return a list of line numbers
53+
Args:
54+
lines (list): All lines of the log file
55+
error_key (str): errors keyword
56+
start_line (int): The line number where the search began
57+
Returns:
58+
list: A list of errors line numbers
59+
"""
60+
matches = []
61+
for i in range(start_line, len(lines)):
62+
if error_key.lower() in lines[i].lower():
63+
matches.append(i + 1) # Line numbers start from 1
64+
return matches
65+
66+
67+
def format_line_range(line_numbers):
68+
"""
69+
Format the line number range display
70+
Args:
71+
line_numbers (list): List of line numbers
72+
Returns:
73+
str: Formatted line number range, such as "111-112" or "111"
74+
"""
75+
if not line_numbers:
76+
return "unknown"
77+
elif len(line_numbers) == 1:
78+
return str(line_numbers[0])
79+
else:
80+
return f"{min(line_numbers)}-{max(line_numbers)}"
81+
82+
4283
def generate_diagnostic_report(config, host, node_rank, log_file, return_content=False):
4384
"""
44-
Generate a diagnostic report from a log file.
85+
Generate an incremental diagnostic report from a log file.
4586
Args:
4687
config (DictConfig): Configuration object.
4788
host (str): Hostname or IP.
4889
node_rank (int): Node rank.
4990
log_file (str): Path to the log file.
5091
return_content (bool): If True, return report as string instead of writing to file.
5192
Returns:
52-
str: Diagnostic report content if return_content=True, else None.
93+
str: Diagnostic report content if return_content=True, else diagnostic file path.
5394
"""
54-
report_content = f"Diagnostic Report for {host} (node {node_rank})\n"
55-
report_content += f"Generated at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
56-
# report_content += f"Log file analyzed: {log_file}\n"
57-
report_content += "Analysis:\n"
95+
global _diagnostic_offsets
96+
97+
# Generate the path of the diagnostic file
98+
log_dir = os.path.dirname(log_file)
99+
diagnostic_file = os.path.join(log_dir, f"host_{node_rank}_{host}_diagnostic.txt")
100+
host_key = f"{host}_{node_rank}"
58101

59102
try:
60103
if not os.path.exists(log_file) or os.path.getsize(log_file) == 0:
61-
report_content += "- Log file is empty or does not exist, no analysis possible.\n"
62-
return report_content if return_content else None
63-
else:
64-
with open(log_file, 'r') as f:
65-
log_content = f.read()
66-
if not log_content.strip():
67-
report_content += "- Log file is empty, no analysis possible.\n"
68-
else:
69-
matched_errors = []
70-
for key, desc in error_types.items():
71-
if key in log_content.lower():
72-
matched_errors.append(desc)
73-
74-
if matched_errors:
75-
for err in matched_errors:
76-
report_content += f"- {err}\n"
77-
else:
78-
report_content += "- No errors or unknown error detected in logs.\n"
79-
except Exception as e:
80-
logger.error(f"Failed to read log file {log_file} for {host} (node {node_rank}): {e}")
81-
report_content += f"- Error reading log file: {e}\n"
104+
logger.debug(f"Log file {log_file} is empty or does not exist")
105+
return diagnostic_file if not return_content else ""
106+
# Read all lines of the log file
107+
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
108+
all_lines = f.readlines()
82109

83-
if return_content:
84-
return report_content
85-
else:
86-
try:
87-
diagnostic_file = log_file.replace("temp", "diagnostic").replace(".log", ".txt")
110+
# Get the number of rows analyzed last time
111+
last_analyzed_line = _diagnostic_offsets.get(host_key, 0)
112+
113+
# If it is the first analysis, create the header of the diagnostic file
114+
if last_analyzed_line == 0 and not os.path.exists(diagnostic_file):
115+
os.makedirs(os.path.dirname(diagnostic_file), exist_ok=True)
116+
header_content = f"Diagnostic Report for {host} (node {node_rank})\n"
117+
header_content += (
118+
f"Generated at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}\n"
119+
)
120+
header_content += "Analysis:\n"
121+
122+
with open(diagnostic_file, 'w', encoding='utf-8') as f:
123+
f.write(header_content)
124+
125+
# Only analyze the newly added rows
126+
new_lines = all_lines[last_analyzed_line:]
127+
if not new_lines:
128+
logger.debug(f"No new lines to analyze in {log_file}")
129+
return diagnostic_file if not return_content else ""
130+
131+
# Analyze the errors in the new line
132+
current_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
133+
new_errors = []
134+
for key, desc in error_types.items():
135+
# Look for errors in the newly added lines
136+
error_lines = find_error_lines(all_lines, key, last_analyzed_line)
137+
if error_lines:
138+
line_range = format_line_range(error_lines)
139+
log_filename = os.path.basename(log_file)
140+
error_entry = f"[{current_time}] {desc} Check {log_filename} line:{line_range}"
141+
new_errors.append(error_entry)
142+
143+
# Update Analysis Location
144+
_diagnostic_offsets[host_key] = len(all_lines)
145+
146+
# If there are new errors, append them to the diagnostic file
147+
if new_errors:
88148
os.makedirs(os.path.dirname(diagnostic_file), exist_ok=True)
89-
with open(diagnostic_file, 'w') as f:
90-
f.write(report_content)
149+
with open(diagnostic_file, 'a', encoding='utf-8') as f:
150+
for error in new_errors:
151+
f.write(f"{error}\n")
152+
91153
logger.debug(
92-
f"Generated diagnostic report for {host} (node {node_rank}) at {diagnostic_file}"
154+
f"Added {len(new_errors)} new errors to diagnostic report for {host} (node {node_rank})"
93155
)
156+
157+
if return_content:
158+
return '\n'.join(new_errors) if new_errors else ""
159+
else:
94160
return diagnostic_file
95-
except Exception as e:
96-
logger.error(f"Failed to write diagnostic report for {host} (node {node_rank}): {e}")
161+
except Exception as e:
162+
logger.error(f"Failed to generate diagnostic report for {host} (node {node_rank}): {e}")
163+
if return_content:
164+
return f"Error analyzing log file: {e}"
165+
else:
97166
return None

flagscale/runner/elastic/log_collector.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,27 @@ def collect_logs(config, host, node_rank, destination_dir, dryrun=False):
4949
run_local(command, dryrun)
5050
logger.debug(f"Collected incremental local log to {dest_log_file}")
5151

52-
if os.path.exists(dest_log_file) and os.path.getsize(dest_log_file) > 0:
53-
new_offset = os.path.getsize(src_log_file)
54-
_log_offsets[log_key] = new_offset
55-
return dest_log_file
52+
# Check if the source file exists and update the offset
53+
if os.path.exists(src_log_file):
54+
current_src_size = os.path.getsize(src_log_file)
55+
if current_src_size > offset: # There is new content in the source file
56+
_log_offsets[log_key] = current_src_size
57+
58+
if os.path.exists(dest_log_file) and os.path.getsize(dest_log_file) > 0:
59+
logger.debug(f"Collected {current_src_size - offset} bytes from {src_log_file}")
60+
return dest_log_file
61+
else:
62+
logger.debug(f"No new content extracted from {src_log_file}")
63+
if os.path.exists(dest_log_file):
64+
os.remove(dest_log_file)
65+
return None
66+
else:
67+
logger.debug(f"No new content in source file {src_log_file}")
68+
if os.path.exists(dest_log_file):
69+
os.remove(dest_log_file)
70+
return None
5671
else:
57-
logger.warning(f"Log file {src_log_file} not found or empty")
72+
logger.debug(f"Source log file {src_log_file} not found")
5873
if os.path.exists(dest_log_file):
5974
os.remove(dest_log_file)
6075
return None

0 commit comments

Comments
 (0)