`ZarrsCodecPipeline` deadlocks in forked child processes (torch DataLoader with `num_workers>0`)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 80
- Forks
- 8
- Avg merge
- 21h 44m
- Merged PRs (30d)
- 5
Description
Reading or writing a Zarr array through ZarrsCodecPipeline deadlocks in a forked child process if the parent process has already decoded a chunk. This makes torch.utils.data.DataLoader(num_workers>0) which forks workers by default deadlock on Linux.
Small example(s)
Forking without PyTorch
import os, tempfile
import numpy as np, zarr
zarr.config.set({"codec_pipeline.path": "zarrs.ZarrsCodecPipeline"})
path = tempfile.mkdtemp() + "/a.zarr"
a = zarr.create_array(store=path, shape=(256, 256), chunks=(32, 32),
dtype="int16", zarr_format=3)
a[:] = np.arange(256 * 256, dtype="int16").reshape(256, 256) # parent uses the codec
pid = os.fork()
if pid == 0:
zarr.open_array(path, mode="r")[:] # hangs forever
os._exit(0)
os.waitpid(pid, 0) # parent never returns
Forking with PyTorch DataLoader
import sys, tempfile
import numpy as np, torch, zarr
from torch.utils.data import DataLoader, Dataset
zarr.config.set({"codec_pipeline.path": "zarrs.ZarrsCodecPipeline"})
class ImageDataset(Dataset):
def __init__(self, root: str, n: int):
self.root, self.n = root, n # instance attrs travel to spawned workers
def __len__(self) -> int:
return self.n
def __getitem__(self, idx: int) -> torch.Tensor:
arr = zarr.open_array(f"{self.root}/img_{idx}.zarr", mode="r")
return torch.from_numpy(np.asarray(arr[:])) # full read -> parallel codec
def main(mode: str) -> None:
root = tempfile.mkdtemp()
n = 8
for i in range(n):
a = zarr.create_array(store=f"{root}/img_{i}.zarr", shape=(256, 256),
chunks=(32, 32), dtype="int16", zarr_format=3)
a[:] = np.full((256, 256), i, dtype="int16")
zarr.open_array(f"{root}/img_0.zarr", mode="r")[:] # parent arms the codec pool
kwargs = {"batch_size": 2}
if mode == "fork":
kwargs |= {"num_workers": 4, "multiprocessing_context": "fork"}
elif mode == "spawn":
kwargs |= {"num_workers": 4, "multiprocessing_context": "spawn"}
# "serial": num_workers=0 (default)
loader = DataLoader(ImageDataset(root, n), **kwargs)
total = sum(int(batch.shape[0]) for batch in loader)
print(f"[{mode}] read {total} images")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "serial")
So far from what I've debugged with claude a bit and found that the first codec call spins up a global thread pool, and when forked, the child inherits the pools' state but none of its threads. Then the next decode blocks on a thread which never runs.
The annbatch docs mention setting the multiprocessign_context to "spawn" which fixes it along with setting the num_workers to 0. But it would be nice if fork could play nicely with the codec.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reproducing the standalone os.fork example and the PyTorch DataLoader example in the issue, comparing fork, spawn, and serial modes. Trace the first codec call that creates the global thread pool and verify the child’s behavior after forking. Done means forked child processes can read or write through ZarrsCodecPipeline without hanging, while the existing spawn and serial cases still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- backend, data, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100