Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion python/src/random.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

#include "mlx/ops.h"
#include "mlx/random.h"
#include "python/src/random.h"
#include "python/src/small_vector.h"
#include "python/src/utils.h"

Expand Down Expand Up @@ -63,15 +64,54 @@ PyKeySequence& default_key() {
return ks;
}

// A process-global sentinel for `mx.random.state`. Since it is the same object
// on every thread, capturing it (e.g. with `mx.compile`) is thread-independent;
// the pytree traversal in trees.cpp resolves it to the calling thread's key.
class RandomState {};

nb::object random_state_sentinel() {
static nb::object sentinel = []() {
auto sentinel = nb::cast(RandomState{});
sentinel.inc_ref();
return sentinel;
}();

return sentinel;
}

mx::array random_state_key() {
return nb::cast<mx::array>(default_key().state()[0]);
}

void set_random_state_key(const mx::array& key) {
default_key().state()[0] = nb::cast(key);
}

void init_random(nb::module_& parent_module) {
auto m = parent_module.def_submodule(
"random",
"mlx.core.random: functionality related to random number generation");

nb::class_<RandomState>(m, "_RandomState")
.def("__len__", [](const RandomState&) { return 1; })
.def(
"__getitem__",
[](const RandomState&, int i) -> nb::object {
if (i != 0 && i != -1) {
throw nb::index_error("random state index out of range");
}
return default_key().state()[0];
},
"index"_a)
.def("__iter__", [](const RandomState&) {
return nb::iter(default_key().state());
});

m.def("__getattr__", [&](nb::handle key) -> nb::object {
// Create random.state lazily to avoid initializing device during import.
if (nb::isinstance<nb::str>(key) && nb::cast<std::string>(key) == "state") {
return default_key().state();
default_key().state();
return random_state_sentinel();
}
return nb::steal(PyErr_Format(
PyExc_AttributeError,
Expand Down
17 changes: 17 additions & 0 deletions python/src/random.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright © 2026 Apple Inc.

#pragma once

#include <nanobind/nanobind.h>

#include "mlx/array.h"

namespace mx = mlx::core;
namespace nb = nanobind;

// The process-global `mx.random.state` sentinel.
nb::object random_state_sentinel();

// Read/write the calling thread's current PRNG key.
mx::array random_state_key();
void set_random_state_key(const mx::array& key);
27 changes: 18 additions & 9 deletions python/src/trees.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright © 2023-2024 Apple Inc.

#include "python/src/trees.h"
#include "python/src/random.h"

template <typename T, typename U, typename V>
void validate_subtrees(const std::vector<nb::object>& subtrees) {
Expand Down Expand Up @@ -152,8 +153,12 @@ void tree_visit(

void tree_visit(nb::handle tree, std::function<void(nb::handle)> visitor) {
std::function<void(nb::handle)> recurse;
auto random_state = random_state_sentinel();
recurse = [&](nb::handle subtree) {
if (nb::isinstance<nb::list>(subtree) ||
if (subtree.is(random_state)) {
visitor(nb::cast(random_state_key()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can do this in a less intrusive way by replacing

// Replace tracers with originals in captured inputs
if (!captured_inputs.is_none()) {
tree_replace(captured_inputs, trace_captures, flat_in_captures);
}

with

if (!captured_inputs.is_none()) { 
  trace_captures.push_back(random_state_sentinel());
  flat_in_captures.push_back(random_state_key());
  tree_replace(captured_inputs, trace_captures, flat_in_captures);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is the random state can be in any place in the captured inputs. As a result we have two options

  1. Walk the captured inputs and replace the random state with the thread local one before flattening
  2. Do it when the random state is encountered while flattening and while writing back

Option 1 has the overhead of 2 extra tree walks which for say 100k nodes (like a big transformer or sth) can be non-negligible.

Option 2 is intrusive and ugly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, we can probably add a callback to tree_flatten to make it cheap to do replacement but it feels over-engineered and still does not fix the hook in tree_visit_update, I'm good with current approach and I can't come with a better one.

} else if (
nb::isinstance<nb::list>(subtree) ||
nb::isinstance<nb::tuple>(subtree)) {
for (auto item : subtree) {
recurse(item);
Expand All @@ -174,8 +179,14 @@ void tree_visit_update(
nb::object tree,
std::function<nb::object(nb::handle)> visitor) {
std::function<nb::object(nb::handle)> recurse;
auto random_state = random_state_sentinel();
recurse = [&](nb::handle subtree) {
if (nb::isinstance<nb::list>(subtree)) {
if (subtree.is(random_state)) {
// Read/write the calling thread's key; keep the sentinel in the tree.
set_random_state_key(
nb::cast<mx::array>(visitor(nb::cast(random_state_key()))));
return nb::cast<nb::object>(subtree);
} else if (nb::isinstance<nb::list>(subtree)) {
auto l = nb::cast<nb::list>(subtree);
for (int i = 0; i < l.size(); ++i) {
l[i] = recurse(l[i]);
Expand Down Expand Up @@ -262,14 +273,12 @@ nb::object tree_unflatten(
}

nb::object structure_sentinel() {
static nb::object sentinel;

if (sentinel.ptr() == nullptr) {
sentinel = nb::capsule(&sentinel);
// probably not needed but this should make certain that we won't ever
// delete the sentinel
static nb::object sentinel = []() {
PyObject* raw_obj = PyObject_New(PyObject, &PyBaseObject_Type);
nb::object sentinel = nb::steal(raw_obj);
sentinel.inc_ref();
}
return sentinel;
}();

return sentinel;
}
Expand Down
68 changes: 68 additions & 0 deletions python/tests/test_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,74 @@ def fun():

self.assertFalse(mx.allclose(fun(), fun(), 1e-2, 1e-2))

def test_compile_rng_across_threads(self):
# A function compiled with inputs/outputs=mx.random.state on one thread
# must still use (and advance/seed) the calling thread's RNG state when
# invoked from another thread, whether captured directly or nested.

# The state sentinel is a single global object shared across threads.
state_from_thread = {}

def grab():
state_from_thread["s"] = mx.random.state

t = threading.Thread(target=grab)
t.start()
t.join()
self.assertIs(mx.random.state, state_from_thread["s"])

direct = partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)(
lambda: mx.random.uniform(shape=(10, 10))
)

nested_state = [{"unused": mx.array(0.0)}, mx.random.state]
nested = partial(mx.compile, inputs=nested_state, outputs=nested_state)(
lambda: mx.random.uniform(shape=(10, 10))
)

for fun in (direct, nested):
results = {}

def worker():
with mx.stream(mx.cpu):
a = fun()
b = fun()
results["advances"] = not bool(mx.allclose(a, b, 1e-2, 1e-2).item())
mx.random.seed(42)
c = fun()
mx.random.seed(42)
d = fun()
results["seed_reproducible"] = bool(mx.allclose(c, d).item())
mx.random.seed(1234)
e = fun()
results["seed_changes"] = not bool(
mx.allclose(c, e, 1e-2, 1e-2).item()
)

t = threading.Thread(target=worker)
t.start()
t.join()

self.assertTrue(results["advances"])
self.assertTrue(results["seed_reproducible"])
self.assertTrue(results["seed_changes"])

def test_compile_state_capture_with_rng_updates_in_place(self):
# Capturing mx.random.state alongside other state via outputs= must not
# break in-place updates of the other captured containers.
counter = {"v": mx.array(0.0)}
state = [counter, mx.random.state]

@partial(mx.compile, inputs=state, outputs=state)
def step():
counter["v"] = counter["v"] + 1.0
return mx.random.uniform(shape=(2,))

for _ in range(3):
step()
mx.eval(counter["v"])
self.assertEqual(counter["v"].item(), 3.0)

def test_compile_kwargs(self):
@mx.compile
def fun(x, y, z):
Expand Down
Loading