
Run many `verywise` analyses in parallel (on SLURM)
Serena Defina
2026-07-31
Source:vignettes/articles/04-run-slurm-array.Rmd
04-run-slurm-array.RmdDefine a job array
The run_vw_*()" functions in verywise are
designed to run one vertex-wise analysis: i.e. a single
model in one hemisphere.
However, in practice, you are probably interested in both hemispheres, and possibly you’d like to assess multiple models (i.e. looking a more than one brain outcome, or at more than one set of predictors and so on).
This kind of design is intentional. Keeping each run modular makes the workflow easier to debug, and gives you better control over resource allocation. Let me explain :)
You can of course simply run your analyses sequentially by
calling run_vw_*() multiple times with different arguments
/ specifications. But if you have many analyses to run, this can quickly
become slow and inconvenient.
This article shows an example of how to run many
verywise analyses in parallel using
SLURM job arrays, which are a common workflow on HPC
clusters.
A) Create a list of analysis specifications
Start by creating an analysis.R script (you can call
this however you like of course, but I am low on fantasy today). This
script has two jobs, broadly: define the set of analyses you will run
and run one of those analysis. We will call this script as many times as
there are analyses in the set (and jobs in the SLURM array).
The exact content of analysis.R will depend on your
study, but the basic structure looks something like this:
: define paths and constants. These are things that will be the same across all the analyses (note: you don’t really need to set these up as variables, but I think it makes the code cleaner and easier to maintain, and I am in charge here so we do this how I like it).
# Load verywise
library(verywise)
# Define project paths and constants ----------------------------------------------
proj_dir <- "path/to/main/project/directory"
fs_home <- "/path/to/FREESURFER_HOME"
subj_dir <- "path/to/freesurfer/data"
outp_dir <- file.path(proj_dir, "results")
pheno_filepath <- file.path(proj_dir, 'phenotype_clean.rds')
# this part of the formula will be constant across all analyses
covariates <- 'birth_weight + SES + ethnicity'
random_effects <- '(1 | id)'Step 2: Read SLURM environment variables. There are two key variables you can use to manage the parallel workflow:
-
analysis_id(the array task ID) tells the script which analysis to run -
n_cores(number of CPUs per task) tellsverywisehow many resources (CPU cores) can be used to run each analysis
We will define these values in the SLURM job script below (be patient).
# Get SLURM parameters ------------------------------------------------------------
analysis_id <- as.integer(Sys.getenv('SLURM_ARRAY_TASK_ID', unset = 1))
n_cores <- as.integer(Sys.getenv('SLURM_CPUS_PER_TASK', unset = 1))Step 3: Build a grid of analyses. It’s time to define all combinations of parameters (i.e. combinations of hemispheres, outcomes, models, datasets etc) that you want to run.
In this example I want to analyze both hemispheres (lh
and rh), three different outcomes (thickness,
area and w_g.pct), and a two model
specifications: one including an interaction between sex and age
(*), and one without this interaction (+).
This gives us a total of 12 analyses (2 hemispheres x 3 outcomes x 2 models).
The base R function expand.grid() is a useful way to
create such a grid of all possible parameter combinations, but you can
also create this manually (for example, if only certain combinations
make sense for you).
: We then use the analysis_id variable to select one
specific parameter combination (i.e. a row in the grid) and use that to
define the analysis.
# Define parameter space ==========================================================
hemis <- c('lh','rh')
outcs <- c('thickness', 'area', 'w_g.pct')
model <- c('*', '+')
param_grid <- expand.grid('hemi'=hemis, 'outc'=outcs, 'mode'=model)
# Select and process current parameter combination --------------------------------
params <- param_grid[analysis_id, ]
hemi <- as.character(params$hemi)
outc <- paste0('vw_', params$outc)
model_spec <- as.formula(paste(outc, '~ age', params$mode, 'sex +', covariates, '+', random_effects)): Run the model with the selected combination of parameters, for example:
# Run analysis --------------------------------------------------------------------
output <- run_vw_lmm(formula = model_spec,
subj_dir = subj_dir,
outp_dir = outp_dir,
hemi = hemi,
pheno = pheno_filepath,
n_cores = n_cores,
FS_HOME = fs_home,
save_ss = TRUE)B) Create a job script
All of this was fun, but we still need to run the actual analyses. So
lastly create a SLURM job script (e.g. run_analyses.sh),
which will submit the job array to the scheduler and get stuff done.
This script will tell the scheduler:
- how many jobs to launch (i.e. how many analyses) using the
--arrayargument. This range should match the number of rows inparam_grid(or a subset of them) - how many resources to allocate to each job (so that each analysis
will run in parallel) using the
--cpus-per-task - which R script to run (e.g. our
analysis.R) - (optionally) you can also control other aspects, e.g. set a time limit, logs, and email notifications
#!/bin/bash
#SBATCH --job-name=verywise_job_array
#SBATCH --array=1-12 # 12 models in total (see above)
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=64 # NOTE R limit parallel processes = 124
#SBATCH --time=1-00:00:00 # 1 day time limit, just to be on the safe side
#SBATCH --error=logs/analysis_%a
#SBATCH --output=logs/analysis_%a
#SBATCH --mail-type=END,FAIL
#SBATCH --mail-user=your.email@email.com
# Load modules (not always but sometimes necessary on some HPCs) -------------
module load R
# Run analyses (in parallel) -------------------------------------------------
Rscript analysis.RThen, inside your project directory, submit the job array to SLURM
with: sbatch run_analyses.sh.
Performance tips
verywise is designed to be fast, but runtime depends
heavily on:
- the number of vertices in the template
- model complexity (e.g. number of predictors and variable transformations)
- the random-effects structure (e.g. number of groups, correlated random terms)
- the number of imputations,
- and the available hardware.
Simple models with a moderate number of fixed effects and a single random term often finish within minutes under modest resources (e.g. < 10 cores). But more complex models can take hours or even days.
Say you got greedy. You are running several high-resolution LMM models with many parameters (e.g. > 15), a complex random effect structure (e.g. multiple correlated random effects and > 10.000 groups), in a multiple imputation setting (e.g. with 30 datasets). Even with a good parallel set up (e.g. I tried this with 64 workers on my hands), you can expect such analyses to take a 1 or 2 days to finish running.
Here are a few tricks can help mantaining speed even when scaling up to large analysis batches.
Avoid implicit parallelization
On some systems, low-level matrix libraries such as BLAS, LAPACK,
OpenBLAS, MKL, or Accelerate may use their own hidden threading. This
can interfere with the explicit parallelization used by
verywise and actually make your analyses slower. If you
feel like processing is taking too long for your liking, this may be a
likely cause.
To avoid this problem, it is best to disable implicit threading. You
can do so, for example by setting the following environment variables in
your shell or in your SLURM job script (before calling
Rscript [...]):
Number of cores and chunk size
Using more cores will not always get you faster analyses. Using a very large number of cores (e.g. > 120) may instead cause worker startup and scheduling issues on some systems (e.g. addionally you may run into R parallel processes limits).
Internally, verywise spits the job into “chunks” (i.e. groups of vertices) that are independently run in parallel. You can further optimize the speed of your analyses by picking a smart chunk size: e.g. by splitting the size of your template of choice by the number of cores available, and then further divide this number so that the chunks remain reasonably small (1000-2000 max) to avoid bottlenecks and memory issues.
Precompute and re-use the super-subject matrix
If you find yourself running multiple analyses on the same dataset (i.e. same hemisphere and same brain measure), you can save some computation time by precomputing the “super-subject matrix” only once, and re-using it across all your analyses.
This is especially helpful when you are fitting many alternative model specifications to the same imaging data.
library(verywise)
pheno <- readRDS("/path/to/phenotype_clean.rds")
supsubj_dir <- "/path/to/save/ss"
ss <- build_supersubject(
subj_dir = "/path/to/freesurfer/subjects",
folder_ids = pheno[, 'folder_id'],
supsubj_dir = supsubj_dir,
measure = "thickness",
hemi = "lh",
fs_template = "fsaverage",
n_cores = 4
) Then simply use supsubj_dir as your
subj_dir argument in run_vw_lmm(), and we will
do the rest :)
Tune models at low resolution first
Before launching a large batch of high-resolution analyses, test the
model on a lower-resolution template such as fsaverage3 or
fsaverage4. You can do this using the
fs_template argument. This is useful for:
- checking that the model converges,
- estimating approximate runtime and resources (i.e. CPU and memory) you need
- tuning the model (i.e. find the optimal specification)
Once the model behaves as expected, rerun the final analysis on the
high-resolutionfsaverage template.
Use lmm_control with
calc.derivs = FALSE
You may cut ~20% computation time by disabling derivative
calculations through
lmm_control = lmerControl(calc.derivs = FALSE), but this is
usually better left for later-stage analyses once you are already
confident that the model behaves well.
During model development, diagnostics are often more valuable than a speed-up. See this article for more information on model convergence and fit.
Next article: Inspect and visualize verywise results