Rope Embedding Optimization

Understanding RoPE from an engineer's perspective — from the math to the CUDA kernel.

旋转编码 RoPE

旋转编码是苏剑林老师网名"苏神",科学空间(spaces.ac.cn)博客作者,RoPE 的提出者。把鼠标悬停在这个上标数字上就能看到这条脚注——不需要点击。在论文 Roformer: Enhanced Transformer With Rotary Position Embedding 中提出的,苏神是怎么想到这个 idea 的详细可以参考他的 博采众长的旋转式位置编码,其他位置编码的调研则参考了苏神的 让研究人员绞尽脑汁的 Transformer 位置编码。下面是笔者从博客中简单做的总结,对于懒得看数学推导的朋友们可以就看这个结论:

因为只靠 Attention 模块无法捕捉输入的位置信息。换句话说,将输入随意做 permute,再对 Attention 的输出做 unpermute,获得的结果都是一样的。这是一件比较糟糕的事情,为此,我们需要引入位置编码。RoPE 的核心思想是”通过绝对位置编码的方式实现相对位置编码”。这句话可能还是有一点点抽象,让笔者详细解释一下:相对位置编码的目标是让第 i 个词和第 j 个词之间距离只和 (i - j) 相关,传统相对位置编码的实现方式主要是想办法对 Attention 计算中间过程里 QK 这个矩阵做一点操作,从而保证 QK 矩阵里的每一行的第 i 个元素和第 j 个元素之间有一个和 (i - j) 相关的差;绝对位置编码的意思就是不去修改 Attention 里的矩阵,只修改 Attention 里的输入。而 RoPE 就只修改了输入,同时还能保留相对位置信息。

既然把”只修改输入前提下保留相对位置”这句话翻译成数学的语言就是下面这行公式。

  • 其中 f 就是我们需要求解的函数,这个函数以向量和它的位置作为输入,输出一个加了位置信息的向量。
  • 因为 Attention 计算里 QK 的本质就是向量之间两两做内积,所以修改 Attention 里矩阵和要求内积结果只和 (m - n) 相关是等价的。
\[\langle\boldsymbol{f}(\boldsymbol{q}, m), \boldsymbol{f}(\boldsymbol{k}, n)\rangle=g(\boldsymbol{q}, \boldsymbol{k}, m-n) \\ \boldsymbol{f}(\boldsymbol{q}, 0) = \boldsymbol{q} \label{eq:rope-core}\]

求解公式 $\eqref{eq:rope-core}$,得到下面这个函数是上面这个方程的一个解,OK,我们就得到了 RoPE 了。(点击上面这个公式右侧的编号可以跳转回来。)

点击展开:RoPE 的矩阵形式推导 \[\left(\begin{array}{ccccccc} \cos m \theta_0 & -\sin m \theta_0 & 0 & 0 & \cdots & 0 & 0 \\ \sin m \theta_0 & \cos m \theta_0 & 0 & 0 & \cdots & 0 & 0 \\ 0 & 0 & \cos m \theta_1 & -\sin m \theta_1 & \cdots & 0 & 0 \\ 0 & 0 & \sin m \theta_1 & \cos m \theta_1 & \cdots & 0 & 0 \\ \vdots & \vdots & \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & 0 & 0 & \cdots & \cos m \theta_{d / 2-1} & -\sin m \theta_{d / 2-1} \\ 0 & 0 & 0 & 0 & \cdots & \sin m \theta_{d / 2-1} & \cos m \theta_{d / 2-1} \end{array}\right)\left(\begin{array}{c} q_0 \\ q_1 \\ q_2 \\ q_3 \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right)\]

上面这个函数又等价于下面这个函数,下面这个函数里的矩阵都是 Dense 的,因此算起来更高效,所以在后文计算的时候都以下面这个矩阵为准。其中 $ \theta_i=10000^{-2 i / d} $,这点和最经典的 Sinusoidal 保持一致。

