How Python 3.15 Watches Itself Run, For Almost Free

2 months ago

To make Python faster, its new compiler watches the interpreter run. But watching a running program normally slows the thing you measure. This one barely does.

Ask

Ask about this presentation

Answers are generated from this presentation.

Chapters

  1. 0:00How Python 3.15 Watches Itself Run, For Almost Free
  2. 0:09Its new JIT records the interpreter, then compiles the path.
  3. 0:24Three ways to watch. Two of them are too slow.
  4. 0:38Keep two dispatch tables. Swap one pointer.
  5. 1:10On a toy benchmark: at most 4.5× — profiling and compiling together.
  6. 1:23The cost didn't vanish. It moved.
  7. 1:37When you must watch every step, do not check at every step.
Show transcript

How Python 3.15 Watches Itself Run, For Almost Free

The new JIT profiles the interpreter to decide what to compile, at almost no runtime cost
CPython experimental JIT dual dispatch tables

To make Python faster, its new compiler watches the interpreter run. But watching a running program normally slows the thing you measure. This one barely does.

Its new JIT records the interpreter, then compiles the path.

runs
interpreter
running
→
the watcher
record the
instruction stream
→
feeds
compiler
fidget-spinner.github.io/posts/ultra-fast-tracing.html

Python 3.15 ships an experimental compiler. To know what to compile, it records the exact stream of instructions the interpreter runs. But recording means watching every instruction. And the watcher has to stay cheap.

Three ways to watch. Two of them are too slow.

Two whole interpreters
~6% on pyperformance · roughly doubles the C interpreter body
A check in every instruction
bloats every handler · slows the loop even when the branch predicts
Dual dispatch tables
?
fidget-spinner.github.io/posts/ultra-fast-tracing.html

There are three ways to build that watcher. The first two are too slow. One runs a second interpreter beside the normal one, costing about six percent. The other checks every instruction. Both grow the hot loop, and it has to stay small.

Keep two dispatch tables. Swap one pointer.

tracing dispatch table LOAD_FAST BINARY_OP STORE_FAST CALL every opcode … normal dispatch table opcode → its handler handler handler handler handler fan-out one variable picks the live table
fan-in: every opcode → one recorder TRACE_RECORD record, then dispatch
#  define ENTER_TRACING() \
    DISPATCH_TABLE_VAR = TRACING_DISPATCH_TABLE;
#  define LEAVE_TRACING() \
    DISPATCH_TABLE_VAR = DISPATCH_TABLE;
fidget-spinner.github.io · verified in CPython ceval_macros.h (3.15) · fan-in verified in generated_cases.c.h (3.15)

Here's the third way. An interpreter picks where to go next by looking each instruction up in a table of addresses. Keep two tables. One variable says which is live. So switching modes is one pointer swap, with no branch on any instruction. Now the clever part. Every instruction in the profiling table points at one recorder. It logs the step, then dispatches through the normal table to the real handler. Off, the interpreter runs as before. Turning it on, or off, is one line.

On a toy benchmark: at most 4.5× — profiling and compiling together.

1.72 µs no profiling plain interpreter profiling + JIT compile
7.47 µs ~4.5× at most
toy micro-benchmark · median of 40 runs · 148 repeated x + x statements · CPython only · docs: fidget-spinner.github.io

On a toy benchmark, profiling and compiling together ran at most four and a half times slower than the plain interpreter. That cost is real, but small, and only while recording. The switch itself is free.

The cost didn't vanish. It moved.

runtime cost
→
maintenance cost
The interpreter now carries two dispatch tables and a fan-in for a person to keep straight.
“elegant… definitely not that simple to reason about”
the author · fidget-spinner.github.io/posts/ultra-fast-tracing.html

The runtime cost nearly vanished. But it did not disappear. It moved. The interpreter now carries two dispatch tables and a fan-in for a person to keep straight. The author calls it elegant, and hard to reason about.

When you must watch every step, do not check at every step.

The lesson has nothing to do with Python.
Swap the rulebook. Route every step through one watcher.
CPython 3.15 experimental JIT verified in ceval_macros.h
fidget-spinner.github.io/posts/ultra-fast-tracing.html

The lesson has nothing to do with Python. When you must watch every step, do not check at every step. Swap the rulebook. Route every step through one watcher.