调试的第一个也是最简单的方法是添加Info语句,以缩小代码的细分部分。然而,这种方法最终要比学习如何使用gdb之类的调试器花费更多的时间。代码分析过程通常只应用于功能正常的程序,目的是提高计算效率。

提示:了解如何使用调试器:如果OpenFOAM是并行运行的,那么在不同的MPI进程中没有指定的代码执行顺序,这使得Info语句及其并行对等语句Pout或Perr对调试没有用处。

6.2.1 使用GNU调试器(gdb)进行调试

当OpenFOAM在编译时没有进行任何优化时,使用GNU调试器(gdb)调试代码更为有益。优化由编译器执行,通常不会干扰为已编译代码生成的调试信息。然而,分析和检查编译器未优化的代码可以更深入地了解隐藏的计算瓶颈。

用于OpenFOAM的编译器标志被绑定到多组选项中,这些选项可以根据目标配置进行交换。

编译器选项位于$WM_PROJECT_DIR/etc/bashrc全局配置脚本中,如下所示:

#- Optimised, debug, profiling:
# WM_COMPILE_OPTION = Opt | Debug | Prof
export WM_COMPILE_OPTION=Opt

要使用gdb在调试模式下使用OpenFOAM,必须按以下方式设置$WM_COMPILE_OPTION环境变量:

export WM_COMPILE_OPTION=Debug

为了使此更改生效,必须重新编译OpenFOAM。对于所有需要调试的自定义库和应用程序,也需要重新编译。请记住,使用debug选项编译的代码的执行时间要长得多。

提示:使用gdb进行调试非常简单,官方网站www.gnu.org/software/gdb。

对于诸如segmentation fault(访问错误的内存地址)或floating point exception(除以零)的错误,代码调试可能比较简单。代码执行中的错误触发系统信号,如SIGFPE(浮点异常信号)和SIGSEV(分段冲突信号),并被调试器捕获。然后调试器允许用户浏览代码以查找错误、设置断点、检查变量值等等。

为了显示实际的gdb,本节将介绍使用gdb进行调试的示例。本教程位于ofprimer/applications/test/testDebugging目录下的示例代码库中。

本教程包含一个测试应用程序,其中包含一个函数模板,用于在模拟的所有时间步长上计算给定字段的调和平均值。由于调和平均值涉及计算,其中是变量值,如果代码在包含零值的变量上执行,则会出现浮点异常(SIGFPE)。

函数模板是在fvc::命名空间中定义的,其行为类似于其余的OpenFOAM操作。然而,它的功能确实略有减少。因此,函数模板定义和声明与测试应用程序打包在一起,这不是(也不应该是)标准实践。

可用于此示例的教程模拟案例是ofbook-cases/chapter4/rising-bubble-2D。使用参数-field alpha.water调用testDebugging应用程序。risingBubble-2D案例产生以下错误:

?>  testDebugging -field alpha1
(snipped header output)
Time = 0
#0 Foam::error::printStack(Foam::Ostream&) at ??:?
#1 Foam::sigFpe::sigHandler(int) at ??:?
#2 ? in /usr/lib/libpthread.so.0
#3 ? in $FOAM_USER_APPBIN/testDebugging
#4 ? in $FOAM_USER_APPBIN/testDebugging
#5 __libc_start_main in /usr/lib/libc.so.6
#6 ? in $FOAM_USER_APPBIN/testDebugging
Floating point exception (core dumped)

$FOAM_USER_APPBIN变量会扩展到机器上的相应路径。调试此问题的第一步是启动gdb,并结合testDebugging。testDebugging的所有组件都必须在调试模式下编译,这还包括所有感兴趣的库以及OpenFOAM平台。

gdb testDebugging

这将启动gdb的控制台,用于执行任何需要调试的程序:

(gdb)

在gdb的这个调试控制台中,任何命令都可以在run命令的前面执行以进行调试。对于testDebugging应用程序,这意味着alpha1也必须作为附加参数传递:

(gdb) run -field alpha1

执行上述命令后,再次出现SIGFPE错误,但包含更多详细信息:

Program received signal SIGFPE, Arithmetic exception.
0x0000000000414706 in Foam::fvc::harmonicMean<double> 
(inputField=...) at testDebugging.C:79
79 resultField[I] = (2. / resultField[I]);
(gdb)

