F-Stack发送零拷贝介绍

数据包在服务器的处理分接收和发送两个方向,收包方向因为我们自己本身的业务场景涉及收包数据很少,后续另行介绍。

本文主要介绍F-Stack发包方向上当前的零拷贝处理方案、效果和应用场景的选择,发包方向上的数据拷贝目前主要为两个阶段,一是协议栈数据拷贝到DPDK的rte_mbuf中,二是应用层调用socket发送接口时会将数据从应用层拷贝到FreeBSD协议栈,下面将分别进行介绍。

协议栈到DPDK

该过程的零拷贝实现由 @jinhao2 提交的Pull Request #364 合并到F-Stack主线中,相关实现细节可以参考相关代码,这里仅对实现方案进行简要介绍。

方案介绍

  • 进程初始化时,通过mmap 为 BSD 堆栈分配指定大小的内存(目前默认256M),可以通过在config.ini中通过参数memsz_MB修改默认配置。
  • 通过 mlock() 固定物理内存,防止被换出到交换分区造成内存虚拟地址和物理地址对应关系的变化。
  • 计算每个页面的起始地址并保存,包括虚拟地址和物理地址,物理地址的计算可以通过DPDK的提供的相关接口进行。
  • 初始化一个堆栈结构来管理所有分配的页面。
  • 通过从已经初始化的堆栈结构中获取/释放一页来替换 ff_mmap()/ff_munmap()的实际mmap行为,而BSD协议栈调用kmem_malloc()/kmem_free()时调用ff_mmap()/ff_munmap()来获取内存页。
  • 在将BSD协议栈mbuf的数据地址赋值给DPDK的rte_mbuf时用于判断是否为初始化申请的内存池中的地址,并通过虚拟地址查找对应的物理地址,分别赋值给rte_buf结构的buf_addr/buf_physaddr,而不再实际进行内存拷贝。
  • 使用一个循环队列保存发送的mbuf的指针,队列的长度应该与NIC的tx_queue_length相同。在队列中的一项被推入新值之前,旧的 mbuf 必须由 NIC 处理并且可以安全地释放。
  • 如果mbufext_cluster类型,其中包括一个rte_mbuf,表示是收包时零拷贝附加的数据地址,则使用 rte_pktmbuf_clone()代替。

使用方式及注意事项

使用方式

该功能默认并未开启,需要通过在lib/Makefile中打开编译选项FF_USE_PAGE_ARRAY,并重新编译F-Stack lib 库和应用程序后才能生效。

其他应用编程及使用方式与常规拷贝模式没有区别,对应用层透明。

注意事项

  • 内存池初始化时在本进程通过mmapmlock申请,为进程私有地址空间,相关内存不能传递到其他进程使用。
    • 可以考虑在初始化时映射大页内存或者使用共享内存(同样需要SHM_LOCkmlock锁定内存,防止交换)来达到可以跨进程使用的目的,但是对应的地址保存和查找结构也需要进行变更,一般应用建议避免跨进程使用即可,不建议进行修改。
  • 协议栈到DPDK的零拷贝功能可以单独开启FF_USE_PAGE_ARRAY使用,也可以与零拷贝发送接口FF_ZC_SEND一起开启使用。
  • 此处减少的内存拷贝是否对应用性能有提升还需要结合具体的应用进行实际测试,数据包在一定大小且使用方式合适时则可以有一定的性能优化效果,但优化效果并不一定很明显,比如只有2-3%左右的提升。

应用层到协议栈

通过提供单独的零拷贝API,使应用层在通过socket接口发送数据时,避免应用层到BSD协议栈的数据拷贝,具体细节见提交e12886c,下面将进行较为具体的介绍。

方案介绍

  • 提供单独的零拷贝结构体ff_zc_mbuf,用于应用层缓存结构,后续应用层的数据操作和发送都应该使用该结构体,具体类型如下所示:struct ff_zc_mbuf {
      void *bsd_mbuf;         /* 指向BSD mbuf链的头节点 */
      void *bsd_mbuf_off;     /* 指向BSD mbuf链中偏移off后的当前节点 */
      int off;               /* mbuf链中的偏移量,应用层不应该直接修改 */
      int len;               /* 申请的mbuf链缓存的总长度,小于等于mbuf链实际能承载的数据长度 */
    };
  • 提供接口ff_zc_mbuf_get(),用于应用提前申请包含可以由内核直接使用的mbuf的结构体作为应用层数据缓存,接口声明如下。int ff_zc_mbuf_get(struct ff_zc_mbuf *m, int len);该接口输入struct ff_zc_mbuf *指针和需要申请的缓存总长度,内部将通过m_getm2()分配mbuf链,首地址保存在ff_zc_mbuf结构的bsd_mbuf变量中,后续可以传递给ff_write()接口。其中m_getm2()为标准socket接口拷贝应用层数据到协议栈时分配mbuf链的接口,所以使用该接口范围的mbuf链作为应用层缓存,可以在发送数据时完全兼容。
  • 提供了缓存数据写入函数ff_zc_mbuf_write(),函数声明如下,
  • int ff_zc_mbuf_write(struct ff_zc_mbuf *m, const char *data, int len); 应用层在保存待发送的数据时,应通过接口ff_zc_mbuf_wirte()直接将数据写到ff_zc_mbuf指向的mbuf链的缓存中,ff_zc_mbuf_wirte()接口可以多次调用写入缓存数据,接口内部自动处理缓存的偏移情况,但多次总的写入长度不能超过初始申请的缓存长度
  • 应用调用ff_write()接口时指定传递ff_zc_mubf.bsd_mbufbuf参数,示例如下所示,ff_write(clientfd, zc_buf.bsd_mbuf, buf_len);在m_uiotombuf()函数中,直接使用传递的mbuf链的首地址,不再额外进行mbuf链的分配和数据拷贝,如下所示,#ifdef FSTACK_ZC_SEND
    if (uio->uio_segflg == UIO_SYSSPACE && uio->uio_rw == UIO_WRITE) {
    m = (struct mbuf *)uio->uio_iov->iov_base; /* 直接使用应用层的mbuf链首地址 */
    uio->uio_iov->iov_base = (char *)(uio->uio_iov->iov_base) + total;
    uio->uio_iov->iov_len = 0;
    uio->uio_resid = 0;
    uio->uio_offset = total;
    progress = total;
    } else {
    #endif
    m = m_getm2(NULL, max(total + align, 1), how, MT_DATA, flags); /* 拷贝模式分配mbuf链*/
    if (m == NULL)
    return (NULL);
    m->m_data += align;

    /* Fill all mbufs with uio data and update header information. */
    for (mb = m; mb != NULL; mb = mb->m_next) {
    length = min(M_TRAILINGSPACE(mb), total – progress);

    error = uiomove(mtod(mb, void *), length, uio); /* 拷贝模式拷贝应用层数据到协议栈 */
    if (error) {
    m_freem(m);
    return (NULL);
    }

    mb->m_len = length;
    progress += length;
    if (flags & M_PKTHDR)
    m->m_pkthdr.len += length;
    }
    #ifdef FSTACK_ZC_SEND
    }
    #endif
  • ff_write()函数成功返回后,之前申请的ff_zc_mbuf结构内部mbuf链数据不需要释放,该结构可以在函数ff_zc_mbuf_get()中复用重新分配BSD的mbuf
    • 不能够再次直接在ff_zc_mbuf_wirte()使用,必须重新调用ff_zc_mbuf_get()分配新的mbuf链之后才可以继续使用

使用方式及注意事项

使用方式

该功能默认并未开启,需要通过在lib/Makefile中打开编译选项FF_ZC_SEND,并重新编译F-Stack lib 库和应用程序后才能生效。

零拷贝发送接口的使用方式与标准socket接口也有区别,具体可以参考前面的方案介绍及示例代码

注意事项

  • 使用零拷贝发送接口需要对原有应用进行修改才能接入,且并不一定有很明显的性能提升,所以默认不开启。
  • 零拷贝发送接口可以单独开启FF_ZC_SEND使用,也可以与FF_USE_PAGE_ARRAY一起开启使用。
  • 与协议栈到DPDK的零拷贝类似,此处减少的内存拷贝是否对应用性能有提升还需要结合具体的应用进行实际测试,在特定应用场景下才会有一定的性能提升,但效果并不一定很明显,比如只有2-3%左右的提升。
  • 目前struct ff_zc_mbuf *的结构是对外暴露给应用层的,可以更方便的进行测试使用,后续不排除隐藏该数据结构的可能。

F-Stack常用配置参数介绍

目前F-Stack的配置文件中包含有以下8个部分,下面将分别进行简单的介绍:

[dpdk]、[pcap]、[portN]、[vdevN]、[bondN]、[kni]、[freebsd.boot]、[freebsd.sysctl]

[DPDK]

设置运行DPDK的相关参数,如果是DPDK也有的参数,则含义和使用方法同DPDK参数。

lcore_mask

16进制位掩码,用于设置进程运行在哪些CPU核心上。如fc表示使用CPU第2-7个核,不使用第0和1核。

建议优先使用物理核,数据尽量不要跨NUMA节点交互,可以空出前2个CPU核心给系统,且配置其他进程不调度到DPDK要使用的CPU核心上。

channel

内存通道数,一般无需修改,使用默认值即可。

base_virtaddr

指定mmap内存到主进程的虚拟地址,默认关闭。

某些特定场景下可能需要使用,如自动分配的虚地址与其他地址冲突时,可以多次尝试使用DPDK启动时的错误提示进行指定或在应用中尝试修改初始化F-Stack(DPDK)的位置。

promiscuous

0或1,是否开启网卡的混杂模式,默认开启。

建议开启,尤其是对可能需要处理多播包(如OSPF协议包)等场景。

numa_on

0或1,是否开启NUMA支持,默认开启。

建议开启。

tx_csum_offoad_skip

0或1,是否关闭发包校验和的卸载,默认否。

当网卡支持发包校验和卸载时,F-Stack正常总是开启该功能,一般不需要修改。该参数配置为1时,则不会设置发包校验和的网卡硬件卸载,用于某些特殊场景,如需要发送错误的校验和用于测试、或某些网卡宣传支持发包校验和卸载但实际并未计算校验和等。

tso

0或1,是否开启TCP分段卸载(TCP segment offload),默认关闭。

理论上开启应该有更好的性能表现,TCP协议栈无需对大包进行软件分段,交给网卡硬件进行,但目前实测并未表现出性能优势,所以默认关闭。

vlan_strip

0或1,是否开启VLAN卸载(TCP segment offload),默认开启。

开启后,网卡会将收包的VLAN头卸载剥离,某些特殊场景可能需要关闭该功能,如KNI需要VLAN的场景,详细介绍见前期文章《F-Stack vlan 的支持与使用》。

idle_sleep

当前循环未收到数据包的空闲休眠时间,单位微秒,默认0,即一直保持轮询模式,不进行休眠,CPU使用率为100%。

线上实际使用时建议设置为不超过100的值,即当本次循环没有收到数据包时,休眠不超过100微秒,主要目的是降低CPU使用率,且实际对线上业务基本无影响,但是会增加单连接小数据量的收包延迟,如果单纯想测试收发包延迟情况或不在意线上CPU使用率一直保持100%,可以设置为0

目前DPDK已经支持中断+轮询模式,但是F-Stack初始开发时(2012年)DPDK尚未支持中断模式,所以在当时的业务中引入了该参数用于降低CPU使用率,虽然后来DPDK支持了中断模式,但因为影响基本可以忽略,F-Stack目前暂未支持中断模式。

pkt_tx_delay

F-Stack发包延迟时间,单位微秒,默认为100,支持配置范围[0,100],配置超过100时强制置为100。

类似于TCP中的delay ack的概念,为了使用批量发包提升最大的并发吞吐量性能,F-Stack在发包时会先进行缓存并延迟发送,实际发包的触发条件有两个,凑够一次批量发包的包数(目前硬编码为32),或延迟发包时间超时。

默认延迟发包可以提升大并发下的吞吐量性能,但是会增加单连接小数据量的发包延迟,如果单纯想测试收发包延迟情况,可以设置为0,则每次发包都会立即实际发送。除了测试使用,一般不建议修改为0

symmetric_rss

0或1,是否开启对称RSS,默认否。

网关或类似服务可以开启对称RSS选项,通过设置特殊的RSS hash key,使四元组中IP和端口号互换的数据包可以收到同一队列(CPU)中,主要目的是增加CPU的缓存命中率 。

pci_whitelist

F-Stack(DPDK)可以识别加载的网卡设备白名单,默认为所有支持的设备。参数值为设备号,如02:00.0或02:00.0,03:00.0,主要用于仅希望指定的网卡设备可以被DPDK识别使用时。

port_list

F-Stack(DPDK)实际要接管的网卡(网口)设备序号列表,从0开始。如00,1,20-2等。

可以与pci_whitelist配合使用,仅从白名单中的网口设备从0开始进行排序编号。

设置了接管几个网口,后面就应该配置几个对应的[portN]的地址信息配置段,N为网卡网口序号。

当使用bonding模式时,参数值应为bonding虚拟设备的网口号(从实际的设备数往上递增),不应该包含slave设备的网口号。

nb_vdev

配置有几个容器虚拟设备,设置了几个设备,后面就应该配置几个对应的[vdevN]的信息配置段,N为容器编号。

因为容器是F-Stack是第一个支持的虚拟设备,此处的vdev仅用于配置容器参数,其他虚拟设备则使用对应的设备类型来配置,如bonding。

nb_bond

配置有几个bonding虚拟设备,设置了几个设备,后面就应该配置几个对应的[bondN]的信息配置段,N为bonding设备编号。

file_prefix

文件前缀,主要用于同时启动不同的F-Stack(DPDK)进程组,通过不同的配置文件中配置不同的文件前缀,可以同时启动多个主进程及其对应的辅进程,某些特殊场景可能会用到。

no_huge

0或1,是否不使用大页内存,默认为0,即使用大页内存,一般无需修改。

[PCAP]

抓包相关配置选项,每个进程分别写入自己的抓包文件。需要注意的是开启抓包将会严重影响性能,一般仅调试时使用。

enable

0或1,是否开启抓包,默认否。

snaplen

每个包的最大抓包长度,默认96字节。

savelen

单个抓包文件的大小限制,达到限制后将重新打开新的抓包文件,默认值16777216,即16M。

savepath

抓包文件保存目录,默认为.,即程序启动目录。

[portN]

配置网口的地址等相关信息,N对应[DPDK]段的port_list值,如0,1,2,5等,每一个接管的网口都需要单独的一段[portN]来进行配置

addr

网口需要配置的IPv4地址,此处仅支持配置一个IP。

netmask

IPv4掩码。

broadcast

IPv4广播地址。

gateway

IPv4路由地址。

if_name

可选参数,配置F-Stack中的设备名称,默认为f-stack-N,N从0开始,与PortN对应。>= 1.22。

addr6

可选参数,配置本网口的IPv6地址。

prefix_len

IPv6的prefix len,配置了addr6之后才需要配置,默认64。

gateway6

配置了addr6之后的可选参数,当本地IPv6的环境不使用NDP时才需要配置(如腾讯云),如果使用NDP则不需要配置(如AWS)。

vip_ifname

虚拟IP配置到哪个网口设备,默认f-stack-N,根据实际需要可以配置到lo0等设备。>= 1.22。

vip_addr

分号分隔的IPv4虚拟地址,最大支持64个虚拟地址。目前不支持单独配置掩码和广播地址,在函数ff_veth_setvaddr中硬编码使用255.255.255.255x.x.x.255。>= 1.22。

vip_addr6

分号分隔的IPv6虚拟地址,最大支持64个虚拟地址。>= 1.22。

vip_prefix_len

虚拟IPv6地址的prefix_len,所有地址只能使用统一前缀,默认为64。>= 1.22。

lcore_list

使用哪些CPU核心处理本网口的队列,格式与port_list一致,默认为全部CPU核心都绑定处理本网口的队列。

不同进程之间是数据隔离的,如果需要在不同网口间转发数据,必须同一个CPU核心同时绑定处理多个网卡的队列或自行进行IPC,使用时需要注意,一般无特殊需求的话,无需修改配置该参数。

slave_port_list

当本网口为bonding虚拟设备的时候需要配置该参数,指定组成本bonding的slave网口,配置格式与port_list一致,如0,10-1

[vdevN]

配置容器的相关信息,N对应[DPDK]段的nb_vdev值,如0,1,2,5等,每一个虚拟设备都需要单独的一段[vdevN]来进行配置

iface

默认值/usr/local/var/run/openvswitch/vhost-userN,不应该设置修改。

path

必选参数,容器内的vhost user设备路径,如/var/run/openvswitch/vhost-userN,

queues

可选参数, vuser的最大队列数,应等于或大于F-Stack的进程数,默认为1。

queue_size

可选参数,队列大小,默认值256。

mac

可选参数,vuser设备的MAC地址,默认值为随机地址。

如果vhost使用物理网卡,则vuser的MAC地址应设置为物理网卡的MAC地址。

cq

可选参数,如果队列数queues为1,则设置为0,默认值。如果队列数queues大于1,则设置为1。

[bond0]

配置bonding虚拟设备的相关信息,N对应[DPDK]段的nb_vdev值,如0,1,2,5等,每一个虚拟设备都需要单独的一段[bondN]来进行配置。

此处仅简单介绍下配置项,bonding的具体信息可以参考DPDK的帮助文档 http://doc.dpdk.org/guides/prog_guide/link_bonding_poll_mode_drv_lib.html

需要注意的时,当前DPDK的bonding驱动不支持多进程模式,而F-Stack目前仅支持多进程模式,多线程模式需要使用方自行修改测试。

mode

bonding模式,默认为模式4,该模式需交换机配置支持。

slave

子设备号列表,多个子设备时需设置多个k=v格式,逗号分隔,如slave=0000:0a:00.0,slave=0000:0a:00.1

primary

主设备号,如0000:0a:00.0

mac

bonding设备的MAC地址,一般可以设置为主网口的MAC地址。

其他可选参数

具体含义可以参考DPDK相关文档

  • socket_id=0
    • NUMA节点号,根据实际设置
  • xmit_policy=l23
    • 转发负载均衡策略
  • lsc_poll_period_ms=100
  • up_delay=10
  • down_delay=50

[kni]

配置kni数据包转发到内核相关参数,配置文件中默认未开启kni段,如需要需自行取消注释并配置相关参数。

enable

0或1,是否开启kni。

method

rejectaccept,配置kni转发的默认策略。

如果设置为reject,则下面tcp_portudp_port指定的数据转发到F-Stack进程协议栈,除此之外其他数据包都转发到内核。

如果设置为accept,则下面tcp_portudp_port指定的数据转发到内核,除此之外其他数据包都转发到F-Stack进程协议栈。

tcp_port

kni转发过滤器过滤的TCP端口,配置格式与port_list一致,如80,443

udp_port

kni转发过滤器过滤的UDP端口,配置格式与port_list一致,如53,443

kni_action

defaultalltoknialltoff,可选参数,可以通过工具knictl分进程控制不同进程的kni转发策略。>= 1.22。

default,默认值,使用上面的通用kni转发配置。

alltokni,所有数据包通过kni转发到内核。

`alltoff,所有数据包转发到F-Stack协议栈。

FreeBSD

网络调优配置,包含一些F-Stack独有的配置,其他为FreeBSD的配置项,绝大部分FreeBSD的配置项都支持,但此处仅列举了少数配置,详细的配置项可以通过工具ff_sysctl -a获取,配置项的详细信息则可以参考FreeBSD的man page。

[freebsd.boot]

hz

定时器每秒扫描频率,默认为100,即10ms精度,无特殊需求一般无需修改。

调大该值可以提高定时器精度,但是不一定会提高性能,目前建议不要设置太高,如不超过1000。

注意:目前F-Stack 1.22版本(尚未正式发布)使用的FreeBSD 13.0,支持开启RACK和BBR,而RACK和BBR都依赖高精度定时器,目前该版本的RACK和BBR暂时都无法正常工作,不排除会受定时器精度影响,后续将进行调试排查。

physmem

一个进程使用的内存大小,单位字节,默认256M,无特殊需求无需修改。

memsz_MB

开启编译选项FF_USE_PAGE_ARRAY之后有效,每进程mmap的页面数组内存大小,单位M字节,默认256M,无特殊需求无需修改。

FF_USE_PAGE_ARRAY编译选项用于开启发送数据包时FreeBSD协议栈到DPDK的零拷贝,虽然减少了内存数据拷贝,但是因为多了一些其他操作,性能不一定提升,如小数据包发送时,开启该选项是否能提升性能需要使用方在自己的使用场景单独进行对比测试

目前应用层到FreeBSD协议栈的socket接口的发包零拷贝也已经支持,正在测试中,在某些特定场景会有一定的性能提升,同样的对特定应用场景是否有提升需使用方单独开启测试,预计很快将提交代码到1.22版本(dev分支),但该功能需要修改应用层的socket接口使用行为,由使用方自行选择是否使用。

fd_reserve

屏蔽一系列描述符以避免与内核的描述符空间重叠,默认1024,即应用层从1024开始分配fd。 您可以根据您的应用增加此值。

特别的,某些较老应用支持的fd范围有限,移植到F-Stack之后可能无法正常运行,需要减小该值。

其他协议栈选项

根据F-Stack调优过的协议栈选项,无特殊需求一般无需修改,相关限制数值都为进程级,非全局限制,因为F-Stack每个进程启动了一个独立的协议栈。部分参数值设置错误可能导致F-Stack进程的协议栈异常,如部分参数值要求必须为2的N次幂。

  • kern.ipc.maxsockets=262144
  • net.inet.tcp.syncache.hashsize=4096
  • net.inet.tcp.syncache.bucketlimit=100
  • net.inet.tcp.tcbhashsize=65536
  • kern.ncallout=262144
  • kern.features.inet6=1
    • 开启IPv6支持,IPv6的部分参数也可以参考前期文章《F-Stack IPv6 的支持与使用》。
  • net.inet6.ip6.auto_linklocal=1
  • net.inet6.ip6.accept_rtadv=2
  • net.inet6.icmp6.rediraccept=1
  • net.inet6.ip6.forwarding=0

[freebsd.sysctl]

  • kern.ipc.somaxconn=32768
    • 等待连接数,应用层可能也可以根据需要配置backlog
  • kern.ipc.maxsockbuf=16777216
  • net.link.ether.inet.maxhold=5
  • net.inet.tcp.fast_finwait2_recycle=1
  • net.inet.tcp.sendspace=16384
  • net.inet.tcp.recvspace=8192
  • #net.inet.tcp.nolocaltimewait=1
    • 开启该参数可能导致某些场景的IPv6异常,所以关闭。
  • net.inet.tcp.cc.algorithm=cubic
    • 设置拥塞算法为cubic,FreeBSD的默认拥塞算法为new reno。当参数net.inet.tcp.functions_default设置为freebsd时有效.
  • net.inet.tcp.sendbuf_max=16777216
  • net.inet.tcp.recvbuf_max=16777216
  • net.inet.tcp.sendbuf_auto=1
  • net.inet.tcp.recvbuf_auto=1
  • net.inet.tcp.sendbuf_inc=16384
  • net.inet.tcp.recvbuf_inc=524288
  • net.inet.tcp.sack.enable=1
  • net.inet.tcp.blackhole=1
  • net.inet.tcp.msl=2000
  • net.inet.tcp.delayed_ack=1
    • 早期版本F-Stack默认没有开启dealy ack,当前版本修改为默认开启,可以提高大并发场景的吞吐量性能,但是会增加单连接小数据量的延迟,如需测试相关场景,可以关闭该功能,参考dpdk.pkt_tx_delay选项。
  • net.inet.udp.blackhole=1
  • net.inet.ip.redirect=0
  • net.inet.ip.forwarding=0
    • 当需要进行IP转发,数据不需要到应用层时需要开启该选项。
  • net.inet.tcp.functions_default=freebsd
    • freebsdrackbbr,设置使用FreeBSD支持的传统拥塞算法(通过参数net.inet.tcp.cc.algorithm设置),还是使用rack或bbr。>= 1.22。
    • 注意:当前尚未正式发布的1.22版本中的rack和bbr尚不能正常工作,需要进一步调试,对希望使用bbr拥塞算法的同学可以一起来调试并提交Pull Request。

基于 F-Stack 的权威 DNS 单机 1 亿 QPS 性能优化实践

腾讯云 DNSPod 的权威 DNS 解析目前包含两个版本,基于 F-Stack 的 FTDNS 和基于内核协议栈的 DPDNS,其中 FTDNS 目前性能远高于 DPDNS,是腾讯云对外提供权威 DNS 服务的主力,而 DPDNS 目前主要用作异构容灾及部分特殊场景的部署,如部分只能使用 CVM 或私有化部署的场景。

本文主要介绍 FTDNS 在对 100G 机型进行适配优化的过程,UDP DNS 如何达到单机 1 亿 QPS 的性能,主要涉及网络 I/O 的性能优化和 DNS 解析计算资源的平衡分配,对于 TCP DNS 和内核版的 DPDNS 的性能优化后续将专门进行介绍。

测试平台

测试程序:FTDNS,权威DNS解析程序
F-Stack: 1.21(DPDK 19.11)
OS: Tencent tlinux release 2.2 (Final)
Kernel: 4.14.105-1-tlinux3-0020
CPU: AMD EPYC 7K62 48-Core Processor * 2
NIC: Mellanox Technologies MT28800 Family [ConnectX-5 Ex]
Device type: ConnectX5
Name: MCX516A-CDA_Ax_Bx
Description: ConnectX-5 Ex EN network interface card; 100GbE dual-port QSFP28; PCIe4.0 x16; tall bracket; ROHS R6

Run to completion 架构

因为 DNS 解析的总体逻辑相对比较简单,除了网络 I/O 外只有查找本地缓存查找,所以 FTDNS 在常规架构下完全使用 RTC(Run to completion) 架构以达到更好的性能,网卡收发队列、协议栈、应用与 CPU 核心一一绑定,如下图所示

FTDNS(RTC)
【注】简化结构,仅简单示意 UDP DNS

对于 UDP DNS 请求,直接通过网卡的 RSS 功能将请求包负载收包至已经绑定到各个队列和 CPU 的 FTDNS 业务进程中,并且使用 F-Stack lib 提供的 ff_regist_packet_dispatcher() 函数直接注册回调函数对 UDP 的 DNS 请求包直接进行应用层的解析处理,并直接回包,旁路掉同为用户态的 FreeBSD 的网络协议栈。

性能测试

在拿到 100G 的机器后,使用原有程序直接进行了性能测试,最优性能表现如下图所示

32 进程 RTC QPS
32 进程 RTC CPU 使用率

该测试机型单个 NUMA 节点的 CPU 为 48 核 96 线程 ,对应一张 100G 网卡,为了达到最优性能,正常只使用 48 个物理核进行测试,通过对不同核数分别进行测试,发现在使用 32 核左右时才能达到最优性能,但是也只有 76M 的 QPS,离一亿的小目标差距有点大,接下来进行瓶颈分析。

瓶颈分析

因为直接启动 48 个业务进程测得的性能很低,只有 35M QPS 左右,而此时 CPU 并没跑满,空闲都在一半以上,如下图所示

48 进程 RTC QPS
48 进程 RTC CPU 使用率

所以分别启动不同数量的业务进程分别测得性能数据,且为了排除 FTDNS 业务应用本身性能的影响,同时做了部分关闭 DNS 实际解析逻辑(其他处理逻辑保留)的 echo server 的对比测试,测试结果大致如下

进程数 空载性能(QPS) DNS性能(QPS)
4 80 M /
8 107 M 19 M
12 113 M 28 M
16 113 M 38 M
20 84 M 47 M
24 80 M 56 M
32 77 M 76 M
40 40 M 40 M
48 37 M /

很明显,从以上数据可以得出几个结论:

  1. 收发包性能受队列数影响很大:当队列数较少时,收发包性能主要受 CPU 性能本身影响;当接近 16 个队列时,收发包性能最优,可以达到 113M QPS(非单纯收发的最高性能,因为还包括其他处理逻辑),之后性能急剧下降,分析主要是受 PCIE 通道数量为 16 个的影响,当队列数超过 16 后造成性能下降(此条以后如拿到其他配置机型后可做对比测试进行验证)。
  2. DNS 解析查询性能可线性扩展:当进程数较少时,FTDNS 的性能主要受限于 CPU 的计算性能,在达到收发包极限前,性能可以随进程数增长而线性增长(纯内存缓存、无共享、无锁等),超过 32 进程之后则受收发包性能影响,CPU 出现大量空闲。

优化方向

1. 调整网卡相关参数

通过查看 MLX5 网卡的相关参数文档,对部分参数进行组合调整并压测验证,如 mprq_en, rxqs_min_mprq, mprq_log_stride_size, mprq_max_memcpy_len, txqs_min_inline, txq_mpw_en 等,期望可以达到在网卡开启更多接收队列(如 48 个)也能达到更高的收发包性能,最终通过大量的测试表明,在48队列时网卡的收发包性能依然很低,辅证是由 PCIE 通道数引起的性能下降,此处不再详细展开。

2. 架构优化

2.1 Pipeline

既然网卡开启 16 个接收队列时可以达到最好的接收性能,那么很自然的一个想法,将业务程序的架构由 Run to completion 改为 Pipeline 架构,只开启 16 个接收队列专门用于接收 DNS 请求,再通过 rte ring 将请求报转发到其他业务进程进行实际的 DNS 解析后再直接发送出去,虽然单进程性能会因为 CPU cache miss 的问题急剧下降,但是可以使用更多的 CPU 运行业务进程来弥补,架构简图如下所示。

Pipeline 架构
【注】红色箭头为与 RTC 架构的数据包流向的区别

F-Stack 本身已经提供了函数 ff_regist_packet_dispatcher() 用于对接收到的数据包进行重新分发,且 FTDNS 中已经使用来直接进行 UDP DNS 的解析,稍作修改前 16 个进程仅分发,不再处理 DNS 解析即可,但是实际测试发现转发性能太差,perf 分析主要是软分发回调函数是对数据包一个个进行处理的,性能较差。

所以不得不侵入式修改 F-Stack 的转发逻辑,在 F-Stack 的收发包主循环中,
前 16 个进程 在接收到数据包后直接批量根据配置转发到业务进程中,转发到 ring 的性能得到提升,但业务进程单核性能也下降明显,实测结果如下图所示,

48 进程 Pipelie QPS
【注】该统计将 F-Stack 对流量信息统计进行了修改,软分发也统计到了 worker 进程上,更直观

48 进程 Pipelie CPU

此时 48 核为同一个 NUMA 节点的物理核心,全部使用后性能可以达到 68M QPS,单核性能下降约 12%,符合前期的心里预期,查看 perf 信息,热点也符合预期(如下图所示)。

跨 CPU 访问数据热点

如果有更多业务进程,是否就可以达到小目标了呢?因为机器架构限制的原因,本物理核的另外 48 个超线程核心很难使用到,且根据历史经验,即使使用性能提升也非常小,所以直接继续使用另一个 NUMA 节点的 CPU 的物理核心进行进一步测试,可以用到 80 个 CPU 核心,对应的收包分发线程调整为 20 进程,测试得到 Pipeline 的最优性能如下图所示。

80 进程 Pipeline QPS
【注】:此处 PPS 统计使用的是 F-Stack 原始统计方式,不够直观

80 进程 Pipeline CPU

性能有很大进展,已经达到了 95M QPS,离小目标不远了,但是也可以看出来进程 48 – 79 跨 NUMA 访问的 32 个进程 CPU 使用率是高于本 NUMA 节点的 CPU 的,查看 perf 信息同样证明了跨 NUMA 访问存在的问题。

此架构下继续调整尝试多次,包括继续增加 CPU 等,也无法达到更好的性能,只好尝试其他方向。

2.2 Run to completion + Pipeline

分析 48 进程 Pipeline 架构的 CPU 图可以发现,dispatcher 进程的 CPU 尚有一半空闲,是否也可以利用起来呢?

继续对分发部分代码进行稍微的修改, dispatcher 进程可以支持按照配置的分发进程数自动将收包分别交由本进程的应用层进行解析处理或者通过 Ring 转发到其他 worker 进程,架构如下图所示

RTC + Pipleline 架构
【注】:红色箭头表示与 Pipeline 架构不同的数据包流向

经过多次尝试调整不同进程的数量以及数据包分发比例,最终还是每个 dispatcher 与 worker 进程处理相同量的数据包,但是一个 dispatcher 进程可以根据配置自动对应多个 worker 进程,并测试得到了此时的最优性能,如下图所示

48 进程 RTC + Pipeline PPS
48 进程 RTC + Pipeline CPU

此处达到了 92M的性能,但是总性能还是略差于 Pipleline 性能,优势就是只使用了 48 个 CPU,使用 24 dispatcher + 24 worker 与 16 dispatcher + 32 worker 总体性能相当,此时虽然 dispatcher 进程依然有部分 CPU 空闲,但是增加其 DNS 处理比列并不能提升整体性能了,主要是因为如果分配更多的 CPU 时间片到 DNS 业务处理,则整体轮询次数减少,导致收包能力下降。

此时但是小目标依然没能实现,还需要考虑其他优化点。

2.3 单进程性能提升

在 RTC 测试中,单核 DNS 的性能虽然达到 2.38M QPS,已经很高了,但是通过 perf 分析在 DNS 业务处理逻辑中查询缓存时的 Hash 和 Key 的字符串比较函数占用 CPU 较高,依然有一定的优化空间,先看下 FTDNS 内存缓存结构,如下图所示

FTDNS 内存缓存结构

内存缓存是常规的 Hash 表结构,解决 Hash 冲突使用的是拉链法,存储的是写入时已经组好 DNS 应答包格式的数据,且无共享(无任何多写的数据结构)、无锁(类似 RCU 锁思想,但是并不加锁)等。

对 Hash 的 Key 进行优化,去除了部分不必包含和字符串比较函数进行优化,去除了部分非必要的字段,减少了需要进行 Hash 计算的 Key 长度。

因为大部分 Key 较短,暂未使用 memcmp() 进行比较, 因为仅需要判断 Key 是否相等即可,所以自行实现字符串比较函数,去除标准比较函数中多余的操作。

优化完成后,Pipeline 和 RTC + Pipeline 架构的性能都达到了 1 亿(100M) QPS 的小目标,性能测试结果如下所示

80 进程 Pipeline QPS100M
80 进程 Pipeline CPU 100M
48 进程 RTC + Pipeline QPS 100M
48 进程 RTC + Pipeline CPU100M

虽然两种架构的性能都达到了目标,但是综合考虑 RTC + Pipeline 只需要使用一个 NUMA 节点的 48 个物理核心即可,可以节省大量的 CPU 计算资源,且性能只是略低,所以最终在 FTDNS 中采用 RTC + Pipeline 的架构,常规配置 dispatcher 进程数量为 0 表示使用 RTC 模式,如有需要可以随时修改配置切换为 RTC + Pipeline 模式。

其他问题

  1. 本次测试使用的是 AMD CPU + Mellanox 的网卡,对于其他组合后续拿到其他组合的机型(如 Intel 的 CPU,Intel 或 Broadcom 的网卡等)也需要进行对应的测试,测试性能及验证队列数多于 PCIE 通道数时性能下降的问题,该问题也与 Intel 工程师有过交流。
  2. 跨 CPU 核心访问数据性能下降问题 Intel 工程师表示后续会有新的指令集可能对性能提升会有帮助。
  3. 后续还需要对 TCP DNS,或者说 F-Stack 在 100GE 机型上的短/长链接 TCP 进行测试和优化。