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.
@@ -1,25 +1,32 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: libpipe
3
- Version: 0.8
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. Give the memory (`--mem`) in the options:
57
- inside an allocation, a step without it can claim all the memory of its node.
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
- line = line.strip()
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
- Options (resources, partition, account, ...) come from slurm_options, or from the
453
- LIBPIPE_SLURM_OPTIONS environment variable. max_time becomes the job time limit.
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
- # Inside an allocation, --exact (Slurm >= 21.08) gives each step only its resources,
470
- # so that steps run side by side instead of one after the other
471
- if os.environ.get('SLURM_JOB_ID'):
472
- self.exact = await self._slurm_version() >= (21, 8)
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
- if self.exact:
491
- args.append('--exact')
492
- has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in self.options)
493
- if self.max_time and not has_time:
494
- args.append(f'--time={int(-(-self.max_time // 60))}')
495
- args += self.options
496
- marker = f'echo "{SLURM_MARKER}${{SLURM_JOB_ID}} on $(hostname -s)"'
497
- return f'{shlex.join(args)} bash -c {shlex.quote(f"{marker}; {task.command}")}'
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
- process = await asyncio.create_subprocess_shell(
517
- self.srun_command(task), stdout=asyncio.subprocess.PIPE,
518
- stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp)
519
- task.set_process(Process(self, process))
520
-
521
- await asyncio.gather(
522
- self._log_stdout(task, process.stdout),
523
- task.log_stream(process.stderr, self.host, err=True),
524
- )
525
- task.returncode = await process.wait()
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
- pass
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(max_tasks_per_worker):
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.8"
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