2222
2323from __future__ import annotations
2424
25+ import atexit
2526import logging
2627import os
2728import shutil
2829import threading
30+ import weakref
31+ from functools import partial
2932from 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+
143155class 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
0 commit comments