
大概是去年下半年我在调一个显示链路的丢帧问题对着dma-buf的 attach/detach 日志一行一行抠缓存同步时序同一时间隔壁组在给 70B 模型压推理延迟每天挂在嘴边的词是 tensor parallel 里的 AllReduce 通信量和 KV cache 的 block 管理。直到有次晚饭聊天两边把各自画在白板上的框图放到一起忽然发现一个在内核里搞显存/帧缓冲共享一个在 CUDA 侧搞权重分片和显存复用底层逻辑居然惊人地一致——都在做同一件事让数据尽量留在原地只搬运描述数据的信息或者干脆把计算挪到数据边上。这篇文章不打算讲零拷贝的教科书定义而是想借两个我实际接触过的系统——dma-buf 和 tensor parallel——把这种“跨越领域的零拷贝模式”拆开揉碎。里面涉及到内核对象、GPU 通信、显存管理、消息队列看起来风马牛不相及但当你把“谁拥有数据、谁访问数据、谁负责同步、谁在什么时候释放”这条线拉出来会发现它们是一道题的不同变体。写这篇文章的另一个动机是零拷贝在 2024、2025 年又成了热词ZeroMQ/JeroMQ 那边在聊零拷贝消息传递大模型推理这边在聊 PagedAttention 和 KV cache 复用内核这边 dma-buf 已经是显示、相机、编解码链路的默认基础设施。可很多人在自己的领域里谈零拷贝时并不知道隔壁领域是怎么解决同样问题的。希望这篇东西能帮你建立一张“地图”。1. 为什么把 dma-buf 和 tensor parallel 放在一起谈1.1 两个表面上毫无交集的痛点先聊聊 dma-buf 解决的那个痛点。在 Android 或者嵌入式 Linux 的多媒体链路上Camera 的 ISP 每秒钟要产出 30 到 60 帧图像一帧 4K60 的 YUV 数据大概长这样3840 × 2160 × 2 字节YUV422 采样再乘 60 帧大约是每秒 995MB接近 1GB/s 的数据量。这帧数据要同时给三四个消费者Display 要扫出来上屏GPU 可能要加特效Video Encoder 要编码录像拍照模块还要抽帧。在没有 dma-buf 这种共享机制的老代码里最粗暴的做法是每个消费者各拿一份拷贝Display 读一份、Encoder 拷贝一份、GPU 再拷贝一份。1GB 的数据流被复制成 4GB、5GB内存带宽瞬间被吃满功耗、发热、延迟全崩。这不是优化不优化的问题是物理上行不行的问题。tensor parallel 的痛点其实是它的镜像。7B 参数的模型用 bf16 保存光权重就是 14GB单卡放不下就得分到多张 GPU 上。如果只是简单地把模型按层切成几段做流水线并行每一层前向计算结束以后都要把完整的中间激活从一张卡搬到下一张卡。对一个 hidden size 为 8192、sequence length 为 2048、batch size 为 8 的 Transformer 层来说单个中间激活张量的大小是 8 × 2048 × 8192 × 2 字节也就是 268MB。几十层网络搬下来通信量爆炸。老链路里以为“复制一下没什么”新链路里发现“这根本搬不动”。这两个例子面对的瓶颈其实同源数据体积大到“搬运”这件事本身变成了系统的天花板。解决思路也就自然地往同一个方向收敛——不搬数据或者只搬非搬不可的那部分信息。1.2 零拷贝在这里的真正含义我见过很多对零拷贝的误解最常见的是把零拷贝等同于mmap或者共享内存觉得“大家指向同一块地址就是零拷贝”。实际上零拷贝的关键从来不是“地址相同”而是数据在整个生产者/消费者链路上只有物理上的一份传递的是指针、描述符、索引而不是数据内容。拿 dma-buf 和 tensor parallel 对比一下就清楚了。dma-buf 的 producer比如 Camera ISP和 consumer比如 GPU之间共享的是同一块物理内存真正在两方之间传递的是一个 fd文件描述符或者一个 attachment。tensor parallel 里权重矩阵被切分后存在各自的 GPU 显存里卡与卡之间传递的是通信协议里的 rank 信息、block 索引、广播的“该去读哪块显存”的地址信息而权重数据本身始终待在原处。所以不要被两个领域截然不同的术语迷惑。dma-buf 叫“buffer 共享”tensor parallel 叫“张量并行”但它们共享同一条底层原则减少拷贝的方法是减少数据的移动而不是优化拷贝本身的速度。2. dma-buf 到底做了什么事拆开四个内核对象看零拷贝的原子操作2.1 sg_table给 DMA 引擎看的地图dma-buf 里最底层的东西其实不是 dma_buf 这个对象而是sg_tablescatter/gather table。它的作用简单说就是一张“物理页面地图”。一块连续的用户态虚拟内存背后对应的物理页很可能是不连续的而 DMA 引擎需要知道“你要我去哪里拿数据”时必须逐一给出物理地址和长度。sg_table 就是把这些物理页的地址和长度列出来的一个数组。我一开始搞混过sg_table和dma_addr_t的关系。简单区分sg_table描述的是 buffer 的物理布局而dma_addr_t是某一个具体设备经过 IOMMU 映射之后可以在设备侧使用的总线地址。同一个 sg_table给 GPU 和给 ISP 用最后拿到的dma_addr_t有可能是不同的因为不同设备挂在不同的 IOMMU 域里看到的地址空间不一样。这个区分特别重要否则你会在多设备共享场景里拿到一个“来自 GPU 的地址”直接塞给 ISP然后硬件就懵了。2.2 dma_buf、dma_buf_attachment、dma_buf_map_attachment 的分工struct dma_buf本身代表的是“一块可以被共享的内存”这个抽象对象。它内部有引用计数、有 size、有一组操作函数指针dma_buf_ops有维护 attachments 的链表。注意这个对象本身不直接代表物理内存而是代表一种“共享协议”。struct dma_buf_attachment是“消费者入场”的凭证。每个想访问这块 buffer 的设备驱动都要先dma_buf_attach()得到自己的 attachment。这是零拷贝里最容易被忽略的一层共享不意味着无门槛准入。buffer 的生产者和消费者之间通过 attachment 建立了明确一对关系——你是谁、你要以什么方向访问DMA_TO_DEVICE 还是 DMA_FROM_DEVICE、你怎么映射。dma-buf 在底层通过这些约束来保证多个设备同时访问同一块内存时不会互相踩踏。dma_buf_map_attachment()则是把 attachment 真正变成设备可用的地址映射。调用这个函数之后你才拿到可以写进 DMA 寄存器或者 GPU 命令队列里的地址。所以完整的流程必然是这样的producer 创建 dma_buf每个 consumer 各自 attach各自 map各自访问各自 unmap、detach。这样的好处是buffer 只创建一次物理内存只分配一次而多设备的地址映射可以按需建立、按需释放。这种“一次分配、多方映射”就是 dma-buf 零拷贝的骨架。2.3 时序问题为什么需要 dma_fence 和隐式同步共享内存的另一个问题叫“时序”。CPU 或者 ISP 正在往 buffer 里写数据时GPU 开始读同一块内存通常不会有问题因为硬件通常足够快但如果你在写的同时开始读那读到的一定是脏数据。更麻烦的是视频链路里存在多级流水Camera 写第 N 帧GPU 同时读第 N-1 帧Encoder 同时在读第 N-2 帧。这种多级并行的生产者/消费者关系靠单个布尔值加锁根本管理不过来。Linux 内核给出的解法是dma_fence。这个东西本质上是一个跨设备、跨驱动的异步完成信号生产者说“我要写这个 buffer我发一个 fence写完了我 signal 这个 fence”消费者说“我要读这个 buffer我先 wait 在这个 fence 上等它 signal 了我才启动读取”。在 Android 的 sync framework 里这个 fence 被暴露成用户态可以传的 fd叫 in-fence/out-fenceSurfaceFlinger 用这一套管理 App、GPU、HWC硬件合成器之间的帧时序。也就是说dma-buf 的零拷贝不是“无脑共享”而是用一套严格的同步协议来换取“不复制”的权利。这点在后面的 tensor parallel 里会再次出现。3. tensor parallel 吞掉的通信量权重分区与 KV cache 的零拷贝逻辑3.1 列并行的数学权重分片如何把通信量降了一个数量级如果你在一个 Transformer 的前向里做 tensor parallel最简单的做法是把一个 Linear 层的权重按输出通道拆成两半分别放在 GPU0 和 GPU1 上。输入 X 需要广播给两张卡每一张卡各算一半的输出结果最后通过一次 AllReduce 把两边结果加到一起得到完整的输出。这个过程里最妙的一点权重矩阵本身从头到尾没有移动过。每张卡持有的是完整权重的一个 partition数据在初始化时就已经“就地放置”了。计算也是在本地完成的没有把完整的 X 或者 W 传来传去。唯一发生的通信是最后那一次 AllReduce——把两个部分的点积结果加起来。以模型并行度 P2 来算这里的通信量大约是 2 × (P-1) / P 倍的中间激活大小而如果不做 tensor parallel把完整的 W 广播给所有卡通信量是 W 大小乘以 P。你可能觉得“AllReduce 也是数据移动啊这怎么能算零拷贝”。从纯 C 语言内存拷贝角度看不算是零拷贝但往上层抽象去看它符合零拷贝的核心精神不复制完整权重不搬动驻留数据只传输部分结果。而且这些结果本身就是必须跨设备汇总的没有冗余移动。3.2 KV cache 与 PagedAttention显存里的“映射而不复制”如果说 tensor parallel 的权重分片是在“空间维度”的零拷贝那 KV cache 的管理就是“时间维度”的零拷贝。推理的时候每生成一个 token注意力层都要重新读一遍历史 token 的 K 和 V。prefill 阶段产出的 KV 数据量很大decode 阶段要反复读取同一份。朴素的解码器会给每个请求分配一块能容纳max_seq_len的连续显存这个空间使用率往往只有百分之三四十而且碎片化严重。更糟的是当 beam search 或 parallel sampling 需要保存多个候选序列时每个候选序列如果各自拷贝一份 KV显存直接爆掉。PagedAttention 的思路我非常喜欢因为它本质上就是把操作系统的分页机制挪到了显存里KV cache 被切成固定大小的 block比如 32 个 token 一个 block每个序列维护一张 block 表。block 可以按需分配序列之间也可以共享同一个 block——只要它们的 KV 内容相同。这时候如果要做 beam search 的分支回退不需要真的把旧分支的 KV 数据 memcpy 一份只需要在新序列的 block 表里增加一个对旧 block 的引用再更新引用计数就行。这不就是 dma_buf_attachment 吗物理数据只有一份多个逻辑消费者各自持有“访问凭证”引用计数决定何时真正释放物理页。领域不同结构完全一样。3.3 NCCL 和 CUDA 里的零拷贝细节注册内存与避免隐式副本在 GPU 通信层面NCCL 也一直在做“减少拷贝”的事。之前用 NCCL 跑多卡训练时如果每个 step 都调用cudaMemcpyAsync把数据从用户态 buffer 拷到 NCCL 内部 buffer传输效率会被内存拷贝打掉不少。后来 NCCL 提供了一组ncclCommRegister/ncclCommDeregister接口允许你提前把要通信的内存区域注册到通信库的映射表里之后每次通信直接复用这个映射关系省掉反复注册和地址转换的开销。CUDA 零拷贝内存Zero-Copy Memory通过cudaHostAlloccudaHostGetDevicePointer拿到设备可访问的主机内存地址解决的是另一个问题CPU 写好的数据 GPU 直接读不经过cudaMemcpy。这里的数据本身还是那份搬来搬去的只是地址映射关系。这和 dma-buf 的dma_buf_map_attachment()思路完全同构CPU 端和 GPU 端各自持有对同一物理内存的映射CPU 写完GPU 直接读。区别只是 dma-buf 在内核态由设备驱动管理而 CUDA zero-copy 在用户态由 CUDA runtime 管理。4. 藏在两个系统背后的统一模式用五问识别真正的零拷贝4.1 一套判断真正零拷贝的五问框架工作里如果要判断一个方案是不是真零拷贝或者设计一个新的共享路径我习惯问五个问题数据是不是只有一份生产者写入的每一个字节在消费者读取之前有没有被复制到第二个物理位置传递的是数据还是描述符生产者给消费者传的是一个指向数据的引用、一个 fd、一个 block id还是一整块数据的内容谁管理生命周期消费者持有时生产者会不会释放引用计数在哪里维护谁负责时序同步生产者写完和消费者开始读之间谁保证了顺序用的是 fence、信号量、事件还是同步通信地址空间怎么转换CPU 虚拟地址、物理地址、设备地址之间的映射由谁维护是在内核里做 IOMMU 映射还是在 CUDA 侧做 unified addressing还是在 NCCL 里做 buffer 注册如果五个问题都有明确答案而且第 2 题答的是“描述符”这个系统大概率是真正零拷贝的。如果第 2 题答的是“数据本身”那你只是在优化带宽不是在做零拷贝。拿前面讲的两个系统代入这套框架可以做一张对照表问题dma-buftensor parallel / PagedAttention数据只有一份物理内存只分配一次所有设备共享权重分片驻留在各卡KV block 物理上只存一份传递的是数据还是描述符fd、dma_buf_attachment、sync_file fdTCP 层的 rank 信息、block 索引、通信句柄谁管理生命周期dma_buf 引用计数 attachment 引用计数显存 block 引用计数、NCCL 注册表的生命周期谁负责时序同步dma_fence / 隐式同步 / sync frameworkAllReduce 的同步语义、CUDA stream 顺序地址空间如何转换IOMMU、设备地址映射、sg_tableCUDA unified addressing、NCCL buffer 注册前后的映射这张表的好处是让跨领域的沟通变得容易你跟一个内核工程师说“attachment 引用计数”他秒懂你跟一个 AI 框架工程师说他做的是“显存管理里的 attach/detach”他也能很快把概念捡起来。4.2 映射代替复制数据留在原地视图四处移动“零拷贝”最本质的思维模式我总结成一句话不要把数据复制到别人那里去而是把访问数据的权利复制出去。这句话听起来有点绕但一步步拆解就清楚了。dma-buf 里物理页没动动的是“设备地址映射”和“该设备的访问序列”tensor parallel 里权重矩阵没动动的是“计算拆分”和“梯度的聚合关系”PagedAttention 里KV block 没动动的是“逻辑序列的 block 表项”和“引用计数”。它们通通都是“让视图移动、让数据驻留”的例子。这样做有一个额外的工程收益视图描述符通常比数据小几个数量级所以同步和拷贝的成本被急剧降低同时系统的可组合性变强了。因为视图可以自由复制你可以让同一个数据同时出现在多个消费者面前而不会造成物理内存爆炸。这就是零拷贝方案的放大效应。5. 跳出内核和 GPUZeroMQ、JeroMQ、RDMA 里的同一种套路5.1 ZeroMQ 的零拷贝消息zmq_msg_init_data和生命周期接管如果只看内核和 GPU容易觉得零拷贝是“底层人才需要关心的事”。实际上消息队列领域也在做同样的事。ZeroMQ 的zmq_msg_init_data()允许传入一段用户数据并指定一个释放回调函数ffnZeroMQ 在发送这条消息时直接引用这块 buffer不做内存拷贝。这个 API 有个非常苛刻的要求在消息没有真正发送完之前你不能释放这块 buffer。所以你自己必须管理好生命周期通常用引用计数或者事件回调来保证。一旦你接管了生命周期你就在用户态实现了“拷贝描述符而不拷贝数据”的模式——消息对象拷贝的只是一个指向数据的指针和长度数据本身不被搬动。JeroMQ 作为 ZeroMQ 的纯 Java 实现也保留了这个“referenced message”的思路。Java 世界里大多数网络库会把 byte[] 拷贝一次到堆外 DirectByteBuffer 再发出去而 JeroMQ 在某些路径上允许你直接引用堆外内存避免堆内到堆外的拷贝。这跟 CUDA 的 zero-copy 逻辑非常像——关键是让你能在本领域写出零拷贝代码而不是调用 API 就万事大吉。5.2 RDMA把协议栈拷贝移出临界路径再往下一层RDMA 的零拷贝体现在它让网卡可以直接访问用户态注册的内存区域Memory RegionMR数据从应用内存到网卡再到对端应用内存的整个过程不需要经过内核 socket 缓冲区的拷贝。但 RDMA 的“零拷贝”付出了另一个代价——注册 MR 的成本挺高你需要ibv_reg_mr还要保证内存在注册期间不被换页。这和 dma-buf 的 pin pages 是一回事唯一区别是 pin 页面是在内核里做的。RDMA 的 MR 注册和 dma-buf 的 map_attachment 本质相同都是告诉硬件“这块内存已经在物理上准备好了地址给你可以 DMA 了”。因此如果数据块很小注册 MR 的开销可能比数据拷贝还大。这就是“什么时候不该用零拷贝”的第一个判断标准当描述符本身和数据差不多大或者同步开销超过拷贝开销时老老实实 memcpy 就是最优解。5.3 零拷贝不是银弹什么场景不值得用从我自己实践的角度不值得用零拷贝的场景大致有三类第一数据尺寸接近或者小于管理它的描述符。比如你要传一个 8 字节的计数器搞共享内存加 fence 的方案整体开销肯定比直接发送带锁的普通消息要大得多。第二数据生产者和消费者的访问模式极度不同步。比如生产者突发写入、消费者不知道什么时候才来读这时候共享内存需要长期 pin 页面内存换页、生命周期管理都会很痛苦不如直接用消息队列做一次拷贝换清爽的语义。第三消费频率极低且不规律。为一个每 30 秒才读一次的数据维护一套共享映射代价摊薄不来。所以成熟工程师判断“要不要上零拷贝”的方式不是“零拷贝很牛逼所以我要用”而是反问自己如果不复制我需要付出哪些同步和生命周期成本这些成本会不会超过复制本身的成本6. 实测中的坑dma-buf 和 tensor parallel 都没有文档写的那么省心6.1 dma-buf 调试时容易翻车的三个点第一个坑是cache 同步被忽略。在 ARM 平台上CPU 和设备对同一块内存的一致性通常不能依赖硬件自动保证。你写完 buffer必须确保 cache 里的脏数据已经刷到物理内存设备才能看到反过来设备写完CPU 读之前需要先失效 cache line。这一对操作在dma_buf_map_attachment()调用时通过dma_map_sg/dma_sync_sg_for_device等路径完成。我踩过的实际案例是GPU 渲染完一帧Encoder 拿到 buffer 去编码偶尔出现花屏查到最后是渲染完成栅栏已经触发但 cache 同步的dma_sync_sg_for_device没被正确调用Encoder 的 DMA 读到了一部分脏 cache 里的旧数据。这种问题随机出现非常难追。第二个坑是map/unmap 的频率控制。每次dma_buf_map_attachment()都有可能引发 IOMMU 的地址映射操作成本并不低。在视频链路里如果你每一帧都重新 map/unmap很快你会发现 CPU 占用和下不来。成熟的 pipeline 通常用“长映射 手动同步”的模式只在 attach/detach 时做一次映射平时用 fence 和 cache 同步来保证可见性。这样虽然少了“每次访问前临时建映射”的安全性隔离但性能好很多。第三个坑是fence 的死锁。跨设备共享时fence 的顺序如果绕成一个环A 在等 B 释放 bufferB 在等 CC 又在等 A整个 pipeline 就直接卡死。这种死锁的表现是设备空闲但既没有报错也不往下走。排查时一定要把 fence 的依赖图打出来或者在设计阶段就避免让两个设备互相等待同一个 buffer 的两个方向。6.2 tensor parallel 调优时容易翻车的点首先tensor parallel 对通信带宽极其敏感。它在单机多卡NVLink上收益显著但一旦跨机走 RDMA 网络通信延迟和带宽都会劣化。我试过在 4 机 8 卡的网络拓扑上强行把 TP 度拉到 8结果每层 AllReduce 的网络往返延迟比节省下来的算力时间还长训练反而变慢。经验是单机内优先 TP跨机优先流水线并行或者数据并行不要无视物理拓扑堆 TP 度。其次KV cache 的 block size 设置是个吸血鬼。block size 设得太大内部碎片多、浪费显存设得太小索引表和引用计数的开销就会上升而且 GPU kernel 遍历 block 表时也变得低效。通常推理引擎会提供一个 trade-off 参数但很少有人告诉你最优值和 head size、sequence length 分布有关。我在实际跑长文本摘要任务时block size 设置为 16 反而比 32 吞吐更高因为长序列的 KV 复用更频繁小粒度带来的灵活性超过了索引开销。最后通信和计算的重叠需要显式调度。很多人在 tensor parallel 里发现“已经零拷贝了但端到端延迟还是高”原因往往是每次通信都在阻塞计算算完一个 chunk 才发起 AllReduceAllReduce 结束后才开始下一个 chunk。正确做法是类似流水线的方式把一批 chunk 切成多个微批次计算第 N1 个 chunk 时同时让 AllReduce 搬运第 N 个 chunk 的结果。零拷贝只解决了“传什么”重叠调度解决的是“什么时候传”两条腿缺一不可。6.3 我的选型经验什么时候该往零拷贝上靠做了几年底层系统后我逐渐总结出自己的选型直觉当一份数据在同一生命周期里会被 2 个以上的消费者访问并且数据体积超过几十 KB且消费者之间的时序关系可控时零拷贝共享就是必然选择如果只被单个消费者用一次、用完即弃那普通的 buffer 拷贝反而更简单可靠。从 dma-buf 和 tensor parallel 两个系统里看到的共同经验是零拷贝方案真正的复杂度从来不在“怎么共享”而在“怎么收场”——谁最终释放内存、谁保证看见的数据是完整的、多消费者之间如何不互相干扰。能在设计的最开始把这三个问题想清楚你的共享方案就成功了一半如果等到代码写完了才考虑那你大概率会收获一个调试地狱。这个内容其实还能往外延伸很多kv cache 的 block 复用和文件系统的 page cache 共享逻辑也同构NUMA 架构里的 first-touch 策略和 tensor parallel 的权重放置策略也存在对应关系。以后有机会再单独写一篇把零拷贝和“数据局部性”的家族图谱整个串起来。