Coverage for python/lsst/utils/timer.py: 17%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11#
13"""Utilities for measuring execution time.
14"""
16from __future__ import annotations
18__all__ = ["logInfo", "timeMethod", "time_this"]
20import datetime
21import functools
22import inspect
23import logging
24import resource
25import time
26from contextlib import contextmanager
27from typing import (
28 TYPE_CHECKING,
29 Any,
30 Callable,
31 Collection,
32 Iterable,
33 Iterator,
34 MutableMapping,
35 Optional,
36 Tuple,
37)
39from astropy import units as u
41from .usage import get_current_mem_usage, get_peak_mem_usage
43if TYPE_CHECKING: 43 ↛ 44line 43 didn't jump to line 44, because the condition on line 43 was never true
44 from .logging import LsstLoggers
47_LOG = logging.getLogger(__name__)
50def _add_to_metadata(metadata: MutableMapping, name: str, value: Any) -> None:
51 """Add a value to dict-like object, creating list as needed.
53 The list grows as more values are added for that key.
55 Parameters
56 ----------
57 metadata : `dict`-like, optional
58 `dict`-like object that can store keys. Uses `add()` method if
59 one is available, else creates list and appends value if needed.
60 name : `str`
61 The key to use in the metadata dictionary.
62 value : Any
63 Value to store in the list.
64 """
65 try:
66 try:
67 # PropertySet should always prefer LongLong for integers
68 metadata.addLongLong(name, value) # type: ignore
69 except (TypeError, AttributeError):
70 metadata.add(name, value) # type: ignore
71 except AttributeError:
72 pass
73 else:
74 return
76 # Fallback code where `add` is not implemented.
77 if name not in metadata:
78 metadata[name] = []
79 metadata[name].append(value)
82def _find_outside_stacklevel() -> int:
83 """Find the stack level corresponding to caller code outside of this
84 module.
86 This can be passed directly to `logging.Logger.log()` to ensure
87 that log messages are issued as if they are coming from caller code.
89 Returns
90 -------
91 stacklevel : `int`
92 The stack level to use to refer to a caller outside of this module.
93 A ``stacklevel`` of ``1`` corresponds to the caller of this internal
94 function and that is the default expected by `logging.Logger.log()`.
96 Notes
97 -----
98 Intended to be called from the function that is going to issue a log
99 message. The result should be passed into `~logging.Logger.log` via the
100 keyword parameter ``stacklevel``.
101 """
102 stacklevel = 1 # the default for `Logger.log`
103 for i, s in enumerate(inspect.stack()):
104 module = inspect.getmodule(s.frame)
105 if module is None:
106 # Stack frames sometimes hang around so explicilty delete.
107 del s
108 continue
109 if not module.__name__.startswith("lsst.utils"):
110 # 0 will be this function.
111 # 1 will be the caller which will be the default for `Logger.log`
112 # and so does not need adjustment.
113 stacklevel = i
114 break
115 # Stack frames sometimes hang around so explicilty delete.
116 del s
118 return stacklevel
121def logPairs(
122 obj: Any,
123 pairs: Collection[Tuple[str, Any]],
124 logLevel: int = logging.DEBUG,
125 metadata: Optional[MutableMapping] = None,
126 logger: Optional[logging.Logger] = None,
127 stacklevel: Optional[int] = None,
128) -> None:
129 """Log ``(name, value)`` pairs to ``obj.metadata`` and ``obj.log``
131 Parameters
132 ----------
133 obj : `object`
134 An object with one or both of these two attributes:
136 - ``metadata`` a `dict`-like container for storing metadata.
137 Can use the ``add(name, value)`` method if found, else will append
138 entries to a list.
139 - ``log`` an instance of `logging.Logger` or subclass.
141 If `None`, at least one of ``metadata`` or ``logger`` should be passed
142 or this function will do nothing.
143 pairs : sequence
144 A sequence of ``(name, value)`` pairs, with value typically numeric.
145 logLevel : `int, optional
146 Log level (an `logging` level constant, such as `logging.DEBUG`).
147 metadata : `collections.abc.MutableMapping`, optional
148 Metadata object to write entries to. Ignored if `None`.
149 logger : `logging.Logger`
150 Log object to write entries to. Ignored if `None`.
151 stacklevel : `int`, optional
152 The stack level to pass to the logger to adjust which stack frame
153 is used to report the file information. If `None` the stack level
154 is computed such that it is reported as the first package outside
155 of the utils package. If a value is given here it is adjusted by
156 1 to account for this caller.
157 """
158 if obj is not None:
159 if metadata is None:
160 try:
161 metadata = obj.metadata
162 except AttributeError:
163 pass
164 if logger is None:
165 try:
166 logger = obj.log
167 except AttributeError:
168 pass
169 strList = []
170 for name, value in pairs:
171 if metadata is not None:
172 _add_to_metadata(metadata, name, value)
173 strList.append(f"{name}={value}")
174 if logger is not None:
175 # Want the file associated with this log message to be that
176 # of the caller. This is expensive so only do it if we know the
177 # message will be issued.
178 timer_logger = logging.getLogger("timer." + logger.name)
179 if timer_logger.isEnabledFor(logLevel):
180 if stacklevel is None:
181 stacklevel = _find_outside_stacklevel()
182 else:
183 # Account for the caller stack.
184 stacklevel += 1
185 timer_logger.log(logLevel, "; ".join(strList), stacklevel=stacklevel)
188def logInfo(
189 obj: Any,
190 prefix: str,
191 logLevel: int = logging.DEBUG,
192 metadata: Optional[MutableMapping] = None,
193 logger: Optional[logging.Logger] = None,
194 stacklevel: Optional[int] = None,
195) -> None:
196 """Log timer information to ``obj.metadata`` and ``obj.log``.
198 Parameters
199 ----------
200 obj : `object`
201 An object with both or one these two attributes:
203 - ``metadata`` a `dict`-like container for storing metadata.
204 Can use the ``add(name, value)`` method if found, else will append
205 entries to a list.
206 - ``log`` an instance of `logging.Logger` or subclass.
208 If `None`, at least one of ``metadata`` or ``logger`` should be passed
209 or this function will do nothing.
210 prefix : `str`
211 Name prefix, the resulting entries are ``CpuTime``, etc.. For example
212 `timeMethod` uses ``prefix = Start`` when the method begins and
213 ``prefix = End`` when the method ends.
214 logLevel : optional
215 Log level (a `logging` level constant, such as `logging.DEBUG`).
216 metadata : `collections.abc.MutableMapping`, optional
217 Metadata object to write entries to, overriding ``obj.metadata``.
218 logger : `logging.Logger`
219 Log object to write entries to, overriding ``obj.log``.
220 stacklevel : `int`, optional
221 The stack level to pass to the logger to adjust which stack frame
222 is used to report the file information. If `None` the stack level
223 is computed such that it is reported as the first package outside
224 of the utils package. If a value is given here it is adjusted by
225 1 to account for this caller.
227 Notes
228 -----
229 Logged items include:
231 - ``Utc``: UTC date in ISO format (only in metadata since log entries have
232 timestamps).
233 - ``CpuTime``: System + User CPU time (seconds). This should only be used
234 in differential measurements; the time reference point is undefined.
235 - ``MaxRss``: maximum resident set size.
237 All logged resource information is only for the current process; child
238 processes are excluded.
239 """
240 cpuTime = time.process_time()
241 res = resource.getrusage(resource.RUSAGE_SELF)
242 if metadata is None and obj is not None:
243 try:
244 metadata = obj.metadata
245 except AttributeError:
246 pass
247 if metadata is not None:
248 # Log messages already have timestamps.
249 utcStr = datetime.datetime.utcnow().isoformat()
250 _add_to_metadata(metadata, name=prefix + "Utc", value=utcStr)
251 if stacklevel is not None:
252 # Account for the caller of this routine not knowing that we
253 # are going one down in the stack.
254 stacklevel += 1
255 logPairs(
256 obj=obj,
257 pairs=[
258 (prefix + "CpuTime", cpuTime),
259 (prefix + "UserTime", res.ru_utime),
260 (prefix + "SystemTime", res.ru_stime),
261 (prefix + "MaxResidentSetSize", int(res.ru_maxrss)),
262 (prefix + "MinorPageFaults", int(res.ru_minflt)),
263 (prefix + "MajorPageFaults", int(res.ru_majflt)),
264 (prefix + "BlockInputs", int(res.ru_inblock)),
265 (prefix + "BlockOutputs", int(res.ru_oublock)),
266 (prefix + "VoluntaryContextSwitches", int(res.ru_nvcsw)),
267 (prefix + "InvoluntaryContextSwitches", int(res.ru_nivcsw)),
268 ],
269 logLevel=logLevel,
270 metadata=metadata,
271 logger=logger,
272 stacklevel=stacklevel,
273 )
276def timeMethod(
277 _func: Optional[Any] = None,
278 *,
279 metadata: Optional[MutableMapping] = None,
280 logger: Optional[logging.Logger] = None,
281 logLevel: int = logging.DEBUG,
282) -> Callable:
283 """Measure duration of a method.
285 Parameters
286 ----------
287 func
288 The method to wrap.
289 metadata : `collections.abc.MutableMapping`, optional
290 Metadata to use as override if the instance object attached
291 to this timer does not support a ``metadata`` property.
292 logger : `logging.Logger`, optional
293 Logger to use when the class containing the decorated method does not
294 have a ``log`` property.
295 logLevel : `int`, optional
296 Log level to use when logging messages. Default is `~logging.DEBUG`.
298 Notes
299 -----
300 Writes various measures of time and possibly memory usage to the
301 metadata; all items are prefixed with the function name.
303 .. warning::
305 This decorator only works with instance methods of any class
306 with these attributes:
308 - ``metadata``: an instance of `collections.abc.Mapping`. The ``add``
309 method will be used if available, else entries will be added to a
310 list.
311 - ``log``: an instance of `logging.Logger` or subclass.
313 Examples
314 --------
315 To use:
317 .. code-block:: python
319 import lsst.utils as utils
320 import lsst.pipe.base as pipeBase
321 class FooTask(pipeBase.Task):
322 pass
324 @utils.timeMethod
325 def run(self, ...): # or any other instance method you want to time
326 pass
327 """
329 def decorator_timer(func: Callable) -> Callable:
330 @functools.wraps(func)
331 def timeMethod_wrapper(self: Any, *args: Any, **keyArgs: Any) -> Any:
332 # Adjust the stacklevel to account for the wrappers.
333 # stacklevel 1 would make the log message come from this function
334 # but we want it to come from the file that defined the method
335 # so need to increment by 1 to get to the caller.
336 stacklevel = 2
337 logInfo(
338 obj=self,
339 prefix=func.__name__ + "Start",
340 metadata=metadata,
341 logger=logger,
342 logLevel=logLevel,
343 stacklevel=stacklevel,
344 )
345 try:
346 res = func(self, *args, **keyArgs)
347 finally:
348 logInfo(
349 obj=self,
350 prefix=func.__name__ + "End",
351 metadata=metadata,
352 logger=logger,
353 logLevel=logLevel,
354 stacklevel=stacklevel,
355 )
356 return res
358 return timeMethod_wrapper
360 if _func is None:
361 return decorator_timer
362 else:
363 return decorator_timer(_func)
366@contextmanager
367def time_this(
368 log: Optional[LsstLoggers] = None,
369 msg: Optional[str] = None,
370 level: int = logging.DEBUG,
371 prefix: Optional[str] = "timer",
372 args: Iterable[Any] = (),
373 mem_usage: bool = False,
374 mem_child: bool = False,
375 mem_unit: u.Quantity = u.byte,
376 mem_fmt: str = ".0f",
377) -> Iterator[None]:
378 """Time the enclosed block and issue a log message.
380 Parameters
381 ----------
382 log : `logging.Logger`, optional
383 Logger to use to report the timer message. The root logger will
384 be used if none is given.
385 msg : `str`, optional
386 Context to include in log message.
387 level : `int`, optional
388 Python logging level to use to issue the log message. If the
389 code block raises an exception the log message will automatically
390 switch to level ERROR.
391 prefix : `str`, optional
392 Prefix to use to prepend to the supplied logger to
393 create a new logger to use instead. No prefix is used if the value
394 is set to `None`. Defaults to "timer".
395 args : iterable of any
396 Additional parameters passed to the log command that should be
397 written to ``msg``.
398 mem_usage : `bool`, optional
399 Flag indicating whether to include the memory usage in the report.
400 Defaults, to False.
401 mem_child : `bool`, optional
402 Flag indication whether to include memory usage of the child processes.
403 mem_unit : `astropy.units.Unit`, optional
404 Unit to use when reporting the memory usage. Defaults to bytes.
405 mem_fmt : `str`, optional
406 Format specifier to use when displaying values related to memory usage.
407 Defaults to '.0f'.
408 """
409 if log is None:
410 log = logging.getLogger()
411 if prefix:
412 log_name = f"{prefix}.{log.name}" if not isinstance(log, logging.RootLogger) else prefix
413 log = logging.getLogger(log_name)
415 success = False
416 start = time.time()
417 if mem_usage:
418 current_usages_start = get_current_mem_usage()
419 peak_usages_start = get_peak_mem_usage()
421 try:
422 yield
423 success = True
424 finally:
425 end = time.time()
427 # The message is pre-inserted to allow the logger to expand
428 # the additional args provided. Make that easier by converting
429 # the None message to empty string.
430 if msg is None:
431 msg = ""
433 # Convert user provided parameters (if any) to mutable sequence to make
434 # mypy stop complaining when additional parameters will be added below.
435 params = list(args) if args else []
437 if not success:
438 # Something went wrong so change the log level to indicate
439 # this.
440 level = logging.ERROR
442 # Specify stacklevel to ensure the message is reported from the
443 # caller (1 is this file, 2 is contextlib, 3 is user)
444 params += (": " if msg else "", end - start)
445 msg += "%sTook %.4f seconds"
446 if mem_usage and log.isEnabledFor(level):
447 current_usages_end = get_current_mem_usage()
448 peak_usages_end = get_peak_mem_usage()
450 current_deltas = [end - start for end, start in zip(current_usages_end, current_usages_start)]
451 peak_deltas = [end - start for end, start in zip(peak_usages_end, peak_usages_start)]
453 current_usage = current_usages_end[0]
454 current_delta = current_deltas[0]
455 peak_delta = peak_deltas[0]
456 if mem_child:
457 current_usage += current_usages_end[1]
458 current_delta += current_deltas[1]
459 peak_delta += peak_deltas[1]
461 if not mem_unit.is_equivalent(u.byte):
462 _LOG.warning("Invalid memory unit '%s', using '%s' instead", mem_unit, u.byte)
463 mem_unit = u.byte
465 msg += (
466 f"; current memory usage: {current_usage.to(mem_unit):{mem_fmt}}"
467 f", delta: {current_delta.to(mem_unit):{mem_fmt}}"
468 f", peak delta: {peak_delta.to(mem_unit):{mem_fmt}}"
469 )
470 log.log(level, msg, *params, stacklevel=3)