Skip to content

Commit 0293c7d

Browse files
committed
Implement --timeout when running benchmarks
If the benchmark execution is exceeding the timeout execution, pyperf exits with an error 124. This error can be caught by pyperformance or other tool and report it back to the user.
1 parent 922ad35 commit 0293c7d

File tree

5 files changed

+67
-8
lines changed

5 files changed

+67
-8
lines changed

doc/runner.rst

+4
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Option::
9898
--inherit-environ=VARS
9999
--copy-env
100100
--no-locale
101+
--timeout TIMEOUT
101102
--track-memory
102103
--tracemalloc
103104

@@ -140,6 +141,9 @@ Option::
140141
- ``LC_TELEPHONE``
141142
- ``LC_TIME``
142143

144+
* ``--timeout``: set a timeout in seconds for an execution of the benchmark.
145+
If the benchmark execution times out, pyperf exits with error code 124.
146+
There is no time out by default.
143147
* ``--tracemalloc``: Use the ``tracemalloc`` module to track Python memory
144148
allocation and get the peak of memory usage in metadata
145149
(``tracemalloc_peak``). The module is only available on Python 3.4 and newer.

pyperf/_manager.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def worker_cmd(self, calibrate_loops, calibrate_warmups, wpipe):
6969
if args.profile:
7070
cmd.extend(['--profile', args.profile])
7171

72+
if args.timeout:
73+
cmd.extend(['--timeout', str(args.timeout)])
74+
7275
if args.hook:
7376
for hook in args.hook:
7477
cmd.extend(['--hook', hook])
@@ -83,7 +86,7 @@ def spawn_worker(self, calibrate_loops, calibrate_warmups):
8386
self.args.locale,
8487
self.args.copy_env)
8588

86-
rpipe, wpipe = create_pipe()
89+
rpipe, wpipe = create_pipe(timeout=self.args.timeout)
8790
with rpipe:
8891
with wpipe:
8992
warg = wpipe.to_subprocess()
@@ -102,10 +105,12 @@ def spawn_worker(self, calibrate_loops, calibrate_warmups):
102105
proc = subprocess.Popen(cmd, env=env, **kw)
103106

104107
with popen_killer(proc):
105-
with rpipe.open_text() as rfile:
106-
bench_json = rfile.read()
107-
108-
exitcode = proc.wait()
108+
try:
109+
bench_json = rpipe.read_text()
110+
exitcode = proc.wait()
111+
except TimeoutError as exc:
112+
print(exc)
113+
sys.exit(124)
109114

110115
if exitcode:
111116
raise RuntimeError("%s failed with exit code %s"

pyperf/_runner.py

+4
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ def __init__(self, values=None, processes=None,
183183
'value, used to calibrate the number of '
184184
'loops (default: %s)'
185185
% format_timedelta(min_time))
186+
parser.add_argument('--timeout',
187+
help='Specify a timeout in seconds for a single '
188+
'benchmark execution (default: disabled)',
189+
type=strictly_positive)
186190
parser.add_argument('--worker', action='store_true',
187191
help='Worker process, run the benchmark.')
188192
parser.add_argument('--worker-task', type=positive_or_nul, metavar='TASK_ID',

pyperf/_utils.py

+31-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import contextlib
22
import math
33
import os
4+
import select
45
import statistics
56
import sys
67
import sysconfig
8+
import time
79
from shlex import quote as shell_quote # noqa
810
from shutil import which
911

@@ -286,8 +288,9 @@ def create_environ(inherit_environ, locale, copy_all):
286288
class _Pipe:
287289
_OPEN_MODE = "r"
288290

289-
def __init__(self, fd):
291+
def __init__(self, fd, timeout=None):
290292
self._fd = fd
293+
self._timeout = timeout
291294
self._file = None
292295
if MS_WINDOWS:
293296
self._handle = msvcrt.get_osfhandle(fd)
@@ -317,9 +320,34 @@ def __exit__(self, *args):
317320
class ReadPipe(_Pipe):
318321
def open_text(self):
319322
file = open(self._fd, "r", encoding="utf8")
323+
if self._timeout:
324+
os.set_blocking(file.fileno(), False)
320325
self._file = file
321326
return file
322327

328+
def read_text(self):
329+
with self.open_text() as rfile:
330+
if self._timeout:
331+
return self._read_text_timeout(rfile, self._timeout)
332+
else:
333+
return rfile.read()
334+
335+
def _read_text_timeout(self, rfile, timeout):
336+
start_time = time.time()
337+
while True:
338+
if time.time() - start_time > timeout:
339+
raise TimeoutError(f"Timed out after {timeout} seconds")
340+
ready, _, _ = select.select([rfile], [], [], timeout)
341+
if ready:
342+
data = rfile.read()
343+
if data:
344+
return data
345+
else:
346+
break
347+
else:
348+
pass
349+
350+
323351

324352
class WritePipe(_Pipe):
325353
def to_subprocess(self):
@@ -346,9 +374,9 @@ def open_text(self):
346374
return file
347375

348376

349-
def create_pipe():
377+
def create_pipe(timeout=None):
350378
rfd, wfd = os.pipe()
351-
rpipe = ReadPipe(rfd)
379+
rpipe = ReadPipe(rfd, timeout)
352380
wpipe = WritePipe(wfd)
353381
return (rpipe, wpipe)
354382

pyperf/tests/test_runner.py

+18
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ def test_pipe(self):
149149
self.assertEqual(bench_json,
150150
tests.benchmark_as_json(result.bench))
151151

152+
def test_pipe_with_timeout(self):
153+
rpipe, wpipe = create_pipe(timeout=1)
154+
with rpipe:
155+
with wpipe:
156+
arg = wpipe.to_subprocess()
157+
# Don't close the file descriptor, it is closed by
158+
# the Runner class
159+
wpipe._fd = None
160+
161+
self.exec_runner('--pipe', str(arg), '--worker', '-l1', '-w1')
162+
163+
# Mock the select to make the read pipeline not ready
164+
with mock.patch('pyperf._utils.select.select', return_value=(False, False, False)):
165+
with self.assertRaises(TimeoutError) as cm:
166+
rpipe.read_text()
167+
self.assertEqual(str(cm.exception),
168+
'Timed out after 1 seconds')
169+
152170
def test_json_exists(self):
153171
with tempfile.NamedTemporaryFile('wb+') as tmp:
154172

0 commit comments

Comments
 (0)