Mail::Box significant reducation in memory usage

Andy Maas <[email protected]> Mon, 20 Sep 2004 15:32:47 -0700
Newsgroups gmane.comp.lang.perl.modules.mail-box
Message-ID <[email protected]>
Hi Mark,

I have some modification to Mail::Box files to help reduce memory usage 
significantly without sacrificing performance (speed). In fact with 
reduced footprint, processing time is reduced significantly on large msgs.

*Background*:

In our environment we process lots of messages. We receive msgs and 
parse them into Message objects and at sometimes serialize them to be 
stored in DB. Performance at times are impacted severely when 
significant size msg come to the system and increased memory footprint 
caused the system to swap a lot.

Because of this I spent time to see if any memory optimization can be 
done. In the end I managed to chop more than half of memory usage just 
by avoiding data copying which many times happen unexpectedly.

For my test case, processing a 27MB msg would originally take as much as 
268 MB in memory (on Message object related processing):

    * 188MB parsing into Message object ($message->readFromParser)
    * 80 MB serializing Message body (FastScalar::Print)

After improvement the same msg size now takes 77 MB

    * 45MB parsing into Message object
    * 32 MB serializing Message body

This was all tested in Perl 5.8.5 environment and Mail::Box 2.056.

FYI, I had Todd Richmond, the original author of FastScalar.pm to look 
at the changes. I also performed as much manual unit testing as I could 
on FastScalar.

I hope you find this mod to be useful and are able to incorporate it as 
you see fit.

Thank you.

Andy Maas

*Summary:*

Changes are in two areas

   1. Avoid twice copying @lines array returned by $file->getlines (in
      Mail::Box::Parser::Perl::_read_stripped_lines line 198) all the
      way up to Mail::Message::Body::Lines::read where it is stored in
      $self->{MMBL_array}- saved 91MB in message parsing
   2. Avoid data copying inside FastScalar.pm - saved 52MB on parsing
      and another 48MB on print (serializing body). Changes are:

          o changed from {data} model to {pos} model (using position
            instead of data buffer to keep current position) - 27MB saving
          o getlines(): avoid unintended data copy - 27MB saving
                + my $data = $self->{data};
          o print(): avoid unnecessary data copying (one on join and one
            for $buf) - 48MB
                + *before*: my $buf = $#_ ? join('', @_) : $_[0]; $$ref
                  .= $buf;
                + *after*: $$ref .= $_ foreach @_;
          o and a couple of bug fix:
                + seek() to handle $whence=2 case correctly
                + write() to return length written for ($len=0) case

Change #1 is minimal so I include it below.
Change #2 is significant and included as attachment of the complete file 
(FastScalar.pm)

*Change #1 Detail*

A. Mail::Message::Body::Lines::read

    Now expect bodyAsList to return array of three things (begin, end, 
lines reference). This change in API semantic is necessary and seems to 
be confined to this one place.

    sub read($$;$@)
    {   my ($self, $parser, $head, $bodytype) = splice @_, 0, 4;
        my @lines = $parser->bodyAsList(@_);
        return undef unless @lines;

        $self->fileLocation(shift @lines, shift @lines);
    /#    $self->{MMBL_array} = \@lines; this is before
    /    *$self->{MMBL_array} = shift @lines; # this is now*
        $self;
    }

