File: MemoryGrowth.pm

package info (click to toggle)
libtest-memorygrowth-perl 0.05-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 124 kB
  • sloc: perl: 152; makefile: 2
file content (358 lines) | stat: -rw-r--r-- 12,754 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
#  You may distribute under the terms of either the GNU General Public License
#  or the Artistic License (the same terms as Perl itself)
#
#  (C) Paul Evans, 2010-2024 -- leonerd@leonerd.org.uk

package Test::MemoryGrowth 0.05;

use v5.14;
use warnings;
use base qw( Test::Builder::Module );

our @EXPORT = qw(
   no_growth
);

use constant HAVE_DEVEL_GLADIATOR => defined eval { require Devel::Gladiator };

use constant HAVE_DEVEL_MAT_DUMPER => defined eval { require Devel::MAT::Dumper };

=head1 NAME

C<Test::MemoryGrowth> - assert that code does not cause growth in memory usage

=head1 SYNOPSIS

=for highlighter language=perl

   use Test::More;
   use Test::MemoryGrowth;

   use Some::Class;

   no_growth {
      my $obj = Some::Class->new;
   } 'Constructing Some::Class does not grow memory';

   my $obj = Some::Class->new;
   no_growth {
      $obj->do_thing;
   } 'Some::Class->do_thing does not grow memory';


   #### This test will fail ####
   my @list;
   no_growth {
      push @list, "Hello world";
   } 'pushing to an array does not grow memory';

   done_testing;

=head1 DESCRIPTION

This module provides a function to check that a given block of code does not
result in the process consuming extra memory once it has finished. Despite the
name of this module it does not, in the strictest sense of the word, test for a
memory leak: that term is specifically applied to cases where memory has been
allocated but all record of it has been lost, so it cannot possibly be
reclaimed. While the method employed by this module can detect such bugs, it
can also detect cases where memory is still referenced and reachable, but the
usage has grown more than would be expected or necessary.

The block of code will be run a large number of times (by default 10,000), and
the difference in memory usage by the process before and after is compared. If
the memory usage has now increased by more than one byte per call, then the
test fails.

In order to give the code a chance to load initial resources it needs, it will
be run a few times first (by default 10); giving it a chance to load files,
AUTOLOADs, caches, or any other information that it requires. Any extra memory
usage here will not count against it.

This simple method is not a guaranteed indicator of the absence of memory
resource bugs from a piece of code; it has the possibility to fail in both a
false-negative and a false-positive way.

=over 4

=item False Negative

It is possible that a piece of code causes memory usage growth that this
module does not detect. Because it only detects memory growth of at least one
byte per call, it cannot detect cases of linear memory growth at lower rates
than this. Most memory usage growth comes either from Perl-level or C-level
bugs where memory objects are created at every call and not reclaimed again.
(These are either genuine memory leaks, or needless allocations of objects
that are stored somewhere and never reclaimed). It is unlikely such a bug
would result in a growth rate smaller than one byte per call.

A second failure case comes from the fact that memory usage is taken from the
Operating System's measure of the process's Virtual Memory size, so as to be
able to detect memory usage growth in C libraries or XS-level wrapping code,
as well as Perl functions. Because Perl does not aggressively return unused
memory to the Operating System, it is possible that a piece of code could use
un-allocated but un-reclaimed memory to grow into; resulting in an increase in
its requirements despite not requesting extra memory from the Operating
System.

=item False Positive

It is possible that the test will claim that a function grows in memory, when
the behaviour is in fact perfectly normal for the code in question. For
example, the code could simply be some function whose behaviour is required to
store extra state; for example, adding a new item into a list. In this case it
is in fact expected that the memory usage of the process will increase.

=back

By careful use of this test module, false indications can be minimised. By
splitting tests across many test scripts, each one can be started in a new
process state, where most of the memory assigned from the Operating System is
in use by Perl, so anything extra that the code requires will have to request
more. This should reduce the false negative indications.

