Skip to content

Commit 68f338b

Browse files
authored
Feature/concurrent build fixes #67 (#71)
1 parent b094f40 commit 68f338b

File tree

8 files changed

+132
-12
lines changed

8 files changed

+132
-12
lines changed

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Test
22

3-
on: [push]
3+
on: [push, pull_request]
44

55
jobs:
66
test:

README.md

+16
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ cppimport.settings['force_rebuild'] = True
157157

158158
And if this is a common occurence, I would love to hear your use case and why the combination of the checksum, `cfg['dependencies']` and `cfg['sources']` is insufficient!
159159

160+
Note that `force_rebuild` does not work when importing the module concurrently.
161+
162+
### Can I import my model concurrently?
163+
164+
It's safe to use `cppimport` to import a module concurrently using multiple threads, processes or even machines!
165+
166+
Before building a module, `cppimport` obtains a lockfile preventing other processors from building it at the same time - this prevents clashes that can lead to failure.
167+
Other processes will wait maximum 10 mins until the first process has built the module and load it. If your module does not build within 10 mins then it will timeout.
168+
You can increase the timeout time in the settings:
169+
170+
```python
171+
cppimport.settings['lock_timeout'] = 10*60 # 10 mins
172+
```
173+
174+
You should not use `force_rebuild` when importing concurrently.
175+
160176
### How can I get information about filepaths in the configuration block?
161177
The module name is available as the `fullname` variable and the C++ module file is available as `filepath`.
162178
For example,

cppimport/__init__.py

+18-7
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from cppimport.find import _check_first_line_contains_cppimport
99

1010
settings = dict(
11-
force_rebuild=False,
11+
force_rebuild=False, # `force_rebuild` with multiple processes is not supported
1212
file_exts=[".cpp", ".c"],
1313
rtld_flags=ctypes.RTLD_LOCAL,
14+
lock_suffix=".lock",
15+
lock_timeout=10 * 60,
1416
remove_strict_prototypes=True,
1517
release_mode=os.getenv("CPPIMPORT_RELEASE_MODE", "0").lower()
1618
in ("true", "yes", "1"),
@@ -57,19 +59,26 @@ def imp_from_filepath(filepath, fullname=None):
5759
module : the compiled and loaded Python extension module
5860
"""
5961
from cppimport.importer import (
62+
build_safely,
6063
is_build_needed,
6164
load_module,
6265
setup_module_data,
63-
template_and_build,
6466
try_load,
6567
)
6668

69+
filepath = os.path.abspath(filepath)
6770
if fullname is None:
6871
fullname = os.path.splitext(os.path.basename(filepath))[0]
6972
module_data = setup_module_data(fullname, filepath)
73+
# The call to try_load is necessary here because there are times when the
74+
# only evidence a rebuild is needed comes from attempting to load an
75+
# existing extension module. For example, if the extension was built on a
76+
# different architecture or with different Python headers and will produce
77+
# an error when loaded, then the load will fail. In that situation, we will
78+
# need to rebuild.
7079
if is_build_needed(module_data) or not try_load(module_data):
71-
template_and_build(filepath, module_data)
72-
load_module(module_data)
80+
build_safely(filepath, module_data)
81+
load_module(module_data)
7382
return module_data["module"]
7483

7584

@@ -108,17 +117,19 @@ def build_filepath(filepath, fullname=None):
108117
ext_path : the path to the compiled extension.
109118
"""
110119
from cppimport.importer import (
120+
build_safely,
111121
is_build_needed,
122+
load_module,
112123
setup_module_data,
113-
template_and_build,
114124
)
115125

126+
filepath = os.path.abspath(filepath)
116127
if fullname is None:
117128
fullname = os.path.splitext(os.path.basename(filepath))[0]
118129
module_data = setup_module_data(fullname, filepath)
119130
if is_build_needed(module_data):
120-
template_and_build(filepath, module_data)
121-
131+
build_safely(filepath, module_data)
132+
load_module(module_data)
122133
# Return the path to the built module
123134
return module_data["ext_path"]
124135

cppimport/checksum.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def _load_checksum_trailer(module_data):
4545
except FileNotFoundError:
4646
logger.info("Failed to find compiled extension; rebuilding.")
4747
return None, None
48+
except OSError:
49+
logger.info("Checksum trailer invalid. Rebuilding.")
50+
return None, None
4851

4952
try:
5053
deps, old_checksum = json.loads(json_s)
@@ -79,7 +82,7 @@ def _save_checksum_trailer(module_data, dep_filepaths, cur_checksum):
7982
# legal (see e.g. https://stackoverflow.com/questions/10106447).
8083
dump = json.dumps([dep_filepaths, cur_checksum]).encode("ascii")
8184
dump += _FMT.pack(len(dump), _TAG)
82-
with open(module_data["ext_path"], "ab") as file:
85+
with open(module_data["ext_path"], "ab", buffering=0) as file:
8386
file.write(dump)
8487

8588

cppimport/importer.py

+48
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import os
44
import sys
55
import sysconfig
6+
from contextlib import suppress
7+
from time import sleep, time
8+
9+
import filelock
610

711
import cppimport
812
from cppimport.build_module import build_module
@@ -12,6 +16,46 @@
1216
logger = logging.getLogger(__name__)
1317

1418

19+
def build_safely(filepath, module_data):
20+
"""Protect against race conditions when multiple processes executing
21+
`template_and_build`"""
22+
binary_path = module_data["ext_path"]
23+
lock_path = binary_path + cppimport.settings["lock_suffix"]
24+
25+
def build_completed():
26+
return os.path.exists(binary_path) and is_checksum_valid(module_data)
27+
28+
t = time()
29+
30+
# Race to obtain the lock and build. Other processes can wait
31+
while not build_completed() and time() - t < cppimport.settings["lock_timeout"]:
32+
try:
33+
with filelock.FileLock(lock_path, timeout=1):
34+
if build_completed():
35+
break
36+
template_and_build(filepath, module_data)
37+
except filelock.Timeout:
38+
logging.debug(f"Could not obtain lock (pid {os.getpid()})")
39+
if cppimport.settings["force_rebuild"]:
40+
raise ValueError(
41+
"force_build must be False to build concurrently."
42+
"This process failed to claim a filelock indicating that"
43+
" a concurrent build is in progress"
44+
)
45+
sleep(1)
46+
47+
if os.path.exists(lock_path):
48+
with suppress(OSError):
49+
os.remove(lock_path)
50+
51+
if not build_completed():
52+
raise Exception(
53+
f"Could not compile binary as lock already taken and timed out."
54+
f" Try increasing the timeout setting if "
55+
f"the build time is longer (pid {os.getpid()})."
56+
)
57+
58+
1559
def template_and_build(filepath, module_data):
1660
logger.debug(f"Compiling {filepath}.")
1761
run_templating(module_data)
@@ -79,11 +123,15 @@ def is_build_needed(module_data):
79123

80124

81125
def try_load(module_data):
126+
"""Try loading the module to test if it's not corrupt and for the correct
127+
architecture"""
82128
try:
83129
load_module(module_data)
84130
return True
85131
except ImportError as e:
86132
logger.info(
87133
f"ImportError during import with matching checksum: {e}. Trying to rebuild."
88134
)
135+
with suppress(OSError):
136+
os.remove(module_data["fullname"])
89137
return False

environment.yml

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ dependencies:
1010
- pytest
1111
- pytest-cov
1212
- pre-commit
13+
- filelock

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use_scm_version={"version_scheme": "post-release"},
77
setup_requires=["setuptools_scm"],
88
packages=["cppimport"],
9-
install_requires=["mako", "pybind11"],
9+
install_requires=["mako", "pybind11", "filelock"],
1010
zip_safe=False,
1111
name="cppimport",
1212
description="Import C++ files directly from Python!",

tests/test_cppimport.py

+43-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import copy
33
import logging
44
import os
5+
import shutil
56
import subprocess
67
import sys
8+
from multiprocessing import Process
9+
from tempfile import TemporaryDirectory
710

811
import cppimport
912
import cppimport.build_module
@@ -40,11 +43,28 @@ def subprocess_check(test_code, returncode=0):
4043
stdout=subprocess.PIPE,
4144
stderr=subprocess.PIPE,
4245
)
43-
print(p.stdout.decode("utf-8"))
44-
print(p.stderr.decode("utf-8"))
46+
if len(p.stdout) > 0:
47+
print(p.stdout.decode("utf-8"))
48+
if len(p.stderr) > 0:
49+
print(p.stderr.decode("utf-8"))
4550
assert p.returncode == returncode
4651

4752

53+
@contextlib.contextmanager
54+
def tmp_dir(files=None):
55+
"""Create a temporary directory and copy `files` into it. `files` can also
56+
include directories."""
57+
files = files if files else []
58+
59+
with TemporaryDirectory() as tmp_path:
60+
for f in files:
61+
if os.path.isdir(f):
62+
shutil.copytree(f, os.path.join(tmp_path, os.path.basename(f)))
63+
else:
64+
shutil.copyfile(f, os.path.join(tmp_path, os.path.basename(f)))
65+
yield tmp_path
66+
67+
4868
def test_find_module_cpppath():
4969
mymodule_loc = find_module_cpppath("mymodule")
5070
mymodule_dir = os.path.dirname(mymodule_loc)
@@ -170,3 +190,24 @@ def test_import_hook():
170190

171191
cppimport.force_rebuild(False)
172192
hook_test
193+
194+
195+
def test_multiple_processes():
196+
with tmp_dir(["tests/hook_test.cpp"]) as tmp_path:
197+
test_code = f"""
198+
import os;
199+
os.chdir('{tmp_path}');
200+
import cppimport.import_hook;
201+
import hook_test;
202+
"""
203+
processes = [
204+
Process(target=subprocess_check, args=(test_code,)) for i in range(100)
205+
]
206+
207+
for p in processes:
208+
p.start()
209+
210+
for p in processes:
211+
p.join()
212+
213+
assert all(p.exitcode == 0 for p in processes)

0 commit comments

Comments
 (0)