libpipe 0.11__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libpipe
3
- Version: 0.11
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. Slurm packs the steps on the nodes
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. Slurm packs the steps on the nodes
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
 
@@ -48,8 +48,9 @@ DEFAULT_RESOURCES = {
48
48
  THREAD_VARS = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')
49
49
  # The holding job of an allocation ends after this many minutes without job step
50
50
  ALLOC_IDLE_MINUTES = 10
51
- # Allocations requested in a row before giving up, when they end before running
52
- ALLOC_MAX_REQUESTS = 3
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')
53
54
  # Launches of a task in an allocation, when srun ends before the command starts (the allocation
54
55
  # ended, or is being replaced)
55
56
  ALLOC_MAX_LAUNCHES = 3
@@ -57,6 +58,31 @@ ALLOC_MAX_LAUNCHES = 3
57
58
  ALLOC_WORKERS_PER_NODE = 32
58
59
 
59
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
+
60
86
  def now_str():
61
87
  return datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')
62
88
 
@@ -560,10 +586,10 @@ SBATCH_TRANSIENT = ('timed out', 'temporarily', 'Unable to contact', 'Connection
560
586
 
561
587
  class SlurmClient:
562
588
  """Run each task with srun: a Slurm job of its own when the pool runs on a login node, a
563
- job step when it runs inside an allocation (salloc, sbatch). With alloc_nodes, the client
564
- requests an allocation of that many nodes and runs the tasks as job steps in it, packed on
565
- its nodes from their CPUs and memory; the allocation ends with the pool, or after
566
- 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.
567
593
 
568
594
  Options (partition, account, ...) come from slurm_options, or from the LIBPIPE_SLURM_OPTIONS
569
595
  environment variable; they apply to the jobs and the allocation, not to the job steps.
@@ -580,9 +606,10 @@ class SlurmClient:
580
606
  self.max_time = max_time
581
607
  self.exact = False
582
608
  self.steps = False
583
- self.alloc_nodes = alloc_nodes
609
+ self.alloc_nodes = node_range(alloc_nodes)
584
610
  self.alloc_id = None
585
611
  self.alloc_refused = None
612
+ self.alloc_ran = False
586
613
  self.alloc_checked = 0
587
614
  self.alloc_lock = asyncio.Lock()
588
615
  # CPUs of the allocation and those free: a step starts when its CPUs are free, instead of
@@ -683,33 +710,47 @@ class SlurmClient:
683
710
  return process.returncode, out.decode().strip(), err.decode().strip()
684
711
 
685
712
  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."""
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."""
688
717
  async with self.alloc_lock:
689
718
  if self.alloc_refused:
690
719
  raise AllocationRefused(self.alloc_refused)
691
720
  if check:
692
721
  self.alloc_checked = 0
693
- n_requested = 0
694
722
  while True:
695
723
  if self.alloc_id and time.monotonic() - self.alloc_checked < 30:
696
724
  return self.alloc_id
697
725
  if self.alloc_id is None:
698
- if n_requested == ALLOC_MAX_REQUESTS:
699
- raise RuntimeError(f'{n_requested} allocations ended before running')
700
726
  self.alloc_id = await self._submit_allocation()
701
- n_requested += 1
727
+ self.alloc_ran = False
702
728
  _, state, _ = await self._run('squeue', '-h', '-j', self.alloc_id, '-o', '%T')
703
729
  if state == 'RUNNING':
704
730
  self.alloc_checked = time.monotonic()
731
+ self.alloc_ran = True
705
732
  await self._set_cpus(self.alloc_id)
706
733
  return self.alloc_id
707
- if state not in ('PENDING', 'CONFIGURING'):
708
- print(f'[{self.job_name}] allocation {self.alloc_id} ended '
709
- f'({state or "gone"}): requesting a new one')
710
- self.alloc_id = None
734
+ if state in ('PENDING', 'CONFIGURING'):
735
+ await asyncio.sleep(5)
711
736
  continue
712
- await asyncio.sleep(5)
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 ''
713
754
 
714
755
  async def _submit_allocation(self):
715
756
  # The holding job: ends after ALLOC_IDLE_MINUTES without step (other than its own)
@@ -722,7 +763,8 @@ class SlurmClient:
722
763
  for o in self.options):
723
764
  time_option = ['--time=24:00:00']
724
765
  # --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',
766
+ args = ['sbatch', '--parsable', f'--nodes={nodes_option(self.alloc_nodes)}', '--exclusive',
767
+ '--no-kill',
726
768
  f'--job-name={self.job_name}:alloc', '--output=/dev/null', *time_option,
727
769
  *self.options, '--wrap', holder]
728
770
  returncode, out, err = await self._run(*args)
@@ -734,8 +776,8 @@ class SlurmClient:
734
776
  raise AllocationRefused(self.alloc_refused)
735
777
  raise RuntimeError(f'Requesting the allocation failed: {err}')
736
778
  job_id = out.split(';')[0]
737
- print(f'[{self.job_name}] allocation {job_id} of {self.alloc_nodes} node(s): the tasks '
738
- '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')
739
781
  return job_id
740
782
 
741
783
  async def _log_stdout(self, task, stream):
@@ -1016,8 +1058,9 @@ class WorkerPool:
1016
1058
  '-p cpu -c 16 --mem=64G' (default: $LIBPIPE_SLURM_OPTIONS).
1017
1059
  kill_grace_s (int, optional): Time a task past max_time gets to stop after SIGTERM
1018
1060
  before SIGKILL.
1019
- slurm_nodes (int, optional): For 'slurm' from a login node: run the tasks as job
1020
- 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.
1021
1064
  resources (dict, optional): CPUs and memory of the task categories as job steps,
1022
1065
  {category: {'cpus': .., 'mem': ..}}, updating DEFAULT_RESOURCES.
1023
1066
  """
@@ -1044,7 +1087,7 @@ class WorkerPool:
1044
1087
  self._waiters = collections.deque()
1045
1088
  self._cooldown_timer = None
1046
1089
  self.resources = resolve_resources(resources)
1047
- self.slurm_nodes = slurm_nodes
1090
+ self.slurm_nodes = node_range(slurm_nodes)
1048
1091
  # Wall time, CPU time and peak memory of each task (libpipe.taskrun), with the Python
1049
1092
  # of the pool: on this machine or on the nodes of the cluster
1050
1093
  self.accounting = all(h in (localhost_shortname, 'localhost', SLURM_HOST) for h in hosts)
@@ -1055,9 +1098,9 @@ class WorkerPool:
1055
1098
  client = LocalClient(localhost_shortname, force_sync=force_sync)
1056
1099
  elif host == SLURM_HOST:
1057
1100
  client = SlurmClient(slurm_options, job_name=name, max_time=max_time,
1058
- alloc_nodes=slurm_nodes)
1059
- if slurm_nodes:
1060
- 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])
1061
1104
  else:
1062
1105
  client = Client(host, user, force_sync=force_sync, password=password,
1063
1106
  connect_timeout=connect_timeout)
@@ -1276,7 +1319,8 @@ class WorkerPool:
1276
1319
  # An allocation no larger than the number of tasks
1277
1320
  for client in {w.client for w in self.workers}:
1278
1321
  if isinstance(client, SlurmClient) and client.alloc_nodes:
1279
- client.alloc_nodes = min(self.slurm_nodes, len(self.tasks))
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
1280
1324
 
1281
1325
  self.futures = [asyncio.ensure_future(task.run(self)) for task in self.tasks]
1282
1326
  self.futures.append(asyncio.ensure_future(self._monitor_queue()))
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "libpipe"
7
- version = "0.11"
7
+ version = "0.12"
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
File without changes