为什么 W4A8 / W4A4 ConvRot、NVFP4 在我们的 RTX 5090 上都没有省显存

临时报告 · 追到 comfy_kitchen 0.2.28/0.2.31 内核源码级别 · 目标机器 viggle_new_5090 (RTX 5090 ×8, compute capability 12.0)

一句话结论: 在我们的 RTX 5090(Blackwell,compute capability 12.0)上, comfy-kitchen 的原生 INT4 张量核心加速被硬编码关闭——只对 major == 8(Ampere/Ada,即 3090/4090/A100 这一档)生效。 所以无论是 NVFP4(weight-only)、W4A8 ConvRot,还是真正的 W4A4 ConvRot, 在我们机器上实际执行的都不是"低比特张量核心矩阵乘",而是"先把低比特权重解包/反量化回 INT8 或 BF16, 再跑一次普通的 INT8/BF16 矩阵乘"——多了一步转换开销,却没拿到该有的算力/显存收益。 这也是为什么"别人在 4090 上能跑得很好"(4090 属于 major==8,吃得到原生加速),而我们在 5090 上测出来 W4A8 反而比生产用的 int8_convrot 还多占 2GB 显存。

先回答你的两个具体问题

1"必须转换"指的是什么、发生在哪?

有两处完全独立的"转换"开销,分别对应不同的量化格式:

2W4A4 是不是就不需要转换了?—— 不是,在我们的硬件上依然需要。

W4A4 的设计初衷确实是"权重和激活都是 4bit,理论上可以用真正的 INT4×INT4 张量核心,不用转换"。 但 comfy_kitchen 的 CUDA 后端在真正调用 INT4 MMA 之前,会先检查显卡是否支持:

def _cuda_device_supports_native_int4_mma(tensor: torch.Tensor) -> bool:
    if not tensor.is_cuda or _FORCE_INT4_INT8_FALLBACK:
        return False
    major, _minor = _cuda_device_capability(tensor.get_device())
    # The current ConvRot W4A4 kernel emits m16n8k64 s4 MMA, which is the
    # sm80+ integer MMA shape. Hopper is routed through the INT8 fallback for
    # better behavior with this implementation.
    return major == 8

comfy_kitchen/backends/cuda/__init__.py:293-300 (确认 0.2.28 已装版本与最新发布版 0.2.31 完全一致,未变)

实测我们机器:

>>> torch.cuda.get_device_name(0)
'NVIDIA GeForce RTX 5090'
>>> torch.cuda.get_device_capability(0)
(12, 0)

major=12,不等于 8,直接判 False。RTX 4090 是 Ada Lovelace, compute capability 8.9,major=8,吃到原生加速——这就是"4090都能跑他们的" 背后的真实原因:4090 用户走的是真·INT4 张量核心路径,我们 5090 走的是下面这条 fallback:

if linear_dtype == "int8" or not (
    _cuda_device_supports_native_int4_mma(x2d) or _should_use_turing_int4(x2d)
):
    qact_int8, x_scale = quantize_int8_rowwise_convrot64(x2d, convrot_groupsize)  # 激活值先转 int8
    ...
    out = _int4_weight_int8_act_gemm_dequant_chunked(   # 权重 int4 解包成 int8,再做 INT8×INT8 GEMM
        qact_int8, qweight, x_scale, wscales, bias, x.dtype)

comfy_kitchen/backends/cuda/__init__.py:1231-1290 (convrot_w4a4_linear 的完整分支逻辑)

也就是说,在 5090 上,W4A4 实际执行的矩阵乘跟 W4A8 完全一样——都是"解包 int4 权重到 int8 + INT8 GEMM"。 W4A4 唯一的区别是激活值本来想走 int4(quantize_int4_rowwise_convrot64),但因为硬件门槛没过, 这条真 int4 路径根本没被触发,退回去和 W4A8 用的是同一段 int8 fallback 代码。 两者在我们机器上是同一件事,W4A4 不会比 W4A8 更省。


为什么之前测的 NVFP4 也没省显存(补充细节,呼应你问的"有做过 nvfp4 的吗")

做过,而且这次翻源码把机制钉死了。我们自制的 minimax_h3_ref2va_pruned_nvfp4.safetensors 是 weight-only 配方(参照 lilcheaty/MiniMax-H3-NVFP4),检查了实际存的 key:

blocks.0.mlp.fc1.weight        U8   [28672, 2688]      # 打包的 fp4 数据
blocks.0.mlp.fc1.weight_scale  F8_E4M3 [28672, 336]     # block scale
blocks.0.mlp.fc1.weight_scale_2 F32  []                 # 二级 scale
# 没有 input_scale / pre_quant_scale —— 说明激活值量化的标定数据根本不存在

