最近,我接手了一项颇具挑战性的任务:在一台高性能计算(HPC)集群上完整复现一篇名为《Physics-informed deep generative learning for quantitative assessment of the retina》的论文,其核心代码库是 RetinaSim。这项工作的目标不仅仅是简单地运行代码,而是要在一个与开发者原始环境可能截然不同的、受严格管理的计算环境中,完整地重现其复杂的软件栈和模拟流程。

我们的战场是一个典型的HPC集群,名为“Barkla2”,它运行着 Rocky Linux 9,并使用 Slurm 作为作业调度器。这意味着所有操作都必须通过命令行完成,并且任何耗时较长的计算任务都必须作为批处理作业提交,而不能直接在登录节点上运行。RetinaSim 项目本身是一个复杂的混合体,它融合了 Python 脚本作为主流程控制,同时依赖于一个用 C++ 编写的高性能流体动力学模拟器(Reanimate)和一个用 .NET(C#)编写的血管生成程序(RetinaGen)。这种异构的技术栈几乎注定了在新的环境中会遇到各种意想不到的编译和运行时问题。

我的第一步是获取代码并制定一个初步的 Slurm 脚本。代码通过 git clone 获取,其目录结构清晰地展示了各个子模块。初步分析 READMECMakeLists.txt 等文件后,我了解到编译 Reanimate 需要 CMake 和 C++ 编译器,而 RetinaGen 则需要 .NET SDK。Python 部分则依赖于一份 requirements.txt 文件。

基于这些信息,我编写了第一个版本的 Slurm 脚本。这个脚本的目标是按顺序完成所有准备工作:加载必要的环境模块(如 GCC 编译器、CMake、Python),创建 Python 虚拟环境并安装依赖,然后编译 C++ 和 .NET 子模块,最后尝试运行主 Python 脚本 main.py。这是一个标准的、看似直接的流程,然而,现实很快就给了我第一击。

第一次失败:找不到 CMakeLists.txt

当我提交第一个作业后,几秒钟内它就失败了。检查错误日志,我看到了一个熟悉而又基础的错误信息:CMake Error: The source directory "..." does not appear to contain CMakeLists.txt。这个错误的意思是,CMake 在我指定的目录中没有找到它的核心配置文件 CMakeLists.txt

我的第一反应是检查脚本中的路径。在 Slurm 脚本里,我为了编译 Reanimate 子模块,先切换到了 retinasim/Reanimate/Reanimate 目录,然后执行了 cmake . 命令。这个命令告诉 CMake 使用当前目录作为源码树的根目录。然而,通过 ls -R 命令仔细检查项目结构后,我发现 CMakeLists.txt 文件实际上位于 retinasim/Reanimate 目录下,而不是它的子目录 retinasim/Reanimate/Reanimate。这是一个典型的相对路径错误,尤其是在处理嵌套的子项目时很容易犯。

诊断过程非常直接。既然 CMakeLists.txt 在父目录,那么解决方案就是告诉 CMake 去父目录寻找它。在 Unix-like 系统中,“..” 代表父目录。因此,我需要将编译命令从 cmake . 修改为 cmake ..。这个小小的改动意义重大:它指示 CMake 从当前目录(Reanimate/Reanimate)向上一层查找 CMakeLists.txt,同时将当前的 Reanimate/Reanimate 目录作为构建目录(build directory)。这样,CMake 就能找到配置文件,并将所有编译生成的文件(如 Makefiles、对象文件和最终的可执行文件)都放在我所在的目录,保持了源码树的整洁。

这个小插曲虽然简单,但它提醒了我,在处理不熟悉的、尤其是包含多语言和多构建系统的复杂项目时,第一步永远是仔细地、耐心地审阅其目录结构和构建脚本。想当然地认为配置文件会存在于某个“理所当然”的位置,是导致初级错误的常见原因。修复这个问题后,我满怀信心地重新提交了作业,期待着编译过程能顺利推进。然而,HPC 环境的复杂性远超于此,一个更深层次的问题正在等待着我。

第二次失败:NVHPC 与 GCC 的冲突

修正了 CMake 路径问题后,编译过程确实开始了,但很快就戛然而止。这次的错误日志更加晦涩难懂,不再是简单的文件未找到,而是指向了 C++ 库的头文件内部,报出了一系列 error: extra text after expected end of number 的错误。这些错误都源自 armadillo_bits/include_superlu.hpp 这个文件,它是 Armadillo 线性代数库的一部分,用于集成 SuperLU 稀疏矩阵求解器。

错误信息本身非常奇怪。它抱怨在一个数字后面出现了多余的文本,而且都指向同一行预处理宏:#if __has_include(ARMA_INCFILE_WRAP(ARMA_SLU_HEADER_A)) && __has_include(ARMA_INCFILE_WRAP(ARMA_SLU_HEADER_B))__has_include 是一个现代 C++ 编译器支持的特性,用于在编译时检查某个头文件是否存在。这种错误通常暗示着编译器在解析这个宏时遇到了问题,它可能不认识这个语法,或者宏展开后的内容不符合它的预期。

起初,我怀疑是 Armadillo 或 SuperLU 库的版本与代码不兼容。但是,我使用的 Armadillo 和 SuperLU 是通过 HPC 管理员推荐的 Spack 包管理器加载的,版本相对较新,理论上不应该有这种基础语法问题。我开始仔细审查作业的输出日志,寻找编译过程的更多线索。很快,我在 CMake 配置阶段的输出中找到了关键信息:

-- The C compiler identification is NVHPC 25.3.0 -- The CXX compiler identification is NVHPC 25.3.0

谜底揭晓了。尽管我通过 module load gcc/14.2.0 加载了 GCC 编译器,但 CMake 却自动选择了 NVIDIA HPC SDK 的编译器(NVHPC)。这在许多现代 HPC 集群上是一个常见现象,因为 NVHPC 编译器通常与 GPU 环境深度集成,系统可能会将其作为默认的 C++ 编译器。

问题由此产生:我通过 Spack 加载的 armadillosuperlu 库,几乎可以肯定是使用系统的主力编译器 GCC 14.2.0 编译的。而现在,CMake 却让 NVHPC 编译器去编译依赖于这些 GCC 编译库的 Reanimate 代码。Armadillo 的头文件为了实现跨平台兼容性,内部包含了大量针对不同编译器的预处理宏。出错的那一行 __has_include 很可能就是特定于 GCC 或 Clang 的现代特性,而我所使用的 NVHPC 25.3.0 版本可能对其支持不佳,或者在宏展开时产生了语法不兼容的结果。这是一个典型的编译器混用导致的“环境地狱”(environment hell)。

解决方案必须是强制 CMake 使用我指定的 GCC 编译器,以确保整个编译工具链的一致性。为了实现这一点,我采取了几个步骤。首先,在 Slurm 脚本中,我导出了两个至关重要的环境变量:export CC=gccexport CXX=g++。这两个变量是 Unix-like 环境中约定俗成的,用于指定 C 和 C++ 编译器的默认路径。当 CMake 启动时,它会检查这些环境变量,并优先使用它们指定的编译器。

然而,仅仅设置环境变量还不够保险。CMake 有一个非常重要的特性,那就是它会缓存第一次配置时检测到的编译器和环境信息到一个名为 CMakeCache.txt 的文件中。如果我不清除这个缓存,即使设置了新的环境变量,CMake 仍会固执地使用上一次失败时找到的 NVHPC 编译器。因此,在运行 cmake 命令之前,必须先清理构建目录,删除所有旧的 CMake 生成文件。我在脚本中加入了 rm -rf CMakeCache.txt CMakeFiles cmake_install.cmake Makefile 这条命令,确保每次都是一次全新的配置。

为了进一步确保万无一失,我还给 cmake 命令本身加上了参数,直接指定编译器:cmake -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ ..。这种方式的优先级最高,可以覆盖任何环境变量或系统默认设置。

这次的修复过程远比第一次复杂,它要求我对 HPC 的模块系统、Spack 包管理器、CMake 的工作原理以及不同 C++ 编译器之间的差异都有所了解。这个问题也凸显了在 HPC 环境中进行软件构建时,“显式优于隐式”的原则是多么重要。不能依赖工具的自动检测,必须明确地告诉它使用哪个编译器、哪个库,才能在复杂的环境中获得可预测和可重复的结果。带着这份更深的理解,我再次提交了作业,这一次,编译过程终于成功地生成了目标文件,但链接阶段又抛出了新的挑战。

第三次失败:链接器找不到库

编译过程顺利通过,所有的 .cpp 文件都被成功地编译成了 .o 对象文件。然而,在最后的链接阶段,当 ld(链接器)试图将所有对象文件和外部库链接成最终的可执行文件 Reanimate 时,它失败了。错误信息清晰明了:

/usr/bin/ld: cannot find -lopenblas /usr/bin/ld: cannot find -lsuperlu

链接器抱怨说,它找不到 openblassuperlu 这两个库。这非常令人困惑,因为我在脚本的开头明确地通过 module load openblas/0.3.29/gcc-14.2.0spack load superlu@5.3.0 加载了它们。理论上,环境应该已经配置好了。

为了诊断这个问题,我需要理解编译和链接过程中的库搜索路径是如何工作的。在 Unix-like 系统中,有两个关键的环境变量:LD_LIBRARY_PATHLIBRARY_PATHLD_LIBRARY_PATH 主要用于运行时,它告诉动态链接器在程序启动时去哪里寻找共享库(.so 文件)。而 LIBRARY_PATH 则用于编译时,它为链接器 ld 提供了一个额外的搜索路径列表,用于查找静态库(.a 文件)和共享库。module loadspack load 命令通常会正确地更新 LD_LIBRARY_PATH,但它们是否会更新 LIBRARY_PATH,或者 cmake 是否会自动使用 LIBRARY_PATH,则不一定。

CMakeLists.txt 文件中使用了 link_libraries(-llapack -lopenblas -lsuperlu) 这样的命令。-l<name> 语法只是告诉链接器需要一个名为 lib<name>.solib<name>.a 的库,但并没有告诉它去哪里找。链接器会搜索一系列默认路径(如 /usr/lib, /usr/local/lib)以及由 -L/path/to/lib 参数指定的路径。显然,openblassuperlu 的安装路径并不在默认搜索路径中,并且 CMake 没有自动地将它们的路径添加进去。

我的解决方案是,必须在 Slurm 脚本中找出这些库的实际安装路径,并显式地将它们传递给 CMake。这需要一些脚本编程技巧。对于 Spack 安装的包,我可以使用 spack location -i <package_name> 命令来获取其安装根目录。例如,spack location -i superlu@5.3.0 会返回类似 /mnt/data2/users/.../superlu-5.3.0-... 的路径。库文件通常位于其下的 liblib64 目录中。对于通过 module 加载的包,通常会有一个名为 OPENBLAS_ROOT 或类似的环境变量指向其安装根目录。如果没有,我可以退而求其次,解析 LD_LIBRARY_PATH 环境变量,找到包含 openblas 字样的路径。

在脚本中,我添加了自动检测这些路径的逻辑,并将它们存储在 SUPERLU_LIBARMADILLO_LIBOPENBLAS_LIB 等变量中。接下来,我需要将这些路径信息传递给链接器。最直接和稳健的方法是使用 CMake 的 CMAKE_EXE_LINKER_FLAGS 变量。我构造了一个字符串,如 LINKER_FLAGS="-L/path/to/superlu/lib -L/path/to/openblas/lib",然后通过 -DCMAKE_EXE_LINKER_FLAGS="$LINKER_FLAGS" 参数将其传递给 CMake。这会确保在最终的 g++ 链接命令中,这些 -L 标志被正确地添加进去,从而让 ld 能够找到所需的库文件。同时,为了保险起见,我也将相应的头文件路径 (-I/path/to/include) 传递给了 CMAKE_CXX_FLAGS

这个问题再次印证了在 HPC 环境中显式指定路径的重要性。仅仅加载一个模块并不总是足以让所有工具链(尤其是像 CMake 这样复杂的构建系统)都能无缝工作。开发者需要理解从编译到链接的整个过程,并知道如何在必要时手动介入,将环境信息“翻译”成构建工具能够理解的语言。这次修复让我对 CMake 与环境模块的交互有了更深的认识。编译和链接都成功后,Reanimate 可执行文件终于生成了。接下来轮到 .NET 部分了。

第四次失败:找不到 .NET 运行时

C++ 部分的编译大功告成,.NET 的 dotnet build 也顺利完成了,生成了 RetinaGen.dll。眼看就要成功了,我满心欢喜地等待 Python 脚本的执行。然而,当主程序 main.py 运行到调用 RetinaGen 的地方时,作业再次崩溃。错误日志显示:

You must install .NET to run this application. App: /.../RetinaGen/bin/Debug/net6.0/RetinaGen .NET location: Not found

这个问题非常令人费解。我在脚本开头已经 spack load dotnet-core-sdk@6.0.25 了,并且 dotnet build 命令也成功执行,这证明 dotnet SDK 是存在的。为什么在运行时,由 Python 脚本通过 subprocess.Popen 调用的同一个程序却找不到 .NET 运行时了呢?

诊断这个问题需要理解 .NET 在 Linux 上的部署方式以及子进程的环境继承机制。dotnet build 生成的 RetinaGen 文件实际上是一个“AppHost”可执行文件。它是一个小型的原生启动器,其主要作用是找到系统上的 .NET 运行时,加载它,然后再将 RetinaGen.dll(真正的程序集)交给运行时来执行。当这个 AppHost 启动器找不到 .NET 运行时时,就会报上述错误。

原因很可能在于环境传播。虽然我在 Slurm 脚本的顶层加载了 Spack 环境,设置了 PATH 等变量,使得 dotnet 可执行文件可见,但当 Python 解释器作为一个进程启动,然后它再派生(fork)出一个子进程来执行 RetinaGen 时,这个新的子进程可能没有完整地继承父进程(即 Slurm 作业脚本的 shell)的所有环境变量,特别是那些由 Spack 动态设置的、用于定位 .NET 运行时的变量(如 DOTNET_ROOT)。

为了解决这个问题,我决定采用一种更稳健的 .NET 程序调用方式。与其直接运行 AppHost(RetinaGen),我可以直接调用 dotnet CLI,并将 DLL 文件作为参数传递给它,即 dotnet RetinaGen.dll。这种方式的优点在于,我直接使用了 dotnet 这个可执行文件,它本身就知道如何去寻找与之关联的运行时,从而绕过了 AppHost 的环境搜索问题。只要 dotnetPATH 中,这个命令就应该能工作。

为了实现这个改动,我不能直接修改仓库中的 Python 源代码,因为这会影响代码的可移植性和完整性。最好的办法是在 Slurm 脚本中动态地“打补丁”。我再次使用了 sed 这个强大的流编辑器。我首先在脚本中定位到调用 RetinaGen 的 Python 文件,即 retinasim/vascular.py。然后,我编写了一条 sed 命令,在运行 main.py 之前,将 vascular.pycmd = [exe_path, fname] 这行代码替换为 cmd = ['dotnet', exe_path, fname],同时也将 EXE_PATH 的定义从指向 RetinaGen 修改为指向 RetinaGen.dll。为了安全起见,我在修改前先创建了一个备份文件 vascular.py.bak

这个解决方案展示了一种在不修改原始代码库的情况下,适应特定运行环境的高级技巧。在批处理环境中,能够非交互式地动态修改代码以解决环境问题,是一项非常实用的能力。它不仅解决了眼前的问题,还保持了代码库的清洁,并且所有修改都记录在 Slurm 脚本中,使得整个过程完全可重现。在应用了这个补丁之后,.NET 部分的调用也终于成功了。但就在我以为大功告成时,最后一个与图形界面相关的拦路虎出现了。

第五次失败:Open3D

在解决了所有编译和环境依赖问题后,程序终于开始执行核心的模拟逻辑。然而,在 generate_lsystem 函数中,它又一次崩溃了。这次的错误来自 Python 的运行时,与 Open3D 库有关:

[Open3D WARNING] GLFW Error: Failed to detect any supported platform [Open3D WARNING] GLFW initialized for headless rendering. [Open3D WARNING] GLFW Error: OSMesa: Library not found [Open3D WARNING] Failed to create window AttributeError: 'NoneType' object has no attribute 'background_color'

错误信息的前半部分是 Open3D 的警告。它尝试初始化一个图形窗口(通过 GLFW 库),但失败了,因为它在一个没有物理显示器的“无头”(headless)计算节点上运行。然后它尝试回退到使用 OSMesa 进行离屏渲染,但也失败了,因为系统中没有找到相应的库。最终,由于无法创建窗口,vis.create_window() 调用可能返回了 None

最后的 AttributeError 证实了这一点。代码的下一行 opt.background_color = np.asarray(self.bgcolor) 试图在一个 None 对象上设置背景颜色,从而导致了程序崩溃。分析 main.py 中的 generate_lsystem 函数调用,我发现其中有一个参数 screen_grab=True。这意味着即使我没有请求交互式显示,代码仍然试图初始化一个渲染环境来保存一张图片。

对于在 HPC 上运行的科学计算任务而言,中间过程的可视化通常是不必要的,甚至是应该避免的。我们的目标是获得最终的模拟数据,而不是调试图片。因此,最直接、最务实的解决方案就是禁用这个截图功能。

我再次求助于 sed。在 Slurm 脚本中,运行 main.py 之前,我添加了一条命令来修补 main.py 文件:sed -i 's/screen_grab=True/screen_grab=False/g' main.py。这条命令会在 main.py 文件中查找所有 screen_grab=True 的实例,并将其替换为 screen_grab=False。我还额外修改了 argparse 的默认值,确保即使不提供命令行参数,所有与绘图相关的默认行为也都被关闭。这从根本上避免了任何调用 Open3D 窗口创建功能的代码路径。

这次的修复体现了在研究和工程实践中一种重要的思维方式:分清主次。修复 HPC 节点上复杂的无头渲染环境(可能需要管理员权限安装系统级依赖)是一个耗时且偏离主题的任务。而我的核心目标是复现模拟。通过一个简单的代码补丁绕过这个问题,让我能够专注于最终的科学产出,而不是在环境配置的泥潭中挣扎。

在应用了这个最终的补丁之后,我重新提交了作业。这一次,日志中不再有错误。我看到了程序按预期一步步执行的输出:创建 L-system 种子网络、写入 Amira 文件、启动 CCO 血管生成……程序终于在 Barkla2 集群上完整地、成功地运行了起来。

这次从头到尾的复现过程,充满了挑战,但每一步的调试和解决都加深了我对 HPC 环境、多语言项目构建和软件依赖管理的理解。从简单的路径错误,到复杂的编译器和链接器问题,再到运行时环境的差异,这一系列的障碍正是科学计算软件在迁移和复现过程中普遍面临的缩影。通过系统性的分析、大胆的假设、小心的验证,以及一些脚本技巧,我们最终能够驯服这头复杂的“猛兽”,让科学研究得以在强大的计算资源上顺利进行。