\[\left(\begin{array}{c} q_0 \\ q_1 \\ q_2 \\ q_3 \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \cos m \theta_0 \\ \cos m \theta_0 \\ \cos m \theta_1 \\ \cos m \theta_1 \\ \vdots \\ \cos m \theta_{d / 2-1} \\ \cos m \theta_{d / 2-1} \end{array}\right)+\left(\begin{array}{c} -q_1 \\ q_0 \\ -q_3 \\ q_2 \\ \vdots \\ -q_{d-1} \\ q_{d-2} \end{array}\right) \otimes\left(\begin{array}{c} \sin m \theta_0 \\ \sin m \theta_0 \\ \sin m \theta_1 \\ \sin m \theta_1 \\ \vdots \\ \sin m \theta_{d / 2-1} \\ \sin m \theta_{d / 2-1} \end{array}\right)\]

另外,在实现上为了更高效,Llama 其实是这么实现的,下面也以这种实现为基准。

\[\left(\begin{array}{c} q_0 \\ q_1 \\ \vdots \\ q_{d/2-1} \\ q_{d/2} \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \cos m \theta_0 \\ \cos m \theta_1 \\ \vdots \\ \cos m \theta_{d / 2-1} \\ \cos m \theta_{0} \\ \vdots \\ \cos m \theta_{d / 2-2} \\ \cos m \theta_{d / 2-1} \end{array}\right)+\left(\begin{array}{c} -q_0 \\ -q_1 \\ \vdots \\ -q_{d/2-1} \\ q_{d/2} \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \sin m \theta_0 \\ \sin m \theta_1 \\ \vdots \\ \sin m \theta_{d / 2-1} \\ \sin m \theta_{0} \\ \vdots \\ \sin m \theta_{d / 2-2} \\ \sin m \theta_{d / 2-1} \end{array}\right)\]

That’s It! 下面就让我们来实现一版吧!

RoPE 实现

让我们以 2025 年 1 月初 Megatron-LM 里的一个实现作为 baseline,链接。下面简单写一个伪代码。

  • 公式里的 m 通过 torch.arange(seq_len) 生成。torch.arange 的结果完全可以复用,因此可以 Cache。
  • 公式里的 theta 对应 inv_freq,其结果也可以复用。
  • outer 的输入是两个一维的向量,输出是一个二维的向量,其含义是输入的笛卡尔积。
  • _rotate_half 生成最后那个公式里右边的那部分。
def _rotate_half(x):
    x1, x2 = torch.chunk(x, 2, dim=-1)
    return torch.cat((-x2, x1), dim=-1)
inv_freq = 1.0 / (rotary_base ** (torch.arange(0, dim, 2) / dim))
freqs = torch.outer(torch.arange(seq_len), inv_freq)
cos_ = torch.cos(freqs)
sin_ = torch.sin(freqs)
t = (t * cos_) + (_rotate_half(t) * sin_)

CUDA 实现

Apex、TransformerEngine、FlashAttention 都提供了 API,具体依然可以参考 Megatron 里的代码。本文的目的既不是想探究 API 怎么用(这部分看 Megatron 代码就好了),也懒得自己写一个 CUDA 代码了,因此直接跑一个实验,看看这几个 API 实现的效果如何,从而大概了解一下这种类型的函数应该怎么实现。

首先, 是不同 API 在 A100 上的实验结果。可以看到 FlashAttention 的实现其实还差一点,Apex 和 TransformerEngine 的实现是最优的。恰好笔者对 TransformerEngine 这个库比较熟,就直接看看这里是怎么实现的吧。

实现 耗时 (ms)
RotaryEmbeddingMegatron 73.51
RotaryEmbeddingApex 14.40
RotaryEmbeddingTE 14.08
RotaryEmbeddingFlash 21.12
不同 RoPE 实现在 A100 上的耗时(size = 20971520)。点击正文中的“表 1”即可跳转到这里。

具体代码是 FusedRoPEFunc 这个函数,一上来就是巨大的槽点。也就是说,不管输入是什么类型,TransformerEngine 都会先把它转成 Float32。那么很自然的大家就会想到,如果输入是 BFloat16,正确性还能保证吗?笔者做了实验,发现把 Input 的 Dtype 从 Float 改成 BFloat16 之后,FlashAttention 和 TransformerEngine 都和 baseline 对不齐。emmm 只能说世界就是巨大的草台班子,大家调用这种 API 之前最好还是本地实测一下。至于 Megatron 很机智的用了 Apex 的实现做了默认的实现,不知道是不是出于这个正确性的考虑。

if freqs.dtype != torch.float32:
    freqs = freqs.float()

让我们再看看具体 CUDA 实现,毕竟这个实现性能还是很好的。让我们无视掉每个变量具体的含义,快速地看一些 High level Idea,思想很简单,就是每个线程去做 Load/Store。这个函数是典型的 Memory Bound 的函数,因此 sincosf 这个计算绝对是 Free 的。这也解释了,为什么 FlashAttention 的实现是提前算好 sin/cos,而 TransformerEngine 是每次计算 sin/cos,反而是后者的性能更好,答案是因为计算根本就是 free 的。我看了 FlashAttention 的代码,他是用 Triton 实现的,那确实性能比 CUDA 差是符合预期的。

#pragma unroll
  for (int d_id = threadIdx.x; d_id < d2; d_id += blockDim.x) {
    float v_cos, v_sin;
    sincosf(freqs[s_id * d2 + d_id], &v_sin, &v_cos);
#pragma unroll
    for (int h_id = threadIdx.y; h_id < h; h_id += blockDim.y) {
      int offset_src = offset_block + h_id * stride_h + d_id * stride_d;
      int offset_dst = offset_block_dst + h_id * o_stride_h + d_id * o_stride_d;
      float v_src = src[offset_src];
      float v_src_rotate = (d_id + d2 / 2 < d2)
                               ? -static_cast<float>(src[offset_src + (d2 / 2) * stride_d])
                               : static_cast<float>(src[offset_src + (d2 / 2 - d2) * stride_d]);
      dst[offset_dst] = v_src * v_cos + v_src_rotate * v_sin;
    }
  }

本文就到此结束了,代码都在笔者的 这个 GitHub 仓库 里。如果想深究、追求一个极致,还可以想办法通过 float4 这种方式来加速 Load/Store。或许日后某天会补上吧。

Rotary Position Embedding (RoPE)

RoPE was proposed by Jianlin SuKnown online as "苏神"; author of the spaces.ac.cn blog and the originator of RoPE. Hover over the superscript number to read this footnote — no click needed. in Roformer: Enhanced Transformer With Rotary Position Embedding. For how he arrived at the idea, see his post 博采众长的旋转式位置编码; for a survey of other position encodings, see 让研究人员绞尽脑汁的 Transformer 位置编码. Below is my short summary — if you’d rather skip the derivation, just read this conclusion:

Attention alone cannot capture positional information. In other words, if you arbitrarily permute the inputs and then un-permute the attention outputs, you get the exact same result. That is undesirable, so we need to introduce position encodings. The core idea of RoPE is “to realize relative position encoding through absolute position encoding.” That may still be a little abstract, so let me explain: the goal of relative position encoding is to make the distance between token $i$ and token $j$ depend only on $(i - j)$. Traditional approaches mostly operate on the $QK$ matrix in the middle of attention, ensuring that within each row of $QK$ the difference between elements $i$ and $j$ depends on $(i - j)$. Absolute position encoding, by contrast, leaves the attention matrices untouched and only modifies the attention inputs. RoPE modifies only the inputs, while still preserving relative-position information.

