Tutorial: make imports lazy
A big program can spend a surprising fraction of its startup time importing
modules it may never actually use on a given run. In this tutorial you’ll follow
the design of the shipped lazy_imports.py tracer, which defers every
import until the imported name is first used — turning eager import cost into
pay-per-use.
Because lazy imports only make sense for real modules loaded through the import
system (a name bound by import x as y becomes a fast local under
pyccolo.exec(), so this tracer is meant for import-time instrumentation or
the pyc CLI — see Tracing real programs), the snippets here are
illustrative excerpts of pyccolo/examples/lazy_imports.py rather than runnable
doctests.
The plan
When an
importstatement is about to run, don’t run it. Instead bind a lightweight placeholder to each imported name and skip the statement.When one of those names is first loaded, resolve it for real — import the module, cache it, and hand back the real object.
Use a local guard so that resolving costs one event per name, not one per use.
Step 1: a placeholder for an unresolved import
The placeholder just remembers what it stands for and imports it on demand:
import importlib
import pyccolo as pyc
class _LazySymbol:
def __init__(self, module_name):
self.module_name = module_name
self._resolved = None
def unwrap(self):
if self._resolved is None:
with pyc.allow_reentrant_event_handling():
self._resolved = importlib.import_module(self.module_name)
return self._resolved
allow_reentrant_event_handling matters: the import runs instrumented code, and
without it the tracer would refuse to re-enter its own handlers.
Step 2: intercept the import statement
A before_stmt handler, statically gated to import statements, binds a
_LazySymbol for each name and returns pyc.Pass so the real import never
executes:
@pyc.before_stmt(
when=pyc.Predicate(
lambda node: isinstance(node, (ast.Import, ast.ImportFrom))
and pyc.is_outer_stmt(node),
static=True,
)
)
def defer_import(self, _ret, node, frame, *_, **__):
for alias in node.names:
name = alias.asname or alias.name
frame.f_globals[name] = _LazySymbol(alias.name)
return pyc.Pass # skip the actual import
The static=True predicate is resolved during the rewrite — whether a statement
is an import is decidable from syntax alone — so this handler adds nothing at
runtime except on the import lines themselves.
Step 3: resolve on first use
Now catch the first load of a deferred name. The guard= argument names a
per-variable guard so that, once resolved, later loads of the same name run
uninstrumented:
@pyc.init_module
def init(self, _ret, node, frame, *_, **__):
for guard in self.local_guards_by_module_id.get(id(node), []):
frame.f_globals[guard] = False
@pyc.load_name(guard=lambda node: f"{pyc.PYCCOLO_BUILTIN_PREFIX}_{node.id}")
def resolve(self, ret, node, frame, _evt, guard, *_, **__):
frame.f_globals[guard] = True # never fire for this name again
if isinstance(ret, _LazySymbol):
real = ret.unwrap()
frame.f_globals[node.id] = real # replace the placeholder for good
return real
return ret
The first time math (say) is loaded, ret is the _LazySymbol; we import
the module, rebind the global, activate the guard, and return the real module. The
guard means every subsequent math.* reference is a plain, uninstrumented global
lookup.
Putting it to work
Point the tracer at a module through the import machinery and its imports become lazy:
from pyccolo.examples.lazy_imports import LazyImportTracer
with LazyImportTracer.instance():
import my_slow_starting_app # its top-level imports are now deferred
my_slow_starting_app.main() # modules resolve as `main` touches them
Or run a whole script this way from the command line:
pyc my_app.py -t pyccolo.examples.lazy_imports.ScriptLazyImportTracer
The full example handles from x import y, relative imports, star imports
(which it declines to make lazy), attribute and subscript chains, and more — but
every path is a variation on the three steps above: defer, then resolve once on
first use.
Where to next
The local-guard technique that keeps resolution to one event per name is covered in Guards and performance; the import-time instrumentation that makes this tracer apply to real modules is in Tracing real programs.