File Coverage

blib/lib/Timer/Milestones.pm
Criterion Covered Total %
statement 209 213 98.1
branch 77 80 96.2
condition 27 37 72.9
subroutine 35 36 97.2
pod 7 7 100.0
total 355 373 95.1


line stmt bran cond sub pod time code
1             package Timer::Milestones;
2              
3 5     5   774166 use strict;
  5         21  
  5         127  
4 5     5   23 use warnings;
  5         8  
  5         121  
5              
6 5     5   1857 use parent 'Exporter';
  5         1278  
  5         24  
7              
8 5     5   260 use Carp;
  5         11  
  5         238  
9 5     5   25 use Scalar::Util qw(blessed);
  5         9  
  5         9266  
10              
11             our @EXPORT_OK = qw(start_timing add_milestone stop_timing);
12             our %EXPORT_TAGS = (all => \@EXPORT_OK);
13              
14             # Have you updated the version number in the POD below?
15             our $VERSION = '0.001';
16             $VERSION = eval $VERSION;
17              
18             =head1 NAME
19              
20             Timer::Milestones - measure code execution time succinctly by setting milestones
21              
22             =head1 VERSION
23              
24             This is version 0.001.
25              
26             =head1 SYNOPSIS
27              
28             use Timer::Milestones qw(start_timing mark_milestone stop_timing
29             time_method);
30              
31             start_timing();
32             time_method('Some::ThirdParty::Module::do_slow_thing');
33             my @objects = _set_up_objects();
34             mark_milestone('Everything set up');
35             for my $object (@objects) {
36             _do_something_potentially_slow($object);
37             }
38             mark_milestone('Telling the user')
39             for my $object (@objects) {
40             _inform_user($object);
41             }
42             ...
43             stop_timing();
44              
45             Spits out to STDERR e.g.
46              
47             START: Tue Feb 4 16:03:08 2020
48             3 s ( 0.28%)
49             Everything set up
50             5 min 30 s ( 31.22%)
51             3 min 7 s Some::ThirdParty::Module::do_slow_thing
52             Telling the user
53             12 min 7 s ( 68.78%)
54             8 min 30 s Some::ThirdParty::Module::do_slow_thing (x3)
55             END: Tue Feb 4 16:20:48 2020
56              
57             =head1 DESCRIPTION
58              
59             At its simplest, Timer::Milestones is yet another timer module. It is designed
60             to have the smallest possible interface, so adding timing calls to your code
61             doesn't make it look unreadable. It can also time execution time of functions
62             in other modules, as a more informative (and quicker!) alternative to running
63             everything under Devel::NYTProf.
64              
65             =head2 Functional vs OO interface
66              
67             You can use Timer::Milestones via a functional interface:
68              
69             use Timer::Milestones qw(start_timing mark_milestone stop_timing);
70             start_timing();
71             ...;
72             mark_milestone('Half-way through');
73             ...;
74             stop_timing();
75              
76             Or via an OO interface:
77              
78             use Timer::Milestones;
79             {
80             my $timer = Timer::Milestones->new;
81             # $timer->start_timing automatically called
82             ...;
83             $timer->mark_milestone('Half-way through');
84             ...;
85             }
86             # $timer->stop_timing automatically called when $timer is destroyed
87              
88             The OO interface is simpler if you're timing a monolithic block of code. If you
89             need to add timing calls throughout code scattered across multiple files, you're
90             better off with the functional interface as you don't need to pass a
91             Timer::Milestone object around.
92              
93             =head2 Milestones and reports
94              
95             At its simplest, the report you get will include when timing started and when
96             timing ended. These are displayed in your locale's local time, on the assumption
97             that that's what makes sense to you.
98              
99             As soon as you add milestones, you will also be told how much time passed
100             between the start, each milestone, and the end. Times are specified in both
101             human-friendly periods (a number of milliseconds; seconds; minutes and seconds;
102             or hours and minutes), and percentages of the total elapsed time.
103              
104             If you decide that you want to L
105             well|/time_function>, they'll be mentioned with the milestones they follow. If
106             you provide a coderef to summarise the arguments passed to them that will be
107             included; if you decide that you don't need to see individual timings for each
108             function call, just an overall time, you'll get a shorter list of function
109             calls and an overall time.
110              
111             =head2 Basic functionality
112              
113             =head3 new
114              
115             Out: $timer
116              
117             Creates a new Timer::Milestones object, and calls L on it.
118              
119             =cut
120              
121             sub new {
122             # Start off with an empty hashref
123 20     20 1 4248 my $invocant = shift;
124 20   33     94 my $self = bless {} => ref($invocant) || $invocant;
125              
126             # Accept replacement coderefs for get_time and notify_report (mostly
127             # used in tests), and otherwise populate them with default values.
128 20 100       65 if (my %params = @_) {
129 8         23 for my $coderef_param (qw(get_time notify_report)) {
130 16 100       42 if (ref($params{$coderef_param}) eq 'CODE') {
131 8         23 $self->{$coderef_param} = $params{$coderef_param};
132             }
133             }
134             }
135 20   66     99 $self->{get_time} ||= $self->_default_get_time;
136 20   66     92 $self->{notify_report} ||= $self->_default_notify_report;
137              
138             # Start timing, and return this object.
139 20         98 $self->start_timing;
140 20         45 return $self;
141             }
142              
143             # Passed a list of arguments, returns a similar list of arguments beginning
144             # with a Timer::Milestones object - the first argument, if it was such an
145             # object, or otherwise a singleton, followed by the other arguments.
146             {
147             my $singleton;
148              
149             sub _object_and_arguments {
150 131     131   4937 my (@arguments) = @_;
151            
152 131 100 100     643 unless (blessed($arguments[0])
153             && $arguments[0]->isa('Timer::Milestones'))
154             {
155 8   66     28 $singleton ||= __PACKAGE__->new;
156 8         12 unshift @arguments, $singleton
157             }
158 131         341 return @arguments;
159             }
160             }
161              
162              
163             =head3 start_timing
164              
165             If timing hadn't already been started, starts timing. Otherwise does nothing.
166             Automatically called by L, but you'll need to call it explicitly when
167             using the functional interface.
168              
169             =cut
170              
171             sub start_timing {
172 22     22 1 1918 my ($self) = _object_and_arguments(@_);
173 22 100       57 unless (exists $self->{milestones}) {
174 20         50 $self->add_milestone('START');
175             }
176             }
177              
178             =head3 add_milestone
179              
180             In: $name (optional)
181              
182             Adds another milestone. If supplied with a name, uses that name for the
183             milestone; otherwise, generates a name from the place it was called from
184             (package, function, line number).
185              
186             Throws an exception if a timing report has already been generated by
187             L.
188              
189             =cut
190              
191             sub add_milestone {
192 35     35 1 257 my ($self, $milestone_name) = _object_and_arguments(@_);
193              
194             # Can't add milestones if we've decided that we're finished.
195 35 100       69 if ($self->{timing_stopped}) {
196 2         336 croak 'Stopped timing already';
197             }
198              
199             # Build up a milestone structure with the name provided, or a suitable
200             # default.
201 33   66     93 my $milestone = { name => $milestone_name || $self->_milestone_name };
202              
203             # End the previous milestone, if there was one; reuse the time it ended
204             # if we can.
205 33 100       60 if (my $previous_milestone = $self->_end_previous_milestone) {
206 13         19 $milestone->{started} = $previous_milestone->{ended};
207             } else {
208 20         32 $milestone->{started} = $self->_now
209             }
210              
211             # Remember this new milestone.
212 33         59 push @{ $self->{milestones} }, $milestone;
  33         61  
213              
214             # We can now usefully generate a new report.
215 33         45 delete $self->{generated_report};
216              
217 33         65 return $milestone;
218             }
219              
220             sub _milestone_name {
221             # Where we were called from (skipping over add_milestone which called *us*):
222 1     1   7 my ($package, $filename, $line) = caller(1);
223             # The subroutine the calling code was called from, if any. It will be
224             # fully-qualified, so no need to mention the package.
225 1 50       6 if (my $calling_subroutine = (caller(2))[3]) {
226 1         7 return "$calling_subroutine (line $line of $filename)";
227             } else {
228 0         0 return "Line $line of $package ($filename)";
229             }
230             }
231              
232             =head3 generate_intermediate_report
233              
234             Out: $report
235              
236             Returns a report on the milestones that have elapsed so far, or undef if a
237             report has previously been generated and no new milestones have been
238             reached since then.
239              
240             =cut
241              
242             sub generate_intermediate_report {
243 4     4 1 404 my ($self) = _object_and_arguments(@_);
244            
245 4         8 $self->_generate_report;
246             }
247              
248             sub _generate_report {
249 33     33   58 my ($self) = @_;
250              
251             # If we've got nothing new since the last time since we said anything,
252             # don't say anything.
253 33 100       115 return if $self->{generated_report};
254              
255             # There's also nothing to say if we don't have any milestones.
256 22 50       42 return if !$self->{milestones};
257              
258             # Build up a report.
259 22         29 my ($previous_milestone, @elements, @function_calls);
260 22         26 for my $milestone (@{ $self->{milestones} }) {
  22         43  
261             # If this is the first milestone, mention when this milestone started,
262             # as it's the start of it all.
263 37 100       63 if (!$previous_milestone) {
264             push @elements,
265             {
266             type => 'time',
267             name => $milestone->{name},
268             time => $milestone->{started},
269 21         73 };
270             }
271              
272             # But if we *do* have a previous milestone, we can now report how long
273             # it took to get to this one.
274 37 100       68 if ($previous_milestone) {
275             my $elapsed_time = $previous_milestone->{ended}
276 16         29 - $previous_milestone->{started};
277 16         31 push @elements,
278             { type => 'interval', elapsed_time => $elapsed_time };
279 16 100       28 push @elements, @function_calls if @function_calls;
280             push @elements,
281 16         48 { type => 'milestone', name => $milestone->{name} };
282             }
283            
284             # Remember this milestone for when we reach the next one.
285 37         48 $previous_milestone = $milestone;
286              
287             # If there were any function calls in this milestone, remember them.
288 37         50 @function_calls = ();
289 37 100       64 if ($milestone->{function_calls}) {
290 6         24 for my $function_call (@{ $milestone->{function_calls} }) {
  6         29  
291 40         83 $self->_add_function_call_to_list($function_call,
292             \@function_calls);
293             }
294             }
295             }
296              
297             # If we've ended, also remember that.
298 22 100       54 if ($self->{timing_stopped}) {
299             push @elements,
300             {
301             type => 'interval',
302             elapsed_time => $previous_milestone->{ended}
303             - $previous_milestone->{started}
304 18         49 };
305 18 100       38 push @elements, @function_calls if @function_calls;
306             push @elements,
307             {
308             type => 'time',
309             name => 'END',
310             time => $self->{timing_stopped}
311 18         46 };
312             }
313              
314             # Now that we've got all the elements, generate a report from them.
315 22         52 my $report = $self->_generate_report_from_elements(@elements);
316              
317             # Remember that we generated a report, so we don't produce it again.
318 22         44 $self->{generated_report} = 1;
319              
320             # And return the report we generated.
321 22         134 return $report;
322             }
323              
324             # Provided with a function call hashref and an arrayref of function call
325             # elements, adds or combines the function call with what we have already.
326              
327             sub _add_function_call_to_list {
328 40     40   55 my ($self, $function_call, $call_elements) = @_;
329              
330 40         55 my $elapsed_time = $function_call->{ended} - $function_call->{started};
331              
332             # If we're not summarising calls, we're going to add another element,
333             # so just do that.
334 40 100       57 if (!$function_call->{summarise_calls}) {
335             my $element = {
336             type => 'function_call',
337             function_name => $function_call->{function_name},
338 14         56 elapsed_time => $elapsed_time,
339             };
340 14 100       29 if (exists $function_call->{argument_summary}) {
341             $element->{arguments_seen} = [
342             {
343             call_count => 1,
344             argument_summary => $function_call->{argument_summary},
345             }
346 6         24 ];
347             }
348 14         21 push @$call_elements, $element;
349 14         26 return;
350             }
351              
352             # OK, find out which element we're going to use.
353             my ($element)
354 26         36 = grep { $_->{function_name} eq $function_call->{function_name} }
  117         163  
355             @$call_elements;
356 26 100       37 if (!$element) {
357 3         7 push @{$call_elements},
358             {
359             type => 'function_call',
360             function_name => $function_call->{function_name},
361 3         3 elapsed_time => 0,
362             };
363 3         4 $element = $call_elements->[-1];
364             }
365 26         30 $element->{elapsed_time} += $elapsed_time;
366              
367             # If we're summarising arguments as well, store that information inside
368             # this element, once for each argument summary we see.
369 26 100       35 if (exists $function_call->{argument_summary}) {
370             my ($matching_arguments) = grep {
371             $_->{argument_summary} eq $function_call->{argument_summary}
372 13   100     14 } @{ $element->{arguments_seen} ||= [] };
  14         24  
  13         26  
373 13 100       17 if (!$matching_arguments) {
374 4         9 push @{ $element->{arguments_seen} },
375 4         5 { argument_summary => $function_call->{argument_summary} };
376 4         7 $matching_arguments = $element->{arguments_seen}[-1];
377             }
378 13         16 $matching_arguments->{call_count}++;
379             }
380              
381             # No matter what, remember that this function was called again.
382 26         41 $element->{call_count}++;
383             }
384              
385              
386             sub _generate_report_from_elements {
387 22     22   47 my ($self, @elements) = @_;
388              
389             # Work out how much time passed between all intervals so far.
390 22         27 my $total_elapsed_time = 0;
391 22         47 for my $element (grep { $_->{type} eq 'interval' } @elements) {
  106         181  
392 34         58 $total_elapsed_time += $element->{elapsed_time};
393             }
394              
395             # In case all our timestamps are equal (which *can* happen if you're
396             # testing very, very fast code, or Time::HiRes isn't working), tweak the
397             # total elapsed time to merely be *very small*, to avoid a divide by zero
398             # error later on when we work out percentages.
399 22   100     49 $total_elapsed_time ||= 0.000_001;
400              
401             # Now we can report all of this: static times, and intervals between them.
402 22         27 my $report;
403 22         30 for my $element (@elements) {
404 106 100       290 if ($element->{type} eq 'time') {
    100          
    100          
    50          
405             $report .= $element->{name} . ': '
406 39         910 . localtime($element->{time}) . "\n";
407             } elsif ($element->{type} eq 'milestone') {
408 16         29 $report .= $element->{name} . "\n";
409             } elsif ($element->{type} eq 'interval') {
410             my $elapsed_time_ratio
411 34         63 = $element->{elapsed_time} / $total_elapsed_time;
412             $report .= sprintf(
413             " %s (%6.2f%%)\n",
414 34         70 $self->_human_elapsed_time($element->{elapsed_time}),
415             $elapsed_time_ratio * 100
416             );
417             } elsif ($element->{type} eq 'function_call') {
418 17         24 my $function_name = $element->{function_name};
419 17 100       45 if ($element->{call_count}) {
420 3         5 $function_name .= ' (x' . $element->{call_count} . ')';
421             }
422             $report .= sprintf(" %6s %s\n",
423 17         70 $self->_human_elapsed_time($element->{elapsed_time}),
424             $function_name
425             );
426 17 100       42 if ($element->{arguments_seen}) {
427 8         8 for my $arguments (@{ $element->{arguments_seen}}) {
  8         15  
428 10         14 $report .= (' ' x 12) . $arguments->{argument_summary};
429 10 100       17 if ($arguments->{call_count} > 1) {
430 3         5 $report .= ' (x' . $arguments->{call_count} . ')';
431             }
432 10         16 $report .= "\n";
433             }
434             }
435             } else {
436             croak 'What the hell is an element of type '
437 0         0 . $element->{type} . '?';
438             }
439             }
440 22         86 return $report;
441             }
442              
443             sub _human_elapsed_time {
444 68     68   5475 my ($self, $elapsed_time) = @_;
445              
446 68         123 my @unit_specs = $self->_unit_specs;
447             unit_spec:
448 68         149 for my $unit_spec (@unit_specs) {
449             next unit_spec
450 124 100 100     374 if $unit_spec->{max} && $elapsed_time >= $unit_spec->{max};
451             return sprintf(
452             $unit_spec->{label_format},
453             $unit_spec->{transform}
454 68 100       206 ? $unit_spec->{transform}->($elapsed_time)
455             : $elapsed_time
456             );
457             }
458             }
459              
460             sub _unit_specs {
461             (
462             {
463             max => 1,
464             label_format => '%3d ms',
465 39     39   396 transform => sub { (shift) * 1_000 },
466             },
467             {
468             max => 60,
469             label_format => '%2d s',
470             },
471             {
472             max => 60 * 60,
473             label_format => '%2d min %2d s',
474             transform => sub {
475 15     15   25 my $seconds = shift;
476 15         196 ($seconds / 60, $seconds % 60)
477             },
478             },
479             {
480             label_format => '%d h %2d min',
481             transform => sub {
482 6     6   11 my $seconds = shift;
483 6         11 my $minutes = $seconds / 60;
484 6         53 ($minutes / 60, $minutes % 60)
485             },
486             }
487 68     68   607 );
488             }
489              
490             =head3 generate_final_report
491              
492             Out: $report
493              
494             Stops timing, and returns a report for all of the milestones.
495              
496             =cut
497              
498             sub generate_final_report {
499 28     28 1 887 my ($self) = _object_and_arguments(@_);
500              
501 28 100       66 if (!$self->{timing_stopped}) {
502 18         37 my $milestone = $self->_end_previous_milestone;
503 18         29 $self->{timing_stopped} = $milestone->{ended};
504 18         32 delete $self->{generated_report};
505             }
506              
507 28         54 return $self->_generate_report;
508             }
509              
510             =head3 stop_timing
511              
512             Stops timing, and spits out the result of L to
513             STDERR. This is called automatically in OO mode when the object goes out of
514             scope. This does nothing if you've already called L.
515              
516             =cut
517              
518             sub stop_timing {
519 23     23 1 3484 my ($self) = _object_and_arguments(@_);
520 23 100       47 if (my $report = $self->generate_final_report) {
521 13   33     33 $self->{notify_report} ||= $self->_default_notify_report;
522 13         39 return $self->{notify_report}->($report);
523             }
524             }
525              
526             sub _default_notify_report {
527 0     0   0 sub { my $report = shift; print STDERR $report }
  0         0  
528 4     4   16 }
529              
530             # Makes sure that we have a list of milestones; if we also had a previous
531             # milestone, marks it as having ended now.
532              
533             sub _end_previous_milestone {
534 51     51   75 my ($self) = @_;
535 51   100     125 $self->{milestones} ||= [];
536 51 100       102 if (my $previous_milestone = $self->{milestones}[-1]) {
537 30         45 $previous_milestone->{ended} = $self->_now;
538 30         83 return $previous_milestone;
539             }
540 21         44 return;
541             }
542              
543             # Returns the current time, via the get_time coderef. The main use for this
544             # level of indirection is (a) supporting Time::HiRes if it's installed, and
545             # otherwise falling back to the standard time function, and (b) making it
546             # possible to mock time, which we'll need in the tests.
547              
548             sub _now {
549 140     140   552 my ($self) = @_;
550              
551 140   33     215 $self->{get_time} ||= $self->_default_get_time;
552 140         288 return $self->{get_time}->();
553             }
554              
555             sub _default_get_time {
556 16         1902 eval { require Time::HiRes; Time::HiRes::time() }
  16         4808  
557             ? \&Time::HiRes::time
558 3 100   3   13 : sub { time };
  16     16   22  
559             }
560              
561             sub DESTROY {
562 15     15   7985 my ($self) = @_;
563              
564 15         38 $self->stop_timing;
565             }
566              
567             =head2 Timing other people's code
568              
569             Adding calls to L throughout your code is all very well, but
570             sometimes you want to time a small handful of methods deep in someone else's
571             code (or deep in I code - same difference). By carefully targeting only
572             a few methods to time, you can avoid the pitfalls of profiling with
573             L, where code that does zillions of fast method calls will
574             appear to be much slower than it is when not profiling.
575              
576             =head3 time_function
577              
578             In: $function_name
579             In: %args (optional)
580              
581             Supplied with a function name, e.g. C,
582             and an optional hash of arguments, wraps it with a temporary shim that records
583             the time spent inside this function. That shim is removed, and the original
584             code restored, when timing stops. Details of the functions called are included
585             between milestones in the resulting report.
586              
587             Optional arguments are as follows:
588              
589             =over
590              
591             =item summarise_arguments
592              
593             A coderef, which will be passed the arguments passed to the function,
594             and which should return a scalar that will be included in the report.
595              
596             =item summarise_calls
597              
598             If set to a true value, repeated calls to this function will be summarised
599             rather than listed individually: the first time a function call is found, it
600             will also mention all subsequent calls.
601              
602             This can combine with C: calls which result in an
603             identical return value from that coderef will be combined.
604              
605             =back
606              
607             =cut
608              
609             sub time_function {
610 12     12 1 898 my ($self, $function_name, %args) = _object_and_arguments(@_);
611              
612             # There had better be a function of this name.
613 5     5   36 no strict 'refs';
  5         10  
  5         217  
614 12         18 my $orig_code = \&{ $function_name };
  12         38  
615 5     5   29 use strict 'refs';
  5         7  
  5         1088  
616 12 100       35 if (!defined &$orig_code) {
617 2         21 die "No such function as $function_name";
618             }
619              
620             # OK, generate a wrapper.
621             my $wrapper = sub {
622             # Remember how this function was called.
623 43     43   1228 my @args = @_;
624 43         52 my $wantarray = wantarray;
625              
626             # Take a snapshot before we called it.
627 43   100     44 push @{ $self->{milestones}[-1]{function_calls} ||= [] },
  43         119  
628             my $function_call = {
629             function_name => $function_name,
630             started => $self->_now,
631             };
632              
633             # Remember that we want to summarise these calls if necessary.
634 43 100       90 if ($args{summarise_calls}) {
635 26         54 $function_call->{summarise_calls} = 1;
636             }
637              
638             # Include a summary of the arguments provided if necessary.
639 43 100       72 if ($args{summarise_arguments}) {
640             $function_call->{argument_summary}
641 19         31 = $args{summarise_arguments}->(@args);
642             }
643              
644             # Call it.
645 43         119 my ($scalar_return, @list_return);
646 43 100       71 if ($wantarray) {
    100          
647 1         3 @list_return = $orig_code->(@args);
648             } elsif (defined $wantarray) {
649 1         4 $scalar_return = $orig_code->(@args);
650             } else {
651 41         60 $orig_code->(@args);
652             }
653              
654             # Take a snapshot at the end.
655 43         533 $function_call->{ended} = $self->_now;
656              
657             # And return the original return values.
658 43 100       87 if ($wantarray) {
    100          
659 1         7 return @list_return;
660             } elsif (defined $wantarray) {
661 1         6 return $scalar_return;
662             } else {
663 41         70 return;
664             }
665 10         43 };
666              
667             # And install that.
668 5     5   31 no strict 'refs';
  5         8  
  5         152  
669 5     5   25 no warnings 'redefine';
  5         8  
  5         248  
670 10         11 *{ $function_name } = $wrapper;
  10         29  
671 5     5   28 use warnings 'redefine';
  5         7  
  5         179  
672 5     5   24 use strict 'refs';
  5         10  
  5         346  
673              
674             # Remember that we did this, so we can unwind it all.
675 10   100     11 push @{ $self->{wrapped_functions} ||= [] },
  10         57  
676             {
677             function_name => $function_name,
678             orig_code => $orig_code,
679             };
680             }
681              
682             =head1 SEE ALSO
683              
684             L, which is simpler but more verbose.
685              
686             L, which does some similar things.
687              
688             L, which is probably worth using as a first pass, even if you
689             don't necessarily trust its idea of what's I slow.
690              
691             =head1 AUTHOR
692              
693             Sam Kington
694              
695             The source code for this module is hosted on GitHub
696             L - this is probably the
697             best place to look for suggestions and feedback.
698              
699             =head1 COPYRIGHT
700              
701             Copyright (c) 2020 Sam Kington.
702              
703             =head1 LICENSE
704              
705             This library is free software and may be distributed under the same terms as
706             perl itself.
707              
708             =cut
709              
710             1;