Skip to content

Commit 9754ce3

Browse files
author
Vasileios Karakasis
authored
Merge pull request #2545 from vkarak/feat/time-profiler
[feat] Add a lightweight time profiler in the framework
2 parents 8d758ce + ca5f8da commit 9754ce3

File tree

9 files changed

+218
-4
lines changed

9 files changed

+218
-4
lines changed

reframe/core/decorators.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import reframe.core.hooks as hooks
2424
from reframe.core.exceptions import ReframeSyntaxError, SkipTestError, what
2525
from reframe.core.fixtures import FixtureRegistry
26-
from reframe.core.logging import getlogger
26+
from reframe.core.logging import getlogger, time_function
2727
from reframe.core.pipeline import RegressionTest
2828
from reframe.utility.versioning import VersionValidator
2929

@@ -64,6 +64,7 @@ def skip(self, test):
6464
'''Add a test to the skip set.'''
6565
self._skip_tests.add(test)
6666

67+
@time_function
6768
def instantiate_all(self, reset_sysenv=0):
6869
'''Instantiate all the registered tests.
6970

reframe/core/logging.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import reframe.utility.jsonext as jsonext
2121
import reframe.utility.osext as osext
2222
from reframe.core.exceptions import ConfigError, LoggingError
23+
from reframe.utility.profile import TimeProfiler
2324

2425

2526
# Global configuration options for logging
@@ -726,3 +727,31 @@ def getlogger():
726727

727728
def getperflogger(check):
728729
return LoggerAdapter(_perf_logger, check)
730+
731+
732+
# Global framework profiler
733+
_profiler = TimeProfiler()
734+
735+
736+
def getprofiler():
737+
return _profiler
738+
739+
740+
def time_function(fn):
741+
'''Decorator for timing a function using the global profiler'''
742+
743+
def _fn(*args, **kwargs):
744+
with _profiler.time_region(fn.__qualname__):
745+
return fn(*args, **kwargs)
746+
747+
return _fn
748+
749+
750+
def time_function_noexit(fn):
751+
'''Decorator for timing a function using the global profiler'''
752+
753+
def _fn(*args, **kwargs):
754+
_profiler.enter_region(fn.__qualname__)
755+
return fn(*args, **kwargs)
756+
757+
return _fn

reframe/frontend/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def format_env(envvars):
4747
return ret
4848

4949

50+
@logging.time_function
5051
def list_checks(testcases, printer, detailed=False, concretized=False):
5152
printer.info('[List of matched checks]')
5253
unique_checks = set()
@@ -114,6 +115,7 @@ def dep_lines(u, *, prefix, depth=0, lines=None, printed=None):
114115
printer.info(f'Found {len(unique_checks)} check(s)\n')
115116

116117

118+
@logging.time_function
117119
def describe_checks(testcases, printer):
118120
records = []
119121
unique_names = set()
@@ -186,6 +188,7 @@ def calc_verbosity(site_config, quiesce):
186188
return curr_verbosity - quiesce
187189

188190

191+
@logging.time_function_noexit
189192
def main():
190193
# Setup command line options
191194
argparser = argparse.ArgumentParser()
@@ -950,6 +953,8 @@ def print_infoline(param, value):
950953
print_infoline('output directory', repr(session_info['prefix_output']))
951954
printer.info('')
952955
try:
956+
logging.getprofiler().enter_region('test processing')
957+
953958
# Need to parse the cli options before loading the tests
954959
parsed_job_options = []
955960
for opt in options.job_options:
@@ -1369,6 +1374,7 @@ def module_unuse(*paths):
13691374
sys.exit(1)
13701375
finally:
13711376
try:
1377+
logging.getprofiler().exit_region() # region: 'test processing'
13721378
log_files = logging.log_files()
13731379
if site_config.get('general/0/save_log_files'):
13741380
log_files = logging.save_log_files(rt.output_prefix)
@@ -1379,3 +1385,6 @@ def module_unuse(*paths):
13791385
finally:
13801386
if not restrict_logging():
13811387
printer.info(logfiles_message())
1388+
1389+
logging.getprofiler().exit_region() # region: 'main'
1390+
logging.getprofiler().print_report(printer.debug)

reframe/frontend/dependencies.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
import reframe.utility as util
1515
from reframe.core.exceptions import DependencyError
16-
from reframe.core.logging import getlogger
16+
from reframe.core.logging import getlogger, time_function
1717

1818

19+
@time_function
1920
def build_deps(cases, default_cases=None):
2021
'''Build dependency graph from test cases.
2122
@@ -139,6 +140,7 @@ def _reduce_deps(graph):
139140
return ret
140141

