文章目的
- 帮助作者本人记录笔记帮助回忆。(可能是错误的知识!欢迎指正和讨论QQ: 1061393710)。
- 尝试找几个感兴趣的朋友一起学。找不到学习WebGPU的社区,加了俩群发现都是吹水的,而Orillusion的大牛群里假设已经人人都能手搓渲染引擎、甚至游戏引擎???,我不能!需要几个共同学习探讨的朋友一起学。
基于以上两点本文假设你不是JS小白,听说过V8
、JSCore
等。例如以下两个知识点你应该在哪看到过:
- 默认64位V8分配多少内存给JS进程堆(Heap)。
1.4GB左右
。 原始类型
如number/bigint
、短字符串
、boolean
、null
、undefined
、symbol
、当前执行上下文的调用栈
、临时变量
、局部变量
会分配到栈(Stack)中,访问极快但空间有限通常仅有几Mb。而长字符串
、引用类型
的数据载体如Array
、Object
、Function
、Regex
、Date
以及部分闭包中的变量
会分配到堆(Heap)上。
本文涉及到问题和关键词
- 啥是
DMA
? - 啥是
内存零拷贝
(Zero-Copy)? - 使用
图形系统API
提交命令让GPU、CPU操作缓冲区
(Buffer)时发生了啥?
概念关键词(方便AI拓展了解):
JS堆内存
、暂存区(Staging Buffer)
、内存RAM
、显存VRAM
、统一内存Unified-RAM
。
数据流示意图
独立显卡方案(dGPU)
集成显卡/统一内存方案(iGPU)
- Apple、PS等设备方案
- 手机、平板、笔记本等移动端设备的常用方案
- 集成显卡调度方案
什么是DAM?
显卡、固态等硬件售卖时经常标的"主控芯片"
哪家滴怎么怎么NB! 里面就含DAM
。
其实DMA
是一种硬件机制,允许外设(如GPU、磁盘、网卡)直接访问主内存,无需CPU逐字节参与数据传输。解放CPU,避免其在大量数据搬运时被阻塞(例如拷贝纹理或顶点数据到GPU)。
什么是"零拷贝 (Zero-Copy)"?
数据最初的 "诞生" 总是在 CPU 端(某个由 JS 引擎管理的内存区域)。 似乎永远无法完全绕过"JS Heap"
。 关键区别在于 "操作的性质"
和 "拷贝的次数"
。在 CPU-GPU 交互的语境中,"零拷贝"
是一个比较模糊的术语,我们需要区分两个阶段的拷贝:
- 拷贝阶段 A (JS -> 浏览器/驱动) :数据从 JS 的
堆内存
(TypedArray)复制到浏览器或驱动程序控制的内存Staging Buffer
。 - 拷贝阶段 B (Staging Buffer -> VRAM) :数据通过
DMA
跨PCIe
总线从系统内存Staging Buffer
复制到 GPU 专用的高速显存VRAM
。
真正的"零拷贝"意味着 A 和 B 都不发生。 这在独立显卡 (dGPU) 架构中几乎不可能实现,但在统一内存架构 (UMA/iGPU) 中是可能的。
WebGPU 的设计目标是尽量减少拷贝阶段 A。
图形系统API调用发生了什么?
方法 1:device.queue.writeBuffer
这是最简单、直接的方式,用于将数据立即写入 GPU 缓冲区。
javascript
// 创建一个未映射的可以立即被GPU操作的Buffer区域。
const gpuBuffer = device.createBuffer({
size: <指定字节整数>,
usage: GPUBufferUsage.COPY_DST, // 告知GPU这块Buffer可以作为拷贝目的地写入
mappedAtCreation: false
});
const data = new Float32Array([1, 2, 3, 4]);
device.queue.writeBuffer(gpuBuffer, 0, data);
工作原理
- 你调用
writeBuffer
。 - 浏览器立即将你的
data
(JS 堆内存) 复制到一个临时的、内部的Staging Buffer
中(拷贝阶段 A 发生)。 - 浏览器立即向 GPU 队列中添加一个"传输"命令。
- GPU 执行该命令,通过
DMA
将数据从临时Staging Buffer
复制到最终的gpuBuffer
(如果gpuBuffer
在 VRAM 中,拷贝阶段 B 发生)。
性能分析
- 不是零拷贝:至少发生一次拷贝(阶段 A),大概率发生两次(A+B)。
- 延迟 (Latency) :最低 。从你调用 API 到 GPU 开始传输,延迟是最小的。它不需要你手动管理
Encoder
或Staging Buffer
。 - 性能 (Throughput) :较低。由于涉及 JS 堆内存的同步拷贝(阶段 A),对于大数据块来说效率不高,会阻塞主线程一小段时间。
适用场景
- 小数据量的频繁更新。
- 每帧更新的
Uniform Buffer
(如 MVP 矩阵、时间、光照参数等)。 - 数据量通常小于几 KB。
方法 2:mappedAtCreation: true + unmap()
这是一种在创建缓冲区时立即填充数据的便捷方式。
javascript
const data = new Float32Array([1, 2, 3, 4]);
const gpuBuffer = device.createBuffer({
size: data.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
// 获取映射的内存,并直接写入
new Float32Array(gpuBuffer.getMappedRange()).set(data);
// 关键步骤:取消映射,将控制权交给 GPU
gpuBuffer.unmap();
工作原理
createBuffer
被调用,并设置mappedAtCreation: true
。- 驱动程序分配一块内存。关键在于,这块内存立即可被 CPU 访问(通常是分配在系统内存 RAM 中
Staging Buffer
)。 getMappedRange()
给你一个直接指向这块驱动内存的ArrayBuffer
引用。- 当你写入这个
ArrayBuffer
时,你实际上是直接写入了驱动控制的内存 (拷贝阶段 A 避免了!)。 - 当你调用
unmap()
时:- 在 dGPU 上 :如果该 Buffer 的 Usage 需要高性能 GPU 访问(如
VERTEX
),驱动程序通常会在后台启动一次DMA
传输,将数据从这个 CPU 可见区域Staging Buffer
移到显存VRAM
(拷贝阶段 B 发生)。 - 在 UMA/iGPU 上 :驱动可能什么都不做,或者只是改变内存的缓存属性(拷贝阶段 B 可能避免)。
- 在 dGPU 上 :如果该 Buffer 的 Usage 需要高性能 GPU 访问(如
性能分析
- 接近零拷贝(消除了阶段 A) 。你直接写入了驱动管理的内存。在
UMA
系统上可能是真正的零拷贝。 - 延迟 (Latency) :低。数据在创建时就准备好了。
- 性能 (Throughput) :高。非常适合一次性加载大数据,因为它消除了阶段 A。
适用场景
- 初始化静态资源 , 它只能在调用API创建时使用一次。
- 加载模型顶点数据、纹理数据等只加载一次且不再更改的内容。
方法 3: encoder.copyBufferToBuffer
VRAM
->VRAM
javascript
// 1. 记录拷贝命令
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(gpuBuffer, 0, finalGpuBuffer, 0, data.byteLength);
// 2. 提交命令
device.queue.submit([encoder.finish()]);
- 从
Staging Buffer
->VRAM
这是最经典、最标准、控制力最强的 GPU 数据上传管线(也称为 Map-Unmap-Copy 模式)。
javascript
// 1. 创建一个 Staging Buffer (MAP_WRITE | COPY_SRC) 和一个最终的 GPU Buffer (COPY_DST)
// ... (省略创建过程)
// 2. 异步映射 Staging Buffer
await stagingBuffer.mapAsync(GPUMapMode.WRITE);
// 3. 写入数据到映射区域
const mappedRange = stagingBuffer.getMappedRange();
new Float32Array(mappedRange).set(data);
// 4. 取消映射
stagingBuffer.unmap();
// 5. 记录拷贝命令
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(stagingBuffer, 0, finalGpuBuffer, 0, data.byteLength);
// 6. 提交命令
device.queue.submit([encoder.finish()]);
工作原理
mapAsync
请求访问Staging Buffer
。这允许浏览器异步准备内存。getMappedRange()
给你一个直接指向Staging Buffer
的引用(拷贝阶段 A 避免了)。unmap()
告诉驱动 CPU 写完了,应尽快把控制权还给GPU。copyBufferToBuffer
明确指示 GPU 执行一次从Staging Buffer
到finalGpuBuffer
的传输。这确保了finalGpuBuffer
可以被放置在VRAM
中(拷贝阶段 B 显式发生)。
性能分析
- 可能零拷贝(消除了阶段 A)。可能包含了一个 GPU 端的拷贝(阶段 B)。
- 延迟 (Latency) :最高 。你需要等待
mapAsync
完成,然后需要创建并提交Encoder
。 - 性能 (Throughput/GPU Performance) :最高 。
- 对于大数据上传,
mapAsync
是异步的,不会阻塞主线程。 - 最重要的是,这是唯一能保证数据最终位于VRAM 的方法(在 dGPU 系统上)。虽然上传慢一点,但 GPU 后续的渲染访问速度会快得多。
- 对于大数据上传,
适用场景
VRAM
内部拷贝缓冲区- 大型资源(模型、大纹理)。
- 流式传输数据(动态地形、视频帧)。
总结与对比
为了更清晰地对比,我们总结一下:
特性 | queue.writeBuffer |
mappedAtCreation: true |
copyBufferToBuffer |
---|---|---|---|
API 复杂度 | 低 | 中 | 高(多步骤,需管理 Staging) |
延迟 (Latency) | 低 (适合即时更新) | 低 | 高 (需 mapAsync 和 submit) |
上传吞吐量 (Throughput) | 低 (涉及 JS 内存拷贝) | 高 | 极高 |
GPU 访问性能 | 中/高 (取决于驱动优化) | 中/高 (取决于驱动优化) | 最高 (VRAM) |
消除拷贝阶段 A (JS->Staging) | 否 | 是 | 是 |
消除拷贝阶段 B (Staging->VRAM) | 否 (dGPU) | 否 (dGPU) | 否 (显式执行阶段 B) |
适用场景 | 每帧少量 Uniform 数据更新 | 静态资源初始化 | 大型资源、流数据、需要最高性能的顶点/索引缓冲 |
关键点
- 如果你需要最低延迟 (例如每帧更新摄像机位置),使用
writeBuffer
。 - 如果你在初始化 时加载数据,使用
mappedAtCreation
。 - 如果你需要暂存GPU中的数据或上传大量数据 ,使用
copyBufferToBuffer
。
疑问🤔
A阶段(JS Heap
-> Staging Buffer
)真的存在"零拷贝(Zero-Copy)"么?
- 用户声明变量
javascript
// 1. 数据诞生在 JS Heap 中
const myData = new Float32Array([1, 2, 3, 4]);
// 在这一刻, 16字节的数据存在于 V8/JSCore 的堆内存中。
// 2. 告诉 GPU "请拿走这个数据"
device.queue.writeBuffer(gpuBuffer, 0, myData);
- 用户未声明变量
javascript
// 1. 数据诞生在 JS Heap 中, 在这一刻, 16字节的数据存在于 V8/JSCore 的堆内存中。
// 相比上一段代码无非是没有显示声明变量名"myData", 在JS Heap中创建了一个临时变量还是占用了同样大的一块内存,
// 由于JS栈中没有用户标记这块JS Heap, 在以下操作完成后的某个时刻会被GC自动回收内存
// 2. 告诉 GPU "请拿走这个数据"
device.queue.writeBuffer(gpuBuffer, 0, new Float32Array([1, 2, 3, 4]));
背后发生的事情(简化版):
- JS 引擎的 myData 指向内存地址 0xAAAA。
writeBuffer
的实现(在浏览器 C++ 代码中)不能直接把 GPU 指向 0xAAAA ,因为这个地址由 JS GC 管理,可能随时被移动。- 所以,writeBuffer 会在内部申请一块临时的
Staging Buffer
,比如在地址 0xBBBB。 然后,它执行一次 memcpy(dest: =0xBBBB, src: 0xAAAA, size: 16) 。这就是我们说的 "拷贝阶段 A" 。 最后,它命令 GPU 从 0xBBBB 处抓取数据。
- 避免A阶段
javascript
// 1. 向 GPU 申请一块可写的内存区域, 返回Staging Buffer区域的一块ArrayBuffer
await stagingBuffer.mapAsync(GPUMapMode.WRITE);
const mappedRange = stagingBuffer.getMappedRange();
// 2. 直接在该区域上 "诞生" 数据
const view = new Float32Array(mappedRange);
view[0] = 1;
view[1] = 2;
view[2] = 3;
view[3] = 4;
stagingBuffer.unmap();
- 逐个元素赋值 (view[i] = ...) :数据确实是通过 CPU 的指令直接写入了由 mappedRange 指向的内存地址
Staging Buffer
。在这个过程中,没有一个独立的、完整的 myData 数组在 JS Heap 中被创建并作为一个整体被复制。数据是 "原地构造" 的。这最接近 "消除拷贝A" 的理想情况。 - 使用 TypeArray.set(sourceData) :确实发生了一次 memcpy 。sourceData 在 JS Heap 中,然后它的内容被复制到了
Staging Buffer
。
所以压根儿没有所谓的 "零拷贝(Zero-Copy)" !
为什么我们还说它"性能更高"或者"消除了拷贝"呢?
这里的关键优势在于 控制权 和 效率:
-
绕过了浏览器内部的额外层 :当使用
writeBuffer
时,数据从 JS -> 浏览器 C++ -> 驱动Staging Buffer
,可能涉及额外的中间步骤和验证。而mapAsync
让你获得一个更直接的内存指针(通过ArrayBuffer
抽象),数据路径更短。 -
批处理 (Batching) :
writeBuffer
是一个立即执行的命令,每次调用都会触发一次小的拷贝和提交。而mapAsync
的模式允许你一次性映射一个大的缓冲区,然后用多次.set()
或复杂的逻辑填充它,最后通过一次unmap()
和一次copyBufferToBuffer
完成所有工作。这摊销了 API 调用的开销,符合 GPU 批量处理的理念。 -
异步性 :
mapAsync
不会阻塞主线程 。你可以请求映射,然后去做别的事情,等 promise resolve 了再填充数据。而writeBuffer
是一个同步操作,虽然很快,但如果数据量稍大,依然可能造成微小的卡顿。
更精确的结论表述
queue.writeBuffer
:执行了一次隐式的、由浏览器管理的从 JS Heap 到内部 Staging Buffer 的拷贝。mapAsync
+.set()
:执行了一次显式的、由你代码控制的从 JS Heap 到外部 Staging Buffer 的拷贝。mapAsync
+ 原地构造 :最接近"零拷贝A" ,因为数据直接在 Staging Buffer 中被构造,没有一个完整的、临时的 JSArrayBuffer
作为中介。
所以,与其说 "消除拷贝",不如说 mapAsync
机制提供了一个直接写入 Staging Buffer
的通道。这给了你选择权:
- 你可以选择从一个已有的 JS
ArrayBuffer
中显式拷贝 数据过去 (.set()
),这在 API 设计和批量处理上依然优于writeBuffer
。 - 或者数据是程序生成的(比如计算出来的地形、粒子位置),你可以 直接在 Staging Buffer 中构造 它,从而实现最理想的性能,真正避免了冗余的中间内存分配和复制。