あなたのネットワークスタック正しく設定されていますか?


はじめに

Linux Advent Calendar 10 日目の記事です。

運用や研究開発の現場では、ソフトウェアの実験、または機器のテストや選定などのために、ベンチマークツールや自前のアプリケーションでコンピュータ間の通信速度を計測する機会が多々あると思います。一方で10Gbpsや40Gbpsといった昨今の高速ネットワークにおいては、これらの計測結果はアプリケーションの通信API部分の実装、カーネルパラメータまたはコンパイルオプションによって大きく変わってしまうため、正確な計測を行うためにはこれらを正しく設定/理解する必要があります。この記事では、ネットワーク周りのカーネルとアプリケーションの動作の概要と、その中の重要なポイントを理解することを目的にします。

ネットワークプログラミングのおさらい

まず最初に、TCPを使う今時のサーバプログラムがどのようにできているか簡単におさらいします。
サーバプログラムは基本的には、クライアントからのリクエストを待つ、リクエストを処理する、レスポンスをクライアントに返す、という処理になり、クライアントプログラムは、リクエストを生成する、リクエストを送信する、サーバからの応答を待つ、という処理になり、順番は異なるものの、プログラムの構造としてはだいたい同じです。クライアントもサーバも、非同期に動作する複数TCPコネクション上でリクエスト (クライアントの場合はレスポンス) を扱う必要があるので、epoll などのOSが提供するイベント監視フレームワークを用います。epoll_* を用いたプログラムのイメージは以下のような感じになります。完全なコードは例えばここを参照してください。

int lfd, epfd, newfd, nevts = 1000;
struct epoll_event ev;
struct epoll_event evts[1000]; /* 最大1000リクエストを一度に受信 */
struct sockaddr_in sin = {.sin_port = htons(50000), .sin_addr = INADDR_ANY};
char buf[10000]; /* 10KB の受信/送信バッファ */

/* 新たなコネクションを待ち受ける (listen) ためのソケット */
lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
bind(lfd, &sin, sizeof(sin));
listen(listen_fd);

/* 複数ソケットのイベントを待つためのイベントファイルディスクリプタ */
epfd = epoll_create1(EPOLL_CLOEXE);

/* listen ソケットをイベントファイルディスクリプタに登録 */
bzero(&ev, sizeof(ev));
ev.events = POLLIN;
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);

for (;;) {
  int i, nfds; 
  /* 最大1000msブロックしてイベントを待つ */
  nfds = epoll_wait(epfd, evts, nevts, 1000);
  for (i = 0; i < nfds; i++) {
    int fd = evts[i].data.fd;
    if (fd == lfd) { /* 新しいTCPコネクション */
        bzero(&ev, sizeof(ev));
        newfd = accept(fd);
        ev.events = POLLIN;
        ev.data.fd = newfd;
        epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);
    } else { /* 既存TCPコネクションに対するリクエスト */
        read(fd, buf);
        /* bufに読み込んだリクエストを処理してbufにレスポンスを準備 */
        write(fd, buf); /* レスポンスの送信 */
    }
  }
}

このプログラムには、イベント監視用のファイルディスクリプタ epfd, 新たなコネクション待ち受け用のソケット lfd、接続が確立したTCPコネクション毎のソケットが登場します。
epoll_wait()はカーネルからイベントを取得し、それらを一つづつ処理します (変数iを増加させる for ループ)。新たなコネクション要求は listen ソケット (lfd) に通知され、既存のコネクション上のリクエストは既存コネクションのソケットに通知されます。前者のイベントに対しては accept() システムコールにより新たなコネクションに対応するソケットを生成して epoll が扱うディスクリプタのリストに登録します (epoll_ctl())。後者のイベントに対しては、read()で対象ディスクリプタに対するリクエストを読み込み、何か処理をした後で write() でレスポンスを書き込みます。
複数CPUコアを使うためには、この epoll_wait() のイベントループを各コアの上で別スレッドで動かします。

nginx, memcached, Redis, libuvなどのサーバアプリケーションやライブラリもだいたい同じようなプログラミングモデルになっていますので、複数TCPコネクションを扱うプログラムを動かす時は、このような動作の概要を頭に入れておくとよいと思います。

ネットワークスタックおさらい

次に、Linux の中のネットワークスタックがどのように動作するか簡単におさらいします。
パケットが NIC に到着すると、NIC はメモリ上にパケットを転送し、CPUに割り込みをかけます。
そうすると、現在そのCPU上で動作しているスレッドが一時的に停止して、代わりに割り込みハンドラと呼ばれる関数のようなものが実行されます。厳密にはハードウェア割り込みとソフトウェア割り込みに分かれますが、ここではこれらの処理をまとめて割り込みハンドラとします。割り込みハンドラは、例えばパケットをネットワークスタック (IPやTCP) に移動させてヘッダの処理などを行います。この過程で、アプリケーションに通知する必要のあるパケット(新たなコネクションを表す SYN/ACK に対する ACK パケット、データを含むパケット)かどうかが判明します。そのパケット処理に対してアプリケーションへの通知の必要があり、もしアプリケーションが epoll_wait()に当該ディスクリプタを登録して待っていた場合 (つまり上記のコードの場合) は、epoll のキュー(上記のコード例のepfdに対応) にそのディスクリプタとイベントの内容 (受信ならPOLLIN) を登録します。このように登録されたイベントは、アプリケーションがブロックしている epoll_wait() が返る時にまとめて通知されます。

パフォーマンスに大きく関わるパラメータ

ここまで簡単にカーネルがパケットを受信してからアプリケーションにデータが受け取られるまでを見てきましたが、この中で多くのパラメータが介在します。本節ではこれらのパラメータがネットワークパフォーマンスに与える影響を実験を交えて説明します。

サーバは、Intel Xeon Silver 4110 (2.1Ghz)、クライアントは Xeon の E5-2690v4 (2.6 Ghz) で、Intel X540 の 10GbE NIC でつながっています。
詳細な内容は後述しますが、基本構成として特に断りがない限り、ターボブーストとハイパースレッディング、CPUのスリープモード、netfilter は全て無効にしてあり、NIC のキューの数は1つに、また epoll におけるブロック時間はゼロにしてあります。
ベンチマークソフトウェアとしては、サーバは上記のコードと同じように動く僕が書いた 実験用サーバプログラム、クライアントは、ポピュラーな HTTP ベンチマークツールの wrk を使います。wrk は指定された時間、指定の数の TCP コネクション上でサーバにリクエストを投げてレスポンスを受け取り続けます。TCPコネクションは張りっぱなしで、やりとりされるHTTP GET と OK の TCP/IP/Ethernetヘッダを除いた大きさは、それぞれ 44バイトと151バイトです。どちらも小さなデータで1パケットにおさまるため、TSOやLRO、チェックサムオフロードといったNICのオフロード機能は効果がないため無効にしてあります。

NICの割り込み遅延

前の説明で、NIC がCPUに割り込みをかけると述べましたが、高速なネットワークでは、パケットの受信レートは毎秒数100万、あるいは数1000万パケットにもなります。割り込みの処理は現在のアプリケーションのための処理を中断して行われるため、パケットの受信レートがあまり大きいと、割り込みの処理に大半のCPU時間が使われてしまう、あるいはアプリケーションあるいはカーネルの処理が頻繁に中断されてしまう問題が発生します。この問題を、一般的にライブロックと呼びます。1つの受信パケットの処理には数10-数100nsの時間がかかりますので、数GhzのCPUサイクルがまるまる割り込みの処理だけに使われてしまう計算になります。

そのため、NIC は、CPUに割り込む頻度を抑えるための仕組みを備えています。デフォルトでは、1us に一回程度にしてあることが多いです。ただし、単にこの値を大きくすれば割り込みが減っていいのかというとそうでもなく、新しいパケットが入ってきてもしばらく割り込みを起こさなくなるため、低負荷時の遅延が増大するという問題があります。また、NAPIという機構により、カーネルは未処理の受信パケット量に応じて自分でNICからの割り込みを無効にするため、この値を大きくしてもスループットすら上がらず、新たなパケットの受信が遅れるだけになる、といった状況にもなり得ます。

ここで、割り込みレートの違いによるHTTPでリクエストを発行してレスポンスを受けとるまでにかかる時間を計測してみます。まず、サーバに対してTCPコネクションを一本だけ張り、その上でひたすらHTTP GET の送信、HTTP OK の受信を繰り返してみます。以下がそのコマンドと結果で、サーバ側の割り込み遅延をゼロにしてあります。-d 3 は実験の時間、-c 1-t 1 はそれぞれ 1 コネクション、1スレッドを表します。

root@client:~# wrk -d 3 -c 1 -t 1 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    24.56us    2.11us 268.00us   97.76%
    Req/Sec    38.43k   231.73    38.85k    64.52%
  118515 requests in 3.10s, 17.07MB read
Requests/sec:  38238.90
Transfer/sec:      5.51MB

以下が、サーバ側の割り込み遅延を 1us に設定した場合の結果です。

root@client:~# wrk -d 3 -c 1 -t 1 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    29.16us    2.59us 314.00us   99.03%
    Req/Sec    32.53k   193.15    32.82k    74.19%
  100352 requests in 3.10s, 14.45MB read
Requests/sec:  32379.67
Transfer/sec:      4.66MB

このように、だいぶ違う結果になります。

次に、複数の並列TCPコネクションを使ってみます。以下のコマンドでは、クライアントは100本のTCPコネクションを張り、100スレッドでそれぞれのコネクション上でリクエストを送信、レスポンスを受信します。

以下が割り込み遅延なしの場合で、

root@client:~# wrk -d 3 -c 100 -t 100 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  100 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   245.83us   41.77us   1.22ms   84.60%
    Req/Sec     4.05k   152.00     5.45k    88.90%
  1248585 requests in 3.10s, 179.80MB read
Requests/sec: 402774.94
Transfer/sec:     58.00MB

以下が 1us の割り込み遅延です。

root@client:~# wrk -d 3 -c 100 -t 100 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  100 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   247.22us   41.20us   1.16ms   84.70%
    Req/Sec     4.03k   137.84     5.43k    90.80%
  1241477 requests in 3.10s, 178.78MB read
Requests/sec: 400575.95
Transfer/sec:     57.68MB

割り込み遅延による影響はほとんどありませんが、これは、先ほど述べたNICが割り込みを自分で無効にする機能のためです。
多くのパケットが(最大100)同時に届くため、設定した割り込み頻度が高くても、カーネルはNICからの割り込みを一時的に無効にしています。

ここでもうひとつ注目して欲しい点が、並列コネクションなしの場合に比べて、遅延が一桁程度大きくなっている点です。これは、複数のイベント (受信リクエスト) がアプリケーションのイベントループ内で一つずつ処理されるためです。上記のサーバプログラムで、最大100イベントを同時に受け取る (epoll_wait() から返される nfds が最大100) ことを想像してみてください。

NIC の割り込みレートは、ixgbe ドライバ (Intel X520 や X540の 10GbE NIC) や i40e ドライバ (Intel の X710/XXV710/XL710 の 10/25/40 GbE NIC) では、

root@server:~# ethtool -C enp23s0f0 rx-usecs 0 # 割り込み遅延をゼロに

のようにして設定できます。

ネットワークの遅延を計測する際には、NIC の割り込みの設定を見直してみましょう。

ターボブースト

この機能は、一部のCPUコアの負荷が低い時に、別の負荷が高いCPUコアのクロックを上げる機能です。
実はこれは非常にやっかいな機能で、パフォーマンスの計測をする目的の時には無効にする方がよいでしょう。
なぜかというと、CPUのコア数に対するスケーラビリティの実験を行なっている際、6コア中1コアから3コア程度まではスループットがリニアにスケールするのに、そのあと突然スケールしなくなる、みたいなことが起こります。これはもちろんプログラムやカーネルが悪いのではなく、使用コア数が1-3だった場合は残りのコアがアイドルだったためにターボブーストによってアクティブなコアのクロックが上がっていたせいだった、ということが多いです。

ターボブーストを無効にするには、BIOS の設定で無効にしてしまうか、以下のようにしてもいけます。

root@server:~# sh -c "echo 1 >> /sys/devices/system/cpu/intel_pstate/no_turbo"

マルチコアのスケーラビリティで問題が起こった時には、ターボブーストの設定も見直してみましょう。

ハイパースレッディング

何も考えずオフにしましょう

CPUのスリープ機能

最近の CPU は消費電力の節約のために、負荷に応じて複数段階のスリープ状態に入るようになっていて、そのスリープ状態への行き来のためにソフトウェアの性能が不安定になることがあります。それによって起こる一見不可解な現象としては、例えば上記のサーバプログラムようなコードで、単一コネクションをさばく場合より複数並列コネクションをさばいた方が負荷が適度に上がってCPUがスリープ状態に入らないため、クライアントで観測する遅延が短くなるといったことが起こります。例えばこの論文 の Figure 2 では、5 コネクションの場合の遅延が1コネクションの場合よりわずかに小さいですが、これはうっかりこのCPUのスリープを無効にし忘れていたためです。

CPU がスリープ状態に入るのを防ぐためには、カーネルの起動パラメータ (grub.cfgやpxelinux.cfg/defaultなどのファイルで指定) に、intel_idle.max_cstate=0 processor.max_cstate=1 を設定するとよいです。以下は僕のpxelinux.cfg/defaultの抜粋です

APPEND  ip=::::::dhcp console=ttyS0,57600n8 intel_idle.max_cstate=0 processor.max_cstate=1

NICのキューの数

最近の NIC は、複数のパケットバッファのキューを持っていて、それぞれが別々の CPU に割り込めるなっています。
そのため、NIC のキューの数はカーネル内の割り込み処理に大きく影響するため、しっかりと見直した方がよいでしょう。

root@c307:~# wrk -d 3 -c 1 -t 1 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    25.22us    2.03us 259.00us   97.68%
    Req/Sec    37.50k   271.83    38.40k    74.19%
  115595 requests in 3.10s, 16.65MB read
Requests/sec:  37299.11
Transfer/sec:      5.37MB

上記は、先ほどのNICの割り込み遅延 の節で割り込み遅延をゼロにしたものと同じ実験ですが、サーバの NIC のキューの数を 8 にしてスレッド数とコア数を 8 にしてあります。イメージとしては、上記のサーバプログラムの epoll_wait のイベントループが各コア上で別スレッドで動いていると思ってください。スループットが向上しない原因は、この実験では 1 つのコネクションしか使用しておらず、NIC はコネクションのポートやアドレスのハッシュ値で割り込み先のキュー/CPUを決定しているため、全ての処理が同じCPUとスレッドで行われているためです。また、1スレッド、1キューを使った割り込み遅延の節の実験と比べてわずかに遅延が増加しています (24.56->25.22)。これは、複数キューを有効にするとわずかにオーバヘッドが発生することを示しています。そのため、実験によってはキューの数を一つに減らす等した方がいいかもしれません。

以下のように、100コネクションを使うと、異なるコネクションに属するパケットが複数キューに分散されるため、完璧とまではいかなくても、スループットがスケールしています。

root@client:~# wrk -d 3 -c 100 -t 100 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  100 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    65.68us  120.17us  17.26ms   99.53%
    Req/Sec    15.52k     1.56k   28.00k    81.43%
  4780372 requests in 3.10s, 688.40MB read
Requests/sec: 1542345.05
Transfer/sec:    222.11MB

NIC のキューの数は、

ethtool -L enp23s0f0 combined 8 # NIC のキューの数を 8 に

のようにして設定できます。

まとめとして、NIC のキューは基本的にはアプリケーションが使うコア数と同じにするのがよいですが、場合によってはオーバヘッドになることもあるので、必要に応じて変更しましょう。また当然ですが、アプリケーションも複数のコアを使って実行されるようにプログラムあるいは設定されている必要があるので気をつけましょう。

ファイアウォールのための仕組み

Linux カーネルにはネットワークスタック内の様々な場所でパケットをフックするための仕組み (netfilter) が備わってます。これらのフックは iptables などによっては動的に有効になりますが、個別のフックの有効無効に関わらずこの仕組みそのものが性能に影響を与えることが多々あります。パフォーマンスを正確に測りたい場合には、必要でない限り、カーネルのコンフィグレーションで、CONFIG_NETFILTER と CONFIG_RETPOLINE を無効にしましょう。

アプリケーションのブロッキング

上記のサーバプログラムでみたように、アプリケーションは通常 epoll_wait() でブロック(スリープ)してイベントを待ちます。epoll_wait() にはブロックする時間を ms 単位で指定する値を渡せるようになってます。
しかし、先述のように割り込みハンドラは動作中のスレッドをのコンテキストを間借りして実行されるため、CPU が NIC から割り込みを受けた時に動作中のスレッドが存在しない場合、まずスリープしているスレッドを起こす必要が生じます。このスレッドを起こす操作はかなりのオーバヘッドを伴います。以下は、epoll_wait() で 1000ms ブロックするようにした場合の結果です (NICの割り込み遅延はゼロ)。

root@client:~# wrk -d 3 -c 1 -t 1 http://192.168.11.3:60000/
Running 3s test @ http://192.168.11.3:60000/
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    33.56us    3.28us 282.00us   95.50%
    Req/Sec    28.42k   192.11    28.76k    54.84%
  87598 requests in 3.10s, 12.61MB read
Requests/sec:  28257.46
Transfer/sec:      4.07MB

NICの割り込み遅延の節の実験では 24.56us だったので、10us 近く遅延が増大していることがわかります。CPUが割り込みを受けた際に必ず実行中のスレッドが存在するようにするには、epoll_wait() に渡すブロックする時間をゼロにすれば大丈夫です。

まとめ

この記事では、ネットワークパフォーマンスに影響を与える様々なパラメータについて紹介しました。
サーバ管理者やアプリケーション開発者、これからネットワークスタックや (ライブラリ)OS に関する研究を(計画)している方の実験の役にたてば幸いです。