Skip to content

Commit 7988ca8

Browse files
committed
feat: lower indexed_put / indexed_add in the native Expr compiler
Nx.indexed_put and Nx.indexed_add (scatter) now lower under `compiler: Emily.Compiler, native: true` for MLX-scatter-compatible index layouts, instead of forcing a graceful fallback to the evaluator. The MLX ops and eager NIFs already existed (Native.{scatter,scatter_add} -> mx::{scatter,scatter_add}); only the compiler path was missing. - opcodes.hpp: add Scatter/ScatterAdd (92-93), bump kOpcodeCount to 94, dispatch to mx::scatter (overwrite) / mx::scatter_add (accumulate). operands [target, updates, idx0, ...]; iattrs [[axes...]]. - ir.ex: add the opcodes; lower :indexed_put/:indexed_add (one shared clause), mirroring Emily.Backend's apply_scatter — reuse the existing gather index-split + scatter_gather_compatible? helpers, port updates_shape_for_scatter. Incompatible index layouts raise (no fallback), matching the native gather; the evaluator handles them under native_fallback: :eval. - compiler_equivalence_test.exs: native-vs-evaluator bit-identical cases for indexed_put/indexed_add (2-D grid, duplicate-index accumulation, partial-axis whole-row writes). Another op surfaced while running the native compiler on the Whisper livebook.
1 parent 88d9f51 commit 7988ca8

4 files changed

Lines changed: 108 additions & 2 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@
6767
`speech_to_text` serving — the log-mel featurizer's STFT — so that path
6868
now compiles fully native too.
6969

70+
- **`indexed_put` / `indexed_add` (scatter) lower natively** — both now
71+
compile under the native single-NIF path for MLX-scatter-compatible index
72+
layouts (the same layout the native gather already requires), mirroring
73+
`Emily.Backend`'s scatter — split the index tensor into per-axis s32
74+
arrays, reshape updates into MLX's layout, then `mx::scatter` (overwrite)
75+
/ `mx::scatter_add` (accumulate) bit-for-bit. Layouts MLX can't scatter
76+
still route through the evaluator under `native_fallback: :eval`.
77+
7078
- **Window (pooling) ops lower natively — forward and backward.** The
7179
forward window family (`window_sum`/`window_max`/`window_min`/
7280
`window_product`, i.e. average and max pooling), the select-and-scatter

c_src/emily/opcodes.hpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,13 @@ enum class Opcode : int64_t {
175175
Ifftn = 89, // complex -> complex (inverse)
176176
Rfftn = 90, // real -> complex (half spectrum)
177177
Irfftn = 91, // complex half-spectrum -> real
178+
// Scatter (Nx.indexed_put / indexed_add). operands [target, updates,
179+
// idx0, ...] (one s32 index array per scattered axis); iattrs [[axes...]].
180+
Scatter = 92, // overwrite (last write wins on duplicates)
181+
ScatterAdd = 93, // accumulate
178182
};
179183

180-
inline constexpr int64_t kOpcodeCount = 92;
184+
inline constexpr int64_t kOpcodeCount = 94;
181185

182186
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
183187
inline std::string qmode_from_code(int64_t code) {
@@ -691,6 +695,21 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
691695
emily::to_mlx_shape(attr_at(iattrs, 0, "irfftn")),
692696
emily::to_int_vec(attr_at(iattrs, 1, "irfftn")),
693697
mx::fft::FFTNorm::Backward, s);
698+
// --- Scatter (shares the eager index.cpp entry points) ---
699+
case Opcode::Scatter:
700+
case Opcode::ScatterAdd: {
701+
if (in.size() < 3) {
702+
throw std::invalid_argument(
703+
"scatter expects >= 3 operands (target, updates, >=1 index), got " +
704+
std::to_string(in.size()));
705+
}
706+
// operands [target, updates, idx0, ...]; the index arrays follow updates.
707+
std::vector<mx::array> indices(in.begin() + 2, in.end());
708+
auto axes = emily::to_int_vec(attr0(iattrs, "scatter"));
709+
return op == Opcode::Scatter
710+
? mx::scatter(in[0], indices, in[1], axes, s)
711+
: mx::scatter_add(in[0], indices, in[1], axes, s);
712+
}
694713
}
695714
throw std::invalid_argument("unknown opcode " +
696715
std::to_string(static_cast<int64_t>(op)));

lib/emily/ir.ex

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,12 @@ defmodule Emily.IR do
153153
fftn: 88,
154154
ifftn: 89,
155155
rfftn: 90,
156-
irfftn: 91
156+
irfftn: 91,
157+
# Scatter (Nx.indexed_put / indexed_add). operands [target, updates,
158+
# idx0, ...] (one s32 index array per scattered axis); iattrs [[axes...]].
159+
# scatter overwrites (last-write on duplicates); scatter_add accumulates.
160+
scatter: 92,
161+
scatter_add: 93
157162
}
158163

159164
# Quant mode string -> code; decoded by qmode_from_code in
@@ -661,6 +666,39 @@ defmodule Emily.IR do
661666
coerce(r, t.type, state)
662667
end
663668

