WDL 概述:了解它的前世今生与核心概念

WDL,全称是 Workflow Description Language。说白了,就是一套用来描述数据处理流程的语言。我第一次接触它是在做大规模基因组数据分析的时候,那时项目里几十个步骤串在一起,光维护 shell 脚本就快崩溃了。后来发现 WDL 能把这些步骤组织成可复用的工作流,嗯,那感觉就像从手写代码进化到了用脚手架。

它由 Broad Institute 开发,最初面向生物信息学领域。但很快,任何需要编排多步骤任务、依赖并行、跨平台运行的团队都开始用。我自己的团队就把 WDL 引入到了机器学习数据管线的构建中。

从一张笨重的图说起:WDL 的诞生

2014 年左右,Broad 的团队在跑大规模基因组分析时发现:

  • 每个分析步骤(比对、排序、去重、变异检测)都是独立的程序
  • 步骤之间有复杂的文件依赖关系
  • 本地跑和云上跑的路径、资源完全不同
  • 每一步出错后重跑成本极高

于是他们设计了一种声明式语言——你只需要告诉系统“我要做什么,输入是什么,输出是什么”,具体怎么调度、怎么并行、怎么重试,交给 WDL 引擎。我记得第一次看到 WDL 代码时,第一反应是:这不就是个带 type 的 Makefile 吗?但真正用起来才发现,它比 Makefile 更擅长处理分布式依赖。

小提示:WDL 的设计哲学是“关注数据流向,而非执行顺序”。你的工作流只需要描述数据从哪个 task 流向哪个 task,引擎自动推导执行顺序。

为什么你需要 WDL?——真实场景

来看看我遇到过的几个典型痛点:

  1. 串行阻塞:任务 A 做完才能做 B,B 做完才能做 C。实际上很多步骤可以并行,比如 B 和 C 都依赖 A,但 B 和 C 之间没有依赖。WDL 可以自动识别这些并行分支。
  2. 环境不一致:本地用 conda,集群用 docker,云上用 singularity。WDL 统一用 runtime 块控制执行环境,不用改流程逻辑。
  3. 失败重跑:一个 10 小时的任务跑到第 9 小时崩溃了,你不想重头再来。WDL 支持断点续跑,只重新执行失败任务及其下游。
  4. 跨平台迁移:项目从本地 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 里,结果重跑时每次都要重新比对,浪费了大量时间。

黄金法则:一个 task 只做一件事,做好就输出。微服务的思想同样适用于 WDL。

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 之间的关系以及数据流向。

WDL 工作流核心结构 Workflow Task A 输入: ref.fa, reads.fq Task B 输入: ref.fa Task C 输入: A.out, B.out A.out B.out in 最终输出 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 来处理多输入,这里只是示意串行依赖。

注意:WDL 中 task 的输入参数数量必须严格匹配。如果你声明了 File sam,就不能直接传入一个 Array[File]。我曾经在调用时忘记加 .sam 后缀,导致引擎报错说类型不匹配——白跑了两小时的重试。

小结

WDL 的本质就是把“怎么做”和“在哪里做”分开。你只需要描述数据流和每个步骤的容器环境,调度、依赖、并行、重试都交给引擎。记住三个核心:Task 是原子步骤,Workflow 是编排容器,Call 是在编排中实例化 task 的入口。掌握了这些,后面的章节就顺了。