ATLAS Offline Software
Loading...
Searching...
No Matches
EventLoopCPRunScript.py
Go to the documentation of this file.
1# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
2from AnalysisAlgorithmsConfig.CPBaseRunner import CPBaseRunner
3import os
4import sys
5
6class EventLoopCPRunScript(CPBaseRunner):
7 def __init__(self):
8 super().__init__()
9 self.logger.info("EventLoopCPRunScript initialized")
11 self.algSeq = None
12 self.outputStreams = []
13 # Avoid putting call to parse_args() here! Otherwise it is hard to retrieve the parser infos
14
16 # add arguments here
17 derivedGroup = self.parser.add_argument_group('EventLoop specific arguments')
18 derivedGroup.add_argument('--direct-driver', dest='direct_driver',
19 action='store_true', help='Run the job with the direct driver')
20 derivedGroup.add_argument('--work-dir', dest='work_dir', nargs='?', const='workDir', default=None,
21 help='The work directory for the EL job. defaults to "workDir".')
22 derivedGroup.add_argument('--dump-full-config', dest='dump_full_config', action='store_true', help='Save the full CP configuration log to a json file. This can be useful for debugging purposes.')
23
24 expertGroup = self.parser.add_argument_group('Experts arguments')
25 expertGroup.add_argument('--run-perf-stat', dest='run_perf_stat', action='store_true', help='Run xAOD::PerfStats to get input branch access data. This is mostly useful for AMG experts wanting to understand branch access patterns.')
26 expertGroup.add_argument('--algorithm-timers', dest='algorithm_timers', action='store_true', help='Enable algorithm timers. This is mostly useful for AMG experts wanting to understand tool performance.')
27 expertGroup.add_argument('--algorithm-memory-monitoring', dest='algorithm_memory_monitoring', action='store_true', help='Enable algorithm memory monitoring. This is mostly useful for AMG experts wanting to understand tool memory usage. Note that this is imperfect and may in cases assign memory to the wrong algorithm.')
28 return
29
30 def makeAlgSequence(self):
31 from AnaAlgorithm.AlgSequence import AlgSequence
32 from AnalysisAlgorithmsConfig.ConfigAccumulator import ConfigAccumulator
33 algSeq = AlgSequence()
34 self.logger.info("Configuring algorithms based on YAML file")
35 configSeq = self.config.configure()
36 self.logger.info("Configuring common services")
37 configAccumulator = ConfigAccumulator(flags=self.flags,
38 algSeq=algSeq,
39 noSystematics=self.args.no_systematics)
40 # EventLoop writes histograms to its histogram file whatever their stream,
41 # so they should not request the tree stream ANALYSIS
42 import ROOT
43 configAccumulator.setDefaultHistogramStream(ROOT.EL.Job.histogramStreamName)
44 self.logger.info("Configuring algorithms")
45 configSeq.fullConfigure(configAccumulator)
46 self.algSeq = algSeq
47 self.outputStreams = configAccumulator.outputStreams()
48 self.modifyAlgSequence()
49 return algSeq
50
51 def readSamples(self):
52 import ROOT
53 self.sampleHandler = ROOT.SH.SampleHandler()
54 sampleFiles = ROOT.SH.SampleLocal(f"{self.outputName}")
55 self.logger.info("Adding files to the sample handler")
56 for file in self.inputList:
57 sampleFiles.add(file)
58 self.sampleHandler.add(sampleFiles)
59
60 # This functionality should not be in the runscript, instead should be put into PrintConfiguration alg.
61 # This is a temporary solution to dump the full config until PrintConfiguration alg is completely ready.
62 #
63 # The dump happens in two steps: the algorithm sequence is written out by
64 # the process that did the configuration (before the job is submitted), and
65 # the combination with the tool configuration written by the job happens
66 # after the job has finished.
67 _algSequenceFile = "_alg_sequence.json"
68
70 from AnalysisAlgorithmsConfig.SaveConfigUtils import save_algs_from_sequence_ELjob
71 import json
72 with open(self._algSequenceFile, 'w', encoding='utf-8') as seq_out_file:
73 output_dict = {}
74 try:
75 save_algs_from_sequence_ELjob(self.algSeq, output_dict)
76 json.dump(output_dict, seq_out_file, ensure_ascii=False, indent=4)
77 except Exception as e:
78 self.logger.warning(f'Dumping full config failed with: {e}')
79 self.logger.warning('Please also check if "PrintConfiguration" is enabled in the text config.')
80
82 from AnalysisAlgorithmsConfig.SaveConfigUtils import combine_tools_and_algorithms_ELjob
83 try:
84 combine_tools_and_algorithms_ELjob(combine_dictionaries=True, alg_file=self._algSequenceFile, output_file="full_config.json")
85 self.logger.info("Combining full config to full_config.json succeeded")
86
87 except Exception as e:
88 self.logger.warning(f'Combining full config failed with: {e}')
89 self.logger.warning('Please also check if "PrintConfiguration" is enabled in the text config.')
90 finally:
91 # the file may be missing if the configuration failed before writing it
92 if os.path.exists(self._algSequenceFile):
93 os.remove(self._algSequenceFile)
94
95 def moveOutputFiles(self):
96 from pathlib import Path
97 import shutil
98 self.logger.info("Moving the analysis root file and the hist file to the top level.")
99 workDir = Path(self.args.work_dir) if self.args.work_dir else Path('workDir')
100 rootfileSymlink = (workDir / 'data-ANALYSIS' / f'{self.outputName}.root')
101 rootfilePath = rootfileSymlink.resolve()
102 histfileSymlink = (workDir / f'hist-{self.outputName}.root')
103 histfilePath = histfileSymlink.resolve()
104 currentDir = Path.cwd()
105 # move ntuple file if it exists
106 if rootfilePath.exists():
107 self.logger.info(f"Moving {rootfilePath} to {currentDir / f'{self.outputName}.root'}")
108 if rootfileSymlink.is_symlink(): # The check is needed to avoid FileNotFoundError if using direct driver
109 rootfileSymlink.unlink()
110 shutil.move(str(rootfilePath), str(currentDir / f"{self.outputName}.root"))
111 else:
112 self.logger.warning(f"Root file {rootfilePath} does not exist or merging is enabled, skipping move.")
113 #move histogram file if it exists
114 if histfilePath.exists():
115 self.logger.info(f"Moving {histfilePath} to {currentDir / f'hist-{self.outputName}.root'}")
116 if histfileSymlink.is_symlink(): # The check is needed to avoid FileNotFoundError if using direct driver
117 histfileSymlink.unlink()
118 shutil.move(str(histfilePath), str(currentDir / f"hist-{self.outputName}.root"))
119 else:
120 self.logger.warning(f"Histogram file {histfilePath} does not exist or merging, skipping move.")
121 # move the other output stream files, named as in AthenaCPRunScript; taken from disk as
122 # the streams are only known in the job process, which the exec driver forks off
123 for streamDir in sorted(workDir.glob('data-*')):
124 stream = streamDir.name.removeprefix('data-')
125 if stream == 'ANALYSIS':
126 continue
127 streamSymlink = streamDir / f'{self.outputName}.root'
128 streamPath = streamSymlink.resolve()
129 target = currentDir / f"{self.outputName}_{stream.removeprefix('ANALYSIS_')}.root"
130 if streamPath.exists():
131 self.logger.info(f"Moving {streamPath} to {target}")
132 if streamSymlink.is_symlink():
133 streamSymlink.unlink()
134 shutil.move(str(streamPath), str(target))
135 else:
136 self.logger.warning(f"Root file {streamPath} does not exist, skipping move.")
137
138 newHistFile = currentDir / f"hist-{self.outputName}.root"
139 # rename merged hist-ntuple to output_name.root
140 if self.args.merge_output_files and newHistFile.exists():
141 self.logger.info(f"renaming the hist-{self.outputName}.root to {self.outputName}.root")
142 newHistFile.rename(currentDir / f"{self.outputName}.root")
143
144 @property
145 def submitDir(self):
146 return self.args.work_dir if self.args.work_dir else 'workDir'
147
148 def getExitCode(self):
149 '''
150 Exit code for the direct driver, where the job ran in this process.
151 For the exec driver the exit status of the worker process is used
152 instead, so that this process never has to load ROOT.
153 '''
154 import ROOT
155 statusCode = ROOT.EL.Driver.retrieve(self.submitDir)
156 if statusCode:
157 return 0
158 return 1
159
160 @staticmethod
162 sys.stdout.flush()
163 sys.stderr.flush()
164
166 '''
167 Do the full job configuration and submit the job.
168
169 Everything that loads ROOT (and with it the ROOT-python
170 dictionaries, which account for most of the configuration-time
171 memory) happens in here. With the exec driver, driver.submit()
172 replaces the current process with the worker and never returns.
173 '''
174 self.setupConfig()
175 # importing ROOT has a long upfront time and high memory
176 # consumption, so we do it here
177 import ROOT
178 ROOT.xAOD.Init().ignore()
179 self.readSamples()
180 self.flags.lock()
181 self.printFlags()
182
183 self.job = ROOT.EL.Job()
185 self.job.options().setDouble(ROOT.EL.Job.optFilesPerWorker, 100)
186 self.job.options().setDouble(ROOT.EL.Job.optMaxEvents, self.flags.Exec.MaxEvents)
187 self.job.options().setString(ROOT.EL.Job.optSubmitDirMode, 'unique-link')
188 self.job.options().setDouble(ROOT.EL.Job.optSkipEvents, self.flags.Exec.SkipEvents)
189
190 for alg in self.makeAlgSequence():
191 self.job.algsAdd(alg)
192 # Every requested output stream not going to the histogram file gets its own file: the
193 # default tree stream ANALYSIS only if something writes to it, plus e.g. the streams of
194 # Output.splitBySelection
195 histStreams = {ROOT.EL.Job.histogramStreamName}
196 if self.args.merge_output_files:
197 self.job.options().setString(ROOT.EL.Job.optStreamAliases, "ANALYSIS=" + ROOT.EL.Job.histogramStreamName)
198 histStreams.add('ANALYSIS')
199 for stream in [stream for stream in self.outputStreams if stream not in histStreams]:
200 self.job.outputAdd(ROOT.EL.OutputStream(stream))
201
202 if self.args.run_perf_stat:
203 self.job.options().setBool(ROOT.EL.Job.optXAODPerfStats, 1)
204 if self.args.algorithm_timers:
205 self.job.options().setBool(ROOT.EL.Job.optAlgorithmTimer, 1)
206 if self.args.algorithm_memory_monitoring:
207 self.job.options().setBool(ROOT.EL.Job.optAlgorithmMemoryMonitor, 1)
208
209 if self.args.dump_full_config:
210 self._dumpAlgSequence()
211
212 driver = ROOT.EL.DirectDriver() if self.args.direct_driver else ROOT.EL.ExecDriver()
213 # the exec driver replaces this process, so anything still sitting in
214 # the python output buffers would be lost without this flush
215 self._flushOutput()
216 driver.submit(self.job, self.submitDir)
217
219 '''
220 Run configureAndSubmit() in a forked child process and return the
221 exit code of the job.
222
223 The fork has to happen here, before anything imports ROOT or reads
224 the input file metadata through the flags. The exec driver frees the
225 configuration-time memory by replacing the configuring process with
226 the worker, and that only helps if the process that stays behind to
227 wait for the job (this one) never loaded ROOT in the first place.
228 Forking after the configuration would just keep all that memory alive
229 in the waiting parent for the duration of the job.
230
231 The worker (eventloop_exec_worker) runs all job segments, retrieves
232 the output and exits non-zero on any failure, so its exit status is
233 the exit status of the job.
234 '''
235 self._flushOutput()
236 if (pid := os.fork()) == 0: # child process
237 try:
238 self.configureAndSubmit()
239 # the exec driver never returns from submit, so getting here
240 # means the process was not replaced
241 self.logger.error('Job submission returned without running the job')
242 except Exception:
243 import traceback
244 traceback.print_exc()
245 self.logger.error('Job configuration or submission failed')
246 self._flushOutput()
247 os._exit(1)
248
249 _, status = os.waitpid(pid, 0) # parent waits for the job to finish
250 exitCode = os.waitstatus_to_exitcode(status)
251 if exitCode != 0:
252 self.logger.error(f'Job in child process failed with exit code {exitCode}')
253 return 1
254 return 0
255
256 def run(self):
257 # only the argument parsing happens before the fork, see runInChildProcess()
258 self.setupArguments()
259
260 if self.args.direct_driver:
261 try:
262 self.configureAndSubmit()
263 except Exception:
264 import traceback
265 traceback.print_exc()
266 self.logger.error('Job submission failed')
267 exitCode = self.getExitCode()
268 else:
269 exitCode = self.runInChildProcess()
270
271 if self.args.dump_full_config:
272 self._combineFullConfig()
273
274 if self.args.work_dir is None: # move output if work_dir is not used
275 self.moveOutputFiles()
276
277 sys.exit(exitCode)
virtual void lock()=0
Interface to allow an object to lock itself when made const in SG.
bool configure(asg::AnaToolHandle< ITrigGlobalEfficiencyCorrectionTool > &tool, ToolHandleArray< IAsgElectronEfficiencyCorrectionTool > &electronEffToolsHandles, ToolHandleArray< IAsgElectronEfficiencyCorrectionTool > &electronSFToolsHandles, ToolHandleArray< CP::IMuonTriggerScaleFactors > &muonToolsHandles, ToolHandleArray< IAsgPhotonEfficiencyCorrectionTool > &photonEffToolsHandles, ToolHandleArray< IAsgPhotonEfficiencyCorrectionTool > &photonSFToolsHandles, const std::string &triggers, const std::map< std::string, std::string > &legsPerTool, unsigned long nToys, bool debug)
bool add(const std::string &hname, TKey *tobj)
Definition fastadd.cxx:55