UE5PSO PreCache

Posted by LioMiss on April 24, 2025 阅读()

PSO Precaching

手动PSO Cache需要打包运行游戏才能将PSO信息收集到Bundled Cache中,而PSO Precaching会自动收集PSo并对渲染中可能使用到的所有PSO进行异步编译。

配置PSO Precaching

可以通过控制台参数控制PSO Precaching: | Console Variable | Description | Default State| | :—– | :————– | :———— | |r.PSOPrecaching | 开启或关闭PSOPrecaching. 依赖于RHI flag GRHISupportsPSOPrecaching. | Enabled| |r.PSOPrecache.Components | Precache PSOs used by components. |Enabled| |r.PSOPrecache.Resources | 所有资源的PSO,包括UStaticMesh, USkinnedMesh等, 这些PSO可能不具备正确的渲染状态,因为某些状态只能从component生成。不过,它们应该确保为驱动程序提供正确的着色器以供编译。|Disabled| |r.PSOPrecache.ProxyCreationWhenPSOReady | Wait for component proxy creation until all required PSOs are compiled. If they are still compiling when creating the proxy, those PSOs are marked as high-priority. |Enabled| |r.PSOPrecache.ProxyCreationDelayStrategy | When PSOs are still compiling during proxy creation of the component, this adds an option to replace the material with the default material. This relies on r.PSOPrecache.ProxyCreationWhenPSOReady. See Proxy Creation Delay Strategy below for more information. |0 (see below)| |r.PSOPrecaching.WaitForHighPriorityRequestsOnly | Only wait for high-priority PSOs during loading. All non-essential PSOs will still compile during gameplay. A PSO is marked as high-priority when it is needed by a proxy and is not done compiling yet. |Disabled| |r.PSOPrecache.GlobalShaders | 启动引擎时,是否预缓存全局的compute和graphics PSO |Enabled|

全局Shader PSO预缓存

确保开启了全局Shader PSOs的预缓存,这回提高第一次使用时的命中率,这些PSOs会在引擎启动时进行缓存,可以通过控制台参数r.PSOPrecaching.GlobalShaders来控制开启,所有可用于游戏的全局Compute shaders排列都会进行预缓存。

1
static EShaderPermutationPrecacheRequest ShouldPrecachePermutation(const FShaderPermutationParameters& Parameters)

这个函数用来检查给定的排列是否可在运行时使用,通过检查控制台变量设置,以排除某些组合,默认情况下会使用这个函数进行排除,因此预缓存的computer shader的排列应该时所有排列的子集。
大多数全局graphic PSOs是在加载后的几帧中创建的,在这几帧中可能会出现问题,使用一个非常基础的PSO bundled缓存去收集这些全局graphics PSOs会有一定帮助,但有些PSO排列是在运行时创建和编译的,因此这些也应该进行预处理。对局全局图形PSo,需要特定的PSO收集器来收集所有正确的渲染状态,这是编译图形PSO所必需的。
PSO预缓存目前可以用于处理以下类型的全局shader:

  • Slate
  • Deferred Lights
  • Cascade Particle Simulation
  • Volumetric fog

tips:在启动时编译所有全局PSO需要一些时间,通常在进入主界面之前完成,虽然这不会在启动时阻塞引擎的Tick,但最好还是在Loading时作为编译等待阶段的一部分,否则会造成不好的体验。

Component PSO Precaching

Primitive Components在加载后(PostLoad)会立即预缓存所需的所有的PSO,该预缓存会收集编译PSO所需的所有管道状态信息,包括:

  • Materials
  • Vertex factories
  • Vertex element information
  • Specific precache parameters

UE使用这些信息在所有可能被渲染的mesh pass处理器进行迭代,每个mesh pass处理器都会添加渲染过程中可能需要的PSO初始化器。后台任务会检查共享的PSO缓存,以确保所需的数据尚未准备好,并异步编译这些请求。
单个组件可能需要大量的PSO才能在所有不同的pass中正确渲染,例如base、custom depth、depth、distortion、shadow、virtual shadow map、velocity等等,所有这些PSO都需要在组件准备就绪之前准备就绪,以避免出现图形错误,因为它可能在一个pass中渲染,另一个不渲染。
当UE为Primitive组件创建PrimitiveProxy且其所需的PSO仍在编译时,有以下几个选项可用:

  • 延迟代理创建,直到PSo编译完成(默认)
  • 将材质替换为引擎默认材质
  • 继续执行,有一定概率出现问题,这次绘制会被PSO编译阻塞

代理创建延迟策略

r.PSOPrecache.ProxyCreationDelayStrategy依赖于r.PSOPrecache.ProxyCreationWhenPSOReady,如果ProxyCreationWhenPSOReady设为1,ProxyCreationDelayStrategy则会根据以下配置执行: | Value | Behavior | | :—– | :————– | |0 | 跳过这次绘制,直到pso就绪| |1 | 使用引擎默认材质,直到PSO就绪|

Loading Screen

tips:强烈建议在初始Loading界面处理PSO预缓存。
在为游戏设置初始Loading界面时,应该等待所有PSO预缓存请求完成,否则可能会看到一些明显的视觉弹出(visual popping),甚至一些不支持延迟代理创建的组件的运行故障,例如landscape,terrain,在这种情况下,不建议将这些Mesh替换为默认材质或不渲染它们。
FShaderPipelineCache::NumPrecompilesRemaining()可用于检查Bundled cache和PSO预缓存的未完成的PSO编译次数,可以修改Loading界面的逻辑以检查这个数目,保持Loading界面直到所有PSO编译完成。大多数情况下,中端CPU在缓存为空的情况下,初始PSO编译时间基本少于一分钟。

管理系统资源

PSO预缓存依赖于使用后台线程进行异步编译,这回对系统性能产生影响,并占用系统内存,本节会介绍如何通过设置选项来调整和优化这些资源占用,以适应不同的项目。

内存

为了节省运行时系统内存,UE会删除编译后用于预缓存的PSO,这是因为,如果程序预缓存的PSO数量非常大,除非将其清理,否则会显著增加内存占用。
PSO预缓存依赖于底层驱动程序缓存的存在,即使预缓存后删除了PSO,它们也会保留在驱动程序的缓存中。如果运行时需要PSO,显卡驱动程序会从其压缩的缓存中加载它。然而,这可能也是资源密集型的,从这些缓存中首次检索可能需要几毫秒,可以用D3D12禁用D3D12中预缓存的PSO删除:

1
D3D12.PSOPrecache.KeepLowLevel

从驱动缓存创建PSOs在某些硬件上可能会很慢,对于Nvidia,有一个选项可以保留一定数量的预缓存PSO在内存中:

1
r.PSOPrecache.KeepInMemoryUntilUsed

这可以避免驱动程序缓存性能收到影响,可以使用r.PSOPrecache.KeepInMemoryGraphicsMaxNum 和 r.PSOPrecache.KeepInMemoryComputeMaxNum分别调整Compute和Graphics保留在内存中的PSO的数量,如果使用此选项,建议用不同的设置测试最终的内存开销,以在PSO创建性能和内存开销之间找到最佳平衡点。

性能

默认情况下,UE使用PSO预处理线程池来异步编译PSO,如果设置了r.pso.PrecompileThreadPoolSize 或 r.pso.PrecompileThreadPoolPercentOfHardwareThreads,则将使用线程池,否则,PSO编译将回到使用常规后台任务,这些任务将于引擎的其它工作负载一起调度。 |Console Variable |Description| Default State| | :—– | :————– | :————– | |r.pso.PrecompileThreadPoolSize |设置线程池中线程的确切数量 |0| |r.pso.PrecompileThreadPoolPercentOfHardwareThreads |将线程池大小设置为可用硬件线程的百分比,并使用该大小创建线程池。默认大小为75,表示75%的硬件线程。 |75| |r.pso.PrecompileThreadPoolSizeMin |PSO线程池中使用的线程的最小数量 |2| |r.pso.PrecompileThreadPoolSizeMax |PSO线程池中使用的线程的最大数量。默认为INT_Max,表示没有最大线程数。 |INT_Max|

