Source code for timing

#
##
##  SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
##  SPDX-License-Identifier: GPL-3.0-or-later
##
##  This file is part of pyFormex 3.5  (Thu Feb  8 19:11:13 CET 2024)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: https://pyformex.org
##  Project page: https://savannah.nongnu.org/projects/pyformex/
##  Development: https://gitlab.com/bverheg/pyformex
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##

"""Timing: Measuring elapsed wall or cpu time.

This module defines tools to measure the wall or cpu time between two events.
When developing algorithms for operations on large data models,
accurate and multiple measurements of the time spent in different parts
of the code are a vital accessory. The Timing and Timings classes make the
process of measuring and reporting as simple as possible for a variety
of uses. The following examples illustrate the most common use cases.

Examples
--------
We use :func:`time.sleep` to provide a dummy code block with some known
execution time.

>>> from time import sleep

The example below shows the three basic steps: create a Timing instance,
make a measurement, report the result. The Timing class acts as a context
manager, so it can be used in a with statement to measure the time spent
in block of code inside the with clause.

>>> t = Timing()     # 1. create a Timing
>>> with t:          # 2. measure the time for a code block
...     sleep(0.07)
>>> print(t)         # 3. report the measured time
Timing: 0.07... sec. Total: 0.07... sec.

Two values are printed out: the first is the measured time,
the second is the accumulated time of all measurements with this Timing
instance. Let's add a second measurement:

>>> sleep(0.05)
>>> with t:
...     sleep(0.06)
>>> print(t)
Timing: 0.06... sec. Total: 0.13... sec.

Notice that only the time inside the with block is measured.
Printing out the Timing after measurement is so common, that there is
an option to do it automatically. Just set the auto mode on
(this can also be given as an option when creating the Timing).

>>> t.auto = True
>>> with t:
...     sleep(0.03)
Timing: 0.03... sec. Total: 0.16... sec.

If you do not need the accumulation feature, you can switch it off by
setting acc=None. We create a new Timing t1, with appropriate tag. The
tag is the string printed before the timing values (the default tag is
'Timing'):

>>> with Timing('No accumulation', auto=True, acc=None) as t1:
...     sleep(0.07)
No accumulation: 0.07... sec.
>>> with t1: sleep(0.13)
No accumulation: 0.13... sec.

You can specify the precision of the output (the actual readings
are still done at the highest precision):

>>> t2 = Timing('Precision 2', dec=2, auto=True)
>>> with t2: sleep(0.057)
Precision 2: 0.06 sec. Total: 0.06 sec.
>>> with t2: sleep(0.093)
Precision 2: 0.09 sec. Total: 0.15 sec.

When using multiple Timing instances it is often convenient to be able to use a
single variable to keep all instances alive, or to print all the timings at once.
The :class:`Timings` class provides a dict of Timing instances to do just that.

>>> timings = Timings(t, t1, t2)
>>> print(timings)
Timings report
  Timing: 0.03... sec. Total: 0.16... sec.
  No accumulation: 0.13... sec.
  Precision 2: 0.09 sec. Total: 0.15 sec.
>>> print(len(timings))
3

Plain dict methods can be used on the Timings class, but you shouldn't add
anything but Timing instances. Using the :meth:`add` method is recomended,
as its tests for the object being a Timing.

>>> print(timings['No accumulation'])
No accumulation: 0.13... sec.
>>> t3 = Timing('Precision 2')  # this overrides existing with same tag
>>> print(len(timings))
3
>>> timings.add(t3)
>>> print(len(timings))
3
>>> timings.get('Precision 2') is t3
True
>>> timings.get('Precision 2') is t2
False
>>> timings.clear()
>>> print(len(timings))
0

Timing context managers can be nested, so one can easily do partial
and overall timings at the same time:

>>> with Timing("Outer block", dec=2, reg=timings):
...    sleep(0.08)
...    with Timing("Inner block", dec=2, reg=timings):
...        sleep(0.12)
...    sleep(0.07)
...    with Timing("Inner block 2", dec=2, reg=timings):
...        sleep(0.17)
...    sleep(0.06)
...
>>> print(timings)
Timings report
  Outer block: 0.50 sec. Total: 0.50 sec.
  Inner block: 0.12 sec. Total: 0.12 sec.
  Inner block 2: 0.17 sec. Total: 0.17 sec.

The output of the above on some machine gave::

    Process time: 0.02... sec. Total: 0.02... sec.
    Real time: 0.08... sec. Total: 0.08... sec.

There may be times when the use of a context manager is not useful
or even possible (like when the start and end of measurement are in
different functions). Then you can always use the low level interface.
The measurement is always done with the :meth:`read` method. It measures
and returns the time since the last :meth:`start` or the creation of the
Timing. Here is an example:

>>> t = Timing(dec=2)
>>> sleep(0.03)
>>> v = t.read()
>>> sleep(0.05)
>>> t.start()
>>> sleep(0.07)
>>> v1 = t.read()
>>> print(v, v1)
0.03... 0.07...

The last reading and the accumulated value remain accessible for
further processing or customized reports:

>>> print(t2.mem, t2.acc)
0.09... 0.15...
>>> print(f"'{t2.tag}' timing: last read {t2.mem}"
...       f", accumulated {t2.acc}")
'Precision 2' timing: last read 0.09, accumulated 0.15

You can have a peek at the current value of the Timing, without
actually reading it, and thus not changing the Timing's memory:

>>> print(t)
Timing: 0.07 sec. Total: 0.10 sec.
>>> sleep(0.09)
>>> print(t.value)
0.09...
>>> print(t)
Timing: 0.07 sec. Total: 0.10 sec.

Measuring process time is easy as well. Just provide another time measuring
function:

>>> timings = Timings(
...     Timing('Real time', auto=True, dec=2, acc=None),
...     Timing('Process time', auto=True, acc=None, timefunc=time.process_time)
... )
>>> with timings:
...     _ = [i*i for i in range(10_000)]  # do something
...     sleep(0.06)
Process time: 0.00... sec.
Real time: 0.06 sec.
"""
import time


