硬件性能优化本质上是在解决四类问题:
- CPU 算得太慢
- GPU 执行得太慢
- 内存访问效率太低
- CPU、GPU、内存之间互相等待
之前的ECS其实已经有了对连续内存读取硬件相关的优化了,属于内存访问效率太低的问题:UE5 ECS 原理-CSDN博客
今天了解更多关于硬件性能优化相关的内容
一、CPU 性能优化
CPU 优化不只是"减少代码",核心是减少:
- 指令数量
- 分支判断
- 内存访问
- 缓存未命中
- 线程同步
- 函数调用和对象管理开销
1. CPU Cache
CPU 不直接高效地读取任意内存,它会优先读取缓存:
寄存器
L1 Cache
L2 Cache
L3 Cache
内存 RAM
越靠近 CPU 越快,容量也越小。
例如:
struct FObject
{
FVector Position;
FVector Rotation;
FVector Velocity;
bool bActive;
};
如果你有 100 万个对象,CPU 遍历对象时可能不断跳跃读取不同字段,缓存效率较差。
更适合批处理的形式是 SoA:
TArray<FVector> Positions;
TArray<FVector> Rotations;
TArray<FVector> Velocities;
TArray<uint8> ActiveFlags;
当你只更新位置时,只读取 Positions 和 Velocities,无关数据不会进入 Cache。
这就是:
AoS:Array of Structures
SoA:Structure of Arrays
游戏引擎、ECS、粒子系统通常更偏向 SoA。
2. 减少 UObject 和动态分配
大量创建和销毁对象会带来:
- 内存分配成本
- 垃圾回收压力
- UObject 管理开销
- 指针跳转
- 缓存不连续
高性能系统通常使用:
对象池
数据池
句柄 Handle
索引 Index
连续数组
3. 多线程不是越多越快
线程越多不代表性能越好,因为还会受到:
- 线程创建成本
- 锁竞争
- 原子操作
- Cache Line 争用
- 任务依赖
- CPU 核心数量
影响。
比较理想的任务是:
任务之间相互独立
数据分块连续
每个线程处理自己的区域
最后只做少量合并
比较糟糕的任务是:
多个线程频繁修改同一个数组
多个线程竞争同一把锁
每一步都等待其他线程
二、GPU 性能优化
GPU 的特点是:
大量并行计算
较少分支
较高吞吐量
依赖连续、规则的数据访问
GPU 性能通常受以下因素影响:
- Shader 指令数量
- Pixel 数量
- Overdraw
- Texture 采样次数
- 内存带宽
- Register 数量
- Thread Occupancy
- Barrier 和同步
- Draw Call 数量
Compute Shader 本身计算量太大。
常见原因:
- 每个线程执行太多指令
- 线程组尺寸不合适
- 分支发散
- 随机内存访问
- 大量 UAV 写入
- 原子操作太多
问:为什么原子操作太多也会影响性能呢?
原子操作慢,核心原因是:它会把原本可以并行执行的内存访问,变成部分串行执行。
普通写入:
Buffer[Index] = Value;
多个线程写不同位置时,可以并行:
线程 0 → Buffer[0]
线程 1 → Buffer[1]
线程 2 → Buffer[2]
线程 3 → Buffer[3]
但原子操作必须保证"读、修改、写"不可被打断:
InterlockedAdd(Buffer[Index], Value);
它实际上类似:
读取旧值
计算新值
写回新值
GPU 必须保证多个线程不会同时覆盖彼此的结果。
1. 多个线程访问同一个地址会产生竞争
例如大量粒子写入同一个网格格子:
粒子 0 ─┐
粒子 1 ─┼── InterlockedAdd(Grid[100])
粒子 2 ─┤
粒子 3 ─┘
这些线程不能真正同时修改 Grid[100],只能排队:
线程 0 执行
线程 1 等待
线程 2 等待
线程 3 等待
粒子越集中,竞争越严重。
2. 原子操作通常涉及内存同步
原子操作不仅要完成修改,还要保证其他线程看到正确结果。因此 GPU 需要维护内存的一致性。
这可能导致:
- Cache Line 被频繁更新
- 多个计算单元之间同步
- 内存访问延迟增加
- 后续线程等待结果
尤其是多个线程组同时访问同一段全局 UAV 内存时,代价更明显。
Core 0 Cache:A = 10
Core 1 请求修改 A
Core 0 的缓存副本可能被标记为失效
Core 1 获得修改权限
Core 1 把 A 改成 11
其他核心之后只能看到正确的新值
1. Core 之间可以共享 L3 Cache
一般来说:L1、L2 通常不是 Core 之间直接共享的。L3可以共享一部分
Core 0 ─┐
Core 1 ─┼─ 共享某一部分 L3
Core 2 ─┤
Core 3 ─┘
Core 0 读取 A
↓ Cache Miss
缓存一致性系统查询,查缓存目录
↓
Core 1 Cache 直接提供 A = 11
↓
Core 0 得到 A = 11
CPU 会维护一份"缓存目录"或类似的状态信息。
它记录的不是每个变量的值,而是:
某个内存地址对应的 Cache Line
现在被哪些核心缓存了
哪个核心拥有最新的修改版本
哪些核心的副本已经失效
例子
假设变量 A 位于某个缓存行:
Cache Line X:包含 A
一开始:
RAM:A = 10
Core 0 Cache:A = 10
缓存目录可能记录:
Cache Line X:
Core 0 持有副本
状态:Shared
然后 Core 1 执行:
InterlockedAdd(&A, 1);
Core 1 不能直接修改,因为 Core 0 也有旧副本。它首先要申请这个缓存行的独占修改权限。
缓存一致性系统会做类似的事情:
Core 1 请求 Cache Line X 的修改权限
↓
目录发现 Core 0 也有 Cache Line X
↓
通知 Core 0:你的副本失效
↓
Core 0 的副本变成 Invalid
↓
Core 1 获得独占权限
↓
Core 1 修改 A = 11
此时目录更新为:
Cache Line X:
Core 1 拥有最新版本
状态:Modified 或 Owned
Core 0:Invalid
所以之后 Core 0 再读取 A 时:
Core 0 查 L1:没有有效副本
Core 0 查 L2:没有有效副本
查询目录:Core 1 拥有最新版本
请求 Core 1 提供数据
这就是为什么它能发现 Core 1 可能有新副本。
单核 + 多线程:并发
多核 + 多线程:真正并行
单核多线程
假设只有一个 CPU 核心:
线程 A 执行一小段
线程 B 执行一小段
线程 C 执行一小段
线程 A 继续执行
操作系统通过线程切换,让多个线程"交替运行"。看起来像同时执行,但同一时刻实际上只有一个线程在使用 CPU。
时间轴:
A A A | B B | C | A A | B
这叫并发,不是真正同时计算。
如果只有一个 CPU 核心,并且所有任务都是纯计算,那么并发确实几乎没有加速收益。
甚至可能更慢,因为会增加:
- 线程切换
- 调度成本
- 锁和同步
- Cache 失效
- 任务拆分和合并成本
这种情况下,单线程顺序执行往往更高效。
但"并发"不只用于加速计算,它还有几个重要用途。
并发真正有用的场景,是"一个任务在等待时,CPU 可以去做另一个任务",或者"多个任务可以同时占用不同硬件资源"。
场景一:任务需要等待 IO
例如读取硬盘文件:
读取文件:等待硬盘
解析文件:需要 CPU
单线程:
读取文件
等待硬盘
等待期间 CPU 基本没事做
读取完成
解析文件
并发:
线程 A:等待硬盘
线程 B:处理已经读完的数据
简单来说需要等待的时候,单核多线程,可以把之前计算到线程等待的算力放到其他地方
多核多线程
如果有 4 个 CPU 核心:
Core 0:线程 A
Core 1:线程 B
Core 2:线程 C
Core 3:线程 D
多个线程可以在同一个时间真正执行:
Core 0 ─ 线程 A
Core 1 ─ 线程 B
Core 2 ─ 线程 C
Core 3 ─ 线程 D
这才是真正的并行。
如何看自己电脑多少核?

