半夜两点盯着终端里那行torch.cuda.OutOfMemoryError: CUDA out of memory,前面明明已经跑过一百多张图,什么参数都没动,它偏偏在这一张上崩了——这种经历但凡用 Stable Diffusion 出过一段时间图的人都有过。报错最烦的地方不在于它难,而在于它给的信息太少、太杂、太反直觉:明明提示显存不足,加了参数还是不足;明明提示某个模块找不到,重装了三次还在报同一个错。这篇内容就是把 Stable Diffusion 从加载模型到出图落盘的各类报错按故障域拆开,讲清楚每一类报错背后到底发生了什么,以及在不同硬件条件下最省事的验证路径。适合刚装好环境、跑第一张图就开始报错的新手,也适合已经能稳定出图、但一遇到换模型换前端就卡住的进阶使用者。我不打算给一份"报错对答案"的表,因为同一个报错文本可能来自完全不同的原因,真正有用的是判断链路。
1. 报错先分层,再谈修:把一条日志拆成五个故障域
1.1 五个故障域的划分依据
Stable Diffusion 的推理链路很长,从 Python 解释器加载动态库开始,到最终把一张 PNG 写到磁盘,中间任何一个环节出问题,用户看到的都是"跑不出来"。如果不做分层,把所有报错混在一起看,排查效率会低得离谱——你会去给一个文件损坏的模型反复调显存参数,或者给一个显存不足的问题反复重装依赖。
我习惯把它切成五个故障域,划分标准是"错误发生在数据流的哪个位置":
- 依赖层:Python 包、CUDA 运行时、显卡驱动之间的版本契约。这一层的报错通常在程序还没开始加载模型时就抛出来了,关键词是
undefined symbol、libcudart.so、ImportError、DLL load failed。 - 模型层:权重文件的完整性、序列化格式、张量键名、架构版本。这一层报错发生在加载权重的那几秒里,关键词是
UnpicklingError、invalid load key、Error while deserializing header、size mismatch、Missing key(s)。 - 显存层:分配失败与内存碎片。关键词非常统一,就是
out of memory,但要细分是首次分配就失败,还是跑到中途才失败。 - 精度层:半精度、单精度、注意力算子的实现是否可用。关键词是
not implemented for 'Half'、expected scalar type、xFormers、No operator found。 - 输出层:VAE 解码后的像素处理、图像编码、文件写入。这一层的报错最隐蔽,因为程序往往不报错,只是给你一张全黑的图。
为什么这样分?因为这五层的时间成本完全不一样。依赖层的问题可能需要半小时重装环境,模型层的问题五分钟换个文件就行,显存层的问题可能只需要改一个数字。先判断在哪一层,才能知道该花多少时间。
1.2 日志里真正该看的三段位置
大部分人看日志的习惯是从上往下读,这恰恰是最低效的读法。一条几百行的堆栈,真正有信息量的只有三处。
第一处是最后一行。Python 的异常最终都会以异常类型: 描述的形式收尾,这一行决定了你该去查哪个故障域。OutOfMemoryError和RuntimeError: expected scalar type完全是两条路。
第二处是倒数五到十五行。这是异常实际抛出的位置,通常会带上具体的张量形状、文件路径、层名。比如size mismatch for model.diffusion_model.input_blocks.1.1.proj_in.weight: copying a param with shape torch.Size([320, 320, 1, 1]) from checkpoint,这一行直接告诉你两个模型架构对不上,不用再猜。
第三处是堆栈的第一帧。它告诉你入口在哪,是前端的某个按钮触发的,还是后台的模型切换逻辑触发的。这一点在排查"只有某个功能报错、其他功能正常"时特别有用,因为它能定位到具体是哪个模块在调用。
还有一个容易忽略的细节:Python 在异常链里会打印During handling of the above exception, another exception occurred。出现这句话意味着真正的根因在上面那一段,下面那段只是二次抛出的结果。我见过太多次拿着下面那段去搜,搜出来的全是无关答案。
1.3 关键词到故障域的速查表
有了分层意识,接下来需要的是一张把报错关键词映射到故障域的对照表。下面这张表是我自己长期维护的,覆盖了九成以上的常见情况。
| 报错关键词 | 所属故障域 | 首要怀疑对象 |
|---|---|---|
CUDA out of memory | 显存层 | 分辨率、批次、注意力实现、碎片 |
not implemented for 'Half' | 精度层 | 半精度落在了 CPU 张量上 |
expected scalar type Half but found Float | 精度层 | 混合精度下的类型不一致 |
UnpicklingError/invalid load key | 模型层 | 文件下载不完整或格式错误 |
Error while deserializing header | 模型层 | safetensors 文件头损坏 |
Missing key(s) in state_dict | 模型层 | 架构版本不匹配 |
undefined symbol | 依赖层 | 编译时的运行时版本与当前不一致 |
CUDA driver version is insufficient | 依赖层 | 驱动版本低于运行时要求 |
xFormers can't load C++/CUDA extensions | 精度层 / 依赖层 | 扩展与框架版本不配套 |
| 出图全黑、全绿 | 输出层 | VAE 精度溢出或 VAE 与模型不匹配 |
保存时报cannot write mode | 输出层 | 图像色彩模式与目标格式不符 |
注意:这张表只用于快速缩小范围,不能直接当结论。同一个关键词在不同环境下根因可能完全不同,比如
undefined symbol既可能是包版本冲突,也可能是环境变量里混进了另一个路径的同名库。
2. 模型加载类报错:文件头、键名与架构错配
2.1 模型文件没下完,比大多数人想的更常见
模型加载失败里,占比最高的原因不是架构不兼容,而是文件本身就是坏的。这件事在大文件上尤其普遍:一个几 GB 的权重文件,下载过程中网络波动断了,浏览器或者下载工具给你留了一个半截文件,扩展名还是对的,图标还是对的,程序也会老老实实去加载它,然后在一个完全随机的位置报错。
怎么判断是不是这一类?看报错文本。如果是旧格式的打包文件,损坏通常表现为UnpicklingError: invalid load key, '<',那个<基本就是 HTML 页面的尖括号——你下载到的其实是一个错误页面或者跳转页面,只是被存成了模型扩展名。如果是新的张量格式,损坏通常表现为接近文件头部的反序列化失败,因为这种格式在文件开头有一段 JSON 描述,长度信息对不上就会立刻报错。
还有一个更隐蔽的情况:文件大小是对的,但内容是错的。比如从某个不稳定的源断点续传,拼接出了字节数正确、中间段错乱的文件。这种最难查,因为所有"看起来能用"的信号都对。
我的处理习惯很简单:新模型第一次加载失败,先不做任何技术排查,直接换一个来源重新下载,并且在下载完成后核对一次文件大小和校验值。这一步能省掉后面大量的无效调试。
2.2 用十几行脚本给模型文件做体检
与其靠程序报错来倒推,不如在加载前主动检查。这种新格式的权重文件结构其实很简单:开头 8 个字节是一个小端序的无符号整数,表示后面 JSON 头的长度;紧接着是 JSON 头,描述每个张量的数据类型、形状和数据偏移;再往后才是实际的张量数据。这个设计的好处是可以只读头部就知道里面有什么,不用把整个文件载入内存。
import json import struct import os def inspect_model(path): size = os.path.getsize(path) print(f"file: {path}") print(f"bytes: {size:,}") with open(path, "rb") as f: raw = f.read(8) if len(raw) < 8: print("头部长度不足 8 字节,文件几乎肯定不完整") return header_len = struct.unpack("<Q", raw)[0] if header_len <= 0 or header_len > 100 * 1024 * 1024: print(f"头部长度异常: {header_len},文件损坏") return header = json.loads(f.read(header_len)) meta = header.pop("__metadata__", {}) print(f"tensor count: {len(header)}") print(f"metadata keys: {list(meta.keys())[:8]}") # 抽查前三个张量的描述 for i, (k, v) in enumerate(header.items()): print(f" {k} -> {v.get('dtype')} {v.get('shape')}") if i >= 2: break这个脚本能解决三类问题:一是判断文件是不是被截断,二是看里面有没有正常的张量描述,三是通过__metadata__里的信息大致判断这是哪个架构的权重。如果脚本在json.loads这一步就炸了,那基本不用往下查了,文件是坏的;如果脚本能正常跑完但模型仍然加载失败,问题就一定在键名或架构上,排查范围立刻收窄一半。
2.3 键名不匹配的三种真实成因
Missing key(s) in state_dict和Unexpected key(s) in state_dict这两个警告是模型层的另一个高频报错。很多人看到一长串键名就慌了,其实要先分清是哪一种情况。
第一种是架构代差。不同代的模型在文本编码器宽度、注意力头维度、层数上都不一样。把旧代的权重往新一代的模型结构里塞,形状对不上,一定报size mismatch。这种情况没有任何补丁能救,只能换对应的权重。判断方法很简单:看报错里torch.Size的具体数字,如果文本编码相关的张量宽度是 768 而目标期望 2048,就是典型的两代模型互串。
第二种是封装格式差异。同一个模型可能被导出成完整包、只含去噪网络、只含文本编码器等不同形态。完整包加载进只需要单一组件的流程里,就会冒出一堆Unexpected key。这类警告通常不致命,程序会自动忽略多余部分,但如果你发现出图效果明显不对,就要怀疑是不是关键的组件根本没被加载进去。
第三种是附加组件的键名前缀。微调权重、嵌入向量这类附加组件,键名往往带有一套转换后的前缀规则。当前端的解析逻辑版本和组件导出时的规则版本不一致,就会出现"文件在、能读到、但一个键都匹配不上"的诡异状态。这种情况的典型症状是:加载时没有任何红色报错,只有几行黄色警告,出图效果和没加载完全一样。
我的判断顺序是:先看有没有size mismatch,有就是架构问题,直接放弃;再看Missing key的数量,如果只有零星几个且集中在少数层,多半是版本微调差异,可以忽略;如果Missing key数量和总键数量级相当,就是格式或前缀问题。
2.4 VAE 与外挂组件叠加时的隐性冲突
VAE 是个特别容易出问题的组件,因为它涉及"替换内置组件"这个操作。默认情况下,模型自带解码器,你要是额外挂载一个外部 VAE,程序会用外部的替换内置的。这里有两个坑。
坑一是精度不匹配。相当一部分外部 VAE 在单精度下正常、半精度下溢出,表现就是出图全黑或大面积为灰。这类问题不会报错,只会给你一张废图,排查起来极其费劲,因为所有日志都是绿的。经验做法是:挂载外部 VAE 时同步开启对应的精度保护选项,让解码环节单独用更高精度跑。
坑二是版本代差。新一代模型的隐空间通道数与旧代不同,把旧代的 VAE 挂上去,要么在加载时形状报错,要么在解码时输出一张完全无意义的噪点图。判断方法很直接:看 VAE 权重里第一个卷积层的输入通道数,和当前模型的隐空间通道数对比,不相等就一定不能混用。
还有一类问题是多个附加组件同时生效时的优先级冲突。比如同时加载了改变风格的微调权重和一个独立的嵌入向量,两者都试图影响文本编码器的输出,结果互相覆盖。这种问题没有报错,只有"效果和预期不符",唯一的排查手段是每次只加一个组件,逐个做对照出图。
3. 显存不足的真实账本:从峰值张量说到内存碎片
3.1 一组估算公式和几条实测参考线
想解决显存问题,先得知道显存花在哪了。推理阶段的显存占用可以粗略拆成三块:常驻权重、激活值、峰值临时张量。
常驻权重很好算:参数量乘以每个参数的字节数。半精度下每个参数 2 字节,单精度 4 字节。以常见的旧代模型为例,去噪网络约 8.6 亿参数,半精度约 1.7 GB;文本编码器约 3.4 亿参数,约 0.7 GB;解码器约 8300 万参数,约 0.17 GB。加起来 2.6 GB 左右,这就是理论下限。
激活值才是变量。它跟隐空间分辨率直接相关,而隐空间的边长是像素边长除以 8。512 像素对应 64×64 的隐空间,注意力层的序列长度是 4096;768 像素对应 96×96,序列长度 9216。注意力的计算量随序列长度的平方增长,所以从 512 提到 768,理论上注意力开销涨了约 5 倍,从 512 提到 1024 涨了约 16 倍。这就是为什么分辨率一提高,显存需求不是线性上升而是断崖式上升。
下面的参考线来自我在几台不同显存容量机器上的实测,注意是"能稳定出图"的保守值,不是极限值:
| 场景 | 半精度下峰值占用(约) | 建议显存 |
|---|---|---|
| 512×512 单张,旧代模型 | 4 GB | 6 GB |
| 768×768 单张,旧代模型 | 7 GB | 8 GB |
| 1024×1024 单张,旧代模型 | 11 GB | 12 GB |
| 1024×1024 单张,新代模型 | 12 GB | 16 GB |
| 512×512 批次 4,旧代模型 | 9 GB | 12 GB |
还有一块容易被忽略的峰值:解码阶段的像素空间运算。采样过程本身可能只占 5 GB,但最后把隐空间还原成 1024×1024×3 的像素图时,中间激活会冲到 3 GB 以上。这就是为什么有些配置"采样跑到 100% 然后崩了",崩点在解码而不是采样。
3.2 OOM 的三种面孔:首次分配、碎片化、泄漏
显存不足的报错文本一模一样,但成因完全不同,处理方式也完全不同。
首次分配失败最容易识别:程序刚启动,加载完模型,第一次前向传播就炸。这时显存占用曲线是一条陡直的上升线然后断掉。原因通常很直接——分辨率、批次或者模型大小超出了物理容量。处理方法就是降分辨率、降批次或者换更省显存的注意力实现。
碎片化是最反直觉的一种,也是最多人踩坑的地方。症状是:之前一直能跑的配置,突然报 OOM;你重启程序,同样的配置又能跑了;跑了十几张之后再次 OOM。这就是内存碎片。框架的显存分配器会缓存之前用过的块,当新请求的尺寸和缓存块不匹配时,即使总空闲显存足够,也会分配失败。判断依据是看分配器统计:如果"已保留"的数值明显大于"已分配"的数值,且两者差距在持续扩大,就是碎片。
泄漏是慢性病。表现为随着出图张数增加,占用缓慢爬升,从 5 GB 到 6 GB 到 8 GB,最后撑爆。常见诱因是循环中持有的张量没有被释放,或者切换模型时旧模型没被真正回收。这类问题在短时间测试里完全看不出来,必须跑几十上百张才会暴露。
3.3 不换卡也能压下来的六组参数
显存不够的时候,绝大多数人的第一反应是"得换卡了"。其实在动手之前,至少还有六组参数可以调,而且它们的组合效果是叠加的。
第一组,降分辨率配合后期放大。先用 512 出图,再用放大模块做 2 倍放大。这比直接跑 1024 省得多,因为放大阶段可以在低显存模式下分块执行。代价是细节层次略损失,但对大多数用途足够。
第二组,注意力实现切换。这是收益最大的一步。主流的注意力实现有三四种,内存占用排序大致是:朴素实现最费,分块注意力明显更省,基于特定库的优化实现最省但要版本对得上。切换到分块实现通常能省 30% 到 50% 的峰值,而且几乎不影响出图质量。切换到不匹配的实现会直接报错,所以每次只改一项。
第三组,解码分块。把解码阶段的运算切成小块依次处理,峰值能降一大截,代价是解码变慢。这个选项在"采样能跑完、解码崩"的场景里几乎是唯一解。
第四组,批次改小,用串行代替并行。批次 4 不等于四倍显存,但确实是线性增长。改成批次 1 跑四次,用队列串起来,总耗时增加不多,峰值降一半以上。
第五组,模型分片加载。把不参与当前计算的组件先挪到内存里,按需换入。这对显存紧张的机器很有效,代价是每次切组件会有几秒的等待。
第六组,优化器与缓存策略。如果同时还在做微调训练,梯度检查点和低秩适配能把显存需求压到原来的三分之一左右,这个就不展开说了。
提示:这几组参数不要一次性全开。全开之后速度可能慢到无法接受,而且一旦出错你根本不知道是哪一项导致的。正确做法是每次只改一项,改完跑三张图确认稳定,再叠加下一项。
3.4 长跑泄漏的定位手法
怀疑是泄漏而不是容量不足的时候,得用数据说话而不是靠感觉。框架自带一套显存统计接口,能分别读出已分配和已保留的数值。
import torch, gc def mem_report(tag=""): torch.cuda.synchronize() allocated = torch.cuda.memory_allocated() / 1024**3 reserved = torch.cuda.memory_reserved() / 1024**3 print(f"[{tag}] allocated={allocated:.2f}GB reserved={reserved:.2f}GB") # 在出图循环里,每张图前后各打一次 for i in range(50): mem_report(f"before-{i}") # ... 出图逻辑 ... gc.collect() torch.cuda.empty_cache() mem_report(f"after-{i}")判读方法很关键:如果每轮结束后allocated回到接近初始值,而reserved只增不减,那就是碎片问题,加empty_cache能回收一部分;如果每轮结束后allocated都比上一轮高几十兆,那就是真泄漏,得去查循环里哪里持有引用了。
一个具体的排查技巧:在循环里打印所有还活着的图像对象的数量。前端如果没有在每轮结束后主动释放图像句柄,列表会一直增长,每个句柄背后都是一块显存或内存。这类问题在批量任务里特别常见,而且表现完全像显存不足。
碎片问题还有一个环境变量层面的缓解手段,就是给分配器设置一个最大分块尺寸,让它倾向于复用而不是切分。这个手段不是万能的,但在混合尺寸的工作流里(有的图 512、有的图 768)效果明显。
4. 精度与后端环境:那些看起来玄学的 dtype 报错
4.1 Half 与 Float 的边界在哪里
LayerNormKernelImpl not implemented for 'Half'这个报错,第一次见到的人会觉得莫名其妙——明明显卡支持半精度,为什么某个算子说没实现。
原因是这样的:半精度支持是算子级别的,不是设备级别的。同一块显卡上,卷积、矩阵乘这些主力算子通常都有高效实现,但某些归一化、某些特殊激活函数在小尺寸输入上可能没有对应的半精度内核,框架就会直接抛错而不是悄悄降级。这个报错的关键是它总会带一个算子名,看到算子名就知道问题出在哪个环节。
真正让这个报错变得棘手的,是它经常出现在"大部分流程用显卡、少数环节被迫用处理器"的混合场景里。比如某些情况下文本编码器的某个归一化层被放到了处理器上执行,而处理器根本不支持半精度浮点,于是报错。这时候解法不是去修那个算子,而是让整个流程统一精度,或者把该环节强制提升到单精度。
expected scalar type Half but found Float是另一个方向的错:某处张量是单精度,公式期望的却全是半精度。常见于手动拼接的输入(比如你自己用图像库读的图转成张量后忘了统一类型),或者外挂组件用了不同精度导出。这个错的解法很直接,找到报错里给的形状,对照检查那个张量的来源。
实操上有一个很实用的分界原则:显卡上的主力计算尽量用半精度省显存,编码与解码这两端尽量用单精度保数值稳定。这条原则能避开绝大部分精度类报错。
4.2 注意力后端的四选一逻辑
注意力实现的选择是显存和速度的核心杠杆,也是最容易出兼容问题的环节。常见的几种实现各有各的前提:
- 朴素实现:兼容性最好,任何环境都能跑,但显存和速度最差。
- 分块实现:纯 Python 层面做切块,不依赖额外编译,兼容性好,显存收益明显,速度略慢于优化实现。
- 优化库实现:依赖预编译扩展,对框架版本和运行时版本极其敏感,速度最快,显存最省,但版本一变就失效。
- 框架原生优化实现:作为框架内置选项,兼容性相对好,收益介于朴素和优化库之间。
选择的逻辑不是"哪个最好用哪个",而是"哪个在当前环境下真的能加载"。判断方法:启动时看日志里有没有加载成功的提示。优化库实现加载失败时,程序通常不会崩溃,只会静默回退到别的实现,你在完全不知情的情况下用着更慢的方案。这一点必须主动确认。
版本冲突的典型表现是xFormers can't load C++/CUDA extensions。这句话的意思不是"库坏了",而是扩展编译时针对的框架版本和当前加载的框架版本不一致。解法只有两条:要么把扩展降级到与框架匹配的版本,要么升级框架同时升级扩展。绝不能只升一个。
4.3 驱动、运行时与框架版本的三方对齐
依赖层的报错里,CUDA driver version is insufficient for CUDA runtime version是最需要理解原理的一个。
这三者的关系是:显卡驱动提供一个最高支持的运行时版本;框架在编译时绑定一个具体的运行时版本;运行时版本必须不高于驱动支持的上限。驱动是向下兼容的,所以升级驱动通常能解决运行时版本过高的问题;反过来,如果驱动老旧而框架很新,就只能升级驱动,没有别的办法。
用一条命令就能看清当前状态:
nvidia-smi右上角的那个CUDA Version指的是驱动支持的最高运行时版本,不是已安装的版本。要查框架实际绑定的版本:
python -c "import torch; print(torch.version.cuda); print(torch.__version__)"两个数字对比:如果框架绑定的版本高于驱动支持的上限,就会报上面那个错;如果框架打印出的是None,说明装的是处理器版本,显卡根本用不上。
还有一个高频的坑是环境里同时存在多个框架安装。比如系统级装了一个、用户目录装了一个、虚拟环境里又装了一个,导入时加载的可能是你不想要的那个,然后就出现各种undefined symbol。查看实际加载路径的命令很关键:
python -c "import torch; print(torch.__file__)"如果路径不是你预期的虚拟环境路径,那就说明环境污染了,先把多余的清理掉再说。我自己踩过最惨的一次,是系统里有一份旧版本,虚拟环境里装新版本时因为已经"满足条件"而跳过了安装,结果跑起来加载的还是旧的那份,报了一堆莫名其妙的符号错误,查了两个小时。
4.4 一个最小复现脚本
遇到精度类或依赖类报错,我习惯先写一个十几行的最小脚本,把变量降到最少。这么做的好处是排除前端代码、插件、工作流配置的所有干扰。
import torch print("torch:", torch.__version__) print("cuda runtime:", torch.version.cuda) print("cuda available:", torch.cuda.is_available()) if torch.cuda.is_available(): print("device:", torch.cuda.get_device_name(0)) x = torch.randn(64, 64, device="cuda", dtype=torch.half) layer = torch.nn.LayerNorm(64).cuda().half() try: y = layer(x) print("half layernorm ok", y.shape) except Exception as e: print("half layernorm failed:", type(e).__name__, e) a = torch.randn(512, 4096, device="cuda", dtype=torch.half) try: b = torch.nn.functional.scaled_dot_product_attention( a.unsqueeze(0), a.unsqueeze(0), a.unsqueeze(0)) print("attention ok", b.shape) except Exception as e: print("attention failed:", type(e).__name__, e)跑一遍这个脚本,就能一次性确认三件事:显卡是否真的被用上了、半精度算子是否齐全、注意力实现是否可用。如果这个脚本全绿而完整程序报错,那问题一定出在程序层面而非环境层面,可以直接跳过所有重装依赖的步骤。
5. 采样之后的翻车:VAE 解码、黑图与保存失败
5.1 全黑、全绿、全灰分别指向什么
最消耗耐心的不是报错,是没报错。程序显示完成,进度条走到 100%,然后你得到一张纯黑的图。
全黑最常见的原因是半精度下的数值溢出。解码阶段最后一层激活函数的输出范围很窄,半精度能表示的范围虽然够大,但中间累积误差会把它推到边界之外,一旦溢出就变成无效值,反传回像素就是零。解决办法是让解码环节单独用单精度,其他环节保持半精度,这样显存代价很小。
全绿或者整片偏色通常指向组件不匹配。隐空间通道数不一致导致的解码,输出不是黑而是无意义的色块。另一种可能是外部 VAE 与主模型来自不同的训练体系,虽然维度对得上,但分布对不上,出来就是偏色。判断方法:把外部 VAE 去掉,用内置的跑一张,如果正常就是 VAE 的问题。
整体发灰、对比度极低多半是缩放因子的问题。不同代模型的隐空间缩放系数不一样,用错系数会让解码出的像素值整体压缩在一个很窄的区间里。这类问题看起来像"风格不对",实际上是个数值问题。
还有一种情况是全图被噪点覆盖。这通常意味着采样阶段实际上没跑完或者步数被设成了极低值,也可能是调度器与模型不配套。检查方法是看日志里报告的步数和实际设置是否一致。
5.2 批量出图时的内存缓慢爬升
跑单张一切正常,跑批量跑到三十张左右开始变慢,跑到五十张直接崩——这种模式几乎可以确定是内存或显存没有及时回收。
常见的原因有四个,按出现频率排序:
第一个是图像对象没有主动释放。前端每生成一张图都会创建一个图像句柄,如果批量任务的循环里把它塞进一个结果列表方便最后统一展示,那这个列表就是显存泄漏源。解法是改为逐张写入磁盘、只保留路径字符串。
第二个是中间张量留在了计算图里。推理时如果没有正确使用无梯度上下文,框架会为每一步保留计算图信息以便反传,长跑下来积累的量非常可观。这个错误在自定义脚本里很常见,用前端的时候反而少。
第三个是模型缓存的策略问题。很多前端会把用过的模型留在显存里加速下次调用,这在切换频繁的工作流里会导致多个模型同时驻留。批量任务开始前先清空模型缓存是个好习惯。
第四个是日志与预览图的累积。有些前端会保留每一步的中间预览,这些低分辨率图看着小,几百张加起来也是几百兆。
5.3 落盘环节的低级错误
保存失败的报错通常很直白,但有一类特别隐蔽:路径里有特殊字符。某些图像库在拼接路径时对非 ASCII 字符的处理不够稳,尤其是路径中包含会被误认为编码前缀的字符时。解决方式很土但有效:把输出目录换成纯 ASCII 的短路径,比如根目录下一个两三个字母的文件夹。
另一个是色彩模式的问题。有些流程输出的图是带透明通道或者高动态范围的模式,直接存成常见格式会报cannot write mode。处理方法是保存前统一做一次模式转换,把通道和位深都规范化。
磁盘满这件事看起来很低级,但它确实发生在我身上过不止一次。上千张高分辨率 PNG 能吃掉几十 GB,而我习惯把输出目录放在系统盘。现在我的做法是在开始批量任务前先算一下预估体积,用分辨率乘通道乘大概的压缩率,再和剩余空间比一下。
6. 我压箱底的排查顺序与三条反常识经验
6.1 一份按顺序执行的清单
前面几节讲的是原理,真到了要动手的时候,需要一份固定顺序的清单,避免东一榔头西一棒子。下面这个顺序是按"排除成本从低到高"排的,前面的步骤能排掉大部分问题,后面的步骤才需要动环境。
- 读日志最后一行,确定异常类型,对照第一节的表格确定故障域。
- 读倒数十行,找出具体的文件路径、张量形状、层名。
- 检查是否有
During handling of the above exception,有就往上看原始异常。 - 确认报错是新出现的还是历史遗留的,用"上一张能跑通的配置"做一次对照。
- 模型层:跑一遍文件体检脚本,确认文件头和张量数量正常。
- 模型层:核对权重与模型架构的代数是否一致,看张量宽度是否匹配。
- 显存层:确认崩点是首次前向、采样中途还是解码阶段,三者解法完全不同。
- 显存层:降分辨率到 512、批次降到 1,做一次最小负载验证。
- 显存层:打开解码分块,看是否解决"采样完就崩"。
- 精度层:用最小复现脚本确认半精度算子和注意力实现是否可用。
- 精度层:让解码环节回到单精度,其他保持半精度。
- 依赖层:对比驱动支持的运行时版本和框架绑定的版本。
- 依赖层:确认框架的实际加载路径是不是当前虚拟环境。
- 依赖层:确认注意力扩展的编译版本与框架版本严格匹配。
- 前端层:禁用所有插件与自定义节点,用纯净配置再跑一次。
- 输出层:换纯 ASCII 短路径,换常见图像格式,确认磁盘剩余空间。
这个清单的价值不在于每一步都做,而在于当你卡住的时候知道下一步该做什么,而不是无头苍蝇一样反复重装。
6.2 三条反常识经验
第一条:绝大多数"突然坏了"其实是配置漂移,不是硬件故障。我见过太多人一报错就怀疑显卡坏了、内存坏了,然后花几个小时跑压力测试。实际上九成的情况是你不小心切换了模型、改了一个参数、或者某个自动更新把扩展升级了。判断方法是回想最后一次成功出图之后动了什么,哪怕是改了个默认分辨率这种小动作。如果实在想不起来,就找一份确定可用的配置做对照,不要凭记忆。
第二条:报错文本里的数字比文字有用得多。人们习惯搜索报错的文字部分,但那部分往往高度通用。真正唯一的信息是里面的形状、维度、路径、版本号。比如size mismatch后面紧跟的两个形状数字,直接告诉你哪一维不匹配,据此就能判断是架构代差还是封装差异。养成先看数字后看文字的习惯,能省掉大量搜索时间。
第三条:能跑通的环境,备份比升级更值钱。这个生态的版本耦合非常紧,框架、运行时、扩展、插件四者的版本必须成套。一旦某次升级破坏了这套组合,回滚的难度远高于重装。我的做法是每配好一套稳定环境,就把依赖清单完整导出成一份锁版本文件,同时把工作目录整个打包留一份。下次要装新环境时用独立目录,绝不覆盖旧的那份。这个习惯让我在多次版本动荡里保住了可以随时干活的环境,也让我有底气去做各种实验。
最后再补一个实践中很好用的小技巧:给每个模型文件在文件名里带上架构代数和量化方式,比如把代数、精度、用途直接写进文件名。加载报错时,第一眼看文件名就能判断是不是拿错了模型,比打开程序看配置快得多。这个习惯看似不起眼,但在模型数量上百个之后,能省下的排查时间相当可观。