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
71 changes: 61 additions & 10 deletions tensorrt_llm/_torch/pyexecutor/hang_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ def __init__(
self.active = False
self._detected = False
self._status_providers: list[Callable[[], str]] = []
# Monotonic stamp the watcher compares against; ``inf`` means disarmed.
# A plain float store is the entire cost of ``checkpoint()``.
self._deadline = math.inf

def start(self):
"""Enable hang detection."""
Expand All @@ -354,18 +357,69 @@ def run_loop():
asyncio.set_event_loop(self.loop)
self.loop.run_forever()

self.active = True
with self.lock:
# Locked, not a bare check: concurrent callers could both observe
# ``active`` false and schedule a watcher, and watchers share
# ``_deadline``, so a second one reports the same lapse twice and
# propagates two hard kills.
if self.active:
_best_effort_log_error(
"HangDetector.start() called while already active; ignoring."
)
return
# Disarmed until the first checkpoint so startup does not lapse.
# Stored before ``active`` is published so a checkpoint racing this
# call cannot have its arm overwritten here.
self._deadline = math.inf
self.active = True

self.loop = asyncio.new_event_loop()
self.loop_thread = threading.Thread(target=run_loop, daemon=True, name="hang_detector_loop")
self.loop_thread.start()
# One long-lived watcher, scheduled once. The hot path never cancels or
# re-arms it; it only moves ``_deadline``.
self.task = asyncio.run_coroutine_threadsafe(self._watch(), self.loop)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def register_status_provider(self, provider: Callable[[], str]) -> None:
"""Register a nonblocking callable that returns status to dump on hang detection."""
with self.lock:
self._status_providers.append(provider)

async def _detect_hang(self) -> None:
await asyncio.sleep(self.timeout)
async def _watch(self) -> None:
"""Sleep until the deadline lapses, report, and keep watching.

Waking early is normal: ``checkpoint()`` pushes ``_deadline`` forward
without touching this task, so each wake-up either finds time left and
sleeps again, or finds the deadline passed and reports. While disarmed
the deadline is ``inf``; the sleep is clamped to ``timeout`` because
``checkpoint()`` only stores a float and never wakes this loop, so an
unclamped sleep would not notice a later arm.

This task outlives a report, and outlives a report that raises. A
watchdog that quietly stopped watching would be the exact failure it
exists to catch, and ``on_detected`` is the cross-rank hard kill, which
can itself fail on an already-degraded job.
"""
while self.active:
deadline = self._deadline
remaining = deadline - time.monotonic()
if remaining > 0:
await asyncio.sleep(min(remaining, self.timeout))
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Disarm only the deadline observed to lapse, so one lapse reports
# once. A checkpoint racing this branch installs a newer deadline;
# clearing that would leave the watcher alive but permanently
# disarmed, which is the failure this watchdog exists to catch.
if self._deadline == deadline:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if self._deadline == deadline: self._deadline = math.inf is a non-atomic compare-and-set — the load/compare and the store are separate bytecodes, so a checkpoint() from the executor thread landing between them is still erased. That is the case the comment rules out, and test_a_checkpoint_racing_the_disarm_is_not_erased doesn't cover it either: it injects from _watch's own time.monotonic() call, i.e. between the local read and the comparison, not between the comparison and the store. The practical impact is small (a lapse only reaches here after timeout without a checkpoint, and the next checkpoint re-arms within an iteration), but since this is off the hot path, could these two lines take self.lock, or the comment be softened to best-effort?

self._deadline = math.inf
try:
await self._report_hang()
except Exception as error: # noqa: BLE001 - the watcher must survive
_best_effort_log_error(
f"HangDetector: reporting failed with {type(error).__name__}: {error}"
)

async def _report_hang(self) -> None:
with self.lock:
status_providers = tuple(self._status_providers)

Expand Down Expand Up @@ -399,21 +453,18 @@ def detected(self):

def checkpoint(self):
"""Reset hang detection timer."""
self.cancel_task()
if self.active:
self.task = asyncio.run_coroutine_threadsafe(self._detect_hang(), self.loop)
self._deadline = time.monotonic() + self.timeout

def cancel_task(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel_task() no longer cancels a task — it only disarms the deadline, as the new docstring correctly says, so the name now points at the old design. Note it can't simply be renamed: run_precheck.py calls it and test_cache_transceiver_precheck_run.py::test_hang_detector_surface asserts the attribute exists. Consider adding disarm() as the real name and keeping cancel_task = disarm as an explicit compatibility alias.

"""Cancel the hang detection task."""
if self.task is not None and not self.task.done():
self.task.cancel()
self.task = None
"""Disarm hang detection until the next checkpoint."""
self._deadline = math.inf

@contextmanager
def pause(self):
"""Pause hang detection in scope."""
self._deadline = math.inf

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pause() previously called cancel_task(); inlining self._deadline = math.inf here makes four places that write the sentinel. pause() runs twice per iteration and is already orders of magnitude cheaper than the task cancel it replaces, so the call shouldn't affect the numbers — keeping it leaves "what disarming means" (and any future change to it, e.g. taking the lock) in one place.

try:
self.cancel_task()
yield
finally:
self.checkpoint()
Expand Down
22 changes: 11 additions & 11 deletions tensorrt_llm/_torch/pyexecutor/py_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1813,9 +1813,8 @@ def profile_step():
# — the events being read have already passed by the time we
# read them. Stashing on self lets the /metrics serializer pick
# up the values without going through the log line.
should_capture_timing = start_time is not None and (
self.print_log or self.enable_iter_perf_stats)
if should_capture_timing:
should_capture_timing = self.print_log or self.enable_iter_perf_stats
if should_capture_timing and start_time is not None:
end_time = time.time()
if it % 2 == 0:
end_event_1.record()
Expand Down Expand Up @@ -1880,14 +1879,15 @@ def profile_step():

calibrator.pre_step(it)
start_time = time.time()
if it % 2 == 0:
if start_event_1 is None:
start_event_1 = torch.cuda.Event(enable_timing=True)
start_event_1.record()
else:
if start_event_2 is None:
start_event_2 = torch.cuda.Event(enable_timing=True)
start_event_2.record()
if should_capture_timing:
if it % 2 == 0:
if start_event_1 is None:
start_event_1 = torch.cuda.Event(enable_timing=True)
start_event_1.record()
else:
if start_event_2 is None:
start_event_2 = torch.cuda.Event(enable_timing=True)
start_event_2.record()

try:
yield profile_step
Expand Down
103 changes: 102 additions & 1 deletion tests/unittest/_torch/executor/test_hang_detector_kill.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import asyncio
import contextlib
import math
import os
import shutil
import signal
Expand Down Expand Up @@ -66,6 +67,106 @@ def test_checkpoint_resets_timer():
assert hd.detected() is False


def test_checkpoint_reuses_one_watcher_task():
"""One watcher task serves every checkpoint, pause and resume.

The executor loop checkpoints several times per iteration, and each
schedule/cancel of a task wakes the detector's event-loop thread, so the
single-task design is what keeps checkpoint() off that thread entirely.
"""
hd = HangDetector(timeout=30)
with hd:
task = hd.task
assert task is not None
for _ in range(10):
hd.checkpoint()
with hd.pause():
hd.checkpoint()
hd.checkpoint()
assert hd.task is task
assert not task.done()


def test_detector_is_disarmed_until_the_first_checkpoint():
"""start() enables detection; the first checkpoint arms the deadline.

Callers separate lifecycle start from arming, so the start-to-first-
checkpoint window must not be attributed to the loop as a hang.
"""
fired = []
hd = HangDetector(timeout=1, on_detected=lambda: fired.append(1))
with hd:
time.sleep(2.0) # would fire if start() armed the deadline itself
assert fired == []
assert hd.detected() is False


def test_watcher_survives_a_raising_callback():
"""on_detected is the cross-rank hard kill and can fail on a broken job."""
fired = []

def boom():
fired.append(1)
raise RuntimeError("hard kill failed")

hd = HangDetector(timeout=1, on_detected=boom)
with hd:
hd.checkpoint()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and len(fired) < 1:
time.sleep(0.05)
assert len(fired) == 1

# The watcher is still live and still able to report.
assert not hd.task.done()
hd.checkpoint()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and len(fired) < 2:
time.sleep(0.05)
assert len(fired) == 2


def test_a_checkpoint_racing_the_disarm_is_not_erased(monkeypatch):
"""A checkpoint landing as the watcher disarms must survive.

The watcher reads the lapsed deadline, then clears it. A checkpoint in
between installs a newer deadline; clearing that would leave the watcher
running but permanently disarmed, so the work it just armed could hang
undetected.
"""
hd = HangDetector(timeout=1, on_detected=lambda: None)
real_monotonic = hang_detector_module.time.monotonic
state = {"injected": False, "busy": False}

def racing_monotonic():
now = real_monotonic()
if state["injected"] or state["busy"]:
return now
# Only inject from `_watch`'s own lapse computation. asyncio's event
# loop also reads the clock, and injecting from there would land
# outside the window and silently make this test vacuous.
caller = sys._getframe(1)
if caller.f_code.co_name != "_watch" or hd._deadline > now:
return now
# `_watch` has already read `self._deadline` into a local by now, so
# this checkpoint lands exactly between that read and the disarm.
state["busy"] = True
state["injected"] = True
hd.checkpoint()
state["busy"] = False
return now

monkeypatch.setattr(hang_detector_module.time, "monotonic", racing_monotonic)
with hd:
hd.checkpoint()
deadline = real_monotonic() + 5.0
while real_monotonic() < deadline and not state["injected"]:
time.sleep(0.05)
assert state["injected"], "the racing checkpoint never landed"
time.sleep(0.2) # let the watcher finish its disarm/report pass
assert hd._deadline != math.inf, "the racing checkpoint's arm was erased"


def test_pause_suppresses_detection():
fired = []
hd = HangDetector(timeout=1, on_detected=lambda: fired.append(1))
Expand Down Expand Up @@ -102,7 +203,7 @@ def failing_provider():
detector.register_status_provider(failing_provider)
detector.register_status_provider(lambda: "transceiver status")

asyncio.run(detector._detect_hang())
asyncio.run(detector._report_hang())

messages = "\n".join(message for kind, message in events if kind == "log")
assert "provider failed" in messages
Expand Down
Loading