以下の内容はhttps://www.valinux.co.jp/blog/entry/20260226より取得しました。


新Linuxカーネル解読室 - マルチキュー受信処理 ~仕組み解剖編~

「Linuxカーネル2.6解読室」(以降、旧版)出版後、Linuxには多くの機能が追加され、エンタープライズ領域をはじめとする様々な場所で使われるようになりました。
それに伴いコードが肥大かつ複雑化し、多くのエンジニアにとって解読不能なブラックボックスとなっています。
世界中のトップエンジニア達の傑作であるLinuxカーネルにメスを入れ、ブラックボックスをこじ開けて、時に好奇心の赴くままにカーネルの世界を解読する「新Linuxカーネル解読室」プロジェクト。

本稿では、マルチキュー受信処理について、Linuxカーネルv6.8のコードをベースに解説します。

執筆者:矢野 安希子、稲葉 貴昭

※ 「新Linuxカーネル解読室」連載記事一覧はこちら


0. はじめに

今回は、CPUのマルチコア環境を活かしてパフォーマンス向上を目指した仕組みについて解説を行います。
ネットワーク帯域が拡大し、トラフィック量が増大したことで、シングルコアでパケット受信処理を行うと負荷が高くなってしまうため、マルチキューで受信処理を行う仕組みが考えられました。*1
本記事では、以下の4つの機能について取り扱います。

機能 実装方法 説明
RSS(Receive Side Scaling) ハードウェア マルチキュー受信処理を行う機能
RPS(Receive Packet Steering) ソフトウェア RSSをソフトウェア実装した機能
RFS(Receive Flow Steering) ソフトウェア RPSを効率的に拡張させた機能
aRFS(Accelerated Receive Flow Steering) ハードウェア RFSをハードウェア実装した機能

RSSとaRFSはハードウェア依存となるため、特にRPSとRFSに焦点を当てた解説を行います。


1. RSS

RSSはNIC内でマルチキュー実装を行う仕組みです。

図1-0:RSSのイメージ

(1) 受信パケットの送信元IP、送信元ポート番号、宛先IP、宛先ポート番号の4つの情報からhash値を計算
(2) hash値からパケットを渡すCPUに対応した宛先queueを決定*2
(3) 決定したqueueへ配送

このようにして、NICがhash計算、hash値とCPU毎のqueueとの紐づけ、届けたいCPUに対応したqueueへ配送を行います。

hash値の計算は、NICがオフロード*3に対応していることも多いです。
RSSでは以下のように、ethtool コマンドを使用してhash計算の計算式(フィールド)をチューニングすることも可能です。
ただし、サポートされる組み合わせはNICに依存します。

【チューニング方法】
例:`$ ethtool -N eth0 rx-flow-hash tcp4 sdfn` (TCP/IPv4パケットに対し、送信元IP(s)、宛先IP(d)、送信元ポート番号(f)、宛先ポート番号(n)の4つの情報を基に計算)    

2. RPS:概要

RSSはマルチコアを活かす効率的な機能ですが、ハードウェア依存の技術なので、RSS対応のNICを用意する必要があります。
そこでこの機能をハードウェアに依存することなく、ソフトウェアで実現したものがRPSです。
基本的な考え方はRSS同様であり、RSSではNICが行っていた「ヘッダの情報から特定のキューに割り振る」という役割を、1つのCPUが請け負います。

ハードウェア実装のRSSと、ソフトウェア実装のRPSの違いの1つとして、マルチキュー対応となるタイミングが挙げられます。
受信パケットをGRO(Generic Receive Offload)*4
で結合した後、上位レイヤにパケットが配送される前にRPSの処理があります。

図2-0-1:RSS,aRFS(ハードウェア実装)での処理のイメージとRPS,RFS(ソフトウェア実装)での処理のイメージ

(1) ハードウェア(NIC)がパケットを受信。
  ※RSS/aRFS(ハードウェア実装)はここでどのリングバッファに割り振るか決める。
