【続】やはり Perl はメモリ喰いな言語。データ型の内部構造

以前、「やはり Perl はメモリ喰いな言語。データ型の内部構造」という記事を書いたことがあるのですが、自分で書いておきながらしばらく立つと完全忘却してました。時代は変わって、今仕事で運用しているサーバは、64bit 版のOSです。

最近になって、DB のテーブルのデータを加工・集計しながら CSV にダンプするってプログラムが、データ数が非常に多いときに、1.5 GByte ほどメモリを食いつぶしているってことに気がつきました。理由は至って簡単なのですが、結構ハマリどころなので備忘録として記事にしておくことにしました。

みなさん、仕事とかでは特にそうだと思うのですが、DBI の処理って何らかのラッパーを書いて使っていると思います。僕は適当に書くとよくやってしまいがちなのですが、イメージ的には、こんな処理の流れのコードを書いていました。
(・・・えっ?そんなへぼコード書いてない??・・・すんません・・・orz)

sub dumper {
    my $dbh = shift;
    my $table = shift;
    my $sth = $dbh->prepare(qq{select * from $table}) or die;
    $sth->execute or die;
    my $data = [];
    while( my @rows = $sth->fetchrow_array) {  push @$data, [@rows];  }
    return $data;
}

DB上の全レコードをいったん perl 側の配列に格納して、その結果を返す。ってコードなのですが、当然ながらレコード数が多くなればメモリを食うのは当たり前なのですが、以前の記事の内容を完全に忘却してました。ここには落とし穴があるのです。

- スポンサーリンク -

さて、以前の復習として perl がどれだけメモリを食っているのか見てみましょう。

use Devel::Size::Report qw/report_size/;
# 1bit, 1byte, 2byte, 3byte, 4byte(=32bit), 5byte
my $b = [ 0, 0xFF, 0xFFFF, 0xFFFFFF, 0xFFFFFFFF, 0xFFFFFFFFFF ];
print report_size($b, { indent => " " });

# null, length=1, 2, 3, 4, 5
my $c = [ '', '1', '10', '100', '1000', '10000' ];
print report_size($c, { indent => " " });

これを、32 bit OS と 64 bit OS で実行した場合はこうなります。32bit と 64 bit のデータサイズの違いはこちらの記事を参考にして下さい。

v5.8.6 built for i686-linux-thread-multi v5.8.6 built for x86_64-linux-thread-multi
Size report v0.10 for 'ARRAY(0x83aac28)':
Array ref 192 bytes (overhead: 92 bytes, 47.92%)
Scalar 16 bytes
Scalar 16 bytes
Scalar 16 bytes
Scalar 16 bytes
Scalar 16 bytes
Scalar 20 bytes
Total: 192 bytes in 7 elements
Size report v0.10 for 'ARRAY(0x847a55c)':
Array ref 257 bytes (overhead: 92 bytes, 35.80%)
Scalar 25 bytes
Scalar 26 bytes
Scalar 27 bytes
Scalar 28 bytes
Scalar 29 bytes
Scalar 30 bytes
Total: 257 bytes in 7 elements
Size report v0.10 for 'ARRAY(0x505290)':
Array ref 304 bytes (overhead: 160 bytes, 52.63%)
Scalar 24 bytes
Scalar 24 bytes
Scalar 24 bytes
Scalar 24 bytes
Scalar 24 bytes
Scalar 24 bytes
Total: 304 bytes in 7 elements
Size report v0.10 for 'ARRAY(0x64b240)':
Array ref 421 bytes (overhead: 160 bytes, 38.00%)
Scalar 41 bytes
Scalar 42 bytes
Scalar 43 bytes
Scalar 44 bytes
Scalar 45 bytes
Scalar 46 bytes
Total: 421 bytes in 7 elements

ここで注目するところは、64 bit OS の場合は、DB の NULL 値を空文字で表現した場合、41 byte 消費するという点です(perl 上の undef ならば 16 byte だけど・・・)。たとえば、10カラムのテーブル×100万レコードだとすると、32 bit OS の場合は、最低 250 Mbyte ですむところが、64 bit OS の場合は、最低 410 Mbyte 必要になるのです。

半年前までは 32 bit OS で動作していたので気がつかなかったのですが、64 bit OS にしてからどうもメモリの使用量が怪しいなぁ〜とは思うことがあった程度ですが、先日 OS のレスポンスが急激に悪くなって調べてみたところ、発覚したわけです。

取りあえず、こんな感じのテストを実行するとします。イメージ的には、10カラムのテーブルが10万レコードです。データは文字列の1が入っている仮想データです。

use Devel::Size qw/size total_size/;
my @data;
foreach(1..1000000) {
    my @item;
    foreach(1..10) { push @item, '1'; }
    push @data, [@item];
}
print total_size(\@data);

結果は、62,248,648 byte のメモリを消費します。100万レコードなら 600 Mbyte ・・・うちのお仕事の内容的には、データ集計とかするために、一度配列に入れておきたいところなんですが、これは良い策ではありませんね。SQL レベルでクロス集計とかデータ加工とかやっちゃった方が良さそうです。


さて、話は変わって、DBIC とか CDBI とかどういう実装になってるんだろうと思って、調べたので以下書き殴ります。

DBIC は速度と効率をすごく気にした実装になっているだけあって、ちゃんとやってました。
DBIx::Class::Storage::DBI::Cursor の next メソッドのコードですが、

  my @row = $self->{sth}->fetchrow_array;
  if (@row) {
    $self->{pos}++;
  } else {
    delete $self->{sth};
    $self->{done} = 1;
  }
  return @row;

ってな具合になっていて、 next を呼ぶ度に、fetchrow_array で DB からデータを受け取って値を返す実装になってます。うん、賢い。

一方、CDBI の実装はどうなっているかというと、Class::DBI::__::Base の sth_to_objects メソッドが該当する部分で、

eval {
    $sth->execute(@$args) unless $sth->{Active};
    $sth->bind_columns(\(@data{ @{ $sth->{NAME_lc} } }));
    push @rows, {%data} while $sth->fetch;
};

な感じになってます。つまり、僕が適当にかいたロジックと同じで、いったん perl 側に全てのデータを持ってくる実装になっています。これが、よく言われているデータ量が多いと破綻するってことなんですね。

いやぁ〜老人力が UP してきているので、自分が書いた一年前の記事の内容すら忘れてしまっていました・・・orz

またまた話は変わって、メモリの削減のために、Tie::Array::Packed っていうモジュールも試してみたのですが、なんかうまく動かないです。ってか、そもそもコンパイル通らないし。このモジュール。

SV *tpa_get_ushort_le(pTHX_ ushort_le *ptr) {
    return newSVuv((ptr[1] << 8) + ptr[0] );
}

って部分のコード間違ってるよ。

    return newSVuv((ptr->c[1] << 8) + ptr->c[0] );

だって。

- スポンサーリンク -