1616
1717After the kernel mutates native memory, `writeback_job_state` pulls mutable
1818reference 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
2425Parameters and returns use the same recursive pack/unpack logic, so scalars,
2526`Threadable` instances, nested structures, `list[T]`, and `dict[str|int, T]`
2930Every public entry point takes an explicit pack pointer (and optionally a
3031`SharedHost` pointer). Marshal does not keep a process-global pack slot, so
3132concurrent 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
3436The kernel dynamic-link library (DLL) is loaded once per kernel path and
3537cached 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
101107def _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
11011192def writeback_params (
0 commit comments