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 time
25from contextlib import contextmanager
26from typing import (
27 TYPE_CHECKING,
28 Any,
29 Callable,
30 Collection,
31 Iterable,
32 Iterator,
33 MutableMapping,
34 Optional,
35 Tuple,
36)
38from astropy import units as u
40from .usage import _get_current_rusage, get_current_mem_usage, get_peak_mem_usage
42if TYPE_CHECKING: 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true
43 from .logging import LsstLoggers
46_LOG = logging.getLogger(__name__)
49def _add_to_metadata(metadata: MutableMapping, name: str, value: Any) -> None:
50 """Add a value to dict-like object, creating list as needed.
52 The list grows as more values are added for that key.
54 Parameters
55 ----------
56 metadata : `dict`-like, optional
57 `dict`-like object that can store keys. Uses `add()` method if
58 one is available, else creates list and appends value if needed.
59 name : `str`
60 The key to use in the metadata dictionary.
61 value : Any
62 Value to store in the list.
63 """
64 try:
65 try:
66 # PropertySet should always prefer LongLong for integers
67 metadata.addLongLong(name, value) # type: ignore
68 except (TypeError, AttributeError):
69 metadata.add(name, value) # type: ignore
70 except AttributeError:
71 pass
72 else:
73 return
75 # Fallback code where `add` is not implemented.
76 if name not in metadata:
77 metadata[name] = []
78 metadata[name].append(value)
81def _find_outside_stacklevel() -> int:
82 """Find the stack level corresponding to caller code outside of this
83 module.
85 This can be passed directly to `logging.Logger.log()` to ensure
86 that log messages are issued as if they are coming from caller code.
88 Returns
89 -------
90 stacklevel : `int`
91 The stack level to use to refer to a caller outside of this module.
92 A ``stacklevel`` of ``1`` corresponds to the caller of this internal
93 function and that is the default expected by `logging.Logger.log()`.
95 Notes
96 -----
97 Intended to be called from the function that is going to issue a log
98 message. The result should be passed into `~logging.Logger.log` via the
99 keyword parameter ``stacklevel``.
100 """
101 stacklevel = 1 # the default for `Logger.log`
102 for i, s in enumerate(inspect.stack()):
103 module = inspect.getmodule(s.frame)
104 if module is None:
105 # Stack frames sometimes hang around so explicilty delete.
106 del s
107 continue
108 if not module.__name__.startswith("lsst.utils"):
109 # 0 will be this function.
110 # 1 will be the caller which will be the default for `Logger.log`
111 # and so does not need adjustment.
112 stacklevel = i
113 break
114 # Stack frames sometimes hang around so explicilty delete.
115 del s
117 return stacklevel
120def logPairs(
121 obj: Any,
122 pairs: Collection[Tuple[str, Any]],
123 logLevel: int = logging.DEBUG,
124 metadata: Optional[MutableMapping] = None,
125 logger: Optional[logging.Logger] = None,
126 stacklevel: Optional[int] = None,
127) -> None:
128 """Log ``(name, value)`` pairs to ``obj.metadata`` and ``obj.log``
130 Parameters
131 ----------
132 obj : `object`
133 An object with one or both of these two attributes:
135 - ``metadata`` a `dict`-like container for storing metadata.
136 Can use the ``add(name, value)`` method if found, else will append
137 entries to a list.
138 - ``log`` an instance of `logging.Logger` or subclass.
140 If `None`, at least one of ``metadata`` or ``logger`` should be passed
141 or this function will do nothing.
142 pairs : sequence
143 A sequence of ``(name, value)`` pairs, with value typically numeric.
144 logLevel : `int, optional
145 Log level (an `logging` level constant, such as `logging.DEBUG`).
146 metadata : `collections.abc.MutableMapping`, optional
147 Metadata object to write entries to. Ignored if `None`.
148 logger : `logging.Logger`
149 Log object to write entries to. Ignored if `None`.
150 stacklevel : `int`, optional
151 The stack level to pass to the logger to adjust which stack frame
152 is used to report the file information. If `None` the stack level
153 is computed such that it is reported as the first package outside
154 of the utils package. If a value is given here it is adjusted by
155 1 to account for this caller.
156 """
157 if obj is not None:
158 if metadata is None:
159 try:
160 metadata = obj.metadata
161 except AttributeError:
162 pass
163 if logger is None:
164 try:
165 logger = obj.log
166 except AttributeError:
167 pass
168 strList = []
169 for name, value in pairs:
170 if metadata is not None:
171 _add_to_metadata(metadata, name, value)
172 strList.append(f"{name}={value}")
173 if logger is not None:
174 # Want the file associated with this log message to be that
175 # of the caller. This is expensive so only do it if we know the
176 # message will be issued.
177 timer_logger = logging.getLogger("timer." + logger.name)
178 if timer_logger.isEnabledFor(logLevel):
179 if stacklevel is None:
180 stacklevel = _find_outside_stacklevel()
181 else:
182 # Account for the caller stack.
183 stacklevel += 1
184 timer_logger.log(logLevel, "; ".join(strList), stacklevel=stacklevel)
187def logInfo(
188 obj: Any,
189 prefix: str,
190 logLevel: int = logging.DEBUG,
191 metadata: Optional[MutableMapping] = None,
192 logger: Optional[logging.Logger] = None,
193 stacklevel: Optional[int] = None,
194) -> None:
195 """Log timer information to ``obj.metadata`` and ``obj.log``.
197 Parameters
198 ----------
199 obj : `object`
200 An object with both or one these two attributes:
202 - ``metadata`` a `dict`-like container for storing metadata.
203 Can use the ``add(name, value)`` method if found, else will append
204 entries to a list.
205 - ``log`` an instance of `logging.Logger` or subclass.
207 If `None`, at least one of ``metadata`` or ``logger`` should be passed
208 or this function will do nothing.
209 prefix : `str`
210 Name prefix, the resulting entries are ``CpuTime``, etc.. For example
211 `timeMethod` uses ``prefix = Start`` when the method begins and
212 ``prefix = End`` when the method ends.
213 logLevel : optional
214 Log level (a `logging` level constant, such as `logging.DEBUG`).
215 metadata : `collections.abc.MutableMapping`, optional
216 Metadata object to write entries to, overriding ``obj.metadata``.
217 logger : `logging.Logger`
218 Log object to write entries to, overriding ``obj.log``.
219 stacklevel : `int`, optional
220 The stack level to pass to the logger to adjust which stack frame
221 is used to report the file information. If `None` the stack level
222 is computed such that it is reported as the first package outside
223 of the utils package. If a value is given here it is adjusted by
224 1 to account for this caller.
226 Notes
227 -----
228 Logged items include:
230 - ``Utc``: UTC date in ISO format (only in metadata since log entries have
231 timestamps).
232 - ``CpuTime``: System + User CPU time (seconds). This should only be used
233 in differential measurements; the time reference point is undefined.
234 - ``MaxRss``: maximum resident set size. Always in bytes.
236 All logged resource information is only for the current process; child
237 processes are excluded.
239 The metadata will be updated with a ``__version__`` field to indicate the
240 version of the items stored. If there is no version number it is assumed
241 to be version 0.
243 * Version 0: ``MaxResidentSetSize`` units are platform-dependent.
244 * Version 1: ``MaxResidentSetSize`` will be stored in bytes.
245 """
246 if metadata is None and obj is not None:
247 try:
248 metadata = obj.metadata
249 except AttributeError:
250 pass
251 if metadata is not None:
252 # Log messages already have timestamps.
253 utcStr = datetime.datetime.utcnow().isoformat()
254 _add_to_metadata(metadata, name=prefix + "Utc", value=utcStr)
256 # Force a version number into the metadata.
257 # v1: Ensure that max_rss field is always bytes.
258 metadata["__version__"] = 1
259 if stacklevel is not None:
260 # Account for the caller of this routine not knowing that we
261 # are going one down in the stack.
262 stacklevel += 1
264 usage = _get_current_rusage()
265 logPairs(
266 obj=obj,
267 pairs=[(prefix + k[0].upper() + k[1:], v) for k, v in usage.dict().items()],
268 logLevel=logLevel,
269 metadata=metadata,
270 logger=logger,
271 stacklevel=stacklevel,
272 )
275def timeMethod(
276 _func: Optional[Any] = None,
277 *,
278 metadata: Optional[MutableMapping] = None,
279 logger: Optional[logging.Logger] = None,
280 logLevel: int = logging.DEBUG,
281) -> Callable:
282 """Measure duration of a method.
284 Parameters
285 ----------
286 func
287 The method to wrap.
288 metadata : `collections.abc.MutableMapping`, optional
289 Metadata to use as override if the instance object attached
290 to this timer does not support a ``metadata`` property.
291 logger : `logging.Logger`, optional
292 Logger to use when the class containing the decorated method does not
293 have a ``log`` property.
294 logLevel : `int`, optional
295 Log level to use when logging messages. Default is `~logging.DEBUG`.
297 Notes
298 -----
299 Writes various measures of time and possibly memory usage to the
300 metadata; all items are prefixed with the function name.
302 .. warning::
304 This decorator only works with instance methods of any class
305 with these attributes:
307 - ``metadata``: an instance of `collections.abc.Mapping`. The ``add``
308 method will be used if available, else entries will be added to a
309 list.
310 - ``log``: an instance of `logging.Logger` or subclass.
312 Examples
313 --------
314 To use:
316 .. code-block:: python
318 import lsst.utils as utils
319 import lsst.pipe.base as pipeBase
320 class FooTask(pipeBase.Task):
321 pass
323 @utils.timeMethod
324 def run(self, ...): # or any other instance method you want to time
325 pass
326 """
328 def decorator_timer(func: Callable) -> Callable:
329 @functools.wraps(func)
330 def timeMethod_wrapper(self: Any, *args: Any, **keyArgs: Any) -> Any:
331 # Adjust the stacklevel to account for the wrappers.
332 # stacklevel 1 would make the log message come from this function
333 # but we want it to come from the file that defined the method
334 # so need to increment by 1 to get to the caller.
335 stacklevel = 2
336 logInfo(
337 obj=self,
338 prefix=func.__name__ + "Start",
339 metadata=metadata,
340 logger=logger,
341 logLevel=logLevel,
342 stacklevel=stacklevel,
343 )
344 try:
345 res = func(self, *args, **keyArgs)
346 finally:
347 logInfo(
348 obj=self,
349 prefix=func.__name__ + "End",
350 metadata=metadata,
351 logger=logger,
352 logLevel=logLevel,
353 stacklevel=stacklevel,
354 )
355 return res
357 return timeMethod_wrapper
359 if _func is None:
360 return decorator_timer
361 else:
362 return decorator_timer(_func)
365@contextmanager
366def time_this(
367 log: Optional[LsstLoggers] = None,
368 msg: Optional[str] = None,
369 level: int = logging.DEBUG,
370 prefix: Optional[str] = "timer",
371 args: Iterable[Any] = (),
372 mem_usage: bool = False,
373 mem_child: bool = False,
374 mem_unit: u.Quantity = u.byte,
375 mem_fmt: str = ".0f",
376) -> Iterator[None]:
377 """Time the enclosed block and issue a log message.
379 Parameters
380 ----------
381 log : `logging.Logger`, optional
382 Logger to use to report the timer message. The root logger will
383 be used if none is given.
384 msg : `str`, optional
385 Context to include in log message.
386 level : `int`, optional
387 Python logging level to use to issue the log message. If the
388 code block raises an exception the log message will automatically
389 switch to level ERROR.
390 prefix : `str`, optional
391 Prefix to use to prepend to the supplied logger to
392 create a new logger to use instead. No prefix is used if the value
393 is set to `None`. Defaults to "timer".
394 args : iterable of any
395 Additional parameters passed to the log command that should be
396 written to ``msg``.
397 mem_usage : `bool`, optional
398 Flag indicating whether to include the memory usage in the report.
399 Defaults, to False.
400 mem_child : `bool`, optional
401 Flag indication whether to include memory usage of the child processes.
402 mem_unit : `astropy.units.Unit`, optional
403 Unit to use when reporting the memory usage. Defaults to bytes.
404 mem_fmt : `str`, optional
405 Format specifier to use when displaying values related to memory usage.
406 Defaults to '.0f'.
407 """
408 if log is None:
409 log = logging.getLogger()
410 if prefix:
411 log_name = f"{prefix}.{log.name}" if not isinstance(log, logging.RootLogger) else prefix
412 log = logging.getLogger(log_name)
414 success = False
415 start = time.time()
416 if mem_usage:
417 current_usages_start = get_current_mem_usage()
418 peak_usages_start = get_peak_mem_usage()
420 try:
421 yield
422 success = True
423 finally:
424 end = time.time()
426 # The message is pre-inserted to allow the logger to expand
427 # the additional args provided. Make that easier by converting
428 # the None message to empty string.
429 if msg is None:
430 msg = ""
432 # Convert user provided parameters (if any) to mutable sequence to make
433 # mypy stop complaining when additional parameters will be added below.
434 params = list(args) if args else []
436 if not success:
437 # Something went wrong so change the log level to indicate
438 # this.
439 level = logging.ERROR
441 # Specify stacklevel to ensure the message is reported from the
442 # caller (1 is this file, 2 is contextlib, 3 is user)
443 params += (": " if msg else "", end - start)
444 msg += "%sTook %.4f seconds"
445 if mem_usage and log.isEnabledFor(level):
446 current_usages_end = get_current_mem_usage()
447 peak_usages_end = get_peak_mem_usage()
449 current_deltas = [end - start for end, start in zip(current_usages_end, current_usages_start)]
450 peak_deltas = [end - start for end, start in zip(peak_usages_end, peak_usages_start)]
452 current_usage = current_usages_end[0]
453 current_delta = current_deltas[0]
454 peak_delta = peak_deltas[0]
455 if mem_child:
456 current_usage += current_usages_end[1]
457 current_delta += current_deltas[1]
458 peak_delta += peak_deltas[1]
460 if not mem_unit.is_equivalent(u.byte):
461 _LOG.warning("Invalid memory unit '%s', using '%s' instead", mem_unit, u.byte)
462 mem_unit = u.byte
464 msg += (
465 f"; current memory usage: {current_usage.to(mem_unit):{mem_fmt}}"
466 f", delta: {current_delta.to(mem_unit):{mem_fmt}}"
467 f", peak delta: {peak_delta.to(mem_unit):{mem_fmt}}"
468 )
469 log.log(level, msg, *params, stacklevel=3)