libpipe 0.8__tar.gz → 0.9__tar.gz
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.
- {libpipe-0.8 → libpipe-0.9}/PKG-INFO +33 -6
- libpipe-0.9/README.md +50 -0
- libpipe-0.9/libpipe/taskrun.py +25 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/worker.py +311 -38
- {libpipe-0.8 → libpipe-0.9}/pyproject.toml +8 -1
- libpipe-0.8/README.md +0 -30
- {libpipe-0.8 → libpipe-0.9}/LICENSE +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/__init__.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/attrmap.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/futils.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/msutils.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/settings.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.8 → libpipe-0.9}/libpipe/tools/nodetool.py +0 -0
|
@@ -1,25 +1,32 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: libpipe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9
|
|
4
4
|
Summary: Pipeline orchestration and node management utilities
|
|
5
|
-
License: GPL-3.0-or-later
|
|
5
|
+
License-Expression: GPL-3.0-or-later
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Florent Mertens
|
|
7
8
|
Author-email: florent.mertens@gmail.com
|
|
8
9
|
Requires-Python: >=3.10,<4.0
|
|
9
|
-
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
16
|
Provides-Extra: dev
|
|
17
|
+
Provides-Extra: docs
|
|
16
18
|
Requires-Dist: asyncssh (>=2.0)
|
|
17
19
|
Requires-Dist: click (>=8.0)
|
|
20
|
+
Requires-Dist: myst-parser (>=2) ; extra == "docs"
|
|
18
21
|
Requires-Dist: progressbar2 (>=3.40)
|
|
22
|
+
Requires-Dist: pydata-sphinx-theme (>=0.15) ; extra == "docs"
|
|
19
23
|
Requires-Dist: pytest (>=8.0) ; extra == "dev"
|
|
20
24
|
Requires-Dist: pytest-cov (>=5.0) ; extra == "dev"
|
|
21
25
|
Requires-Dist: ruff (==0.16.1) ; extra == "dev"
|
|
26
|
+
Requires-Dist: sphinx (>=7) ; extra == "docs"
|
|
27
|
+
Requires-Dist: sphinx-click (>=6) ; extra == "docs"
|
|
22
28
|
Requires-Dist: toml (>=0.10)
|
|
29
|
+
Project-URL: Documentation, https://libpipe.readthedocs.io
|
|
23
30
|
Project-URL: Homepage, https://gitlab.com/flomertens/libpipe
|
|
24
31
|
Project-URL: Issues, https://gitlab.com/flomertens/libpipe/-/issues
|
|
25
32
|
Project-URL: Repository, https://gitlab.com/flomertens/libpipe
|
|
@@ -53,6 +60,26 @@ on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
|
53
60
|
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
54
61
|
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
55
62
|
Host constraints (`run_on_host`) do not apply: Slurm places the tasks, so the data must be
|
|
56
|
-
on a file system shared with the compute nodes.
|
|
57
|
-
|
|
63
|
+
on a file system shared with the compute nodes.
|
|
64
|
+
|
|
65
|
+
With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
|
|
66
|
+
when its first task starts, runs each task as a job step in it (`srun --jobid --exact`), and
|
|
67
|
+
releases it at the end, or after 10 minutes without step. Slurm packs the steps on the nodes
|
|
68
|
+
from their CPUs and memory, given by the category of the task; a step starts when the
|
|
69
|
+
allocation has its CPUs free, so that no srun waits polling the controller:
|
|
70
|
+
|
|
71
|
+
pool = worker.WorkerPool(['slurm'], name='Cubes', slurm_nodes=4,
|
|
72
|
+
resources={'heavy': {'cpus': 12, 'mem': '40GB'}})
|
|
73
|
+
pool.add('pstool gen_vis_cube ...', category='small', label='gen_vis_cube')
|
|
74
|
+
|
|
75
|
+
The categories (`DEFAULT_RESOURCES`) are `small` (1 CPU, 2 GB), `medium` (4, 8 GB),
|
|
76
|
+
`high_mem` (2, 16 GB), `cpu` (12, 8 GB) and `heavy` (12, 40 GB, the default), the others capped
|
|
77
|
+
at `heavy`; a step also gets the matching `OMP_NUM_THREADS`. The options apply to the jobs and
|
|
78
|
+
the allocation, not to the steps. Inside an allocation started by hand, the tasks are job
|
|
79
|
+
steps with these resources as well.
|
|
80
|
+
|
|
81
|
+
Slurm does not necessarily limit the memory of a job step: the memory of a category must stay
|
|
82
|
+
above the peak memory of its tasks. The local and Slurm pools measure each task (wall time,
|
|
83
|
+
CPU time, peak RSS, in its log file), and `WorkerPoolReport.usage_lines()` summarises them per
|
|
84
|
+
task type (`label`).
|
|
58
85
|
|
libpipe-0.9/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Libpipe
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
Common pipeline framework and library
|
|
5
|
+
|
|
6
|
+
Installation
|
|
7
|
+
------------
|
|
8
|
+
|
|
9
|
+
libpipe can be installed via pip:
|
|
10
|
+
|
|
11
|
+
$ pip install libpipe
|
|
12
|
+
|
|
13
|
+
and requires Python 3.10 or higher.
|
|
14
|
+
|
|
15
|
+
Worker pools on a Slurm cluster
|
|
16
|
+
-------------------------------
|
|
17
|
+
|
|
18
|
+
A `WorkerPool` runs its tasks locally, on other hosts over SSH, or through Slurm with the
|
|
19
|
+
host `slurm`: each task is then started with `srun`, as a job of its own when the pool runs
|
|
20
|
+
on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
21
|
+
|
|
22
|
+
pool = worker.WorkerPool(['slurm'], name='Imaging', max_tasks_per_worker=8,
|
|
23
|
+
slurm_options='-p regular --mem=200G --time=24:00:00')
|
|
24
|
+
|
|
25
|
+
`max_tasks_per_worker` is the number of jobs in flight. The srun options (partition,
|
|
26
|
+
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
27
|
+
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
28
|
+
Host constraints (`run_on_host`) do not apply: Slurm places the tasks, so the data must be
|
|
29
|
+
on a file system shared with the compute nodes.
|
|
30
|
+
|
|
31
|
+
With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
|
|
32
|
+
when its first task starts, runs each task as a job step in it (`srun --jobid --exact`), and
|
|
33
|
+
releases it at the end, or after 10 minutes without step. Slurm packs the steps on the nodes
|
|
34
|
+
from their CPUs and memory, given by the category of the task; a step starts when the
|
|
35
|
+
allocation has its CPUs free, so that no srun waits polling the controller:
|
|
36
|
+
|
|
37
|
+
pool = worker.WorkerPool(['slurm'], name='Cubes', slurm_nodes=4,
|
|
38
|
+
resources={'heavy': {'cpus': 12, 'mem': '40GB'}})
|
|
39
|
+
pool.add('pstool gen_vis_cube ...', category='small', label='gen_vis_cube')
|
|
40
|
+
|
|
41
|
+
The categories (`DEFAULT_RESOURCES`) are `small` (1 CPU, 2 GB), `medium` (4, 8 GB),
|
|
42
|
+
`high_mem` (2, 16 GB), `cpu` (12, 8 GB) and `heavy` (12, 40 GB, the default), the others capped
|
|
43
|
+
at `heavy`; a step also gets the matching `OMP_NUM_THREADS`. The options apply to the jobs and
|
|
44
|
+
the allocation, not to the steps. Inside an allocation started by hand, the tasks are job
|
|
45
|
+
steps with these resources as well.
|
|
46
|
+
|
|
47
|
+
Slurm does not necessarily limit the memory of a job step: the memory of a category must stay
|
|
48
|
+
above the peak memory of its tasks. The local and Slurm pools measure each task (wall time,
|
|
49
|
+
CPU time, peak RSS, in its log file), and `WorkerPoolReport.usage_lines()` summarises them per
|
|
50
|
+
task type (`label`).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'''Run a command, then report its wall time, CPU time and peak memory on stderr, for the task
|
|
2
|
+
accounting of WorkerPool.
|
|
3
|
+
|
|
4
|
+
Usage: python taskrun.py COMMAND [ARGS...]'''
|
|
5
|
+
|
|
6
|
+
import resource
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
MARKER = '[libpipe] usage '
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
start = time.monotonic()
|
|
16
|
+
returncode = subprocess.call(sys.argv[1:])
|
|
17
|
+
# Of the waited descendants: peak RSS of the largest one, CPU time of all
|
|
18
|
+
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
19
|
+
print(f'{MARKER}wall={time.monotonic() - start:.1f} cpu={usage.ru_utime + usage.ru_stime:.1f} '
|
|
20
|
+
f'maxrss_kb={usage.ru_maxrss}', file=sys.stderr, flush=True)
|
|
21
|
+
sys.exit(returncode if returncode >= 0 else 128 - returncode)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == '__main__':
|
|
25
|
+
main()
|
|
@@ -13,6 +13,7 @@ import re
|
|
|
13
13
|
import shlex
|
|
14
14
|
import signal
|
|
15
15
|
import socket
|
|
16
|
+
import subprocess
|
|
16
17
|
import sys
|
|
17
18
|
import time
|
|
18
19
|
import warnings
|
|
@@ -22,6 +23,9 @@ import asyncssh
|
|
|
22
23
|
import progressbar
|
|
23
24
|
from click import secho, style
|
|
24
25
|
|
|
26
|
+
from libpipe import taskrun
|
|
27
|
+
from libpipe.taskrun import MARKER as USAGE_MARKER
|
|
28
|
+
|
|
25
29
|
localhost_shortname = socket.gethostname().split('.', 1)[0]
|
|
26
30
|
|
|
27
31
|
# Host name selecting the Slurm client: each task runs through srun
|
|
@@ -29,6 +33,57 @@ SLURM_HOST = 'slurm'
|
|
|
29
33
|
SLURM_OPTIONS_ENV = 'LIBPIPE_SLURM_OPTIONS'
|
|
30
34
|
SLURM_MARKER = '[libpipe] slurm job '
|
|
31
35
|
|
|
36
|
+
# CPUs and memory of the task categories, used when the tasks run as job steps of an allocation;
|
|
37
|
+
# the categories other than heavy are capped at its values
|
|
38
|
+
DEFAULT_RESOURCES = {
|
|
39
|
+
'small': {'cpus': 1, 'mem': '2GB'},
|
|
40
|
+
'medium': {'cpus': 4, 'mem': '8GB'},
|
|
41
|
+
'high_mem': {'cpus': 2, 'mem': '16GB'},
|
|
42
|
+
'cpu': {'cpus': 12, 'mem': '8GB'},
|
|
43
|
+
'heavy': {'cpus': 12, 'mem': '40GB'},
|
|
44
|
+
}
|
|
45
|
+
THREAD_VARS = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')
|
|
46
|
+
# The holding job of an allocation ends after this many minutes without job step
|
|
47
|
+
ALLOC_IDLE_MINUTES = 10
|
|
48
|
+
# Allocations requested in a row before giving up, when they end before running
|
|
49
|
+
ALLOC_MAX_REQUESTS = 3
|
|
50
|
+
# Workers per node of an allocation: Slurm holds the steps that do not fit yet
|
|
51
|
+
ALLOC_WORKERS_PER_NODE = 32
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mem_mb(text):
|
|
55
|
+
'''Memory such as 40GB, 40G or 512MB in MB (GB is 1024 MB, as in Slurm).'''
|
|
56
|
+
m = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?\s*', str(text), re.IGNORECASE)
|
|
57
|
+
if not m:
|
|
58
|
+
raise ValueError(f'{text!r}: expected a memory such as 40GB')
|
|
59
|
+
unit = 'KMGT'.index((m.group(2) or 'M').upper()) - 1
|
|
60
|
+
return max(1, int(float(m.group(1)) * 1024 ** unit))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_resources(resources=None):
|
|
64
|
+
'''{category: (cpus, mem_mb)}: DEFAULT_RESOURCES updated with RESOURCES ({category: {'cpus':
|
|
65
|
+
.., 'mem': ..}}), the categories other than heavy capped at its values.'''
|
|
66
|
+
merged = {k: dict(v) for k, v in DEFAULT_RESOURCES.items()}
|
|
67
|
+
for category, res in (resources or {}).items():
|
|
68
|
+
merged.setdefault(category, {}).update(res)
|
|
69
|
+
resolved = {}
|
|
70
|
+
for category, res in merged.items():
|
|
71
|
+
if 'cpus' not in res or 'mem' not in res:
|
|
72
|
+
raise ValueError(f'Resources of the category {category}: give cpus and mem')
|
|
73
|
+
resolved[category] = (int(res['cpus']), mem_mb(res['mem']))
|
|
74
|
+
heavy = resolved['heavy']
|
|
75
|
+
return {c: r if c == 'heavy' else (min(r[0], heavy[0]), min(r[1], heavy[1]))
|
|
76
|
+
for c, r in resolved.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def slurm_cluster(options):
|
|
80
|
+
'''The cluster of --clusters/-M in a list of Slurm options, or None.'''
|
|
81
|
+
for i, option in enumerate(options):
|
|
82
|
+
m = re.fullmatch(r'(?:--clusters?|-M)=?(.*)', option)
|
|
83
|
+
if m:
|
|
84
|
+
return m.group(1) or (options[i + 1] if i + 1 < len(options) else None)
|
|
85
|
+
return None
|
|
86
|
+
|
|
32
87
|
|
|
33
88
|
def get_event_loop():
|
|
34
89
|
"""The current event loop, or a new one when there is none or it is closed (e.g. after
|
|
@@ -74,11 +129,12 @@ def get_hosts(host_string):
|
|
|
74
129
|
|
|
75
130
|
|
|
76
131
|
def get_worker_pool(name, nodes='localhost', max_concurrent=4, env_file=None, max_time=None,
|
|
77
|
-
debug=False, dry_run=False, slurm_options=None
|
|
132
|
+
debug=False, dry_run=False, slurm_options=None, slurm_nodes=None,
|
|
133
|
+
resources=None):
|
|
78
134
|
hosts = get_hosts(nodes)
|
|
79
135
|
return WorkerPool(hosts, name=name, max_tasks_per_worker=max_concurrent,
|
|
80
136
|
env_source_file=env_file, max_time=max_time, debug=debug, dry_run=dry_run,
|
|
81
|
-
slurm_options=slurm_options)
|
|
137
|
+
slurm_options=slurm_options, slurm_nodes=slurm_nodes, resources=resources)
|
|
82
138
|
|
|
83
139
|
|
|
84
140
|
class TaskStatus(Enum):
|
|
@@ -114,7 +170,8 @@ class Task:
|
|
|
114
170
|
"""
|
|
115
171
|
|
|
116
172
|
def __init__(self, name, command, output_file=None, done_callback=None, task_done_callback=None,
|
|
117
|
-
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False
|
|
173
|
+
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False,
|
|
174
|
+
category=None, label=None, cpus=None, mem_mb=None):
|
|
118
175
|
self.name = name
|
|
119
176
|
self.command = command
|
|
120
177
|
self.output_file = output_file
|
|
@@ -134,6 +191,12 @@ class Task:
|
|
|
134
191
|
self.start_time = None
|
|
135
192
|
self.sigterm_time = None
|
|
136
193
|
self.slurm_job_id = None
|
|
194
|
+
# Resources of a job step, and the usage reported by libpipe.taskrun
|
|
195
|
+
self.category = category
|
|
196
|
+
self.label = label or category
|
|
197
|
+
self.cpus = cpus
|
|
198
|
+
self.mem_mb = mem_mb
|
|
199
|
+
self.usage = None
|
|
137
200
|
|
|
138
201
|
def done_callback(self):
|
|
139
202
|
err_msg = ''
|
|
@@ -181,9 +244,17 @@ class Task:
|
|
|
181
244
|
if isinstance(line, bytes):
|
|
182
245
|
# Tools can print any byte: a decoding error must not interrupt the task
|
|
183
246
|
line = line.decode(errors='replace')
|
|
247
|
+
if line.startswith(USAGE_MARKER):
|
|
248
|
+
with contextlib.suppress(ValueError):
|
|
249
|
+
self.usage = {k: float(v) for k, v in
|
|
250
|
+
(f.split('=', 1) for f in line[len(USAGE_MARKER):].split())}
|
|
251
|
+
if self.fd is not None:
|
|
252
|
+
self.fd.write(line)
|
|
253
|
+
return
|
|
184
254
|
if self.fd is not None:
|
|
185
255
|
self.fd.write(line)
|
|
186
|
-
|
|
256
|
+
# Keep the indentation of the tool's output
|
|
257
|
+
line = line.rstrip()
|
|
187
258
|
if err:
|
|
188
259
|
line = style(line, fg='red')
|
|
189
260
|
print(f'[{self.name}:{host}] {line}')
|
|
@@ -447,13 +518,17 @@ class LocalClient:
|
|
|
447
518
|
|
|
448
519
|
class SlurmClient:
|
|
449
520
|
"""Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
|
|
450
|
-
job step when it runs inside an allocation (salloc, sbatch).
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
521
|
+
job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes, the client
|
|
522
|
+
requests an allocation of that many nodes and runs the tasks as job steps in it, packed on
|
|
523
|
+
its nodes from their CPUs and memory; the allocation ends with the pool, or after
|
|
524
|
+
ALLOC_IDLE_MINUTES without step.
|
|
525
|
+
|
|
526
|
+
Options (partition, account, ...) come from slurm_options, or from the LIBPIPE_SLURM_OPTIONS
|
|
527
|
+
environment variable; they apply to the jobs and the allocation, not to the job steps.
|
|
528
|
+
max_time becomes the time limit of the jobs and job steps.
|
|
454
529
|
"""
|
|
455
530
|
|
|
456
|
-
def __init__(self, options=None, job_name='libpipe', max_time=None):
|
|
531
|
+
def __init__(self, options=None, job_name='libpipe', max_time=None, alloc_nodes=None):
|
|
457
532
|
self.host = SLURM_HOST
|
|
458
533
|
self.started = asyncio.Event()
|
|
459
534
|
if options is None:
|
|
@@ -462,16 +537,63 @@ class SlurmClient:
|
|
|
462
537
|
self.job_name = re.sub(r'\s+', '_', job_name)
|
|
463
538
|
self.max_time = max_time
|
|
464
539
|
self.exact = False
|
|
540
|
+
self.steps = False
|
|
541
|
+
self.alloc_nodes = alloc_nodes
|
|
542
|
+
self.alloc_id = None
|
|
543
|
+
self.alloc_checked = 0
|
|
544
|
+
self.alloc_lock = asyncio.Lock()
|
|
545
|
+
# CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
|
|
546
|
+
# waiting in srun, which polls the controller
|
|
547
|
+
self.cpus_total = None
|
|
548
|
+
self.cpus_free = 0
|
|
549
|
+
self.cpus_of = None
|
|
550
|
+
self.cpus_changed = asyncio.Condition()
|
|
551
|
+
# squeue, scancel and steps of a job of another cluster need SLURM_CLUSTERS
|
|
552
|
+
cluster = slurm_cluster(self.options)
|
|
553
|
+
self.env = dict(os.environ, SLURM_CLUSTERS=cluster) if cluster else None
|
|
465
554
|
|
|
466
555
|
async def start(self):
|
|
467
556
|
if self.started.is_set():
|
|
468
557
|
return
|
|
469
|
-
#
|
|
470
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
558
|
+
# As job steps, --exact (Slurm >= 20.11) gives each step only its resources, so that
|
|
559
|
+
# steps run side by side instead of one after the other
|
|
560
|
+
in_allocation = bool(os.environ.get('SLURM_JOB_ID'))
|
|
561
|
+
if in_allocation or self.alloc_nodes:
|
|
562
|
+
self.exact = await self._slurm_version() >= (20, 11)
|
|
563
|
+
if self.alloc_nodes and (in_allocation or not self.exact):
|
|
564
|
+
if not in_allocation:
|
|
565
|
+
print(f'[{self.job_name}] Slurm < 20.11: one job per task instead of job steps')
|
|
566
|
+
self.alloc_nodes = None
|
|
567
|
+
self.steps = in_allocation or bool(self.alloc_nodes)
|
|
568
|
+
if in_allocation and self.exact:
|
|
569
|
+
await self._set_cpus(os.environ['SLURM_JOB_ID'])
|
|
473
570
|
self.started.set()
|
|
474
571
|
|
|
572
|
+
async def _set_cpus(self, job_id):
|
|
573
|
+
if self.cpus_of == job_id:
|
|
574
|
+
return
|
|
575
|
+
_, out, _ = await self._run('squeue', '-h', '-j', job_id, '-o', '%C')
|
|
576
|
+
async with self.cpus_changed:
|
|
577
|
+
self.cpus_of = job_id
|
|
578
|
+
self.cpus_total = int(out) if out.isdigit() else None
|
|
579
|
+
self.cpus_free = self.cpus_total or 0
|
|
580
|
+
self.cpus_changed.notify_all()
|
|
581
|
+
|
|
582
|
+
async def _acquire_cpus(self, cpus):
|
|
583
|
+
if self.cpus_total is None:
|
|
584
|
+
return 0
|
|
585
|
+
cpus = min(cpus, self.cpus_total)
|
|
586
|
+
async with self.cpus_changed:
|
|
587
|
+
await self.cpus_changed.wait_for(lambda: self.cpus_free >= cpus)
|
|
588
|
+
self.cpus_free -= cpus
|
|
589
|
+
return cpus
|
|
590
|
+
|
|
591
|
+
async def _release_cpus(self, cpus):
|
|
592
|
+
if cpus and self.cpus_total is not None:
|
|
593
|
+
async with self.cpus_changed:
|
|
594
|
+
self.cpus_free = min(self.cpus_total, self.cpus_free + cpus)
|
|
595
|
+
self.cpus_changed.notify_all()
|
|
596
|
+
|
|
475
597
|
@staticmethod
|
|
476
598
|
async def _slurm_version():
|
|
477
599
|
try:
|
|
@@ -484,17 +606,79 @@ class SlurmClient:
|
|
|
484
606
|
m = re.search(r'(\d+)\.(\d+)', out.decode())
|
|
485
607
|
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
|
486
608
|
|
|
609
|
+
def _time_option(self, options):
|
|
610
|
+
has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in options)
|
|
611
|
+
return [f'--time={int(-(-self.max_time // 60))}'] if self.max_time and not has_time else []
|
|
612
|
+
|
|
487
613
|
def srun_command(self, task):
|
|
488
614
|
args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
|
|
489
615
|
'--unbuffered']
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
616
|
+
command, job_id, options = task.command, '${SLURM_JOB_ID}', self.options
|
|
617
|
+
if self.steps:
|
|
618
|
+
if self.alloc_id:
|
|
619
|
+
args.append(f'--jobid={self.alloc_id}')
|
|
620
|
+
if self.exact:
|
|
621
|
+
# --quiet: no "step creation disabled, retrying" while a step waits for its node
|
|
622
|
+
args += ['--exact', '--quiet']
|
|
623
|
+
if task.cpus:
|
|
624
|
+
args += [f'--cpus-per-task={task.cpus}', f'--mem={task.mem_mb}M']
|
|
625
|
+
threads = ' '.join(f'{v}={task.cpus}' for v in THREAD_VARS)
|
|
626
|
+
command = f'export {threads}; {command}'
|
|
627
|
+
job_id, options = '${SLURM_JOB_ID}.${SLURM_STEP_ID}', []
|
|
628
|
+
args += self._time_option(options) + options
|
|
629
|
+
marker = f'echo "{SLURM_MARKER}{job_id} on $(hostname -s)"'
|
|
630
|
+
return f'{shlex.join(args)} bash -c {shlex.quote(f"{marker}; {command}")}'
|
|
631
|
+
|
|
632
|
+
async def _run(self, *args):
|
|
633
|
+
process = await asyncio.create_subprocess_exec(
|
|
634
|
+
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=self.env)
|
|
635
|
+
out, err = await process.communicate()
|
|
636
|
+
return process.returncode, out.decode().strip(), err.decode().strip()
|
|
637
|
+
|
|
638
|
+
async def ensure_allocation(self):
|
|
639
|
+
"""Request the allocation, or a new one when it has ended, and wait for it to run."""
|
|
640
|
+
async with self.alloc_lock:
|
|
641
|
+
n_requested = 0
|
|
642
|
+
while True:
|
|
643
|
+
if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
|
|
644
|
+
return
|
|
645
|
+
if self.alloc_id is None:
|
|
646
|
+
if n_requested == ALLOC_MAX_REQUESTS:
|
|
647
|
+
raise RuntimeError(f'{n_requested} allocations ended before running')
|
|
648
|
+
self.alloc_id = await self._submit_allocation()
|
|
649
|
+
n_requested += 1
|
|
650
|
+
_, state, _ = await self._run('squeue', '-h', '-j', self.alloc_id, '-o', '%T')
|
|
651
|
+
if state == 'RUNNING':
|
|
652
|
+
self.alloc_checked = time.monotonic()
|
|
653
|
+
await self._set_cpus(self.alloc_id)
|
|
654
|
+
return
|
|
655
|
+
if state not in ('PENDING', 'CONFIGURING'):
|
|
656
|
+
print(f'[{self.job_name}] allocation {self.alloc_id} ended '
|
|
657
|
+
f'({state or "gone"}): requesting a new one')
|
|
658
|
+
self.alloc_id = None
|
|
659
|
+
continue
|
|
660
|
+
await asyncio.sleep(5)
|
|
661
|
+
|
|
662
|
+
async def _submit_allocation(self):
|
|
663
|
+
# The holding job: ends after ALLOC_IDLE_MINUTES without step (other than its own)
|
|
664
|
+
holder = (f'unset SLURM_CLUSTERS; idle=0; while [ $idle -lt {ALLOC_IDLE_MINUTES} ]; do '
|
|
665
|
+
'sleep 60; if squeue -h -s -j "$SLURM_JOB_ID" -o %i '
|
|
666
|
+
'| grep -q -v -E "\\.(batch|extern)$"; then idle=0; else idle=$((idle + 1)); '
|
|
667
|
+
'fi; done')
|
|
668
|
+
time_option = self._time_option(self.options) if self.max_time else []
|
|
669
|
+
if not time_option and not any(o == '-t' or o.startswith(('-t', '--time'))
|
|
670
|
+
for o in self.options):
|
|
671
|
+
time_option = ['--time=24:00:00']
|
|
672
|
+
args = ['sbatch', '--parsable', f'--nodes={self.alloc_nodes}', '--exclusive',
|
|
673
|
+
f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
|
|
674
|
+
*self.options, '--wrap', holder]
|
|
675
|
+
returncode, out, err = await self._run(*args)
|
|
676
|
+
if returncode:
|
|
677
|
+
raise RuntimeError(f'Requesting the allocation failed: {err}')
|
|
678
|
+
job_id = out.split(';')[0]
|
|
679
|
+
print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes} node(s): the tasks '
|
|
680
|
+
'run as job steps')
|
|
681
|
+
return job_id
|
|
498
682
|
|
|
499
683
|
async def _log_stdout(self, task, stream):
|
|
500
684
|
"""Log the task output, recording the job id from the marker line."""
|
|
@@ -513,16 +697,22 @@ class SlurmClient:
|
|
|
513
697
|
|
|
514
698
|
async def execute(self, task):
|
|
515
699
|
task.slurm_job_id = None
|
|
516
|
-
|
|
517
|
-
self.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
task.
|
|
524
|
-
|
|
525
|
-
|
|
700
|
+
if self.alloc_nodes:
|
|
701
|
+
await self.ensure_allocation()
|
|
702
|
+
cpus = await self._acquire_cpus(task.cpus or 1) if self.steps else 0
|
|
703
|
+
try:
|
|
704
|
+
process = await asyncio.create_subprocess_shell(
|
|
705
|
+
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
706
|
+
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
|
|
707
|
+
task.set_process(Process(self, process))
|
|
708
|
+
|
|
709
|
+
await asyncio.gather(
|
|
710
|
+
self._log_stdout(task, process.stdout),
|
|
711
|
+
task.log_stream(process.stderr, self.host, err=True),
|
|
712
|
+
)
|
|
713
|
+
task.returncode = await process.wait()
|
|
714
|
+
finally:
|
|
715
|
+
await self._release_cpus(cpus)
|
|
526
716
|
|
|
527
717
|
async def terminate(self, task, sig=signal.SIGTERM):
|
|
528
718
|
job_id = getattr(task, 'slurm_job_id', None)
|
|
@@ -530,7 +720,7 @@ class SlurmClient:
|
|
|
530
720
|
task.log(f'Cancelling Slurm job {job_id} ({sig.name})\n', self.host, err=True)
|
|
531
721
|
args = ['scancel'] + (['-s', 'KILL'] if sig == signal.SIGKILL else []) + [job_id]
|
|
532
722
|
with contextlib.suppress(Exception):
|
|
533
|
-
process = await asyncio.create_subprocess_exec(*args)
|
|
723
|
+
process = await asyncio.create_subprocess_exec(*args, env=self.env)
|
|
534
724
|
await asyncio.wait_for(process.wait(), timeout=30)
|
|
535
725
|
else:
|
|
536
726
|
# Still pending: stopping srun withdraws the job request
|
|
@@ -542,7 +732,12 @@ class SlurmClient:
|
|
|
542
732
|
return self.started.is_set()
|
|
543
733
|
|
|
544
734
|
def close(self):
|
|
545
|
-
|
|
735
|
+
if self.alloc_id:
|
|
736
|
+
job_id, self.alloc_id = self.alloc_id, None
|
|
737
|
+
print(f'[{self.job_name}] releasing the allocation {job_id}')
|
|
738
|
+
with contextlib.suppress(Exception):
|
|
739
|
+
subprocess.run(['scancel', job_id], env=self.env, capture_output=True,
|
|
740
|
+
timeout=30)
|
|
546
741
|
|
|
547
742
|
|
|
548
743
|
class Worker:
|
|
@@ -640,13 +835,59 @@ class WorkerPoolReport:
|
|
|
640
835
|
return getattr(task.process.client, "host", "?")
|
|
641
836
|
return task.run_on_host or "?"
|
|
642
837
|
|
|
838
|
+
def usage_lines(self):
|
|
839
|
+
'''Per task type (label): tasks, longest wall time, total CPU time, mean cores used,
|
|
840
|
+
peak RSS, and the CPUs and memory of its category.'''
|
|
841
|
+
groups = collections.defaultdict(list)
|
|
842
|
+
for t in self.all_tasks:
|
|
843
|
+
if getattr(t, 'usage', None):
|
|
844
|
+
groups[t.label or '-'].append(t)
|
|
845
|
+
if not groups:
|
|
846
|
+
return []
|
|
847
|
+
lines = [f'{"task":24s} {"n":>4s} {"wall max":>9s} {"CPU total":>10s} {"cores":>6s} '
|
|
848
|
+
f'{"peak RSS":>9s} category (request)']
|
|
849
|
+
for label, tasks in sorted(groups.items()):
|
|
850
|
+
wall = [t.usage.get('wall', 0) for t in tasks]
|
|
851
|
+
cpu = sum(t.usage.get('cpu', 0) for t in tasks)
|
|
852
|
+
rss = max(t.usage.get('maxrss_kb', 0) for t in tasks) / 2 ** 20
|
|
853
|
+
t = tasks[0]
|
|
854
|
+
lines.append(f'{label:24s} {len(tasks):4d} {max(wall) / 60:7.1f} m {cpu / 3600:8.2f} h '
|
|
855
|
+
f'{cpu / max(sum(wall), 1e-9):6.1f} {rss:5.1f} GiB {t.category} '
|
|
856
|
+
f'({t.cpus} CPUs, {t.mem_mb / 1024:g} GB)')
|
|
857
|
+
return lines
|
|
858
|
+
|
|
859
|
+
def memory_warnings(self):
|
|
860
|
+
'''Task types whose peak memory exceeds the memory of their category.'''
|
|
861
|
+
peaks = collections.defaultdict(float)
|
|
862
|
+
requests = {}
|
|
863
|
+
for t in self.all_tasks:
|
|
864
|
+
if getattr(t, 'usage', None) and getattr(t, 'mem_mb', None):
|
|
865
|
+
label = t.label or '-'
|
|
866
|
+
peaks[label] = max(peaks[label], t.usage.get('maxrss_kb', 0) / 1024)
|
|
867
|
+
requests[label] = (t.category, t.mem_mb)
|
|
868
|
+
return [f'{label} used up to {peak / 1024:.1f} GiB, above the {mem / 1024:g} GB of its '
|
|
869
|
+
f'category {category}: raise its memory or limit the task'
|
|
870
|
+
for label, peak in sorted(peaks.items())
|
|
871
|
+
for category, mem in [requests[label]] if peak > mem]
|
|
872
|
+
|
|
873
|
+
def print_usage(self):
|
|
874
|
+
usage = self.usage_lines()
|
|
875
|
+
if not usage:
|
|
876
|
+
return
|
|
877
|
+
print('\nUsage per task type (the memory of a category must stay above its peak RSS):')
|
|
878
|
+
print('\n'.join(usage))
|
|
879
|
+
for warning in self.memory_warnings():
|
|
880
|
+
secho(f'Warning: {warning}', fg='red')
|
|
881
|
+
print()
|
|
882
|
+
|
|
643
883
|
|
|
644
884
|
class WorkerPool:
|
|
645
885
|
|
|
646
886
|
def __init__(self, hosts, name='Worker', max_tasks_per_worker=4,
|
|
647
887
|
env_source_file=None, user=None, max_time=None, force_sync=False,
|
|
648
888
|
debug=False, dry_run=False, password=None, connect_timeout=15,
|
|
649
|
-
host_cooldown_s=10, slurm_options=None, kill_grace_s=30
|
|
889
|
+
host_cooldown_s=10, slurm_options=None, kill_grace_s=30, slurm_nodes=None,
|
|
890
|
+
resources=None):
|
|
650
891
|
"""Initiate a worker pool.
|
|
651
892
|
|
|
652
893
|
Args:
|
|
@@ -664,6 +905,10 @@ class WorkerPool:
|
|
|
664
905
|
'-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
|
|
665
906
|
kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
|
|
666
907
|
before SIGKILL.
|
|
908
|
+
slurm_nodes (int, optional): For 'slurm' from a login node: run the tasks as job
|
|
909
|
+
steps of an allocation of up to that many nodes, instead of one job each.
|
|
910
|
+
resources (dict, optional): CPUs and memory of the task categories as job steps,
|
|
911
|
+
{category: {'cpus': .., 'mem': ..}}, updating DEFAULT_RESOURCES.
|
|
667
912
|
"""
|
|
668
913
|
get_event_loop()
|
|
669
914
|
|
|
@@ -687,17 +932,26 @@ class WorkerPool:
|
|
|
687
932
|
# Tasks waiting for a worker, in arrival order: (requested host, future)
|
|
688
933
|
self._waiters = collections.deque()
|
|
689
934
|
self._cooldown_timer = None
|
|
935
|
+
self.resources = resolve_resources(resources)
|
|
936
|
+
self.slurm_nodes = slurm_nodes
|
|
937
|
+
# Wall time, CPU time and peak memory of each task (libpipe.taskrun), with the Python
|
|
938
|
+
# of the pool: on this machine or on the nodes of the cluster
|
|
939
|
+
self.accounting = all(h in (localhost_shortname, 'localhost', SLURM_HOST) for h in hosts)
|
|
690
940
|
|
|
691
941
|
for host in self.hosts:
|
|
942
|
+
n_workers = max_tasks_per_worker
|
|
692
943
|
if host == localhost_shortname or host == 'localhost':
|
|
693
944
|
client = LocalClient(localhost_shortname, force_sync=force_sync)
|
|
694
945
|
elif host == SLURM_HOST:
|
|
695
|
-
client = SlurmClient(slurm_options, job_name=name, max_time=max_time
|
|
946
|
+
client = SlurmClient(slurm_options, job_name=name, max_time=max_time,
|
|
947
|
+
alloc_nodes=slurm_nodes)
|
|
948
|
+
if slurm_nodes:
|
|
949
|
+
n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * slurm_nodes)
|
|
696
950
|
else:
|
|
697
951
|
client = Client(host, user, force_sync=force_sync, password=password,
|
|
698
952
|
connect_timeout=connect_timeout)
|
|
699
953
|
|
|
700
|
-
for i in range(
|
|
954
|
+
for i in range(n_workers):
|
|
701
955
|
worker = Worker(client, f'{client.host}:{i}')
|
|
702
956
|
self.workers.append(worker)
|
|
703
957
|
|
|
@@ -783,7 +1037,8 @@ class WorkerPool:
|
|
|
783
1037
|
min(ends) - now + 0.01, self._dispatch)
|
|
784
1038
|
|
|
785
1039
|
def add(self, command, name=None, output_file=None, task_done_callback=None, done_callback=None,
|
|
786
|
-
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False
|
|
1040
|
+
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False,
|
|
1041
|
+
category=None, label=None):
|
|
787
1042
|
"""Add a command to execute by a worker in the pool. Optionally output result
|
|
788
1043
|
in output_file.
|
|
789
1044
|
|
|
@@ -791,7 +1046,14 @@ class WorkerPool:
|
|
|
791
1046
|
command (str): Command to execute.
|
|
792
1047
|
name (str, optional): Name of the task. If not set it will be set by the worker pool
|
|
793
1048
|
output_file (str, optional): Optional filename to log output into.
|
|
1049
|
+
category (str, optional): Resource category of the task as a job step (see
|
|
1050
|
+
DEFAULT_RESOURCES): small, medium, high_mem, cpu or heavy (the default).
|
|
1051
|
+
label (str, optional): Task type, grouping the tasks in the usage summary
|
|
1052
|
+
(default: the category).
|
|
794
1053
|
"""
|
|
1054
|
+
if category is not None and category not in self.resources:
|
|
1055
|
+
raise ValueError(f'Unknown resource category {category!r}: '
|
|
1056
|
+
f'{", ".join(self.resources)}')
|
|
795
1057
|
if run_on_host is not None and run_on_host not in self.hosts:
|
|
796
1058
|
# Slurm places the tasks itself: a host constraint is expected not to apply
|
|
797
1059
|
if SLURM_HOST not in self.hosts:
|
|
@@ -802,6 +1064,8 @@ class WorkerPool:
|
|
|
802
1064
|
script = f". {shlex.quote(self.env_file)}; {command}" if self.env_file else command
|
|
803
1065
|
|
|
804
1066
|
command = f"bash -lc {shlex.quote(script)}"
|
|
1067
|
+
if self.accounting:
|
|
1068
|
+
command = f'{shlex.quote(sys.executable)} {shlex.quote(taskrun.__file__)} {command}'
|
|
805
1069
|
|
|
806
1070
|
if name is None:
|
|
807
1071
|
name = f'T{len(self.tasks)}'
|
|
@@ -811,10 +1075,12 @@ class WorkerPool:
|
|
|
811
1075
|
if self.dry_run:
|
|
812
1076
|
return
|
|
813
1077
|
|
|
1078
|
+
cpus, mem = self.resources[category or 'heavy']
|
|
814
1079
|
self.tasks.append(Task(name, command, output_file=output_file, done_callback=done_callback,
|
|
815
1080
|
task_done_callback=task_done_callback, run_on_host=run_on_host,
|
|
816
1081
|
n_try=n_try, pid_filename=pid_filename,
|
|
817
|
-
keep_pid_file_on_error=keep_pid_file_on_error
|
|
1082
|
+
keep_pid_file_on_error=keep_pid_file_on_error,
|
|
1083
|
+
category=category or 'heavy', label=label, cpus=cpus, mem_mb=mem))
|
|
818
1084
|
|
|
819
1085
|
def get_all_tasks(self):
|
|
820
1086
|
tasks = collections.defaultdict(list)
|
|
@@ -885,6 +1151,11 @@ class WorkerPool:
|
|
|
885
1151
|
|
|
886
1152
|
self.pbar.start(max_value=len(self.tasks))
|
|
887
1153
|
|
|
1154
|
+
# An allocation no larger than the number of tasks
|
|
1155
|
+
for client in {w.client for w in self.workers}:
|
|
1156
|
+
if isinstance(client, SlurmClient) and client.alloc_nodes:
|
|
1157
|
+
client.alloc_nodes = min(self.slurm_nodes, len(self.tasks))
|
|
1158
|
+
|
|
888
1159
|
self.futures = [asyncio.ensure_future(task.run(self)) for task in self.tasks]
|
|
889
1160
|
self.futures.append(asyncio.ensure_future(self._monitor_queue()))
|
|
890
1161
|
|
|
@@ -926,6 +1197,8 @@ class WorkerPool:
|
|
|
926
1197
|
|
|
927
1198
|
self.last_report = WorkerPoolReport(self.tasks)
|
|
928
1199
|
report = self.last_report
|
|
1200
|
+
# At the end of the pool, before the next tasks fill the console
|
|
1201
|
+
report.print_usage()
|
|
929
1202
|
|
|
930
1203
|
if print_summary:
|
|
931
1204
|
print("\n=== WorkerPool summary ===")
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "libpipe"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9"
|
|
8
8
|
description = "Pipeline orchestration and node management utilities"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "GPL-3.0-or-later"
|
|
@@ -26,11 +26,18 @@ dev = [
|
|
|
26
26
|
"pytest-cov>=5.0",
|
|
27
27
|
"ruff==0.16.1",
|
|
28
28
|
]
|
|
29
|
+
docs = [
|
|
30
|
+
"sphinx>=7",
|
|
31
|
+
"myst-parser>=2",
|
|
32
|
+
"pydata-sphinx-theme>=0.15",
|
|
33
|
+
"sphinx-click>=6",
|
|
34
|
+
]
|
|
29
35
|
|
|
30
36
|
[project.urls]
|
|
31
37
|
Homepage = "https://gitlab.com/flomertens/libpipe"
|
|
32
38
|
Repository = "https://gitlab.com/flomertens/libpipe"
|
|
33
39
|
Issues = "https://gitlab.com/flomertens/libpipe/-/issues"
|
|
40
|
+
Documentation = "https://libpipe.readthedocs.io"
|
|
34
41
|
|
|
35
42
|
[project.scripts]
|
|
36
43
|
nodetool = "libpipe.tools.nodetool:main"
|
libpipe-0.8/README.md
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
Libpipe
|
|
2
|
-
=======
|
|
3
|
-
|
|
4
|
-
Common pipeline framework and library
|
|
5
|
-
|
|
6
|
-
Installation
|
|
7
|
-
------------
|
|
8
|
-
|
|
9
|
-
libpipe can be installed via pip:
|
|
10
|
-
|
|
11
|
-
$ pip install libpipe
|
|
12
|
-
|
|
13
|
-
and requires Python 3.10 or higher.
|
|
14
|
-
|
|
15
|
-
Worker pools on a Slurm cluster
|
|
16
|
-
-------------------------------
|
|
17
|
-
|
|
18
|
-
A `WorkerPool` runs its tasks locally, on other hosts over SSH, or through Slurm with the
|
|
19
|
-
host `slurm`: each task is then started with `srun`, as a job of its own when the pool runs
|
|
20
|
-
on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
21
|
-
|
|
22
|
-
pool = worker.WorkerPool(['slurm'], name='Imaging', max_tasks_per_worker=8,
|
|
23
|
-
slurm_options='-p regular --mem=200G --time=24:00:00')
|
|
24
|
-
|
|
25
|
-
`max_tasks_per_worker` is the number of jobs in flight. The srun options (partition,
|
|
26
|
-
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
27
|
-
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
28
|
-
Host constraints (`run_on_host`) do not apply: Slurm places the tasks, so the data must be
|
|
29
|
-
on a file system shared with the compute nodes. Give the memory (`--mem`) in the options:
|
|
30
|
-
inside an allocation, a step without it can claim all the memory of its node.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|