Fix Impl.CPP memory leaks due to mjModel/mjData not clearing from pytree.

PiperOrigin-RevId: 874622402
Change-Id: I38a00633dea77d940e4b0422f93e9456ad84daf9
This commit is contained in:
Google DeepMind
2026-02-24 08:16:39 -08:00
committed by Copybara-Service
parent 1d6ff2cece
commit 94acdb33bd
3 changed files with 80 additions and 38 deletions
+67 -22
View File
@@ -494,9 +494,12 @@ def _put_model_warp(
return _strip_weak_type(model) return _strip_weak_type(model)
# TODO(josechenf): Iterate on the keepalive implementation to make it easier to
# use before OSS.
def _put_model_cpp( def _put_model_cpp(
m: mujoco.MjModel, m: mujoco.MjModel,
device: Optional[jax.Device] = None, device: Optional[jax.Device] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Model: ) -> types.Model:
"""Puts mujoco.MjModel onto a device, resulting in mjx.Model.""" """Puts mujoco.MjModel onto a device, resulting in mjx.Model."""
@@ -517,9 +520,11 @@ def _put_model_cpp(
c_pointers_impl = types.ModelCPP( c_pointers_impl = types.ModelCPP(
pointer_lo=pointer_lo, pointer_lo=pointer_lo,
pointer_hi=pointer_hi, pointer_hi=pointer_hi,
_model=m,
) )
if keepalive_refs is not None:
keepalive_refs[addr] = m
model = types.Model( model = types.Model(
**{k: copy.copy(v) for k, v in fields.items()}, _impl=c_pointers_impl **{k: copy.copy(v) for k, v in fields.items()}, _impl=c_pointers_impl
) )
@@ -532,6 +537,7 @@ def put_model(
device: Optional[jax.Device] = None, device: Optional[jax.Device] = None,
impl: Optional[Union[str, types.Impl]] = None, impl: Optional[Union[str, types.Impl]] = None,
graph_mode: Optional[mjxw.types.GraphMode] = None, graph_mode: Optional[mjxw.types.GraphMode] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Model: ) -> types.Model:
"""Puts mujoco.MjModel onto a device, resulting in mjx.Model. """Puts mujoco.MjModel onto a device, resulting in mjx.Model.
@@ -541,6 +547,9 @@ def put_model(
impl: implementation to use impl: implementation to use
graph_mode: CUDA graph capture mode (for Warp only). Use GraphMode enum from graph_mode: CUDA graph capture mode (for Warp only). Use GraphMode enum from
warp._src.jax_experimental.ffi. GraphMode.WARP is the default mode. warp._src.jax_experimental.ffi. GraphMode.WARP is the default mode.
keepalive_refs: optional dict to store references to underlying MuJoCo
objects, preventing them from being garbage collected. Required for CPP
impl to keep the model alive.
Returns: Returns:
an mjx.Model placed on device an mjx.Model placed on device
@@ -560,7 +569,7 @@ def put_model(
graph_mode = graph_mode or getattr(mjxw.types.GraphMode, 'WARP') graph_mode = graph_mode or getattr(mjxw.types.GraphMode, 'WARP')
return _put_model_warp(m, graph_mode, device) return _put_model_warp(m, graph_mode, device)
elif impl == types.Impl.CPP: elif impl == types.Impl.CPP:
return _put_model_cpp(m, device) return _put_model_cpp(m, device, keepalive_refs=keepalive_refs)
else: else:
raise ValueError(f'Unsupported implementation: {impl}') raise ValueError(f'Unsupported implementation: {impl}')
@@ -906,9 +915,12 @@ def _make_data_warp(
return data return data
# TODO(josechenf): Iterate on the keepalive implementation to make it easier to
# use before OSS.
def _make_data_cpp( def _make_data_cpp(
m: Union[types.Model, mujoco.MjModel], m: Union[types.Model, mujoco.MjModel],
device: Optional[jax.Device] = None, device: Optional[jax.Device] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Data: ) -> types.Data:
"""Allocate and initialize Data for the CPP implementation.""" """Allocate and initialize Data for the CPP implementation."""
if isinstance(m, mujoco.MjModel): if isinstance(m, mujoco.MjModel):
@@ -918,7 +930,13 @@ def _make_data_cpp(
m_impl = m._impl # pylint: disable=protected-access m_impl = m._impl # pylint: disable=protected-access
if not isinstance(m_impl, types.ModelCPP): if not isinstance(m_impl, types.ModelCPP):
raise ValueError(f'Expected ModelCPP impl, got {type(m_impl)}') raise ValueError(f'Expected ModelCPP impl, got {type(m_impl)}')
mj_model = m_impl._model # pylint: disable=protected-access model_addr = int(m_impl.pointer_lo) | (int(m_impl.pointer_hi) << 32)
if keepalive_refs is None or model_addr not in keepalive_refs:
raise ValueError(
'keepalive_refs must be provided and contain the model when calling'
' _make_data_cpp with a types.Model.'
)
mj_model = keepalive_refs[model_addr]
# Create the raw MuJoCo data # Create the raw MuJoCo data
mj_data = mujoco.MjData(mj_model) mj_data = mujoco.MjData(mj_model)
@@ -933,9 +951,11 @@ def _make_data_cpp(
c_pointers_impl = types.DataCPP( c_pointers_impl = types.DataCPP(
pointer_lo=pointer_lo, pointer_lo=pointer_lo,
pointer_hi=pointer_hi, pointer_hi=pointer_hi,
_data=[mj_data],
) )
if keepalive_refs is not None:
keepalive_refs[addr] = [mj_data]
data = types.Data( data = types.Data(
_impl=c_pointers_impl, _impl=c_pointers_impl,
**fields, **fields,
@@ -952,6 +972,7 @@ def make_data(
nconmax: Optional[int] = None, nconmax: Optional[int] = None,
naconmax: Optional[int] = None, naconmax: Optional[int] = None,
njmax: Optional[int] = None, njmax: Optional[int] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Data: ) -> types.Data:
"""Allocate and initialize Data. """Allocate and initialize Data.
@@ -969,6 +990,9 @@ def make_data(
`naconmax` argument to set the upper bound for the number of contacts `naconmax` argument to set the upper bound for the number of contacts
across all worlds, rather than the `nconmax` argument from MuJoCo Warp. across all worlds, rather than the `nconmax` argument from MuJoCo Warp.
njmax: maximum number of constraints to allocate for warp across all worlds njmax: maximum number of constraints to allocate for warp across all worlds
keepalive_refs: optional dict to store references to underlying MuJoCo
objects, preventing them from being garbage collected. Required for CPP
impl when passing a types.Model.
Returns: Returns:
an initialized mjx.Data placed on device an initialized mjx.Data placed on device
@@ -997,7 +1021,7 @@ def make_data(
elif impl == types.Impl.C: elif impl == types.Impl.C:
return _make_data_c(m, device) return _make_data_c(m, device)
elif impl == types.Impl.CPP: elif impl == types.Impl.CPP:
return _make_data_cpp(m, device) return _make_data_cpp(m, device, keepalive_refs=keepalive_refs)
elif impl == types.Impl.WARP: elif impl == types.Impl.WARP:
_check_warp_installed() _check_warp_installed()
naconmax = nconmax if naconmax is None else naconmax naconmax = nconmax if naconmax is None else naconmax
@@ -1300,11 +1324,14 @@ def _put_data_c(
return _strip_weak_type(data) return _strip_weak_type(data)
# TODO(josechenf): Iterate on the keepalive implementation to make it easier to
# use before OSS.
def _put_data_cpp( def _put_data_cpp(
m: mujoco.MjModel, m: mujoco.MjModel,
d: mujoco.MjData, d: mujoco.MjData,
device: Optional[jax.Device] = None, device: Optional[jax.Device] = None,
dummy_arg_for_batching: Optional[jax.Array] = None, dummy_arg_for_batching: Optional[jax.Array] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Data: ) -> types.Data:
"""Puts mujoco.MjData onto a device, resulting in mjx.Data.""" """Puts mujoco.MjData onto a device, resulting in mjx.Data."""
@@ -1317,6 +1344,8 @@ def _put_data_cpp(
mujoco.mj_copyData(new_d, m, d) mujoco.mj_copyData(new_d, m, d)
data_list.append(new_d) data_list.append(new_d)
addr = new_d._address addr = new_d._address
if keepalive_refs is not None:
keepalive_refs[addr] = new_d
# To ensure that we retain the full pointer even if jax.config.enable_x64 is # To ensure that we retain the full pointer even if jax.config.enable_x64 is
# set to True, we store the pointer as two 32-bit values. In the FFI call, # set to True, we store the pointer as two 32-bit values. In the FFI call,
# we combine the two values into a single pointer value. # we combine the two values into a single pointer value.
@@ -1341,7 +1370,6 @@ def _put_data_cpp(
c_pointers_impl = types.DataCPP( c_pointers_impl = types.DataCPP(
pointer_lo=pointer_lo, pointer_lo=pointer_lo,
pointer_hi=pointer_hi, pointer_hi=pointer_hi,
_data=data_list,
) )
data = types.Data( data = types.Data(
@@ -1399,6 +1427,7 @@ def put_data(
naconmax: Optional[int] = None, naconmax: Optional[int] = None,
njmax: Optional[int] = None, njmax: Optional[int] = None,
dummy_arg_for_batching: Optional[jax.Array] = None, dummy_arg_for_batching: Optional[jax.Array] = None,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> types.Data: ) -> types.Data:
"""Puts mujoco.MjData onto a device, resulting in mjx.Data. """Puts mujoco.MjData onto a device, resulting in mjx.Data.
@@ -1415,6 +1444,8 @@ def put_data(
njmax: maximum number of constraints to allocate for warp njmax: maximum number of constraints to allocate for warp
dummy_arg_for_batching: dummy argument to use for batching in cpp dummy_arg_for_batching: dummy argument to use for batching in cpp
implementation implementation
keepalive_refs: optional dict to store references to underlying MuJoCo
objects, preventing them from being garbage collected.
Returns: Returns:
an mjx.Data placed on device an mjx.Data placed on device
@@ -1434,7 +1465,11 @@ def put_data(
return _put_data_c(m, d, device) return _put_data_c(m, d, device)
elif impl == types.Impl.CPP: elif impl == types.Impl.CPP:
return _put_data_cpp( return _put_data_cpp(
m, d, device, dummy_arg_for_batching=dummy_arg_for_batching m,
d,
device,
dummy_arg_for_batching=dummy_arg_for_batching,
keepalive_refs=keepalive_refs,
) )
elif impl == types.Impl.WARP: elif impl == types.Impl.WARP:
_check_warp_installed() _check_warp_installed()
@@ -1692,15 +1727,18 @@ def _get_data_into(
mujoco.mj_factorM(m, result_i) mujoco.mj_factorM(m, result_i)
# TODO(josechenf): Iterate on the keepalive implementation to make it easier to
# use before OSS.
def _get_data_into_cpp( def _get_data_into_cpp(
result: Union[mujoco.MjData, List[mujoco.MjData]], result: Union[mujoco.MjData, List[mujoco.MjData]],
m: mujoco.MjModel, m: mujoco.MjModel,
d: types.Data, d: types.Data,
keepalive_refs: Optional[Dict[int, Any]] = None,
): ):
"""Gets mjx.Data from CPP impl into an existing mujoco.MjData or list. """Gets mjx.Data from CPP impl into an existing mujoco.MjData or list.
For the CPP implementation, the mjx.Data wraps underlying mujoco.MjData For the CPP implementation, the mjx.Data wraps underlying mujoco.MjData
objects that are stored in DataCPP._data. This function simply copies the objects that are stored in keepalive_refs. This function simply copies the
data from those underlying MjData objects to the result using mj_copyData. data from those underlying MjData objects to the result using mj_copyData.
""" """
@@ -1712,13 +1750,8 @@ def _get_data_into_cpp(
if not isinstance(d_impl, types.DataCPP): if not isinstance(d_impl, types.DataCPP):
raise ValueError(f'Expected DataCPP impl, got {type(d_impl)}') raise ValueError(f'Expected DataCPP impl, got {type(d_impl)}')
mj_data_list = d_impl._data # pylint: disable=protected-access if keepalive_refs is None:
raise ValueError('keepalive_refs must be provided for CPP implementation.')
if batch_size > len(mj_data_list):
raise ValueError(
f'Batch size {batch_size} exceeds number of underlying MjData objects '
f'({len(mj_data_list)}). Cannot copy data.'
)
# Verify that the underlying MjData state matches the mjx.Data state # Verify that the underlying MjData state matches the mjx.Data state
# Ideally we'd use mj_getState and get_state here but that requires an # Ideally we'd use mj_getState and get_state here but that requires an
@@ -1728,7 +1761,19 @@ def _get_data_into_cpp(
d_i: types.Data = ( d_i: types.Data = (
jax.tree_util.tree_map(lambda x, i=i: x[i], d) if batched else d jax.tree_util.tree_map(lambda x, i=i: x[i], d) if batched else d
) )
src_data = mj_data_list[i] result_i = result[i] if batched else result
if batched:
addr_i = int(d_impl.pointer_lo[i]) | (int(d_impl.pointer_hi[i]) << 32)
else:
addr_i = int(d_impl.pointer_lo) | (int(d_impl.pointer_hi) << 32)
if addr_i not in keepalive_refs:
raise ValueError(
f'Address {addr_i} not found in keepalive_refs. '
'Ensure keepalive_refs from the original compile() is passed.'
)
src_data = keepalive_refs[addr_i]
needs_syncing = False needs_syncing = False
for field in fields_to_check: for field in fields_to_check:
@@ -1745,9 +1790,6 @@ def _get_data_into_cpp(
src_data.mocap_quat[:] = d_i.mocap_quat src_data.mocap_quat[:] = d_i.mocap_quat
mujoco.mj_kinematics(m, src_data) mujoco.mj_kinematics(m, src_data)
for i in range(batch_size):
result_i = result[i] if batched else result
src_data = mj_data_list[i]
mujoco.mj_copyData(result_i, m, src_data) mujoco.mj_copyData(result_i, m, src_data)
@@ -1755,6 +1797,7 @@ def get_data_into(
result: Union[mujoco.MjData, List[mujoco.MjData]], result: Union[mujoco.MjData, List[mujoco.MjData]],
m: mujoco.MjModel, m: mujoco.MjModel,
d: types.Data, d: types.Data,
keepalive_refs: Optional[Dict[int, Any]] = None,
): ):
"""Gets mjx.Data from a device into an existing mujoco.MjData or list.""" """Gets mjx.Data from a device into an existing mujoco.MjData or list."""
is_batched = isinstance(result, list) is_batched = isinstance(result, list)
@@ -1770,7 +1813,7 @@ def get_data_into(
return _get_data_into(result, m, d) return _get_data_into(result, m, d)
if d.impl == types.Impl.CPP: if d.impl == types.Impl.CPP:
return _get_data_into_cpp(result, m, d) return _get_data_into_cpp(result, m, d, keepalive_refs=keepalive_refs)
if d.impl == types.Impl.WARP: if d.impl == types.Impl.WARP:
return _get_data_into_warp(result, m, d) return _get_data_into_warp(result, m, d)
@@ -1781,7 +1824,9 @@ def get_data_into(
def get_data( def get_data(
m: mujoco.MjModel, d: types.Data m: mujoco.MjModel,
d: types.Data,
keepalive_refs: Optional[Dict[int, Any]] = None,
) -> Union[mujoco.MjData, List[mujoco.MjData]]: ) -> Union[mujoco.MjData, List[mujoco.MjData]]:
"""Gets mjx.Data from a device, resulting in mujoco.MjData or List[MjData].""" """Gets mjx.Data from a device, resulting in mujoco.MjData or List[MjData]."""
batched = len(d.qpos.shape) > 1 batched = len(d.qpos.shape) > 1
@@ -1792,7 +1837,7 @@ def get_data(
else: else:
result = mujoco.MjData(m) result = mujoco.MjData(m)
get_data_into(result, m, d) get_data_into(result, m, d, keepalive_refs=keepalive_refs)
return result return result
+13 -14
View File
@@ -768,9 +768,10 @@ class DataIOTest(parameterized.TestCase):
m = mujoco.MjModel.from_xml_string(_MULTIPLE_CONSTRAINTS) m = mujoco.MjModel.from_xml_string(_MULTIPLE_CONSTRAINTS)
d = mujoco.MjData(m) d = mujoco.MjData(m)
mujoco.mj_step(m, d, 2) mujoco.mj_step(m, d, 2)
dx = mjx.put_data(m, d, impl=impl) keepalive = {} if impl == 'cpp' else None
dx = mjx.put_data(m, d, impl=impl, keepalive_refs=keepalive)
d_2 = mujoco.MjData(m) d_2 = mujoco.MjData(m)
mjx.get_data_into(d_2, m, dx) mjx.get_data_into(d_2, m, dx, keepalive_refs=keepalive)
# check a few fields # check a few fields
np.testing.assert_allclose(d_2.qpos, d.qpos) np.testing.assert_allclose(d_2.qpos, d.qpos)
@@ -931,12 +932,11 @@ class DataIOTest(parameterized.TestCase):
) )
self.assertEqual(vmjx_data.qpos.shape, (2, m.nq)) self.assertEqual(vmjx_data.qpos.shape, (2, m.nq))
self.assertEqual(len(vmjx_data._impl._data), 2) lo = vmjx_data._impl.pointer_lo
# check that the data pointers in fact point to different datas hi = vmjx_data._impl.pointer_hi
self.assertNotEqual( addr0 = int(lo[0]) | (int(hi[0]) << 32)
vmjx_data._impl._data[0]._address, addr1 = int(lo[1]) | (int(hi[1]) << 32)
vmjx_data._impl._data[1]._address, self.assertNotEqual(addr0, addr1)
)
# Test cases for `_resolve_impl_and_device` where the device is # Test cases for `_resolve_impl_and_device` where the device is
@@ -1265,12 +1265,11 @@ class StateIOTest(parameterized.TestCase):
) )
self.assertEqual(vmjx_data.qpos.shape, (2, m.nq)) self.assertEqual(vmjx_data.qpos.shape, (2, m.nq))
self.assertEqual(len(vmjx_data._impl._data), 2) lo = vmjx_data._impl.pointer_lo
# check that the data pointers in fact point to different datas hi = vmjx_data._impl.pointer_hi
self.assertNotEqual( addr0 = int(lo[0]) | (int(hi[0]) << 32)
vmjx_data._impl._data[0]._address, addr1 = int(lo[1]) | (int(hi[1]) << 32)
vmjx_data._impl._data[1]._address, self.assertNotEqual(addr0, addr1)
)
def test_get_set_state(self): def test_get_set_state(self):
m = mujoco.MjModel.from_xml_string(_MULTIPLE_CONSTRAINTS) m = mujoco.MjModel.from_xml_string(_MULTIPLE_CONSTRAINTS)
-2
View File
@@ -538,7 +538,6 @@ class ModelCPP(PyTreeNode):
# we combine the two values into a single pointer value. # we combine the two values into a single pointer value.
pointer_lo: jax.Array pointer_lo: jax.Array
pointer_hi: jax.Array pointer_hi: jax.Array
_model: mujoco.MjModel
class DataCPP(PyTreeNode): class DataCPP(PyTreeNode):
@@ -548,7 +547,6 @@ class DataCPP(PyTreeNode):
# we combine the two values into a single pointer value. # we combine the two values into a single pointer value.
pointer_lo: jax.Array pointer_lo: jax.Array
pointer_hi: jax.Array pointer_hi: jax.Array
_data: list[Any] = dataclasses.field(default_factory=list, repr=False)
class ModelC(PyTreeNode): class ModelC(PyTreeNode):