上一篇(RoPE 原理,order 5010)从函数方程推出旋转矩阵。落到代码里还有一层窗户纸:实现几乎没人真的构造 $2\times2$ 旋转矩阵,而是把维度两两配对看成复数,乘一个单位复数 $e^{im\theta_i}$。这篇用复数语言重写 RoPE,然后讲三个从复数形式直接读出来的工程技巧。
复数形式:配对维度即一个复数
把 $q\in\mathbb{R}^d$ 相邻两维组成 $d/2$ 个复数:
$$ z_j = q_{2j} + i\, q_{2j+1},\qquad j = 0,\dots,d/2-1. $$
位置 $m$ 的作用是一次复数乘法:
$$ \tilde z_j = z_j \cdot e^{i m \theta_j},\qquad \theta_j = \mathrm{base}^{-2j/d}. $$
写成展开式就是
$$ \tilde z_j = \big(q_{2j}\cos m\theta_j - q_{2j+1}\sin m\theta_j\big) + i\big(q_{2j}\sin m\theta_j + q_{2j+1}\cos m\theta_j\big). $$
对照 $2\times2$ 旋转矩阵 $\begin{pmatrix}\cos m\theta_j & -\sin m\theta_j\\ \sin m\theta_j & \cos m\theta_j\end{pmatrix}$——两者是逐字相同的运算,复数乘法就是平面旋转。用复数的收益是推导工具:旋转的群结构、内积的共轭对称性($\mathrm{Re}[\tilde q_m \overline{\tilde k_n}]$ 只依赖 $m-n$,见第 5010 篇)在复数域里都是一行的事。
技巧一:rotate_half 与"配对"的自由度
上面取的是相邻配对 $(2j, 2j+1)$,即 interleaved 布局。但复数视角下"哪两维配成一对"是任意的——只要 query 和 key 用同一配对,旋转结构就成立。GPT-NeoX / LLaMA 的实现流行另一种布局:把向量劈成前后两半,第 $j$ 维与第 $j+d/2$ 维配对:
$$ \tilde q = q \odot \cos(m\theta) + \mathrm{rotate\_half}(q) \odot \sin(m\theta),\qquad \mathrm{rotate\_half}(q) = (-q_{d/2:},\; q_{0:d/2}). $$
两个布局差一个固定的置换矩阵 $P$:interleaved 布局 $=$ $P\cdot$ half-split 布局,而置换与逐对旋转可交换($P$ 只是把坐标换序,不改变每对的旋转),所以两者数学等价,只影响内存排布。half-split 的好处是实现更简单——只需一次切片拼接,rotate_half 在 PyTorch 里是四行;interleaved 布局则对向量化更友好。选哪个是工程口味问题,HF 的 LlamaRotaryEmbedding 走 half-split。
技巧二:cos/sin 表缓存
每个位置每对维度要两个标量 $\cos m\theta_j,\ \sin m\theta_j$。逐 token 现场算要 $O(n\cdot d/2)$ 个 sincos,decode 阶段纯属浪费——角度是位置的确定性函数,与模型参数无关。所以实现都预计算缓存:
inv_freq = base ** (-torch.arange(0, d, 2).float() / d) # θ_j 序列
t = torch.arange(max_seq_len)
freqs = torch.outer(t, inv_freq) # (L, d/2)
cos, sin = freqs.cos(), freqs.sin() # 缓存,register_buffer
前向时按 position_ids 索引取出行,广播乘进 q/k。两个细节:一是缓存表不带梯度(requires_grad=False),位置相位不属于可学习参数,这保证了 RoPE "零位置参数"的性质不被意外破坏;二是表里存半宽(d/2 列),两个配对分量共享 $\cos m\theta_j$ 与 $\sin m\theta_j$,所以 rotate_half 版本只需半宽表,省一半显存。
技巧三:把旋转融进 attention kernel
RoPE 是在 Q/K 离开投影之后、进注意力之前作用的,朴素实现是"先 rotate 再 attention"两个 kernel。由于旋转是逐元素运算,可以融进 FlashAttention 的 kernel:在 kernel 内部加载 q/k tile 后先乘 cos/sin 再做内积,省掉一次全局显存往返。vLLM、FlashAttention 的 apply_rotary 变体都这么干。另一个角度:RoPE 只对 Q/K 作用,V 不动——位置信息只进打分函数,不进被聚合的内容,这与正弦 PE(位置向量加在输入嵌入上,V 也带位置)有本质区别,也是 RoPE 在长上下文下更稳的原因之一。
小结
复数形式是 RoPE 的最优表达:数学上,旋转矩阵群结构一览无余;工程上,"配对任意性 → rotate_half"、"角度是位置的函数 → 缓存表"、"逐元素运算 → 融合进 kernel"三个技巧都能从它直接读出来。读完第 5010 篇的"为什么是旋转",加上这篇的"怎么写旋转",RoPE 本身已经闭环;接下来三篇处理它真正的软肋:基频怎么选(5070)、怎么外推(5080、5090)、外推时哪里先坏(5110)。