Repository navigation
Fix shared memory closed by two threads after mp_prefetch finishes a shard - #1421
mahdi-shafiei wants to merge 1 commit into
Conversation
…a shard. `ThreadPrefetchDatasetIterator.__next__` and `ProcessPrefetchDatasetIterator.__next__` re-raise a stored exception from a local variable. The exception's traceback references the raising frame and the frame references the exception, so every shard that ends leaves a reference cycle that holds the `ProcessPrefetchDatasetIterator` and, through the producer thread's frame, the last element. Only the cyclic garbage collector frees it. When the cyclic garbage collector frees a `SharedMemoryArray` together with its `SharedMemory`, it calls both finalizers in one pass: `SharedMemoryArray.__del__` hands the `SharedMemory` to the delete thread pool, which closes it, and `SharedMemory.__del__` closes it on the collecting thread. `SharedMemory.close()` releases the GIL in `os.close` before it sets `_fd = -1`, so both threads can close the same descriptor number, and the second call closes whatever file reused that number in between. Delete the exception locals after raising, drop the producer's reference to an element once it is in the buffer, and make the delete thread the only owner of a close that has been handed over to it.
|
Two more effects of the cycle in #1420, both seen downstream and both gone with this PR's head (65bac7d). The consumer's frame keeps its last element alive. The traceback in the cycle reaches the frame that iterated the dataset, so after a function that loops over an """Repro: after an mp_prefetch run ends, the consumer's frame keeps its last element alive.
Needs only grain and numpy. Run on Linux:
python repro.py
"""
import gc
import sys
import time
import weakref
from absl import flags
import grain
import numpy as np
def _element(index):
# 256 KiB, so it is sent from the worker through shared memory.
return np.full((64, 1024), index, np.float32)
def consume(ds):
last = None
for x in ds:
last = x
return weakref.ref(last)
if __name__ == "__main__":
flags.FLAGS(sys.argv[:1])
print(f"grain {grain.__version__}, Python {sys.version.split()[0]}")
ds = grain.MapDataset.range(64).map(_element).to_iter_dataset()
ds = ds.mp_prefetch(grain.MultiprocessingOptions(num_workers=2))
gc.collect()
gc.disable()
last = consume(ds)
print(f" consume() returned: last element alive = {last() is not None}")
del ds
print(f" dataset dropped: last element alive = {last() is not None}")
gc.collect()
print(f" after gc.collect(): last element alive = {last() is not None}")
# Let the delete thread pool finish before exit. Without the wait, the
# resource tracker can report a segment still queued for deletion at exit,
# with or without the fix.
time.sleep(1)Command, run in an empty directory, once per build: Output, the same in 3 of 3 runs of each (absl's jax profiler warnings removed): ( A collection on the consumer thread does Grain's cleanup. Whichever thread triggers the collection that frees the cycle also runs the
That test is in our own suite and needs our package, so it isn't posted here. The repro above needs only Grain and NumPy. |
Fixes #1420.
What
Two fixes, one per cause in the issue.
No reference cycle when a prefetch iterator re-raises.
ThreadPrefetchDatasetIterator.__next__andProcessPrefetchDatasetIterator.__next__now delete theirerrlocal after raising it (try: raise err/finally: del err, asconcurrent.futures.Future.result()does). The producer_put_iterator_elements_in_bufferdrops itselementandstatelocals once they are in the buffer, so an exception raised by the next__next__call does not keep the last element alive through the producer frame. TheProcessPrefetchDatasetIteratorchange also folds the tworaise errsites into one. Behaviour is unchanged: StopIteration is still raised without a reconstructed traceback, and other errors still get the worker traceback.One closer per SharedMemory.
SharedMemoryArraynow creates its segments as_SharedMemory, a privateSharedMemorysubclass. WhenSharedMemoryArray.__del__hands a close to the delete thread pool, it setsclose_handed_overon the segment, and_SharedMemory.__del__then leaves the close to the delete thread. When the cyclic GC finalizes the array and itsSharedMemoryin one pass, only the delete thread closes the segment. IfSharedMemory.__del__runs first, it closes on the collecting thread before the hand-off, and the delete thread'sclose()finds_fd == -1. Either wayos.closeruns once.A plain
SharedMemorypassed toSharedMemoryArray.from_shared_memorykeeps its own__del__, which cannot be coordinated with the delete thread, so__del__now closes that one synchronously. Every segment Grain creates (SharedMemoryArray(...),from_metadata, unpickling) is a_SharedMemoryand still goes through the delete thread pool, so munmap stays off the consumer thread as before. The semaphore flow control is unchanged.Why not something smaller
Making
SharedMemory.close()thread-safe with a lock would still let the collecting thread do the munmap whenever it wins the lock, which is what the delete pool exists to avoid. Disabling async delete, or callinggc.collect()at pool shutdown, hides the race instead of removing the second closer. Fixing only the cycles is not enough either. Any user code that puts a batch in a cycle (an exception stored with a batch in a local, for example) reaches the same double close.Tests
SharedMemoryArrayTest.test_cyclic_gc_closes_shm_once_with_async_del: an array that survived one collection is made cyclic and collected with async delete on.os.closeis wrapped to record calls and to sleep briefly after closing the segment's fd. On main the fd is closed twice (2 != 1, 3 of 3 runs). With the fix it is closed once.ThreadPrefetchIterDatasetTest.test_finished_iterator_is_freed_without_cyclic_gc_{stop_iteration,error,error_without_traceback_filtering}: with the GC disabled, after a ThreadPrefetch iterator ends and is dropped, the last element and the parent iterator are freed. On mainstop_iterationanderror_without_traceback_filteringfail.errorpasses on main because traceback filtering happens to break that cycle, and it stays as a guard.ThreadPrefetchIterDatasetTest.test_raised_error_does_not_keep_last_element_alive: a caller that keeps the raised error does not keep the last element alive. Fails on main.MultiprocessingPrefetchTest.test_finished_iteration_leaves_no_cyclic_garbage: after anmp_prefetchrun over 256 KiB elements ends and is closed, agc.DEBUG_SAVEALLcollection finds noSharedMemoryArray, noProcessPrefetchDatasetIteratorand no exception. Fails on main.Each source change is pinned by its own test. Without the
process_prefetch.pychange the mp test fails onProcessPrefetchDatasetIterator. Without the producerdelthe held-error test fails.Local runs on Linux, CPython 3.12.6, from a source install (
pip install -e .), each module throughabsltest.main:shared_memory_array_testprefetch_testprocess_prefetch_testbatch_test,cache_test,repeat_test,stats_test,stats_utils_test,options_test,traceback_util_testdata_loader_test,dataset_testThe
data_loader_testerrors opentestdata/by a relative path and thedataset_testfailure is a logging assertion. Both fail identically on main outside Bazel. Bazel itself was not run locally. The newprefetch_testandprocess_prefetch_testimports have matchingBUILDdeps.The repro from the issue prints, with this PR:
Not in this PR
On a worker error,
traceback_util.reconstruct_tracebackstill leaves a cycle (its_local fromsys.exc_info(), and its frame is thef_backof the reconstructed frames). It holds theProcessPrefetchDatasetIteratorand its SemLocks but noSharedMemoryArray, so it can still cause the ResourceTracker reentrancy warning on error paths.PrefetchDatasetIterator.__next__,batch.pyparallel stacking andparallel.run_in_parallelhave the same re-raise pattern on error paths. These are separate changes.📚 Documentation preview 📚: https://google-grain--1421.org.readthedocs.build/