Skip to content

Add R04, frozen modules - #182

Merged
tamnd merged 2 commits into
mainfrom
r04-frozen-modules
Sep 5, 2026
Merged

Add R04, frozen modules#182
tamnd merged 2 commits into
mainfrom
r04-frozen-modules

Conversation

@tamnd

@tamnd tamnd commented Sep 5, 2026

Copy link
Copy Markdown
Owner

R04, the fourth of the nine runtime lessons. R03 finished on a fact it did not explain: import os never opens os.py, because FrozenImporter is asked before PathFinder and says yes first. This lesson is about why that module was already in the binary.

The problem it solves is a real chicken and egg. The import system is written in Python, in Lib/importlib/_bootstrap.py, so there is no way to import it. CPython gets around that by compiling that file during its own build, marshalling the code object and writing the bytes out as a C array that the C compiler puts in the binary. The evidence is in the bytecode, and the first cell goes and looks: the module body of _frozen_importlib has zero IMPORT_NAME opcodes in it, which is what makes it loadable with nothing running, while _frozen_importlib_external, which loads second and by then has an import system to use, has eight. init_importlib hands sys and _imp to it as arguments, which is why _bootstrap.py never writes import sys anywhere.

Ten code cells. The thirty three frozen names split into the three arrays frozen.c keeps them in, which the flag treats differently: three for the import system that no setting can remove, nineteen for what a bare startup needs, eleven hello world modules for the test suite. A frozen code object pulled straight out of the binary with _imp.get_frozen_object and weighed. os asked five questions about where it came from, answering <frozen os> to one of them and a real path on disk to another. The flag switched off inside the running interpreter with _imp._override_frozen_modules_for_tests, so you can watch os move from FrozenImporter to SourceFileLoader and back without leaving the notebook. Four steps of a module load timed separately. A startup run twice under -v, counting the files the second one reads that the first one did not.

Three things worth calling out.

A frozen module knows perfectly well which file it would have opened. FrozenImporter.find_spec works the path out from sys._stdlib_dir and parks it on the spec as loader_state, and the loader then copies it onto __file__. So __spec__.origin says frozen, __spec__.cached says None, and __file__ says /some/path/os.py, and all three are true at once. That is the whole reason a traceback through frozen code is readable. When linecache sees a filename that starts with <frozen , it ignores it and reads __file__ out of the module globals it was handed instead.

The cost is not where you would guess. It is tempting to think frozen wins because unmarshalling from memory beats unmarshalling from a file, but both paths end in the same marshal.loads over the same bytes and that call is most of the bill. What freezing removes is the finder search and the file read in front of it. The timing cell measures those four steps separately rather than comparing two totals, and the -v cell shows that every file the second startup reads is a .pyc that had already been compiled, so freezing is not saving compilation either.

The two builds disagree about the default, and finding that out is what shaped the experiment. initconfig.c sets use_frozen_modules to zero under Py_DEBUG, so a debug build ships with frozen modules off unless you ask for them. The first version of the Tier 1 program relied on the default and died on the debug build with No such frozen object named 'os'. The rewritten one never relies on it: it asks for on and off explicitly on every child process and reports what the default happens to be as one more measured fact. Freezing gives back 14.2 percent of a release startup and 9.9 percent of a debug one.

Both recordings alternate the two cases round by round rather than running one case forty times and then the other, which is the same lesson C07 and R03 learned the hard way. An early version of the same comparison read 7936 against 14898 microseconds cold and 5910 against 6926 warm, which is a factor of two of pure page cache.

One cell had to be rewritten after the numbers refused to reproduce. It originally claimed loading frozen cost about half of reading the .pyc, which a standalone probe agreed with at 274.9 against 578.9 microseconds. Under a real IPython kernel the same measurement came back 438.0 against 432.8, a ratio of 1.0. The gap was noise around the unmarshal, which is identical on both paths, so the cell and the claim were both rewritten around what the numbers actually support.

Four new glossary terms: frozen module, import bootstrap, loader state and module alias. Six diagrams and twenty one citations. Both READMEs updated. GLOSSARY.md is 226 terms and CLAIMS.md is 589 claims across 75 lessons.

Nine of the ten cells run end to end in a browser. The last one needs a second process, so it prints a line saying so instead.

Part of #27.

The fourth runtime lesson. R03 ended on the fact that import os never opens
os.py, because FrozenImporter gets asked before PathFinder, so this one goes
after the modules that live inside the binary. Ten code cells, twenty one
citations, six diagrams, four glossary terms and two Tier 1 recordings.

The spine is the chicken and egg problem. The import system is written in
Python, in Lib/importlib/_bootstrap.py, so it cannot be imported. CPython cuts
the loop by compiling that file during its own build, marshalling the code
object and writing the bytes into the binary as a C array. The proof that this
works is in the bytecode: the module body of _frozen_importlib contains zero
IMPORT_NAME opcodes, while _frozen_importlib_external, which is loaded second
and by then has an import system, contains eight. init_importlib hands sys and
_imp in as arguments, which is why _bootstrap.py never writes import sys.

A stock 3.15 build freezes thirty three names in three arrays that the flag
treats differently. Three are the import system and no setting can remove them,
because look_up_frozen walks that array unconditionally and only the other two
sit behind a check. Nineteen are what a bare startup needs. Eleven are hello
world modules for the test suite.

A frozen module still knows where it came from. FrozenImporter.find_spec works
the source path out from sys._stdlib_dir and parks it on the spec as
loader_state, and the loader copies it onto __file__, so the spec says frozen
and __file__ says a real path and both are true. That is what makes a traceback
through frozen code readable: linecache sees a filename starting with <frozen
and reads __file__ out of the globals it was handed instead.

The cost story is not the one you would guess. Both paths end in the same
marshal.loads over the same bytes, and that call is most of the bill. What
freezing removes is the finder search and the file read in front of it, which
the timing cell separates into four steps rather than comparing two totals. The
first version of that cell claimed frozen loading cost half as much, which was
measurement noise and did not reproduce under a real kernel.

The two recordings run the same program on a release build and a debug build,
and the interesting part is that the builds disagree about the default.
initconfig.c turns frozen modules off under Py_DEBUG, so the program was
rewritten to ask for on and off explicitly and report the default as a fact.
Freezing gives back 14.2 percent of a release startup and 9.9 percent of a
debug one. Both measurements alternate the two cases round by round, because
running one case forty times and then the other measures the page cache.

New glossary terms: frozen module, import bootstrap, loader state and module
alias. GLOSSARY.md is 226 terms, CLAIMS.md is 589 claims across 75 lessons, and
citations.lock.json is 1000 entries.

Part of #27.
@tamnd tamnd added this to the M8 Concurrency and runtime milestone Sep 5, 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 5, 2026
CPython prints # code object from with a repr when it loads a cached pyc
and without one when it compiles the source instead, and the cell only
handled the first spelling, so it raised IndexError on a checkout with no
cached bytecode next to the standard library. Take the marker off the
front and strip the quotes if there are any, work the package name out
from either an __init__.py or a __pycache__ directory, and print the
suffix in the listing so the difference is visible rather than assumed.
The claim now says every file holds a frozen name, with the pyc part
conditional on the install.
@tamnd
tamnd merged commit 423db72 into main Sep 5, 2026
16 checks passed
@tamnd
tamnd deleted the r04-frozen-modules branch September 5, 2026 23:56
@tamnd tamnd mentioned this pull request Sep 5, 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