gh-154904: Speed up import of shutil by probing compression extensions - #154908
gh-154904: Speed up import of shutil by probing compression extensions#154908maxday wants to merge 2 commits into
Conversation
…ensions shutil imported bz2, lzma and compression.zstd at module scope only to set the _*_SUPPORTED flags, then discarded the bindings. Executing those pure Python wrappers also pulled in compression._common[._streams] and compression.zstd._zstdfile, a cost paid by every program that imports shutil even if it never touches an archive. Their only importable dependency that may be missing is the extension module each one wraps, so probe _bz2, _lzma and _zstd directly instead. This keeps the exact ImportError semantics of the previous code -- unlike a find_spec() check, which resolves the always-present wrapper and would report success on builds where the extension fails to load. The wrappers are still imported by tarfile/zipfile when an archive is actually created or extracted. Cuts the net cost of "import shutil" by ~2.5 ms (-19.6%) and of "import urllib.request" by ~2.3 ms (-6.4%).
| def test_compression_wrappers_not_imported_by_shutil(self): | ||
| # Importing shutil must not pull in the compression wrappers: they are | ||
| # only needed once an archive is actually created or extracted, and | ||
| # importing them measurably slows down every process that uses shutil. | ||
| wrappers = ('bz2', 'lzma', 'compression', 'compression.zstd') | ||
| script = ( | ||
| 'import sys, shutil; ' | ||
| f'print([m for m in {wrappers!r} if m in sys.modules])' | ||
| ) | ||
| # -I so that a sitecustomize/usercustomize importing one of these | ||
| # cannot make the test fail spuriously. | ||
| rc, stdout, stderr = assert_python_ok('-I', '-c', script) | ||
| self.assertEqual(stdout.decode().strip(), '[]', stderr) |
There was a problem hiding this comment.
Maybe we can use ensure_lazy_imports to make it cleaner:
| def test_compression_wrappers_not_imported_by_shutil(self): | |
| # Importing shutil must not pull in the compression wrappers: they are | |
| # only needed once an archive is actually created or extracted, and | |
| # importing them measurably slows down every process that uses shutil. | |
| wrappers = ('bz2', 'lzma', 'compression', 'compression.zstd') | |
| script = ( | |
| 'import sys, shutil; ' | |
| f'print([m for m in {wrappers!r} if m in sys.modules])' | |
| ) | |
| # -I so that a sitecustomize/usercustomize importing one of these | |
| # cannot make the test fail spuriously. | |
| rc, stdout, stderr = assert_python_ok('-I', '-c', script) | |
| self.assertEqual(stdout.decode().strip(), '[]', stderr) | |
| def test_compression_wrappers_not_imported_by_shutil(self): | |
| # gh-154904: Importing shutil must not pull in the compression wrappers: they are | |
| # only needed once an archive is actually created or extracted, and | |
| # importing them measurably slows down every process that uses shutil. | |
| ensure_lazy_imports("shutil", {"bz2", "lzma", "compression", "compression.zstd"}) |
I'm not test it, and you need to add the import statement at the top of this file:
from test.support.import_helper import ensure_lazy_imports
|
I'm not quite sure if |
Per review feedback on pythonGH-154908: - Rewrite test_compression_wrappers_not_imported_by_shutil on top of test.support.import_helper.ensure_lazy_imports instead of hand-rolling an assert_python_ok subprocess. - Remove test_compression_supported_flags: since each wrapper imports its extension module at the top level, the assertion is near-tautological, and the zlib subtest compared importing zlib with itself.
|
Thanks for the review @aisk, both applied. One open question: I left off |
|
I guess it's the behavior of don't add a placeholder object to the module, when lazy importing, is the implement detail of CPython, so added the decorator to let other implementation to skip this. In this PR, we don't use lazy import, so we should not add it. |
|
Thanks @aisk leaving it as is then! Let me know if you have other comments |
|
cc @emmatyping who may be interested in this. Another idea, replace the three from importlib.util import find_spec
_BZ2_SUPPORTED = find_spec('_bz2') is not None
_LZMA_SUPPORTED = find_spec('_lzma') is not None
_ZSTD_SUPPORTED = find_spec('_zstd') is not NoneGives:
|
|
Thanks @hugovk! I did try that first, and it's what pushed me to the find_spec('_bz2') returns a spec as soon as ExtensionFileLoader finds the .so on the path. Everything that happens after that: dlopen, symbol resolution, PyInit__bz2, module exec, is where a broken extension actually fails, and find_spec never gets there. Let's say the extension is compiled against a library that isn't present at runtime (ie: missing/mismatched libbz2). The .so is on disk, dlopen fails: Let me know if I'm missing something, happy to use find_spec if you think that scenario can not happen but it might feels a bit safer to use import? |
|
Ah yes, it is a change in behaviour. With ValueError: unknown archive format 'bztar'With tarfile.CompressionError: bz2 module is not availableWhich is maybe more informative? Anyway, it's probably not worth changing the behaviour for this small extra speedup, especially as |
|
Thanks for confirming! |



Speed up
import shutilby probing compression extensions instead of importing the wrappersshutilimportsbz2,lzmaandcompression.zstdat module scope only to set the_*_SUPPORTEDbooleans, then discards them, also dragging incompression._common[._streams]andcompression.zstd._zstdfile. Every program importingshutilpays for it even if it never touches an archive. They are pure Python wrappers whose only dependency that can be missing is the extension they wrap, so probe that directly:Same for
lzma→_lzma,compression.zstd→_zstd.zlibis left alone: it is the extension.tarfile/zipfilestill import the wrappers when an archive is created or extracted.Net import cost (median of 40 fresh interpreters, minus
-c pass, one core):import shutilimport urllib.requesturllib.requestbenefits viatempfile→shutil. Motivation is short-lived processes on constrained hardware (CLI tools, serverless cold starts).No API change: flags, both registries and
get_{archive,unpack}_formats()are unchanged, including on builds missing a compression library. The probe is a realimport, soImportErrorsemantics are exact, unlikefind_spec('bz2'), which resolves the always-present wrapper and reports success where_bz2failed to compile (the case on my machine). PEP 810lazy importalso won't do: the flags are read at module scope (shutil.py:1145,:1354), so must be final at import time.Tests
Added to
TestArchives:test_compression_supported_flagspins the flags to realimportsemantics,test_compression_wrappers_not_imported_by_shutilprevents the eager imports returning. Existingtest_unpack_archive_*coverage unchanged;patchcheckclean.fixes: #154904