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.
@@ -1,17 +1,18 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: libpipe
3
- Version: 0.9.1
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
- async def log_stream(self, stream, host, err=False):
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(lambda: self.cpus_free >= cpus)
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
- return cpus
636
+ return cpus, self.cpus_of
590
637
 
591
- async def _release_cpus(self, cpus):
592
- if cpus and self.cpus_total is not None:
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 self.alloc_id:
619
- args.append(f'--jobid={self.alloc_id}')
666
+ if alloc_id:
667
+ args.append(f'--jobid={alloc_id}')
620
668
  if self.exact:
621
- # --quiet: no "step creation disabled, retrying" while a step waits for its node
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, and wait for it to run."""
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
- args = ['sbatch', '--parsable', f'--nodes={self.alloc_nodes}', '--exclusive',
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
- await self.ensure_allocation()
703
- cpus = await self._acquire_cpus(task.cpus or 1) if self.steps else 0
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 ===")
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "libpipe"
7
- version = "0.9.1"
7
+ version = "0.11"
8
8
  description = "Pipeline orchestration and node management utilities"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes