Skip to content

Commit 446c23f

Browse files
committed
fix bug where write back of mutable types (ie lists, dicts...) could run in parallel leading to invalid writeback results and undefined behaviour
1 parent 8c36517 commit 446c23f

2 files changed

Lines changed: 200 additions & 20 deletions

File tree

src/cthreads/python/cthreads/marshal.py

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
1717
After the kernel mutates native memory, `writeback_job_state` pulls mutable
1818
reference arguments back into the same Python objects the caller passed in.
19-
It first runs `demote_shared_from_host` for any `Shared[T]` parameters (and
20-
returns), then `writeback_params` for `Threadable`, `list`, and `dict`
21-
arguments. `unpack_return` reads the `ret` field from the pack (or shared
22-
host) and builds the Python return value.
19+
For each live `Threadable`, `list`, or `dict` argument it takes a per-object
20+
row lock, demotes that `Shared[T]` slot if needed, then unpacks into the
21+
caller object. A shared return is demoted once after those arguments.
22+
`unpack_return` reads the `ret` field from the pack (or shared host) and
23+
builds the Python return value.
2324
2425
Parameters and returns use the same recursive pack/unpack logic, so scalars,
2526
`Threadable` instances, nested structures, `list[T]`, and `dict[str|int, T]`
@@ -29,7 +30,8 @@
2930
Every public entry point takes an explicit pack pointer (and optionally a
3031
`SharedHost` pointer). Marshal does not keep a process-global pack slot, so
3132
concurrent jobs can pack, write back, and unpack in parallel while ctypes
32-
releases the Global Interpreter Lock (GIL).
33+
releases the Global Interpreter Lock (GIL). Writeback of the same Python
34+
object is serialized by `_wb_table` so `clear` and `append` cannot overlap.
3335
3436
The kernel dynamic-link library (DLL) is loaded once per kernel path and
3537
cached as a `CDLL` handle. Each native call goes through `_call`, which binds
@@ -97,6 +99,10 @@ class _Path:
9799
_cached_lib: ctypes.CDLL | None = None
98100
_cached_path: str | None = None
99101

102+
# Per Python object identity: [row lock, waiter count]. Created on first
103+
# writeback of that object and deleted when the last waiter leaves.
104+
_wb_table: dict[int, list] = {}
105+
_wb_table_mu = threading.Lock()
100106

