Zero-copy Sendmsg/Receiveについて調べてみた

この記事は[武蔵野アドベントカレンダー2018](https://qiita.com/advent-calendar/2018/musashino)の9日目の記事です。

## 概要
100G EthernetやInfinibandをはじめとした広帯域のインターコネクトの性能をフルに活かすには従来のTCP/IPでは力不足とされ、それに変わる方式が多数提案・実装・公開されています。
Zero-copy SendmsgとZero-copy TCP ReceiveはLinux kernel 4.14及び4.18以降で導入された機能であり、実装上いくつかの変更点や制限があるものの、ユーザ空間上のバッファとカーネル空間上のソケットバッファ間のコピーを伴わないデータの送受信を実現する低遅延な通信処理を提供しています。
本記事ではその2つのZero-copy機能について簡単に紹介しようと思います。

※本記事は、[LWN.net](https://lwn.net/)の [Zero-copy TCP receive](https://lwn.net/Articles/752188/) と [Zero-copy networking](https://lwn.net/Articles/726917/) 、TCP/IPの概要については[UNIXネットワークプログラミング第二版Vol.1](https://www.amazon.co.jp/UNIX%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E3%80%88Vol-1%E3%80%89%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AFAPI-%E3%82%BD%E3%82%B1%E3%83%83%E3%83%88%E3%81%A8XTI-W-%E3%83%AA%E3%83%81%E3%83%A3%E3%83%BC%E3%83%89-%E3%82%B9%E3%83%86%E3%82%A3%E3%83%BC%E3%83%B4%E3%83%B3%E3%82%B9/dp/4894712059)を参照しながら書いているため、より正確に理解したい方はこれらを読んでいただければと。

## TCP/IPのデータフロー  
zero-copy TCPについて触れる前に、TCP/IPの基本的なデータフローについて簡単にまとめます。
下の図は、TCP/IPにおけるデータフローを非常に単純に表したものです。
![flow.PNG](https://qiita-image-store.s3.amazonaws.com/0/222677/3e10e42e-f89b-8aa4-3ecd-71c0d10390ce.png)


- **送信時**
ユーザプログラムから送信関数（_send()_, _write()_）に渡されたデータは、カーネル空間内の送信用バッファ(sk_buff)にコピーされます。以降のTCP, IP層内ではオーバーヘッド削減のためTCPヘッダやIPヘッダなどのデータはポインタで渡され、最後に対向マシンへ送信されて完了します。
UDPでは、カーネル空間内のバッファの保持期間がTCPとは違いますが、送信のためにコピーされるのはTCPと同様です。

- **受信時**
対向サーバからデータを受信すると、デバイスは受信したデータをカーネル空間内のバッファに格納します。
ユーザプログラムで呼び出した受信関数（_read()_, _recv()_）はカーネル空間内のバッファに受信データが格納されるまでは成功を返さず、格納され次第値をユーザ空間にコピーし、戻り値を返して終了します。  



### TCP概要まとめ  
  送受信時共にユーザ空間⇄カーネル空間のデータコピーが発生する他、システムコール呼び出しや受信時のデバイスからカーネルへの割り込みなどもオーバーヘッドとされていますが、Zero-copy TCPのスコープ内ではないため、今回は割愛させていただきます。

## Zero-copy sendmsg/Receiveの概要  
　Zero-copy sendmsg/Receiveは冒頭で述べたようにLinux kernel4.14以降で導入されている機能であり、
前述のデータフローのうちUser空間⇄Kernel空間のメモリコピーの削減を行なっています。
以下では、Zero-copy送信と受信のそれぞれについて簡単にまとめました。


### Zero-copy sendmsg
元論文：https://netdevconf.org/2.1/papers/netdev.pdf
※この機能はTCPだけでなく、UDP, raw packetでも利用可能な機能です

4.14でZero-copy sendmsgが導入される以前から、ユーザ空間からカーネル空間へのコピーを削減する方法として_sendfile()_システムコールの利用するという方法がありました。_sendfile()_システムコールは内部的に[splice()](https://linuxjm.osdn.jp/html/LDP_man-pages/man2/splice.2.html)を利用して実装されているため、出力先をsocketにすることで、入力ファイルディスクリプタ→pipe→socketの順にpageを移動し、コピーすることなくデータを渡すことができます。しかしながら、_sendfile()_システムコールはpage cacheを上手に活用する実装となっているらしく、**送信するデータはファイルから直接読み込まれたものでなくてはいけない（＝演算した結果を対象とできない）**という結構厳しい制限があり、使い所が限定されてきました。

4.14で追加されたZero-copy sendmsgでは、ユーザ空間のpageをkernelが追い出されないように固定し、そのpageを直接socket bufferのfragmentとしてしまう、という方法でZero-copyを実現しています。

利用方法は、socket作成後に_setsockopt()_で`SOCK_ZEROCOPY`オプションをセットし、_send()_に`MSG_ZEROCOPY`を渡して実行するだけ、と比較的単純なものとなっているのですが、ユーザメモリ上に存在するデータに対して直接送信処理を行う&send関数が戻り値を返した時に送信が終わっているとは限らない、という特性上、**送信の完了が確認できるまで送信データを触ってはいけない**という制限があります。また、page上のデータをsk_buffのfragmentとして扱うため、scatter/gatherに対応していないデバイスを用いる場合は利用できない、という制限もあります。
以下はzero-copy sendmsgのサンプルコードです。（引用元：https://netdevconf.org/2.1/papers/netdev.pdf）

```
ret = send(fd, buf, sizeof(buf), MSG_ZEROCOPY);
if (ret != sizeof(buf))
  error(1, errno, "send");

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 ||
    pfd.revents & POLLERR == 0)
  error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
  error(1, errno, "recvmsg");

read_notification(msg);
```

気になる性能ですが、patchの投稿者らによると、netperfを用いたベンチマークでは39%の速度向上が見られたようですが、"more realistic production workload"では 5-8% の速度向上に落ち着いたとのこと。

送信データサイズ次第ではZero-copyを実現するための処理のオーバーヘッドにより余り効果が得られないことや、カーネル空間にデータを保持しなくなったことによってユーザ空間バッファの扱いが難しくなるなど実装の難易度は上がりますが、お手軽に確かな効果が得られる方法のようです。


### Zero-copy TCP Recieve

  Zero-copy TCP Receiveでは、socket bufferをアプリケーションのプロセス空間に_mmap()_を用いてマッピングすることでユーザ空間へのコピーを回避しています。（socket bufferに対して呼び出された際の_mmap()_の挙動がこのパッチで追加されたようです。）
そのため、カーネル空間のバッファに格納された受信データ(headerは含まない)がpage sizeにalignされている & page sizeの整数倍でなければならない、という条件があります。
例えば[Zero-copy TCP Receiveに同梱されたサンプルコード](https://elixir.bootlin.com/linux/latest/source/tools/testing/selftests/net/tcp_mmap.c)では、MTUは61512に設定されています（4096byte pages * 15 + 40byte IPv6 header + 32byte TCP header）。
そして受信データの処理終了後は、処理プロセスが_munmap()_を呼び出して、page及びバッファを解放します。

この機能の注意点としては、_mmap()_自体のオーバーヘッドにより、状況によっては性能向上が少なくなる点が挙げられています。そのため、複数threadでの呼び出しの削減(_mmap_sem_のオーバーヘッド)や、なるべく複数ページを一度にmapする（mmapのオーバーヘッド）などの実装上のコツがより大きな性能向上のために必要となるようです。

  そしてこの実装により、マイクロベンチマーク上とは言え、パケット処理性能が129μsec/MB -> 45μsec/MBの約1/3に改善されたそうです。例えば100Gbpsのインターコネクトに1518byteのパケットを全力で流し込んだ場合、パケットが届く間隔は大体1518*8/100 ≒ 121[ns/packets]となり、Zero-copy Receiveを利用することで滞留することなく受信可能となります。


## 雑感

  以上、Zero-copy sendmsg/Receiveについて記事の内容を雑にまとめたものとなります。

ここからは雑感を。
[DPDK](https://www.dpdk.org/)や[XDP](https://www.iovisor.org/technology/xdp)などの広帯域なインターコネクトの性能を活かすいくつかの方法の中で、恐らくZero-copy Sendmsg及びZero-copy TCP Receiveの与えてくれる性能向上はそう大きいものではないはずです。しかし、これら2つの方法は従来のsocketプログラミングに近しい、という点で取っつきやすい機能となっているように見えます。100Gbpsや200Gbpsという帯域をフルで使い切るような処理には少々力不足かもしれないですが、それ以下の負荷の処理の性能向上であったり、そもそもの性能改善の余地を確かめるのに役立ちそうな機能だと思いました。


## 最後に
100Gbpsやそれ以上の帯域のインターコネクトの性能を十分に活用しようとすると、nsecオーダーでの遅延が性能にクリティカルに効いてくるせいか、CPUやカーネルの動作についてある程度把握していないと満足に読み進めることもできず、記事を書くにあたっては勉強不足を痛感しました。

カーネル分かりカーネル。