libpipe 0.10__tar.gz → 0.12__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.10 → libpipe-0.12}/PKG-INFO +3 -2
- {libpipe-0.10 → libpipe-0.12}/README.md +2 -1
- {libpipe-0.10 → libpipe-0.12}/libpipe/worker.py +177 -48
- {libpipe-0.10 → libpipe-0.12}/pyproject.toml +1 -1
- {libpipe-0.10 → libpipe-0.12}/LICENSE +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/__init__.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/attrmap.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/futils.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/msutils.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/settings.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/taskrun.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.10 → libpipe-0.12}/libpipe/tools/nodetool.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: libpipe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12
|
|
4
4
|
Summary: Pipeline orchestration and node management utilities
|
|
5
5
|
License-Expression: GPL-3.0-or-later
|
|
6
6
|
License-File: LICENSE
|
|
@@ -64,7 +64,8 @@ on a file system shared with the compute nodes.
|
|
|
64
64
|
|
|
65
65
|
With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
|
|
66
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.
|
|
67
|
+
releases it at the end, or after 10 minutes without step. `slurm_nodes='2-10'` starts it as
|
|
68
|
+
soon as 2 nodes are free, with up to 10 of them, as many as free at that moment. Slurm packs the steps on the nodes
|
|
68
69
|
from their CPUs and memory, given by the category of the task; a step starts when the
|
|
69
70
|
allocation has its CPUs free, so that no srun waits polling the controller:
|
|
70
71
|
|
|
@@ -30,7 +30,8 @@ on a file system shared with the compute nodes.
|
|
|
30
30
|
|
|
31
31
|
With `slurm_nodes`, a pool on a login node requests one allocation of up to that many nodes
|
|
32
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.
|
|
33
|
+
releases it at the end, or after 10 minutes without step. `slurm_nodes='2-10'` starts it as
|
|
34
|
+
soon as 2 nodes are free, with up to 10 of them, as many as free at that moment. Slurm packs the steps on the nodes
|
|
34
35
|
from their CPUs and memory, given by the category of the task; a step starts when the
|
|
35
36
|
allocation has its CPUs free, so that no srun waits polling the controller:
|
|
36
37
|
|
|
@@ -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
|
|
@@ -45,16 +48,54 @@ DEFAULT_RESOURCES = {
|
|
|
45
48
|
THREAD_VARS = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')
|
|
46
49
|
# The holding job of an allocation ends after this many minutes without job step
|
|
47
50
|
ALLOC_IDLE_MINUTES = 10
|
|
48
|
-
#
|
|
49
|
-
|
|
51
|
+
# Final states of an allocation that stop the pool instead of requesting another one: each new
|
|
52
|
+
# allocation would land on other nodes, and suspend or end other jobs, for nothing
|
|
53
|
+
ALLOC_FAILED_STATES = ('NODE_FAIL', 'BOOT_FAIL', 'FAILED', 'OUT_OF_MEMORY')
|
|
54
|
+
# Launches of a task in an allocation, when srun ends before the command starts (the allocation
|
|
55
|
+
# ended, or is being replaced)
|
|
56
|
+
ALLOC_MAX_LAUNCHES = 3
|
|
50
57
|
# Workers per node of an allocation: Slurm holds the steps that do not fit yet
|
|
51
58
|
ALLOC_WORKERS_PER_NODE = 32
|
|
52
59
|
|
|
53
60
|
|
|
61
|
+
def node_range(slurm_nodes):
|
|
62
|
+
"""(min, max) nodes of an allocation from SLURM_NODES: N, or 'MIN-MAX' to start it once MIN
|
|
63
|
+
nodes are free, with up to MAX of them; None without (None, 0)."""
|
|
64
|
+
if slurm_nodes is None or slurm_nodes == 0:
|
|
65
|
+
return None
|
|
66
|
+
if isinstance(slurm_nodes, tuple):
|
|
67
|
+
lo, hi = slurm_nodes
|
|
68
|
+
elif isinstance(slurm_nodes, int) and not isinstance(slurm_nodes, bool):
|
|
69
|
+
lo = hi = slurm_nodes
|
|
70
|
+
elif isinstance(slurm_nodes, str) and re.fullmatch(r'\d+(-\d+)?', slurm_nodes.strip()):
|
|
71
|
+
lo, _, hi = slurm_nodes.strip().partition('-')
|
|
72
|
+
lo, hi = int(lo), int(hi or lo)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f'slurm_nodes: {slurm_nodes!r} is neither a number of nodes nor MIN-MAX')
|
|
75
|
+
if not 1 <= lo <= hi:
|
|
76
|
+
raise ValueError(f'slurm_nodes: {slurm_nodes!r}, MIN-MAX needs 1 <= MIN <= MAX')
|
|
77
|
+
return lo, hi
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def nodes_option(nodes):
|
|
81
|
+
"""The --nodes value of an allocation of (min, max) nodes."""
|
|
82
|
+
lo, hi = nodes
|
|
83
|
+
return f'{lo}-{hi}' if lo < hi else str(hi)
|
|
84
|
+
|
|
85
|
+
|
|
54
86
|
def now_str():
|
|
55
87
|
return datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
|
|
56
88
|
|
|
57
89
|
|
|
90
|
+
def note_node_failure(task, line):
|
|
91
|
+
'''Record on task the node named by a node-failure line (bytes) of srun or slurmstepd.'''
|
|
92
|
+
if b'Node failure' not in line and b'NODE FAILURE' not in line:
|
|
93
|
+
return
|
|
94
|
+
m = NODE_FAILURE_RE.search(line.decode(errors='replace'))
|
|
95
|
+
if m:
|
|
96
|
+
task.failed_nodes.add(m.group(1) or m.group(2))
|
|
97
|
+
|
|
98
|
+
|
|
58
99
|
def mem_mb(text):
|
|
59
100
|
'''Memory such as 40GB, 40G or 512MB in MB (GB is 1024 MB, as in Slurm).'''
|
|
60
101
|
m = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?\s*', str(text), re.IGNORECASE)
|
|
@@ -195,6 +236,8 @@ class Task:
|
|
|
195
236
|
self.start_time = None
|
|
196
237
|
self.sigterm_time = None
|
|
197
238
|
self.slurm_job_id = None
|
|
239
|
+
# Slurm nodes whose failure stopped this task
|
|
240
|
+
self.failed_nodes = set()
|
|
198
241
|
# Resources of a job step, and the usage reported by libpipe.taskrun
|
|
199
242
|
self.category = category
|
|
200
243
|
self.label = label or category
|
|
@@ -268,8 +311,9 @@ class Task:
|
|
|
268
311
|
if self.fd is not None:
|
|
269
312
|
self.fd.write(f'[libpipe] {now_str()} {text}\n')
|
|
270
313
|
|
|
271
|
-
async def log_stream(self, stream, host, err=False, stamp_prefix=None):
|
|
272
|
-
"""Log the lines of stream; those starting with stamp_prefix get the current time.
|
|
314
|
+
async def log_stream(self, stream, host, err=False, stamp_prefix=None, on_line=None):
|
|
315
|
+
"""Log the lines of stream; those starting with stamp_prefix get the current time.
|
|
316
|
+
on_line is called with each line (bytes)."""
|
|
273
317
|
while True:
|
|
274
318
|
try:
|
|
275
319
|
line = await stream.readline()
|
|
@@ -278,6 +322,8 @@ class Task:
|
|
|
278
322
|
continue
|
|
279
323
|
if not line:
|
|
280
324
|
break
|
|
325
|
+
if on_line is not None:
|
|
326
|
+
on_line(line)
|
|
281
327
|
if stamp_prefix and line.startswith(stamp_prefix):
|
|
282
328
|
line = f'{now_str()} '.encode() + line
|
|
283
329
|
self.log(line, host, err=err)
|
|
@@ -333,6 +379,8 @@ class Task:
|
|
|
333
379
|
break
|
|
334
380
|
|
|
335
381
|
self.status = TaskStatus.ERROR
|
|
382
|
+
if getattr(worker.client, 'alloc_refused', None):
|
|
383
|
+
break
|
|
336
384
|
if not ret_status and self.process is not None:
|
|
337
385
|
# The process had started and may still be running: running the task
|
|
338
386
|
# again could have two copies write the same outputs
|
|
@@ -528,12 +576,20 @@ class LocalClient:
|
|
|
528
576
|
pass
|
|
529
577
|
|
|
530
578
|
|
|
579
|
+
class AllocationRefused(RuntimeError):
|
|
580
|
+
"""Slurm refused the allocation request (account, partition, QOS, ...): not retried."""
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# sbatch errors worth another request; any other rejection is a configuration error
|
|
584
|
+
SBATCH_TRANSIENT = ('timed out', 'temporarily', 'Unable to contact', 'Connection refused')
|
|
585
|
+
|
|
586
|
+
|
|
531
587
|
class SlurmClient:
|
|
532
588
|
"""Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
|
|
533
|
-
job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes
|
|
534
|
-
requests an allocation of that many nodes and runs
|
|
535
|
-
its nodes from their CPUs and memory; the allocation
|
|
536
|
-
ALLOC_IDLE_MINUTES without step.
|
|
589
|
+
job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes (N or
|
|
590
|
+
'MIN-MAX', see node_range), the client requests an allocation of that many nodes and runs
|
|
591
|
+
the tasks as job steps in it, packed on its nodes from their CPUs and memory; the allocation
|
|
592
|
+
ends with the pool, or after ALLOC_IDLE_MINUTES without step.
|
|
537
593
|
|
|
538
594
|
Options (partition, account, ...) come from slurm_options, or from the LIBPIPE_SLURM_OPTIONS
|
|
539
595
|
environment variable; they apply to the jobs and the allocation, not to the job steps.
|
|
@@ -550,8 +606,10 @@ class SlurmClient:
|
|
|
550
606
|
self.max_time = max_time
|
|
551
607
|
self.exact = False
|
|
552
608
|
self.steps = False
|
|
553
|
-
self.alloc_nodes = alloc_nodes
|
|
609
|
+
self.alloc_nodes = node_range(alloc_nodes)
|
|
554
610
|
self.alloc_id = None
|
|
611
|
+
self.alloc_refused = None
|
|
612
|
+
self.alloc_ran = False
|
|
555
613
|
self.alloc_checked = 0
|
|
556
614
|
self.alloc_lock = asyncio.Lock()
|
|
557
615
|
# CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
|
|
@@ -592,16 +650,21 @@ class SlurmClient:
|
|
|
592
650
|
self.cpus_changed.notify_all()
|
|
593
651
|
|
|
594
652
|
async def _acquire_cpus(self, cpus):
|
|
653
|
+
'''CPUs taken, and the job whose CPUs they are (None without CPU count).'''
|
|
595
654
|
if self.cpus_total is None:
|
|
596
|
-
return 0
|
|
597
|
-
cpus = min(cpus, self.cpus_total)
|
|
655
|
+
return 0, None
|
|
598
656
|
async with self.cpus_changed:
|
|
599
|
-
await self.cpus_changed.wait_for(
|
|
657
|
+
await self.cpus_changed.wait_for(
|
|
658
|
+
lambda: self.cpus_total is None or self.cpus_free >= min(cpus, self.cpus_total))
|
|
659
|
+
if self.cpus_total is None:
|
|
660
|
+
return 0, None
|
|
661
|
+
cpus = min(cpus, self.cpus_total)
|
|
600
662
|
self.cpus_free -= cpus
|
|
601
|
-
|
|
663
|
+
return cpus, self.cpus_of
|
|
602
664
|
|
|
603
|
-
async def _release_cpus(self, cpus):
|
|
604
|
-
|
|
665
|
+
async def _release_cpus(self, cpus, job_id):
|
|
666
|
+
# CPUs of an allocation that has been replaced are not those of the new one
|
|
667
|
+
if cpus and self.cpus_total is not None and job_id == self.cpus_of:
|
|
605
668
|
async with self.cpus_changed:
|
|
606
669
|
self.cpus_free = min(self.cpus_total, self.cpus_free + cpus)
|
|
607
670
|
self.cpus_changed.notify_all()
|
|
@@ -622,13 +685,13 @@ class SlurmClient:
|
|
|
622
685
|
has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in options)
|
|
623
686
|
return [f'--time={int(-(-self.max_time // 60))}'] if self.max_time and not has_time else []
|
|
624
687
|
|
|
625
|
-
def srun_command(self, task):
|
|
688
|
+
def srun_command(self, task, alloc_id=None):
|
|
626
689
|
args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
|
|
627
690
|
'--unbuffered']
|
|
628
691
|
command, job_id, options = task.command, '${SLURM_JOB_ID}', self.options
|
|
629
692
|
if self.steps:
|
|
630
|
-
if
|
|
631
|
-
args.append(f'--jobid={
|
|
693
|
+
if alloc_id:
|
|
694
|
+
args.append(f'--jobid={alloc_id}')
|
|
632
695
|
if self.exact:
|
|
633
696
|
args.append('--exact')
|
|
634
697
|
if task.cpus:
|
|
@@ -646,29 +709,48 @@ class SlurmClient:
|
|
|
646
709
|
out, err = await process.communicate()
|
|
647
710
|
return process.returncode, out.decode().strip(), err.decode().strip()
|
|
648
711
|
|
|
649
|
-
async def ensure_allocation(self):
|
|
650
|
-
"""Request the allocation, or a new one when it has ended
|
|
712
|
+
async def ensure_allocation(self, check=False):
|
|
713
|
+
"""Request the allocation, or a new one when it has ended after running normally (time
|
|
714
|
+
limit, idle), wait for it to run and return its id. An allocation that ends before
|
|
715
|
+
running, or in a failed state (node failure, ...), stops the pool. check: ask Slurm for
|
|
716
|
+
its state even if it was checked in the last 30 s."""
|
|
651
717
|
async with self.alloc_lock:
|
|
652
|
-
|
|
718
|
+
if self.alloc_refused:
|
|
719
|
+
raise AllocationRefused(self.alloc_refused)
|
|
720
|
+
if check:
|
|
721
|
+
self.alloc_checked = 0
|
|
653
722
|
while True:
|
|
654
723
|
if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
|
|
655
|
-
return
|
|
724
|
+
return self.alloc_id
|
|
656
725
|
if self.alloc_id is None:
|
|
657
|
-
if n_requested == ALLOC_MAX_REQUESTS:
|
|
658
|
-
raise RuntimeError(f'{n_requested} allocations ended before running')
|
|
659
726
|
self.alloc_id = await self._submit_allocation()
|
|
660
|
-
|
|
727
|
+
self.alloc_ran = False
|
|
661
728
|
_, state, _ = await self._run('squeue', '-h', '-j', self.alloc_id, '-o', '%T')
|
|
662
729
|
if state == 'RUNNING':
|
|
663
730
|
self.alloc_checked = time.monotonic()
|
|
731
|
+
self.alloc_ran = True
|
|
664
732
|
await self._set_cpus(self.alloc_id)
|
|
665
|
-
return
|
|
666
|
-
if state
|
|
667
|
-
|
|
668
|
-
f'({state or "gone"}): requesting a new one')
|
|
669
|
-
self.alloc_id = None
|
|
733
|
+
return self.alloc_id
|
|
734
|
+
if state in ('PENDING', 'CONFIGURING'):
|
|
735
|
+
await asyncio.sleep(5)
|
|
670
736
|
continue
|
|
671
|
-
await
|
|
737
|
+
final = await self._final_state(self.alloc_id) or state or 'gone'
|
|
738
|
+
if final.split()[0] in ALLOC_FAILED_STATES or not self.alloc_ran:
|
|
739
|
+
when = '' if self.alloc_ran else ' before running'
|
|
740
|
+
self.alloc_refused = (f'allocation {self.alloc_id} ended{when} ({final}): no '
|
|
741
|
+
f'new allocation is requested; check the nodes and the '
|
|
742
|
+
f'partition (sacct -j {self.alloc_id})')
|
|
743
|
+
print(style(f'[{self.job_name}] {self.alloc_refused}', fg='red'))
|
|
744
|
+
raise AllocationRefused(self.alloc_refused)
|
|
745
|
+
print(f'[{self.job_name}] allocation {self.alloc_id} ended ({final}): '
|
|
746
|
+
'requesting a new one')
|
|
747
|
+
self.alloc_id = None
|
|
748
|
+
|
|
749
|
+
async def _final_state(self, job_id):
|
|
750
|
+
"""State of an ended job from sacct, e.g. NODE_FAIL or COMPLETED; '' if unknown."""
|
|
751
|
+
returncode, out, _ = await self._run('sacct', '-n', '-X', '-P', '-j', job_id, '-o',
|
|
752
|
+
'State')
|
|
753
|
+
return out.splitlines()[0].strip() if returncode == 0 and out.strip() else ''
|
|
672
754
|
|
|
673
755
|
async def _submit_allocation(self):
|
|
674
756
|
# The holding job: ends after ALLOC_IDLE_MINUTES without step (other than its own)
|
|
@@ -680,15 +762,22 @@ class SlurmClient:
|
|
|
680
762
|
if not time_option and not any(o == '-t' or o.startswith(('-t', '--time'))
|
|
681
763
|
for o in self.options):
|
|
682
764
|
time_option = ['--time=24:00:00']
|
|
683
|
-
|
|
765
|
+
# --no-kill: a node that fails ends only the steps on it, not the allocation
|
|
766
|
+
args = ['sbatch', '--parsable', f'--nodes={nodes_option(self.alloc_nodes)}', '--exclusive',
|
|
767
|
+
'--no-kill',
|
|
684
768
|
f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
|
|
685
769
|
*self.options, '--wrap', holder]
|
|
686
770
|
returncode, out, err = await self._run(*args)
|
|
687
771
|
if returncode:
|
|
772
|
+
if not any(t in err for t in SBATCH_TRANSIENT):
|
|
773
|
+
# Every task would ask again and be refused the same way: say it once
|
|
774
|
+
self.alloc_refused = f'Slurm refused the allocation: {err}'
|
|
775
|
+
print(style(f'[{self.job_name}] {self.alloc_refused}; no task is run', fg='red'))
|
|
776
|
+
raise AllocationRefused(self.alloc_refused)
|
|
688
777
|
raise RuntimeError(f'Requesting the allocation failed: {err}')
|
|
689
778
|
job_id = out.split(';')[0]
|
|
690
|
-
print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes}
|
|
691
|
-
'run as job steps')
|
|
779
|
+
print(f'[{self.job_name}] allocation {job_id} of {nodes_option(self.alloc_nodes)} '
|
|
780
|
+
'node(s): the tasks run as job steps')
|
|
692
781
|
return job_id
|
|
693
782
|
|
|
694
783
|
async def _log_stdout(self, task, stream):
|
|
@@ -705,20 +794,35 @@ class SlurmClient:
|
|
|
705
794
|
if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
|
|
706
795
|
task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
|
|
707
796
|
task.start_time = time.time()
|
|
797
|
+
note_node_failure(task, line)
|
|
708
798
|
task.log(line, self.host)
|
|
709
799
|
|
|
710
800
|
async def execute(self, task):
|
|
801
|
+
for launch in range(1, ALLOC_MAX_LAUNCHES + 1):
|
|
802
|
+
await self._launch(task, check=launch > 1)
|
|
803
|
+
# Without the marker line, the command did not start: in an allocation, srun ended
|
|
804
|
+
# because the allocation did (or a new one is not running yet), and the task can run
|
|
805
|
+
# on the next one
|
|
806
|
+
if (not self.alloc_nodes or task.slurm_job_id is not None or task.returncode == 0
|
|
807
|
+
or task.sigterm_time is not None or launch == ALLOC_MAX_LAUNCHES):
|
|
808
|
+
return
|
|
809
|
+
task.log_event(f'the command did not start: launching it again '
|
|
810
|
+
f'({launch + 1}/{ALLOC_MAX_LAUNCHES})')
|
|
811
|
+
|
|
812
|
+
async def _launch(self, task, check=False):
|
|
711
813
|
task.slurm_job_id = None
|
|
712
|
-
if self.alloc_nodes
|
|
713
|
-
|
|
714
|
-
cpus = 0
|
|
814
|
+
alloc_id = await self.ensure_allocation(check) if self.alloc_nodes else None
|
|
815
|
+
cpus, cpus_of = 0, None
|
|
715
816
|
if self.steps and self.cpus_total is not None:
|
|
716
817
|
task.log_event(f'waiting for {min(task.cpus or 1, self.cpus_total)} CPUs '
|
|
717
818
|
f'({self.cpus_free} of {self.cpus_total} free)')
|
|
718
|
-
cpus = await self._acquire_cpus(task.cpus or 1)
|
|
819
|
+
cpus, cpus_of = await self._acquire_cpus(task.cpus or 1)
|
|
820
|
+
if self.alloc_nodes:
|
|
821
|
+
# The allocation may have been replaced while waiting: the CPUs are of the new one
|
|
822
|
+
alloc_id = cpus_of or alloc_id
|
|
719
823
|
try:
|
|
720
824
|
process = await asyncio.create_subprocess_shell(
|
|
721
|
-
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
825
|
+
self.srun_command(task, alloc_id), stdout=asyncio.subprocess.PIPE,
|
|
722
826
|
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
|
|
723
827
|
task.log_event(f'srun started{self._cpus_state()}')
|
|
724
828
|
task.set_process(Process(self, process))
|
|
@@ -727,12 +831,13 @@ class SlurmClient:
|
|
|
727
831
|
|
|
728
832
|
await asyncio.gather(
|
|
729
833
|
self._log_stdout(task, process.stdout),
|
|
730
|
-
task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: '
|
|
834
|
+
task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: ',
|
|
835
|
+
on_line=lambda line: note_node_failure(task, line)),
|
|
731
836
|
)
|
|
732
837
|
task.returncode = await process.wait()
|
|
733
838
|
task.log_event(f'srun exited with code {task.returncode}')
|
|
734
839
|
finally:
|
|
735
|
-
await self._release_cpus(cpus)
|
|
840
|
+
await self._release_cpus(cpus, cpus_of)
|
|
736
841
|
if cpus:
|
|
737
842
|
task.log_event(f'{cpus} CPUs released{self._cpus_state()}')
|
|
738
843
|
|
|
@@ -801,6 +906,11 @@ class Worker:
|
|
|
801
906
|
except asyncio.CancelledError:
|
|
802
907
|
self.status = WorkerStatus.IDLE
|
|
803
908
|
raise
|
|
909
|
+
except AllocationRefused as exc:
|
|
910
|
+
# Said once on the console by the client: in the task log only
|
|
911
|
+
task.log_event(f'not run: {exc}')
|
|
912
|
+
self.status = WorkerStatus.IDLE
|
|
913
|
+
return False, None
|
|
804
914
|
except Exception as exc:
|
|
805
915
|
if isinstance(exc, ConnectionError):
|
|
806
916
|
err_msg = f'Error connecting client: {exc!s}\n'
|
|
@@ -897,6 +1007,22 @@ class WorkerPoolReport:
|
|
|
897
1007
|
for label, peak in sorted(peaks.items())
|
|
898
1008
|
for category, mem in [requests[label]] if peak > mem]
|
|
899
1009
|
|
|
1010
|
+
def node_failure_lines(self):
|
|
1011
|
+
'''One line per Slurm node whose failure stopped tasks, with these tasks.'''
|
|
1012
|
+
tasks = collections.defaultdict(list)
|
|
1013
|
+
for t in self.all_tasks:
|
|
1014
|
+
for node in sorted(getattr(t, 'failed_nodes', ())):
|
|
1015
|
+
tasks[node].append(t.name)
|
|
1016
|
+
return [f'{node}: {len(names)} task(s) stopped ({", ".join(names)})'
|
|
1017
|
+
for node, names in sorted(tasks.items())]
|
|
1018
|
+
|
|
1019
|
+
def print_node_failures(self):
|
|
1020
|
+
lines = self.node_failure_lines()
|
|
1021
|
+
if lines:
|
|
1022
|
+
secho('\nSlurm node failures (run these tasks again):', fg='red')
|
|
1023
|
+
print('\n'.join(lines))
|
|
1024
|
+
print()
|
|
1025
|
+
|
|
900
1026
|
def print_usage(self):
|
|
901
1027
|
usage = self.usage_lines()
|
|
902
1028
|
if not usage:
|
|
@@ -932,8 +1058,9 @@ class WorkerPool:
|
|
|
932
1058
|
'-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
|
|
933
1059
|
kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
|
|
934
1060
|
before SIGKILL.
|
|
935
|
-
slurm_nodes (int, optional): For 'slurm' from a login node: run the tasks as
|
|
936
|
-
steps of an allocation of up to that many nodes, instead of one job each
|
|
1061
|
+
slurm_nodes (int or str, optional): For 'slurm' from a login node: run the tasks as
|
|
1062
|
+
job steps of an allocation of up to that many nodes, instead of one job each;
|
|
1063
|
+
'MIN-MAX' starts it once MIN nodes are free, with up to MAX of them.
|
|
937
1064
|
resources (dict, optional): CPUs and memory of the task categories as job steps,
|
|
938
1065
|
{category: {'cpus': .., 'mem': ..}}, updating DEFAULT_RESOURCES.
|
|
939
1066
|
"""
|
|
@@ -960,7 +1087,7 @@ class WorkerPool:
|
|
|
960
1087
|
self._waiters = collections.deque()
|
|
961
1088
|
self._cooldown_timer = None
|
|
962
1089
|
self.resources = resolve_resources(resources)
|
|
963
|
-
self.slurm_nodes = slurm_nodes
|
|
1090
|
+
self.slurm_nodes = node_range(slurm_nodes)
|
|
964
1091
|
# Wall time, CPU time and peak memory of each task (libpipe.taskrun), with the Python
|
|
965
1092
|
# of the pool: on this machine or on the nodes of the cluster
|
|
966
1093
|
self.accounting = all(h in (localhost_shortname, 'localhost', SLURM_HOST) for h in hosts)
|
|
@@ -971,9 +1098,9 @@ class WorkerPool:
|
|
|
971
1098
|
client = LocalClient(localhost_shortname, force_sync=force_sync)
|
|
972
1099
|
elif host == SLURM_HOST:
|
|
973
1100
|
client = SlurmClient(slurm_options, job_name=name, max_time=max_time,
|
|
974
|
-
alloc_nodes=slurm_nodes)
|
|
975
|
-
if slurm_nodes:
|
|
976
|
-
n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * slurm_nodes)
|
|
1101
|
+
alloc_nodes=self.slurm_nodes)
|
|
1102
|
+
if self.slurm_nodes:
|
|
1103
|
+
n_workers = max(n_workers, ALLOC_WORKERS_PER_NODE * self.slurm_nodes[1])
|
|
977
1104
|
else:
|
|
978
1105
|
client = Client(host, user, force_sync=force_sync, password=password,
|
|
979
1106
|
connect_timeout=connect_timeout)
|
|
@@ -1192,7 +1319,8 @@ class WorkerPool:
|
|
|
1192
1319
|
# An allocation no larger than the number of tasks
|
|
1193
1320
|
for client in {w.client for w in self.workers}:
|
|
1194
1321
|
if isinstance(client, SlurmClient) and client.alloc_nodes:
|
|
1195
|
-
|
|
1322
|
+
hi = min(self.slurm_nodes[1], len(self.tasks))
|
|
1323
|
+
client.alloc_nodes = (min(self.slurm_nodes[0], hi), hi) if hi else None
|
|
1196
1324
|
|
|
1197
1325
|
self.futures = [asyncio.ensure_future(task.run(self)) for task in self.tasks]
|
|
1198
1326
|
self.futures.append(asyncio.ensure_future(self._monitor_queue()))
|
|
@@ -1237,6 +1365,7 @@ class WorkerPool:
|
|
|
1237
1365
|
report = self.last_report
|
|
1238
1366
|
# At the end of the pool, before the next tasks fill the console
|
|
1239
1367
|
report.print_usage()
|
|
1368
|
+
report.print_node_failures()
|
|
1240
1369
|
|
|
1241
1370
|
if print_summary:
|
|
1242
1371
|
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
|