ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解hyperframes:从DPDK到内核的批量收包与网络性能优化

深入理解hyperframes:从DPDK到内核的批量收包与网络性能优化 搞网络抓包和高速转发的人大概率都见过“hyperframes”这个词。它不是什么高深莫测的玄学也不是某个厂商的秘传黑科技本质上就是一次收包时处理多个网络帧的批处理机制。但就这么一个看似简单的思路几乎是现代高性能网络数据面应用的基石。Netfilter、DPDK、VPP、XDP这些项目里都能看到它的影子。这篇就围绕hyperframes这个主题从设计思路、核心细节、实测踩坑到性能调优一步步拆开讲清楚。不管你是刚接触网络编程的学生还是已经在搞数据面开发、边缘网关、流量审计的工程师这篇应该都能给你一些能直接落地的参考。1. 从单包处理到批量收包的思路转变先搞清楚一个问题为什么需要hyperframes而不是老老实实一个包一个包地收早期网络栈里中断驱动模型是主流。网卡每收到一个数据包就触发一次CPU中断内核去处理、拷贝、投递。这套机制在百兆、千兆时代的低流量场景下没有大问题。但等到万兆、25G甚至100G口普及之后小包场景下CPU根本来不及响应那么多中断。一个千兆口满速跑64字节小包每秒大约能到148万包万兆就直接乘以10。要是每个包都来一次中断和完整协议栈处理CPU早就在跑中断和上下文切换了正经业务没时间跑。所以业界的思路拐了个大弯从靠中断通知改成主动轮询收包从一次取一个包改成一次取一大堆包。这一大堆包就是各种语境下的hyperframe。这里有个生活化的类比去食堂打饭。早期做法是每次炒好一个菜按一下铃让一个人来取。人少还行一到饭点所有窗口同时按铃窗口和厨房全乱套。后来的做法是每凑够一托盘菜再统一按一次铃按铃频率降低了单次取走的菜变多了后厨吞吐自然上去了。hyperframes就是那个“托盘”。从实现形态上说hyperframes有几种常见存在方式驱动层的批量收包接口。比如Intel I40E网卡驱动里的i40e_clean_rx_irq()一次while循环最多能取走cleaned个描述符指向的包上限通常就是budget(比如64或256)。应用层的批量取包接口。DPDK的rte_eth_rx_burst()在现在的实现里已经支持一次从接收队列里取最多指定数量(如32、64、128)的报文并返回实际拿到的包数。内核网络栈的批量处理。Netfilter的nf_hook_slow_list、协议栈里的list_packets处理本质上也是把同一批收上来的包链路化一次性走hook点。不管哪种形态核心逻辑都是一样的减少上下文切换、分摊循环成本、批量移动元数据、批量处理协议栈逻辑。2. 为什么批量能让性能翻倍核心细节拆解很多人以为批量收包的好处只是“中断少一点”。其实这只是冰山一角。真正让性能拉开差距的是下面这几个细节。2.1 分摊循环与函数调用开销每次调用收包接口无论内核还是用户态都有一段固定的开销函数调用、锁竞争(轮询模式下少很多)、缓存行状态检查、判NULL等。假设每次调用固定开销是100ns单包模式收一个包花100ns收100个包就要100次调用光调用开销就是10us。但如果一次批量取32个包调用一次收包接口固定开销还是100ns平摊到每个包上只有3ns左右。就这一个差异就能让吞吐差出一个数量级。2.2 元数据缓存的局部性网卡DMA写完包内容后驱动要走接收描述符(ring descriptor)来取包的地址、长度、哈希值、VLAN标记等元数据。这个描述符在内存里是连续排布的批量处理时CPU可以顺序读一大片硬件prefetcher能提前把后续描述符对应的缓冲区地址预取到cache里。单包处理时每处理一个包就要重新访问一次描述符所在内存cache命中率低得多。这一点的实际收益在DPDK的收包循环里非常明显。用rte_eth_rx_burst()一次拿32个包构造rte_mbuf数组的过程几乎不需要访问全局锁而且mbuf指针数组是线性排布的遍历时跑得非常顺滑。2.3 驱动层批量收包并不是简单叠加真正让性能起飞的是驱动层的批量收包逻辑。拿亿联(Yongchip)这类支持多队列的网卡驱动来说rx_loop里如果检测到当前批次的包数超过阈值就会把这一批包一次性挂到netif_receive_skb_list或者napi_gro_receive的链表上。这里关键点在于如果这一批包的目的地都是同一个协议栈路径可以共享许多判断比如判断VLAN、判断隧道协议、判断是否是广播包等。这些一次性判断做完后整个批次的包都能复用结果大大减少了CPU分支预测失败的代价。2.4 减少锁与原子操作单包处理模式下每收一个包都可能触发一次队列锁或者refcnt操作。hyperframes模式把这种操作合并成批量操作比如批量增加队列长度统计、批量释放mbuf等。DPDK的rte_pktmbuf_free_bulk就是一个典型实践一次释放100个mbuf只用一次锁和一次批量归还比单个释放要快得多因为缓存行可以被复用而不是反复失效。3. 常用实现路径与选型考量hyperframes不是某一个库的专利不同层次有不同的实现路径。选哪一条路取决于你是在内核态做网关、在用户态做转发还是在网卡固件里做硬件卸载。3.1 内核NAPI与批量netif_receive_skb_list内核里最典型的批量收包路径是NAPI。驱动注册poll回调内核在软中断里批量调用poll()每次poll最多处理budget个包。驱动把收上来的skb链成链表后用netif_receive_skb_list一次性喂给协议栈。这种方案的优点是不用改应用代码对所有内核协议栈生效缺点是批量粒度受驱动实现影响不同网卡表现差异较大而且协议栈内部仍然有不少单包逻辑批量的收益被部分稀释。3.2 DPDK用户态轮询批量收发DPDK里rte_eth_rx_burst()一次调用就能从RX队列里捞出多个包。用户拿到一个struct rte_mbuf *bufs[32]数组后只需要循环处理。如果要实现一个完整的高性能转发应用核心循环大概长这样for (;;) { uint16_t nb_rx rte_eth_rx_burst(port, queue_id, bufs, 32); if (nb_rx 0) continue; for (uint16_t i 0; i nb_rx; i) { process_packet(bufs[i]); // 解析、查表、修改、染色…… } uint16_t nb_tx rte_eth_tx_burst(port, out_queue, bufs, nb_rx); if (nb_tx nb_rx) { // 发送失败的部分需要回收或重新入队 for (uint16_t i nb_tx; i nb_rx; i) { rte_pktmbuf_free(bufs[i]); } } }这里一次rx_burst返回的包数不一定是32可能是0到32之间的任何值。轮询模式下当没有包时接口会立即返回0CPU空转继续下一次循环。这种方案的优点是灵活用户完全掌控批量大小、内存归属和处理逻辑缺点是需要自己管理资源、处理NUMA、写驱动学习曲线陡峭调试难度也高。3.3 XDP与AF_XDP的批量处理XDP在网卡驱动里直接挂BPF程序处理完的包可以原地转发、丢弃或redirect到AF_XDP socket。AF_XDP的收包路径也是批量化的通过xsk_ring_bp从内核批量拿描述符。实际用起来写法类似DPDK但入包路径经过了BPF程序可以做自定义过滤灵活性比纯DPDK高一些。3.4 硬件批量加速有些网卡本身就支持LRO(Large Receive Offload)、GRO(Generic Receive Offload)把多个小包合并成大包再上报上层这本质上是一种硬件/软件配合的hyperframe聚合。开启GRO后TCP下发的收包数量骤减CPU的处理压力明显下降。但要小心合并后的包会变大如果下游需要按原始包处理比如按个包打时间戳或者做精细化限速就必须关掉GRO。选型时我习惯用一个简单的表格来对比方案批量粒度开发成本灵活性适用场景内核NAPI skb_list中等(受驱动影响)低低常规网关、iptables/nftables、NAT等DPDK轮询收发包高(用户可控)高高高性能转发、流量分析、负载均衡XDP/AF_XDP中高(依赖BPF路径)中中高防火墙、DDOS过滤、定制转发GRO/LRO卸载高(聚合后包数少)低低TCP业务接收方向性能优化3.5 从我的角度看小包场景必须上批量我自己的实际体会是如果业务里主要是TCP大流量(例如视频流、文件同步)单包处理配合中断模式其实也能跑得动因为大包个数少收包速率低。真正吃性能的地方全部集中在小包场景。比如在线游戏的UDP小包(几十字节一个)行情推送服务(每秒百万级行情消息)监控系统的遥测数据采集(几百字节的指标点)DDoS攻击检测(海量SYN小包)这些小包场景如果不做批量收包CPU会一直忙于状态切换和函数调用几个核心跑满也只能处理几十万包每秒距离线速差得很远。采用批量收包后单核通常可以把小包处理能力拉到数百万包每秒甚至靠近千万级别。4. 实操怎么用DPDK把hyperframes跑起来光说不练假把式。这一部分我用一个最小可运行的DPDK收包示例来演示hyperframes的核心链路从环境准备到代码编写再到验证一步一步来。4.1 环境准备以下操作基于Ubuntu 22.04、DPDK 21.11 LTS、两张Intel XL710网卡(双口40G)作为实验环境。如果你的网卡不支持DPDK也可以用virtio-user或者TAP接口做虚拟测试但性能数据会有差异。安装依赖apt update apt install -y build-essential meson ninja-build python3-pip pciutils pip3 install pyelftools下载并编译DPDKgit clone https://dpdk.org/git/dpdk cd dpdk meson build ninja -C build ninja -C build install ldconfig绑定网卡到DPDK用户态驱动。先确认网卡PCI地址dpdk-devbind.py --status假设网卡PCI地址是0000:02:00.0和0000:02:00.1绑定到igb_uio或vfio-pci模块modprobe vfio-pci dpdk-devbind.py --bindvfio-pci 0000:02:00.0 0000:02:00.1绑定成功后网卡从内核协议栈中脱离不再有IP地址收发完全交给DPDK控制。4.2 最小收包转发程序创建一个main.c文件内容如下#include rte_eal.h #include rte_ethdev.h #include rte_lcore.h #include rte_mbuf.h #include rte_mempool.h #include stdio.h #define RX_RING_SIZE 1024 #define TX_RING_SIZE 1024 #define NUM_MBUFS 8191 #define BURST_SIZE 32 static struct rte_mempool *mbuf_pool; static int port_init(uint16_t port) { struct rte_eth_conf port_conf { .rxmode { .max_rx_pkt_len 1518, .offloads 0 }, }; int ret rte_eth_dev_configure(port, 1, 1, port_conf); if (ret 0) { printf(configure failed: %d\n, ret); return ret; } ret rte_eth_rx_queue_setup(port, 0, RX_RING_SIZE, rte_eth_dev_socket_id(port), NULL, mbuf_pool); if (ret 0) return ret; ret rte_eth_tx_queue_setup(port, 0, TX_RING_SIZE, rte_eth_dev_socket_id(port), NULL); if (ret 0) return ret; ret rte_eth_dev_start(port); if (ret 0) return ret; rte_eth_promiscuous_enable(port); return 0; } static int lcore_main(void *arg) { (void)arg; uint16_t port 0; struct rte_mbuf *bufs[BURST_SIZE]; uint64_t total_pkts 0, total_bytes 0; unsigned lcore_id rte_lcore_id(); printf(Core %u started in rx/tx mode\n, lcore_id); for (;;) { uint16_t nb_rx rte_eth_rx_burst(port, 0, bufs, BURST_SIZE); if (nb_rx 0) continue; total_pkts nb_rx; for (uint16_t i 0; i nb_rx; i) { total_bytes rte_pktmbuf_pkt_len(bufs[i]); // 这里可以加解析逻辑取MAC、IP头、五元组等 } // 同一口收发演示用。实际场景通常是port0收、port1发 uint16_t nb_tx rte_eth_tx_burst(port, 0, bufs, nb_rx); if (nb_tx nb_rx) { for (uint16_t i nb_tx; i nb_rx; i) { rte_pktmbuf_free(bufs[i]); } } if ((total_pkts 0xFFFFF) 0) { printf(Core %u: %lu pkts, %lu bytes\n, lcore_id, (unsigned long)total_pkts, (unsigned long)total_bytes); } } return 0; } int main(int argc, char *argv[]) { int ret rte_eal_init(argc, argv); if (ret 0) return -1; argc - ret; argv ret; mbuf_pool rte_pktmbuf_pool_create(MBUF_POOL, NUM_MBUFS, 256, 0, 2176, rte_socket_id()); if (mbuf_pool NULL) { printf(mempool create failed\n); return -1; } if (port_init(0) ! 0) return -1; rte_eal_remote_launch(lcore_main, NULL, rte_get_next_lcore(0, 1, 0)); rte_eal_mp_wait_lcore(); rte_eth_dev_stop(0); rte_eth_dev_close(0); return 0; }编译方式gcc -O2 -g -o hyperframe_demo main.c $(pkg-config --cflags --libs libdpdk)启动参数./hyperframe_demo -l 0,1 -n 4 -- -p 0x1用-l 0,1指定主核和工作核-n 4指定4个内存通道。程序启动后两个核心都会进入轮询收包循环每批最多拿32个包处理并原口发送。4.3 关键参数怎么调这个demo虽然简单但里面有无数细节值得抠。这里讲三个直接影响hyperframes效果的参数。BURST_SIZE的大小。这个值不是越大越好。DPDK里rte_eth_rx_burst一次最多能拿多少取决于网卡驱动描述符队列的配置。一般建议配置成32、64或128。我试过把BURST_SIZE调到256有些网卡驱动会拆分请求实际返回的包数会低于预期反而增加空跑开销。实测下来Intel 40G网卡用64效果最好Mellanox CX5系列用32就足够再增大收益不明显。RX ring size。ring描述符数量设置太少会导致丢包太多(例如4096)消耗内存并且在收包路径上缓存预取效果变差。常规推荐是1024或2048。如果压测时出现大流量丢包优先加大ring而不是加大burst。mempool的cache size。rte_pktmbuf_pool_create的第二个参数256是per-core cache。这个值影响DPDK从pool里批量取mbuf的频率。cache太小每次收包都要走pool的公共链表会引入锁竞争cache太大在某些不均衡的流量模式下会浪费内存。经验值256对大多数场景够用。4.4 验证收包转发效果起一个终端跑demo再起另一个终端用pktgen-dpdk打流./pktgen -l 0-3 -n 4 -- -m [1:0].0 -T在pktgen命令行里配置64字节包和线速发送set 0 src mac 00:11:22:33:44:55 set 0 dst mac 66:77:88:99:aa:bb set 0 proto udp set 0 src ip 10.0.0.1 set 0 dst ip 10.0.0.2 set 0 size 64 enable 0 rate 0 str不出意外的话demo程序每秒打印的包数会在几百万左右。单核64B转发如果低于200万pps大概率是NUMA或编译参数没对。5. 实践中的性能调优与隐藏杀手能把demo跑通只是第一步真正要上线干活的系统还要面对下面这些问题。这些全是实战中很容易让人懵的坑。5.1 NUMA亲和是性能的地基多路服务器上如果网卡插在CPU0的PCIe插槽但DPDK工作核跑在CPU1的核上每次收包都要跨NUMA访问内存延迟会显著拉高带宽损失至少在30%以上。检查方法dpdk-devbind.py --status lscpu-l参数里尽量让主核和工作核都在网卡所在NUMA节点。如果不得不跨NUMA就一定要保证mbuf_pool创建时指定了正确的socket_id实在不行也要开启--socket-mem单独分配大页内存。5.2 PCIe带宽瓶颈很多人把性能差归咎于CPU其实PCIe带宽才是隐藏瓶颈。PCIe 3.0 x8的理论带宽大约7.88GB/s也就是单方向大约63Gbps双口40G网卡跑满线速会有双向带宽压力。想要双向都顶满必须插在PCIe 3.0 x16或者PCIe 4.0 x16槽上。判断方法很简单lspci -vvv -s 02:00.0 | grep -E LnkCap|LnkSta如果LnkSta显示8GT/s, x8那这根槽是PCIe 3.0 x8。双口40G转发时大概率会卡在带宽上限上。5.3 中断与轮询的混跑问题DPDK轮询模式理论上没有中断开销但如果你绑定的网卡同时也有内核驱动管理的队列(例如开启了多队列但只绑了部分队列)内核中断依然会跑抢占CPU时间片。最稳妥的做法是在DPDK绑定前彻底卸载内核网卡驱动或者只绑定目标队列所在的物理端口。5.4 大页内存不够导致mbuf分配失败DPDK依赖大页内存(HugePages)。默认2MB大页下如果有8191个mbuf每个mbuf约2KB一共需要16MB左右还有pool对象、ring等。如果系统没预留足够的大页rte_pktmbuf_pool_create会返回NULL程序在创建阶段直接失败。预留大页的方法echo 1024 /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages mkdir -p /mnt/huge mount -t hugetlbfs pagesize2MB /mnt/huge5.5 batching接收之外的发送端优化所有人都盯着收包批量但发送端也有批量问题。rte_eth_tx_burst如果一次只发一两个包发送描述符的写回效率低、PCIe事务次数多。理想情况下发送也应该攒够一定数量再一次性burst出去或者使用pktgen之类工具验证发送路径。有时候收包已经上去了吞吐还在原地踏步问题就出在发送侧。6. 常见问题速查与避坑心得以下汇总一下我在项目里实际踩过的坑以及从同事、社区里获取的那些“文档里不写但非常有用”的经验。6.1 丢包排查顺序遇到丢包先确认丢在哪一层丢包现象可能原因排查方法驱动层rx missedring太小或收包不及时ethtool -S看rx_missed、rx_no_buffer应用层能收但处理不过单核处理能力到瓶颈用perf看热点函数考虑多队列RSS分流发送失败TX ring满或对端反压统计rte_eth_tx_burst返回值及时free应用无包头信息网卡KO/LRO把多个包合并关闭GRO/LRO重新check pkt_len在DPDK里rte_eth_stats_get(port, stats)能拿到imissed、ierrors、rx_nombuf等关键指标。rx_nombuf增加说明mbuf pool耗尽mempool cache太少或池子太小imissed增加说明ring描述符被占满需要扩大ring或提高处理速度。6.2 常见误区一burst越大越好我见过新人把burst设到512、1024结果性能不升反降。原因在于驱动和内存子系统对批量长度有物理限制过长描述符操作会让硬件prefetch失效批量的包同时命中缓存的比例降低反而增加延迟。现网典型的转发机推荐32到128之间。6.3 常见误区二只看pps不看转发时延hyperframes模式能提升吞吐但会增加每个包在网卡队列里等待攒批的时间。如果业务是低时延敏感的(例如高频交易或实时语音)把burst调大可能让尾延迟从几微秒跳到几十微秒。这种场景要折中burst保持16或32同时配合RSS多队列降低队列内的排队延迟。6.4 常见误区三忽略了描述符预取的硬件行为DPDK驱动收包是有两个阶段硬件DMA写包到内存然后写描述符状态软件通过描述符获取地址并处理。如果mbuf池里的内存是离散的物理页硬件DMA写完后CPU可能需要重新加载页表项和cache导致延迟变长。用rte_pktmbuf_pool_create时指定socket_id和合理的cache_size能缓解。更激进的做法是使用外部buffer池甚至用rte_memzone预分配一段连续物理内存但这需要更深入的定制。6.5 网卡寄存器统计与实际行为不符跑测试的时候一定不要只看DPDK应用内部的计数网卡自己的硬件统计是另一个维度。例如i40e网卡在超大流量下的rx_errors、rx_crc_errors可能是由于物理链路信号问题或光模块不兼容导致的跟软件逻辑无关。先看硬件统计再排查软件逻辑能省去很多无效时间。7. 从hyperframes到完整数据面还要做哪些事想要把hyperframes从一个demo变成生产可用的高性能数据面还有很多配套工作要做。7.1 多队列与RSS分流单核跑再多也有上限想要整体提升必须用网卡RSS(Receive Side Scaling)把流量按五元组哈希分散到多个队列每个队列绑定一个CPU核。DPDK里配置RSS需要设置rte_eth_conf的rss_conf字段包括rss_hf为ETH_RSS_IP或ETH_RSS_TCP等。RSS打开后同一连接会被稳定分到同一队列避免乱序和锁竞争。7.2 无锁数据结构与内存管理每个核在DPDK里拥有自己独立的队列和内存池核心间尽量不要共享数据。如果一定要跨核通信推荐用无锁队列(如DPDK自带的rte_ringsp/sc模式)或者带批次处理的共享队列把跨核消息积累到一定数量再批量交换。这样可以把跨核的cache一致性开销分摊到一批消息上。7.3 协议解析的高效写法解析包时最推荐的手段是解析一次、存偏移量。不要在每层协议判断上反复取指针、做分支判断。比如解析完以太网头后立即把L3头的偏移量记到mbuf-l3_len等字段上后续查表、修改只需移动一次指针。另外在循环里做分支预测友好的写法把常规包路径放在if的前半段罕见包路径放else分支能减少流水线清空。7.4 无整机超线程性能测试和上线部署时建议禁掉超线程或者至少让一个物理核只跑一个DPDK工作线程。超线程会让两个逻辑核共享L1、L2缓存收包线程的缓存容易被挤占导致数据面性能抖动。如果业务需要多线程并发处理推荐绑定到不同的物理核而不是同一个物理核的两个线程。8. 我的实操总结核心参数与经验值参考下面这组参数是我在多种服务器组合上反复调过、相对稳妥的起点值你可以根据实际硬件和业务再修正参数项推荐值备注BURST_SIZE32 或 64大包取32小包可取64超过128通常不划算RX/TX Ring Size1024 或 2048大流量加ring不轻易动burstmbuf数量端口带宽(Mbps) / 包大小(byte) * 时延预算比理论值多留30%余量mempool cache256缓存过小会引入pool链表锁竞争工作核绑定与网卡同NUMA检查lscpu与PCIe槽位编号RSS队列数等于物理核心数或核心数减1留一个核做管理、统计或控制面PCIe槽位至少x16双口40G/100G必须保证带宽我个人在实际操作中的体会是hyperframes这套东西真正难的不是理解“批量收包”这个概念而是搞清楚批量之后的连锁反应缓存预取、队列长度、内存池行为、NUMA亲和、硬件卸载、PCIe带宽这些都是互相拉扯的变量。你调burst发现没提升往往不是批量本身没用而是某个下游环节卡死了。把它当成一整个链路来调而不是只盯一个参数这才是解决问题的心态。如果要在这个demo基础上继续扩展我建议下一步优先加多队列支持、RSS分流、以及基于五元组的HASH表查找。这三件事做下来你的程序就已经接近一个可以接真实流量的转发框架了。到时候回头看hyperframes就会觉得它只是你工具箱里的一把扳手关键是怎么用、用在哪、跟谁搭配。
返回列表