从零实现vLLM系列【5】:RotaryEmbedding

2450 字
12 分钟
从零实现vLLM系列【5】:RotaryEmbedding
如何获取完整项目

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

如何获取项目
如何获取项目

RotaryEmbedding#

我在博客中Attention的那一章提到过,Attention机制本身是没有位置信息的,需要显式的注入位置信息。 经过研究表明,相对位置的重要性是要比绝对位置更加重要的。这句话什么意思呢?

例如:我 爱 你。 其中我的位置是0,你的位置是2,它们的相对位置是2,模型并不关心“我”和“你”的实际位置,而是更加关系二者之间的距离。

RoPE的核心思想

通过旋转变换为向量注入位置信息,使得两个向量的内积只依赖于它们的相对位置。

向量旋转图
向量旋转图

1. 原理讲解#

假设我们在二维平面上有一个向量q=(x, y), 如果想要把它逆时针旋转一个角度 θ\theta,在线性代数中,这相当于左乘一个二维旋转矩阵 R(θ)R(\theta)。 但这个矩阵具体长什么样?

我们高中的时候学过,在二维平面中,只需要两个向量就可以把平面内的任意向量表示出来,例如X和Y轴的单位向量,他们在矩阵论中被叫做基向量。

实际上只要确定两个基向量旋转后落在哪里,平面内任意向量都可以通过左乘一个旋转向量来解决。

假设我们用的是二维直角坐标系,我们定义两个单位向量分别是:

  • e1=(1,0)Te_1 = (1, 0)^T
  • e2=(0,1)Te_2 = (0, 1)^T

先看 e1=(1,0)Te_1 = (1, 0)^T,它指向 x 轴正方向。逆时针转 θ\theta 后,落点仍在单位圆上,对应的角度就是 θ\theta。单位圆上角度为 θ\theta 的点的坐标就是:

e1↦(cos⁡θ, sin⁡θ)Te_1 \mapsto (\cos\theta,\ \sin\theta)^T

再看 e2=(0,1)Te_2 = (0, 1)^T,它指向 y 轴正方向,本身就是 90∘90^\circ 方向。再逆时针转 θ\theta,落点就在 90∘+θ90^\circ + \theta 的角度上:

e2↦(cos⁡(90∘+θ), sin⁡(90∘+θ))T=(−sin⁡θ, cos⁡θ)Te_2 \mapsto (\cos(90^\circ+\theta),\ \sin(90^\circ+\theta))^T = (-\sin\theta,\ \cos\theta)^T

两个基向量的像都求出来了,按列排开,第一列放 e1e_1 的像,第二列放 e2e_2 的像:

R(θ)=(cos⁡θ−sin⁡θsin⁡θcos⁡θ)R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}

向量左乘这个矩阵,就可以得到旋转后的新的向量 q′q':

q′=R(θ)⋅(xy)=(xcos⁡θ−ysin⁡θxsin⁡θ+ycos⁡θ)q' = R(\theta) \cdot \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} x\cos\theta - y\sin\theta \\ x\sin\theta + y\cos\theta \end{pmatrix}

2. RoPE在大模型的示例讲解#

现在,我们有一段文本,每个 Token 都有一个物理上的绝对位置编号 mm(比如第 m=0,1,2...m=0, 1, 2... 个词)。

RoPE 的做法是我在第 mm 个位置上,就把向量旋转 m⋅θm \cdot \theta 这么多角度。 也就是说,向量在这个句子中越靠后,它被旋转的角度就越大。

此时,位置 mm 处的旋转矩阵变成了:

R(mθ)=(cos⁡(mθ)−sin⁡(mθ)sin⁡(mθ)cos⁡(mθ))R(m\theta) = \begin{pmatrix} \cos(m\theta) & -\sin(m\theta) \\ \sin(m\theta) & \cos(m\theta) \end{pmatrix}

把 Query 向量放在位置 mm 旋转,把 Key 向量放在位置 nn 旋转:

  • q~m=R(mθ)⋅q\tilde{q}_m = R(m\theta) \cdot q
  • k~n=R(nθ)⋅k\tilde{k}_n = R(n\theta) \cdot k

到这一步为止,我们赋予 qq 和 kk 的都只是绝对位置信息。


Transformer的核心是Attention,Attention的核心就是计算Query和Key的内积来决定注意力分数。

Q×KTQ\times K^T

我们算一下,带有绝对位置 mm 的 q~m\tilde{q}_m 和带有绝对位置 nn 的 k~n\tilde{k}_n 的内积是什么:

q~m⋅k~n=(R(mθ)q)T(R(nθ)k)=qTR(mθ)TR(nθ)k\tilde{q}_m \cdot \tilde{k}_n = (R(m\theta) q)^T (R(n\theta) k) = q^T R(m\theta)^T R(n\theta) k

