100万ppsを受信するプログラムを書くのはどのくらい難しいのか?【翻訳】CloudFlare ブログ
2016/02/15
無料枠が充実していることでも人気なコンテンツデリバリネットワーク (CDN) を提供するCloudFlareは、最近1億1000万ドルの資金調達が発表され、ますます注目を集めている。そんなCloudFlareのブログから、ネットワークチューニングの知見を紹介したい。
原文:https://blog.cloudflare.com/how-to-receive-a-million-packets/ (2015-11-20)
※元記事の筆者には直接翻訳の許可を頂いて、翻訳・公開しております。
CC BY-SA 2.0 image by Bob McCaffrey
先週、何気ない会話の中で同僚がこう話すのをふと耳にした。「Linuxのネットワーク・スタックが遅いんだよ!1コアあたり5万pps以上は期待できない」
この発言に、私は考えさせられた。おそらく実用的には、コア毎50万パケット毎秒(pps)が限界だとは思うのだが、Linuxのネットワーク・スタックでどこまで可能なのか?もっと楽しめるように、言い換えてみよう。
Linuxで100万UDP ppsを受信するプログラムを書くのは、どのくらい難しいのか?
できれば、この質問に答えることが最新のネットワーク・スタック設計をする上で良い教訓になると良いと思う。
まず、以下のことを想定しよう。
- パケット/秒(pps)の測定は、バイト/秒(Bps)の測定よりずっと面白い。Bpsを高くするには、より良いパイプライン方式でより長いパケットを送信することで達成できる。ppsを改善するのはもっと難しい。
- ppsに興味があるので、私たちは短いUDPメッセージを使って実験を行う。正確には、32バイトのUDPペイロード、つまり、イーサネットレイヤでは74バイトだ。
- この実験のために、私たちは「受信用」と「送信用」の、2つの物理サーバを使う。
- この2台のサーバは両方とも、6コア2GHzのXeonプロセッサを搭載している。 ハイパースレッディング(HT)を使うことで、それぞれ24プロセッサまで増やすことができる。この2台のサーバは、Solarflare製のマルチキュー10Gのネットワークカードを有しており、11の受信キューが設定されている。そのことについては後ほど。
- テストプログラムのソースコードはここで入手できる。[udpsender], [udpreceiver]
必要条件
UDPパケットには、ポート4321を使うとしよう。始める前に、トラフィックが [iptables] によって妨げられないようにしなければならない。
1 2 | receiver$ iptables -I INPUT 1 -p udp --dport 4321 -j ACCEPT receiver$ iptables -t raw -I PREROUTING 1 -p udp --dport 4321 -j NOTRACK |
明確に定義されたIPアドレス(2~3個)が、後に有効となる。
1 2 3 4 | receiver$ for i in `seq 1 20`; do \ ip addr add 192.168.254.$i/24 dev eth2; \ done sender$ ip addr add 192.168.254.30/24 dev eth3 |
1. 簡単な方法
始めるにあたって、最も簡単な実験をしてみよう。単純な送受信には、どのくらいパケットを使うのだろうか?
送信側の擬似コード:
1 2 3 4 5 | fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) fd.bind(("0.0.0.0", 65400)) # select source port to reduce nondeterminism fd.connect(("192.168.254.1", 4321)) while True: fd.sendmmsg(["\x00" * 32] * 1024) |
通常の[send] システムコールを使うこともできたかもしれないが、それは効率的ではないだろう。カーネルに切り替えるコンテキストはコストがかかるし、避けたほうがいい。幸いにも、最近Linuxに手頃なシステムコールが追加された。[sendmmsg] だ。これによって、一回でたくさんのパケットを送信することができる。一度に1,024パケットでやってみよう。
受信側の擬似コード:
1 2 3 4 5 | fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) fd.bind(("0.0.0.0", 4321)) while True: packets = [None] * 1024 fd.recvmmsg(packets, MSG_WAITFORONE) |
同じように、[recvmmsg] は一般的な [recv] システムコールのより効率的なバージョンだ。
試してみよう。
1 2 3 4 5 6 7 8 9 10 | sender$ ./udpsender 192.168.254.1:4321 receiver$ ./udpreceiver1 0.0.0.0:4321 0.352M pps 10.730MiB / 90.010Mb 0.284M pps 8.655MiB / 72.603Mb 0.262M pps 7.991MiB / 67.033Mb 0.199M pps 6.081MiB / 51.013Mb 0.195M pps 5.956MiB / 49.966Mb 0.199M pps 6.060MiB / 50.836Mb 0.200M pps 6.097MiB / 51.147Mb 0.197M pps 6.021MiB / 50.509Mb |
この単純なアプローチでは、19万7千pps~35万ppsの通信ができる。悪くはないが、残念ながら、かなりのばらつきがある。これは、カーネルがコア間でプログラムを切り替えているため起こる。CPUにプロセス・ピニングをすると良いだろう。
1 2 3 4 5 6 7 8 | sender$ taskset -c 1 ./udpsender 192.168.254.1:4321 receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321 0.362M pps 11.058MiB / 92.760Mb 0.374M pps 11.411MiB / 95.723Mb 0.369M pps 11.252MiB / 94.389Mb 0.370M pps 11.289MiB / 94.696Mb 0.365M pps 11.152MiB / 93.552Mb 0.360M pps 10.971MiB / 92.033Mb |
さて、カーネルのスケジューラは定義されたCPU上でプロセスを保存する。これはプロセッサのキャッシュ局所参照性を改善し、希望する通りに数の整合性を取ってくれる。
2. より多くのパケットを送信する
37万 ppsというのは、単純なプログラムには悪くないが、100万ppsという目標にはまだほど遠い。より多く受信するためにはまず、より多くのパケットを送信しなければならない。2つのスレッドからそれぞれ送信するのはどうだろうか。
1 2 3 4 5 6 7 | sender$ taskset -c 1,2 ./udpsender \ 192.168.254.1:4321 192.168.254.1:4321 receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321 0.349M pps 10.651MiB / 89.343Mb 0.354M pps 10.815MiB / 90.724Mb 0.354M pps 10.806MiB / 90.646Mb 0.354M pps 10.811MiB / 90.690Mb |
受信側の数字は増えていない。[ethtool-S] でパケットが実際にどこに行ったかがわかる。
1 2 3 4 5 6 7 8 9 10 11 12 13 | receiver$ watch 'sudo ethtool -S eth2 |grep rx' rx_nodesc_drop_cnt: 451.3k/s rx-0.rx_packets: 8.0/s rx-1.rx_packets: 0.0/s rx-2.rx_packets: 0.0/s rx-3.rx_packets: 0.5/s rx-4.rx_packets: 355.2k/s rx-5.rx_packets: 0.0/s rx-6.rx_packets: 0.0/s rx-7.rx_packets: 0.5/s rx-8.rx_packets: 0.0/s rx-9.rx_packets: 0.0/s rx-10.rx_packets: 0.0/s |
このような統計値から、RXキュー番号#4に約35万ppsを正常に送信したとNICが報告してくれる。[rx_nodesc_drop_cnt] は、NICが45万ppsをカーネルに送信することができないというSolarflare特有のカウンターである。
時に、なぜパケットが送信されなかったのかが明確でない時がある。でも私たちの場合、とても明らかで、RXキュー#4がCPU#4にパケットを送信したのだ。そして、CPU#4が手いっぱいになり、35万ppsを読み取るのにビジー状態となってしまう。[htop] ではこのように見える。
マルチキューNICの集中講座
歴史的に見て、ネットワークカードにはかつてハードウェアとカーネル間でパケットを渡していた単一RXキューがあった。この設計は明らかに限界があった。つまり、単一CPUが処理を超えたパケットを送信することは不可能だったのだ。
マルチコアシステムを活用するために、NICは複数RXキューのサポートを開始した。この設計はシンプルだ。各RXキューが個別のCPUに固定されているので、全てのRXキューにパケットを送信することで、NICは全てのUPUを利用できる。しかしここで疑問が生じる。パケットを渡されて、NICはプッシュするRXキューをどのように決めているのだろうか?
ラウンドロビン(均等負荷分散)バランシングは好ましくない。なぜなら、 一つのコネクションでパケットの再組み立てを行う可能性があるためだ。もう一つの選択肢は、RXキュー番号を決めるのにパケットのハッシュを使うことだ。ハッシュは通常タプル(送信元IP、送信先IP、送信元ポート、送信先ポート)から数えられる。この方法だと、単一フロー用パケットが常に全く同じRXキューに行き着くようにし、単一フロー内のパケット再組み立てが起こらないように保証してくれる。
私たちのケースでは、ハッシュをこのように使うことができただろう。
1 | RX_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues |
マルチキューハッシングアルゴリズム
ハッシュアルゴリズムは [ethtool] で設定できる。私たちの設定ではこうだ。
1 2 3 4 | receiver$ ethtool -n eth2 rx-flow-hash udp4 UDP over IPV4 flows use these fields for computing Hash flow key: IP SA IP DA |
これは、IPv4 UDPパケットのために、NICがアドレス(送信元IP、送信先IP)をハッシュするということだ。すなわち、以下のようになる。
1 | RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues |
これは、ポート番号を無視するため、かなり制限される。NICの多くでは、ハッシュをカスタマイズすることができる。この場合もやはり、[ethtool] を使って、ハッシュ値を計算するタプル(送信元IP、送信先IP、送信元ポート、送信先ポート)を選ぶことができる。
1 2 | receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn Cannot change RX network flow hashing options: Operation not supported |
残念ながら、私たちのNICはこれをサポートしていないため、 (送信元IP、送信先IP)ハッシュの生成のみ行う。
NUMA(不均等メモリーアクセス)パフォーマンスにおける考察
これまでのところ、私たちの全てのパケットは一つのRXキューにのみ流れ、一つのCPUにのみたどり着いている。これを機に、様々なCPUのパフォーマンスをベンチマークしてみよう。私たちの設定では、受信ホストにそれぞれ別々のNUMAノードを持った個別プロセッサバンクが2つ入っている。
私たちの設定では、興味深い4つのCPUのうち1つに、シングルスレッドのレシーバを固定できる。その4つのオプションとは以下の通りだ。
- レシーバを、もう一つのCPUで、ただしRXキューと同じNUMAノードで稼働させる。上述した通り、パフォーマンスはおよそ36万ppsだ。
- RXキューと全く同じCPUのレシーバでは、~43万ppsまで達することができる。しかしこれは変動性が高くなる。NICがパケットで一杯になると、パフォーマンスはゼロまで落ちる。
- RXキューを処理するCPUと対応しているハイパースレッドでレシーバが動く時、パフォーマンスは通常の半分の約20万ppsになる。
- RXキューとは異なるNUMAノードにおけるCPUのレシーバでは、~33万ppsまで達する。ただし、数字にあまり一貫性がない。
別々のNUMAノード上で動くことでの10%の損失はそこまで悪く聞こえないかもしれないが、拡張する度に問題は悪化する。幾つかのテストでは、コア毎に25ppsまでしかひねり出せなかった。
NUMA間で行った全てのテストでばらつきが見られた。処理量が多くなると、NUMAノード間でのパフォーマンス損失はいっそう一目瞭然となった。あるテストでは、悪いNUMAノードでレシーバを動かした時には4倍の損失があった。
3. 復数の受信IPアドレス
私たちのNICでは、ハッシングアルゴリズムはかなり制限されるので、RXキュー間でパケットを分散する唯一の方法はIPアドレスをたくさん使うことだ。異なる距離のIPアドレスへパケットを送信する方法は以下の通りだ。
1 | sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321 |
[ethtool] がパケットを明確なRXキューにパケットを確認する。
1 2 3 4 5 6 7 8 9 10 11 12 | receiver$ watch 'sudo ethtool -S eth2 |grep rx' rx-0.rx_packets: 8.0/s rx-1.rx_packets: 0.0/s rx-2.rx_packets: 0.0/s rx-3.rx_packets: 355.2k/s rx-4.rx_packets: 0.5/s rx-5.rx_packets: 297.0k/s rx-6.rx_packets: 0.0/s rx-7.rx_packets: 0.5/s rx-8.rx_packets: 0.0/s rx-9.rx_packets: 0.0/s rx-10.rx_packets: 0.0/s |
受信部分:
1 2 3 4 | receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321 0.609M pps 18.599MiB / 156.019Mb 0.657M pps 20.039MiB / 168.102Mb 0.649M pps 19.803MiB / 166.120Mb |
万歳!RXキューの処理でビジー状態の2つのコアと、アプリケーションを実行中の3番目のコアで、65万ppsを達成できる!
3または4のRXキューにデータを送れば、この数字をさらに増やすことが出来るが、アプリケーションはすぐに別の限界に達する。今回、[rx_nodesc_drop_cnt] は増えないが、netstatが受け取った「レシーバエラー」は以下の通りだ。
1 2 3 4 5 6 7 8 9 | receiver$ watch 'netstat -s --udp' Udp: 437.0k/s packets received 0.0/s packets to unknown port received. 386.9k/s packet receive errors 0.0/s packets sent RcvbufErrors: 123.8k/s SndbufErrors: 0 InCsumErrors: 0 |
つまり、NICはカーネルにパケットを送ることが出来るが、カーネルはアプリケーションにパケットを送ることができないということだ。私たちのケースでは、44万ppsしか送信できず、アプリケーションが十分速く受信できないために残りの39万pps+12万3千ppsは落ちてしまう。
4. 多くのスレッドから受信する
これには、受信側アプリケーションをスケールアウトする必要がある。多くのスレッドから受信するためには、簡単な方法ではうまくいかない。
1 2 3 4 5 6 | sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321 receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2 0.495M pps 15.108MiB / 126.733Mb 0.480M pps 14.636MiB / 122.775Mb 0.461M pps 14.071MiB / 118.038Mb 0.486M pps 14.820MiB / 124.322Mb |
受信中のパフォーマンスは、シングルスレッドのプログラムと比べて低い。これは、UDP受信バッファ側のロック競合によって起こる。両方のスレッドが同じソケットディスクリプタを使用しているので、UDP受信バッファ周りのロックに時間をかけすぎてしまうのだ。この資料に、この問題についてのより詳しい記述がある。
一つのディスクリプタから受信するために、たくさんのスレッドを使うことは最適ではない。
5. SO_REUSEPORT
ラッキーなことに、最近Linuxに追加された次善策がある。SO_REUSEPORTフラグだ。このフラグがソケットディスクリプタにセットされている時、Linuxは多くのプロセスが同じポートに結び付くことを許可する。実際、任意の数のプロセスがバインドされることを許可され、負荷が分散するだろう。
SO_REUSEPORTによって、各プロセスが独立したソケットディスクリプタを持つ。したがって、それぞれが専用のUDP受信バッファを所有することになる。これは、以前遭遇した競合の問題を回避してくれる。
1 2 3 4 | receiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1 1.114M pps 34.007MiB / 285.271Mb 1.147M pps 34.990MiB / 293.518Mb 1.126M pps 34.374MiB / 288.354Mb |
よしよし、スループットがまともになった!
より進んだ調査をすれば、さらに改善の余地が見つかるだろう。私たちは受信スレッドを4つ開始したにもかかわらず、負荷はそれぞれに均等にかかるわけではなかった。
4つのうち、2つのスレッドは全てのワークを受信したが、他の2つはパケットを全く受信しなかった。これはハッシングの衝突によって起こるものだが、今回はSO_REUSEPORTレイヤで起こったものだ。
おわりに
私はさらなる追加テストを行い、完全に整列したRXキューと単独NUMAノードでの受信スレッドで 140万ppsを達成することができた。異なるNUMAノードでレシーバを動かすと数字は落ち、せいぜい100万ppsまでしかいかなかった。要するに、完璧なパフォーマンスを望むなら、以下のようにする必要がある。
- たくさんのRXキューとSO_REUSEPORTプロセスに、均等にトラフィックを分散させるようにすること。実際には、接続(フロー)数が多ければ、負荷はうまく分散する。
- カーネルから実際にパケットを受け取れるよう、CPUキャパシティに十分に空きを持たせておく必要がある。
- さらに大変なことに、RXキューと受信プロセスは同一のNUMAノードにある必要がある
私たちはLinuxマシンで100万ppsを達成することが技術的に可能だということを証明してきたが、アプリケーションは実際にパケット受信処理をしていたわけではないし、トラフィックの中身すら見ていない。踏み込んだ調査をせずに、実際の運用で同じようなパフォーマンスは期待しないでいただきたい。
このような低レイヤでの高パフォーマンス論争に興味がありますか?CloudFlare社はロンドン、サンフランシスコ、シンガポールで採用をしていますよ!
関連記事
-
V8はどうやってJavaScriptコードを最適化しているのか?
僕の過去記事で、NodeJSがなぜ速いかについて話した。今日は、V8について話したいと思う。 多分、
-
コーディングが捗るROCK MUSIC
みなさんは作業をするときに音楽を聴きますか? 私は仕事中は聞きませんが、家で作業したり勉強するときは
-
Node vs. Go : Roadomatic の実装における比較
目次 概要 サーバ運用 ラウンド1: リクエスト処理 UDPソケット リクエストの検証 ラウンド2:
-
GolangをJavaと比べてみた~Java愛好家がGoの機能を見たときの第一印象~
最初に断っておきたいのだが、私はGoのエキスパートではない。2~3週間前にGoを勉強し始めたばかりな
-
WEBエンジニアの祭典!LL系カンファレンスの歴史
例年夏から秋にかけて開催されているLL(lightweight language, 軽量プログラミン
-
Dockerコンテナとイメージの仕組みを視覚化してみた
この記事は、Docker 102レベルを意図して書かれている。Dockerが何か分からない、または仮
-
今夜が頑張り時のエンジニアのために。徹夜しても生産性を落とさない10のコツ
「徹夜したくないけど、今日は避けられない」というあなたに まず前提として、徹夜はすべきではありません
-
広告あるある 〜第一弾〜
by Klearchos Kapoutsis こんにちは、今回はネット広告について、あるあるを書きま
-
作業用BGMは本当に仕事が捗るのか?
みなさんは普段どんなスタイルで仕事をしていますか? エンジニアでよくヘッドホンをしながら仕事をしてる
-
継続的デリバリがもたらす効果と価値 ~ソフトウェア業界全体のトレンド “React” を追え~
あなたが「リリース」という言葉を聞いた時、どのような感情が呼び起こされるだろうか?安堵?高揚感?ある