从零实现vLLM系列【5】:RotaryEmbedding
点击博客上方 【关于-赞助】,扫描第一个二维码并备注github邮箱,或者加入 QQ 群聊 1102504490 后添加群主 QQ。

RotaryEmbedding
苏神的RoPE blog | 袁朝发的Blog |
我在博客中Attention的那一章提到过,Attention机制本身是没有位置信息的,需要显式的注入位置信息。 经过研究表明,相对位置的重要性是要比绝对位置更加重要的。这句话什么意思呢?
例如:我 爱 你。 其中我的位置是0,你的位置是2,它们的相对位置是2,模型并不关心“我”和“你”的实际位置,而是更加关系二者之间的距离。
通过旋转变换为向量注入位置信息,使得两个向量的内积只依赖于它们的相对位置。

1. 原理讲解
假设我们在二维平面上有一个向量q=(x, y), 如果想要把它逆时针旋转一个角度 ,在线性代数中,这相当于左乘一个二维旋转矩阵 。
但这个矩阵具体长什么样?
我们高中的时候学过,在二维平面中,只需要两个向量就可以把平面内的任意向量表示出来,例如X和Y轴的单位向量,他们在矩阵论中被叫做基向量。
实际上只要确定两个基向量旋转后落在哪里,平面内任意向量都可以通过左乘一个旋转向量来解决。
假设我们用的是二维直角坐标系,我们定义两个单位向量分别是:
先看 ,它指向 x 轴正方向。逆时针转 后,落点仍在单位圆上,对应的角度就是 。单位圆上角度为 的点的坐标就是:
再看 ,它指向 y 轴正方向,本身就是 方向。再逆时针转 ,落点就在 的角度上:
两个基向量的像都求出来了,按列排开,第一列放 的像,第二列放 的像:
向量左乘这个矩阵,就可以得到旋转后的新的向量 :
2. RoPE在大模型的示例讲解
现在,我们有一段文本,每个 Token 都有一个物理上的绝对位置编号 (比如第 个词)。
RoPE 的做法是我在第 个位置上,就把向量旋转 这么多角度。 也就是说,向量在这个句子中越靠后,它被旋转的角度就越大。
此时,位置 处的旋转矩阵变成了:
把 Query 向量放在位置 旋转,把 Key 向量放在位置 旋转:
到这一步为止,我们赋予 和 的都只是绝对位置信息。
Transformer的核心是Attention,Attention的核心就是计算Query和Key的内积来决定注意力分数。
我们算一下,带有绝对位置 的 和带有绝对位置 的 的内积是什么:
根据旋转矩阵的几何性质,一个旋转矩阵的转置,等于反向旋转,即 。 而两个旋转矩阵相乘,等于它们的角度相加:
所以,上面的内积公式化简为:
可以看到,现在它们只和两个Token的相对距离有关。
我们接着来思考多维向量的情况。
刚才讲的都是 2 维向量。但大模型里,每个 Attention 头(Head)的维度通常是 128 维(记为 head_size),而参与旋转的 rotary_dim 通常就等于它。
RoPE 的做法是把 rotary_dim 个维度按前后对半分成两段(各 rotary_dim/2 维),让前半的第 维与后半的第 维配成一对,并把这一对放到它们张成的二维平面里旋转 。
以 rotary_dim = 128 为例,前 64 维与后 64 维一一配对:
- 第 维与第 维配成一对,按 旋转;
- 第 维与第 维配成一对,按 旋转;
- …
- 第 维与第 维配成一对,按 旋转。
这 rotary_dim/2 个二维旋转子空间互相独立,整体相当于一个 rotary_dim 维的块对角旋转矩阵.只不过这些 2×2 块在矩阵中的位置是前后错开的,而不是相邻的。
原 RoFormer 论文用的是”奇偶交错”约定,即把第 维配成一对。但是这种方法会让批处理效果下降。
不同的维度,旋转的速度(频率)不一样 每个维度对用自己的频率,RoPE 沿用原版 Transformer 的频率衰减公式:
代码实现如下:
inv_freq = 1.0 / (base ** (arange(0, rotary_dim, 2) / rotary_dim))- 索引 小的维度对: 接近 ,位置每加 1 角度就大幅前进,转得极快。这就像时钟的秒针,稍微错开一个位置,角度就差很多,用来感知局部、近距离的依赖关系。
- 索引 大的维度对: 接近 ,越过成百上千个 Token 角度也没怎么变,转得很慢。这就像时钟的时针,用来感知全局、长距离的上下文关系。
回过头再看RoPE的代码,就可以很好的理解。
3. 代码实现
整个模块只有三部分:__init__ 预计算查找表,_rotate 做二维旋转,forward 查表并把旋转施加到 Q/K 上。
3.1. 准备阶段
import torchfrom torch import nn
class RotaryEmbedding(nn.Module): def __init__(self, head_size: int, rotary_dim: int, max_position_embedding: int, base: float) -> None: super(RotaryEmbedding, self).__init__() if rotary_dim % 2 != 0: raise ValueError("rotary_dim must be even") self.head_size = head_size self.rotary_dim = rotary_dim
inv_freq = 1.0 / ( base ** (torch.arange(0, rotary_dim, 2, dtype=torch.float) / rotary_dim) ) t = torch.arange(max_position_embedding, dtype=torch.float) freqs = torch.outer(t, inv_freq) self.register_buffer( "cos_sin_cache", torch.cat((freqs.cos(), freqs.sin()), dim=-1), persistent=False, )四个参数的含义:
head_size是每个头的向量长度,也就是 Q、K 最后一维的大小;rotary_dim是参与旋转的维度数量,必须为偶数且不超过head_size,比它小的时候只有靠前的维度带位置信息,后面的维度原样保留;max_position_embedding是 cos/sin 查找表的行数,也就是模型能处理的最大位置数,初始化时一次性算好存进 cache;base是 RoPE 的基底基数,base 越大位置之间的区分越弱,越小则对短距离位置更敏感。
register_buffer 是 nn.Module 的内置方法,把张量注册成模块的 buffer,用 GPU 运算时可以自动把 cache 搬到 GPU;persistent=False 表示这个 cache 不会放进 state_dict,也就不会被写进权重文件。
Qwen3 的 base 取 1,000,000。它支持 32768 的上下文,更大的 base 让旋转更慢,远端位置不容易出现角度重叠。
3.2. 执行旋转
def _rotate(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: x1, x2 = x.chunk(2, dim=-1) return torch.cat((x1 * cos - x2 * sin, x2 * cos + x1 * sin), dim=-1)chunk 在这里切的是 x,切出的 x1、x2 各 rotary_dim/2 长,配对关系是前半第 个配后半第 个(见 §2)。cos、sin 长度同样是 rotary_dim/2。
之所以要先拆后拼,是因为 rotary_dim/2 对维度的旋转角度各不相同,没法用一次整体乘法算完。把「所有实部」和「所有虚部」分别聚成两个数组之后,rotary_dim/2 个二维旋转就变成一次逐元素乘加,不用写循环。
3.3. 查表与施加
def forward(self, positions: torch.Tensor, query: torch.Tensor, key: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: flat = positions.reshape(-1) cos_sin = self.cos_sin_cache.index_select(0, flat).to(dtype=query.dtype) cos, sin = cos_sin.chunk(2, dim=-1) cos = cos.unsqueeze(-2) sin = sin.unsqueeze(-2)
def _apply(x: torch.Tensor) -> torch.Tensor: shape = x.shape x = x.reshape(flat.numel(), -1, self.head_size) x_rot = _rotate(x[..., : self.rotary_dim], cos, sin) x_pass = x[..., self.rotary_dim:] x_out = torch.cat([x_rot, x_pass], dim=-1) return x_out.reshape(shape)
return _apply(query), _apply(key)positions 一般是 torch.arange(seq_len).unsqueeze(0).expand(B, -1),形状 (B, S);query/key 是注意力投影出来的结果,形状 (B, S, H, D)。
按代码顺序来看:
- 首先把
(B, S)展平成(B*S,),因为index_select只接受一维索引。不同句子的位置编号可以重复(如[0,1,2,0,1,2]),句子内部不能重复;RoPE 只关心句内的相对关系,直接展平不会有问题。 - 其次按位置编号到 cache 里挑行,得到
cos_sin,形状(B*S, rotary_dim),每行是一个 token 的旋转系数。 - 之后转换类型,和模型保持一致。
- 再把缓存沿最后一维对半分,前一半是 cos、后一半是 sin。
- 在倒数第二维插入一个长度为 1 的维度, 方便与头的维度进行广播。
- 最后执行。
整体数据流
第一步:把位置编号变成旋转系数
第二步:把旋转施加到 Q/K 上
rd即rotary_dim;第二步里的cos / sin由第一步算出。
3.4. 跑一遍
if __name__ == "__main__": head_size = 64 rotary_dim = 32 max_pos = 128 base = 10000.0 rope = RotaryEmbedding(head_size, rotary_dim, max_pos, base)
q = torch.randn(1, 8, 4, head_size) k = torch.randn_like(q) pos = torch.arange(8).unsqueeze(0)
q_rot, k_rot = rope.forward(pos, q, k) print("q shape", q_rot.shape) print("k shape", k_rot.shape)这个例子正好覆盖了 rotary_dim < head_size 的情况(32 < 64):x 是 (1, 8, 4, 64),flat.numel() = 8,中间维自动推出 4,进 _rotate 的是前 32 维 (8, 4, 32),剩下 32 维由 x_pass 原样拼回,最后 reshape 回 (1, 8, 4, 64)。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!