16核
CPU 是:
16 个物理核心
32 个逻辑处理器
说明每个物理核心可以同时维护两个硬件线程:
物理核心 0
├── 逻辑处理器 0
└── 逻辑处理器 1
物理核心 1
├── 逻辑处理器 2
└── 逻辑处理器 3
这项技术叫:
- AMD:SMT
- Intel:Hyper-Threading
逻辑处理器并不等于额外的完整物理核心。两个逻辑处理器会共享同一个物理核心的:
- 运算单元
- L1/L2 Cache
- 内存访问资源
- 分支预测资源
什么是SMT呢?
Simultaneous Multithreading
同时多线程
AMD 叫 SMT,Intel 常见名称是 Hyper-Threading。它的核心思想是:
让一个物理 CPU 核心同时管理两个或多个硬件线程,从而尽量填满核心内部原本空闲的执行资源。
逻辑处理器的价值在于:当一个线程正在等待内存时,另一个逻辑线程可以利用物理核心中暂时空闲的执行资源。
例如:
逻辑线程 A:等待内存
逻辑线程 B:执行计算
这样可以提高硬件利用率。
这也是一种并发
10 万个粒子不需要 10 万个线程。更合理的是:
10 万个粒子数据
若干个并行任务
每个任务处理一段连续数据
例如:
任务 0:粒子 0 到 9999
任务 1:粒子 10000 到 19999
任务 2:粒子 20000 到 29999
那相当于,我10万个粒子,我8个核心,最多就开8个多线程跑
8 个物理核心
可以让最多大约 8 个 CPU 任务同时执行:
Core 0:处理粒子 0 - 12499
Core 1:处理粒子 12500 - 24999
Core 2:处理粒子 25000 - 37499
...
但实际通常会把数据拆成更多任务,例如 32 个任务:
任务 0:粒子 0 - 3124
任务 1:粒子 3125 - 6249
任务 2:粒子 6250 - 9374
...
任务 31:最后一段
然后线程池中可能只有 8 个工作线程:
8 个线程同时处理 32 个任务
某个线程完成一个任务后,再领取下一个任务
这样比固定写死 8 段更灵活,因为每个任务的工作量可能不同。
为什么不直接只拆成 8 个任务?
如果 8 个任务的工作量完全相同,拆成 8 个任务没有问题。
但如果粒子处理存在差异:
任务 0:很快完成
任务 1:碰撞计算很多
任务 2:几乎没有工作
就会出现:
部分核心已经空闲
某一个核心还在处理重任务
拆成更多小任务后,空闲线程可以继续领取任务,负载更均衡。
7950X 是:
16 个物理核心
32 个逻辑处理器
平均就是:
每个物理核心 2 个逻辑处理器
因此,一个物理核心最多可以同时维护并执行:
2 个硬件线程
例如:
物理核心 0
├── 逻辑处理器 0:线程 A
└── 逻辑处理器 1:线程 B
但软件线程可以远远超过 32 个:
程序有 100 个线程
CPU 有 32 个逻辑处理器
这时系统会这样调度:
所以要准确区分:
软件线程数量:可以很多
逻辑处理器数量:可以同时运行的硬件线程数量
物理核心数量:真正的核心计算资源数量
软件线程和硬件线程的区别是什么?
它们之间的关系
假设你的程序创建 100 个软件线程:
100 个软件线程
↓ 操作系统调度
32 个逻辑处理器
↓
16 个物理核心
由于只有 32 个硬件线程,系统最多让大约 32 个软件线程处于硬件执行状态。
剩下的软件线程等待调度:
软件线程 0 → 硬件线程 0
软件线程 1 → 硬件线程 1
软件线程 2 → 硬件线程 2
...
软件线程 31 → 硬件线程 31
软件线程 32 → 等待
软件线程 33 → 等待
四、GPU Cache 和内存访问
GPU 也有 Cache。
如果线程访问的数据是连续的,GPU 可以合并访问:
线程 0 读取 A[0]
线程 1 读取 A[1]
线程 2 读取 A[2]
线程 3 读取 A[3]
这是高效访问。
如果访问方式是:
线程 0 读取 A[1000]
线程 1 读取 A[23]
线程 2 读取 A[80000]
线程 3 读取 A[7]
GPU 需要进行大量随机显存访问,效率会明显下降。
所以 GPU 数据结构通常强调:
连续
对齐
规则
批量
顺序访问
CPU 和 GPU 都希望访问连续数据,根本原因都和"局部性"和"缓存行"有关。
但 GPU 还有一个额外机制,叫:
内存访问合并
Memory Coalescing
所以两者原理相似,但具体优化重点不同。
CPU 为什么喜欢连续内存?
CPU Cache 通常不是只加载一个字节,而是加载一整条 Cache Line。
假设 Cache Line 是 64 字节:
A[0]、A[1]、A[2]、A[3]
如果它们都位于同一条 Cache Line,CPU 读取 A0 时,可能顺便把附近的数据一起加载进 Cache。
之后访问:
A[1];
A[2];
A[3];
就可能直接从 Cache 读取,不需要访问 RAM。
这叫空间局部性:
访问了 A[0]
很可能马上还会访问 A[1]、A[2]、A[3]
CPU 还通常有硬件预取器,会根据访问规律提前加载数据:
发现程序连续访问 A[0]、A[1]、A[2]
推测后面还会访问 A[3]、A[4]
提前加载后续 Cache Line
GPU 为什么喜欢连续内存?
GPU 通常以一个线程组中的多个线程同时执行。
例如一个 Wave 中:
线程 0 读取 A[0]
线程 1 读取 A[1]
线程 2 读取 A[2]
线程 3 读取 A[3]
GPU 会发现这些线程访问的地址相邻,于是可以把多个访问合并成较少的内存事务:
多个线程请求
↓
合并成一个或少量内存访问事务
↓
返回给多个线程
这就是 Memory Coalescing。
所以 GPU 连续访问的优势不只是:
更容易命中 Cache
还包括:
多个线程的请求可以合并
减少显存事务数量
提高显存带宽利用率
GPU 随机访问为什么慢?
例如:
线程 0 读取 A[1000]
线程 1 读取 A[23]
线程 2 读取 A[80000]
线程 3 读取 A[7]
这些地址距离很远,GPU 很难合并请求:
线程 0 → 一次内存事务
线程 1 → 一次内存事务
线程 2 → 一次内存事务
线程 3 → 一次内存事务
如果一个 Wave 有 32 或 64 个线程,情况会更明显。
Memory Coalescing 可以理解成:
GPU 把同一个 Wave/Warp 中多个线程发出的内存请求,尽量合并成更少的显存访问事务。
它不是把数据合成一个值,而是把"多次取数据的请求"合并处理。
先看连续访问
假设一个 Wave 有 4 个线程,每个线程读取一个 float,每个 float 4 字节:
线程 0 → A[0] → 地址 0
线程 1 → A[1] → 地址 4
线程 2 → A[2] → 地址 8
线程 3 → A[3] → 地址 12
GPU 看到:
地址 0、4、8、12
这些地址连续,可以合并成一次内存事务:
一次读取:
地址 0 - 15
然后分发:
线程 0 得到 A[0]
线程 1 得到 A[1]
线程 2 得到 A[2]
线程 3 得到 A[3]
过程类似:
4 个线程请求
↓
一次连续内存读取
↓
分别返回给 4 个线程
五、分支发散
GPU 是以线程组为单位执行的。
如果同一个线程组中:
if (Condition)
{
DoA();
}
else
{
DoB();
}
不同线程进入不同分支,GPU 往往需要两边都执行,只是让部分线程暂时失效。
这叫 Branch Divergence,分支发散。
六、Draw Call 和 CPU 提交开销
Draw Call 的成本主要不一定在 GPU 绘制本身,而可能在:
CPU 准备参数
RHI 调用
驱动处理
PSO 切换
材质切换
状态切换
命令生成
所以:
1000 次小 Draw Call
可能比:
1 次包含 1000 个实例的 Draw Call
更慢。
常见批处理方式:
- Instanced Static Mesh
- Hierarchical Instanced Static Mesh
- Dynamic Mesh Batch
- Niagara GPU 粒子
- Structured Buffer
- Indirect Draw
- MultiDraw
- 合并顶点和索引
但是要注意:批处理主要减少 CPU 和提交开销,不一定减少 GPU 像素计算量。
七、同步是性能杀手
CPU 和 GPU 之间、不同 GPU Pass 之间如果频繁等待,会导致硬件空闲。
典型问题:
CPU 提交命令
CPU 等 GPU 完成
CPU 读取 GPU 数据
GPU 等 CPU 新数据
这样会形成 Pipeline Bubble,也就是流水线空洞。
GPU 异步计算只有在计算任务能够和图形任务重叠时才有价值:
Graphics:渲染场景
Async Compute:计算粒子
Graphics:继续后续渲染
如果流程是:
Compute
↓ 等待完成
Graphics 使用结果
那么 Compute 和 Graphics 实际上仍然是串行的,异步计算收益可能很小。
八、局部更新为什么有效
假设一个 2048×2048 的纹理:
总像素数 = 4,194,304
如果每帧只影响 10 个 100×100 区域:
10 × 10,000 = 100,000 像素
理论处理量只相当于整张纹理的约 2.4%。
这类优化叫:
减少工作规模
它通常比单纯优化 Shader 指令更有效。
常见方法:
- Dirty Rect
- Scissor
- 小 Quad
- Tile 更新
- 局部 Dispatch
- 粗粒度空间分区
不过大量小区域也可能增加 Draw Call 或状态切换,所以最终需要在:
覆盖像素数量
Draw Call 数量
状态切换数量
之间做平衡。
你应该重点掌握的硬件优化思想
最核心的是这五句话:
让数据连续,减少随机访问。
让任务批量化,减少提交次数。
让线程独立,减少同步等待。
让 Shader 规则,减少分支发散。
让每帧处理的数据量尽可能小。
再进一步,可以把优化分成三个层次:
第一层:减少工作量
第二层:提高并行度
第三层:提高内存访问效率
通常优先级也是这个顺序。