PSO 预缓存
自动关联目录:PSO 预缓存
官方依据:PSO Precaching(UE 5.8 Documentation)。本页覆盖该页全部 17 个条目。
1. 定位
PSO(Pipeline State Object)预缓存解决的是"首次用到某个绘制状态时,驱动现场编译导致的卡顿"。
一句话定位:手动 PSO 缓存需要你真的玩一遍游戏去收集;PSO 预缓存是自动的——它自动收集并异步编译渲染时可能用到的所有 PSO。
这是首帧/切场景卡顿的正解,和 材质与着色器编译 是同一问题的两半:那篇讲着色器怎么编译,这篇讲驱动层的 PSO 怎么提前准备好。
2. 配置
| CVar | 默认 | 作用 |
|---|---|---|
r.PSOPrecaching | Enabled | 总开关(依赖 RHI 的 GRHISupportsPSOPrecaching) |
r.PSOPrecache.Components | Enabled | 预缓存 组件用到的 PSO |
r.PSOPrecache.Resources | Disabled | 预缓存所有资源(UStaticMesh、USkinnedMesh…)的 PSO。这些 PSO 可能渲染状态不对(某些状态只能从组件推导),但能让驱动拿到正确的着色器去编译 |
r.PSOPrecache.ProxyCreationWhenPSOReady | Enabled | 等 PSO 编译完再创建组件的 proxy;仍在编译时把它们标为高优先级 |
r.PSOPrecache.ProxyCreationDelayStrategy | 0 | 见下 |
r.PSOPrecaching.WaitForHighPriorityRequestsOnly | Disabled | 加载时只等高优先级 PSO,非必需的在游戏过程中继续编译 |
r.PSOPrecache.GlobalShaders | Enabled | 引擎启动时也预缓存全局 compute/graphics PSO |
默认配置(Components 开、Resources 关)就是官方推荐的:按组件收集能拿到正确的渲染状态,按资源收集虽然覆盖更广但状态可能不准。
3. 两类预缓存
全局着色器 PSO
某些全局着色器 PSO 会在首次使用时引入运行时卡顿,所以引擎启动时就编译它们(r.PSOPrecache.GlobalShaders 默认开),所有游戏可能用到的全局 compute 着色器 permutation 都会被预缓存。
C++ 侧用 ShouldPrecachePermutation(const FShaderPermutationParameters&) 判断某个 permutation 运行时是否可能用到(通过检查当前 CVar 设置来排除某些组合)。默认它复用 ShouldCompilePermutation,所以预缓存的 permutation 应该是已编译 permutation 的子集。
| 要点 | 说明 |
|---|---|
| 大部分全局 graphics PSO 在加载后的头几帧创建 | 这几帧可能感觉到卡顿 → 用一个很小的 PSO bundled cache 收集这些,会有帮助 |
| 但部分全局 graphics permutation 也是运行时才创建编译的 | 这些也需要预缓存;graphics PSO 需要专门的 collector 收集完整渲染状态 |
| 目前已实现的全局类型 | Slate、Deferred Lights、Cascade Particle Simulation、Volumetric fog |
启动时编译全部全局 PSO 需要一些时间,通常在主菜单期间完成。官方强调:这不阻塞引擎启动,但应该被纳入初始加载屏的 PSO 编译等待阶段。
组件 PSO
UPrimitiveComponent 在加载后立刻(PostLoad 期间)预缓存渲染所需的全部 PSO,收集的信息包括:材质、vertex factory、顶点元素信息、特定预缓存参数。
引擎用这些信息遍历该组件可能被渲染的所有 mesh pass processor,每个 processor 加入它可能需要的 PSO initializer。后台任务检查共享 PSO 缓存,避免重复,然后异步编译。
单个组件可能需要大量 PSO:base、custom depth、depth、distortion、shadow、virtual shadow map、velocity 等等。重要的是这些 PSO 要在组件就绪前全部准备好——否则会出现"在某个 pass 里被画了、在另一个 pass 里没画"的视觉错误。
4. Proxy 创建延迟策略
创建 Primitive Proxy 时若所需 PSO 仍在编译,有三个选择:
| 选项 | 行为 |
|---|---|
| 延迟 proxy 创建直到编译完成(默认) | 等于跳过这次绘制直到 PSO 就绪 |
| 换成引擎默认材质 | 视觉上是错的材质,但不会出现缺 pass |
| 继续 | 绘制会阻塞在 PSO 编译上,可能产生卡顿 |
r.PSOPrecache.ProxyCreationDelayStrategy(依赖 ProxyCreationWhenPSOReady=1):
| 值 | 行为 |
|---|---|
| 0 | 跳过绘制直到 PSO 就绪 |
| 1 | 回退到引擎默认材质 |
5. 加载屏(官方"强烈建议")
初始加载屏应该等待所有未完成的 PSO 预缓存请求,否则会看到明显的视觉 pop,甚至运行时卡顿——尤其是不支持延迟 proxy 创建的组件(如 landscape 地形,既不适合换成默认材质,也不能不渲染)。
// 检查仍未完成的预缓存编译数(bundled cache + PSO precaching 都算)
FShaderPipelineCache::NumPrecompilesRemaining()
// 保持加载屏直到它归零官方给的参考量级:在中等规格 CPU 上,驱动缓存为空时的初始 PSO 编译时间通常应少于一分钟。
6. 资源开销
内存
编译完成后,UE 会删掉为预缓存编译的 PSO——因为预缓存的 PSO 数量很大时不清理会显著增加内存占用(几百 MB 甚至 GB)。
但预缓存依赖底层压缩驱动缓存的存在:PSO 被删后仍保留在驱动缓存里,运行时需要时由驱动从压缩缓存加载。首次从这些缓存取回可能要几毫秒。
| CVar | 作用 |
|---|---|
D3D12.PSOPrecache.KeepLowLevel | D3D12 下禁止删除预缓存的 PSO |
r.PSOPrecache.KeepInMemoryUntilUsed | NVIDIA 上保留最近 N 个预缓存 PSO 在内存里,避免驱动缓存的性能损失 |
r.PSOPrecache.KeepInMemoryGraphicsMaxNum | 保留的 graphics PSO 数量 |
r.PSOPrecache.KeepInMemoryComputeMaxNum | 保留的 compute PSO 数量 |
某些 IHV 上从驱动缓存创建 PSO 很慢——官方明确说 NVIDIA 有上述选项。用这个选项要实测不同设置下的内存代价,在"PSO 创建性能"与"内存开销"之间找平衡点。
性能(线程池)
| CVar | 默认 | 说明 |
|---|---|---|
r.pso.PrecompileThreadPoolSize | 0 | 精确指定线程数 |
r.pso.PrecompileThreadPoolPercentOfHardwareThreads | 75 | 线程池大小 = 硬件线程的百分比 |
r.pso.PrecompileThreadPoolSizeMin | 2 | 下限 |
r.pso.PrecompileThreadPoolSizeMax | INT_MAX | 上限(默认无上限) |
两条官方提醒:
- 线程数无上限时,在多核但内存不大的机器上编译 PSO 可能耗尽系统内存——每个编译 PSO 的线程最多可用 2 GB。限制上限是合理的。
- 游戏过程中占 75% 硬件线程偏多,会与前台线程争抢造成轻微掉帧。引擎不支持运行时改这些 CVar,但如果你自己改引擎:加载时调高、游戏时调低是有帮助的(会延长 PSO 编译、增加延迟 proxy 创建,但不应引入运行时卡顿)。
测试命令行参数
| 参数 | 作用 |
|---|---|
-clearPSODriverCache | 强制清空驱动缓存。测试首次启动体验时必开——否则卡顿会被上一轮留下的驱动 PSO 缓存掩盖 |
-corelimit=n | 限制核心数(官方建议在高核心数 PC 上限制到 8 或典型消费级核心数) |
-processaffinity=n | 确保 Windows 只把游戏调度到 n 个物理核 |
官方强调:所有评估流畅度的测试都应该一致地使用
-clearPSODriverCache。
7. 验证与追踪
r.PSOPrecache.Validation:
| 值 | 说明 |
|---|---|
| 0 | 关闭 |
| 1 | 轻量追踪,只有高层数字,性能影响极小,可用于发行版 |
| 2 | 详细追踪并记录预缓存 miss |
开启后用 stat PSOPrecache 查看。统计分三组:
| 组 | 内容 | 依赖 |
|---|---|---|
| Shader-only PSOs | 只追踪用到的 RHI 着色器,忽略其它状态。看"至少着色器都预缓存了吗" | r.PSOPrecache.Validation.TrackMinimalPSOs |
| Minimal PSOs | 着色器 + 渲染状态 + 顶点元素信息,但不含 render target 信息(RT 信息只在绘制时可验证) | 同上 |
| Full PSOs | 完整的运行时所需状态(Minimal + RT 信息) | — |
每组追踪的参数:
| 参数 | 含义 |
|---|---|
| Missed | 应该被预缓存、但绘制/dispatch 时没预缓存到的数量。可能原因:着色器/RT 状态/顶点属性不对 |
| Untracked | 未启用预缓存的 PSO(验证关闭、全局材质、不支持的 VF、不支持的 mesh pass processor)。发行版里某些调试信息不可用,Untracked 会表现为 Missed |
| Hit | 运行时用到且成功预缓存的数量 |
| Too late | 已入队预缓存,但需要时还没编译完 |
| Used | 运行时使用的总数(以上各项之和) |
| Precached | 已预缓存的数量(不一定被用到) |
卡顿判定:Shader Pipeline Cache 会统计因 PSO 编译导致的实际运行时卡顿。PSO 编译超过某个毫秒阈值就记为一次卡顿,默认阈值 20 毫秒,可用 r.PSO.RuntimeCreationHitchThreshold 调整(应保持尽可能小)。
默认 20ms 偏高,是因为首次命中驱动缓存本身就可能很慢。
日志里的 miss 长什么样
PSO PRECACHING MISS:
Type: FullPSO
PSOPrecachingState: Missed
Material: M_AdvancedSkyDome
VertexFactoryType: FLocalVertexFactory
MDCStatsCategory: StaticMeshComponent
MeshPassName: SkyPass
Shader Hashes:
VertexShader: EC68796503F829FDEACC56B913C4CA86C6AD3C16
PixelShader: 651BF1ABBAEC0B74C8D2A5E917702A00EF29817B只有在验证开启时,正确的预缓存状态才会出现在日志与 Insights 里。
Unreal Insights
把 PSOPrecache: Missed 和 PSOPrecache: Too Late 两个计时器加到 game frame state series,可以直观看到一段时间内由 PSO 编译引起的所有卡顿。
调试一个 miss
LogPSOMissInfo 是下断点的好位置——调用栈与监视窗口能看到用到的材质、渲染 pass、vertex factory 与 FPrimitiveSceneProxy;也能通过 ComponentForDebuggingOnly 拿到 UPrimitiveComponent。大部分这些信息也会打印到日志。
但要注意:LogPSOMissInfo 执行时,该组件的预缓存通常已经发生过了。要查"为什么预缓存时用了错误的着色器/渲染状态",得在预缓存期间对该组件或材质下断点。
| CVar | 作用 |
|---|---|
r.PSOPrecache.BreakOnMaterialName | 预缓存遇到指定名字的材质时中断 |
r.PSOPrecache.BreakOnPassName | 指定 pass 名中断 |
r.PSOPrecache.BreakOnShaderHash | 指定着色器哈希中断 |
r.PSOPrecache.UseBackgroundThreadForCollection | 关掉后台线程收集,便于调试时追踪组件信息 |
另外要检查 FPSOPrecacheParams 的值——它们也会影响最终用到的着色器与渲染状态。
更详细的 per-pass / per-VF 统计在 PSOPrecacheValidation.cpp 的 FullPSOPrecacheStatsCollector / ShadersOnlyPSOPrecacheStatsCollector / MinimalPSOPrecacheStatsCollector 里(r.PSOPrecache.Validation=2 时按 mesh pass processor 与 VF 类型分组)。
8. 引擎侧扩展(写自定义渲染代码时必须知道)
| 需要做什么 | 怎么做 |
|---|---|
| 新组件类型 | 多数情况不用实现 PrecachePSOs(),只需覆写参数收集函数;参考 UStaticMeshComponent::CollectPSOPrecacheData(完整)与 WaterMeshComponent::CollectPSOPrecacheData(简单) |
| 新 Vertex Factory | 用 IMPLEMENT_VERTEX_FACTORY_TYPE 带上 EVertexFactoryFlags::SupportsPSOPrecaching,并实现 GetPSOPrecacheVertexFetchElements |
| 新 Mesh Pass Processor | 实现 CollectPSOInitializers(...);逻辑与 AddMeshBatch 大体相同,但收集时机早得多(组件 PostLoad)。参考 FDistortionMeshProcessor(简单)、FBasePassMeshProcessor(完整) |
| 不走 mesh pass processor 的材质着色器(Hair、Nanite、光追动态几何更新) | 直接从 IPSOCollector 派生,并用全局 FRegisterPSOCollectorCreateFunction 注册。参考 FTranslucentLightingMaterialPSOCollector、FRayTracingDynamicGeometryPSOCollector |
| 全局 graphics PSO | 用简化的 GlobalPSOCollector,通过 FRegisterGlobalPSOCollectorFunction 注册。参考 DeferredLightGlobalPSOCollector、RegisterVolumetricFogGlobalPSOCollector |
顶点元素集的坑:如果顶点元素列表依赖于网格的顶点缓冲数据,正确的一组必须在 UPrimitiveComponent::CollectPSOPrecacheData 里通过 FPSOPrecacheVertexFactoryData 提供(参考 UStaticMeshComponent::CollectPSOPrecacheData 与 FLocalVertexFactory::GetVertexElements)。
9. 代价与权衡
| 权衡 | 说明 | 什么时候不该用 |
|---|---|---|
| 自动 vs 手动 | 自动收集免去"玩一遍收集",但可能预缓存用不到的 PSO | 有稳定收集流程的项目可仍用 bundled cache |
| 内存 | 不清理会占几百 MB~GB | 内存紧的平台要调 KeepInMemory 系列 |
| 线程占用 | 默认 75% 硬件线程 | 游戏过程中可考虑调低(需改引擎) |
| 延迟 proxy 创建 | 跳过绘制 or 默认材质,都是视觉妥协 | 关键物体应确保进加载屏等待 |
| 驱动缓存依赖 | 删了 PSO 后靠驱动缓存,首次取回几毫秒 | 某些 IHV 上慢,需 KeepInMemory |
10. 踩坑与排查
| 坑 | 现象 | 怎么验证 |
|---|---|---|
| 加载屏没等 PSO | 进游戏后 pop 与卡顿 | NumPrecompilesRemaining() 是否纳入加载屏逻辑 |
| 地形/lanscape 卡顿 | 不支持延迟 proxy 创建的组件 | 加载屏必须等完 |
| 测试没清驱动缓存 | 卡顿测不出来 | 加 -clearPSODriverCache |
| 高核机器上测不出问题 | 用户机器更慢 | 加 -corelimit=8 |
| 多核少内存机器编译崩 | 内存耗尽 | 每个编译线程最多 2GB,限制 r.pso.PrecompileThreadPoolSizeMax |
| Missed 很多 | 预缓存没覆盖 | r.PSOPrecache.Validation=2 + 日志看是哪个 pass/VF/材质 |
| 发行版里 Missed 变多 | 调试信息不可用导致 Untracked 归入 Missed | 属正常现象,需在开发版定位 |
| 自定义 VF/pass 没预缓存 | 卡顿 | 是否实现 CollectPSOInitializers / 注册 collector |
| 顶点元素依赖顶点缓冲 | 预缓存的 PSO 与实际不符 | 必须在 CollectPSOPrecacheData 里提供正确元素集 |
| 阈值判断失真 | 卡顿统计不准 | r.PSO.RuntimeCreationHitchThreshold,默认 20ms 偏高 |
11. 排查顺序
1. 复现:-clearPSODriverCache + -corelimit=8
2. 看有没有卡顿:Unreal Insights 加 PSOPrecache: Missed / Too Late
3. 开 r.PSOPrecache.Validation=2 → stat PSOPrecache → 看 Missed / Too late
4. 日志定位到具体材质 + VF + Pass
5. 自定义渲染代码?检查 CollectPSOInitializers 与 collector 注册
6. 加载屏是否等待?NumPrecompilesRemaining() 归零才放行
7. 内存/线程:KeepInMemory 系列与线程池 CVar