Skip to content

Commit d51242e

Browse files
mlouboutJDBetteridge
authored andcommitted
compiler: order the async task handshake
The lock and flag an asynchronous task synchronises on are plain volatile ints, written by both threads with no ordering imposed. `volatile` guarantees the loads are re-issued; it does not order stores, so on a weakly ordered target they can be observed out of order and the handshake loses an update: compute: lock0[0] = 0; (release_lock0) -- observed late sdata0->flag = 2; (activate0) -- observed first task: sees the request, delivers lock0[0] = 2, sets flag = 1 compute: the late lock0[0] = 0 lands, wiping the delivery the next release_lock0 waits for a 2 nobody will write again Both threads then spin forever: the compute waiting for data whose request it has already spent, the task waiting to be asked. `lock == 0 && flag == 1` is reachable only this way -- the task sets the lock before the flag, so a completed cycle must leave the lock at 2 -- and that is the state a stalled run sits in. Fenced on both sides: a release before the flag that publishes a request or a completion, an acquire before reading what either stands for. `__atomic_thread_fence` is a builtin, valid in C and C++ alike, so the device targets that render through CXXPrinter are unaffected; declaring the two objects `_Atomic` would have been the standard-clean alternative but that is not valid C++ under g++. Found through a streaming-checkpoint FWI gradient on arm64, where it stalled one worker in six within minutes and, in the same runs, had CvxCompress trip its own assertion on a buffer the task never filled. x86's store ordering hides it. The tests index the two callables positionally, so they move with the fences.
1 parent 05a94fe commit d51242e

5 files changed

Lines changed: 77 additions & 11 deletions

File tree

devito/ir/iet/nodes.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
AFFINE, INBOUND, PARALLEL, PARALLEL_IF_ATOMIC, PARALLEL_IF_PVT, SEQUENTIAL,
1919
VECTORIZED, Forward, PrefetchUpdate, Property, WithLock
2020
)
21-
from devito.symbolics import CallFromPointer, ListInitializer
21+
from devito.symbolics import CallFromPointer, ListInitializer, Macro
2222
from devito.tools import (
2323
Signer, as_tuple, ctypes_to_cstr, filter_ordered, filter_sorted, flatten
2424
)
@@ -61,6 +61,7 @@
6161
'Section',
6262
'Switch',
6363
'SyncSpot',
64+
'ThreadFence',
6465
'TimedList',
6566
'Transfer',
6667
'Using',
@@ -1302,6 +1303,31 @@ def size(self):
13021303
return self.ispace.size
13031304

13041305

1306+
class ThreadFence(Call):
1307+
1308+
"""
1309+
A memory fence, ordering a thread's accesses either side of it.
1310+
1311+
Threads exchanging work through a shared flag need memory ordering on both
1312+
sides: a release before publishing the flag, and an acquire after observing it.
1313+
This ensures the state associated with the flag is visible before it is used.
1314+
Without this ordering, updates may be observed in the wrong order and lost.
1315+
1316+
`__atomic_thread_fence` is a compiler builtin, so this renders the same in C
1317+
and in C++ and needs no header.
1318+
"""
1319+
1320+
def __init__(self, order):
1321+
assert order in ('acquire', 'release')
1322+
super().__init__('__atomic_thread_fence',
1323+
[Macro(f'__ATOMIC_{order.upper()}')])
1324+
self._order = order
1325+
1326+
@property
1327+
def order(self):
1328+
return self._order
1329+
1330+
13051331
class Prodder(Call):
13061332

