前阵子我在整理一批短视频素材,用 Cosmos Curator 做内容标注。任务很简单——就一条 40 秒的视频。可我刚按下运行键,侧边栏的模型加载日志就刷刷地往上滚:视觉编码器加载完毕、语音识别模型加载完毕、文本向量模型加载完毕……一口气出来八九条记录。再看一眼 GPU 显存,从空闲的 2GB 直接冲到 14GB。我当时第一反应是:这工具是不是把我后续要跑的任务全给预加载了?
后来把源码和任务调度逻辑翻了一遍才明白,事情没我想的那么夸张,但也确实暴露了一个工程上很容易被忽略的问题:单条视频的处理本身就是一条多模型流水线,而流水线里每个环节的模型,生命周期默认都是"进了内存就不走"。这篇文章我就把这条链路完整拆开,说说为什么一条视频会带出一串模型,以及怎么在排查的时候快速定位、在改造的时候精准控制。不管你是自己写视频处理工具,还是只是被某个现成工具搞得显存焦虑,这篇应该都能给你一些能直接上手的思路。
1. 现象复盘:一条视频,日志里冒出一整串模型
1.1 我看到的加载日志到底长什么样
拿当时最典型的运行日志来说,Cosmos Curator 在启动之后几乎是"秒连"地把一排依赖项全部拉了起来:
[13:02:11] info pipeline-start task=video_curation source=clip_001.mp4 [13:02:12] info model-load vision_encoder arch=ViT-L/14 device=cuda:0 precision=fp16 [13:02:12] info model-load frame_sampler ready (ffmpeg 6.x, sampling=1fps) [13:02:14] info model-load speech_transcriber arch=transformer-asr device=cuda:0 precision=fp16 [13:02:16] info model-load ocr_reader arch=convnext+attention device=cuda:0 precision=fp16 [13:02:19] info model-load caption_llm arch=multimodal-7b device=cuda:0 precision=int8 [13:02:21] info model-load text_embedder arch=bi-encoder-768 device=cuda:0 precision=fp16 [13:02:23] info all model dependencies satisfied, start inference注意时间戳:从第一条加载记录到最后一条,总共只花了 12 秒左右。也就是说,在单条视频真正开始推理之前,系统已经把后续几个环节要用到的模型全部"预备"好了。而且这还是只跑一条视频的情况——如果后面挂了任务队列,这个模型列表还会继续膨胀。
我当时用的是一条纯画面、带背景音乐的短视频,理论上没有对白、没有字幕,语音识别和 OCR 这两个模型完全是多余的。但日志清楚地显示,它们照样被加载进了显存。这背后其实是"任务编译"的机制在起作用:框架在任务开始前,会先把整条处理管线里可能用到的节点全部实例化,而不是等真正遇到对白帧再去现场加载。
1.2 显存从 2GB 涨到 14GB,中间发生了什么
跑完这条视频之后,我随手敲了个nvidia-smi,输出大概是这样的:
+-----------------------------------------------------------------------------+ | NVIDIA-SMI ... Driver Version ... CUDA Version ... | |-------------------------------+----------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | |=============================================================================| | 0 ... On | 00000000:01:00.0 On | N/A | | 30% 62C P0 190W / 250W | 14052MiB / 24576MiB | 5% Default | |=============================================================================| | Processes: | | python ... 14052MiB | +-----------------------------------------------------------------------------+显存占用 14GB,但 GPU 利用率只有 5%。这说明模型全都躺在显存里,但真正在算的东西非常少。这种"内存高、算力低"的组合,是典型的驻留式模型管理造成的:权重、中间激活、KV cache 全部常驻,可面对一条 40 秒的短视频,根本跑不满那么多计算资源。
如果你也遇到过类似情况,先别急着骂工具写得烂。这里有个常见的认知误区:加载了九个模型,不等于九个模型都会在这一条视频上被完整调用。有些模型可能只跑一帧,有些可能压根只在特定条件下才触发。真正的问题不在"加载得多",而在"加载之后不释放"。
1.3 先分清两件事:"加载"不等于"正在用"
我在排查的时候习惯先画一个状态矩阵:模型是否被加载、是否被调用、是否被卸载。Cosmos Curator 的场景里,大多数模型的调用其实是分布在不同处理阶段的。比如画面理解阶段只需要视觉编码器,音频阶段才轮到语音识别,最后汇总阶段才会把多模态大模型拉出来生成摘要。从单条视频的视角看,它们不是同时在工作,而是排队登场。
但框架层面为了减少重复加载的开销,选择了"一次全加载、全程驻留"的策略。这就像你去食堂打饭,厨师提前把所有菜都炒好放在保温台里,不管你只点一个菜,还是点五个菜,后厨都已经开火了。省时间,但浪费资源。这个取舍在大批量任务场景下是划算的,可一旦落到"只跑一条视频"这种极端情形,问题就会被放大得很明显。
2. 视频是复合媒体:Cosmos Curator 的多模型处理链路拆解
2.1 一条视频里其实藏了三路信号
要理解为什么一条视频需要多个模型,得先回到视频本身。大多数人想到"视频处理",第一反应是画面分析。但一个标准的视频文件里,至少包含三路相互独立的信息流:
- 视频轨:连续帧画面,包含场景、物体、人脸、动作、字幕叠印等内容。
- 音频轨:人声对白、背景音乐、环境音、音效。
- 元数据轨:封装在容器里的标题、标签、章节信息,以及叠加在画面上的硬字幕。
Cosmos Curator 的定位是"内容策展",也就是要把一条原始视频变成一条带结构化描述、可检索、可去重的内容条目。它需要对这三路信号分别做理解,再合并成最终结果。你能指望一个只会看画面的模型去听懂对白吗?显然不行。所以多模型是业务需求逼出来的,不是架构师炫技。
2.2 完整链路里的模型职责清单
我把当时日志里出现的模型和它们的实际职责整理了一下:
| 处理阶段 | 输入信号 | 使用的模型类型 | 输出 |
|---|---|---|---|
| 解码抽帧 | 视频文件 | 解码器 + 帧采样器(非深度模型) | 关键帧序列、FPS、时长 |
| 画面理解 | 关键帧 | ViT 类视觉编码器 | 物体、场景、人物等视觉标签 |
| 字幕识别 | 关键帧中的文字区域 | OCR 模型 | 画面内文字内容 |
| 语音转写 | 音频轨 | Transformer 结构 ASR 模型 | 对白/旁白文本 |
| 语义摘要 | 视觉标签 + 转写文本 | 7B 级别多模态大模型 | 生成标题、摘要、标签 |
| 文本向量化 | 最终文本 | 双塔编码器 | 可检索的向量表示 |
这六类模型里,解码抽帧那一项不算深度模型,但也会占用 CPU 和内存。剩下的五项,每一个都有自己擅长的模态和能力边界,无法互相替代。所以从"视频处理需要理解什么"这个角度看,加载这些模型并非设计错误。
2.3 串联执行与并联依赖:为什么会像"一串"
理论上,这六类模型可以是串行执行的:先抽帧,再画面理解,再 ASR,再摘要……但 Cosmos Curator 采取的是一种类似工作流 DAG 的调度方式。它在初始化阶段会分析当前任务的完整依赖图,然后把所有节点一次性实例化,再按拓扑顺序执行。
这种设计的优点是中途切换处理类型时不需要现场加载模型,缺点也很直接:任务粒度越小,浪费越明显。一条 40 秒、没有对白、没有字幕的视频,本质上是"画面理解 + 摘要 + 向量化"就能搞定的,但 DAG 编译期只认任务类型,不看具体内容,于是把 ASR 和 OCR 的模型也一并加载了。如果你把视频换成纯音频输入,可能还会看到背景音乐分离模型、人声降噪模型再插进来。
所以"一串模型"这个现象,本质上是一个"任务类型驱动"的框架,遇上了"媒体内容高度差异化"的现实。它不区分长尾场景,把所有处理路径统一按最重的那种配方准备。
3. 模型生命周期管理:为什么"跑完就卸载"这么难
3.1 常驻 vs 按需:工程师为什么默认选常驻
在排查过程中,我翻了工具内部对模型的管理代码。看到核心逻辑的那一刻,我就明白了:这不是一个 bug,而是一个典型的工程取舍。
模型加载的成本极高,主要体现在三块:权重从磁盘读到内存的 IO 耗时、权重初始化时对随机数生成和 batch norm 统计的预热、以及 CUDA context 和 cuDNN 算法的自动搜索。在大模型时代,光是一次完整的加载就可能是几秒到几十秒。如果每跑一条视频就把模型卸载一次,那吞吐量会急剧下降,因为"加载模型"的时间比"推理时间"还长。
所以工程上默认的做法是:模型加载一次,放进一个全局注册表,后续任务直接复用。这在批量处理场景下非常合理。但问题在于:注册表只实现了"放进不来"的接口,没有实现"不用的先走"的接口。当任务从"一天处理一万条"变成"我就想跑通一条验证一下",这种设计就把显存吃得干干净净。
3.2 全局单例与模型注册表:进了内存就不出来
Cosmos Curator 内部的模型管理,可以简写成这样一段伪代码:
_MODEL_REGISTRY = {} def get_model(name: str): if name not in _MODEL_REGISTRY: _MODEL_REGISTRY[name] = build_model(name) # 加载权重 + 移动到 GPU return _MODEL_REGISTRY[name]这就是典型的全局单例模式。任何一个处理节点只要调用了get_model("speech_transcriber"),这个模型就会永久驻留在_MODEL_REGISTRY里,直到进程退出。Python 的模块级变量是没有自动回收概念的,只要注册表里还持有引用,模型对象就不会被垃圾回收。
更隐蔽的一点是,即使你手动执行del model,如果还有别的对象(比如 tokenizer、feature extractor、优化器状态)持有它的引用,或者模型内部有注册到torch.cuda的缓存,显存也不会真正释放。我后来在注册表外面包了一层引用计数和缓存淘汰策略,才把"只跑一条视频"的显存占用从 14GB 降到了 4GB 左右。这个后面详细说。
3.3 CUDA context 和显存碎片的隐藏成本
除了模型本身的权重,还有一个经常被忽略的显存黑洞:CUDA context 和缓存分配器。
PyTorch 在第一次执行任何 CUDA 操作时,会初始化一个 CUDA context,这个 context 本身就占 300MB 到 500MB 的显存。你加载一个 300MB 的小模型,实际显存增量可能是它的两倍。而 PyTorch 的缓存分配器(caching allocator)默认不会把所有显存返回给驱动,它会保留一部分作为缓存,方便下一次快速分配。这就是为什么你经常看到torch.cuda.empty_cache()之后,nvidia-smi里的显存占用还是没有完全降下来。
在 Cosmos Curator 的场景里,六个模型叠加,每个模型除了权重还有中间激活、注意力机制的 KV cache、beam search 的临时张量。这些叠加起来,显存自然就奔着 14GB 去了。要记住一个结论:多模型的显存成本不是简单相加,而是模型权重 + CUDA 运行开销 + 缓存碎片三者相乘。
4. 显存定位实战:怎么确认是谁吃掉了你的 GPU 内存
4.1 nvidia-smi 只能看到进程,看不到模型
排查的第一步,大家都会先看nvidia-smi。但说实话,它的信息粒度很粗:只能告诉你"某个 python 进程占了多少显存",完全看不到进程内部是哪个模型、哪部分张量在占空间。
我当时的判断依据有两个:一是日志里的模型加载顺序,二是用torch.cuda.memory_allocated()在关键节点上打印显存增量。把两者对照起来,才能确认"视觉编码器加载完,显存从 2GB 到了 5GB,ASR 加载完,从 5GB 到了 7GB",这种逐个击破的定位方式才真正有用。
4.2 用 torch.cuda.memory_summary 看张量分布
如果你用的是 PyTorch,最直接的工具是内置的显存总结:
import torch print(torch.cuda.memory_summary(device="cuda:0", abbreviated=False))它会输出当前进程在 GPU 上的所有显存分配明细,包括:
- 分配器当前缓存了多少块显存
- 活跃张量和空闲(可回收)张量分别占多少
- 每个段的大小,以及哪些段有碎片
我在排查的时候发现,模型权重只占显存的 60% 左右,剩下的 40% 都是推理过程中产生的中间激活和缓存碎片。这个比例在大模型场景里还会更夸张,因为 attention 机制的中间张量尺寸和序列长度是二次方关系。
如果memory_summary的输出太难看懂,也可以自己写一个简单的采样工具:
import torch class MemLogger: def __init__(self): self.prev = torch.cuda.memory_allocated() def step(self, tag: str): cur = torch.cuda.memory_allocated() print(f"[mem-log] {tag}: delta={cur - self.prev:.0f}MB total={cur:.0f}MB") self.prev = cur把它插到每个模型加载和推理节点前后,就能看到一条清晰的显存变化曲线。这个方法不需要改任何训练逻辑,纯粹是观察层面的埋点,非常安全。
4.3 加载日志埋点:还原模型加载顺序和耗时
除了显存,我还建议在模型构建函数外面包一层计时和显存记录的 wrapper,这样每个模型加载了多久、占了多少显存,都能被存到一个统一的日志里:
import time import torch def load_model_with_metrics(name, build_fn): before = torch.cuda.memory_allocated() t0 = time.monotonic() model = build_fn(name) after = torch.cuda.memory_allocated() print( f"[model-load] {name} took={time.monotonic() - t0:.2f}s " f"mem_delta={(after - before) / 1024**2:.0f}MB" ) return model实测下来你会发现一个很有意思的现象:有些模型的权重只有几百 MB,但加载耗时却比一个 7B 的量化模型还长。原因往往出在 tokenizer 或 processor 的初始化上,它们要读取大量词汇表文件和预处理配置。这一类外部依赖也是显存之外的隐性成本。
4.4 症状与原因对照表:快速判断该往哪查
如果你不想从头摸排,我整理了一张基于这次排查经验的对照表,遇到类似问题可以直接按图索骥:
| 症状 | 可能原因 | 优先排查项 |
|---|---|---|
| 显存高但 GPU 利用率低 | 模型全部常驻,推理任务量小 | 全局注册表、模型引用 |
| 加载日志出现不相关模型 | 任务编译期提前实例化全部依赖 | 任务类型与模型依赖声明 |
| 显存释放不干净 | PyTorch 缓存分配器保留缓存 | empty_cache后观察实际回落 |
| 同一模型重复加载多次 | 缺少单例管理或引用计数错误 | 注册表是否存在多个实例 |
| 跑几个任务后显存持续上涨 | 每个任务都在注册表里累积新模型 | 缓存 TTL 与淘汰策略 |
这张表的价值在于帮你快速收敛排查范围。很多同学一上来就盯着模型结构改,改了半天发现显存掉得不多,因为根本问题是生命周期管理,不是模型尺寸。
5. 改造方案:把"一串模型"变成"必要的那一两个"
5.1 最小依赖集合:按任务类型声明模型
我改造的第一步,是给 Cosmos Curator 引入"任务最小依赖集合"。说白了,就是明确告诉框架:这个任务类型只需要哪些模型,其余的全部不要加载。
TASK_MODEL_DEPS = { "visual_caption": ["vision_encoder", "caption_llm"], "audio_transcribe": ["speech_transcriber", "text_embedder"], "full_curation": [ "vision_encoder", "speech_transcriber", "ocr_reader", "caption_llm", "text_embedder", ], } def plan_models(task_type: str): required = set(TASK_MODEL_DEPS[task_type]) for name, model in list(_MODEL_REGISTRY.items()): if name not in required: release_model(name) return [get_model(name) for name in required]这个改动看起来简单,效果非常立竿见影。纯画面视频走visual_caption路径时,只会加载视觉编码器和多模态大模型,ASR 和 OCR 连碰都不会碰。我实测的显存占用直接从 14GB 降到了 6GB 左右。
要注意的是,这里必须考虑任务类型的动态判断。比如你无法预知视频里到底有没有对白,那么可以在抽帧和解码阶段做一次快速检测:如果音频轨能量接近静音,就跳过 ASR;如果画面里没有文字区域检测结果,就跳过 OCR。这种"预处理探针"能进一步把加载集合收窄。
5.2 用完即走:主动卸载与缓存淘汰
第二步是给模型注册表加上引用计数和 TTL 缓存淘汰。核心思路是:一个模型被加载后,记录它最近一次被使用的时间;当注册表里的模型数量超过阈值,或者某个模型的空闲时间超过设定值时,就主动把它卸载并释放显存。
import torch from collections import OrderedDict class ModelCache(OrderedDict): def __init__(self, capacity=3, ttl_seconds=300): super().__init__() self.capacity = capacity self.ttl = ttl_seconds def get(self, name): if name not in self: return None self.move_to_end(name) _, last_used = self[name] if time.monotonic() - last_used > self.ttl: self.release(name) return None self[name] = (self[name][0], time.monotonic()) return self[name][0] def load(self, name, build_fn): if name in self: self.move_to_end(name) return self[name][0] model = build_fn(name) self[name] = (model, time.monotonic()) if len(self) > self.capacity: self.release(next(iter(self))) return model def release(self, name): model, _ = self.pop(name) del model torch.cuda.empty_cache()关键点在于release的时候除了del model,还要调用torch.cuda.empty_cache()。虽然上面说过 empty_cache 不会把所有显存还回驱动,但至少能让缓存分配器把空闲块合并,给后续加载腾出连续空间,对降低碎片率非常有帮助。
5.3 权重常驻 CPU,推理时搬到 GPU
如果还是觉得高频切换时加载太慢,可以再上一层:模型权重常驻 CPU 内存,推理时再把权重搬到 GPU,推理完立刻搬回 CPU。
def infer_with_offload(model, inputs, device="cuda:0"): model = model.to(device) try: with torch.no_grad(): return model(inputs) finally: model.cpu()这种方案适合"模型不大但用得很频繁,且不想一直占显存"的场景。代价是每次推理都要多一次 H2D/D2H 的拷贝,延迟会多出几十到几百毫秒。但对视频处理这种本身就要好几秒甚至几十秒的任务来说,这点额外延迟完全可以接受。
我当时把 ASR 和 OCR 这两个"低频触发"的模型全部改成了 CPU offload 模式,显存峰值再降了 2GB 左右。整体体验是:任务耗时几乎没变,但显存压力小了一大截。
5.4 共享视觉骨干,避免重复计算与重复加载
还有一个更偏架构层面的优化:让不同任务共享同一个视觉编码器。比如画面理解任务和多模态摘要任务都要对关键帧做视觉特征提取,完全没有必要让两个模型各自维护一套视觉权重。
做法是把视觉编码器的输出特征缓存起来,作为公共中间结果供下游多个模型复用。这样"caption_llm"在加载时就不再需要内置的视觉编码器,可以直接引用前面已经算好的特征序列。显存省了,计算也省了。这个优化对批量处理特别有价值,因为不同视频的帧特征差异很大,但编码器本身是完全一样的。
5.5 显存水位监控与自动降级
最后一步,是给整个推理进程加一个显存水位监控。在每次加载模型之前检查当前显存利用率,如果超过阈值,就自动禁用掉非必要模型,或者把精度从 fp16 降到 int8。
def safe_get_model(name, build_fn, max_mem_ratio=0.75): current = torch.cuda.memory_allocated() total = torch.cuda.get_device_properties(0).total_mem if current / total > max_mem_ratio: print(f"[warn] memory high ({current/total:.0%}), skip {name}") return None return load_model_with_metrics(name, build_fn)这一层相当于给整套框架加了一个"安全气囊"。平时用不到,但一旦碰到极端场景,比如同时来了几个大任务的并发请求,它能防止进程因显存不足直接崩溃。
6. 最后聊两句实际体会
这次排查给我最大的一个感触是:工具跑得慢、资源占得高,真正的问题往往不在模型本身,而在模型生命周期管理。Cosmos Curator 不是唯一会这么干的项目,很多视频处理、多模态推理框架都有类似的设计惯性——为了吞吐量牺牲单任务的内存效率。
如果你也要做类似的优化,我的建议是从"最小依赖集合"和"显存埋点"这两件事同时开工。前者能立刻让显存掉下来,后者能告诉你到底是谁在偷偷占地方。等这两步做完,再去碰 CPU offload 和缓存淘汰这种更精细的机制,顺序一定不要反。
还有一个小技巧值得分享:改造完记得在 CI 里加一条"单条视频处理显存峰值"的回归断言。一个显存上限阈值,比任何代码 review 都更能防止这种问题再次出现。这在我们团队后续的迭代里帮了大忙,每次改动合入前都会自动跑一次,再也没出现过"一跑就爆显存"的尴尬。