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
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