Coverage for python/lsst/ctrl/execute/slurmPlugin.py: 8%

276 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:56 -0700

1# 

2# LSST Data Management System 

3# Copyright 2008-2016 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23import hashlib 

24import logging 

25import math 

26import os 

27import subprocess 

28import sys 

29import time 

30from pathlib import Path 

31from string import Template 

32 

33import htcondor 

34 

35from lsst.ctrl.bps.htcondor import condor_q 

36from lsst.ctrl.execute.allocator import Allocator 

37from lsst.ctrl.execute.findPackageFile import find_package_file 

38from lsst.resources import ResourcePath, ResourcePathExpression 

39 

40_LOG = logging.getLogger(__name__) 

41 

42 

43class SlurmPlugin(Allocator): 

44 @staticmethod 

45 def countSlurmJobs(jobname, jobstates): 

46 """Check Slurm queue for Glideins of given states 

47 

48 Parameters 

49 ---------- 

50 jobname : `string` 

51 Slurm jobname to be searched for via squeue. 

52 jobstates : `string` 

53 Slurm jobstates to be searched for via squeue. 

54 

55 Returns 

56 ------- 

57 numberOfJobs : `int` 

58 The number of Slurm jobs detected via squeue. 

59 """ 

60 batcmd = f"squeue --noheader --states={jobstates} --name={jobname} | wc -l" 

61 _LOG.debug("The squeue command is %s", batcmd) 

62 time.sleep(3) 

63 try: 

64 resultPD = subprocess.check_output(batcmd, shell=True) 

65 except subprocess.CalledProcessError as e: 

66 _LOG.error(e.output) 

67 numberOfJobs = int(resultPD.decode("UTF-8")) 

68 return numberOfJobs 

69 

70 @staticmethod 

71 def countIdleSlurmJobs(jobname): 

72 """Check Slurm queue for Idle Glideins 

73 

74 Parameters 

75 ---------- 

76 jobname : `string` 

77 Slurm jobname to be searched for via squeue. 

78 

79 Returns 

80 ------- 

81 numberOfJobs : `int` 

82 The number of Slurm jobs detected via squeue. 

83 """ 

84 _LOG.info("Checking if idle Slurm job %s exists:", jobname) 

85 numberOfJobs = SlurmPlugin.countSlurmJobs(jobname, jobstates="PD") 

86 return numberOfJobs 

87 

88 @staticmethod 

89 def countRunningSlurmJobs(jobname): 

90 """Check Slurm queue for Running Glideins 

91 

92 Parameters 

93 ---------- 

94 jobname : `string` 

95 Slurm jobname to be searched for via squeue. 

96 

97 Returns 

98 ------- 

99 numberOfJobs : `int` 

100 The number of Slurm jobs detected via squeue. 

101 """ 

102 _LOG.info("Checking if running Slurm job %s exists:", jobname) 

103 numberOfJobs = SlurmPlugin.countSlurmJobs(jobname, jobstates="R") 

104 return numberOfJobs 

105 

106 def createFilesFromTemplates(self): 

107 """Create the Slurm submit, script, and htcondor config files 

108 

109 Returns 

110 ------- 

111 generatedSlurmFile : `str` 

112 name of the Slurm job description file 

113 """ 

114 

115 scratchDirParam = self.getScratchDirectory() 

116 template = Template(scratchDirParam) 

117 template.substitute(USER_HOME=self.getUserHome()) 

118 

119 # create the slurm submit file 

120 slurmName = find_package_file("generic.slurm.template", kind="templates", platform=self.platform) 

121 generatedSlurmFile = self.createSubmitFile(slurmName) 

122 

123 # create the condor configuration file 

124 condorFile = find_package_file( 

125 "glidein_condor_config.template", kind="templates", platform=self.platform 

126 ) 

127 self.createCondorConfigFile(condorFile) 

128 

129 # create the script that the slurm submit file calls 

130 allocationName = find_package_file("allocation.sh.template", kind="templates", platform=self.platform) 

131 self.createAllocationFile(allocationName) 

132 

133 _LOG.debug("The generated Slurm submit file is %s", generatedSlurmFile) 

134 

135 return generatedSlurmFile 

136 

137 def submit(self): 

138 """Submit the glidein jobs to the Batch system.""" 

139 configName = find_package_file("slurmConfig.py", platform=self.platform) 

140 

141 self.loadSlurm(configName) 

142 verbose = self.isVerbose() 

143 auto = self.isAuto() 

144 

145 cpus = self.getCPUs() 

146 memoryPerCore = self.getMemoryPerCore() 

147 totalMemory = cpus * memoryPerCore 

148 

149 # run the sbatch command 

150 template = Template(self.getLocalScratchDirectory()) 

151 localScratchDir = Path(template.substitute(USER_SCRATCH=self.getUserScratch())) 

152 slurmSubmitDir = localScratchDir / self.defaults["DATE_STRING"] 

153 localScratchDir.mkdir(exist_ok=True) 

154 slurmSubmitDir.mkdir(exist_ok=True) 

155 os.chdir(slurmSubmitDir) 

156 _LOG.debug( 

157 "The working local scratch directory localScratchDir is %s ", 

158 localScratchDir, 

159 ) 

160 

161 auser = self.getUserName() 

162 jobname = f"glide_{auser}" 

163 _LOG.debug("The unix user name is %s", auser) 

164 _LOG.debug("The Slurm job name for the glidein jobs is %s", jobname) 

165 _LOG.debug("The user home directory is %s", self.getUserHome()) 

166 

167 if auto: 

168 self.glideinsFromJobPressure() 

169 else: 

170 generatedSlurmFile = self.createFilesFromTemplates() 

171 cmd = f"sbatch --mem {totalMemory} {generatedSlurmFile}" 

172 nodes = self.getNodes() 

173 # In this case 'nodes' is the Target. 

174 

175 # Limit number of cores to be <= 8000 which 500 16-core glideins 

176 # allowed auto glideins is 500 

177 allowedAutoGlideins = self.getAllowedAutoGlideins() 

178 # auto glidein size is 16 

179 autoSize = self.getAutoCPUs() 

180 targetedCores = nodes * cpus 

181 coreLimit = allowedAutoGlideins * autoSize 

182 if targetedCores > coreLimit: 

183 # Reduce number of nodes because of threshold 

184 nodes = int(coreLimit / cpus) 

185 _LOG.info("Reducing number of glideins because of core limit threshold") 

186 _LOG.debug("coreLimit %d", coreLimit) 

187 _LOG.debug("glidein size %d", cpus) 

188 _LOG.info("New number of glideins %d", nodes) 

189 

190 _LOG.info("Targeting %d glidein(s) for the computing pool/set.", nodes) 

191 batcmd = "".join(["squeue --noheader --name=", jobname, " | wc -l"]) 

192 _LOG.debug("The squeue command is: %s", batcmd) 

193 try: 

194 result = subprocess.check_output(batcmd, shell=True) 

195 except subprocess.CalledProcessError as e: 

196 _LOG.error(e.output) 

197 strResult = result.decode("UTF-8") 

198 

199 _LOG.info("Detected this number of preexisting glidein jobs: %d", int(strResult)) 

200 

201 numberToAdd = nodes - int(strResult) 

202 _LOG.info("The number of glidein jobs to submit now is %d", numberToAdd) 

203 

204 for glide in range(0, numberToAdd): 

205 _LOG.info("Submitting glidein %d", glide) 

206 exitCode = self.runCommand(cmd, verbose) 

207 if exitCode != 0: 

208 _LOG.error("error running %s", cmd) 

209 sys.exit(exitCode) 

210 

211 def loadSlurm(self, name): 

212 if self.opts.reservation is not None: 

213 self.defaults["RESERVATION"] = f"#SBATCH --reservation {self.opts.reservation}" 

214 else: 

215 self.defaults["RESERVATION"] = "" 

216 

217 if self.opts.exclude is not None: 

218 self.defaults["EXCLUDE"] = f"#SBATCH --exclude {self.opts.exclude}" 

219 else: 

220 self.defaults["EXCLUDE"] = "" 

221 

222 if self.opts.nodelist is not None: 

223 self.defaults["NODELIST"] = f"#SBATCH --nodelist {self.opts.nodelist}" 

224 else: 

225 self.defaults["NODELIST"] = "" 

226 

227 if self.opts.exclusive is not None: 

228 self.defaults["EXCLUSIVE"] = "#SBATCH --exclusive" 

229 else: 

230 self.defaults["EXCLUSIVE"] = "" 

231 

232 if self.opts.exclusiveUser is not None: 

233 self.defaults["EXCLUSER"] = "#SBATCH --exclusive=user" 

234 else: 

235 self.defaults["EXCLUSER"] = "" 

