昇腾CANN ops-transformer RoPE 旋转位置编码:从复数旋转到 NTK 外推的完整实战
RoPE(旋转位置编码)是LLaMA等主流大模型采用的位置编码方案。它通过旋转操作将位置信息注入Q和K向量:将特征维度分成二维组,每组按位置m旋转mθ角度(θ_i=10000^(-2i/d))。这种设计使Q_m·K_n点积仅依赖相对位置(m-n),而非绝对位置。高频维度(θ_i≈1)捕捉局部依赖,低频维度(θ_i≈0.0001)处理长距离关系。Ascend C实现中,预计算cos/sin缓存加速计
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 分段缩放提供另一种外推思路。后向传播只需用负角度再旋转一次——正交矩阵的性质让梯度流动简单。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)