(2) メインメモリ上のリングバッファに書き込み。
(3) NAPIでポーリングし、リングバッファからパケットをまとめて取り出す。GROで複数のパケットを結合する。
  ※RPS/RFS(ソフトウェア実装)はGROで結合されたパケットに対してどのコアに割り振るか決める。
(4) マルチコアでの受信処理 *5

続いて、実際にRPSを利用するための設定手順を説明します。
実際に使用するためには、以下の手順でカーネルの設定および対象のCPUを指定して有効化する必要があります。
筆者の環境であるubuntu 24.04のカーネルでは、デフォルトで前準備(1)のCONFIG_RPS=yとなっていましたが、前準備(2)のCPUマスク値は0となっており、RPSは無効となっていました。

【前準備(1)】  
カーネルが `CONFIG_RPS=y`でコンパイルされているか確認  

【前準備(2)】  
`/sys/class/net/eth*/queues/rx-*/rps_cpus`に有効にしたいCPUマスクを16進数で設定   
- eth*:設定したいNICの名前    
- rx-*:NICの受信キュー番号    
例:`$ echo 5 > /sys/class/net/eth0/queues/rx-0/rps_cpus` (5(16) → 00000101(2) → CPU 0とCPU 2が有効)    

ここからは、RPSの処理の流れを以下3つのSTEPに分けて説明していきます。

  • STEP1. 決定(受信したパケットをどのCPUに委譲するかを決定する処理)
  • STEP2. 配送(委譲されたCPUが管理するデータ構造にパケットを配送する処理)
  • STEP3. 受信(パケットを受信する処理)

RPSの代表的な関数コールのおおまかな流れは以下となっています。

図2-0-2:RPSの代表的な関数コールの流れ

①GRO処理後のタイミングから、RPS(RFS)の処理が始まります。*6
get_rps_cpu関数によって、パケットの委譲先を決定します。※STEP1
enqueue_to_backlog関数によって、委譲先のCPUが管理するキュー(input_pkt_queue)へパケットを配送します。※STEP2
④IPI(Inter-Processor Interrupt)によって、委譲先のCPUが起床します。※STEP2
napi_schedule_rps関数でスケジュールされたNAPIによってポーリングハンドラ(process_backlog)が呼ばれます。※STEP2
process_backlog関数でinput_pkt_queueからprocess_queueへパケットを移動させます。※STEP3
ip_rcv関数で上位レイヤへのパケット受信が行われます。*7

※補足:この図では便宜上「データ書き込み/読み取り/移動」と書いていますが、実際にはパケットデータそのものをコピーしているわけではありません。
本当の意味での「データ書き込み」は、最初にNICがリングバッファにパケットを配置する時(DMA:Direct Memory Access)だけです。 その後のデータの受け渡しで行われているのは、sk_buff と呼ばれる「パケットの管理情報」の受け渡しです。データの実体はメモリ上の同じ場所に置かれたまま、管理権限だけが移り変わります。

それでは、各STEPについて確認していきましょう。


2-1. RPS:STEP1 決定

RPSの処理のエントリとなるのはget_rps_cpu関数です。この関数は、受信したパケットをどのCPUに委譲するかを決定します。

図2-1:get_rps_cpu関数の処理のイメージ

RPSの場合、前準備(2)でrps_cpusに設定したCPUを配列(rps_map構造体のcpusメンバ)で管理しています。
受信パケットの送信元IP、送信元ポート番号、宛先IP、宛先ポート番号の4つの情報を基に計算したhash値からパケットを渡すCPU(Target CPU:tcpu)を決定します。このhash値の計算は、RSS同様にNICがオフロードに対応している場合はNICで行われます。


2-2. RPS:STEP2 配送

