Skip to content

Commit c0c3410

Browse files
charles-typfacebook-github-bot
authored andcommitted
Per-sub-benchmark PMU + sysstat for WDL prod_set
Summary: The WDL bench `prod_set` job runs ~25 individual sub-benchmarks (memcpy_benchmark, openssl, lzbench, ProtocolBench, ...) back-to-back in a single benchpress run. Today the perf hook spans the whole run, so PMU + sysstat data ends up smeared across all sub-benchmarks in one set of CSVs. Distinguishing IPC, topdown breakdown, mpstat etc. per sub-benchmark requires teasing apart timestamps after the fact, which is brittle. This change adds an opt-in stage-aware mode to the perf hook so each sub-benchmark gets its own folder of perf data: benchmark_metrics_<uuid>/ memcpy_benchmark/ mpstat.csv mem-stat.csv perf-stat.csv topdown-... .csv (etc -- one set per enabled perf monitor) hash_hash_benchmark/ ... ... 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. 4. `packages/wdl_bench/run_prod.sh` emits "START <bench>" before each sub-benchmark and "STOP" after. The emit is gated on the BENCHPRESS_PERF_STAGE_FIFO env var, so running prod_set without stage-aware mode (or without the perf hook at all) is unchanged. 5. A new job entry `prod_set_per_stage_perf` in jobs_wdl.yml documents the wiring; it has the same shape as `prod_set`. To activate, run: ./benchpress run prod_set_per_stage_perf \\ -k perf -a '{"perf": {"stage_aware": true}}' Differential Revision: D108110315
1 parent 8584479 commit c0c3410

13 files changed

Lines changed: 368 additions & 67 deletions

File tree

benchpress/config/jobs_wdl.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,44 @@
183183
args:
184184
- '--type {type}'
185185
- '--output {output}'
186+
- '--name {name}'
187+
vars:
188+
- 'type=prod'
189+
- 'output=wdl_bench_results.txt'
190+
- 'name=none'
191+
hooks:
192+
- hook: cpu-mpstat
193+
options:
194+
args:
195+
- '-u' # utilization
196+
- '1' # second interval
197+
- hook: copymove
198+
options:
199+
is_move: true
200+
after:
201+
- 'benchmarks/wdl_bench/wdl_bench_results.txt'
202+
- 'benchmarks/wdl_bench/out_*.json'
203+
204+
# Stage-aware variant of prod_set. Runs the same set of WDL sub-benchmarks
205+
# but signals the perf hook before/after each sub-benchmark via a FIFO so
206+
# every sub-benchmark gets its own folder of PMU + sysstat data under
207+
# benchmark_metrics_<uuid>/<sub_benchmark_name>/. To use, run
208+
# ./benchpress run prod_set_per_stage_perf -k perf -a '{"perf": {"stage_aware": true}}'
209+
# (or with whatever monitor overrides you want).
210+
- name: prod_set_per_stage_perf
211+
benchmark: wdl_bench
212+
description: >
213+
Same as prod_set, but pairs with the perf hook's stage-aware mode to
214+
collect a separate set of PMU + sysstat data per WDL sub-benchmark.
215+
Pass `-k perf -a '{"perf": {"stage_aware": true}}'` to enable.
216+
args:
217+
- '--type {type}'
218+
- '--output {output}'
219+
- '--name {name}'
186220
vars:
187221
- 'type=prod'
188222
- 'output=wdl_bench_results.txt'
223+
- 'name=none'
189224
hooks:
190225
- hook: cpu-mpstat
191226
options:

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
@@ -75,6 +76,27 @@
7576
logger = logging.getLogger(__name__)
7677

7778

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

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

119-
for monitor in self.monitors:
313+
for monitor in monitors:
120314
try:
121315
if isinstance(monitor, perfstat.PerfStat) and not should_run_perf_stat:
122316
continue
123317
monitor.run()
124318
except Exception as e:
125319
logger.warning(
126-
f"Could not run perf monitor {mon_name} due to the following exception:"
320+
f"Could not run perf monitor {monitor.name} due to the following exception:"
127321
)
128322
logger.warning(traceback.print_exception(type(e), e, e.__traceback__))
323+
return monitors
129324

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

0 commit comments

Comments
 (0)