13071333
"""

devito/passes/iet/asynchrony.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from devito.ir import (
88
AsyncCall, AsyncCallable, BlankLine, Call, Callable, Conditional, DummyEq, DummyExpr,
99
EntryFunction, FindNodes, FindSymbols, Increment, Iteration, List, PointerCast,
10-
Return, ThreadCallable, Transformer, While, make_callable, maybe_alias
10+
Return, ThreadCallable, ThreadFence, Transformer, While, make_callable, maybe_alias
1111
)
1212
from devito.passes.iet.definitions import DataManager
1313
from devito.passes.iet.engine import iet_pass
@@ -86,6 +86,11 @@ def _lower_async_objs(iet, tracker=None, sregistry=None, **kwargs):
8686
arguments.append(i)
8787
activation.extend([DummyExpr(FieldFromComposite(i.base, sdata[d]), i)
8888
for i in arguments])
89+
90+
# Publishing the request must happen last. Everything the thread depends on
91+
# must be visible before this flag is set. `volatile` does not provide that
92+
# ordering, so weakly ordered targets could otherwise observe stale state.
93+
activation.append(ThreadFence('release'))
8994
activation.append(
9095
DummyExpr(FieldFromComposite(sdata.symbolic_flag, sdata[d]), 2)
9196
)
@@ -138,12 +143,17 @@ def _(iet, key=None, tracker=None, sregistry=None, **kwargs):
138143
tbase = threads.indexed
139144

140145
# Prepend the SharedData fields available upon thread activation
141-
preactions = [DummyExpr(i, FieldFromPointer(i.base, sbase)) for i in ncfields]
146+
preactions = [ThreadFence('acquire')]
147+
preactions.extend(DummyExpr(i, FieldFromPointer(i.base, sbase))
148+
for i in ncfields)
142149
preactions.append(BlankLine)
143150

144151
# Append the flag reset
145152
postactions = [List(body=[
146153
BlankLine,
154+
# Whatever the task produced -- data in a buffer, a lock handed back --
155+
# has to be visible before the flag says it is done.
156+
ThreadFence('release'),
147157
DummyExpr(FieldFromPointer(sdata.symbolic_flag, sbase), 1)
148158
])]
149159

devito/passes/iet/orchestration.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from devito.exceptions import CompilationError
88
from devito.ir.iet import (
99
AsyncCall, AsyncCallable, BlankLine, Block, BusyWait, Call, Callable, Conditional,
10-
DummyExpr, List, SyncSpot, Transformer, derive_parameters, make_callable
10+
DummyExpr, List, SyncSpot, ThreadFence, Transformer, derive_parameters, make_callable
1111
)
1212
from devito.ir.iet.visitors import Visitor
1313
from devito.ir.support import (
@@ -53,7 +53,11 @@ def _make_waitlock(self, iet, sync_ops, *args):
5353
def _make_releaselock(self, iet, sync_ops, *args):
5454
pre = []
5555
pre.append(BusyWait(Or(*[CondNe(s.handle, 2) for s in sync_ops])))
56+
pre.append(ThreadFence('acquire'))
5657
pre.extend(DummyExpr(s.handle, 0) for s in sync_ops)
58+
# Hand the lock back before anything that follows can be observed --
59+
# in particular before the request that asks the thread to refill it.
60+
pre.append(ThreadFence('release'))
5761

5862
name = self.sregistry.make_name(prefix="release_lock")
5963
parameters = derive_parameters(pre, ordering='canonical')

tests/test_gpu_common.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,16 @@ def test_tasking_in_isolation(self, opt):
413413
assert str(sections[0].body[0].body[0].body[0].body[0]) == 'while(lock0[0] == 0);'
414414
body = op._func_table['release_lock0'].root.body
415415
assert str(body.body[0].condition) == 'Ne(lock0[0], 2)'
416-
assert str(body.body[1]) == 'lock0[0] = 0;'
416+
# An acquire fence pairs with the thread's release before the lock is
417+
# read back, and a release fence publishes it before the next request
418+
assert 'atomic_thread_fence' in str(body.body[1])
419+
assert str(body.body[2]) == 'lock0[0] = 0;'
420+
assert 'atomic_thread_fence' in str(body.body[3])
417421
body = op._func_table['activate0'].root.body
418422
assert str(body.body[0].condition) == 'Ne(sdata0[0].flag, 1)'
419423
assert str(body.body[1]) == 'sdata0[0].time = time;'
420-
assert str(body.body[2]) == 'sdata0[0].flag = 2;'
424+
assert 'atomic_thread_fence' in str(body.body[2])
425+
assert str(body.body[3]) == 'sdata0[0].flag = 2;'
421426

422427
op.apply(time_M=nt-2)
423428

@@ -529,12 +534,15 @@ def test_tasking_forcefuse(self):
529534
'while(lock0[0] == 0 || lock1[0] == 0);') # Wait-lock
530535
body = op._func_table['release_lock0'].root.body
531536
assert str(body.body[0].condition) == 'Ne(lock0[0], 2) | Ne(lock1[0], 2)'
532-
assert str(body.body[1]) == 'lock0[0] = 0;' # Set-lock
533-
assert str(body.body[2]) == 'lock1[0] = 0;' # Set-lock
537+
assert 'atomic_thread_fence' in str(body.body[1])
538+
assert str(body.body[2]) == 'lock0[0] = 0;' # Set-lock
539+
assert str(body.body[3]) == 'lock1[0] = 0;' # Set-lock
540+
assert 'atomic_thread_fence' in str(body.body[4])
534541
body = op._func_table['activate0'].root.body
535542
assert str(body.body[0].condition) == 'Ne(sdata0[0].flag, 1)' # Wait-thread
536543
assert str(body.body[1]) == 'sdata0[0].time = time;'
537-
assert str(body.body[2]) == 'sdata0[0].flag = 2;'
544+
assert 'atomic_thread_fence' in str(body.body[2])
545+
assert str(body.body[3]) == 'sdata0[0].flag = 2;'
538546
assert len(op._func_table) == 5
539547
exprs = FindNodes(Expression).visit(op._func_table['copy_to_host0'].root)
540548
b = 21 if configuration['language'] == 'openacc' else 20 # No `qid` w/ OMP
@@ -597,8 +605,9 @@ def test_tasking_multi_output(self):
597605
assert str(sections[0].body[0].body[0].body[0].body[0]) ==\
598606
'while(lock0[t2] == 0);'
599607
body = op1._func_table['release_lock0'].root.body
608+
assert 'atomic_thread_fence' in str(body.body[1])
600609
for i in range(3):
601-
assert 'lock0[t' in str(body.body[1 + i]) # Set-lock
610+
assert 'lock0[t' in str(body.body[2 + i]) # Set-lock
602611
body = op1._func_table['activate0'].root.body
603612
assert str(body.body[-1]) == 'sdata0[wi0].flag = 2;'
604613
assert len(op1._func_table) == 5

tests/test_iet.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from devito.ir.iet import (
1313
Call, Callable, CGen, Conditional, Definition, Dereference, DeviceCall, DummyExpr,
1414
ElementalFunction, FindNodes, FindSymbols, Iteration, KernelLaunch, Lambda, List,
15-
Switch, Transformer, filter_iterations, make_callable, make_efunc,
15+
Switch, ThreadFence, Transformer, filter_iterations, make_callable, make_efunc,
1616
retrieve_iteration_tree
1717
)
1818
from devito.ir.iet.visitors import sorted_efuncs
@@ -118,6 +118,23 @@ def test_nested_calls_cgen():
118118
assert str(code) == 'foo(bar());'
119119

120120

121+
def test_thread_fence_cgen():
122+
"""
123+
A fence renders as the builtin, which is valid in C and C++ alike.
124+
125+
Threads that hand work to each other through a shared flag need one either
126+
side of the handshake; without them the stores can be observed out of order
127+
and a hand-off is lost.
128+
"""
129+
assert str(CGen().visit(ThreadFence('acquire'))) == \
130+
'__atomic_thread_fence(__ATOMIC_ACQUIRE);'
131+
assert str(CGen().visit(ThreadFence('release'))) == \
132+
'__atomic_thread_fence(__ATOMIC_RELEASE);'
133+
134+
with pytest.raises(AssertionError):
135+
ThreadFence('seq_cst')
136+
137+
121138
@pytest.mark.parametrize('mode,expected', [
122139
('basics', '["x"]'),
123140
('symbolics', '["f"]')

0 commit comments

Comments
 (0)