Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion GLOSSARY.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ A lesson can also end with a boss fight, which is a problem the text does not so
| C06 | [Four threads reading the same list](lessons/c06-reading-without-a-lock/c06.ipynb) | What replaced the GIL on the read path, and the thing that decides whether removing it helps at all. Reads of a list, a dict and a set are all lock free in 3.15 and all three do the same optimistic read, which is load the pointer, try to add one to the object's count and load the pointer again to see whether it moved, with the object's critical section kept as the retry rather than the road. Then the half nobody expects: once reads are lock free the bottleneck is the reference count itself, because four cores writing to one word take turns in hardware whatever Python does, so four threads reading a list of small integers get three and a half times the work of one thread and the same code over a list of ordinary objects gets a third of it. Immortal objects have no count to contend for, which is why the free threaded build immortalizes every constant it compiles, including strings and numbers the ordinary build leaves alone. And the storage a writer replaces cannot be freed while a reader might still be holding it, so it goes on a queue and is handed back at the periodic check from C05 | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/c06-reading-without-a-lock/c06.ipynb) |
| C07 | [The lock that comes back](lessons/c07-the-lock-that-comes-back/c07.ipynb) | Whether a build was compiled without the GIL and whether the GIL is on right now are two different questions, and the second one can change while your program runs. Four ways to ask for a lock state at startup, two on the command line and two in the environment, with an ordinary build refusing to start at all rather than quietly ignoring a request to drop the lock. Then the way it comes back on that nobody asked for: importing a compiled module turns the lock on before running its init function, because the runtime cannot ask whether a module is safe until after that function has run, and a module that never declared Py_mod_gil leaves the lock on for the rest of the process with one warning naming it. Behind the switch is a counter rather than a flag, and raising it from zero stops every thread first. The second half is the bill, eight single threaded workloads on both builds, slower without the lock on anything that touches many objects and faster on anything dominated by allocation, because removing the lock also swapped pymalloc for mimalloc | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/c07-the-lock-that-comes-back/c07.ipynb) |
| C08 | [Sending work to another interpreter](lessons/c08-sending-work-to-another-interpreter/c08.ipynb) | C04 made a second interpreter and found that the two share almost nothing, which leaves the question of how you give one of them a job. Interpreter.call sends a function by sending its code, so it only works on a function that reads no globals and closes over nothing, which is why a recursive function written at the top level will not cross. Arguments go through a chain of three routes tried in order, a purpose written one for a short list of types, then the function route, then pickle, and a lambda proves the middle one exists by failing the other two and crossing anyway. An exception from over there arrives as an ExecutionFailed carrying a snapshot rather than the exception itself, so an existing except clause will not catch what it looks like it catches. A compiled module has to declare that it is safe in more than one interpreter and the import is refused outright if it says nothing, which is stricter than the equivalent rule for the lock. Then the cost: a queue does over a million round trips a second for a small int and about a hundred thousand for a hundred item list, and two workloads split three ways on both builds show interpreters winning the compute job by two and a half times on a build with the lock, losing it to plain threads on a build without one, and losing the data heavy job by forty two times on both | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/c08-sending-work-to-another-interpreter/c08.ipynb) |
| R01 | [Before your first line](lessons/r01-before-your-first-line/r01.ipynb) | Everything the interpreter has already done by the time your first statement runs. A fresh interpreter has a few dozen modules in sys.modules and, with site out of the way, not one of them was read from a file, because they are either C compiled into the binary or Python bytecode frozen into it. Startup comes in two halves on purpose, and the first half has no import system and no sys.path, which is why a configuration mistake comes out as a fatal error with a plain C string rather than as a traceback. Settings arrive from the command line, the environment and the embedder, and the answer is the highest number anybody asked for rather than whichever one is nearest, so PYTHONOPTIMIZE=2 is not undone by passing -O. sys.path itself is produced by a Python program that is frozen into the binary and handed eleven C functions to stand in for the os.path it cannot import, and its output always includes a zip file that usually does not exist. The front of sys.path is pushed on last, after startup is over, which is the whole mechanism behind a file called random.py shadowing the standard library, and -P turns it off. Two recordings then compare a release build against a debug one and find that the extra time is mostly not the assertions, it is the fourteen modules a debug build reads off the disk because it turns frozen modules off | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/r01-before-your-first-line/r01.ipynb) |

More are landing in order. [lessons/README.md](lessons/README.md) explains how one is put together and how to run them locally.

Expand Down
75 changes: 75 additions & 0 deletions citations.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1375,16 +1375,56 @@
"first_line": "static PyObject *",
"lines": 14
},
"Modules/getpath.c:560-582@v3.15.0rc1": {
"digest": "bacacbf121ce39c5",
"first_line": "#endif",
"lines": 23
},
"Modules/getpath.c:830-840@v3.15.0rc1": {
"digest": "30c0ddbe82894ce3",
"first_line": "",
"lines": 11
},
"Modules/getpath.c:855-894@v3.15.0rc1": {
"digest": "0f1a2db7ace0f0ec",
"first_line": "PyStatus",
"lines": 40
},
"Modules/main.c:37-76@v3.15.0rc1": {
"digest": "0290f26f3c232762",
"first_line": "pymain_init(const _PyArgv *args)",
"lines": 40
},
"Modules/main.c:446-446@v3.15.0rc1": {
"digest": "d5c57b74dc318fbb",
"first_line": "pymain_run_file(const PyConfig *config)",
"lines": 1
},
"Modules/main.c:657-696@v3.15.0rc1": {
"digest": "3b8b28f1f23a697a",
"first_line": "static void",
"lines": 40
},
"Modules/main.c:696-728@v3.15.0rc1": {
"digest": "38ac6f2f84817165",
"first_line": "PyObject *path0 = NULL;",
"lines": 33
},
"Modules/main.c:830-852@v3.15.0rc1": {
"digest": "b15e6301c9c95772",
"first_line": "int",
"lines": 23
},
"Modules/main.c:831-852@v3.15.0rc1": {
"digest": "2980a854547fda9d",
"first_line": "Py_RunMain(void)",
"lines": 22
},
"Modules/main.c:855-868@v3.15.0rc1": {
"digest": "8c43d6304d18923c",
"first_line": "static int",
"lines": 14
},
"Modules/posixmodule.c:8660-8672@v3.15.0rc1": {
"digest": "3f3960989b85426a",
"first_line": "if (_PyInterpreterState_GetFinalizing(interp) != NULL) {",
Expand Down Expand Up @@ -4210,6 +4250,11 @@
"first_line": "int",
"lines": 21
},
"Python/initconfig.c:1193-1201@v3.15.0rc1": {
"digest": "0cd6d8c3fdac2d2a",
"first_line": "#ifdef MS_WINDOWS",
"lines": 9
},
"Python/initconfig.c:1970-1990@v3.15.0rc1": {
"digest": "19e00e6a2eee5295",
"first_line": "static PyStatus",
Expand All @@ -4220,6 +4265,21 @@
"first_line": "config_read_gil(PyConfig *config, size_t len, wchar_t first_char)",
"lines": 30
},
"Python/initconfig.c:1995-2020@v3.15.0rc1": {
"digest": "a936b285993c1090",
"first_line": "{",
"lines": 26
},
"Python/initconfig.c:2849-2866@v3.15.0rc1": {
"digest": "ea23f8c25d79e425",
"first_line": "/* -X frozen_modules=[on|off] */",
"lines": 18
},
"Python/initconfig.c:3210-3222@v3.15.0rc1": {
"digest": "cf83f51eca363edc",
"first_line": "case 'E':",
"lines": 13
},
"Python/instrumentation.c:1190-1216@v3.15.0rc1": {
"digest": "cea5143d2a582d2a",
"first_line": "size_t nargsf = (size_t) nargs | PY_VECTORCALL_ARGUMENTS_OFFSET;",
Expand Down Expand Up @@ -4335,6 +4395,21 @@
"first_line": "Logically, all symbols begin as UNKNOWN, and can transition downwards along the",
"lines": 24
},
"Python/preconfig.c:560-578@v3.15.0rc1": {
"digest": "83631839f7dcfc98",
"first_line": "}",
"lines": 19
},
"Python/pylifecycle.c:1150-1172@v3.15.0rc1": {
"digest": "b9c5af5cdd32a97b",
"first_line": "",
"lines": 23
},
"Python/pylifecycle.c:1589-1606@v3.15.0rc1": {
"digest": "f26bda3613cf2a5c",
"first_line": "static PyStatus",
"lines": 18
},
"Python/pylifecycle.c:1609-1639@v3.15.0rc1": {
"digest": "332183fc478125a5",
"first_line": "PyStatus",
Expand Down
2 changes: 2 additions & 0 deletions experiments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ So those programs run somewhere else. They run in the images this project publis
| [c07-what-one-thread-pays-with-the-lock](tier1/c07-what-one-thread-pays-with-the-lock.md) | C07 | release | What do the same eight workloads cost on a build that kept the lock? |
| [c08-three-ways-to-split-the-work](tier1/c08-three-ways-to-split-the-work.md) | C08 | release | Does handing work to another interpreter pay, and what decides whether it does? |
| [c08-three-ways-without-the-lock](tier1/c08-three-ways-without-the-lock.md) | C08 | freethreaded | Once plain threads run in parallel too, is there anything left for interpreters? |
| [r01-what-startup-costs](tier1/r01-what-startup-costs.md) | R01 | release | How much is already loaded before your program starts, and how long did that take? |
| [r01-what-startup-costs-on-a-debug-build](tier1/r01-what-startup-costs-on-a-debug-build.md) | R01 | debug | Does a debug build do more work at startup, or the same work more slowly? |

## The commands

Expand Down
125 changes: 125 additions & 0 deletions experiments/tier1/r01-what-startup-costs-on-a-debug-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# The same startup on a build with the assertions left in

Generated by `just build-tier1`. Do not edit by hand, the change will be overwritten.

Does a debug build do more work at startup, or the same work more slowly?

- Lesson: R01
- Build: debug
- Image: ghcr.io/tamnd/cpython-internals/cpython:debug@sha256:7baea8f3dd4de2e4c3b020543729b147e636494ae9758dabffb4675793e37170
- Interpreter: 3.15.0rc1 (37e98da:37e98da, Aug 29 2026, 09:24:21) [GCC 14.2.0]
- Recorded: 2026-09-06

Why this needs the debug build: it needs an interpreter configured with --with-pydebug, and it has to come out of the same image pipeline as the release half or the two sets of timings cannot be compared.

## The program

```python
"""What has already happened before your first line runs, and what it cost.

Nothing here is about your code. Every number is the interpreter getting itself ready: reading a
configuration, building a runtime, creating the main interpreter, importing the modules it cannot
run without, working out where the standard library is, and importing site.

The module counts come from a child process asking itself what is in sys.modules before it does
anything. The timings are the fastest of twenty runs of a child that does nothing at all, so what
is being measured is startup and only startup.

Running this on a debug build as well as a release one is the point. The work is identical, the
counts come out the same, and the clock does not, which puts a number on what the assertions and
the reference count bookkeeping cost before your program even begins.
"""

import subprocess
import sys
import time

COUNT = """
import sys
kinds = {"built-in": 0, "frozen": 0, "from a file": 0}
for module in sys.modules.values():
origin = getattr(getattr(module, "__spec__", None), "origin", None)
if origin in ("built-in", "frozen"):
kinds[origin] += 1
elif origin is not None:
kinds["from a file"] += 1
print(len(sys.modules), kinds["built-in"], kinds["frozen"], kinds["from a file"])
"""

WAYS = [
("everything", []),
("no site", ["-S"]),
("isolated and no site", ["-I", "-S"]),
]


def child(flags, code):
"""Run a fresh interpreter with those flags and hand back what it printed."""
done = subprocess.run(
[sys.executable, *flags, "-c", code], capture_output=True, text=True, check=True
)
return done.stdout.strip()


def best(flags, rounds=20):
"""The fastest of a few runs, which is the honest number on a shared machine."""
fastest = None
for _ in range(rounds):
started = time.perf_counter()
subprocess.run([sys.executable, *flags, "-c", "pass"], capture_output=True, check=True)
taken = time.perf_counter() - started
fastest = taken if fastest is None else min(fastest, taken)
return fastest


def import_cost():
"""Add up the self times that -X importtime prints, in milliseconds."""
done = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "pass"], capture_output=True, text=True
)
total = 0
for line in done.stderr.splitlines():
parts = line.split("|")
if len(parts) == 3 and parts[0].startswith("import time:"):
head = parts[0].removeprefix("import time:").strip()
if head.isdigit():
total += int(head)
return total / 1000


print(f"~ this is a debug build: {hasattr(sys, 'gettotalrefcount')}")

for label, flags in WAYS:
total, builtin, frozen, files = child(flags, COUNT).split()
print(f"modules at the first line, {label}: {total}")
print(f" of those, built into the binary: {builtin}")
print(f" of those, frozen bytecode: {frozen}")
print(f" of those, read from a file on disk: {files}")

for label, flags in WAYS:
print(f"~ starting up, {label}: {best(flags) * 1000:.1f} ms")

print(f"~ of that, spent importing: {import_cost():.1f} ms")
```

## What it printed

```text
~ this is a debug build: True
modules at the first line, everything: 33
of those, built into the binary: 15
of those, frozen bytecode: 3
of those, read from a file on disk: 14
modules at the first line, no site: 21
of those, built into the binary: 12
of those, frozen bytecode: 3
of those, read from a file on disk: 5
modules at the first line, isolated and no site: 21
of those, built into the binary: 12
of those, frozen bytecode: 3
of those, read from a file on disk: 5
~ starting up, everything: 56.5 ms
~ starting up, no site: 35.7 ms
~ starting up, isolated and no site: 33.9 ms
~ of that, spent importing: 30.8 ms
```
125 changes: 125 additions & 0 deletions experiments/tier1/r01-what-startup-costs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# What the interpreter has already done before your first line, on a release build

Generated by `just build-tier1`. Do not edit by hand, the change will be overwritten.

How much is already loaded before your program starts, and how long did that take?

- Lesson: R01
- Build: release
- Image: ghcr.io/tamnd/cpython-internals/cpython:release@sha256:fb55d6afcf053c974de6447fafbd2be6af20cdb9f596e25a0445607b8af981e3
- Interpreter: 3.15.0rc1 (37e98da:37e98da, Aug 29 2026, 09:24:54) [GCC 14.2.0]
- Recorded: 2026-09-06

Why this needs the release build: it starts twenty child interpreters and takes the fastest, so it needs a machine that is not doing anything else and an interpreter installed the ordinary way rather than a source tree, because the path search is part of what is being timed.

## The program

```python
"""What has already happened before your first line runs, and what it cost.

Nothing here is about your code. Every number is the interpreter getting itself ready: reading a
configuration, building a runtime, creating the main interpreter, importing the modules it cannot
run without, working out where the standard library is, and importing site.

The module counts come from a child process asking itself what is in sys.modules before it does
anything. The timings are the fastest of twenty runs of a child that does nothing at all, so what
is being measured is startup and only startup.

Running this on a debug build as well as a release one is the point. The work is identical, the
counts come out the same, and the clock does not, which puts a number on what the assertions and
the reference count bookkeeping cost before your program even begins.
"""

import subprocess
import sys
import time

COUNT = """
import sys
kinds = {"built-in": 0, "frozen": 0, "from a file": 0}
for module in sys.modules.values():
origin = getattr(getattr(module, "__spec__", None), "origin", None)
if origin in ("built-in", "frozen"):
kinds[origin] += 1
elif origin is not None:
kinds["from a file"] += 1
print(len(sys.modules), kinds["built-in"], kinds["frozen"], kinds["from a file"])
"""

WAYS = [
("everything", []),
("no site", ["-S"]),
("isolated and no site", ["-I", "-S"]),
]


def child(flags, code):
"""Run a fresh interpreter with those flags and hand back what it printed."""
done = subprocess.run(
[sys.executable, *flags, "-c", code], capture_output=True, text=True, check=True
)
return done.stdout.strip()


def best(flags, rounds=20):
"""The fastest of a few runs, which is the honest number on a shared machine."""
fastest = None
for _ in range(rounds):
started = time.perf_counter()
subprocess.run([sys.executable, *flags, "-c", "pass"], capture_output=True, check=True)
taken = time.perf_counter() - started
fastest = taken if fastest is None else min(fastest, taken)
return fastest


def import_cost():
"""Add up the self times that -X importtime prints, in milliseconds."""
done = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "pass"], capture_output=True, text=True
)
total = 0
for line in done.stderr.splitlines():
parts = line.split("|")
if len(parts) == 3 and parts[0].startswith("import time:"):
head = parts[0].removeprefix("import time:").strip()
if head.isdigit():
total += int(head)
return total / 1000


print(f"~ this is a debug build: {hasattr(sys, 'gettotalrefcount')}")

for label, flags in WAYS:
total, builtin, frozen, files = child(flags, COUNT).split()
print(f"modules at the first line, {label}: {total}")
print(f" of those, built into the binary: {builtin}")
print(f" of those, frozen bytecode: {frozen}")
print(f" of those, read from a file on disk: {files}")

for label, flags in WAYS:
print(f"~ starting up, {label}: {best(flags) * 1000:.1f} ms")

print(f"~ of that, spent importing: {import_cost():.1f} ms")
```

## What it printed

```text
~ this is a debug build: False
modules at the first line, everything: 33
of those, built into the binary: 15
of those, frozen bytecode: 17
of those, read from a file on disk: 0
modules at the first line, no site: 21
of those, built into the binary: 12
of those, frozen bytecode: 8
of those, read from a file on disk: 0
modules at the first line, isolated and no site: 21
of those, built into the binary: 12
of those, frozen bytecode: 8
of those, read from a file on disk: 0
~ starting up, everything: 26.5 ms
~ starting up, no site: 20.1 ms
~ starting up, isolated and no site: 21.0 ms
~ of that, spent importing: 11.6 ms
```
Loading
Loading