By keeping in mind that the module simply measures the change in allocated
memory size, false positives can be minimised, by not attempting to assert
that certain pieces of code do not grow in memory, when in fact it would be
expected that they do.

=head2 Devel::Gladiator Integration

I<Since version 0.04.>

If L<Devel::Gladiator> is installed, this test module will use it as a second
potential source of detecting memory growth. A walk of the Perl memory heap is
taken before running the code, in order to count the number of every kind of
object present. This is then compared to a second count taken afterwards. Any
object types that have increased by at least one per call are reported.

For example, the output might contain the following extra lines of diagnostic
output:

=for highlighter

   # Growths in arena object counts:
   #   ARRAY 1735 -> 11735 (1.00 per call)
   #   HASH 459 -> 10459 (1.00 per call)
   #   REF 1387 -> 21387 (2.00 per call)
   #   REF-ARRAY 163 -> 10163 (1.00 per call)
   #   REF-HASH 66 -> 10066 (1.00 per call)
   #   WithContainerSlots 10 -> 10010 (1.00 per call)

=head2 Devel::MAT Integration

If L<Devel::MAT> is installed, this test module will use it to dump the state
of the memory after a failure. It will create a F<.pmat> file named the same
as the unit test, but with the trailing F<.t> suffix replaced with
F<-TEST.pmat> where C<TEST> is the number of the test that failed (in case
there was more than one). It will then run the code under test one more time,
before writing another file whose name is suffixed with F<-TEST-after.pmat>.
This pair of files may be useful for differential analysis.

=cut

=head1 FUNCTIONS

=for highlighter language=perl

=cut

sub get_heapcounts
{
   return {} unless HAVE_DEVEL_GLADIATOR;

   my $counts = Devel::Gladiator::arena_ref_counts();

   return $counts;
}

sub get_memusage_linux
{
   # TODO: This implementation sucks piggie. Write a proper one
   open( my $statush, "<", "/proc/self/status" ) or die "Cannot open status - $!";

   m/^VmSize:\s+([0-9]+) kB/ and return $1 for <$statush>;
}

sub get_memusage_freebsd
{
   open( my $procstath, "-|", "procstat -v $$" ) or die "Cannot open procstat - $!";
   # Sample command output (some parts of it at least):
   # PID              START                END PRT  RES PRES REF SHD FLAG  TP PATH
   # 18607           0x200000           0x3e5000 r--  353  729   8   4 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
   # 18607           0x3e5000           0x55c000 r-x  375  729   8   4 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
   # 18607           0x55c000           0x55d000 r--    1    0   1   0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
   # 18607           0x55d000           0x55f000 rw-    2    0   1   0 CN--- vn /root/perl5/perlbrew/perls/perl-5.40.0/bin/perl
   # 18607           0x55f000           0x567000 rw-    8    8   1   0 C---- sw
   # 18607        0x801148000        0x821128000 ---    0    0   0   0 ----- gd
   # 18607        0x821128000        0x821148000 rw-    8    8   1   0 C--D- sw
   # 18607        0x821a0d000        0x821a0e000 r-x    1    1 115   0 ----- ph
   # 18607        0x82293f000        0x82294c000 r--   13   32  34  14 CN--- vn /lib/libthr.so.3
   # 18607        0x82294c000        0x82295e000 r-x   18   32  34  14 CN--- vn /lib/libthr.so.3
   # 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
   # 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
   # 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
   # 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
   # 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

   my $head = <$procstath>;
   my @columns = split /\s+/, $head;
   my ($res_column) = grep { $columns[$_] eq 'RES' } 0 .. $#columns;
   my ($pres_column) = grep { $columns[$_] eq 'PRES' } 0 .. $#columns;

   my $pages = 0;
   while (my $line = <$procstath>) {
      @columns = split /\s+/, $line;
      $pages += $columns[$res_column] + $columns[$pres_column];
   }

   # NOTE: FreeBSD wiki states that page size is 4KB *on most platforms*.
   # I don't know how to query the system for its page size
   return 4 * $pages if $pages > 0;
}