141142

143+
@time_function
142144
def validate_deps(graph):
143145
'''Validate dependency graph.'''
144146

@@ -181,6 +183,7 @@ def validate_deps(graph):
181183
sources -= visited
182184

183185

186+
@time_function
184187
def prune_deps(graph, testcases, max_depth=None):
185188
'''Prune the graph so that it contains only the specified cases and their
186189
dependencies up to max_depth.
@@ -205,6 +208,7 @@ def prune_deps(graph, testcases, max_depth=None):
205208
return pruned_graph
206209

207210

211+
@time_function
208212
def toposort(graph, is_subgraph=False):
209213
'''Return a list of the graph nodes topologically sorted.
210214

reframe/frontend/executors/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def clone(self):
106106
return TestCase(self._check_orig, self._partition, self._environ)
107107

108108

109+
@logging.time_function
109110
def generate_testcases(checks, prepare=False):
110111
'''Generate concrete test cases from checks.
111112
@@ -313,22 +314,27 @@ def __exit__(this, exc_type, exc_value, traceback):
313314
self.fail()
314315
raise TaskExit from e
315316

317+
@logging.time_function
316318
def setup(self, *args, **kwargs):
317319
self.testcase.prepare()
318320
self._safe_call(self.check.setup, *args, **kwargs)
319321
self._notify_listeners('on_task_setup')
320322

323+
@logging.time_function
321324
def compile(self):
322325
self._safe_call(self.check.compile)
323326
self._notify_listeners('on_task_compile')
324327

328+
@logging.time_function
325329
def compile_wait(self):
326330
self._safe_call(self.check.compile_wait)
327331

332+
@logging.time_function
328333
def run(self):
329334
self._safe_call(self.check.run)
330335
self._notify_listeners('on_task_run')
331336

337+
@logging.time_function
332338
def run_complete(self):
333339
done = self._safe_call(self.check.run_complete)
334340
if done:
@@ -337,23 +343,28 @@ def run_complete(self):
337343

338344
return done
339345

346+
@logging.time_function
340347
def compile_complete(self):
341348
done = self._safe_call(self.check.compile_complete)
342349
if done:
343350
self._notify_listeners('on_task_compile_exit')
344351

345352
return done
346353

354+
@logging.time_function
347355
def run_wait(self):
348356
self._safe_call(self.check.run_wait)
349357
self.zombie = False
350358

359+
@logging.time_function
351360
def sanity(self):
352361
self._safe_call(self.check.sanity)
353362

363+
@logging.time_function
354364
def performance(self):
355365
self._safe_call(self.check.performance)
356366

367+
@logging.time_function
357368
def finalize(self):
358369
try:
359370
jsonfile = os.path.join(self.check.stagedir, '.rfm_testcase.json')
@@ -367,6 +378,7 @@ def finalize(self):
367378
self._current_stage = 'finalize'
368379
self._notify_listeners('on_task_success')
369380

381+
@logging.time_function
370382
def cleanup(self, *args, **kwargs):
371383
self._safe_call(self.check.cleanup, *args, **kwargs)
372384

@@ -479,6 +491,7 @@ def policy(self):
479491
def stats(self):
480492
return self._stats
481493

494+
@logging.time_function
482495
def runall(self, testcases, restored_cases=None):
483496
num_checks = len({tc.check.unique_name for tc in testcases})
484497
self._printer.separator('short double line',

reframe/frontend/loader.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import reframe.utility as util
1919
import reframe.utility.osext as osext
2020
from reframe.core.exceptions import NameConflictError, is_severe, what
21-
from reframe.core.logging import getlogger
21+
from reframe.core.logging import getlogger, time_function
2222

2323

2424
class RegressionCheckValidator(ast.NodeVisitor):
@@ -267,6 +267,7 @@ def load_from_dir(self, dirname, recurse=False, force=False):
267267

268268
return checks
269269

270+
@time_function
270271
def load_all(self, force=False):
271272
'''Load all checks in self._load_path.
272273

