Skip to content

Commit c7b5a12

Browse files
authored
Merge pull request #160 from ausimian/feat/expr-compiler-cm12
feat: lower Nx.Random natively (bitcast, erf_inv, dynamic slice) (CM12)
2 parents 7bd30c0 + 2158e7b commit c7b5a12

4 files changed

Lines changed: 209 additions & 16 deletions

File tree

RELEASE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@
6161
opt-in `mx::compile` eval mode degrades to a sync replay for while-containing
6262
programs, which it can't trace.)
6363

64+
- **`Nx.Random` compiles native.** The PRNG surface (`split`, `uniform`,
65+
`normal`, `randint`, `gumbel`, `choice`) now lowers under the native
66+
compiler, so sampling-based generation runs on the single-NIF path and a
67+
PRNG key threads through a decode loop as ordinary carried state. This
68+
needed three primitives: `bitcast` (random bits → float), `erf_inv`
69+
(`normal`), and a **dynamic-start `slice`** — threefry indexes its rotation
70+
table by the loop counter, a genuine runtime-start slice (the eager backend
71+
materialises the index to a host int; the compiled replay threads it as a
72+
runtime `s32` instead, same result).
73+
6474
- **`Emily.Generation` — a model-agnostic decode-loop driver.** JIT-compiles a
6575
caller-supplied **shape-stable** per-token forward (`fn token, offset, cache,
6676
params -> {logits, cache} end`) with the native single-NIF compiler and drives

c_src/emily/opcodes.hpp

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,18 @@ enum class Opcode : int64_t {
133133
// `replay_program` (it needs the subprograms + multi-output), never via
134134
// `dispatch_op`.
135135
While = 71,
136+
// Reinterpret the bytes as another dtype (Nx.bitcast). operands [a];
137+
// iattrs [[dtype_code]]
138+
Bitcast = 72,
139+
// Inverse error function (for Nx.Random.normal). operands [a]
140+
ErfInv = 73,
141+
// Slice with runtime (dynamic) start indices, stride 1 (the threefry/RNG
142+
// path indexes by a loop counter). operands [a, start(s32 [naxes])];
143+
// iattrs [[axes...], [slice_sizes...]]
144+
DynSlice = 74,
136145
};
137146

138-
inline constexpr int64_t kOpcodeCount = 72;
147+
inline constexpr int64_t kOpcodeCount = 75;
139148

140149
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
141150
inline std::string qmode_from_code(int64_t code) {
@@ -541,6 +550,21 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
541550
// Multi-output + carries subprograms; handled directly in
542551
// replay_program, never dispatched here.
543552
throw std::invalid_argument("while is handled in replay_program");
553+
case Opcode::Bitcast:
554+
return mx::view(arg1(in, "bitcast"),
555+
emily::to_mlx_dtype_code(scalar_at(iattrs, 0, "bitcast")), s);
556+
case Opcode::ErfInv:
557+
return mx::erfinv(arg1(in, "erf_inv"), s);
558+
case Opcode::DynSlice:
559+
// operands [a, start(s32 [naxes])]; iattrs [[axes...], [slice_sizes...]].
560+
// Stride-1 dynamic slice (mx::slice's dynamic-start overload).
561+
if (in.size() != 2) {
562+
throw std::invalid_argument("dyn_slice expects 2 operands, got " +
563+
std::to_string(in.size()));
564+
}
565+
return mx::slice(in[0], in[1],
566+
emily::to_int_vec(attr_at(iattrs, 0, "dyn_slice")),
567+
emily::to_mlx_shape(attr_at(iattrs, 1, "dyn_slice")), s);
544568
}
545569
throw std::invalid_argument("unknown opcode " +
546570
std::to_string(static_cast<int64_t>(op)));

lib/emily/ir.ex

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ defmodule Emily.IR do
120120
flip: 70,
121121
# control flow: operands = initial loop-carried state; iattrs [[arity]];
122122
# subprograms [condition, body]. Multi-output (produces `arity` values).
123-
while: 71
123+
while: 71,
124+
# RNG / dynamic indexing primitives
125+
bitcast: 72,
126+
erf_inv: 73,
127+
dyn_slice: 74
124128
}
125129

126130
# Quant mode string -> code; decoded by qmode_from_code in
@@ -256,7 +260,8 @@ defmodule Emily.IR do
256260
sigmoid: :sigmoid,
257261
floor: :floor,
258262
ceil: :ceil,
259-
erf: :erf
263+
erf: :erf,
264+
erf_inv: :erf_inv
260265
}
261266

262267
@doc """
@@ -358,6 +363,13 @@ defmodule Emily.IR do
358363
emit(state, :astype, [ra], [[dtype_code(t.type)]])
359364
end
360365

366+
# bitcast: reinterpret the bytes as out.type (mirrors Emily.Backend.bitcast/2,
367+
# which calls mx::view). Used by the RNG path to turn random bits into floats.
368+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :bitcast, args: [a]}} = t, state) do
369+
{ra, state} = lower_node(a, state)
370+
emit(state, :bitcast, [ra], [[dtype_code(t.type)]])
371+
end
372+
361373
defp lower_op(%T{data: %Nx.Defn.Expr{op: :reshape, args: [a]}} = t, state) do
362374
{ra, state} = lower_node(a, state)
363375
emit(state, :reshape, [ra], [Tuple.to_list(t.shape)])
@@ -510,24 +522,50 @@ defmodule Emily.IR do
510522
coerce(r, t.type, state)
511523
end
512524

513-
# slice(t, starts, lengths, strides): static integer starts only. Nx
514-
# passes scalar-tensor starts for dynamic slicing; those depend on
515-
# runtime values and are deferred (the decode offset becomes a runtime
516-
# input in CM3). Stops = starts + lengths (see Emily.Backend.slice/5).
525+
# slice(t, starts, lengths, strides). Nx passes starts as integers (static
526+
# slice) or scalar tensors (dynamic slice — e.g. threefry indexing a
527+
# rotation table by the loop counter). Static starts -> mx::slice with
528+
# integer bounds. Dynamic starts -> mx::slice's dynamic-start overload via
529+
# the `dyn_slice` opcode (stride 1 only; the eager backend materialises the
530+
# start to a host int, but the compiled replay can't, so it threads the
531+
# start as a runtime s32 array).
517532
defp lower_op(
518533
%T{data: %Nx.Defn.Expr{op: :slice, args: [a, starts, lengths, strides]}} = t,
519534
state
520535
) do
521-
unless Enum.all?(starts, &is_integer/1) do
522-
raise ArgumentError,
523-
"Emily Expr compiler: dynamic (tensor) slice start indices are not yet " <>
524-
"supported (they require a runtime input). Got: #{inspect(starts)}"
525-
end
526-
527536
{ra, state} = lower_node(a, state)
528-
stops = Enum.zip_with(starts, lengths, fn st, l -> st + l end)
529-
{r, state} = emit(state, :slice, [ra], [starts, stops, strides])
530-
coerce(r, t.type, state)
537+
538+
if Enum.all?(starts, &is_integer/1) do
539+
stops = Enum.zip_with(starts, lengths, fn st, l -> st + l end)
540+
{r, state} = emit(state, :slice, [ra], [starts, stops, strides])
541+
coerce(r, t.type, state)
542+
else
543+
unless Enum.all?(strides, &(&1 == 1)) do
544+
raise ArgumentError,
545+
"Emily Expr compiler: dynamic (tensor) slice start indices are only " <>
546+
"supported with unit strides. Got strides: #{inspect(strides)}"
547+
end
548+
549+
# Build the [ndim] s32 start array from the mixed int / scalar-tensor
550+
# starts (same machinery as the dynamic put_slice write). Each runtime
551+
# start is clamped to `[0, dim - length]` — MLX's dynamic slice reads
552+
# out of bounds, whereas Nx (XLA semantics) clamps the start so the
553+
# window stays in range; clamp is a no-op for the in-bounds starts the
554+
# threefry/RNG path produces.
555+
dims = Tuple.to_list(a.shape)
556+
557+
{start_refs, state} =
558+
[starts, lengths, dims]
559+
|> Enum.zip()
560+
|> Enum.map_reduce(state, fn {start, length, dim}, state ->
561+
{r, state} = lower_start_index(start, state)
562+
clamp_start(r, dim - length, state)
563+
end)
564+
565+
{start_arr, state} = emit(state, :concatenate, start_refs, [[0]])
566+
axes = Enum.to_list(0..(length(starts) - 1)//1)
567+
emit_coerced(state, :dyn_slice, [ra, start_arr], [axes, lengths], t.type)
568+
end
531569
end
532570

533571
# put_slice(src, start_indices, slice): write `slice` into `src` at
@@ -945,4 +983,28 @@ defmodule Emily.IR do
945983
{r, state} = emit(state, :astype, [r], [[dtype_code({:s, 32})]])
946984
emit(state, :reshape, [r], [[1]])
947985
end
986+
987+
# Clamp a runtime s32 `[1]` dynamic-slice start to `[0, hi]`
988+
# (hi = dim - length), matching Nx/XLA dynamic-slice semantics — MLX's
989+
# dynamic slice would otherwise read out of bounds. Both bounds are static.
990+
defp clamp_start(ref, hi, state) do
991+
{lo_c, state} =
992+
materialize_const(
993+
Nx.tensor([0], type: :s32, backend: Nx.BinaryBackend),
994+
{1},
995+
{:s, 32},
996+
state
997+
)
998+
999+
{hi_c, state} =
1000+
materialize_const(
1001+
Nx.tensor([hi], type: :s32, backend: Nx.BinaryBackend),
1002+
{1},
1003+
{:s, 32},
1004+
state
1005+
)
1006+
1007+
{r, state} = emit(state, :maximum, [ref, lo_c])
1008+
emit(state, :minimum, [r, hi_c])
1009+
end
9481010
end

test/emily/compiler_rng_test.exs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
defmodule Emily.CompilerRngTest do
2+
@moduledoc """
3+
CM12 — `Nx.Random` lowers under the native compiler. threefry2x32 is pure
4+
tensor ops (bitwise/shift/reshape/concat) plus a `while` whose body indexes
5+
a rotation table by the loop counter — a *dynamic* (runtime-start) slice —
6+
and `uniform`/`gumbel` turn random bits into floats via `bitcast`. With
7+
those wired (CM12), the whole RNG surface compiles, and a PRNG key threads
8+
through a generation-style loop as ordinary carried state.
9+
10+
Determinism makes the evaluator the oracle: for a fixed seed the native
11+
single-NIF path must produce bit-identical draws. Run under
12+
`native_fallback: :raise` so any un-lowered op fails rather than passing.
13+
"""
14+
use ExUnit.Case, async: true
15+
import Nx.Defn
16+
17+
@native [compiler: Emily.Compiler, native: true, native_fallback: :raise]
18+
@eval [compiler: Emily.Compiler]
19+
20+
defp key(seed \\ 7), do: Nx.Random.key(seed) |> Nx.backend_copy(Emily.Backend)
21+
22+
defp equiv(fun, args) do
23+
native = apply(Nx.Defn.jit(fun, @native), args)
24+
eval = apply(Nx.Defn.jit(fun, @eval), args)
25+
assert Nx.to_binary(native) == Nx.to_binary(eval)
26+
native
27+
end
28+
29+
# A PRNG key threaded through a decode-style loop: each step splits the key,
30+
# draws a uniform with one half, keeps the other, and accumulates. Nests
31+
# threefry's own `while` (and its dynamic rotation-table slice) inside the
32+
# outer loop body.
33+
defn sample_loop(key) do
34+
{_i, _key, acc} =
35+
while {i = 0, key = key, acc = Nx.tensor(0.0, type: :f32)}, i < 5 do
36+
keys = Nx.Random.split(key)
37+
{u, _} = Nx.Random.uniform(keys[1])
38+
{i + 1, keys[0], acc + u}
39+
end
40+
41+
acc
42+
end
43+
44+
describe "Nx.Random primitives: native == evaluator (fixed seed)" do
45+
test "uniform", do: equiv(fn k -> Nx.Random.uniform(k, shape: {8}) |> elem(0) end, [key()])
46+
test "normal", do: equiv(fn k -> Nx.Random.normal(k, shape: {8}) |> elem(0) end, [key()])
47+
48+
test "randint",
49+
do: equiv(fn k -> Nx.Random.randint(k, 0, 100, shape: {8}) |> elem(0) end, [key()])
50+
51+
test "gumbel", do: equiv(fn k -> Nx.Random.gumbel(k, shape: {8}) |> elem(0) end, [key()])
52+
test "split", do: equiv(fn k -> Nx.Random.split(k, parts: 4) end, [key()])
53+
end
54+
55+
describe "dynamic-start slice (the threefry enabler)" do
56+
test "slice with a runtime start index matches the evaluator" do
57+
t = Nx.tensor([10.0, 20.0, 30.0, 40.0, 50.0], backend: Emily.Backend)
58+
start = Nx.tensor(1, type: :s32, backend: Emily.Backend)
59+
out = equiv(fn t, s -> Nx.slice(t, [s], [3]) end, [t, start])
60+
assert Nx.to_flat_list(out) == [20.0, 30.0, 40.0]
61+
end
62+
63+
test "bitcast (random bits -> float) matches the evaluator" do
64+
bits = Nx.tensor([0, 1_065_353_216, 1_073_741_824], type: :u32, backend: Emily.Backend)
65+
equiv(fn b -> Nx.bitcast(b, :f32) end, [bits])
66+
end
67+
68+
test "out-of-bounds runtime start clamps to [0, dim - length] (Nx semantics)" do
69+
# Oracle is Nx (BinaryBackend), which clamps the start to dim-length;
70+
# MLX's dynamic slice would read out of bounds without the clamp.
71+
data = [10.0, 20.0, 30.0, 40.0, 50.0]
72+
fun = fn t, s -> Nx.slice(t, [s], [3]) end
73+
74+
native =
75+
Nx.Defn.jit(fun, @native).(
76+
Nx.tensor(data, backend: Emily.Backend),
77+
Nx.tensor(4, type: :s32, backend: Emily.Backend)
78+
)
79+
80+
canonical = Nx.slice(Nx.tensor(data), [Nx.tensor(4)], [3])
81+
assert Nx.to_flat_list(native) == Nx.to_flat_list(canonical)
82+
assert Nx.to_flat_list(native) == [30.0, 40.0, 50.0]
83+
end
84+
end
85+
86+
describe "key-splitting sampling while" do
87+
test "native matches the evaluator bit-for-bit" do
88+
equiv(&sample_loop/1, [key()])
89+
end
90+
91+
test "draws actually depend on the seed (real RNG, not a constant)" do
92+
a = Nx.Defn.jit(&sample_loop/1, @native).(key(1))
93+
b = Nx.Defn.jit(&sample_loop/1, @native).(key(2))
94+
refute Nx.to_binary(a) == Nx.to_binary(b)
95+
end
96+
end
97+
end

0 commit comments

Comments
 (0)