続いてSTEP2では、TargetCPU(tcpu)にパケットの配送を行います。
重要になる処理は、以下の2つです。

  1. enqueue_to_backlog関数:STEP1で決定したtcpu用の受信キューにパケットの配送をする。
  2. napi_schedule_rps関数 :RPS処理のため、NAPIをスケジューリングする。

図2-2-1:enqueue_to_backlog関数のイメージ

まずenqueue_to_backlog関数です。各CPUは、自分専用の受信したパケットを溜めておくキュー、input_pkt_queueを持っています。 STEP1で決めたtcpuinput_pkt_queueに既にパケットがある場合は、このキューの末尾にパケットを追加します。 動作中のベルトコンベアの最後尾に荷物を追加するイメージです。この場合は、このあとSTEP3の処理へと進みます。

図2-2-2:napi_schedule_rps関数のイメージ

tcpuinput_pkt_queueにパケットがない場合は、そのキューにはじめてパケットが置かれたタイミングとなります。先程のイメージでいうと、ベルトコンベアが停止しており、荷物が並んでいない状態です。停止しているベルトコンベアに荷物を置いただけでは荷物が流れてくれないため、動作スイッチをONにする = NAPIをスケジュールする必要があります。
これを行うのが、STEP2のメインとなる2つ目の関数、napi_schedule_rps関数です。

napi_schedule_rps関数では、現在処理を行っているCPUとtcpuが「同じ場合」と「異なる場合」の2つの場合で動作スイッチのONの仕方が変わります。

図2-2-3:現在のCPUとtcpuが同じ場合と異なる場合の処理の流れ

  • 現在のCPUとtcpuが同じ場合
    現在受信処理中のCPUが、たまたまtcpuと同じであった場合は、自分自身に対して動作スイッチをON(NAPIをスケジューリング)し、STEP3の処理へ進みます。

  • 現在のCPUとtcpuが異なる場合
    現在受信処理中のCPUとtcpuが異なる場合、処理中のCPUがIPIを使いtcpuを呼び出す必要があります。
    tcpuは呼び出し(IPI)を受けると、HardIRQとして応答します。HardIRQは、CPUが寝ていたり他の動作を行っていても、強制的に作業をさせる役割を持ちます。*8
    起床したtcpuは自分自身に対して動作スイッチをON(NAPIをスケジューリング)し、STEP3の処理へ進みます。


2-3. RPS:STEP3 受信

最後に、受信の処理です。process_backlog関数はパケットを上位レイヤへ配送する役割を担います。
効率よく処理を行うために、input_pkt_queueprocess_queueの2つのキューを使用します。

input_pkt_queueは、パケットを受け取ったCPUとtcpuで共有するため、同時にアクセスされないようにロックが必要です。 tcpuで実行するprocess_backlog関数は、process_queueにパケットがなくなったタイミングのみinput_pkt_queueのロックを取得し、パケットをまとめてprocess_queueに移動することで、input_pkt_queueのロックを取得する回数を減らしています。

input_pkt_queueは、元々パケットを受け取ったCPUからtcpuへ受け渡しが起こる際、2つのコンテキストでアクセスされるため、ロックが必要です。つまりキューがこれ1つだけだと、パケットを取り出すたびにキューの占有が起こります。そこで各CPUが個別に持つロック不要のキュー、process_queueが存在します。 input_pkt_queueからまとめてprocess_queueへパケットを移動させた後、上位レイヤへ受け渡すことで、input_pkt_queueのロックの頻度を減らす工夫がされています。

図2-3:process_backlog関数のイメージ

処理の順序としては、はじめにprocess_queueに前回送り切れなかったパケットが残っていないか確認します。ある場合はそのパケットから優先して上位レイヤへ配送します。 1回で処理できるパケット数には上限があり、上限に達するか、キューが空になるまで続けられます。

RPSの責務はここで完了です。 このように「決定・配送・受信」の処理を通して、パケットをマルチコアで受け渡すことを可能にしています。


3. RFS:概要

