Skip to content

Commit 3c57ed1

Browse files
authored
fix(python): import converters and specs lazily so inference does not load torch (memory reduction) (#2080)
* fix(python): import converters and specs lazily so inference does not load torch `import ctranslate2` eagerly imported the `converters` and `specs` submodules, both of which import torch at module level (`converters` also imports transformers and huggingface_hub). Those dependencies are only needed to convert models, so every inference-only user with torch in the environment paid for the import. Import both submodules on first attribute access via a module-level `__getattr__` (PEP 562). `from ctranslate2 import converters`, `import ctranslate2.converters` and the `ct2-*-converter` entry points are unaffected; `models` stays eager because it only imports from the compiled extension. Measured with torch 2.5.1+cu121 installed: `import ctranslate2` drops from 3.04s to 0.15s (the package's own import subtree, per -X importtime, from 2.73s to 0.11s). Fixes #2078 * test(python): cover wildcard import of the lazy submodules * test(python): cover wildcard import of the lazy submodules
1 parent a93c2bf commit 3c57ed1

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

python/ctranslate2/__init__.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,35 @@
5555
else:
5656
raise
5757

58-
from ctranslate2 import converters, models, specs
58+
from ctranslate2 import models
5959
from ctranslate2.version import __version__
60+
61+
# converters and specs import torch (and, for converters, transformers) at module level.
62+
# Those dependencies are only needed to convert models, not to run inference, so import
63+
# these submodules on first use to keep "import ctranslate2" free of them.
64+
_LAZY_SUBMODULES = ("converters", "specs")
65+
66+
67+
def __getattr__(name):
68+
if name in _LAZY_SUBMODULES:
69+
import importlib
70+
71+
module = importlib.import_module(f"{__name__}.{name}")
72+
globals()[name] = module
73+
return module
74+
75+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
76+
77+
78+
def __dir__():
79+
return sorted(set(globals()) | set(_LAZY_SUBMODULES))
80+
81+
82+
# A wildcard import resolves ``__all__`` when it is defined and the module globals
83+
# otherwise, so without this the lazy submodules would silently drop out of
84+
# ``from ctranslate2 import *``. Deriving the list keeps the wildcard surface identical
85+
# to what it was before they became lazy; a wildcard import asks for everything, so
86+
# resolving them here is expected.
87+
__all__ = sorted(
88+
[name for name in globals() if not name.startswith("_")] + list(_LAZY_SUBMODULES)
89+
)

python/tests/test_misc.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import subprocess
2+
import sys
3+
14
import pytest
25

36
from ctranslate2.extensions import _batch_iterator as batch_iterator
@@ -17,3 +20,41 @@ def test_batch_iterator(batch_size, batch_type, lengths, expected_batch_sizes):
1720
batch_sizes = [len(batch[0]) for batch in batches]
1821

1922
assert batch_sizes == expected_batch_sizes
23+
24+
25+
@pytest.mark.parametrize("module_name", ["torch", "transformers"])
26+
def test_import_does_not_load_conversion_dependencies(module_name):
27+
# The converters and specs submodules are only needed to convert models, so importing
28+
# the package for inference should not pull their heavy dependencies into the process.
29+
# Run in a subprocess because the test session itself imports them.
30+
code = "import sys; import ctranslate2; print(%r in sys.modules)" % module_name
31+
result = subprocess.run(
32+
[sys.executable, "-c", code],
33+
capture_output=True,
34+
check=True,
35+
text=True,
36+
)
37+
38+
assert result.stdout.strip() == "False"
39+
40+
41+
def test_wildcard_import_still_exposes_lazy_submodules():
42+
# Wildcard imports read ``__all__`` when it is defined, so the lazy submodules must
43+
# stay listed there to keep exposing the same names as before they became lazy.
44+
# converters imports transformers, which is only installed on Linux.
45+
pytest.importorskip("transformers")
46+
47+
# Run in a subprocess so the wildcard import does not leak into the test session.
48+
code = (
49+
"from ctranslate2 import *\n"
50+
"names = set(dir())\n"
51+
"print(sorted(n for n in ('converters', 'specs') if n in names))\n"
52+
)
53+
result = subprocess.run(
54+
[sys.executable, "-c", code],
55+
capture_output=True,
56+
check=True,
57+
text=True,
58+
)
59+
60+
assert result.stdout.strip() == "['converters', 'specs']"

0 commit comments

Comments
 (0)