236 

237 if self.opts.qos: 

238 self.defaults["QOS"] = f"#SBATCH --qos {self.opts.qos}" 

239 else: 

240 self.defaults["QOS"] = "" 

241 

242 allocationConfig = self.loadAllocationConfig(name, "slurm") 

243 

244 template = Template(allocationConfig.platform.scratchDirectory) 

245 scratchDir = template.substitute(USER_SCRATCH=self.getUserScratch()) 

246 self.defaults["SCRATCH_DIR"] = scratchDir 

247 

248 self.allocationFileName = Path(self.configDir) / f"allocation_{self.uniqueIdentifier}.sh" 

249 self.defaults["GENERATED_ALLOCATE_SCRIPT"] = self.allocationFileName.name 

250 

251 if self.opts.openfiles is None: 

252 self.defaults["OPEN_FILES"] = 20480 

253 else: 

254 self.defaults["OPEN_FILES"] = self.opts.openfiles 

255 

256 # For partitionable slots the classad 'Cpus' shows how many cpus 

257 # remain to be allocated. Thus for a slot running jobs the value 

258 # of Rank of TotalCpus - Cpus will increase with the load. 

259 # Because higher Rank is preferred, loaded slots are favored. 

260 if self.opts.packnodes is None: 

261 self.defaults["PACK_BLOCK"] = "#" 

262 else: 

263 self.defaults["PACK_BLOCK"] = "Rank = TotalCpus - Cpus" 

264 

265 # handle dynamic slot block template: 

266 # 1) if it isn't specified, just put a comment in its place 

267 # 2) if it's specified, but without a filename, use the default 

268 # 3) if it's specified with a filename, use that. 

269 if self.opts.dynamic is None: 

270 self.defaults["DYNAMIC_SLOTS_BLOCK"] = "#" 

271 return 

272 

273 if self.opts.dynamic == "__default__": 

274 dynamicSlotsName = find_package_file( 

275 "dynamic_slots.template", kind="templates", platform=self.platform 

276 ) 

277 else: 

278 dynamicSlotsName = ResourcePath(self.opts.dynamic) 

279 

280 with dynamicSlotsName.open() as f: 

281 lines = f.readlines() 

282 block = "" 

283 for line in lines: 

284 block += line 

285 self.defaults["DYNAMIC_SLOTS_BLOCK"] = block 

286 

287 def createAllocationFile(self, input: ResourcePathExpression): 

288 """Creates Allocation script file using the file "input" as a Template 

289 

290 Returns 

291 ------- 

292 outfile : `str` 

293 The newly created file name 

294 """ 

295 outfile = self.createFile(input, self.allocationFileName) 

296 _LOG.debug("Wrote new Slurm job allocation bash script to %s", outfile) 

297 os.chmod(outfile, 0o755) 

298 return outfile 

299 

300 def glideinsFromJobPressure(self): 

301 """Determine and submit the glideins needed from job pressure.""" 

302 

303 verbose = self.isVerbose() 

304 cpus = self.getCPUs() 

305 autoCPUs = self.getAutoCPUs() 

306 minAutoCPUs = self.getMinAutoCPUs() 

307 if cpus >= minAutoCPUs: 

308 autoCPUs = cpus 

309 memoryPerCore = self.getMemoryPerCore() 

310 memoryLimit = autoCPUs * memoryPerCore 

311 auser = self.getUserName() 

312 

313 # projection contains the job classads to be returned. 

314 # These include the cpu and memory profile of each job, 

315 # in the form of RequestCpus and RequestMemory 

316 projection = [ 

317 "ClusterId", 

318 "ProcId", 

319 "JobStatus", 

320 "Owner", 

321 "RequestCpus", 

322 "JobUniverse", 

323 "RequestMemory", 

324 ] 

325 owner = f'(Owner=="{auser}")' 

326 # query for idle jobs 

327 jstat = f"(JobStatus=={htcondor.JobStatus.IDLE})" 

328 # query for vanilla universe 

329 # JobUniverse constants are in htcondor C++ 

330 # UNIVERSE = { 1: "Standard", ..., 5: "Vanilla", ... } 

331 juniv = "(JobUniverse==5)" 

332 

333 # The constraint determines that the jobs to be returned belong to 

334 # the current user (Owner) and are Idle vanilla universe jobs. 

335 full_constraint = f"{owner} && {jstat} && {juniv}" 

336 _LOG.info("Auto: Query for htcondor jobs.") 

337 _LOG.debug("full_constraint %s", full_constraint) 

338 try: 

339 condorq_data = condor_q( 

340 constraint=full_constraint, 

341 projection=projection, 

342 ) 

343 

344 except Exception as exc: 

345 raise type(exc)("Problem querying condor schedd for jobs") from None 

346 

347 if not condorq_data: 

348 _LOG.info("Auto: No HTCondor Jobs detected.") 

349 return 

350 

351 generatedSlurmFile = self.createFilesFromTemplates() 

352 condorq_large = [] 

353 condorq_small = [] 

354 schedd_name, condorq_full = condorq_data.popitem() 

355 

356 _LOG.info("Auto: Search for Large htcondor jobs.") 

357 for jid, ajob in condorq_full.items(): 

358 thisCpus = ajob["RequestCpus"] 

359 if isinstance(ajob["RequestMemory"], int): 

360 thisEvalMemory = ajob["RequestMemory"] 

361 else: 

362 thisEvalMemory = ajob["RequestMemory"].eval() 

363 _LOG.debug("Making an evaluation %s", thisEvalMemory) 

364 # Search for jobs that are Large jobs 

365 # thisCpus > 16 or thisEvalMemory > 16*4096 

366 ajob["RequestMemoryEval"] = thisEvalMemory 

367 if thisEvalMemory > memoryLimit or thisCpus > autoCPUs: 

368 _LOG.info("Appending a Large Job %s", jid) 

369 condorq_large.append(ajob) 

370 else: 

371 condorq_small.append(ajob) 

372 

373 if not condorq_large: 

374 _LOG.info("Auto: no Large jobs detected.") 

375 else: 

376 _LOG.info("Auto: detected Large jobs") 

377 for ajob in condorq_large: 

378 _LOG.debug("\n%d.%d", ajob["ClusterId"], ajob["ProcId"]) 

379 _LOG.debug("%s", ajob) 

380 thisMemory = ajob["RequestMemoryEval"] 

381 useCores = ajob["RequestCpus"] 

382 clusterid = ajob["ClusterId"] 

383 procid = ajob["ProcId"] 

384 job_label = f"{clusterid}_{procid}_{thisMemory}" 

385 if useCores < autoCPUs: 

386 useCores = autoCPUs 

387 hash = hashlib.sha1(job_label.encode("UTF-8")).hexdigest() 

388 shash = hash[:6] 

389 jobname = f"{auser}_{shash}" 

390 _LOG.debug("jobname %s", jobname) 

391 # Check if Job exists Idle in the queue 

392 numberJobname = SlurmPlugin.countIdleSlurmJobs(jobname) 

393 if numberJobname > 0: 

394 _LOG.info("Job %s already exists, do not submit", jobname) 

395 continue 

396 cpuopt = f"--cpus-per-task {useCores}" 

397 memopt = f"--mem {thisMemory}" 

398 jobopt = f"-J {jobname}" 

399 cmd = f"sbatch {cpuopt} {memopt} {jobopt} {generatedSlurmFile}" 

400 _LOG.debug(cmd) 

401 _LOG.info( 

402 "Submitting Large glidein for %d.%d", 

403 ajob["ClusterId"], 

404 ajob["ProcId"], 

405 ) 

406 time.sleep(3) 

407 exitCode = self.runCommand(cmd, verbose) 

408 if exitCode != 0: 

409 _LOG.error("error running %s", cmd) 

410 sys.exit(exitCode) 

411 

412 if not condorq_small: 

413 _LOG.info("Auto: no small Jobs detected.") 

414 else: 

415 _LOG.info("Auto: summarize small jobs.") 

416 maxNumberOfGlideins = self.getNodes() 

417 maxAllowedNumberOfGlideins = self.getAllowedAutoGlideins() 

418 _LOG.debug("maxNumberOfGlideins %d", maxNumberOfGlideins) 

419 _LOG.debug("maxAllowedNumberOfGlideins %d", maxAllowedNumberOfGlideins) 

420 # The number of cores for the small glideins is capped at 8000 

421 # Corresponds to maxAllowedNumberOfGlideins = 500 16-core glideins 

422 if maxNumberOfGlideins > maxAllowedNumberOfGlideins: 

423 maxNumberOfGlideins = maxAllowedNumberOfGlideins 

424 _LOG.info("Reducing Small Glidein limit due to threshold.") 

425 # 

426 # In the following loop we calculate the number of cores 

427 # required by the set of small jobs. This calculation utilizes 

428 # the requested cpus for a job, but also checks the requested 

429 # memory and counts an effective core for each 'memoryPerCore' 

430 # of memory (by default the 4GB per core of S3DF Slurm scheduler). 

431 totalCores = 0 

432 for ajob in condorq_small: 

433 requestedCpus = ajob["RequestCpus"] 

434 # if isinstance(ajob["RequestMemory"], int): 

435 # requestedMemory = ajob["RequestMemory"] 

436 # else: 

437 # requestedMemory = ajob["RequestMemoryEval"] 

438 # logging.debug("Using RequestMemoryEval") 

439 requestedMemory = ajob["RequestMemoryEval"] 

440 totalCores = totalCores + requestedCpus 

441 _LOG.debug("small: jobid %d.%d", ajob["ClusterId"], ajob["ProcId"]) 

442 _LOG.debug("\tRequestCpus %d", requestedCpus) 

443 _LOG.debug("\tCurrent value of totalCores %d", totalCores) 

444 neededCpus = requestedMemory / memoryPerCore 

445 if neededCpus > requestedCpus: 

446 _LOG.debug("\t\tNeed to Add More:") 

447 _LOG.debug("\t\tRequestMemory is %d", requestedMemory) 

448 _LOG.debug("\t\tRatio to %d MB is %d", memoryPerCore, neededCpus) 

449 totalCores = totalCores + (neededCpus - requestedCpus) 

450 _LOG.debug("\t\tCurrent value of totalCores %d", totalCores) 

451 

452 _LOG.info("small: The final TotalCores is %d", totalCores) 

453 

454 # The number of Glideins needed to service the detected Idle jobs 

455 # is "numberOfGlideins" 

456 numberOfGlideins = math.ceil(totalCores / autoCPUs) 

457 _LOG.info("small: Number for detected jobs is %d", numberOfGlideins) 

458 

459 jobname = f"glide_{auser}" 

460 

461 # Check Slurm queue Running glideins 

462 existingGlideinsRunning = SlurmPlugin.countRunningSlurmJobs(jobname) 

463 

464 # Check Slurm queue Idle Glideins 

465 existingGlideinsIdle = SlurmPlugin.countIdleSlurmJobs(jobname) 

466 

467 _LOG.debug("small: existingGlideinsRunning %d", existingGlideinsRunning) 

468 _LOG.debug("small: existingGlideinsIdle %d", existingGlideinsIdle) 

469 

470 # The number of Glideins needed to service the detected 

471 # Idle jobs is "numberOfGlideins" less the existing Idle glideins 

472 numberOfGlideinsReduced = numberOfGlideins - existingGlideinsIdle 

473 _LOG.debug("small: Target Number to submit %d", numberOfGlideinsReduced) 

474 

475 # The maximum number of Glideins that we can submit with 

476 # the imposed threshold (maxNumberOfGlideins) 

477 # is maxSubmitGlideins 

478 existingGlideins = existingGlideinsRunning + existingGlideinsIdle 

479 maxSubmitGlideins = maxNumberOfGlideins - existingGlideins 

480 _LOG.debug("small: maxNumberOfGlideins %d", maxNumberOfGlideins) 

481 _LOG.debug("small: maxSubmitGlideins %d", maxSubmitGlideins) 

482 

483 # Reduce the number of Glideins to submit if threshold exceeded 

484 if numberOfGlideinsReduced > maxSubmitGlideins: 

485 numberOfGlideinsReduced = maxSubmitGlideins 

486 _LOG.info("small: Reducing due to threshold.") 

487 _LOG.debug("small: Number of Glideins to submit is %d", numberOfGlideinsReduced) 

488 

489 cpuopt = f"--cpus-per-task {autoCPUs}" 

490 memopt = f"--mem {memoryLimit}" 

491 jobopt = f"-J {jobname}" 

492 cmd = f"sbatch {cpuopt} {memopt} {jobopt} {generatedSlurmFile}" 

493 _LOG.debug(cmd) 

494 for glide in range(0, numberOfGlideinsReduced): 

495 _LOG.info("Submitting glidein %s", glide) 

496 exitCode = self.runCommand(cmd, verbose) 

497 if exitCode != 0: 

498 _LOG.error("error running %s", cmd) 

499 sys.exit(exitCode) 

500 

501 return