669+
# indexed_put / indexed_add (Nx scatter). Mirrors Emily.Backend's
670+
# apply_scatter: split the {..., R} index tensor into R per-axis s32 index
671+
# arrays, reshape `updates` into MLX's scatter layout, then mx::scatter
672+
# (overwrite) / mx::scatter_add (accumulate). Only MLX-scatter-compatible
673+
# index layouts lower; others raise (no fallback) — the Evaluator handles
674+
# them via_binary under `native_fallback: :eval`, matching native gather.
675+
# Operands [target, updates, idx0, ...]; iattrs [[axes...]].
676+
defp lower_op(
677+
%T{data: %Nx.Defn.Expr{op: op, args: [target, indices, updates, opts]}} = t,
678+
state
679+
)
680+
when op in [:indexed_put, :indexed_add] do
681+
axes = opts[:axes] || Enum.to_list(0..(tuple_size(target.shape) - 1)//1)
682+
indices_shape = Tuple.to_list(indices.shape)
683+
684+
unless scatter_gather_compatible?(indices_shape, axes) do
685+
raise ArgumentError,
686+
"Emily Expr compiler: #{op} index layout #{inspect(indices_shape)} for axes " <>
687+
"#{inspect(axes)} is not MLX-scatter-compatible (no fallback)."
688+
end
689+
690+
{rt, state} = lower_node(target, state)
691+
{rx, state} = lower_node(indices, state)
692+
{idx_refs, state} = split_indices_for_gather(rx, indices_shape, length(axes), state)
693+
694+
{ru, state} = lower_node(updates, state)
695+
updates_shape = updates_shape_for_scatter(indices_shape, target.shape, axes)
696+
{ru, state} = emit(state, :reshape, [ru], [updates_shape])
697+
698+
opcode = if op == :indexed_put, do: :scatter, else: :scatter_add
699+
emit_coerced(state, opcode, [rt, ru | idx_refs], [axes], t.type)
700+
end
701+
664702
# put_slice(src, start_indices, slice): write `slice` into `src` at
665703
# `start_indices`. Mirrors Emily.Backend.put_slice/4 (cast src + update
666704
# to out.type), but supports RUNTIME (tensor) start indices — the decode
@@ -1285,4 +1323,19 @@ defmodule Emily.IR do
12851323
rank = tuple_size(input_shape)
12861324
for i <- 0..(rank - 1)//1, do: if(i in axes_set, do: 1, else: elem(input_shape, i))
12871325
end
1326+
1327+
# Rewrap Nx's updates shape {batch ++ non_indexed_dims} into MLX's scatter
1328+
# layout {batch ++ per_axis_slot}, where per_axis_slot has length
1329+
# rank(target) with 1 on indexed axes and target_shape[i] elsewhere. Mirrors
1330+
# Emily.Backend.updates_shape_for_scatter/3.
1331+
defp updates_shape_for_scatter(indices_shape, target_shape, axes) do
1332+
batch = Enum.take(indices_shape, length(indices_shape) - 1)
1333+
axes_set = MapSet.new(axes)
1334+
rank = tuple_size(target_shape)
1335+
1336+
trailing =
1337+
for i <- 0..(rank - 1)//1, do: if(i in axes_set, do: 1, else: elem(target_shape, i))
1338+
1339+
batch ++ trailing
1340+
end
12881341
end

test/emily/compiler_equivalence_test.exs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,32 @@ defmodule Emily.CompilerEquivalenceTest do
395395
end
396396
end
397397

398+
describe "scatter (indexed_put / indexed_add)" do
399+
test "indexed_put / indexed_add into a 2-D target match the evaluator" do
400+
target = et([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
401+
# Two distinct cells in the {2,3} grid -> order-independent.
402+
idx = Nx.tensor([[0, 0], [1, 2]], type: :s64, backend: Emily.Backend)
403+
upd = et([10.0, 20.0])
404+
405+
assert_equiv(fn t, i, u -> Nx.indexed_put(t, i, u) end, [target, idx, upd])
406+
assert_equiv(fn t, i, u -> Nx.indexed_add(t, i, u) end, [target, idx, upd])
407+
end
408+
409+
test "indexed_add accumulates duplicate indices (same MLX kernel both paths)" do
410+
target = et([0.0, 0.0, 0.0, 0.0])
411+
idx = Nx.tensor([[1], [1], [3]], type: :s64, backend: Emily.Backend)
412+
upd = et([5.0, 7.0, 2.0])
413+
assert_equiv(fn t, i, u -> Nx.indexed_add(t, i, u) end, [target, idx, upd])
414+
end
415+
416+
test "indexed_put on a partial axis set (axes: [0], whole-row writes) matches" do
417+
target = et([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]])
418+
idx = Nx.tensor([[0], [2]], type: :s64, backend: Emily.Backend)
419+
upd = et([[10.0, 11.0, 12.0], [13.0, 14.0, 15.0]])
420+
assert_equiv(fn t, i, u -> Nx.indexed_put(t, i, u, axes: [0]) end, [target, idx, upd])
421+
end
422+
end
423+
398424
describe "window reductions (pooling forward)" do
399425
test "2x2 maxpool / sumpool / minpool (CNN-shaped) match the Evaluator" do
400426
# {batch, channels, h, w}; pool only the spatial axes, stride 2.

0 commit comments

Comments
 (0)