Repository navigation
gh-129724: Make marshalled code output deterministic - #156862
khmyznikov wants to merge 5 commits into
Conversation
Reuse the manually constructed ambiguous constants across refleak repetitions so free-threaded constant immortalization is not counted as a marshal leak. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad09eb5a-b18e-4bb2-9225-d2d3d589452e
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad09eb5a-b18e-4bb2-9225-d2d3d589452e
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the |
|
Thanks for working on this, @khmyznikov, and for the reproducer on the issue, which helped in pinning this down. I'm closing this in favor of #159192 and #159193, which fix the same problem at its two sources: the marshal writer's use of reference counts, and the compiler un-sharing constants when it interns strings. This PR makes the output stable by flagging every object, but only on the free-threaded build and only for top-level code objects, and the underlying cause also affects the default build. It also makes a frozenset of two NaNs raise |
Summary
This makes marshalled code-object output deterministic in free-threaded builds, including when multiple threads compile modules concurrently.
Fixes #129724.
Fixes #156504.
The standard build retains current
maincompiler-cache and marshal behavior.Problem
Marshal decides whether to add an object to its reference table using
_PyObject_IsUniquelyReferenced(). In a free-threaded build, that result can depend on object ownership and shared-reference state. The same code object can therefore receive differentFLAG_REFplacements depending on thread timing and prior accesses.Because a
.pycfile contains the marshalled code object after its 16-byte header, this makes otherwise identical parallel compilations produce byte-different.pycfiles. Running a free-threaded interpreter with-X gil=1does not avoid the problem because it does not change the free-threaded object's reference representation.Approach
For a top-level code object in a free-threaded build, marshal now uses deterministic reference tracking:
In free-threaded builds, the compiler also avoids sharing rebuildable constant containers across code units. Frozensets and slices use a code-unit-local cache instead of the compiler-wide cache. This preserves same-code-unit deduplication while preventing compilation order from changing the object graph later passed to marshal.
Valid source can create distinct NaN constants whose marshal encodings compare equal. In free-threaded builds, constant folding therefore leaves NaN-containing set displays as runtime
BUILD_SEToperations instead of creating an ambiguously ordered constant.Marshal versions 0 through 2 do not have a reference table, so their reference-tracking behavior is unchanged. The new ambiguous-set rejection applies to every marshal version when deterministic code-object mode is active. Generic non-code-object marshaling retains the existing path.
Compatibility
mainsharing behavior. Baseline and candidate bytes matched for both tested compilation histories.maincan itself produce different standard-build bytes after an unrelated compilation changes frozenset sharing. This pre-existing behavior is outside the free-threaded scope of this change..pycsize increased by 77 bytes forpip across 17 of 404 files and by 63 bytes for Plotly across 4 of 1,507
files. The largest per-file change was 23 bytes, and each total change was
below 0.002%.
Determinism results
Testing used x64 Release standard and free-threaded interpreters built from current
main(42de93a62e) and the core implementation (eae9b877a7) on the same 8-vCPU Windows VM. The final compatibility follow-up is3f056a9782; it narrows standard-build compiler behavior without changing the free-threaded marshal algorithm.mainmarshal.dumps(code)py_compile-X gil=1marshal.dumps(code)-X gil=1py_compileThat is 80 candidate stress attempts with no differing output.
The exact final tree also completed 10 attempts for each combination locally: 40 additional attempts with no differing output.
Performance
The same 8-vCPU VM ran eight paired current-main/candidate rounds, alternating process order. Ratios below are the median paired candidate/baseline ratio; values above 1 are slower.
mainmarshal.dumps(code)marshal.loads(code_payload)marshal.loadsThe stable tradeoff is the synthetic repeated free-threaded
marshal.dumps(code)case: about 51% more time and 35% more peak memory because deterministic mode records all supported references.marshal.loadsis about 5% slower with 4.5% more peak memory. Compile-plus-marshal was neutral, and the synthetic cold-import result was about 1.4% slower. Generic and threaded measurements had wider VM variance and should not be interpreted as performance improvements.Integrated pip workload
The change was tested end-to-end with pip PR 14286, which parallelizes Windows wheel bytecode compilation. The benchmark copy removed only pip's guard that keeps free-threaded builds serial. Both CPython variants and both GIL modes used eight worker threads.
Each timing is the median of six balanced runs on the same eight-vCPU Windows VM with Defender antivirus, real-time, IOAV, and behavior monitoring enabled.
The benchmark fixed
SOURCE_DATE_EPOCH=1704067200andPYTHONHASHSEED=0toisolate thread-order effects.
Current main produced serial/parallel artifact differences in 14 of 48
full-matrix parallel installs. The deterministic build produced zero
differences in all 48.
The exact-final follow-up used four counterbalanced runs per wheel, filesystem,
and GIL mode. Current main differed in 9 of 32 parallel installs; exact
3f056a9782differed in 0 of 32, and its 32 serial repeats were identical.Together, the full and exact-final matrices successfully unmarshalled 305,760
measured
.pycpayloads.Across the six-run matrix, aggregate serial timing for the fixed runtime was
about 1% slower than current main and within run-to-run variance, with no
consistent parallel penalty. In the exact-final matrix,
candidate/current-main peak committed/pagefile ratios ranged from 0.998-1.004
serial and 0.960-1.020 parallel, so there was no consistent whole-process
memory increase attributable to this change. Absolute fixed-runtime peak
committed usage was 133.5-134.8 MiB serial and 239.6-300.6 MiB parallel; peak
working set was 98.7-107.5 MiB serial and 169.3-196.5 MiB parallel. These
whole-process measurements do not replace the marshal-only peak-memory result.
Default GIL-off PythonT retained a 1.44x-3.64x speedup. With the GIL re-enabled, NTFS still benefited substantially, while the trusted Dev Drive was neutral to slightly slower.
The pip implementation used eight workers on a VM with eight logical CPUs;
these measurements do not establish the optimal worker count elsewhere.
WSL cross-platform confirmation
The exact candidate was also built with GCC 15.2 on x86-64 Ubuntu 26.04 WSL2
and tested on native ext4 storage. The CPython diff has no OS- or
CPU-architecture-specific branch; its conditional behavior uses only
Py_GIL_DISABLED. This run therefore adds Linux ABI/toolchain coverage, butnot Arm64 coverage.
The standalone reproducer found a current-main difference on attempt 1 for
both
marshalandpy_compile, with the GIL both disabled and enabled. Exact3f056a9782completed all 40 corresponding attempts with no difference.Focused
test_marshal,test_compile, andtest_coderuns passed 313 testsin each GIL mode.
For an integrated check, the pip PR copy was temporarily enabled on Linux and
free-threaded builds. Six counterbalanced runs used four workers:
Current main differed from serial output in 13 of 24 parallel installs. The
candidate differed in 0 of 24, and all 91,728 measured
.pycpayloadsunmarshalled successfully. Candidate/current-main geometric-mean timing ratios
were 0.993 serial and 1.018 parallel. The approximately 1.8% aggregate
parallel difference varied by workload and is small relative to the GIL-off
pip gain. Peak virtual size was effectively identical; geometric-mean peak
RSS ratios were 1.001 serial and 1.014 parallel.
The size effect matched Windows: pip
.pycfiles grew by 77 total bytes andPlotly by 63, with a maximum per-file change of 23 bytes. The result also
confirms the pip policy distinction: default GIL-off PythonT benefits on ext4,
while GIL-on PythonT was 21-29% slower with four workers.
Alternatives measured
The set-sort ordinal is normally a cached small integer, so replacing it with a temporary set or dict would add hashing and table allocation to save one tuple slot. That was not retained as a promising optimization.
Validation
Tools/patchcheck/patchcheck.pypassed.-X gil=1focused suite: 1,812 tests successful.test_marshal,test_compile, andtest_codepassed in both standard and free-threaded builds with-R 3:3.One
test_device_encodingassertion was excluded from the final clean host run because the automation pseudo-console reports stdin as a TTY whileos.device_encoding(0)isNone. Currentmainfails the same isolated assertion in that environment.AI-assisted development disclosure
GitHub Copilot was used to assist investigation, implementation, test drafting, and benchmark organization.