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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: libpipe
3
- Version: 0.8
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. Give the memory (`--mem`) in the options:
57
- inside an allocation, a step without it can claim all the memory of its node.
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
 
@@ -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."""
@@ -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
- 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()
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
- pass
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(max_tasks_per_worker):
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.8"
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