Skip to content

Commit 6da2283

Browse files
authored
[mypyc] Cache built librt in tests (#20626)
Store built `librt` under `build/librt-cache`, so that it can be reused to speed up mypyc tests when there are no changes under `mypyc/lib-rt`. Also invalidate the cache if the build environment changes. This also ensures that each test run only compiles `librt` at most twice (with and without experimental features), instead of once per test case that requires `librt`. Use `filelock` to support parallel tests. Building `librt` can take 6+ seconds, so the savings can be quite significant. We are also planning to add several additional `librt` submodules, which will further slow down the build, and this will result in a larger number of tests that require `librt`. The main benefit is that adding more tests or submodules doesn't significantly slow down tests.
1 parent 476d3a7 commit 6da2283

File tree

2 files changed

+239
-5
lines changed

2 files changed

+239
-5
lines changed

mypyc/test/librt_cache.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Build and cache librt for use in tests.
2+
3+
This module provides a way to build librt extension modules once and cache
4+
them across test runs, and across different test cases in a single run. The
5+
cache is invalidated when source files or details of the build environment change.
6+
7+
Note: Tests must run in a subprocess to use the cached librt, since importing
8+
this module also triggers the import of the regular installed librt.
9+
10+
Usage:
11+
from mypyc.test.librt_cache import get_librt_path, run_with_librt
12+
13+
# Get path to built librt (builds if needed)
14+
path = get_librt_path()
15+
16+
# Run a test file in subprocess with built librt
17+
result = run_with_librt("test_librt.py")
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import hashlib
23+
import os
24+
import shutil
25+
import subprocess
26+
import sys
27+
import sysconfig
28+
from typing import Any
29+
30+
import filelock
31+
32+
from mypyc.build import LIBRT_MODULES, get_cflags, include_dir
33+
from mypyc.common import RUNTIME_C_FILES
34+
35+
36+
def _librt_build_hash(experimental: bool) -> str:
37+
"""Compute hash for librt build, including sources and build environment."""
38+
# Import lazily to ensure mypyc.build has ensured that distutils is correctly set up
39+
from distutils import ccompiler
40+
41+
h = hashlib.sha256()
42+
# Include experimental flag
43+
h.update(b"exp" if experimental else b"noexp")
44+
# Include full Python version string (includes git hash for dev builds)
45+
h.update(sys.version.encode())
46+
# Include debug build status (gettotalrefcount only exists in debug builds)
47+
is_debug = hasattr(sys, "gettotalrefcount")
48+
h.update(b"debug" if is_debug else b"release")
49+
# Include free-threading status (Python 3.13+)
50+
is_free_threaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
51+
h.update(b"freethreaded" if is_free_threaded else b"gil")
52+
# Include compiler type (e.g., "unix" or "msvc")
53+
compiler: Any = ccompiler.new_compiler()
54+
h.update(compiler.compiler_type.encode())
55+
# Include environment variables that affect C compilation
56+
for var in ("CC", "CXX", "CFLAGS", "CPPFLAGS", "LDFLAGS"):
57+
val = os.environ.get(var, "")
58+
h.update(f"{var}={val}".encode())
59+
# Hash runtime files
60+
for name in RUNTIME_C_FILES:
61+
path = os.path.join(include_dir(), name)
62+
h.update(name.encode() + b"|")
63+
with open(path, "rb") as f:
64+
h.update(f.read())
65+
# Hash librt module files
66+
for mod, files, extra, includes in LIBRT_MODULES:
67+
for fname in files + extra:
68+
path = os.path.join(include_dir(), fname)
69+
h.update(fname.encode() + b"|")
70+
with open(path, "rb") as f:
71+
h.update(f.read())
72+
return h.hexdigest()[:16]
73+
74+
75+
def _generate_setup_py(build_dir: str, experimental: bool) -> str:
76+
"""Generate setup.py content for building librt directly.
77+
78+
We inline LIBRT_MODULES/RUNTIME_C_FILES/include_dir/cflags values to avoid
79+
importing mypyc.build, which recursively imports lots of things.
80+
"""
81+
lib_rt_dir = include_dir()
82+
83+
# Get compiler flags using the shared helper (with -O0 for faster builds)
84+
cflags = get_cflags(opt_level="0", experimental_features=experimental)
85+
86+
# Serialize values to inline in generated setup.py
87+
librt_modules_repr = repr(
88+
[(m.module, m.c_files, m.other_files, m.include_dirs) for m in LIBRT_MODULES]
89+
)
90+
runtime_files_repr = repr(RUNTIME_C_FILES)
91+
cflags_repr = repr(cflags)
92+
93+
return f"""\
94+
import os
95+
from setuptools import setup, Extension
96+
import build_setup # noqa: F401 # Monkey-patches compiler for per-file SIMD flags
97+
98+
build_dir = {build_dir!r}
99+
lib_rt_dir = {lib_rt_dir!r}
100+
101+
RUNTIME_C_FILES = {runtime_files_repr}
102+
LIBRT_MODULES = {librt_modules_repr}
103+
CFLAGS = {cflags_repr}
104+
105+
def write_file(path, contents):
106+
os.makedirs(os.path.dirname(path), exist_ok=True)
107+
with open(path, "wb") as f:
108+
f.write(contents)
109+
110+
# Copy runtime C files
111+
for name in RUNTIME_C_FILES:
112+
src = os.path.join(lib_rt_dir, name)
113+
dst = os.path.join(build_dir, name)
114+
with open(src, "rb") as f:
115+
write_file(dst, f.read())
116+
117+
# Build extensions for each librt module
118+
extensions = []
119+
for mod, file_names, extra_files, includes in LIBRT_MODULES:
120+
# Copy source files
121+
for fname in file_names + extra_files:
122+
src = os.path.join(lib_rt_dir, fname)
123+
dst = os.path.join(build_dir, fname)
124+
with open(src, "rb") as f:
125+
write_file(dst, f.read())
126+
127+
extensions.append(Extension(
128+
mod,
129+
sources=[os.path.join(build_dir, f) for f in file_names + RUNTIME_C_FILES],
130+
include_dirs=[lib_rt_dir] + [os.path.join(lib_rt_dir, d) for d in includes],
131+
extra_compile_args=CFLAGS,
132+
))
133+
134+
setup(name='librt_cached', ext_modules=extensions)
135+
"""
136+
137+
138+
def get_librt_path(experimental: bool = True) -> str:
139+
"""Get path to librt built from the repository, building and caching if necessary.
140+
141+
Uses build/librt-cache/ under the repo root (gitignored). The cache is
142+
keyed by a hash of sources and build environment, so it auto-invalidates
143+
when relevant factors change.
144+
145+
Safe to call from multiple parallel pytest workers - uses file locking.
146+
147+
Args:
148+
experimental: Whether to enable experimental features.
149+
150+
Returns:
151+
Path to directory containing built librt modules.
152+
"""
153+
# Use build/librt-cache/ under the repo root (gitignored)
154+
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
155+
cache_root = os.path.join(repo_root, "build", "librt-cache")
156+
build_hash = _librt_build_hash(experimental)
157+
build_dir = os.path.join(cache_root, f"librt-{build_hash}")
158+
lock_file = os.path.join(cache_root, f"librt-{build_hash}.lock")
159+
marker = os.path.join(build_dir, ".complete")
160+
161+
os.makedirs(cache_root, exist_ok=True)
162+
163+
with filelock.FileLock(lock_file, timeout=300): # 5 min timeout
164+
if os.path.exists(marker):
165+
return build_dir
166+
167+
# Clean up any partial build
168+
if os.path.exists(build_dir):
169+
shutil.rmtree(build_dir)
170+
171+
os.makedirs(build_dir)
172+
173+
# Create librt package directory for --inplace to copy .so files into
174+
librt_pkg = os.path.join(build_dir, "librt")
175+
os.makedirs(librt_pkg)
176+
with open(os.path.join(librt_pkg, "__init__.py"), "w") as f:
177+
pass
178+
179+
# Copy build_setup.py for per-file SIMD compiler flags
180+
build_setup_src = os.path.join(
181+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "build_setup.py"
182+
)
183+
build_setup_dst = os.path.join(build_dir, "build_setup.py")
184+
shutil.copy(build_setup_src, build_setup_dst)
185+
186+
# Write setup.py
187+
setup_py = os.path.join(build_dir, "setup.py")
188+
with open(setup_py, "w") as f:
189+
f.write(_generate_setup_py(build_dir, experimental))
190+
191+
# Build (parallel builds don't work well because multiple extensions
192+
# share the same runtime C files, causing race conditions)
193+
result = subprocess.run(
194+
[sys.executable, setup_py, "build_ext", "--inplace"],
195+
cwd=build_dir,
196+
capture_output=True,
197+
text=True,
198+
)
199+
if result.returncode != 0:
200+
raise RuntimeError(f"librt build failed:\n{result.stdout}\n{result.stderr}")
201+
202+
# Mark complete
203+
with open(marker, "w") as f:
204+
f.write("ok")
205+
206+
return build_dir
207+
208+
209+
def run_with_librt(
210+
file_path: str, experimental: bool = True, check: bool = True
211+
) -> subprocess.CompletedProcess[str]:
212+
"""Run a Python file in a subprocess with built librt available.
213+
214+
This runs the file in a fresh Python process where the built librt
215+
is at the front of sys.path, avoiding conflicts with any system librt.
216+
217+
Args:
218+
file_path: Path to Python file to execute.
219+
experimental: Whether to use experimental features.
220+
check: If True, raise CalledProcessError on non-zero exit.
221+
222+
Returns:
223+
CompletedProcess with stdout, stderr, and returncode.
224+
"""
225+
librt_path = get_librt_path(experimental)
226+
# Prepend librt path to PYTHONPATH
227+
env = os.environ.copy()
228+
existing = env.get("PYTHONPATH", "")
229+
env["PYTHONPATH"] = librt_path + (os.pathsep + existing if existing else "")
230+
231+
return subprocess.run(
232+
[sys.executable, file_path], capture_output=True, text=True, check=check, env=env
233+
)

mypyc/test/test_run.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from mypyc.errors import Errors
2727
from mypyc.options import CompilerOptions
2828
from mypyc.test.config import test_data_prefix
29+
from mypyc.test.librt_cache import get_librt_path
2930
from mypyc.test.test_serialization import check_serialization_roundtrip
3031
from mypyc.test.testutil import (
3132
ICODE_GEN_BUILTINS,
@@ -289,6 +290,7 @@ def run_case_step(self, testcase: DataDrivenTestCase, incremental_step: int) ->
289290

290291
setup_file = os.path.abspath(os.path.join(WORKDIR, "setup.py"))
291292
# We pass the C file information to the build script via setup.py unfortunately
293+
# Note: install_librt is always False since we use cached librt from librt_cache
292294
with open(setup_file, "w", encoding="utf-8") as f:
293295
f.write(
294296
setup_format.format(
@@ -297,16 +299,15 @@ def run_case_step(self, testcase: DataDrivenTestCase, incremental_step: int) ->
297299
(cfiles, deps),
298300
self.multi_file,
299301
opt_level,
300-
librt,
302+
False, # install_librt - use cached version instead
301303
experimental_features,
302304
)
303305
)
304306

305307
if librt:
306-
# This hack forces Python to prefer the local "installation".
307-
os.makedirs("librt", exist_ok=True)
308-
with open(os.path.join("librt", "__init__.py"), "a"):
309-
pass
308+
# Use cached pre-built librt instead of rebuilding for each test
309+
cached_librt = get_librt_path(experimental_features)
310+
shutil.copytree(os.path.join(cached_librt, "librt"), "librt")
310311

311312
if not run_setup(setup_file, ["build_ext", "--inplace"]):
312313
if testcase.config.getoption("--mypyc-showc"):

0 commit comments

Comments
 (0)