Design and Implementation of
Gracious Days

この記事は、シェルスクリプトの記事よりも前に読んだような気がする。同じくらい古い記事だけれど、ちょっと書いてみる。記事への反論はいくつか検索すると見つかって、たぶんみんな知っていることなのだと思うけれど、まとまって書かれている文章はないみたい。

tl;dr

  • read(2) と mmap(2) の性能差に絶対的な回答はない。どちらか一方が常に高速だと主張している文章は、根拠が証拠とともに明確に書かれていない限り信用しないほうが良い。
  • メモリコピーのコストが高かった時代と、L1キャッシュが巨大になってメモリコピーのコストが低くなった時代と、SMPが一般的になってメモリのマッピング処理のコストが高くなった時代とで、この性能差は頻繁に入れ替わっている。少なくともスループットとレイテンシを分けないで分析できるものではない。

迷信

まず当該記事には技術的な間違いがいくつかある。

  • 「mmap()はユーザランドにデータをコピーしないから速い」とあるけれど、その処理がどれくらい余計に時間を必要とするのかという証拠や根拠が定性的にも定量的にも示されていないので想像の産物だと思う。mmap(2)はページインでファイルの中身を取得するが、read(2)もvnodeを対象としているのであれば、同じようにI/Oはページインである。異なるのは、read(2)の場合はユーザランド側であらかじめバッファを用意する必要があり、かつシステムコール発行時に指定したデータ量が一度に読み出される(もちろんここではO_NONBLOCKでオープンされていないと仮定している)のに対して、mmap(2)はメモリアクセス時のデマンドページングに頼るという点だ。特にシーケンシャルに全部アクセスするなら、両者のI/O処理部分には大きな違いはない。

  • 記事にあるベンチマークの数値がおかしい。最初は 1.4 秒と 0.3 秒の結果が表になっているが、次の truss の結果は fgetc/fputc が 0.2秒、read/writeが1.21秒、mmapが0.0003秒になっている。FreeBSDのI/Oは1トランザクションあたり128KBしか書き込めないので、100MBのファイルをシーケンシャルにアクセスしてHDDから読んで書き込むのであれば800回のI/Oが発生する。0.3秒で100MBのコピー(読み書き)できたということは、入出力が 300MB/s 以上あって IOPS が 4800 以上ということになるが、これは記事が書かれた時期を考えてちょっと考えづらい。おそらくキャッシュの影響が大きく出ている。

反論記事からたどれる内容はどれも反論として正しい。一般論としては、次のような違いが言えると思う。

mmap(2)

  • 利点

    • ファイルアクセス時に小さな固定長のバッファを意識しないでプログラムできるので、プログラムを作る側から考えるとコードを書くのが楽。
    • 同じファイルを複数のプロセスから読み出しアクセスする場合、ページが共有されるので効率が良い。
    • ファイルに長い時間アクセスする必要があり、アクセスパターンがランダムアクセスという場合は性能的な恩恵が受けられる可能性が高い。
  • 欠点

    • 仮想アドレス空間のマップ/アンマップに時間がかかる。SMP環境では特にmmap()のたびにIPIを発行することになるので、TLB shootdownによるオーバヘッド(遅延時間)が大きく並列実行環境への悪影響がある。
    • 仮想アドレス空間を消費する。64-bit環境ではあまり問題にならないが、32-bit環境ではシビア。
    • I/Oの発生タイミングの制御や予測が困難。ページインの時点で実行コンテキストが長時間ブロックすることがある。

read(2)

  • 利点

    • I/Oの発生タイミングや、I/Oの量が制御できる。また、当然ながらnon-blocking I/Oにも対応している。
  • 欠点

    • ファイルアクセス用のバッファを管理しなければならない。
    • ランダムアクセスの場合にオフセットの管理が大変。
    • アクセスのたびにシステムコールを発行しなければならない。システムコールの数を減らすために scatter/gather I/O も用意されているが、struct iovec のセットアップが必要など処理は繁雑。

まとめ

この話題は時おり質問されるもので、探すとstack overflowやUNIX系OSのメーリングリストで見つけることができる。おおむね議論され尽くされているので、今からこの話題を出しても、スルーされるか勉強しろと言われるような気がする。

最近はメモリアクセスのコストが下がっていて、かつ、いかに TLB ミスやキャッシュミスを減らすかが全体の性能に影響するようになっているので、mmap() を使って高速にしようという考え方は捨てたほうが良いのではないかと思う。コードを簡単にするためにmmap()を使うのは悪いことではないので、mmap()が使いやすいと思う時には遠慮なくつかうべきだ。64-bit環境なら神経質に避ける必要はない。

しかし、当該記事のプログラム例は、次のように100MBのバッファをスタックにとろうとしているのがとても気持ちが悪いのだけれど、最近は気にしないものなのだろうか。

#include <sys/types.h>
#include <unistd.h>

int
main(void)
{
    int fdi, fdo, fsize = 104857600;
    char b[fsize];

    fdi = open("in", O_RDONLY);
    fdo = open("out", O_WRONLY);
    read(fdi, b, fsize);
    write(fdo, b, fsize);
}

Next post: Unmapped I/O on FreeBSD

Previous post: シェルスクリプトの書き方