Skip to content

Commit 9ffc221

Browse files
authored
Merge pull request #177 from ausimian/feat/expr-compiler-take-along-axis
Lower take_along_axis natively + opcode lockstep guard
2 parents e6203bf + 4578ceb commit 9ffc221

7 files changed

Lines changed: 165 additions & 25 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
selection on the native path. Remaining gaps (`gather`/scatter,
5151
pooling/`window_*`, cumulative) continue to work via the graceful fallback.
5252

53+
- **`take_along_axis` lowers natively**`Nx.take_along_axis` (the
54+
`Nx.Block.TakeAlongAxis` block) now compiles under the native single-NIF
55+
path, mirroring `Emily.Backend.native_take_along_axis/4` (cast indices to
56+
s32, then `mlx::core::take_along_axis`) bit-for-bit. This was the last op
57+
forcing a fallback in `Bumblebee.Text.question_answering`'s answer-span
58+
gather, so a DistilBERT question-answering `Nx.Serving` forward now runs
59+
fully native — and fused — under `native_fallback: :raise`.
60+
5361
- **`Bumblebee.Text.generation` compiles fully native — greedy and sampling.**
5462
The headline result: an end-to-end Bumblebee generation (the transformer
5563
forward, the `defn while` decode loop, dynamic KV-cache writes, `cumsum`

c_src/emily/opcodes.hpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,12 @@ enum class Opcode : int64_t {
153153
Gather = 79,
154154
// Stack tensors along a new axis. operands [t0, t1, ...]; iattrs [[axis]]
155155
Stack = 80,
156+
// Gather along one axis with a same-rank s32 index tensor.
157+
// operands [input, indices]; iattrs [[axis]]
158+
TakeAlongAxis = 81,
156159
};
157160

158-
inline constexpr int64_t kOpcodeCount = 81;
161+
inline constexpr int64_t kOpcodeCount = 82;
159162

160163
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
161164
inline std::string qmode_from_code(int64_t code) {
@@ -503,6 +506,15 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
503506
int axis = emily::checked_int(scalar_at(iattrs, 0, "take"), "axis");
504507
return mx::take(in[0], in[1], axis, s);
505508
}
509+
case Opcode::TakeAlongAxis: {
510+
if (in.size() != 2) {
511+
throw std::invalid_argument("take_along_axis expects 2 operands, got " +
512+
std::to_string(in.size()));
513+
}
514+
int axis =
515+
emily::checked_int(scalar_at(iattrs, 0, "take_along_axis"), "axis");
516+
return mx::take_along_axis(in[0], in[1], axis, s);
517+
}
506518
case Opcode::Concatenate: {
507519
if (in.empty()) {
508520
throw std::invalid_argument("concatenate expects >= 1 operand");

lib/emily/ir.ex

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ defmodule Emily.IR do
132132
cummin: 78,
133133
# multi-axis gather: operands [input, idx0, ...]; iattrs [[axes],[slice_sizes]]
134134
gather: 79,
135-
stack: 80
135+
stack: 80,
136+
# take_along_axis: gather along one axis with a same-rank s32 index
137+
# tensor. operands [input, indices]; iattrs [[axis]].
138+
take_along_axis: 81
136139
}
137140

138141
# Quant mode string -> code; decoded by qmode_from_code in
@@ -163,6 +166,14 @@ defmodule Emily.IR do
163166
outputs: [ref()]
164167
}
165168

169+
@doc """
170+
The opcode name -> wire-value map. Exposed for the opcode-parity test,
171+
which checks these stay in lockstep with the `Opcode` enum and
172+
`kOpcodeCount` in `c_src/emily/opcodes.hpp`.
173+
"""
174+
@spec opcodes() :: %{atom() => non_neg_integer()}
175+
def opcodes, do: @opcodes
176+
166177
@doc "Numeric wire value for an opcode name."
167178
@spec opcode(atom()) :: non_neg_integer()
168179
def opcode(name) when is_map_key(@opcodes, name), do: Map.fetch!(@opcodes, name)
@@ -915,6 +926,16 @@ defmodule Emily.IR do
915926
emit_coerced(state, :take, [ri, rx], [[axis]], t.type)
916927
end
917928

929+
# Nx.take_along_axis (Nx.Block.TakeAlongAxis). Mirrors
930+
# Emily.Backend.native_take_along_axis/4: cast indices to s32, then
931+
# mx::take_along_axis along `axis`.
932+
defp lower_block(%Nx.Block.TakeAlongAxis{axis: axis}, [input, indices], _expr, t, state) do
933+
{ri, state} = lower_node(input, state)
934+
{rx, state} = lower_node(indices, state)
935+
{rx, state} = emit(state, :astype, [rx], [[dtype_code({:s, 32})]])
936+
emit_coerced(state, :take_along_axis, [ri, rx], [[axis]], t.type)
937+
end
938+
918939
# Cumulative families. Like Emily.Backend.block/4, the last-axis case uses
919940
# the native MLX `cumsum`/`cumprod`/`cummax`/`cummin` kernel; interior axes
920941
# (which MLX can't always factor) fall back to the block's composed

test/emily/compiler_equivalence_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,26 @@ defmodule Emily.CompilerEquivalenceTest do
368368
end
369369
end
370370

371+
describe "take_along_axis" do
372+
test "along the last axis matches the Evaluator" do
373+
x = et([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0], [9.0, 10.0, 11.0, 12.0]])
374+
idx = Nx.tensor([[3, 0], [1, 2], [0, 3]], type: :s64, backend: Emily.Backend)
375+
assert_equiv(fn t, i -> Nx.take_along_axis(t, i, axis: 1) end, [x, idx])
376+
end
377+
378+
test "along axis 0 matches" do
379+
x = et([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
380+
idx = Nx.tensor([[0, 1, 0], [1, 0, 1], [0, 0, 1]], type: :s64, backend: Emily.Backend)
381+
assert_equiv(fn t, i -> Nx.take_along_axis(t, i, axis: 0) end, [x, idx])
382+
end
383+
384+
test "3-D gather along the last axis (transformer-shaped) matches" do
385+
x = Nx.iota({1, 2, 4}, type: :f32, backend: Emily.Backend) |> Nx.divide(8.0)
386+
idx = Nx.tensor([[[3, 1], [0, 2]]], type: :s32, backend: Emily.Backend)
387+
assert_equiv(fn t, i -> Nx.take_along_axis(t, i, axis: 2) end, [x, idx])
388+
end
389+
end
390+
371391
describe "dynamic put_slice (KV-cache write)" do
372392
test "put_slice at a runtime offset matches the Evaluator" do
373393
# {batch, n_kv_heads, max_len, head_dim} KV buffer; write one token.

test/emily/conformance/distilbert_test.exs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ defmodule Emily.Conformance.DistilbertTest do
3333

3434
use ExUnit.Case, async: false
3535

36-
import Emily.ConformanceHelper, only: [assert_all_close: 2, assert_all_close: 3, mode_test: 2]
36+
import Emily.ConformanceHelper,
37+
only: [assert_all_close: 2, assert_all_close: 3, mode_test: 2, mode_test: 3]
3738

3839
alias Emily.Bumblebee.FastKernels
3940

@@ -228,15 +229,22 @@ defmodule Emily.Conformance.DistilbertTest do
228229
# (vocab 30522) with a tiny-random model (1124-row embedding)
229230
# feeds out-of-range token ids into gather and relies on backend
230231
# OOB behaviour, which is how we originally hit a :nan score.
231-
@tag :distilbert_full
232-
test "batched_run drives DistilBERT-QA through Nx.Serving" do
232+
# `tag: :distilbert_full, lane_tags: false` keeps all three lanes gated
233+
# behind `:distilbert_full` (not the lightweight `:native`), so they run
234+
# under `--only distilbert_full` and never bloat `--only native`. The
235+
# forward is driven through `Nx.Serving`'s `:defn_options`, which is
236+
# where the compiler lanes plug in.
237+
mode_test "batched_run drives DistilBERT-QA through Nx.Serving",
238+
tag: :distilbert_full,
239+
lane_tags: false do
233240
{:ok, model_info} =
234241
Bumblebee.load_model({:hf, "distilbert-base-uncased-distilled-squad"})
235242

236243
{:ok, tokenizer} =
237244
Bumblebee.load_tokenizer({:hf, "distilbert-base-uncased-distilled-squad"})
238245

239-
serving = Bumblebee.Text.question_answering(model_info, tokenizer)
246+
serving =
247+
Bumblebee.Text.question_answering(model_info, tokenizer, defn_options: predict_opts)
240248

241249
start_supervised!({Nx.Serving, serving: serving, name: __MODULE__.Serving})
242250

test/emily/opcode_parity_test.exs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule Emily.OpcodeParityTest do
2+
@moduledoc """
3+
Guards the one hand-maintained lockstep in the native compiler: the
4+
opcode wire values live in **two** places that must agree —
5+
`Emily.IR`'s `@opcodes` map (Elixir) and the `Opcode` enum +
6+
`kOpcodeCount` in `c_src/emily/opcodes.hpp` (C++). Nothing in the build
7+
enforces it: a mismatch compiles fine and only misbehaves at runtime
8+
(an instruction dispatches to the wrong MLX op, or `valid_opcode`
9+
rejects a real one).
10+
11+
The check is value-based rather than name-based — both sides must be a
12+
contiguous `0..N-1` with N == `kOpcodeCount`. That catches every
13+
realistic drift (an opcode added to one side only, a forgotten
14+
`kOpcodeCount` bump, a duplicate or gapped number) without depending on
15+
the Elixir snake_case names matching the C++ PascalCase ones (they
16+
don't always: `negate`/`Negative`, `fast_rms_norm`/`FastRMSNorm`). A
17+
name/value *permutation* that keeps both contiguous would slip past
18+
here, but that produces wrong results and is caught by
19+
`Emily.CompilerEquivalenceTest`.
20+
"""
21+
use ExUnit.Case, async: true
22+
23+
alias Emily.IR
24+
25+
@header Path.expand("../../c_src/emily/opcodes.hpp", __DIR__)
26+
27+
# Parse `kOpcodeCount` and the `Opcode` enum's explicit `Name = N,`
28+
# values out of the C++ header.
29+
defp parse_header do
30+
src = File.read!(@header)
31+
32+
[_, count] = Regex.run(~r/kOpcodeCount\s*=\s*(\d+)/, src)
33+
count = String.to_integer(count)
34+
35+
[_, body] = Regex.run(~r/enum class Opcode\s*:\s*int64_t\s*\{(.*?)\};/s, src)
36+
37+
values =
38+
~r/^\s*[A-Za-z]\w*\s*=\s*(\d+)\s*,/m
39+
|> Regex.scan(body)
40+
|> Enum.map(fn [_, v] -> String.to_integer(v) end)
41+
42+
%{count: count, enum_values: values}
43+
end
44+
45+
test "C++ Opcode enum is contiguous 0..N-1 and matches kOpcodeCount" do
46+
%{count: count, enum_values: values} = parse_header()
47+
48+
assert length(values) == count,
49+
"opcodes.hpp has #{length(values)} enum entries but kOpcodeCount is #{count} — " <>
50+
"bump kOpcodeCount when adding an opcode"
51+
52+
assert Enum.sort(values) == Enum.to_list(0..(count - 1)),
53+
"Opcode enum values are not a unique, gap-free 0..#{count - 1}"
54+
end
55+
56+
test "Emily.IR @opcodes stays in lockstep with the C++ enum" do
57+
%{count: count} = parse_header()
58+
opcodes = IR.opcodes()
59+
60+
assert map_size(opcodes) == count,
61+
"Emily.IR has #{map_size(opcodes)} opcodes but c_src/emily/opcodes.hpp kOpcodeCount " <>
62+
"is #{count} — the two must be updated together"
63+
64+
assert opcodes |> Map.values() |> Enum.sort() == Enum.to_list(0..(count - 1)),
65+
"Emily.IR @opcodes values are not a unique, gap-free 0..#{count - 1}"
66+
end
67+
end

test/support/conformance_helper.ex

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,21 +96,27 @@ defmodule Emily.ConformanceHelper do
9696
suite's own `:*_full` moduletag; otherwise `--only native` would
9797
start pulling full-size checkpoints. `--only vit_full` then runs all
9898
three lanes of that suite.
99+
100+
* `:tag` — an extra tag stamped on *every* lane. Used by the
101+
`Nx.Serving` test, which lives in a `:conformance`-tagged module but
102+
must stay gated behind `:distilbert_full` like its eval lane:
103+
`tag: :distilbert_full, lane_tags: false`.
99104
"""
100105
defmacro mode_test(name, opts \\ [], do: body) do
101106
tag_lanes? = Keyword.get(opts, :lane_tags, true)
107+
extra_tag = Keyword.get(opts, :tag)
102108

103109
lanes = [
104-
lane(false, name, "", [], body),
110+
lane([extra_tag], name, "", [], body),
105111
lane(
106-
tag_lanes? && :native,
112+
[extra_tag, tag_lanes? && :native],
107113
name,
108114
" [native]",
109115
[compiler: Emily.Compiler, native: true, native_fallback: :raise],
110116
body
111117
),
112118
lane(
113-
tag_lanes? && :native_compiled,
119+
[extra_tag, tag_lanes? && :native_compiled],
114120
name,
115121
" [native_compiled]",
116122
[compiler: Emily.Compiler, native: true, native_fallback: :raise, native_compiled: true],
@@ -124,27 +130,25 @@ defmodule Emily.ConformanceHelper do
124130
end
125131

126132
# Build one `mode_test` lane: a `test` that binds `predict_opts` for the
127-
# body, optionally preceded by `@tag tag`. `tag` is `false` to emit no
128-
# lane tag (the `*_full` suites rely on their own `:*_full` moduletag).
129-
defp lane(tag, name, suffix, predict_opts, body) do
133+
# body, preceded by one `@tag` per entry in `tags` (nil/false entries are
134+
# dropped). The `*_full` suites pass `lane_tags: false` to drop the
135+
# `:native` / `:native_compiled` tags and rely on their own `:*_full`
136+
# moduletag (or an explicit `:tag`) instead.
137+
defp lane(tags, name, suffix, predict_opts, body) do
138+
tags = Enum.reject(tags, &(&1 in [nil, false]))
139+
130140
name_ast =
131141
if suffix == "", do: name, else: quote(do: unquote(name) <> unquote(suffix))
132142

133-
test =
134-
quote do
135-
test unquote(name_ast) do
136-
var!(predict_opts) = unquote(predict_opts)
137-
unquote(body)
138-
end
139-
end
143+
tag_attrs = for t <- tags, do: quote(do: @tag(unquote(t)))
144+
145+
quote do
146+
(unquote_splicing(tag_attrs))
140147

141-
if tag do
142-
quote do
143-
@tag unquote(tag)
144-
unquote(test)
148+
test unquote(name_ast) do
149+
var!(predict_opts) = unquote(predict_opts)
150+
unquote(body)
145151
end
146-
else
147-
test
148152
end
149153
end
150154

0 commit comments

Comments
 (0)