gstlal 1.13.0
Loading...
Searching...
No Matches
__init__.py
1# Copyright (C) 2020 Patrick Godwin (patrick.godwin@ligo.org)
2#
3# This program is free software; you can redistribute it and/or modify it
4# under the terms of the GNU General Public License as published by the
5# Free Software Foundation; either version 2 of the License, or (at your
6# option) any later version.
7#
8# This program is distributed in the hope that it will be useful, but
9# WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11# Public License for more details.
12#
13# You should have received a copy of the GNU General Public License along
14# with this program; if not, write to the Free Software Foundation, Inc.,
15# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16
17
18from collections.abc import Iterable
19from dataclasses import dataclass, field
20import itertools
21import os
22from typing import Optional, Union
23
24import htcondor
25
26from gstlal.dags import Argument, Option
27from gstlal.dags import util as dagutils
28
29
30@dataclass
31class Layer:
32 """Defines a single layer (or set of related jobs) in an HTCondor DAG.
33
34 Stores submit configuration for a set of nodes as well as
35 providing functionality to determine the parent-child
36 relationships between nodes.
37
38 Parameters
39 ----------
40 executable
41 The path of the executable to run.
42 name
43 The human-readable name of this node. Defaults to the basename
44 of the executable if not given.
45 universe
46 The execution environment for a job. Defaults to 'vanilla'.
47 retries
48 The number of retries given for a job. Defaults to 3.
49 transfer_files
50 Whether to leverage Condor file transfer for moving around
51 files. On by default.
52 dynamic_memory
53 Whether to dynamically increase memory request if jobs are
54 put on hold due to going over memory requested.
55 Off by default.
56 requirements
57 Additional key-value pairs in the submit description.
58 inputs
59 The arguments the nodes takes as inputs. If not specified,
60 they will be automatically generated when nodes are added
61 to this layer.
62 outputs
63 The arguments the nodes takes as outputs. If not specified,
64 they will be automatically generated when nodes are added
65 to this layer.
66 nodes
67 The nodes representing the layer. Nodes can be passed upon
68 instantiation or added to the layer after the fact via
69 Layer.append(node), Layer.extend(nodes), or Layer += node.
70
71 """
72 executable: str
73 name: Optional[str] = ""
74 universe: Optional[str] = "vanilla"
75 log_dir: Optional[str] = "logs"
76 retries: Optional[int] = 3
77 transfer_files: Optional[bool] = True
78 dynamic_memory: Optional[bool] = False
79 requirements: Optional[dict] = field(default_factory=dict)
80 inputs: Optional[dict] = field(default_factory=dict)
81 outputs: Optional[dict] = field(default_factory=dict)
82 nodes: Optional[list] = field(default_factory=list)
83
84 def __post_init__(self):
85 if not self.name:
86 self.name = os.path.basename(self.executable)
87
88 def config(self):
89 # check that nodes are valid
90 self.validate()
91
92 # add base submit opts + requirements
93 submit_options = {
94 "universe": self.universe,
95 "executable": dagutils.which(self.executable),
96 "arguments": self._arguments(),
97 }
98
99 # file submit opts
100 if self.universe != "local":
101 submit_options.update({
102 "periodic_release": "(HoldReasonCode == 5)",
103 **self.requirements,
104 })
105 if self.transfer_files:
106 inputs = self._inputs()
107 outputs = self._outputs()
108 output_remaps = self._output_remaps()
109
110 if inputs or outputs:
111 submit_options["should_transfer_files"] = "YES"
112 submit_options["when_to_transfer_output"] = "ON_SUCCESS"
113 submit_options["success_exit_code"] = 0
114 submit_options["preserve_relative_paths"] = True
115 if inputs:
116 submit_options["transfer_input_files"] = inputs
117 if outputs:
118 submit_options["transfer_output_files"] = outputs
119 submit_options["transfer_output_remaps"] = f'"{self._output_remaps()}"'
120
121 # log submit opts
122 submit_options["output"] = f"{self.log_dir}/$(nodename)-$(cluster)-$(process).out"
123 submit_options["error"] = f"{self.log_dir}/$(nodename)-$(cluster)-$(process).err"
124
125 # extra boilerplate submit opts
126 submit_options["notification"] = "never"
127
128 # set dynamic memory opts if requested
129 if self.dynamic_memory:
130 base_memory = submit_options["request_memory"]
131 submit_options["+MemoryUsage"] = f"( {base_memory} ) * 2 / 3"
132 submit_options["request_memory"] = "( MemoryUsage ) * 3 / 2"
133 submit_options["periodic_release"] = " || ".join([
134 submit_options["periodic_release"],
135 "((CurrentTime - EnteredCurrentStatus > 180) && (HoldReasonCode != 34))"
136 ])
137
138 return {
139 "name": self.name,
140 "submit_description": htcondor.Submit(submit_options),
141 "vars": self._vars(),
142 "retries": self.retries,
143 }
144
145 def append(self, node):
146 for input_ in node.inputs:
147 self.inputs.setdefault(input_.name, []).append(input_.argument)
148 for output in node.outputs:
149 self.outputs.setdefault(output.name, []).append(output.argument)
150 self.nodes.append(node)
151
152 def extend(self, nodes):
153 for node in nodes:
154 self.append(node)
155
156 def __iadd__(self, nodes):
157 if isinstance(nodes, Iterable):
158 self.extend(nodes)
159 else:
160 self.append(nodes)
161 return self
162
163 def validate(self):
164 assert self.nodes, "at least one node must be connected to this layer"
165
166 # check arg names across nodes are equal
167 args = [arg.name for arg in self.nodes[0].arguments]
168 for node in self.nodes[:-1]:
169 assert args == [arg.name for arg in node.arguments]
170
171 # check input/output names across nodes are equal
172 inputs = [arg.name for arg in self.nodes[0].inputs]
173 for node in self.nodes[:-1]:
174 assert inputs == [arg.name for arg in node.inputs]
175 outputs = [arg.name for arg in self.nodes[0].outputs]
176 for node in self.nodes[:-1]:
177 assert outputs == [arg.name for arg in node.outputs]
178
179 @property
180 def has_dependencies(self):
181 return any([node.requires for node in self.nodes])
182
183 def _arguments(self):
184 args = [f"$({arg.condor_name})" for arg in self.nodes[0].arguments]
185 io_args = []
186 io_opts = []
187 for arg in itertools.chain(self.nodes[0].inputs, self.nodes[0].outputs):
188 if not arg.suppress:
189 if isinstance(arg, Argument):
190 io_args.append(f"$({arg.condor_name})")
191 else:
192 io_opts.append(f"$({arg.condor_name})")
193 return " ".join(itertools.chain(args, io_opts, io_args))
194
195 def _inputs(self):
196 return ",".join([f"$(input_{arg.condor_name})" for arg in self.nodes[0].inputs])
197
198 def _outputs(self):
199 return ",".join([f"$(output_{arg.condor_name})" for arg in self.nodes[0].outputs])
200
201 def _output_remaps(self):
202 return ";".join([f"$(output_{arg.condor_name}_remap)" for arg in self.nodes[0].outputs])
203
204 def _vars(self):
205 allvars = []
206 for i, node in enumerate(self.nodes):
207 nodevars = {"nodename": f"{self.name}_{i:05X}"}
208 # add arguments which aren't suppressed
209 if node.arguments:
210 nodevars.update({arg.condor_name: arg.vars() for arg in node.arguments if not arg.suppress})
211 # then add arguments defined as 'inputs'. if file transfer is enabled,
212 # also define the $(input_{arg}) variable containing the files
213 if node.inputs:
214 if self.transfer_files:
215 # adjust file location for input files if they are absolute paths.
216 # condor will transfer the file /path/to/file.txt to the job's
217 # current working directory, so arguments should point to file.txt
218 args = {}
219 for arg in node.inputs:
220 if not arg.suppress:
221 args[f"{arg.condor_name}"] = arg.vars(basename=os.path.isabs)
222 nodevars.update(args)
223 nodevars.update({f"input_{arg.condor_name}": arg.files() for arg in node.inputs})
224 else:
225 nodevars.update({f"{arg.condor_name}": arg.vars() for arg in node.inputs if not arg.suppress})
226 # finally, add arguments defined as 'outputs'. if file transfer is enabled,
227 # also define the $(output_{arg}) variable containing the files. if argument
228 # if not suppressed, some extra hoops are done with remaps to ensure that
229 # files are also saved to the right place. the main problem is that when jobs
230 # are submitted, the directory structure is present in the submit node but
231 # not the execute node, so when a job tries to create a file assuming the
232 # directories are there, the job fails. this gets around the issue by writing
233 # the files to the root directory then remaps them so they get stored in the
234 # right place after the job completes and files are transferred back
235 if node.outputs:
236 for arg in node.outputs:
237 if not arg.suppress:
238 basename = self.transfer_files and arg.remap
239 nodevars.update({f"{arg.condor_name}": arg.vars(basename=basename)})
240 if self.transfer_files:
241 if arg.remap:
242 nodevars.update({f"output_{arg.condor_name}": arg.files(basename=True)})
243 nodevars.update({f"output_{arg.condor_name}_remap": arg.remaps()})
244 else:
245 nodevars.update({f"output_{arg.condor_name}": arg.files()})
246 allvars.append(nodevars)
247
248 return allvars
249
250
251@dataclass
252class Node:
253 """Defines a single node (or job) in an HTCondor DAG.
254
255 Stores both the arguments used within a job as well
256 as capturing any inputs and outputs the job uses/creates.
257
258 Parameters
259 ----------
260 arguments
261 The arguments the node uses which aren't I/O related.
262 inputs
263 The arguments the node takes as inputs.
264 outputs
265 The arguments the node takes as outputs.
266
267 """
268 arguments: Optional[Union[Argument, Option, list]] = field(default_factory=list)
269 inputs: Optional[Union[Argument, Option, list]] = field(default_factory=list)
270 outputs: Optional[Union[Argument, Option, list]] = field(default_factory=list)
271
272 def __post_init__(self):
273 if isinstance(self.arguments, Argument) or isinstance(self.arguments, Option):
274 self.arguments = [self.arguments]
275 if isinstance(self.inputs, Argument) or isinstance(self.inputs, Option):
276 self.inputs = [self.inputs]
277 if isinstance(self.outputs, Argument) or isinstance(self.outputs, Option):
278 self.outputs = [self.outputs]
279
280 @property
281 def requires(self):
282 """
283 Returns
284 -------
285 list
286 The inputs this node explicitly depends on to run.
287
288 """
289 return list(itertools.chain(*[input_.argument for input_ in self.inputs if input_.track]))
290
291 @property
292 def provides(self):
293 """
294 Returns
295 -------
296 list
297 The outputs this node provides when it completes.
298
299 """
300 return list(itertools.chain(*[output.argument for output in self.outputs if output.track]))