UE5 硬件性能优化

硬件性能优化本质上是在解决四类问题:

  1. CPU 算得太慢
  2. GPU 执行得太慢
  3. 内存访问效率太低
  4. 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;

当你只更新位置时,只读取 PositionsVelocities,无关数据不会进入 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 规则,减少分支发散。
让每帧处理的数据量尽可能小。

再进一步,可以把优化分成三个层次:

复制代码
第一层:减少工作量
第二层:提高并行度
第三层:提高内存访问效率

通常优先级也是这个顺序。

相关推荐
张宇Joaquin18 小时前
高性能模板库Eigen架构分析及性能优化实践
性能优化·架构
AI服务老曹20 小时前
视觉算法模型管理性能优化指南:多版本平滑升级、灰度与快速回滚实战
算法·性能优化
西安景驰电子21 小时前
《PTP精确时间协议系列》第二篇:工程部署、调试与性能优化
运维·服务器·网络·数据库·windows·性能优化
执子念的飞鱼1 天前
70MB Excel 浏览器预览失败:定位 XLSX 重复解压与内存峰值
javascript·性能优化
蓝天居士2 天前
Linux性能优化工具系列详解(3)
性能优化
HAYDENR2 天前
数据库如何做性能优化?数据库性能调优有哪些常见注意事项?
数据库·性能优化
DsirNg2 天前
React Server Components 在真实项目中的边界:哪些组件该放在服务端
性能优化·react·next.js·app router·前端架构·rsc·react server components
caimouse2 天前
ReactOS 图形系统分析(31):内核 GDI — ntgdi
性能优化·reactos