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.
Files changed (30) hide show
  1. snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
  2. snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
  3. snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
  4. snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
  5. snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
  6. snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
  7. snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
  8. snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
  9. snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
  10. snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
  11. snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
  12. snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
  13. snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
  14. snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
  15. snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
  16. snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
  17. snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
  18. snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
  19. snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
  20. snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
  21. snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
  22. snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
  23. snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
  24. snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
  25. snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
  26. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
  27. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
  28. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
  29. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
  30. 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)