从 dma-buf 到 tensor parallel:跨越领域的零拷贝模式

从 dma-buf 到 tensor parallel:跨越领域的零拷贝模式

前言:不同领域的工程师在解决同一问题

最近在阅读两个完全不同领域的项目时,我发现了一个有趣的现象。

一个是 Qualcomm 的 dmabuf_transport 项目,用于在 ROS 2 生态中实现机器人传感器数据的零拷贝传输。

另一个是 llama.cpp 的 tensor parallelism PR(#19378),用于在多个 GPU 之间并行运行大语言模型。

这两个项目看似毫无关联:一个是机器人操作系统,一个是 AI 推理框架。但它们的核心思想竟然惊人地一致------都试图避免不必要的数据复制

这种跨领域的一致性值得深入探究。通过对比这两个项目,我们可以提炼出一些超越特定技术的通用模式。


问题:数据复制的代价

dmabuf_transport 面临的问题

在机器人系统中,摄像头、激光雷达等传感器产生大量数据。这些数据需要在以下组件间传递:

  • 传感器驱动 → ROS 节点
  • ROS 节点 → GPU 视觉处理
  • ROS 节点 → DSP 信号处理

传统方式的问题

复制代码
传感器数据 (1920x1080 RGB图像 ≈ 6MB)
  ↓ memcpy
CPU 缓冲区
  ↓ memcpy
ROS 消息 (sensor_msgs::msg::Image::data)
  ↓ 序列化
网络传输
  ↓ 反序列化
ROS 节点
  ↓ memcpy
GPU 处理

每次传输都要复制数据,6MB 的图像要经过多次 memcpy,CPU 和带宽都成为瓶颈。当传感器数据以 30fps 持续输入时,这个开销会指数级放大。

llama.cpp 面临的问题

运行大语言模型(如 LLaMA 3 70B)时,单个 GPU 显存不够用。需要将模型分配到多个 GPU。

传统方式(pipeline parallelism)的问题

复制代码
GPU 0: Layer 0-23
  ↓ 复制激活值
GPU 1: Layer 24-47
  ↓ 复制激活值
GPU 2: Layer 48-71

模型权重在 GPU 间复制,层与层之间传输激活值,通信开销巨大。在实际测试中,对于 GPT-OSS 120B MXFP4 MoE 模型,使用 2x RTX 4090 进行 tensor parallelism 比纯 pipeline parallelism 在高上下文深度下快约 30%。


解决方案:零拷贝的两种实现

方案一:dmabuf_transport - 共享文件描述符

核心思想:传输数据的"引用"而非数据本身。

cpp 复制代码
// dmabuf_transport/type/image.hpp
struct Image {
  std_msgs::msg::Header header;
  uint32_t width;
  uint32_t height;
  std::string encoding;
  std::shared_ptr<lib_mem_dmabuf::DmaBuffer> dmabuf;  // 关键!
};

dmabuf 是一个指向 Linux dma-buf 的文件描述符(fd)。物理内存只分配一次,所有需要访问这个数据的组件共享同一个 fd。

传输流程

复制代码
1. 传感器驱动分配 dma-buf
2. 填充数据到 dma-buf
3. 创建 Image 消息,dmabuf 字段持有 fd
4. ROS 发布消息(只传输 fd,不传输数据)
5. GPU/DSP 通过 fd 直接访问物理内存

关键代码(src/type/image.cpp):

cpp 复制代码
// 创建时分配 dma-buf
auto dmabuf = lib_mem_dmabuf::DmaBuffer::alloc(size, "/dev/dma_heap/system");
msg->dmabuf = dmabuf;

// 转换时映射内存(仅当需要标准 ROS 类型时)
if (source.dmabuf->map() && source.dmabuf->sync_start()) {
  std::memcpy(dest.data.data(), source.dmabuf->addr(), size);
  source.dmabuf->unmap();
}

方案二:llama.cpp tensor parallel - 拆分与直接传输

核心思想:将大张量拆分到多个设备,通过直接设备间通信同步。

Meta Backend 架构

cpp 复制代码
// 新的设备类型:包装多个 simple backends
enum {
    GGML_BACKEND_DEVICE_TYPE_META,  // 张量并行专用
};

// 拆分状态定义
enum ggml_backend_meta_split_axis {
    GGML_BACKEND_SPLIT_AXIS_0,   // 按维度 0 拆分
    GGML_BACKEND_SPLIT_AXIS_1,   // 按维度 1 拆分
    GGML_BACKEND_SPLIT_AXIS_MIRRORED,  // 所有设备复制(权重)
    GGML_BACKEND_SPLIT_AXIS_PARTIAL,  // 部分结果(需要 AllReduce)
};

权重拆分策略(对于 Transformer):

复制代码
权重矩阵 W [hidden, model_dim]
  ↓ 按维度 1 拆分
GPU 0: W[:, 0:model_dim/2]
GPU 1: W[:, model_dim/2:model_dim]

前向传播后:
  ↓ AllReduce
GPU 0: 部分结果 + GPU 1 的部分结果 = 完整结果
GPU 1: 部分结果 + GPU 0 的部分结果 = 完整结果

对于 Transformer 模型,这个策略意味着两次 AllReduce 操作:一次在注意力层之后,一次在 FFN 层之后。

关键代码模式(ggml/src/ggml-backend-meta.cpp):

cpp 复制代码
// 地址移植:避免重复分配内存
// meta backend 分配一次地址,然后"移植"到底层 backend
for (size_t j = 0; j < n_backends; j++) {
    auto & bcj = backend_ctx->backend_configs[j];
    // 只需要分配完整张量的一小部分
    ggml_backend_alloc_buffer(bcj.backend, max_tmp_size);
}

// 设备间直接传输(异步)
bool cpy_tensor_async(
    ggml_backend_t backend_src,
    ggml_backend_t backend_dst,
    const ggml_tensor * src,
    ggml_tensor * dst
);

PR 作者 JohannesGaessler 提到,meta backend 通过"地址移植"(address transplantation)的方式解决了内存分配问题:先为 meta backend 分配内存,然后将计算出的地址相对于底层 buffer 指针移植到各个 backend。因为每个底层张量只需要完整内存的一小部分,所以能够正确工作,虽然这会导致计算图的内存过度分配。


跨领域综合:零拷贝的本质

通过对比这两个项目,我们可以提炼出零拷贝模式的三个层次。

零拷贝的三层抽象

层 1:物理层 - 只分配一次物理内存

  • dma-buf:GPU/CPU 共享物理页
  • GPU memory:GPU 直接访问设备内存
  • 共享内存:多进程映射同一物理区域

这一层的核心是:物理内存只分配一次,所有需要使用数据的组件共享同一块物理内存。

层 2:逻辑层 - 使用引用而非副本

  • 文件描述符 / 句柄:dmabuf_transport 的方式
  • shared_ptr:llama.cpp 使用智能指针管理张量生命周期
  • 张量元数据:meta backend 通过元数据描述如何拆分

这一层的核心是:传递数据时,传递引用/元数据,而非数据本身。

层 3:同步层 - 控制访问而非复制数据

  • dma-buf sync_start/sync_end:确保数据一致性
  • AllReduce 同步:多设备间同步部分结果
  • 内存屏障:防止内存访问顺序问题

这一层的核心是:当多个组件访问同一份数据时,通过同步机制保证一致性,而非复制多份。

跨领域的对比表格

领域 数据单位 共享机制 传输方式 典型场景
操作系统 文件描述符 fd 传递 进程间通信 Unix Domain Socket + SCM_RIGHTS
机器人 传感器帧 dma-buf fd ROS 消息 相机 → GPU 视觉处理
AI 推理 张量切片 GPU 直接访问 peer-to-peer / AllReduce 多 GPU LLM 推理
数据库 数据页 共享缓冲池 指针传递 Buffer Pool 管理
分布式系统 数据块 RDMA 零拷贝网络协议 跨机器数据共享
高性能网络 数据包 DPDK 网卡 DMA 数据包处理

从这个表格可以看出,零拷贝模式在各个领域都有应用,只是表现形式不同。

图像化理解

复制代码
传统复制模式:
┌─────────┐    memcpy    ┌─────────┐    memcpy    ┌─────────┐
│ 来源   │  ─────────>  │  缓冲区 │  ─────────>  │ 目标   │
└─────────┘               └─────────┘               └─────────┘
   (数据 A)                 (数据 A)                  (数据 A)

零拷贝模式:
┌─────────┐   指针/fd   ┌─────────┐   指针/fd   ┌─────────┐
│ 来源   │  ─────────>  │  共享区 │  ─────────>  │ 目标   │
└─────────┘               └─────────┘               └─────────┘
   (数据 A)               (数据 A)                  (数据 A)
   ↓ 只有一个数据块 A 存在

实践:如何在自己的项目中应用零拷贝

识别零拷贝机会的三个问题

通过分析这两个项目,我们可以提炼出三个关键问题,帮助识别零拷贝的机会:

问题 1:你的数据有多少次被复制?

  • 检查代码中的 memcpystd::copy、序列化/反序列化操作
  • 画出数据流图,标记每一步是否发生了复制
  • 使用性能分析工具(如 perf、nvprof)找出热点

问题 2:复制是否必要?

  • 多个消费者是否可以共享同一份数据?
  • 是否可以通过引用、指针、文件描述符传递?
  • 数据的生命周期是否足够清晰,使得共享是安全的?

问题 3:零拷贝的代价是什么?

零拷贝不是免费的,需要权衡:

收益 代价
减少内存带宽占用 同步复杂度增加
降低 CPU 使用率 生命周期管理困难
降低延迟 调试难度增加
节省内存空间 可能引入新的瓶颈

dmabuf_transport 的权衡

收益

  • 避免传感器数据的多次复制
  • GPU/DSP 直接访问,降低延迟
  • 支持高通 RB3 Gen2、IQ-9075 等硬件平台

代价

  • 需要内核 5.12+ 支持
  • 必须管理 dma-buf 生命周期(使用 shared_ptr)
  • TypeAdapter 转换时仍有拷贝(不完美)
  • 硬编码 /dev/dma_heap/system 路径,灵活性受限

llama.cpp tensor parallel 的权衡

收益

  • 多 GPU 并行,提高吞吐量(2x RTX 4090 上 LLaMA 3 性能提升显著)
  • 权重不重复,节省显存
  • 支持 NCCL 加速 AllReduce
  • 后端无关,可适配 CUDA、Metal、Vulkan 等

代价

  • AllReduce 通信开销(比 NCCL 官方库慢)
  • 图划分的 CPU 开销(162 微秒 vs 61 微秒单 GPU)
  • 同步调试困难(Vulkan backend 存在死锁问题)
  • 当前限制:只支持 1-4 GPU,模型维度必须可整除设备数

PR 中的性能数据显示,对于 LLaMA 3 在 2x RTX 4090 上,tensor parallelism 在大模型和高上下文深度下表现更好。Token generation 比 prompt processing 受益更大,因为需要传输的数据量与 batch size 成正比。


扩展:其他领域的零拷贝模式

零拷贝模式在计算机科学的其他领域也有广泛应用,了解这些模式可以帮助我们在自己的项目中识别机会。

1. gRPC 零拷贝(Zero-copy RPC)

传统的 gRPC 调用会将整个消息序列化、传输、反序列化:

protobuf 复制代码
message LargeData {
  bytes data = 1;  // 传统:复制整个数据
}

零拷贝版本使用 Arena 分配:

cpp 复制代码
// 使用 Arena 分配,避免重复分配
grpc::ByteBuffer buffer;
buffer.set_data(ptr, size);  // 不复制,只存储指针

2. RDMA(远程直接内存访问)

RDMA 允许跨机器的零拷贝数据传输:

  • 直接访问远程机器内存
  • 避免内核参与数据传输
  • CPU 不参与数据复制
  • 延迟降低,吞吐量提升

3. Unix Domain Socket + SCM_RIGHTS

通过 Unix Domain Socket 传递文件描述符:

c 复制代码
struct msghdr msg = {0};
char buf[CMSG_SPACE(sizeof(int))];
msg.msg_control = buf;
msg.msg_controllen = sizeof(buf);

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_type = SCM_RIGHTS;
*(int *)CMSG_DATA(cmsg) = fd;  // 传递 fd 而非数据

这与 dmabuf_transport 的思路类似:传递访问权限,而非数据本身。

4. Linux io_uring

io_uring 是 Linux 的异步 I/O 框架,支持零拷贝操作:

  • IORING_OP_SENDZC:零拷贝发送
  • IORING_OP_PROVIDE_BUFFERS:提供缓冲池,避免重复分配
  • IORING_OP_READ_FIXED:读取到固定缓冲区

5. DPDK(Data Plane Development Kit)

DPDK 用于高性能网络数据包处理:

  • 跳过内核网络栈
  • 网卡直接将数据包 DMA 到用户空间
  • 零拷贝的数据包处理
  • 用于 10Gbps+ 的网络场景

总结

通过对比 dmabuf_transport 和 llama.cpp tensor parallelism 两个项目,我们可以提炼出一些超越特定技术的通用模式:

核心洞察

  1. 零拷贝是一种思维模式,而非特定技术的技巧

从机器人传感器到 AI 推理,从操作系统到分布式系统,零拷贝模式无处不在。理解这种以"共享"替代"复制"的思维模式,可以帮助我们在不同领域发现优化的机会。

  1. 三层抽象提供理解框架
  • 物理层:只分配一次内存
  • 逻辑层:传递引用而非副本
  • 同步层:控制访问而非复制数据

这个框架可以用来分析和设计任何零拷贝系统。

  1. 权衡是必要的

零拷贝不是免费的,需要根据具体场景权衡收益与代价。在机器人实时系统中,延迟优先;在 AI 推理中,吞吐量优先。

  1. 识别零拷贝机会的三个问题
  • 你的数据有多少次被复制?
  • 复制是否必要?
  • 零拷贝的代价是什么?

这三个问题可以作为检查清单,帮助我们在设计和优化系统时识别零拷贝的机会。

最终结论

当我们深入观察不同领域的技术实现时,会发现很多核心思想是一致的。零拷贝就是这样一个核心思想------它跨越了操作系统、机器人、AI、数据库、分布式系统等领域,却在解决同一类问题:如何高效地在多个组件之间共享数据。

理解这种跨领域的一致性,可以帮助我们建立自己的"内在知识库",在面对任何技术问题时,都能从中找到可以应用的思维模式。


参考文献

  1. dmabuf_transport - GitHub - Qualcomm 的 ROS 2 零拷贝传输库
  2. llama.cpp Tensor Parallelism PR #19378 - ggml-org/llama.cpp 的张量并行实现
  3. Linux DMA-BUF Documentation - Linux DMA-BUF 内核文档
  4. REP-2007: Type Adaptation - ROS 2 类型适配规范
相关推荐
一条大祥脚1 小时前
Manacher/马拉车算法
算法
phoenix@Capricornus1 小时前
初等数学中点到直线的距离
人工智能·算法·机器学习
田里的水稻2 小时前
FA_规划和控制(PC)-快速探索随机树(RRT)
人工智能·算法·数学建模·机器人·自动驾驶
jaysee-sjc2 小时前
十三、Java入门进阶:异常、泛型、集合与 Stream 流
java·开发语言·算法
元亓亓亓2 小时前
LeetCode热题100--41. 缺失的第一个正数--困难
数据结构·算法·leetcode
码云数智-大飞2 小时前
.NET 10 & C# 14 新特性详解:扩展成员 (Extension Members) 全面指南
java·数据库·算法
weixin_477271692 小时前
狗象:与强大的一方建立联系,并控制调用对方的力量。)马王堆帛书《周易》原文及甲骨文还原周朝生活现象《函谷门
算法·图搜索算法
小妖6662 小时前
js 实现归并排序算法
算法·排序算法
fu的博客3 小时前
【数据结构7】链式栈实现
数据结构·算法