由于OpenFOAM及应用程序在debug模式下编译,gdb直接指向源代码中负责SIGFPE的那一行。对于testDebugging应用程序的最基本示例,手动分析源代码可能会得到相同的见解。随着算法复杂性的增加,手动搜索bug成功的可能性越来越小。虽然插入Info语句是初学者用于调试的第一种方法,但最终它所耗费的时间远远超过学习一些基本gdb命令所需的时间。另一方面,调试器可以:使用存储在内存堆栈上的信息单步执行函数,一次又一次执行循环,更改变量值,在执行中设置断点,根据变量值调整断点,以及调试器文档中提供的许多其他高级选项。

为了使用gdb查看基本工作流,假设上述错误发生在复杂的代码库中。此代码位于具有非内聚类的库中,这些类与跨数百行的fat接口和成员函数强耦合。在这种情况下,程序员需要检查信号线上方和下方的代码,以掌握执行的上下文。调试器可以显示信号的路径:从测试应用程序中的顶级调用,一直到引发此错误的低级容器。通过在gdb控制台中执行frame,可以在gdb中获得此信息:

Program received signal SIGFPE, Arithmetic exception.
0x0000000000414706 in Foam::fvc::harmonicMean<double> 
(inputField=...) at testDebugging.C:79
79 resultField[I] = (1. / resultField[I]);
(gdb) frame
#0 0x0000000000414706 in Foam::fvc::harmonicMean<double> 
(inputField=...) at testDebugging.C:79
79 resultField[I] = (1. / resultField[I]);

由于testDebugging示例仅包含main函数,因此可以使用单堆栈框架,其中存储了有关函数目标代码的信息。进行多个函数调用时,有多个帧可用。SIGSEV信号的最低帧通常指向基本的OpenFOAM容器UList。然而,错误不太可能是由UList引起的,更可能是由自定义代码引起的。在这种情况下,容器(继承自UList)中的寻址错误是导致错误的原因。

选择frame 0并使用list命令列出源代码,可以缩小错误的位置:

(gdb) frame 0
(gdb) list
75 volumeField& resultField = resultTmp();
76
77 forAll (resultField, I)
78 {
79 // SIGFPE.
80 resultField[I] = (1. / resultField[I]);

因此,SIGFPE信号的罪魁祸首是第80行。要在第80行放置断点,必须执行break命令并重新运行testdebug:

(gdb) break 80
Breakpoint 1 at 0x4146cd: file testDebugging.C, line 80.
(gdb) run
The program being debugged has been started already.
Breakpoint 1, Foam::fvc::harmonicMean<double>  (inputField=...) at
testDebugging.C:80
80 resultField[I] = (1. / resultField[I]);

在第80行中放置断点后,可以在此位置计算变量I:

(gdb) print I
$1 = 0

打印resultField[I]显示其值为0。为了检查resultField[I]的不同值的影响,可以使用gdb手动设置它们:

(gdb) set resultField[I]=1
(gdb) c
Continuing.
Breakpoint 1,
Foam::fvc::harmonicMean<double>  (inputField=...)
at testDebugging.C:80
80resultField[I] = (1. / resultField[I]);
(gdb) c
Continuing.
Program received signal SIGFPE, Arithmetic exception.
0x0000000000414706 in
Foam::fvc::harmonicMean<double>  (inputField=...)
at testDebugging.C:80
80 resultField[I] = (1. / resultField[I]);
(gdb) print I
$2 = 1
(gdb)

对于这个简单的示例,很明显,对于resultField,至少有两个I值会导致0。根据定义,字段resultField永远不能为0,因此下一步是调查变量是否已正确预处理。

提示:此示例的源代码中已经包含了问题的解决方案-取消对标记行的注释可以使应用程序正确运行。

6.2.2 代码分析

使用性能度量的代码分析对于CFD软件至关重要,因为CFD软件不仅必须准确、健壮,而且在单个CPU核上必须快速,对于大型问题,在多个CPU核上运行时必须快速。

使用valgrind评测应用程序评测代码相对简单。valgrind允许开发人员发现代码中的计算瓶颈,并使用调用图和类似图以图形方式显示它们。在优化代码以提高效率时,可以使用这些信息更有效地集中精力。通常,使用估计的90-10或80-20规则分布性能,这意味着90(80%)的计算通常由10(20%)的代码执行。

与前面关于使用gdb进行调试的部分类似,代码需要使用调试编译选项进行编译。优化的高级形式与软件设计直接相关:适当地选择算法和数据结构。一旦有效地选择了算法和数据结构,就有可能执行所谓的低级优化(例如循环展开)。

复杂性是用来计算算法和数据结构效率的参数。用大写字母“O”表示:例如:,其中n表示操作的元素数量。当有一种复杂度较低的替代算法可用时,通常不建议深入研究低级优化。这同样适用于数据结构选择:仔细选择数据结构是绝对必要的。有关容器结构的更多信息,可以从C++数据结构文档中的标准模板库(STL)中获得(有关详细信息,请参见Josuttis[1])。OpenFOAM容器不是基于STL的(尽管其中一些容器提供STL编译器迭代器接口),但它们的功能非常相似,例如:

DynamicList类似于std::vector。如果容器大小小于其容量,则两者都可以直接访问具有复杂性和插入复杂性的元素。如果插入操作产生的大小大于当前容量,则插入复杂性将与成比例(见[2])。

DLListstd::list类似,插入复杂性(容器中的任何位置)为,访问复杂性为

OpenFOAM中的许多算法工作都与非结构化有限体积网格的特定owner-neighbour寻址有关,具有算法代码自动并行的优点。对于标准OpenFOAM库代码,数据结构的选择介于四个主要容器族之间:

  • List
  • DynamicList
  • 链表*LList*
  • 作为哈希表实现的关联映射

在为特定算法选择容器之前,必须事先检查容器接口。否则,所选容器可能无法正确满足算法的需要,导致执行时间更长。

示例应用程序testProfiling可以使用一个非常简单的示例来说明DynamicField容器的这一点。尝试在系统上的任何位置执行评测测试应用程序(它不需要在OpenFOAM simulation案例目录中执行):

?>  testProfiling -containerSize 1000000

传递给testProfiling测试应用程序的-containerSize选项设置附加到两个不同DynamicField对象的元素数:第一个对象初始化为null,第二个对象初始化为与传递给应用程序的整数值对应的大小。对于在DynamicField末尾追加元素的代码部分,已将计时代码添加到应用程序中。您可以执行为容器大小提供不同值的应用程序,并查看初始化容器和具有初始化大小的容器之间的时间差异。作为DynamicList容器,如果插入后容器的大小小于初始容器容量,则DynamicField在容器末尾插入元素时具有恒定的复杂性,否则在末尾插入(附加)操作的复杂性与容器大小成线性关系。这意味着,如果容器的大小很大,那么它将对代码的效率产生更大的影响;如果容器存储复杂的对象,那么会增加不必要的n−1附加在容器末端的每个元素的构造。

在调试模式下构建示例源代码存储库后,可以使用valgrind评测testProfiling应用程序:

?>  valgrind --tool=callgrind testProfiling -containerSize 1000000

Valgrind可以使用各种工具,如捕捉缓存未命中、检查程序是否存在内存泄漏等,但这超出了本书的范围。有关这些主题的更多信息,请参阅valgrind官方文档。

执行上述命令将生成一个名为callgrind.out.ID的文件,其中ID是进程标识号。输出文件可以使用kcachegrind打开,kcachegrind是一个开源应用程序,用于可视化valgrind的输出。正如您可能在testProfiling应用程序生成的计时输出中所注意到的,valigrind显示,大约62%的总执行时间用于将元素附加到未初始化的DynamicField对象,大约14%用于将元素附加到预初始化的DynamicField对象。

这使我们得出结论,尽管DynamicList和DynamicField是动态的,但一旦超出容量,在这样的容器的末端附加元素是需要付出代价的。如果我们的算法不需要直接访问元素,如果它一个接一个地循环元素,那么使用基于堆的链表可能是更好的选择。然而,分配非顺序内存块和指针间接寻址也会占用CPU周期,因此很难事先知道容器的正确选择。这个问题的答案是以泛型编程的形式出现的,以一种仔细而周到的方式将算法与容器分离(这并不总是可能的),并分析代码。