Skip to content

Commit c8d49b3

Browse files
excelle08meta-codesync[bot]
authored andcommitted
Fix perf hook teardown hang via process-group signaling and bounded waits (#717)
Summary: Pull Request resolved: #717 The `perf` hook (used as `-k perf` or as part of `-k fb_chef_off_turbo_on fb_stop_dynologd perf`) was hanging indefinitely on benchmark completion. Symptom: after the benchmark JSON metrics had been written, the parent `./benchpress run` Python stayed alive forever waiting on `subprocess.Popen.wait()` for the perf collector bash scripts. On BGM the hung children were `collect_amd_perf_counters.sh` + `collect_amd_zen4_perf_counters.sh`; on Grace the analog was `collect_nvda_neoversev2_perf_counters.sh`. This blocked every overnight automation run that used the perf hook, and required manually `kill -9`-ing the collector PIDs. Root cause: `Monitor.terminate()` did `os.kill(self.proc.pid, signal.SIGINT)` followed by an *unbounded* `self.proc.wait()`. But `self.proc` is the bash interpreter PID running the collector script; bash does NOT synchronously forward signals to its `wait`-blocked foreground child (`perf stat`). Even when the collector script's SIGINT trap eventually fires, the parent Python is also blocked on the output-catcher thread's `readline()` until the pipe closes, which only happens after bash exits. On AMD hosts the two simultaneous collectors (`perfutil` + `perfutil_zen4`) compete for the PMU and one of them can hang at startup — its `wait()` therefore never returns. No timeout anywhere → indefinite hang. Fix (centralized in `Monitor.terminate()`): 1. **Process-group signaling.** `os.killpg(os.getpgid(self.proc.pid), SIGINT)` so both the bash wrapper and its `perf stat` child receive SIGINT in the same atomic delivery. Requires the Popen to have been started with `start_new_session=True` so a new pgid was created. 2. **Bounded waits.** `self.proc.wait(timeout=15)` after SIGINT; if it times out, escalate to `os.killpg(..., SIGKILL)` with another `wait(timeout=5)`. Catcher threads also get `join(timeout=5)`. Worst-case teardown is now ~25s with a logger warning, not indefinite. Coordinated fix in every long-running `subprocess.Popen` that becomes `self.proc`: - `perf_monitors/__init__.py` `Monitor.terminate()` — process-group SIGINT + escalating SIGKILL + bounded waits + thread joins with timeouts - `perf_monitors/perfstat.py:96` (`PerfStat.run`) — add `start_new_session=True` - `perf_monitors/topdown.py:187` (`IntelPerfSpect.run`) — same - `perf_monitors/topdown.py:268` (`IntelPerfSpect3.run`) — same - `perf_monitors/topdown.py:332` (`BasePerfUtil.run` — AMD collectors) — same - `perf_monitors/topdown.py:505` (`ARMPerfUtil.run` — Grace) — same Short-lived post-processing Popens (those followed immediately by `.wait()` with no concurrent reader) were left unchanged — they don't suffer from the hang because they're synchronous. Reviewed By: YifanYuan3 Differential Revision: D107426991
1 parent f972432 commit c8d49b3

3 files changed

Lines changed: 67 additions & 10 deletions

File tree

benchpress/plugins/hooks/perf_monitors/__init__.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,45 @@ def run(self):
9898

9999
def terminate(self):
100100
"""
101-
Kill the monitoring process using SIGINT signal and join the stdout
102-
and stderr catcher threads.
101+
Kill the monitoring process and join its stdout/stderr catcher threads.
102+
103+
The monitor's `proc` is typically a bash wrapper script (e.g.
104+
`perfutils/collect_amd_perf_counters.sh`) that itself spawns `perf
105+
stat`. Signaling only the bash PID is unreliable because bash does
106+
not synchronously forward signals to its `wait`-blocked foreground
107+
child — leaving `perf stat` running and `proc.wait()` hanging
108+
forever. We send SIGINT to the entire process group (which requires
109+
the Popen to have used `start_new_session=True`) and bound the wait
110+
with a timeout, escalating to SIGKILL if SIGINT isn't honored.
103111
"""
104112
exitcode = -1
105113
if hasattr(self, "proc") and isinstance(self.proc, subprocess.Popen):
106-
os.kill(self.proc.pid, signal.SIGINT)
107-
exitcode = self.proc.wait()
114+
try:
115+
os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
116+
except (ProcessLookupError, PermissionError):
117+
pass
118+
try:
119+
exitcode = self.proc.wait(timeout=15)
120+
except subprocess.TimeoutExpired:
121+
logger.warning(
122+
f"{getattr(self, 'name', 'Monitor')}: SIGINT did not "
123+
"terminate within 15s, escalating to SIGKILL"
124+
)
125+
try:
126+
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
127+
except (ProcessLookupError, PermissionError):
128+
pass
129+
try:
130+
exitcode = self.proc.wait(timeout=5)
131+
except subprocess.TimeoutExpired:
132+
logger.error(
133+
f"{getattr(self, 'name', 'Monitor')}: SIGKILL did "
134+
"not reap process within 5s; leaving as orphan"
135+
)
108136
if hasattr(self, "oc") and isinstance(self.oc, threading.Thread):
109-
self.oc.join()
137+
self.oc.join(timeout=5)
110138
if hasattr(self, "ec") and isinstance(self.ec, threading.Thread):
111-
self.ec.join()
139+
self.ec.join(timeout=5)
112140
return exitcode
113141

114142
def get_result(self):

benchpress/plugins/hooks/perf_monitors/perfstat.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,11 @@ def run(self):
9393
"--log-fd",
9494
"1",
9595
]
96-
self.proc = subprocess.Popen(args, stdout=subprocess.PIPE, encoding="utf-8")
96+
self.proc = subprocess.Popen(
97+
args,
98+
stdout=subprocess.PIPE,
99+
encoding="utf-8",
100+
# Process-group isolation for clean teardown.
101+
start_new_session=True,
102+
)
97103
super(PerfStat, self).run()

