File Coverage

blib/lib/Test/MemoryGrowth.pm
Criterion Covered Total %
statement 72 85 84.7
branch 13 22 59.0
condition 6 6 100.0
subroutine 9 10 90.0
pod 1 4 25.0
total 101 127 79.5


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2010-2024 -- leonerd@leonerd.org.uk
5              
6             package Test::MemoryGrowth 0.05;
7              
8 3     3   918259 use v5.14;
  3         12  
9 3     3   16 use warnings;
  3         9  
  3         208  
10 3     3   30 use base qw( Test::Builder::Module );
  3         7  
  3         1829  
11              
12             our @EXPORT = qw(
13             no_growth
14             );
15              
16 3     3   45618 use constant HAVE_DEVEL_GLADIATOR => defined eval { require Devel::Gladiator };
  3         12  
  3         6  
  3         2827  
17              
18 3     3   3760 use constant HAVE_DEVEL_MAT_DUMPER => defined eval { require Devel::MAT::Dumper };
  3         7  
  3         7  
  3         1927  
19              
20             =head1 NAME
21              
22             C - assert that code does not cause growth in memory usage
23              
24             =head1 SYNOPSIS
25              
26             =for highlighter language=perl
27              
28             use Test::More;
29             use Test::MemoryGrowth;
30              
31             use Some::Class;
32              
33             no_growth {
34             my $obj = Some::Class->new;
35             } 'Constructing Some::Class does not grow memory';
36              
37             my $obj = Some::Class->new;
38             no_growth {
39             $obj->do_thing;
40             } 'Some::Class->do_thing does not grow memory';
41              
42              
43             #### This test will fail ####
44             my @list;
45             no_growth {
46             push @list, "Hello world";
47             } 'pushing to an array does not grow memory';
48              
49             done_testing;
50              
51             =head1 DESCRIPTION
52              
53             This module provides a function to check that a given block of code does not
54             result in the process consuming extra memory once it has finished. Despite the
55             name of this module it does not, in the strictest sense of the word, test for a
56             memory leak: that term is specifically applied to cases where memory has been
57             allocated but all record of it has been lost, so it cannot possibly be
58             reclaimed. While the method employed by this module can detect such bugs, it
59             can also detect cases where memory is still referenced and reachable, but the
60             usage has grown more than would be expected or necessary.
61              
62             The block of code will be run a large number of times (by default 10,000), and
63             the difference in memory usage by the process before and after is compared. If
64             the memory usage has now increased by more than one byte per call, then the
65             test fails.
66              
67             In order to give the code a chance to load initial resources it needs, it will
68             be run a few times first (by default 10); giving it a chance to load files,
69             AUTOLOADs, caches, or any other information that it requires. Any extra memory
70             usage here will not count against it.
71              
72             This simple method is not a guaranteed indicator of the absence of memory
73             resource bugs from a piece of code; it has the possibility to fail in both a
74             false-negative and a false-positive way.
75              
76             =over 4
77              
78             =item False Negative
79              
80             It is possible that a piece of code causes memory usage growth that this
81             module does not detect. Because it only detects memory growth of at least one
82             byte per call, it cannot detect cases of linear memory growth at lower rates
83             than this. Most memory usage growth comes either from Perl-level or C-level
84             bugs where memory objects are created at every call and not reclaimed again.
85             (These are either genuine memory leaks, or needless allocations of objects
86             that are stored somewhere and never reclaimed). It is unlikely such a bug
87             would result in a growth rate smaller than one byte per call.
88              
89             A second failure case comes from the fact that memory usage is taken from the
90             Operating System's measure of the process's Virtual Memory size, so as to be
91             able to detect memory usage growth in C libraries or XS-level wrapping code,
92             as well as Perl functions. Because Perl does not aggressively return unused
93             memory to the Operating System, it is possible that a piece of code could use
94             un-allocated but un-reclaimed memory to grow into; resulting in an increase in
95             its requirements despite not requesting extra memory from the Operating
96             System.
97              
98             =item False Positive
99              
100             It is possible that the test will claim that a function grows in memory, when
101             the behaviour is in fact perfectly normal for the code in question. For
102             example, the code could simply be some function whose behaviour is required to
103             store extra state; for example, adding a new item into a list. In this case it
104             is in fact expected that the memory usage of the process will increase.
105              
106             =back
107              
108             By careful use of this test module, false indications can be minimised. By
109             splitting tests across many test scripts, each one can be started in a new
110             process state, where most of the memory assigned from the Operating System is
111             in use by Perl, so anything extra that the code requires will have to request
112             more. This should reduce the false negative indications.
113              
114             By keeping in mind that the module simply measures the change in allocated
115             memory size, false positives can be minimised, by not attempting to assert
116             that certain pieces of code do not grow in memory, when in fact it would be
117             expected that they do.
118              
119             =head2 Devel::Gladiator Integration
120              
121             I
122              
123             If L is installed, this test module will use it as a second
124             potential source of detecting memory growth. A walk of the Perl memory heap is
125             taken before running the code, in order to count the number of every kind of
126             object present. This is then compared to a second count taken afterwards. Any
127             object types that have increased by at least one per call are reported.
128              
129             For example, the output might contain the following extra lines of diagnostic
130             output:
131              
132             =for highlighter
133              
134             # Growths in arena object counts:
135             # ARRAY 1735 -> 11735 (1.00 per call)
136             # HASH 459 -> 10459 (1.00 per call)
137             # REF 1387 -> 21387 (2.00 per call)
138             # REF-ARRAY 163 -> 10163 (1.00 per call)
139             # REF-HASH 66 -> 10066 (1.00 per call)
140             # WithContainerSlots 10 -> 10010 (1.00 per call)
141              
142             =head2 Devel::MAT Integration
143              
144             If L is installed, this test module will use it to dump the state
145             of the memory after a failure. It will create a F<.pmat> file named the same
146             as the unit test, but with the trailing F<.t> suffix replaced with
147             F<-TEST.pmat> where C is the number of the test that failed (in case
148             there was more than one). It will then run the code under test one more time,
149             before writing another file whose name is suffixed with F<-TEST-after.pmat>.
150             This pair of files may be useful for differential analysis.
151              
152             =cut
153              
154             =head1 FUNCTIONS
155              
156             =for highlighter language=perl
157              
158             =cut
159              
160             sub get_heapcounts
161             {
162 12     12 0 23 return {} unless HAVE_DEVEL_GLADIATOR;
163              
164 12         56 my $counts = Devel::Gladiator::arena_ref_counts();
165              
166 12         3255595 return $counts;
167             }
168              
169             sub get_memusage_linux
170             {
171             # TODO: This implementation sucks piggie. Write a proper one
172 12 50   12 0 1019 open( my $statush, "<", "/proc/self/status" ) or die "Cannot open status - $!";
173              
174 12   100     1593 m/^VmSize:\s+([0-9]+) kB/ and return $1 for <$statush>;
175             }
176              
177             sub get_memusage_freebsd
178             {
179 0 0   0 0 0 open( my $procstath, "-|", "procstat -v $$" ) or die "Cannot open procstat - $!";
180             # Sample command output (some parts of it at least):
181             # PID START END PRT RES PRES REF SHD FLAG TP PATH
182             # 18607 0x200000 0x3e5000 r-- 353 729 8 4 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
183             # 18607 0x3e5000 0x55c000 r-x 375 729 8 4 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
184             # 18607 0x55c000 0x55d000 r-- 1 0 1 0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
185             # 18607 0x55d000 0x55f000 rw- 2 0 1 0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
186             # 18607 0x55f000 0x567000 rw- 8 8 1 0 C---- sw
187             # 18607 0x801148000 0x821128000 --- 0 0 0 0 ----- gd
188             # 18607 0x821128000 0x821148000 rw- 8 8 1 0 C--D- sw
189             # 18607 0x821a0d000 0x821a0e000 r-x 1 1 115 0 ----- ph
190             # 18607 0x82293f000 0x82294c000 r-- 13 32 34 14 CN--- vn /lib/libthr.so.3
191             # 18607 0x82294c000 0x82295e000 r-x 18 32 34 14 CN--- vn /lib/libthr.so.3
192             # 18607 0x82794b000 0x82794f000 r-x 4 5 4 2 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/lib/5.40.0/amd64-freebsd/auto/mro/mro.so
193             # 18607 0x82794f000 0x827950000 r-- 1 0 1 0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/lib/5.40.0/amd64-freebsd/auto/mro/mro.so
194             # 18607 0x827950000 0x827951000 rw- 1 0 1 0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/lib/5.40.0/amd64-freebsd/auto/mro/mro.so
195             # 18607 0x828599000 0x82859a000 r-- 1 2 4 2 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/lib/site_perl/5.40.0/amd64-freebsd/auto/Devel/Gladiator/Gladiator.so
196             # 18607 0x82859a000 0x82859c000 r-x 2 2 4 2 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/lib/site_perl/5.40.0/amd64-freebsd/auto/Devel/Gladiator/Gladiator.so
197              
198 0         0 my $head = <$procstath>;
199 0         0 my @columns = split /\s+/, $head;
200 0         0 my ($res_column) = grep { $columns[$_] eq 'RES' } 0 .. $#columns;
  0         0  
201 0         0 my ($pres_column) = grep { $columns[$_] eq 'PRES' } 0 .. $#columns;
  0         0  
202              
203 0         0 my $pages = 0;
204 0         0 while (my $line = <$procstath>) {
205 0         0 @columns = split /\s+/, $line;
206 0         0 $pages += $columns[$res_column] + $columns[$pres_column];
207             }
208              
209             # NOTE: FreeBSD wiki states that page size is 4KB *on most platforms*.
210             # I don't know how to query the system for its page size
211 0 0       0 return 4 * $pages if $pages > 0;
212             }
213              
214             BEGIN {
215 3 50   3   90 my $get_memusage = __PACKAGE__->can( "get_memusage_$^O" )
216             or die "Unable to find an implementation of get_memusage for OS=$^O";
217              
218 3         2171 *get_memusage = $get_memusage;
219             }
220              
221             =head2 no_growth
222              
223             no_growth { CODE } %opts, $name;
224              
225             Assert that the code block does not consume extra memory.
226              
227             Takes the following named arguments:
228              
229             =over 8
230              
231             =item calls => INT
232              
233             The number of times to call the code during growth testing.
234              
235             =item burn_in => INT
236              
237             The number of times to call the code initially, before watching for memory
238             usage.
239              
240             =back
241              
242             =cut
243              
244             sub no_growth(&@)
245             {
246 4     4 1 575175 my $code = shift;
247 4 100       10 my $name; $name = pop if @_ % 2;
  4         24  
248 4         12 my %args = @_;
249              
250 4         51 my $tb = __PACKAGE__->builder;
251              
252 4   100     85 my $burn_in = $args{burn_in} || 10;
253 4   100     19 my $calls = $args{calls} || 10_000;
254              
255 4         10 my $i = 0;
256 4         24 $code->() while $i++ < $burn_in;
257              
258             # Fetch usage twice; first to allocate memory for it to run in so the
259             # second can account for it.
260 4         1335 my $before_usage = get_memusage;
261 4         32 my $before_counts = get_heapcounts;
262              
263             # Fetch a second copy before code, to preallocate memory for it now
264 4         21 my $after_counts = get_heapcounts;
265 4         75 $before_counts = $after_counts;
266              
267 4         17 my $after_usage = get_memusage;
268 4         26 $before_usage = $after_usage;
269              
270 4         25 $i = 0;
271 4         33 $code->() while $i++ < $calls;
272              
273 4         51077 undef $after_usage;
274 4         26 undef $after_counts;
275              
276 4         19 $after_usage = get_memusage;
277 4         77 $after_counts = get_heapcounts;
278              
279             # Collect up various test results
280 4         15 my $ok = 1;
281              
282 4         35 my $increase = ( $after_usage - $before_usage ) * 1024; # in bytes
283             # Even if we increased in memory usage, it's OK as long as we didn't gain
284             # more than one byte per call
285 4 50       22 $ok = 0 if $increase >= $calls;
286              
287 4         10 my $growth_counts;
288 4         114 foreach my $type ( keys %$after_counts ) {
289 208         412 my $growth = $after_counts->{$type} - $before_counts->{$type};
290 208 100       451 next unless $growth >= $calls;
291              
292             $growth_counts->{$type} = sprintf "%d -> %d (%.2f per call)",
293 1         20 $before_counts->{$type}, $after_counts->{$type}, $growth / $calls;
294             }
295 4 100       37 $ok = 0 if $growth_counts;
296              
297 4         41 $tb->ok( $ok, $name );
298 4 100       3296 return $ok if $ok;
299              
300 1 50       5 if( $increase >= $calls ) {
301 0         0 $tb->diag( sprintf "Lost %d bytes of memory over %d calls, average of %.2f per call",
302             $increase, $calls, $increase / $calls );
303             }
304              
305 1 50       5 if( $growth_counts ) {
306             $tb->diag( "Growths in arena object counts:\n" .
307 1         6 join( "\n", map { " $_ $growth_counts->{$_}" } sort keys %$growth_counts ) );
  1         10  
308             }
309              
310 1         601 if( HAVE_DEVEL_MAT_DUMPER ) {
311 1         3 my $file = $0;
312 1         6 my $num = $tb->current_test;
313              
314             # Trim the .t off first then append -$num.pmat, in case $0 wasn't a .t file
315 1         244 $file =~ s/\.(?:t|pm|pl)$//;
316              
317 1         5 my $beforefile = "$file-$num.pmat";
318 1         2 my $afterfile = "$file-$num-after.pmat";
319              
320             # Try to arrange the memory in as similar as state as possible by running
321             # one more iteration now before we take the "before" snapshot
322 1         6 $code->();
323              
324 1         10 $tb->diag( "Writing heap dump to $beforefile" );
325 1         145675 Devel::MAT::Dumper::dump( $beforefile );
326              
327 1         12 $code->();
328              
329 1         14 $tb->diag( "Writing heap dump after one more iteration to $afterfile" );
330 1         143043 Devel::MAT::Dumper::dump( $afterfile );
331             }
332              
333 1         37 return $ok;
334             }
335              
336             =head1 TODO
337              
338             =over 8
339              
340             =item * More OS portability
341              
342             Currently, this module uses OS-specific methods of determining process memory
343             usage (namely, by inspecting F on Linux or calling
344             F on FreeBSD). It would be nice to support more OSes, and
345             potentially find a better abstraction for doing so.
346              
347             Currently I am unaware of a simple portable mechanism to query this. Patches
348             very much welcome. :)
349              
350             =back
351              
352             =head1 AUTHOR
353              
354             Paul Evans
355              
356             =cut
357              
358             0x55AA;