GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Concurrent compilation changes marshal output in free-threaded builds · Issue #156504 · python/cpython

Concurrent compilation changes marshal output in free-threaded builds #156504

New issue
Copy link
New issue
Copy link
Closed
#159192
Closed
Concurrent compilation changes marshal output in free-threaded builds#156504
#159192
Copy link
Assignees
gpshead
Labels
interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Description

@khmyznikov
khmyznikov
opened on Aug 27, 2026
Issue body actions

Bug description:

Concurrent compilation on free-threaded CPython builds can produce marshal output that differs byte-for-byte from serial compilation of the same source.

This occurs both with the GIL disabled and with the GIL explicitly re-enabled. It reproduces on Windows and Linux/WSL. Standard CPython builds used as controls did not reproduce the difference.

This was discovered while implementing threaded bytecode compilation in pypa/pip#14286.

Reproducer

A standalone standard-library-only reproducer is available here:

  • Reproducer script
  • Methodology and results

It does not import or execute pip. It:

  • creates 64 copies of a minimized source fixture;
  • compiles corresponding files serially and with a ThreadPoolExecutor, in fresh child interpreters;
  • fixes logical filenames, PYTHONHASHSEED, and SOURCE_DATE_EPOCH;
  • compares checked-hash .pyc files or raw marshal.dumps(compile(...)) payloads;
  • unmarshals differing outputs and recursively compares documented code-object fields.

Example using the raw marshal backend:

python3.14t repro_threaded_pyc_determinism.py --gil off --backend marshal --workers 4 --output repro-ft-off-marshal --require-difference
python3.14t repro_threaded_pyc_determinism.py --gil on --backend marshal --workers 4 --output repro-ft-on-marshal --require-difference

The output directory must be new or empty.

Expected behavior

Serial and concurrent compilation of the same source, using the same logical filename and deterministic environment settings, should produce byte-identical marshal output.

Actual behavior

On free-threaded builds, some concurrently compiled files have different serialized bytes from their serial counterparts.

The differing payloads have:

  • equal lengths;
  • equal .pyc headers when using py_compile;
  • equal values for all recursively inspected code-object fields after unmarshalling, including bytecode, constants, names, filenames, line tables, exception tables, and nested code objects.

The raw marshal backend reproduces the behavior without py_compile or .pyc headers. Its differing offsets are exactly 16 bytes earlier than the corresponding .pyc offsets, confirming that the .pyc header is not responsible.

An empty semanticDifferences result does not establish that the object identity or alias graph is identical. The precise cause has not yet been determined.

Observed results

Windows

Windows 11 prerelease VM, eight virtual CPUs, CPython 3.15.0rc1 free-threaded build and corresponding standard CPython 3.15 build:

Build GIL Backend Result
Free-threaded Off py_compile Reproduced; 49 differing bytes in a 3,447-byte .pyc
Free-threaded On py_compile Reproduced; 49 differing bytes in a 3,447-byte .pyc
Free-threaded Off marshal Reproduced; 49 differing bytes in a 3,431-byte payload
Free-threaded On marshal Reproduced; 49 differing bytes in a 3,431-byte payload
Standard On/default Both No difference in 10 attempts

Linux under WSL2

Ubuntu 26.04 LTS, kernel 6.18.33.2-microsoft-standard-WSL2, native ext4 storage, four available CPUs:

Build GIL Backend Result
CPython 3.14.7 free-threaded Off py_compile Reproduced on attempt 1
CPython 3.14.7 free-threaded On py_compile Reproduced on attempt 1
CPython 3.14.7 free-threaded Off marshal Reproduced on attempt 1
CPython 3.14.7 free-threaded On marshal Reproduced on attempt 1
CPython 3.14.4 standard On/default Both No difference in 10 attempts

The WSL differences contained 50 differing bytes per output. All inspected decoded code-object field values were equal.

Impact

Although the decoded code objects appear semantically equivalent, byte-different outputs affect:

  • reproducible builds;
  • artifact hashing;
  • build caches;
  • deterministic CI output;
  • tools that record .pyc hashes.

Re-enabling the GIL on a free-threaded build does not prevent the behavior. As a temporary precaution, pip’s proposed threaded compilation path detects Py_GIL_DISABLED at build time and remains serial for free-threaded builds.

CPython versions tested on:

3.15

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
  • [3.15] gh-129724: gh-156504: Make marshal output independent of reference counts (GH-159192) #159290