补充说明

由于线程池中最大线程的数量是无限的,因此在编译处理器较多但内存不足的系统上编译PSO时,可能会出现内存不足的情况,每个编译线程最多可使用2Gb的内存,因此限制线程的数量在这种情况下会很有帮助。
在游戏中,75%的硬件线程可能会偏多,与常规前台线程的争用可能会导致小的掉帧,在loading时可以增加这个值,在游戏中减少它可能会是比较好的策略。但是这样做会延迟PSO的编译,并增加延迟代理创建,它不应该引起运行时的故障。
可以用命令行参数:-clearPSODriverCache来强制清除驱动缓存,建议测试首次启动体验时使用这个参数。
在有很多核的CPU上测试时,建议将核心数量限制在8,或者设为其它的典型的消费级CPU的核心数量,使用命令行参数-corelimit=n,其中n是核心数量,以及-processaffinity=n,进一步确保系统只在n个核心上调度。这可以确保用户的体验。
tips: 在评估游戏流畅性的所有测试运行中,始终使用 - clearPSODriverCache 开关。如果没有它,问题可能会被图形驱动程序构建的 PSO 缓存所掩盖,这些缓存是前几次运行留下的。

验证和监测

有几个选项可以验证和检测PSO预缓存系统的性能。
可以使用以下参数启用r.PSOPrecache.Validation的功能:
|Console Variable |Description| | :—– | :————– | |0 |禁用 | |1 |仅使用high-level数字进行轻量级追踪,对性能影响较小,适用于shipping版本 | |2 |详细追踪,并记录PSO Cache未命中的日志 | 当PSO Precache Validation开启时,可以使用stat PSOPrecache命令查看统计的数据。
stat

Stats分成了三组:
|Group |Description| | :—– | :————– | | Shader-only PSOs |这些统计信息仅跟踪使用的RHI着色器,并忽略PSO中的所有其他状态信息。这有助于查看是否至少所有着色器都已预缓存,以及是否有其他渲染状态缺失或错误。需要r.PSOPrecache.Validation.TrackMinimalPSOs。| | Minimal PSOs |包含着色器和所有渲染状态和顶点元素信息,除了渲染目标信息。渲染目标信息仅在绘制时可用,但最小PSO统计信息可以在MeshDrawCommand构建期间更新和检查。需要r.PSOPrecache.Validation.TrackMinimalPSOs。| | Full PSOs |图形API使用的完整运行时所需的PSO状态。这与最小PSO相同,但带有额外的渲染目标信息。| 对于每个组,都可以查看以下统计信息: |Parameter |Description| | :—– | :————– | |Missed |未预缓存的PSO数量,但应该在绘制或调度时使用。可能的原因:错误的着色器,渲染目标状态,顶点属性,渲染目标信息。| |Untracked |未启用预缓存的PSO数量。可能的原因:验证已禁用,全局材质,不支持的顶点工厂,不支持的网格通道处理器类型。在发布版本中,当某些调试信息不可用时,未跟踪的PSO将显示为未命中。| |Hit |成功预缓存的PSO数量。| |Too late |排队预缓存的PSO数量,但它们没有及时编译,以便在需要时使用。| |Used |在运行时使用的PSO数量(上述所有数字的总和)。| |Precached |已预缓存的PSO数量(但未必被使用)。| Shader流水线缓存还提供了关于PSO运行时编译的故障信息,如果编译时间超过了运行时PSO编译所需要的时间,则将PSO编译标记为故障。默认阈值为20ms,可以用r.PSO.RuntimeCreationHitchThreshold来调整,但应使其尽可能的小。
tips:默认20ms是偏高的,因为首次命中驱动缓存的时间会比较久。

调试PSO预缓存

如果PSO预缓存未命中,可以使用以下选项进行调试:
|Console Variable |Description| | :—– | :————– | |r.PSOPrecache.Debug |启用PSO预缓存调试。|

收集PSO预缓存信息

