Skip to content

Commit f436598

Browse files
Fix Rscript location for fsl_regfilt.R inside slurm job; fix postproc file desc fields
1 parent 06903b4 commit f436598

3 files changed

Lines changed: 314 additions & 7 deletions

File tree

R/hpc_functions.R

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,308 @@ cluster_job_submit <- function(script, scheduler="slurm", sched_args=NULL,
205205
return(jobid)
206206
}
207207

208+
209+
#' This function pauses execution of an R script while a scheduled qsub job is not yet complete.
210+
#'
211+
#' It is intended to give you control over job dependencies within R when the formal PBS
212+
#' depend approach is insufficient, especially in the case of a script that spawns child jobs that
213+
#' need to be scheduled or complete before the parent script should continue.
214+
#'
215+
#' @param job_ids One or more job ids of existing PBS or slurm jobs, or process ids of a local process for
216+
#' \code{scheduler="sh"}.
217+
#' @param repolling_interval How often to recheck the job status, in seconds. Default: 30
218+
#' @param max_wait How long to wait on the job before giving up, in seconds. Default: 24 hours (86,400 seconds)
219+
#' @param scheduler What scheduler is used for job execution.
220+
#' Options: c("torque", "qsub", "slurm", "sbatch", "sh", "local")
221+
#' @param quiet If \code{TRUE}, \code{wait_for_job} will not print out any status updates on jobs. If \code{FALSE},
222+
#' the function prints out status updates for each tracked job so that the user knows what's holding up progress.
223+
#'
224+
#' @return Nothing. Just returns when the blocking job completes.
225+
#'
226+
#' @details Note that for the \code{scheduler} argument, "torque" and "qsub" are the same;
227+
#' "slurm" and "sbatch" are the same, and "sh" and "local" are the same.
228+
#' @examples
229+
#' \dontrun{
230+
#' # example on qsub/torque cluster
231+
#' wait_for_job("7968857.torque01.util.production.int.aci.ics.psu.edu", scheduler = "torque")
232+
#'
233+
#' # example of waiting for two jobs on slurm cluster
234+
#' wait_for_job(c("24147864", "24147876"), scheduler = "slurm")
235+
#'
236+
#' # example of waiting for two jobs on local machine
237+
#' wait_for_job(c("9843", "9844"), scheduler = "local")
238+
#' }
239+
#'
240+
#' @author Michael Hallquist
241+
#' @importFrom dplyr full_join if_else bind_rows
242+
#' @export
243+
wait_for_job <- function(job_ids, repolling_interval = 60, max_wait = 60 * 60 * 24,
244+
scheduler = "local", quiet = TRUE, stop_on_timeout = TRUE) {
245+
checkmate::assert_number(repolling_interval, lower = 0.1, upper = 2e5)
246+
checkmate::assert_number(max_wait, lower = 1, upper = 1814400) # 21 days
247+
scheduler <- tolower(scheduler) # ignore case
248+
checkmate::assert_subset(scheduler, c("torque", "qsub", "slurm", "sbatch", "sh", "local"))
249+
250+
job_complete <- FALSE
251+
wait_start <- Sys.time()
252+
253+
get_job_status <- function() { # use variables in parent environment
254+
if (scheduler %in% c("slurm", "sbatch")) {
255+
status <- slurm_job_status(job_ids)
256+
state <- sapply(status$State, function(x) {
257+
switch(x,
258+
"BOOT_FAIL" = "failed",
259+
"CANCELLED" = "cancelled",
260+
"COMPLETED" = "complete",
261+
"DEADLINE" = "failed",
262+
"FAILED" = "failed",
263+
"NODE_FAIL" = "failed",
264+
"OUT_OF_MEMORY" = "failed",
265+
"PENDING" = "queued",
266+
"PREEMPTED" = "failed",
267+
"RUNNING" = "running",
268+
"REQUEUED" = "queued",
269+
"REVOKED" = "failed",
270+
"SUSPENDED" = "suspended",
271+
"TIMEOUT" = "failed",
272+
"MISSING" = "missing" # scheduler has not registered the job
273+
)
274+
})
275+
} else if (scheduler %in% c("sh", "local")) {
276+
status <- local_job_status(job_ids)
277+
state <- sapply(status$STAT, function(x) {
278+
switch(x,
279+
"C" = "complete",
280+
"I" = "running", # idle/sleeping
281+
"R" = "running",
282+
"S" = "running", # sleeping
283+
"T" = "suspended",
284+
"U" = "running",
285+
"Z" = "failed", # zombie
286+
stop("Unable to understand job state: ", x)
287+
)
288+
})
289+
} else if (scheduler %in% c("torque", "qsub")) {
290+
# QSUB
291+
status <- torque_job_status(job_ids)
292+
state <- status$State
293+
294+
# no need for additional mapping in simple torque results
295+
# state <- sapply(status$State, function(x) {
296+
# switch(x,
297+
# "C" = "complete",
298+
# "R" = "running",
299+
# "Q" = "queued",
300+
# "H" = "suspended",
301+
# "W" = "suspended", # waiting
302+
# stop("Unable to understand job state: ", x)
303+
# )
304+
# })
305+
} else {
306+
stop("unknown scheduler: ", scheduler)
307+
}
308+
return(state)
309+
}
310+
311+
ret_code <- NULL # should be set to TRUE if all jobs complete and FALSE if any job fails
312+
313+
while (job_complete == FALSE) {
314+
status <- get_job_status()
315+
316+
# update wait time
317+
wait_total <- difftime(Sys.time(), wait_start, units = "sec")
318+
319+
# Debugging
320+
# cat("Wait so far: ", wait_total, "\n")
321+
322+
if (any(status == "running")) {
323+
if (isFALSE(quiet)) {
324+
cat("Job(s) still running:", paste(job_ids[status == "running"], collapse = ", "), "\n")
325+
}
326+
}
327+
328+
if (any(status == "queued")) {
329+
if (isFALSE(quiet)) {
330+
cat("Job(s) still queued:", paste(job_ids[status == "queued"], collapse = ", "), "\n")
331+
}
332+
}
333+
334+
if (any(status == "suspended")) {
335+
if (isFALSE(quiet)) {
336+
cat("Job(s) suspended:", paste(job_ids[status == "suspended"], collapse = ", "), "\n")
337+
}
338+
}
339+
340+
if (any(status == "missing")) {
341+
if (isFALSE(quiet)) {
342+
cat("Job(s) missing from scheduler response:", paste(job_ids[status == "missing"], collapse = ", "), "\n")
343+
}
344+
}
345+
346+
if (wait_total > max_wait) {
347+
if (isTRUE(stop_on_timeout)) {
348+
stop("Maximum wait time: ", max_wait, " exceeded. Stopping execution of parent script because something is wrong.")
349+
} else {
350+
return(FALSE)
351+
}
352+
} else if (all(status %in% c("failed", "complete"))) {
353+
job_complete <- TRUE # drop out of this loop
354+
if (isFALSE(quiet)) {
355+
cat("All jobs have finished.\n")
356+
}
357+
if (any(status == "failed")) {
358+
cat("The following jobs(s) failed:", paste(job_ids[status == "failed"], collapse = ", "), "\n")
359+
ret_code <- FALSE
360+
} else {
361+
ret_code <- TRUE
362+
}
363+
} else {
364+
Sys.sleep(repolling_interval) # wait and repoll jobs
365+
}
366+
}
367+
368+
return(invisible(ret_code))
369+
}
370+
371+
# calls sacct with a job list
372+
slurm_job_status <- function(job_ids = NULL, user = NULL, sacct_format = "jobid,submit,timelimit,start,end,state") {
373+
if (!is.null(job_ids)) {
374+
jstring <- paste("-j", paste(job_ids, collapse = ","))
375+
} else {
376+
jstring <- ""
377+
}
378+
379+
if (!is.null(user)) {
380+
ustring <- paste("-u", paste(user, collapse = ","))
381+
} else {
382+
ustring <- ""
383+
}
384+
385+
# -P specifies a parsable output separated by pipes
386+
# -X avoids printing subsidiary jobs within each job id
387+
#cmd <- paste("sacct", jstring, ustring, "-X -P -o", sacct_format)
388+
cmd <- paste(jstring, ustring, "-X -P -o", sacct_format)
389+
# cat(cmd, "\n")
390+
res <- system2("sacct", args = cmd, stdout = TRUE)
391+
392+
df_base <- data.frame(JobID = job_ids)
393+
df_empty <- df_base %>%
394+
mutate(
395+
Submit = NA_character_,
396+
Timelimit = NA_character_,
397+
Start = NA_character_,
398+
End = NA_character_,
399+
State = "MISSING"
400+
)
401+
402+
# handle non-zero exit status -- return empty data
403+
if (!is.null(attr(res, "status"))) {
404+
warning("sacct call generated non-zero exit status")
405+
print(cmd)
406+
return(df_empty)
407+
}
408+
409+
if (length(res) == 1L) {
410+
# data.table::fread will break down (see Github issue )
411+
out <- readr::read_delim(I(res), delim = "|", show_col_types = FALSE)
412+
} else {
413+
out <- data.table::fread(text = res, data.table=FALSE)
414+
}
415+
416+
if (!checkmate::test_subset(c("JobID", "State"), names(out))) {
417+
warning("Missing columns in sacct output")
418+
return(df_empty)
419+
}
420+
421+
out$JobID <- as.character(out$JobID)
422+
df <- df_base %>%
423+
dplyr::left_join(out, by = "JobID") %>%
424+
mutate(State = if_else(is.na(State), "MISSING", State))
425+
426+
return(df)
427+
}
428+
429+
# torque does not keep information about completed jobs available in qstat or qselect
430+
# thus, need to log when a job is listed as queued, so that it 'going missing' is evidence of it being completed
431+
torque_job_status <- function(job_ids, user = NULL) {
432+
#res <- system2("qstat", args = paste("-f", paste(job_ids, collapse=" "), "| grep -i 'job_state'"), stdout = TRUE)
433+
434+
q_jobs <- system2("qselect", args = "-u $USER -s QW", stdout = TRUE) # queued jobs
435+
r_jobs <- system2("qselect", args = "-u $USER -s EHRT", stdout = TRUE) # running jobs
436+
c_jobs <- system2("qselect", args = "-u $USER -s C", stdout = TRUE) # complete jobs
437+
m_jobs <- setdiff(job_ids, c(q_jobs, r_jobs, c_jobs)) # missing jobs
438+
#state <- c("queued", "running", "complete", "missing")
439+
state <- c("queued", "running", "complete", "complete")
440+
441+
# TORQUE clusters only keep jobs with status C (complete) for a limited period of time. After that, the job comes back as missing.
442+
# Because of this, if one job finishes at time X and another finishes at time Y, job X will be 'missing' if job Y takes a very long time.
443+
# Thus, we return any missing jobs as complete, which could be problematic if they are truly missing immediately after submission (as happened with slurm).
444+
# Ideally, we would track a job within wait_for_job such that it can be missing initially, then move into running, then move into complete.
445+
446+
j_list <- list(q_jobs, r_jobs, c_jobs, m_jobs)
447+
state_list <- list()
448+
for (ii in seq_along(j_list)) {
449+
if (length(j_list[[ii]]) > 0L) {
450+
state_list[[state[ii]]] <- data.frame(JobID = j_list[[ii]], State = state[ii])
451+
}
452+
}
453+
454+
state_df <- bind_rows(state_list)
455+
456+
if (!is.null(attr(q_jobs, "status"))) {
457+
warning("qselect call generated non-zero exit status")
458+
return(data.frame(JobID = job_ids, State = "missing"))
459+
}
460+
461+
#job_state <- sub(".*job_state = ([A-z]).*", "\\1", res, perl = TRUE)
462+
463+
return(state_df)
464+
}
465+
466+
local_job_status <- function(job_ids = NULL, user = NULL,
467+
ps_format = "user,pid,state,time,etime,%cpu,%mem,comm,xstat") {
468+
job_ids <- type.convert(job_ids, as.is = T) # convert to integers
469+
checkmate::assert_integerish(job_ids)
470+
471+
if (!is.null(job_ids)) {
472+
jstring <- paste("-p", paste(job_ids, collapse = ","))
473+
} else {
474+
jstring <- ""
475+
}
476+
477+
if (!is.null(user)) {
478+
ustring <- paste("-u", paste(user, collapse = ","))
479+
} else {
480+
ustring <- ""
481+
}
482+
483+
# cat(paste("ps", jstring, ustring, "-o", ps_format), sep = "\n")
484+
res <- suppressWarnings(system2("ps", args = paste(jstring, ustring, "-o", ps_format), stdout = TRUE)) # intern=TRUE)
485+
486+
# need to trap res of length 1 (just header row) to avoid data.table bug.
487+
if (!is.null(attr(res, "status")) && attr(res, "status") != 0) {
488+
hrow <- strsplit(res, "\\s+")[[1]]
489+
dt <- data.frame(matrix(NA, nrow = length(job_ids), ncol = length(hrow)))
490+
names(dt) <- hrow
491+
dt$PID <- as.integer(job_ids)
492+
} else {
493+
stopifnot(length(res) > 1)
494+
# fread and any other parsing can break down with consecutive spaces in body of output.
495+
# This happens with lstart and start, avoid these for now.
496+
#header <- gregexpr("\\b", res[1], perl = T)
497+
#l2 <- gregexpr("\\b", res[2], perl=T)
498+
dt <- data.table::fread(text = res)
499+
}
500+
501+
# fix difference in column naming between FreeBSD and *nux (make all like FreeBSD)
502+
data.table::setnames(dt, c("S", "COMMAND"), c("STAT", "COMM"), skip_absent = TRUE)
503+
504+
# build df that fills in missing jobs (completed/killed)
505+
all_dt <- data.frame(PID = as.integer(job_ids)) %>%
506+
dplyr::full_join(dt, by = "PID") %>%
507+
mutate(STAT = substr(STAT, 1, 1)) # only care about first character of state
508+
509+
all_dt$STAT[is.na(all_dt$STAT)] <- "C" # complete
510+
511+
return(all_dt)
512+
}

R/postprocess_functions.R

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ postprocess_subject <- function(in_file, cfg=NULL) {
173173

174174
# output files use camelCase, with desc on the end, like desc-ismPostproc1, where ism are the steps that have been applied
175175
prefix_chain <- "" # used for accumulating prefixes with each step
176-
out_desc <- paste0(toupper(substr(cfg$bids_desc, 1, 1)), substr(cfg$bids_desc, 2, nchar(cfg$bids_desc)))
176+
base_desc <- paste0(toupper(substr(cfg$bids_desc, 1, 1)), substr(cfg$bids_desc, 2, nchar(cfg$bids_desc)))
177177

178178
#### Loop over fMRI processing steps in sequence
179179
for (step in processing_sequence) {
@@ -192,7 +192,7 @@ postprocess_subject <- function(in_file, cfg=NULL) {
192192
)
193193

194194
prefix_chain <- paste0(step_prefix, prefix_chain)
195-
out_desc <- paste0(prefix_chain, out_desc)
195+
out_desc <- paste0(prefix_chain, base_desc)
196196

197197
if (step == "apply_mask") {
198198
lg$info("Masking fMRI data using file: {brain_mask}")
@@ -208,6 +208,7 @@ postprocess_subject <- function(in_file, cfg=NULL) {
208208
)
209209
file_set <- c(file_set, cur_file)
210210
} else if (step == "apply_aroma") {
211+
lg$info("Removing AROMA noise components from fMRI data")
211212
cur_file <- apply_aroma(cur_file, out_desc = out_desc,
212213
mixing_file = proc_files$melodic_mix,
213214
noise_ics = proc_files$noise_ics,
@@ -624,7 +625,6 @@ apply_aroma <- function(in_file, out_desc = NULL, mixing_file, noise_ics, overwr
624625
log_file <- lg$appenders$postprocess_log$destination
625626
}
626627

627-
lg$info("Removing AROMA noise components from fMRI data")
628628
lg$debug("in_file: {in_file}")
629629
lg$debug("mixing_file: {mixing_file}")
630630

@@ -655,7 +655,7 @@ apply_aroma <- function(in_file, out_desc = NULL, mixing_file, noise_ics, overwr
655655
regfilt_rscript <- system.file("fsl_regfilt.R", package = "BrainGnomes")
656656
if (!file.exists(regfilt_rscript)) stop("Cannot find fsl_regfilt.R script in the BrainGnomes installation folder")
657657

658-
cmd <- glue("Rscript --vanilla {regfilt_rscript} --input={in_file} --melodic_mix={mixing_file} --filter={noise_ics} --njobs=1 --output={out_file}")
658+
cmd <- glue("{Sys.getenv('R_HOME')}/Rscript --vanilla {regfilt_rscript} --input={in_file} --melodic_mix={mixing_file} --filter={noise_ics} --njobs=1 --output={out_file}")
659659
lg$info("Running fsl_regfilt.R: {cmd}")
660660
system(cmd)
661661
} else {
@@ -827,7 +827,7 @@ confound_regression <- function(in_file, out_desc = NULL, to_regress=NULL, censo
827827
out_file <- res$out_file
828828
}
829829

830-
method <- "lmfit" # for testing
830+
method <- "lmfit" # default -- supports fitting coefficients to good timepoints
831831

832832
if (method == "fsl") {
833833
# convert text file to FSL vest file for fsl_glm to accept it
@@ -848,7 +848,7 @@ confound_regression <- function(in_file, out_desc = NULL, to_regress=NULL, censo
848848
lg$info("Using internal lmfit confound regression function")
849849
Xmat <- data.table::fread(to_regress, sep = "\t", header = FALSE)
850850
if (checkmate::test_file_exists(censor_file)) {
851-
good_vols <- as.logical(readLines(censor_file)) # bad timepoints are 0 in the censor file
851+
good_vols <- as.logical(as.integer(readLines(censor_file))) # bad timepoints are 0 in the censor file
852852
if (sum(good_vols) < length(good_vols)) lg$info("Fitting confound regression with {sum(good_vols)} of {length(good_vols)} volumes.")
853853
}
854854

R/setup_postprocess.R

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,9 @@ setup_confound_calculate <- function(ppcfg = list(), fields = NULL) {
794794
- Filtered regressors: typically continuous-valued signals derived from fMRI (e.g., DVARS, a_comp_cor_*, global signal)
795795
- Unfiltered regressors: typically discrete-valued indicators (e.g., motion_outlier*) that should not be filtered
796796
797-
Wildcards (*) and ranges ([1-6]) can be used to match multiple column names.
797+
The specification can be a regular expression (e.g., a_comp_cor_[0-9+]) or a numeric range using
798+
a syntax like a_comp_cor_<1-10> or a subset like a_comp_cor_<1,2,5>. These will then be expanded
799+
to encompass the matching confound regressors (e.g., the first 10 aCompCor components).
798800
799801
Do you want to create a confound file in postprocessing?\n
800802
"),

0 commit comments

Comments
 (0)