一、认识和理解DPDK
Linux的网络瓶颈
以Linux为例,传统网络设备驱动包处理的动作可以概括如下:
-
数据包到达网卡设备。
-
网卡设备依据配置进行DMA操作。
-
网卡发送中断,唤醒处理器。
-
驱动软件填充读写缓冲区数据结构。
-
数据报文达到内核协议栈,进行高层处理。
-
如果最终应用在用户态,数据从内核搬移到用户态。
-
如果最终应用在内核态,在内核继续进行。
随着网络接口带宽从千兆向万兆迈进,原先每个报文就会触发一个中断,中断带来的开销变得突出,大量数据到来会触发频繁的中断开销,导致系统无法承受,因此有人在Linux内核中引入了NAPI机制(New API)。
在Linux系统中,网络数据包进入计算机后,通常需要经过协议处理(如TCP/IP协议栈)。即使在某些场景下不需要进行协议处理,数据包仍然需要从内核缓冲区复制到用户缓冲区。
将数据包从内核缓冲区复制到用户缓冲区涉及到系统调用和数据拷贝操作。这些操作会消耗CPU资源和时间,影响用户态应用程序从网络设备直接获取数据包的能力。
同时,对于某些网络功能节点(如数据转发节点),并不一定需要完整的TCP/IP协议栈。
Linux中有著名的高性能网络I/O框架Netmap,其减少了内核到用户空间的包复制。
NAPI机制
NAPI机制是一种 “中断 + 轮训” 收包机制。
NAPI 允许网络适配器在短时间内接收多个数据包,并将它们放入一个环形缓冲区中,然后产生一次中断通知CPU。这样可以减少中断的频率,提高系统的处理效率。
当网络适配器进入 NAPI 模式时,它将暂时停止产生中断,直到有足够多的数据包积累在缓冲区中,或者经过一段时间后再产生中断。这样可以减少处理每个数据包所需的中断处理次数。
当CPU处理接收到的数据包时,它会进入软中断上下文,在这个上下文中,CPU可以高效地处理多个网络数据包,从而进一步减少了从用户空间到内核空间的切换开销。
Netmap
Netmap 使用一个共享的环形缓冲区(即数据包池),在内核空间和用户空间之间共享。网络设备的数据包可以直接在内核空间的数据包池中进行处理,而无需立即复制到用户空间。
当用户空间需要处理数据包时,它可以通过映射共享的数据包池来直接访问数据包,而无需进行额外的数据复制操作。这种零拷贝技术显著降低了处理每个数据包时的CPU和内存开销。
Netmap 允许用户空间应用程序通过轮询方式高效地获取和处理数据包,而不是依赖于传统的中断驱动方式。这种方式在高速网络环境中可以显著提高数据包的处理效率和吞吐量。
Netmap没广泛使用。其原因有几个:
-
Netmap需要驱动的支持,即需要网卡厂商认可这个方案。
-
Netmap仍然依赖中断通知机制,没完全解决瓶颈。
-
Netmap更像是几个系统调用,实现用户态直接收发包,功能太过原始,没形成依赖的网络开发框架,社区不完善。
DPDK的基本原理
左边是原来的方式数据从 网卡 -> 驱动 -> 协议栈 -> Socket接口 -> 业务
右边是DPDK的方式,基于UIO(Userspace I/O)旁路数据。数据从 网卡 -> DPDK轮询模式-> DPDK基础库 -> 业务
用户态的好处是易用开发和维护,灵活性好。并且Crash也不影响内核运行,鲁棒性强。
DPDK支持的CPU体系架构:x86、ARM、PowerPC(PPC)
UIO机制
为了让驱动运行在用户态,Linux提供UIO机制。使用UIO可以通过read感知中断,通过mmap实现和网卡的通讯。
UIO(Userspace I/O)是运行在用户空间的I/O技术。Linux系统中一般的驱动设备都是运行在内核空间,而在用户空间用应用程序调用即可,而UIO则是将驱动的很少一部分运行在内核空间,而在用户空间实现驱动的绝大多数功能
核心优化 PMD
DPDK的UIO驱动屏蔽了硬件发出中断,然后在用户态采用主动轮询的方式,这种模式被称为PMD(Poll Mode Driver)。
UIO旁路了内核,主动轮询去掉硬中断,DPDK从而可以在用户态做收发包处理。带来Zero Copy、无系统调用的好处,同步处理减少上下文切换带来的Cache Miss。
相对于linux系统传统中断方式,Intel DPDK避免了中断处理、上下文切换、系统调用、数据复制带来的性能上的消耗,大大提升了数据包的处理性能。
运行在PMD的Core会处于用户态CPU100%的状态,网络空闲时CPU长期空转,会带来能耗问题。所以,DPDK推出Interrupt DPDK模式。
Interrupt DPDK模式可以使用中断模式驱动程序,并且可以和其他进程共享同个CPU Core,但是DPDK进程会有更高调度优先级。
大页内存
Linux操作系统通过查找TLB来实现快速的虚拟地址到物理地址的转化。由于TLB是一块高速缓冲cache,容量比较小,容易发生没有命中。当没有命中的时候,会触发一个中断,然后会访问内存来刷新页表,这样会造成比较大的时延,降低性能。Linux操作系统的页大小只有4K,所以当应用程序占用的内存比较大的时候,会需要较多的页表,开销比较大,而且容易造成未命中。
相比于linux系统的4KB页,Intel DPDK缓冲区管理库提供了Hugepage大页内存,大小有2MB和1GB页面两种,可以得到明显性能的提升,,这样就减少了虚拟页地址到物理页地址的转换时间,也减少了TLB-Miss。
CPU亲和性
随着CPU的核心的数目的增长,Linux的核心间的调度和共享内存争用会严重影响性能。利用Intel DPDK的CPU affinity可以将各个线程绑定到不同的cpu,可以省去来回反复调度带来的性能上的消耗。
在一个多核处理器的机器上,每个CPU核心本身都存在自己的缓存,缓冲区里存放着线程使用的信息。如果线程没有绑定CPU核,那么线程可能被Linux系统调度到其他的CPU上,这样的话,CPU的cache命中率就降低了。
利用CPU的affinity技术,一旦线程绑定到某个CPU后,线程就会一直在指定的CPU上运行,操作系统不会将其调度到其他的CPU上,节省了调度的性能消耗,从而提升了程序执行的效率。
软件调优
比如说有以下几种代码实践:
-
结构的cache line对齐
-
数据在多核间访问避免跨cache line共享
-
适时地预取数据
-
多元数据批量操作
-
使用CPU指令直接操作
-
分支预测,预先做指令读取
DPDK 核心组件
环境抽象层(EAL):为DPDK其他组件和应用程序提供一个屏蔽具体平台特性的统一接口,环境抽象层提供的功能主要有:DPDK加载和启动;支持多核和多线程执行类型;CPU核亲和性处理;原子操作和锁操作接口;时钟参考;PCI总线访问接口;跟踪和调试接口;CPU特性采集接口;中断和告警接口等。
堆内存管理组件(Malloc lib):堆内存管理组件为应用程序提供从大页内存分配对内存的接口。当需要分配大量内存小块时,使用这些接口可以减少TLB缺页。
环缓冲区管理组件(Ring lib):环缓冲区管理组件为应用程序和其他组件提供一个无锁的多生产者多消费者FIFO队列API:Ring。Ring是借鉴了Linux内核kfifo无锁队列,可以无锁出入对,支持多消费/生产者同时出入队。
内存池管理组件(Mem pool lib):为应用程序和其他组件提供分配内存池的接口,内存池是一个由固定大小的多个内存块组成的内存容器,可用于存储相同对象实体,如报文缓存块等。内存池由内存池的名称来唯一标识,它由一个环缓冲区和一组核本地缓存队列组成,每个核从自己的缓存队列分配内存块,当本地缓存队列减少到一定程度时,从内存缓冲区中申请内存块来补充本地队列。
网络报文缓存块管理组件(Mbuf lib):提供应用程序创建和释放用于存储报文信息的缓存块的接口,这些MBUF存储在内存池中。提供两种类型的MBUF,一种用于存储一般信息,一种用于存储报文信息。
定时器组件(Timer lib):提供一些异步周期执行的接口(也可以只执行一次),可以指定某个函数在规定的时间异步的执行,就像LIBC中的timer定时器,但是这里的定时器需要应用程序在主循环中周期调用rte_timer_manage来使定时器得到执行。定时器组件的时间参考来自EAL层提供的时间接口。
DPDK的运行形式
大部分DPDK的代码是以lib的形式运行在用户应用的进程上下文,为了达到更高的性能。应用通常都会多进程或者多线程的形式运行在不同的lcore上
多进程的场景下,为了保证关键信息(比如内存资源)的一致性, 不同进程会把公共的数据mmap同一个文件,这样任何一个进程对数据的修改都可以影响到其他进程。
二、Cache与大页优化
Cache简介
目前Cache主要由三级组成: L1 Cache, L2 Cache和Last Level Cache(LLC)。 L1最快,但容量小,可能只有几十KB。LLC慢,但容量大,可能多达几十MB。
L1和L2 Cache一般集成在CPU内部。另外,,L1和L2 Cache是每个处理器核心独有的 ,而LLC是被所有核心所共享的。
Intel处理器对各级Cache的访问时间一直都保持稳定, 见下表所示
除以上Cache外,现代CPU中还有一个TLB (Translation Look-aside Buffer) Cache,专门用于缓存内存中的页表项。TLB Cache使用虚拟地址进行搜索,直接返回对应的物理地址,相对于内存中的多级页表需要多次访问才能得到最终物理地址。
Cache地址映射与变换
内存容量很大, 一般是GB级,而Cache最大才几十MB。 要把内存数据放到Cache中,需要一个分块机制和映射算法。
Cache和内存以块为单位进行数据交换,块的大小通常以在内存的一个存储周期内能够访问到的数据长度为限,当前主流块的大小为64字节,这也就是Cache line的含义。
而映射算法分为全关联型,直接关联型和组关联型3种。
-
在全关联型映射中,数据块可以存储在缓存的任何位置,而不受特定的限制。
-
直接关联型映射将每个主存块映射到缓存中唯一确定的位置。
-
组关联型映射结合了全关联型和直接关联型映射的优点。缓存被分为多个组(sets),每个组中有多个行(ways)。主存块可以映射到某一组中的任何行,但仅限于这一组内的行。
目前广泛使用组关联型Cache。
Cache的写策略
内存的数据被加载到Cache后,在某个时刻要被写回内存,写回策略有以下几种:
-
直写(write-through) :处理器写入Cache的同时, 将数据写入内存中
-
回写(write-back):为cache line设置dirty标志,当处理器改写了某个cache line后,不立即将其写回内存,而是将dirty标志置1。当处理器再次修改该cache line并且写回cache中, 查表发现dirty=1,则先将cache line内容写回内存,再将新数据写到cache。
-
WC(write-combining): 当cache line的数据被批量修改后,一次性将其写到内存。
-
UC(uncacheable) :针对内存不能被缓存到cache的场景,比如硬件需要立即收到指令。
Cache预取
预取原理
cache之所以能够提高系统性能,主要原因是程序运行存在局部性现象,包括时间局部性和空间局部性。这两种情况下处理器会把之后要用到的指令/数据读取到cache中,提高程序性能。而所谓的cache预取,就是预测哪些指令/数据将会被用到,然后采用合理方法将其预先取入到cache中。
一些处理器提供的软件预取指令(只对数据有效):
-
PREFETCH0 将数据存放在所有cache
-
PREFETCH1 将数据存放在L1 Cache之外的cache
-
PREFETCH2 将数据存放在L1,L2 Cache之外的cache
-
PREFETCHNTA 与PREFETCH0类似,但数据是以非临时数据存储,在使用完一次后,cache认为该数据是可以被淘汰出去的。
这些指令都是汇编指令, 一些程序库会提供对应的C语言版本, 如mmintrin.h中的_mm_prefetch()函数:
// p: 要预取的内存地址
// i: 预取指令类型, 与汇编指令对应关系如下
// _MM_HINT_T0: PREFETCH0
// _MM_HINT_T1: PREFETCH1
// _MM_HINT_T2: PREFETCH2
// _MM_HINT_NTA: PREFETCHNTA
void _mm_prefetch(char* p, int i);
-
p 是要预取的内存地址。
-
i 是预取指令类型,可以是 _MM_HINT_T0、_MM_HINT_T1、_MM_HINT_T2 或 _MM_HINT_NTA 中的一个。
dpdk中的预取
dpdk转发一个报文所需要的基本过程分解:
-
写接收描述符到内存,填充数据缓冲区指针,网卡收到报文后就会根据这个地址把报文内容填充进去。
-
从内存中读取接收描述符(当收到报文时, 网卡会更新该结构)(内存读),从而确认是否收到报文。
-
从接收描述符确认收到报文时,从内存中读取控制结构体的指针(内存读), 再从内存中读取控制结构体(内存读),把从接收描述符读取的信息填充到该控制结构体。
-
更新接收队列寄存器,表示软件接收到了新的报文。
-
内存中读取报文头部(内存读),决定转发端口。 从控制结构体把报文信息填入到发送队列发送描述符,更新发送队列寄存器.
-
从内存中读取发送描述符(内存读),检查是否有包被硬件传送出去。
-
如果有的话,从内存中读取相应控制结构体(内存读),释放数据缓冲区。
处理一个报文的过程,需要6次读取内存(见上“内存读”)。而之前我们讨论过处理器从一级Cache读取数据需要3~5个时钟周期, 二级是十几个时钟周期,三级是几十个时钟周期,而内存则需要几百个时钟周期。从性能数据来说, 每80个时钟周期就要处理一个报文。
因此,dpdk必须保证所有需要读取的数据都在Cache中,否则一旦出现Cache不命中,性能将会严重下降。为了保证这点, dpdk采用了多种技术来进行优化, 预取只是其中的一种。
/*
* Prefetch a cache line into all cache levels.
*/
#define rte_ixgbe_prefetch(p) rte_prefetch0(p)
实际例子:
while (nb_rx < nb_pkts) {
rxdp = &rx_ring[rx_id]; // 读取接收描述符
staterr = rxdp->wb.upper.status_error;
// 检查是否有报文收到
if (!(staterr & rte_cpu_to_le_32(IXGBE_RXDADV_STAT_DD)))
break;
rxd = *rxdp;
// 分配数据缓冲区
nmb = rte_rxmbuf_alloc(rxq->mb_pool); nb_hold++;
// 读取控制结构体
rxe = &sw_ring[rx_id];
......
rx_id++;
if (rx_id == rxq->nb_rx_desc)
rx_id = 0;
// 预取下一个控制结构体mbuf
rte_ixgbe_prefetch(sw_ring[rx_id].mbuf);
// 预取接收描述符和控制结构体指针
if ((rx_id & 0x3) == 0) {
rte_ixgbe_prefetch(&rx_ring[rx_id]);
rte_ixgbe_prefetch(&sw_ring[rx_id]);
}
......
// 预取报文
rte_packet_prefetch((char *)rxm->buf_addr + rxm->data_off);
// 把接收描述符读取的信息存储在控制结构体mbuf中
rxm->nb_segs = 1;
rxm->next = NULL;
rxm->pkt_len = pkt_len;
rxm->data_len = pkt_len;
rxm->port = rxq->port_id;
......
rx_pkts[nb_rx++] = rxm;
}
Cache一致性
cache一致性问题的根源,是因为存在多个处理器核心各自独占的cache(L1,L2),当多个核心访问内存中同一个cache行的内容时, 就会因为多个cache同时缓存了该内容引起同步的问题。
dpdk使用Cache Line对齐,同时避免多个核心访问同一个内存地址或者数据结构来解决cache一致性问题。
dpdk实现Cache Line对齐
实现很简单,定义该数据结构或者数据缓冲区时就申明对齐,DPDK对很多结构体定义的时候就是如此操作的,以下是宏定义。
rte_common.c
/** Minimum Cache line size. */
#define RTE_CACHE_LINE_MIN_SIZE 64
/** Force alignment to cache line. */
#define __rte_cache_aligned __rte_aligned(RTE_CACHE_LINE_SIZE)
/** Force minimum cache line alignment. */
#define __rte_cache_min_aligned __rte_aligned(RTE_CACHE_LINE_MIN_SIZE)
struct __rte_cache_aligned lcore_params {
uint16_t port_id;
uint8_t queue_id;
uint8_t lcore_id;
};
以上定义了一个简单的结构体 lcore_params,用于存储和管理逻辑核心相关的参数信息,包括端口 ID、队列 ID 和逻辑核心 ID。通过 __rte_cache_aligned 这个宏,确保了结构体在内存中按缓存行对齐。
MESI协议
解决Cache一致性问题的机制有著名的MESI协议。
MESI协议是Cache line四种状态的首字母的缩写,分别是修改(Modified)态、独占(Exclusive)态、共享(Shared)态和失效(Invalid)态。Cache中缓存的每个Cache Line都必须是这四种状态中的一种。
Modified(修改)态:当某个处理器或核心修改了一个缓存行中的数据时,该缓存行处于修改态。
-
数据在此缓存中被修改过,并且未写回主存。
-
此状态下的缓存行数据是最新的,并且与主存中的数据不一致(即缓存数据是脏的)。
-
其他处理器或核心若要读取该缓存行,需要先将其写回到主存或者转换为共享态或失效态。
Exclusive(独占)态 :当某个处理器或核心拥有一个缓存行的唯一访问权限,且此缓存行与主存中的数据一致时,该缓存行处于独占态。
-
缓存行中的数据与主存中的数据一致,且没有其他处理器或核心缓存了相同的数据。
-
其他处理器或核心可以读取该缓存行,但必须先将其设置为共享态或者失效态,再读取或修改。
Shared(共享)态: 当多个处理器或核心缓存了同一个缓存行,并且数据与主存中的一致时,该缓存行处于共享态。
-
多个处理器或核心可以同时缓存并访问该缓存行的数据。
-
数据与主存中的数据一致,因此无需写回操作。
Invalid(失效)态: 当某个处理器或核心的缓存行无效或者失效时,处于失效态。
-
处理器或核心的缓存行与主存中的数据不一致,或者缓存行中的数据已经过时。
-
若有其他处理器或核心修改了相同的缓存行,可能会导致当前缓存行失效。
-
在失效态的缓存行上的任何访问操作都将导致从主存重新获取最新数据。
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
dpdk实现缓存一致性
dpdk解决方法很简单,首先就是避免多个核访问同一个内存地址或者数据结构。这样,每个核尽量都避免与其他核共享数据,从而减少因为错误的数据共享(cache line false sharing)导致的Cache一致性的开销。
struct __rte_cache_aligned lcore_conf {
uint16_t n_rx_queue;
struct lcore_rx_queue rx_queue_list[ETHDEV_RX_QUEUE_PER_LCORE_MAX];
struct rte_graph *graph;
char name[RTE_GRAPH_NAMESIZE];
rte_graph_t graph_id;
};
struct