第2章 Call Caching 的工作原理

2.1 什么是 Call Caching

Call Caching 说白了就是让 Cromwell 记住它做过的事情。如果你提交的作业和以前一模一样,直接拿上次的结果给你,省得再跑一遍。

我刚开始用 Cromwell 的时候,对它的理解就是“一个 WDL 执行引擎”。后来有个任务因为参数写错,跑了一天才发现不对。改完参数重新提交,又傻等了一天。那时候我才意识到,要是它能自动识别“同样的输入,同样的命令,同样的环境,就不跑了”该多好。嗯,Call Caching 就是干这个的。

它的核心逻辑其实很简单:每个任务在执行前,Cromwell 会算出一个缓存键。这个键会记录任务的输入、命令、运行时属性、以及使用的 Docker 镜像。如果后面有完全相同的键,它就跳过执行,直接返回之前的结果。

2.2 工作流程:命中与未命中

Call Caching 的流程可以分成两条路:命中(cache hit)和未命中(cache miss)。我画了一张图,你先看一眼整体结构,再往下看细节。

Call Caching 工作流程 提交作业 计算缓存键(哈希) 缓存命中? 返回已缓存结果 实际执行任务 写入缓存 / 返回结果 命中:直接返回结果 未命中:先执行再缓存

具体步骤如下:

  1. 提交作业:Cromwell 接到 WDL 的工作流,开始解析。
  2. 计算缓存键:针对每个 task,Cromwell 收集输入、命令、运行时属性、Docker 镜像等,然后算出一个 SHA-256 哈希值。
  3. 查询缓存:Cromwell 去数据库(默认是 H2,生产环境一般用 MySQL)里找有没有相同的键。
  4. 命中:找到就直接把上一轮的结果文件拷贝过来,跳过执行。
  5. 未命中:没找到就正常跑,跑完后把结果和键存进数据库,下次就能命中了。

关键点:Call Caching 不仅仅是“跳过执行”,它还会把输出文件硬链接(hard link)或拷贝到新结果目录。你看到的是完整的输出,但 Cromwell 根本没用计算资源。

2.3 缓存键的构成

缓存键到底包含哪些东西?我记得有一次排查缓存不命中的问题,费了好大劲才发现是 Docker 镜像的 tag 从 latest 变成了具体的哈希值。虽然镜像内容一样,但 Cromwell 认为键不同。

下表列出了缓存键的所有组成部分:

组件说明举例
命令(command)WDL 中 command 块里的完整 shell 命令
samtools sort -o output.bam input.bam
输入(inputs)所有 task input 的取值(文件内容或值)
File input_bam = "gs://bucket/input.bam"
运行时属性(runtime)cpu、memory、disk、docker 等
cpu: 4, memory: 8 GB, docker: "ubuntu:20.04"
表达式输出(expression outputs)task 中 declaration 的变量值
Int file_count = count_lines(output_file)
后端配置(backend)Cromwell 配置中定义的后端名称和属性
backend: "PAPIv2"

我曾经踩过的坑:在 WDL 里写死了绝对路径的中间文件,结果两次任务的临时目录不同,缓存永远不命中。后来把中间文件改成相对路径或用 write_lines() 函数,才解决。

2.4 哈希计算与校验

缓存键的哈希计算是 Cromwell 内部完成的。你可以在 Cromwell 的日志里看到它算出来的哈希值。比如这样:

2025-03-21 10:22:15,123 INFO  - Call 'Workflow.SampleProcess' cache hit: 
  hash: a1b2c3d4e5f6...
  results found in database

如果想自己验证一下当前任务的缓存键,可以用 Cromwell 提供的 API 接口。我个人习惯在测试阶段开启 call-caching 的 debug 模式,能看到详细的计算过程:

// Cromwell 配置中启用 debug 日志
call-caching {
  enabled = true
  debug-logging = true
}

开启后日志里会多出类似这样的信息:

DEBUG - Computing cache key for 'Workflow.SampleProcess'
  - command: "samtools sort -o output.bam input.bam"
  - inputs: [File(gs://bucket/input.bam)]
  - runtime: {cpu: 4, memory: 8 GB, docker: ubuntu:20.04}
  - hash result: a1b2c3d4e5f6...

如果发现哈希值和预期的不一样,就去检查这些组件项。最常见的差异是 Docker 镜像不一致,或者输入文件的路径换了。

2.5 生效条件与常见陷阱

Call Caching 不是随便就能生效的。它有几个硬性条件:

  • 缓存必须开启:Cromwell 配置中 call-caching.enabled = true(默认就是 true,但有些生产环境会关闭)。
  • 后端支持:所有官方后端(Local、PAPIv2、AWS Batch、SLURM)都支持,但自定义后端可能需要额外实现。
  • 文件内容一致:Cromwell 对比输入文件时,用的是文件内容的 MD5,而不是路径。所以同一个文件放在不同路径下,内容一样就能命中。
  • 运行时属性一致:包括 Docker 镜像、CPU、内存、磁盘等。哪怕只差 1 GB 内存,缓存也不会命中。
  • 工作流版本一致:Cromwell 版本不同可能会改变缓存键计算逻辑,升级 Cromwell 后之前缓存的键可能失效。这是个大坑,我建议升级前先确认兼容性。

我的一个小技巧:在开发阶段,可以在 WDL 里给每个 task 加一个 runtime { ... } 的注释,比如 # cache-version: 1,方便后续排查。虽然 Cromwell 不认注释,但你自己回来查日志时能快速定位。

总结一下:Call Caching 的原理不复杂,但实际用起来细节很多。记住三个核心词:键、哈希、命中。键决定了能不能命中,哈希决定了键的唯一性,命中决定了是不是跳过执行。后面几章我会带你深入看日志、配置文件,以及如何手动调试缓存问题。