libpipe 0.10__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.10 → libpipe-0.11}/PKG-INFO +1 -1
- {libpipe-0.10 → libpipe-0.11}/libpipe/worker.py +108 -23
- {libpipe-0.10 → libpipe-0.11}/pyproject.toml +1 -1
- {libpipe-0.10 → libpipe-0.11}/LICENSE +0 -0
- {libpipe-0.10 → libpipe-0.11}/README.md +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/__init__.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/attrmap.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/futils.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/msutils.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/settings.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/taskrun.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/tools/__init__.py +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/tools/libnodes_bash.sh +0 -0
- {libpipe-0.10 → libpipe-0.11}/libpipe/tools/nodetool.py +0 -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,6 +50,9 @@ 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
|
|
|
@@ -55,6 +61,15 @@ def now_str():
|
|
|
55
61
|
return datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
|
|
56
62
|
|
|
57
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
|
+
|
|
58
73
|
def mem_mb(text):
|
|
59
74
|
'''Memory such as 40GB, 40G or 512MB in MB (GB is 1024 MB, as in Slurm).'''
|
|
60
75
|
m = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([KMGT]?)(?:i?B)?\s*', str(text), re.IGNORECASE)
|
|
@@ -195,6 +210,8 @@ class Task:
|
|
|
195
210
|
self.start_time = None
|
|
196
211
|
self.sigterm_time = None
|
|
197
212
|
self.slurm_job_id = None
|
|
213
|
+
# Slurm nodes whose failure stopped this task
|
|
214
|
+
self.failed_nodes = set()
|
|
198
215
|
# Resources of a job step, and the usage reported by libpipe.taskrun
|
|
199
216
|
self.category = category
|
|
200
217
|
self.label = label or category
|
|
@@ -268,8 +285,9 @@ class Task:
|
|
|
268
285
|
if self.fd is not None:
|
|
269
286
|
self.fd.write(f'[libpipe] {now_str()} {text}\n')
|
|
270
287
|
|
|
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.
|
|
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)."""
|
|
273
291
|
while True:
|
|
274
292
|
try:
|
|
275
293
|
line = await stream.readline()
|
|
@@ -278,6 +296,8 @@ class Task:
|
|
|
278
296
|
continue
|
|
279
297
|
if not line:
|
|
280
298
|
break
|
|
299
|
+
if on_line is not None:
|
|
300
|
+
on_line(line)
|
|
281
301
|
if stamp_prefix and line.startswith(stamp_prefix):
|
|
282
302
|
line = f'{now_str()} '.encode() + line
|
|
283
303
|
self.log(line, host, err=err)
|
|
@@ -333,6 +353,8 @@ class Task:
|
|
|
333
353
|
break
|
|
334
354
|
|
|
335
355
|
self.status = TaskStatus.ERROR
|
|
356
|
+
if getattr(worker.client, 'alloc_refused', None):
|
|
357
|
+
break
|
|
336
358
|
if not ret_status and self.process is not None:
|
|
337
359
|
# The process had started and may still be running: running the task
|
|
338
360
|
# again could have two copies write the same outputs
|
|
@@ -528,6 +550,14 @@ class LocalClient:
|
|
|
528
550
|
pass
|
|
529
551
|
|
|
530
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
|
+
|
|
531
561
|
class SlurmClient:
|
|
532
562
|
"""Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
|
|
533
563
|
job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes, the client
|
|
@@ -552,6 +582,7 @@ class SlurmClient:
|
|
|
552
582
|
self.steps = False
|
|
553
583
|
self.alloc_nodes = alloc_nodes
|
|
554
584
|
self.alloc_id = None
|
|
585
|
+
self.alloc_refused = None
|
|
555
586
|
self.alloc_checked = 0
|
|
556
587
|
self.alloc_lock = asyncio.Lock()
|
|
557
588
|
# CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
|
|
@@ -592,16 +623,21 @@ class SlurmClient:
|
|
|
592
623
|
self.cpus_changed.notify_all()
|
|
593
624
|
|
|
594
625
|
async def _acquire_cpus(self, cpus):
|
|
626
|
+
'''CPUs taken, and the job whose CPUs they are (None without CPU count).'''
|
|
595
627
|
if self.cpus_total is None:
|
|
596
|
-
return 0
|
|
597
|
-
cpus = min(cpus, self.cpus_total)
|
|
628
|
+
return 0, None
|
|
598
629
|
async with self.cpus_changed:
|
|
599
|
-
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)
|
|
600
635
|
self.cpus_free -= cpus
|
|
601
|
-
|
|
636
|
+
return cpus, self.cpus_of
|
|
602
637
|
|
|
603
|
-
async def _release_cpus(self, cpus):
|
|
604
|
-
|
|
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:
|
|
605
641
|
async with self.cpus_changed:
|
|
606
642
|
self.cpus_free = min(self.cpus_total, self.cpus_free + cpus)
|
|
607
643
|
self.cpus_changed.notify_all()
|
|
@@ -622,13 +658,13 @@ class SlurmClient:
|
|
|
622
658
|
has_time = any(o == '-t' or o.startswith(('-t', '--time')) for o in options)
|
|
623
659
|
return [f'--time={int(-(-self.max_time // 60))}'] if self.max_time and not has_time else []
|
|
624
660
|
|
|
625
|
-
def srun_command(self, task):
|
|
661
|
+
def srun_command(self, task, alloc_id=None):
|
|
626
662
|
args = ['srun', f'--job-name={self.job_name}:{task.name}', '--ntasks=1', '--nodes=1',
|
|
627
663
|
'--unbuffered']
|
|
628
664
|
command, job_id, options = task.command, '${SLURM_JOB_ID}', self.options
|
|
629
665
|
if self.steps:
|
|
630
|
-
if
|
|
631
|
-
args.append(f'--jobid={
|
|
666
|
+
if alloc_id:
|
|
667
|
+
args.append(f'--jobid={alloc_id}')
|
|
632
668
|
if self.exact:
|
|
633
669
|
args.append('--exact')
|
|
634
670
|
if task.cpus:
|
|
@@ -646,13 +682,18 @@ class SlurmClient:
|
|
|
646
682
|
out, err = await process.communicate()
|
|
647
683
|
return process.returncode, out.decode().strip(), err.decode().strip()
|
|
648
684
|
|
|
649
|
-
async def ensure_allocation(self):
|
|
650
|
-
"""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."""
|
|
651
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
|
|
652
693
|
n_requested = 0
|
|
653
694
|
while True:
|
|
654
695
|
if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
|
|
655
|
-
return
|
|
696
|
+
return self.alloc_id
|
|
656
697
|
if self.alloc_id is None:
|
|
657
698
|
if n_requested == ALLOC_MAX_REQUESTS:
|
|
658
699
|
raise RuntimeError(f'{n_requested} allocations ended before running')
|
|
@@ -662,7 +703,7 @@ class SlurmClient:
|
|
|
662
703
|
if state == 'RUNNING':
|
|
663
704
|
self.alloc_checked = time.monotonic()
|
|
664
705
|
await self._set_cpus(self.alloc_id)
|
|
665
|
-
return
|
|
706
|
+
return self.alloc_id
|
|
666
707
|
if state not in ('PENDING', 'CONFIGURING'):
|
|
667
708
|
print(f'[{self.job_name}] allocation {self.alloc_id} ended '
|
|
668
709
|
f'({state or "gone"}): requesting a new one')
|
|
@@ -680,11 +721,17 @@ class SlurmClient:
|
|
|
680
721
|
if not time_option and not any(o == '-t' or o.startswith(('-t', '--time'))
|
|
681
722
|
for o in self.options):
|
|
682
723
|
time_option = ['--time=24:00:00']
|
|
683
|
-
|
|
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',
|
|
684
726
|
f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
|
|
685
727
|
*self.options, '--wrap', holder]
|
|
686
728
|
returncode, out, err = await self._run(*args)
|
|
687
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)
|
|
688
735
|
raise RuntimeError(f'Requesting the allocation failed: {err}')
|
|
689
736
|
job_id = out.split(';')[0]
|
|
690
737
|
print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes} node(s): the tasks '
|
|
@@ -705,20 +752,35 @@ class SlurmClient:
|
|
|
705
752
|
if task.slurm_job_id is None and text.startswith(SLURM_MARKER):
|
|
706
753
|
task.slurm_job_id = text[len(SLURM_MARKER):].split()[0]
|
|
707
754
|
task.start_time = time.time()
|
|
755
|
+
note_node_failure(task, line)
|
|
708
756
|
task.log(line, self.host)
|
|
709
757
|
|
|
710
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):
|
|
711
771
|
task.slurm_job_id = None
|
|
712
|
-
if self.alloc_nodes
|
|
713
|
-
|
|
714
|
-
cpus = 0
|
|
772
|
+
alloc_id = await self.ensure_allocation(check) if self.alloc_nodes else None
|
|
773
|
+
cpus, cpus_of = 0, None
|
|
715
774
|
if self.steps and self.cpus_total is not None:
|
|
716
775
|
task.log_event(f'waiting for {min(task.cpus or 1, self.cpus_total)} CPUs '
|
|
717
776
|
f'({self.cpus_free} of {self.cpus_total} free)')
|
|
718
|
-
cpus = await self._acquire_cpus(task.cpus or 1)
|
|
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
|
|
719
781
|
try:
|
|
720
782
|
process = await asyncio.create_subprocess_shell(
|
|
721
|
-
self.srun_command(task), stdout=asyncio.subprocess.PIPE,
|
|
783
|
+
self.srun_command(task, alloc_id), stdout=asyncio.subprocess.PIPE,
|
|
722
784
|
stderr=asyncio.subprocess.PIPE, preexec_fn=os.setpgrp, env=self.env)
|
|
723
785
|
task.log_event(f'srun started{self._cpus_state()}')
|
|
724
786
|
task.set_process(Process(self, process))
|
|
@@ -727,12 +789,13 @@ class SlurmClient:
|
|
|
727
789
|
|
|
728
790
|
await asyncio.gather(
|
|
729
791
|
self._log_stdout(task, process.stdout),
|
|
730
|
-
task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: '
|
|
792
|
+
task.log_stream(process.stderr, self.host, err=True, stamp_prefix=b'srun: ',
|
|
793
|
+
on_line=lambda line: note_node_failure(task, line)),
|
|
731
794
|
)
|
|
732
795
|
task.returncode = await process.wait()
|
|
733
796
|
task.log_event(f'srun exited with code {task.returncode}')
|
|
734
797
|
finally:
|
|
735
|
-
await self._release_cpus(cpus)
|
|
798
|
+
await self._release_cpus(cpus, cpus_of)
|
|
736
799
|
if cpus:
|
|
737
800
|
task.log_event(f'{cpus} CPUs released{self._cpus_state()}')
|
|
738
801
|
|
|
@@ -801,6 +864,11 @@ class Worker:
|
|
|
801
864
|
except asyncio.CancelledError:
|
|
802
865
|
self.status = WorkerStatus.IDLE
|
|
803
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
|
|
804
872
|
except Exception as exc:
|
|
805
873
|
if isinstance(exc, ConnectionError):
|
|
806
874
|
err_msg = f'Error connecting client: {exc!s}\n'
|
|
@@ -897,6 +965,22 @@ class WorkerPoolReport:
|
|
|
897
965
|
for label, peak in sorted(peaks.items())
|
|
898
966
|
for category, mem in [requests[label]] if peak > mem]
|
|
899
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
|
+
|
|
900
984
|
def print_usage(self):
|
|
901
985
|
usage = self.usage_lines()
|
|
902
986
|
if not usage:
|
|
@@ -1237,6 +1321,7 @@ class WorkerPool:
|
|
|
1237
1321
|
report = self.last_report
|
|
1238
1322
|
# At the end of the pool, before the next tasks fill the console
|
|
1239
1323
|
report.print_usage()
|
|
1324
|
+
report.print_node_failures()
|
|
1240
1325
|
|
|
1241
1326
|
if print_summary:
|
|
1242
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
|