Translating “preserve relative position while only modifying the inputs” into math gives the equation below.

  • Here $f$ is the function we need to solve for: it takes a vector and its position, and outputs a position-aware vector.
  • Since $QK$ in attention is essentially a pairwise inner product of vectors, modifying the attention matrices is equivalent to requiring that the inner-product result depend only on $(m - n)$.
\[\langle\boldsymbol{f}(\boldsymbol{q}, m), \boldsymbol{f}(\boldsymbol{k}, n)\rangle=g(\boldsymbol{q}, \boldsymbol{k}, m-n) \\ \boldsymbol{f}(\boldsymbol{q}, 0) = \boldsymbol{q}\]

Solving this equation, the function below is one solution — and that gives us RoPE.

Click to expand: the matrix form of RoPE \[\left(\begin{array}{ccccccc} \cos m \theta_0 & -\sin m \theta_0 & 0 & 0 & \cdots & 0 & 0 \\ \sin m \theta_0 & \cos m \theta_0 & 0 & 0 & \cdots & 0 & 0 \\ 0 & 0 & \cos m \theta_1 & -\sin m \theta_1 & \cdots & 0 & 0 \\ 0 & 0 & \sin m \theta_1 & \cos m \theta_1 & \cdots & 0 & 0 \\ \vdots & \vdots & \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & 0 & 0 & \cdots & \cos m \theta_{d / 2-1} & -\sin m \theta_{d / 2-1} \\ 0 & 0 & 0 & 0 & \cdots & \sin m \theta_{d / 2-1} & \cos m \theta_{d / 2-1} \end{array}\right)\left(\begin{array}{c} q_0 \\ q_1 \\ q_2 \\ q_3 \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right)\]

The function above is equivalent to the one below, whose matrices are all dense and therefore more efficient to compute — so all later computation uses this form. Here $ \theta_i=10000^{-2 i / d} $, consistent with the classic Sinusoidal encoding.

\[\left(\begin{array}{c} q_0 \\ q_1 \\ q_2 \\ q_3 \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \cos m \theta_0 \\ \cos m \theta_0 \\ \cos m \theta_1 \\ \cos m \theta_1 \\ \vdots \\ \cos m \theta_{d / 2-1} \\ \cos m \theta_{d / 2-1} \end{array}\right)+\left(\begin{array}{c} -q_1 \\ q_0 \\ -q_3 \\ q_2 \\ \vdots \\ -q_{d-1} \\ q_{d-2} \end{array}\right) \otimes\left(\begin{array}{c} \sin m \theta_0 \\ \sin m \theta_0 \\ \sin m \theta_1 \\ \sin m \theta_1 \\ \vdots \\ \sin m \theta_{d / 2-1} \\ \sin m \theta_{d / 2-1} \end{array}\right)\]

For further efficiency, Llama actually implements it as follows, and this is the form used as our baseline below.

\[\left(\begin{array}{c} q_0 \\ q_1 \\ \vdots \\ q_{d/2-1} \\ q_{d/2} \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \cos m \theta_0 \\ \cos m \theta_1 \\ \vdots \\ \cos m \theta_{d / 2-1} \\ \cos m \theta_{0} \\ \vdots \\ \cos m \theta_{d / 2-2} \\ \cos m \theta_{d / 2-1} \end{array}\right)+\left(\begin{array}{c} -q_0 \\ -q_1 \\ \vdots \\ -q_{d/2-1} \\ q_{d/2} \\ \vdots \\ q_{d-2} \\ q_{d-1} \end{array}\right) \otimes\left(\begin{array}{c} \sin m \theta_0 \\ \sin m \theta_1 \\ \vdots \\ \sin m \theta_{d / 2-1} \\ \sin m \theta_{0} \\ \vdots \\ \sin m \theta_{d / 2-2} \\ \sin m \theta_{d / 2-1} \end{array}\right)\]

That’s it! Let’s implement one.

Implementing RoPE

