Skip to content

Commit 2b2925c

Browse files
tp5uiuclanluo-nvidia
authored andcommitted
fix(runtime): autosave engine-implicit RuntimeCache via atexit + weakref (#4362)
1 parent 4726ed6 commit 2b2925c

2 files changed

Lines changed: 238 additions & 16 deletions

File tree

py/torch_tensorrt/runtime/_runtime_cache.py

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@
2222

2323
from __future__ import annotations
2424

25+
import atexit
2526
import logging
2627
import os
2728
import shutil
2829
import threading
30+
import weakref
31+
from functools import partial
2932
from typing import (
3033
IO,
3134
Any,
35+
Callable,
3236
Optional,
3337
Protocol,
3438
Sequence,
@@ -140,6 +144,14 @@ def ensure_materialized(self, runtime_config: Any) -> Any:
140144
return self._cache
141145

142146

147+
def _autosave_at_exit(ref: "weakref.ref[RuntimeCache]") -> None:
148+
"""Module-level so the atexit closure only holds a weakref, not a bound
149+
method (see :meth:`RuntimeCache.__init__` for full rationale)."""
150+
rc = ref()
151+
if rc is not None:
152+
rc._autosave_if_enabled()
153+
154+
143155
class RuntimeCache:
144156
"""User-facing handle for the TensorRT-RTX runtime kernel cache.
145157
@@ -150,7 +162,11 @@ class RuntimeCache:
150162
``RuntimeSettings(runtime_cache="/path")``, the engine's
151163
``TRTRuntimeConfig`` materializes a :class:`RuntimeCache` internally
152164
with ``autosave_on_del=True``. No other Python object holds it, so
153-
``__del__`` writes the cache to disk on the engine's last release.
165+
two non-owning hooks back the save: ``__del__`` for mid-program GC,
166+
and a ``weakref``-based ``atexit`` hook for interpreter shutdown
167+
(where ``__del__`` is unsafe because lazy imports like ``filelock``
168+
may already be torn down). Whichever fires first flips
169+
``autosave_on_del`` off so the other no-ops.
154170
155171
2. **Runtime CM** (shared, multi-engine): the :func:`runtime_cache` CM
156172
constructs a :class:`RuntimeCache` with ``autosave_on_del=False``
@@ -163,24 +179,27 @@ class RuntimeCache:
163179
``RuntimeCache(path=..., autosave_on_del=True)`` for with-block-style
164180
autosave on hand-built handles.
165181
166-
The internal ``_handle`` is auto-picked at construction based on
167-
``ENABLED_FEATURES.torch_tensorrt_runtime``: the cpp-rt torchbind
168-
``torch.classes.tensorrt.RuntimeCacheHandle`` when the C++ runtime is
169-
loaded, the python :class:`_RuntimeCacheHandle` otherwise. Both
170-
satisfy :class:`_RuntimeCacheHandleProtocol`; the facade forwards
171-
without branching.
182+
The internal ``_handle`` is the cpp-rt torchbind
183+
``torch.classes.tensorrt.RuntimeCacheHandle`` when
184+
``ENABLED_FEATURES.torch_tensorrt_runtime`` is set, else the
185+
pure-Python :class:`_RuntimeCacheHandle`; both satisfy
186+
:class:`_RuntimeCacheHandleProtocol` interface which this class uses.
172187
173188
**Identity-based equality.** Two handles wrap distinct underlying
174189
``IRuntimeCache`` instances even when they share a path, and the
175-
runtime treats them as such (separate ``IRuntimeConfig`` slots, no
176-
kernel-specialization sharing).
190+
runtime treats them as such.
177191
"""
178192

179193
def __init__(
180194
self,
181195
path: str = "",
182196
autosave_on_del: bool = False,
183197
) -> None:
198+
# Set the atexit-token slot first so ``__del__`` can safely read it
199+
# even if a later step in ``__init__`` raises and leaves the object
200+
# partially constructed.
201+
self._atexit_token: Optional[Callable[..., None]] = None
202+
184203
# Pick the backing that matches the active runtime. The torchbind
185204
# class ``torch.classes.tensorrt.RuntimeCacheHandle`` is registered by
186205
# the C++ shared library; if the .so isn't loaded
@@ -197,6 +216,13 @@ def __init__(
197216
self._handle = _RuntimeCacheHandle(path=path)
198217
self.autosave_on_del = autosave_on_del
199218

219+
# Engine-implicit handles must save before ``sys.meta_path`` is torn
220+
# down at interpreter exit -- ``__del__`` then hits ``ImportError``
221+
# from the torchbind property and the lazy ``filelock`` import.
222+
# ``atexit`` fires before that teardown.
223+
if autosave_on_del:
224+
self._register_atexit_autosave()
225+
200226
def __deepcopy__(self, memo: dict[int, Any]) -> "RuntimeCache":
201227
# RuntimeCache is a live resource handle. Preserve identity so deepcopy
202228
# does not traverse into torchbind objects or the Python handle's lock.
@@ -306,15 +332,79 @@ def save(self, path: Optional[str] = None) -> None:
306332
except OSError:
307333
pass
308334

335+
def _autosave_if_enabled(self) -> None:
336+
"""Idempotent autosave shared by ``__del__`` and the atexit hook.
337+
Flips ``autosave_on_del`` off; swallows any exception so a
338+
shutdown-time ``ImportError`` never leaks as
339+
``Exception ignored in __del__``.
340+
"""
341+
try:
342+
if self.autosave_on_del and self.path:
343+
self.autosave_on_del = False
344+
self.save()
345+
except Exception:
346+
pass
347+
348+
def __getstate__(self) -> dict[str, Any]:
349+
"""Strip the unpicklable atexit token from the pickle stream.
350+
351+
The token is a ``partial`` over a ``weakref`` -- both of which are
352+
per-process artifacts and ``weakref`` is unpicklable. The pickled
353+
state carries only ``_handle`` (cpp torchbind persists path-only;
354+
see ``register_jit_hooks.cpp``) and ``autosave_on_del``;
355+
``__setstate__`` reconstructs a fresh atexit hook in the loading
356+
process if autosave was enabled.
357+
"""
358+
state = self.__dict__.copy()
359+
state["_atexit_token"] = None
360+
return state
361+
362+
def __setstate__(self, state: dict[str, Any]) -> None:
363+
self.__dict__.update(state)
364+
# Re-register atexit autosave in the loading process if it was
365+
# active in the saving one. The fresh ``weakref.ref(self)`` is
366+
# bound to the *new* instance, so the loading-process GC behavior
367+
# mirrors what ``__init__`` would have set up directly.
368+
if self.autosave_on_del:
369+
self._register_atexit_autosave()
370+
371+
def _register_atexit_autosave(self) -> None:
372+
"""Idempotently arm the atexit autosave hook for this handle.
373+
374+
Shared by ``__init__`` (initial wiring when ``autosave_on_del=True``)
375+
and ``__setstate__`` (rewiring after pickle round-trip drops the
376+
token). ``partial`` over a ``weakref`` keeps the registration
377+
non-owning, so mid-program GC still runs ``__del__`` normally; the
378+
``_atexit_token`` slot check makes a second call a no-op.
379+
"""
380+
if self._atexit_token is None:
381+
self._atexit_token = atexit.register(
382+
partial(_autosave_at_exit, weakref.ref(self))
383+
)
384+
309385
def __del__(self) -> None:
310-
# Best-effort autosave for engine-implicit handles. The CM disables
311-
# this (``autosave_on_del=False``) since it saves on ``__exit__``;
312-
# user-constructed handles default to disabled so save timing stays
313-
# under the user's control. ``__del__`` can fire during interpreter
314-
# shutdown when imports/filesystem ops fail unpredictably -- swallow.
315-
if self.autosave_on_del and self.path:
386+
# Mid-program GC path. The companion ``_autosave_at_exit`` hook
387+
# covers the case where the handle survives until interpreter exit;
388+
# whichever path runs first flips ``autosave_on_del`` off so the
389+
# other no-ops.
390+
self._autosave_if_enabled()
391+
392+
# Drop our atexit hook so the registry does not accumulate dead
393+
# entries across many engine-implicit handles in long-running
394+
# processes (each entry holds a now-dead weakref).
395+
#
396+
# Use ``getattr`` rather than direct attribute access: protocols
397+
# like ``copy.deepcopy`` can crash mid-state-copy on an unrelated
398+
# field (e.g. a pre-existing ``threading.Lock`` somewhere else in
399+
# the object graph) and leave the new instance with only some of
400+
# its attributes set. ``__init__`` never ran on that object, so
401+
# ``self._atexit_token`` may simply not exist when ``__del__``
402+
# fires -- ``getattr`` with a default makes that case a no-op
403+
# instead of raising ``AttributeError`` to ``sys.unraisablehook``.
404+
token = getattr(self, "_atexit_token", None)
405+
if token is not None:
316406
try:
317-
self.save()
407+
atexit.unregister(token)
318408
except Exception:
319409
pass
320410

tests/py/dynamo/runtime/test_000_runtime_cache.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import shutil
55
import tempfile
66
import unittest
7+
from unittest.mock import patch
78

89
import torch
910
import torch_tensorrt as torchtrt
@@ -334,6 +335,137 @@ def test_user_built_handle_no_autosave_by_default(self):
334335
"User-built handle with autosave_on_del=False should not save on GC",
335336
)
336337

338+
def test_del_swallows_shutdown_import_error_on_path(self):
339+
"""During interpreter shutdown ``self.path`` (a property that forwards
340+
to ``self._handle.path``) can raise ``ImportError`` from a lazy import
341+
triggered on a torn-down ``sys.meta_path``. ``__del__`` must wrap the
342+
entire body in try/except so this does not surface as a noisy
343+
``Exception ignored in __del__``.
344+
"""
345+
import sys
346+
347+
from torch_tensorrt.runtime._runtime_cache import RuntimeCache
348+
349+
handle = RuntimeCache(path="/nonexistent/path", autosave_on_del=True)
350+
351+
class _Boom:
352+
@property
353+
def path(self) -> str:
354+
raise ImportError(
355+
"sys.meta_path is None, Python is likely shutting down"
356+
)
357+
358+
handle._handle = _Boom()
359+
360+
# An exception escaping ``__del__`` reaches the interpreter via
361+
# ``sys.unraisablehook`` rather than ordinary stderr. Swap the hook
362+
# for a Mock so the call (if any) is recorded and the contract --
363+
# "nothing leaks" -- maps to ``assert_not_called``.
364+
with patch.object(sys, "unraisablehook") as mock_hook:
365+
del handle
366+
gc.collect()
367+
mock_hook.assert_not_called()
368+
369+
def test_atexit_hook_saves_via_weakref(self):
370+
"""``_autosave_at_exit`` resolves the weakref and invokes ``save()``,
371+
and flips ``autosave_on_del`` off so a subsequent ``__del__`` no-ops.
372+
"""
373+
import weakref
374+
375+
from torch_tensorrt.runtime._runtime_cache import (
376+
RuntimeCache,
377+
_autosave_at_exit,
378+
)
379+
380+
with tempfile.TemporaryDirectory() as tmp:
381+
path = os.path.join(tmp, "rc.bin")
382+
handle = RuntimeCache(path=path, autosave_on_del=True)
383+
384+
with patch.object(handle, "save") as mock_save:
385+
_autosave_at_exit(weakref.ref(handle))
386+
mock_save.assert_called_once()
387+
self.assertFalse(
388+
handle.autosave_on_del,
389+
"atexit hook must flip autosave_on_del off so __del__ skips",
390+
)
391+
392+
def test_atexit_hook_no_op_on_dead_weakref(self):
393+
"""If the handle was already collected mid-program, the atexit hook
394+
sees a dead weakref and does nothing -- no exceptions, no save."""
395+
import weakref
396+
397+
from torch_tensorrt.runtime._runtime_cache import _autosave_at_exit
398+
399+
class _WeakrefableDummy:
400+
pass
401+
402+
ref: weakref.ref = weakref.ref(_WeakrefableDummy())
403+
gc.collect()
404+
self.assertIsNone(ref(), "sentinel must be collected by gc")
405+
406+
# Must not raise even though ref() is dead.
407+
_autosave_at_exit(ref)
408+
409+
def test_atexit_token_unregistered_after_del(self):
410+
"""``__del__`` removes the handle's atexit hook so the registry does
411+
not accumulate dead entries across many engine-implicit handles in
412+
long-running processes."""
413+
import atexit
414+
415+
from torch_tensorrt.runtime._runtime_cache import RuntimeCache
416+
417+
handle = RuntimeCache(path="/nonexistent/path", autosave_on_del=True)
418+
token = handle._atexit_token
419+
self.assertIsNotNone(token)
420+
421+
# Spy on ``atexit.unregister`` to verify ``__del__`` cleaned up. Using
422+
# a mock avoids depending on private CPython implementation details
423+
# of the atexit registry (no ``atexit._exithandlers`` in modern
424+
# Python).
425+
with patch.object(atexit, "unregister") as mock_unregister:
426+
del handle
427+
gc.collect()
428+
mock_unregister.assert_called_once_with(token)
429+
430+
def test_pickle_round_trip_strips_atexit_token(self):
431+
"""Standalone ``RuntimeCache`` pickle: the unpicklable ``partial``
432+
over ``weakref`` is stripped on ``__getstate__`` and a fresh atexit
433+
hook is wired up by ``__setstate__`` when ``autosave_on_del`` was on.
434+
435+
``_handle`` is stubbed with a picklable placeholder so that the test
436+
isolates ``RuntimeCache.__getstate__/__setstate__`` from an
437+
orthogonal pre-existing limitation: the python-runtime
438+
``_RuntimeCacheHandle`` carries a ``threading.Lock`` that pickle
439+
can't serialize. The cpp-rt torchbind handle pickles to path-only
440+
(see ``register_jit_hooks.cpp``).
441+
"""
442+
import pickle
443+
from types import SimpleNamespace
444+
445+
from torch_tensorrt.runtime._runtime_cache import RuntimeCache
446+
447+
original = RuntimeCache(path="/nonexistent/path", autosave_on_del=True)
448+
self.assertIsNotNone(original._atexit_token)
449+
450+
# Sidestep the python-rt ``threading.Lock`` so we only exercise the
451+
# RuntimeCache state-transition logic.
452+
original._handle = SimpleNamespace(path="/nonexistent/path")
453+
454+
blob = pickle.dumps(original)
455+
loaded = pickle.loads(blob)
456+
457+
self.assertTrue(loaded.autosave_on_del)
458+
self.assertEqual(loaded.path, "/nonexistent/path")
459+
self.assertIsNotNone(
460+
loaded._atexit_token,
461+
"autosave_on_del=True must re-wire atexit on unpickle",
462+
)
463+
self.assertIsNot(
464+
loaded._atexit_token,
465+
original._atexit_token,
466+
"loaded handle must own its own atexit token (fresh weakref)",
467+
)
468+
337469

338470
@unittest.skipIf(
339471
not ENABLED_FEATURES.tensorrt_rtx,

0 commit comments

Comments
 (0)