没有 input_scale,ComfyUI 就不可能对激活值做 fp4 量化,只能走"weight_only_quant"分支:

if weight_only_quant:
    weight, bias, offload_stream = cast_bias_weight(
        self, input=None, dtype=self.weight.dtype, device=input.device,
        bias_dtype=input.dtype, offloadable=True,
        compute_dtype=compute_dtype, want_requant=True,
    )
    weight = weight.to(dtype=input.dtype)   # <-- 整层权重现场转回 bf16
else:
    ...
x = self._forward(input, weight, bias)      # <-- 然后就是普通 bf16 矩阵乘,没有走 nvfp4 的 cuBLAS FP4 GEMM

comfy/ops.py:1340-1367

换句话说,我们的 NVFP4 checkpoint 从来没有机会用上 comfy_kitchen 里那条真正的 scaled_mm_nvfp4(cuBLAS 原生 FP4×FP4 GEMM,理论上确实能加速+省显存)——因为那条路径要求 输入激活值也必须先转换成 FP4,而我们的 checkpoint 没有做那一步所需的标定 scale。 所以我们测到的"nvfp4 更慢、还得配合 LoRA bypass"完全是 weight-only 反量化+bypass 双重开销叠加的结果, 不是 FP4 张量核心不行,而是我们这份 checkpoint 从格式上就没资格用它。

要用上真正的 scaled_mm_nvfp4 加速,需要一份带完整 input_scale/pre_quant_scale 标定数据的"真混合精度" NVFP4 checkpoint(通常需要用校准数据集做 activation calibration,而不是纯权重转换脚本)。 目前我们下载/自制的几份 NVFP4 都不是这种,这也是一个可以探索的方向,但成本更高(需要校准流程), 且依然要面对 Blackwell 上 comfy-kitchen 内核成熟度的问题。

追问验证:LoRA "必须走 bypass" 到底是不是真的?

之前记录里写的是"nvfp4+LoRA 必须 bypass",但没验证过具体原因,只是经验结论。这次直接补测—— 同一 case、同一 seed,分别用 LoraLoaderModelOnly(普通合并)和 LoraLoaderBypassModelOnly 跑我们的 nvfp4 checkpoint,GPU 清零后对照:

模式是否报错耗时峰值显存
普通合并 LoraLoaderModelOnly不报错,跑完240.2s26.80 GB
Bypass LoraLoaderBypassModelOnly不报错,跑完156.4s26.54 GB

结论:普通合并不会报错,但输出画面严重劣化。左边是普通合并,右边是 bypass,同一 seed、同一参考图:

左:普通合并——画面整体模糊、带一层网格状纹理噪声,人物边缘全程糊掉。右:bypass——清晰连贯,细节正常。

所以"必须 bypass"是真的,但原因不是"跑不通",而是普通合并会静默产出劣化画面,报错都不会报,很容易被忽略。 根源在于重新量化(set_weightrequantize_from_float)这一步:

@classmethod
def requantize_kwargs(cls, qtensor: QuantizedTensor) -> dict[str, Any]:
    """Return layout-specific kwargs for requantizing a dequantized tensor."""
    return {}   # <-- 基类默认实现,NVFP4Layout 没有覆写它

comfy_kitchen/tensor/base.py:119-121 (grep 确认 comfy_kitchen/tensor/nvfp4.py 里没有覆写此方法)

对比 W4A8 那边的量化实现,专门为"LoRA 合并进低比特权重"这件事做了 ALS 迭代精修 + 随机舍入, 代码注释原话是 "unbiased in expectation, so sub-quantum deltas (e.g. a small LoRA merged into a low-bit weight) survive instead of being rounded away"——W4A8 的量化流程是专门考虑过"合并进来的小 delta 不能被舍入抹掉"这件事的。 而 NVFP4 的重新量化完全走通用兜底逻辑,没有任何针对"重新量化时保留小 delta"的特殊处理。NVFP4 的 E2M1 格式本身只有 4 位表示精度,block scale 需要重新拟合,LoRA 合并进去的那部分改动大概率在重新定标时被直接舍入抹平或算错 scale—— 这才是普通合并画面劣化的真正原因,而不是"用不了"这么简单。


实测数据(同一 case,清空显存后严格对照)

1 张参考图 + 文本 prompt,768p,141帧(~5.9s),8-step turbo LoRA,普通合并(非 bypass),GPU 重启清零后测:

格式文件大小峰值显存耗时说明
int8_tensorwise + convrot(生产版)20.0 GB28.2 GB159.2sweight-only,无激活量化
W4A8 ConvRot(asym_w4a8_int8)12.5 GB30.18 GB138.3s激活量化+int4→int8解包,5090无原生INT4 MMA