reframe/frontend/testgenerators.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import reframe.utility as util
1010

1111
from reframe.core.decorators import TestRegistry
12-
from reframe.core.logging import getlogger
12+
from reframe.core.logging import getlogger, time_function
1313
from reframe.core.meta import make_test
1414
from reframe.core.schedulers import Job
1515
from reframe.frontend.executors import generate_testcases
1616

1717

18+
@time_function
1819
def getallnodes(state='all', jobs_cli_options=None):
1920
rt = runtime.runtime()
2021
nodes = {}
@@ -72,6 +73,7 @@ def _rfm_set_valid_systems(obj):
7273
return _rfm_set_valid_systems
7374

7475

76+
@time_function
7577
def distribute_tests(testcases, node_map):
7678
'''Returns new testcases that will be parameterized to run in node of
7779
their partitions based on the nodemap
@@ -126,6 +128,7 @@ def distribute_tests(testcases, node_map):
126128
return generate_testcases(new_checks)
127129

128130

131+
@time_function
129132
def repeat_tests(testcases, num_repeats):
130133
'''Returns new test cases parameterized over their repetition number'''
131134

reframe/utility/profile.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2016-2022 Swiss National Supercomputing Centre (CSCS/ETH Zurich)
2+
# ReFrame Project Developers. See the top-level LICENSE file for details.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
# A lightweight time profiler
7+
8+
import time
9+
import sys
10+
11+
from collections import OrderedDict
12+
13+
14+
class ProfilerError(Exception):
15+
pass
16+
17+
18+
class time_region:
19+
'''Context manager for timing a code region'''
20+
21+
def __init__(self, region, profiler=None):
22+
self._profiler = profiler or TimeProfiler()
23+
self._region = region
24+
25+
def __enter__(self):
26+
self._profiler.enter_region(self._region)
27+
return self._profiler
28+
29+
def __exit__(self, exc_type, exc_val, exc_tb):
30+
self._profiler.exit_region()
31+
32+
33+
class TimeProfiler:
34+
def __init__(self):
35+
self._region_stack = ['root']
36+
if sys.version_info[:2] < (3, 8):
37+
self._region_times = OrderedDict()
38+
else:
39+
self._region_times = {}
40+
41+
@property
42+
def current_region(self):
43+
return self._region_stack[-1]
44+
45+
def enter_region(self, region_name):
46+
timestamp = time.time()
47+
region_fullname = f'{self.current_region}:{region_name}'
48+
if region_fullname in self._region_times:
49+
elapsed = self._region_times[region_fullname][1]
50+
else:
51+
elapsed = 0.0
52+
53+
self._region_times[region_fullname] = (timestamp, elapsed)
54+
self._region_stack.append(region_fullname)
55+
56+
def exit_region(self):
57+
timestamp = time.time()
58+
region = self.current_region
59+
t_start, elapsed = self._region_times[region]
60+
self._region_times[region] = (None, elapsed + timestamp - t_start)
61+
self._region_stack.pop()
62+
63+
def total_time(self, region_name):
64+
for region in reversed(self._region_times.keys()):
65+
if (region == region_name or
66+
region.rsplit(':', maxsplit=1)[-1] == region_name):
67+
timestamp, elapsed = self._region_times[region]
68+
if timestamp:
69+
raise ProfilerError(
70+
f'region {region_name!r} has not exited'
71+
)
72+
73+
return elapsed
74+
75+
raise ProfilerError(f'unknown region: {region_name!r}')
76+
77+
def time_region(self, region):
78+
return globals()['time_region'](region, self)
79+
80+
def print_report(self, print_fn=None):
81+
if print_fn is None:
82+
print_fn = print
83+
84+
print_fn('>>> profiler report [start] <<<')
85+
for name, t_info in self._region_times.items():
86+
# Remove the root prefix
87+
levels = name.count(':')
88+
indent = ' '*4*(levels - 1)
89+
region_name = name.rsplit(':', maxsplit=1)[-1]
90+
msg = f'{indent}{region_name}: {t_info[1]:.6f} s'
91+
if t_info[0]:
92+
msg += ' <incomplete>'
93+
94+
print_fn(msg)
95+
96+
print_fn('>>> profiler report [ end ] <<<')

0 commit comments

Comments
 (0)