Bytecode compilation output depends on order of files compiled #129724
Description
This is minimal reproduction of this downstream bug report: astral-sh/uv#10619
The output of compileall.compile_file depends on the order in which the files are compiled. This means compilation is non-deterministic if builds are distributed over a process pool.
This becomes a problem when building docker images, where you usually bytecode compile ahead of time for faster startup, and where the hash of the image depends on all files in the image, including the .pyc files.
Specifically, the output of
a = {"foo", 2, 3}
def f():
b = {"foo", 2, 3}is different if we previously compiled another file with
import fooReproducer script:
#!/bin/bash
set -e
script=$(cat << EOF
import compileall
import sys
for path in sys.argv[1:]:
compileall.compile_file(path)
EOF
)
cat << EOF > a.py
import foo
EOF
cat << EOF > b.py
a = {"foo", 2, 3}
def f():
b = {"foo", 2, 3}
EOF
# Both files
rm -rf __pycache__
python3.14 -c "$script" a.py b.py
sha256sum __pycache__/b.cpython-314.pyc
# For debugging
cp __pycache__/b.cpython-314.pyc b1.cpython-314.pyc
# Single file only
rm -rf __pycache__
python3.14 -c "$script" b.py
sha256sum __pycache__/b.cpython-314.pyc
# For debugging
cp __pycache__/b.cpython-314.pyc b2.cpython-314.pyc
This is caused be different refcounts in the marshalled files:
import marshal
import sys
with open("b1.cpython-313.pyc", "rb") as f:
f.read(16) # Skip header
pyc1 = marshal.load(f)
with open("b2.cpython-313.pyc", "rb") as f:
f.read(16) # Skip header
pyc2 = marshal.load(f)
print(sys.getrefcount(pyc1.co_consts[0]))
print(sys.getrefcount(pyc2.co_consts[0]))This prints 2 and 3.
The original report is from 3.13, i've reproduced it with 3.14.0a4. It happens at least on linux and windows.
CPython versions tested on:
3.14
Operating systems tested on:
Linux
Linked PRs
- gh-129724: Make marshalled code output deterministic #156862
- gh-129724: gh-156504: Make marshal output independent of reference counts #159192
- gh-129724: Intern string constants in the compiler, not per code object #159193
- [3.15] gh-129724: gh-156504: Make marshal output independent of reference counts (GH-159192) #159290
- [3.15] gh-129724: Intern string constants in the compiler, not per code object (GH-159193) #159291
Activity
encukou commentedon Feb 6, 2025
Yes, bytecode compilation is non-deterministic. See also this issue: #78274 (comment)
A PR to make it deterministic without sacrificing performance would be welcome. You can also use an external tool to post-process the pyc.
konstin commentedon Feb 6, 2025
Is it valid to modify the serialized refcounts (to some specific value)? I'm having trouble following what the marshalled refcounts mean and when they'd be DECREF'd.
encukou commentedon Feb 6, 2025
They're not refcounts.
FLAG_REFgets added if an object might appear later in the file, in which case it can later be referenced byTYPE_REF(using an index to the array of all objects withFLAG_REF).One solution would be to flag all objects (inflating the array and the indices), or doing a second pass over the pyc (wasteful if not needed, so CPython doesn't do it)... or something like redesigning the serialization format entirely.
pythongh-129724: Stabilize marshal refleak regression
pythongh-129724: Limit constant cache changes to free-threaded builds
gpshead commentedon Oct 4, 2026
One specific case of this I stumbled across while analyzing an image build given the way bazel's rules_python works by keeping a long running python process to feed things to compile to:
What: Take a module in which two functions contain the same constant set-membership test, e.g. if kind in {"alpha_kind", "beta_kind"}:. Compile it twice with compile() + marshal.dumps() (which is what py_compile, compileall and importlib do). If, during one of those compiles, some other live code object in the process uses alpha_kind or beta_kind as a name or string constant, the resulting bytes differ from the other compile by a handful of bytes. The two code objects compare equal and behave identically; only the serialization differs.
Why? The compiler deduplicates equal constants within a compilation unit, so both functions share one frozenset object. When each function's code object is created, intern_constants() interns identifier-like strings inside that frozenset; because frozensets are immutable, if any element gets replaced by an already-interned string it builds a brand-new frozenset for that one code object, silently undoing the sharing. Whether an element is "already interned" depends on the rest of the process: string constants of other live code objects (mortal, so this tracks liveness and thread timing) and, on 3.12+, every name any code object in the process has ever had (co_names are interned immortally, so this tracks the process's whole import and compile history). marshal then exposes object identity: w_ref() emits a back-reference for an object seen before only if its refcount is > 1, so "one shared frozenset" and "two equal frozensets" serialize differently.
Possible suggestion for that one: Intern eligible string constants when the compiler first records them in its constant cache, so that intern_constants() never has to rebuild a compiler-produced frozenset.
Details
confirmed on 3.13, 3.14, and 3.15.
While I believe this is fixable, post-processing .pyc files as noted above, or writing a standalone compiler might make more sense given how hard it is to maintain an idempotent compile within the live state machine of a running CPython runtime.
methane commentedon Oct 4, 2026
tran4774 commentedon Oct 8, 2026
A smaller trigger than compile order, in the default (GIL) build: one interned string that equals an item of a frozenset constant is enough. This reproduces on 3.12.15, 3.13.15 and 3.14.8. As far as I can tell, gh-156862 only changes free-threaded builds.
marshal.loads()of the two results compares equal; only the encoding differs. With the frozenset shared, the second occurrence is aTYPE_REF. Without sharing, it is written out in full (+25 bytes here), and every later ref index shifts by one.What seems to happen: the compiler merges the two equal frozensets into one object. When the code objects are created,
intern_constants()(Objects/codeobject.c) turns each frozenset into a tuple and interns its strings. If that replaces an item with an already-interned equal string (tmp_modified), it builds a new frozenset for that code object. Sofandgeach get their own copy, and the sharing that marshal relies on is gone. Whether the replacement happens depends on what the process interned before, for example identifiers of previously compiled modules. That is whycompileall -j Nand uv's worker pool produce different.pycfrom run to run. I have not worked out why the extra frozenset infis needed to trigger it.Real-world hit:
networkx/algorithms/operators/tests/test_binary.pyandpycparser/c_parser.pychange in about 1–2 of 5 Docker builds of the same lockfile (uv, 3.13), so the venv layer of the image changes.pythongh-129724: Make marshal output independent of reference counts
pythongh-159184: Make the marshal order of set elements with equal du…
pythongh-129724: Make marshal output independent of reference counts
pythongh-159184: Make the marshal order of set elements with equal du…
gpshead commentedon Oct 11, 2026
eyeballs on the pair of PRs would be helpful here.
Merge the pythongh-156504 NEWS entry into the pythongh-129724 one
gh-129724: gh-156504: Make marshal output independent of reference co…
gh-129724: Intern string constants in the compiler, not per code obje…
pythongh-159184: Make the marshal order of set elements with equal du…