PSO 预缓存

自动关联目录:PSO 预缓存

官方依据:PSO Precaching(UE 5.8 Documentation)。本页覆盖该页全部 17 个条目。

1. 定位

PSO(Pipeline State Object)预缓存解决的是"首次用到某个绘制状态时,驱动现场编译导致的卡顿"。

一句话定位:手动 PSO 缓存需要你真的玩一遍游戏去收集;PSO 预缓存是自动的——它自动收集并异步编译渲染时可能用到的所有 PSO。

这是首帧/切场景卡顿的正解,和 材质与着色器编译 是同一问题的两半:那篇讲着色器怎么编译,这篇讲驱动层的 PSO 怎么提前准备好。

2. 配置

CVar默认作用
r.PSOPrecachingEnabled总开关(依赖 RHI 的 GRHISupportsPSOPrecaching)
r.PSOPrecache.ComponentsEnabled预缓存 组件用到的 PSO
r.PSOPrecache.ResourcesDisabled预缓存所有资源(UStaticMesh、USkinnedMesh…)的 PSO。这些 PSO 可能渲染状态不对(某些状态只能从组件推导),但能让驱动拿到正确的着色器去编译
r.PSOPrecache.ProxyCreationWhenPSOReadyEnabled等 PSO 编译完再创建组件的 proxy;仍在编译时把它们标为高优先级
r.PSOPrecache.ProxyCreationDelayStrategy0见下
r.PSOPrecaching.WaitForHighPriorityRequestsOnlyDisabled加载时只等高优先级 PSO,非必需的在游戏过程中继续编译
r.PSOPrecache.GlobalShadersEnabled引擎启动时也预缓存全局 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.KeepLowLevelD3D12 下禁止删除预缓存的 PSO
r.PSOPrecache.KeepInMemoryUntilUsedNVIDIA 上保留最近 N 个预缓存 PSO 在内存里,避免驱动缓存的性能损失
r.PSOPrecache.KeepInMemoryGraphicsMaxNum保留的 graphics PSO 数量
r.PSOPrecache.KeepInMemoryComputeMaxNum保留的 compute PSO 数量

某些 IHV 上从驱动缓存创建 PSO 很慢——官方明确说 NVIDIA 有上述选项。用这个选项要实测不同设置下的内存代价,在"PSO 创建性能"与"内存开销"之间找平衡点。

性能(线程池)

CVar默认说明
r.pso.PrecompileThreadPoolSize0精确指定线程数
r.pso.PrecompileThreadPoolPercentOfHardwareThreads75线程池大小 = 硬件线程的百分比
r.pso.PrecompileThreadPoolSizeMin2下限
r.pso.PrecompileThreadPoolSizeMaxINT_MAX上限(默认无上限)

两条官方提醒:

  1. 线程数无上限时,在多核但内存不大的机器上编译 PSO 可能耗尽系统内存——每个编译 PSO 的线程最多可用 2 GB。限制上限是合理的。
  2. 游戏过程中占 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

参考