Skip to content

Commit c2f1e8d

Browse files
authored
Merge pull request #179 from ausimian/feat/expr-compiler-window-ops
Native window/pooling ops — CNN forward + backward lowering
2 parents 82ce9c6 + c7f1a08 commit c2f1e8d

7 files changed

Lines changed: 620 additions & 275 deletions

File tree

RELEASE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@
5858
gather, so a DistilBERT question-answering `Nx.Serving` forward now runs
5959
fully native — and fused — under `native_fallback: :raise`.
6060

61+
- **Window (pooling) ops lower natively — forward and backward.** The
62+
forward window family (`window_sum`/`window_max`/`window_min`/
63+
`window_product`, i.e. average and max pooling), the select-and-scatter
64+
backward (`window_scatter_max`/`window_scatter_min`, the MaxPool/MinPool
65+
gradient), and `reverse` (the conv-backward kernel flip) all now compile
66+
under the native single-NIF path instead of falling back. The pad →
67+
sliding-window → reduce/scatter cores moved into `emily/op_cores.hpp` so
68+
the eager NIFs and the compiled replay share one implementation. A
69+
small-CNN **training step** (conv + maxpool forward and backward, grad,
70+
SGD) now lowers fully native under `native_fallback: :raise`, producing a
71+
loss bit-identical to the evaluator.
72+
6173
- **`Bumblebee.Text.generation` compiles fully native — greedy and sampling.**
6274
The headline result: an end-to-end Bumblebee generation (the transformer
6375
forward, the `defn while` decode loop, dynamic KV-cache writes, `cumsum`
@@ -145,3 +157,15 @@
145157
tensor-offset `fast_rope/8`. Note: feed the kernel the 4-D
146158
`{batch, heads, seq, head_dim}` layout — in 3-D, MLX 0.31 mis-rotates
147159
single-token (`seq == 1`) inputs.
160+
161+
### Fixed
162+
163+
- **Dilated window reductions (`window_dilations > 1`) returned wrong values.**
164+
`window_sum`/`window_max`/`window_min`/`window_product` with a dilated kernel
165+
silently produced garbage for windows past the first stride positions, on both
166+
the eager backend and the native compiler (they share the window-reduce core).
167+
A dilated kernel axis gets an `as_strided` stride > 1, so the sliding-window
168+
view aliases fewer physical elements than its logical size; MLX's strided-reduce
169+
fast path then read past the aliased buffer. The view is now materialised
170+
contiguously before the reduce when any dilation > 1 (the common non-dilated
171+
pooling path is unchanged and stays copy-free).

c_src/emily/op_cores.hpp

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212

1313
#include <mlx/mlx.h>
1414

15+
#include <cstddef>
16+
#include <cstdint>
17+
#include <numeric>
1518
#include <stdexcept>
1619
#include <string>
20+
#include <vector>
1721

1822
namespace emily::ops {
1923

@@ -58,4 +62,302 @@ inline mx::array flip_core(const mx::array &a, int64_t axis, mx::Stream &s) {
5862
s);
5963
}
6064

65+
// --- Window / pooling (forward reductions) ---
66+
//
67+
// MLX exposes no window_sum/max/min/product primitive; each is composed
68+
// as pad -> as_strided (sliding-window view) -> reduce over the kernel
69+
// axes. These cores back both the eager NIFs (c_src/ops/pooling.cpp) and
70+
// the compiled program replay, so the two paths can't drift.
71+
72+
// Contiguous element-strides for a shape, e.g. {B, H, W, C} ->
73+
// {H*W*C, W*C, C, 1}.
74+
inline mx::Strides contiguous_strides(const mx::Shape &shape) {
75+
int rank = static_cast<int>(shape.size());
76+
mx::Strides out(rank, 1);
77+
for (int i = rank - 2; i >= 0; --i) {
78+
out[i] = out[i + 1] * static_cast<int64_t>(shape[i + 1]);
79+
}
80+
return out;
81+
}
82+
83+
// Pad `a` with `pad_value` using per-axis lo/hi pads. Returns `a`
84+
// unchanged if all pads are zero (the common path — avoids a copy).
85+
inline mx::array do_pad(
86+
const mx::array &a,
87+
const std::vector<int64_t> &pad_lo,
88+
const std::vector<int64_t> &pad_hi,
89+
const mx::array &pad_value,
90+
mx::Stream &s) {
91+
int rank = static_cast<int>(a.ndim());
92+
if (pad_lo.size() != static_cast<std::size_t>(rank) ||
93+
pad_hi.size() != static_cast<std::size_t>(rank)) {
94+
throw std::invalid_argument(
95+
"pad: pad_lo/pad_hi length must equal tensor rank " +
96+
std::to_string(rank));
97+
}
98+
bool any_pad = false;
99+
for (int i = 0; i < rank; ++i) {
100+
if (pad_lo[i] > 0 || pad_hi[i] > 0) {
101+
any_pad = true;
102+
break;
103+
}
104+
}
105+
if (!any_pad) {
106+
return a;
107+
}
108+
109+
std::vector<int> axes(rank);
110+
std::iota(axes.begin(), axes.end(), 0);
111+
112+
mx::Shape lo, hi;
113+
lo.reserve(rank);
114+
hi.reserve(rank);
115+
for (int i = 0; i < rank; ++i) {
116+
lo.push_back(static_cast<mx::ShapeElem>(pad_lo[i]));
117+
hi.push_back(static_cast<mx::ShapeElem>(pad_hi[i]));
118+
}
119+
120+
return mx::pad(a, axes, lo, hi, pad_value, "constant", s);
121+
}
122+
123+
// Build an `as_strided` view with shape `[out_dims..., window_shape...]`.
124+
// `out_dims` is filled with the per-axis output size.
125+
//
126+
// eff_window = (window_shape[i] - 1) * dilations[i] + 1
127+
// out[i] = (padded_shape[i] - eff_window) / strides[i] + 1
128+
inline mx::array sliding_windows_view(
129+
const mx::array &padded,
130+
const std::vector<int64_t> &window_shape,
131+
const std::vector<int64_t> &strides,
132+
const std::vector<int64_t> &dilations,
133+
std::vector<int64_t> &out_dims,
134+
mx::Stream &s) {
135+
int rank = static_cast<int>(padded.ndim());
136+
const auto rank_sz = static_cast<std::size_t>(rank);
137+
if (window_shape.size() != rank_sz || strides.size() != rank_sz ||
138+
dilations.size() != rank_sz) {
139+
throw std::invalid_argument(
140+
"window: window_shape/strides/dilations length must equal tensor "
141+
"rank " +
142+
std::to_string(rank));
143+
}
144+
for (int i = 0; i < rank; ++i) {
145+
if (window_shape[i] < 1 || strides[i] < 1 || dilations[i] < 1) {
146+
throw std::invalid_argument(
147+
"window: window dimensions, strides, and dilations must all be "
148+
"positive");
149+
}
150+
}
151+
const auto &padded_shape = padded.shape();
152+
auto cs = contiguous_strides(padded_shape);
153+
154+
out_dims.assign(rank, 0);
155+
mx::Shape new_shape;
156+
mx::Strides new_strides;
157+
new_shape.reserve(2 * rank);
158+
new_strides.reserve(2 * rank);
159+
160+
for (int i = 0; i < rank; ++i) {
161+
int64_t eff = (window_shape[i] - 1) * dilations[i] + 1;
162+
out_dims[i] = (static_cast<int64_t>(padded_shape[i]) - eff) / strides[i] + 1;
163+
new_shape.push_back(static_cast<mx::ShapeElem>(out_dims[i]));
164+
}
165+
for (int i = 0; i < rank; ++i) {
166+
new_shape.push_back(static_cast<mx::ShapeElem>(window_shape[i]));
167+
}
168+
for (int i = 0; i < rank; ++i) {
169+
new_strides.push_back(cs[i] * strides[i]);
170+
}
171+
for (int i = 0; i < rank; ++i) {
172+
new_strides.push_back(cs[i] * dilations[i]);
173+
}
174+
175+
return mx::as_strided(padded, new_shape, new_strides, 0, s);
176+
}
177+
178+
enum class WindowReduceKind { Sum, Max, Min, Product };
179+
180+
// pad -> sliding-window view -> reduce over the kernel axes. `init_value`
181+
// is the dtype identity (0/1/±inf), used both as the pad fill and (for
182+
// max/min) the reduction's boundary identity.
183+
inline mx::array window_reduce_core(
184+
const mx::array &a,
185+
const std::vector<int64_t> &window_shape,
186+
const std::vector<int64_t> &strides,
187+
const std::vector<int64_t> &pad_lo,
188+
const std::vector<int64_t> &pad_hi,
189+
const std::vector<int64_t> &dilations,
190+
const mx::array &init_value,
191+
WindowReduceKind kind,
192+
mx::Stream &s) {
193+
auto padded = do_pad(a, pad_lo, pad_hi, init_value, s);
194+
std::vector<int64_t> out_dims;
195+
auto view =
196+
sliding_windows_view(padded, window_shape, strides, dilations, out_dims, s);
197+
198+
// Dilated windows give the kernel axes an `as_strided` stride > 1, so the
199+
// view aliases fewer physical elements than its logical size (overlapping
200+
// strides). MLX's reduction then picks a strided fast path
201+
// (GeneralStridedReduce) that assumes a dense, non-overlapping layout and
202+
// reads `product(shape)` contiguous elements — over-running the buffer and
203+
// returning garbage for windows past the first stride positions (issue
204+
// #175). Materialise the view first: the general copy reads element-by-
205+
// element via the real strides (always in-bounds, since the last window's
206+
// last tap is the last real element), yielding a dense buffer the reducer
207+
// can safely fast-path. Only dilated windows need this; the common
208+
// (non-dilated) pooling path keeps its copy-free strided reduce.
209+
bool dilated = false;
210+
for (int64_t d : dilations) {
211+
if (d > 1) {
212+
dilated = true;
213+
break;
214+
}
215+
}
216+
if (dilated) {
217+
view = mx::contiguous(view, /*allow_col_major=*/false, s);
218+
}
219+
220+
int rank = static_cast<int>(window_shape.size());
221+
std::vector<int> reduce_axes(rank);
222+
for (int i = 0; i < rank; ++i)
223+
reduce_axes[i] = rank + i;
224+
225+
switch (kind) {
226+
case WindowReduceKind::Sum:
227+
return mx::sum(view, reduce_axes, /*keepdims=*/false, s);
228+
case WindowReduceKind::Max:
229+
return mx::max(view, reduce_axes, /*keepdims=*/false, s);
230+
case WindowReduceKind::Min:
231+
return mx::min(view, reduce_axes, /*keepdims=*/false, s);
232+
case WindowReduceKind::Product:
233+
return mx::prod(view, reduce_axes, /*keepdims=*/false, s);
234+
}
235+
throw std::invalid_argument("window_reduce_core: unknown reduce kind");
236+
}
237+
238+
// Select-and-scatter — the backward of window_max/window_min (Nx rewrites
239+
// grad(window_max) into window_scatter_max). `is_max` picks argmax vs
240+
// argmin. Tie-break: Nx's select_and_scatter uses `>=`/`<=` (LAST
241+
// occurrence); MLX argmax/argmin give FIRST, so we argmax `mask * pos`
242+
// to recover the last winner. Scatter variants take no dilations.
243+
inline mx::array window_scatter_core(
244+
const mx::array &tensor,
245+
const mx::array &source,
246+
const mx::array &init_value,
247+
const std::vector<int64_t> &window_shape,
248+
const std::vector<int64_t> &strides,
249+
const std::vector<int64_t> &pad_lo,
250+
const std::vector<int64_t> &pad_hi,
251+
bool is_max,
252+
mx::Stream &s) {
253+
int rank = static_cast<int>(window_shape.size());
254+
auto original_shape = tensor.shape();
255+
256+
// 1. Pad input with init_value.
257+
auto padded = do_pad(tensor, pad_lo, pad_hi, init_value, s);
258+
auto padded_shape = padded.shape();
259+
260+
// 2. Sliding-window view (dilation is implicitly 1 per axis for scatter).
261+
std::vector<int64_t> dilations(rank, 1);
262+
std::vector<int64_t> out_dims;
263+
auto view =
264+
sliding_windows_view(padded, window_shape, strides, dilations, out_dims, s);
265+
266+
// 3. Flatten the kernel axes so a single reduction spans the window.
267+
int64_t K = 1;
268+
for (int i = 0; i < rank; ++i)
269+
K *= window_shape[i];
270+
271+
mx::Shape flat_view_shape;
272+
flat_view_shape.reserve(rank + 1);
273+
for (int i = 0; i < rank; ++i)
274+
flat_view_shape.push_back(static_cast<mx::ShapeElem>(out_dims[i]));
275+
flat_view_shape.push_back(static_cast<mx::ShapeElem>(K));
276+
277+
auto flat_view = mx::reshape(view, flat_view_shape, s);
278+
int last_axis = rank;
279+
280+
// 4. Argmax-with-tie-break (mask*pos picks the last-occurrence winner).
281+
auto selector = is_max
282+
? mx::max(flat_view, last_axis, /*keepdims=*/true, s)
283+
: mx::min(flat_view, last_axis, /*keepdims=*/true, s);
284+
auto mask = mx::equal(flat_view, selector, s);
285+
286+
auto pos_1d = mx::arange(0.0, static_cast<double>(K), 1.0, mx::int32, s);
287+
mx::Shape pos_shape(rank + 1, 1);
288+
pos_shape[rank] = static_cast<mx::ShapeElem>(K);
289+
auto pos = mx::reshape(pos_1d, pos_shape, s);
290+
291+
auto mask_i = mx::astype(mask, mx::int32, s);
292+
auto mask_pos = mx::multiply(mask_i, pos, s);
293+
auto last_arg = mx::argmax(mask_pos, last_axis, /*keepdims=*/false, s);
294+
295+
// 5. Decompose the flat kernel index into per-axis kernel indices.
296+
std::vector<mx::array> k_idx;
297+
k_idx.reserve(rank);
298+
for (int i = 0; i < rank; ++i)
299+
k_idx.push_back(last_arg); // placeholder; overwritten below
300+
301+
mx::array remaining = last_arg;
302+
for (int i = rank - 1; i >= 0; --i) {
303+
auto w_i = mx::array(static_cast<int32_t>(window_shape[i]), mx::int32);
304+
k_idx[i] = mx::remainder(remaining, w_i, s);
305+
if (i > 0) {
306+
remaining = mx::floor_divide(remaining, w_i, s);
307+
}
308+
}
309+
310+
// 6. Per-axis absolute indices into the padded tensor.
311+
mx::Shape out_shape_s;
312+
out_shape_s.reserve(rank);
313+
for (int i = 0; i < rank; ++i)
314+
out_shape_s.push_back(static_cast<mx::ShapeElem>(out_dims[i]));
315+
316+
std::vector<mx::array> abs_indices;
317+
abs_indices.reserve(rank);
318+
for (int i = 0; i < rank; ++i) {
319+
auto base_i =
320+
mx::arange(0.0, static_cast<double>(out_dims[i]), 1.0, mx::int32, s);
321+
mx::Shape bcast(rank, 1);
322+
bcast[i] = static_cast<mx::ShapeElem>(out_dims[i]);
323+
base_i = mx::reshape(base_i, bcast, s);
324+
auto stride_i = mx::array(static_cast<int32_t>(strides[i]), mx::int32);
325+
auto base_times = mx::multiply(base_i, stride_i, s);
326+
auto bt = mx::broadcast_to(base_times, out_shape_s, s);
327+
abs_indices.push_back(mx::add(bt, k_idx[i], s));
328+
}
329+
330+
// 7. Reshape source so each index tuple is a single-point write.
331+
mx::Shape source_reshape;
332+
source_reshape.reserve(2 * rank);
333+
for (int i = 0; i < rank; ++i)
334+
source_reshape.push_back(static_cast<mx::ShapeElem>(out_dims[i]));
335+
for (int i = 0; i < rank; ++i)
336+
source_reshape.push_back(1);
337+
auto source_r = mx::reshape(source, source_reshape, s);
338+
source_r = mx::astype(source_r, tensor.dtype(), s);
339+
340+
// 8. Output buffer starts filled with init_value (unselected positions
341+
// retain it; selected positions receive init_value + sum(source)).
342+
auto padded_out = mx::full(padded_shape, init_value, tensor.dtype(), s);
343+
344+
// 9. Scatter-add all selected contributions in one dispatch.
345+
std::vector<int> axes(rank);
346+
std::iota(axes.begin(), axes.end(), 0);
347+
auto scattered = mx::scatter_add(padded_out, abs_indices, source_r, axes, s);
348+
349+
// 10. Slice back to the original (unpadded) shape.
350+
mx::Shape slice_start, slice_stop, slice_strides_v;
351+
slice_start.reserve(rank);
352+
slice_stop.reserve(rank);
353+
slice_strides_v.reserve(rank);
354+
for (int i = 0; i < rank; ++i) {
355+
slice_start.push_back(static_cast<mx::ShapeElem>(pad_lo[i]));
356+
slice_stop.push_back(
357+
static_cast<mx::ShapeElem>(pad_lo[i] + original_shape[i]));
358+
slice_strides_v.push_back(1);
359+
}
360+
return mx::slice(scattered, slice_start, slice_stop, slice_strides_v, s);
361+
}
362+
61363
} // namespace emily::ops

0 commit comments

Comments
 (0)