RedisのレイテンシスパイクとLinuxカーネル:もう少し詳しく
今日は、m3.mediumのEC2インスタンスを使ってRedisのレイテンシをテストしていました。BGSAVE中、つまりプロセスがforkして、子プロセスがデータセットをディスクに保存し始めるときに、いつものレイテンシスパイクを再現できました。しかし、予想と違うことがありました。スパイクはディスクI/Oが原因ではなく、fork()の呼び出し中に発生したものでもありませんでした。
テストでは、メモリ上に1GBのデータを置き、別のEC2インスタンスから毎秒15万回の書き込みを行いました。対象は500万個のキーで、均等に分散させています。パイプラインには4つのコマンドを設定しました。redis-benchmarkのコマンドラインにすると、次のようになります。
./redis-benchmark -P 4 -t set -r 5000000 -n 1000000000
BGSAVEを実行するたびに、原因不明の約300ミリ秒のレイテンシスパイクが発生していることを確認できました。forkにかかっていた時間は6ミリ秒だったからです。幸い、Redisにはソフトウェアウォッチドッグ機能があり、レイテンシイベントの発生時にプロセスのスタックトレースを出力できます。かなり単純な仕掛けですが、非常によく機能します。カーネルからSIGALRMが届くように設定しておくのです。serverCron()関数が呼び出されるたびに、スケジュールされたシグナルをクリアします。そのため、Redisプロセスの処理が十分に速く戻れば、Redisがシグナルを受け取ることはありません。逆に、ブロッキング状態が発生すると、カーネルによってシグナルが配信され、シグナルハンドラがスタックトレースを出力します。
fork呼び出しのスタックトレースが得られるはずだと思っていましたが、実際には毎回、fork直後に親プロセスのコンテキストで実行されるMOV*命令の近くでプロセスがブロックされていました。そこで、Linuxは何らかの形で「遅延fork」を行っており、メモリにアクセスしてページをコピーオンライトする段階で、実際に重い処理が発生しているのではないか、という仮説を立て始めました。
次に、Linuxカーネルにおけるfork()の実装を読みました。このシステムコールが実際に行っているのは、マップされたすべての領域(vm_area_struct構造体)をコピーすることです。しかし、従来の実装では、この時点でPTEも複製していました。そして、これは従来copy_page_range()によって行われていました。ところが、何かが変わっています……数年前に最適化が行われたのです。現在のLinuxは、ほとんどのモダンなカーネルと同じく、単にページコピーを遅延させるだけではありません。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.
基本的には、親プロセスが子プロセスと共有している領域にアクセスすると、ページフォールトの処理中に、forkが省略した大量の処理をLinuxが実行します。スタックトレースで毎回MOV命令を確認できたのは、このためです。
この動作はRedisにとっては好ましくありません。PTEを一度の処理ですべてコピーしたほうが効率的だからです。しかし、POSIXシステムでfork()を使う従来の用途、つまり新しいプロセスを起動するためのfork()+exec*()にとっては、こちらのほうがはるかに優れています。
この問題はEC2固有のものではありません。ただし、仮想化されたインスタンスではPTEのコピーが遅くなるため、物理サーバーでは問題が目立ちにくくなります。
しかし、これが全貌というわけではありません。Linuxマシンでこの動作をテストしているとき、過去に、特定の条件ではjemallocではなくlibcのmallocを使ったほうがレイテンシスパイクが少ないと測定できたことを思い出しました。そこで、何か関係があるのか確認してみることにしました。
実際、MALLOC=libcでコンパイルすると、物理サーバーではレイテンシをまったく測定できませんでした。一方、jemallocではEC2インスタンスで観測したものと同じ動作を確認できました。違いをより詳しく理解するため、1500万個のキーと、より大きなパイプラインを使ってテストを設定しました。システムにさらに負荷をかけ、mmapされたすべての領域でページフォールトが非常に短い時間間隔に発生しやすくするためです。そして、同じテストをjemallocとlibc mallocで繰り返しました。
ベアメタル、1500万個のキーに毎秒67万5000回の書き込み、jemalloc:最大スパイク339ミリ秒。
ベアメタル、1500万個のキーに毎秒67万5000回の書き込み、malloc:最大スパイク21ミリ秒。
EC2でも同じ結果をすぐに再現してみました。同じ条件では、mallocを使った場合、スパイクは数分の一でした。
この結果から次に調べるべきなのは、libc mallocを使って動作するRedisと、jemallocを使って動作するRedisで、メモリレイアウトにどのような違いがあるのかということです。Linuxのprocファイルシステムは、プロセス内部を調査するのに便利です(今回は/proc/<pid>/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
ここには、いくつか違いがあるようです。
1) 1行目に[heap]があるのはlibc mallocの場合だけです。
2) libc mallocではAnonHugePagesの値が0ですが、jemallocでは領域のサイズに設定されています。
<this is wrong>
基本的に、レイテンシの違いは、mallocが透過的ヒュージページを使用していることによるもののようです。透過的ヒュージページとは、カーネルの機能の一つで、複数の通常の4kページを、1つあたり2048kの少数の巨大なページに透過的にまとめます。その結果、この領域のPTEをコピーする処理がずっと高速になります。
</this is wrong>
追記:残念ながら、今になって自分が完全に間違っていたことに気づきました。どうやらヒュージページを使っているのはjemallocだけです。あまりにも明白に思えたため、出力を読み違えていました。つまり逆に、高いレイテンシは、何らかの理由でヒュージページによって発生しているようです。実際には、ヒュージページを使っていないmallocのほうが、はるかに高速に動作しています。何が起きているのかはまったくわかりません。したがって、上の結論は無視してください。
<wrong advice, read later EDIT2>
一方、低レイテンシが求められるアプリケーションでは、Redisを「make MALLOC=libc」でビルドするとよいかもしれません。ただし、その前に必ず「make distclean」を実行してください。また、ワークロードによっては、libc mallocのほうがjemallocよりも断片化しやすい点にも注意が必要です。
</wrong>
続報は近日中に……
追記2:あ、待ってください……問題がヒュージページなら、無効にできるので、これはずっと簡単です。そして、実際に効果があることを確認しました。
echo never > /sys/kernel/mm/transparent_hugepage/enabled
どうやら、これが新しいRedisの合言葉になりそうです。
更新:自分でも現実的ではないと思っていましたが、実験によって、ヒュージページによるメモリスパイクの原因を確認しました。50個のクライアントが同時に、それぞれN件のリクエストをキューに入れて書き込みを行うと、Redisプロセスは、*1回のイベントループの反復*の中で、プロセス内のすべてのページにアクセスすることがあります。その結果、プロセスのアドレス空間全体がコピーオンライトされます。つまり、ヒュージページはレイテンシにとって最悪なだけでなく、メモリ使用量にとっても最悪なのです。記事をランダムに読む