Skip to content

gh-148286: Track JIT-allocated memory with Valgrind client requests - #154926

Open
matthiasgoergens wants to merge 1 commit into
python:mainfrom
matthiasgoergens:vgclient-jit-integration
Open

gh-148286: Track JIT-allocated memory with Valgrind client requests#154926
matthiasgoergens wants to merge 1 commit into
python:mainfrom
matthiasgoergens:vgclient-jit-integration

Conversation

@matthiasgoergens

@matthiasgoergens matthiasgoergens commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Python/jit.c's jit_alloc() and jit_free() mmap and munmap the JIT's machine-code buffers directly. Valgrind's leak checker does not track raw mmap'd memory as a "block", so a forgotten jit_free() — the shape of gh-142476, a real ASan-found leak in allocate_executor — is completely invisible to it.

This adds VALGRIND_MALLOCLIKE_BLOCK / VALGRIND_FREELIKE_BLOCK around those two functions, guarded by the existing --with-valgrind configure option, mirroring the pattern vendored mimalloc already uses in Include/internal/mimalloc/mimalloc/track.h.

To measure the effect I injected a forgotten free into jit_free() on a machine-code JIT build (--enable-experimental-jit, LLVM 21) and ran a script that creates eight executors and drops them:

build valgrind --leak-check=full
without --with-valgrind definitely lost: 0 bytes in 0 blocks
with --with-valgrind definitely lost: 139,264 bytes in 10 blocks

and in the second case with a usable backtrace:

131,072 bytes in 8 blocks are definitely lost
   at jit_alloc (Python/jit.c:154)
   by _PyJIT_Compile
   by make_executor_from_uops (Python/optimizer.c:1574)
   by uop_optimize
   by _PyOptimizer_Optimize
   by stop_tracing_and_jit (Python/ceval.c:1090)

Background noise was byte-identical between the two runs (120 bytes possibly lost, 646 bytes still reachable), so the injected leak is the only difference. The injection is not part of this patch.

The include is valgrind/valgrind.h — the header configure already probes for under --with-valgrind, and the one that defines both macros used here, so no build-system change is needed. Objects/obmalloc.c includes the same header.

On VALGRIND_DISCARD_TRANSLATIONS

I deliberately did not add any. It seemed like the obvious thing for a JIT, but it turns out to be unnecessary here.

Valgrind's munmap and mprotect syscall wrappers already discard cached translations for any range that previously held translated code, and that logic lives in architecture-independent code (coregrind/m_syswrap/syswrap-generic.c). I also checked it experimentally with a small driver that emits code, executes it enough times to guarantee a cached translation, changes it, and executes again — with a control case that genuinely does go stale so the harness is known to detect the failure. CPython's mmap → write once → mprotect → execute → munmap-whole-region pattern never executed a stale translation on either x86-64 (Valgrind 3.25.1) or aarch64 (Valgrind 3.19.0), in any --smc-check mode including none.

A side effect worth recording: since that is already true, --smc-check=none is safe for CPython today with no code change. On a tight JIT-compiled integer loop that is worth about 10% over the all-non-file default (median of three runs each: 11.33 s vs 10.17 s, ranges 11.22-11.45 and 10.12-10.36, so non-overlapping). That workload is close to a best case — it is almost entirely JIT-executed — so a realistic program should expect less.

Testing

Builds cleanly both with and without --with-valgrind; test_capi and test_optimizer pass (1,501 tests) on the --with-valgrind build, and under valgrind memcheck.

…ests

Python/jit.c's jit_alloc()/jit_free() mmap and munmap the JIT's
machine-code buffers directly. Valgrind's leak checker does not track
raw mmap'd memory as a "block", so a forgotten jit_free() -- the shape
of pythongh-142476, a real ASan-found leak in allocate_executor -- is
completely invisible to it.

Measured on a machine-code JIT build (--enable-experimental-jit, LLVM
21) with a forgotten-free deliberately injected into jit_free() and
eight executors created and dropped:

  without --with-valgrind:  definitely lost: 0 bytes in 0 blocks
  with    --with-valgrind:  definitely lost: 139,264 bytes in 10 blocks

and in the second case with a backtrace through jit_alloc ->
_PyJIT_Compile -> make_executor_from_uops -> stop_tracing_and_jit.
Background noise was byte-identical between the two runs (120 bytes
possibly lost, 646 still reachable), so the only difference is the
injected JIT leak. The injection was reverted before committing.

Guarded by the existing --with-valgrind configure option, and includes
valgrind/valgrind.h -- the header that configure already probes for and
that defines both macros used here -- mirroring Objects/obmalloc.c.

No VALGRIND_DISCARD_TRANSLATIONS is added. Valgrind's munmap and
mprotect syscall wrappers already discard cached translations for any
range that held translated code, in architecture-independent code
(coregrind/m_syswrap/syswrap-generic.c). Confirmed experimentally on
both x86-64 and aarch64: with a control that does go stale, CPython's
mmap/write-once/mprotect/munmap pattern never executes a stale
translation, even under --smc-check=none.
@matthiasgoergens
matthiasgoergens force-pushed the vgclient-jit-integration branch from 72c2f94 to f3dec60 Compare July 30, 2026 13:24
@matthiasgoergens

Copy link
Copy Markdown
Contributor Author

Noting for whoever picks this up: the red CI is unrelated to this
change.

The only failing job is Windows / Build and test (Win32, switch-case),
and its sole failure is:

test.test_asyncio.test_sslproto.SelectorStartTLSTests
    .test_shutdown_corrupted_ssl_sends_close_notify
  ... Warning -- Uncaught thread exception: AssertionError
== Tests result: ENV CHANGED ==

ENV CHANGED here means the test leaked an uncaught exception from a
background thread, not that an assertion in the test body failed. Run
totals were run=52,041 with no test failures.

This PR touches two files — Python/jit.c and a NEWS entry — so there
is no path from it to asyncio's TLS shutdown handling. I could not find
an existing issue for this particular test, so I am not claiming it is a
known flake, only that it is not caused by anything here.

Happy to rebase onto current main to get a fresh run if that would
help; the branch is from 30 July.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant