4. RoPE的代码实现:PyTorch实现RoPE的前向传播

好,咱们直接进入正题。上一节我们把RoPE的数学原理讲清楚了,这一节就动手写代码。我个人习惯是,先理解公式,再动手实现,最后再集成到Attention里。这样每一步都踩实了,后面调bug的时候心里才有底。

4.1 从公式到代码:RoPE的核心逻辑

先回顾一下RoPE的核心操作。说白了,就是对Query和Key做旋转,让它们带上位置信息。旋转矩阵是分块的,每个2x2的块对应一个频率。

代码实现的关键步骤就三步:

  1. 生成频率向量(也就是公式里的θ)
  2. 计算旋转角度(位置m乘以频率θ)
  3. 应用旋转矩阵到Query和Key上

嗯,这里要注意,实际实现时我们不会真的构建一个巨大的旋转矩阵,那样太浪费显存了。我们用的是“按位旋转”的技巧,把矩阵乘法变成了向量运算。

4.2 逐行解析:PyTorch实现

下面是我写的一个RoPE实现,代码不长,但每一行都有讲究。我在项目中遇到过几次因为维度处理不对导致训练loss不收敛的情况,所以这次我会把维度变化也讲清楚。

import torch
import torch.nn as nn
import math

class RotaryPositionalEmbedding(nn.Module):
    def __init__(self, dim, max_seq_len=2048, base=10000.0):
        super().__init__()
        self.dim = dim
        self.max_seq_len = max_seq_len
        self.base = base
        
        # 1. 生成频率向量 theta_i = base^(-2i/dim)
        # 注意:dim必须是偶数,因为每2个维度共享一个频率
        inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
        # inv_freq shape: [dim/2]
        
        # 2. 预计算所有位置的旋转角度
        # 位置索引: [0, 1, 2, ..., max_seq_len-1]
        position = torch.arange(max_seq_len).float()
        # position shape: [max_seq_len]
        
        # 外积: 每个位置乘以每个频率
        freqs = torch.einsum("i,j->ij", position, inv_freq)
        # freqs shape: [max_seq_len, dim/2]
        
        # 3. 存储cos和sin值
        # 注意:这里要重复一次,因为每个频率对应2个维度
        self.register_buffer("cos_cached", freqs.cos().repeat_interleave(2, dim=-1))
        self.register_buffer("sin_cached", freqs.sin().repeat_interleave(2, dim=-1))
        # cos_cached shape: [max_seq_len, dim]
        
    def forward(self, x, offset=0):
        """
        x: [batch_size, seq_len, num_heads, dim]
        offset: 用于推理时的KV Cache偏移
        """
        seq_len = x.shape[1] + offset
        # 从缓存中取出对应长度的cos和sin
        cos = self.cos_cached[offset:seq_len].unsqueeze(0).unsqueeze(0)
        sin = self.sin_cached[offset:seq_len].unsqueeze(0).unsqueeze(0)
        # cos shape: [1, seq_len, 1, dim]
        
        # 应用旋转
        # 关键公式: RoPE(x) = x * cos + rotate_half(x) * sin
        x_rotated = x * cos + self._rotate_half(x) * sin
        return x_rotated
    
    def _rotate_half(self, x):
        """
        将x的后半部分取负,然后交换前后半部分
        对应旋转矩阵中的 [-x1, x0] 操作
        """
        x1, x2 = x.chunk(2, dim=-1)
        # x1: 前半部分, x2: 后半部分
        return torch.cat([-x2, x1], dim=-1)

代码要点解析:

  • inv_freq的计算:这里用了torch.arange(0, dim, 2),只取偶数索引。因为每两个维度共享一个频率,所以频率向量的长度是dim/2。
  • repeat_interleave的作用:把频率对应的cos/sin值复制一份,这样就能和完整的dim维度对齐。比如dim=4时,频率向量长度是2,复制后变成4。
  • _rotate_half的实现:这个函数实现了旋转矩阵的核心操作——把向量分成两半,交换并取负。你想想看,这不就是二维旋转的推广吗?

4.3 与标准Attention的集成

