#
##
## 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()
__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