前言
性能优化系列的初篇,起笔前有些犹豫,在大语言模型盛行的当下,再去写技术文章还有意义吗?想到一个很搞的理由,大模型没有经历过切肤之痛,写不出那种感同身受的感觉,哈。
最近这几年一直在做程序化广告的算法引擎,偏重在线部分,对高并发服务的性能和稳定性优化有一些经验。不管是汇量还是易点,流量仿佛取之不尽,用之不竭,这也暗示着三方广告平台面临的单条请求的价值较低的窘境,流量和机器成本成为一对矛盾体。
零拷贝/少拷贝
在广告算法引擎中,排序服务涉及大量的数据传输。这包括请求上下文、供给方数据、需求方数据、设备及行为数据等 Raw Data,需要对这些数据进行特征的抽取、归一化及计算,然后透传给推理服务。
在做成本优化时,通过 cpu profiler 发现在调用推理服务时,占用了较多的计算资源,细读推理服务的客户端代码,由于历史原因,存在两次请求数据格式的转换(C++ Object -> C++ Columnar Storage Struct -> Flatbuffer),在减少一次转换后,减少了 10% 的计算资源占用。
基于上面,零拷贝/少拷贝在部分大数据量、高并发服务中,可以节省较多的机器成本。
硬件层面
DMA(Direct Memory Access)
DMA(Direct Memory Access,直接内存访问)是一种允许外部设备(如硬盘、网卡、显卡等)直接与计算机内存进行数据传输,而无需中央处理器(CPU)全程干预的技术。它的核心作用是减轻 CPU 的负担,提高数据传输效率,尤其适用于大批量数据的快速读写场景。
这个技术已经很成熟了,就不过多赘述。
现在有一个 case,当 DMA 遇到虚拟化技术,会怎么样呢?宿主机是否会向虚拟机内存空间拷贝一份数据呢?
现在硬件虚拟化技术中,会用 IOMMU(IO 内存管理单元) 来解决这个问题。原理是,设备发起 DMA 时,IOMMU 拦截 DMA 地址,把它从 设备可见地址 → 宿主机物理地址(HPA) 做一次翻译。
目前主流的 IOMMU 实现包括:
Intel 的 VT-d(Virtualization Technology for Directed I/O)
AMD 的 AMD-Vi(I/O Virtualization Technology)
那怎么判断虚拟机是否使用了 IOMMU 技术呢?可以从宿主机和虚拟机两个角度查看 IOMMU 的支持情况。
查看宿主机是否支持
$ sudo dmesg | grep -e DMAR -e IOMMU
[ 0.610359] pci 0000:00:00.2: AMD-Vi: IOMMU performance counters supported
[ 0.616480] perf/amd_iommu: Detected AMD IOMMU #0 (2 banks, 4 counters/bank).查看虚拟机是否开启 IOMMU
$ sudo dmesg | grep -e IOMMU -e VFIO -e PCI
...
[ 0.689891] PCI-DMA: Using software bounce buffering for IO (SWIOTLB)Using software bounce buffering for IO (SWIOTLB) 意思是当前环境里 DMA 没有用硬件 IOMMU,而是退化为软件模拟的 bounce buffer (SWIOTLB),此时虚拟机从设备读取的数据,会多一次内存拷贝。
操作系统层面
recvmsg
可以借助 recvmsg 等系统调用,减少从设备读取数据时的内存拷贝。
以网络数据传输为例,常规的 recv / read 会存在两次内存操作:
网卡通过 DMA 把数据写入内核的 socket buffer 里
用户代码调用 recv/read 系统函数时,操作系统会把 socket buffer 内的数据拷贝至用户态内存,期间存在内核态和用户态的切换
recvmsg 系统调用的优势:
recvmsg可以一次把 数据 + 元信息 (CMSG) 拿回来,而recvfrom+getsockopt这种写法,可能需要两次系统调用recvmsg在 某些协议/场景 下,允许应用层 buffer 直接被网卡 DMA 发送,减少一次拷贝
从应用角度,folly 的 IOBuf 和 brpc 的 IOBuf 都有使用 recvmsg 来减少网络数据的内存拷贝。
mmap
借助 mmap 系统调用,可以避免 read → malloc → memcpy 的多次拷贝。
read() 会把内核中已有的数据从内核缓冲区(page cache / socket buffer)拷贝到用户态缓冲区;而 mmap() 则把文件的页直接映射到进程地址空间,用户访问时内核只需将页(page)映射/填充到进程页表,不需要显式的 memcpy 把数据从内核区拷到用户区,避免一次拷贝(内核→用户的 memcpy)。
sendfile / splice / tee
sendfile 替换 send,sendfile 调用,内核直接把 page cache 的页面推送到 socket buffer,避免进入用户态,send 调用则会拷贝数据到内核的 socket buffer。
相似的函数还有 splice、tee 等。
框架层面
如上,folly 的 IOBuf 和 brpc 的 IOBuf 都有针对 IO 的内存拷贝优化,在关注性能的地方,可以尝试更换此类库。
代码层面
在这里梳理下 C++ 语言环境下,可以减少内存拷贝的实践。
使用 std::string_view 减少字符串数据的内存拷贝,注意数据的生命周期
返回值使用 const& 或 std::move 避免对象的拷贝,不过 C++ 17 后针对返回值有 RVO(Return Value Optimization) + move 的优化
借助共享指针,减少大对象的拷贝