有了RoPE模块,集成到Attention里就很简单了。我见过一些实现把RoPE放在Attention外面,但我觉得放在里面更干净,因为RoPE只影响Q和K,不影响V。

class RotaryAttention(nn.Module):
    def __init__(self, dim, num_heads, max_seq_len=2048):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        
        # QKV投影
        self.wq = nn.Linear(dim, dim, bias=False)
        self.wk = nn.Linear(dim, dim, bias=False)
        self.wv = nn.Linear(dim, dim, bias=False)
        self.wo = nn.Linear(dim, dim, bias=False)
        
        # RoPE模块
        self.rotary = RotaryPositionalEmbedding(
            dim=self.head_dim, 
            max_seq_len=max_seq_len
        )
        
    def forward(self, x, mask=None):
        batch_size, seq_len, _ = x.shape
        
        # 1. 线性投影并分头
        q = self.wq(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        k = self.wk(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        v = self.wv(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        # q/k/v shape: [batch_size, seq_len, num_heads, head_dim]
        
        # 2. 应用RoPE(只对Q和K)
        q = self.rotary(q)
        k = self.rotary(k)
        
        # 3. 标准Attention计算
        # 转置为 [batch_size, num_heads, seq_len, head_dim]
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)
        
        # 计算注意力分数
        scale = math.sqrt(self.head_dim)
        attn_weights = torch.matmul(q, k.transpose(-2, -1)) / scale
        
        if mask is not None:
            attn_weights = attn_weights.masked_fill(mask == 0, float('-inf'))
        
        attn_weights = torch.softmax(attn_weights, dim=-1)
        
        # 加权求和
        out = torch.matmul(attn_weights, v)
        # out shape: [batch_size, num_heads, seq_len, head_dim]
        
        # 合并多头
        out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, -1)
        out = self.wo(out)
        
        return out

避坑指南:

我曾经在集成RoPE时犯过一个低级错误——把RoPE应用到了V上。结果模型训练了三天,loss死活降不下去。后来排查才发现,V不应该带位置信息,它只是内容表示。所以记住:RoPE只作用于Q和K

4.4 验证实现正确性

写完了代码,怎么验证对不对?我一般会做两个测试:

  1. 对称性测试:对于相同位置的Q和K,注意力分数应该最大。
  2. 距离衰减测试:位置差越大,注意力分数应该越小(在合理范围内)。

下面是一个简单的测试脚本:

def test_rope():
    dim = 64
    num_heads = 8
    seq_len = 10
    
    attn = RotaryAttention(dim, num_heads)
    x = torch.randn(1, seq_len, dim)
    
    # 测试1: 相同位置的注意力
    out = attn(x)
    print(f"Output shape: {out.shape}")  # 应该是 [1, 10, 64]
    
    # 测试2: 检查位置编码效果
    # 构造两个相同的token在不同位置
    token = torch.randn(1, 1, dim)
    x1 = torch.cat([token, torch.zeros(1, 9, dim)], dim=1)
    x2 = torch.cat([torch.zeros(1, 5, dim), token, torch.zeros(1, 4, dim)], dim=1)
    
    out1 = attn(x1)
    out2 = attn(x2)
    
    # 相同token在不同位置的输出应该不同
    assert not torch.allclose(out1[:, 0, :], out2[:, 5, :], atol=1e-6)
    print("位置编码生效:相同token在不同位置输出不同 ✓")

test_rope()

性能注意事项:

在实际训练大模型时,RoPE的cos/sin缓存可能会占用不少显存。如果max_seq_len设得太大(比如32K),建议使用torch.jit.script或者torch.compile来优化。我在训练7B模型时,就因为这个缓存导致OOM,后来改成按需计算才解决。

4.5 小结

这一节我们完成了RoPE的完整实现,从频率生成到旋转应用,再到与Attention的集成。代码量不大,但每个细节都值得推敲。特别是那个_rotate_half函数,它把矩阵乘法变成了向量操作,是RoPE能高效运行的关键。

下一节我们会深入RoPE的数学性质,看看它为什么能处理长序列,以及如何扩展到更高维度。但在此之前,我建议你把这段代码跑一遍,亲手感受一下位置编码的效果。


专注资料整理