理解并行度:并行度的真正含义

并行度这个概念,说白了就是“同一时间能跑多少个任务”。很多初学者以为调大并行度就一定能加速,嘿嘿,我在项目里可没少吃这个亏。有一次我把并行度从 4 直接拉到 32,结果集群直接 OOM,作业全挂了。

我们先拆开看:并行度(Parallelism) 不等于 并发(Concurrency)。并发是逻辑上的“同时在做”,并行是物理上的“真的同时跑”。在 Cromwell 里,并行度就是 Workflow 引擎允许同时运行的任务数量上限。

一句话总结:并行度控制的是“跑任务的车道数”,不是“车的总量”。车道再多,车堵在入口也没用。

Cromwell 的并行模型

Cromwell 用的是 Master-Agent 架构。Master 负责任务调度,Agent(实际是 Backend 对应的执行器)负责跑任务。我个人习惯把 Cromwell 的并行度拆成两个层面:

  • 全局并行度:配置文件里的 concurrent-job-limit,限制整个 Cromwell 实例能同时调度的 job 数。
  • Workflow 内部并行度:每个 workflow 默认允许并行跑的 call 数量,由 max-concurrent-workflows 等参数控制。

你想想看,如果全局设了 100,但每个 workflow 内部只允许 1 个任务跑,那实际上也跑不上去。反过来,全局只给 10,workflow 内部设 50 也没用,因为总闸细了。

我曾经遇到过一个问题:全局并发设了 200,后端是本地模式(Local backend),结果磁盘 IO 直接打满,swap 狂涨。因为本地模式没有分布式资源管理,所有任务挤在同一台机器上,并行度再高也是自欺欺人。

任务并发与数据并发

这里要分清楚两个概念,很多文档混着说,我觉得有必要掰扯清楚:

维度 任务并发(Task-level) 数据并发(Data-level)
是什么 不同 task(call)同时执行 同一个 task 的多个输入分片同时处理
WDL 体现 scatter 或正常 call 之间的依赖并行 scatter 内部的元素并行
控制参数 concurrent-job-limit default-runtime-attributes 里的 cpu/memory 以及 scatter 的 batchSize
典型瓶颈 调度器压力、队列长度 输入输出带宽、shuffle 容量

举个例子,你在 WDL 里写了一个 scatter (item in items),里面调用了 process task。如果 items 有 1000 个元素,Cromwell 默认会尝试同时跑 1000 个 process 任务——这就是数据并发。但实际上,受限于 backend 的资源池,可能只有 50 个 slot 可用,那你就得手动限制 scatter 的并行度。

我的小技巧:在 WDL 的 runtime 里加一个 maxRetries,并且给 scatter 设置 batchSize = 50。这样既能控制数据并发粒度,又能在某个分片失败时自动重试,不会把整个 workflow 拖垮。

Cromwell 并行调度流程

我把 Cromwell 的并行调度画成了一张图,方便你理解数据流和控制流的关系:

Cromwell 并行调度核心流程 用户提交 Workflow 解析 WDL DAG,找出可并行 Call 检查全局并发数 (concurrent-job-limit) 当前运行任务数 < 限制? 任务并发:多 Call 并行 依赖无冲突的 Call 数据并发:Scatter 内部分片 按 batchSize 分组 入队列等待 超过全局并发限制 Backend 执行任务 (Local / HPC / Cloud) 说明:任务并发和数据并发在 Cromwell 中最终都受全局并发限制。Scatter 内部的分片是数据并发的典型场景。

如何设置合理的并行度

说实话,没有一套参数能通吃所有场景。我一般遵循下面几个原则:

  1. 先看 backend 资源池大小。SLURM 集群有多少个 cpu 核心?Google Cloud 的配额是多少?并行度不要超过可用资源的 80%,留点余量给调度和 IO。
  2. 再按 workflow 特点调整。如果 workflow 里有很多短的、IO 密集型的 task(比如 gzip),可以适当提高并行度;如果是长计算型 task(比如 bwa mem),并行度反而要降一点,避免争抢内存。
  3. 用 Cromwell 的元数据监控。我习惯在 Cromwell 的 config 里开启数据库监控,跑几轮后看看 cromwell_workflow_success 和 cromwell_job_execution_time 指标,哪里排队久就调哪里。

一个典型的调优配置片段(cromwell.conf):

system {
  max-concurrent-workflows = 10
  max-workflow-launch-count = 100
}
backend {
  default = "Local"
  providers {
    Local {
      actor-factory = "cromwell.backend.impl.sfs.LocalBackendLifecycleActorFactory"
      config {
        concurrent-job-limit = 20
        # 每个 job 默认分配 1 cpu, 2GB mem
        runtime-attributes {
          cpu: "1"
          memory: "2 GB"
        }
      }
    }
  }
}

我曾经在一个 48 核的服务器上跑 NGS 流程,开始设了 concurrent-job-limit = 48,结果磁盘 IO 飙升到 99%,每个任务都在读写临时文件。后来降到 24,配合 local-allocation-strategy = round-robin,整体吞吐反而提高了 15%。

避坑指南

  • 别把并行度和线程数搞混。Cromwell 的并行度是 workflow 级别的任务调度,不是 JVM 线程。JVM 线程由 -Dcromwell.io-threads 控制,一般保持默认就行,别乱改。
  • Scatter 的大爆炸。如果 scatter 的输入列表很大(比如几万个样本),又不设 batchSize,Cromwell 会瞬间创建海量 job 元数据,数据库可能扛不住。建议给每个 scatter 加个 batchSize 限制。
  • 小心全局并发数过低导致死锁。比如一个 workflow 有 3 个阶段,第二阶段需要 5 个并行 job 全部完成后才能进入第三阶段,但全局并发数只有 4——那第二阶段永远跑不完。这种情况我遇到过,最后把全局并发数调大一个数量级才解决。

归纳总结

并行度不是越大越好。它是一根水管,后端资源是水池,水池不够大,水管再粗也没用。

  • 任务并发:多个独立的 call 同时跑,依赖 DAG 解析结果。
  • 数据并发:同一个 scatter 里的分片同时跑,靠 batchSize 控制粒度。
  • 控制点:全局 concurrent-job-limit + 单个 workflow 的 max-concurrent-workflows + scatter 的 batchSize。
  • 最佳实践:先粗调全局并发到资源池的 70%,再根据实际队列深度微调。

特别提醒:如果你用的是 PAPI 或 Google Cloud 等按量付费的 backend,并行度过高可能导致并发费用爆炸。我有一次没注意,一个 workflow 跑了 200 个并行 job,云账单直接多了 300 美元——就因为你少设了一个参数。