RFSはRPSを拡張した仕組みで、「パケットを最終的に処理するCPUに割り振る」ための仕組みです。
RPSでは受け取ったパケットのヘッダからhash値を計算することで特定のCPUに割り振っていましたが、そのパケットを受信したいアプリケーションがそのCPU上で動作する保証がありません。その場合、パケットをソケットに配送するときに、別CPUで動作するアプリケーションに通知する必要があります。するとプロセッサ間割り込みが発生し、非効率的です。

図3-0:RPSの弱点

ここで登場するのがRFSです。RFSはアプリケーションが実行しているCPUに直接パケットを配送することで、CPUキャッシュ効率を上げ、また無駄な割り込み処理を無くします。これを実現するため、以下のようにしてアプリケーションが動作しているCPUの追跡を行います。

  • アプリケーションが送受信系のソケット操作を行うシステムコールを実行するたびに、現在動作しているCPUを登録。
  • RFSはパケットの配送履歴(グローバルなフローテーブル)を管理し、「どのハッシュ値を持つパケットが、どのCPUで処理されたか」を記録・更新

この追跡機能により、OSのスケジューラによってアプリケーションが別のCPUへ移動した場合でも、RFSはそれを検知して、パケットを配送するキューを動的に変更することを可能にしているのです。

まず、実際にRFSを利用するための設定手順を説明します。
RFSはデフォルトで無効となっており、有効にするためには以下の2つのファイルに設定を行う必要があります。
なお、RPSとRFS、どちらの値も設定されていた場合はRFSが優先されます。

【前準備(1)】  
カーネルが `CONFIG_RPS=y`でコンパイルされているか確認  

【前準備(2)】  
`/proc/sys/net/core/rps_sock_flow_entries` に、同時にアクティブとなる可能性のある通信セッション総数を設定  
例:`$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries`  

【前準備(3)】  
`/sys/class/net/eth*/queues/rx-*/rps_flow_cnt` に、`rps_sock_flow_entries`をNICのキュー数で割った値を設定   
例:`$ echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt`  

前準備(2)と(3)にて設定した2つの値が、パケットが最終的に処理されるCPUに割り振るためのキーポイントとなる、rps_sock_flow_tablerps_dev_flow_tableという2つのテーブルの設定値となります。
2つのテーブルについて説明した後、このテーブルを活用してどのようにパケットを割り振るのか、RPSと同様に3つのSTEPに分けて説明していきます。

  • rps_sock_flow_tablerps_dev_flow_table
  • STEP1. 決定(受信したパケットをどのCPUに委譲するかを決定する処理)
  • STEP2. 配送(委譲されたCPUが管理するデータ構造にパケットを配送する処理)
  • STEP3. 受信(パケットを受信する処理)

3-0. RFS:rps_sock_flow_tablerps_dev_flow_table

  • (1)rps_sock_flow_table
    rps_sock_flow_tableは、アプリケーションが現在どのCPUにいるかを示すハッシュテーブルです。 アプリケーションが送受信系のソケット操作を行うシステムコール(recv/send/read/write等)を実行するたびに、「目的のアプリケーションはどのCPUで動いている 」という情報がここに書き込まれます。 このテーブルの書き換えによって、アプリケーションが実行されるCPUが替わっても、このパケットは新しくこのCPUと紐づいているキューに届けるべき、と行き先を変えることができます。

  • (2)rps_dev_flow_table
    rps_dev_flow_tableは、「前回、このフローのパケットをどのCPUへ送ったか」を記録しているテーブルです。
    アプリケーションが動作するCPUは、スケジューラのマイグレーションなどにより変更されることがあります。 動作するCPUが変更になっても、受信パケットの処理順序が変わらないようにするために、「前回、このフローのパケットをどのCPUへ送ったか」をrps_dev_flow_tableで記録しています。


3-1. RFS:STEP1 決定

