Skip to content

Commit 7dcfef7

Browse files
committed
feat: lower the FFT op family in the native Expr compiler
Nx.fft / ifft (1-D, trailing axis) and the fft2 / ifft2 / rfft / irfft blocks now lower under `compiler: Emily.Compiler, native: true` instead of forcing a graceful fallback to the evaluator. The MLX ops and eager NIFs already existed (Native.{fftn,ifftn,rfftn,irfftn} -> mlx::core::fft::*); only the compiler path was missing. - opcodes.hpp: add Fftn/Ifftn/Rfftn/Irfftn (88-91), bump kOpcodeCount to 92, dispatch each to the matching mx::fft::* with FFTNorm::Backward (unnormalized), mirroring the eager fft.cpp entry points. - ir.ex: add the opcodes; lower :fft/:ifft (lower_op) on the trailing axis (mirroring Emily.Backend.{fft,ifft}/3, which ignore opts[:axis]); lower the Nx.Block.{FFT2,IFFT2,RFFT,IRFFT} blocks (lower_block) with the block's sizes/axes. Output coerced to out.type (complex64 for the forward transforms, real for irfft) like the backend wrap. - compiler_equivalence_test.exs: native-vs-evaluator bit-identical cases for fft/ifft (+ explicit length, batched), fft2/ifft2, and rfft/irfft. This was the op forcing a fallback in a Whisper speech_to_text serving (the log-mel featurizer's STFT), so that path now compiles fully native.
1 parent 26faf73 commit 7dcfef7

4 files changed

Lines changed: 125 additions & 3 deletions

File tree

RELEASE.md

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

61+
- **The FFT family lowers natively**`Nx.fft`/`ifft` (1-D, trailing axis)
62+
and the `fft2`/`ifft2`/`rfft`/`irfft` blocks now compile under the native
63+
single-NIF path instead of falling back. Each mirrors its eager
64+
`Emily.Backend` wrapper, routing to the same `mlx::core::fft::*` kernel
65+
(unnormalized `FFTNorm::Backward`) bit-for-bit, including the complex64
66+
outputs. This was the op forcing a graceful fallback in a Whisper
67+
`speech_to_text` serving — the log-mel featurizer's STFT — so that path
68+
now compiles fully native too.
69+
6170
- **Window (pooling) ops lower natively — forward and backward.** The
6271
forward window family (`window_sum`/`window_max`/`window_min`/
6372
`window_product`, i.e. average and max pooling), the select-and-scatter

c_src/emily/opcodes.hpp

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,16 @@ enum class Opcode : int64_t {
168168
// [[window...],[strides...],[pad_lo...],[pad_hi...]] (no dilations)
169169
WindowScatterMax = 86,
170170
WindowScatterMin = 87,
171+
// FFT family — n-dimensional transforms over the given sizes/axes, with
172+
// FFTNorm::Backward (unnormalized), matching Nx + the eager fft NIFs.
173+
// operands [input]; iattrs [[sizes...], [axes...]].
174+
Fftn = 88, // complex/real -> complex
175+
Ifftn = 89, // complex -> complex (inverse)
176+
Rfftn = 90, // real -> complex (half spectrum)
177+
Irfftn = 91, // complex half-spectrum -> real
171178
};
172179

173-
inline constexpr int64_t kOpcodeCount = 88;
180+
inline constexpr int64_t kOpcodeCount = 92;
174181

