【Bug已解决】[Bug]: Pixtral model(Migstral-Small-2509) will raise AttributeError NoneType Size on graph mode. Eager mode is ok 解决方案
一、现象长什么样
用Pixtral(具体是Migstral-Small-2509,一个多模态模型)时,开 graph 模式(CUDA graph 捕获 /torch.compile图模式)加载,崩溃:
AttributeError: 'NoneType' object has no attribute 'size'有时日志里被简写成AttributeError NoneType Size——意思就是代码对None调了.size()。崩溃发生在graph 捕获阶段(不是实际推理时):
File "pixtral/vision_encoder.py", in forward num_images = pixel_values.size(0) AttributeError: 'NoneType' object has no attribute 'size'几个特征:
- eager 模式(关 graph)完全正常,只有 graph 模式炸。
- 崩在「捕获 CUDA 图」那一步,还没真正处理用户图片。
- 模型是多模态(带视觉编码器),纯文本模型不会踩。
- 报错一定落在「访问图像相关张量的
.size()/ 形状」那一行,且对象为None。
本质:graph 模式捕获时用的是「无图占位输入」(dummy input,只有文本、没有图片),于是视觉编码器拿到的pixel_values/image_sizes是None;而模型代码没对None做保护,直接.size(0),于是 AttributeError。eager 模式每次都是真实请求、有图,所以不炸。
二、背景
要理解这个,得知道 CUDA graph 捕获怎么用 dummy input。
CUDA graph 为了「录」下整段前向,需要先用一批虚拟输入(dummy)跑一遍,把 kernel 调用序列录下来。vLLM 在捕获时为文本模型准备的 dummy 输入是「带 token 但没有图像」的——因为图捕获关心的是「计算图结构」,不关心真实数据。
对纯文本模型,dummy 里所有张量都有形状,捕获顺利。但 Pixtral 这种多模态模型,前向里有个视觉编码器分支,它要读pixel_values(图片像素张量)来提取图像特征,再和文本特征拼起来。问题在于:
- 捕获用的 dummy 输入没包含图片,所以
pixel_values是None。 - 模型代码的视觉分支直接
pixel_values.size(0)拿图片数量,没判断None。 - graph 捕获阶段这一行就炸了(
None没有.size)。
为什么 eager 模式没事?因为 eager 模式每个请求都是真实的,用户发图文请求时pixel_values必然有值;即使发纯文本请求,eager 路径通常会走「没有图就跳过视觉分支」的逻辑(至少在运行时上下文里有判断)。但 graph 捕获路径为了「录图」,强制走完整前向(包括视觉分支),而 dummy 输入又没图,于是撞上None.size()。
一句话:graph 捕获用无图 dummy 输入,触发了模型视觉分支对None张量调.size(),而 eager 模式有真实图所以不触发。
三、根因
根因是模型视觉分支假设「图像输入一定存在」,在 graph 捕获(无图 dummy)场景下拿到None后未做保护就访问.size(),三层:
第一层(主因):视觉分支对pixel_values判空缺失。代码写的是n = pixel_values.size(0),没有if pixel_values is None:的提前返回或占位。当 graph 捕获传入None,立刻 AttributeError。正确的多模态前向应该「无图就跳过视觉编码、用零维占位」。
第二层:graph 捕获的 dummy 输入没给「空图占位」。
vLLM 的 dummy input 构造器对多模态模型应该提供一个「0 张图」的合法占位(比如pixel_values形状(0, 3, H, W)的空张量),而不是None。但 Pixtral 的 dummy 构造没这么做,直接留None,把问题甩给了模型代码。
第三层:graph 模式无法「运行时跳过分支」。
eager 模式可以「这次没图就不进视觉分支」(动态控制流),但 CUDA graph 捕获的是静态图——如果捕获时进了视觉分支,之后所有请求都得进(哪怕没图);如果捕获时没图、跳过分支,之后有图的请求又走不通。graph 模式要求「捕获时的控制流 = 推理时的控制流」。于是「无图就跳过」在 graph 模式下行不通,必须改成「无图就用零维占位、始终走同一分支」,才能既捕获成功、又推理正确。
一句话:视觉分支对 None 未保护 + dummy 输入没给空图占位 + graph 要求控制流静态,三者叠加导致 graph 捕获崩在None.size()。
四、最小可运行复现
下面用纯 Python 模拟「视觉分支对 None 调 .size() 在 graph 捕获(无图 dummy)时崩、eager(有图)正常」的控制流,不需要 GPU:
class FakeTensor: def __init__(self, n): self.n = n def size(self, dim): return self.n def vision_branch_buggy(pixel_values): # 模型视觉分支:假设 pixel_values 一定有值 n = pixel_values.size(0) # None 上没有 size -> AttributeError return f"encoded {n} images" def eager_mode(real_images): # eager:真实请求有图 return vision_branch_buggy(real_images) def graph_capture_mode(): # graph 捕获:用无图 dummy,pixel_values = None dummy = None return vision_branch_buggy(dummy) def main(): # eager 正常 try: print("eager:", eager_mode(FakeTensor(2))) except Exception as e: print("eager 异常:", e) # graph 捕获崩 try: graph_capture_mode() except AttributeError as e: print("graph 捕获复现成功:", e) if __name__ == "__main__": main()跑出来 eager 打印encoded 2 images,graph 捕获打印graph 捕获复现成功: 'NoneType' object has no attribute 'size',和线上「eager OK、graph 崩」完全一致。
五、解决方案(第一层:最小直接修复)
最省事的救火:关掉 graph 模式,退回 eager,避开捕获阶段的 None 访问:
llm = LLM( model="Migstral-Small-2509", enforce_eager=True, # 关 CUDA graph,eager 模式有真实图、不触发 None.size() )或者,如果你必须用 graph 模式,临时做法是给 dummy 输入补一个空图占位,让pixel_values不是None而是「0 张图的合法空张量」:
# 构造 graph 捕获用的 dummy 输入时,给视觉分支一个零维占位 dummy_pixel_values = torch.empty(0, 3, 336, 336) # 0 张图,但形状合法 dummy_image_sizes = torch.empty(0, 2)这样视觉分支dummy_pixel_values.size(0)返回0,不崩,且语义正确(0 张图 → 不编码任何图像特征)。
六、解决方案(第二层:结构性改进)
第一层是「避开或补占位」,第二层是「让模型视觉分支对 None / 空图都安全,且控制流在 graph 模式下保持静态」,从设计上消灭问题:
import torch def vision_branch_safe(pixel_values, image_sizes): """视觉分支:对 None / 空图都安全,且控制流静态(始终进同一分支)。""" # 1) None -> 用零张图占位,避免 AttributeError if pixel_values is None: pixel_values = torch.empty(0, 3, 336, 336) if image_sizes is None: image_sizes = torch.empty(0, 2) n = pixel_values.size(0) # 2) 0 张图:返回零维图像特征占位,控制流不变(仍走这分支) if n == 0: hidden = pixel_values.new_zeros(0, 4096) # 与有图时同维度 return hidden # 3) 有图:正常编码 features = encode_images(pixel_values, image_sizes) return features def encode_images(pixel_values, image_sizes): # 真实视觉编码器前向(省略) return pixel_values.new_zeros(pixel_values.size(0), 4096)配套:dummy 输入构造器统一提供空图占位(而不是 None),保证 graph 捕获与实际推理走完全相同的控制流:
def build_dummy_multimodal_input(has_image: bool): if has_image: return { "pixel_values": torch.randn(1, 3, 336, 336), "image_sizes": torch.tensor([[336, 336]]), } # 关键:无图也给空占位,绝不给 None return { "pixel_values": torch.empty(0, 3, 336, 336), "image_sizes": torch.empty(0, 2), }这样 graph 捕获(dummy 无图)和实际图文请求(有图)都进vision_branch_safe的同一个分支,控制流一致,且都不会因None崩溃。
七、解决方案(第三层:断言 / CI 守护)
把「视觉分支对 None 安全」「无图返回零维」「dummy 给空占位」固化成测试:
import torch import pytest def test_vision_branch_none_safe(): out = vision_branch_safe(None, None) assert out.shape == (0, 4096) # None 不崩,返回零维 def test_vision_branch_empty_placeholder(): pv = torch.empty(0, 3, 336, 336) out = vision_branch_safe(pv, torch.empty(0, 2)) assert out.shape[0] == 0 def test_vision_branch_real_images(): pv = torch.randn(2, 3, 336, 336) out = vision_branch_safe(pv, torch.tensor([[336, 336], [336, 336]])) assert out.shape == (2, 4096) def test_dummy_no_image_not_none(): d = build_dummy_multimodal_input(has_image=False) assert d["pixel_values"] is not None assert d["pixel_values"].shape == (0, 3, 336, 336) def test_graph_capture_control_flow_static(): # 捕获(无图)与推理(有图)必须进同一分支、都不崩 cap = vision_branch_safe(*map(lambda d: d[1], [(k, v) for k, v in build_dummy_multimodal_input(False).items()].__reversed__())) real = vision_branch_safe(torch.randn(1, 3, 336, 336), torch.tensor([[336, 336]])) assert cap.shape[0] == 0 and real.shape[0] == 1再加一个端到端回归:graph 模式加载 Pixtral + 纯文本请求不崩:
def test_pixtral_graph_mode_text_only(): engine = make_engine(model="Migstral-Small-2509", enforce_eager=False) out = engine.generate("请描述这张图", images=None) # 捕获用空占位 assert out is not None八、排查清单
- 看报错是不是
'NoneType' object has no attribute 'size'(或简写NoneType Size)且崩在 graph 捕获阶段 → 坐实本问题。 - 关
enforce_eager=True试,能跑则说明是 graph 捕获的 None 问题。 - 看栈是否落在视觉编码器 / 多模态 projector 的
.size()/ 形状访问行。 - 临时救火:关 graph 模式;或给 dummy 输入补空图占位(0 张图的合法空张量)。
- 长期修复:视觉分支对 None/空图做保护并返回零维占位,dummy 构造器永远给空占位而非 None。
- 升级模型实现到合了多模态 graph 安全修复的版本,并跑上面的「None 安全」用例。
- 注意 graph 模式要求控制流静态:别用「无图就 return」的动态分支,要用「无图就用零维占位、始终同分支」。
九、小结
Pixtral 在 graph 模式报NoneType Size(即None.size()),不是模型能力问题,而是graph 捕获用无图 dummy 输入,触发了视觉分支对None图像张量调.size(),而 eager 模式有真实图所以不触发。最小修复是关 graph 模式或给 dummy 补空图占位;结构性修复是视觉分支对 None/空图做保护并始终走同一静态分支(无图返回零维占位)、dummy 构造器永远给空占位;最后用 pytest 把「None 安全」「无图零维」「控制流静态」锁死。抓住「graph 模式要求捕获与推理控制流一致、且 dummy 输入不能为 None」这条,所有多模态模型在 graph 模式下的崩溃都能照此排查。