← 返回列表

RoPE:位置不是加进去的,是旋转出来的

先说结论:RoPE 的全部数学只有四个公式——

公式一句话
① 频率ωi=base−2i/d\omega_i = \mathrm{base}^{-2i/d}每对维度一根指针,转速写死
② 角度θm,i=m ωi\theta_{m,i} = m\,\omega_i位置 × 转速 = 角度,位置编码到此完成
③ 旋转x1′=x1cos⁡θ−x2sin⁡θ,x2′=x1sin⁡θ+x2cos⁡θx_1' = x_1\cos\theta - x_2\sin\theta,\quad x_2' = x_1\sin\theta + x_2\cos\theta按角度旋转一对维度
④ 性质R(m)⊤R(n)=R(n−m)R(m)^\top R(n) = R(n-m)两个绝对旋转相乘 = 一个相对旋转,一切成立的原因

需要的背景只有一件事:注意力打分是内积 qA⋅kBq_A \cdot k_B,只看两个向量的内容,天生不知道词的先后顺序,所以要把位置注入进去。而且该注入的是「隔多远」,不是「在第几位」——同一对词挪到文中任何地方,打分不该变。把位置加到向量里做不到这一点(内积一展开就留下绝对位置项,消不掉);RoPE 把位置做成角度,靠公式④让绝对位置在内积里自动消失。

RoPE 的全部数学:四个公式,一条流水线

①②③ 是机制:造表、位置变角度、旋转。④ 是原因。下面逐条讲清楚,然后单独讲代码实现,最后用一组能手算的数字把四条全部走通。

① 频率:每对维度一根指针

把 dd 维向量切成 d/2d/2 对,第 ii 对分到一个固定转速:

ωi=base−2i/d,i=0,1,…,d2−1\omega_i = \mathrm{base}^{-2i/d}, \qquad i = 0, 1, \ldots, \tfrac{d}{2}-1

base = 10000(Llama3 提到 5×1055\times10^5)。d=128d = 128 时:ω0=1\omega_0 = 1 最快,ω63≈1.2×10−4\omega_{63} \approx 1.2\times10^{-4} 最慢,对应的转圈周期(波长 2π/ωi2\pi/\omega_i)从约 6 个词到约 5.4 万个词。

为什么要 64 根不同的指针?一根不够:cos⁡((n−m) ω)\cos\big((n{-}m)\,\omega\big) 有周期,相差整圈的距离读数完全相同,分不开。快针(ω\omega 大)分辨近处,慢针(ω\omega 小)覆盖远处——像秒针、分针、时针各管一段,组合读数才能定位。这张表没有任何可学习参数,是写死的常数。

② 角度:位置 × 转速

θm,i=m ωi\theta_{m,i} = m\,\omega_i

位置 mm 的词,第 ii 对维度要转的角度。类比:秒针每秒 6°,走了 mm 秒就转 m×6°m \times 6°。

「位置编码」这一步就完成了——位置变成了角度,没有任何参数要学。注意角度逐对不同:同一个 mm,在 64 对维度上转出 64 个不同的角度,这就是①的转速表在起作用。

③ 旋转:角度作用到一对维度

拿到角度,怎么转?旋转是二维运算——角度天然定义在一张平面上,所以 RoPE 不是把整根向量转一个角度,而是两两一组:dd 维切成 d/2d/2 组,每组 2 个维度构成一张平面,在自己的平面上独立转。对 (x1,x2)(x_1, x_2) 这一组,转 θ\theta 就是:

R(θ)=(cos⁡θ−sin⁡θsin⁡θcos⁡θ),x1′=x1cos⁡θ−x2sin⁡θx2′=x1sin⁡θ+x2cos⁡θR(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}, \qquad \begin{aligned} x_1' &= x_1\cos\theta - x_2\sin\theta \\ x_2' &= x_1\sin\theta + x_2\cos\theta \end{aligned}

具体到 d=8d = 8(按论文的相邻配对,vjv_j 是第 jj 维):

组两个维度各自的转角
1(v0, v1)(v_0,\ v_1)θm,1=m ω1\theta_{m,1} = m\,\omega_1
2(v2, v3)(v_2,\ v_3)θm,2=m ω2\theta_{m,2} = m\,\omega_2
3(v4, v5)(v_4,\ v_5)θm,3=m ω3\theta_{m,3} = m\,\omega_3
4(v6, v7)(v_6,\ v_7)θm,4=m ω4\theta_{m,4} = m\,\omega_4

(d=128d = 128 就是 64 组,每组用自己的 ωi\omega_i。)这个「两两一组」有两条关键性质:

  • 组内混合:v0′v_0' 同时由 v0v_0 和 v1v_1 决定(cos、sin 交叉相乘)——旋转就发生在这两个维度撑起的平面上;
  • 组间独立:v0′v_0' 只依赖第 1 组,和 v2…v7v_2 \ldots v_7 无关——所以整根向量的变换矩阵是分块对角的,R(m)=diag(R(θm,1), …, R(θm,d/2))R(m) = \mathrm{diag}\big(R(\theta_{m,1}),\ \ldots,\ R(\theta_{m,d/2})\big):64 个 2×22\times2 小块沿对角线排开,互不干扰。

位置 mm 的 Q、K 变成 q~=R(m) q\tilde{q} = R(m)\,q、k~=R(n) k\tilde{k} = R(n)\,k。旋转只改变方向、不改变长度(∥Rq∥=∥q∥\|Rq\| = \|q\|),内容的大小关系全部保留。(哪两个维度凑成一组有两种约定,数学等价,见代码节。)

机制到此讲完:①造表、②变角、③旋转。但为什么这么转,打分就自动只依赖相对距离?靠④。

④ 核心性质:R(m)⊤R(n)=R(n−m)R(m)^\top R(n) = R(n-m)

理解 RoPE 最重要的一条公式。先看它从哪来。注意力打分是内积,把③的结果代进去:

q~⋅k~=(R(m) q)⊤(R(n) k)=q⊤ R(m)⊤R(n)⏟两个绝对旋转相乘 k\tilde{q} \cdot \tilde{k} = \big(R(m)\,q\big)^\top \big(R(n)\,k\big) = q^\top\, \underbrace{R(m)^\top R(n)}_{\text{两个绝对旋转相乘}}\, k

内积自动把位置 mm 的旋转和位置 nn 的旋转乘到了一起。这个乘积是什么?两条初等性质,各一行就能验证:

  • 转置 = 反转:R(θ)⊤=R(−θ)R(\theta)^\top = R(-\theta)。从③的矩阵看,转置恰好把 −sin⁡-\sin 换了边,等于反着转;
  • 复合 = 相加:R(a) R(b)=R(a+b)R(a)\,R(b) = R(a+b)。用③的矩阵直接乘开,就是和角公式 cos⁡(a+b)=cos⁡acos⁡b−sin⁡asin⁡b\cos(a{+}b) = \cos a\cos b - \sin a \sin b。

两条性质合起来,④就出来了:

R(m)⊤R(n)=R(−θm,i) R(θn,i)=R(θn,i−θm,i)=R((n−m) ωi)=R(n−m)R(m)^\top R(n) = R(-\theta_{m,i})\, R(\theta_{n,i}) = R(\theta_{n,i} - \theta_{m,i}) = R\big((n{-}m)\,\omega_i\big) = R(n{-}m)

(第三步用的是②:θn,i−θm,i=(n−m) ωi\theta_{n,i} - \theta_{m,i} = (n{-}m)\,\omega_i。记号 R(n−m)R(n{-}m) 是转 (n−m)ωi(n{-}m)\omega_i 的简写;每对维度上都成立,整根向量的分块矩阵同样成立。)

代回打分:

q~⋅k~=q⊤R(n−m) k\tilde{q} \cdot \tilde{k} = q^\top R(n{-}m)\, k

mm 和 nn 单独消失了,只剩 n−mn - m。这就是「相对位置编码」四个字的确切含义:词对 (3,5)(3, 5) 和 (1003,1005)(1003, 1005) 的打分完全一样,因为距离都是 2。

公式④的几何图像:两个绝对角度相减,只剩相对角度

几何上就是角度相减:q~\tilde{q} 转过 mωim\omega_i,k~\tilde{k} 转过 nωin\omega_i,两个灰弧是各自的绝对角度,橙弧是夹角 (n−m)ωi(n{-}m)\omega_i。同一对词往后挪 100 个位置:两个灰弧都变,橙弧不变——打分只看橙弧。

④也是所有工程优势的来源:注意力公式一字未改,只是 Q、K 进场前先转个角度,所以 KV cache、FlashAttention 原样可用;V 不用转——位置信息的唯一用途是参与打分,V 是被打分加权的内容本身。

(原论文用复数记号推导④:把一对维度看成复数,旋转 = 乘 eiθe^{i\theta},共轭相乘时角度相消。本质相同,矩阵记号更直白。)

代码实现:四个公式在代码里的位置

原理到此结束,实现单独看(llama2/model.py)。对照①②③④读,每一行都能认领:

def precompute_rope_cache(seq_len, head_dim, base=10000.0):
    inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))  # ① ωᵢ
    t = torch.arange(seq_len).float()                                            # 位置 m
    freqs = torch.outer(t, inv_freq)          # ② θ_{m,i} = m·ωᵢ,(S, d/2)
    emb = torch.cat((freqs, freqs), dim=-1)   # 复制成 (S, d):前半/后半同用一套角
    return emb.cos(), emb.sin()               # ③ 的原料

旋转角只依赖 (m,i)(m, i),和内容无关,训练前算一次缓存成 buffer。

③ 的高效实现是全文最巧的一步。R(m)R(m) 是 d×dd\times d 的稀疏矩阵,直接矩阵乘又大又慢;观察到它分块对角、每块只有 cos 和 sin,乘法可以重排成逐元素乘加。设配对为前半 xx、后半 yy(第 ii 维和第 i+d/2i{+}d/2 维一对,原因见下面配对约定),③变成:

v~=(xcos⁡−ysin⁡;  ycos⁡+xsin⁡)=vcos⁡+(−y; x)sin⁡\tilde{v} = \big(x\cos - y\sin;\ \ y\cos + x\sin\big) = v\cos + \big({-y};\, x\big)\sin

而 (−y; x)(-y;\,x) 正是 rotate_half 做的事:

def rotate_half(x):                    # [x1, x2] → [-x2, x1]
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat((-x2, x1), dim=-1)

def apply_rope(q, k, cos, sin):
    cos = cos.unsqueeze(0).unsqueeze(0)     # (S,d)→(1,1,S,d):batch、head 维广播
    sin = sin.unsqueeze(0).unsqueeze(0)
    q_rot = (q * cos) + (rotate_half(q) * sin)
    k_rot = (k * cos) + (rotate_half(k) * sin)
    return q_rot, k_rot                     # ④ 的效果在这里兑现;只碰 Q、K

旋转就是一行 v * cos + rotate_half(v) * sin:「配对维度交叉」的矩阵结构被压缩成一次 chunk + cat。cos/sin 表复制成两份的原因也在这:前半的 xix_i 和后半的 yiy_i 用同一个 ωi\omega_i。

一层注意力里的 RoPE:只作用于 Q 和 K

两个必须知道的工程细节:

  • 配对约定有两种,等价但权重不互通。论文是相邻配对 (2i, 2i+1)(2i,\ 2i{+}1);这份实现(HuggingFace Llama / NeoX 风格)是前半配后半 (i, i+d/2)(i,\ i{+}d/2)。只差一个维度排列,forward 数学上等价;但预训练权重里的维度排列跟着训练时的约定走,加载第三方权重时配错,模型会静默输出乱码——手写实现最典型的「跑通但不对」。
  • 只旋转 Q、K,V 直通(图里 V 那条路绕过旋转节点)。

案例:d = 4,四个公式手算一遍

用 d=4d = 4(两对维度)、手选好算的转速,把①②③④用一组数字全部走通:

① 转速:ω=(30°, 15°)\omega = (30°,\ 15°)。 ② 角度:qq 放位置 m=2m=2,角度 (2×30°, 2×15°)=(60°, 30°)(2\times30°,\ 2\times15°) = (60°,\ 30°);kk 放位置 n=5n=5,角度 (150°, 75°)(150°,\ 75°)。

设 q=(1,1,0,0)q = (1, 1, 0, 0)、k=(0,0,1,1)k = (0, 0, 1, 1)。按代码用的前/后半配对(第 ii 维和第 i+d/2i{+}d/2 维一组,d=4d{=}4 时就是 (0,2)(0,2)、(1,3)(1,3) 两组):平面 1 = (q0,q2)=(1,0)(q_0, q_2) = (1, 0),平面 2 = (q1,q3)=(1,0)(q_1, q_3) = (1, 0);kk 的两个平面都是 (0,1)(0, 1)。

③ 旋转。 qq 的平面 1 转 60°:(1,0)→(0.5, 0.866)(1,0) \to (0.5,\ 0.866);平面 2 转 30°:(1,0)→(0.866, 0.5)(1,0) \to (0.866,\ 0.5)。结果写回各自的维度(平面 1 → 维度 0 和 2,平面 2 → 维度 1 和 3):

q~=(0.5, 0.866, 0.866, 0.5)\tilde{q} = (0.5,\ 0.866,\ 0.866,\ 0.5)

kk 的平面 1 转 150°:(0,1)(0,1) 从 90° 转到 240°,(−0.5, −0.866)(-0.5,\ -0.866);平面 2 转 75°:转到 165°,(−0.966, 0.259)(-0.966,\ 0.259):

k~=(−0.5, −0.966, −0.866, 0.259)\tilde{k} = (-0.5,\ -0.966,\ -0.866,\ 0.259)

④ 打分。

q~⋅k~=−0.25−0.837−0.75+0.13=−1.707\tilde{q} \cdot \tilde{k} = -0.25 - 0.837 - 0.75 + 0.13 = -1.707

逐平面看更清楚:平面 1 夹角 240°−60°=180°240° - 60° = 180°,贡献 cos⁡180°=−1\cos 180° = -1;平面 2 夹角 165°−30°=135°165° - 30° = 135°,贡献 cos⁡135°=−0.707\cos 135° = -0.707。−1−0.707=−1.707-1 - 0.707 = -1.707 ✓——打分是各平面读数之和,每个平面里绝对角度都已相减(④)。

④ 的两个直接验证:

  • 平移不变:整对词后挪 10 位(m=12, n=15m{=}12,\ n{=}15,距离仍 3)→ q~′=(1,−1,0,0)\tilde{q}' = (1, -1, 0, 0),k~′=(−1, 0.707, 0, −0.707)\tilde{k}' = (-1,\ 0.707,\ 0,\ -0.707),内积 −1−0.707=−1.707-1 - 0.707 = -1.707,不变;
  • 距离敏感:kk 挪到 n=4n = 4(距离 2)→ 两平面夹角 150°150° 和 120°120°,打分 −0.866−0.5=−1.366≠−1.707-0.866 - 0.5 = -1.366 \neq -1.707,变了。

同一组数字过代码。 m=2m = 2 那行表:cos⁡=(0.5, 0.866, 0.5, 0.866)\cos = (0.5,\ 0.866,\ 0.5,\ 0.866),sin⁡=(0.866, 0.5, 0.866, 0.5)\sin = (0.866,\ 0.5,\ 0.866,\ 0.5);rotate_half(1, 1, 0, 0) = (0, 0, 1, 1):

q * cos              = (0.5,   0.866, 0,     0   )
rotate_half(q) * sin = (0,     0,    0.866, 0.5 )   ← 相加
                     = (0.5,   0.866, 0.866, 0.5)   ← 手算的 q̃,一致

可运行版(输出经 PyTorch 实测):

import torch
omega = torch.tensor([30, 15]) * torch.pi / 180    # ① ω = (30°, 15°)

def rot(x, m):                                     # ② + ③
    ang = m * omega
    c = torch.cat([ang.cos(), ang.cos()]); s = torch.cat([ang.sin(), ang.sin()])
    return x * c + rotate_half(x) * s

q = torch.tensor([1., 1., 0., 0.]); k = torch.tensor([0., 0., 1., 1.])
print(rot(q, 2))                # [0.5000, 0.8660, 0.8660, 0.5000] — ③ 的 q̃
print(rot(q, 2) @ rot(k, 5))    # -1.7071 — ④ 打分(距离 3)
print(rot(q, 12) @ rot(k, 15))  # -1.7071 — 平移 +10,不变
print(rot(q, 2) @ rot(k, 4))    # -1.3660 — 距离 2,变了

总结

  • ① ωi=base−2i/d\omega_i = \mathrm{base}^{-2i/d}:64 根快慢指针,写死的常数表,快针管近处、慢针管远处;
  • ② θm,i=m ωi\theta_{m,i} = m\,\omega_i:位置乘转速——位置编码的全部,零参数;
  • ③ 二维旋转:逐对维度转 θm,i\theta_{m,i},只改方向不改长度,R(m)R(m) 分块对角;
  • ④ R(m)⊤R(n)=R(n−m)R(m)^\top R(n) = R(n{-}m):转置 = 反转、复合 = 相加,内积把两个绝对旋转乘到一起,绝对位置消掉,打分 =q⊤R(n−m) k= q^\top R(n{-}m)\,k,只剩相对距离;
  • 实现:precompute_rope_cache 是①②,rotate_half + 逐元素乘加是③,只旋转 Q、K;配对约定等价但权重不互通。

延伸

④保证了「训练长度内,打分只依赖距离」,但没解决「训练 4K、推理 128K」:超出训练见过的距离后,慢针也开始转满圈,不同距离的读数撞车(混叠)。沿这条线长出一族长度外推方法——位置插值(PI)把位置线性压回训练范围,NTK-aware 调大 base 让慢针更慢,YaRN 是两者的组合;Llama3 则直接把 base 提到 5×1055\times10^5 用长数据重训。想继续动手:把案例里的 rot 扩到 d=128d = 128,画出各平面的 cos⁡(mωi)\cos(m\omega_i) 曲线,秒针、分针、时针会直接长在眼前。