测试脚本:test_w4a8_lora_merge.py / test_int8_compare.py,均在 GPU0(systemctl --user restart 清零后)上跑,峰值显存用 2s 轮询 nvidia-smi 采样。

文件小了 37%,峰值显存反而多了约 2GB——文件大小只反映"静态存储",不反映"运行时要额外分配多少临时转换缓冲区"。 参考视频越长(我们真正在意的 15s/362帧场景),这个 gap 只会因为激活量化缓冲区随 token 数线性增长而进一步拉大,不会缩小。



追加实测:峰值显存到底出现在哪一刻?(逐秒时间线)

刚才的对照表只给了"峰值"这一个数字,不够看。这次给同一个 nvfp4 普通合并跑一遍, 每 1 秒采一次 nvidia-smi,同时实时读 ComfyUI 自己的加载日志,把两者按时间对上, 才看得出显存到底花在哪个阶段——鼠标悬停柱子可以看到具体秒数和显存值:

① CLIP 文本/视觉编码 0-83s ② 模型加载+LoRA合并 83-100s ③ 采样(8步) 100-204s ④ VAE 解码+保存 204-249s
阶段耗时显存变化说明
① CLIP(Qwen3VL-32B)编码0 → 83.5s(83.5s)0.5GB → 17.2GB 光加载+跑这一个 32B 参数的视觉语言模型做文本/参考图编码,就吃掉三分之一的总耗时,显存缓慢爬升到 17GB
② 扩散模型加载 + LoRA 合并83.5 → 99.5s(16s)17.2GB → 27.4GB(全程峰值) 94.2s 那一下从 19.8GB 直接跳到 25.4GB——这就是 LoRA 合并触发的重新量化(反量化+算 delta+重新拟合 block scale)的瞬时开销,全程最高点就出现在这里,不是在采样阶段
③ 8 步采样99.5 → 204.6s(105s,占比42%)26.3GB ~ 26.9GB(平稳) 真正的扩散计算反而全程平稳,比阶段②的瞬时峰值还低,说明峰值不是被"算力"撑起来的,是被"合并"那一下的临时缓冲区撑起来的
④ VAE 解码 + 保存204.6 → 249.1s(44.5s)25.5GB 跌到 16.7GB 再爬回 22.9GB 采样一结束扩散模型被换出(16.7GB 那个骤降),换video VAE(4.97GB)进来解码 141 帧,显存重新爬升
直接回答"为什么普通合并显存也没降下来":
1. 总显存里最大头根本不是扩散模型本身,是 CLIP/Qwen3VL-32B 文本视觉编码器,单独站着就要 14.96GB(比扩散模型自己合并LoRA后的 11.94GB 还大),这部分开销是固定的——不管扩散模型用 nvfp4/int8/w4a8 哪种格式、LoRA 走不走 bypass,这 15GB 都原封不动地要花。
2. 真正的全程峰值(27.4GB)不是出现在采样阶段,而是集中在 LoRA 合并那一瞬间(83.5-99.5s 这 16 秒内,94.2s 那一下跳变最明显)——这跟前面讲的"NVFP4 重新量化没有针对小 delta 做保护、需要临时反量化整层权重再重新拟合 block scale"是同一个机制,只是这次不是靠画质劣化看出来,是直接从显存曲线上量出来的。
3. bypass 模式没有②这个尖峰,但它把开销摊到了③采样阶段的每一步里(105秒内持续多算一遍 LoRA 分支)——一个是"一次性尖峰",一个是"持续分摊",两种账算下来对这个 141 帧的短 case 而言峰值总量差不多,这也是为什么我们测出来两者峰值只差 260MB 的原因。

追问验证:轻量 case 的结论,换成真正的重负载(15s/362帧+参考视频)还成立吗?

上面那组时间线用的是 141 帧(~5.9s)、没有参考视频的轻量 case——用户敏锐地指出这可能不是生产 真正关心的场景。直接换成跟生产同规格的重负载重测一遍:768p、362帧(15s)、1张参考图 + 1个参考视频 (按生产设置压缩到 0.02MP),同样每秒采样 + 对齐加载日志:

① CLIP编码(含参考视频)0-88s ② 合并瞬时尖峰 88-100s ③ 采样(8步) 100-431s ④ VAE解码 431-504s
阶段耗时显存对比轻量case
① CLIP 编码(含参考视频)0 → 88s0.5GB → 17.0GB基本持平(83.5s→88s,参考视频编码多花了点时间)
② 模型加载+LoRA合并88 → 100s17.0GB → 30.19GB(全程峰值)比轻量case的27.4GB还要高,峰值绝对值确实随负载变重而升高
③ 8步采样100 → 431.6s(331.6s,占比66%)22.1GB ~ 22.7GB(平稳)反而比轻量case的采样阶段(26.3-26.9GB)还低,且全程远低于②的峰值
④ VAE解码+保存431.6 → 504.1s跌到 1.1GB 再爬回 8.8GBComfyUI 对长视频走了分块解码,峰值反而比轻量case的VAE阶段(22.9GB)更低
结论没有变,而且更明确了: 即使换成 362 帧 + 参考视频的真实重负载,采样阶段(占了 66% 的总时长)显存依然 平稳维持在 22GB 左右,全程真正的最高点还是出现在①→②过渡、LoRA 合并那几秒钟内(88-100s), 这次冲到了 30.19GB,比轻量 case 的 27.4GB 还高——说明参考视频/更长帧数确实会推高这个瞬时尖峰的绝对值 (合并那一刻,扩散模型刚加载+参考条件张量也在,负载叠加),但没有把峰值位置从"合并瞬间"转移到"采样阶段"。 也就是说:不管轻量还是重负载,拖累显存的都不是"算力堆出来的稳态占用",而是"CLIP常驻的固定15GB + LoRA合并那几秒的瞬时尖峰"这两块, 采样本身反而是最"省"的阶段。

追问验证②:参考视频不压缩,保留原生 480×704,会撑爆吗?

直接测(生产 int8_convrot 格式,768p/362帧/15s,1图 + 参考视频保留原生 480×704,不经过任何压缩节点):

成功,无 OOM 峰值显存 30.95GB(121.7s,出现在模型加载/合并阶段,跟压缩版同一个位置)。 采样阶段稳定在 22.3-22.7GB。总耗时 718s,其中 8 步采样占了约 516s(64.6s/步)—— 比同规格压缩参考视频版本(37.4s/步)慢了 1.7 倍,因为参考视频没压缩,attention 要处理的 token 数直接多了好几倍。

结论:原生分辨率不会撞显存墙,但会让参考视频的 token 负担直接体现在时间上——不压缩不代表跑不动, 只是从"跑不完(OOM)"变成"跑得慢"。这跟之前算过的 token 成本模型是一致的:只要没有超过总显存预算, 更多 token 换来的是更长的采样时间,不是失败。

顺带修正一下"合并为什么撑爆"这个说法

之前把这个尖峰主要归因于"NVFP4 重新量化需要额外的 scratch buffer"。拿这次 int8 + 原生视频的实测数字核对了一下, 发现真正占大头的原因更简单——不是量化数学本身要很多显存,是模型切换的交接窗口期:

组件显存(Staged)
CLIP(Qwen3VL-32B 文本视觉编码器,常驻)14.96GB
扩散模型(刚加载进来)11.94GB(nvfp4)/ 19.99GB(int8)
LoRA 文件1.96GB
简单相加28.9GB(nvfp4)/ 36.9GB(int8)

nvfp4 那组简单相加(28.9GB)跟实测峰值(27.4~30.2GB)量级完全对得上。int8 那组更能说明问题:三者相加(36.9GB) 已经超过了显卡总容量(32.1GB),但实测峰值只有 30.95GB——说明 ComfyUI 的 DynamicVRAM 管理器并不是傻等三个东西 完全常驻,而是新模型往里搬的同时,旧的 CLIP 在被逐步换出去,只是这个换出过程没快到完全避免瞬间的部分重叠, 这个重叠窗口就是尖峰的真正来源——不是"量化计算"这个动作本身要额外显存,是"新旧模型交接没完全错开"。

结论 / 建议

NVFP4(weight-only)、W4A8 ConvRot、W4A4 ConvRot —— 这三个我们已验证过的量化路线, 在 RTX 5090 + 当前 comfy-kitchen(0.2.28,最新发布版 0.2.31 同样未变)组合下,都拿不到该有的显存/速度收益, 根源是 Blackwell 没有被纳入原生 INT4 张量核心加速的白名单(major == 8 硬编码), 所有"低比特"格式实际上都退化成"int4/fp4 解包 + 标准 INT8/BF16 矩阵乘",只多了转换开销。

目前唯一真正验证有效、且不依赖硬件特殊支持的容量优化手段,还是我们已经上线的方案: 用 ImageScaleToTotalPixels 压缩参考视频的空间分辨率,直接砍掉参考材料贡献的 token 数——这个手段 不涉及任何量化内核,在任何显卡上都生效。

如果以后想真正吃到 W4A4/W4A8 的收益,需要等 comfy-kitchen 后续版本给 Blackwell(sm_120)加上原生 INT4 MMA 支持(目前看代码注释,连 Hopper 都被排除在外,是刻意的设计决定,不是遗漏),这不是我们这边能控制的时间线。