175182
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
176183
inline std::string qmode_from_code(int64_t code) {
@@ -663,6 +670,27 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
663670
}
664671
return mx::stack(in, emily::checked_int(scalar_at(iattrs, 0, "stack"), "axis"),
665672
s);
673+
// --- FFT family (shares the eager fft.cpp entry points) ---
674+
case Opcode::Fftn:
675+
return mx::fft::fftn(arg1(in, "fftn"),
676+
emily::to_mlx_shape(attr_at(iattrs, 0, "fftn")),
677+
emily::to_int_vec(attr_at(iattrs, 1, "fftn")),
678+
mx::fft::FFTNorm::Backward, s);
679+
case Opcode::Ifftn:
680+
return mx::fft::ifftn(arg1(in, "ifftn"),
681+
emily::to_mlx_shape(attr_at(iattrs, 0, "ifftn")),
682+
emily::to_int_vec(attr_at(iattrs, 1, "ifftn")),
683+
mx::fft::FFTNorm::Backward, s);
684+
case Opcode::Rfftn:
685+
return mx::fft::rfftn(arg1(in, "rfftn"),
686+
emily::to_mlx_shape(attr_at(iattrs, 0, "rfftn")),
687+
emily::to_int_vec(attr_at(iattrs, 1, "rfftn")),
688+
mx::fft::FFTNorm::Backward, s);
689+
case Opcode::Irfftn:
690+
return mx::fft::irfftn(arg1(in, "irfftn"),
691+
emily::to_mlx_shape(attr_at(iattrs, 0, "irfftn")),
692+
emily::to_int_vec(attr_at(iattrs, 1, "irfftn")),
693+
mx::fft::FFTNorm::Backward, s);
666694
}
667695
throw std::invalid_argument("unknown opcode " +
668696
std::to_string(static_cast<int64_t>(op)));

lib/emily/ir.ex

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ defmodule Emily.IR do
145145
# window select-and-scatter (pooling backward). operands
146146
# [input, source, init]; iattrs [[window],[strides],[pad_lo],[pad_hi]].
147147
window_scatter_max: 86,
148-
window_scatter_min: 87
148+
window_scatter_min: 87,
149+
# FFT family — n-D transforms. operands [input]; iattrs
150+
# [[sizes...],[axes...]]. `fft`/`ifft` (1-D, last axis) and the
151+
# `fft2`/`ifft2`/`rfft`/`irfft` blocks all route here. Unnormalized
152+
# (`FFTNorm::Backward`) is baked C++-side, matching Nx / the eager NIFs.
153+
fftn: 88,
154+
ifftn: 89,
155+
rfftn: 90,
156+
irfftn: 91
149157
}
150158

151159
# Quant mode string -> code; decoded by qmode_from_code in
@@ -498,6 +506,19 @@ defmodule Emily.IR do
498506
coerce(r, t.type, state)
499507
end
500508

509+
# 1-D FFT / inverse FFT (Nx.fft / Nx.ifft). Mirrors Emily.Backend.{fft,
510+
# ifft}/3: route through the n-D MLX kernel restricted to one axis. The
511+
# eager path uses the trailing axis and ignores `opts[:axis]`, so we do
512+
# too — keeping native bit-identical to the evaluator. Output is complex
513+
# (`Nx.Type.to_complex/1`); the trailing coerce matches the backend `wrap`.
514+
defp lower_op(%T{data: %Nx.Defn.Expr{op: op, args: [a, opts]}} = t, state)
515+
when op in [:fft, :ifft] do
516+
{ra, state} = lower_node(a, state)
517+
axis = tuple_size(a.shape) - 1
518+
opcode = if op == :fft, do: :fftn, else: :ifftn
519+
emit_coerced(state, opcode, [ra], [[opts[:length]], [axis]], t.type)
520+
end
521+
501522
# Non-batched dot -> tensordot over the contraction axes.
502523
defp lower_op(%T{data: %Nx.Defn.Expr{op: :dot, args: [a, ca, [], b, cb, []]}} = t, state) do
503524
{ra, state} = lower_node(a, state)
@@ -1012,10 +1033,35 @@ defmodule Emily.IR do
10121033
end
10131034
end
10141035

