PERF/ENH: Polish PR #6183 PEP 562 lazy loading (per-module locks, EAGER_IMPORT, importtime gate)#6210
Closed
hjmjohnson wants to merge 18 commits intoInsightSoftwareConsortium:mainfrom
Closed
Conversation
Replace the LazyITKModule sys.modules-swap orchestration in itk/__init__.py with a native PEP 562 implementation: module-level __getattr__ and __dir__ resolve symbols on first access, gated by a single threading.RLock. itkConfig.LazyLoading is no longer consulted; lazy is the only mode. multiprocessing.RLock is deliberately avoided so the multiprocessing start method stays pickable after `import itk`. Per-submodule LazyITKModule instances and the __init_<modulename>__.py discovery hook are preserved unchanged so the build stays green; Phase 03 will migrate them. itk_base_global_lazy_attributes (consumed by support/template_class._LoadModules) is still populated with the full set of owners per attribute, alongside the new first-owner-only _lazy_attribute_to_module that drives the top-level __getattr__. Validated via py_compile and a mock-driver smoke harness covering PEP 366 __package__, dir-without-load, lazy-load-with-cache, first-owner precedence, AttributeError on miss, dunder short-circuit, and a 6-thread first-touch race.
The new PEP 562 module-level __dir__ called the unqualified `set` name,
which Python resolves through `itk.__dict__`. Once any SWIG submodule is
loaded (e.g. ITKCommon on first `itk.Image` access), `itk.set` is
populated as an `itkTemplate` binding `std::set` and shadows the
builtin. Subsequent calls to `dir(itk)` then raised
`TemplateTypeError: itk.set is not wrapped for input type None`.
This is the same alias hazard that the existing _initialize_module
already guards against by importing `_builtin_set` from `builtins`.
Switch __dir__ to a set-literal form `{*globals().keys(), ...}` which
constructs through the C-level set type with no name lookup, so it is
unaffected by names introduced into module globals at lazy-load time.
Introduce itk.support._lazy_submodule with a builder that returns a plain types.ModuleType for itk.<Module> wired with module-level __getattr__ / __dir__ closures, registered in sys.modules['itk.<Module>'], and carrying a one-line __reduce_ex__ shim for pickle / cloudpickle by-name round-trip. This is the replacement target for the legacy LazyITKModule subclass; the next commit swaps the construction site in itk/__init__.py.
Replace the LazyITKModule(types.ModuleType) construction in _initialize_module() with the PEP 562 helper added in the previous commit. Each itk.<Module> namespace is now a plain types.ModuleType with __getattr__/__dir__ closures and a sys.modules registration, so cloudpickle round-trips by dotted name (Tests/lazy.py). The shared _lazy_load_lock is passed into the helper so top-level and per-submodule lazy loads continue to serialise on a single RLock. After this change __init__.py no longer references LazyITKModule; the class itself remains in support/lazy.py for now and will be removed in a follow-up commit.
The PEP 562 mechanism is now the only lazy-loading path so the legacy itkConfig.LazyLoading flag and its ITK_PYTHON_LAZYLOADING environment variable are no-ops. Drop the flag definition, the docstring paragraph documenting it, and the three test-side overrides that toggled it. Specifically: * Wrapping/Generators/Python/itkConfig.template.in.py: drop the LazyLoading docstring paragraph and the LazyLoading bool assignment driven by ITK_PYTHON_LAZYLOADING. * Wrapping/Generators/Python/itk/__init__.py: refresh a leading comment that used LazyLoading as the canonical example of a mutable itkConfig flag; use DefaultFactoryLoading instead so the example still names a real attribute. * Wrapping/Generators/Python/Tests/lazy.py: remove the itkConfig.LazyLoading=True override at the top of the test and refresh the now-stale "PEP 366 compliance of LazyITKModule" comment to reference the per-submodule namespaces that replaced the class. * Wrapping/Generators/Python/Tests/multiprocess_lazy_loading.py: remove the itkConfig.LazyLoading=True override and the now-unused import itkConfig. The descriptive header comments referencing the *concept* of lazy loading remain intact. * Modules/Filtering/ImageIntensity/wrapping/test/itkImageFilterNumPyInputsTest.py: remove the itkConfig.LazyLoading=False override and the associated import itkConfig (otherwise the test would raise AttributeError once the flag is gone).
Tests/nolazy.py was the eager-mode counterpart to Tests/lazy.py and forced itkConfig.LazyLoading=False to exercise the non-lazy import path. With the LazyLoading flag removed in the previous commit the PEP 562 lazy mechanism is the only import path, so the eager test is no longer meaningful and would raise AttributeError on import. Delete the file and its CMake registration. * Wrapping/Generators/Python/Tests/nolazy.py: deleted. * Wrapping/Generators/Python/Tests/CMakeLists.txt: drop the itk_python_add_test(NAME PythonNoLazyModule ...) block.
Remove the legacy custom-subclass-of-types.ModuleType implementation now that the itk package and its per-submodule namespaces resolve attributes via module-level __getattr__ / __dir__. - Delete Wrapping/Generators/Python/itk/support/lazy.py (LazyITKModule, ITKLazyLoadLock, _lazy_itk_module_reconstructor, not_loaded sentinel). - Drop support/lazy from ITK_PYTHON_SUPPORT_MODULES in Wrapping/Generators/Python/CMakeLists.txt so the wheel no longer installs the deleted file. A repo-wide grep confirms no live import of from itk.support import lazy, from itk.support.lazy, support.lazy, _lazy., or LazyITKModule remains.
Extend Tests/lazy.py beyond PEP 366 + cloudpickle to assert the three remaining contracts the PEP 562 lazy mechanism must preserve: - PEP 562 __dir__: "Image" appears in dir(itk) and dir(ITKCommon) but is absent from vars(itk) / vars(ITKCommon) until first access -- the lazy attribute map is enumerable without forcing a SWIG load. - Factory hook: under DefaultFactoryLoading, accessing a class whose module declares a needed factory (ITKIOImageBase -> ImageIO) grows ObjectFactoryBase.GetRegisteredFactories(); the disabled path is already covered by nodefaultfactories.py. - stdlib pickle: pickle.loads(pickle.dumps(itk.ITKCommon)) returns the same instance, exercising the per-submodule __reduce_ex__ shim wired by _make_itk_lazy_submodule (a bare types.ModuleType is unpicklable on CPython 3.12+ without it). Remove unused symbols: - _lazy_submodule.py: docstring named the deleted LazyITKModule subclass; rephrased to describe the module's behavior directly. - multiprocess_lazy_loading.py: header comments used CamelCase "LazyLoading" as a symbol; rephrased to "lazy module loading" / "PEP 562 __getattr__ hook" while keeping the threading contract the test still enforces. - Wrapping/Generators/Python/CMakeLists.txt: directory-layout comment listed itk(...|LazyLoading|...).py as a static support file; updated to the actual filenames currently shipped. Comment- and docstring-only; no functional change. PythonLazyModule, PythonMultiprocessLazyLoad, and PythonLazyLoadingImage all still pass.
Add a section to the ITK 6 migration guide describing the removal of itkConfig.LazyLoading and the ITK_PYTHON_LAZYLOADING environment variable, which were dropped when the Python lazy-loading mechanism was rewritten on top of PEP 562. The section distinguishes per-symbol behavior: assignment to itkConfig.LazyLoading is silently ignored, any subsequent read raises AttributeError, and the environment variable is no longer consulted.
Via pixi run pre-commit-run
Two follow-ups from the Greptile review of PR 6183: * Drop the dead `loaded_modules: set[str]` declaration and its `loaded_modules.add(target)` write in `_make_itk_lazy_submodule`. The set was a vestige of `LazyITKModule.__getstate__`/`__setstate__`; the new `__reduce_ex__` shim delegates to `importlib.import_module` and never consults it. * Reword the migration-guide example for `itkConfig.LazyLoading`. The previous inline comment claimed the second line raised `AttributeError`, which was wrong: the preceding assignment had already set the attribute, so the read returned `False`. Split the example into separately commented write and read patterns and clarify that the `AttributeError` only occurs on a fresh `import itkConfig` with no prior assignment.
…heck assert is silently stripped under `python -O`, leaving the invariant unchecked. Replace with an explicit `if/raise` so the guard survives optimization mode.
Bare types.ModuleType has no reducer, so pickle.dumps(itk) raises TypeError: cannot pickle 'module' object on CPython 3.12+. Define a module-level __reduce_ex__ that delegates to importlib.import_module, matching the per-submodule shim. Cover the round-trip in Tests/lazy.py.
Synthetic submodules previously shipped with __loader__=None and no __spec__, which importlib.util.find_spec, inspect.getsourcefile, pkgutil, and IDE introspection tools treat as a broken module. Build a placeholder spec via importlib.util.spec_from_loader so the modules present a normal PEP 451 surface.
Removing the LazyLoading attribute outright is a hard API break:
downstream code that reads itkConfig.LazyLoading raises AttributeError
on import. Add a one-release shim:
- itkConfig.__getattr__ returns True for LazyLoading reads and emits
DeprecationWarning so legacy `if itkConfig.LazyLoading:` keeps
working.
- ITK_PYTHON_LAZYLOADING is no longer consulted, but its presence in
the environment now emits DeprecationWarning at itkConfig import
time so launch scripts get a single audible heads-up.
Both shims are intended for ITK 6.x and may be removed in 7.0.
PR InsightSoftwareConsortium#6183's first PEP 562 lazy-loading conversion serialised every first-touch SWIG load through one process-wide threading.RLock. When parallel-worker code (threading.Pool, joblib threading backend, dask local cluster) does first-touch `itk.X` from one thread and first-touch `itk.Y` from another, the second thread blocks until the first's load finishes — even though X and Y share no state. Replace the single `_lazy_load_lock` with a per-SWIG-module dict of RLocks created lazily on first lookup. Per-module serialisation is preserved (template registration and factory-loading hooks remain race-free for any single SWIG module), but unrelated modules now load in parallel. Also refactors module-attribute access from `globals()[name]` / `g.update(namespace)` to `getattr(this_module, name)` / `setattr(this_module, attr, value)`. Functionally equivalent (both ultimately update the same module `__dict__`) but avoids non-literal indexing of `globals()` that static analyzers (semgrep CWE-96) flag as a code-injection foot-gun. The same refactor lands on the per-submodule path in support/_lazy_submodule.py. The `_make_itk_lazy_submodule(...)` signature changes from `lazy_load_lock` (a single RLock) to `get_module_load_lock` (a callable returning the RLock for a given target SWIG module name) so the per-submodule `__getattr__` can lock on the actual target rather than the containing submodule.
Provides a debug escape hatch missing from PR InsightSoftwareConsortium#6183 after the deprecation of `itkConfig.LazyLoading`. When the user sets `ITK_EAGER_IMPORT` to `1`, `true`, `yes`, or `on` in the environment, `import itk` walks every SWIG module in the lazy-attribute map and triggers a single first-touch `__getattr__` on each. Any import-time failure (missing C-extension dependency, broken factory, version mismatch) surfaces synchronously at `import itk` rather than at the first `itk.<thing>` attribute access in user code, which is invaluable for triaging "why does my itk import fail in this Docker image?". Mirrors SPEC 1's `EAGER_IMPORT` convention used by `scientific-python/lazy-loader`, `scikit-image`, NetworkX, MNE-Python. Default behaviour is unchanged (env var unset => fully lazy as in PR InsightSoftwareConsortium#6183). Leaves the lazy machinery in place so subsequent accesses still hit the fast cached path.
PR InsightSoftwareConsortium#6183's motivation cites cold-start improvements from the PEP 562 conversion but ships zero quantitative measurements. PEP 810 (accepted for Python 3.15) sets the ecosystem expectation at 50-70% startup-time reduction; without an in-CI gate, ITK has no way to detect a future change that accidentally re-introduces an eager SWIG load at `import itk` time. This test runs `python -X importtime -c 'import itk'` in a subprocess, parses the cumulative cost from the bundled tracer's output, and asserts: - cumulative `import itk` time stays below 5000 ms - `len(sys.modules)` after `import itk` stays below 400 Both ceilings are intentionally generous so the test does not flap on slow CI runners; their job is to surface a hard regression (e.g., a stray `import itk.ITKCommon` at module top-level that would force the SWIG load) rather than gate ordinary noise. The `ITK_EAGER_IMPORT=0` env override on each subprocess defeats any caller-set EAGER_IMPORT mode so the benchmark always measures the lazy path.
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three quick-win polish commits stacked on PR #6183 (PEP 562 lazy-loading baseline). Closes the Tier-A gaps from the lazy-loading deep-dive: per-module locks (concurrency),
ITK_EAGER_IMPORTenv-var (debug ergonomics), and apython -X importtimebenchmark gate (regression safety).Stacked on #6183 — that PR's PEP 562 conversion must merge first.
Per-commit walkthrough
PERF: Use per-SWIG-module RLock instead of single global lazy lockPR #6183 serialised every first-touch SWIG load through one
process-wide
threading.RLock. Withthreading.Pool/ joblib /Dask local-cluster workers doing parallel first-touches of unrelated
SWIG modules, every load except the first was needlessly blocked.
The fix replaces the single
_lazy_load_lockwith a per-SWIG-moduledict of
RLockobjects (_module_load_locks), created lazily onfirst lookup via
_get_module_load_lock(module_name). Per-moduleserialisation is preserved (template registration and factory hooks
still race-free for any single SWIG module); unrelated modules now
load in parallel.
This commit also refactors module-attribute access from
globals()[name]/g.update(namespace)togetattr(this_module, name)/setattr(this_module, attr, value).Functionally equivalent (both ultimately update the same module
__dict__) but avoids non-literal indexing ofglobals()thatstatic analyzers (semgrep CWE-96) flag as a code-injection foot-gun.
_make_itk_lazy_submodule(...)signature changes fromlazy_load_lock(a singleRLock) toget_module_load_lock(acallable returning the
RLockfor a given target SWIG module name)so the per-submodule
__getattr__can lock on the actual targetrather than the containing submodule.
ENH: Add ITK_EAGER_IMPORT env var to force-load all SWIG modulesPR #6183 deprecated
itkConfig.LazyLoadingbut left users with noruntime escape hatch for debug workflows ("force-load every
submodule on import to find which one fails"). When
ITK_EAGER_IMPORTis set to1/true/yes/on,import itknowwalks every SWIG module in the lazy-attribute map and triggers a
single first-touch
__getattr__on each.Mirrors SPEC 1's
EAGER_IMPORTconvention used byscientific-python/lazy-loader,scikit-image, NetworkX, andMNE-Python. Default behaviour is unchanged (env var unset =>
fully lazy as in PR #6183). Leaves the lazy machinery in place so
subsequent accesses still hit the fast cached path.
ENH: Add PythonLazyImportTime cold-start benchmark gatePR #6183's motivation cites cold-start improvements but ships zero
quantitative measurements. PEP 810 (accepted for Python 3.15) sets
the ecosystem expectation at 50–70% startup-time reduction; without
an in-CI gate, ITK has no way to detect a future change that
accidentally re-introduces an eager SWIG load at
import itktime.Tests/lazy_importtime.pyrunspython -X importtime -c 'import itk'in a subprocess, parses the cumulative cost from CPython's bundled
tracer, and asserts:
import itktime stays below 5000 mslen(sys.modules)afterimport itkstays below 400Both ceilings are intentionally generous (so the test does not flap
on slow CI runners); their job is to surface a hard regression
(stray
import itk.ITKCommonat module top-level forcing a SWIGload) rather than gate ordinary noise. The
ITK_EAGER_IMPORT=0env override on each subprocess defeats any caller-set EAGER_IMPORT
mode so the benchmark always measures the lazy path.
What this PR does NOT address
The lazy-loading deep-dive identified seven gaps; this PR closes the
three Tier-A quick wins. The remaining four are out of scope:
.pyistub generation — biggest user-visible win (IDEautocomplete for ~10 000 ITK template instantiations) but a
multi-week wrapping-generator effort. Tracked in a follow-up.
__getattr__("Image")fires, theentire
ITKCommon.soloads. Real fix is splitting_module.soper-template-family. Substantial wrapping refactor.guide. Defer to any future ITK 6 doc PR.
documented in lazy-loader issue ENH: Added IndexRange for efficient region and grid space iteration #155. Either a sentinel-proxy
refactor (~50 lines, hard) or accept and document.