Shared-memory containers¶
How do I let worker processes read a very large list without every worker copying it?
1. See the growth with a plain list¶
A multiprocessing.Pool with the fork start method inherits the parent memory. The first time a worker reads an object in that memory, the kernel copies the page that holds it, because reading a Python object changes its reference count. Every worker copies pages, and the memory of every worker grows.
The benchmark builds 1,000,000 synthetic strings that look like paths, starts 4 workers, and measures the unique set size (uss) and the proportional set size (pss) of each worker before and after it reads the items. rss is not used: it counts the shared pages, so it cannot tell shared from copied. The benchmark runs on Linux:
The table below is the full run (1,000,000 items, 5 runs, median and IQR), from benchmarks/data_shared_memory.json. Under fork the plain list gives a worker uss of 104,611,840 bytes.
2. Use SharedStrings or SharedList¶
SharedStrings stores the strings in one UTF-8 buffer, with an int64 offsets array; reading item i decodes only item i. SharedList stores the pickled items in one buffer, with the same offsets array; reading item i unpickles only item i. Both keep the data off the Python heap, in a SharedMemory block (/dev/shm on Linux).
from dense_armor.utility.infrastructure.data import SharedStrings
paths = [f"/data/img_{i:06d}.png" for i in range(1000)]
ss = SharedStrings(paths)
print(len(ss), ss[0], ss[-1])
ss.unlink()
ss.close()
unlink() frees the block (only the creator does it); close() detaches this process from it.
3. Hand the handle to a spawn Pool¶
A handle can be pickled. The pickle carries only the block names and the lengths, not the data, so it stays under 1 KiB however long the list is.
import pickle
from dense_armor.utility.infrastructure.data import SharedStrings
ss = SharedStrings([f"/data/img_{i:06d}.png" for i in range(100_000)])
att = pickle.loads(pickle.dumps(ss))
print(len(pickle.dumps(ss)) < 1024, att[99_999])
att.close()
ss.unlink()
ss.close()
The unpickled handle att attaches to the same blocks; closing it never frees them. A Pool does the same thing for each worker:
import multiprocessing as mp
from dense_armor.utility.infrastructure.data import SharedStrings
def read(args):
ss, i = args
return ss[i]
if __name__ == "__main__":
with SharedStrings([f"/data/img_{i}.png" for i in range(4)]) as ss:
with mp.get_context("spawn").Pool(4) as pool:
print(pool.map(read, [(ss, i) for i in range(4)]))
This is a script, not a notebook cell: spawn workers import the file, so read must live at module level.
Results¶
fork, fraction 1.0 (every item read), 1,000,000 synthetic paths, 4 workers, 5 runs, median and IQR, from benchmarks/data_shared_memory.py:
| Contender | USS median (B) | USS IQR (B) | PSS median (B) | Latency median (µs) |
|---|---|---|---|---|
| plain list | 104,611,840 | 4,096 | 91,772,928 | 6.24 |
| numpy object array | 112,594,944 | 8,192 | 99,753,984 | 6.70 |
Manager().list |
48,320,512 | 2,048 | 48,307,200 | 131.86 |
ShareableList |
80,461,824 | 8,192 | 76,823,552 | 13.15 |
SharedStrings |
48,349,184 | 8,192 | 49,253,888 | 2.66 |
SharedList |
48,353,280 | 20,480 | 51,175,936 | 3.67 |
spawn, same data (the plain list is passed to each worker as an argument, so each worker holds its own copy):
| Contender | USS median (B) | USS IQR (B) | PSS median (B) | Latency median (µs) |
|---|---|---|---|---|
| plain list | 46,698,496 | 200,704 | 46,697,984 | 0.64 |
ShareableList |
48,109,568 | 2,048 | 54,135,296 | 5.18 |
SharedStrings |
40,157,184 | 0 | 45,694,464 | 2.74 |
SharedList |
40,157,184 | 0 | 48,697,344 | 3.70 |
The three claims were declared before the run (ISTRUZIONI.md, item C) and are read from the two tables:
- Worker
ussofSharedStringsandSharedListbelow 0.5 times that of the plain list underfork: holds. 48,349,184 / 104,611,840 = 0.462 and 48,353,280 / 104,611,840 = 0.462. - Read latency: holds, on both parts.
SharedStringsis within 10 times of the plain list: 2.66 µs against 6.24 µs underfork(0.43), and 4.1 times against the plain list underspawn(2.74 µs against 0.64 µs). The plain list reads in 6.24 µs underforkand 0.64 µs underspawn; the benchmark does not isolate the reason for the difference, so both ratios are given.SharedListis at least 10 times faster thanManager().list: 131.86 µs against 3.67 µs, 36.0 times. spawnresult within 20% of theforkresult: holds withforkas the reference, does not hold withspawnas the reference. TheussofSharedStringsis 48,349,184 underforkand 40,157,184 underspawn: the difference is 16.9% of theforkvalue and 20.4% of thespawnvalue.SharedListgives 17.0% and 20.4%. The claim did not say which of the two is the reference.
Details¶
References¶
- Issue pytorch/pytorch#13246,
papers/issue_13246.md: comment 1364587359 (ppwwyyxx: replacing Python objects with anumpyarray solves the growth only with theforkstart method) and comment 436632186 (the original poster: iterating over a huge list makes memory grow, iterating over anumpyarray does not). - Post
papers/ppwwyyxx_demystify_ram.txt: section "Measure RAM Usage" (RSS, PSS, USS) and section "Copy-on-read of forked CPython objects" (the reference-count mechanism). - Pull request 191555,
papers/pr_191555.md, as prior art: a flat buffer withint64offsets. It is read, not run. - Python documentation,
multiprocessing.shared_memory.SharedMemory: thetrackparameter (3.13), and the resource tracker for attached blocks (3.10 to 3.12).
Choices stated explicitly¶
- One flat buffer plus an
int64offsets array, so reading itemitouches only that item. SharedDictlooks up withhashlib.blake2bover the pickle of the key, nothash(): thehash()of astrdiffers between processes. The digests are sorted in auint64array and found withnumpy.searchsorted; the stored key is then compared, so a collision never returns the wrong value.- The creator owns the blocks and unlinks them (at
unlink(), at the end of awith, at garbage collection or at exit). An attached handle only closes. On Linux with Python 3.10 to 3.12 an attached handle is removed from the resource tracker; with Python 3.13 it attaches withtrack=False. On Windows there is no resource tracker, andunlink()does nothing: the block disappears when the last handle closes. SharedArray.arrayis read-only.allow_pickle=Falserestricts what is written at construction toNone,bool,int,float,str,bytesandtupleorlistof these. Reading still unpickles, so every local process that can attach the blocks is trusted.- An empty list or dict raises
ValueError, and so does a list of strings that are all empty: aSharedMemoryblock cannot have size 0. ussandpssare measured,rssis not.
Declared limitation of SharedDict¶
Keys are compared by the bytes of their pickle, not by Python equality. On a dict with key 1, d[1.0] and d[True] raise KeyError, while a Python dict returns the value. The reason is the cost of a lookup: a digest-only match would unpickle every candidate key, which is slower than the byte comparison.
What pickles and what does not¶
Custom classes with __getstate__ and __setstate__, nested dicts, cyclic structures and tensors go through. Connections, file handles, sockets, threads, locks, lambdas and generators are not picklable: put the recipe (the URL, the credentials, the path) in the container and open the resource in the worker.
Measured cost¶
The JSON of benchmarks/data_shared_memory.py: 1,000,000 synthetic items, 4 workers, 5 runs, median and IQR, all sizes in bytes.
Development diary¶
On Windows the first version hung every spawn Pool: attaching a handle called resource_tracker.unregister, which on Windows tries to start the tracker with _posixsubprocess, a module that Windows does not have. The worker failed while unpickling and Pool.map never returned. The call now runs only off Windows, and on Linux it uses the registered name (with its leading /), which SharedMemory.name does not return.