99// plus a one-line `FINE_ASYNC_NIF(NAME)` — the registered NIF name, arity,
1010// and the ASYNC_NIF/nif_funcs[] wiring in emlx_nif.cpp are all unchanged.
1111
12+ // ── Multi-head-safe RoPE helpers ────────────────────────────────────────────
13+ //
14+ // mlx::core::fast::rope only recognizes Bumblebee's {B, T, H, D}
15+ // (heads-not-yet-transposed) convention via a stride-detection special case
16+ // meant to catch a *transposed view* of {B, H, T, D}. A plain, freshly
17+ // allocated {B, T, H, D} tensor is row-contiguous, so that guard never fires
18+ // and the kernel falls into its row_contiguous branch, which treats axis -2
19+ // (H) as the sequence axis — rotating head `h` at angle `position + h`
20+ // instead of `position` for every h > 0.
21+ // See https://github.com/elixir-nx/emlx/issues/121.
22+ //
23+ // H==1 has no such ambiguity (there's only one "row" per position), so the
24+ // fused Metal path stays correct and fast there. For H>1 we compose the same
25+ // split-half rotation from primitive ops instead, broadcasting cos/sin across
26+ // every head — this mirrors mlx::fast::rope's non-traditional formula
27+ // bit-for-bit:
28+ // out[..half] = a[..half]*cos(angles) - a[half..dims]*sin(angles)
29+ // out[half..] = a[half..dims]*cos(angles) + a[..half]*sin(angles)
30+ // (equivalently: rotate_half = concat(-a[half..dims], a[..half]); out =
31+ // a[..dims]*cos_full + rotate_half*sin_full — the form used below).
32+ //
33+ // `a` is {B, T, H, D}; `angles` is {B, T, half} (already position*inv_freq*scale).
34+ static mlx::core::array rope_rotate_from_angles (const mlx::core::array &a,
35+ const mlx::core::array &angles,
36+ int dims,
37+ mlx::core::Device device) {
38+ int B = a.shape (0 );
39+ int T = a.shape (1 );
40+ int H = a.shape (2 );
41+ int D = a.shape (3 );
42+ int half = dims / 2 ;
43+
44+ auto cos_bt1h = astype (reshape (cos (angles, device), {B, T, 1 , half}, device), a.dtype (), device);
45+ auto sin_bt1h = astype (reshape (sin (angles, device), {B, T, 1 , half}, device), a.dtype (), device);
46+
47+ auto cos_full = concatenate (std::vector<array>{cos_bt1h, cos_bt1h}, 3 , device);
48+ auto sin_full = concatenate (std::vector<array>{sin_bt1h, sin_bt1h}, 3 , device);
49+
50+ auto x1 = slice (a, to_shape ({0 , 0 , 0 , 0 }), to_shape ({B, T, H, half}), device);
51+ auto x2 = slice (a, to_shape ({0 , 0 , 0 , half}), to_shape ({B, T, H, dims}), device);
52+ auto rotated = concatenate (std::vector<array>{negative (x2, device), x1}, 3 , device);
53+
54+ auto a_head = slice (a, to_shape ({0 , 0 , 0 , 0 }), to_shape ({B, T, H, dims}), device);
55+ auto rope_head = add (multiply (a_head, cos_full, device), multiply (rotated, sin_full, device), device);
56+
57+ if (dims == D) {
58+ return rope_head;
59+ }
60+ auto tail = slice (a, to_shape ({0 , 0 , 0 , dims}), to_shape ({B, T, H, D}), device);
61+ return concatenate (std::vector<array>{rope_head, tail}, 3 , device);
62+ }
63+
64+ // inv_freq[i] = 1 / base^(2i/dims), i in [0, dims/2).
65+ static mlx::core::array rope_inv_freq_from_base (int dims, double base) {
66+ int half = dims / 2 ;
67+ std::vector<float > inv_freq_host (half);
68+ float base_f = static_cast <float >(base);
69+ for (int i = 0 ; i < half; ++i) {
70+ float expo = (2 .0f * static_cast <float >(i)) / static_cast <float >(dims);
71+ inv_freq_host[i] = 1 .0f / std::pow (base_f, expo);
72+ }
73+ return array (inv_freq_host.begin (), {half}, float32);
74+ }
75+
76+ // Reconstructs per-token angles = (offset[b] + t) * inv_freq * scale for the
77+ // assumed-sequential-positions contract of fast_rope_ids/fast_rope_with_freqs
78+ // (offset is {B}, one starting position per batch example; T is a's shape(1)).
79+ static mlx::core::array rope_sequential_angles (const mlx::core::array &offset,
80+ const mlx::core::array &inv_freq,
81+ int B, int T, int half,
82+ double scale,
83+ mlx::core::Device device) {
84+ auto offset_b1 = reshape (astype (offset, float32, device), {B, 1 }, device);
85+ auto steps_1t = reshape (astype (arange (T, int32, device), float32, device), {1 , T}, device);
86+ auto pos_bt = add (offset_b1, steps_1t, device);
87+ auto pos_bt1 = reshape (pos_bt, {B, T, 1 }, device);
88+ auto inv_11h = reshape (inv_freq, {1 , 1 , half}, device);
89+ auto scale_arr = array (static_cast <float >(scale), float32);
90+ return multiply (multiply (pos_bt1, inv_11h, device), scale_arr, device);
91+ }
92+
1293// fast_rms_norm — fused RMS normalisation
1394// MLX: mlx::fast::rms_norm(x, weight, eps, stream) → array, same shape as x
1495// weight is optional<array> in the C++ API; a plain TensorArg implicitly
@@ -86,10 +167,31 @@ FINE_ASYNC_NIF(fast_layer_norm_no_bias)
86167// Calls the array-offset overload of mlx::fast::rope.
87168// offset must be shape {B} — one starting position per batch example.
88169// Assumes positions are sequential within each example: [offset[b], offset[b]+1, ..., offset[b]+T-1].
170+ //
171+ // H>1 (Bumblebee {B,T,H,D} convention — see the multi-head-safe RoPE helpers
172+ // comment above) falls back to the hand cos/sin/rotate composition; H==1
173+ // keeps the fused Metal path.
89174mlx::core::array fast_rope_ids_impl (ErlNifEnv *env, TensorArg a, int dims,
90175 bool traditional, double base,
91176 double scale, TensorArg offset,
92177 mlx::core::Device device) {
178+ if (a->ndim () == 4 && a->shape (2 ) > 1 ) {
179+ if (traditional) {
180+ throw std::invalid_argument (
181+ " fast_rope_ids: traditional=true not supported for multi-head "
182+ " (H>1) input" );
183+ }
184+
185+ int B = a->shape (0 );
186+ int T = a->shape (1 );
187+ int half = dims / 2 ;
188+
189+ auto inv_freq = rope_inv_freq_from_base (dims, base);
190+ auto angles = rope_sequential_angles (*offset, inv_freq, B, T, half, scale, device);
191+
192+ return rope_rotate_from_angles (*a, angles, dims, device);
193+ }
194+
93195 return fast::rope (*a, dims, traditional, (float )base, (float )scale, *offset,
94196 std::nullopt , device);
95197}
@@ -99,11 +201,34 @@ FINE_ASYNC_NIF(fast_rope_ids)
99201// Calls the freqs overload of mlx::fast::rope (base=nullopt, freqs supplied).
100202// offset must be shape {B} — one starting position per batch example.
101203// freqs must be shape {dims/2} — precomputed inverse frequencies.
204+ //
205+ // H>1 falls back to the hand cos/sin/rotate composition (see the
206+ // multi-head-safe RoPE helpers comment above); H==1 keeps the fused Metal
207+ // path. mlx::fast::rope's freqs overload uses inv_freq = reciprocal(freqs)
208+ // internally, so we replicate that reciprocal here to stay bit-for-bit with
209+ // the fused path.
102210mlx::core::array fast_rope_with_freqs_impl (ErlNifEnv *env, TensorArg a,
103211 int dims, bool traditional,
104212 double scale, TensorArg offset,
105213 TensorArg freqs,
106214 mlx::core::Device device) {
215+ if (a->ndim () == 4 && a->shape (2 ) > 1 ) {
216+ if (traditional) {
217+ throw std::invalid_argument (
218+ " fast_rope_with_freqs: traditional=true not supported for "
219+ " multi-head (H>1) input" );
220+ }
221+
222+ int B = a->shape (0 );
223+ int T = a->shape (1 );
224+ int half = dims / 2 ;
225+
226+ auto inv_freq = reciprocal (astype (*freqs, float32, device), device);
227+ auto angles = rope_sequential_angles (*offset, inv_freq, B, T, half, scale, device);
228+
229+ return rope_rotate_from_angles (*a, angles, dims, device);
230+ }
231+
107232 return fast::rope (*a, dims, traditional, std::nullopt , (float )scale,
108233 *offset, *freqs, device);
109234}
@@ -140,7 +265,6 @@ mlx::core::array fast_rope_positions_impl(ErlNifEnv *env, TensorArg a,
140265
141266 int B = a->shape (0 );
142267 int T = a->shape (1 );
143- int H = a->shape (2 );
144268 int D = a->shape (3 );
145269
146270 if (position_ids->shape (0 ) != B || position_ids->shape (1 ) != T) {
@@ -158,40 +282,15 @@ mlx::core::array fast_rope_positions_impl(ErlNifEnv *env, TensorArg a,
158282
159283 int half = dims / 2 ;
160284
161- std::vector<float > inv_freq_host (half);
162- float base_f = static_cast <float >(base);
163- for (int i = 0 ; i < half; ++i) {
164- float expo = (2 .0f * static_cast <float >(i)) / static_cast <float >(dims);
165- inv_freq_host[i] = 1 .0f / std::pow (base_f, expo);
166- }
167-
168- auto inv_freq = array (inv_freq_host.begin (), {half}, float32);
285+ auto inv_freq = rope_inv_freq_from_base (dims, base);
169286
170287 auto pos_f = astype (*position_ids, float32, device);
171288 auto pos_bt1 = reshape (pos_f, {B, T, 1 }, device);
172289 auto inv_11h = reshape (inv_freq, {1 , 1 , half}, device);
173290 auto scale_arr = array (static_cast <float >(scale), float32);
174291 auto angles = multiply (multiply (pos_bt1, inv_11h, device), scale_arr, device);
175292
176- auto cos_bt1h = astype (reshape (cos (angles, device), {B, T, 1 , half}, device), a->dtype (), device);
177- auto sin_bt1h = astype (reshape (sin (angles, device), {B, T, 1 , half}, device), a->dtype (), device);
178-
179- auto cos_full = concatenate (std::vector<array>{cos_bt1h, cos_bt1h}, 3 , device);
180- auto sin_full = concatenate (std::vector<array>{sin_bt1h, sin_bt1h}, 3 , device);
181-
182- auto x1 = slice (*a, to_shape ({0 , 0 , 0 , 0 }), to_shape ({B, T, H, half}), device);
183- auto x2 = slice (*a, to_shape ({0 , 0 , 0 , half}), to_shape ({B, T, H, dims}), device);
184- auto rotated = concatenate (std::vector<array>{negative (x2, device), x1}, 3 , device);
185-
186- auto a_head = slice (*a, to_shape ({0 , 0 , 0 , 0 }), to_shape ({B, T, H, dims}), device);
187- auto rope_head = add (multiply (a_head, cos_full, device), multiply (rotated, sin_full, device), device);
188-
189- if (dims == D) {
190- return rope_head;
191- } else {
192- auto tail = slice (*a, to_shape ({0 , 0 , 0 , dims}), to_shape ({B, T, H, D}), device);
193- return concatenate (std::vector<array>{rope_head, tail}, 3 , device);
194- }
293+ return rope_rotate_from_angles (*a, angles, dims, device);
195294}
196295FINE_ASYNC_NIF (fast_rope_positions)
197296
0 commit comments