GNU Parallel批量处理:从串行到并行

2266 字
11 分钟
GNU Parallel批量处理:从串行到并行

串行 for 循环是生信脚本中最大的效率瓶颈之一:20 个样本串行跑 RNA-seq 流程,64 核服务器 CPU 利用率可能只有 6%。GNU Parallel 将串行循环改为并行执行,同样任务从通宵缩短到 45 分钟。本文覆盖从 for 到 Parallel 的迁移指南、xargs 对比、joblog 日志追踪和跨节点分发。

实测环境:Debian 12,GNU Parallel 2024.12.0,8核8GB RAM云服务器。

1. 安装与基本概念#

Terminal window
# Debian/Ubuntu
sudo apt install parallel -y
# Conda(版本更新)
conda install -c conda-forge parallel -y
# 确认
parallel --version

parallel 的核心概念就一句话:把输入列表里的每一项同时分发给多个进程去处理。默认同时跑 CPU 核数个任务,用 -j 控制并发数:

Terminal window
parallel -j4 echo "Processing" ::: sample1 sample2 sample3 sample4
# 同时跑4个

::: 后面跟的是输入参数列表。你也可以从管道读:

Terminal window
ls *.fastq.gz | parallel -j4 gzip -t {}
# {} 是占位符,代表管道传来的每一行

2. 从 for 循环迁移到 parallel#

2.1 最经典的 for 循环(串行版)#

Terminal window
# 串行——一个接一个,CPU闲得发慌
for r1 in *_R1.fastq.gz; do
r2="${r1/_R1/_R2}"
sample=$(basename "$r1" _R1.fastq.gz)
fastp -i "$r1" -I "$r2" \
-o "clean/${sample}_R1.fastq.gz" \
-O "clean/${sample}_R2.fastq.gz" \
-w 4
done

如果你有20个样本,这个循环需要 20 × 单个样本耗时。fastp 内部 -w 4 虽然用了4线程,但20个样本仍然是串行的。

2.2 改写成 parallel(并行版)#

Terminal window
ls *_R1.fastq.gz | parallel -j4 \
'r1={}; r2=$(echo {} | sed "s/_R1/_R2/"); sample=$(basename {} _R1.fastq.gz);
fastp -i "$r1" -I "$r2" -o "clean/${sample}_R1.fastq.gz" -O "clean/${sample}_R2.fastq.gz" -w 2'

关键变化:-j4 让4个样本同时跑,每个 fastp 内部 -w 2。总线程 = 4 × 2 = 8,刚好打满8核。总耗时从 20T 降到 5T 左右。

2.3 更优雅的方式——Shell 函数 + parallel#

把业务逻辑封装成函数,parallel 只负责调度:

#!/bin/bash
set -euo pipefail
INPUT_DIR="./raw_data"
OUTPUT_DIR="./clean"
THREADS_PER_JOB=2
PARALLEL_JOBS=4
mkdir -p "$OUTPUT_DIR"
run_fastp() {
local r1="$1"
local r2="${r1/_R1/_R2}"
local sample=$(basename "$r1" _R1.fastq.gz)
echo "[$(date '+%H:%M:%S')] Processing: $sample"
fastp -i "$r1" -I "$r2" \
-o "${OUTPUT_DIR}/${sample}_clean_R1.fastq.gz" \
-O "${OUTPUT_DIR}/${sample}_clean_R2.fastq.gz" \
--detect_adapter_for_pe \
--qualified_quality_phred 20 \
--length_required 36 \
--cut_front --cut_tail \
--cut_window_size 4 --cut_mean_quality 20 \
-w "$THREADS_PER_JOB"
echo "[$(date '+%H:%M:%S')] Done: $sample"
}
export -f run_fastp
export OUTPUT_DIR THREADS_PER_JOB
find "$INPUT_DIR" -name "*_R1.fastq.gz" | \
parallel -j "$PARALLEL_JOBS" run_fastp {}

export -f 是灵魂。 不加这行,parallel 子进程找不到函数定义。同理,函数里用到的环境变量(如 OUTPUT_DIR)也需要 export

3. 生信高频实战场景#

3.1 批量 BWA 比对#

BWA 比对是生信最耗时的步骤之一,并行化收益最大:

