/* * ============================================================================ * ViroProfiler - local execution config * ============================================================================ * Portable overrides for deng-lab/viroprofiler under Singularity. * * nextflow run deng-lab/viroprofiler -c local.config ... * * PATHS * Nothing is hardcoded. Everything hangs off VP_HOME, which is $VP_HOME if * set, otherwise the directory you launch nextflow from. * * RESOURCES * CPUs and RAM are DETECTED from the machine and then divided into tiers * below. Override with VP_MAX_CPUS / VP_MAX_MEM_GB if you want to leave more * headroom for other work: * VP_MAX_CPUS=8 VP_MAX_MEM_GB=20 nextflow run ... * ============================================================================ */ def VP_HOME = System.getenv('VP_HOME') ?: System.getProperty('user.dir') // --------------------------------------------------------------------------- // Detect the machine, then reserve a little for the OS and desktop. // --------------------------------------------------------------------------- def detectedCpus = Runtime.runtime.availableProcessors() def detectedMemGb = { try { def line = new File('/proc/meminfo').readLines().find { it.startsWith('MemTotal') } return (long) ((line.replaceAll(/\D/, '') as long) / 1024 / 1024) } catch (Exception e) { return 16L // conservative fallback } }() // Leave 1 core and ~3 GB for the OS: a workstation that is 100% committed to // the pipeline becomes unusable, and an OOM at 100% commitment kills tasks. // Swap is the safety net for the peaks, not a substitute for this headroom. def MAX_CPUS = (System.getenv('VP_MAX_CPUS') ?: "${Math.max(1, detectedCpus - 1)}") as int def MAX_MEM_GB = (System.getenv('VP_MAX_MEM_GB') ?: "${Math.max(8, detectedMemGb - 3)}") as int // Tiers. Heavy tasks take the machine; light ones run several at a time. def CPU_HEAVY = MAX_CPUS def CPU_MEDIUM = Math.max(2, (int) (MAX_CPUS / 2)) def CPU_LIGHT = Math.max(2, (int) (MAX_CPUS / 6)) // >=2: fastp/FastQC still thread def MEM_HEAVY = "${MAX_MEM_GB}.GB" def MEM_MEDIUM = "${Math.max(6, (int) (MAX_MEM_GB / 2))}.GB" def MEM_LIGHT = "${Math.max(4, (int) (MAX_MEM_GB / 6))}.GB" // iPHoP is deliberately below CPU_HEAVY - see the note on its block below. def CPU_IPHOP = Math.max(1, Math.min(8, MAX_CPUS - 2)) singularity { enabled = true autoMounts = true cacheDir = "${VP_HOME}/nextflow-singularity-cache" // 1. -B binds the project root so containers can read db/ and write work/. // Do NOT add a bind that mounts over $HOME. Nextflow separately binds // the pipeline's scripts from ~/.nextflow/assets/.../bin onto PATH, and // mounting over your home directory hides that bind - every process // calling a pipeline script then dies with // run_checkv.sh: command not found (exit 127) // Redirect HOME instead, via the env scope below. // // 2. --writable-tmpfs gives each container a small in-memory overlay. // DRAMV needs it: DRAM hardcodes its database to /opt/conda/db2, so the // pipeline does 'ln -s /opt/conda/db2', which fails on Singularity's // read-only squashfs: // ln: failed to create symbolic link '/opt/conda/db2': // Read-only file system // Binding over /opt/conda/db2 does NOT work instead - 'ln -s' is called // unconditionally and a pre-existing path fails with "File exists". runOptions = "-B ${VP_HOME} --writable-tmpfs" } // Writable $HOME for containers, without shadowing the real one. Nextflow uses // --no-home, so $HOME points into the image's read-only layer and VirSorter2 // dies creating ~/.virsorter: // [Errno 30] Read-only file system: '/home//.virsorter' env { HOME = "${VP_HOME}/container-home" } executor { name = 'local' cpus = MAX_CPUS memory = "${MAX_MEM_GB}.GB" queueSize = 8 pollInterval = '5 sec' } process { // ====================================================================== // WHY EVERY PROCESS IS LISTED EXPLICITLY // // ViroProfiler's processes carry only CONTAINER labels // (viroprofiler_base, viroprofiler_host, ...). The nf-core resource labels // (process_low/medium/high) are largely absent or do not take effect, so // processes silently fall back to the pipeline default of 1 CPU / 10 GB. // Observed consequences, all of them real: // // SPADES ran with '--threads 1 --memory 10' despite label // 'process_high', then died at its own 10 GB ceiling: // mmap(2) failed. Reason: Cannot allocate memory // DRAMV ran single-threaded (6 min of work stretched out) // VCONTACT ran with '-t 1' // IPHOP ran blastn with '--num_threads 1' - 4 hours on stage 1 of 6 // // params.max_cpus / max_memory CANNOT fix this: check_max() is a ceiling // applied to a request, so it only caps values downward, never raises them. // Only an explicit withName: block does, and withName has the highest // precedence of any selector. // ====================================================================== // Sensible floor for anything not matched below. cpus = CPU_LIGHT memory = MEM_LIGHT time = '48.h' // Database setup is network-bound; retry transient download failures. // CAVEAT: the DB_* processes guard with a non-atomic 'if [ ! -d ]', // so a failure that already created its directory makes every retry // short-circuit to "already exists" and exit 0. A retry therefore cannot // repair such a task, it only hides it. Verify with setup_databases.sh // --verify, never by trusting the pipeline's exit status. withLabel: 'setup' { errorStrategy = { task.attempt <= 3 ? 'retry' : 'finish' } maxRetries = 3 cpus = CPU_MEDIUM memory = MEM_MEDIUM } // ---------------------------------------------------------------- HEAVY // Each of these asks for the whole memory budget, so the executor runs // them one at a time. That is intended: they are the peak-RAM stages. // Assembly. Memory-bound; '--memory' is taken from this directive, so an // under-declaration becomes SPAdes' own hard ceiling and it aborts. // maxForks=1 because two concurrent assemblies will exhaust any machine. withName: 'SPADES' { cpus = CPU_HEAVY memory = MEM_HEAVY maxForks = 1 } // vConTACT2 merges the input into the full ProkaryoticViralRefSeq database // (~421k protein profiles), so runtime is driven by the reference, not by // input size. Threads help the Diamond all-vs-all stage; the profile and // ClusterONE stages are single-threaded regardless, so this stays slow // (hours) no matter what you give it. withName: 'TAXONOMY_VCONTACT' { cpus = CPU_HEAVY memory = MEM_HEAVY maxForks = 1 } // DRAM-v. Every stage is an hmmsearch or mmseqs search and threads well; // memory stays modest as threads rise. Measured at 4 threads: ~6 min total // (kofam 2m30s, VOGDB 1m26s, pfam 45s, peptidase 44s, viral 34s, dbCAN 3s). withName: 'DRAMV' { cpus = CPU_HEAVY memory = MEM_HEAVY } // iPHoP. Stages 1-5 (blastn, CRISPR, WIsH, VHM, PHP) are CPU-bound and // thread well. Stage 6 (RaFAH) runs a random forest in R whose memory // scales with THREAD COUNT, because it replicates its input per thread: // 1 thread : anon-rss 19,325,368 kB (~19.3 GB) - OOM-killed // 4 threads: anon-rss 20,469,616 kB (~20.5 GB) - OOM-killed // Both kills happened on a 31 GB machine WITH NO SWAP. // // Throttling all six stages to 1 CPU to protect stage 6 was a mistake: it // left blastn 4 hours into stage 1 while 11 cores sat idle. // // The approach now is: run at CPU_IPHOP (8) with swap present to absorb the // peak, AND fall back automatically if that is still too much. The measured // growth is ~1.2 GB per 3 extra threads, so 8 threads should land near // 22 GB - inside the 28 GB budget - but that is an EXTRAPOLATION, not a // measurement, so it is backed by a retry ladder rather than trusted: // // attempt 1 -> 8 threads (fast path) // attempt 2 -> 4 threads (measured 20.5 GB) // attempt 3 -> 1 thread (measured 19.3 GB, lowest possible) // // 137 = 128+9, i.e. SIGKILL, which is what the OOM killer sends; the others // are the usual out-of-memory / abort signals. Retrying is safe here: iPHoP // writes into a fresh task directory each attempt, so unlike the DB_* // processes there is no non-atomic guard for a retry to short-circuit. withName: 'VIRALHOST_IPHOP' { cpus = { task.attempt == 1 ? CPU_IPHOP : (task.attempt == 2 ? 4 : 1) } memory = MEM_HEAVY maxForks = 1 errorStrategy = { task.exitStatus in [104, 134, 137, 139, 143, 247, 251] && task.attempt <= 3 ? 'retry' : 'terminate' } maxRetries = 2 } // --------------------------------------------------------------- MEDIUM // Several of these can run concurrently within the memory budget. withName: 'CHECKV|VIBRANT|VIRSORTER2|DVF|TAXONOMY_MMSEQS|MAPPING2CONTIGS.*|ABUNDANCE|CONTIGINDEX|CONTIGLIB.*|DECONTAM|BBMAP_ALIGN|VRHYME|EMAPPER|REPLIDEC|BRACKEN.*' { cpus = CPU_MEDIUM memory = MEM_MEDIUM } // ---------------------------------------------------------------- LIGHT // Fast, low-memory steps. Keeping these small is what lets them overlap // with each other instead of queueing behind a heavyweight. withName: 'FASTQC|FASTP|BACPHLIP|GENEPRED.*|NRPROT|NRGENE|NRSEQS|VIRCONTIGS_PRE|TAXONOMY_MERGE|RESULTS_TSE|MULTIQC|CUSTOM_DUMPSOFTWAREVERSIONS|ABRICATE' { cpus = CPU_LIGHT memory = MEM_LIGHT } } params { max_cpus = MAX_CPUS max_memory = "${MAX_MEM_GB}.GB" max_time = '48.h' use_iphop = true use_dram = true } // Printed once at startup so the allocation is visible in the run log rather // than something you have to reverse-engineer from a failure. System.err.println """\ [local.config] detected ${detectedCpus} CPUs / ${detectedMemGb} GB RAM [local.config] using ${MAX_CPUS} CPUs / ${MAX_MEM_GB} GB [local.config] tiers heavy ${CPU_HEAVY}c/${MEM_HEAVY} medium ${CPU_MEDIUM}c/${MEM_MEDIUM} light ${CPU_LIGHT}c/${MEM_LIGHT} iphop ${CPU_IPHOP}c """.stripIndent()