まずはRPSと同様に、get_rps_cpu関数によって受信したパケットをどのCPUに委譲するのか決定します。早速この関数内で、(1)rps_sock_flow_table と (2)rps_dev_flow_table の2つのテーブルを活用して、TargetCPU(tcpu)へ配送しても順序が入れ替わらないかどうか確認したうえで、tcpuの決定を行います。

図3-1-1:next_cpu = tcpu の場合の処理のイメージ

はじめに (1)rps_sock_flow_tableを参照します。 パケットの4つの情報(送信元/宛先のIPとポート番号)から計算したハッシュ値を使い、「このパケットを受け取るアプリケーションは、今どのCPUで動いているか」を確認します。ここで取得したCPUをnext_cpuと呼びます。

次に、 (2)rps_dev_flow_tableを参照します。 ここでは、「このフローのパケットは、前回どのCPUに配送されたか」という実績情報を確認します。 ここで取得したCPUが、tcpuとなります。

最後に、この2つのCPU、next_cputcpuを比較し、配送先を決定します。

  • next_cputcpuの場合
    next_cpuは、前回も同じCPUtcpuに配送していたことがわかったので、アプリケーションは前回と同じCPUで動いていると判断し、そのCPUを配送先として決定します。

図3-1-2:next_cpu ≠ tcpu の場合の処理のイメージ

  • next_cputcpuの場合
    next_cpuは、前回は異なるCPUtcpuへ配送されているということは、アプリケーションが別のCPUへ移動していると判断します。 ここでパケットの順序が入れ替わらないように、即座に切り替えず、(2)rps_dev_flow_tableを用いてtcpuの受信キューに未処理のパケットが残っていないか確認を行います。
    tcpu宛のパケットが残っている場合】
    まだ処理されていないパケットがあるのに新しいCPUへ送ると、パケットの順序が変わってしまいます。 そのため、今回は切り替えを見送り、あえて移動先であるCPUnext_cpuではなく、前回と同様のtcpuへ配送して順序を守ります。
    tcpu宛のパケットがない場合】
    tcpuに未処理パケットがないため、next_cpuへ切り替えてもパケットの順序が入れ替わることはありません。ここで初めて配送先に切り替えます。

このようにして、パケットの順序が入れ替わらないかチェックを行ったうえで、配送先の決定が行われます。


3-2. RFS:STEP2 配送

配送の処理は、RPSと同様の処理を行います。
パケットを受信するアプリケーションが他のCPUで受信待ち状態である場合、コア間割り込みを使って通知する必要がありますが、RFSの場合はこのコア間割り込みが不要となり、パフォーマンスが向上します。


3-3. RFS:STEP3 受信

受信の処理も、RPSと同様の処理を行います。
RPSの場合、パケットを受信したいアプリケーションがそのCPU上で動作している保証がありませんでした。
しかしRFSの場合、アプリケーションが動作しているCPUで受信を実行しているので、CPUキャッシュを効率的に利用でき、パフォーマンスが向上します。

このようにしてRFSでは、RPSの機能を拡張し、アプリケーションが新しいCPUに移行した際でも最終的に処理を行いたいCPUへパケットを割り振ることを可能にしています。


4. aRFS

最後に紹介する技術として、aRFSを紹介します。aRFSは、RFSをソフトウェアではなくハードウェア(NIC)で実現した技術です。
前述の通り、RPS/RFSは、あくまでパケットを受け取った後、ソフトウェアでマルチキューに振り分ける仕組みでした。「図2-0:RSS,aRFSと、RPS,RFSの処理の違い」 で示したように、ハードウェアの実装の方がマルチコアの利点をより活かすことになります。
aRFSは、アプリケーションが違うCPUで処理されるようになると、そのことをrps_dev_flow_tableを通してNICが知ることで、NICが最初からアプリケーションが動作しているCPUにパケットを届ける仕組みです。この仕組みにより、より高負荷なワークロードにも対応できるようになります。
しかし、RSSと同様にaRFS対応のNICを準備する必要があります。現在ハイエンドのNICでは、aRFSに対応しているようですので、対応のものをお使いの場合は是非試してみてはいかがでしょうか。

aRFSを有効にする手順は以下となります。

【前準備(1)】  
カーネルが `CONFIG_RFS_ACCEL=y`でコンパイルされているか確認  

【前準備(2)】  
`/proc/sys/net/core/rps_sock_flow_entries` に、同時にアクティブとなる可能性のある通信セッション総数を設定  
例:`$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries`  

【前準備(3)】  
`/sys/class/net/eth*/queues/rx-*/rps_flow_cnt` に、`rps_sock_flow_entries`をNICのキュー数で割った値を設定   
例:`$ echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt`  

【前準備(4)】
`$ ethtool -K eth0 ntuple on`でNICのハードウェアフィルタ機能をONに設定

おわりに

今回の記事では、マルチキュー受信処理である、RSS/RPS/RFS/aRFSの技術について紹介しました。
パケットの処理をどのようにしてマルチキュー受信対応させているのか、理解の助けになれば幸いです。
続編として、主にRSSのチューニングや性能測定を行い、実際にどれくらい効果があるのか、性能検証を行います。
無事公開した際には、是非そちらも読んでいただけますと幸いです。お楽しみに。
それではまた、次の記事で。


参考文献
Scaling in the Linux Networking Stack — The Linux Kernel documentation :Linux Kernel公式ドキュメント


*1:単に複数のCPUにパケットを分散させれば性能が上がるというわけではなく、効率性を下げる要因を排除する必要があります。 (かつて受信割り込みを各CPUへラウンドロビン(順番)に振り分けるNICもありましたが、これでは効率が良いとは言えませんでした。)
RSS/RPS/RFS/aRFSでは、「プロセッサ間割り込み(IPI)の発生回数を抑える」、「キャッシュミスのペナルティを減らす」などの工夫がされています。

*2:図1-0ではqueueの数とCPUの数が対応していますが、実際はqueueの数よりもCPUの数のほうが多いことが一般的です。queueに紐づかないCPUも存在することになります。

*3:ethtool(8)にあるrxhashのこと

*4:GRO:NICから受信した複数のパケットを、1つの大きなパケットに結合する仕組み。上位レイヤ(TCP/IPスタック)が処理するパケット数が減るため、ヘッダ解析や構造体管理の回数が減り、CPU負荷を下げる役割があります。

*5:つまり、GROの処理まではソフトウェア実装のRPS/RFSはシングルコアでの実行となり、GROもマルチコアで動作するハードウェア実装のRSS/aRFSの方がよりマルチコアの利点を活かすことができます。

*6:下記の記事、「2.2 概要」にて関数コールの流れが紹介されています。netif_receive_skb_list_internal関数が、本記事で紹介するRPSでパケットの仕分けを行う分岐となる関数です。
Linuxカーネル解読室プロジェクト: 新Linuxカーネル解読室 - パケット受信処理 ~Ethernetドライバ ポーリング処理編~ - VA Linux エンジニアブログ

*7:現在の実装では、RSS/aRFS(ハードウェア実装)は現在の受信処理のメインルートである ip_list_rcv ルート、RPS/RFS(ソフトウェア実装)では ip_rcv ルートを通ります。つまり、RPS/RFSは下記ブログの Routing table や hint table を使った検索に対応しておらず、また関数の呼び出しが増えている状態です。
Linuxカーネル解読室プロジェクト: 新Linuxカーネル解読室 - パケット受信処理 ~IPレイヤーにおける受信処理~ - VA Linux エンジニアブログ

*8:割り込み処理流れについてはこちらのブログをご確認ください。
Linuxカーネル解読室プロジェクト: 新Linuxカーネル解読室 - パケット受信処理 ~Ethernetドライバ 概要編~ - VA Linux エンジニアブログ




以上の内容はhttps://www.valinux.co.jp/blog/entry/20260226より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14