snakemake-executor-plugin-aws-batch-flex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
- snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
- snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
- snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
- snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
- snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
- snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
- snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
- snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
- snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
- snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
- snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
- snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
- snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
- snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
- snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
- snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
- snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
- snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
- snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
- snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
- snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
- snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
- snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
- snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Job tags: global (--aws-batch-flex-tags), automatic, and per-rule (`batch_tags`).
|
|
2
|
+
|
|
3
|
+
Precedence, lowest to highest: automatic < global < per-rule.
|
|
4
|
+
|
|
5
|
+
AWS Batch tag limits: max 50 tags, key 1-128 chars, value up to 256, characters limited
|
|
6
|
+
to letters, digits, spaces and _ . : / = + - @ (anything else is replaced with `_`).
|
|
7
|
+
Keys starting with `aws:` are reserved.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Iterable, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
14
|
+
|
|
15
|
+
_BAD = re.compile(r"[^A-Za-z0-9 _.:/=+\-@]")
|
|
16
|
+
MAX_TAGS = 50
|
|
17
|
+
# On every job, even without the automatic tags: the IAM policy in terraform/ lets users stop
|
|
18
|
+
# only jobs that have it
|
|
19
|
+
MANAGED_BY = {"ManagedBy": "snakemake-batch-flex"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean(text: str, limit: int) -> str:
|
|
23
|
+
return _BAD.sub("_", str(text))[:limit]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_pairs(items: Iterable[str], source: str) -> dict:
|
|
27
|
+
"""['k=v', ...] -> dict, validating each entry."""
|
|
28
|
+
out = {}
|
|
29
|
+
for item in items:
|
|
30
|
+
item = item.strip()
|
|
31
|
+
if not item:
|
|
32
|
+
continue
|
|
33
|
+
if "=" not in item:
|
|
34
|
+
raise WorkflowError(f"Invalid tag '{item}' in {source}, expected KEY=VALUE.")
|
|
35
|
+
k, v = item.split("=", 1)
|
|
36
|
+
k = k.strip()
|
|
37
|
+
if not k:
|
|
38
|
+
raise WorkflowError(f"Empty tag key in {source}: '{item}'.")
|
|
39
|
+
if k.lower().startswith("aws:"):
|
|
40
|
+
raise WorkflowError(f"Tag key '{k}' in {source} uses the reserved 'aws:' prefix.")
|
|
41
|
+
out[_clean(k, 128)] = _clean(v.strip(), 256)
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_resource_string(value: Optional[str]) -> dict:
|
|
46
|
+
"""The `batch_tags` resource: 'k1=v1,k2=v2'."""
|
|
47
|
+
if not value:
|
|
48
|
+
return {}
|
|
49
|
+
return parse_pairs(str(value).split(","), "resource batch_tags")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def job_tags(
|
|
53
|
+
*,
|
|
54
|
+
run_id: str,
|
|
55
|
+
workflow_id: str = "",
|
|
56
|
+
rule_names: Iterable[str],
|
|
57
|
+
jobid: int,
|
|
58
|
+
global_tags: Mapping[str, str],
|
|
59
|
+
rule_tags: Mapping[str, str],
|
|
60
|
+
auto: bool,
|
|
61
|
+
) -> dict:
|
|
62
|
+
tags: dict = {}
|
|
63
|
+
if auto:
|
|
64
|
+
tags["snakemake-run-id"] = _clean(run_id, 256)
|
|
65
|
+
if workflow_id:
|
|
66
|
+
tags["snakemake-workflow"] = _clean(workflow_id, 256)
|
|
67
|
+
tags["snakemake-rule"] = _clean("+".join(sorted(set(rule_names))), 256)
|
|
68
|
+
tags["snakemake-job-id"] = _clean(str(jobid), 256)
|
|
69
|
+
tags.update(global_tags)
|
|
70
|
+
tags.update(rule_tags)
|
|
71
|
+
tags.update(MANAGED_BY)
|
|
72
|
+
if len(tags) > MAX_TAGS:
|
|
73
|
+
raise WorkflowError(
|
|
74
|
+
f"{len(tags)} tags exceed the AWS Batch limit of {MAX_TAGS}: {sorted(tags)}"
|
|
75
|
+
)
|
|
76
|
+
return tags
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""smkflex push and pull: files to and from the workflow's storage, at the paths Snakemake maps
|
|
2
|
+
them to, 16 at a time; unchanged ones are skipped, as aws s3 sync does."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
from botocore.config import Config as BotoConfig
|
|
11
|
+
|
|
12
|
+
from .profiles import Fail, _load_profile
|
|
13
|
+
from .sources import SKIP_DIRS, workflow_files
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _remote_files(prefix, path, s3):
|
|
17
|
+
"""(relative path, size, modification time) of the files under prefix/path, in storage."""
|
|
18
|
+
path = path.strip("/")
|
|
19
|
+
if prefix.startswith("s3://"):
|
|
20
|
+
bucket, _, base = prefix[len("s3://"):].partition("/")
|
|
21
|
+
base = base.strip("/")
|
|
22
|
+
key = f"{base}/{path}" if base else path
|
|
23
|
+
out = []
|
|
24
|
+
for page in s3.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=key):
|
|
25
|
+
for o in page.get("Contents", []):
|
|
26
|
+
rel = o["Key"][len(base) + 1:] if base else o["Key"]
|
|
27
|
+
# "results" must not also match "results2/..."
|
|
28
|
+
if rel == path or rel.startswith(path + "/") or not path:
|
|
29
|
+
out.append((rel, o["Size"], o["LastModified"].timestamp()))
|
|
30
|
+
return out
|
|
31
|
+
root = os.path.join(prefix, path)
|
|
32
|
+
if os.path.isfile(root):
|
|
33
|
+
return [(path, os.path.getsize(root), os.path.getmtime(root))]
|
|
34
|
+
out = []
|
|
35
|
+
for d, _dirs, names in os.walk(root):
|
|
36
|
+
for n in names:
|
|
37
|
+
full = os.path.join(d, n)
|
|
38
|
+
out.append((os.path.relpath(full, prefix), os.path.getsize(full), os.path.getmtime(full)))
|
|
39
|
+
return sorted(out)
|
|
40
|
+
|
|
41
|
+
def _same(size, mtime, other_size, other_mtime):
|
|
42
|
+
"""A copy is up to date if it has the same size and was made after the original last
|
|
43
|
+
changed, as `aws s3 sync` decides (a copy's time is when it was copied)."""
|
|
44
|
+
return other_size == size and other_mtime >= mtime
|
|
45
|
+
|
|
46
|
+
def _bucket_key(prefix, rel):
|
|
47
|
+
"""(bucket, key) of the file at `rel` under an s3:// prefix."""
|
|
48
|
+
bucket, _, base = prefix[len("s3://"):].partition("/")
|
|
49
|
+
base = base.strip("/")
|
|
50
|
+
return bucket, f"{base}/{rel}" if base else rel
|
|
51
|
+
|
|
52
|
+
TRANSFER_THREADS = 16 # files at once; big files are also split into parallel parts by boto3
|
|
53
|
+
|
|
54
|
+
def _storage_path(path):
|
|
55
|
+
"""Where Snakemake puts a workflow path in storage, relative to the prefix: as it is, or
|
|
56
|
+
for an absolute path without its leading /."""
|
|
57
|
+
norm = os.path.normpath(path)
|
|
58
|
+
if norm == ".." or norm.startswith(".." + os.sep):
|
|
59
|
+
raise Fail(f"{path}: Snakemake can't map paths outside the workflow folder to storage; "
|
|
60
|
+
"use the path the workflow names (relative, or absolute like /shared/refs)")
|
|
61
|
+
return norm.lstrip("/") if os.path.isabs(norm) else norm
|
|
62
|
+
|
|
63
|
+
def _transfer(jobs, work, verb):
|
|
64
|
+
"""Run work(item) for every item, TRANSFER_THREADS at a time; prints each as it finishes."""
|
|
65
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
66
|
+
|
|
67
|
+
done = total = 0
|
|
68
|
+
start = time.time()
|
|
69
|
+
with ThreadPoolExecutor(TRANSFER_THREADS) as pool:
|
|
70
|
+
futures = {pool.submit(work, item): item for item in jobs}
|
|
71
|
+
for f in as_completed(futures):
|
|
72
|
+
name, size = futures[f][0], futures[f][-1]
|
|
73
|
+
f.result() # re-raises the first error
|
|
74
|
+
done += 1
|
|
75
|
+
total += size
|
|
76
|
+
print(f"{verb} {name}")
|
|
77
|
+
secs = max(time.time() - start, 0.001)
|
|
78
|
+
return done, total, secs
|
|
79
|
+
|
|
80
|
+
def _rate(total, secs):
|
|
81
|
+
mb = total / 1e6
|
|
82
|
+
return f"{mb:.1f} MB in {secs:.0f} s ({mb / secs:.1f} MB/s)" if total else ""
|
|
83
|
+
|
|
84
|
+
def cmd_pull(a):
|
|
85
|
+
_dir, profile = _load_profile(a.profile)
|
|
86
|
+
prefix = str(a.prefix or profile.get("default-storage-prefix") or "").rstrip("/")
|
|
87
|
+
if not prefix:
|
|
88
|
+
raise Fail("no default-storage-prefix in the profile")
|
|
89
|
+
region = profile.get("aws-batch-flex-region")
|
|
90
|
+
s3 = boto3.client("s3", config=BotoConfig(region_name=region, max_pool_connections=TRANSFER_THREADS * 2)) \
|
|
91
|
+
if prefix.startswith("s3://") else None
|
|
92
|
+
if not a.paths:
|
|
93
|
+
tops = sorted({rel.split("/")[0] for rel, *_ in _remote_files(prefix, "", s3)
|
|
94
|
+
if not rel.startswith((".smkflex/", "snakemake-workflow-sources."))})
|
|
95
|
+
raise Fail("say what to download, e.g. `smkflex pull results`. In storage: " + (", ".join(tops) or "nothing"))
|
|
96
|
+
todo, skipped = [], 0
|
|
97
|
+
for path in a.paths:
|
|
98
|
+
files = _remote_files(prefix, _storage_path(path), s3)
|
|
99
|
+
if not files:
|
|
100
|
+
print(f"nothing in storage under {path}", file=sys.stderr)
|
|
101
|
+
for rel, size, mtime in files:
|
|
102
|
+
dest = os.path.join(a.to, rel)
|
|
103
|
+
if not a.force and os.path.isfile(dest) and _same(size, mtime, os.path.getsize(dest), os.path.getmtime(dest)):
|
|
104
|
+
skipped += 1
|
|
105
|
+
continue
|
|
106
|
+
if a.dry_run:
|
|
107
|
+
print(f"would download {rel} ({size} bytes)")
|
|
108
|
+
continue
|
|
109
|
+
todo.append((rel, dest, size))
|
|
110
|
+
|
|
111
|
+
def get(item):
|
|
112
|
+
rel, dest, _size = item
|
|
113
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
114
|
+
if s3:
|
|
115
|
+
s3.download_file(*_bucket_key(prefix, rel), dest)
|
|
116
|
+
else:
|
|
117
|
+
shutil.copyfile(os.path.join(prefix, rel), dest)
|
|
118
|
+
|
|
119
|
+
if not a.dry_run:
|
|
120
|
+
got, total, secs = _transfer(todo, get, "downloaded")
|
|
121
|
+
rate = _rate(total, secs)
|
|
122
|
+
print(f"{got} downloaded" + (f" ({rate})" if rate else "")
|
|
123
|
+
+ (f", {skipped} already here (unchanged; --force downloads them again)" if skipped else ""))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
def cmd_push(a):
|
|
127
|
+
"""Upload local input files to where the workflow's rules look for them in storage: a
|
|
128
|
+
relative path to the same path under the prefix, an absolute one (/shared/refs) to
|
|
129
|
+
prefix/shared/refs, as Snakemake maps them."""
|
|
130
|
+
_dir, profile = _load_profile(a.profile)
|
|
131
|
+
prefix = str(profile.get("default-storage-prefix") or "").rstrip("/")
|
|
132
|
+
if a.prefix:
|
|
133
|
+
prefix = a.prefix.rstrip("/")
|
|
134
|
+
if not prefix:
|
|
135
|
+
raise Fail("no default-storage-prefix in the profile")
|
|
136
|
+
files = {}
|
|
137
|
+
for path in a.paths:
|
|
138
|
+
target = _storage_path(path)
|
|
139
|
+
if not os.path.exists(path):
|
|
140
|
+
raise Fail(f"{path}: no such file or folder")
|
|
141
|
+
if os.path.isfile(path):
|
|
142
|
+
files[target] = path
|
|
143
|
+
continue
|
|
144
|
+
for d, dirs, names in os.walk(path):
|
|
145
|
+
dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
|
|
146
|
+
for n in names:
|
|
147
|
+
local = os.path.join(d, n)
|
|
148
|
+
files[os.path.normpath(os.path.join(target, os.path.relpath(local, path)))] = local
|
|
149
|
+
s3 = boto3.client("s3", config=BotoConfig(region_name=profile.get("aws-batch-flex-region"),
|
|
150
|
+
max_pool_connections=TRANSFER_THREADS * 2)) \
|
|
151
|
+
if prefix.startswith("s3://") else None
|
|
152
|
+
there = {}
|
|
153
|
+
for top in {f.split("/")[0] for f in files}:
|
|
154
|
+
there.update({rel: (size, mtime) for rel, size, mtime in _remote_files(prefix, top, s3)})
|
|
155
|
+
todo, skipped = [], 0
|
|
156
|
+
for rel, local in sorted(files.items()):
|
|
157
|
+
size = os.path.getsize(local)
|
|
158
|
+
if not a.force and rel in there and _same(size, os.path.getmtime(local), *there[rel]):
|
|
159
|
+
skipped += 1
|
|
160
|
+
continue
|
|
161
|
+
if a.dry_run:
|
|
162
|
+
print(f"would upload {rel} ({size} bytes)")
|
|
163
|
+
continue
|
|
164
|
+
todo.append((rel, local, size))
|
|
165
|
+
|
|
166
|
+
def put(item):
|
|
167
|
+
rel, local, _size = item
|
|
168
|
+
if s3:
|
|
169
|
+
s3.upload_file(local, *_bucket_key(prefix, rel))
|
|
170
|
+
else:
|
|
171
|
+
os.makedirs(os.path.dirname(os.path.join(prefix, rel)), exist_ok=True)
|
|
172
|
+
shutil.copyfile(local, os.path.join(prefix, rel))
|
|
173
|
+
|
|
174
|
+
if not a.dry_run:
|
|
175
|
+
sent, total, secs = _transfer(todo, put, "uploaded")
|
|
176
|
+
rate = _rate(total, secs)
|
|
177
|
+
print(f"{sent} uploaded to {prefix}" + (f" ({rate})" if rate else "")
|
|
178
|
+
+ (f", {skipped} already there (unchanged; --force sends them again)" if skipped else ""))
|
|
179
|
+
_hint_gitignore([p for p in a.paths if not os.path.isabs(p)])
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
def _hint_gitignore(paths):
|
|
183
|
+
"""Data uploaded with push reaches the jobs through storage; if smkflex run would also
|
|
184
|
+
pack it with the workflow, say how to leave it out."""
|
|
185
|
+
if not paths:
|
|
186
|
+
return
|
|
187
|
+
sent = set(workflow_files("."))
|
|
188
|
+
tops = sorted({os.path.normpath(p).split(os.sep)[0] for p in paths
|
|
189
|
+
if any(f == os.path.normpath(p) or f.startswith(os.path.normpath(p) + "/") for f in sent)})
|
|
190
|
+
if tops:
|
|
191
|
+
lines = "".join(f"{t}/\n" if os.path.isdir(t) else f"{t}\n" for t in tops)
|
|
192
|
+
print(f"\nNote: smkflex run would also send {', '.join(tops)} with the workflow (it doesn't need to: the "
|
|
193
|
+
f"jobs read it from storage). To leave it out, add to .gitignore:\n{lines}", end="")
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""What a run used on Batch, per rule, from Batch's own start and stop times of every attempt
|
|
2
|
+
(Spot retries included) and the figures each job logs (jobmonitor.py): vCPU- and GiB-hours,
|
|
3
|
+
memory, CPUs and disk, with the resources to paste into the profile. Printed when the run ends.
|
|
4
|
+
|
|
5
|
+
What it cost is AWS's to say: every job carries the tags snakemake-run-id and snakemake-rule,
|
|
6
|
+
which AWS Billing (with split cost allocation data) breaks costs down by."""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import statistics
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
17
|
+
|
|
18
|
+
# A Batch job costs time on top of its command: measured on real runs, about 15 s from submitted to
|
|
19
|
+
# running on an instance that's already up (a minute or more when one has to start), and about
|
|
20
|
+
# 10 s for Snakemake in the job and its files. Many short jobs of one rule are better grouped.
|
|
21
|
+
SHORT_JOB_SECONDS = 60
|
|
22
|
+
MANY_JOBS = 20
|
|
23
|
+
|
|
24
|
+
# Usage figures: each job logs them when it ends (jobmonitor.py). Reading the logs costs an
|
|
25
|
+
# API call per job, so big runs read a sample of each rule's jobs.
|
|
26
|
+
USAGE_RE = re.compile(r"\[snakemake-batch\] (peak-memory-bytes=\S+.*)")
|
|
27
|
+
PEAK_SAMPLE = 25 # jobs per rule, for runs with more than ALL_PEAKS jobs
|
|
28
|
+
ALL_PEAKS = 300
|
|
29
|
+
HEADROOM = 1.3 # suggested memory = peak * HEADROOM
|
|
30
|
+
THREAD_HEADROOM = 1.25 # suggested threads = most in use at once * THREAD_HEADROOM
|
|
31
|
+
FEW_THREADS = 0.6 # suggest fewer threads when a rule never had more than this share busy
|
|
32
|
+
BIG_DISK_GIB = 20 # say so when a rule's job used more disk than this
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _seconds(d: dict) -> float:
|
|
36
|
+
attempts = d.get("attempts") or []
|
|
37
|
+
spans = [(a.get("startedAt"), a.get("stoppedAt")) for a in attempts] or [(d.get("startedAt"), d.get("stoppedAt"))]
|
|
38
|
+
return sum(max(0, stop - start) / 1000 for start, stop in spans if start is not None and stop is not None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Row:
|
|
43
|
+
"""One rule's jobs."""
|
|
44
|
+
jobs: int = 0
|
|
45
|
+
vcpu_h: float = 0.0
|
|
46
|
+
gib_h: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Usage:
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self.rows = defaultdict(Row) # rule -> Row
|
|
52
|
+
self.seconds = defaultdict(list) # rule -> run time of each job that ran
|
|
53
|
+
self.asked = defaultdict(int) # rule -> most memory a job asked for (MiB)
|
|
54
|
+
self.logs = defaultdict(list) # rule -> [(log group, stream)] of jobs that succeeded
|
|
55
|
+
self.peaks = defaultdict(list) # rule -> peak memory of jobs (MiB) read from their logs
|
|
56
|
+
self.busy = defaultdict(list) # rule -> most vCPUs in use at once, per job
|
|
57
|
+
self.disk = defaultdict(list) # rule -> most disk a job's working folder took (GiB)
|
|
58
|
+
self.threads = defaultdict(float) # rule -> most vCPUs a job asked for
|
|
59
|
+
self.oom_kills = defaultdict(int) # rule -> its jobs' processes killed for lack of memory
|
|
60
|
+
self.platform = {} # rule -> ec2 | fargate
|
|
61
|
+
self.sampled = False
|
|
62
|
+
|
|
63
|
+
def add(self, rule: str, d: dict, vcpu: float, mem_mib: int, platform: str):
|
|
64
|
+
secs = _seconds(d)
|
|
65
|
+
if secs:
|
|
66
|
+
self.seconds[rule].append(secs)
|
|
67
|
+
hours = secs / 3600
|
|
68
|
+
self.asked[rule] = max(self.asked[rule], mem_mib)
|
|
69
|
+
self.threads[rule] = max(self.threads[rule], vcpu)
|
|
70
|
+
self.platform[rule] = platform
|
|
71
|
+
row = self.rows[rule]
|
|
72
|
+
row.jobs += 1
|
|
73
|
+
row.vcpu_h += vcpu * hours
|
|
74
|
+
row.gib_h += mem_mib / 1024 * hours
|
|
75
|
+
|
|
76
|
+
def add_log(self, rule: str, group: str, stream: str):
|
|
77
|
+
self.logs[rule].append((group, stream))
|
|
78
|
+
|
|
79
|
+
def fetch_peaks(self, logs_client):
|
|
80
|
+
"""Read the peak-memory line from the end of each job's log (a sample per rule on big
|
|
81
|
+
runs). Best effort: jobs whose line can't be read are left out."""
|
|
82
|
+
total = sum(len(v) for v in self.logs.values())
|
|
83
|
+
todo = []
|
|
84
|
+
for rule, streams in self.logs.items():
|
|
85
|
+
if total > ALL_PEAKS and len(streams) > PEAK_SAMPLE:
|
|
86
|
+
self.sampled = True
|
|
87
|
+
streams = streams[-PEAK_SAMPLE:]
|
|
88
|
+
todo += [(rule, g, st) for g, st in streams]
|
|
89
|
+
|
|
90
|
+
def one(item):
|
|
91
|
+
rule, group, stream = item
|
|
92
|
+
try:
|
|
93
|
+
events = logs_client.get_log_events(
|
|
94
|
+
logGroupName=group, logStreamName=stream, limit=20, startFromHead=False
|
|
95
|
+
)["events"]
|
|
96
|
+
except (ClientError, BotoCoreError):
|
|
97
|
+
return rule, None
|
|
98
|
+
for e in reversed(events):
|
|
99
|
+
m = USAGE_RE.search(e.get("message", ""))
|
|
100
|
+
if m:
|
|
101
|
+
return rule, dict(kv.split("=", 1) for kv in m.group(1).split() if "=" in kv)
|
|
102
|
+
return rule, None
|
|
103
|
+
|
|
104
|
+
with ThreadPoolExecutor(8) as pool:
|
|
105
|
+
for rule, fields in pool.map(one, todo):
|
|
106
|
+
if not fields:
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
if int(fields.get("peak-memory-bytes", 0)):
|
|
110
|
+
self.peaks[rule].append(int(fields["peak-memory-bytes"]) / 1024 / 1024)
|
|
111
|
+
if "peak-cpus" in fields:
|
|
112
|
+
self.busy[rule].append(float(fields["peak-cpus"]))
|
|
113
|
+
if "peak-disk-bytes" in fields:
|
|
114
|
+
self.disk[rule].append(int(fields["peak-disk-bytes"]) / 1024 ** 3)
|
|
115
|
+
self.oom_kills[rule] += int(fields.get("oom-kills", 0))
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def suggestions(self, overhead_mib: int) -> dict:
|
|
120
|
+
"""rule -> mem_mb that gives its jobs their peak plus headroom (at least 512 MiB in
|
|
121
|
+
all), for every rule whose peak is known. Not for rules whose jobs ran out: what was
|
|
122
|
+
seen before that is less than they need."""
|
|
123
|
+
out = {}
|
|
124
|
+
for rule, peaks in self.peaks.items():
|
|
125
|
+
if self.oom_kills.get(rule):
|
|
126
|
+
continue
|
|
127
|
+
want = max(512, math.ceil(max(peaks) * HEADROOM / 100) * 100) # container memory, MiB
|
|
128
|
+
# the plugin adds overhead_mib for Snakemake itself; mem_mb is in MB
|
|
129
|
+
out[rule] = max(100, math.ceil((want - overhead_mib) * 1.048576 / 100) * 100)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
def thread_suggestions(self) -> dict:
|
|
133
|
+
"""rule -> threads, for rules that never had most of their vCPUs busy at once."""
|
|
134
|
+
out = {}
|
|
135
|
+
for rule, busy in self.busy.items():
|
|
136
|
+
asked, most = self.threads.get(rule, 0), max(busy)
|
|
137
|
+
if asked >= 2 and most <= asked * FEW_THREADS:
|
|
138
|
+
out[rule] = max(1, min(int(asked) - 1, math.ceil(most * THREAD_HEADROOM)))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
def table(self, overhead_mib: int = 256) -> Optional[str]:
|
|
142
|
+
if not self.rows:
|
|
143
|
+
return None
|
|
144
|
+
width = max(10, *(len(r) for r in self.rows))
|
|
145
|
+
lines = [
|
|
146
|
+
"AWS Batch usage of this run (costs: AWS Billing, by the jobs' tags snakemake-run-id and "
|
|
147
|
+
"snakemake-rule):",
|
|
148
|
+
f" {'rule':<{width}} {'jobs':>5} {'vCPU-h':>8} {'GiB-h':>8}"
|
|
149
|
+
f" {'vCPUs':>5} {'busy':>5} {'mem MiB':>8} {'peak':>6} {'disk GiB':>8}",
|
|
150
|
+
]
|
|
151
|
+
total = Row()
|
|
152
|
+
# the rules that took the most first
|
|
153
|
+
for rule in sorted(self.rows, key=lambda r: -self.rows[r].vcpu_h):
|
|
154
|
+
row = self.rows[rule]
|
|
155
|
+
n, v, g = row.jobs, row.vcpu_h, row.gib_h
|
|
156
|
+
peak = f"{max(self.peaks[rule]):.0f}" if self.peaks.get(rule) else "-"
|
|
157
|
+
busy = f"{max(self.busy[rule]):.1f}" if self.busy.get(rule) else "-"
|
|
158
|
+
disk = f"{max(self.disk[rule]):.1f}" if self.disk.get(rule) else "-"
|
|
159
|
+
vcpus = f"{self.threads.get(rule, 0):g}"
|
|
160
|
+
lines.append(f" {rule:<{width}} {n:>5} {v:>8.2f} {g:>8.2f}"
|
|
161
|
+
f" {vcpus:>5} {busy:>5} {self.asked.get(rule, 0):>8} {peak:>6} {disk:>8}")
|
|
162
|
+
total = Row(total.jobs + n, total.vcpu_h + v, total.gib_h + g)
|
|
163
|
+
if len(self.rows) > 1:
|
|
164
|
+
lines.append(f" {'total':<{width}} {total.jobs:>5} {total.vcpu_h:>8.2f} {total.gib_h:>8.2f}")
|
|
165
|
+
if self.peaks:
|
|
166
|
+
note = f" (from up to {PEAK_SAMPLE} jobs per rule)" if self.sampled else ""
|
|
167
|
+
lines.append(f"vCPUs, mem MiB: what a job had; busy: the most vCPUs in use at once (on EC2 a job can use "
|
|
168
|
+
"idle vCPUs of its instance, so this can be more than it had); peak: the most "
|
|
169
|
+
f"memory its processes used (without file cache); disk GiB: the most its working "
|
|
170
|
+
f"folder took. Each the highest of the rule's jobs{note}; memory sampled every "
|
|
171
|
+
f"0.5 s (a shorter spike isn't seen), CPUs every 2 s.")
|
|
172
|
+
tips = self.suggestions(overhead_mib)
|
|
173
|
+
if tips:
|
|
174
|
+
lines.append("Memory for each rule (its peak + 30%; mem_mb leaves out the plugin's own "
|
|
175
|
+
f"{overhead_mib} MiB, added on top), to paste into the project's "
|
|
176
|
+
"profiles/default/config.yaml (smkflex init makes it):")
|
|
177
|
+
lines.append("set-resources:")
|
|
178
|
+
for rule, mb in sorted(tips.items()):
|
|
179
|
+
had = self.asked.get(rule)
|
|
180
|
+
gets = int(mb / 1.048576) + overhead_mib # what the job is given for that, MiB
|
|
181
|
+
lines += [f" {rule}:",
|
|
182
|
+
f" mem_mb: {mb:<8}# job gets {gets} MiB; had {had}, peak {max(self.peaks[rule]):.0f}"]
|
|
183
|
+
threads = self.thread_suggestions()
|
|
184
|
+
if threads:
|
|
185
|
+
lines.append("Threads for rules that never had most of theirs busy (the most in use at once "
|
|
186
|
+
"+ 25%; a burst shorter than 2 s isn't seen, so check the tool first):")
|
|
187
|
+
lines.append("set-threads:")
|
|
188
|
+
for rule, n in sorted(threads.items()):
|
|
189
|
+
lines.append(f" {rule}: {n:<6}# had {self.threads[rule]:g}, at most {max(self.busy[rule]):.1f} busy")
|
|
190
|
+
for rule, n in sorted(self.oom_kills.items()):
|
|
191
|
+
if n:
|
|
192
|
+
lines.append(f"Hint: in {rule}'s jobs, Linux killed {n} process(es) for lack of memory (a job "
|
|
193
|
+
"can still succeed if the tool retried). Its peak is from before that, so it "
|
|
194
|
+
"needs more: raise its mem_mb.")
|
|
195
|
+
for rule, gib in sorted((r, max(d)) for r, d in self.disk.items() if d and max(d) > BIG_DISK_GIB):
|
|
196
|
+
where = ("Fargate gives a job 20 GiB unless disk_mb asks for more"
|
|
197
|
+
if self.platform.get(rule) == "fargate" else
|
|
198
|
+
"on EC2 the jobs on an instance share its disk (root_volume_gib in Terraform)")
|
|
199
|
+
lines.append(f"Hint: {rule}'s jobs used up to {gib:.0f} GiB of disk each; {where}.")
|
|
200
|
+
for rule, secs in sorted(self.seconds.items()):
|
|
201
|
+
if len(secs) >= MANY_JOBS and statistics.median(secs) < SHORT_JOB_SECONDS:
|
|
202
|
+
lines.append(
|
|
203
|
+
f"Hint: {rule} ran {len(secs)} jobs of {statistics.median(secs):.0f} s each (median). Every "
|
|
204
|
+
"Batch job adds about 25 s (a minute or more when a new instance has to start), so "
|
|
205
|
+
"grouping them makes the run faster: "
|
|
206
|
+
"see \"Many small jobs\" in the plugin's README."
|
|
207
|
+
)
|
|
208
|
+
return "\n".join(lines)
|