Merge pull request #3465 from shi-eric:shi-eric/mjx-warp-cache-ffi-callables

PiperOrigin-RevId: 961136122
Change-Id: Ia468812e0557bb4efb7866f7130dab5d1cea673f
This commit is contained in:
Copybara-Service
2026-08-07 14:59:07 -07:00
2 changed files with 169 additions and 27 deletions
+93 -27
View File
@@ -17,6 +17,7 @@
import dataclasses import dataclasses
import functools import functools
import inspect import inspect
import threading
import typing import typing
from typing import Any, Callable, Optional, Sequence, Tuple, Union from typing import Any, Callable, Optional, Sequence, Tuple, Union
@@ -30,6 +31,15 @@ from mujoco.mjx._src.types import tree_path_to_attr_str
from mujoco.mjx.warp import types as mjx_warp_types from mujoco.mjx.warp import types as mjx_warp_types
# ``warp_ffi.jax_callable`` keys its registry by wrapper function and
# configuration. Cache generated wrappers here by the original shim and MJX's
# flattened call structure so equivalent retraces reuse the same Warp entry.
_JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY: dict[
tuple[Any, ...], Callable[..., Any]
] = {}
_JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY_LOCK = threading.Lock()
def flatten_signature(signature: inspect.Signature, args: Tuple[Any, ...]): def flatten_signature(signature: inspect.Signature, args: Tuple[Any, ...]):
"""Flattens a tuple/dataclass signature.""" """Flattens a tuple/dataclass signature."""
@@ -109,38 +119,94 @@ def jax_callable_variadic_tuple(
): ):
"""Wraps a JAX callable to support variadic tuples and dataclasses.""" """Wraps a JAX callable to support variadic tuples and dataclasses."""
# Snapshot mapping and name-set options into stable cache-key forms. Warp
# consumes them by argument name, so caller ordering does not distinguish
# callable configurations.
hashable_output_dims = (
None if output_dims is None else tuple(sorted(output_dims.items()))
)
hashable_in_out_argnames = (
tuple(sorted(in_out_argnames)) if in_out_argnames else None
)
hashable_stage_in_argnames = (
tuple(sorted(stage_in_argnames)) if stage_in_argnames else None
)
hashable_stage_out_argnames = (
tuple(sorted(stage_out_argnames)) if stage_out_argnames else None
)
def callable_wrapper(*args, **kwargs): def callable_wrapper(*args, **kwargs):
def func_wrapper(*flat_args, **kwargs):
num_inputs = in_tree.num_leaves
flat_inputs = flat_args[:num_inputs]
output_buffers = flat_args[num_inputs:]
unflat_args = jax.tree.unflatten(in_tree, flat_inputs)
return func(*unflat_args, *output_buffers, **kwargs)
# Provide a flattened signature for the Warp callable machinery. # Provide a flattened signature for the Warp callable machinery.
flat_args, in_tree = jax.tree.flatten(args)
new_signature = flatten_signature(inspect.signature(func), args) new_signature = flatten_signature(inspect.signature(func), args)
func_wrapper.__signature__ = new_signature # pyrefly: ignore[missing-attribute] # Cache the wrapper's structural ABI, not per-call leaves. The flattened
func_wrapper.__annotations__ = { # signature defines Warp's arguments and the PyTree defines reconstruction.
p.name: p.annotation # Leaf arrays and tracers are forwarded on every invocation; keying on them
for p in new_signature.parameters.values() # would prevent equivalent retraces from reusing the same FFI target.
if p.annotation is not inspect.Parameter.empty callable_cache_key = (
} func,
if new_signature.return_annotation is not inspect.Signature.empty: new_signature,
func_wrapper.__annotations__['return'] = new_signature.return_annotation in_tree,
num_outputs,
my_callable = warp_ffi.jax_callable( graph_mode,
func_wrapper, vmap_method,
num_outputs=num_outputs, hashable_output_dims,
graph_mode=graph_mode, hashable_in_out_argnames,
vmap_method=vmap_method, hashable_stage_in_argnames,
output_dims=output_dims, hashable_stage_out_argnames,
in_out_argnames=in_out_argnames, has_side_effect,
stage_in_argnames=stage_in_argnames,
stage_out_argnames=stage_out_argnames,
has_side_effect=has_side_effect,
) )
flat_args, in_tree = jax.tree.flatten(args) # Serialize construction so concurrent traces cannot register duplicate
# Warp callbacks for the same structural key.
with _JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY_LOCK:
my_callable = _JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY.get(
callable_cache_key
)
if my_callable is None:
# Restore the original PyTree inputs; Warp appends output buffers.
def func_wrapper(*flat_args, **kwargs):
num_inputs = in_tree.num_leaves
flat_inputs = flat_args[:num_inputs]
output_buffers = flat_args[num_inputs:]
unflat_args = jax.tree.unflatten(in_tree, flat_inputs)
return func(*unflat_args, *output_buffers, **kwargs)
# Warp derives the FFI ABI from this synthetic signature and
# annotations.
func_wrapper.__signature__ = ( # pyrefly: ignore[missing-attribute]
new_signature
)
func_wrapper.__annotations__ = {
p.name: p.annotation
for p in new_signature.parameters.values()
if p.annotation is not inspect.Parameter.empty
}
if new_signature.return_annotation is not inspect.Signature.empty:
func_wrapper.__annotations__['return'] = (
new_signature.return_annotation
)
# Constructing the callable registers its FFI target.
my_callable = warp_ffi.jax_callable(
func_wrapper,
num_outputs=num_outputs,
graph_mode=graph_mode,
vmap_method=vmap_method,
output_dims=(
None
if hashable_output_dims is None
else dict(hashable_output_dims)
),
in_out_argnames=hashable_in_out_argnames,
stage_in_argnames=hashable_stage_in_argnames,
stage_out_argnames=hashable_stage_out_argnames,
has_side_effect=has_side_effect,
)
_JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY[callable_cache_key] = my_callable
# Invocation may re-enter JAX or Warp and needs no registry serialization.
return my_callable(*flat_args, **kwargs) return my_callable(*flat_args, **kwargs)
return callable_wrapper return callable_wrapper
+76
View File
@@ -0,0 +1,76 @@
# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Tests for MJX-Warp FFI helpers."""
import os
from unittest import mock
from absl.testing import absltest
from mujoco.mjx._src import io
import mujoco.mjx.warp as mjxw
try:
from mujoco.mjx.warp import ffi # pylint: disable=g-import-not-at-top
except ImportError:
ffi = None
_FORCE_TEST = os.environ.get('MJX_WARP_FORCE_TEST', '0') == '1'
class FfiTest(absltest.TestCase):
def test_jax_callable_variadic_tuple_cache(self):
if not _FORCE_TEST:
if not mjxw.WARP_INSTALLED:
self.skipTest('Warp not installed.')
if not io.has_cuda_gpu_device():
self.skipTest('No CUDA GPU device available.')
def func_a(values: tuple[int, ...], output: int):
del values, output
def func_b(values: tuple[int, ...], output: int):
del values, output
# Count callback registrations without creating real JAX FFI targets.
def create_callable(*args, **kwargs):
del args, kwargs
return mock.Mock()
with mock.patch.object(ffi, '_JAX_CALLABLE_VARIADIC_TUPLE_REGISTRY', {}):
with mock.patch.object(
ffi.warp_ffi, 'jax_callable', side_effect=create_callable
) as jax_callable:
wrapper_a = ffi.jax_callable_variadic_tuple(func_a)
wrapper_a_again = ffi.jax_callable_variadic_tuple(func_a)
wrapper_b = ffi.jax_callable_variadic_tuple(func_b)
# Equivalent traces of the same callable reuse one callback.
wrapper_a((1, 2))
wrapper_a_again((3, 4))
self.assertEqual(jax_callable.call_count, 1)
# Distinct shim functions with the same signature do not share a target.
wrapper_b((5, 6))
self.assertEqual(jax_callable.call_count, 2)
# A different tuple arity changes the signature and PyTree cache key.
wrapper_b((1, 2, 3))
self.assertEqual(jax_callable.call_count, 3)
if __name__ == '__main__':
absltest.main()