Skip to content

Commit 078bfe3

Browse files
authored
Merge pull request #3071 from devitocodes/fix-complex-header-race
compiler: Write the complex arithmetic header atomically
2 parents 16e6861 + 9a332fc commit 078bfe3

6 files changed

Lines changed: 130 additions & 12 deletions

File tree

‎devito/arch/compiler.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from devito.logger import debug, warning
2323
from devito.parameters import configuration
2424
from devito.tools import (
25-
Signer, as_list, change_directory, filter_ordered, make_tempdir, memoized_func
25+
Signer, as_list, atomic_write, change_directory, filter_ordered, make_tempdir,
26+
memoized_func
2627
)
2728

2829
__all__ = ['compiler_registry', 'sniff_mpi_distro']
@@ -316,10 +317,17 @@ def save_header(self, filename, code):
316317
The name of the header file (w/o the suffix).
317318
code : str
318319
The source code to be stored.
320+
321+
Returns
322+
-------
323+
Path
324+
The path of the header file.
319325
"""
320326
hfile = self.get_jit_dir().joinpath(filename).with_suffix('.h')
321-
with open(str(hfile), 'w') as f:
322-
f.write(code)
327+
# The JIT dir is shared with other processes, e.g. the workers of a
328+
# parallel test run, so the header is written atomically: a concurrent
329+
# compiler never reads a truncated or half-written header
330+
return atomic_write(hfile, code)
323331

324332
def save(self, soname, binary):
325333
"""

‎devito/passes/iet/dtypes.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ def _complex_includes(iet: Callable, langbb: type[LangBB], compiler: Compiler,
2828

2929
# Some languages such as c++11 need some extra arithmetic definitions
3030
if langbb.get('def-complex'):
31-
dest = compiler.get_jit_dir()
32-
hfile = dest.joinpath('complex_arith.h')
33-
with open(str(hfile), 'w') as ff:
34-
ff.write(str(langbb['def-complex']))
31+
# The definitions differ across languages (e.g., CUDA vs C++), hence
32+
# the language-specific header name
33+
langname = langbb['name'].lower()
34+
hfile = compiler.save_header(f'{langname}_complex_arith',
35+
str(langbb['def-complex']))
3536
lib += (str(hfile),)
3637

3738
metadata['includes'] = lib

‎devito/tools/os_helper.py‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import os
2+
from contextlib import suppress
23
from pathlib import Path
3-
from tempfile import gettempdir
4+
from tempfile import gettempdir, mkstemp
45

5-
__all__ = ['change_directory', 'make_tempdir']
6+
__all__ = ['atomic_write', 'change_directory', 'make_tempdir']
67

78

89
class change_directory:
@@ -34,3 +35,32 @@ def make_tempdir(prefix=None):
3435
tmpdir = Path(gettempdir()).joinpath(name)
3536
tmpdir.mkdir(parents=True, exist_ok=True)
3637
return tmpdir
38+
39+
40+
def atomic_write(path, data, mode='w'):
41+
"""
42+
Write `data` to `path` through a private temporary file in the same
43+
directory, then rename it into place. The rename is atomic, so a concurrent
44+
reader never sees a partial file: it reads either the previous content or
45+
the new one, and a reader that already opened the previous file keeps
46+
reading that one in full. The temporary file is removed if the write fails.
47+
48+
Note: the resulting file has the restrictive mode of the temporary file
49+
(0600, further restricted by the process umask).
50+
"""
51+
path = Path(path)
52+
fd, tmpfile = mkstemp(dir=path.parent, prefix=f'{path.name}.', suffix='.tmp')
53+
try:
54+
try:
55+
f = os.fdopen(fd, mode)
56+
except BaseException:
57+
os.close(fd)
58+
raise
59+
with f:
60+
f.write(data)
61+
os.replace(tmpfile, path)
62+
except BaseException:
63+
with suppress(FileNotFoundError):
64+
os.unlink(tmpfile)
65+
raise
66+
return path

‎tests/test_arch.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from concurrent.futures import ThreadPoolExecutor
2+
from contextlib import suppress
3+
14
import pytest
25

36
from devito import configuration, switchconfig
@@ -29,3 +32,37 @@ def test_switcharch():
2932
assert isinstance(tmp_comp, old_compiler.__class__)
3033
assert old_compiler.suffix == tmp_comp.suffix
3134
assert old_compiler.name == tmp_comp.name
35+
36+
37+
def test_save_header_concurrent(tmp_path, monkeypatch):
38+
"""
39+
The JIT dir is shared across processes, so a header being rewritten must
40+
never be seen truncated or half-written by a concurrent reader.
41+
"""
42+
compiler = configuration['compiler']
43+
monkeypatch.setattr(compiler, 'get_jit_dir', lambda: tmp_path)
44+
45+
hfile = tmp_path.joinpath('header.h')
46+
codes = ['a'*2**20, 'b'*2**20]
47+
48+
def write(code):
49+
for _ in range(20):
50+
compiler.save_header('header', code)
51+
52+
def read(writers):
53+
seen = set()
54+
while not all(w.done() for w in writers):
55+
with suppress(FileNotFoundError):
56+
seen.add(hfile.read_text())
57+
return seen
58+
59+
with ThreadPoolExecutor(max_workers=8) as tpe:
60+
writers = [tpe.submit(write, codes[i % 2]) for i in range(4)]
61+
readers = [tpe.submit(read, writers) for _ in range(4)]
62+
for w in writers:
63+
w.result()
64+
seen = set.union(*[r.result() for r in readers])
65+
66+
# Only complete headers are ever read, and no temporary file is left behind
67+
assert seen and seen <= set(codes)
68+
assert list(tmp_path.iterdir()) == [hfile]

‎tests/test_dtypes.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pathlib import Path
2+
13
import numpy as np
24
import pytest
35
import sympy
@@ -13,8 +15,9 @@
1315
from devito.ir.cgen.printer import BasePrinter, get_printer
1416
from devito.passes.iet.langbase import LangBB
1517
from devito.passes.iet.languages.C import CBB, CPrinter
18+
from devito.passes.iet.languages.CXX import CXXPrinter
1619
from devito.passes.iet.languages.openacc import AccBB, AccPrinter
17-
from devito.passes.iet.languages.openmp import OmpBB
20+
from devito.passes.iet.languages.openmp import CXXOmpBB, OmpBB
1821
from devito.symbolics.extended_dtypes import ctypes_vector_mapper
1922
from devito.tools import dtype_to_cstr
2023
from devito.types.basic import Basic, Scalar, Symbol
@@ -25,12 +28,14 @@
2528
_languages: dict[str, type[LangBB]] = {
2629
'C': CBB,
2730
'openmp': OmpBB,
31+
'CXXopenmp': CXXOmpBB,
2832
'openacc': AccBB
2933
}
3034

3135
_printers: dict[str, type[BasePrinter]] = {
3236
'C': CPrinter,
3337
'openmp': CPrinter,
38+
'CXXopenmp': CXXPrinter,
3439
'openacc': AccPrinter
3540
}
3641

@@ -147,6 +152,30 @@ def test_complex_headers(dtype: np.dtype[np.inexact], kwargs: dict[str, str]) ->
147152
assert header not in op._includes
148153

149154

155+
@pytest.mark.parametrize('kwargs', _configs + [_config_kwargs('cpu64', 'CXXopenmp')],
156+
ids=kw_id)
157+
def test_complex_arith_header(kwargs: dict[str, str]) -> None:
158+
"""
159+
Tests that the extra complex arithmetic definitions some languages need are
160+
saved in full to a language-specific header the operator includes.
161+
"""
162+
langbb = _get_language(**kwargs)
163+
code = langbb.get('def-complex')
164+
if code is None:
165+
pytest.skip(f"`{kwargs['language']}` needs no complex arithmetic header")
166+
167+
# Set up an operator
168+
grid = Grid(shape=(3, 3))
169+
x, y = grid.dimensions
170+
171+
u = Function(name='u', grid=grid, dtype=np.complex64)
172+
op = Operator(Eq(u, x*y + 1j), **kwargs)
173+
174+
hfile, = [Path(i) for i in op._includes if 'complex_arith' in i]
175+
assert hfile.name == f"{langbb['name'].lower()}_complex_arith.h"
176+
assert hfile.read_text() == str(code)
177+
178+
150179
@pytest.mark.parametrize('dtype', [np.complex64, np.complex128])
151180
@pytest.mark.parametrize('kwargs', _configs, ids=kw_id)
152181
def test_imag_unit(dtype: np.complexfloating, kwargs: dict[str, str]) -> None:

‎tests/test_tools.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
from devito import Eq, Operator, switchenv
99
from devito.tools import (
10-
CacheInstances, DefaultFrozenDict, UnboundedMultiTuple, UnboundTuple, ctypes_to_cstr,
11-
filter_ordered, memoized_meth, toposort, transitive_closure
10+
CacheInstances, DefaultFrozenDict, UnboundedMultiTuple, UnboundTuple, atomic_write,
11+
ctypes_to_cstr, filter_ordered, memoized_meth, toposort, transitive_closure
1212
)
1313
from devito.types.basic import Symbol
1414

@@ -338,3 +338,16 @@ def foo():
338338
finally:
339339
os.environ.clear()
340340
os.environ.update(previous_environ)
341+
342+
343+
def test_atomic_write(tmp_path):
344+
path = tmp_path.joinpath('data.txt')
345+
346+
assert atomic_write(path, 'foo') == path
347+
assert path.read_text() == 'foo'
348+
349+
# A failed write leaves the previous file untouched and no temporary file behind
350+
with pytest.raises(TypeError):
351+
atomic_write(path, 1)
352+
assert path.read_text() == 'foo'
353+
assert list(tmp_path.iterdir()) == [path]

0 commit comments

Comments
 (0)