Skip to content

Commit 726ffa7

Browse files
authored
fix: add fast rope fix (#122)
1 parent cce7a0f commit 726ffa7

8 files changed

Lines changed: 338 additions & 42 deletions

File tree

emlx/c_src/emlx_compiler.cpp

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,33 +1500,105 @@ static const std::unordered_map<std::string, OpFn> op_registry = {
15001500
// rope (per-batch offset array): operands = [a, position_ids];
15011501
// attrs = [dims, traditional, base_bits, scale_bits]. Offsets are
15021502
// position_ids[:, 0] (matches EMLX.Fast.rope_with_positions_fast_callback).
1503+
//
1504+
// H>1 (Bumblebee {B,T,H,D} convention) falls back to the hand
1505+
// cos/sin/rotate composition (rope_rotate_from_angles) — see
1506+
// emlx_fast.cpp's multi-head-safe RoPE helpers comment /
1507+
// https://github.com/elixir-nx/emlx/issues/121. H==1 keeps the fused
1508+
// mlx::core::fast::rope path. The full position_ids operand is already
1509+
// available here, so no offset+arange reconstruction is needed.
15031510
{"fast_rope_ids",
15041511
[](const auto &ops, const auto &attrs) {
1512+
const auto &a = ops[0];
15051513
const auto &pos = ops[1];
1514+
int dims = static_cast<int>(attrs[0]);
1515+
bool traditional = attrs[1] != 0;
1516+
1517+
if (a.ndim() == 4 && a.shape(2) > 1) {
1518+
if (traditional) {
1519+
throw std::invalid_argument(
1520+
"fast_rope_ids: traditional=true not supported for "
1521+
"multi-head (H>1) input");
1522+
}
1523+
float base = attr_to_float(attrs[2]);
1524+
float scale = attr_to_float(attrs[3]);
1525+
int half = dims / 2;
1526+
1527+
std::vector<float> inv_freq_host(half);
1528+
for (int i = 0; i < half; ++i) {
1529+
float expo = (2.0f * static_cast<float>(i)) / static_cast<float>(dims);
1530+
inv_freq_host[i] = 1.0f / std::pow(base, expo);
1531+
}
1532+
auto inv_freq =
1533+
mlx::core::array(inv_freq_host.begin(), {half}, mlx::core::float32);
1534+
1535+
int B = pos.shape(0);
1536+
int T = pos.shape(1);
1537+
auto pos_f = mlx::core::astype(pos, mlx::core::float32);
1538+
auto pos_bt1 = mlx::core::reshape(pos_f, to_shape({B, T, 1}));
1539+
auto inv_11h = mlx::core::reshape(inv_freq, to_shape({1, 1, half}));
1540+
auto angles = mlx::core::multiply(
1541+
mlx::core::multiply(pos_bt1, inv_11h),
1542+
mlx::core::array(scale, mlx::core::float32));
1543+
1544+
return rope_rotate_from_angles(a, angles, dims);
1545+
}
1546+
15061547
int B = pos.shape(0);
15071548
auto offsets = mlx::core::reshape(
15081549
mlx::core::slice(pos, to_shape({0, 0}), to_shape({B, 1}),
15091550
to_shape({1, 1})),
15101551
to_shape({B}));
15111552
return mlx::core::fast::rope(
1512-
ops[0], static_cast<int>(attrs[0]), attrs[1] != 0,
1553+
a, dims, traditional,
15131554
attr_to_float(attrs[2]), attr_to_float(attrs[3]), offsets,
15141555
std::nullopt);
15151556
}},
15161557

15171558
// rope (precomputed freqs): operands = [a, position_ids, freqs];
15181559
// attrs = [dims, traditional, scale_bits]. base = nullopt (freqs supplied).
1560+
//
1561+
// H>1 falls back to the hand cos/sin/rotate composition — see the
1562+
// "fast_rope_ids" comment above and emlx_fast.cpp's multi-head-safe RoPE
1563+
// helpers comment. H==1 keeps the fused mlx::core::fast::rope path.
15191564
{"fast_rope_with_freqs",
15201565
[](const auto &ops, const auto &attrs) {
1566+
const auto &a = ops[0];
15211567
const auto &pos = ops[1];
1568+
const auto &freqs = ops[2];
1569+
int dims = static_cast<int>(attrs[0]);
1570+
bool traditional = attrs[1] != 0;
1571+
1572+
if (a.ndim() == 4 && a.shape(2) > 1) {
1573+
if (traditional) {
1574+
throw std::invalid_argument(
1575+
"fast_rope_with_freqs: traditional=true not supported for "
1576+
"multi-head (H>1) input");
1577+
}
1578+
float scale = attr_to_float(attrs[2]);
1579+
int half = dims / 2;
1580+
1581+
int B = pos.shape(0);
1582+
int T = pos.shape(1);
1583+
auto pos_f = mlx::core::astype(pos, mlx::core::float32);
1584+
auto pos_bt1 = mlx::core::reshape(pos_f, to_shape({B, T, 1}));
1585+
auto inv_freq = mlx::core::reciprocal(mlx::core::astype(freqs, mlx::core::float32));
1586+
auto inv_11h = mlx::core::reshape(inv_freq, to_shape({1, 1, half}));
1587+
auto angles = mlx::core::multiply(
1588+
mlx::core::multiply(pos_bt1, inv_11h),
1589+
mlx::core::array(scale, mlx::core::float32));
1590+
1591+
return rope_rotate_from_angles(a, angles, dims);
1592+
}
1593+
15221594
int B = pos.shape(0);
15231595
auto offsets = mlx::core::reshape(
15241596
mlx::core::slice(pos, to_shape({0, 0}), to_shape({B, 1}),
15251597
to_shape({1, 1})),
15261598
to_shape({B}));
15271599
return mlx::core::fast::rope(
1528-
ops[0], static_cast<int>(attrs[0]), attrs[1] != 0, std::nullopt,
1529-
attr_to_float(attrs[2]), offsets, ops[2]);
1600+
a, dims, traditional, std::nullopt,
1601+
attr_to_float(attrs[2]), offsets, freqs);
15301602
}},
15311603

15321604
// rope (arbitrary per-token position_ids, base-derived inv_freq):

emlx/c_src/emlx_fast.cpp

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,87 @@
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.
89174
mlx::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.
102210
mlx::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
}
196295
FINE_ASYNC_NIF(fast_rope_positions)
197296

