Skip to content

Commit e796eba

Browse files
Copilotmmcky
andauthored
ENH: Add timeit function for multiple runs timing with statistics (#794)
* Initial plan * Implement Timer multiple runs enhancement with runs parameter Co-authored-by: mmcky <[email protected]> * Add time_run() context manager for multiple runs mode - Implement time_run() method that returns a context manager for timing individual runs - Allow usage pattern: with Timer(runs=5): for i in range(5): with timer.time_run(): ... - Enhanced __exit__ to compute statistics when all runs are completed in context manager mode - Add comprehensive tests for new time_run functionality - Maintain full backward compatibility with existing timeit() method and single run mode Co-authored-by: mmcky <[email protected]> * Simplify Timer API by removing time_run() method - Remove _RunTimer class and time_run() method as requested - Context manager now only supports single runs (runs=1) - Multiple runs only supported via timeit() method - Update tests to reflect simplified API - Maintain full backward compatibility for existing usage patterns Co-authored-by: mmcky <[email protected]> * Implement timeit function as requested - revert Timer to original simple form and add separate timeit function Co-authored-by: mmcky <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mmcky <[email protected]> Co-authored-by: Matt McKay <[email protected]>
1 parent ecc6ad2 commit e796eba

File tree

4 files changed

+258
-3
lines changed

4 files changed

+258
-3
lines changed

quantecon/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@
5252
#<-
5353
from ._rank_nullspace import rank_est, nullspace
5454
from ._robustlq import RBLQ
55-
from .util import searchsorted, fetch_nb_dependencies, tic, tac, toc, Timer
55+
from .util import searchsorted, fetch_nb_dependencies, tic, tac, toc, Timer, timeit

quantecon/util/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
from .array import searchsorted
77
from .notebooks import fetch_nb_dependencies
88
from .random import check_random_state, rng_integers
9-
from .timing import tic, tac, toc, loop_timer, Timer
9+
from .timing import tic, tac, toc, loop_timer, Timer, timeit

quantecon/util/tests/test_timing.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import time
77
from numpy.testing import assert_allclose, assert_
8-
from quantecon.util import tic, tac, toc, loop_timer, Timer
8+
from quantecon.util import tic, tac, toc, loop_timer, Timer, timeit
99

1010

1111
class TestTicTacToc:
@@ -147,3 +147,116 @@ def test_timer_exception_handling(self):
147147
# Timer should still record elapsed time
148148
assert timer.elapsed is not None
149149
assert_allclose(timer.elapsed, self.sleep_time, atol=0.05, rtol=2)
150+
151+
def test_timeit_basic(self):
152+
"""Test basic timeit functionality."""
153+
def test_func():
154+
time.sleep(self.sleep_time)
155+
156+
result = timeit(test_func, runs=3, silent=True)
157+
158+
# Check that we have results
159+
assert 'elapsed' in result
160+
assert 'average' in result
161+
assert 'minimum' in result
162+
assert 'maximum' in result
163+
assert isinstance(result['elapsed'], list)
164+
assert len(result['elapsed']) == 3
165+
166+
# Check timing accuracy
167+
for run_time in result['elapsed']:
168+
assert_allclose(run_time, self.sleep_time, atol=0.05, rtol=2)
169+
170+
assert_allclose(result['average'], self.sleep_time, atol=0.05, rtol=2)
171+
assert result['minimum'] <= result['average'] <= result['maximum']
172+
173+
def test_timeit_lambda_function(self):
174+
"""Test timeit with lambda functions for arguments."""
175+
def test_func_with_args(sleep_time, multiplier=1):
176+
time.sleep(sleep_time * multiplier)
177+
178+
# Use lambda to bind arguments
179+
func_with_args = lambda: test_func_with_args(self.sleep_time, 0.5)
180+
result = timeit(func_with_args, runs=2, silent=True)
181+
182+
# Check results
183+
assert len(result['elapsed']) == 2
184+
for run_time in result['elapsed']:
185+
assert_allclose(run_time, self.sleep_time * 0.5, atol=0.05, rtol=2)
186+
187+
def test_timeit_validation(self):
188+
"""Test validation for timeit function."""
189+
def test_func():
190+
time.sleep(self.sleep_time)
191+
192+
# Test invalid runs parameter
193+
try:
194+
timeit(test_func, runs=0)
195+
assert False, "Should have raised ValueError"
196+
except ValueError as e:
197+
assert "runs must be a positive integer" in str(e)
198+
199+
try:
200+
timeit(test_func, runs=-1)
201+
assert False, "Should have raised ValueError"
202+
except ValueError as e:
203+
assert "runs must be a positive integer" in str(e)
204+
205+
# Test invalid function
206+
try:
207+
timeit("not a function", runs=1)
208+
assert False, "Should have raised ValueError"
209+
except ValueError as e:
210+
assert "func must be callable" in str(e)
211+
212+
def test_timeit_single_run(self):
213+
"""Test that timeit works with single run."""
214+
def test_func():
215+
time.sleep(self.sleep_time)
216+
217+
result = timeit(test_func, runs=1, silent=True)
218+
219+
assert len(result['elapsed']) == 1
220+
assert result['average'] == result['elapsed'][0]
221+
assert result['minimum'] == result['elapsed'][0]
222+
assert result['maximum'] == result['elapsed'][0]
223+
224+
def test_timeit_different_units(self):
225+
"""Test timeit with different time units."""
226+
def test_func():
227+
time.sleep(self.sleep_time)
228+
229+
# Test milliseconds (silent mode to avoid output during tests)
230+
result_ms = timeit(test_func, runs=2, unit="milliseconds", silent=True)
231+
assert len(result_ms['elapsed']) == 2
232+
233+
# Test microseconds
234+
result_us = timeit(test_func, runs=2, unit="microseconds", silent=True)
235+
assert len(result_us['elapsed']) == 2
236+
237+
# All results should be in seconds regardless of display unit
238+
for run_time in result_ms['elapsed']:
239+
assert_allclose(run_time, self.sleep_time, atol=0.05, rtol=2)
240+
for run_time in result_us['elapsed']:
241+
assert_allclose(run_time, self.sleep_time, atol=0.05, rtol=2)
242+
243+
def test_timeit_stats_only(self):
244+
"""Test timeit with stats_only option."""
245+
def test_func():
246+
time.sleep(self.sleep_time)
247+
248+
# This test is mainly to ensure stats_only doesn't crash
249+
result = timeit(test_func, runs=2, stats_only=True, silent=True)
250+
assert len(result['elapsed']) == 2
251+
252+
def test_timeit_invalid_timer_kwargs(self):
253+
"""Test that invalid timer kwargs are rejected."""
254+
def test_func():
255+
time.sleep(self.sleep_time)
256+
257+
try:
258+
timeit(test_func, runs=1, invalid_param="test")
259+
assert False, "Should have raised ValueError"
260+
except ValueError as e:
261+
assert "Unknown timer parameters" in str(e)
262+

quantecon/util/timing.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,148 @@ def _print_elapsed(self):
271271
print(f"{prefix}{elapsed_display:.{self.precision}f} {unit_str} elapsed")
272272

273273

274+
def timeit(func, runs=1, stats_only=False, **timer_kwargs):
275+
"""
276+
Execute a function multiple times and collect timing statistics.
277+
278+
This function provides a convenient way to time a function multiple times
279+
and get summary statistics, using the Timer context manager internally.
280+
281+
Parameters
282+
----------
283+
func : callable
284+
Function to execute multiple times. Function should take no arguments,
285+
or be a partial function or lambda with arguments already bound.
286+
runs : int, optional(default=1)
287+
Number of runs to execute. Must be a positive integer.
288+
stats_only : bool, optional(default=False)
289+
If True, only display summary statistics. If False, display
290+
individual run times followed by summary statistics.
291+
**timer_kwargs
292+
Keyword arguments to pass to Timer (message, precision, unit, silent).
293+
Note: silent parameter controls all output when stats_only=False.
294+
295+
Returns
296+
-------
297+
dict
298+
Dictionary containing timing results with keys:
299+
- 'elapsed': list of elapsed times for each run
300+
- 'average': average elapsed time
301+
- 'minimum': minimum elapsed time
302+
- 'maximum': maximum elapsed time
303+
304+
Examples
305+
--------
306+
Basic usage:
307+
>>> def slow_function():
308+
... time.sleep(0.01)
309+
>>> timeit(slow_function, runs=3)
310+
Run 1: 0.01 seconds
311+
Run 2: 0.01 seconds
312+
Run 3: 0.01 seconds
313+
Average: 0.01 seconds, Minimum: 0.01 seconds, Maximum: 0.01 seconds
314+
315+
Summary only:
316+
>>> timeit(slow_function, runs=3, stats_only=True)
317+
Average: 0.01 seconds, Minimum: 0.01 seconds, Maximum: 0.01 seconds
318+
319+
With custom Timer options:
320+
>>> timeit(slow_function, runs=2, unit="milliseconds", precision=1)
321+
Run 1: 10.1 ms
322+
Run 2: 10.0 ms
323+
Average: 10.1 ms, Minimum: 10.0 ms, Maximum: 10.1 ms
324+
325+
With function arguments using lambda:
326+
>>> add_func = lambda: expensive_computation(5, 10)
327+
>>> timeit(add_func, runs=2)
328+
"""
329+
if not isinstance(runs, int) or runs < 1:
330+
raise ValueError("runs must be a positive integer")
331+
332+
if not callable(func):
333+
raise ValueError("func must be callable")
334+
335+
# Extract Timer parameters
336+
timer_params = {
337+
'message': timer_kwargs.pop('message', ''),
338+
'precision': timer_kwargs.pop('precision', 2),
339+
'unit': timer_kwargs.pop('unit', 'seconds'),
340+
'silent': timer_kwargs.pop('silent', False) # Explicit silent parameter
341+
}
342+
343+
# Warn about unused kwargs
344+
if timer_kwargs:
345+
raise ValueError(f"Unknown timer parameters: {list(timer_kwargs.keys())}")
346+
347+
run_times = []
348+
349+
# Execute the function multiple times
350+
for i in range(runs):
351+
# Always silence individual Timer output to avoid duplication with our run display
352+
individual_timer_params = timer_params.copy()
353+
individual_timer_params['silent'] = True
354+
355+
with Timer(**individual_timer_params) as timer:
356+
func()
357+
run_times.append(timer.elapsed)
358+
359+
# Print individual run times unless stats_only or silent
360+
if not stats_only and not timer_params['silent']:
361+
# Convert to requested unit for display
362+
unit = timer_params['unit'].lower()
363+
precision = timer_params['precision']
364+
365+
if unit == "milliseconds":
366+
elapsed_display = timer.elapsed * 1000
367+
unit_str = "ms"
368+
elif unit == "microseconds":
369+
elapsed_display = timer.elapsed * 1000000
370+
unit_str = "μs"
371+
else: # seconds
372+
elapsed_display = timer.elapsed
373+
unit_str = "seconds"
374+
375+
print(f"Run {i + 1}: {elapsed_display:.{precision}f} {unit_str}")
376+
377+
# Calculate statistics
378+
average = sum(run_times) / len(run_times)
379+
minimum = min(run_times)
380+
maximum = max(run_times)
381+
382+
# Print summary statistics unless completely silent
383+
if not timer_params['silent']:
384+
# Convert to requested unit for display
385+
unit = timer_params['unit'].lower()
386+
precision = timer_params['precision']
387+
388+
if unit == "milliseconds":
389+
avg_display = average * 1000
390+
min_display = minimum * 1000
391+
max_display = maximum * 1000
392+
unit_str = "ms"
393+
elif unit == "microseconds":
394+
avg_display = average * 1000000
395+
min_display = minimum * 1000000
396+
max_display = maximum * 1000000
397+
unit_str = "μs"
398+
else: # seconds
399+
avg_display = average
400+
min_display = minimum
401+
max_display = maximum
402+
unit_str = "seconds"
403+
404+
print(f"Average: {avg_display:.{precision}f} {unit_str}, "
405+
f"Minimum: {min_display:.{precision}f} {unit_str}, "
406+
f"Maximum: {max_display:.{precision}f} {unit_str}")
407+
408+
return {
409+
'elapsed': run_times,
410+
'average': average,
411+
'minimum': minimum,
412+
'maximum': maximum
413+
}
414+
415+
274416
def tic():
275417
return __timer__.tic()
276418

0 commit comments

Comments
 (0)