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

文章目的

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

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

  1. 默认64位V8分配多少内存给JS进程堆(Heap)。1.4GB左右
  2. 原始类型number/bigint短字符串booleannullundefinedsymbol当前执行上下文的调用栈临时变量局部变量会分配到栈(Stack)中,访问极快但空间有限通常仅有几Mb。而长字符串引用类型的数据载体如ArrayObjectFunctionRegexDate以及部分闭包中的变量会分配到堆(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) :数据通过 DMAPCIe 总线从系统内存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 开始传输,延迟是最小的。它不需要你手动管理 EncoderStaging 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 BufferfinalGpuBuffer 的传输。这确保了 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. 逐个元素赋值 (view[i] = ...) :数据确实是通过 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 中构造 它,从而实现最理想的性能,真正避免了冗余的中间内存分配和复制。
相关推荐
brzhang21 分钟前
我操,终于有人把 AI 大佬们 PUA 程序员的套路给讲明白了!
前端·后端·架构
止观止1 小时前
React虚拟DOM的进化之路
前端·react.js·前端框架·reactjs·react
goms1 小时前
前端项目集成lint-staged
前端·vue·lint-staged
谢尔登1 小时前
【React Natve】NetworkError 和 TouchableOpacity 组件
前端·react.js·前端框架
Lin Hsüeh-ch'in1 小时前
如何彻底禁用 Chrome 自动更新
前端·chrome
augenstern4163 小时前
HTML面试题
前端·html
张可3 小时前
一个KMP/CMP项目的组织结构和集成方式
android·前端·kotlin
G等你下课4 小时前
React 路由懒加载入门:提升首屏性能的第一步
前端·react.js·前端框架
蓝婷儿5 小时前
每天一个前端小知识 Day 27 - WebGL / WebGPU 数据可视化引擎设计与实践
前端·信息可视化·webgl
然我5 小时前
面试官:如何判断元素是否出现过?我:三种哈希方法任你选
前端·javascript·算法