根据旋转矩阵的几何性质,一个旋转矩阵的转置,等于反向旋转,即 R(mθ)T=R(−mθ)R(m\theta)^T = R(-m\theta)。 而两个旋转矩阵相乘,等于它们的角度相加:

R(−mθ)⋅R(nθ)=R(nθ−mθ)=R((n−m)θ)R(-m\theta) \cdot R(n\theta) = R(n\theta - m\theta) = R((n-m)\theta)

所以,上面的内积公式化简为:

q~m⋅k~n=qT⋅R((n−m)θ)⋅k\tilde{q}_m \cdot \tilde{k}_n = q^T \cdot R((n-m)\theta) \cdot k

可以看到,现在它们只和两个Token的相对距离有关。


我们接着来思考多维向量的情况。 刚才讲的都是 2 维向量。但大模型里,每个 Attention 头(Head)的维度通常是 128 维(记为 head_size),而参与旋转的 rotary_dim 通常就等于它。

RoPE 的做法是把 rotary_dim 个维度按前后对半分成两段(各 rotary_dim/2 维),让前半的第 ii 维与后半的第 ii 维配成一对,并把这一对放到它们张成的二维平面里旋转 θi\theta_i。

以 rotary_dim = 128 为例,前 64 维与后 64 维一一配对:

  • 第 00 维与第 6464 维配成一对,按 θ0\theta_0 旋转;
  • 第 11 维与第 6565 维配成一对,按 θ1\theta_1 旋转;
  • …
  • 第 6363 维与第 127127 维配成一对,按 θ63\theta_{63} 旋转。

这 rotary_dim/2 个二维旋转子空间互相独立,整体相当于一个 rotary_dim 维的块对角旋转矩阵.只不过这些 2×2 块在矩阵中的位置是前后错开的,而不是相邻的。

为什么前后,而不是相邻呢?

原 RoFormer 论文用的是”奇偶交错”约定,即把第 0,10, 1 维配成一对。但是这种方法会让批处理效果下降。

不同的维度,旋转的速度(频率)不一样 每个维度对用自己的频率,RoPE 沿用原版 Transformer 的频率衰减公式:

θi=base−2id\theta_i = \mathrm{base}^{-\frac{2i}{d}}

代码实现如下:

inv_freq = 1.0 / (base ** (arange(0, rotary_dim, 2) / rotary_dim))
  • 索引 ii 小的维度对:θi\theta_i 接近 11,位置每加 1 角度就大幅前进,转得极快。这就像时钟的秒针,稍微错开一个位置,角度就差很多,用来感知局部、近距离的依赖关系。
  • 索引 ii 大的维度对:θi\theta_i 接近 00,越过成百上千个 Token 角度也没怎么变,转得很慢。这就像时钟的时针,用来感知全局、长距离的上下文关系。

回过头再看RoPE的代码,就可以很好的理解。

3. 代码实现#

整个模块只有三部分:__init__ 预计算查找表,_rotate 做二维旋转,forward 查表并把旋转施加到 Q/K 上。

3.1. 准备阶段#

import torch
from 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 长,配对关系是前半第 ii 个配后半第 ii 个(见 §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 的维度, 方便与头的维度进行广播。
  • 最后执行。

整体数据流#

第一步:把位置编号变成旋转系数

--- config: flowchart: nodeSpacing: 18 rankSpacing: 20 padding: 4 --- flowchart LR A0["positions<br/>(B, S)"] --> A1["flat<br/>(B*S,)"] A1 --> A2["cos_sin<br/>(B*S, rd)"] A2 --> A3["cos / sin<br/>(B*S, 1, rd/2)"]

第二步:把旋转施加到 Q/K 上

--- config: flowchart: nodeSpacing: 18 rankSpacing: 20 padding: 4 --- flowchart LR B0["query / key<br/>(B, S, H, D)"] --> B1["x<br/>(B*S, H, D)"] B1 --> B2["_rotate"] B3["cos / sin"] --> B2 B1 --> B4["x_pass"] B2 --> B5["cat + reshape"] B4 --> B5 B5 --> B6["输出<br/>(B, S, H, D)"]

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)。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
从零实现vLLM系列【5】:RotaryEmbedding
https://dlog.com.cn/posts/offer06/rotaryembedding/
作者
杜子源
发布于
2026-09-05
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
杜子源
都是风景,幸会
公告
如需vLLM项目,请点击赞助第一个二维码并备注github邮箱,或者加我Q:402555241私发我截图
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
39
分类
9
标签
15
总字数
123,754
运行时长
0 天
最后活动
0 天前

目录