B. Mail::Box::Parser::Perl::bodyAsList

    sub bodyAsList(;$$)
    {   my ($self, $exp_chars, $exp_lines) = @_;
        my $file  = $self->{MBPP_file};
        my $begin = $file->tell;

        my ($end, $lines) = $self->_read_stripped_lines($exp_chars,
    $exp_lines);
    /#    ($begin, $end, @$lines); before
    /*    ($begin, $end, $lines); # now
    *}

C. Mail::Box::Parser::Perl::_read_stripped_lines

    Change @lines to $lines

    sub _read_stripped_lines(;$$)
    {   my ($self, $exp_chars, $exp_lines) = @_;
        $exp_lines  = -1 unless defined $exp_lines;
        my @seps    = @{$self->{MBPP_separators}};

        my $file    = $self->{MBPP_file};
        my *$lines*   = *[]*;

        if(@seps && $self->{MBPP_trusted})
        {   my $sep  = $seps[0];
            my $l    = length $sep;

            while(1)
            {   my $where = $file->tell;
                my $line  = $file->getline or last;

                if(   substr($line, 0, $l) eq $sep
                   && ($sep ne 'From ' || $line =~ m/
    (?:19[789]\d|20[01]\d)/)
                   )
                {   $file->seek($where, 0);
                    last;
                }

                push *@$lines*, $line;
            }
        }
        elsif(@seps)
        {

      LINE: while(1)
            {   my $where = $file->getpos;
                my $line  = $file->getline or last;

                foreach my $sep (@seps)
                {   next if substr($line, 0, length $sep) ne $sep;
                    next if $sep eq 'From ' && $line !~ m/
    (?:19[789]\d|20[01]\d)/;

                    $file->setpos($where);
                    last LINE;
                }

                $line =~ s/\015$//;
                push *@$lines*, $line;
            }
        }
        else
        {   # File without separators.
            *$lines* = $file->getlines;
        }

        my $end = $file->tell;
        if($exp_lines > 0 )
        {    while(*@$lines* > $exp_lines && *$lines->[-1]* =~ $empty)
             {   $end -= length *$lines->[-1]*;
                 pop *@$lines*;
             }
        }
        elsif(@seps && *@$lines* && *$lines->[-1]* =~ $empty)
        {   # blank line should be in place before a separator.  Only that
            # line is removed.
            $end -= length *$lines->[-1]*;
            pop *@$lines*;
        }

        map { s/^\>(\>*From\s)/$1/ } *@$lines*
            if $self->{MBPP_strip_gt};

        $end, *$lines*;
    }
FastScalar.pm (text/plain, 3.7 KB)
package Mail::Box::FastScalar;

use strict;
use warnings;
use integer;

sub new($) {
    my ($class, $ref) = @_;
    $$ref = '' unless defined($$ref);
    my $self = { ref => $ref, pos => 0 };

    bless $self, $class;
    return $self;
}

sub autoflush() {}

sub binmode() {}

sub clearerr { return 0; }

sub flush() {}

sub sync() { return 0; }

sub opened() { return $_[0]->{ref}; }

sub open($) {
    my $self = $_[0];

    ${$_[1]} = '' unless defined(${$_[1]});
    $self->{ref} = $_[1];
    $self->{pos} = 0;
}

sub close() {
    undef $_[0]->{ref};
}

sub eof() {
    my $self = $_[0];

    return $self->{pos} >= length(${$self->{ref}});
}

sub getc() {
    my $self = $_[0];

    return substr(${$self->{ref}}, $self->{pos}++, 1);
}

sub print {
    my $self = shift;
    my $pos = $self->{pos};
    my $ref = $self->{ref};
    my $len = length($$ref);
    
    if ($pos >= $len) {
	$$ref .= $_ foreach @_;
	$self->{pos} = length($$ref);
    } else {
	my $buf = $#_ ? join('', @_) : $_[0];
	
	$len = length($buf);
	substr($$ref, $pos, $len) = $buf;
	$self->{pos} = $pos + $len;
    }
    1;
}

sub read($$;$) {
    my $self = $_[0];
    my $buf = substr(${$self->{ref}}, $self->{pos}, $_[2]);
    $self->{pos} += $_[2];

    ($_[3] ? substr($_[1], $_[3]) : $_[1]) = $buf;
    return length($buf);
}

sub sysread($$;$) {
    return shift()->read(@_);
}

sub seek($$) {
    my $self = $_[0];
    my $whence = $_[2];
    my $len = length(${$self->{ref}});

    if ($whence == 0) {
	$self->{pos} = $_[1];
    } elsif ($whence == 1) {
	$self->{pos} += $_[1];
    } elsif ($whence == 2) {
	$self->{pos} = $len + $_[1];
    } else {
	return;
    }
    if ($self->{pos} > $len) {
	$self->{pos} = $len;
    } elsif ($self->{pos} < 0) {
	$self->{pos} = 0;
    }
    return 1;
}

sub sysseek($$) {
    return $_[0]->seek($_[1], $_[2]);
}

sub setpos($) {
    return $_[0]->seek($_[1], 0);
}

sub sref() {
    return $_[0]->{ref};
}

sub getpos() {
    return $_[0]->{pos};
}

sub tell() {
    return $_[0]->{pos};
}

sub write($$;$) {
    my $self = $_[0];
    my $pos = $self->{pos};
    my $ref = $self->{ref};
    my $len = length($$ref);

    if ($pos >= $len) {
	$$ref .= substr($_[1], $_[3] || 0, $_[2]);
	$self->{pos} = length($$ref);
	$len = $self->{pos} -  $len;
    } else {
	my $buf = substr($_[1], $_[3] || 0, $_[2]);
	
	$len = length($buf);
	substr($$ref, $pos, $len) = $buf;
	$self->{pos} = $pos + $len;
    }
    return $len;
}

sub syswrite($;$$) {
    return shift()->write(@_);
}

sub getline() {
    my $self = $_[0];
    my $ref = $self->{ref};
    my $pos = $self->{pos};

    if (!defined($/) || (my $idx = index($$ref, $/, $pos)) == -1) {
	return if ($pos >= length($$ref));
	$self->{pos} = length($$ref);
	return substr($$ref, $pos);
    } else {
	return substr($$ref, $pos, ($self->{pos} = $idx + length($/)) - $pos);
    }
}

sub getlines() {
    my $self = $_[0];
    my @lines;
    my $ref = $self->{ref};
    my $pos = $self->{pos};

    if (defined($/)) {
	my $idx;
	
	while (($idx = index($$ref, $/, $pos)) != -1) {
	    push(@lines, substr($$ref, $pos, ($idx + 1) - $pos));
	    $pos = $idx + 1;
	}
    }
    my $r = substr($$ref, $pos);
    if (length($r) > 0) {
	push(@lines, $r);
    }
    $self->{pos} = length($$ref);
    return wantarray() ? @lines : \@lines;
}

sub TIEHANDLE {
    ((defined($_[1]) && UNIVERSAL::isa($_[1], "Mail::Box::FastScalar"))
         ? $_[1] : shift->new(@_));
}

sub GETC { shift()->getc(@_) }
sub PRINT { shift()->print(@_) }
sub PRINTF { shift()->print(sprintf(shift, @_)) }
sub READ { shift()->read(@_) }
sub READLINE { wantarray ? shift()->getlines(@_) : shift()->getline(@_) }
sub WRITE { shift()->write(@_); }
sub CLOSE { shift()->close(@_); }
sub SEEK { shift()->seek(@_); }
sub TELL { shift()->tell(@_); }
sub EOF { shift()->eof(@_); }

1;