三层网络PPS极限测试

前言

最近忙于网络三层,四层,七层的测试工作团团转。在解决项目的问题时偶然浏览到了一片国外大牛写的博客。看了之后收到了很多启发决定翻译一下。这篇文章主要讲述了如何使用linux内核单网卡收发UDP达到百万级别pps。该博主的一些实验和实验数据给予了很多启发,借此机会想让更多的人了解。

源博文出处:https://blog.cloudflare.com/how-to-receive-a-million-packets/

上周,在一次闲聊中我无意中听到以为同事说:“linux网络协议栈太慢了!你不要期望linux能够在单核跑到5万pss。”这让我思考到,诚然我同意5万PPS是实际应用中可能打到的极限值,那linux网络协议栈的性能能到多少。让我们换个测试目标来找点乐子。

在linux系统上,写一个每秒接收一百万UDP包的程序有多难?希望回答这个问题将会是关于现代网络栈设计的一个很好的思路。

首先让我们做这样的一个估计:

1.测试pps(packets per second)值比测试Bps(bytes per second)值将更加有价值。你可以通过使用更好的流水线技术(pipelining)和发送更长字节的包来获得更高的Bps值。但是提高pps值显得更加困难。
2.由于我们现在针对pps,我们实验将会使用短UDP包来测试。这意味着32字节的UDP负载,74字节的二层以太网包长。
3.实验中我们将会使用两台服务器,一台作为“receiver”发包端,一台作为“sender”收包端。
4.两台服务器均有两颗6核2GHZ的Xeon处理器。在开启了超线程之后每台服务器上有24颗core。每台服务器上有一个由solarflare提供的10G多队列网卡,服务器上配置了11个多队列。稍后会对此多更多的介绍。
5.测试的源码可以从git上下载。
udpsender:https://github.com/majek/dump/blob/master/how-to-receive-a-million-packets/udpsender.c
udpreceiver:https://github.com/majek/dump/blob/master/how-to-receive-a-million-packets/udpreceiver1.c

先前准备:
我们使用端口4321来发送UDP包。在运行测试pps程序之前我们首先确保端到端的网络链路不会被iptables防火墙阻拦。

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来为后续测试提供便利:
定义发送端的测试IP:
receiver$ for i in seq 1 20; do
ip addr add 192.168.254.KaTeX parse error: Expected 'EOF', got '\ ' at position 16: i/24 dev eth2; \̲ ̲ done … ip addr add 192.168.254.30/24 dev eth3

  1. 最简单的实验

首先让我们做一个最简单的实验。定义一个简单的发送和接收,将发送多少个包。

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系统调用中比较有效的版本。我们查看一下收包输出结果:

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

用这种简单的方法我们可以得到197K到350K的pps输出值,不幸的是这种方式测得每次输出结果误差都比较大。这是由于程序运行在内核中,内核发生上下文切换导致的。将进程和CPU做和绑定将会有效的改善这种情况。

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

现在系统内核调度会将程序放在默认设定和绑定的内核中执行。 这改进了处理器缓存局部性,使测试结果更加一致,这正是我们想要的。

  1. 发送更多的包

370k的pps对于一个程序来说并不坏,但是它里我们目标的一百万pps仍然有差距。为了能够收到更多的包,首先我们必须发送更多的包。为何不考虑用两个独立的线程来发送包。

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可以看到包实际去向去哪了。

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

通过这个状态可以看到,网卡已经成功的将350Kpps包交付给了收包队列4号。rx_nodesc_drop_cnt是Solarflare特有的计数器,计数器显示的数字告知网卡有发送给内核的450Kpps包被丢弃。有些时候并不清楚为什么包会被丢弃。但在我们的例子当中,原因非常的明显:RX四号队列将包交付给了第四号CPU。但是四号CPU无法提供更多的运算能力来处理。对于这颗核来说处理350Kpps已经是极限值。这里通过“htop”命令来查看CPU状态可以得知:
在这里插入图片描述

  • 使用网卡多队列特性

过去,网卡只有一个RX队列,用于在硬件和内核之间传递数据包。这个设计有着很明显的限制,它不能传输超过单核可以处理包的上限,更多的包发送给该核只能被丢弃。利用多内核系统,网卡开始支持网卡多队列特性。这个设计可以简单地表述如下:每个接收队列都会绑定到与之对应的一颗核上。因此,一个网卡的所有传输队列可以与之对应到特定CPU上将网卡的性能最大化。但是这也导致了一个问题,网卡如何决定一个包交给哪个队列来处理。
在这里插入图片描述
round-robin(轮询策略)这种策略是不行的,因为这样会导致在单链接的情况下数据包重新排序。 另一种方案是根据对包进行hash来决定RX队列号。通常对以(src IP, dst IP, src port, dst port)的一组序列来进行哈希。这保证了单个流的包将始终位于完全相同的RX队列上,在单链接的情况下对包重新排序的情况将不会发生。

在我们实验用例中,要进行哈希的队列可能如下所示:

RX_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues
  • 多队列哈希算法

该哈希算法可以通过ethtool来进行配置,在我们示例中配置可以如下:

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协议,网卡会对数组(srcIP,dstIP)进行哈希。

receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
Cannot change RX network flow hashing options: Operation not supported

不幸的是我们的网卡不支持sdfn算法,我们还是考虑使用(src IP,dst IP)进行哈希运算。

  • 关于NUMA特性的说明

到目前为止,我们所有的包只流向一个RX队列,并且只核绑到一个CPU。让我们借此机会对不同cpu的性能进行基准测试。在我们做测试的服务器中,接收主机有两个独立的处理器,每一个都是不同的NUMA节点。

在我们选择对线程做pinning的时候,我们可以参考以下四个选项来选择所要pinning的核。
1.在另一个核上运行receiver,但是该核在与RX队列所pinning的核在相同的NUMA节点上。我们在上面实验可以看到性能大约是360kpps。
2.接收端与RX队列pinning到完全相同的核上,我们可以达到~430kpps。但它造成了高度的可变性。如果网卡被包压得喘不过气来,性能就会下降到零。
3.当接收端进程运行在处理RX队列的核上并且也是对应的HT上时,性能是通常在200kpps。
4.当运行程序和接收队列运行在不同的NUMA节点上不同的核时,测试收包大概能到330kpps。不过所得到的结果不稳定并没有太大实用价值。

  1. 接收端实用多个IP

使用NIC上的散列算法优化得到的结果非常有限,所以跨RX队列分发数据包的惟一方法是使用多IP地址。这里演示是如何发送数据包到不同的目的地ip:

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321

ethtool确认数据包进入不同的RX队列:

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

The receiving part:
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队列,第三个核心运行应用程序,可以获得~650k pps。 我们可以通过将流量发送到3或4个RX队列来进一步增加这个数字,但是很快测试程序遇到另一个限制。这一次rx_nodesc_drop_cnt没有增长,但是使用netstat命令查看“接收错误”的状态可以考到该数在增加。

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

这意味着即便网卡有能力接收包将包转发给内核,内核也无力将包回转给应用程序。在我们的测试用例中单核仅能够转发440kpps,剩余的390kpps + 123kpps由于测试程序接收它们的速度不够快而被丢弃。

  1. 接收端开启多队列

我们需要扩展接收应用程序。从开启多线程来增加接收数据的天真方法并不会很好地工作。

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接收缓冲区周围的锁上花费了不成比例的时间。详细有关缓冲区的问题描述可以参考此文章:
http://www.jcc2014.ucm.cl/jornadas/WORKSHOP/WSDP 2014/WSDP-4.pdf

使用多个线程从单个描述符接收数据并不是最优的。

  1. SO_REUSEPORT

幸运的是,Linux最近添加了一个解决方案:the SO_REUSEPORT flag(详情可参考:https://lwn.net/Articles/542629/) 当在套接字描述符上设置此标志时,Linux将允许许多进程绑定到同一个端口。事实上,任何数量的进程都可以绑定,并且各个进程将分摊负载之间。

使用SO_REUSEPORT,每个进程都有一个单独的套接字描述符。因此,每个都将拥有一个专用的UDP接收缓冲区。这避免了以前遇到的进程争用问题:

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

这才像话!吞吐量现在还不错!在进行了进一步调查之后显示出更多的改进空间。即使我们启动了四个接收线程,负载也没有均匀地分布在它们之间:
在这里插入图片描述
两个线程接收了所有的工作,另外两个线程根本没有收到包。这是由散列碰撞引起的,但是这次是在SO_REUSEPORT层。

结束语
我还做了一些进一步的测试,在一个NUMA节点上使用完全对齐的RX队列和接收线程可以获得1.4 mpp。在不同的NUMA节点上运行接收器导致数字下降,达到最多1mpp。

总之,如果你想要一个完美的表现,你需要作如下改进:

  1. 确保流量均匀分布在RX队列和SO_REUSEPORT进程中。在实践中,只要有大量的连接(或流),负载通常是均匀分布的。

后言:
该博文的的小程序可以很好地测试三层网络,通过增加“udpsender”的数量可以达到限速,不失为除开pktgen工具之外另外一种很好地测试方法。git上的代码经过编译之后可以使用,以下链接为编译好的可以直接在centos系统直接使用。
https://download.csdn.net/download/minxihou/10838692

展开阅读全文

没有更多推荐了,返回首页