【WebGPU学习杂记】缓冲区是个啥?内存?闪存?

文章目的

  1. 帮助作者本人记录笔记帮助回忆。(可能是错误的知识!欢迎指正和讨论QQ: 1061393710)。
  2. 尝试找几个感兴趣的朋友一起学。找不到学习WebGPU的社区,加了俩群发现都是吹水的,而Orillusion的大牛群里假设已经人人都能手搓渲染引擎、甚至游戏引擎???,我不能!需要几个共同学习探讨的朋友一起学。

基于以上两点本文假设你不是JS小白,听说过V8、JSCore等。例如以下两个知识点你应该在哪看到过:

  1. 默认64位V8分配多少内存给JS进程堆(Heap)。1.4GB左右。
  2. 原始类型如 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)

graph TD subgraph CPU 子系统 CPU_Cores[CPU 核心] RAM[系统内存 RAM] end subgraph GPU 子系统 GPU_Cores[GPU 核心] VRAM[显存 VRAM] end CPU_Cores -- 高速访问 --> RAM GPU_Cores -- 高速访问 --> VRAM subgraph "数据流" A[JS Array in RAM] --> B{Staging Buffer in RAM}; B -- "CPU写入数据 (mapAsync)" --> B; B -- "DMA 传输 (由 copyBufferToBuffer 触发)" --> C[Final Buffer in VRAM]; C -- "GPU高速读取" --> GPU_Cores; end RAM -- PCIe --> VRAM style B fill:#f9f,stroke:#333,stroke-width:2px; style C fill:#9cf,stroke:#333,stroke-width:2px; linkStyle 4 stroke:red,stroke-width:3px,stroke-dasharray: 5 5;

集成显卡/统一内存方案(iGPU)

  • Apple、PS等设备方案
  • 手机、平板、笔记本等移动端设备的常用方案
  • 集成显卡调度方案
graph TD subgraph 系统SoC direction LR CPU_Cores[CPU 核心] GPU_Cores[GPU 核心] Unified_Memory[统一物理内存] CPU_Cores -- 高速总线 --> Unified_Memory GPU_Cores -- 高速总线 --> Unified_Memory end subgraph "数据流" A[1.JS Array in Unified Memory] --> B{2.Staging Area 逻辑区域}; B -- "3.CPU写入 (mapAsync)" --> B; B -- "4.快速内存拷贝 / 缓存策略变更" --> C[Final Buffer 逻辑区域]; C -- "5.GPU高速读取" --> GPU_Cores; end style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#9cf,stroke:#333,stroke-width:2px linkStyle 4 stroke:green,stroke-width:2px;

什么是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);

工作原理

  1. 你调用 writeBuffer。
  2. 浏览器立即将你的 data (JS 堆内存) 复制到一个临时的、内部的 Staging Buffer 中(拷贝阶段 A 发生)。
  3. 浏览器立即向 GPU 队列中添加一个"传输"命令。
  4. 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();

工作原理

  1. createBuffer 被调用,并设置 mappedAtCreation: true。
  2. 驱动程序分配一块内存。关键在于,这块内存立即可被 CPU 访问(通常是分配在系统内存 RAM 中 Staging Buffer)。
  3. getMappedRange() 给你一个直接指向这块驱动内存的 ArrayBuffer 引用。
  4. 当你写入这个 ArrayBuffer 时,你实际上是直接写入了驱动控制的内存 (拷贝阶段 A 避免了!)。
  5. 当你调用 unmap() 时:
    • 在 dGPU 上 :如果该 Buffer 的 Usage 需要高性能 GPU 访问(如 VERTEX),驱动程序通常会在后台启动一次 DMA 传输,将数据从这个 CPU 可见区域 Staging Buffer 移到显存VRAM(拷贝阶段 B 发生)。
    • 在 UMA/iGPU 上 :驱动可能什么都不做,或者只是改变内存的缓存属性(拷贝阶段 B 可能避免)。

性能分析

  • 接近零拷贝(消除了阶段 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()]);

工作原理

  1. mapAsync 请求访问 Staging Buffer。这允许浏览器异步准备内存。
  2. getMappedRange() 给你一个直接指向 Staging Buffer 的引用(拷贝阶段 A 避免了)。
  3. unmap() 告诉驱动 CPU 写完了,应尽快把控制权还给GPU。
  4. 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]));

背后发生的事情(简化版):

  1. JS 引擎的 myData 指向内存地址 0xAAAA。
  2. writeBuffer 的实现(在浏览器 C++ 代码中)不能直接把 GPU 指向 0xAAAA ,因为这个地址由 JS GC 管理,可能随时被移动。
  3. 所以,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();
  1. 逐个元素赋值 (viewi = ...) :数据确实是通过 CPU 的指令直接写入了由 mappedRange 指向的内存地址 Staging Buffer。在这个过程中,没有一个独立的、完整的 myData 数组在 JS Heap 中被创建并作为一个整体被复制。数据是 "原地构造" 的。这最接近 "消除拷贝A" 的理想情况。
  2. 使用 TypeArray.set(sourceData) :确实发生了一次 memcpy 。sourceData 在 JS Heap 中,然后它的内容被复制到了 Staging Buffer。

所以压根儿没有所谓的 "零拷贝(Zero-Copy)" !

为什么我们还说它"性能更高"或者"消除了拷贝"呢?

这里的关键优势在于 控制权 和 效率:

  1. 绕过了浏览器内部的额外层 :当使用 writeBuffer 时,数据从 JS -> 浏览器 C++ -> 驱动 Staging Buffer,可能涉及额外的中间步骤和验证。而 mapAsync 让你获得一个更直接的内存指针(通过 ArrayBuffer 抽象),数据路径更短。

  2. 批处理 (Batching) :writeBuffer 是一个立即执行的命令,每次调用都会触发一次小的拷贝和提交。而 mapAsync 的模式允许你一次性映射一个大的缓冲区,然后用多次 .set() 或复杂的逻辑填充它,最后通过一次 unmap() 和一次 copyBufferToBuffer 完成所有工作。这摊销了 API 调用的开销,符合 GPU 批量处理的理念。

  3. 异步性 :mapAsync 不会阻塞主线程 。你可以请求映射,然后去做别的事情,等 promise resolve 了再填充数据。而 writeBuffer 是一个同步操作,虽然很快,但如果数据量稍大,依然可能造成微小的卡顿。

更精确的结论表述

  • queue.writeBuffer :执行了一次隐式的、由浏览器管理的从 JS Heap 到内部 Staging Buffer 的拷贝。
  • mapAsync + .set() :执行了一次显式的、由你代码控制的从 JS Heap 到外部 Staging Buffer 的拷贝。
  • mapAsync + 原地构造 :最接近"零拷贝A" ,因为数据直接在 Staging Buffer 中被构造,没有一个完整的、临时的 JS ArrayBuffer 作为中介。

所以,与其说 "消除拷贝",不如说 mapAsync 机制提供了一个直接写入 Staging Buffer 的通道。这给了你选择权:

  1. 你可以选择从一个已有的 JS ArrayBuffer 中显式拷贝 数据过去 (.set()),这在 API 设计和批量处理上依然优于 writeBuffer。
  2. 或者数据是程序生成的(比如计算出来的地形、粒子位置),你可以 直接在 Staging Buffer 中构造 它,从而实现最理想的性能,真正避免了冗余的中间内存分配和复制。
相关推荐
福兮说5 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理5 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
小呆呆6666 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
Dovis(誓平步青云)6 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan7 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_7 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒8 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
广州华水科技8 小时前
大坝安全监测解决方案:单北斗GNSS形变监测系统应用与维护
前端
yivifu8 小时前
中文古籍电子书注释集成
前端·javascript·python·beautifulsoup·epub
默_笙9 小时前
🛴 从散件到整机:DeepAgents 与 Agent 身上预留的那些"插槽"(前置介绍)
前端·javascript