libpipe 0.8__tar.gz → 0.9.1__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.1}/PKG-INFO +29 -3
- libpipe-0.9.1/README.md +50 -0
- libpipe-0.9.1/libpipe/taskrun.py +25 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/worker.py +325 -38
- {libpipe-0.8 → libpipe-0.9.1}/pyproject.toml +8 -1
- libpipe-0.8/README.md +0 -30
- {libpipe-0.8 → libpipe-0.9.1}/LICENSE +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/__init__.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/attrmap.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/futils.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/msutils.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/settings.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.8 → libpipe-0.9.1}/libpipe/tools/nodetool.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: libpipe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Pipeline orchestration and node management utilities
|
|
5
5
|
License: GPL-3.0-or-later
|
|
6
6
|
Author: Florent Mertens
|
|
@@ -13,13 +13,19 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
15
|
Provides-Extra: dev
|
|
16
|
+
Provides-Extra: docs
|
|
16
17
|
Requires-Dist: asyncssh (>=2.0)
|
|
17
18
|
Requires-Dist: click (>=8.0)
|
|
19
|
+
Requires-Dist: myst-parser (>=2) ; extra == "docs"
|
|
18
20
|
Requires-Dist: progressbar2 (>=3.40)
|
|
21
|
+
Requires-Dist: pydata-sphinx-theme (>=0.15) ; extra == "docs"
|
|
19
22
|
Requires-Dist: pytest (>=8.0) ; extra == "dev"
|
|
20
23
|
Requires-Dist: pytest-cov (>=5.0) ; extra == "dev"
|
|
21
24
|
Requires-Dist: ruff (==0.16.1) ; extra == "dev"
|
|
25
|
+
Requires-Dist: sphinx (>=7) ; extra == "docs"
|
|
26
|
+
Requires-Dist: sphinx-click (>=6) ; extra == "docs"
|
|
22
27
|
Requires-Dist: toml (>=0.10)
|
|
28
|
+
Project-URL: Documentation, https://libpipe.readthedocs.io
|
|
23
29
|
Project-URL: Homepage, https://gitlab.com/flomertens/libpipe
|
|
24
30
|
Project-URL: Issues, https://gitlab.com/flomertens/libpipe/-/issues
|
|
25
31
|
Project-URL: Repository, https://gitlab.com/flomertens/libpipe
|
|
@@ -53,6 +59,26 @@ on a login node, as a job step inside an allocation (`salloc`, `sbatch`).
|
|
|
53
59
|
account, resources) come from `slurm_options`, or from the `LIBPIPE_SLURM_OPTIONS`
|
|
54
60
|
environment variable; `max_time` becomes the job time limit when the options set none.
|
|
55
61
|
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
|
-
|
|
62
|
+
on a file system shared with the compute nodes.
|
|
63
|
+
|
|
64
|
+
With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
|
|
65
|
+
when its first task starts, runs each task as a job step in it (`srun --jobid --exact`), and
|
|
66
|
+
releases it at the end, or after 10 minutes without step. Slurm packs the steps on the nodes
|
|
67
|
+
from their CPUs and memory, given by the category of the task; a step starts when the
|
|
68
|
+
allocation has its CPUs free, so that no srun waits polling the controller:
|
|
69
|
+
|
|
70
|
+
pool = worker.WorkerPool(['slurm'], name='Cubes', slurm_nodes=4,
|
|
71
|
+
resources={'heavy': {'cpus': 12, 'mem': '40GB'}})
|
|
72
|
+
pool.add('pstool gen_vis_cube ...', category='small', label='gen_vis_cube')
|
|
73
|
+
|
|
74
|
+
The categories (`DEFAULT_RESOURCES`) are `small` (1 CPU, 2 GB), `medium` (4, 8 GB),
|
|
75
|
+
`high_mem` (2, 16 GB), `cpu` (12, 8 GB) and `heavy` (12, 40 GB, the default), the others capped
|
|
76
|
+
at `heavy`; a step also gets the matching `OMP_NUM_THREADS`. The options apply to the jobs and
|
|
77
|
+
the allocation, not to the steps. Inside an allocation started by hand, the tasks are job
|
|
78
|
+
steps with these resources as well.
|
|
79
|
+
|
|
80
|
+
Slurm does not necessarily limit the memory of a job step: the memory of a category must stay
|
|
81
|
+
above the peak memory of its tasks. The local and Slurm pools measure each task (wall time,
|
|
82
|
+
CPU time, peak RSS, in its log file), and `WorkerPoolReport.usage_lines()` summarises them per
|
|
83
|
+
task type (`label`).
|
|
58
84
|
|
libpipe-0.9.1/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."""
|
|
@@ -509,20 +693,29 @@ class SlurmClient:
|
|
|
509
693
|
text = line.decode(errors='replace')
|
|
510
694
|
if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
|
|
511
695
|
task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
|
|
696
|
+
task.start_time = time.time()
|
|
512
697
|
task.log(line, self.host)
|
|
513
698
|
|
|
514
699
|
async def execute(self, task):
|
|
515
700
|
task.slurm_job_id = None
|
|
516
|
-
|
|
517
|
-
self.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
task.
|
|
524
|
-
|
|
525
|
-
|
|
701
|
+
if self.alloc_nodes:
|
|
702
|
+
await self.ensure_allocation()
|
|
703
|
+
cpus = await self._acquire_cpus(task.cpus or 1) if self.steps else 0
|
|
704
|
+
try:
|
|
705
|
+
process = await asyncio.create_subprocess_shell(
|
|
706
|
+
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
707
|
+
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
|
|
708
|
+
task.set_process(Process(self, process))
|
|
709
|
+
# srun waits for the job or the step to start: running from the marker line
|
|
710
|
+
task.start_time = None
|
|
711
|
+
|
|
712
|
+
await asyncio.gather(
|
|
713
|
+
self._log_stdout(task, process.stdout),
|
|
714
|
+
task.log_stream(process.stderr, self.host, err=True),
|
|
715
|
+
)
|
|
716
|
+
task.returncode = await process.wait()
|
|
717
|
+
finally:
|
|
718
|
+
await self._release_cpus(cpus)
|
|
526
719
|
|
|
527
720
|
async def terminate(self, task, sig=signal.SIGTERM):
|
|
528
721
|
job_id = getattr(task, 'slurm_job_id', None)
|
|
@@ -530,7 +723,7 @@ class SlurmClient:
|
|
|
530
723
|
task.log(f'Cancelling Slurm job {job_id} ({sig.name})\n', self.host, err=True)
|
|
531
724
|
args = ['scancel'] + (['-s', 'KILL'] if sig == signal.SIGKILL else []) + [job_id]
|
|
532
725
|
with contextlib.suppress(Exception):
|
|
533
|
-
process = await asyncio.create_subprocess_exec(*args)
|
|
726
|
+
process = await asyncio.create_subprocess_exec(*args, env=self.env)
|
|
534
727
|
await asyncio.wait_for(process.wait(), timeout=30)
|
|
535
728
|
else:
|
|
536
729
|
# Still pending: stopping srun withdraws the job request
|
|
@@ -542,7 +735,12 @@ class SlurmClient:
|
|
|
542
735
|
return self.started.is_set()
|
|
543
736
|
|
|
544
737
|
def close(self):
|
|
545
|
-
|
|
738
|
+
if self.alloc_id:
|
|
739
|
+
job_id, self.alloc_id = self.alloc_id, None
|
|
740
|
+
print(f'[{self.job_name}] releasing the allocation {job_id}')
|
|
741
|
+
with contextlib.suppress(Exception):
|
|
742
|
+
subprocess.run(['scancel', job_id], env=self.env, capture_output=True,
|
|
743
|
+
timeout=30)
|
|
546
744
|
|
|
547
745
|
|
|
548
746
|
class Worker:
|
|
@@ -640,13 +838,59 @@ class WorkerPoolReport:
|
|
|
640
838
|
return getattr(task.process.client, "host", "?")
|
|
641
839
|
return task.run_on_host or "?"
|
|
642
840
|
|
|
841
|
+
def usage_lines(self):
|
|
842
|
+
'''Per task type (label): tasks, longest wall time, total CPU time, mean cores used,
|
|
843
|
+
peak RSS, and the CPUs and memory of its category.'''
|
|
844
|
+
groups = collections.defaultdict(list)
|
|
845
|
+
for t in self.all_tasks:
|
|
846
|
+
if getattr(t, 'usage', None):
|
|
847
|
+
groups[t.label or '-'].append(t)
|
|
848
|
+
if not groups:
|
|
849
|
+
return []
|
|
850
|
+
lines = [f'{"task":24s} {"n":>4s} {"wall max":>9s} {"CPU total":>10s} {"cores":>6s} '
|
|
851
|
+
f'{"peak RSS":>9s} category (request)']
|
|
852
|
+
for label, tasks in sorted(groups.items()):
|
|
853
|
+
wall = [t.usage.get('wall', 0) for t in tasks]
|
|
854
|
+
cpu = sum(t.usage.get('cpu', 0) for t in tasks)
|
|
855
|
+
rss = max(t.usage.get('maxrss_kb', 0) for t in tasks) / 2 ** 20
|
|
856
|
+
t = tasks[0]
|
|
857
|
+
lines.append(f'{label:24s} {len(tasks):4d} {max(wall) / 60:7.1f} m {cpu / 3600:8.2f} h '
|
|
858
|
+
f'{cpu / max(sum(wall), 1e-9):6.1f} {rss:5.1f} GiB {t.category} '
|
|
859
|
+
f'({t.cpus} CPUs, {t.mem_mb / 1024:g} GB)')
|
|
860
|
+
return lines
|
|
861
|
+
|
|
862
|
+
def memory_warnings(self):
|
|
863
|
+
'''Task types whose peak memory exceeds the memory of their category.'''
|
|
864
|
+
peaks = collections.defaultdict(float)
|
|
865
|
+
requests = {}
|
|
866
|
+
for t in self.all_tasks:
|
|
867
|
+
if getattr(t, 'usage', None) and getattr(t, 'mem_mb', None):
|
|
868
|
+
label = t.label or '-'
|
|
869
|
+
peaks[label] = max(peaks[label], t.usage.get('maxrss_kb', 0) / 1024)
|
|
870
|
+
requests[label] = (t.category, t.mem_mb)
|
|
871
|
+
return [f'{label} used up to {peak / 1024:.1f} GiB, above the {mem / 1024:g} GB of its '
|
|
872
|
+
f'category {category}: raise its memory or limit the task'
|
|
873
|
+
for label, peak in sorted(peaks.items())
|
|
874
|
+
for category, mem in [requests[label]] if peak > mem]
|
|
875
|
+
|
|
876
|
+
def print_usage(self):
|
|
877
|
+
usage = self.usage_lines()
|
|
878
|
+
if not usage:
|
|
879
|
+
return
|
|
880
|
+
print('\nUsage per task type (the memory of a category must stay above its peak RSS):')
|
|
881
|
+
print('\n'.join(usage))
|
|
882
|
+
for warning in self.memory_warnings():
|
|
883
|
+
secho(f'Warning: {warning}', fg='red')
|
|
884
|
+
print()
|
|
885
|
+
|
|
643
886
|
|
|
644
887
|
class WorkerPool:
|
|
645
888
|
|
|
646
889
|
def __init__(self, hosts, name='Worker', max_tasks_per_worker=4,
|
|
647
890
|
env_source_file=None, user=None, max_time=None, force_sync=False,
|
|
648
891
|
debug=False, dry_run=False, password=None, connect_timeout=15,
|
|
649
|
-
host_cooldown_s=10, slurm_options=None, kill_grace_s=30
|
|
892
|
+
host_cooldown_s=10, slurm_options=None, kill_grace_s=30, slurm_nodes=None,
|
|
893
|
+
resources=None):
|
|
650
894
|
"""Initiate a worker pool.
|
|
651
895
|
|
|
652
896
|
Args:
|
|
@@ -664,6 +908,10 @@ class WorkerPool:
|
|
|
664
908
|
'-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
|
|
665
909
|
kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
|
|
666
910
|
before SIGKILL.
|
|
911
|
+
slurm_nodes (int, optional): For 'slurm' from a login node: run the tasks as job
|
|
912
|
+
steps of an allocation of up to that many nodes, instead of one job each.
|
|
913
|
+
resources (dict, optional): CPUs and memory of the task categories as job steps,
|
|
914
|
+
{category: {'cpus': .., 'mem': ..}}, updating DEFAULT_RESOURCES.
|
|
667
915
|
"""
|
|
668
916
|
get_event_loop()
|
|
669
917
|
|
|
@@ -687,17 +935,26 @@ class WorkerPool:
|
|
|
687
935
|
# Tasks waiting for a worker, in arrival order: (requested host, future)
|
|
688
936
|
self._waiters = collections.deque()
|
|
689
937
|
self._cooldown_timer = None
|
|
938
|
+
self.resources = resolve_resources(resources)
|
|
939
|
+
self.slurm_nodes = slurm_nodes
|
|
940
|
+
# Wall time, CPU time and peak memory of each task (libpipe.taskrun), with the Python
|
|
941
|
+
# of the pool: on this machine or on the nodes of the cluster
|
|
942
|
+
self.accounting = all(h in (localhost_shortname, 'localhost', SLURM_HOST) for h in hosts)
|
|
690
943
|
|
|
691
944
|
for host in self.hosts:
|
|
945
|
+
n_workers = max_tasks_per_worker
|
|
692
946
|
if host == localhost_shortname or host == 'localhost':
|
|
693
947
|
client = LocalClient(localhost_shortname, force_sync=force_sync)
|
|
694
948
|
elif host == SLURM_HOST:
|
|
695
|
-
client = SlurmClient(slurm_options, job_name=name, max_time=max_time
|
|
949
|
+
client = SlurmClient(slurm_options, job_name=name, max_time=max_time,
|
|
950
|
+
alloc_nodes=slurm_nodes)
|
|
951
|
+
if slurm_nodes:
|
|
952
|
+
n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * slurm_nodes)
|
|
696
953
|
else:
|
|
697
954
|
client = Client(host, user, force_sync=force_sync, password=password,
|
|
698
955
|
connect_timeout=connect_timeout)
|
|
699
956
|
|
|
700
|
-
for i in range(
|
|
957
|
+
for i in range(n_workers):
|
|
701
958
|
worker = Worker(client, f'{client.host}:{i}')
|
|
702
959
|
self.workers.append(worker)
|
|
703
960
|
|
|
@@ -783,7 +1040,8 @@ class WorkerPool:
|
|
|
783
1040
|
min(ends) - now + 0.01, self._dispatch)
|
|
784
1041
|
|
|
785
1042
|
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
|
|
1043
|
+
run_on_host=None, n_try=3, pid_filename=None, keep_pid_file_on_error=False,
|
|
1044
|
+
category=None, label=None):
|
|
787
1045
|
"""Add a command to execute by a worker in the pool. Optionally output result
|
|
788
1046
|
in output_file.
|
|
789
1047
|
|
|
@@ -791,7 +1049,14 @@ class WorkerPool:
|
|
|
791
1049
|
command (str): Command to execute.
|
|
792
1050
|
name (str, optional): Name of the task. If not set it will be set by the worker pool
|
|
793
1051
|
output_file (str, optional): Optional filename to log output into.
|
|
1052
|
+
category (str, optional): Resource category of the task as a job step (see
|
|
1053
|
+
DEFAULT_RESOURCES): small, medium, high_mem, cpu or heavy (the default).
|
|
1054
|
+
label (str, optional): Task type, grouping the tasks in the usage summary
|
|
1055
|
+
(default: the category).
|
|
794
1056
|
"""
|
|
1057
|
+
if category is not None and category not in self.resources:
|
|
1058
|
+
raise ValueError(f'Unknown resource category {category!r}: '
|
|
1059
|
+
f'{", ".join(self.resources)}')
|
|
795
1060
|
if run_on_host is not None and run_on_host not in self.hosts:
|
|
796
1061
|
# Slurm places the tasks itself: a host constraint is expected not to apply
|
|
797
1062
|
if SLURM_HOST not in self.hosts:
|
|
@@ -802,6 +1067,8 @@ class WorkerPool:
|
|
|
802
1067
|
script = f". {shlex.quote(self.env_file)}; {command}" if self.env_file else command
|
|
803
1068
|
|
|
804
1069
|
command = f"bash -lc {shlex.quote(script)}"
|
|
1070
|
+
if self.accounting:
|
|
1071
|
+
command = f'{shlex.quote(sys.executable)} {shlex.quote(taskrun.__file__)} {command}'
|
|
805
1072
|
|
|
806
1073
|
if name is None:
|
|
807
1074
|
name = f'T{len(self.tasks)}'
|
|
@@ -811,10 +1078,12 @@ class WorkerPool:
|
|
|
811
1078
|
if self.dry_run:
|
|
812
1079
|
return
|
|
813
1080
|
|
|
1081
|
+
cpus, mem = self.resources[category or 'heavy']
|
|
814
1082
|
self.tasks.append(Task(name, command, output_file=output_file, done_callback=done_callback,
|
|
815
1083
|
task_done_callback=task_done_callback, run_on_host=run_on_host,
|
|
816
1084
|
n_try=n_try, pid_filename=pid_filename,
|
|
817
|
-
keep_pid_file_on_error=keep_pid_file_on_error
|
|
1085
|
+
keep_pid_file_on_error=keep_pid_file_on_error,
|
|
1086
|
+
category=category or 'heavy', label=label, cpus=cpus, mem_mb=mem))
|
|
818
1087
|
|
|
819
1088
|
def get_all_tasks(self):
|
|
820
1089
|
tasks = collections.defaultdict(list)
|
|
@@ -830,6 +1099,7 @@ class WorkerPool:
|
|
|
830
1099
|
tasks = self.get_all_tasks()
|
|
831
1100
|
n_done = len(tasks[TaskStatus.SUCCESS])
|
|
832
1101
|
n_todo = len(tasks[TaskStatus.WAITING]) + len(tasks[TaskStatus.RUNNING])
|
|
1102
|
+
self._start_pbar_timer()
|
|
833
1103
|
self.pbar.update(n_done)
|
|
834
1104
|
|
|
835
1105
|
if self.max_time is not None:
|
|
@@ -838,6 +1108,15 @@ class WorkerPool:
|
|
|
838
1108
|
except asyncio.CancelledError:
|
|
839
1109
|
pass
|
|
840
1110
|
|
|
1111
|
+
def _start_pbar_timer(self):
|
|
1112
|
+
"""Time the pool from the first task start, without the wait for the Slurm jobs."""
|
|
1113
|
+
if self.pbar_timed:
|
|
1114
|
+
return
|
|
1115
|
+
starts = [t.start_time for t in self.tasks if t.start_time is not None]
|
|
1116
|
+
if starts:
|
|
1117
|
+
self.pbar.start_time = datetime.datetime.fromtimestamp(min(starts))
|
|
1118
|
+
self.pbar_timed = True
|
|
1119
|
+
|
|
841
1120
|
async def _enforce_max_time(self):
|
|
842
1121
|
now = time.time()
|
|
843
1122
|
for worker in self.workers:
|
|
@@ -884,6 +1163,12 @@ class WorkerPool:
|
|
|
884
1163
|
self.tasks.sort(key=lambda task: not task.run_on_host)
|
|
885
1164
|
|
|
886
1165
|
self.pbar.start(max_value=len(self.tasks))
|
|
1166
|
+
self.pbar_timed = False
|
|
1167
|
+
|
|
1168
|
+
# An allocation no larger than the number of tasks
|
|
1169
|
+
for client in {w.client for w in self.workers}:
|
|
1170
|
+
if isinstance(client, SlurmClient) and client.alloc_nodes:
|
|
1171
|
+
client.alloc_nodes = min(self.slurm_nodes, len(self.tasks))
|
|
887
1172
|
|
|
888
1173
|
self.futures = [asyncio.ensure_future(task.run(self)) for task in self.tasks]
|
|
889
1174
|
self.futures.append(asyncio.ensure_future(self._monitor_queue()))
|
|
@@ -926,6 +1211,8 @@ class WorkerPool:
|
|
|
926
1211
|
|
|
927
1212
|
self.last_report = WorkerPoolReport(self.tasks)
|
|
928
1213
|
report = self.last_report
|
|
1214
|
+
# At the end of the pool, before the next tasks fill the console
|
|
1215
|
+
report.print_usage()
|
|
929
1216
|
|
|
930
1217
|
if print_summary:
|
|
931
1218
|
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.1"
|
|
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
|