Let’s take an implementation from Megatron-LM around early January 2025 as our baseline (link). Here is some simplified pseudocode.

  • The $m$ in the formula is generated by torch.arange(seq_len). Its result can be fully reused, so it can be cached.
  • The $\theta$ in the formula corresponds to inv_freq, whose result can also be reused.
  • outer takes two 1-D vectors and outputs a 2-D matrix — the Cartesian product of the inputs.
  • _rotate_half produces the right-hand part of the final formula.
def _rotate_half(x):
    x1, x2 = torch.chunk(x, 2, dim=-1)
    return torch.cat((-x2, x1), dim=-1)
inv_freq = 1.0 / (rotary_base ** (torch.arange(0, dim, 2) / dim))
freqs = torch.outer(torch.arange(seq_len), inv_freq)
cos_ = torch.cos(freqs)
sin_ = torch.sin(freqs)
t = (t * cos_) + (_rotate_half(t) * sin_)

CUDA Implementation

Apex, TransformerEngine, and FlashAttention all provide APIs — again, see the Megatron code for details. The point of this post is neither to explore how to call the APIs (just read Megatron for that) nor to write my own CUDA kernel; instead I’ll just run an experiment to see how these implementations perform, and get a rough sense of how this kind of function should be implemented.

First, here are results for the different APIs on an A100. You can see FlashAttention’s implementation lags a bit, while Apex and TransformerEngine are the best. Since I happen to be familiar with TransformerEngine, let’s look at how it’s implemented there.

RotaryEmbeddingMegatron take 73.508257 ms while size = 20971520

RotaryEmbeddingApex take 14.397789 ms while size = 20971520

RotaryEmbeddingTE take 14.080192 ms while size = 20971520

RotaryEmbeddingFlash take 21.117988 ms while size = 20971520

The relevant code is FusedRoPEFunc (this function), and the very first thing it does is a big red flag: no matter the input type, TransformerEngine first casts it to Float32. Naturally you’d wonder — if the input is BFloat16, is correctness still guaranteed? I ran an experiment and found that after changing the input dtype from Float to BFloat16, both FlashAttention and TransformerEngine failed to match the baseline. Well — the world is one big improvised stage; you’re better off testing these APIs locally before trusting them. As for Megatron, it cleverly uses the Apex implementation as the default — perhaps out of exactly this correctness concern.

if freqs.dtype != torch.float32:
    freqs = freqs.float()

Let’s look at the actual CUDA implementation, since its performance is quite good. Ignoring the exact meaning of each variable, let’s quickly grasp the high-level idea: it’s simple — each thread just does a Load/Store. This is a textbook memory-bound function, so the sincosf computation is essentially free. That also explains why FlashAttention precomputes sin/cos while TransformerEngine recomputes them every time — yet the latter is faster, because the computation is basically free. FlashAttention’s code is written in Triton, so it being slower than CUDA is expected.

#pragma unroll
  for (int d_id = threadIdx.x; d_id < d2; d_id += blockDim.x) {
    float v_cos, v_sin;
    sincosf(freqs[s_id * d2 + d_id], &v_sin, &v_cos);
#pragma unroll
    for (int h_id = threadIdx.y; h_id < h; h_id += blockDim.y) {
      int offset_src = offset_block + h_id * stride_h + d_id * stride_d;
      int offset_dst = offset_block_dst + h_id * o_stride_h + d_id * o_stride_d;
      float v_src = src[offset_src];
      float v_src_rotate = (d_id + d2 / 2 < d2)
                               ? -static_cast<float>(src[offset_src + (d2 / 2) * stride_d])
                               : static_cast<float>(src[offset_src + (d2 / 2 - d2) * stride_d]);
      dst[offset_dst] = v_src * v_cos + v_src_rotate * v_sin;
    }
  }

That’s the end of this post. All the code is in this GitHub repo. If you want to go deeper and squeeze out every last bit, you could try speeding up the Load/Store with something like float4. Maybe I’ll add that someday.