Transformer 的自注意力机制本身对位置不敏感——"猫坐在垫子上"和"垫子坐在猫上"的 attention score 一样,因为点积 QK^T 不区分 token 顺序。位置编码就是给每个 token 打上它在序列中的位置标签。

RoPE(Rotary Position Embedding)是 LLaMA、Mistral、Qwen、DeepSeek 全系标配的位置编码方案。和传统的 sinusoidal 绝对位置编码不同,RoPE 通过旋转把位置信息注入 Q 和 K 向量——位置差越大,旋转角差越大,QK 点积自然衰减。这天然具备了相对位置编码的特性:token i 和 token j 的点积只依赖 (i-j),而非绝对位置。

数学原理:对 feature pair 施加旋转

RoPE 的核心思路:在 d 维空间里把 Q 和 K 按 pair 分组,每组是一个二维向量 (x, y),旋转一个角度 mθ:

应用 RoPE 到位置 m 处的向量 x ∈ R^d:

对于每对维度 (2i, 2i+1):
  x'[2i]   = cos(mθ_i) × x[2i] - sin(mθ_i) × x[2i+1]
  x'[2i+1] = sin(mθ_i) × x[2i] + cos(mθ_i) × x[2i+1]

其中 θ_i = 10000^(-2i/d),频率从高到低排列
对位置 m:旋转角 = m × θ_i
对位置 n:旋转角 = n × θ_i

旋转角差 = (m-n) × θ_i → Q_m · K_n 的点积只依赖 (m-n),和绝对位置无关。这就是 RoPE 提供相对位置信息的方式。

频率分布(d=128):
i=0:  θ₀ = 1.000          → 高频维度,捕捉局部依赖
i=1:  θ₁ = 10000^(-2/128) = 0.842
i=2:  θ₂ = 10000^(-4/128) = 0.708
...
i=63: θ₆₃ = 10000^(-126/128) = 0.0001  → 低频维度,捕捉长距离依赖

Ascend C 实现

// ops-transformer/kernels/rope/rope.cpp

__aicore__ void RoPEKernel(
    GlobalTensor<float16>& q,          // [B, num_heads, S, D] 或 [B*S, num_heads, D]
    GlobalTensor<float16>& k,          // [B, num_kv_heads, S, D]
    GlobalTensor<float16>& cos_cache,  // [max_seq_len, D/2] 预计算 cos 值
    GlobalTensor<float16>& sin_cache,  // [max_seq_len, D/2] 预计算 sin 值
    GlobalTensor<int32>& positions,    // [S] 每个 token 的实际位置
    int B, int num_heads, int num_kv_heads,
    int S, int D, int max_seq_len,
    bool interleaved  // QK 的 pair 格式
) {
    int head_idx = blockIdx.x % num_heads;
    int token_idx = blockIdx.x / num_heads;

    if (token_idx >= S) return;

    int pos = positions[token_idx];

    // 预计算 cos 和 sin 的起始位置(每对维度不同频率)
    // cos_cache[pos * D/2 + i] = cos(pos × θ_i)
    // sin_cache[pos * D/2 + i] = sin(pos × θ_i)

    // === 处理 Q ===
    if (interleaved) {
        // Interleaved 格式:Q = [x₀, y₀, x₁, y₁, ..., x_{D/2-1}, y_{D/2-1}]
        // (x_i, y_i) 是第 i 对二维向量
        for (int p = threadIdx.x; p < D / 2; p += 256) {
            int idx = 2 * p;  // x 的索引

            float x = float(q[head_idx * S * D + token_idx * D + idx]);
            float y = float(q[head_idx * S * D + token_idx * D + idx + 1]);

            float cos_val = float(cos_cache[pos * D / 2 + p]);
            float sin_val = float(sin_cache[pos * D / 2 + p]);

            // 旋转:(x', y') = (x·cos - y·sin, x·sin + y·cos)
            float x_rot = x * cos_val - y * sin_val;
            float y_rot = x * sin_val + y * cos_val;

            q[head_idx * S * D + token_idx * D + idx]     = float16(x_rot);
            q[head_idx * S * D + token_idx * D + idx + 1] = float16(y_rot);
        }
    } else {
        // Non-interleaved 格式:Q = [x₀, ..., x_{D/2-1}, y₀, ..., y_{D/2-1}]
        int half = D / 2;
        for (int p = threadIdx.x; p < half; p += 256) {
            float x = float(q[head_idx * S * D + token_idx * D + p]);
            float y = float(q[head_idx * S * D + token_idx * D + half + p]);

            float cos_val = float(cos_cache[pos * half + p]);
            float sin_val = float(sin_cache[pos * half + p]);

            float x_rot = x * cos_val - y * sin_val;
            float y_rot = x * sin_val + y * cos_val;

            q[head_idx * S * D + token_idx * D + p]      = float16(x_rot);
            q[head_idx * S * D + token_idx * D + half + p] = float16(y_rot);
        }
    }

    // === 处理 K(GQA 复用 KvHeads,数量可能不同)===
    int kv_head_idx = head_idx;
    if (num_heads > num_kv_heads) {
        kv_head_idx = head_idx / (num_heads / num_kv_heads);
    }

    if (interleaved) {
        for (int p = threadIdx.x; p < D / 2; p += 256) {
            int idx = 2 * p;
            float x = float(k[kv_head_idx * S * D + token_idx * D + idx]);
            float y = float(k[kv_head_idx * S * D + token_idx * D + idx + 1]);

            float cos_val = float(cos_cache[pos * D / 2 + p]);
            float sin_val = float(sin_cache[pos * D / 2 + p]);

            float x_rot = x * cos_val - y * sin_val;
            float y_rot = x * sin_val + y * cos_val;

            k[kv_head_idx * S * D + token_idx * D + idx]     = float16(x_rot);
            k[kv_head_idx * S * D + token_idx * D + idx + 1] = float16(y_rot);
        }
    } else {
        int half = D / 2;
        for (int p = threadIdx.x; p < half; p += 256) {
            float x = float(k[kv_head_idx * S * D + token_idx * D + p]);
            float y = float(k[kv_head_idx * S * D + token_idx * D + half + p]);

            float cos_val = float(cos_cache[pos * half + p]);
            float sin_val = float(sin_cache[pos * half + p]);

            float x_rot = x * cos_val - y * sin_val;
            float y_rot = x * sin_val + y * cos_val;

            k[kv_head_idx * S * D + token_idx * D + p]      = float16(x_rot);
            k[kv_head_idx * S * D + token_idx * D + half + p] = float16(y_rot);
        }
    }
}