Activity

  1. khmyznikov
    added
    type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
    on Aug 27, 2026
  2. aisk
    added
    extension-modulesC modules in the Modules dirC modules in the Modules dir
    topic-free-threading
    on Aug 28, 2026
  3. ZeroIntensity
    added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)
    and removed
    extension-modulesC modules in the Modules dirC modules in the Modules dir
    on Aug 28, 2026
  4. SurbhiAgarwal1 commented on Aug 29, 2026

    @SurbhiAgarwal1
    SurbhiAgarwal1
    on Aug 29, 2026
    More actions

    Hi @khmyznikov , I took a look at marshal.c and pycore_object.h on main, think I found the mechanism.

    Marshal picks how to encode a string (inline vs TYPE_REF, and TYPE_SHORT_ASCII_INTERNED vs TYPE_SHORT_ASCII) based on _PyObject_IsUniquelyReferenced(v). On GIL builds that's just Py_REFCNT(ob) == 1 — stable. On free-threaded builds it's:

    return (_Py_IsOwnedByCurrentThread(ob) &&
            _Py_atomic_load_uint32_relaxed(&ob->ob_ref_local) == 1 &&
            _Py_atomic_load_ssize_relaxed(&ob->ob_ref_shared) == 0);

    That depends on thread ownership + cross-thread refcounts, which can shift while multiple threads are compiling concurrently.

    Your repro compiles 64 near-identical fixtures in parallel, so they share a ton of interned identifiers (self, dunders, etc). Two threads racing to touch the same interned string can flip this check — which flips the encoding of that string, not its value. Matches everything you saw: bytes differ but decoded fields are equal, GIL re-enabled at runtime doesn't help (compile-time macro, not runtime), standard builds never hit it, and the 16-byte offset gap is just the .pyc header.

    Not confirmed yet — next step is mapping the differing byte offsets to marshal's type bytes and checking if they land on shared identifiers. If that holds, fix is probably decoupling the type-byte choice (only needs PyUnicode_CHECK_INTERNED, race-free) from the TYPE_REF caching (doesn't need global refcount at all, the per-dump memo already tracks what's written).

    Happy to dig into this further could someone assign this to me so I can run the offset-mapping check and follow up with a patch if it confirms out?

    Refs: [marshal.c](https://github.com/python/cpython/blob/main/Python/marshal.c) · [pycore_object.h](https://github.com/python/cpython/blob/main/Include/internal/pycore_object.h)

    Reacted by Gleb Khmyznikov
  5. dylanpulver commented on Aug 31, 2026

    @dylanpulver
    dylanpulver
    on Aug 31, 2026
    More actions

    Confirmed on main@03503dc (free-threaded, macOS/arm64 — a new platform on top of the Windows/WSL data). In-process, 64 identical fixtures, serial vs ThreadPoolExecutor(8): the threaded output differs from serial in 49 bytes out of 3426, and marshal.loads() of both is structurally identical (co_code byte-identical, same consts/names). The payloads are equivalent; only the encoding moves.

    Mechanism. It's w_ref() in Python/marshal.c, the early-return guard:

    if (_PyObject_IsUniquelyReferenced(v) &&
        !(PyUnicode_CheckExact(v) && PyUnicode_CHECK_INTERNED(v)))
        return 0;   // write inline, do NOT add to the ref table

    _PyObject_IsUniquelyReferenced is Py_REFCNT(ob)==1 on the default build, but on free-threading it's owned-by-current-thread && ob_ref_local==1 && ob_ref_shared==0. That predicate is timing-dependent: the moment any other thread touches an object, biased reference counting moves it to the shared state and ob_ref_shared is no longer 0. So an object inlined in the serial run gets memoized in the threaded run — its type byte gains FLAG_REF (| 0x80) and it enters the reference table.

    Causal proof (deterministic, single-threaded): marshal a runtime-built small tuple that is uniquely referenced vs one to which I hold one extra reference — identical value, and the inner type byte flips 0x29→0xa9 (TYPE_SMALL_TUPLE | FLAG_REF), nothing else. Flip one reference, one bit moves.

    Why re-enabling the GIL doesn't help (the part the report flagged as surprising): a single cross-thread read of an otherwise-unique object flips the same byte, and it flips identically under -X gil=1. Re-enabling the GIL doesn't change the free-threaded build's refcount representation, so the shared-refcount transition still fires.

    Why 49 bytes from only ~5 real flips: in this repro exactly 5 objects gain FLAG_REF (3 TYPE_SMALL_TUPLE, 2 TYPE_STRING). Each extra registration shifts every later TYPE_REF index up by one, so the other 44 differing bytes are downstream index low-bytes (+5 ×39, +2 ×5; max delta 5 = the number of new registrations). The value survives because a larger-but-consistent ref index decodes to the same object.

    One refinement to @SurbhiAgarwal1's note: the culprit predicate is exactly right, but the byte that moves is FLAG_REF on non-interned objects (bytes/tuples), not TYPE_SHORT_ASCII_INTERNED↔TYPE_SHORT_ASCII. That byte depends only on PyUnicode_CHECK_INTERNED (race-free), and interned strings take the memo path unconditionally anyway.

    Fix direction (not proposing a PR here): the _PyObject_IsUniquelyReferenced fast-path in w_ref/w_complete is a size optimization that is inherently non-deterministic under free-threading, because the memoization decision depends on a live refcount. Reproducible marshal output means that decision can't read refcounts — gate it on something stable, or always memoize eligible types. Happy to help chase whichever direction the marshal maintainers prefer.

    Disclosure: this diagnosis was produced by Claude (Opus) working as a coding agent; the reproductions, causal tests, and byte accounting above were all run and verified against a free-threaded build.

  6. gpshead
    self-assigned this
    on Oct 11, 2026
  7. gpshead
    added 4 commits that reference this issue on Oct 11, 2026

    pythongh-129724: Make marshal output independent of reference counts

    7893950

    pythongh-129724: Make marshal output independent of reference counts

    6ee27e0

    Merge the pythongh-156504 NEWS entry into the pythongh-129724 one

    d69021e

    gh-129724: gh-156504: Make marshal output independent of reference co…

    48f18f0
  8. gpshead
    closed this as completedin #159192on Oct 13, 2026
  9. gpshead
    added a commit that references this issue on Oct 24, 2026

    [3.15] pythongh-129724: pythongh-156504: Make marshal output independ…

    8fb8fc3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

  • @gpshead
    gpshead

Labels

interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    You are visitor 01402503 — GeezerVison does not store the pages you look up.
    Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.