这段内容讲的是离散显卡(Discrete GPU)中的内存管理模型,重点是CPU和GPU各自独立管理自己的物理内存,以及它们如何通过虚拟内存和DMA引擎实现高效通信。以下是详细的理解和梳理:
1. 基本概念
- CPU 和 GPU 是两个独立的处理单元
- 它们各自拥有自己的物理内存区域:
- CPU的物理内存叫 System Memory(系统内存,即RAM)
- GPU的物理内存叫 Video Memory(显存,即VRAM)
2. 虚拟内存和内存管理单元(MMU)
- CPU通过**内存管理单元(MMU)**访问系统内存
- MMU 提供虚拟内存支持,使得:
- 物理内存中不连续的内存页,可以映射为连续的虚拟地址空间
- 允许操作系统将不常用内存页暂存到磁盘,实现“虚拟内存扩展”
- GPU也有自己的MMU,实现类似的虚拟内存抽象,管理显存
3. CPU和GPU共享内存的两种情况
- CPU物理内存(系统内存)映射给GPU访问
- 系统内存中的某些物理页,被映射到CPU和GPU的虚拟地址空间中
- CPU和GPU都可以通过自己的虚拟地址访问这块共享的物理内存
- 这实现了CPU和GPU间通过内存通信的通道
- GPU物理内存(显存)映射给CPU访问
- 显存中的物理页也可以映射到CPU和GPU的虚拟地址空间
- 这样CPU可以直接访问显存中的数据
4. DMA引擎(Direct Memory Access)
- DMA引擎是GPU内部的专用硬件,用于高效地在CPU内存和GPU显存间复制数据
- 重要的是,DMA通常不处理虚拟内存,只能直接对物理内存进行操作
- 因为物理内存页通常是不连续的,DMA复制时需要逐页操作
5. 总结和理解
角色 | 作用及特点 |
---|---|
CPU | 有自己的系统内存和MMU,管理虚拟内存 |
GPU | 有自己的显存和MMU,管理自己的虚拟内存 |
虚拟内存 | 将不连续的物理页映射为连续的虚拟地址 |
CPU/GPU共享内存 | 系统内存或显存页可以映射到双方的虚拟地址空间,支持通信 |
DMA引擎 | 高效复制CPU <-> GPU物理内存数据,需逐页复制 |
这套模型帮助理解离散显卡内存架构的复杂性,尤其是为什么数据复制效率不易达到理论最高,以及现代GPU/CPU设计如何通过虚拟内存和DMA硬件优化性能。 |
集成显卡(Integrated GPU)中的内存管理模型,和之前的离散显卡相比,有些不同。下面帮你详细解读:
1. 集成显卡的CPU和GPU关系
- CPU和GPU几乎是合成一体的单个芯片或单元
- 他们共享同一块物理内存区域(系统内存),不再是分开的系统内存和显存
- 但CPU和GPU依然拥有各自独立的虚拟地址空间
- 这意味着虽然物理内存共享,但两者的虚拟地址映射可能不同
- 理论上存在共享虚拟地址空间的可能(如OpenCL的共享虚拟内存SVM),但并不通用,不能依赖
2. 共享物理内存,但虚拟地址空间分离
- CPU和GPU访问的是同一块物理内存(系统内存),
- 但各自的MMU管理各自的虚拟地址映射
- 这种情况下,CPU和GPU可以直接通过物理内存通信,避免了之前离散显卡中频繁的跨内存拷贝
3. 文件系统和页面调度的限制
- GPU访问的系统内存不连接文件系统
- 这点和离散GPU类似
- 也就是说,GPU不能像CPU一样依赖操作系统的页面交换机制(如页面置换到硬盘)
- GPU无法利用文件系统进行“自动页面缺失(page fault)”处理
- 虽然技术上可以实现GPU页缺失机制,但这不是主流或广泛支持的用法
4. 总结和理解
方面 | 说明 |
---|---|
CPU & GPU | 物理上融合为一个单元,使用同一块物理内存 |
虚拟地址空间 | CPU和GPU有各自独立的虚拟地址空间 |
共享虚拟地址空间 | 存在可能(如OpenCL SVM),但不可假设普遍支持 |
文件系统支持 | GPU访问的内存不连接文件系统,无法使用文件系统页面调度 |
性能影响 | 共享物理内存减少数据拷贝成本,但缺页处理受限 |
你可以想象
- 离散GPU就像是两台电脑互相通过网线(PCIe)通信,数据需要复制通过通道
- 集成GPU则像是一个电脑内的两个核心共享一块内存,但每个核心访问内存的“视角”不同(各自虚拟地址空间)
这部分内容讲的是 Command Lists(命令列表) 在CPU和GPU协作中的作用,下面帮你详细拆解和理解:
1. CPU和GPU的执行模型差异
- CPU是**乱序执行(Out-of-Order)**的处理器
- 可以动态调整指令执行顺序,以提升效率(比如推测执行)
- GPU是**顺序执行(In-Order)**的处理器
- 严格按照命令给出的顺序执行任务,不会乱序
- 这就要求提交给GPU的工作顺序必须合理高效
2. Command Lists是什么?
- 命令列表是CPU准备好的“工作清单”
- CPU负责构建这份命令调度(Schedule),组织好任务顺序
- GPU接收到命令列表后,顺序执行其中的命令
3. 类比:Gromit和火车
- 火车代表GPU,只能沿着铺好的轨道前进(严格顺序执行)
- Gromit代表CPU,负责提前铺好轨道(生成命令列表)
- 轨道即命令列表,必须铺设合理,火车才能顺畅运行
4. Fences(栅栏)是什么?
- Fence是一个操作系统对象,用来监控GPU的执行进度
- 举例:初始Fence值是0
- GPU执行命令,执行到第一个Fence位置时,Fence值变成1
- 表示GPU完成了第1段命令
- GPU继续执行,到达Fence 2时,Fence值变成2,依此类推
- 通过Fence,CPU或系统可以检测GPU执行到什么程度了,方便同步或资源管理
5. 总结
角色 | 作用 |
---|---|
CPU | 准备命令列表(合理安排任务顺序) |
GPU | 按命令列表顺序执行任务 |
Command List | CPU提交给GPU的任务清单 |
Fence | 用于追踪GPU执行进度的同步机制 |
如何利用Fence实现CPU和GPU之间的同步:
场景说明
- Fence初始值是1(这个值一般是任意的起始状态)
- CPU准备了一串命令列表,并在命令列表的末尾放了一个特殊命令——signal(信号命令)
- 这个signal命令的作用是:当GPU执行到这里时,会把Fence的值从1更新到2
执行流程
- CPU阶段
- CPU生成命令列表,命令序列可能是:
cmd1
,cmd2
,cmd3
, …,signal
signal
命令附带的效果是“将Fence的值设为2”
- CPU生成命令列表,命令序列可能是:
- GPU阶段
- GPU接收命令列表,顺序执行
- 执行每条命令,最后执行
signal
命令时,GPU会把Fence值从1改成2
- 同步机制
- CPU通过检查Fence的值是否≥2来判断GPU是否完成了命令列表中的所有工作
- 如果Fence≥2,CPU就知道GPU已经执行到signal命令之后了,意味着GPU完成了之前的所有命令
- 同理,GPU内部也能用Fence进行同步,比如多个命令队列之间协调执行顺序
形象解释
- Fence就是一个“进度条”或者“里程碑”,它的值随着GPU执行进度递增
- CPU可以通过Fence知道GPU跑到了哪一步,从而决定下一步的操作(比如是否提交新的命令、释放资源等)
- GPU也能利用Fence同步自身的不同工作流
总结表格
角色 | 行为 |
---|---|
CPU | 生成命令列表,并在结尾加上signal 命令,表示GPU执行到这儿时更新Fence的值 |
GPU | 按顺序执行命令,执行到signal 时更新Fence值 |
Fence | 共享变量,CPU和GPU通过检查它的值来判断GPU执行进度,实现同步 |
GPU 命令列表中常见的命令类型及其背后的抽象模型,我来帮你系统性地理解这些内容:
大致分类(GPU 命令的种类)
GPU 接收的命令并不是像 CPU 那样执行通用指令,而是高度结构化的“任务请求”,主要分为以下几类:
1. DMA 拷贝类(数据传输命令)
示例:
Copy{src, dst}
- 用于在 CPU 和 GPU、或 GPU 的不同区域之间复制数据
- 通常映射到底层的 DMA Engine,能实现高性能、异步的数据拷贝
2. 执行 GPU 程序(计算/绘图调用)
示例:
SetProgram{program}
SetParams{buf, tex, 1337}
Draw{N vertices} // 图形渲染
Dispatch{N threads} // 通用计算(GPGPU)
- 类似于函数调用的抽象模型:
- 设置程序(类似函数名)
- 设置参数(函数参数)
- 发起执行(函数调用)
GPU编程流程就像是:
set_registers(params);
jump_to_shader_entry();
3. 渲染控制命令(图形管线配置)
示例:
SetRenderTarget{rt}
SetGfxPipeline{pipeline}
- 设置渲染目标(比如一块屏幕区域或纹理)
- 设置图形流水线(配置如光栅化、混合、深度测试等状态)
4. 内存 & 对象模型(资源管理与状态同步)
示例:
MemoryBarrier{object}
Transition{object, a, b}
Construct{object}
Destruct{object}
MemoryBarrier
:手动控制数据一致性(GPU 不像 CPU 有自动缓存一致性机制)Transition
:GPU 的某些资源在不同用途间需要显式“状态转换”- 例如:一张纹理从“渲染输出”变为“采样输入”,需要做状态转换
Construct
/Destruct
:- 抽象上的资源生命周期控制
- 类似 C++ 的
placement new/delete
- 实际底层不会叫这个名字,而是分成一堆底层资源创建与释放命令
思维类比:函数调用 vs GPU命令序列
CPU/C++ | GPU Command List |
---|---|
设置函数参数 | SetParams{...} |
调用函数 | Dispatch{...} 或 Draw{...} |
析构对象 | Destruct{object} (抽象) |
局部变量构造 | Construct{object} (抽象) |
小结
- GPU 命令本质上是一个面向任务的指令模型,种类清晰、目标明确
- 命令的构成是高度结构化的,分配了明确的职责,比如数据传输、程序调用、渲染状态设置、资源同步等
- 类似 CPU 的函数调用和内存管理机制,但需要手动控制同步、状态转换和资源生命周期
GPU 编程中一个非常关键但经常被忽视的细节 —— 命令参数的读取时机(indirection,间接寻址),我来帮你逐步理解:
核心问题:命令参数何时读取?
GPU 在什么时候读取命令参数?是在CPU 录制命令的时候,还是在GPU 执行命令的时候?
两种方式的对比
方式 | 含义 | 示例 | 特点 |
---|---|---|---|
Record-Time(录制时) | 参数在 CPU 构建命令列表时就确定了 | Dispatch{512} | 更快 更容易优化 灵活性差 |
Execute-Time(执行时) | 参数在 GPU 执行时从内存中读取 | DispatchIndirect{&512} | 更灵活 数据驱动 可能较慢 |
举个例子说明:
Dispatch{N_threads}
// 直接记录:512 个线程
command_list.record(Dispatch{512});
- 参数是个立即数(value)
- GPU 可以在命令列表生成阶段提前优化好调度
- 不依赖运行时数据
DispatchIndirect{&N_threads}
// 从内存中读取线程数
uint32_t N = ComputeThreadCountSomehow();
command_list.record(DispatchIndirect{&N});
- 参数是引用 / 指针
- GPU 执行时从内存中读取这个值
- 可以根据其他计算结果动态决定线程数
- 更灵活,但更难优化
Trade-off 总结
方面 | Record-Time 参数 | Execute-Time 参数(Indirect) |
---|---|---|
性能 | 更高,易优化 | 可能慢,不易推测 |
灵活性 | 低 | 高(数据驱动、逻辑更丰富) |
实现复杂度 | 低 | 高 |
示例 | Draw{100} | DrawIndirect{&count} |
实际应用场景
- Record-time(立即值)适合固定流程:大部分渲染、预定义计算
- Execute-time(间接寻址)适合数据驱动流程:基于之前 GPU 输出决定下一步行为(如 GPU 级剔除后的渲染)
接下来的话题:Descriptors
你这段最后提到要进入 descriptors —— 它跟资源绑定密切相关,比如纹理、缓冲区绑定到着色器,是现代 GPU API(如 Vulkan、D3D12)中非常核心的机制。
GPU 编程中极为重要的概念 —— Descriptor(描述符),它是现代图形/计算 API(如 D3D12、Vulkan、Metal)核心机制之一。下面是逐点解析,帮助你真正吃透这个概念:
什么是 Descriptor?
简单说:Descriptor 就是 GPU 眼中对资源的定义。
- 描述符 = 内存地址 + 元数据
- 描述的是 buffer 或 texture 的布局与位置
类比理解
你可以把 descriptor 想象成一本图书馆里的卡片:
- 地址(Address):告诉你书在哪一排哪一列(物理地址)
- 元数据(Metadata):告诉你这本书有几页、是什么语言、是否彩印(数据大小、类型、格式等)
例子:AMD GCN3 Texture Descriptor(128 位)
在 GCN3 架构中,一个纹理描述符的具体布局如下:
位范围 | 含义 |
---|---|
[39:0] | Address:纹理内存地址 |
[77:64] | Width:宽度(像素) |
[91:78] | Height:高度(像素) |
[127:92] | 其他标志/格式信息 |
这些字段告诉 GPU: |
- 要访问哪一块内存?
- 数据的形状是什么?
- 怎么解释这块数据?
用法示例:伪装配代码
dst = image_sample(xy, texture_descriptor, sampler_descriptor);
解释:
texture_descriptor
:告诉 GPU 从哪里读取纹理、如何解释纹理sampler_descriptor
:告诉 GPU 如何采样(过滤、边缘处理等)xy
:采样坐标dst
:结果写入的位置(颜色、深度等)
这个调用最终会变成底层硬件执行的采样操作,离开了 descriptor,GPU 连纹理在哪都不知道。
为什么需要 Descriptor?
- GPU 是高度并行的,需要能独立高效地访问资源
- 每个线程不能靠 CPU 传参,它需要从描述符表中快速查表
- 提前定义好描述符能让 GPU 直接执行,不需要解释
延伸:Descriptor Heap / Table
在 D3D12、Vulkan 中,你会维护一张 “描述符表” 或 “描述符堆”:
- 就像 GPU 的“资源目录”
- 每个描述符代表一个资源(纹理、缓冲区等)
- 着色器访问资源时,不是直接用地址,而是通过“索引 + 根参数 + offset”来找到 descriptor
总结记忆
项目 | 内容 |
---|---|
什么是 Descriptor? | GPU 用来访问资源的硬件对象:地址 + 元数据 |
描述哪些资源? | Texture、Buffer、Sampler 等 |
存储在哪里? | 通常在描述符表 / 堆中,供 GPU 索引 |
为什么重要? | GPU 无需 CPU 干预即可并行访问资源 |
示例? | image_sample(xy, texture_desc, sampler_desc); |
这部分讲的是 实时渲染器架构(Real-Time Renderer Architecture) 的大局观设计 —— 即如何把“游戏世界的逻辑”转换成“屏幕上的图像”。我们来逐步拆解,帮你建立对整套架构的清晰认知:
实时渲染系统:核心流程图解
Continuous DiscreteBehaviors Events↓ ↓+------------------------------+| Simulation | ← 游戏逻辑| ("Game Objects") |+------------------------------+↓Update Scene Graph↓+------------------------------+| Scene | ← 高层描述:几何体、材质、灯光等+------------------------------+↓+------------------------------+| Renderer | ← 将场景转为GPU指令(图形资源)+------------------------------+↓Submit Commands↓GPU Execution↓+------------------------------+| Swap Chain | ← 双缓冲帧管理(显示帧切换)+------------------------------+↓Compositing / Display
概念拆解
1. Simulation(模拟)
- 负责应用逻辑,如物理、AI、动画、用户输入
- 抽象单位是“Game Object”
- 处理连续状态变化 + 离散事件(如按键、碰撞等)
2. Scene(场景)
- 表示“需要被渲染的世界状态”
- 高层资源描述,如:
Geometry
:网格、顶点数据Material
:表面着色逻辑、纹理Instances
:几何体的具体位置与变换Camera
:观察视角Light
:光照源及其类型/位置/颜色等
Scene ≠ Renderer,它只是个逻辑数据结构,不关心如何画。
3. Renderer(渲染器)
- 读取 Scene,生成图形命令(Command Lists)
- 管理 GPU 资源:
Buffers
:存储数据(如变换矩阵、顶点、索引)Textures
:纹理图像Shaders
:程序(Vertex、Fragment、Compute 等)Passes
:渲染流程的阶段(如 Shadow Pass, G-Buffer Pass)
4. GPU 提交 + Swap Chain 显示
- 命令列表被提交给 GPU 执行
- 完成后输出到 Swap Chain:
- 显示帧缓存机制(通常双缓冲/三缓冲)
- 如果不是全屏,帧最终会被桌面 compositor 混合显示
- 最终呈现在显示器上
性能目标:10~100 毫秒/帧
应用类型 | 推荐帧时间 | 帧率目标 |
---|---|---|
快节奏游戏 | 10~16ms | 60~90 FPS |
普通游戏 | 16~33ms | 30~60 FPS |
工具类程序(如 3D 建模) | 可接受更高延迟 | 可低至 15 FPS |
总结记忆
模块 | 职责 |
---|---|
Simulation | 游戏逻辑 + 控制场景内容变化 |
Scene | 高层资源组织结构 |
Renderer | 构建 GPU 指令 + 管理资源 |
GPU + Swap Chain | 执行绘制 + 显示输出 |
你可以把整个系统想象成一台流水线工厂,Simulation 是设计图纸、Scene 是原材料仓库、Renderer 是装配线操作员,GPU 是机器人臂,Swap Chain 是成品传送带。 |
这部分介绍了**Ring Buffer(环形缓冲区)**在实时渲染系统中的应用,尤其是用于 CPU → GPU 数据传输 的高效机制。下面是结构化总结与理解:
Ring Buffer(环形缓冲区)
基本定义
- 是一种循环使用的连续内存区域,用来进行数据流的生产与消费。
- 适用于CPU 和 GPU 并行工作场景(producer-consumer 模型)。
应用场景:CPU 向 GPU 传输数据
- CPU:不断地写入新的数据。
- GPU:异步读取这些数据进行渲染。
- 二者操作同一个环形缓冲区,但操作的“位置”不同,避免冲突。
优势: - 避免频繁创建/销毁资源。
- 实现高效、可重用的上传路径。
- 提供流式数据更新方式(如动态相机参数、动画数据等)。
示例 API
auto [pCPU, pGPU] = pRing->Alloc<Camera>();
*pCPU = camera;
pCmdList->SetDrawParam(CAMERA_PARAM_IDX, pGPU);
解读:
Alloc<T>()
分配一段用于T
类型(如 Camera)数据的空间。- 返回值是二元组:一个是
pCPU
(CPU 虚拟地址),另一个是pGPU
(GPU 虚拟地址)。 - 使用
pCPU
写数据,用pGPU
作为绘制参数传给命令列表。
Descriptor Ring Buffer 也是一种 Ring Buffer!
auto [pCPU, pGPU] = pDescriptorRing->Alloc(1);
WriteDescriptor(pCPU, desc);
pCmdList->SetDrawParam(TEXTURE_PARAM_IDX, pGPU);
- 用法类似,用于分配临时描述符(绑定纹理、缓冲等资源的元数据结构)。
- 描述符是 GPU 使用资源时的“视图”,包含地址和访问方式等信息。
性能提醒:
对于 离散 GPU(如独显),数据最终应拷贝到 专用显存(video memory),否则系统内存读性能可能拖慢渲染。
总结一句话:
Ring Buffer 是连接 CPU 和 GPU 的高效流式桥梁,支持异步上传数据或描述符,并最大限度减少内存分配成本。
这一部分深入讲解了 Ring Buffer 的两个棘手问题:内存不足(Out-of-Memory) 和 环绕(Wrap-Around),并提供了实际工程上的处理建议。
1. Ring Buffer: Out-of-Memory(内存不足)
问题场景:
- CPU 想在 ring buffer 中分配一段内存。
- 但这一段内存 仍在被 GPU 读取中,尚未完成处理。
解决方法:
- CPU 阻塞等待 GPU 完成。
- 使用 GPU-Fence 来同步:
if (AllocWouldOverlapWithGPU()) {WaitForFence(gpuFence); }
本质:
避免 CPU 写入还未被 GPU 消费的数据区域,确保数据一致性。
2. Ring Buffer: Wrap-Around(缓冲区环绕)
问题:
- Ring Buffer 是循环结构,当分配位置接近尾部时,可能需要“从头开始”。
- 如果处理不好,可能出现 数据重叠或逻辑混乱。
推荐做法 1:使用 虚拟偏移(virtual offset)
// virtualOffset 持续增长,不受 RING_SIZE 限制
uint64_t virtualOffset = ...;
uint64_t realOffset = virtualOffset & (RING_SIZE - 1);
data = buffer[realOffset];
优势:
- 避免 wrap-around 检查逻辑复杂化。
- 配合 Fence 使用更简单:Fence value 可直接绑定 virtual offset。
- Power-of-two 大小 ring 帮助简化位运算。
推荐做法 2:禁用 wrap-around,按帧预分配
// 每帧分配固定大小内存
const size_t PER_FRAME_BUDGET = 64KB;
if (allocSize > PER_FRAME_BUDGET) {assert(false && "Need to increase per-frame memory budget");
}
优势:
- 极简实现。
- 保证分帧分配逻辑清晰。
- 适合大多数典型帧时间控制的实时应用(如游戏渲染)。
推荐阅读:
Fabian Giesen 的博客
➡ 深入讲解 ring buffer 的数据结构原理、同步策略,以及设计不变量(invariants)。
总结一句话:
用虚拟偏移简化 wrap-around,用 Fence 处理同步冲突,用帧预算确保稳定性 —— Ring Buffer 成为 GPU 数据流式传输的可靠利器。
这一节讲述了如何在 Ring Buffer 中实现无锁(Lock-Free)内存分配 —— 这对于高性能 GPU 数据流非常关键,尤其是在多线程或并发渲染环境下。
核心思想:用原子变量 std::atomic<uint64_t> offset
实现无锁分配
基本做法(结构化数据):
std::atomic<uint64_t> offset;
uint64_t alloc(uint64_t n) {return offset.fetch_add(n); // 原子地分配 n 个单位
}
fetch_add(n)
会原子地把offset
增加n
,返回旧值。- 所以你拿到的就是你该写入的位置。
- 多线程下也不会有数据覆盖或竞争问题。
应对 原始数据(Raw Byte Data)+ GPU 对齐需求
GPU 通常有对齐要求,比如 DirectX 12 下最大可能要求是 512 字节对齐(如上传贴图数据时)。
对齐版本的分配函数:
#define WORST_ALIGNMENT 512
uint64_t aligned_alloc(uint64_t sz) {uint64_t padded_sz = (sz + WORST_ALIGNMENT - 1) & ~(WORST_ALIGNMENT - 1);uint64_t alloced = offset.fetch_add(padded_sz);assert(alloced + sz <= RING_SIZE); // 简单溢出检查return alloced;
}
技巧说明:
概念 | 解释 |
---|---|
fetch_add | 原子操作,避免加锁,天然线程安全。 |
对齐(Alignment) | (sz + A - 1) & ~(A - 1) 是常见的向上对齐写法。 |
512 字节对齐 | 适配所有 GPU 子系统的最坏情况,虽然浪费点空间,但简单可靠。 |
无锁优势 | 性能极高,线程间不会互相阻塞,适合现代多核系统中的实时任务。 |
总结一句话:
用
atomic::fetch_add
实现无锁并发分配,用统一对齐消除 GPU 对齐问题 —— 简单、稳定、快得离谱。
Ring Buffer(环形缓冲区)在实时渲染中的优缺点,特别是在 CPU → GPU 数据流场景中的应用。以下是中文结构化的要点归纳:
Ring Buffer 优点(Pros)
优点 | 说明 |
---|---|
简化内存管理 | 统一分配策略,避免手动管理多个小 buffer |
API 极其简单 | 通过一个 Alloc() 函数即可分配空间 |
避免碎片化 | 连续线性分配,不会留下“内存洞” |
多功能构建块 | 适用于各种用途,如几何体上传、纹理流、sprite 批渲染等 |
典型用法示例:
- Procedural Geometry(程序化几何体):直接上传 vertex/index 数据。
- Texture Streaming(纹理流式传输):分段上传 mipmap 或贴图 block。
- Sprite Batch:合并多个小对象的绘制调用,提高效率。
Ring Buffer 缺点(Cons)
缺点 | 说明 |
---|---|
内存大小需要“校准” | 太小:性能差/易崩溃;太大:浪费宝贵显存/系统内存 |
没有统一配置方式 | 不同用例对内存类型和对齐策略需求不同 |
缓存策略复杂 | 写合并(Write-Combined)、写回(Write-Back)等策略影响访问性能 |
物理内存选择困难 | 系统内存 vs. 显存:取决于设备架构和数据使用模式 |
内存策略的选择影响极大
比如:
- 写合并(Write-Combined, WC):
- 对 CPU 写入性能好,但不能频繁读取。
- 不适合需要频繁读取或修改的 procedural data。
- 写回(Write-Back, WB):
- 更通用,适合需要读取的 CPU 数据结构。
- 内存类型选择:
- System Memory:适用于集成显卡或数据上传阶段。
- Video Memory:适合长期 GPU 访问的资源(如材质、顶点缓冲)。
总结建议:
Ring Buffer 是非常强大的构建块,但它不是“万能工具”。你必须根据具体用途(渲染什么、更新频率、访问模式)合理设置大小、内存类型和缓存属性。
并行命令录制(Parallel Command Recording),这是 Vulkan 和 DirectX 12 等现代图形 API 中的核心优化特性之一。下面是中文归纳与重点说明:
并行命令录制:核心思想
CPU 写命令是很重的任务。
如果你要渲染大量对象(比如成千上万个),那么即使还没交给 GPU 执行,仅在 CPU 上构造这些命令本身就会成为性能瓶颈。
解决方案:
将 命令录制任务分配到多个 CPU 线程上并行执行,每个线程写自己的命令缓冲区(Command List),最后把它们汇总提交给 GPU。
示例结构图(可视化):
Thread 0: cmd cmd cmd ↘
Thread 1: cmd cmd cmd cmd → [Submit to GPU]
Thread N: cmd cmd cmd ↗
每个线程独立录制命令,最后统一提交。
简单用例:场景中有大量“规律性”的工作
CmdList lists[NUM_JOBS];
parallel_for (jobID = 0 .. NUM_JOBS) {lists[jobID].SetRenderTarget(rt);foreach (object in job) {lists[jobID].SetGeometry(object.geometry);lists[jobID].SetMaterial(object.material);lists[jobID].Draw(object.num_vertices);}
}
Submit(lists); // 提交所有命令缓冲区
说明:
- 将场景对象分成若干“工作块”(jobs)。
- 每个线程独立处理一个 job。
- 每个线程录制自己的命令列表。
- 所有命令列表录制完后一次性提交。
实用建议
建议 | 原因 |
---|---|
不要过早并行化 | 对于小批量命令(例如 UI 渲染),并行录制得不偿失 |
每个命令缓冲区应耗费至少 50μs 的 GPU 时间 | 太短会导致提交开销掩盖了收益 |
每次 Submit 尽量包含 ≥500μs 的 GPU 工作量 | 提交操作本身有代价,别太频繁 |
小结
优势 | 应用场景 |
---|---|
更高 CPU 并发利用率 | 大量对象渲染(例如:开放世界游戏、海量粒子) |
降低单线程瓶颈 | 命令录制分散到多个核心 |
和现代 GPU API 匹配 | Vulkan / DX12 等天然支持多线程命令构建 |
这部分讲的是 “困难情况:不规则的工作量”,也就是当你渲染的对象种类很多,且每个对象准备命令所需的 CPU 工作量差异较大时,命令录制并行化就没那么简单了。
核心点总结:
- 对象异构(Heterogeneous):
场景中有不同类型的 Drawable,比如:Blob
:需要运行marching cubes算法进行多边形化Subdiv
:需要进行三角形细分Particles
:需要进行粒子分箱和排序
- 工作量不均匀:
不同对象的命令准备时间相差很大,甚至受其它因素影响,比如物体距离摄像机的远近或遮挡情况。
为什么这很难?
- 很难均匀分配任务给多个线程,因为:
- 有些线程会处理复杂对象,耗时长
- 有些线程处理简单对象,耗时短,导致 CPU 线程负载不均
- 会导致线程等待,浪费多核资源,降低并行效率。
现实中的挑战与思考:
- 动态负载均衡:需要设计工作窃取(work stealing)或者更智能的任务分配策略,避免某些线程提前完成而空闲。
- 优先级和剔除:可以根据距离摄像机或遮挡情况提前剔除或降低处理优先级,减少命令准备的总开销。
- 异步任务处理:复杂计算尽量异步或在后台线程中进行,渲染线程专注于轻量命令录制。
这是解决**不规则工作量(Irregular Work)**的经典方法——Fork/Join 并行。
// 递归并行绘制函数,参数:
// cmdlist - 当前线程/任务用的命令列表
// drawables - 当前需要绘制的对象集合
void draw_par(cmdlist, drawables) {// 判断当前任务的“工作量”是否低于阈值// 如果工作量小,直接串行绘制,避免过度拆分导致开销过大if (drawables.cost() < WORTH_SPLITTING) {draw_seq(cmdlist, drawables); // 串行绘制当前这批对象} else {// 否则将任务拆分为两个子任务,分别处理左右两半对象auto [left, right] = drawables.split();// 并行启动绘制左半部分,继续使用当前命令列表spawn draw_par(cmdlist, left);// 并行启动绘制右半部分,给右边子任务新建独立命令列表,避免线程冲突spawn draw_par(new CmdList(), right);// 等待左右两个子任务都完成绘制命令的录制sync;}// 这行看起来像注释或示意(非实际代码)// 可能表示这里有对命令列表3的操作或者绘制// draw_seq// CmdList 3
}
// 从外部调用入口,传入新的命令列表和所有待绘制对象,开始递归并行绘制
draw_par(new CmdList(), all_drawables);
核心思路:
- 递归拆分任务
把待绘制对象列表递归拆分成更小的子列表,直到任务足够小(低于某个“值得拆分”的阈值WORTH_SPLITTING
),然后串行执行。 - 多线程并行执行
大任务拆分后,利用任务调度器(task scheduler)异步运行左右两个子任务(spawn),并在最后用同步(sync)等待子任务完成。 - 独立命令列表
每个子任务有自己的CmdList
,独立记录 GPU 命令,最后汇总提交。
优点:
- 任务细分能适配不同复杂度的对象,实现负载均衡。
- 结合任务调度器(如工作窃取 Work Stealing),能高效利用多核 CPU。
- 代码结构清晰,易维护。
背景问题
- 在并行绘制(fork/join)不规则工作负载时,每个子任务通常创建独立的命令列表(CmdList)。
- 如果多个任务实际上在同一个CPU上顺序执行,创建多个命令列表就显得浪费,增加额外开销。
关键思想:Hyperobject优化
- Hyperobject 是一种语言特性(起源于Cilk++),用于在并行任务中管理线程/任务局部状态。
- 通过“偷用父任务的命令列表”,可以让运行于同一个CPU的连续任务共用同一个命令列表,避免重复创建。
- 这种方式减少了命令列表的数量,从而降低了管理和合并命令列表的开销。
重要特性
- 保持绘制顺序不变
Hyperobject管理的命令列表不会改变绘制命令的提交顺序,这对于避免图像闪烁和性能波动至关重要。 - 优化性能
避免过度拆分命令列表,提高CPU多线程写命令效率。
推荐资料
- “Reducers and Other Cilk++ Hyperobjects” 论文
- 这些论文介绍了Cilk调度器中如何整合Hyperobject及其实现细节。
总结
使用Hyperobject优化方案,可以更智能地管理不规则并行工作负载中的命令列表,减少开销并保持绘制顺序的稳定性,是一种非常优雅的设计思路。
GPU调度(Scheduling GPU Work & Memory)核心概念
1. 大框架(Big Picture)
- GPU的工作可以看成是一个帧图(frame graph),由多个**渲染通道(passes)**组成。
- 这些通道之间会通过共享的资源(如纹理Texture、缓冲Buffer)传递数据。
- 调度器的任务就是根据这个依赖关系图,按照正确且高效的顺序提交GPU任务。
2. 调度职责(Duties)
- 任务提交顺序有效且高效
需要确保依赖关系被满足,不会提前使用未生成的数据,同时还要尽量减少GPU空闲,提升性能。 - 资源管理
调度器负责管理内存资源(比如纹理和缓冲区)的分配与释放,尽可能地在资源的生命周期内使用,减少内存浪费。- 例如:纹理Texture 2在Pass 1中生成,Pass 2中使用,用完就释放。
- 动态适应
因为实时渲染中场景内容不断变化,调度器应支持动态重建任务图(每帧可能不同),不能假设任务图是静态的。
3. 实际意义
- 通过这种帧图调度方式,GPU的工作被组织得更清晰且高效,避免了资源冲突和冗余占用。
- 支持复杂渲染管线,同时保持灵活性和性能。
Scheduling: Classic Multi-Pass Approach(经典多通道渲染调度)
1. 代码结构:
- 第一个Pass:
- 绑定渲染目标为
shadowMap
(深度缓冲区)。 - 设置阴影绘制管线(shadowPipeline)。
- 遍历场景中的所有物体,设置几何体并绘制。
- 该Pass输出深度信息,写入
shadowMap
(可读写)。
- 绑定渲染目标为
- 第二个Pass:
- 绑定回屏缓冲和深度缓冲为渲染目标。
- 设置场景绘制管线(scenePipeline)。
- 设置阴影贴图(shadowMap)作为输入纹理参数(只读)。
- 遍历场景中的物体,设置几何体、材质,绘制。
- 该Pass使用第一个Pass产生的阴影深度贴图,进行光照计算等。
2. 状态转换:
shadowMap
从第一Pass的读写深度缓冲变成第二Pass的只读纹理。- 这种状态切换需要显式的资源屏障(barriers),保证GPU正确处理同步和内存一致性。
3. API差异:
- 传统的OpenGL和Direct3D 11驱动通常会自动帮你处理资源状态切换和同步。
- 低级别API(如Vulkan和DirectX 12)要求程序员显式管理这些状态转换和资源屏障。
4. 总结:
- 经典多通道渲染是一种典型的图形编程模式,先写入一个渲染目标,然后在后续Pass读取它。
- 在现代低级图形API中,正确管理资源状态和同步是显式且必要的,这增加了代码的复杂度但提高了性能可控性。
Frame Graph 和作业提交(Work Submission)概述
1. Frame Graph是什么?
- Frame Graph(帧图)是现代渲染管线中用来组织和调度渲染任务的一种数据结构。
- 它将渲染过程拆分为多个“Pass”(渲染通道),每个Pass读写不同的资源(如纹理、缓冲区)。
- 通过分析Pass之间的依赖关系,Frame Graph能帮助优化渲染工作流,比如去除不必要的渲染操作,合理安排Pass执行顺序,以及有效管理内存资源。
2. 关键优化点:
- 死代码消除(Dead Code Elimination):
如果某个Pass产生的资源没有被后续Pass使用(比如某个纹理没被采样),整个Pass可以跳过,避免浪费计算资源。 - 数据访问冲突和屏障插入(Data Hazard & Barriers):
如果Pass1写入了某个资源,而Pass2需要读取这个资源,必须在两者之间插入同步屏障(memory barrier),保证数据正确传递,避免访问冲突。 - Pass间的延迟管理(Latency Management):
有些Pass紧密连在一起执行,缓存利用率高但可能造成CPU或GPU等待;而把它们错开执行可以减少阻塞但会增加内存开销。如何平衡这两者是一个复杂的调度问题。 - 命令列表提交(Command List Recording & Submission):
调度器根据依赖关系和优先级,依次执行各Pass对应的命令录制和提交操作。
3. 调度算法:List Scheduling
- 给每个任务(Pass)分配一个优先级(优先级是通过启发式算法决定的)。
- 选择当前所有满足资源和依赖要求的最高优先级任务执行。
- 依次重复直到所有任务完成。
- 例如任务A优先级最高,先执行;如果任务B资源不足,先执行C。
4. 内存和资源生命周期管理
- 资源(纹理、缓冲等)只在其被使用的Pass生命周期内存在。
- 用完后可以释放资源,或者在条件允许时复用内存(比如两个资源大小和格式相同且生命周期不重叠)。
- 这种复用降低了显存占用,提高效率。
- 类似于D3D12中的
CreatePlacedResource
,需要显式管理内存和资源绑定。
总结
- Frame Graph是一种把渲染流程视为任务图,进行全局优化的技术。
- 通过死代码剔除、同步屏障插入、合理调度和内存复用,实现高性能且内存友好的渲染流程。
- 新的底层图形API(Vulkan、D3D12)需要程序员手动管理资源状态和屏障,复杂但控制更细。
- 需要根据具体应用和硬件做出权衡和优化。