做了大半年AI漫剧推文短视频的批量生成,中间最折磨人的不是画面质量,也不是提示词花活,而是显卡动不动就“爆显存”。单条视频跑得好好的,一行代码上批量,三个任务下去GPU直接OOM,进程被杀,调度器跟着一起崩。后来我把整套链路拆开重新设计,做了显存池化加并发调度,才算是把批量生成真正跑稳了。这篇文章就把这段时间的工程实践完整记录下来,不藏私,从设计思路到核心代码到踩坑过程都有。
1. 开工前的账本:场景特点与显存瓶颈
1.1 漫剧推文流水线到底在跑什么
先把项目形态说清楚。所谓AI漫剧推文短视频,指的是用AI把小说或推文内容转成一条带配音、带画面动效、带分镜切换的短视频。一条60秒的漫剧视频,内部其实是一条很长的生产链:先是文案拆解和分镜规划,再为每个分镜生成角色一致的插画,然后做局部重绘、超分、扩图,接着给画面加镜头动效和运镜,最后合成配音、字幕和片头片尾。链路里面至少有三次大模型调用,文生图、图生视频、配音合成,其中文生图和图生视频是最吃显存的两个环节。
批量生成的意思是,不是做一条视频,而是一次性丢进去几十条推文,系统自动产出几十条成片。这个场景在MCN矩阵号、网文推广、短剧切片分发里非常常见。量和速度直接决定商业模式能不能跑通,所以批量的吞吐能力才是核心指标,单条视频的生成质量反而是次要矛盾。
1.2 为什么批量一上来就爆显存
先算一笔显存账。以常用的SDXL模型做文生图为例,一张512x768的图,在fp16精度下跑一次完整推理,峰值显存大约在8到10GB左右。图生视频的模型更夸张,一个几秒钟的镜头片段,端到端跑完峰值能摸到12GB以上。单卡24GB的显存,纯按“一个任务独占一张卡”的老思路,最多同时跑两个文生图任务,第三个任务基本必炸。
更隐蔽的问题是模型加载开销。原生的批量生成逻辑往往是每个任务独立加载模型,任务跑了,进程退了,然后下一个任务重新加载。SDXL模型文件本身就有6到7GB,从磁盘读进内存再搬到显存,一次完整加载要花30到60秒。批量任务一多,大量时间全部消耗在反复加载模型上,GPU核心算力反而是空闲的。
还有一类问题是显存碎片。跑完一个文生图任务再跑图生视频时,由于两个模型的网络结构完全不同,显存分配器会对显存做大量申请和释放。碎片积累到一定程度,明明总空闲显存够用,但连续空闲块不够,照样OOM。这种问题最阴间,你从日志上看不到任何异常,就是莫名其妙地爆。
1.3 池化和调度想解决的核心矛盾
传统方案只有两个坐标轴,要么串行执行保证不爆显存,要么粗暴并发追求速度。串行执行一天出不了多少条成片,完全喂不饱内容生产的需求;粗暴并发又动不动就崩。显存池化和并发调度本质上就是在这个矛盾里找一个平衡点。
显存池化解决的是“资源复用”问题,让多个任务共享同一批常驻显存和模型上下文,而不是每个任务重复加载、重复申请。并发调度解决的是“资源分配”问题,通过一个统一的调度器来规划每个任务在何时、占用多少显存、以什么顺序执行。两者是配合关系:没有池化,调度器并发度上不去;没有调度,池化再省也会被多个糊涂任务同时突破水位猛冲上去。
2. 显存池化:用“水池”思路对抗显存碎片
2.1 第一层:上下文常驻复用
显存池化最简单的理解方式,是把显存看成一个大水池,里面一直养着几组模型上下文,任务来了直接从池子里取用,用完还回来,而不是每次任务来了重新挖一个池子。
我在项目里做的第一步,是把原来“每任务独立进程加载模型”的模式,改成“常驻进程持有模型上下文”。也就是GPU侧常驻几个生成Worker进程,每个进程启动时加载好SDXL、图生视频、配音模型,并做一次前向推理“热身”,把CUDA上下文彻底稳定下来。后续所有任务都复用这几个进程,不销毁不重启。
这一步做下来,收获立竿见影。原来跑20条视频要加载几十次模型,现在整个批处理期间模型只加载一次,光加载时间就省了差不多40%。从监控上看,CUDA上下文的数量从几十个降到几个,显存碎片问题也明显缓解。常驻进程的真正价值不只是省加载时间,它还能把CUDA的上下文分配器养熟,让显存分配模式稳定下来。
2.2 第二层:预分配与缓存命中
常驻模型只是打底,更关键的是对推理过程中的中间结果做缓存。我实现的池化是一个带LRU淘汰机制的缓存池,不只是存模型,还存三类东西:VAE解码结果、ControlNet中间特征、以及常用的风格化底图特征。
这里有个实际收益很大的场景:漫剧推文中角色一致性处理。批量生成同一部小说的几十个分镜时,主角的脸部特征向量、服装Lora特征、环境风格向量都是高度重复的。改造前每个分镜都要重新算一遍这些特征,改造后直接打缓存,命中率在长剧情批次里经常能到70%以上。
缓存池还要处理淘汰策略。显存总量是固定的,不能无限缓存,所以我在池子上加了一个水位上限,比如24GB的卡只允许缓存池占到18GB,剩余6GB留给生成过程的动态峰值。缓存满了就按LRU淘汰最久没用的项,释放显存。这个水位线参数非常关键,设太高容易OOM,设太低又浪费资源,我用了一段时间之后发现“总量减去单任务最大峰值”的思路最稳妥。
2.3 池化粒度怎么定
显存池化的粒度,我踩过坑,一开始试着把整个模型层的输出都缓存,结果显存爆炸,因为缓存key的维度膨胀得太厉害。后来总结出经验:只缓存稳定且重复价值高的产物,不缓存每个任务的个性化产物。
以漫剧生成来说,值得进池子的优先级排序是这样的:角色特征向量,文本语义向量,常用风格底图特征,然后是分镜插画成品图。配音音频和最终成片视频这种“一次性产物”绝对不进池子,直接落盘。池化粒度不是越大越好,而是要找到“命中收益”和“显存占用”的最优点。越靠近模型前端的特征复用价值越高,越靠近最终输出的内容越个性化,不值得占用宝贵的池子空间。
这个思路放到Python工程里,核心数据结构就是一个带水位控制的OrderedDict。我给出一段简化的核心实现,实际项目里在此基础上加了多卡策略和动态水位调整。
# 显存缓存池核心实现(简化版) from collections import OrderedDict import torch class GPUPool: def __init__(self, max_gb=18): # 池子上限18GB,剩余显存留给动态执行 self._max_bytes = max_gb * 1024**3 self._pool = OrderedDict() # key -> (tensor_size, tensor_ref) self._current_bytes = 0 def get(self, key): if key in self._pool: # 命中即刷新LRU位置 item = self._pool.pop(key) self._pool[key] = item return item[-1] return None def put(self, key, tensor): item_size = tensor.numel() * tensor.element_size() self._pool[key] = (item_size, tensor) self._current_bytes += item_size self._evict_lru() def _evict_lru(self): while self._current_bytes > self._max_bytes: _, (old_size, _) = self._pool.popitem(last=False) self._current_bytes -= old_size def reserve_peak(self, peak_gb=5): """动态执行时,预留峰值显存遏制水位,防OOM""" torch.cuda.empty_cache() free = torch.cuda.mem_get_info()[0] if free < peak_gb * 1024**3: self._evict_lru(self._max_bytes - free - peak_gb * 1024**3)这里有个容易被忽略的细节:用完池子里的tensor之后,一定要显式释放引用,否则LRU淘汰下来后显存并不会真正还给CUDA。我项目里所有任务都统一走一个finalize()函数,强制释放并触发一次torch.cuda.empty_cache()清理碎片。多任务并发的时候,这个清理动作会产生一定开销,所以只在每次任务交接时做一次,不在推理中间做。
3. 并发调度:把任务排成流水线
3.1 资源计数与并发度计算
有了显存池,只能说“不浪费资源了”,但还不能保证“并发跑得快”。并发调度要解决的是另一层问题:已分配的显存按照什么节奏释放,新任务什么时候能拿到算力。如果没有任何调度,所有任务一起涌进池子里抢显存,池化的水位控制就会被淹没。
调度系统的核心是一个资源计数器。我实现的调度器维护两个关键数字:当前池内显存占用、当前正在执行的推理任务数。新任务入场前必须先向调度器申请资源,调度器根据池子的实时水位和动态预留空间来决定给不给。
并发度不是拍脑袋定的,我按这个公式来估算并发上限:
可执行并发数 = (单卡可用显存峰值 - 池化常驻水位) / 单任务峰值显存举个例子:24GB卡,池化常驻水位18GB,单任务峰值显存约5GB,并发上限就是(24-18)/5,约等于1.2。也就是说大部分时间只能跑1个任务,只有在池子水位低到一个较高缓存命中率时段才敢放开到2个并发。这个并发度是动态的,我做成每30秒重新计算一次,不靠人工配置。
3.2 多优先级队列
同一批推文中任务的重要程度不一样,所以只用一个FIFO队列是不够的。我给调度器加了三个优先级队列,高优先级处理加急分镜和角色一致性锚点图,中优先级处理常规分镜插画,低优先级处理后处理类的超分、扩图任务。同时配合超时机制,低优先级任务等待超过一定时间就自动升级优先级,防止长尾任务饿死。
这个设计基于一个实际观察:漫剧推文批处理对“单条视频产出顺序”有强依赖。比如一个分镜的角色特征图必须先生成出来,后面的几十个分镜才能复用;如果这个锚点图被排到低优先级队列后面,整批任务都会卡住。调度器必须能够识别这种依赖关系,把前置依赖任务插队到高优先级。
3.3 超时、重试与回收
调度器还得背负可靠性。批量任务在并发环境下失败率比单条高得多,显存分配失败、CUDA OOM、进程僵死都会出现。我在调度器里做了三个动作:超时回收、自动重试、失败隔离。
每个任务执行前都要登记一个预期耗时上限,超过上限就触发工具收集相关CUDA状态,然后强制终止该任务并把显存池回滚到任务开始前的快照。重试不是无限重试,同一任务超过三次直接丢弃并把错误上报到生产端,防止坏任务反复占用集体资源。失败隔离则是当某个Worker进程出现不可恢复异常时,不把整批任务打挂,只是标记该Worker失联,由调度器把它的任务重新分发到其他Worker上。
这些逻辑全部做在一个全局调度线程里,Worker只管执行推理,不关心排队和重试这些杂务。调度器的实现相当轻量,核心就两个数据结构:一个任务队列加一个状态映射。对于中小规模场景,完全不需要引入外部任务队列组件,Python线程加标准库就能扛住日均几百条成片的量。
4. 实操落地:一版可复制的实现
4.1 整个批量链路设计
这套系统跑起来之后,整个批量链路的执行流程是这样一个状态机:任务从数据库取出后,先进入调度器的等待队列,调度器按优先级和资源水位决定何时放行。放行后任务被分发到某个空闲Worker进程,Worker先从显存池尝试命中角色特征和分镜底图,命中失败才执行推理,推理完成后缓存特征到池子,然后执行下一节点。整条链路走完,任务结果写回数据库或对象存储,显存池资源由Worker归还给调度器。
我画不成复杂的架构图,但可以口述这个拓扑:一个调度主进程统管全局,N个Worker进程并行执行,调度和Worker之间通过一个全局共享队列通信。Worker不直接互相通信,所有中间状态都汇总到调度器的状态表里。这个拓扑的好处是很容易做故障恢复,任何一个Worker挂了,调度器重新分配任务即可。
这个设计里我一开始就放弃了把调度逻辑塞进Worker进程内部的做法。第一次改造时曾试过让每个Worker自己检查显存、自己去队列取任务,结果一旦某个Worker因OOM僵死,整个调度逻辑也跟着死,排查困难。后来彻底分离,调度器独立进程,Worker无状态化,出问题直接重启Worker,调度器不受影响。
4.2 池化的核心实现细节
池化代码前面已经贴了核心类,这里补充一些工程化细节。第一点是池子的键设计,我用一个签名函数生成缓存key,把prompt信息、模型版本、尺寸、seed这些会影响输出特征的因素全部拼进去。比如角色特征缓存的key就是(project_id, role_name, model_version, style_tag),精确但不过度细化。
第二点是池子的持久化问题。显存池是内存态,进程一重启就什么都没了。我在池子之上加了一个磁盘缓存层,特征向量落盘到本地缓存目录,显存池miss时先查磁盘缓存,命中则直接读入显存,不命中才执行推理。对长周期项目来说,这个磁盘缓存层的收益甚至高于显存池本身,因为同一部小说的后续批次可以跨进程复用特征。
4.3 调度的核心实现细节
调度器的核心实现,我贴一段任务分发逻辑。整体思路是:任务入队时登记资源需求、依赖关系和预计耗时;调度线程周期性检查队列,把满足条件且不超过当前并发阈值的任务取出,分发到有空闲显存水位的Worker。
# 调度器核心分发逻辑(简化版) import threading import queue import time class TaskScheduler: def __init__(self, max_workers=4): self._workers = max_workers self._free_slots = max_workers self._lock = threading.Lock() # 三个优先级队列,这里用列表模拟,实际项目可用PriorityQueue self._queues = {0: [], 1: [], 2: []} # 0高优先级 def submit(self, task, priority=1): with self._lock: self._queues[priority].append(task) self._dispatch() def _dispatch(self): # 并发度受限,先不分配 if self._free_slots <= 0: return # 按优先级取任务 for prio in (0, 1, 2): if self._queues[prio]: task = self._queues[prio].pop(0) self._free_slots -= 1 self._run_task(task) break def _run_task(self, task): # 实际执行时会提交给线程池,此处省略 def complete(): # 组装任务到worker执行,完成后回收槽位 with self._lock: self._free_slots += 1 self._dispatch() threading.Thread(target=complete).start()真正要跑生产环境,建议直接在状态表上做完整决策,因为多优先级队列混用很容易出现低优先级任务饿死的问题。我最终改成了按“最早提交时间+依赖就绪状态+资源预算评分”的混合优先级策略,实现起来复杂一些,但在长任务批次里表现稳定得多。
4.4 满负荷运行实测数据
系统上线后,我用一批50条推文的生产任务做了压测。硬件是一台双卡24GB的GPU服务器,之前串行跑这批任务需要大约11个小时。池化加并发调度优化后,整批跑完约4个小时,吞吐提升了接近3倍。显存监控最直观:之前并发跑3个任务经常OOM,同一个池化方案跑满并发,显存峰值稳定在单卡21GB上下,不再突破。
再拆分看时间构成。总耗时里模型加载时间占比从原来的30%降到不到5%,因为全程常驻进程不重启。池化的缓存命中率在第一批任务里较低,因为冷启动没有缓存,但从第二十条开始命中率稳定在65%以上。多个任务之间的资源等待时间也从原来的“各种排长队”压缩到了总时长的15%左右。
这个实验数据可能不算极致优化,但作为一次工程改造,效果足够说服团队向这个方向持续投入。
5. 现场踩坑与排查实录
5.1 OOM的真相
批量生成在线下(或内网)调试时遇到最多的就是各种形式的显存问题。排查下来,真正原因是“池化水位+任务峰值共存”这一个问题。24GB的卡,池子设了18GB上限,但有的任务瞬间峰值需要8GB,两个任务叠加就是18+8+8=34GB,必然溢出。
解决方案是调度器在分配第二个任务时,执行一个“动态水位压缩”,即先把任务分解为多个子任务、以小步快跑的方式让不同任务穿插执行。同时把池子上限由固定值改成根据当前已分配任务数动态调整。这个动态水位逻辑是整个系统里最值得保留的一段经验。
5.2 僵尸进程与显存泄漏
池化方案有一个隐藏缺陷:常驻Worker进程一旦出现显存泄漏不会自动恢复,因为进程不重启,内存只会越涨越高。我在一次连续跑了两天的批量任务之后发现,Worker进程的显存占用从18GB慢慢涨到了22GB,于是定时任务触发了“水位异常”告警。
排查下来,泄漏点不是模型本身,而是推理过程中的临时张量没释放干净。有的第三方库在几次调用之后会在CUDA context里残留引用。解决办法是给每个Worker进程加了一个“任务计数”机制,每处理一定数量任务之后就触发一次软重启,释放全部上下文重新加载模型。这个机制保证单个任务不会有额外开销,但整批任务能长期稳定运行。
5.3 卡死与死锁
调度器和Worker之间的通信曾经出现过死锁。原因是Worker在执行推理时占用了GIL(Python的全局解释器锁),导致调度器线程无法获取锁来更新状态,看起来就像整个系统卡住。我自己排查了很久,最后通过给Worker执行推理的线程设置threading.TIMEOUT才定位到问题。
解决方式有点微妙:不让调度线程和Worker线程共用一把锁,而是直接把调度决策改成“广播式”,即Worker执行完再来拉取新任务。避免调度器在Worker执行期间需要和它同步任何状态,死锁自然消失。
5.4 排查小工具
批量任务在非生产环境做问题排查时,光靠日志效率太低。我平时会直接起一个定时监控线程,每10秒打印一次nvidia-smi中的显存占用,并和调度器的资源水位表做对照。这个对照操作非常有用,你能直接看出“池子显示已释放显存,但GPU实际占用没降”的类型差异,能定位到是模型上下文没释放还是显存碎片堆积。
另外推荐一个容易被忽略的操作:在所有推理调用前统一加一条torch.cuda.synchronize(),强制CPU与GPU同步。很多“莫名其妙的卡住”其实是CUDA异步执行导致的问题,这条同步会让日志更准确地反映真实执行状态。
6. 经验沉淀与后续还能怎么玩
6.1 一条铁律和两个指标
做完整套系统,我自己的体会是:批量生成类项目先别急着优化模型质量,先把“资源账本”理清楚。一条铁律是“池化管存量,调度管增量”,这两块必须同时做,单靠池化省显存、不靠调度排任务,并发一高照样崩;只管调度不管池化,频繁加载模型时间浪费更多。
两个核心指标建议时刻盯在监控面板上:GPU利用率(算力是否吃满)和显存碎片率(显存是否被低效占用)。很多项目只看第一个指标,觉得GPU利用率60%就是正常,实际显存碎片率可能已经30%以上了,这部分浪费才是批量产能上不去的隐形瓶颈。
6.2 可能的扩展方向
这套池化加调度的思路后续还能往几个方向扩展。一是往多机分布式推,把单机的显存池升级成分布式资源池,通过资源调度协议做跨机器协调,理论上并发度可以跟着机器数量线性加。二是把调度系统改成基于负载预测的决策,不只是响应式调度,而是能根据历史任务耗时估算出未来一段时间的资源需求,提前分配显存,做到任务到达时不用等资源。
还有一个小方向值得做:把池化层做成通用中间件,支持接入多种模型和多个推理框架。目前池化代码和业务逻辑耦合在漫剧生成本身,抽取成通用层之后,任何AI内容生成项目都能直接复用这套资源管理能力。这个方向是我后续准备重点投入的。
最后补一个很实际的建议:如果目前跑批量任务总是被显存问题折磨,第一步不要急着上什么高级调度框架,先做池化。把常驻进程和显存缓存做起来,大部分OOM问题已经能缓解一半。在这基础上再看忙闲不均的情况,才用得上调度这剂药。先省后排,顺序别反了。