benchpress/plugins/hooks/perf_monitors/topdown.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,16 @@ def run(self):
185185
self.collect_output_path,
186186
]
187187
self.proc = subprocess.Popen(
188-
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
188+
args,
189+
stdout=subprocess.PIPE,
190+
stderr=subprocess.PIPE,
191+
encoding="utf-8",
192+
# Run in its own process group so Monitor.terminate() can killpg()
193+
# both the bash wrapper and its perf-stat child cleanly. Without
194+
# this, SIGINT to the bash PID alone leaves perf-stat orphaned
195+
# and parent .wait() hangs (root cause of the t29/t32 teardown
196+
# hang on fb_chef_off_turbo_on + perf hook combo).
197+
start_new_session=True,
189198
)
190199
super(IntelPerfSpect, self).run()
191200

@@ -266,7 +275,12 @@ def run(self):
266275
self.collect_output_path,
267276
]
268277
self.proc = subprocess.Popen(
269-
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
278+
args,
279+
stdout=subprocess.PIPE,
280+
stderr=subprocess.PIPE,
281+
encoding="utf-8",
282+
# Process-group isolation for clean teardown (see IntelPerfSpect).
283+
start_new_session=True,
270284
)
271285
super(IntelPerfSpect3, self).run()
272286

@@ -329,7 +343,14 @@ def run(self):
329343
cmd = [perf_collect_script]
330344
if self.interval is not None:
331345
cmd.append(str(self.interval))
332-
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, encoding="utf-8")
346+
self.proc = subprocess.Popen(
347+
cmd,
348+
stdout=subprocess.PIPE,
349+
encoding="utf-8",
350+
# Process-group isolation for clean teardown — Monitor.terminate
351+
# killpg()'s this so perf stat + bash wrapper both die together.
352+
start_new_session=True,
353+
)
333354
super(BasePerfUtil, self).run()
334355

335356
def gen_csv(self):
@@ -494,6 +515,8 @@ def run(self):
494515
stdout=subprocess.PIPE,
495516
stderr=subprocess.PIPE,
496517
encoding="utf-8",
518+
# Process-group isolation for clean teardown.
519+
start_new_session=True,
497520
)
498521
super(ARMPerfUtil, self).run()
499522

0 commit comments

Comments
 (0)