libpipe 0.9.1__tar.gz → 0.11__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.9.1 → libpipe-0.11}/PKG-INFO +5 -4
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/worker.py +133 -24
- {libpipe-0.9.1 → libpipe-0.11}/pyproject.toml +1 -1
- {libpipe-0.9.1 → libpipe-0.11}/LICENSE +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/README.md +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/__init__.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/attrmap.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/futils.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/msutils.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/settings.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/taskrun.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.9.1 → libpipe-0.11}/libpipe/tools/nodetool.py +0 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: libpipe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11
|
|
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
|
|
16
17
|
Provides-Extra: docs
|
|
17
18
|
Requires-Dist: asyncssh (>=2.0)
|
|
@@ -32,6 +32,9 @@ localhost_shortname = socket.gethostname().split('.', 1)[0]
|
|
|
32
32
|
SLURM_HOST = 'slurm'
|
|
33
33
|
SLURM_OPTIONS_ENV = 'LIBPIPE_SLURM_OPTIONS'
|
|
34
34
|
SLURM_MARKER = '[libpipe] slurm job '
|
|
35
|
+
# Lines of srun and slurmstepd naming a node that failed, or the node of a step it ended
|
|
36
|
+
NODE_FAILURE_RE = re.compile(r'Node failure on (\S+)'
|
|
37
|
+
r'|\bON (\S+) CANCELLED AT \S+ DUE TO NODE FAILURE')
|
|
35
38
|
|
|
36
39
|
# CPUs and memory of the task categories, used when the tasks run as job steps of an allocation;
|
|
37
40
|
# the categories other than heavy are capped at its values
|
|
@@ -47,10 +50,26 @@ THREAD_VARS = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')
|
|
|
47
50
|
ALLOC_IDLE_MINUTES = 10
|
|
48
51
|
# Allocations requested in a row before giving up, when they end before running
|
|
49
52
|
ALLOC_MAX_REQUESTS = 3
|
|
53
|
+
# Launches of a task in an allocation, when srun ends before the command starts (the allocation
|
|
54
|
+
# ended, or is being replaced)
|
|
55
|
+
ALLOC_MAX_LAUNCHES = 3
|
|
50
56
|
# Workers per node of an allocation: Slurm holds the steps that do not fit yet
|
|
51
57
|
ALLOC_WORKERS_PER_NODE = 32
|
|
52
58
|
|
|
53
59
|
|
|
60
|
+
def now_str():
|
|
61
|
+
return datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def note_node_failure(task, line):
|
|
65
|
+
'''Record on task the node named by a node-failure line (bytes) of srun or slurmstepd.'''
|
|
66
|
+
if b'Node failure' not in line and b'NODE FAILURE' not in line:
|
|
67
|
+
return
|
|
68
|
+
m = NODE_FAILURE_RE.search(line.decode(errors='replace'))
|
|
69
|
+
if m:
|
|
70
|
+
task.failed_nodes.add(m.group(1) or m.group(2))
|
|
71
|
+
|
|
72
|
+
|
|
54
73
|
def mem_mb(text):
|
|
55
74
|
'''Memory such as 40GB, 40G or 512MB in MB (GB is 1024 MB, as in Slurm).'''
|
|
56
75
|
m = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?\s*', str(text), re.IGNORECASE)
|
|
@@ -191,6 +210,8 @@ class Task:
|
|
|
191
210
|
self.start_time = None
|
|
192
211
|
self.sigterm_time = None
|
|
193
212
|
self.slurm_job_id = None
|
|
213
|
+
# Slurm nodes whose failure stopped this task
|
|
214
|
+
self.failed_nodes = set()
|
|
194
215
|
# Resources of a job step, and the usage reported by libpipe.taskrun
|
|
195
216
|
self.category = category
|
|
196
217
|
self.label = label or category
|
|
@@ -259,7 +280,14 @@ class Task:
|
|
|
259
280
|
line = style(line, fg='red')
|
|
260
281
|
print(f'[{self.name}:{host}] {line}')
|
|
261
282
|
|
|
262
|
-
|
|
283
|
+
def log_event(self, text):
|
|
284
|
+
"""Write a timestamped libpipe event to the log file only."""
|
|
285
|
+
if self.fd is not None:
|
|
286
|
+
self.fd.write(f'[libpipe] {now_str()} {text}\n')
|
|
287
|
+
|
|
288
|
+
async def log_stream(self, stream, host, err=False, stamp_prefix=None, on_line=None):
|
|
289
|
+
"""Log the lines of stream; those starting with stamp_prefix get the current time.
|
|
290
|
+
on_line is called with each line (bytes)."""
|
|
263
291
|
while True:
|
|
264
292
|
try:
|
|
265
293
|
line = await stream.readline()
|
|
@@ -268,6 +296,10 @@ class Task:
|
|
|
268
296
|
continue
|
|
269
297
|
if not line:
|
|
270
298
|
break
|
|
299
|
+
if on_line is not None:
|
|
300
|
+
on_line(line)
|
|
301
|
+
if stamp_prefix and line.startswith(stamp_prefix):
|
|
302
|
+
line = f'{now_str()} '.encode() + line
|
|
271
303
|
self.log(line, host, err=err)
|
|
272
304
|
|
|
273
305
|
def close_log(self):
|
|
@@ -321,6 +353,8 @@ class Task:
|
|
|
321
353
|
break
|
|
322
354
|
|
|
323
355
|
self.status = TaskStatus.ERROR
|
|
356
|
+
if getattr(worker.client, 'alloc_refused', None):
|
|
357
|
+
break
|
|
324
358
|
if not ret_status and self.process is not None:
|
|
325
359
|
# The process had started and may still be running: running the task
|
|
326
360
|
# again could have two copies write the same outputs
|
|
@@ -516,6 +550,14 @@ class LocalClient:
|
|
|
516
550
|
pass
|
|
517
551
|
|
|
518
552
|
|
|
553
|
+
class AllocationRefused(RuntimeError):
|
|
554
|
+
"""Slurm refused the allocation request (account, partition, QOS, ...): not retried."""
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# sbatch errors worth another request; any other rejection is a configuration error
|
|
558
|
+
SBATCH_TRANSIENT = ('timed out', 'temporarily', 'Unable to contact', 'Connection refused')
|
|
559
|
+
|
|
560
|
+
|
|
519
561
|
class SlurmClient:
|
|
520
562
|
"""Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
|
|
521
563
|
job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes, the client
|
|
@@ -540,6 +582,7 @@ class SlurmClient:
|
|
|
540
582
|
self.steps = False
|
|
541
583
|
self.alloc_nodes = alloc_nodes
|
|
542
584
|
self.alloc_id = None
|
|
585
|
+
self.alloc_refused = None
|
|
543
586
|
self.alloc_checked = 0
|
|
544
587
|
self.alloc_lock = asyncio.Lock()
|
|
545
588
|
# CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
|
|
@@ -580,16 +623,21 @@ class SlurmClient:
|
|
|
580
623
|
self.cpus_changed.notify_all()
|
|
581
624
|
|
|
582
625
|
async def _acquire_cpus(self, cpus):
|
|
626
|
+
'''CPUs taken, and the job whose CPUs they are (None without CPU count).'''
|
|
583
627
|
if self.cpus_total is None:
|
|
584
|
-
return 0
|
|
585
|
-
cpus = min(cpus, self.cpus_total)
|
|
628
|
+
return 0, None
|
|
586
629
|
async with self.cpus_changed:
|
|
587
|
-
await self.cpus_changed.wait_for(
|
|
630
|
+
await self.cpus_changed.wait_for(
|
|
631
|
+
lambda: self.cpus_total is None or self.cpus_free >= min(cpus, self.cpus_total))
|
|
632
|
+
if self.cpus_total is None:
|
|
633
|
+
return 0, None
|
|
634
|
+
cpus = min(cpus, self.cpus_total)
|
|
588
635
|
self.cpus_free -= cpus
|
|
589
|
-
|
|
636
|
+
return cpus, self.cpus_of
|
|
590
637
|
|
|
591
|
-
async def _release_cpus(self, cpus):
|
|
592
|
-
|
|
638
|
+
async def _release_cpus(self, cpus, job_id):
|
|
639
|
+
# CPUs of an allocation that has been replaced are not those of the new one
|
|
640
|
+
if cpus and self.cpus_total is not None and job_id == self.cpus_of:
|
|
593
641
|
async with self.cpus_changed:
|
|
594
642
|
self.cpus_free = min(self.cpus_total, self.cpus_free + cpus)
|
|
595
643
|
self.cpus_changed.notify_all()
|
|
@@ -610,23 +658,22 @@ class SlurmClient:
|
|
|
610
658
|
has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in options)
|
|
611
659
|
return [f'--time={int(-(-self.max_time // 60))}'] if self.max_time and not has_time else []
|
|
612
660
|
|
|
613
|
-
def srun_command(self, task):
|
|
661
|
+
def srun_command(self, task, alloc_id=None):
|
|
614
662
|
args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
|
|
615
663
|
'--unbuffered']
|
|
616
664
|
command, job_id, options = task.command, '${SLURM_JOB_ID}', self.options
|
|
617
665
|
if self.steps:
|
|
618
|
-
if
|
|
619
|
-
args.append(f'--jobid={
|
|
666
|
+
if alloc_id:
|
|
667
|
+
args.append(f'--jobid={alloc_id}')
|
|
620
668
|
if self.exact:
|
|
621
|
-
|
|
622
|
-
args += ['--exact', '--quiet']
|
|
669
|
+
args.append('--exact')
|
|
623
670
|
if task.cpus:
|
|
624
671
|
args += [f'--cpus-per-task={task.cpus}', f'--mem={task.mem_mb}M']
|
|
625
672
|
threads = ' '.join(f'{v}={task.cpus}' for v in THREAD_VARS)
|
|
626
673
|
command = f'export {threads}; {command}'
|
|
627
674
|
job_id, options = '${SLURM_JOB_ID}.${SLURM_STEP_ID}', []
|
|
628
675
|
args += self._time_option(options) + options
|
|
629
|
-
marker = f'echo "{SLURM_MARKER}{job_id} on $(hostname -s)"'
|
|
676
|
+
marker = f'echo "{SLURM_MARKER}{job_id} on $(hostname -s) at $(date \'+%F %T.%3N\')"'
|
|
630
677
|
return f'{shlex.join(args)} bash -c {shlex.quote(f"{marker}; {command}")}'
|
|
631
678
|
|
|
632
679
|
async def _run(self, *args):
|
|
@@ -635,13 +682,18 @@ class SlurmClient:
|
|
|
635
682
|
out, err = await process.communicate()
|
|
636
683
|
return process.returncode, out.decode().strip(), err.decode().strip()
|
|
637
684
|
|
|
638
|
-
async def ensure_allocation(self):
|
|
639
|
-
"""Request the allocation, or a new one when it has ended,
|
|
685
|
+
async def ensure_allocation(self, check=False):
|
|
686
|
+
"""Request the allocation, or a new one when it has ended, wait for it to run and return
|
|
687
|
+
its id. check: ask Slurm for its state even if it was checked in the last 30 s."""
|
|
640
688
|
async with self.alloc_lock:
|
|
689
|
+
if self.alloc_refused:
|
|
690
|
+
raise AllocationRefused(self.alloc_refused)
|
|
691
|
+
if check:
|
|
692
|
+
self.alloc_checked = 0
|
|
641
693
|
n_requested = 0
|
|
642
694
|
while True:
|
|
643
695
|
if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
|
|
644
|
-
return
|
|
696
|
+
return self.alloc_id
|
|
645
697
|
if self.alloc_id is None:
|
|
646
698
|
if n_requested == ALLOC_MAX_REQUESTS:
|
|
647
699
|
raise RuntimeError(f'{n_requested} allocations ended before running')
|
|
@@ -651,7 +703,7 @@ class SlurmClient:
|
|
|
651
703
|
if state == 'RUNNING':
|
|
652
704
|
self.alloc_checked = time.monotonic()
|
|
653
705
|
await self._set_cpus(self.alloc_id)
|
|
654
|
-
return
|
|
706
|
+
return self.alloc_id
|
|
655
707
|
if state not in ('PENDING', 'CONFIGURING'):
|
|
656
708
|
print(f'[{self.job_name}] allocation {self.alloc_id} ended '
|
|
657
709
|
f'({state or "gone"}): requesting a new one')
|
|
@@ -669,11 +721,17 @@ class SlurmClient:
|
|
|
669
721
|
if not time_option and not any(o == '-t' or o.startswith(('-t', '--time'))
|
|
670
722
|
for o in self.options):
|
|
671
723
|
time_option = ['--time=24:00:00']
|
|
672
|
-
|
|
724
|
+
# --no-kill: a node that fails ends only the steps on it, not the allocation
|
|
725
|
+
args = ['sbatch', '--parsable', f'--nodes={self.alloc_nodes}', '--exclusive', '--no-kill',
|
|
673
726
|
f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
|
|
674
727
|
*self.options, '--wrap', holder]
|
|
675
728
|
returncode, out, err = await self._run(*args)
|
|
676
729
|
if returncode:
|
|
730
|
+
if not any(t in err for t in SBATCH_TRANSIENT):
|
|
731
|
+
# Every task would ask again and be refused the same way: say it once
|
|
732
|
+
self.alloc_refused = f'Slurm refused the allocation: {err}'
|
|
733
|
+
print(style(f'[{self.job_name}] {self.alloc_refused}; no task is run', fg='red'))
|
|
734
|
+
raise AllocationRefused(self.alloc_refused)
|
|
677
735
|
raise RuntimeError(f'Requesting the allocation failed: {err}')
|
|
678
736
|
job_id = out.split(';')[0]
|
|
679
737
|
print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes} node(s): the tasks '
|
|
@@ -694,28 +752,57 @@ class SlurmClient:
|
|
|
694
752
|
if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
|
|
695
753
|
task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
|
|
696
754
|
task.start_time = time.time()
|
|
755
|
+
note_node_failure(task, line)
|
|
697
756
|
task.log(line, self.host)
|
|
698
757
|
|
|
699
758
|
async def execute(self, task):
|
|
759
|
+
for launch in range(1, ALLOC_MAX_LAUNCHES + 1):
|
|
760
|
+
await self._launch(task, check=launch > 1)
|
|
761
|
+
# Without the marker line, the command did not start: in an allocation, srun ended
|
|
762
|
+
# because the allocation did (or a new one is not running yet), and the task can run
|
|
763
|
+
# on the next one
|
|
764
|
+
if (not self.alloc_nodes or task.slurm_job_id is not None or task.returncode == 0
|
|
765
|
+
or task.sigterm_time is not None or launch == ALLOC_MAX_LAUNCHES):
|
|
766
|
+
return
|
|
767
|
+
task.log_event(f'the command did not start: launching it again '
|
|
768
|
+
f'({launch + 1}/{ALLOC_MAX_LAUNCHES})')
|
|
769
|
+
|
|
770
|
+
async def _launch(self, task, check=False):
|
|
700
771
|
task.slurm_job_id = None
|
|
701
|
-
if self.alloc_nodes
|
|
702
|
-
|
|
703
|
-
|
|
772
|
+
alloc_id = await self.ensure_allocation(check) if self.alloc_nodes else None
|
|
773
|
+
cpus, cpus_of = 0, None
|
|
774
|
+
if self.steps and self.cpus_total is not None:
|
|
775
|
+
task.log_event(f'waiting for {min(task.cpus or 1, self.cpus_total)} CPUs '
|
|
776
|
+
f'({self.cpus_free} of {self.cpus_total} free)')
|
|
777
|
+
cpus, cpus_of = await self._acquire_cpus(task.cpus or 1)
|
|
778
|
+
if self.alloc_nodes:
|
|
779
|
+
# The allocation may have been replaced while waiting: the CPUs are of the new one
|
|
780
|
+
alloc_id = cpus_of or alloc_id
|
|
704
781
|
try:
|
|
705
782
|
process = await asyncio.create_subprocess_shell(
|
|
706
|
-
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
783
|
+
self.srun_command(task, alloc_id), stdout=asyncio.subprocess.PIPE,
|
|
707
784
|
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
|
|
785
|
+
task.log_event(f'srun started{self._cpus_state()}')
|
|
708
786
|
task.set_process(Process(self, process))
|
|
709
787
|
# srun waits for the job or the step to start: running from the marker line
|
|
710
788
|
task.start_time = None
|
|
711
789
|
|
|
712
790
|
await asyncio.gather(
|
|
713
791
|
self._log_stdout(task, process.stdout),
|
|
714
|
-
task.log_stream(process.stderr, self.host, err=True
|
|
792
|
+
task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: ',
|
|
793
|
+
on_line=lambda line: note_node_failure(task, line)),
|
|
715
794
|
)
|
|
716
795
|
task.returncode = await process.wait()
|
|
796
|
+
task.log_event(f'srun exited with code {task.returncode}')
|
|
717
797
|
finally:
|
|
718
|
-
await self._release_cpus(cpus)
|
|
798
|
+
await self._release_cpus(cpus, cpus_of)
|
|
799
|
+
if cpus:
|
|
800
|
+
task.log_event(f'{cpus} CPUs released{self._cpus_state()}')
|
|
801
|
+
|
|
802
|
+
def _cpus_state(self):
|
|
803
|
+
if self.cpus_total is None:
|
|
804
|
+
return ''
|
|
805
|
+
return f' ({self.cpus_free} of {self.cpus_total} CPUs free)'
|
|
719
806
|
|
|
720
807
|
async def terminate(self, task, sig=signal.SIGTERM):
|
|
721
808
|
job_id = getattr(task, 'slurm_job_id', None)
|
|
@@ -777,6 +864,11 @@ class Worker:
|
|
|
777
864
|
except asyncio.CancelledError:
|
|
778
865
|
self.status = WorkerStatus.IDLE
|
|
779
866
|
raise
|
|
867
|
+
except AllocationRefused as exc:
|
|
868
|
+
# Said once on the console by the client: in the task log only
|
|
869
|
+
task.log_event(f'not run: {exc}')
|
|
870
|
+
self.status = WorkerStatus.IDLE
|
|
871
|
+
return False, None
|
|
780
872
|
except Exception as exc:
|
|
781
873
|
if isinstance(exc, ConnectionError):
|
|
782
874
|
err_msg = f'Error connecting client: {exc!s}\n'
|
|
@@ -873,6 +965,22 @@ class WorkerPoolReport:
|
|
|
873
965
|
for label, peak in sorted(peaks.items())
|
|
874
966
|
for category, mem in [requests[label]] if peak > mem]
|
|
875
967
|
|
|
968
|
+
def node_failure_lines(self):
|
|
969
|
+
'''One line per Slurm node whose failure stopped tasks, with these tasks.'''
|
|
970
|
+
tasks = collections.defaultdict(list)
|
|
971
|
+
for t in self.all_tasks:
|
|
972
|
+
for node in sorted(getattr(t, 'failed_nodes', ())):
|
|
973
|
+
tasks[node].append(t.name)
|
|
974
|
+
return [f'{node}: {len(names)} task(s) stopped ({", ".join(names)})'
|
|
975
|
+
for node, names in sorted(tasks.items())]
|
|
976
|
+
|
|
977
|
+
def print_node_failures(self):
|
|
978
|
+
lines = self.node_failure_lines()
|
|
979
|
+
if lines:
|
|
980
|
+
secho('\nSlurm node failures (run these tasks again):', fg='red')
|
|
981
|
+
print('\n'.join(lines))
|
|
982
|
+
print()
|
|
983
|
+
|
|
876
984
|
def print_usage(self):
|
|
877
985
|
usage = self.usage_lines()
|
|
878
986
|
if not usage:
|
|
@@ -1213,6 +1321,7 @@ class WorkerPool:
|
|
|
1213
1321
|
report = self.last_report
|
|
1214
1322
|
# At the end of the pool, before the next tasks fill the console
|
|
1215
1323
|
report.print_usage()
|
|
1324
|
+
report.print_node_failures()
|
|
1216
1325
|
|
|
1217
1326
|
if print_summary:
|
|
1218
1327
|
print("\n=== WorkerPool summary ===")
|
|
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
|
|
File without changes
|
|
File without changes
|