Skip to content

Commit 66e91a1

Browse files
charles-typfacebook-github-bot
authored andcommitted
Add generic stage-aware perf hook infrastructure (facebookresearch#683)
Summary: Benchpress's perf hook spans an entire benchmark run today, so PMU + sysstat data ends up smeared across every sub-benchmark in one set of CSVs. For benchmarks that run many workloads back-to-back (WDL prod_set, SPEC2017 intrate, ...), distinguishing IPC, topdown breakdown, mpstat etc. per sub-benchmark requires teasing apart timestamps after the fact, which is brittle. This diff adds a generic, opt-in stage-aware mode to the perf hook. It is not tied to any specific benchmark -- consumers (WDL, SPEC, ...) land on top of this and just emit stage markers. The mechanism: 1. The perf hook accepts a new option `stage_aware: true`. When set, `before_job` does NOT start any monitors. Instead it creates a FIFO under benchmark_metrics_<uuid>/perf_stage.fifo, advertises its path via the env var BENCHPRESS_PERF_STAGE_FIFO, and spawns a coordinator thread. 2. The coordinator reads commands from the FIFO. Each "START <stage_name>" allocates a fresh set of perf monitors with that stage name as a sub-folder; each "STOP" terminates the monitors and writes their CSVs. Multiple START/STOP cycles are supported. `after_job` writes a final __EXIT__ to drain the coordinator. 3. Every existing perf monitor (mpstat, memstat, netstat, perfstat, vmstat, cpufreq*, power, topdown -- including IntelPerfSpect/3, BasePerfUtil, AMDPerfUtil, ARMPerfUtil, NVPerfUtil, NeoVerseV3PerfUtil) gains a `subdir` constructor arg that the base `Monitor.gen_path` joins under benchmark_metrics_<uuid>/. Existing callers that don't pass `subdir` keep their flat layout, so default mode is byte-for-byte unchanged. The resulting per-stage layout, e.g.: benchmark_metrics_<uuid>/ <stage_name>/ mpstat.csv mem-stat.csv perf-stat.csv topdown-... .csv Benchmark-specific wiring (WDL prod_set, SPEC2017 intrate) lands in later diffs in this stack. Reviewed By: YifanYuan3 Differential Revision: D108110315
1 parent 71fd704 commit 66e91a1

11 files changed

Lines changed: 308 additions & 67 deletions

File tree

benchpress/plugins/hooks/perf.py

Lines changed: 215 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import os
11+
import threading
1112
import traceback
1213

1314
from benchpress.lib import open_source
@@ -76,6 +77,27 @@
7677
logger = logging.getLogger(__name__)
7778

7879

80+
# Env var used to advertise the stage FIFO path to a child benchmark process.
81+
# When set, a stage-aware benchmark (e.g. WDL prod_set's run_prod.sh) writes
82+
# stage markers like ``START <stage_name>`` and ``STOP`` to that FIFO so
83+
# this hook can rotate the perf monitor set per sub-benchmark and emit one
84+
# folder of perf data per stage.
85+
PERF_STAGE_FIFO_ENV = "BENCHPRESS_PERF_STAGE_FIFO"
86+
87+
88+
def _resolve_subdir_option(opts):
89+
"""Return whether the user opted into stage-aware mode.
90+
91+
Stage-aware mode is requested either by setting ``stage_aware: true`` in
92+
the perf hook options, or by leaving the default (False). Today the only
93+
benchmark that emits stage markers is WDL prod_set, but the wiring is
94+
generic.
95+
"""
96+
if not isinstance(opts, dict):
97+
return False
98+
return bool(opts.get("stage_aware", False))
99+
100+
79101
class Perf(Hook):
80102
def before_job(self, opts, job):
81103
self.opts = DEFAULT_OPTIONS
@@ -91,8 +113,180 @@ def before_job(self, opts, job):
91113
if not os.path.isdir(self.benchmark_metrics_dir):
92114
os.mkdir(self.benchmark_metrics_dir)
93115

116+
self._stage_aware = _resolve_subdir_option(opts)
117+
self._stage_thread = None
118+
self._stage_fifo_path = None
119+
self._stage_lock = threading.Lock()
120+
self._current_stage = None
121+
self._current_monitors = []
122+
123+
if self._stage_aware:
124+
self._start_stage_coordinator(job)
125+
else:
126+
# Legacy / default path: start one set of monitors that span the
127+
# entire benchmark.
128+
self.monitors = self._build_and_run_monitors(job, subdir=None)
129+
130+
def after_job(self, opts, job):
131+
if self._stage_aware:
132+
self._stop_stage_coordinator()
133+
return
134+
for monitor in self.monitors:
135+
monitor.terminate()
136+
for monitor in self.monitors:
137+
monitor.write_csv()
138+
139+
# ------------------------------------------------------------------
140+
# Stage-aware mode
141+
# ------------------------------------------------------------------
142+
143+
def _start_stage_coordinator(self, job):
144+
"""Open a FIFO under the benchmark metrics dir and spawn a thread
145+
that reads ``START <stage>`` / ``STOP`` commands. Each START gets
146+
a fresh monitor set whose CSVs land in
147+
``benchmark_metrics_<uuid>/<stage>/``.
148+
149+
The FIFO path is exposed via the ``BENCHPRESS_PERF_STAGE_FIFO``
150+
env var so the child benchmark process can find it. We deliberately
151+
DO NOT start any monitors yet -- the coordinator launches them
152+
when the first STAGE command arrives.
153+
"""
154+
self._stage_fifo_path = os.path.join(
155+
self.benchmark_metrics_dir, "perf_stage.fifo"
156+
)
157+
# Recreate the FIFO each run so a stale one from a previous run
158+
# never confuses us.
159+
if os.path.exists(self._stage_fifo_path):
160+
os.unlink(self._stage_fifo_path)
161+
os.mkfifo(self._stage_fifo_path, 0o600)
162+
os.environ[PERF_STAGE_FIFO_ENV] = self._stage_fifo_path
163+
self._stage_stop_event = threading.Event()
164+
self._stage_thread = threading.Thread(
165+
target=self._stage_loop,
166+
args=(job,),
167+
name="perf-stage-coordinator",
168+
daemon=True,
169+
)
170+
self._stage_thread.start()
171+
logger.info(
172+
f"Perf hook: stage-aware mode active, FIFO at {self._stage_fifo_path}"
173+
)
174+
175+
def _stop_stage_coordinator(self):
176+
# Tell the coordinator to exit and unblock the FIFO read by writing
177+
# a final STOP. A standalone writer also lets us flush any lingering
178+
# monitor set without depending on the benchmark script having sent
179+
# its own STOP.
180+
self._stage_stop_event.set()
181+
try:
182+
with open(self._stage_fifo_path, "w") as f:
183+
f.write("STOP\n")
184+
f.write("__EXIT__\n")
185+
except Exception as e:
186+
logger.warning(f"Perf hook: failed to nudge stage FIFO: {e}")
187+
if self._stage_thread is not None:
188+
self._stage_thread.join(timeout=30)
189+
# Clean up the FIFO and the env var so a subsequent benchmark in the
190+
# same process doesn't accidentally re-use stale state.
191+
try:
192+
os.unlink(self._stage_fifo_path)
193+
except OSError:
194+
pass
195+
os.environ.pop(PERF_STAGE_FIFO_ENV, None)
196+
197+
def _stage_loop(self, job):
198+
"""Read commands from the FIFO until __EXIT__. Each line is one of:
199+
200+
START <stage_name>
201+
STOP
202+
__EXIT__
203+
204+
START allocates fresh monitors and runs them; STOP terminates them
205+
and writes their CSVs. Multiple START/STOP cycles are supported.
206+
207+
Implementation note: each writer that closes the FIFO causes EOF on
208+
the reader, so we have to re-open the FIFO after every burst rather
209+
than holding a single file handle. Each ``open(..., "r")`` blocks
210+
until a writer is available again -- which is exactly the behavior
211+
we want for a coordinator that's awaiting the next stage marker.
212+
"""
213+
try:
214+
while not self._stage_stop_event.is_set():
215+
with open(self._stage_fifo_path, "r") as fifo:
216+
for line in fifo:
217+
line = line.strip()
218+
if not line:
219+
continue
220+
if line == "__EXIT__":
221+
self._end_current_stage(job)
222+
return
223+
if line.startswith("START "):
224+
stage = line[len("START ") :].strip()
225+
self._begin_stage(job, stage)
226+
continue
227+
if line == "STOP":
228+
self._end_current_stage(job)
229+
continue
230+
logger.warning(
231+
f"Perf hook: ignoring unknown stage command: {line!r}"
232+
)
233+
except Exception as e:
234+
logger.warning(
235+
f"Perf hook: stage coordinator crashed: {type(e).__name__}: {e}"
236+
)
237+
238+
def _begin_stage(self, job, stage_name):
239+
with self._stage_lock:
240+
if self._current_stage is not None:
241+
# Implicit stop of the previous stage -- the script forgot to
242+
# close it. Don't lose data; flush before starting the next.
243+
logger.warning(
244+
f"Perf hook: implicit STOP of stage "
245+
f"{self._current_stage!r} before START {stage_name!r}"
246+
)
247+
self._end_current_stage_locked(job)
248+
# Sanitize the stage name into a filesystem-friendly subdir.
249+
sanitized = _sanitize_subdir(stage_name)
250+
logger.info(f"Perf hook: starting stage {sanitized!r}")
251+
self._current_stage = sanitized
252+
self._current_monitors = self._build_and_run_monitors(job, subdir=sanitized)
253+
254+
def _end_current_stage(self, job):
255+
with self._stage_lock:
256+
self._end_current_stage_locked(job)
257+
258+
def _end_current_stage_locked(self, job):
259+
if self._current_stage is None:
260+
return
261+
logger.info(f"Perf hook: stopping stage {self._current_stage!r}")
262+
for monitor in self._current_monitors:
263+
try:
264+
monitor.terminate()
265+
except Exception as e:
266+
logger.warning(
267+
f"Perf hook: terminating monitor {monitor.name} failed: {e}"
268+
)
269+
for monitor in self._current_monitors:
270+
try:
271+
monitor.write_csv()
272+
except Exception as e:
273+
logger.warning(f"Perf hook: write_csv on {monitor.name} failed: {e}")
274+
self._current_stage = None
275+
self._current_monitors = []
276+
277+
# ------------------------------------------------------------------
278+
# Shared monitor setup
279+
# ------------------------------------------------------------------
280+
281+
def _build_and_run_monitors(self, job, subdir):
282+
"""Instantiate every enabled monitor (with the given subdir) and
283+
start it. Returns the list of started monitors.
284+
285+
Refactored out of the original ``before_job`` body so both default
286+
mode and stage-aware mode share the same monitor-bring-up logic.
287+
"""
94288
should_run_perf_stat = True
95-
self.monitors = []
289+
monitors = []
96290
for mon_name in AVAIL_MONITORS.keys():
97291
# `enabled` is a perf-hook-level flag, not a monitor constructor
98292
# arg. Pop it out before passing the rest to the monitor class.
@@ -102,34 +296,45 @@ def before_job(self, opts, job):
102296
continue
103297
try:
104298
MonitorClass = AVAIL_MONITORS[mon_name]
105-
monitor = MonitorClass(job_uuid=job.uuid, **init_args)
299+
monitor = MonitorClass(job_uuid=job.uuid, subdir=subdir, **init_args)
106300
# We should disable PerfStat (and not run anything that uses PMU)
107301
# if IntelPerfSpect3 is enabled.
108302
if isinstance(monitor, topdown.IntelPerfSpect3) and monitor.supported:
109303
logger.info(
110304
"Disabling PerfStat to avoid conflict with IntelPerfSpect3"
111305
)
112306
should_run_perf_stat = False
113-
self.monitors.append(monitor)
307+
monitors.append(monitor)
114308
except Exception as e:
115309
logger.warning(
116310
f"Failed to load the perf monitor {mon_name} due to the following exception:"
117311
)
118312
logger.warning(traceback.print_exception(type(e), e, e.__traceback__))
119313

120-
for monitor in self.monitors:
314+
for monitor in monitors:
121315
try:
122316
if isinstance(monitor, perfstat.PerfStat) and not should_run_perf_stat:
123317
continue
124318
monitor.run()
125319
except Exception as e:
126320
logger.warning(
127-
f"Could not run perf monitor {mon_name} due to the following exception:"
321+
f"Could not run perf monitor {monitor.name} due to the following exception:"
128322
)
129323
logger.warning(traceback.print_exception(type(e), e, e.__traceback__))
324+
return monitors
130325

131-
def after_job(self, opts, job):
132-
for monitor in self.monitors:
133-
monitor.terminate()
134-
for monitor in self.monitors:
135-
monitor.write_csv()
326+
327+
def _sanitize_subdir(name):
328+
"""Make a stage name safe to use as a directory component.
329+
330+
Strip path separators and trim. Keep alphanumerics, dashes, underscores;
331+
replace anything else with underscore.
332+
"""
333+
out = []
334+
for ch in name.strip():
335+
if ch.isalnum() or ch in "-_.":
336+
out.append(ch)
337+
else:
338+
out.append("_")
339+
cleaned = "".join(out).strip("._")
340+
return cleaned or "unnamed_stage"

benchpress/plugins/hooks/perf_monitors/__init__.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,44 @@
2121

2222
class Monitor:
2323
def gen_path(self, filename):
24-
return os.path.join(
25-
get_artifacts_dir(), f"benchmark_metrics_{self.job_uuid}", filename
26-
)
24+
"""Build a path under the job's benchmark_metrics directory.
25+
26+
When ``self.subdir`` is set (per-stage perf collection in stage-aware
27+
mode), the path is nested one level further so each stage gets its
28+
own sub-folder. Without ``subdir`` the layout is unchanged from
29+
legacy behavior:
2730
28-
def __init__(self, interval, name, job_uuid):
29-
"""Initialize some common parameters and storage variables"""
31+
benchmark_metrics_<uuid>/<filename> # no subdir
32+
benchmark_metrics_<uuid>/<subdir>/<filename> # stage-aware mode
33+
"""
34+
base = os.path.join(get_artifacts_dir(), f"benchmark_metrics_{self.job_uuid}")
35+
if getattr(self, "subdir", None):
36+
base = os.path.join(base, self.subdir)
37+
return os.path.join(base, filename)
38+
39+
def __init__(self, interval, name, job_uuid, subdir=None):
40+
"""Initialize some common parameters and storage variables.
41+
42+
Args:
43+
subdir: Optional sub-folder under benchmark_metrics_<uuid>/. Used
44+
by the perf hook's stage-aware mode to give each WDL prod_set
45+
sub-benchmark its own slice of perf data. None preserves the
46+
original flat layout.
47+
"""
3048
self.name = name
3149
self.interval = interval
3250
# Reserved for result processing
3351
self.res = []
3452
# Reserved for original output of the monitoring process
3553
self.output = ""
3654
self.job_uuid = job_uuid
55+
self.subdir = subdir
3756
self.logpath = self.gen_path(f"{name}.log")
3857
self.csvpath = self.gen_path(f"{name}.csv")
58+
# Make sure the (possibly nested) directory exists before opening
59+
# the log file. Stage-aware mode creates per-stage dirs lazily so we
60+
# can't rely on the perf hook to have made them.
61+
os.makedirs(os.path.dirname(self.logpath), exist_ok=True)
3962
self.logfile = open(self.logpath, "w", buffering=1) # noqa: P201
4063

4164
def __del__(self):

benchpress/plugins/hooks/perf_monitors/cpufreq_cpuinfo.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515

1616
class CPUFreq(Monitor):
17-
def __init__(self, interval, job_uuid):
18-
super(CPUFreq, self).__init__(interval, "cpufreq_cpuinfo", job_uuid)
17+
def __init__(self, interval, job_uuid, subdir=None):
18+
super(CPUFreq, self).__init__(
19+
interval, "cpufreq_cpuinfo", job_uuid, subdir=subdir
20+
)
1921
self.run_freq_collector = False
2022
self.supported = os.path.exists(
2123
"/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq"

benchpress/plugins/hooks/perf_monitors/cpufreq_scaling.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515

1616
class CPUFreq(Monitor):
17-
def __init__(self, interval, job_uuid):
18-
super(CPUFreq, self).__init__(interval, "cpufreq_scaling", job_uuid)
17+
def __init__(self, interval, job_uuid, subdir=None):
18+
super(CPUFreq, self).__init__(
19+
interval, "cpufreq_scaling", job_uuid, subdir=subdir
20+
)
1921
self.run_freq_collector = False
2022
self.supported = os.path.exists(
2123
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"

benchpress/plugins/hooks/perf_monitors/memstat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222

2323

2424
class MemStat(Monitor):
25-
def __init__(self, interval, job_uuid, additional_counters=()):
26-
super(MemStat, self).__init__(interval, "mem-stat", job_uuid)
25+
def __init__(self, interval, job_uuid, additional_counters=(), subdir=None):
26+
super(MemStat, self).__init__(interval, "mem-stat", job_uuid, subdir=subdir)
2727
counters = {"MemTotal", "MemFree", "MemAvailable", "SwapTotal", "SwapFree"}
2828
self.counters = counters.union(set(additional_counters))
2929
self.run_collector = False

benchpress/plugins/hooks/perf_monitors/mpstat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313

1414
class MPStat(Monitor):
15-
def __init__(self, interval, job_uuid):
16-
super(MPStat, self).__init__(interval, "mpstat", job_uuid)
15+
def __init__(self, interval, job_uuid, subdir=None):
16+
super(MPStat, self).__init__(interval, "mpstat", job_uuid, subdir=subdir)
1717
self.headers = []
1818

1919
def run(self):

benchpress/plugins/hooks/perf_monitors/netstat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515

1616
class NetStat(Monitor):
17-
def __init__(self, interval, job_uuid, additional_counters=()):
18-
super(NetStat, self).__init__(interval, "net-stat", job_uuid)
17+
def __init__(self, interval, job_uuid, additional_counters=(), subdir=None):
18+
super(NetStat, self).__init__(interval, "net-stat", job_uuid, subdir=subdir)
1919
counters = {"rx_bytes", "rx_packets", "tx_bytes", "tx_packets"}
2020
self.counters = counters.union(set(additional_counters))
2121
self.run_collector = False

benchpress/plugins/hooks/perf_monitors/perfstat.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ def unpack_perf_stat_line(line, delim=","):
3939

4040

4141
class PerfStat(Monitor):
42-
def __init__(self, interval, job_uuid, additional_events=(), delim=","):
43-
super(PerfStat, self).__init__(interval, "perf-stat", job_uuid)
42+
def __init__(
43+
self, interval, job_uuid, additional_events=(), delim=",", subdir=None
44+
):
45+
super(PerfStat, self).__init__(interval, "perf-stat", job_uuid, subdir=subdir)
4446
self.events = ["instructions", "cycles"] + list(additional_events)
4547
self.delim = delim
4648

0 commit comments

Comments
 (0)