BEGIN {
   my $get_memusage = __PACKAGE__->can( "get_memusage_$^O" )
      or die "Unable to find an implementation of get_memusage for OS=$^O";

   *get_memusage = $get_memusage;
}

=head2 no_growth

   no_growth { CODE } %opts, $name;

Assert that the code block does not consume extra memory.

Takes the following named arguments:

=over 8

=item calls => INT

The number of times to call the code during growth testing.

=item burn_in => INT

The number of times to call the code initially, before watching for memory
usage.

=back

=cut

sub no_growth(&@)
{
   my $code = shift;
   my $name; $name = pop if @_ % 2;
   my %args = @_;

   my $tb = __PACKAGE__->builder;

   my $burn_in = $args{burn_in} || 10;
   my $calls   = $args{calls}   || 10_000;

   my $i = 0;
   $code->() while $i++ < $burn_in;

   # Fetch usage twice; first to allocate memory for it to run in so the
   #   second can account for it.
   my $before_usage = get_memusage;
   my $before_counts = get_heapcounts;

   # Fetch a second copy before code, to preallocate memory for it now
   my $after_counts = get_heapcounts;
   $before_counts = $after_counts;

   my $after_usage = get_memusage;
   $before_usage = $after_usage;

   $i = 0;
   $code->() while $i++ < $calls;

   undef $after_usage;
   undef $after_counts;

   $after_usage = get_memusage;
   $after_counts = get_heapcounts;

   # Collect up various test results
   my $ok = 1;

   my $increase = ( $after_usage - $before_usage ) * 1024; # in bytes
   # Even if we increased in memory usage, it's OK as long as we didn't gain
   # more than one byte per call
   $ok = 0 if $increase >= $calls;

   my $growth_counts;
   foreach my $type ( keys %$after_counts ) {
      my $growth = $after_counts->{$type} - $before_counts->{$type};
      next unless $growth >= $calls;

      $growth_counts->{$type} = sprintf "%d -> %d (%.2f per call)",
         $before_counts->{$type}, $after_counts->{$type}, $growth / $calls;
   }
   $ok = 0 if $growth_counts;

   $tb->ok( $ok, $name );
   return $ok if $ok;

   if( $increase >= $calls ) {
      $tb->diag( sprintf "Lost %d bytes of memory over %d calls, average of %.2f per call",
         $increase, $calls, $increase / $calls );
   }

   if( $growth_counts ) {
      $tb->diag( "Growths in arena object counts:\n" .
         join( "\n", map { "  $_ $growth_counts->{$_}" } sort keys %$growth_counts ) );
   }

   if( HAVE_DEVEL_MAT_DUMPER ) {
      my $file = $0;
      my $num = $tb->current_test;

      # Trim the .t off first then append -$num.pmat, in case $0 wasn't a .t file
      $file =~ s/\.(?:t|pm|pl)$//;

      my $beforefile = "$file-$num.pmat";
      my $afterfile  = "$file-$num-after.pmat";

      # Try to arrange the memory in as similar as state as possible by running
      # one more iteration now before we take the "before" snapshot
      $code->();

      $tb->diag( "Writing heap dump to $beforefile" );
      Devel::MAT::Dumper::dump( $beforefile );

      $code->();

      $tb->diag( "Writing heap dump after one more iteration to $afterfile" );
      Devel::MAT::Dumper::dump( $afterfile );
   }

   return $ok;
}

=head1 TODO

=over 8

=item * More OS portability

Currently, this module uses OS-specific methods of determining process memory
usage (namely, by inspecting F</proc/self/status> on Linux or calling
F<procstat> on FreeBSD). It would be nice to support more OSes, and
potentially find a better abstraction for doing so.

Currently I am unaware of a simple portable mechanism to query this. Patches
very much welcome. :)

=back

=head1 AUTHOR

Paul Evans <leonerd@leonerd.org.uk>

=cut

0x55AA;