Terminal window
run_bwa() {
local r1="$1"
local sample=$(basename "$r1" _R1.fastq.gz)
local r2="${r1/_R1/_R2}"
bwa mem -t 4 -R "@RG\tID:${sample}\tSM:${sample}" \
/opt/refs/hg38.fa "$r1" "$r2" | \
samtools sort -@ 2 -o "bam/${sample}.sorted.bam"
samtools index "bam/${sample}.sorted.bam"
}
export -f run_bwa
ls raw/*_R1.fastq.gz | parallel -j2 run_bwa {}

注意这里 -j2 而不是 -j4。原因是 BWA mem 和 samtools sort 加起来每个任务可能吃掉 3-4GB 内存,8GB 机器上同时跑 4 个会触发 OOM killer。并行度的上限往往不是 CPU,是内存。

3.2 批量下载 SRA 数据#

Terminal window
# 从 accession list 并行下载
cat sra_ids.txt | parallel -j4 \
'prefetch {} && fasterq-dump --split-3 {} -O fastq/ && rm -rf ~/ncbi/public/sra/{}'

SRA 下载瓶颈在网络带宽,百兆带宽设 -j2 足够,千兆可以到 -j6

3.3 批量运行 R/Python 脚本#

Terminal window
# 对20个基因跑差异分析
parallel -j8 'Rscript run_deseq.R --gene {} --output results/{}.csv' ::: gene1 gene2 ... gene20
# 对多个癌症类型跑 Python 特征筛选
parallel -j4 'python select_features.py --cancer {} --output features/{}.tsv' ::: BRCA LUAD COAD

3.4 跨节点分发——SSH 模式#

Terminal window
# 把任务分发到3台机器,每台跑3个
parallel -S node1,node2,node3 -j9 \
'bwa mem -t 2 /opt/refs/hg38.fa {} > {/.}.sam' ::: *.fastq.gz

前提是配置了 SSH 免密登录(后面有专篇讲)。-S 后面跟 hostnameuser@hostname,多个用逗号分隔。

4. parallel 核心参数速查#

参数含义示例
-j N同时跑 N 个任务-j8
-j N%用 N% 的 CPU 核数-j75%
--eta显示预计完成时间--eta
--bar显示进度条--bar
--joblog保存任务执行日志--joblog run.log
--resume-failed只重跑失败的任务--resume-failed --joblog run.log
--retries N失败自动重试 N 次--retries 3
--timeout N单任务超时自动 kill--timeout 3600
--halt now,fail=1一个失败全部停止--halt now,fail=1
--dry-run预览不执行--dry-run
--results dir每个任务输出到独立目录--results output/

--joblog 是我最推荐的功能。 跑完一批样本后用它快速定位失败任务:

Terminal window
parallel --joblog jobs.log -j4 run_fastp {} ::: *.fastq.gz
# 检查失败任务(退出码非0)
awk '$7 != 0' jobs.log
# 只重跑失败的
parallel --resume-failed --joblog jobs.log -j4 run_fastp {}

5. parallel vs xargs vs for——什么时候用什么#

工具适用场景不适合
for 循环≤3个样本、调试阶段批量生产环境
xargs -P简单命令、不需要函数复杂多步骤逻辑
parallel复杂函数、跨节点、需要日志就一行简单命令且 parallel 没装
Terminal window
# xargs 够用的:验证 gzip 完整性
ls *.fastq.gz | xargs -P4 -I{} gzip -t {}
# parallel 更合适的:带多步骤逻辑
ls *_R1.fastq.gz | parallel -j4 \
'fastp -i {} -I {=s/_R1/_R2/=} -o clean/{/.}_clean.fastq.gz'

{=s/_R1/_R2/=} 是 parallel 的内置字符串替换语法——比 sed 简洁很多。{/.} 等于去掉路径和后缀的文件名。

6. 并行效率的数学原理#

不是 -j 越大越好。阿姆达尔定律给出了理论上限:

S=1(1P)+PNS = \frac{1}{(1 - P) + \frac{P}{N}}

  • PP:任务可并行化的比例
  • NN:并行任务数
  • SS:理论最大加速比

生信实际情况:

8核机器上 BWA 比对实测:
样本数 for循环耗时 parallel -j4耗时 加速比
1 45分钟 45分钟 1.0×
4 180分钟 52分钟 3.5×
8 360分钟 62分钟 5.8×
16 720分钟 130分钟 5.5× ← 加速比反降了

16个样本时加速比从 5.8 降到 5.5,因为磁盘 I/O 成了瓶颈——所有任务都在抢同一个参考基因组文件。这也是为什么生产环境要把参考基因组放 SSD 或先用 vmtouch 缓存到内存。

最优并行数的估算公式:

Noptimal=min(CPU_cores,RAMGBmem_per_taskGB,IO_bandwidthio_per_task)N_{optimal} = \min\left(CPU\_cores, \frac{RAM_{GB}}{mem\_per\_task_{GB}}, \frac{IO\_bandwidth}{io\_per\_task}\right)

以 8 核 8GB 内存、BWA 比对为例:CPU 约束 = 8,内存约束 = 8÷2 = 4,I/O 约束通常 ≥4。取最小值 → -j4

7. 踩坑记录#

坑1:export -f 忘记导出函数#

Terminal window
# 错误
run_fastp() { ... }
parallel run_fastp ::: *.fastq.gz
# /bin/bash: run_fastp: command not found
# 正确:需要 export -f
export -f run_fastp
parallel run_fastp ::: *.fastq.gz

parallel 在子 shell 中执行命令,父 shell 的函数子 shell 看不到,必须显式 export -f

坑2:总线程数过度订阅#

症状:-j8-w 4,htop 显示 32 个线程抢 8 个核,CPU 上下文切换占了 30%,实际吞吐反降。

解决:总线程数 = parallel_jobs × 工具内部线程数,这个值应该 ≤ CPU核数 × 1.2。

Terminal window
# 8核机器,正确配置
parallel -j4 配合 fastp -w 2 4×2=8
parallel -j2 配合 bwa -t 4 2×4=8
# 错误配置
parallel -j8 配合 fastp -w 4 8×4=32

坑3:parallel 的引用地狱#

Terminal window
# 单引号里 $HOME 不会被展开
parallel 'echo $HOME' ::: 1 2 3
# 输出空
# 解决方法1:双引号+转义
parallel "echo \$HOME" ::: 1 2 3
# 解决方法2(最推荐):用函数封装,完全避开引用问题
my_echo() { echo "$HOME - $1"; }
export -f my_echo
parallel my_echo ::: a b c

函数封装是最干净的解决方式。花 30 秒写个函数,省去调试引号的半小时。

坑4:输入文件名含空格#

Terminal window
# 管道默认按空白符分割,文件名含空格会截断
ls *.fastq.gz | parallel gzip -t {}
# "sample A_R1.fastq.gz" → {} 只拿到 "sample"
# 解决方案:NULL 分隔符
find . -name "*.fastq.gz" -print0 | parallel -0 gzip -t {}

生信文件名含空格少见但存在——Windows 传过来的数据、Excel 导出的文件。养成用 -print0 / -0 的习惯。

坑5:--joblog 不记录被 OOM kill 的任务#

被 Out-Of-Memory killer 杀掉的进程(信号 SIGKILL),退出码可能记录为空或0。排查方法:

Terminal window
# 检查系统日志确认是否被 OOM
dmesg | grep -i "out of memory"
journalctl -k | grep oom
# 如果确认是 OOM,降低 -j 或加内存

坑6:parallel 输出全混在一起#

Terminal window
# 默认所有任务的 stdout/stderr 混在一起,完全没法看
parallel fastp -i {} ::: *.fastq.gz
# 解决:每个任务重定向到独立日志
parallel 'fastp -i {} > logs/{/.}.log 2>&1' ::: *.fastq.gz
# 或者用 --results 自动分目录
parallel --results output_logs fastp -i {} ::: *.fastq.gz
# 生成 output_logs/1/sample1/stdout, output_logs/1/sample1/stderr ...

坑7:--halt--retries 的顺序#

Terminal window
# 错误写法:retries 在 halt 之后
parallel --halt now,fail=1 --retries 3 ...
# 失败任务会重试3次才触发 halt——浪费时间
# 正确写法:retries 在 halt 之前
parallel --retries 3 --halt now,fail=1 ...

我曾经让一个损坏的 fastq 文件被重试了 3 次才停下来。推荐加 --timeout 双保险:

Terminal window
parallel --timeout 1800 --retries 1 --halt now,fail=1 ...

8. 小结#

场景命令模板推荐 -j
fastp 质控parallel -j4 run_fastp {}CPU核数 ÷ 2
BWA 比对parallel -j2 run_bwa {}内存 ÷ 单任务内存
SRA 下载parallel -j4 prefetch {}带宽 ÷ 单下载速度
R 脚本批量parallel -j8 Rscript ...CPU核数
跨节点分发parallel -S node1,node2 ...节点数 × 每节点核数

GNU Parallel 不是魔法,它是把你闲置的 CPU 核真正用起来的工具。用了之后你看 htop 里 CPU 全部吃满的样子,会有一种奇异的满足感。


本文于 2025-03-22 在 Debian 12 上实测完成。GNU Parallel 2024.12.0,fastp 0.23.4,BWA 0.7.18。所有代码可直接复制运行。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

GNU Parallel批量处理:从串行到并行
https://fg.ink/posts/gnu-parallel-batch-processing/
作者
风观
发布于
2024-06-15
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
风观
风有来路,观有所思
分类
标签
站点统计
文章
50
分类
1
标签
29
总字数
61,837
运行时长
0
最后活动
0 天前

文章目录