Redis latency spikes and the Linux kernel: a few more details

Salvatore Sanfilippo

RedisのレイテンシスパイクとLinuxカーネル:もう少し詳しく

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

今日、m3.mediumのEC2インスタンスでRedisのレイテンシをテストしていた。BGSAVE時にいつも発生するレイテンシスパイクを再現できた。プロセスがforkし、子プロセスがデータセットをディスクに保存し始めるときに起きるものだ。しかし今回は予想と違う点があった。スパイクはディスクI/Oが原因でもなければ、fork()呼び出し自体の最中に起きたわけでもなかった。

テストはメモリ上に1GBのデータを置き、別のEC2インスタンスから500万キーに対して均等に分散させながら、毎秒15万回の書き込みを発生させて行った。パイプラインは4コマンドに設定した。これはredis-benchmarkでは次のコマンドラインに相当する。

    ./redis-benchmark -P 4 -t set -r 5000000 -n 1000000000

BGSAVEをトリガーするたびに、原因不明の約300ミリ秒のレイテンシスパイクが見られた。forkは6ミリ秒しかかかっていなかったからだ。幸いRedisにはソフトウェアウォッチドッグ機能があり、レイテンシイベントの際にプロセスのスタックトレースを出力できる。仕組みはとてもシンプルだがよく効く。カーネルからSIGALRMが配信されるように仕掛けておくのだ。serverCron()関数が呼ばれるたびに、スケジュールされたシグナルはクリアされる。つまり、制御が十分に速くRedisプロセスに戻ってくれば、実際にはシグナルを受信することはない。逆にブロックする状況があれば、カーネルによってシグナルが配信され、シグナルハンドラがスタックトレースを出力する。

fork呼び出しを含むスタックトレースが得られるかと思いきや、プロセスは常にfork直後の親プロセスのコンテキストで、MOV*系の命令の近くでブロックしていた。「Linuxが何らかの形で遅延forkしているのではないか」という仮説を立て始めた。実際の重い処理は後でメモリにアクセスしたとき、ページがコピーオンライトされるタイミングで発生しているのではないか、と。

次のステップはLinuxカーネルのfork()の実装を読むことだった。システムコールが実際にやっているのは、マップされた領域(vm_area_struct構造体)をすべてコピーすることだ。ただし従来の実装ではこの時点でもPTEを複製しており、それは従来copy_page_range()によって行われていた。しかし何年か前に最適化としてある変更が加わった。最近の多くのカーネルと同様に遅延的なページコピーを行うだけでなく、PTEのコピーもフォールト時に遅延的に行われるようになったのだ。copy_range_range()の冒頭コメントはこうなっている。

         * Don't copy ptes where a page fault will fill them correctly.
         * Fork becomes much lighter when there are big shared or private
         * readonly mappings. The tradeoff is that copy_page_range is more
         * efficient than faulting.

基本的に、親プロセスが子プロセスと共有している領域にアクセスした途端、ページフォルトの処理の中でLinuxはforkでスキップした大量の作業を実行する。だからこそスタックトレースでは常にMOV命令が見えていたわけだ。

この挙動はRedisにとっては好ましくない。すべてのPTEを一括でコピーする方が効率的だからだ。しかし、POSIXシステムにおけるfork()の伝統的な使い方、すなわち新しいプロセスを生成するためのfork()+exec*()という用途にとっては、こちらの方がはるかに優れている。

この問題はEC2特有のものではない。ただし仮想化されたインスタンスはPTEのコピーが遅いため、物理サーバーでは問題が目立ちにくくなる。

しかし、これで話が全てというわけでは決してない。Linuxマシンでこの件をテストしている最中に、以前、jemallocの代わりにlibcのmallocを使った場合、特定の条件下でレイテンシスパイクが少なかったことを思い出した。そこで何か関係があるのではないかと調べてみることにした。

実際、MALLOC=libcでコンパイルすると物理サーバーではレイテンシをまったく計測できなかったのに対し、jemallocではEC2インスタンスで観測されたのと同じ挙動が見られた。違いをよりよく理解するため、1500万キーとより大きなパイプラインでテストを組み、システムにより負荷をかけて、mmapされた領域すべてのページフォルトがごく短い時間内に起こりやすくなるようにした。そして同じテストをjemallocとlibc mallocで繰り返した。

bare metal, 675k/sec writes to 15 million keys, jemalloc: max spike 339 milliseconds.
bare metal, 675k/sec writes to 15 million keys, malloc: max spike 21 milliseconds.

EC2でも同じ結果を再現しようとすぐに試してみたが、やはりmallocではスパイクは数分の一だった。

こうした結果を踏まえて次に考えるべきは、libc mallocで動くRedisとjemallocで動くRedisとで、メモリレイアウトに何の違いがあるかを調べることだ。Linuxのprocファイルシステムはプロセス内部を調べるのに便利だ(今回は/proc//smapsファイルを使った)。

Jemallocのメモリはこの領域に確保されていた。

7f8002c00000-7f8062400000 rw-p 00000000 00:00 0
Size:            1564672 kB
Rss:             1564672 kB
Pss:             1564672 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   1564672 kB
Referenced:      1564672 kB
Anonymous:       1564672 kB
AnonHugePages:   1564672 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

一方、libcの大きな領域はこんなふうに見える。

0082f000-8141c000 rw-p 00000000 00:00 0                                  [heap]
Size:            2109364 kB
Rss:             2109276 kB
Pss:             2109276 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   2109276 kB
Referenced:      2109276 kB
Anonymous:       2109276 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

ここでは2つほど違う点があるように見える。

1) 最初の行に[heap]があるのはlibc mallocだけだ。
2) AnonHugePagesフィールドはlibc mallocではゼロだが、jemallocの場合は領域のサイズと同じ値になっている。


基本的に、レイテンシの違いはmallocがTransparent Huge Pagesを使っていることに起因しているように見える。これは複数の通常の4kページを透過的に1つのヒュージページ(1ページあたり2048k)にまとめるカーネル機能だ。その結果、この領域のPTEのコピーがはるかに高速になる。


EDIT: 残念ながら完全に間違っていることに気づいた。ヒュージページを使っているのはどうやらjemallocの方だけのようだ。出力を読み間違えていたのだ、あまりにも自明に思えたので。だから実際には逆で、高いレイテンシはなぜかヒュージページが原因で起きているようだ。つまり、ヒュージページを「使っていない」mallocの方がはるかに高速に動いている。ここで何が起きているのかまったく分からないので、上記の結論は無視してほしい。


当面、低レイテンシが求められるアプリケーションではRedisを「make MALLOC=libc」でビルドすることを検討してもいいだろう。ただし事前に「make distclean」することを忘れず、ワークロードによってはlibc mallocの方がjemallocよりフラグメンテーションが起きやすいことにも注意してほしい。


続報を待たれよ……

EDIT2: おっと……問題がヒュージページにあるのなら、これはむしろずっと良い。無効化できるからだ。そして実際に効果があることも確認した。

 echo never > /sys/kernel/mm/transparent_hugepage/enabled

これがどうやら新たなRedisの定番設定になるようだ。

UPDATE: 非現実的に思えたが、実験的に検証したところ、ヒュージページによるメモリのスパイクは、50のクライアントが同時に書き込み、それぞれN件のリクエストをキューに溜めていると、Redisプロセスが*1回のイベントループのイテレーションの間に*プロセスの全ページに触れてしまい、プロセスのアドレス空間全体をコピーオンライトしてしまうことが原因だった。これはヒュージページがレイテンシにとってひどいだけでなく、メモリ使用量にとってもひどいことを意味する。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント