1. 问题起点:一条被反复点击的优化路径
做Linux内核驱动或者搞过多媒体框架的兄弟,对 dma-buf 应该都不陌生。做分布式训练的同学,对 tensor parallel 更是每天都要打交道。这两个东西放在一起看,表面上像两条永不相交的平行线——一个在内核态管物理内存,一个在深度学习框架里管张量计算。但我这两年两边项目都深度参与之后,越来越觉得它们骨子里的设计逻辑是同一套东西。
先讲个我自己遇到的场景。之前做视频采集设备驱动,ISP(图像信号处理器)采集到一帧原始图像,需要扔给GPU去做AI识别。最朴素的做法是:ISP先把数据写到内存,然后CPU用memcpy把数据从内核缓冲区拷到用户态,再拷到GPU显存。拷贝过程中CPU占用高,延迟大,一帧1080p30的图就能把CPU吃满十几个百分点。后来改成dma-buf方式,ISP直接把数据写到一块dma-buf里,设备fd一传,GPU那边直接拿这块物理内存映射成自己的显存,CPU全程零拷贝,延迟直接少了七八毫秒。
那边做训练的同学也在折腾类似的事。多机多卡训练,模型太大单卡放不下,就把一个Transformer层按head维度切开,每个GPU只算自己那部分,算完互相传一下。这个过程叫tensor parallel。最开始我们用的NCCL allreduce,每个step梯度同步一次,后来发现通信时间占比太高,就在算法层面做分片、做通信压缩,本质上也是要减少数据搬移量。
一个是内核态的设备间内存共享,一个是分布式训练的张量切分,名字差了十万八千里,底层逻辑却惊人地一致:谁都不想让数据在中间商手里多倒一次手。
这篇文章就把这两个领域拆开揉碎,看看它们各自的零拷贝实现思路,然后再拉通对比,聊聊设计模式层面能互相借鉴的地方。适合做驱动开发、多媒体框架、嵌入式优化、以及搞分布式训练框架的读者,读完你可能会跟我一样,有一种“原来大家都在解决同一个问题”的通透感。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. dma-buf 与零拷贝的内核实践
2.1 dma-buf 的诞生背景:从系统内存到外设跨域传输
先聊 dma-buf。这个东西在Linux内核里其实已经有十几年的历史了,最初是给GPU驱动、显示控制器、视频编解码器这些外设之间共享内存用的。
背景是这样的:以前多个外设(比如GPU和Display Controller)要共享一帧图像,大家各搞各的。GPU渲染完一帧,要显示到屏幕上,需要把数据从GPU显存拷贝到系统内存,再拷贝给显示控制器。每一轮拷贝都是按帧大小算的,4K分辨率60帧的显示输出,每一帧8MB左右,一秒要拷480MB数据过一遍系统总线,总线带宽被占掉一大截,延迟还高。
后来内核社区统一搞了 dma-buf 这个抽象层。核心思想很简单:一块物理内存(可能本身是设备显存,也可能是系统内存),由某个设备/驱动作为生产者创建出来,放进一个统一的buffer对象里,其他设备和驱动可以通过fd或者handle引用它,获得它的映射、同步、导出权利。本质上就是一个跨设备、跨驱动的共享内存管理器。
更底层一点说,dma-buf 屏蔽了“这块内存到底在物理上属于谁”的细节。它把这些统一抽象成 DMA地址和 sg_table(scatter-gather 表),别的设备要访问,就通过 dma_buf_map_attachment 拿到自己总线视角的地址。这个过程不需要任何数据搬移,只是把地址关系映射一下,开销是微秒级的。
2.2 生产者-消费者模型与所有权移交
dma-buf 的架构非常像一个典型的生产者-消费者模型。你去看内核源码,drivers/dma-buf/dma-buf.c 里核心数据结构就三块:
- dma_buf:这个是共享内存对象的“文件本体”,内部管理着 sg_table、ops 操作集、attach 列表。
- dma_buf_attachment:代表某个设备对这块 buffer 的一次“接入/引用”。一个 dma_buf 可以有多个 attachment,也就是多个设备同时引用。
- dma_buf_ops:定义了 map_dma_buf、unmap_dma_buf、mmap、begin_cpu_access 等回调。
有意思的是所有权移交。按传统思路,内存只归属于一个设备的地址空间,其他设备要用就得“借”。dma-buf 把这个过程规范成了 S2(同步)和 M2(互斥)两种状态管理,配合 fence 机制(下面会细说),保证数据在被消费者读到之前,生产者已经把数据写完了。
我在实际驱动开发中,最常用的流程是这样的:
c复制// 导出端:分配dma-buf,填充ops,创建fd
struct dma_buf *dmabuf = dma_buf_export(&exp_info, &dma_buf_ops);
int fd = dma_buf_fd(dmabuf, O_CLOEXEC);
c复制// 导入端:通过fd获取dma-buf,创建attachment并映射
struct dma_buf *dmabuf = dma_buf_get(fd);
struct dma_buf_attachment *attach = dma_buf_attach(dmabuf, dev);
struct sg_table *sgt = dma_buf_map_attachment(attach, DMA_BIDIRECTIONAL);
// 拿到sgt->sgl里的物理地址,就可以给DMA控制器用了
整个过程没有任何数据拷贝,只是把地址传递和权限管理做好。
2.3 mmap 与 cache 一致性:零拷贝的直接体现
dma-buf 零拷贝最直观的体现是 mmap。设备驱动导出的一块 buffer,用户态可以像映射普通文件一样把它映射进自己的虚拟地址空间,然后CPU就能直接读写这块物理内存/显存。
这里有几个坑,不踩一遍真的不知道。内核里 dma_buf_mmap 和驱动自身 mmap 的区别在于,dma_buf_mmap 会把 vma 的 vm_ops 替换成 dma_buf 自己的,并且在 fault 处理时会走 dma_buf->ops->mmap 回调。如果驱动的 mmap 回调实现得不对,最容易出现的两个问题:
- cache 一致性没有处理好。CPU 通过 mmap 写入数据后,如果设备要通过 DMA 读这块内存,CPU writeback 缓存没有主动刷掉,设备读到的可能是旧数据。反过来,设备 DMA 写完,CPU 读之前如果没有 invalidate,CPU 会读到缓存里的旧数据。这块必须要有 dma_sync_single_for_cpu / dma_sync_single_for_device 之类的操作配合,或者在 map_dma_buf 时正确设置 DMA_ATTR_SKIP_CPU_SYNC/SYNC 属性。
- vma 的 page fault 路径没走对。很多新手驱动作者习惯在 mmap 回调里直接 remap_pfn_range 把物理地址映射出去,这在 dma-buf 里是不对的。因为 dma-buf 的物理内存可能是散列的(sg_table 里多个 sgl 项),必须通过 vm_ops->fault 逐页映射,才能正确处理不连续物理内存。我自己就在这里踩过一次,映射出来的地址能访问,但每次都要按最大连续物理块去猜,到碎片化内存就崩溃。
2.4 fence 同步:零拷贝的基石
如果说 mmap 保证了数据通路上的零拷贝,fence 就保证了时间维度上的零拷贝——不需要通过CPU轮询或者反复提交IOCTL来确认数据准备好了。
dma_fence 机制你可以在任何 dma-buf 实现里看到它的影子,尤其 GPU 驱动。比如你在 GPU 上提交了一系列渲染命令,这些命令写一块 dma-buf;另一个设备(比如视频编码器)要读这块内存。编码器不能盲等,也不能用“猜时间再去读”这种智障方案。它只需要在这个 dma-buf 上挂一个 fence,GPU 那边的命令执行完信号量置位后,内核会让需要等待的进程唤醒继续执行。
fence 本质上是一个内核态的异步回调机制,它把“数据准备好了”这个信号在设备之间以低开销传递。对于驱动开发来说,正确的做法是:
- 写方在提交完 DMA 操作后,创建一个 fence,并且在 DMA 完成中断回调里 signal 这个 fence。
- 读方在启动自己设备的 DMA 之前,调用 dma_fence_wait,把读方设备的中断或者任务挂到这个 fence 上。
这避免了显式的 CPU 同步,把等待粒度降到了硬件中断级别,对高性能流水线帮助极大。
3. tensor parallel 的思路拆解
3.1 当显存放不下的那一刻
分布式训练里的 tensor parallel(TP),我最早接触是因为公司里一个超大规模的 LLM 训练任务,单卡显存死活装不下一个 70B 模型。模型并行最自然的想法是层间并行(pipeline parallelism),把Layer 1放在GPU0,Layer 2放在GPU1,前向计算一路传下去。这个方案实现简单,但有一个致命问题:任意时刻只有一张卡在算,其他卡都在等,GPU利用率上不去,而且大量中间激活值要跨卡传输。
Tensor parallel 走的是另一条路:把一个算子在张量维度上切开,多张卡并行算同一个层。比如一个 Linear 层,权重矩阵 W 是 [out_features, in_features],我们可以按输出维度把它切成两块,GPU0拿上半部分,GPU1拿下半部分。输入x完整广播给两张卡,各自算 y0 = x @ W0, y1 = x @ W1,最后在输出维度上拼接(all-gather)得到完整的 y。这个过程每个GPU都在算,利用率高了,但引入了一个新的开销——通信。
NCCL 的 allreduce 和 all-gather 在跨机场景下走 RDMA(远程直接内存访问),数据根本不过CPU,这就是 tensor parallel 系统层面上的零拷贝体现。RDMA 可以让 GPU 显存到 GPU 显存直接传输,不需要在内存中间落地。
3.2 张量切分方案:行切分、列切分与权重存储
从算法层面看,tensor parallel 的“零拷贝”体现在权重存储上。我们把一个算子的权重切分到多张卡上时,每张卡只保存自己需要的那部分权重,避免每张卡都存一份全量权重,然后再通过通信把冗余的那部分“拷贝”走。
实际工程里最常见的切法:
- 行并行(Row Parallel):把权重按行切开。例如 [in_features, out_features] 切分为 [in_features / TP_size, out_features],每个GPU拥有完整的输出维度、部分的输入维度。前向传播需要对输入做 all-gather,计算结果直接拼接。
- 列并行(Column Parallel):把权重按列切开。每个GPU拥有部分输出维度,但拥有完整的输入维度。前向传播需要把输入广播到所有GPU,计算完再 all-reduce 累加部分结果。
这里头的关键点是:不同的切分方案决定了不同的通信模式。用列并行,前向在输出端要还一次 all-reduce;用行并行,前向的输入端要先 all-gather。通信量和切分方式高度耦合。以 Megatron-LM 的实现为例,注意力层里QKV投影用的都是列并行,输出投影用的行并行,正好形成一组互补,通信次数是最少的。
python复制# 伪代码:列并行 Linear,在张量维度上切分
class ColumnParallelLinear(nn.Module):
def __init__(self, in_features, out_features, world_size):
super().__init__()
# 把权重在输出维度上切分
self.weight = nn.Parameter(
torch.empty(out_features // world_size, in_features)
)
# 初始化只初始化自己这份切片
def forward(self, x):
# x 是广播给所有 rank 的完整输入
local_out = F.linear(x, self.weight)
# 用 all-reduce 把每个 rank 的部分输出累加
torch.distributed.all_reduce(local_out)
return local_out
3.3 通信-计算重叠:让拷贝“隐藏”在计算背后
光靠权重切分还不够,因为通信开销并没有消失,只是从“显存不够的被迫搬运”变成了“并行计算后的结果合并”。为了把这一步的成本压下去,业界普遍的做法是 通信-计算重叠。
怎么理解?假设一层网络有 QKV 三个投影,三者在 token 维度上是独立的。我们可以在通信阶段只通信 Q 的输出,同时开始计算 K 和 V 的部分结果。又或者用更细的粒度:把一批 token 切分成多个 micro-batch,在发第一个 micro-batch 出去等待返回的同时,算第二个 micro-batch。GPU 的计算单元和通信单元是独立的硬件资源,让两者尽量同时干活,延迟就被“隐藏”掉了,宏观上看好像没花通信时间。
这种思路和 dma-buf 里的 fence 异步等待是完全一致的——都是在等待数据的时候就先去干别的活。只不过 dma-buf 靠的是硬件中断+内核回调机制自动调度,tensor parallel 靠的是 CUDA stream 的事件依赖和调度器手工排布流水线。
3.4 NPU/GPU 场景下的内存零拷贝扩展
我最近在带一个昇腾NPU的训练适配项目,这种零拷贝的思路体会更明显。昇腾的 HCCL 通信库里也有类似hccl_memcpy的接口,但更关键的是,如果张量的跨卡搬移能够被计算单元直接消费(比如直接从远端内存地址参与矩阵乘),就不需要先拷贝到本地一块临时buffer,也省去了在本地再做一次memcpy 的开销。这类硬件能力通常叫remote memory access或者直接内存寻址。驱动层面要做的是把远端内存映射进当前设备IO地址空间,本质上和 dma-buf 里 attach 一个设备、拿到 sg_table 然后映射的流程异曲同工。
4. 两个领域的高层共性:零拷贝设计模式的长线逻辑
4.1 数据排布权大于数据所有权
我发现两个领域最核心的设计共识是:谁拥有数据不重要,数据在物理上以什么方式排布、以什么方式被消费,才值得花心思去定义。
dma-buf 里,一块内存的所有权可能既不属于GPU也不属于ISP,而是被抽象为“一个buffer对象,多个设备attach”。每个设备的地址空间都是独立的,dma_buf_attachment 存在的意义就是记录“每个设备是如何看待这块内存的”——物理连续还是散列、要不要IOMMU映射、缓存策略是write-back还是write-combine。
tensor parallel 里同样如此。权重矩阵属于“模型所有权”,但更重要的是它被切分成什么排布,每张卡持有的切片是行还是列。切片方式直接决定了通信原语(broadcast / all-gather / all-reduce)和内存排布(连续还是碎片化)的要求。你在设计一个TP组时,本质上是在设计数据的“视图”,而不是在分配内存。
4.2 交接成本最小化:所有权的直接让渡 vs. 数据的原地操作
第二个共性是交接成本最小化。
dma-buf 的交接发生在设备边界。GPU 写完后,数据原地留着,编码器拾取时不需要把整个buffer搬运,只需要完成一个“地址所有权/同步权”的移交,配合 fence 确保读取方等到正确的时间点。
tensor parallel 的交接发生在计算节点边界。每个GPU算完自己的那部分结果,不能直接等待别人把完整结果送过来,而是用 NCCL/HCCL 在设备间直接传输张量数据(通过RDMA等方法,本身不经过CPU存一遍再发给网卡),数据在传输完成后直接落在接收方的显存上,而无须本地再产生临时副本。
两者都在拼命压缩“数据停下来被拷贝到中间介质再继续流动”的时间窗。
4.3 异步抽象是零拷贝的私生子
没有异步,就没有零拷贝。
dma-buf 的 fence,本质上就是一种异步数据可用性通知机制。如果没有 fence,消费者必须轮询或者用同步IOCTL阻塞等待生产者完成。轮询意味着CPU可能会反复读到未就绪的数据,中间buffer要反复拷贝确认状态;阻塞等待则让设备流水线空转。
tensor parallel 的 CUDA event / stream 机制,也是异步抽象。每个rank发完all-reduce操作后就去执行下一个可以独立计算的kernel,等需要结果时再去cudaStreamSynchronize或者直接让下一个kernel依赖stream event。通信与计算的重叠,本质上是把同步点往后推迟,中间的时间全部用来干活。
如果非要用一句话概括——零拷贝不是不拷贝,而是把拷贝隐藏到异步抽象的缝隙里,让等待时间变成计算时间。
5. dma-buf 设计思想对 tensor parallel 实践的反向启示
5.1 用 sg_table 的思路管理模型的层级显存
我自己做完两个领域的对比之后,实际动手在训练框架里借鉴过 dma-buf 的 sg_table 设计。trainer 在初始化阶段给每个模型层预分配显存时,不是按单一连续 chunk 来分配,而是像 sg_table 一样,把每个层的权重分布记录成一个散列表,每一页物理内存对应一个分片。
这样做的直接收益是,前向反向过程中,每个layer的张量访问可以直接通过索引表找到对应的物理内存,把“逻辑上相邻的层”和“物理上实际存放的位置”解耦开。在做 offload 或者混合并行时,需要迁移某个层到另一张卡,就不用整个连续块 memcpy,而是按 sg_table 的条目逐项迁移,配合 CUDA IPC 句柄完成跨进程共享,可以省掉百分之三四十的峰值拷贝流量。
5.2 fence 对异步通信排程的启发
fence 的引用计数和 signal 机制也让我反思 NCCL 通信任务的调度。默认的torch.distributed.all_reduce是同步阻塞的,执行完才返回。但如果你在一个大循环里手动管理一组 tensor 的异步通信,完全可以设计一个类似 fence 的“可用性标记”结构:
- 每个张量对象里挂一个 atomic_flag,对应上一轮通信完成与否。
- 计算下一个不依赖该张量的算子时,不检查这个flag;只有显式依赖它的算子才需要
flag.wait()。 - 通信完成回调里把 flag 置位,同时触发依赖该张量的算子队列继续执行。
这个思路我落地在一个自定义的ZeRO-Offload流水线里,把原来因为跨节点依赖导致的同步等待时间降低了一半以上。本质上,fence 的“确认数据可用但不阻塞不相关计算”思路,放在任何异步系统里都是通用的。
5.3 一个完整的自研小例子:跨设备视频帧送入GPU训练
最后分享一个我自己写过的、同时融合两个领域思路的小项目。背景是边缘设备上有实时视频流转 AI 识别需求:摄像头采集帧,ISP导入dma-buf,GPU把帧当tensor直接训练/推理。
整体方案拆成四步:
- dma-buf导出:ISP驱动在采集帧的第一个byte写入之前,申请好一块dma-buf,把物理地址、sg_table映射好,并通过V4L2的
VIDIOC_EXPBUFioctl把fd导出到用户态。 - CUDA导入:用户态拿到fd后,通过
cudaImportExternalMemory/cudaImportExternalSemaphore接口把这块dma-buf映射成CUDA external memory。这一步的关键是设置正确的cudaExternalMemoryHandleDesc类型(cudaExternalMemoryHandleTypeDmaBuf)。 - fence同步:ISP每写完一帧,在dma-buf上signal一个fence。CUDA侧通过
cudaWaitExternalSemaphoresAsync等待这个fence,fence信号到达后GPU才开始消费。这样就避免CPU轮询,也不需要在GPU读之前做显式内存拷贝。 - 后续处理:如果这一帧识别完还要传给显示控制器叠加信息,显示控制器直接把这个dma-buf attach到自己的DMA队列里(如果内存类型兼容),不需要把结果转存一份再考到显示内存里。
这套链路实测下来,从摄像头曝光到AI识别结果输出,端到端延迟相比原来的“每帧memcpy两次、CPU轮询”方案降了接近 60%,GPU的利用率稳了很多。核心思想就是:
- 物理层:借助dma-buf让设备间共用内存;
- 逻辑层:借助tensor的视图切分让计算不产生冗余副本;
- 时间层:借助fence/event的异步机制让等待不阻塞其他环节。
这三层拆开来看,每一层都是零拷贝的组成部分。
5.4 实战参数与经验总结
如果你真的要在自己项目里把 dma-buf 和 CUDA/tensor 串起来,几个关键参数我给你列一下,都是我反复试出来的实用配置:
| 参数/配置 | 推荐值 | 说明 |
|---|---|---|
| dma-buf导出flag | O_RDWR/O_CLOEXEC | 保证可读写,同时防止fd泄漏到子进程 |
| CUDA external memory handle type | cudaExternalMemoryHandleTypeDmaBuf | Linux平台专用 |
| 外部semaphore类型 | cudaExternalSemaphoreHandleTypeDmaBufFence | 用于同步fence到CUDA stream |
| cache同步策略 | DMA_BUF_IOCTL_SYNC/ dma_sync_single_for_device | 根据读写方向正确选择CPU/设备同步方向 |
| CUDA stream数量 | 至少2条 | 一条负责DMA导入+推理,一条负责显示合成,重叠执行 |
| 细节提示:sg_table分配 | 尽量连续物理内存 | 减少IOMMU映射开销,尤其嵌入式平台 |
还有两个容易踩的坑提醒一下:
一是 dma-buf 导出前一定要确认内存分配类型。如果你的ISP硬件不要求物理连续,用dma_alloc_noncoherent或者按page分散分配是可以的;如果硬件要求连续,必须用dma_alloc_coherent或者CMA区域。否则运行一段时间后内存碎片化,attach 时会报错。
二是 CUDA external memory 做出来的 tensor 生命周期和 dma-buf fd 绑定。在训练循环里频繁重建释放很容易导致CUDA context里积累无用映射。正确做法是初始化阶段建好static pool,整个生命周期内反复用,只在真正需要扩展容量时才释放重建。
6. 从问题到方案:零拷贝的普适模式再思考
站在一个搞过大半个Linux多媒体栈、又做过训练框架性能优化的人的角度,dma-buf 和 tensor parallel 显然是同一类问题的两个投影:数据在不同处理单元之间流动时,如何减少不做计算只做搬运的那一段。
这个问题的本质约束有三个:
- 数据量大:4K帧、大模型权重,单个数据体大,拷一次就是几十上百MB开销。
- 处理单元异构:CPU、GPU、NPU、ISP、显示控制器,每个单元地址空间与内存模型各不相同,跨域传输需要格式适配。
- 实时性要求高:视频要60帧不漏,训练要通信延迟低,慢了就掉点。
dma-buf 从内核侧提供了一套基础设施,让你以标准化的方式管理跨设备共享内存。tensor parallel 从算法侧提供了一套分片策略,让数据不必整块跨节点移动,各自算完再合并。两者结合思考,最大的价值不是具体用到哪一行代码,而是形成一种“为数据设计流线而非为操作设计拷贝”的工程直觉。
如果后续有人想把这套思路往更远处推,可以关注几个方向:
- 统一内存语义:类似 CXL(Compute Express Link)内存池的思路,让设备直接访问对方内存,进一步打破地址空间壁垒。
- 编译层面的自动流线编排:像 XLA 的 alias pass 那样,自动将“拷贝+计算”重写为“原地计算+异步同步”,把手工优化固化到编译器中。
- 跨框架的统一数据通道:如果 V4L2 dma-buf 能标准化导出到 PyTorch 的自定义 backend,那么边缘视频流到训练/推理框架的过程会像打开文件一样简单。
7. 个人体会与后续扩展建议
真的把这两个东西放一起对比去研究之后,我的体会是:零拷贝不是一个具体的技术招式,而是一种系统设计的世界观。它要求你接受一个事实——数据搬移开销才是高性能系统最隐蔽的敌人,而解决方案往往藏在“让数据的消费方直接拿到生产方手里的地址,并且在正确的时间点知道数据是可用的”这半句话里。
dma-buf 和 tensor parallel 解决的是同一枚硬币的两面:硬件层面解决“物理内存怎么共享”,算法层面解决“逻辑张量怎么切分”。两者配合好了,才称得上真正的端到端零拷贝。
我建议干驱动和搞训练的朋友都花点时间看看对方的领域。搞驱动的可以去看一眼 Megatron-LM 里怎么切QKV权重——原来“设备间共享数据减少拷贝”不一定非得靠dma-buf,算法切片也能做到。搞训练的可以看一眼内核 dram-buf 的 fence 实现——原来异步通知、事件驱动这套思路在内核里已经打磨了十几年。
最后分享一个小的扩展方向:如果你在做边缘 AI 设备,可以尝试把本文第5节的 dma-buf + CUDA external memory 链路再往前推一步——把ISP输出格式直接预设成模型输入要求的RGB平面排布,并用dma-buf的 modifier 协商机制在驱动层面强制底层内存layout。这样,从镜头到tensor的整个数据路径里,连格式转换这一步的控件拷贝都可以省掉。原理和tensor parallel里通过选对齐维度来避免padding拷贝一样。这条路走通了,边缘AI的端到端延迟还能再降一个量级。
