Hatena::ブログ(Diary)

hnwの日記 このページをアンテナに追加 RSSフィード

[プロフィール]
 | 

2010年8月24日(火) PHPのcopy関数がファイルサイズ分のメモリを消費する件の対策 このエントリーを含むブックマーク このエントリーのブックマークコメント

補足(2010/08/24 15:00):rename関数について言えば、同一ファイルシステム上であればrenameシステムコールを利用するのでこの問題は起こりません。さらに蛇足ですが、ファイルシステムをまたがってrename関数を利用するとコピーしてから削除することになり、アトミック性を保証できないため、障害の原因にならないかどうかの検討が必要だと思います。


AKIBA de: PHPのrename()関数はファイルシステム間で使うとメモリをバカ食いする」で指摘されている通り、PHPのcopy関数ファイルシステムをまたがってrename関数を使う場合に、PHPがファイルサイズと同じ大きさのメモリを消費してしまいます。環境によっては再現しないかもしれませんが、僕の手元のMacOSX 10.5+PHP5.3.3環境では再現しました。


<?php
// 「dd if=/dev/urandom of=1gb.dat bs=1m count=1024」でファイルを作ってから実行してください。
copy("./1gb.dat","./1gb.bak");

原因と対策

この原因はPHPの実装にあります。copy関数を実現しているコード部分を以下に示します。


