Skip to content

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
hjmjohnson:lazy-loading-polish
Closed

PERF/ENH: Polish PR #6183 PEP 562 lazy loading (per-module locks, EAGER_IMPORT, importtime gate)#6210
hjmjohnson wants to merge 18 commits intoInsightSoftwareConsortium:mainfrom
hjmjohnson:lazy-loading-polish

Conversation

@hjmjohnson
Copy link
Copy Markdown
Member

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_IMPORT env-var (debug ergonomics), and a python -X importtime benchmark 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 lock

PR #6183 serialised every first-touch SWIG load through one
process-wide threading.RLock. With threading.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_lock with a per-SWIG-module
dict of RLock objects (_module_load_locks), created lazily on
first lookup via _get_module_load_lock(module_name). Per-module
serialisation 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) 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.

_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.

ENH: Add ITK_EAGER_IMPORT env var to force-load all SWIG modules

PR #6183 deprecated itkConfig.LazyLoading but left users with no
runtime escape hatch for debug workflows ("force-load every
submodule on import to find which one fails"). When
ITK_EAGER_IMPORT is set to 1/true/yes/on, import itk now
walks every SWIG module in the lazy-attribute map and triggers a
single first-touch __getattr__ on each.

Mirrors SPEC 1's EAGER_IMPORT convention used by
scientific-python/lazy-loader, scikit-image, NetworkX, and
MNE-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 gate

PR #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 itk time.

Tests/lazy_importtime.py runs python -X importtime -c 'import itk'
in a subprocess, parses the cumulative cost from CPython's bundled
tracer, 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
(stray import itk.ITKCommon at module top-level forcing a 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.

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:

  • .pyi stub generation — biggest user-visible win (IDE
    autocomplete for ~10 000 ITK template instantiations) but a
    multi-week wrapping-generator effort. Tracked in a follow-up.
  • First-touch SWIG cost — when __getattr__("Image") fires, the
    entire ITKCommon .so loads. Real fix is splitting
    _module.so per-template-family. Substantial wrapping refactor.
  • PEP 810 forward-compat doc — one paragraph in the migration
    guide. Defer to any future ITK 6 doc PR.
  • Tab-completion forces full lazy load — known anti-pattern
    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.

thewtex and others added 18 commits May 4, 2026 18:13
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.
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.
@github-actions github-actions Bot added type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots area:Python wrapping Python bindings for a class type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct area:Filtering Issues affecting the Filtering module area:Documentation Issues affecting the Documentation module labels May 4, 2026
@hjmjohnson
Copy link
Copy Markdown
Member Author

Folded into #6183 — the 3 polish commits (per-module locks, ITK_EAGER_IMPORT, importtime benchmark gate) cherry-picked onto thewtex/python-lazy-mechanism. New PR #6183 head: d4d359e1e5. Keeping the lazy-loading work as a single self-contained PR for reviewer context.

@hjmjohnson hjmjohnson closed this May 4, 2026
@hjmjohnson hjmjohnson deleted the lazy-loading-polish branch May 4, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Documentation Issues affecting the Documentation module area:Filtering Issues affecting the Filtering module area:Python wrapping Python bindings for a class type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants