Vulkan着色器预编译实战:彻底消除运行时PSO卡顿
游戏启动后第一次遇到新特效就掉帧跑图跑着跑着突然卡半秒这种体验在PC端图形程序里太常见了。很多人第一反应是显卡不行、驱动太老但真正的原因往往藏在着色器编译这条链路上——管线状态对象在运行时才被创建驱动在后台把着色器源码翻译成机器码这个翻译过程是同步阻塞的帧时间瞬间飙高。SCSKiller这个思路就是冲着这个痛点去的把能提前做的编译工作全部挪到加载阶段让运行时只做查表和绑定。这篇内容适合正在做Vulkan渲染、被首帧卡顿折磨过的图形程序也适合想理解管线状态管理底层逻辑的开发者。我会把预编译的完整链路、缓存策略、实测中踩到的坑和绕行方案都摊开讲尽量让你看完能直接在自己的工程里落地。1. 先搞清楚卡顿到底发生在哪一帧动手改代码之前得先确认卡顿的根因确实在着色器编译上而不是纹理上传、内存分配或者别的什么。我见过不少团队一上来就上预编译方案结果卡顿依旧最后发现是资源加载策略的问题。所以这一节先把诊断这件事做扎实。1.1 管线状态对象的创建时机决定了卡顿位置Vulkan里有个绕不开的概念叫管线状态对象Pipeline State Object后面简称PSO。它把着色器阶段、顶点输入布局、光栅化状态、深度模板状态、混合状态等一大堆配置打包成一个不可变对象。关键点在于PSO的创建是昂贵的。驱动需要校验所有状态组合的合法性把SPIR-V中间码编译成目标GPU的机器码还要做寄存器分配和指令调度。这套流程在桌面显卡上动辄几毫秒到几十毫秒在移动端更夸张。问题就出在创建时机上。如果你在渲染循环里第一次用到某个材质时才创建对应的PSO那一帧的耗时就会突然拉长。玩家感知到的就是走到某个场景突然顿一下。更隐蔽的情况是某些驱动会把编译工作延迟到第一次绘制调用时才做你甚至看不到创建PSO的耗时但帧时间照样爆掉。所以诊断的第一步是给PSO创建打点。在vkCreateGraphicsPipelines前后各取一次时间戳把耗时和对应的着色器组合记录下来。跑一遍完整的游戏流程把所有超过阈值的创建点列出来。我一般用1毫秒作为警戒线超过这个值的都值得关注。auto t0 std::chrono::high_resolution_clock::now(); vkCreateGraphicsPipelines(device, cache, 1, info, nullptr, pipeline); auto t1 std::chrono::high_resolution_clock::now(); auto ms std::chrono::durationdouble, std::milli(t1 - t0).count(); if (ms 1.0) { // 记录着色器哈希、状态组合、耗时 }这段代码看着简单但有个细节要注意时间戳的精度。某些平台上high_resolution_clock的分辨率不够测出来的值抖动很大。更稳的做法是用vkGetQueryPoolResults配合时间戳查询或者直接用平台的高精度计时接口。测出来的数据要按着色器组合聚合因为同一个组合可能在不同场景被创建多次你要看的是哪些组合贵而不是哪一次创建贵。1.2 用帧时间曲线反推编译热点光看PSO创建耗时还不够因为有些驱动的编译是异步的创建调用很快返回但实际编译在后台线程跑等到绘制时才同步等待。这种情况下创建耗时看起来很正常但帧时间曲线会出现规律性的尖峰。我的做法是同时采集两条曲线一条是每帧的总耗时一条是PSO创建和首次绑定的时间点。把两条曲线叠在一起看如果帧时间尖峰和某个PSO的首次使用时间吻合基本就能锁定嫌疑对象。采集工具可以用引擎自带的profiler也可以自己写个简单的环形缓冲区记录最近几百帧的数据。这里有个经验尖峰往往不是孤立出现的。一个复杂场景可能同时触发十几个PSO的首次创建帧时间会连续爆好几帧。这时候不要被连续卡顿迷惑以为是别的问题实际上就是一批着色器集中编译。把这段时间内所有新建的PSO列出来按着色器哈希分组你会发现热点非常集中通常就那么几组变体。还有一种情况容易被误判着色器变体爆炸。同一个着色器因为宏定义不同编译出几十个变体每个变体对应一个PSO。你以为只有几个材质实际上背后有上百个管线。诊断时要把变体数量也统计进去不然预编译的工作量会严重低估。1.3 区分冷启动卡顿和运行中卡顿这两类卡顿的处理策略完全不同必须先分清楚。冷启动卡顿发生在进入关卡或加载场景时这时候玩家有心理预期稍微慢一点可以接受把编译工作塞进加载流程是合理的。运行中卡顿发生在游戏过程中玩家正在操作任何停顿都很刺眼必须彻底消除。判断方法很简单记录卡顿发生时的时间戳和游戏状态。如果集中在加载阶段归为冷启动如果散落在游戏过程中归为运行中。SCSKiller的核心目标就是消灭第二类第一类可以通过加载界面的进度条来消化。但这里有个陷阱有些引擎的加载完成标志设得太早实际资源还没完全就绪玩家进入游戏后第一波操作就触发编译。这种情况表面上是运行中卡顿根因还是加载流程没做干净。所以诊断时要确认加载阶段的PSO创建是否真的覆盖了后续所有会用到的组合没覆盖的部分就是漏网之鱼。2. 预编译的核心思路把编译从运行时挪到加载时诊断清楚之后方案其实很直接既然运行时创建PSO会卡那就在加载阶段把所有可能用到的PSO全部创建好运行时只做查找和绑定。听起来简单但所有可能用到的这个范围怎么界定缓存怎么存怎么读跨版本怎么处理全是坑。2.1 枚举所有管线组合而不是靠猜预编译的前提是知道要编译什么。靠人工列举材质和特效组合基本不现实项目一大就会漏。我的做法是从渲染管线的数据驱动层入手把PSO的配置抽象成一组状态描述符然后在加载阶段遍历所有可能的组合。具体来说把影响PSO的关键维度拆出来着色器程序、顶点布局、渲染目标格式、深度模板配置、混合模式、图元类型。每个维度枚举出实际用到的取值做笛卡尔积。这个组合数可能很大但实际有效的组合远少于理论值因为很多维度之间存在约束关系。比如某个着色器只配合特定的顶点布局使用这种约束要在枚举时排除掉。struct PipelineKey { ShaderID vs, fs; VertexLayoutID layout; RenderTargetFormat rt; DepthStencilConfig ds; BlendMode blend; PrimitiveType prim; // 哈希与相等比较 };枚举的来源最好是项目里的材质配置文件和特效描述文件而不是硬编码。这样美术新增一个特效配置文件里写上它用的着色器和状态预编译流程自动就能覆盖到。硬编码的列表维护成本太高迟早会漏。枚举完之后要做一次去重和裁剪。去重是按PipelineKey的哈希做裁剪是去掉那些永远不会被绘制的组合。裁剪的依据可以来自实际运行数据的统计跑几遍完整流程记录实际用到的PipelineKey集合和枚举出来的集合做差集差集里的就是冗余组合可以从预编译列表里剔除。这个统计要跑足够多的场景不然会误删。2.2 加载阶段的编译流水线设计枚举出列表之后加载阶段要高效地把它们编译出来。这里的关键是并行和分批。串行编译几百个PSO加载时间会很难看。Vulkan的PSO创建本身是线程安全的只要不共享同一个VkPipelineCache的写操作可以用线程池并行编译。但并行有个度。编译线程太多会抢CPU资源反而拖慢整体加载。我的经验是线程数控制在物理核心数的一半左右留出余量给资源加载和主线程。另外要注意VkPipelineCache的并发访问多个线程同时往同一个cache里写会有竞争标准做法是每个线程用自己的临时cache最后合并或者用外部同步保护。// 每个工作线程持有独立的临时cache VkPipelineCacheCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO}; vkCreatePipelineCache(device, ci, nullptr, threadCache); // 编译完成后合并到主cache vkMergePipelineCaches(device, mainCache, 1, threadCache);分批的意义在于控制内存峰值和给进度条留出更新机会。一次性提交几百个编译任务驱动内部的内存占用会飙升某些驱动甚至会因为资源不足而失败。分成每批二三十个批间让出主线程更新进度体验会平滑很多。还有一个容易忽略的点编译失败的处理。某些组合可能因为驱动限制或者状态非法而创建失败这时候不能直接崩溃要记录失败原因并跳过。加载阶段允许少量失败运行时遇到缺失的PSO再走兜底路径同步创建虽然会卡一下但至少不会崩。2.3 磁盘缓存让第二次启动几乎零成本预编译解决了首次启动的卡顿但如果每次启动都重新编译一遍加载时间还是太长。这时候就要用VkPipelineCache把编译结果持久化到磁盘。Vulkan的管线缓存机制允许你把驱动编译好的二进制存下来下次启动直接加载跳过编译步骤。流程是这样的启动时先尝试从磁盘读取缓存文件用读到的数据初始化VkPipelineCache预编译过程中新编译的PSO会自动写入这个cache加载完成后把cache序列化回磁盘。第二次启动时大部分PSO的创建会直接命中缓存耗时从毫秒级降到微秒级。// 读取缓存 std::vectorchar data readFile(cachePath); VkPipelineCacheCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO}; ci.initialDataSize data.size(); ci.pInitialData data.data(); vkCreatePipelineCache(device, ci, nullptr, cache); // 加载完成后写回 size_t size 0; vkGetPipelineCacheData(device, cache, size, nullptr); std::vectorchar out(size); vkGetPipelineCacheData(device, cache, size, out.data()); writeFile(cachePath, out);缓存文件的有效性要校验。驱动版本、GPU型号、着色器代码任何一项变了缓存就可能失效。Vulkan本身会在加载时做校验失效的条目会被忽略但整个cache文件可能因此变得臃肿。我的做法是在缓存文件头里写一个版本标识包含驱动版本哈希和着色器集合哈希不匹配就直接丢弃重建。这样虽然偶尔要重新编译但避免了脏数据导致的诡异问题。缓存文件的大小也要关注。某些驱动生成的缓存很大几百兆都有可能。加载时读取这么大的文件本身就要时间抵消了部分收益。如果缓存超过一定阈值可以考虑只保留最常用的那部分PSO的缓存或者按场景拆分多个缓存文件。3. 着色器变体管理预编译工作量的真正大头预编译的复杂度不在编译本身而在编译什么。着色器变体管理没做好要么漏编译导致运行时卡顿要么编译太多导致加载时间爆炸。这一节聊聊怎么把变体数量控制在合理范围。3.1 变体爆炸的根源与量化一个着色器加上不同的宏定义组合能编译出几十上百个变体。常见的变体维度包括光照模型PBR、Blinn-Phong、Unlit、阴影类型无阴影、硬阴影、软阴影、级联阴影、雾效开关、法线贴图开关、骨骼动画开关、实例化开关等等。每个维度两三个取值乘起来就是天文数字。量化变体数量是管理的第一步。在着色器编译流程里加统计记录每个着色器实际编译出的变体数和对应的宏组合。跑一遍完整项目你会得到一张变体分布表。通常会发现二八定律百分之二十的变体覆盖了百分之八十的使用场景剩下百分之八十的变体只在极少数情况下用到。对这些长尾变体策略要区别对待。高频变体全部预编译低频变体可以延迟到首次使用时编译但要接受偶尔的卡顿极低频的可以考虑合并或者砍掉功能。关键是这个决策要有数据支撑不能拍脑袋。变体类型使用频率预编译策略预期影响核心材质变体高全部预编译加载时间增加运行时零卡顿特效变体中按场景预编译场景加载稍慢场景内流畅边缘功能变体低延迟编译兜底偶发卡顿可接受调试用变体极低仅开发构建编译发布版不包含3.2 用运行时统计反推预编译列表纯静态分析枚举变体容易过度估计因为很多宏组合在逻辑上互斥实际不会同时出现。更精准的做法是结合运行时统计在开发构建里记录每个变体的实际使用次数跑足够多的测试场景然后按使用频率排序取前百分之九十覆盖率的变体作为预编译列表。这个统计要持续做因为项目在迭代变体使用情况会变。我的做法是把统计结果输出成一份清单文件纳入版本管理。每次发版前 review 这份清单确认预编译列表和实际使用情况吻合。清单里还可以标注每个变体的来源哪个材质、哪个特效方便追溯。统计的埋点要轻量不能在热路径上加锁或者做重操作。用一个无锁的计数器数组按变体哈希索引绘制时递增。定期比如每帧结束把计数器刷到统计文件里。开发构建才开启发布构建编译掉。3.3 变体剔除与合并的实操手段减少变体数量的根本手段是剔除和合并。剔除是把用不到的变体从编译列表里去掉合并是把功能相近的变体合成一个。剔除的依据来自前面的统计。连续几个版本都没被使用的变体可以考虑移除。但移除前要确认不是测试覆盖不足导致的假阴性最好在多个场景、多种玩法下都验证过。合并的手段更巧妙一些。比如某些宏只是控制一个小的功能开关可以把开关做成运行时分支而不是编译期分支。代价是着色器里多一个uniform判断但省掉了一个变体。这个取舍要看分支的开销如果分支在片元着色器里且影响不大合并是划算的如果在顶点着色器里且影响几何处理可能还是保留变体更好。还有一种合并是超集着色器把多个变体的功能合并到一个着色器里用动态分支选择执行路径。这种做法在移动端很常见因为移动GPU对动态分支的容忍度比桌面高得益于TBDR架构。桌面端要谨慎动态分支可能导致warp发散反而降低性能。4. 运行时兜底预编译没覆盖到的怎么办再完善的预编译也会有漏网之鱼。新加的特效、动态生成的材质、玩家触发的特殊组合都可能绕过预编译列表。运行时必须有兜底机制保证这些情况不会导致崩溃或长时间卡死。4.1 同步创建的代价与缓解兜底路径最直接的做法是同步创建PSO发现缓存里没有当场调用vkCreateGraphicsPipelines。这会卡一下但至少能继续运行。关键是把这个卡顿控制在可接受范围内。缓解手段有几个。第一把同步创建安排在帧的末尾比如present之后这样卡顿不会影响当前帧的渲染节奏。第二创建时用最小的状态组合避免触发驱动的复杂优化路径。第三创建完成后立即把新PSO写入缓存下次启动就能命中。VkPipeline getOrCreatePipeline(const PipelineKey key) { auto it pipelineMap.find(key); if (it ! pipelineMap.end()) return it-second; // 缓存未命中同步创建 VkPipeline p createPipeline(key); pipelineMap[key] p; // 标记缓存脏下次启动时持久化 cacheDirty true; return p; }同步创建的耗时波动很大同样的组合在不同驱动上可能差一个数量级。所以兜底路径要有超时保护如果创建超过某个阈值比如50毫秒记录警告日志方便后续把这些组合补进预编译列表。4.2 异步编译与占位渲染更优雅的兜底是异步编译。发现PSO缺失时不阻塞等待而是提交一个后台编译任务当前帧用占位材质渲染比如纯色或者低精度版本等编译完成后再切换。这样玩家看到的是材质渐显而不是卡顿体验好很多。异步编译的实现依赖驱动的支持。Vulkan本身没有标准的异步PSO创建接口但可以通过多线程调用vkCreateGraphicsPipelines来模拟在工作线程里创建主线程继续渲染。要注意的是创建过程中用到的着色器模块和状态信息必须保持有效不能提前释放。占位渲染的策略要设计好。最简单的占位是纯色但视觉跳变明显。更好的做法是用一个通用的低精度着色器先顶着等真实PSO就绪再替换。这个通用着色器本身要预编译好不能也成为卡顿源。4.3 监控与告警让漏网之鱼无处藏身兜底机制是保险但不能依赖它。要有监控手段发现哪些组合走了兜底路径然后把这些组合补进预编译列表形成闭环。监控的埋点放在getOrCreatePipeline的缓存未命中分支。每次未命中都记录PipelineKey和调用堆栈输出到日志。测试阶段定期分析日志把高频未命中的组合加入预编译列表。发布版本可以保留轻量级的计数通过遥测上报注意隐私合规发现线上环境的漏网组合。这里有个实操细节日志要包含足够的信息来复现问题。光记录哈希不够要记录完整的PipelineKey字段值以及触发创建的材质名或特效名。不然拿到日志也不知道是哪个组合缺了。5. 实测数据与踩坑记录理论和方案讲完了这一节聊聊实际落地时遇到的问题。有些坑在文档里看不到只有真正跑起来才会暴露。5.1 不同驱动的缓存行为差异VkPipelineCache的行为在不同驱动上差异很大这是最让人头疼的一点。有的驱动缓存命中率很高第二次启动几乎零编译有的驱动缓存形同虚设每次都要重新编译还有的驱动缓存文件巨大但命中率一般。我实测过几款主流桌面显卡缓存命中后的PSO创建耗时从几十微秒到一两毫秒不等。这意味着即使命中缓存也不能假设创建是免费的。预编译阶段依然要走一遍创建流程命中缓存时很快而不是完全跳过。移动端的情况更复杂。某些移动GPU的驱动对管线缓存的支持不完整序列化出来的数据在下次启动时可能无法正确反序列化。这种情况下要加校验加载缓存后抽查几个PSO的创建耗时如果发现和冷启动差不多说明缓存没生效要回退到全量编译策略。还有一个坑缓存文件和驱动版本的绑定。驱动升级后旧缓存可能被静默忽略但文件还在占用磁盘空间。我的做法是在缓存文件头写驱动版本号启动时比对不匹配就删掉重建。这样虽然升级驱动后第一次启动会慢但避免了脏缓存累积。5.2 预编译导致的加载时间膨胀怎么压全量预编译最直接的副作用是加载时间变长。我见过一个项目预编译后加载时间从3秒涨到15秒玩家体验反而更差。压缩加载时间有几个手段。第一是懒预编译不是所有PSO都在启动时编译而是按场景分组进入某个场景时才编译该场景需要的PSO。这样启动快场景切换时稍微慢一点但玩家对场景加载的容忍度比启动高。第二是增量缓存第一次启动全量编译并缓存后续启动只编译缓存里没有的增量部分。配合版本校验大部分情况下第二次启动只需要几百毫秒。第三是编译优先级把最常用的PSO排在最前面编译加载界面显示进度时前百分之八十的进度对应最常用的部分玩家感觉加载很快剩下的在后台慢慢补。// 按优先级排序后分批编译 std::sort(pipelineList.begin(), pipelineList.end(), [](const auto a, const auto b) { return a.priority b.priority; }); for (size_t i 0; i pipelineList.size(); i batchSize) { compileBatch(pipelineList, i, batchSize); updateProgressBar(i, pipelineList.size()); }实测下来懒预编译加增量缓存的组合效果最好。首次启动加载时间增加控制在百分之五十以内第二次启动基本恢复到预编译前的水平。5.3 多线程编译的同步陷阱多线程编译PSO能大幅缩短加载时间但同步没做好会引入难以复现的崩溃。最常见的错误是多个线程共享同一个VkPipelineCache并同时写入。Vulkan规范说vkCreateGraphicsPipelines对cache的访问是外部同步的也就是说调用方要自己保证线程安全。我的做法是每个线程用自己的临时cache编译完成后用vkMergePipelineCaches合并到主cache。合并操作本身要串行但相比编译耗时可以忽略。合并时要注意cache的兼容性只有相同设备创建的cache才能合并跨设备合并会失败。另一个陷阱是着色器模块的生命周期。多线程编译时VkShaderModule必须在所有使用它的编译任务完成后才能销毁。如果主线程提前释放了着色器模块工作线程还在用就会访问野指针。解决办法是用引用计数管理着色器模块或者等所有编译任务join之后再统一释放。还有资源竞争的问题编译线程和主线程可能同时访问管线映射表。映射表要用读写锁保护或者用无锁的并发哈希表。我倾向于在加载阶段让主线程只做进度更新不碰管线映射表等编译全部完成后再一次性构建映射避免并发访问。5.4 着色器热重载与预编译的冲突开发阶段着色器热重载很方便但和预编译机制会打架。热重载修改了着色器代码旧的缓存全部失效预编译列表也要重新生成。如果处理不好每次改着色器都要等全量重编译开发效率直线下降。我的处理方式是在开发构建里禁用磁盘缓存只保留内存缓存并且只预编译当前场景用到的PSO。热重载触发时清空内存缓存重新编译当前场景的PSO耗时控制在秒级。发布构建才启用完整的磁盘缓存和全量预编译。另外热重载后的PSO创建要走和正常流程一样的路径不能有特殊分支不然测试覆盖不到真实路径。我见过热重载时正常、发布后崩溃的案例就是因为热重载路径绕过了某些校验。6. 把这套方案接进现有工程的落地顺序方案再好落地顺序错了也会事倍功半。这一节给一个推荐的实施顺序从诊断到上线分阶段推进每阶段都有可验证的产出。6.1 第一阶段只做诊断不改架构第一步不要急着上预编译先把诊断工具搭起来。给PSO创建打点采集帧时间曲线跑完整流程输出热点报告。这个阶段的目标是拿到数据确认卡顿根因和影响范围。产出物是一份热点清单哪些着色器组合最贵、在什么场景触发、影响多少帧。这份清单是后续所有工作的依据。如果诊断发现卡顿主要来自别的地方比如纹理流送那预编译的优先级就要重新评估。诊断阶段还要建立基线记录当前版本的加载时间、运行时卡顿次数、平均帧时间。后续每个优化阶段都和基线对比避免优化了一个指标恶化了另一个。6.2 第二阶段静态枚举加全量预编译有了热点清单开始做静态枚举。从材质和特效配置里提取所有PSO组合生成预编译列表。这个阶段先不做裁剪宁可多编译也不要漏。加载阶段串行编译先跑通流程。验证方法是跑完整流程确认运行时不再有PSO创建除了兜底路径。如果还有创建说明枚举有遗漏根据日志补全。这个阶段加载时间会明显变长先接受下一阶段再优化。6.3 第三阶段缓存持久化与增量编译加上磁盘缓存让第二次启动走缓存命中路径。同时引入增量编译启动时先加载缓存只编译缓存里没有的部分。这个阶段要重点验证缓存的正确性清空缓存后首次启动、二次启动、驱动升级后启动三种情况都要测。缓存校验逻辑要写扎实。文件头包含版本标识加载时比对不匹配就丢弃。缓存写入用临时文件加原子重命名避免写入过程中崩溃导致缓存损坏。6.4 第四阶段并行编译与懒加载最后优化加载体验。引入线程池并行编译按优先级分批配合进度条。再根据场景做懒预编译把非核心场景的PSO推迟到场景加载时编译。这个阶段要压测模拟低端机、慢磁盘、小内存的情况确认加载不会超时、不会OOM。并行编译的线程数要做成可配置不同设备用不同策略。7. 几个容易被忽略的边界情况最后补充几个实操中遇到的边界情况这些不在主流程里但处理不好会出问题。7.1 渲染目标格式的动态变化窗口大小改变、HDR开关切换、多显示器拖拽都会导致渲染目标格式变化进而需要新的PSO。这类PSO很难在启动时枚举全因为依赖运行时状态。处理方式是把常见的格式组合预编译好比如几种主流分辨率和HDR开关的组合极端情况走兜底路径。窗口resize触发的PSO重建要特别小心因为resize本身就有性能开销叠加PSO创建可能造成明显卡顿。我的做法是resize过程中用旧的PSO继续渲染拉伸显示等resize结束后再异步重建PSO重建完成前用占位渲染。7.2 多设备与多GPU场景支持多GPU的工程要注意PSO是和设备绑定的不能跨设备共享。每块GPU都要独立预编译和缓存。缓存文件也要按设备区分用设备UUID做文件名后缀。多GPU的枚举逻辑要按设备能力做区分。不同GPU支持的格式和特性可能不同预编译列表要按设备生成不能共用一份。这个在笔记本双显卡场景下尤其要注意核显和独显的能力差异很大。7.3 着色器编译器的版本漂移着色器编译器比如glslang、dxc升级后生成的SPIR-V可能变化导致缓存失效。这个变化很隐蔽因为着色器源码没变但编译产物变了。处理方式是在缓存版本标识里加入编译器版本号编译器升级时自动失效缓存。另外某些编译器优化选项的变化也会影响产物。比如开了调试信息或者关了优化生成的SPIR-V不同缓存自然不匹配。构建配置里要把这些选项固定下来避免不同构建之间缓存互相污染。7.4 显存与内存的峰值控制预编译几百个PSO每个PSO在驱动内部都占资源显存和内存的峰值会上升。低端设备上可能触发OOM。控制手段是分批编译加及时释放编译完一批后把不再需要的临时资源着色器模块、临时cache释放掉降低峰值。监控峰值的方法是在编译过程中定期采样显存和内存占用输出曲线。如果发现峰值接近设备上限就要减少批大小或者引入更激进的懒加载策略。我个人在实际项目里的体会是预编译这套东西技术上不复杂难的是把枚举做全、把缓存做稳、把兜底做顺。诊断阶段的数据采集往往被低估但它是后续所有决策的基础值得多花时间。另外不同驱动的行为差异要有心理准备同一套代码在不同机器上表现可能差很多测试矩阵要覆盖主流配置。最后预编译不是一劳永逸的项目迭代过程中要持续维护枚举列表和缓存策略把它当成一个持续运行的机制而不是一次性的优化任务。