[docs]class Timing: """A class for measuring elapsed time (wall clock or cpu clock). The Timing class is a conventient way to measure and report the elapsed time during some processing. It uses Python's :func:`time.perf_counter`, providing the highest resolution available. It is however not intended for doing micro measurements: use Python's :mod:`timeit` module for that. A Timing instance can be used as a context manager, with automatic reading and even reporting the time spent in the context block. The Timing value is a floating point value giving a number of seconds elapsed since some undefined reference point. By default this is the moment of creation of the Timing, but the starting point can be set at any time. Parameters ---------- tag: str, optional A label identifying the Timing. It is shown together with the time value when printing the Timing. It is also used as the key when registering a Timing. timefunc: callable, optional A callable returning a float. The difference in value between two calls is the measured time in seconds. The default :func:`time.perf_counter` measures real time with high precision. Some other useful values: :func:`time.time`, :func:`time.monotonic`, :func:`time.process_time`, :func:`time.thread_time`. dec: int, optional The number of decimals that will be shown when printing the Timing. The default (6) provides microsecond accuracy, which is more than enough for all purposes. acc: float | None, optional Starting value for the accumulator. When a Timing is read, it accumulates the value in its :attr:`acc` attribute. The accumulted value is printed out together with the reading. A special value None may be provided to switch off the accumulator. auto: bool, optional If True, switches on auto print mode for the Timing. In auto print mode, the value of the Timing is printed out on each :meth:`read` and, when using a context manager, on exiting it. This is very convenient to quickly do timing of some code block. See the examples above. If False, the user is responsible for printing out the Timing. reg: :class:`Timings`, optional If a :class:`Timings` instance is provided, the new :class:`Timing` is added to that collection and its value can be printed out together with that of the others in the collection. """ def __init__(self, tag='Timing', *, timefunc=time.perf_counter, dec=6, acc=0., auto=False, reg=None): """Create and reset the timing.""" self._tag = str(tag) self.dec = int(dec) self.auto = bool(auto) self._acc = acc if acc is None else float(acc) self._mem = 0. if isinstance(timefunc(), float): self._func = timefunc else: raise ValueError("timefunc should return a float") self.start() if isinstance(reg, Timings): reg[self._tag] = self @property def value(self): """The current value of the Timing. Peeking at the Timing's value does not :meth:`read` the Timing. """ return self._func() - self._started @property def mem(self): """The last read value rounded to the timing's precision. Note ---- Use :attr:`_mem` to get the full precision. """ return round(self._mem, self.dec) @property def acc(self): """The accumulator value rounded to the timing's precision. Note ---- Use :attr:`_acc` to get the full precision. """ return round(self._acc, self.dec) @property def tag(self): """The timing's tag string""" return self._tag
[docs] def newtag(self, newtag): """Change the tag string of the Timing. Returns ------- self The Timing itself, thus this method can be used as a context manager. """ self._tag = str(newtag) return self
[docs] def start(self): """Start the timing. This marks the start for the next :meth:`read` operation. """ self._started = self._func()
[docs] def read(self): """Read the timing. Read the time since the last :meth:`read` or :meth:`start`, or since the creation of the Timing. Store the time in self.mem, and accumulate it in self.acc (if not None). Print the Timing if auto mode is on. Returns ------- float The time in seconds since the last :meth:`read` or :meth:`start`, or since the creation of the Timing. """ now = self._func() self._mem = now - self._started if self._acc is not None: self._acc += self._mem self._started = now if self.auto: print(self) return self._mem
def __enter__(self): """Enter the context manager""" self.start() return self def __exit__(self, *exc): """Exit the context manager""" self.read()
[docs] def format(self): """Return a string representation of the Timing. Returns ------- string A string with the Timing's tag, the last read value, and the accumulated value if the Timing is an accumulating one. This is also the string that will be shown when printing a Timing. """ s = f"{self._tag}: {self._mem:.{self.dec}f} sec." if self._acc is not None: s += f" Total: {self._acc:.{self.dec}f} sec." return s
__str__ = format
[docs]class Timings(dict): """A collection of timings Timings is a :class:`dict` with :class:`Timing` instances using their tag attribute as the dict key. All dict methods are can be used, but care should be taken to add nothing but :class:`Timing` instances to the dict. The benefits over using a plain :class:`dict` are that printing a Timings will give a nicely formatted output of the timings and that the Timings can be used as a content manager to activate all its Timing instances at once. The latter is e.g. convenient to measure the same code block with different time functions. Parameters ---------- *timings: optional A sequence of :class:`Timing` instances. """ def __init__(self, *timings): super().__init__() for t in timings: self.add(t)
[docs] def add(self, timing): """Add a Timing to the Timings""" if not isinstance(timing, Timing): raise ValueError(f"Argument should be Timing instance: {timing}") self[timing.tag] = timing
[docs] def report(self, *tags): """Return a full report of all existing or requested Timings. Parameters ---------- *tags: sequence, optional A sequence of tag strings of the timings that should be included in the report. In none provided, all timings are included. Returns ------- string A string containing the formatted Timing instances. This is also the string that will be shown by the print function. """ if tags: timings = (t for t in (self.get(tag, None) for tag in tags) if t is not None) else: timings = self return '\n '.join(['Timings report'] + [str(self[t]) for t in timings])
def __str__(self): return self.report() def __enter__(self): """Enter the context manager""" for tag in self: self[tag].__enter__() return self def __exit__(self, *exc): """Exit the context manager""" for tag in reversed(self): # TODO: Should we use reversed or not? self[tag].__exit__()
if __name__ == "__main__": print(f"Running doctests on {__file__}") import doctest doctest.testmod( optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS) # End