Skip to content

Add R05, lazy imports - #183

Merged
tamnd merged 1 commit into
mainfrom
r05-lazy-imports
Sep 6, 2026
Merged

Add R05, lazy imports#183
tamnd merged 1 commit into
mainfrom
r05-lazy-imports

Conversation

@tamnd

@tamnd tamnd commented Sep 6, 2026

Copy link
Copy Markdown
Owner

R05 is the lazy imports lesson, and it is the fifth of the runtime lessons on M8. PEP 810 landed in 3.15 and added one soft keyword to the import statement, so lazy import json binds the name straight away and leaves the finding, reading and running of the module until the first time something reads that name back.

The lesson starts by weighing what an unused import costs, then shows the five line LazyLoader dance you had to write before 3.15 and why it wakes up on any attribute read at all, including __name__. From there it goes down into the implementation. Four import statements are disassembled to show that a lazy import compiles to the same IMPORT_NAME opcode as a plain one, with the name index shifted up by two bits and the two bits underneath carrying the mode, which is why dis prints json, json + lazy or json + eager.

The middle of the lesson works in a temporary directory on sys.path, so every module it looks at is one we wrote in the cell above and nothing else in the process has already imported it. That is what lets the notebook show the placeholder honestly: it is a five field object rather than a module, it is not in sys.modules, its name is in sys.lazy_modules until it resolves, and dir on it offers exactly one public name. Only two opcodes know about it, so a dict lookup, a membership test and a repr all leave it alone, and a placeholder copied into another variable resolves when that variable is read rather than when the copy was made.

Six spellings of the keyword are compiled to see which are refused and with what message. Four of them come from the symbol table rather than the code generator, which is the reason a lazy import inside a try block gets its own error rather than a generic one. The lesson then turns the behaviour on without the keyword, once with sys.set_lazy_imports_filter and once with a __lazy_modules__ list, and shows what the filter is actually handed. After that it makes a module raise during resolution and reads the two part traceback that comes back, where the ImportError cause is rebuilt from the code object and instruction offset the placeholder was carrying.

The last two sections are the measured ones. r05-what-deferring-an-import-is-worth puts the startup saving at about three quarters of a run for a file that imports twelve modules and uses one, with 54 modules in sys.modules rather than 189 and 15 code objects read off disk rather than 110.

The finding I did not expect is in the last section. Waking a placeholder does not take the per module name lock that an ordinary import takes. _PyImport_LoadLazyImportTstate takes the interpreter wide import lock and holds it for the whole resolution, module body included, which the source itself calls serialising reification. Two recordings on a free threaded build show what that costs: four threads importing four different modules the ordinary way keep 3.60 cores busy, and four threads waking four different deferred imports keep 0.98. That is the one unobservable claim the lesson leans on, and both recordings are checked in.

Everything lazy is 3.15 only, CI runs notebooks on 3.14 and Pyodide is 3.14.2, so every lazy cell is guarded by hasattr(sys, "lazy_modules") and prints one honest line on the else branch instead of going silent. The cells compile their sample source at run time and execute it with a single namespace dict, which is what is_lazy_import_module_level checks for, so a module level statement can run inside a notebook cell at all.

This also carries a fix to R03 that has nothing to do with lazy imports. Re-recording the browser probe turned up r03-17 failing with ModuleNotFoundError: No module named 'three', on a lesson this branch does not otherwise touch. The Emscripten filesystem keeps whole second timestamps, so the directory mtime does not always move when the cell writes a new file into it, and FileFinder keeps serving its stale listing. The cell now falls back to dropping the finder out of sys.path_importer_cache and says so in the prose. It is an intermittent failure that would have gone red in CI at random, and one probe recording covers both changes, so splitting it out would have meant two ten minute probe runs for no gain.

Also here: six diagrams, three glossary terms (lazy import, import placeholder, global import lock), rows in both READMEs, and the refreshed citation lock, claims list and probe recording.

Checked locally with the full just check, which is green, plus just versions with no stale notes and a full probe re-record where all 76 lessons run end to end on Pyodide.

PEP 810 landed in 3.15 and added one soft keyword to the import
statement. `lazy import json` binds the name now and leaves the finding,
reading and running of the module until the first time something reads
that name back.

The lesson weighs what an unused import costs, shows the five line
LazyLoader dance you had to write before 3.15, then disassembles four
import statements to show that a lazy import compiles to the same
IMPORT_NAME opcode with the mode carried in two spare bits of the
argument. It inspects the placeholder without resolving it, resolves it
with a single bare name read, watches a copy of one escape into another
variable, compiles six spellings of the keyword to see which the symbol
table refuses, turns the behaviour on without the keyword with both a
filter and a `__lazy_modules__` list, and reads the two part traceback a
module raises during resolution.

Three recordings back the measured parts. Deferring is worth about three
quarters of a run on a file that imports twelve modules and uses one.
The other two show that waking a placeholder takes the interpreter wide
import lock rather than a lock per module name, so on a free threaded
build four threads waking four different deferred imports keep 0.98
cores busy where four ordinary imports keep 3.60.

Every lazy cell is guarded, because CI runs notebooks on 3.14 and
Pyodide is 3.14.2.

Also fixes an intermittent R03 failure the probe re-record turned up.
The Emscripten filesystem keeps whole second timestamps, so a directory
mtime does not always move when a file is written into it and
FileFinder keeps serving a stale listing. The cell now falls back to
dropping the finder out of sys.path_importer_cache.

With six diagrams, three glossary terms, both README rows, and the
refreshed citation lock, claims list and probe recording.
@tamnd tamnd added this to the M8 Concurrency and runtime milestone Sep 6, 2026
@tamnd tamnd added kind/lesson A chapter: prose, notebook, experiments, boss fight area/runtime Startup, shutdown, import, extension modules and the C API labels Sep 6, 2026
@tamnd
tamnd merged commit f605113 into main Sep 6, 2026
16 checks passed
@tamnd
tamnd deleted the r05-lazy-imports branch September 6, 2026 02:01
@tamnd tamnd mentioned this pull request Sep 6, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Startup, shutdown, import, extension modules and the C API kind/lesson A chapter: prose, notebook, experiments, boss fight

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant