一张消费级显卡能不能跑 Muse Spark Open,先别去看跑分榜。把三个数摆到纸上:这张卡实际能分配出去多少显存、所选精度的权重占多少、图像编码和前向激活还要吃掉多少。三个数加不进去,讨论并发没有意义;加得进去,剩下的余量就是能开的批量上限。顺序反了,通常会在加载完权重、第一次喂图的时候直接 OOM。
判断顺序建议固定:先从 nvidia-smi 读出真实可用显存,再按配置文件里的参数量乘每参数字节数得到权重体积,最后用同一张图在不同分辨率下实测激活增量。权重加激活留出安全余量后还装得下,才谈 batch=1 能不能跑通;batch 逐级上压,第一次 OOM 的前一档就是保守并发上限。显存不足时优先降精度或降分辨率,不要指望 swap 或内存换出来救,那只会让延迟变得不可用。
记下显卡真实可用显存与驱动版本
标称显存和可用显存不是一回事。桌面环境、浏览器硬件加速、残留的 Python 进程都会占掉一块。先在干净状态下抄下这几个字段,后面所有估算都以这份记录为起点。
nvidia-smi `--query-gpu`=name,driver_version,memory.total,memory.used,memory.free `--format`=csv
nvidia-smi `--query-compute-apps`=pid,process_name,used_memory `--format`=csv
需要抄下的是 name(确认具体型号,同型号不同显存版本很常见)、driver_version、memory.total 与 memory.free。第二行列出当前占卡的进程,看到不认识的 PID 就用 ps -p <pid> -o cmd= 确认来源;桌面环境的 Xorg 占用通常在系统重启后才会释放,测试前可以先切到无图形界面或者直接重启一次。
驱动与运行时的一致性检查同样要做:nvidia-smi 右上角显示的 CUDA Version 是驱动能支持的最高运行时版本,而 python -c "import torch; print(torch.version.cuda)" 是框架编译时的版本,后者不能高于前者。两者对不上时,通常表现为加载权重阶段报 no kernel image,而不是 OOM,容易误判成显存问题。
把权重体积按各精度档逐个算一遍
权重体积是唯一能提前算准的部分。公式很直白:参数量乘每参数字节数,再除以 1024 的三次方换成 GiB。
权重 GiB ≈ 参数量 × 每参数字节数 / 1024³
fp16 / bf16 : 2.0 字节/参数
int8 : 1.0 字节/参数,另需为量化 scale 留一点系数(建议按 1.05 左近取)
int4 : 0.5 字节/参数,组 scale 与打包开销建议按 1.1 左近取
参数量必须从模型文件目录里读,不要凭模型名猜。最省事的做法是直接读分片索引里的总字节数,这是文件实际大小,比任何推算都准;config.json 里的层数、隐层宽度、词表大小则用来交叉核对。
python - <<'PY'
import json, pathlib
root = pathlib.Path('./muse-spark-open')
idx = json.loads((root / 'model.safetensors.index.json').read_text())
total = idx['metadata']['total_size']
print('权重文件合计 GiB:', round(total / 1024**3, 2))
cfg = json.loads((root / 'config.json').read_text())
for k in ('num_hidden_layers', 'hidden_size', 'intermediate_size', 'vocab_size'):
print(k, cfg.get(k)) # 字段名以实际 config.json 为准,缺失就跳过
PY
拿到总字节数后,fp16 就按这个数估;int8 大约是一半,int4 大约是四分之一,再按上表系数补上量化开销。值得注意的是,int4 权重加载后往往还需要反量化到计算精度,这部分临时显存不能漏算,通常表现为加载完成瞬间峰值远高于静态权重体积。
为图像编码与前向激活单独留余量
只算权重是新手最容易踩的坑。Muse Spark Open 这类带视觉输入的模型,图像编码器吃显存的方式和文本不同:分辨率提高,切出的 patch token 数通常按平方增长,激活跟着涨。机器不同、框架版本不同,增量也不同,所以这里只能实测,不能套别人的数字。
做法是固定同一张图片内容,只改重采样尺寸,避免图像内容差异干扰结果。每档都用 reset_peak_memory_stats 重新统计,记录的是峰值而不是结束后的占用。
import torch
from PIL import Image
def peak_gib():
return torch.cuda.max_memory_allocated() / 1024**3
torch.cuda.reset_peak_memory_stats()
baseline = peak_gib() # 只加载完权重时的峰值
img0 = Image.open('sample.png').convert('RGB')
for w, h in [(512, 512), (768, 768), (1024, 1024)]:
img = img0.resize((w, h))
torch.cuda.reset_peak_memory_stats()
with torch.inference_mode():
out = model.generate(image=img, prompt='描述这张图')
print(w, h, '峰值 GiB', round(peak_gib(), 2),
'增量 GiB(实测)', round(peak_gib() - baseline, 2))
del out
torch.cuda.empty_cache()
表格里就把“增量”这一列标成实测值,并注明显卡型号、精度、驱动版本,别人复现时才知道边界在哪。如果 1024 这一档直接 OOM,说明留的余量不足,先把分辨率上限压下来,再考虑换精度。
从 batch=1 往上压到第一次 OOM
batch=1 跑通只说明装得下,不说明能并发。逐级加批量的目的不是找极限性能,而是找出第一次失败的档位,取它的前一档作为保守并发值。
import torch, gc
img = Image.open('sample.png').convert('RGB')
def try_batch(n):
torch.cuda.reset_peak_memory_stats()
try:
with torch.inference_mode():
for _ in range(n):
model.generate(image=img, prompt='描述这张图')
return True, torch.cuda.max_memory_allocated() / 1024**3
except torch.cuda.OutOfMemoryError:
return False, torch.cuda.max_memory_allocated() / 1024**3
for n in (1, 2, 3, 4, 6, 8):
ok, peak = try_batch(n)
print(f'batch={n} ok={ok} peak={peak:.2f}GiB')
gc.collect(); torch.cuda.empty_cache()
if not ok:
break
要分清两件事:上面这种串行重复调用,测到的是多请求排队时显存堆积的下限,不等于真实并发。真实并发需要把 n 份输入同时保留在显存里,或者用支持批处理的接口一次喂多张图,两者峰值通常更高。记录本上至少写三列:档位、该档稳定后的显存(连续跑几轮取稳定值)、失败时的报错原文。失败点前后差一大截,说明进入了显存碎片敏感区,取更保守的档位。
输出一张机器档位对照表
测完几轮,把结果整理成别人能直接套的表。行是精度档,列是分辨率与并发,格子里填显存峰值和是否通过,空白或未测就写未测,不要靠估。
| 精度 | 分辨率 | 并发 | 显存峰值(实测, GiB) | 是否通过 | 备注 |
|---|---|---|---|---|---|
| fp16 | 512×512 | 1 | 待填 | 待填 | 基准行 |
| int8 | 768×768 | 2 | 待填 | 待填 | 含量化开销 |
| int4 | 1024×1024 | 4 | 待填 | 待填 | 首次 OOM 前一档 |
表的抬头要带上显卡型号、显存总量、驱动版本和精度实现方式(原生量化还是加载后反量化),否则别人照着套会得到完全不同的结论。判断规则可以写进表的注释里:可用显存减去权重体积和图像激活增量后,剩下不到峰值的一半,就不要再往上加并发;精度换来的余量优先留给分辨率,而不是先堆并发,因为分辨率一超就是直接失败,并发超了通常还能靠排队扛过去。