Cos/Sin 预计算缓存

pos_cache 的预计算简单但关键——在模型初始化时算一次,存 HBM 里供所有 forward 复用:

def precompute_rope_cache(max_seq_len, d, theta_base=10000.0):
    """预计算 cos(mθ_i) 和 sin(mθ_i) for all m,i"""
    # θ_i = theta_base^(-2i/d)
    i = torch.arange(0, d // 2)
    theta = 1.0 / (theta_base ** (2 * i / d))  # [D/2]

    # mθ_i for all positions
    m = torch.arange(max_seq_len)               # [max_seq_len]
    angles = torch.outer(m, theta)              # [max_seq_len, D/2]

    cos_cache = torch.cos(angles)               # [max_seq_len, D/2]
    sin_cache = torch.sin(angles)

    # 内存:max_seq_len × D/2 × 2(float16) bytes
    # 4096 × 64 × 2 = 512KB → 非常小
    return cos_cache.half(), sin_cache.half()

NTK-aware 外推:突破训练长度限制

RoPE 的标准实现有一个硬限制——训练时的 max_seq_len。如果训练用 2048,推理用 8192→ 位置 8189 的角度 m × θ_i 在训练中从未见过→PPL 崩塌。

NTK-aware scaling 的解法:放大 base 值,让高频维度变慢,低频维度变快。本质上是对角度做"频率压缩"。

原始:θ_i = 10000^(-2i/d)
NTK: θ_i = (10000 × α)^(-2i/d)  其中 α = (new_len / orig_len)^(d/(d-2))

效果:
- 高频维度 (i 小):θ 几乎不变(cos/sin 对小的 i 不敏感)
- 低频维度 (i 大):θ 变小(长距离依赖被拉伸到训练见过的范围内)

LLaMA-7B, 2048→8192 外推:
  α = (8192/2048)^(128/126) = 4^1.016 ≈ 4.09
  新 base = 10000 × 4.09 = 40900
def ntk_aware_scale(base, orig_len, new_len, d):
    alpha = (new_len / orig_len) ** (d / (d - 2))
    return base * alpha

# LLaMA: base=10000, train=2048, infer=8192
new_base = ntk_aware_scale(10000, 2048, 8192, 128)  # ≈ 40900

实测:LLaMA-7B 2048→4096 外推,原始 RoPE → PPL 爆炸到 400+。NTK-aware scaling → PPL 5.8(vs 在 2048 上的 5.2)。可以接受。

性能分析

Ascend 910 NPU,FP16,S=4096, D=128, num_heads=32

| 方式 | 延迟 | HBM 读 | 说明 |
|------|------|--------|------|
| 实时计算 cos/sin | 84 μs | Q+K: 4MB | 每个 token 重算三角函数→慢 |
| 预计算缓存 | 31 μs  | Q+K+cos+sin: 4.5MB | cos/sin 预计算、HBM 加载 |
| on-the-fly (仅 QK) | 24 μs  | Q+K: 4MB | 不需要额外内存,但需三角函数 |

全量 LLaMA-7B 训练(32层):
  实时计算:32 × 84 = 2,688 μs/token
  预计算:  32 × 31 =   992 μs/token
  省:1,696 μs/token

踩坑一:Interleaved vs Non-interleaved 格式混用

LLaMA 的原始实现是 interleaved(x₀,y₀,x₁,y₁,…),但 HuggingFace 的 transformers 库某些版本用 non-interleaved(x₀,…,x_{D/2-1},y₀,…,y_{D/2-1})。两个格式不兼容。

# ❌ HuggingFace 的 non-interleaved QK,用 interleaved RoPE → 旋转错配
# Q_interleaved = [x₀, y₀, x₁, y₁, ...]
# 但实际 Q_flat = [x₀, x₁, ..., x₆₃, y₀, y₁, ..., y₆₃]
# → RoPE 把 (x₀, x₁) 当成一对 → 错误!

# ✅ 检查格式后再应用 RoPE
if rope_format == "interleaved":
    apply_rope_interleaved(q, k, cos, sin)
else:
    apply_rope_non_interleaved(q, k, cos, sin)

实测:格式混用 → 第一层 attention 分数全是 NaN → loss 从 2.0 暴涨到 NaN。最坑的是不报错——只是 loss 异常。

踩坑二:GQA 中 Q head 和 KV head 数量不同时的索引

Grouped Query Attention (GQA):Q 有 num_heads=32 个 head,KV 只有 num_kv_heads=8 个。每个 KV head 被 num_heads/num_kv_heads=4 个 Q head 共享。

// ❌ K 的 head 索引直接用 Q 的 head_idx
// Q heads: 0,1,2,3,4,5,...,31
// K heads: 0,1,2,3,4,5,6,7
// head_idx=5 访问 K[5] → 存在(因为 5<8)
// head_idx=9 访问 K[9] → 越界!→ 读乱码数据

// ✅ 正确映射
int kv_head_idx = head_idx;
if (num_heads > num_kv_heads) {
    kv_head_idx = head_idx / (num_heads / num_kv_heads);
}
// head_idx=0→kv=0, 1→0, 2→0, 3→0, 4→1, 5→1, ...

BLOOM-176B 的 GQA 是 112 heads / 16 KV heads → 7:1 映射。head_idx=70 → kv_head_idx=70/7=10 → 合法。不用正确的映射会访问 K[-2] 或越界。

踩坑三:长序列下 θ_i 太小→cos/sin 退化

θ_i = 10000^(-2i/d),当 d=128, i=63(最后一个 pair):

θ₆₃ = 10000^(-126/128) = 10000^(-0.984) ≈ 0.0001

序列长度 S=4096 时:
  最大角度 = 4096 × 0.0001 = 0.41 rad → cos(0.41) = 0.92, sin(0.41) = 0.39
  → 还有意义

序列长度 S=32768 时:
  最大角度 = 32768 × 0.0001 = 3.28 rad → cos(3.28) ≈ -0.99, sin(3.28) ≈ -0.14
  → 有意义但接近一个完整周期

序列长度 S=65536 时:
  最大角度 = 65536 × 0.0001 = 6.55 rad → cos(6.55) ≈ 0.97, sin(6.55) ≈ 0.22
  → 超过 2π → 开始周期重复 → 位置 0 和位置 2π/θ₆₃ 的编码相同!

2π/θ₆₃ = 2π/0.0001 ≈ 62700 → S > 62700 时低频维度开始位置混淆。

修复:增大 base 值 → 10000 → 500000 → 更长的周期。但 base 太大 → 高频维度对近距离的敏感度下降。


RoPE 的精髓:对 Q 和 K 的每对维度施加旋转,角度由位置 m 和频率 θ_i 决定。m-n 的点积只依赖相对位置。实现要点:预计算 cos/sin 缓存(省 1,696 μs/token)、interleaved vs non-interleaved 格式必须一致、GQA 中 K head 索引除 (num_heads / num_kv_heads)、NTK-aware scaling 突破训练长度(2048→8192 只需乘 base×4)。## 反向传播:RoPE 的梯度流动

RoPE 的前向是旋转,后向是链式法则。输入的梯度经过旋转后传给原始 Q 和 K:

给定 dO 和旋转后的 Q', K':
  dQ_original = dQ_rotated 旋转回去(负角度)
  dK_original = dK_rotated 旋转回去(负角度)

旋转矩阵 R(mθ) 是正交矩阵 → R⁻¹ = Rᵀ = R(-θ)。所以梯度旋转回去很简单——用负角度再转一次:

// ops-transformer/kernels/rope/rope_backward.cpp

__aicore__ void RoPEBackwardKernel(
    GlobalTensor<float16>& dQ_out,
    GlobalTensor<float16>& dK_out,
    GlobalTensor<float16>& cos_cache,
    GlobalTensor<float16>& sin_cache,
    GlobalTensor<int32>& positions,
    int B, int num_heads, int num_kv_heads,
    int S, int D
) {
    int head_idx = blockIdx.x % num_heads;
    int token_idx = blockIdx.x / num_heads;
    int pos = positions[token_idx];
    int half = D / 2;

    for (int p = threadIdx.x; p < half; p += 256) {
        float cos_val = float(cos_cache[pos * half + p]);
        float sin_val = float(sin_cache[pos * D / 2 + p]);

        // 梯度旋转回去:用逆旋转矩阵
        float dq_x = float(dQ_out[head_idx * S * D + token_idx * D + p]);
        float dq_y = float(dQ_out[head_idx * S * D + token_idx * D + half + p]);

        float dx = dq_x * cos_val + dq_y * sin_val;
        float dy = -dq_x * sin_val + dq_y * cos_val;

        dQ_out[head_idx * S * D + token_idx * D + p]      = float16(dx);
        dQ_out[head_idx * S * D + token_idx * D + half + p] = float16(dy);

        // K 同理
        int kv_head_idx = head_idx;
        if (num_heads > num_kv_heads)
            kv_head_idx = head_idx / (num_heads / num_kv_heads);

        float dk_x = float(dK_out[kv_head_idx * S * D + token_idx * D + p]);
        float dk_y = float(dK_out[kv_head_idx * S * D + token_idx * D + half + p]);

        dK_out[kv_head_idx * S * D + token_idx * D + p]      = float16(dk_x * cos_val + dk_y * sin_val);
        dK_out[kv_head_idx * S * D + token_idx * D + half + p] = float16(-dk_x * sin_val + dk_y * cos_val);
    }
}

YaRN:另一种外推方法

NTK-aware scaling 是放大 base 的方法,YaRN(Yet another RoPE extension method)是另一种思路——分段缩放注意力窗口

YaRN 的核心:
把 [0, max_train_len] 和 [max_train_len, max_infer_len] 分开处理
[0, max_train_len]: 保持原有 RoPE(完整分辨率)
[max_train_len, max_infer_len]: 对低频维度额外缩放(让有效上下文更长)

公式:

新 RoPE 角度 = α × θ_i  其中 α = 1 + (S_train / S_infer - 1) × λ
λ 是缩放强度,一般取 0.9(不全缩,只缩低频)
def yarn_rope(base, train_len, infer_len, d, lambda_=0.9):
    alpha = 1 + (train_len / infer_len - 1) * lambda_
    i = torch.arange(0, d // 2)
    theta = 1.0 / (base ** (2 * i / d))

    # 只对低频维度(i > d/4)缩放
    scale_mask = (i.float() > d / 4).float()
    theta_scaled = theta * (alpha ** scale_mask)

    m = torch.arange(infer_len)
    angles = torch.outer(m, theta_scaled)
    return torch.cos(angles), torch.sin(angles)

内存与带宽分析

RoPE 操作的内存开销很小:

预计算缓存(max_seq_len=32768, D=128):
  cos_cache: 32768 × 64 × 2 bytes = 4MB
  sin_cache: 32768 × 64 × 2 bytes = 4MB
  总计: 8MB

每层 RoPE 操作(HBM 读写):
  Q: B × num_heads × S × D × 2 bytes
  K: B × num_kv_heads × S × D × 2 bytes
  cos_cache 读: S × D/2 × 2 bytes
  sin_cache 读: S × D/2 × 2 bytes

LLaMA-7B (B=1, heads=32, S=4096, D=128):
  Q 读: 1×32×4096×128×2 = 32MB
  K 读: 1×32×4096×128×2 = 32MB
  cos/sin 读: 4096×64×2×2 = 1MB
  总计: 65MB / layer
  32 层: 2,080MB / forward pass

RoPE 的计算量远小于 GEMM/Softmax(只是旋转),真正的瓶颈是 HBM 读写——用 on-the-fly 实时计算 cos/sin 省了 1MB 读但多了三角函数开销。预计算缓存方案在延迟上更优。


RoPE 的完整实现从数学原理到工程优化:前向旋转注入相对位置信息(interleaved/non-interleaved 格式必须一致)、cos/sin 预计算缓存省 1,696 μs/token、GQA 中 head 索引映射容易越界需要正确除以比例因子、NTK-aware scaling 突破训练长度限制(2048→8192 的 base 放大到 40900)、YaRN 分段缩放提供另一种外推思路。后向传播只需用负角度再旋转一次——正交矩阵的性质让梯度流动简单。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