1036+
# FFT family blocks (Nx.fft2 / ifft2 / rfft / irfft). Each mirrors the
1037+
# matching Emily.Backend.native_* wrapper: route through the n-D MLX
1038+
# fft/ifft/rfft/irfft kernel with the block's sizes + axes, then coerce to
1039+
# out.type (complex for the forward transforms, real for irfft). The
1040+
# block's `eps` is unused (MLX needs none), as in the eager path.
1041+
defp lower_block(%Nx.Block.FFT2{lengths: lengths, axes: axes}, [t], _expr, out, state) do
1042+
{rt, state} = lower_node(t, state)
1043+
emit_coerced(state, :fftn, [rt], [lengths, axes], out.type)
1044+
end
1045+
1046+
defp lower_block(%Nx.Block.IFFT2{lengths: lengths, axes: axes}, [t], _expr, out, state) do
1047+
{rt, state} = lower_node(t, state)
1048+
emit_coerced(state, :ifftn, [rt], [lengths, axes], out.type)
1049+
end
1050+
1051+
defp lower_block(%Nx.Block.RFFT{length: length, axis: axis}, [t], _expr, out, state) do
1052+
{rt, state} = lower_node(t, state)
1053+
emit_coerced(state, :rfftn, [rt], [[length], [axis]], out.type)
1054+
end
1055+
1056+
defp lower_block(%Nx.Block.IRFFT{length: length, axis: axis}, [t], _expr, out, state) do
1057+
{rt, state} = lower_node(t, state)
1058+
emit_coerced(state, :irfftn, [rt], [[length], [axis]], out.type)
1059+
end
1060+
10151061
# Any other block struct raises. Lowering the block's composed
10161062
# expansion would silently diverge from the Evaluator whenever
10171063
# Emily.Backend.block/4 dispatches that struct through a fused / native
1018-
# kernel (e.g. SDPAWithSinks, the Nx.Block.LinAlg.* / FFT families) — a
1064+
# kernel (e.g. SDPAWithSinks, the Nx.Block.LinAlg.* families) — a
10191065
# worse failure than a clear "unsupported".
10201066
# Additional fused blocks are added alongside their opcode.
10211067
defp lower_block(struct, _in_args, _expr, _t, _state) do

test/emily/compiler_equivalence_test.exs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,45 @@ defmodule Emily.CompilerEquivalenceTest do
476476
end
477477
end
478478

479+
describe "fft family (signal transforms)" do
480+
test "1-D fft / ifft on the trailing axis match the evaluator" do
481+
x = et([1.0, 2.0, 3.0, 4.0])
482+
assert_equiv(fn t -> Nx.fft(t) end, [x])
483+
assert_equiv(fn t -> Nx.ifft(t) end, [x])
484+
end
485+
486+
test "fft with explicit length (zero-pad / truncate) matches" do
487+
x = et([1.0, 2.0, 3.0, 4.0, 5.0])
488+
assert_equiv(fn t -> Nx.fft(t, length: 8) end, [x])
489+
assert_equiv(fn t -> Nx.fft(t, length: 4) end, [x])
490+
end
491+
492+
test "batched 1-D fft transforms the last axis only" do
493+
x = et([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]])
494+
assert_equiv(fn t -> Nx.fft(t) end, [x])
495+
end
496+
497+
test "ifft(fft(x)) round-trips through a complex intermediate" do
498+
x = et([1.0, -2.0, 3.0, -4.0])
499+
out = assert_equiv(fn t -> Nx.ifft(Nx.fft(t)) end, [x])
500+
assert out.type == {:c, 64}
501+
end
502+
503+
test "2-D fft2 / ifft2 (Nx.Block) match" do
504+
x = et([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]])
505+
assert_equiv(fn t -> Nx.fft2(t) end, [x])
506+
assert_equiv(fn t -> Nx.ifft2(t) end, [x])
507+
end
508+
509+
test "rfft (real -> half spectrum) and irfft (back to real) match" do
510+
x = et([1.0, 2.0, 3.0, 4.0])
511+
out = assert_equiv(fn t -> Nx.rfft(t) end, [x])
512+
assert out.type == {:c, 64}
513+
# irfft takes the complex half-spectrum back to a real signal.
514+
assert_equiv(fn t -> Nx.irfft(Nx.rfft(t)) end, [x])
515+
end
516+
end
517+
479518
describe "dynamic put_slice (KV-cache write)" do
480519
test "put_slice at a runtime offset matches the Evaluator" do
481520
# {batch, n_kv_heads, max_len, head_dim} KV buffer; write one token.

0 commit comments

Comments
 (0)