那天有人问我,现在的配音方式还能不能再快一点,尤其是锁占用和整体效率。这个问题听起来像是在问模型够不够快,或者机器是不是需要再加一点资源。但我一开始没有急着换模型,也没有先把资源上限往上调,而是先问了一个更基础的问题:这把锁到底锁住了什么?
当时的流程很容易让人产生错觉。一个项目开始配音,外面套着一个看起来很稳妥的资源锁,脚本从头跑到尾,锁也从头占到尾。于是查缓存、切句、去掉句尾杂音、转码、写缓存这些不需要独占推理资源的步骤,也跟着一起排队。甚至一段文字已经命中缓存,根本不用重新合成,仍然可能先去等锁。
这件事让我重新意识到,很多“性能问题”并不是某一个动作太慢,而是资源边界画错了。共享资源最怕的不是有人使用它,而是有人把不需要它的时间也算进占用时间。
表面上是模型慢,实际上是边界错了
原来的感觉是,配音慢就只能接受模型的推理速度。每一句都要等待,批量任务一长,后面的人只能看着前面的任务持续运行。可是把过程拆开以后,慢并不只发生在推理阶段。
有些时间花在查有没有现成音频,有些时间花在把长文本切成句子,有些时间花在转码和清理。它们当然会消耗时间,但不应该消耗那个大家都在争用的独占资源。把这些步骤全部包进锁,相当于一个人进了厨房以后,连在门口查菜单、整理购物袋、洗盘子都不让别人进来。
如果不处理,后果不只是单个任务变慢。缓存命中也要排队,短句会被长批次挡住,多个调用方还会各自猜测“现在是不是忙”。最后大家看到的不是一个可管理的队列,而是一堆进程互相等待。越是着急,越容易再开一个绕过规则的入口,资源争用反而更严重。
这里最容易误判的,是把“从开始调用到结束返回”当成“独占资源使用时间”。我选择先画资源占用边界,是因为只有把这两段时间分开,后面的排队、预估和统计才有意义。否则统计出来的总耗时,混合了准备、等待和推理,任何优化都只能凭感觉判断。
锁只应该包住真正的推理
重新拆分后,查缓存、切句、去尾音、转码和写缓存都移到了锁外。真正需要锁的,只剩模型加载和推理。这样做并不是为了让代码看起来更精巧,而是为了让资源被占用时,确实正在做只有它能做的事情。
缓存命中是最直观的例子。一段音频已经存在时,系统应该直接返回结果,不必为了证明“不需要合成”而先占一次锁。这个判断也让调用方的体验发生了变化:重复使用已经生成过的内容,耗时可以降到很低,也不再把后面的任务堵住。
但锁缩短以后,不能只看持锁时间下降了没有,还要看结果有没有变化。推理阶段的参数、声音设定和原来的默认值都需要保留。那些看起来能提速的做法,例如减少采样步骤或改变精度,可能会改变声音,所以我没有把它们作为默认优化,而是把它们留给单独的试听比较。
这也是一个重要的取舍:我判断“先把锁边界修正”比“先追求模型更快”更稳妥,是因为前者不改变结果,后者可能把性能收益换成声音不一致。对共享资源来说,稳定的边界通常比一次漂亮的峰值更值得保留。
批量请求不能再逐句重新开工
另一个明显的浪费,是批量请求逐句调用。每配一句就启动一次新进程,模型也重复加载。单句看不出问题,批量一多,加载时间就被重复计算了很多遍。
这次把单句和批量统一到同一个入口。调用方不再自己拼接一串命令,也不再各自决定如何查缓存、如何排队或如何检查忙闲。统一入口先接收任务,再完成必要的准备,把可以批量处理的句子交给同一个执行过程。
实测同样的 10 句文案,原来逐句调用总耗时是 705 秒,调整后批量运行降到 419 秒,约快 1.68 倍。模型加载从 11 次降到 3 次,持锁时间从约 705 秒降到 398 秒,减少 44%。这些数字不是为了证明某个脚本厉害,而是用来回答一个更实际的问题:时间究竟省在了哪里?
这里还有一个不能忽略的边界。连续处理太多句时,进程里的显存会逐渐上涨,不能为了少加载几次就无限坚持。实际方案是在句子之间检查资源,超过安全线就换一个新进程继续,保证结果和内存保护都不被牺牲。
所以,批量并不等于“一次把所有东西塞进去”。好的批量处理,是减少重复准备,同时在接近资源边界时主动分段。
队列要替调用方承担秩序
当共享资源可能被多人、多个项目持续使用时,锁本身不够。锁只能回答“现在谁拿到了”,不能回答“我排第几”“前面还要多久”“某个进程突然退出后,后面怎么办”。如果每个调用方都自己判断忙不忙,最终就会出现不同的标准。
因此,这次把排队也放进统一入口。每个请求有自己的票,基本规则是先来先服务;进程退出时,票会被清掉,不让一个已经不存在的任务堵住整条队列。队列还提供状态查询,调用方不需要再通过观察进程名来猜测资源是否忙碌。
长任务也不能永远占着前面的位置。新的规则是,只有任务已经持锁满 180 秒,并且确实有人在等待时,才在句子之间让出一次,再排到队尾。没有等待者时不主动打断,避免为了形式上的公平增加额外开销。
这个让出机制解决的是一个很具体的场景:短任务不应该因为长批次先到,就只能等到最后。实测插入的一句只等了 55 秒;如果长任务完全不让出,等待时间可能接近 478 秒。这里的关键不是把所有任务都切得很碎,而是在有人等待时,让队列重新获得流动性。
公平不是让每个任务占用同样长的时间,而是让后来的任务知道自己为什么等、什么时候有机会继续。
预估让等待变成可安排的时间
排队建立以后,另一个问题自然出现:提交任务时,不能只说“请等待”。用户需要知道大概多久开始、多久完成,调用方也需要据此安排后面的工作。
这次的预估从字数和机器上已经测得的速度出发。提交时先给出预计等待和合成时间,完成后再用实际耗时校准速度。这样,预估不是一个永远固定的拍脑袋数字,而是随着使用逐渐接近真实情况的工作数据。
当然,预估不可能消除所有波动。缓存命中、排队长度、长句触发分段,以及资源保护,都可能改变最后的时间。它的价值也不是承诺精确到某个瞬间,而是让人能在开始前做判断:现在提交是否合适,是否要拆开,是否要先处理别的任务。
我更愿意把它看成一种诚实的接口。系统不知道的时候就应该把不确定性说出来,而不是让调用方在黑暗里反复重试。可预估的等待,比没有解释的快速返回更接近真正的效率。
下次我会怎么做
下次再遇到共享算力变慢,我会按这个顺序检查。先把一次请求拆成准备、等待、独占推理和收尾,分别记录总耗时与持锁时长;再确认缓存命中是否完全绕开独占资源;然后看批量调用有没有重复加载,所有调用方是否走同一个入口。
如果确实存在多人使用,我会把排队、票据清理、状态查询和长任务让出一起考虑,而不是只加一把更大的锁。提交前给出基于字数和历史速度的预估,完成后用实际结果校准。最后再用总耗时、持锁时间、模型加载次数和结果一致性做对比。
这件事表面上是在优化配音,实际上是在重新安排共享资源的边界。真正难的不是找到一个更快的参数,而是承认并不是流程里的每一步都值得占住同一项资源。一个流程成熟的标志,也不是它顺利跑通一次,而是下一个调用方不需要重新猜入口,短任务不必无休止地等长任务,已经完成的工作不会再被重复做一遍。
我最后留下的规则很简单:先测清楚谁在占用资源,再把不需要独占的步骤移出去;再用统一入口承接缓存、排队、预估和回执。这样,速度提升不是靠运气,也不是靠不断加机器,而是靠让每个时刻的资源占用都有明确理由。