可以使用Log文件,Visual Studio debugger或者Unreal Insights来获取PSO预缓存的更多信息,来研究为什么某些PSO仍会在运行时造成故障,正确的PSO预缓存状态只有开启PSO validation才能验证和追踪。
当PSO未命中或太慢时,UE会输出如下信息在日志中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PSO PRECACHING MISS:		
Type:				FullPSO		
PSOPrecachingState:		Missed		
Material:				M_AdvancedSkyDome		
VertexFactoryType:		FLocalVertexFactory		
MDCStatsCategory:		StaticMeshComponent		
MeshPassName:			SkyPass		
Shader Hashes:			
VertexShader:		EC68796503F829FDEACC56B913C4CA86C6AD3C16			
PixelShader:		651BF1ABBAEC0B74C8D2A5E917702A00EF29817B  		
Missed Info:			
Found PSO With same state PSO hash & different render target data:	  		
Differences:					
* RenderTargetsEnabled different:						
Precached:	5						
Requested:	1					
* RenderTargetFormat 1 different:						
Precached:	18						
Requested:	0					
* RenderTargetFlags 1 different:						
Precached:	196617						
Requested:	0

用Insights来调试PSO预缓存会比较方便。添加PSOPrecache: Missed 和 PSOPrecache: Too Late计时器到游戏帧状态中,可以查看PSO编译在一定时间内引起的所有故障,在下面的截图中,有几个5~10ms的PSO预缓存错误故障,也有一个117ms的大故障:
Insights
放大后可以看到这是由Translucency pass引起的,如下图所示,除此之外还可以看到更多相关信息:
Insights
也可以在全局PSO validation辅助对象中找到更详细的信息,当validation设置为全程追踪(r.PSOPrecache.Validation=2)时,会根据网格传递处理器和顶点工厂类型进行分组,这可以帮助跟踪某些缺陷的来源。它还可以帮助更清楚地了解所有预缓存的PSO来自哪里,并且可以帮助找到不应该预缓存那么多着色器的异常。
虽然这些per-pass和per-vertex工厂的统计数据不会直接公开,但可以在调试过程中通过浏览手机他们的数据结构来检查它们,它们位于PSOPrecacheValidation.cpp中:

  • FullPSOPrecacheStatsCollector
  • ShadersOnlyPSOPrecacheStatsCollector
  • MinimalPSOPrecacheStatsCollector 例如截图所示: Debug

    扩展PSO预处理功能

    UPrimitiveComponent

    UPrimitiveComponent收集了设置PSO初始化器所需的所有信息:材质实例、顶点工厂(包括可能的顶点元素集),以及可能影响FMeshPassProcessor中使用的最终着色器或渲染状态的参数集。
    这些参数存储在FPSOPrecacheParams中,默认值会在UPrimitiveComponent::SetupPrecachePSOParams中设置。
    PSO预缓存的入口函数是:
    ``` c++ /** Precache all PSOs which can be used by the primitive component */

ENGINE_API virtual void PrecachePSOs();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在大多数情况下,派生组件不需要实现这个函数,可以简单的覆盖Precache参数集合函数:  
``` c++
/**
* Collect all the data required for PSO precaching 
*/

struct FComponentPSOPrecacheParams
{
  EPSOPrecachePriority Priority = EPSOPrecachePriority::Medium;
  UMaterialInterface* MaterialInterface = nullptr;
  FPSOPrecacheVertexFactoryDataList VertexFactoryDataList;
  FPSOPrecacheParams PSOPrecacheParams;
};

typedef TArray<FComponentPSOPrecacheParams, TInlineAllocator<2> > FComponentPSOPrecacheParamsList;

virtual void CollectPSOPrecacheData(const FPSOPrecacheParams& BasePrecachePSOParams, FComponentPSOPrecacheParamsList& OutParams) {}

简单示例在:WaterMeshComponent::CollectPSOPrecacheData,完整实例在:UStaticMeshComponent::CollectPSOPrecacheData。

FMeshPassProcessor

Mesh Pass预处理器必须实现以下功能,以收集给定FPSOPrecacheParams绘制特定的材质时会用到的所有PSO:

1
virtual void CollectPSOInitializers(const FSceneTexturesConfig& SceneTexturesConfig, const FMaterial& Material, const FPSOPrecacheVertexFactoryData& VertexFactoryData, const FPSOPrecacheParams& PreCacheParams, TArray<FPSOPrecacheData>& PSOInitializers) override {}

这里的逻辑与AddMeshBatch大致相同(理想情况下可以部分复用),但AddMeshBatch在MeshDrawCommand构建时调用,而PSO预缓存系统收集信息的流程会更早。
有关简单示例,请参阅:FDistortionMeshProcessor::CollectPSOInitializers,更全面的示例,请参阅:FBasePassMeshProcessor::CollectPSOinitializers。

IPSOCollector

并非所有的材质Shader都需要经过MeshPass预处理器或使用EMeshPass::Type定义,例如:Hair、Nanite或RayTracing动态几何体更新。对于这些更新,可能需要直接从基础接口派生。
IPSOCollector有一个虚函数需要实现:

1
2
// Collect all PSO for given material, vertex factory & params
virtual void CollectPSOInitializers(const FSceneTexturesConfig& SceneTexturesConfig, const FMaterial& Material, const FPSOPrecacheVertexFactoryData& VertexFactoryData, const FPSOPrecacheParams& PreCacheParams, TArray<FPSOPrecacheData>& PSOInitializers) = 0;

PSO Collector还需要通过全局FRegisterPSOCollectorCreateFunction注册,引擎中有一些简单例子可供参考:FTranslucentLightingMaterialPSOCollector, FRayTracingDynamicGeometryPSOCollector, …

GlobalPSOCollector

正如前文提到的,一些全局图形PSO在启动时就已经预备好了,它们可以编译运行时的排列,为此使用GlobalPSOCollector,它是IPSOCollector的简化版本,需要声明一个全局FRegisterGlobalPSOCollectorFunction对象,该对象提供了全局PSO收集器函数:

1
typedef void (*GlobalPSOCollectorFunction)(const FSceneTexturesConfig& SceneTexturesConfig, int32 GlobalPSOCollectorIndex, TArray<FPSOPrecacheData>& PSOInitializers);

查看DeferredLightGlobalPSOCollector 或 RegisterVolumetricFogGlobalPSOCollector可以看到一些简单示例。

Debug PSO预缓存miss

要调试上述PSO预缓存miss的来源,需要在Visual Studio中手动调试。
在最小PSO状态下调试miss很简单,因为这些miss可以在MeshDrawCommand构建期间触发,而不是在绘制时触发,计算完整PSO所需的最终渲染目标信息只在绘制期间可用,这使得调试更加困难。
LogPSOMissInfo函数是在运行时发生miss时设置调试断点的合适位置,调用堆栈和变量监视窗口可以提供有关材质、渲染Pass、顶点工厂和FPPrimitiveSceneProxy的更多信息,还可以使用ComponentForDebuggingOnly成员获取有关UPrimitiveComponent的信息,当在日志中发现miss时,大多数信息也打印在日志中,就是在此函数中收集的。
然而,当LogPSOMissInfo运行时,PSO预缓存通常已经在该组件上完成,如果要找出为什么在预缓存过程中使用了不正确的着色器或渲染状态,需要给该组件或给定通道的材质进行PSO预缓存时添加一个断点。
r.PSOPrecache.BreakOnMaterialName is useful to break during PSO precaching when it finds a material with a given name - this can help to find out why certain render state is different when compared to the runtime state. r.PSOPrecache.BreakOnPassName and r.PSOPrecache.BreakOnShaderHash can also be used to narrow down the problematic PSO. This information can be found in the log, as mentioned above. 翻译:

r.PSOPrecache.UseBackgroundThreadForCollection is useful to disable the background thread tasks for PSO initializer collection to make it easier to track down component information or other state while debugging a PSO precaching miss.

tips:You might also need to check the values of FPSOPrecacheParams, because these could also influence the shader and render state used in the PSO.

参考资料:
PSO Precaching