WDL 概述:了解它的前世今生与核心概念
WDL,全称是 Workflow Description Language。说白了,就是一套用来描述数据处理流程的语言。我第一次接触它是在做大规模基因组数据分析的时候,那时项目里几十个步骤串在一起,光维护 shell 脚本就快崩溃了。后来发现 WDL 能把这些步骤组织成可复用的工作流,嗯,那感觉就像从手写代码进化到了用脚手架。
它由 Broad Institute 开发,最初面向生物信息学领域。但很快,任何需要编排多步骤任务、依赖并行、跨平台运行的团队都开始用。我自己的团队就把 WDL 引入到了机器学习数据管线的构建中。
从一张笨重的图说起:WDL 的诞生
2014 年左右,Broad 的团队在跑大规模基因组分析时发现:
- 每个分析步骤(比对、排序、去重、变异检测)都是独立的程序
- 步骤之间有复杂的文件依赖关系
- 本地跑和云上跑的路径、资源完全不同
- 每一步出错后重跑成本极高
于是他们设计了一种声明式语言——你只需要告诉系统“我要做什么,输入是什么,输出是什么”,具体怎么调度、怎么并行、怎么重试,交给 WDL 引擎。我记得第一次看到 WDL 代码时,第一反应是:这不就是个带 type 的 Makefile 吗?但真正用起来才发现,它比 Makefile 更擅长处理分布式依赖。
为什么你需要 WDL?——真实场景
来看看我遇到过的几个典型痛点:
- 串行阻塞:任务 A 做完才能做 B,B 做完才能做 C。实际上很多步骤可以并行,比如 B 和 C 都依赖 A,但 B 和 C 之间没有依赖。WDL 可以自动识别这些并行分支。
- 环境不一致:本地用 conda,集群用 docker,云上用 singularity。WDL 统一用
runtime块控制执行环境,不用改流程逻辑。 - 失败重跑:一个 10 小时的任务跑到第 9 小时崩溃了,你不想重头再来。WDL 支持断点续跑,只重新执行失败任务及其下游。
- 跨平台迁移:项目从本地 HPC 迁移到云端,WDL 脚本几乎不用改,改改 runtime 里的镜像和资源即可。
| 场景 | 传统做法 | WDL 做法 |
|---|---|---|
| 多步骤串联 | 手写 shell 脚本 + qsub | 声明 task + workflow |
| 并行加速 | 手动拆分数据、写并行循环 | scatter 自动分配并行任务 |
| 失败重试 | 人工检查日志、手动 rerun | 引擎自动重试(可配置次数) |
| 环境隔离 | 每个步骤手动加载模块 | runtime 内指定 Docker 镜像 |
WDL 核心概念扫盲:Task、Workflow、Call
WDL 只有三个核心元素,你想想看,三个就够用:
- Task:最小执行单元。定义输入、命令、输出、运行时要求。一个 task 通常对应一个独立的程序或脚本。
- Workflow:编排多个 task 的容器。定义 task 之间的数据依赖和执行顺序。
- Call:在 workflow 内部调用 task 的动作。一个 task 可以被多次 call,每次指定不同的输入。
我刚开始学的时候犯过一个错:把 task 写得特别大,里面包含好几个独立步骤。后来发现 WDL 的设计哲学是要让 task 尽量“原子化”——每个 task 只做一件事,这样便于并行、复用和调试。避坑指南:我曾经把一个比对、排序、去重全写在一个 task 里,结果重跑时每次都要重新比对,浪费了大量时间。
WDL 的语法骨架
来看一个完整的 WDL 脚本,我挑了一个最简单的例子:
version 1.0
task hello {
input {
String name
}
command {
echo "Hello, ${name}!"
}
output {
File greeting = stdout()
}
runtime {
docker: "ubuntu:latest"
memory: "1 GB"
cpu: 1
}
}
workflow greet {
input {
String name
}
call hello {
input: name = name
}
output {
File result = hello.greeting
}
}
注意几个关键点:
version 1.0声明 WDL 版本,现在主流是 1.0 或 1.1。command块里可以直接写 shell 命令,WDL 会把它包装成脚本执行。output声明输出文件或值,en 后续 task 可以通过task_name.output_name引用。runtime块定义资源需求和执行环境。
为什么这里用 File greeting = stdout()?因为我喜欢把标准输出作为文件输出,这样下游步骤可以当文件来读。WDL 允许直接输出字符串或数字,但我个人习惯统一用 File 类型,方便后续处理。
把核心概念画成一张图
下面是我为你画的 WDL 工作流结构图,展示了 Task、Workflow、Call 之间的关系以及数据流向。
这张图里,Task A 和 Task B 可以并行执行,因为它们都只依赖外部输入,互不依赖。Task C 需要等待 A 和 B 都完成后才能开始。WDL 的 scatter 机制还能让这种并行更进一步——比如对 100 个样本同时执行 Task A,每个样本独立运行一个实例。
一个最小示例:串联与并行
最后,再来看一个稍微复杂的例子,展示 WDL 如何表达串行与并行的混合:
version 1.0
task align {
input {
File reads
File reference
}
command {
bwa mem ${reference} ${reads} > aligned.sam
}
output {
File sam = "aligned.sam"
}
runtime {
docker: "biocontainers/bwa:v0.7.17"
memory: "4 GB"
cpu: 4
}
}
task sort {
input {
File sam
}
command {
samtools sort ${sam} > sorted.bam
}
output {
File bam = "sorted.bam"
}
runtime {
docker: "biocontainers/samtools:v1.9"
memory: "2 GB"
cpu: 2
}
}
task markdup {
input {
File bam
}
command {
sambamba markdup ${bam} dedup.bam
}
output {
File dedup = "dedup.bam"
}
runtime {
docker: "biocontainers/sambamba:v0.8.0"
memory: "6 GB"
cpu: 4
}
}
workflow pipeline {
input {
Array[File] read_files
File ref
}
scatter (r in read_files) {
call align { input: reads = r, reference = ref }
}
call sort { input: sam = align.sam }
call markdup { input: bam = sort.bam }
output {
Array[File] aligned = align.sam
File sorted = sort.bam
File deduped = markdup.dedup
}
}
这里 scatter 让多个 read 文件并行比对。一旦对齐完成,自动进入 sort 任务。WDL 会为每个 align 实例生成单独的 align.sam,但 sort 只接收一个 sam 文件?嗯,这里有个细节:如果 align 的输出是 Array[File],那么 sort 就需要用 scatter 或 select 处理。实际项目中我会用 flatten 或 select_first 来处理多输入,这里只是示意串行依赖。
File sam,就不能直接传入一个 Array[File]。我曾经在调用时忘记加 .sam 后缀,导致引擎报错说类型不匹配——白跑了两小时的重试。
小结
WDL 的本质就是把“怎么做”和“在哪里做”分开。你只需要描述数据流和每个步骤的容器环境,调度、依赖、并行、重试都交给引擎。记住三个核心:Task 是原子步骤,Workflow 是编排容器,Call 是在编排中实例化 task 的入口。掌握了这些,后面的章节就顺了。