emlx/lib/emlx.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,16 @@ defmodule EMLX do
588588
589589
Single Metal shader. Applies RoPE with a scalar position `offset`.
590590
591-
- `a` — input `{B, ..., T, D}`
591+
- `a` — input `{B, ..., T, D}`; **`T` must be the second-to-last
592+
axis** — e.g. `{B, T, D}` or, with a heads axis, `{B, H, T, D}`
593+
(heads-transposed). If `H` (or any other middle axis) is placed *before*
594+
`T` instead — e.g. `{B, T, H, D}` — `mlx::core::fast::rope` silently
595+
rotates row `h` at angle `position + h` instead of `position` for every
596+
`h > 0` (only row 0 is correct); see
597+
[elixir-nx/emlx#121](https://github.com/elixir-nx/emlx/issues/121).
598+
For Bumblebee's heads-not-yet-transposed `{B, T, H, D}` convention, use
599+
`EMLX.fast_rope_ids/6` or `EMLX.fast_rope_positions/6` instead, which
600+
guard against this.
592601
- `dims` — number of feature dims to rotate (≤ last-axis size, must be even)
593602
- `traditional` — `false` for split-half (Qwen3); `true` for interleaved
594603
- `base` — angular frequency base (e.g. 10_000 or 1_000_000)

emlx/lib/emlx/fast.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,12 @@ defmodule EMLX.Fast do
461461
@doc """
462462
Fused rotary position embedding (`mlx::fast::rope`).
463463
464-
- `a` — input `{B, ..., T, D}`; `...` dims are passed through.
464+
- `a` — input `{B, ..., T, D}`; `...` dims are passed through, but
465+
**`T` must be the second-to-last axis** (e.g. `{B, H, T, D}`,
466+
heads-transposed) — see `EMLX.fast_rope/6`'s `@doc` for why a heads-axis
467+
placed *before* `T` (Bumblebee's `{B, T, H, D}`) silently miscomputes for
468+
more than one head ([elixir-nx/emlx#121](https://github.com/elixir-nx/emlx/issues/121)).
469+
Use `rope_with_positions/6` or `rope_with_freqs/6` for that layout instead.
465470
- `dims` — number of feature dims to rotate (≤ last-axis size, must be even).
466471
- `traditional` — `false` for split-half (Qwen3); `true` for interleaved.
467472
- `base` — angular frequency base (e.g. `10_000` or `1_000_000`).

0 commit comments

Comments
 (0)