burn_mamba/mamba3/rotation/rope.rs
1//! Rotary (RoPE) application helpers for the abelian rotation pathway.
2//!
3//! Purely mechanical: given per-pair cumulative angles, rotate the paired
4//! channels of B/C. See the [module header](crate::mamba3::rotation) for why
5//! these angles are a *state transition*, not a positional encoding.
6
7use burn::prelude::*;
8
9// ---------------------------------------------------------------------------
10// RoPE utility
11// ---------------------------------------------------------------------------
12
13/// Reduce angles modulo `2π` into `[−π, π]`, leaving the autodiff graph intact.
14///
15/// `sin`/`cos` are `2π`-periodic, so subtracting an integer multiple of `2π` is
16/// value-exact. Keeping `|angle| ≤ π` preserves precision in low-bit floats —
17/// roughly half of `f16`'s representable values lie in `|x| ≤ 1`, and the
18/// periodic `sin`/`cos` only lose accuracy when the argument is allowed to drift
19/// to large magnitudes. The same applies to the cumulative angle accumulator,
20/// which would otherwise grow without bound across a long sequence / many decode
21/// steps.
22///
23/// The integer multiple `k` is `detach`ed, so it is a constant with respect to
24/// autodiff: `d/dx (x − k·2π) = 1`, i.e. the backward pass is identical to the
25/// un-wrapped angle. This mirrors the detached `max` rescaling in
26/// [`RmsNormGated`](burn_stack::modules::norm::rms_norm_gated::RmsNormGated).
27pub fn wrap_angle<const D: usize>(angles: Tensor<D>) -> Tensor<D> {
28 let two_pi = 2.0 * std::f32::consts::PI;
29 let k = (angles.clone().detach() * (1.0f32 / two_pi)).round();
30 angles - k * two_pi
31}
32
33/// Apply a rotary embedding (RoPE) to `x` along its last dimension.
34///
35/// Purely the mechanical pairwise rotation. In Mamba-3 the `angles` are the
36/// cumulative rotation of the *state transition* (see [`crate::mamba3::mamba3`]),
37/// not a position index.
38///
39/// Two pairing conventions are supported, selected by `rotate_pairwise`:
40///
41/// - `rotate_pairwise = true` — **interleaved** (NeoX / Triton style): adjacent
42/// pairs `(0,1)`, `(2,3)`, … are rotated together. Used by the SISO Triton
43/// kernel (`mamba3_siso_*.py`).
44/// - `rotate_pairwise = false` — **half-and-half** (GPT-J style): position `n`
45/// is paired with `n + state_rank/2`. Used by the MIMO Tilelang kernel
46/// (`mamba3_mimo_fwd.py`).
47///
48/// Reference: `mamba3.py:335` sets `rotate_pairwise = not self.is_mimo`.
49///
50/// # Shapes
51/// - `x`: `[..., state_rank]` where `state_rank` is even
52/// - `angles`: `[..., state_rank / 2]` (one angle per pair)
53/// - output: same shape as `x`
54pub fn apply_rope<const D: usize>(
55 x: Tensor<D>,
56 angles: Tensor<D>,
57 rotate_pairwise: bool,
58) -> Tensor<D> {
59 let dims = x.dims();
60 let n = dims[D - 1];
61 let n2 = n / 2;
62 let leading: usize = dims[..D - 1].iter().product();
63
64 let angles_flat = wrap_angle(angles.reshape([leading, n2]));
65 let cos = angles_flat.clone().cos();
66 let sin = angles_flat.sin();
67
68 if rotate_pairwise {
69 // Interleaved: reshape to [leading, n2, 2], pairs along last axis.
70 let x_pairs = x.reshape([leading, n2, 2]);
71 let x0 = x_pairs.clone().narrow(2, 0, 1).squeeze_dim(2);
72 let x1 = x_pairs.narrow(2, 1, 1).squeeze_dim(2);
73
74 let x0r = cos.clone() * x0.clone() - sin.clone() * x1.clone();
75 let x1r = sin * x0 + cos * x1;
76
77 Tensor::cat(
78 vec![x0r.unsqueeze_dim::<3>(2), x1r.unsqueeze_dim::<3>(2)],
79 2,
80 )
81 .reshape(dims)
82 } else {
83 // Half-and-half: reshape to [leading, 2, n2], halves along middle axis.
84 let x_halves = x.reshape([leading, 2, n2]);
85 let x0 = x_halves.clone().narrow(1, 0, 1).squeeze_dim(1);
86 let x1 = x_halves.narrow(1, 1, 1).squeeze_dim(1);
87
88 let x0r = cos.clone() * x0.clone() - sin.clone() * x1.clone();
89 let x1r = sin * x0 + cos * x1;
90
91 Tensor::cat(
92 vec![x0r.unsqueeze_dim::<3>(1), x1r.unsqueeze_dim::<3>(1)],
93 1,
94 )
95 .reshape(dims)
96 }
97}
98
99/// Apply RoPE to only the rotation-active entries of the last dimension; the
100/// remainder passes through unchanged. Falls back to [`apply_rope`] when
101/// `rope_dim == state_rank` (full RoPE). `rope_dim` must be positive — a block
102/// that rotates nothing is
103/// [`RotationKind::Real1D`](crate::mamba3::rotation::RotationKind::Real1D),
104/// which never reaches here.
105///
106/// Pairing scheme (must match the reference kernels — see Section
107/// "Data-Dependent RoPE" in the paper, and `mamba3_siso_fwd.py` /
108/// `mamba3_mimo_fwd.py`):
109///
110/// - `rotate_pairwise = true` (SISO, interleaved/NeoX): pairs `(0,1), (2,3), …`.
111/// Only pairs `0..num_rope_angles` are rotated; pairs beyond are passed
112/// through. Equivalent to slicing the first `rope_dim` entries and rotating
113/// them.
114/// - `rotate_pairwise = false` (MIMO, half-and-half/GPT-J): pair distance is
115/// always `state_rank/2`, i.e. element `n` is paired with element
116/// `state_rank/2 + n`. With partial RoPE only the first `num_rope_angles`
117/// pairs are rotated; the remaining elements in both halves pass through.
118pub fn apply_rope_partial<const D: usize>(
119 x: Tensor<D>,
120 angles: Tensor<D>,
121 rope_dim: usize,
122 rotate_pairwise: bool,
123) -> Tensor<D> {
124 debug_assert!(rope_dim > 0, "partial RoPE requires rope_dim > 0");
125
126 let state_rank = x.dims()[D - 1];
127 if rope_dim == state_rank {
128 return apply_rope::<D>(x, angles, rotate_pairwise);
129 }
130
131 if rotate_pairwise {
132 // Pairs are local — slicing the first rope_dim entries gives the same
133 // result as the reference (which rotates the whole headdim but with
134 // identity cos/sin for the tail pairs).
135 let x_rope = x.clone().narrow(D - 1, 0, rope_dim);
136 let x_rest = x.narrow(D - 1, rope_dim, state_rank - rope_dim);
137 let x_rope_rotated = apply_rope::<D>(x_rope, angles, true);
138 return Tensor::cat(vec![x_rope_rotated, x_rest], D - 1);
139 }
140
141 // Half-and-half partial RoPE: pair distance must be `state_rank/2`, not
142 // `rope_dim/2`. Slicing the first `rope_dim` entries and calling
143 // `apply_rope` would pair within the slice and produce the wrong rotation.
144 let half = state_rank / 2;
145 let num_rope_angles = rope_dim / 2;
146 debug_assert!(
147 num_rope_angles < half,
148 "partial RoPE requires rope_dim < state_rank here"
149 );
150
151 // Split x into the two halves, then within each half separate the
152 // rotation-active prefix from the pass-through suffix.
153 let x_h1 = x.clone().narrow(D - 1, 0, half);
154 let x_h2 = x.narrow(D - 1, half, half);
155 let x_h1_rope = x_h1.clone().narrow(D - 1, 0, num_rope_angles);
156 let x_h1_pass = x_h1.narrow(D - 1, num_rope_angles, half - num_rope_angles);
157 let x_h2_rope = x_h2.clone().narrow(D - 1, 0, num_rope_angles);
158 let x_h2_pass = x_h2.narrow(D - 1, num_rope_angles, half - num_rope_angles);
159
160 // angles: [..., num_rope_angles] — broadcasts element-wise against the rope-active slices.
161 let angles = wrap_angle(angles);
162 let cos = angles.clone().cos();
163 let sin = angles.sin();
164 let x_h1_rot = cos.clone() * x_h1_rope.clone() - sin.clone() * x_h2_rope.clone();
165 let x_h2_rot = sin * x_h1_rope + cos * x_h2_rope;
166
167 // Reassemble: [ first-half-rotated | first-half-passthrough | second-half-rotated | second-half-passthrough ]
168 let x_h1_out = Tensor::cat(vec![x_h1_rot, x_h1_pass], D - 1);
169 let x_h2_out = Tensor::cat(vec![x_h2_rot, x_h2_pass], D - 1);
170 Tensor::cat(vec![x_h1_out, x_h2_out], D - 1)
171}