Skip to content

gh-129724: Intern string constants in the compiler, not per code object - #159193

Open
gpshead wants to merge 1 commit into
python:mainfrom
gpshead:compile-intern-consts
Open

gpshead wants to merge 1 commit into
python:mainfrom
gpshead:compile-intern-consts

Conversation

@gpshead

@gpshead gpshead commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Compiling the same source could give code objects whose constants are shared differently, depending on which strings happened to be interned in the process. marshal then writes different bytes, so .pyc files were not reproducible (gh-129724).

Example

Two functions use an equal set constant. The compiler makes it one frozenset object, unless a string in it is already interned when the code objects are created:

1. fresh interpreter                        len(marshal.dumps(code))=398  f and g share one frozenset: True
2. same again                               len(marshal.dumps(code))=398  f and g share one frozenset: True
3. while another code object using the
   same strings is alive                    len(marshal.dumps(code))=408  f and g share one frozenset: False
4. after that code object is freed          len(marshal.dumps(code))=398  f and g share one frozenset: True

With this PR all four are 398 bytes with one shared frozenset. The same happened to the __qualname__ constant of a class body: compiling class A: class B: pass twice while the first result is alive gives different bytes on main.

Cause and fix

The compiler merges equal constants of a module through its constant cache. Interning then ran later, once per code object, in intern_constants(). A frozenset cannot be modified, so when one of its strings had to be replaced by an already interned string, that code object got a private copy.

flowchart TB
  subgraph before["main"]
    direction TB
    m1["compiler constant cache<br>one frozenset F for f and g"] --> m2["code object f<br>intern_constants()"]
    m1 --> m3["code object g<br>intern_constants()"]
    m2 -- "a string of F is<br>already interned" --> m4["F' = copy of F<br>with the interned string"]
    m3 -- "same" --> m5["F'' = another copy"]
    m4 --> m6["f and g no longer share:<br>marshal writes the set twice"]
    m5 --> m6
  end
  subgraph after["this PR"]
    direction TB
    n1["compiler constant cache<br>one frozenset F for f and g"] --> n2["intern_final_constants()<br>when f is assembled"]
    n2 -- "a string of F is<br>already interned" --> n3["F' built once and<br>stored in the cache entry of F"]
    n3 --> n4["g is assembled:<br>the cache gives F'"]
    n4 --> n5["f and g share F'<br>whatever was interned before"]
    n5 --> n6["intern_constants() in<br>the code object: nothing<br>left to replace"]
  end
Loading
  • intern_final_constants() runs in optimize_and_assemble_code_unit(), after the optimizer and before the code object is created, on the constants the unit really ends up with. Strings that never reach a code object are not interned.
  • Tuples are updated in place. A frozenset or slice is rebuilt at most once, and the result is stored in its constant cache entry so later code units get the same object.
  • The qualified name of a class is interned by the compiler only where it would not be interned as a constant anyway.
  • intern_constants() in Objects/codeobject.c is not changed; its should_intern_string() is renamed _PyCode_ShouldInternString() and exported so the compiler uses the same rule.

Behaviour changes

  • Which strings get interned does not change, with one exception: identifier-like strings inside slice constants are now interned on the default build too, as they already were for every other kind of constant.
  • Compared with main in a fresh process, 13 of 2078 stdlib files compile to different (smaller) bytes: constants that main always copied are now shared.
  • compile() is about 0.5% slower on the default build (release, not PGO), which is inside the run-to-run noise.

Tests

TestConstantsIdentity in test_compile compiles a table of cases in child processes prepared in different ways (strings pre-interned, used as names, used as constants, the same source already compiled) and compares both the identity graph of the constants and the marshal output with a fresh process. It also covers tuples of names that are the same object as an equal constant (co_names of methods using a mangled name, keyword names, from lists, __static_attributes__), so that a change which re-creates those tuples per code object fails the test (the current version of draft #122180 does this).

With the marshal PR gh-159192 fixing the other half of gh-129724

Compiling each stdlib file under 14 different process conditions (file order, threads, pre-interned strings, earlier imports, hash seeds, keeping code objects alive) and comparing with one file per process:

default (GIL) free-threaded
main, files differing in the worst condition 433 63
both PRs, plus the fixes for gh-159184, gh-159185, and gh-159188 0 0

The matrix was only run with all of these applied together. The three other fixes do not affect code compiled from source strings, which is all this matrix compiles.


drafted using Claude, reviewed & iterated upon by me.

(combined with the marshal fix, this will close the title issue)

…e object

The compiler merges equal constants of a module through its constant
cache.  intern_constants() then ran on each code object and rebuilt a
frozenset (or, in the free-threaded build, a slice) when one of its
strings was replaced by an already interned one, so a constant shared by
several functions was copied for some of them depending on which strings
happened to be interned in the process.  The class body's __qualname__
constant and co_qualname had the same problem.  marshal then wrote
different bytes for the same source.

Intern the eligible strings of the final constants of each code unit
after optimization and before the code object is created.  Tuples are
updated in place; a frozenset or slice is rebuilt once and stored in the
constant cache so that later units get the same object.  The same code
is used in both builds.

Which strings are interned does not change, except that identifier-like
strings in slice constants are now interned in the default build too.

TestConstantsIdentity compares the identity graph and the marshal
output of code compiled in differently prepared processes, including
names tuples shared with constants.
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 11, 2026
@gpshead gpshead added type-bug An unexpected behavior, bug, or error stdlib Standard Library Python modules in the Lib/ directory interpreter-core (Objects, Python, Grammar, and Parser dirs) labels Oct 11, 2026
@gpshead
gpshead requested a review from methane October 11, 2026 19:23
@gpshead gpshead self-assigned this Oct 11, 2026
@gpshead gpshead removed the type-feature A feature request or enhancement label Oct 11, 2026
@gpshead gpshead added the 🔨 test-with-buildbots Test PR w/ buildbots; report in status section label Oct 11, 2026
@bedevere-bot

Copy link
Copy Markdown

🤖 New build scheduled with the buildbot fleet by @gpshead for commit badcae7 🤖

Results will be shown at:

https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F159193%2Fmerge

If you want to schedule another build, you need to add the 🔨 test-with-buildbots label again.

@bedevere-bot bedevere-bot removed the 🔨 test-with-buildbots Test PR w/ buildbots; report in status section label Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review interpreter-core (Objects, Python, Grammar, and Parser dirs) stdlib Standard Library Python modules in the Lib/ directory type-bug An unexpected behavior, bug, or error

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants