Skip to content

Commit 15300d8

Browse files
Liang (AIML-Compute) Hechanglan
authored andcommitted
Use process group to manage the trainer process in AXLearnFT
GitOrigin-RevId: 1994c53
1 parent 4f59a66 commit 15300d8

2 files changed

Lines changed: 106 additions & 30 deletions

File tree

axlearn/ft/agent.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
--max_restarts=5
1515
"""
1616

17+
import enum
1718
import signal
1819
import subprocess
1920
import sys
@@ -25,11 +26,58 @@
2526
from axlearn.ft.monitor import StatusMonitor
2627
from axlearn.ft.utils import TrainerProcessController
2728

29+
30+
class TerminationAction(enum.Enum):
31+
"""Action to take after a termination request."""
32+
33+
EXIT = "exit" # Pod shutdown - exit gracefully
34+
RESTART = "restart" # Coordinated restart - restart without counting against max_restarts
35+
36+
37+
# Constant for pod shutdown signal reason prefix
38+
_POD_SHUTDOWN_REASON_PREFIX = "Pod shutdown signal"
39+
2840
flags.DEFINE_integer(
2941
"max_restarts", 3, "Maximum number of times to restart the trainer subprocess on failure"
3042
)
3143

3244

45+
def _handle_termination_request(
46+
process_controller: TrainerProcessController,
47+
) -> TerminationAction | None:
48+
"""Check if termination was requested and determine the appropriate action.
49+
50+
Args:
51+
process_controller: The trainer process controller to check for termination requests.
52+
53+
Returns:
54+
TerminationAction.EXIT if pod shutdown was requested (caller should return/exit
55+
gracefully),
56+
TerminationAction.RESTART if coordinated restart was requested (caller should continue
57+
without incrementing restart count),
58+
None if no termination was requested (caller should handle returncode normally).
59+
"""
60+
termination_requested, reason = process_controller.check_termination_requested()
61+
if not termination_requested:
62+
return None
63+
64+
# Distinguish between pod shutdown (exit) and coordinated restart (restart)
65+
if reason.startswith(_POD_SHUTDOWN_REASON_PREFIX):
66+
# SIGTERM received - pod is being killed, exit gracefully
67+
logging.info(
68+
"FT Agent: Pod shutdown signal received (%s), exiting gracefully",
69+
reason,
70+
)
71+
return TerminationAction.EXIT
72+
73+
# Coordinated restart (JAX re-init) - restart trainer without counting against max_restarts
74+
logging.info(
75+
"FT Agent: Coordinated restart requested (%s), restarting trainer",
76+
reason,
77+
)
78+
return TerminationAction.RESTART
79+
80+
3381
def run_ft_agent():
3482
"""The agent launches trainer as a subprocess with fault tolerance."""
3583
logging.info("Starting fault tolerant trainer agent...")
@@ -52,14 +100,14 @@ def signal_handler(signum, *_):
52100

53101
# Report pod shutdown to global manager for coordination
54102
try:
55-
monitor.manager.report_pod_shutdown(f"Pod shutdown signal {signum}")
103+
monitor.manager.report_pod_shutdown(f"{_POD_SHUTDOWN_REASON_PREFIX} {signum}")
56104
logging.info("FT Agent: Pod shutdown reported to global manager")
57105
except Exception as e: # pylint: disable=broad-exception-caught
58106
logging.error("FT Agent: Failed to report pod shutdown: %s", e)
59107

60108
# Terminate the trainer process
61109
logging.info("FT Agent: Terminating trainer due to signal %d", signum)
62-
process_controller.terminate_training(f"Pod shutdown signal {signum}")
110+
process_controller.terminate_training(f"{_POD_SHUTDOWN_REASON_PREFIX} {signum}")
63111

64112
logging.warning("FT Agent: exit with non-zero code due to signal %d recieved.", signum)
65113
sys.exit(1)
@@ -77,13 +125,26 @@ def signal_handler(signum, *_):
77125
logging.info("FT Agent: Starting trainer: %s", " ".join(entrypoint_cmd))
78126

79127
try:
128+
# Start trainer in its own process group (start_new_session=True)
129+
# This allows os.killpg() to terminate all threads cleanly
80130
with subprocess.Popen(
81-
entrypoint_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
131+
entrypoint_cmd,
132+
stdout=subprocess.PIPE,
133+
stderr=subprocess.STDOUT,
134+
text=True,
135+
start_new_session=True,
82136
) as process:
83137
process_controller.set_process(process)
84138
returncode = monitor.monitor_training_process(process)
85139
process_controller.clear_process()
86140

141+
# Handle termination requests (SIGTERM or coordinated restart)
142+
action = _handle_termination_request(process_controller)
143+
if action == TerminationAction.EXIT:
144+
return
145+
if action == TerminationAction.RESTART:
146+
continue # Restart without incrementing restart_count
147+
87148
if returncode == 0:
88149
logging.info("FT Agent: Training completed successfully")
89150
return

axlearn/ft/utils.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
55
Environment Variables Used:
66
HOSTNAME: Current worker's hostname
7-
REPLICA_ID: Current worker's replica ID (0-based)
7+
MEGASCALE_SLICE_ID: Current worker's slice/replica ID (0-based)
88
TPU_WORKER_ID: Current worker's ID within the replica (0-based)
99
TPU_WORKER_HOSTNAMES: Comma-separated list of all worker hostnames in current replica
10-
NUM_REPLICAS: Total number of replicas in the training job
10+
NUM_TPU_SLICES: Total number of slices/replicas in the training job
1111
1212
Hostname Format:
1313
TPU worker hostnames follow the pattern: {job_name}-"job"-{replica_id}-{worker_id}.{job_name}
@@ -18,6 +18,7 @@
1818
import os
1919
import pathlib
2020
import re
21+
import signal
2122
import subprocess
2223
import threading
2324
import time
@@ -35,9 +36,6 @@
3536
# Client timeout in seconds
3637
DEFAULT_CLIENT_TIMEOUT = 10.0
3738

38-
# Timeout for graceful training process termination in seconds
39-
GRACEFUL_TRAINING_TERMINATION_TIMEOUT = 1
40-
4139
# Compiled regex patterns for hostname parsing
4240
_WORKER_ID_PATTERN = re.compile(r"^.+?-job-\d+-(\d+)\..+")
4341
_JOB_NAME_PATTERN = re.compile(r"^(.+?)-job-\d+-\d+\.(\1)(?:\.|$)")
@@ -110,26 +108,43 @@ def _cleanup_tpu_lock_files(self) -> None:
110108
def _do_terminate(self) -> bool:
111109
"""Execute the actual process termination sequence.
112110
111+
Uses process group killing to ensure all threads are terminated.
112+
The trainer subprocess should be started with start_new_session=True.
113+
113114
Returns:
114115
bool: True if termination succeeded, False otherwise
115116
"""
116-
self.current_process.terminate()
117+
pid = self.current_process.pid
118+
119+
# Try to get process group ID
117120
try:
118-
self.current_process.wait(timeout=GRACEFUL_TRAINING_TERMINATION_TIMEOUT)
119-
logging.info("Process terminated gracefully")
121+
pgid = os.getpgid(pid)
122+
except ProcessLookupError:
123+
logging.info("Process %d already dead", pid)
124+
self._cleanup_tpu_lock_files()
120125
return True
121-
except subprocess.TimeoutExpired:
122-
pass
123126

124-
logging.warning("Process did not terminate gracefully, forcing kill")
125-
self.current_process.kill()
127+
# If trainer has its own process group, kill the entire group
128+
if pgid == pid:
129+
logging.warning("Sending SIGKILL to process group %d", pgid)
130+
try:
131+
os.killpg(pgid, signal.SIGKILL)
132+
except ProcessLookupError:
133+
logging.info("Process group %d already dead", pgid)
134+
except PermissionError as e:
135+
logging.warning("Cannot kill process group %d: %s, killing process only", pgid, e)
136+
self.current_process.kill()
137+
else:
138+
logging.warning("Sending SIGKILL to process %d", pid)
139+
self.current_process.kill()
140+
126141
try:
127-
self.current_process.wait(timeout=5)
142+
self.current_process.wait(timeout=10)
128143
logging.info(
129144
"Process killed successfully, exit code: %s", self.current_process.returncode
130145
)
131146
except subprocess.TimeoutExpired:
132-
logging.error("Process still alive after SIGKILL, pid=%d", self.current_process.pid)
147+
logging.error("Process still alive after SIGKILL, pid=%d", pid)
133148
return False
134149

135150
self._cleanup_tpu_lock_files()
@@ -220,19 +235,20 @@ def get_replica_id() -> int:
220235
"""Get replica ID from environment variable with validation.
221236
222237
Returns:
223-
The replica ID as an integer.
238+
The replica ID as an integer. Defaults to 0 for single-slice jobs.
224239
225240
Raises:
226-
ValueError: If REPLICA_ID is not set or not a valid integer.
241+
ValueError: If MEGASCALE_SLICE_ID is not a valid integer.
227242
"""
228-
replica_id_str = os.environ.get("REPLICA_ID")
229-
if replica_id_str is None:
230-
raise ValueError("REPLICA_ID environment variable is not set")
243+
# Default to "0" for single-slice jobs where MEGASCALE_SLICE_ID is not set
244+
replica_id_str = os.environ.get("MEGASCALE_SLICE_ID", "0")
231245

232246
try:
233247
return int(replica_id_str)
234248
except ValueError as e:
235-
raise ValueError(f"REPLICA_ID must be a valid integer, got: '{replica_id_str}'") from e
249+
raise ValueError(
250+
f"MEGASCALE_SLICE_ID must be a valid integer, got: '{replica_id_str}'"
251+
) from e
236252

237253

238254
def get_worker_id() -> int:
@@ -258,23 +274,22 @@ def get_num_replicas() -> int:
258274
"""Get total number of replicas from environment variable with validation.
259275
260276
Returns:
261-
The total number of replicas as an integer.
277+
The total number of replicas as an integer. Defaults to 1 for single-slice jobs.
262278
263279
Raises:
264-
ValueError: If NUM_REPLICAS is not set or not a valid integer.
280+
ValueError: If NUM_TPU_SLICES is not a valid positive integer.
265281
"""
266-
num_replicas_str = os.environ.get("NUM_REPLICAS")
267-
if num_replicas_str is None:
268-
raise ValueError("NUM_REPLICAS environment variable is not set")
282+
# Default to "1" for single-slice jobs where NUM_TPU_SLICES is not set
283+
num_replicas_str = os.environ.get("NUM_TPU_SLICES", "1")
269284

270285
try:
271286
num_replicas = int(num_replicas_str)
272287
if num_replicas < 1:
273-
raise ValueError(f"NUM_REPLICAS must be at least 1, got: {num_replicas}")
288+
raise ValueError(f"NUM_TPU_SLICES must be at least 1, got: {num_replicas}")
274289
return num_replicas
275290
except ValueError as e:
276291
raise ValueError(
277-
f"NUM_REPLICAS must be a valid positive integer, got: '{num_replicas_str}'"
292+
f"NUM_TPU_SLICES must be a valid positive integer, got: '{num_replicas_str}'"
278293
) from e
279294

280295

0 commit comments

Comments
 (0)