101107
def _lib() -> ctypes.CDLL | None:
102108
"""
@@ -1075,8 +1081,11 @@ def writeback_job_state(
10751081
Sync shared host memory and mirror mutable reference arguments into Python.
10761082
10771083
This is the main post-kernel and mid-run sync entry point called from
1078-
`module.cpp`. It demotes shared slots, then writebacks Threadables, lists,
1079-
and dicts into the original caller objects.
1084+
`module.cpp`. Each live list, dict, or Threadable is handled on its own:
1085+
a per-object row lock is taken, that parameter's Shared slot is demoted
1086+
if needed, then the pack is unpacked into the caller's object. Scalars
1087+
and other non-mutable kinds are skipped. A shared return is demoted once
1088+
after the argument loop.
10801089
10811090
#### Args:
10821091
- symbol: str = compiled kernel export prefix
@@ -1091,11 +1100,93 @@ def writeback_job_state(
10911100
#### Technical terms:
10921101
- writeback: mirror native pack fields into the caller's Python objects.
10931102
- demote: refresh staged pack slots from SharedHost before writeback.
1103+
- row lock: per-object mutex in `_wb_table` so two jobs cannot rebuild
1104+
the same Python list or dict at once.
10941105
- kernel meta: compile output passed from `module.cpp`.
10951106
"""
10961107
meta = meta or {}
1097-
demote_shared_from_host(symbol, params, pack_ptr, host_ptr, meta)
1098-
writeback_params(symbol, params, values, pack_ptr, types, schemas)
1108+
types = types or {}
1109+
schemas = schemas or {}
1110+
# Resolve the kernel library and pack pointer once; every demote and unpack
1111+
# in this job uses the same pack.
1112+
lib = _lib()
1113+
pack = _pack_c(pack_ptr)
1114+
1115+
for i, (param, value) in enumerate(zip(params, values)):
1116+
schema = param.get("schema") or _legacy_schema(param)
1117+
# Scalars, sync primitives, and tensor buffers were copied by value or
1118+
# are not Python objects we mutate in place, so they need no lock.
1119+
if schema["kind"] not in ("threadable", "list", "dict"):
1120+
continue
1121+
# A plain dict stand-in is not the live Threadable the caller owns, so
1122+
# unpacking into it would write a throwaway object.
1123+
if isinstance(value, dict) and schema["kind"] == "threadable":
1124+
continue
1125+
1126+
# Key the table by object identity so two jobs sharing `head` wait on
1127+
# one lock, while two jobs with different lists can overlap.
1128+
value_id: int = id(value)
1129+
with _wb_table_mu:
1130+
row = _wb_table.get(value_id)
1131+
if row is None:
1132+
# First writeback of this object: create the row lock and a
1133+
# waiter count of zero before we increment it below.
1134+
row = [threading.Lock(), 0]
1135+
_wb_table[value_id] = row
1136+
# Count this job as a waiter so the row is not deleted while we
1137+
# block on `row_lock.acquire()` or while unpack is running.
1138+
row[1] += 1
1139+
row_lock: threading.Lock = row[0]
1140+
1141+
try:
1142+
# Take the row lock outside the table mutex so other objects can
1143+
# still create or join their own rows while we wait.
1144+
row_lock.acquire()
1145+
try:
1146+
# Demote this slot only, using the real parameter index `i` so
1147+
# a Shared list in `a1` does not hit `a0`. Do not pass `meta`
1148+
# here: that would also demote the return on every argument.
1149+
if param.get("pass_as") == "shared" and host_ptr:
1150+
host = _pack_c(host_ptr)
1151+
demote_fn = _fn(lib, f"{symbol}__demote_a{i}_shared")
1152+
_call(
1153+
demote_fn,
1154+
None,
1155+
[ctypes.c_void_p, ctypes.c_void_p],
1156+
pack,
1157+
host,
1158+
)
1159+
# Unpack while the row lock is still held so another job cannot
1160+
# `clear`/`append` the same Python object mid-rebuild. ctypes
1161+
# may drop the GIL inside `_call`; the row lock still serializes.
1162+
unpack_value(
1163+
lib,
1164+
symbol,
1165+
f"a{i}",
1166+
schema,
1167+
_Path(),
1168+
pack,
1169+
types=types,
1170+
schemas=schemas,
1171+
into=value,
1172+
)
1173+
finally:
1174+
row_lock.release()
1175+
finally:
1176+
# Drop our waiter even if demote or unpack raised, then delete the
1177+
# row when nobody is left so `id` reuse cannot join a stale lock.
1178+
with _wb_table_mu:
1179+
row = _wb_table[value_id]
1180+
row[1] -= 1
1181+
if row[1] == 0:
1182+
del _wb_table[value_id]
1183+
1184+
# Shared returns live in SharedHost, not in a caller argument, so they are
1185+
# demoted once after the per-object loop and need no table row.
1186+
if host_ptr and meta.get("return_pass_as") == "shared":
1187+
host = _pack_c(host_ptr)
1188+
ret_fn = _fn(lib, f"{symbol}__demote_return_shared")
1189+
_call(ret_fn, None, [ctypes.c_void_p, ctypes.c_void_p], pack, host)
10991190

11001191

11011192
def writeback_params(

tests/unit/test_marshal_shared.py

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,36 @@ def test_demote_shared_noop_when_host_null():
116116

117117

118118
def test_writeback_job_state_demotes_then_writebacks(monkeypatch):
119-
calls: list[str] = []
119+
captured: list[str] = []
120+
head = [1, 2]
120121

121-
def fake_demote(symbol, params, pack_ptr, host_ptr, meta=None):
122-
calls.append("demote")
123-
assert host_ptr == 0xBEEF
122+
class FakeFn:
123+
pass
124124

125-
def fake_writeback(symbol, params, values, pack_ptr, types=None, schemas=None):
126-
calls.append("writeback")
127-
assert pack_ptr == 0x1000
125+
demote0 = FakeFn()
126+
demote_ret = FakeFn()
128127

129-
monkeypatch.setattr(marshal, "demote_shared_from_host", fake_demote)
130-
monkeypatch.setattr(marshal, "writeback_params", fake_writeback)
128+
def fake_fn(_lib, name):
129+
captured.append(name)
130+
if name == "step__demote_a0_shared":
131+
return demote0
132+
if name == "step__demote_return_shared":
133+
return demote_ret
134+
raise AssertionError(name)
135+
136+
def fake_call(fn, restype, argtypes, *args):
137+
captured.append("call")
138+
assert fn in (demote0, demote_ret)
139+
140+
def fake_unpack(lib, symbol, prefix, schema, path, pack, **kw):
141+
captured.append(f"unpack:{prefix}")
142+
assert kw.get("into") is head
143+
144+
monkeypatch.setattr(marshal, "_lib", lambda: object())
145+
monkeypatch.setattr(marshal, "_pack_c", lambda p: ctypes.c_void_p(p))
146+
monkeypatch.setattr(marshal, "_fn", fake_fn)
147+
monkeypatch.setattr(marshal, "_call", fake_call)
148+
monkeypatch.setattr(marshal, "unpack_value", fake_unpack)
131149

132150
marshal.writeback_job_state(
133151
"step",
@@ -138,12 +156,83 @@ def fake_writeback(symbol, params, values, pack_ptr, types=None, schemas=None):
138156
"schema": {"kind": "list", "inner": {"kind": "int"}},
139157
}
140158
],
141-
[[1, 2]],
159+
[head],
142160
0x1000,
143161
0xBEEF,
144162
meta={"return_pass_as": "shared", "symbol": "step", "params": []},
145163
)
146-
assert calls == ["demote", "writeback"]
164+
assert captured == [
165+
"step__demote_a0_shared",
166+
"call",
167+
"unpack:a0",
168+
"step__demote_return_shared",
169+
"call",
170+
]
171+
assert marshal._wb_table == {}
172+
173+
174+
def test_writeback_job_state_uses_original_param_index(monkeypatch):
175+
captured: list[str] = []
176+
head = [0, 0]
177+
178+
def fake_fn(_lib, name):
179+
captured.append(name)
180+
return object()
181+
182+
def fake_unpack(lib, symbol, prefix, schema, path, pack, **kw):
183+
captured.append(f"unpack:{prefix}")
184+
185+
monkeypatch.setattr(marshal, "_lib", lambda: object())
186+
monkeypatch.setattr(marshal, "_pack_c", lambda p: ctypes.c_void_p(p))
187+
monkeypatch.setattr(marshal, "_fn", fake_fn)
188+
monkeypatch.setattr(marshal, "_call", lambda *a, **k: None)
189+
monkeypatch.setattr(marshal, "unpack_value", fake_unpack)
190+
191+
marshal.writeback_job_state(
192+
"step",
193+
[
194+
{"name": "n", "pass_as": "value", "schema": {"kind": "int"}},
195+
{
196+
"name": "head",
197+
"pass_as": "shared",
198+
"schema": {"kind": "list", "inner": {"kind": "int"}},
199+
},
200+
],
201+
[3, head],
202+
0x1000,
203+
0xBEEF,
204+
)
205+
assert captured == ["step__demote_a1_shared", "unpack:a1"]
206+
207+
208+
def test_writeback_job_state_skips_scalars_and_threadable_dicts(monkeypatch):
209+
monkeypatch.setattr(marshal, "_lib", lambda: object())
210+
monkeypatch.setattr(marshal, "_pack_c", lambda p: ctypes.c_void_p(p))
211+
monkeypatch.setattr(
212+
marshal,
213+
"_fn",
214+
lambda *a, **k: pytest.fail("demote should not run"),
215+
)
216+
monkeypatch.setattr(
217+
marshal,
218+
"unpack_value",
219+
lambda *a, **k: pytest.fail("unpack should not run"),
220+
)
221+
222+
marshal.writeback_job_state(
223+
"step",
224+
[
225+
{"name": "n", "pass_as": "value", "schema": {"kind": "int"}},
226+
{
227+
"name": "c",
228+
"pass_as": "ref",
229+
"schema": {"kind": "threadable", "type_name": "Counter"},
230+
},
231+
],
232+
[1, {"n": 0}],
233+
0x1000,
234+
0,
235+
)
147236

148237

149238
def test_unpack_return_demotes_shared_return_before_read(monkeypatch):

0 commit comments

Comments
 (0)