Repository navigation
Conversation
Apple Silicon has 128-byte cache lines. struct _qsbr_pad was padded to 64 bytes, so the QSBR states of two neighbouring threads shared a cache line and the unconditional store in _Py_qsbr_quiescent_state() (eval loop back-edges and calls) made threads that share no Python objects contend on it. Use _Py_QSBR_CACHE_LINE_SIZE (128 on AArch64, 64 elsewhere) for the padding, the array alignment and the tail padding of _PyThreadStateImpl added in pythongh-144438. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
_Py_QSBR_CACHE_LINE_SIZE(128 on__aarch64__/_M_ARM64, 64 elsewhere) and uses it in the three places that hard-code 64:struct _qsbr_padchar __padding[64 - sizeof(...)]grow_thread_array()(gh-144438)_PyThreadStateImpltail padding (gh-144438)char __padding[64]char __padding[128]The change is internal only (
Py_BUILD_CORE): no ABI change, and other architectures are unchanged.What goes wrong today
check_periodics()/check_periodics_at_end()run on everyJUMP_BACKWARD, after everyCALL, and inRESUME. Each time they call_Py_qsbr_quiescent_state(), which does an unconditional_Py_atomic_store_uint64_release(&qsbr->seq, ...).sizeof(struct _qsbr_thread_state)is already 64 (checked with lldb), so_qsbr_padadds 0 bytes of padding. On 128-byte lines, slots 2k and 2k+1 share a line.initialize_new_array()pushes the free slots onto a LIFO freelist in index order, and_Py_qsbr_reserve()pops from it. Pool workers therefore end up pairwise on shared lines.To check that the slot layout is the cause: an idle (detached) thread started after each worker keeps busy threads off neighbouring slots. On unpatched
mainthat alone raises 14-thread scaling from ~6× to 7.9–8.6×.Why gh-144438 didn't cover it
gh-144438 was found on x86, with 64-byte lines. The problem there was the first array entry sharing a line with some thread's
tlbc_index(diagnosis). Its fix uses 64 for both the array alignment and the new tail padding. On x86 a 64-byte_qsbr_padalready gives each thread its own line, so the padding between entries never needed changing. On 128-byte lines it does, and the gh-144438 padding is only half a line. The layout is unchanged since 3.13 (3.13.16), so on a Mac the penalty is the same in every version. This fits why the gh-144438 regression was "not seen on Mac" (comment).Results
M4 Pro (10P + 4E), macOS 26.7.1, Apple clang 17,
./configure --disable-gil, base main d42178a.ftscalingbench.py -t 10 --scale 10000 generator --parallel-onlyftscalingbench.py(14 threads), e.g.staticmethod_call/classmethod_call/pymethodAll ftscalingbench benchmarks (14 threads, two runs)
deepcopyandtype_lookupare limited by something else.Pure-Python loop per release and worker count; C stride test
Speedup over 1 worker (
qsbr_false_sharing.pyfrom the issue):There is no difference with 2 threads (1.8–2.0× in every build); the penalty starts at 4.
qsbr_stride.c(the same store pattern in C), ns per iteration. The 14-thread column includes the E-cores.Memory cost (AArch64 only)
Measured on a
mainbuild with lldb (sizeof, and mimalloc'smi_good_size(), whichPyMem_RawCallocuses in the free-threaded build):_PyThreadStateImplThat is about 64 B per QSBR slot. For comparison, CPython reserves a 16 MiB stack per thread on macOS.
Design notes
__GCC_DESTRUCTIVE_SIZE:__APPLE__: on AArch64 cores with 64-byte lines, this only costs the memory above, and it also covers Windows on ARM. Easy to narrow if preferred.Tools/ftscalingbench/ftscalingbench.pygenerator #144438 showed 64 is enough there. (Rust std and folly use 128 on x86_64 because of Intel's adjacent-line prefetcher; I couldn't measure x86.)seqis unchanged. It is safe, because only the owner writesseqwhile attached. It also removes the contention, but it adds a load and a branch to the hottest path: about +50% per call in the C test, and up to ~3% on the 1-thread loop. With the padding it brings nothing extra (10.2–10.3× vs. 10.1–10.3×), so I left it out.Tests
./python.exe -m test -j12 test_free_threading test_threading test_thread test_queue test_weakref test_gc test_sys test_capi test_concurrent_futures test_threading_local test_itertools test_generators: SUCCESS (79 files, 3256 tests).🤖 Generated with Claude Code