夜雨聆风学习资料网

ARTICLE · 1344423

从源码看 Qwen2.5-VL:窗口注意力到底是怎么实现的?

从源码看 Qwen2.5-VL:窗口注意力到底是怎么实现的?

关于 Qwen2.5-VL 视觉编码器,不同资料对窗口注意力、RoPE 和窗口大小的描述略有差异。这里直接以 Hugging Face Transformers 中的 Qwen2.5-VL 源码为准,顺着实际实现看一遍它的视觉处理流程。

下面涉及的结构和数值,均以当前主分支代码为准;具体部署时,应以所使用版本对应的源码为准。

先看完整的整条视觉通路。

预处理:smart_resize 与 patchify

很多 ViT 会把输入统一 resize 到 224 或 336,Qwen2.5-VL 不这么做。图像处理器走的是 smart_resize(),只保证三件事:宽高能被指定 factor 整除、总像素数量落在预算区间内、宽高比尽量不变。这个 factor 等于 patch_size 乘 spatial_merge_size(因为 token 化分两级,先按 14 切 patch、再按 2×2 合并mergee,两级都要能整除,所以边长必须对齐到 ):

处理后的宽高因而都是 28 的倍数。动态分辨率并不等于完全不缩放,这一点容易被误解。

接下来 patchify() 把图像铺平。对静态图,预处理器用 unsqueeze(6).expand(...) 把同一组像素沿 temporal patch 维扩展到长度 2,所以每个扁平 patch 的维度是

单张图的输出是 (grid_h × grid_w, 1176),多张图再沿第一维 torch.cat 拼起来。也就是说传给视觉塔的 pixel_values 是一个二维 packed tensor,batch 被打包进了 sequence 维,边界由 grid_thw 和 cu_seqlens 记录,而不是传统的 (B, C, H, W)。

以一张  的图为例,两条边都是 28 的倍数,不需要再调整,patch 网格是 ,于是 。

Conv3D Patch Embedding

Qwen2_5_VisionPatchEmbed 里定义的是三维卷积:

kernel_size = [temporal_patch_size, patch_size, patch_size]self.proj = nn.Conv3d(in_channels, embed_dim, kernel_size=kernel_size,                      stride=kernel_size, bias=False)

**kernel 和 stride 完全相同,窗口不重叠,数学上等价于对每个 patch 做一次线性映射。forward 里先把扁平 patch 还原成 (-1, 3, 2, 14, 14),卷完再 flatten 回 (N, Dv)**。这一步不减少 token 数量,只是把像素块投到视觉隐空间。

window_size = 112 的三种等价尺度

配置里的 window_size=112 指的是窗口边长为 112 像素,因此一个完整窗口实际覆盖的是  像素区域。

ViT 的 patch 边长是 14 像素,所以沿单个方向看:

也就是说,一个窗口的边长对应 8 个 ViT patch,因此二维窗口大小是  个 patch。

接下来,每  个 patch 会组成一个 merger 单元,所以沿单个方向还要再除以 2:

因此,窗口有多大可以用三种尺度表示:

如果图像边缘不能被  的 merger 窗口整除,代码会在生成窗口索引时临时 padding,方便 reshape;这些补位随后会被过滤掉,不会真正进入 attention 计算。

以  的图像为例,merger 网格是 。其中 ,所以一个方向会留下宽度为 3 个 merger 单元的残窗;,另一个方向正好整除。

最终共有 88 个窗口:80 个完整窗口,每个包含  个 patch token;8 个残窗,每个包含  个 patch token。

按 merge 单元的序列重排

整套窗口注意力能成立,靠的是进入 Transformer 之前的这次重排,而这一步在多数介绍里都被跳过了。

