ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据搬运卡顿真凶:Host-to-Host拷贝次数深度解析与优化

数据搬运卡顿真凶:Host-to-Host拷贝次数深度解析与优化

先说结论:很多从标称带宽上算出来“应该没问题”的数据搬运场景,实际跑起来却卡得让人摸不着头脑。我最近在处理一个跨主机数据搬运的调优问题时,就遇到了这种情况。链路侧 PCIe 的有效带宽并不差,内存侧 DDR 带宽看起来更充裕,最后用工具一打,发现绝大多数 CPU 时间都花在 Host-to-Host(H2H)拷贝上,内存复制相关的内核符号占了大头,链路利用率反而一直上不去。

为什么会这样?因为端到端的数据移动并不像“水管对水管”那样直接。数据往往要从用户态到内核态来回倒腾好几次,每次倒腾都意味着一次完整的内存拷贝。PCIe 再快,DDR 再宽,也架不住一条数据被重复搬移三四遍。这篇文章就把我这段时间的实测记录、定位方法和优化手段完整梳理一遍,重点是讲清楚“拷贝次数”为什么才是真正的关键变量。

1. 先拨开两张“理论带宽”的迷雾:DDR和PCIe的有效值差多少

1.1 DDR再宽也只是总线资源:刷新、行切换与读写转换都在吃份额

一提到内存,很多人习惯把标称带宽直接当成可用带宽。比如某常见服务器平台,双通道 DDR5 的理论峰值能做 38.4GB/s 左右,看上去很吓人。但这个数字是在“持续顺序读”或“持续顺序写”这类理想条件下才能接近的值,真实负载能拿到 60%~80% 就已经相当不错。

一条 DDR 通道背后有很多隐藏开销。首先是刷新:DRAM 靠电容存储电荷,电容会漏电,所以内存控制器必须周期性地对所有 bank 做刷新操作,刷新期间该 bank 不能响应正常的读写请求。其次是行切换:读写数据时要先把目标行“激活”,换到另一行时要 prefetch/precharge,这中间的延迟比纯数据传输长得多。更麻烦的是读写转换,读总线换成写总线时必须插入 wait cycle,总线信号方向切换会有死区。一个内存拷贝操作天然是 50% 读 + 50% 写,读写转换非常频繁,有效带宽掉得比顺序大块读更狠。

还有 ECC 带来的额外开销。很多服务器内存必须过 ECC 校验,数据读出来写回去都要附带校验信息,校验逻辑本身也占用了内存控制器的时间片。加上内存控制器会选择 bank group 交错来提高并发度,但这些优化往往只对特定访问模式友好,一旦数据散布在多个页面又碰到 TLB miss,性能会继续下滑。

我在实际测试中见过最夸张的情况:两个进程跨内存节点做数据交换,表面上看内存带宽很大,但 NUMA 远端访问模式的带宽只有本地访问的一半甚至更低。所以内存侧的真实可用带宽,一定要用实际负载去压测,不能拿标称值直接做计算。

1.2 PCIe并不差,但协议头和编码从第一秒就在扣数据

PCIe 比 DDR 慢,这句话在多数平台上是成立的,但差距没有想象中那么大。以一块 PCIe 4.0 x16 的链路为例,物理层单 lane 的传输速率是 16GT/s,16 条 lane 加起来的原始速率是相当可观的。问题在于 128b/130b 编码,每 130bit 里只有 128bit 是有效数据,光编码开销就吃掉约 1.5% 的理论带宽。接着还有 TLP(事务层报文)头尾、DLLP 控制报文、流量控制更新包等额外开销,这些都要占用链路带宽。

小包场景下 PCIe 的有效利用率会很难看。每个数据包的 TLP 头通常是 12~16 字节,如果普通 DMA 拿到的数据是碎片化的小块,每个块都单独建一个 TLP 传输,那么头部开销占比会迅速上升。一个大块的顺序 DMA 可能拿到 80%~90% 的有效带宽,而小块随机 IO 可能连 50% 都不到。因此 PCIe 链路本身并不“差”,真正差的是上层软件把数据切得太碎,导致协议开销把有效带宽吃掉了。

不过还有一个容易被忽略的对比维度:PCIe 是点到点传输,内存是多重访问的共享介质。PCIe 的传输一旦建立,链路资源基本是独占的;内存带宽则要同时服务 CPU 指令取指、内核数据、用户态业务数据、DMA 读写等多个访问源。所以在并发负载下,内存带宽的竞争远比你想象中激烈。基于这些原因,单纯比较“DDR 带宽 vs PCIe 带宽”,并不能回答实际问题。

2. 为什么实际拖后腿的是H2H拷贝:数据被复制了几遍才是关键

2.1 H2H到底指什么:从两块主机到两个进程的公共路径

先明确一下 H2H 的定义。按我这里的用法,H2H(Host-to-Host)指的是数据从一个“主机侧实体”搬运到另一个“主机侧实体”的整个过程。这个实体可以是两台物理服务器,也可以是同一台服务器上的两个进程、两个容器,甚至是宿主机与虚拟机之间。

H2H 拷贝和设备到设备(如磁盘直接到磁盘)最大的区别在于:设备到设备之间有 DMA 和硬件直通路径可以绕开 CPU,而 H2H 拷贝绕不开“内存作为中转站”这一步。两个进程要交换数据,数据先要从一个进程的地址空间复制到内核缓冲区,再复制到另一个进程的地址空间;两个物理服务器要交换数据,数据要先进协议栈缓冲区,再经过 DMA 到网卡,对端还要从网卡 DMA 收到的数据继续向用户态复制。

因此 H2H 是典型的多段复制路径,每一段都可能是内存拷贝。链路带宽和内存带宽只有在数据“传送”时才起作用,而复制操作在让 CPU 和数据总线承担大量无效转移。

2.2 一次文件“中转”的数据搬家流程:三次拷贝是怎么发生的

用一个最简单的小场景来拆解:某程序从文件读取一段数据,再通过套接字发送给另一个程序。如果按最常规的代码写法,先read()进用户态,再write()到套接字,那么数据至少经历了三段搬运。

第一段,文件内容从内核页缓存复制到用户态缓冲区,这是一个完整的内存拷贝。第二段,用户态缓冲区再复制到内核的套接字发送队列,这又是一个完整的内存拷贝。第三段,套接字发送队列的数据通过 DMA 方式交给网卡,这一段不需要 CPU 逐字节搬,但网卡 DMA 描述符的构建、中断/轮询处理、以及可能的数据校验和分段逻辑,仍然会消耗 CPU 和 PCIe 带宽。如果你写的是一个转发程序,收到的数据还要再发给下一个服务,那么接收侧很可能再来一遍“内核缓冲到用户态再到内核缓冲”的倒腾,总拷贝次数直奔五次。

假设单次有效内存拷贝带宽是 30GB/s,一条路径上拷贝三次,端到端的有效吞吐就只有 10GB/s 了。如果再加上协议栈开销和校验计算,最终拿到的吞吐可能连 10GB/s 都不到。这时候你去看 PCIe 链路,它可能只跑到 40% 的利用率,链路层完全没饱和,可系统却已经累得半死。

2.3 把“拷贝次数”放进公式:有效吞吐立刻掉一半以上

用一个很容易记住的公式来总结:

端到端有效吞吐 = 内存子系统有效带宽 ÷ 数据路径上的拷贝次数

这个公式虽然不能精确预测每一种负载,但它能快速帮你判断瓶颈到底在哪一侧。比如内存有效带宽 25GB/s,链路有效带宽 20GB/s,如果路径上有三次拷贝,那么端到端极限就是 25/3≈8.3GB/s。此时无论把链路从 20GB/s 换成 40GB/s,瓶颈都还在拷贝上面。

这正好解释了标题里的现象:“PCIe 比 DDR 慢”只是单个环节的带宽比较,但“瓶颈却在 H2H 拷贝”是因为拷贝次数把本来充足的内存带宽瓜分掉了。如果能把三次拷贝压缩成一次,端到端吞吐直接翻两三倍,比升级一套网卡或换更高带宽的 PCIe 设备都有效。理解这层逻辑之后,后面所有的优化手段都是朝着“减少拷贝次数”这一个方向使劲。

3. 用采样的数据说话:定位H2H拷贝瓶颈的实测流程

3.1 观测入口:perf、系统调用轨迹和NUMA计数器怎么配合

定位瓶颈不能靠猜,要靠采样和计数。我一般会按下面几步来:

第一步,先用perf top挂在高频采样模式,看 CPU 时间到底花在哪些函数上。如果看到大量时间集中在copy_user_enhanced_fast_string、memcpy_erms、copy_page_to_iter、copy_user_highpage这类内核符号上,那基本可以断定内存复制是热点。

第二步,用strace -c统计系统调用次数和耗时。如果read、write、sendmsg、recvmsg这些调用占了大量耗时,说明进程在内核态和用户态之间频繁切换,每切换一次往往就伴随着一次数据复制。

第三步,看 NUMA 相关的计数器。很多 H2H 场景是两个进程分别调度在不同内存节点上,数据一来一回都在跨节点访问。用numastat可以观察local_node和other_node的占比,如果本地命中率很低,说明跨节点访存严重,这也会大幅降低内存拷贝效率。

第四步,不要忽略网卡侧。用相关工具查看网卡统计中收发队列的中断分布和丢包,这时候可以看到链路是不是真的塞满了。如果链路利用率不到 50%,但 CPU 已经打满,那问题基本就在软件路径而非链路。

3.2 一次真实的定位记录:CPU时间被内存复制吃掉了

之前我调一个转发服务,两端分别用各自的 API 读写数据。一开始看到理论算下来的链路和内存带宽都很充足,但实际吞吐只有理论值的四分之一。刚开始怀疑是 PCIe 链路限速,因为磁盘控制器和网卡都落在 PCIe 上,于是我先看了链路状态,发现实际数据传输速率远没有达到链路上限。

然后我挂上perf record,等负载跑几分钟后导出采样结果。排名第一的是内核页面复制相关函数,占比超过 60%,第二是用户态缓存拷贝,算下来整个数据路径的拷贝次数多达四次,CPU 几乎都在搬数据。之后我又用numastat检查,发现两个进程的接收缓冲区和发送缓冲区落在不同内存节点上,跨节点占比接近 70%,等于每次拷贝都在走远端内存。

问题到这已经很清楚了:链路没瓶颈,内存总带宽也没耗尽,但实际有效吞吐被“多次拷贝 + 跨节点访存”联合绞杀。之后我通过调整内存亲和性,把一个方向的数据尽量固定在同一个 NUMA 节点内,同时把路径中的一次中间拷贝去掉,吞吐提升了两倍以上。

3.3 排除法对照表:链路、内存、拷贝三者怎么区分

为了让排查过程更直观,我习惯把可能性分成三组,逐项做对照测试。

怀疑对象判断方式典型现象
PCIe 链路带宽不足看链路利用率和 DMA 吞吐数据量大时,链路利用率接近 100%,吞吐上不去
内存带宽不足用专门的内存压测工具测读写带宽内存带宽耗尽,CPU 等待内存控制器的周期剧增
H2H 拷贝路径太长采样热点集中在 memcpy 类函数链路利用率不高,CPU 占用率高,吞吐远低于链路有效值

一个非常有效的对照做法是:把同一份数据分别做三种测试。第一种是纯内存拷贝,测出本机内存复制的实际带宽;第二种是磁盘到网卡的零拷贝路径,测出设备到设备直接传送的带宽;第三种就是正常走用户态 API 的 H2H 路径。三者差距多大,拷备路径的损耗就有多大。在我经手的场景里,第二种比第三种往往快出数倍,说明瓶颈确实不在物理链路,而在路径设计。

4. 优化H2H拷贝的常用手段:从零拷贝到内存亲和

4.1 让数据少过一遍CPU:sendfile、splice、io_uring的适用边界

减少 H2H 拷贝最直接的办法,是让数据尽可能在内核里完成转移,或者让 DMA 直接完成设备到设备的数据搬运。

文件发送到网络这个场景,sendfile就是经典解法。它把页缓存里的数据直接组装成网络报文交给网卡,省掉了用户态缓冲区和套接字发送缓冲区之间的一次复制。需要注意的边界是:如果应用需要对数据做加密、压缩、校验或格式转换,sendfile就不适用了,因为数据必须经过 CPU 处理,零拷贝无从谈起。

splice是更通用的管道式零拷贝机制,它能把一个文件描述符的数据直接打到另一个文件描述符,数据不经过用户态。不过它也有两个限制:一是数据在管道里是“移动”而不是“复制”,一旦消费掉就没法回头;二是部分设备和文件系统对 splice 支持不完整,用之前要做兼容性验证。

io_uring是另一个值得优先考虑的入口。它本身不保证零拷贝,但提供了注册缓冲区、固定页等机制,可以在一定程度上减少内存映射和页表参与的开销。如果应用本身就是高并发小包结构,io_uring还能把多次系统调用合并为一次提交,显著降低用户态和内核态切换的开销。

这些手段有一个共同原则:它们只对“透传型”数据路径有效。只要数据在传输过程中不要求被应用层看一遍、改一遍,就优先把复制路径缩短;如果数据必须被业务逻辑处理,那就老老实实接受一次拷贝,但别让同样的数据在路径里反复出现。

4.2 大页与NUMA亲和:TLB miss和跨节点访存怎么同时压下去

减少拷贝次数之后,下一步要处理的就是“每次拷贝本身的效率”。

大页在这里作用明显。默认 4KB 页面对大块缓冲区来说,TLB miss 率居高不下,尤其跨很多页面的顺序拷贝,CPU 大部分时间都在等地址翻译。把缓冲区改成 2MB 大页,TLB 条目能覆盖的地址范围扩大 512 倍,顺序拷贝的 TLB miss 会大幅下降。我一般建议在关键路径上用明确的大页分配,而不是全局开启透明大页,因为透明大页的后台整理有时会带来意外的延迟尖刺。

NUMA 亲和同样重要。两个进程交换数据时,最好让发送方的缓冲区、接收方的缓冲区以及做拷贝的 CPU 都落在同一个内存节点上。如果做不到,至少也要让读写双方的内存一致落在同一个节点,避免一方是本地内存、另一方是远端内存。实际调整方式可以用 NUMA 相关的内存分配策略工具,把关键线程绑核,同时把缓冲区内存绑定到该 CPU 所在节点。

如果两个进程分布在不同的节点上,跨节点访问不可避免,那么要考虑的就不是消灭跨节点,而是减少跨节点次数。很多时候把“四次跨节点拷贝”改成本地节点内部完成大部分工作,只在最终提交时才跨节点写一次,性能立刻会有可感知的提升。

还有一个容易忽视的点:CPU 自动调频和功耗策略会显著影响内存拷贝速度。做性能测试时把调频模式切到高性能模式,否则内存拷贝类负载会频繁撞上频率限制,带宽数据看起来忽高忽低,搞不清楚是程序问题还是 CPU 节能策略在干扰。

4.3 硬件卸载不是万灵药:RDMA、用户态驱动与虚拟化穿透的取舍

当软件路径优化到极限,仍然需要更低延迟和更高吞吐时,才考虑把工作负载卸载到硬件。

RDMA 技术是目前跨物理主机 H2H 拷贝的终极手段之一。它让网卡直接从发送方内存读取数据,写入接收方内存,中间不经过对端 CPU 和操作系统协议栈。这种方案确实能获得非常高的吞吐和极低的延迟,但代价是工程复杂度大幅上升:内存要注册并锁定,缓冲区要提前规划,应用要改用异步事件模型,运维还要理解新的链路配置和故障排查方式。对于中小规模数据中转场景,值不值得上 RDMA 要看业务对延迟的要求。

用户态驱动是另一个方向。把设备直接暴露给应用层,绕过操作系统内核的收发路径,代价是独占设备且安全边界需要自己保证。这类方案适合单机内两个高频交互的 H2H 场景,比如虚拟交换机转发这类数据面应用。维护成本明显比传统内核栈高,开发周期也更长。

虚拟化穿透则是把物理设备直接分配给虚拟机,让虚拟机内部直接访问 PCIe 设备,宿主机不再参与每一次数据搬运。这个方案能减少虚拟化层引入的额外复制,但会牺牲设备的共享能力,且迁移等高级特性会受限。因此不要一看到“硬件卸载”就冲上去,合理判断数据路径中哪些复制可以被硬件吃掉,哪些复制本质上是业务逻辑的一部分,无法卸载。

4.4 不同场景的优化方向怎么选

场景特点优先措施核心目标
大文件从磁盘转发到网络sendfile / splice避免用户态中转
高并发小消息在两个进程间交换共享内存 + 无锁队列避免系统调用和多次复制
数据需要应用修改后发送固定缓冲区 + 注册内存 + io_uring减少用户态内核态切换成本
跨物理主机的低延迟数据传输RDMA 或用户态网卡驱动绕过协议栈和远端 CPU
虚拟机内部访问高性能存储设备穿透 + 大页避免虚拟化层重复复制

优化的取舍原则是:先算拷贝次数,再谈硬件升级。硬件卸载虽然上限高,但它并不能替你解决“软件路径设计不合理”的问题。同一份数据在到达硬件之前已经被复制了三遍,再强的 RDMA 网卡也只能保证“最后一段”很漂亮,前面几段照样拖后腿。

5. 排障速查与几个容易忽略的暗坑

5.1 症状、排查点、对策一页纸

症状可能的根因排查入口对策
吞吐远低于链路带宽,CPU 占用率高数据路径多次拷贝perf top 看 memcpy 类符号改用 sendfile/splice/用户态框架
跨节点流量占比高NUMA 亲和性差numastat绑核 + 内存绑定到同一节点
小包传输性能很差系统调用和中断开销过大strace -c批量合并、用户态驱动
高并发下延迟抖动明显TLB miss 或页分配压力大观察 dTLB-load-misses使用大页并提前固定内存
链路利用率总是上不去CPU 侧拷贝成为瓶颈链路统计与 CPU 采样对比减少拷贝次数后再看链路

5.2 三个被主流文章略过的隐藏因素

第一个是中断分发与 CPU 亲和性的联动。网卡中断和内存拷贝所在的 CPU 如果不在同一个 NUMA 节点上,数据从链路 DMA 进内存后,拷贝 CPU 再读取时就是跨节点访问。很多项目只绑了应用线程,却忘了把中断也绑到同一节点,结果一路都在跨节点折腾。

第二个是内核缓冲区的动态膨胀。默认情况下 TCP 发送接收缓冲区会根据水位动态调整,缓冲区一旦涨上去,数据量变大,复制压力也随之上升。有人以为调大缓冲区一定能提升吞吐,结果反而增加了内存复制负担。合理的做法是根据实际包大小和数据速率把缓冲区设成比较固定的值,避免动态波动带来的额外不确定。

第三个是虚拟化环境下的双层页表开销。虚拟机内部的内存地址要先经过客户机页表翻译,再经过宿主机页表翻译,同样一个缓冲区会带来双倍 TLB miss。如果业务跑在虚拟化环境中,建议在客户机和宿主机两侧都启用大页,并且尽量让接收缓冲区落在宿主机大页内存内,否则无论怎么优化用户态代码,页表开销都会在背后拖后腿。

我后来做项目的习惯是:凡是有人抱怨“链路带宽不够”,我都先问一句——两端之间的数据真的只完整搬运了一次吗?这个问题的答案往往能把问题切掉一半。H2H 拷贝看着不起眼,却是吞吐问题的常客。先把复制路径数清楚,再决定要不要动链路和硬件,顺序反了,往往白砸预算和时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进