Skip to content

Commit 5df28c9

Browse files
committed
Thread PRNG key through wrap_jax_jit to prevent UnexpectedTracerError
Random aten ops (dropout, bernoulli, randn, …) call env.get_and_rotate_prng_key() which mutates RuntimeProperty.prng to a DynamicJaxprTracer while jit-tracing. The tracer escapes the trace and contaminates env.prng_key; subsequent reads return a tracer instead of a usable key, and any later jax.jit invocation that re-traces and references the leaked tracer raises jax.errors.UnexpectedTracerError. This fixes the leak by threading the PRNG key through the jit boundary as the last positional input and trailing output. Random ops rotate on a scoped RuntimeProperty (popped on trace exit), the rotated key is returned as a jit output, and the env is refreshed outside jit so subsequent calls see the advanced key. The fix is transparent to callers — wrap_jax_jit's signature is unchanged. Also adds a prng_key setter (the only mutation path was previously manual_seed) so wrap_jax_jit can refresh the env after each jit call. Closes #17. Signed-off-by: Fei Ding <caffeding@gmail.com>
1 parent 7605a49 commit 5df28c9

3 files changed

Lines changed: 79 additions & 2 deletions

File tree

test/test_interop.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,5 +179,54 @@ def test_torch_jax_view_dtype(self):
179179
self.assertEqual(interop.torch_view(interop.jax_view(dtype)), dtype)
180180

181181

182+
class JaxJitPRNGThreadingTest(unittest.TestCase):
183+
"""Regression tests for PRNG-key threading through `wrap_jax_jit`. Closes #17."""
184+
185+
def setUp(self):
186+
torchax.enable_globally()
187+
self.env = torchax.default_env()
188+
dropout = torch.nn.Dropout(p=0.5).train()
189+
self.jitted = interop.jax_jit(lambda x: dropout(x))
190+
self.x = torch.ones(64, device="jax")
191+
192+
def test_random_op_does_not_leak_tracer(self):
193+
"""One jit-wrapped call with a random op must not leak a tracer into env.prng_key."""
194+
self.jitted(self.x)
195+
self.assertFalse(
196+
isinstance(self.env.prng_key, jax.core.Tracer),
197+
f"env.prng_key leaked a {type(self.env.prng_key).__name__}",
198+
)
199+
200+
def test_consecutive_calls_advance_prng(self):
201+
"""Two same-input calls must differ — the threaded prng key advances."""
202+
a = self.jitted(self.x)
203+
b = self.jitted(self.x)
204+
self.assertFalse(torch.allclose(a, b), "PRNG key not rotating between calls")
205+
206+
def test_setter_symmetric_with_getter(self):
207+
"""`env.prng_key = key` round-trips through the getter."""
208+
new_key = jax.random.PRNGKey(0xDEADBEEF)
209+
self.env.prng_key = new_key
210+
self.assertTrue(
211+
jax.numpy.array_equal(
212+
jax.random.key_data(self.env.prng_key), jax.random.key_data(new_key)
213+
)
214+
)
215+
216+
def test_manual_seed_controls_output(self):
217+
"""Same seed → same outputs; different seed → different; successive calls advance."""
218+
self.env.manual_seed(42)
219+
a1, a2 = self.jitted(self.x), self.jitted(self.x)
220+
self.env.manual_seed(42)
221+
b1, b2 = self.jitted(self.x), self.jitted(self.x)
222+
self.env.manual_seed(99)
223+
c1 = self.jitted(self.x)
224+
225+
torch.testing.assert_close(a1, b1)
226+
torch.testing.assert_close(a2, b2)
227+
self.assertFalse(torch.allclose(a1, c1), "different seeds produced same output")
228+
self.assertFalse(torch.allclose(a1, a2), "PRNG did not advance within seed scope")
229+
230+
182231
if __name__ == "__main__":
183232
unittest.main()

torchax/interop.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,32 @@ def backward(ctx, *grad_out):
384384

385385

386386
def wrap_jax_jit(torch_function, jax_jit_func=jax.jit, kwargs_for_jax=None):
387+
"""Jit a torch function via JAX, threading the PRNG key through the jit
388+
boundary so random ops (dropout, bernoulli, …) don't leak tracers into
389+
`env.prng_key`. The key is appended as the last positional input/output
390+
so positional `donate_argnums` stays valid. Callers passing
391+
`in_shardings`/`out_shardings`/`in_specs`/`out_specs` must extend them
392+
by one replicated entry for the prng leaf.
393+
"""
387394
kwargs_for_jax = kwargs_for_jax or {}
395+
env = torchax.default_env()
388396
jax_func = jax_view(torch_function)
389-
jitted = jax_jit_func(jax_func, **kwargs_for_jax)
390-
return torch_view(jitted)
397+
398+
def jax_func_with_prng(*args_and_key, **kwargs):
399+
*args, prng_key = args_and_key
400+
with env.override_property(prng=prng_key):
401+
out = jax_func(*args, **kwargs)
402+
new_key = env.prng_key
403+
return out, new_key
404+
405+
jitted = jax_jit_func(jax_func_with_prng, **kwargs_for_jax)
406+
407+
def call_with_prng(*args, **kwargs):
408+
out, new_key = jitted(*args, env.prng_key, **kwargs)
409+
env.prng_key = new_key
410+
return out
411+
412+
return torch_view(call_with_prng)
391413

392414

393415
def jax_jit(torch_function, kwargs_for_jax_jit=None, fix_for_buffer_donation=False):

torchax/tensor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,12 @@ def manual_seed(self, key):
416416
def prng_key(self):
417417
return self.param.prng
418418

419+
@prng_key.setter
420+
def prng_key(self, key):
421+
# Mirror of the getter. Use `override_property(prng=...)` for
422+
# trace-scoped keys; calling this inside `jax.jit` leaks a tracer.
423+
self.param.prng = key
424+
419425
def _should_use_torchax_tensor(self, device):
420426
if device is None:
421427
device = torch.get_default_device()

0 commit comments

Comments
 (0)