/* Returns SUCCESS/FAILURE and sets *len to the number of bytes moved */
PHPAPI size_t _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC TSRMLS_DC)
{
    char buf[CHUNK_SIZE];
    size_t readchunk;
    size_t haveread = 0;
    size_t didread;
    size_t dummy;
    php_stream_statbuf ssbuf;

    if (!len) {
        len = &dummy;
    }

    if (maxlen == 0) {
        *len = 0;
        return SUCCESS;
    }

    if (maxlen == PHP_STREAM_COPY_ALL) {
        maxlen = 0;
    }

    if (php_stream_stat(src, &ssbuf) == 0) {
        if (ssbuf.sb.st_size == 0
#ifdef S_ISREG
            && S_ISREG(ssbuf.sb.st_mode)
#endif
        ) {
            *len = 0;
            return SUCCESS;
        }
    }

    if (php_stream_mmap_possible(src)) {
        char *p;
        size_t mapped;

        p = php_stream_mmap_range(src, php_stream_tell(src), maxlen, PHP_STREAM_MAP_MODE_SHARED_READONLY, &mapped);

        if (p) {
            mapped = php_stream_write(dest, p, mapped);

            php_stream_mmap_unmap_ex(src, mapped);

            *len = mapped;

            /* we've got at least 1 byte to read.
             * less than 1 is an error */

            if (mapped > 0) {
                return SUCCESS;
            }
            return FAILURE;
        }
    }
(以下略)

copy関数を呼び出すと、上記コード中のphp_stream_mmap_range関数mmapシステムコールを利用してコピー元ファイル全体をメモリ上にマップし、php_stream_write関数の中でwriteシステムコールでコピー先への書き込みを行います。mmapした直後はメモリは消費しませんが、ファイル全体を読み進めるうちにファイル全体がメモリに乗ってしまうため、最終的にファイルサイズと同じだけのメモリを消費してしまいます。これは巨大ファイルを扱うには不向きなやりかたです。


そもそも、copy関数の挙動を考えるとファイルの先頭から最後までをシーケンシャルに読み込みつつ書き出して、読み終わったら即座に内容を破棄してしまうわけですから、ファイルサイズにかかわらずmmapを使うメリットはほとんどありません(後述の「mmap(2)のメリットって?」も参照ください)。


ところで、php_stream_mmap_range関数の中身を追いかけていくと、ファイルサイズが4MBを超えていたらmmapしないような処理が見つかります。


PHPAPI char *_php_stream_mmap_range(php_stream *stream, size_t offset, size_t length, php_stream_mmap_operation_t mode, size_t *mapped_len TSRMLS_DC)
{
    php_stream_mmap_range range;

    range.offset = offset;
    range.length = length;
    range.mode = mode;
    range.mapped = NULL;

    /* For now, we impose an arbitrary limit to avoid
     * runaway swapping when large files are passed thru. */
    if (length > 4 * 1024 * 1024) {
        return NULL;
    }

    if (PHP_STREAM_OPTION_RETURN_OK == php_stream_set_option(stream, PHP_STREAM_OPTION_MMAP_API, PHP_STREAM_MMAP_MAP_RANGE, &range)) {
        if (mapped_len) {
            *mapped_len = range.length;
        }
        return range.mapped;
    }
    return NULL;
}

このように、mmapしようとするサイズが4*1024*1024=4MBより大きいかどうかチェックしているコードがあります。コメント部分から判断すると、巨大ファイルをmmapして他のプロセススワップアウトさせてしまわないようにという意図のようです。しかし、copy関数から呼び出された場合にはlengthは0となって先のチェックに引っかからないため、大容量のファイルでもmmapしてしまいます。これは本来の意図と異なる挙動なのではないでしょうか。


これを修正し、copy関数から呼び出された場合でも正しいlengthを渡すようなパッチを作成しました。PHP 5.3.3で作りましたが、他のバージョンにも適用可能だと思います。



このパッチを利用すると、4MBを超えるファイルはmmapせず、バッファリングしながらのread(2)&write(2)でファイルコピーされるようになります。もちろんメモリ消費量も改善されます。


mmap(2)のメリットって?

ところでmmapで実ファイルを触ると何が嬉しいんだっけ、と改めて考えてみました。次の2つがメリットだという認識でいいんですかね…?


  • ファイルをランダムアクセスする場合に、fseek(3)とかせずに済むので楽チン。一方で、アクセスした場所だけディスクアクセスにいくので、fread(3)などでファイル全体を取得するより効率が良い。
  • カーネル空間からユーザー空間へのメモリコピーが起こらないので、特にファイルキャッシュに乗っているファイルを読み込む場合に性能面でメリットがある。

そう考えると、頻繁にアクセスされる小さいファイルや、ランダムアクセスされるファイルであればmmapを使うメリットがありそうです。copy関数についても、頻繁に同じファイルをコピーするようならメリットがあるかもしれませんが、まずありえない状況だと思います。


ご意見募集

今回作ったパッチPHP本家に投げようと思ったのですが、その前に他の人の意見も聞ければ嬉しいと思って記事にしてみました。これじゃだめなの?とか、他の言語ではこうしてるよ、などあれば教えてください。本当はsendfile(2)でファイルコピーするのが一番速いのかもしれませんが、今のPHPの実装にうまく組み込むのは難しそうです。sendfile(2)はソケットに対して書き込みするときしか使えないそうです。@dr_awnさん、ご指摘ありがとうございます。ソケットに対してのみsendfile(2)を使うようにするのは余計に難しいと思います。


また、mmap(2)で適切にmadvise(2)すれば読みおわったところからページアウトしてくれるかと想像していたのですが、手元の環境で観察する限りアクティブでないプロセススワップアウトさせる方が優先度が高いようです。mmapでうまくいく方法があればその方が良い気もするんですけどね…。


何にせよ、このあたりは他にも問題がありそうな気がします。ちょっとソースコードのぞいてみるか、という気になった人がもしいれば、記事にまとめた甲斐があるというものです。


まとめ

sodasoda 2010/08/24 13:08 ファイルキャッシュに乗ってなくても、コピー回数はmmap()+write()が最小となるので、MADV_SEQUENTIALを指定していれば、OSによってはこの方法が最速となると思います。たとえば http://kzk9.net/column/unixfastestfilecopy.html を見ても悪くない性能ですよね。もっとも、OSやバージョンによっては、今回の話のようにとんでもなく遅くなることもあるわけで、難しいところです。パッチを出すなら、OSとバージョンと、どれくらい遅くなるかを明確にしておく必要があるかと思います。

hnwhnw 2010/08/24 16:04 すみません、書き忘れましたが「ご意見募集」に書いた実験は MADV_SEQUENTIALを試したものです。これを指定してもうまくページアウトしてくれず、別のプロセスをスワップアウトさせている様子が観察できました。スワップアウトが起きてしまうような状況ではmmapで大容量ファイルを扱うと性能面でのペナルティが大きいように思います。また、スワップアウトが無くても大きいファイルを mmapすることでページイン・ページアウト処理を無駄に引き起こすのは事実だと思います。本当はすでに不要なページについてOSの仕事を増やしているのがもったいないな、と懸念しているだけで、どの程度問題になるかはわかりませんが、教えて頂いたURLでもファイルサイズが増えるに従って mmap+writeが劣勢になっているのが気になりました。

今回はメモリの消費について気にしていたので、多少速度が遅くてもmmap でうまくページ管理できる方法があればその方が嬉しいと思って調べていたんですが、思ったよりうまくいかないという感触を持ったのでこのような記事になりました。read+writeの方がいいよね、という結論はイマイチだと感じていますが、自力では他の結論を導けなかったものですから…。

速度についてはまとまった測定をしていないので何とも言えませんが、別のプロセスに2GBほどメモリを食わせた状況で1GBのファイルをテストすると mmap版は劇的に遅くなりました(テストしたマシンの物理メモリは3GBです)。そうでなくても、mmap+writeはread+writeに比べて5%くらい遅いように見えました。

sodasoda 2010/08/24 19:19 たぶん、この問題は、MacOS X でしか観察されないんじゃないかという気がしています。そのため、パッチは MacOS X 向けの #ifdef で括った上で、環境を明示して出すのが良いのではないか…というのが前のコメントの意図でした。他の OS 上でも試されました?

hnwhnw 2010/08/24 20:05 なるほど、ありがとうございます。他の環境ではここまで絶望的じゃない可能性もあるってことですね。これから試してみます!

hnwhnw 2010/08/25 14:58 Linux kernel 2.6.18-164.el5、glibc-2.5-42.el5_4.3、物理メモリ6GBスワップ8GBの環境で8GBのファイルをコピーしてみましたが、やはりread+writeの方が高速でした。1GBのファイルで試すとmmap版が僅かに速いように見えました。どちらの実験でも、madviseの有無による差は見られませんでした。今はPHPのソースを改造していて原因切り分けがしにくいので、http://code.google.com/p/copybench/で試してまたまとめてみます。

sodasoda 2010/08/26 12:30 むむ、そうですか。NetBSD-5.0.1/i386 メモリ1GB のマシンで、2GB のファイルを使って試したところ rw_cp 48.86秒、mw_cp 48.49秒、mw_madv_cp 48.41秒でした。何回か繰り返してみましたが、madvise(2)有無の違いは誤差程度ですが、read+write よりは mmap+write の方が有意に速いといって良い結果でした。実は NetBSD の場合、read(2) はカーネル内で mmap(2)+madvise(2) 相当の処理を行なっているので、read(2)+write(2) の方が速くなる理由はほとんどありません。試してませんが、Solaris の read(2) 実装も NetBSD と同じ(というか NetBSD が Solaris を真似た)なので、mmap+madivse の方が速いと思います。同じく試してませんが、FreeBSD の場合、mmap+madivse の方が若干遅くなる可能性もありますが、特に問題となるほどには遅くならないと思います。(このあたりの実装は FreeBSD, NetBSD, OpenBSD で一番違う部分の一つなので、試さずに言うのはちと危険ではありますが) なお、32bit OS で copybench-1.0 の mw_cp を動かすと問題がでるため、とりあえず http://www.sra.co.jp/people/soda/patch/copybench-1.0.patch にあるようないい加減なパッチを当てて試しました。(本当は mmap も複数回に分割すべきですし、SSIZE_MAX/2 というサイズもイマイチですが… というか、NetBSD/amd64 を入れて試すべきなんですが…)

 | 
ページビュー
518635