Skip to content

Commit 0400b04

Browse files
committed
Merge the individuals archives without holding them in memory
The merge accumulated every individual's merged content before writing any of it. On a laptop that is merely wasteful; under the 1GiB a container is allowed on the cluster it is fatal, and chr6's fifteen inputs -- 109MB compressed, several times that expanded -- killed the task outright with OOMKilled. Every input carries the same members in the same order, so they are now walked in lockstep and each individual's merged content is written as it is formed. Reading each input once is what makes it affordable: a .tar.gz is not seekable, so looking members up by name would decompress the whole archive per lookup. Building the archive from a fully-joined mapping had the same shape of problem in miniature, doubling peak memory at the moment of writing, so write_archive now joins one member at a time and accepts either text or a sequence of strings. Merging chr7's three chunks: 36.6MB peak and 3.31s before, 16.5MB and 1.27s after, with the output archive identical member for member. Verified against the previous implementation on chr11 split three ways: all three individuals archives and the merged one match on member names, types, modes and content hashes. Worker image 1.7.
1 parent 9907fbb commit 0400b04

8 files changed

Lines changed: 75 additions & 29 deletions

File tree

.github/workflows/equivalence.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
make -C worker-image image
7777
docker build --platform linux/amd64 \
7878
-f engines/nextflow/worker-nf.Dockerfile \
79-
-t 1000genome-worker-nf:1.6 engines/nextflow/
79+
-t 1000genome-worker-nf:1.7 engines/nextflow/
8080
8181
- name: Install Nextflow
8282
run: |

engines/hyperflow/harness/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ services:
3232
- REDIS_URL=redis://redis:6379
3333

3434
# HyperFlow configuration
35-
- HF_VAR_WORKER_CONTAINER=${HF_VAR_WORKER_CONTAINER:-hyperflowwms/1000genome-worker:1.6-je1.4.2}
35+
- HF_VAR_WORKER_CONTAINER=${HF_VAR_WORKER_CONTAINER:-hyperflowwms/1000genome-worker:1.7-je1.4.2}
3636
- HF_VAR_WORK_DIR=${WORKFLOW_DIR}
3737
- HF_VAR_HFLOW_IN_CONTAINER=true
3838
- HF_VAR_function=redisCommand

engines/nextflow/worker-nf.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Nextflow does not use. Both engines therefore run the same scripts from the
77
# same layer, so "the science is identical" is a property of the image graph
88
# rather than a claim about two copies staying in sync.
9-
FROM hyperflowwms/1000genome-worker-base:1.6
9+
FROM hyperflowwms/1000genome-worker-base:1.7
1010

1111
# Nextflow requires bash in the container; the base is Alpine, which ships ash.
1212
RUN apk add --no-cache bash

worker-base-image/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
REPO_NAME = 1000genome-worker-base
22
PREFIX = hyperflowwms
3-
VERSION = 1.6
3+
VERSION = 1.7
44
TAG = $(VERSION)
55

66
all: push

worker-base-image/scripts/archive.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ def _member(name, mode, mtime, uid, gid):
4141

4242

4343
def write_archive(archive, contents):
44-
"""Write `contents`, a mapping of member name to member text, as a .tar.gz."""
44+
"""Write `contents` as a .tar.gz.
45+
46+
Values may be a member's text or a sequence of strings to concatenate. The
47+
joining happens one member at a time: asking the caller to build a second,
48+
fully-joined mapping doubles peak memory, which is what pushed the merge of
49+
a large region past a 1GiB container limit.
50+
"""
4551
mtime = int(time.time())
4652
uid, gid = os.getuid(), os.getgid()
4753

@@ -51,12 +57,63 @@ def write_archive(archive, contents):
5157
handle.addfile(root)
5258

5359
for name in sorted(contents):
54-
payload = contents[name].encode()
60+
payload = contents[name]
61+
if not isinstance(payload, str):
62+
payload = ''.join(payload)
63+
payload = payload.encode()
5564
member = _member(name, FILE_MODE, mtime, uid, gid)
5665
member.size = len(payload)
5766
handle.addfile(member, io.BytesIO(payload))
5867

5968

69+
def merge_archives(archive, inputs):
70+
"""Concatenate the same-named members of `inputs` into one archive.
71+
72+
Holding the merged result in memory does not scale: the fifteen individuals
73+
archives of a chr6 region are 109MB compressed and several times that
74+
expanded, well past the 1GiB a container is allowed here.
75+
76+
Every input carries the same members in the same sorted order, because each
77+
was written from the same column list, so the inputs are walked in lockstep
78+
and only one individual's merged content exists at a time. Walking them once
79+
each is what makes this affordable -- a .tar.gz is not seekable, so looking
80+
members up by name would decompress the whole archive on every lookup.
81+
"""
82+
mtime = int(time.time())
83+
uid, gid = os.getuid(), os.getgid()
84+
handles = [tarfile.open(path, "r:*") for path in inputs]
85+
86+
def members(handle):
87+
# A named function, not a generator expression: an expression closes over
88+
# the loop variable and every stream would end up reading the last
89+
# archive opened.
90+
for member in handle:
91+
if member.isfile():
92+
yield member.name, handle.extractfile(member)
93+
94+
try:
95+
# Each input yields its members in the same order; zip walks them together.
96+
streams = [members(h) for h in handles]
97+
with tarfile.open(archive, "w:gz") as out:
98+
root = _member("", DIR_MODE, mtime, uid, gid)
99+
root.type = tarfile.DIRTYPE
100+
out.addfile(root)
101+
102+
for group in zip(*streams):
103+
names = {name for name, _ in group}
104+
if len(names) != 1:
105+
raise ValueError(
106+
"archives disagree on member order: %s" % sorted(names))
107+
name = names.pop()
108+
payload = b''.join(fh.read() for _, fh in group if fh is not None)
109+
member = _member(name, FILE_MODE, mtime, uid, gid)
110+
member.size = len(payload)
111+
out.addfile(member, io.BytesIO(payload))
112+
finally:
113+
for h in handles:
114+
h.close()
115+
116+
60117
def read_archive(archive):
61118
"""Yield `(name, lines)` for every regular member, never touching the disk.
62119

worker-base-image/scripts/individuals_merge.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,18 @@ def merging(c, tar_files):
1010
print('= Merging chromosome {}...'.format(c))
1111
tic = time.perf_counter()
1212

13-
# Each input is read through memory and the result written straight back
14-
# out. Extracting an input into a temporary directory and reading the files
15-
# back cost three filesystem operations per individual per input, which
16-
# dominates the stage on a shared network volume; it also needed a
17-
# chromosome-specific staging directory to keep parallel merges apart, and
18-
# with no directory there is nothing left to collide. See archive.py.
19-
data = {}
20-
21-
for tar in tar_files:
22-
tic_iter = time.perf_counter()
23-
for name, lines in archive.read_archive(tar):
24-
if name in data:
25-
data[name] += lines
26-
else:
27-
data[name] = lines
28-
29-
print("Merged {} in {:0.2f} sec".format(tar, time.perf_counter()-tic_iter))
30-
13+
# The inputs are streamed in lockstep and the result written as it goes, so
14+
# only one individual's merged content is ever held. Accumulating the whole
15+
# merge first does not fit: chr6's fifteen inputs are 109MB compressed and
16+
# several times that expanded, against the 1GiB a container gets here.
17+
#
18+
# This also needs no staging directory, so the chromosome-specific one that
19+
# used to keep parallel merges apart is gone, and with it anything to
20+
# collide over. See archive.py.
3121
outputfile = "chr{}n.tar.gz".format(c)
32-
print("== Done. Zipping {} files into {}.".format(len(data), outputfile))
22+
print("== Merging {} archives into {}.".format(len(tar_files), outputfile))
3323

34-
archive.write_archive(
35-
outputfile, {name: ''.join(lines) for name, lines in data.items()})
24+
archive.merge_archives(outputfile, tar_files)
3625

3726
print("= Chromosome {} merged in {:0.2f} seconds.".format(
3827
c, time.perf_counter() - tic))

worker-image/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM hyperflowwms/1000genome-worker-base:1.6
1+
FROM hyperflowwms/1000genome-worker-base:1.7
22

33
# Version of the job executor should be passed via docker build, e.g.:
44
# docker build --build-arg hf_job_executor_version="1.3.4"

worker-image/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
REPO_NAME = 1000genome-worker
22
PREFIX = hyperflowwms
3-
VERSION = 1.6
3+
VERSION = 1.7
44
# ?= so CI and Renovate can override the version from the environment
55
HF_JOB_EXECUTOR_VERSION ?= 1.4.2
66
TAG = $(VERSION)-je$(HF_JOB_EXECUTOR_VERSION)

0 commit comments

Comments
 (0)