defpermute_input_for_window_attn(self, input_tensor, window_index, seq_len):    input_tensor = input_tensor.reshape(seq_len // self.spatial_merge_unit,                                        self.spatial_merge_unit, -1)    input_tensor = input_tensor[window_index, :, :]    input_tensor = input_tensor.reshape(seq_len, -1)return input_tensor

其中 spatial_merge_unit = spatial_merge_size ** 2 = 4。代码把每 4 个 patch token 当作一个不可拆的空间单元,再按 window_index 重排这些单元的先后顺序。

(示图中token数只是为了说明,与实际数字不符)

shape 前后都是 (5504, Dv),变的只有排列。这样同一窗口的 token 在 packed sequence 里连续,窗口边界可以直接由 cu_window_seqlens 描述(cumulative sequence lengths),不必显式构造一个  的窗口 attention mask。(hidden_states 负责存所有 token,cu_window_seqlens 负责告诉 attention 这些 token 应该分成哪些独立窗口)

位置编码也跟着重排:cos/sin 在原始顺序下算好,再用同一个 window_index 打乱,这样每个 token 带的始终是它真实的空间坐标。

32 层的窗口与全局排布

配置里写得很直白:

fullatt_block_indexes = (7, 15, 23, 31)

索引是 0-based,对应第 8、16、24、32 层。32 层里只有这 4 层是全局注意力,其余 28 层全是窗口注意力,排布可以写成 ,也就是七层窗口接一层全局,循环四次。3 local + 1 global 那种密集交替属于另一类设计,和这里不是一回事。

值得注意的是,代码里根本没有 WindowAttention 和 GlobalAttention 两个类。视觉塔的循环体是这样:

for layer_num, blk in enumerate(self.blocks):if layer_num in self.fullatt_block_indexes:        cu_seqlens_now, max_seqlen_now = cu_seqlens, max_seqlenelse:        cu_seqlens_now, max_seqlen_now = cu_window_seqlens, max_window_seqlen    hidden_states = blk(hidden_states, cu_seqlens=cu_seqlens_now, ...)

同一个 Block,同一套权重结构,QKV、RoPE、attention kernel、projection 全都没换,唯一的变量是传进去的 cu_seqlens, 也就是哪些 token 属于同一个 attention segment。

计算量的差别也来自这里。全局层要考虑的 query-key 组合随 token 数平方增长,示例图是

窗口层则要按每个窗口的真实长度分别平方再求和,不能简单写成 ,因为其中 8 个残窗只有 48 个 token:

两者相差约 87 倍,而且图越大差距越悬殊,把全局层压到只剩 4 层的原因就在这个量级差上。

Vision Block 的内部结构

结构是标准 Pre-Norm,但用的是 RMSNorm 而不是 LayerNorm,eps 为 :

hidden_states = hidden_states + self.attn(self.norm1(hidden_states),                                          cu_seqlens=cu_seqlens, ...)hidden_states = hidden_states + self.mlp(self.norm2(hidden_states))

Attention 里 qkv = nn.Linear(dim, dim * 3, bias=True),带 bias。shape 走向是 (N, Dv) 到 (N, 3Dv),reshape 成三份 (N, 16, head_dim),应用视觉 RoPE 后进入变长 attention,输出再 proj 回 (N, Dv)。

FFN 不是传统 ViT 的 Linear → GELU → Linear,而是门控结构,并且同样带 bias:

hidden_act 配的是 silu,中间维度 intermediate_size = 3420。这两点都更接近语言模型那一侧的写法。32 层跑完,shape 始终保持 (5504, Dv)。

ViT 内部的二维 axial RoPE

Qwen2_5_VLVisionRotaryEmbedding 的类注释原话是 simple axial 2D rope,接收的 position_ids 形状是 (2, N),两行分别是 h 坐标和 w 坐标。频率的构造方式是:

spatial_dim = dim // 2inv_freq = 1.0 / (base ** (torch.arange(0, spatial_dim, 2,                                        dtype=torch.float) / spatial_dim))

inv_freq 长度是 ,h 和 w 各算一份拼成 ,再整体复制一遍覆盖完整 head dim,不存在部分旋转。

时间维度出现在另一个地方。Qwen2_5_VLModel.get_vision_position_ids() 才生成 T/H/W 三维索引,输出 (3, sequence_length),语言模型侧的 rotary embedding 再按 mrope_section 把三组频率切片交错拼回去。时间间隔和帧率挂钩,docstring 给的关系是

所以更严谨的表述是:ViT 内部是二维 axial RoPE,   T/H/W 是视觉 token 进入语言模型之后才有的事,这是两层不同的机制。

Patch Merger 与顺序还原

self.ln_q = Qwen2_5_VLRMSNorm(context_dim, eps=1e-6)self.mlp = nn.Sequential(    nn.Linear(self.hidden_size, self.hidden_size),    nn.GELU(),    nn.Linear(self.hidden_size, dim),)

其中 hidden_size = context_dim * 4。forward 里先 RMSNorm,再 view(-1, 4Dv) 把相邻四个 token 拼到通道维,最后过两层 MLP 投到 out_hidden_size,token 数降到 ,通道对齐到语言模型的维度。view 之所以能正确对应  空间邻域,前提正是前面那次重排保证了同一 merge 单元的四个 token 序列连续。

除了压缩和投影,这个模块还承担第三件事:

reverse_indices = torch.argsort(window_index)merged_hidden_states = self.merger(hidden_states)merged_hidden_states = merged_hidden_states[reverse_indices, :]

窗口顺序只在 ViT 内部成立,送进语言模型之前必须还原成光栅顺序,否则 LLM 侧的 T/H/W 位置全都对不上。window_index 索引的是 merge 单元,merger 输出恰好  行,一次 argsort 就还原完毕。

另外视觉塔返回的 last_hidden_state 是窗口顺序的 (N, Dv),pooler_output 才是还原后的 (N/4, Dl)。取错不会报错,但后续计算全部基于错误的空间位置。

1300 × 1200 输入的完整数据流

前面的例子边长恰好是 28 的倍数,现实里更常见的是不整除的情况。换一张  的图,整条链路的数字如下。

smart_resize 先把两条边四舍五入到 28 的倍数:,。patch 网格变成 ,,送进视觉塔的是 (7912, 1176)。

merger 网格是 ,两个方向都不整除:,。于是 132 个窗口里只有 110 个是完整的 ,另外三类分别是右侧的 、底部的  和右下角那一个 。把各类窗口的 patch 数加起来正好是 7912,可以反过来印证残窗确实按实际长度参与计算,没有补位。

attention 的计算量差距在这个尺寸上更明显。全局层要算  个组合,窗口层按四类窗口的真实长度求和是 ,相差约 129 倍。

最终  个 token 进入语言模型,和  对得上。这里用的是 resize 之后的 ,直接拿  去除 784 是对不上的。

视觉 token 数与像素的换算

patch 是 ,merger 再合 ,所以送进语言模型的一个视觉 token 覆盖  像素。对静态图,若 、 指 smart_resize 之后真正进入 patchify 的尺寸,则

必须是 resize 之后的尺寸,拿原图边长直接除 784 往往除不尽。用前面的示例图验算:,最初的 5504 个 patch,最终只剩 1376 个 token 进入 LLM。

整条链路串起来是这样:像素切成  patch,Conv3D 投影,按 merge 单元重排,七层窗口加一层全局循环四次, 合并后投到语言模型维度,还原顺序,与文本 token 拼成一条序列。

这套设计的思路不是把 ViT 彻底改造成局部网络,而是让绝大多数层在固定窗口内交互,周期性插入全局层做跨窗口汇总,最后靠 merger 把高分辨率网格压成四分之一长度再交给语言模型。实现层面局部和全局共用同一套代码,区别只在一个 cu_seqlens。

往期好文:

小团队做 on-policy 蒸馏,OPD 和 OPSD 到底该选哪个

物理AI是什么?为什么突然火了:技术、资本与商业化

递归自我改进 RSI(Recursive Self-Improvement):AI 造 AI 现在走到哪一步了?

Kimi K3 为什么舍 RoPE 而用 NoPE?位置信息从哪里来

蒸馏学习 OPD / OPSD 用什么框架?

DEIMv2 解读:如何把 DINOv3 的单尺度表征接入实时 DETR

多尺度三维图像特征接 Qwen3.5,位置编码怎么处理?

瓶颈的艺术:Q-Former 的压缩哲学、演进与选型之道

一张图片,是如何变成视觉 Token 的?——拆解 Qwen3-VL 视觉数据流,兼谈 Qwen3.5 的架构取舍

从 OPD 到 OPSD:让"知道答案的自己"去教"不知道答案的自己"

大模型缓存命中时为什么便宜那么多:从 KV Cache 说清大模型推理的成本结构

如何把Qwen 的视觉塔换成 3D 编码器(医学CT、MRI)

Atlas/Pillar-0:3D 医学影像的多尺度注意力机制,如何为MedVLM选择合适的图像编码器

为什么 SFT 用前向 KL,RLHF 用逆向 KL?

为什么 PPO 把 KL 放 reward,GRPO 放 loss?——位置与估计量其实是同一个问题:从策略梯度的两条路径说起

从蒙特卡洛到 PPO:重要性采样是怎么回事?

Hybrid Transformer是什么?Qwen3.5 为什么 3/4 的层根本不用 Attention?

On-Policy Distillation:从知识蒸馏到同策略模仿

为什么 LLM 采用策略学习,而不是价值学习?——从 DQN 与 PPO 说起,看清 RLHF 背后的算法路线之争

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料