并行高性能计算2并行化规划

2 并行化规划
并行项目的规划步骤
版本控制和团队开发工作流程
了解性能容量和限制
制定程序并行化计划
开发并行应用程序或使现有应用程序并行运行,一开始可能会感觉具有挑战性。初涉并行化的开发人员往往不知道从何入手,也不知道可能会遇到什么陷阱。本章重点介绍开发并行应用程序的工作流模型。该模型提供了在开发并行程序时从何处入手以及如何保持进展的背景。一般来说,最好以小增量的方式实现并行性,这样如果遇到问题,可以回滚最后几次提交。这种模式适合敏捷项目管理技术。

让我们设想一下,您被分配了一个新项目,从空间网格(喀拉喀托火山示例)中加速和并行化一个应用程序。这可能是一个图像检测算法,也可能是一个火山灰羽流的科学模拟,或者是一个由此产生的海啸波浪的模型,也可能是所有这三种算法。要成功开展并行项目,可以采取哪些步骤?

直接进入项目是很有诱惑力的。但如果不经过深思熟虑和准备,就会大大降低成功的几率。首先,您需要为并行化工作制定一个项目计划,因此我们在这里首先对这一工作流程中的步骤进行一个高层次的概述。然后,随着本章的深入,我们将深入探讨每个步骤,重点关注并行项目的典型特征。

快速开发: 并行工作流程
首先,您需要让您的团队和应用程序为快速开发做好准备。由于您现有的串行应用程序是在空间网格上运行的,因此可能会有很多小的改动,并需要经常进行测试以确保结果不会改变。代码准备工作包括设置版本控制、开发测试套件以及确保代码质量和可移植性。团队准备工作将围绕开发流程展开。一如既往,项目管理将涉及任务管理和范围控制。

要为开发周期做好准备,您需要确定可用计算资源的能力、应用程序的需求以及性能要求。系统基准测试有助于确定计算资源的限制,而剖析则有助于了解应用程序的需求及其最昂贵的计算内核。计算内核指的是应用程序中计算密集且概念独立的部分。

根据内核配置文件,您将规划例程并行化和实施更改的任务。只有当例程并行化且代码保持可移植性和正确性时,实施阶段才算完成。满足这些要求后,更改将提交到版本控制系统。在提交增量变更后,流程将再次从应用程序和内核配置文件开始。

2.1 接触新项目: 准备工作

在此阶段,您需要建立版本控制、为应用程序开发测试套件并清理现有代码。通过版本控制,您可以跟踪一段时间内对应用程序所做的更改。它允许您在日后快速撤销错误并追踪代码中的错误。通过测试套件,您可以在每次修改代码时验证应用程序的正确性。如果再加上版本控制,这将成为快速开发应用程序的强大设置。

有了版本控制和代码测试,您现在就可以着手清理代码了。好的代码易于修改和扩展,不会出现不可预测的行为。良好、整洁的代码可以通过模块化和内存问题检查得到保证。模块化是指将内核作为独立的子程序或函数来实现,并明确定义输入和输出。内存问题包括内存泄漏、越界内存访问和使用未初始化内存。以可预测的高质量代码开始并行工作,可促进快速进展和可预测的开发周期。如果最初的结果是由于编程错误造成的,则很难与串行代码相匹配。

最后,您需要确保代码的可移植性。这意味着多个编译器可以编译您的代码。拥有并保持编译器的可移植性,可以让您的应用程序面向更多的平台,而不是您目前所考虑的平台。此外,经验表明,开发可与多种编译器配合使用的代码有助于在代码版本历史记录中出现错误之前发现这些错误。高性能计算领域瞬息万变,可移植性可以让您更快地适应未来的变化。

准备时间与实际并行所花费的时间不相上下的情况并不少见,尤其是对于复杂代码而言。将准备工作纳入项目范围和时间估算,可避免项目进度受阻。在本章中,我们假定您是从串行或原型应用程序开始的。不过,即使您已经开始并行化代码,也能从这一工作流程策略中获益。接下来,我们将讨论项目准备工作的四个组成部分。

2.1.1 版本控制: 为并行代码创建安全保险库

在并行化过程中,不可避免地会发生许多变化,您会突然发现代码被破坏或返回不同的结果。在这种情况下,通过备份到工作版本来恢复是至关重要的。

在我们的场景中,您的图像检测项目已经有了一个版本控制系统。但灰羽模型从未有过任何版本控制。随着深入研究,您发现在不同开发人员的目录中实际上有四个版本的灰羽代码。当有一个版本控制系统在运行时,您可能需要检查一下团队在日常操作中使用的流程。也许团队认为改用 “拉取请求 ”模式是个好主意,在这种模式下,修改会在提交之前发布,供其他团队成员审核。或者,你和你的团队可能觉得 “推送 ”模式的直接提交更符合并行化任务的快速、小规模提交。在 “推送 ”模式中,提交是直接提交到版本库的,无需审核。在我们这个没有版本控制的灰羽应用程序例子中,当务之急是在开发人员之间建立起控制代码分歧的机制。

版本控制有很多选择。如果没有其他偏好,我们建议使用最常见的分布式版本控制系统 Git。分布式版本控制系统允许使用多个版本库数据库,而不是集中式版本控制系统中使用的单一集中式系统。分布式版本控制对于开源项目以及开发人员在笔记本电脑上、远程地点或其他无法连接到网络或靠近中央版本库的情况下工作非常有利。在当今的开发环境中,这是一个巨大的优势。但代价是增加了复杂性。集中式版本控制仍然很流行,而且更适合企业环境,因为只有一个地方存在源代码的所有信息。集中式控制还能为专有软件提供更好的安全性和保护。

关于如何使用 Git,有很多好书、博客和其他资源;我们在本章末尾列出了一些。在第 17 章中,我们还列出了其他一些常见的版本控制系统。这些系统包括免费的分布式版本控制系统(如 Mercurial 和 Git)、商业系统(如 PerForce 和 ClearCase)以及集中式版本控制系统(如 CVS 和 SVN)。无论使用哪种系统,你和你的团队都应该频繁提交。如果不想在主版本库中有大量的小提交,可以使用 Git 等版本控制系统折叠提交,或者为自己维护一个临时版本控制系统。

提交信息是提交者传达任务内容和做出某些改动的原因的地方,无论是为自己还是为当前或未来的团队成员。每个团队对这些信息的详细程度都有自己的偏好;我们建议在提交信息中尽可能多地使用细节。这是您今天勤奋工作、避免日后出现混乱的好机会。

一般来说,提交信息包括摘要和正文。摘要提供了一个简短的声明,清楚地说明了该提交涵盖了哪些新变更。此外,如果您使用的是问题跟踪系统,摘要行将引用该系统中的问题编号。最后,正文包含了提交背后的大部分 “原因 ”和 “方法”。

2.1.2 测试套件: 创建稳健可靠应用程序的第一步

测试套件是一组问题,用于测试应用程序的各个部分,以确保代码的相关部分仍能正常工作。除了最简单的代码外,测试套件对其他代码来说都是必需的。每次更改后,都应测试得到的结果是否相同。这听起来很简单,但有些代码在使用不同的编译器和不同数量的处理器时,结果可能会略有不同。

举例说明: 验证结果的喀拉喀托场景测试

您的项目有一个海洋波浪模拟应用程序,可生成经过验证的结果。验证结果是与实验数据或实际数据进行比较的仿真结果。经过验证的仿真代码非常宝贵。您不希望在并行化代码时失去这些。

在我们的方案中,您和您的团队在开发和生产中使用了两种不同的编译器。第一种是 GNU 编译器集 (GCC) 中的 C 编译器,这是一种无处不在、免费提供的编译器,散布在所有 Linux 发行版和许多其他操作系统中。C 编译器通常被称为 GCC 编译器。您的应用程序也使用市售的英特尔 C 编译器。

下图显示了预测波高和总质量的验证测试问题的假设结果。根据编译器和模拟中使用的处理器数量,输出结果会略有不同。

使用不同编译器和处理器数量进行计算时,哪些差异是可以接受的?

在本例中,程序报告的两个指标之间存在差异。在没有其他信息的情况下,很难确定哪个是正确的,以及解决方案中的哪些差异是可以接受的。一般来说,程序输出结果出现差异的原因可能是

编译器或编译器版本的变化
硬件变化
编译器优化或编译器或编译器版本之间的微小差异
操作顺序的变化,尤其是由于代码并行性造成的变化
2.1.2.1 了解并行化导致的结果变化

并行过程本质上会改变运算顺序,从而稍微修改数值结果。但并行中的错误也会产生微小的差异。在并行代码开发中,了解这一点至关重要,因为我们需要与单处理器运行进行比较,以确定我们的并行编码是否正确。我们将在第 5.7 节讨论全局求和技术时,讨论如何减少数值误差,使并行性误差更加明显。

对于我们的测试套件,我们需要一种工具来比较数值字段,并对差异有较小的容忍度。过去,测试套件开发人员必须为此创建一个工具,但近年来市场上出现了一些数值差异工具。其中两个工具是

Numdiff 来自 https://www.nongnu.org/numdiff/
ndiff 来自 https://www.math.utah.edu/~beebe/software/ndiff/
另外,如果您的代码以 HDF5 或 NetCDF 文件的形式输出状态,这些格式的工具也能让您以不同的公差比较文件中存储的值。

HDF5® 是软件的第 5 版,最初称为层次数据格式,现在称为 HDF。它可从 HDF 集团(https://www .hdfgroup.org/)免费获取,是一种用于输出大型数据文件的通用格式。
NetCDF 或网络通用数据格式是气候和地球科学界使用的另一种格式。当前版本的 NetCDF 建立在 HDF5 的基础之上。您可以在 Unidata 计划中心的网站(https://www.unidata.ucar.edu/software/netcdf/)上找到这些库和数据格式。
这两种文件格式都使用二进制数据,以提高速度和效率。二进制数据是数据的机器表示。这种格式对你我来说就像胡言乱语,但 HDF5 有一些有用的实用程序,可以让我们查看里面的内容。h5ls 工具列出了文件中的对象,例如所有数据数组的名称。h5dump 实用程序会转储每个对象或数组中的数据。对我们来说最重要的是,h5diff 实用程序会比较两个 HDF 文件,并报告超过数值容差的差异。第 16 章将详细讨论 HDF5 和 NetCDF 以及其他并行输入/输出(I/O)主题。

2.1.2.2 使用 cmake 和 CTEST 自动测试代码

近年来出现了许多测试系统。其中包括 CTest、Google test、pFUnit test 等。有关工具的更多信息,请参见第 17 章。现在,让我们来看看使用 CTest 和 ndiff 创建的系统。

CTest 是 CMake 系统的一个组件。CMake 是一个配置系统,能使生成的 makefile 适应不同的系统和编译器。将 CTest 测试系统整合到 CMake 中,可将两者紧密结合成一个统一的系统。这为开发人员提供了极大的便利。使用 CTest 实现测试的过程相对简单。单个测试以任意命令序列的形式编写。要将这些命令纳入 CMake 系统,需要在 CMakeLists.txt 中添加以下内容:

enable_testing()
add_test( )
然后,你可以用 make test、ctest 调用测试,也可以用 ctest -R mpi 选择单个测试,其中 mpi 是一个正则表达式,用于运行任何匹配的测试名称。让我们以使用 CTest 系统创建测试为例进行说明。

示例: CTest 的先决条件

运行此示例需要安装 MPI、CMake 和 ndiff。对于 MPI(消息传递接口),我们将在 Mac 上使用 OpenMPI 4.0.0 和 CMake 3.13.3(包含 CTest),在 Ubuntu 上使用旧版本。我们将使用安装在 Mac 上的 GCC 第 8 版编译器,而不是默认编译器。然后使用软件包管理器安装 OpenMPI、CMake 和 GCC(GNU 编译器集)。我们将在 Mac 和 Apt 上使用 Homebrew,在 Ubuntu Linux 上使用 Synaptic。如果 libopenmpi-dev 的开发头文件与运行时文件分离,请务必从 libopenmpi-dev 获取这些头文件。ndiff 需要手动安装,方法是从 https://www.math.utah.edu/~beebe/ software/ndiff/ 下载工具,然后运行 ./configure、make 和 make install。

如清单 2.1 所示,制作两个源文件,为这个简单的测试系统创建应用程序。我们将使用一个定时器来产生串行和并行程序输出的微小差异。请注意,您可以在 https://github.com/EssentialsofParallelComputing/Chapter2 找到本章的源代码。

TimeIt.c

include

include

include

int main(int argc, char argv[]){ struct timespec tstart, tstop, tresult; // Start timer, call sleep and stop timer clock_gettime(CLOCK_MONOTONIC, &tstart); sleep(10); clock_gettime(CLOCK_MONOTONIC, &tstop); // Timer has two values for resolution and prevent overflows tresult.tv_sec = tstop.tv_sec – tstart.tv_sec; tresult.tv_nsec = tstop.tv_nsec – tstart.tv_nsec; // Print calculated time from timers printf(“Elapsed time is %f secs\n”, (double)tresult.tv_sec + (double)tresult.tv_nsec1.0e-9);
}
MPITimeIt.c

include

include

include

int main(int argc, char *argv[]){
int mype;
// Initialize MPI and get processor rank
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &mype);
double t1, t2;
// Start timer, call sleep and stop timer
t1 = MPI_Wtime();
sleep(10);
t2 = MPI_Wtime();
// Print timing output from first processor
if (mype == 0) printf( “Elapsed time is %f secs\n”, t2 – t1 );
// Shutdown MPI
MPI_Finalize();
}
现在你需要一个测试脚本来运行应用程序并生成几个不同的输出文件。运行这些文件后,应该对输出进行数字比较。下面是一个过程示例,你可以将其放入名为 mympiapp.ctest 的文件中。您应该使用 chmod +x 命令使其可执行。

mympiapp.ctest

!/bin/sh

Run a serial test

./TimeIt > run0.out

Run the first MPI test on 1 processor

mpirun -n 1 ./MPITimeIt > run1.out

Run the second MPI test on 2 processors

mpirun -n 2 ./MPITimeIt > run2.out

Compare the output for the two MPI jobs with a tolerance of 1%

Reduce the tolerance to 1.0e-5 to get test to fail

ndiff –relative-error 1.0e-2 run1.out run2.out

Capture the status set by the ndiff command

test1=$?

Compare the output for the serial job and the 2 processor MPI run with a tolerance of 1%

ndiff –relative-error 1.0e-2 run0.out run2.out

Capture the status set by the ndiff command

test2=$?

Exit with the cumulative status code so CTest can report pass or fail

exit “$(($test1+$test2))”
该测试首先在第 5 行比较 1 个和 2 个处理器的并行作业输出,公差为 0.1%。然后在第 7 行将串行运行与 2 处理器并行作业进行比较。要使测试失败,可尝试将容差减小到 1.0e-5。CTest 使用第 9 行的退出代码报告通过或失败。将大量 CTest 文件添加到测试套件的最简单方法是使用一个循环,查找所有以 .ctest 结尾的文件,并将这些文件添加到 CTest 列表中。下面是 CMakeLists.txt 文件的示例,其中包含创建两个应用程序的附加说明:

CMakeLists.txt

cmake_minimum_required (VERSION 3.0)
project (TimeIt)

Enables CTest functionality in CMake

enable_testing()

CMake has a built-in routine to find most MPI packages

Defines MPI_FOUND if found

MPI_INCLUDE_PATH (being replaced by MPI__INCLUDE_PATH)

MPI_LIBRARIES (being replaced by MPI__LIBRARIES)

find_package(MPI)

Adds build targets of TimeIt and MPITimeIt with source code file(s) TimeIt.c and MPITimeIt.c

add_executable(TimeIt TimeIt.c)

add_executable(MPITimeIt MPITimeIt.c)

Need an include path to the mpi.h file and to the MPI library

target_include_directories(MPITimeIt PUBLIC ${MPI_INCLUDE_PATH})
target_link_libraries(MPITimeIt ${MPI_LIBRARIES})

This gets all files with the extension ‘ctest’ and adds it to the test list for CTest

The ctest file needs to be executable or explicitly launched with the ‘sh’ command as below

file(GLOB TESTFILES RELATIVE “${CMAKE_CURRENT_SOURCE_DIR}” “*.ctest”)
foreach(TESTFILE ${TESTFILES})
add_test(NAME ${TESTFILE} WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/${TESTFILE})
endforeach()

A custom command, distclean, to remove files that are created

add_custom_target(distclean COMMAND rm -rf CMakeCache.txt CMakeFiles
CTestTestfile.cmake Makefile Testing cmake_install.cmake)
第 6 行的 find_package(MPI) 命令定义了 MPI_FOUND、MPI_INCLUDE_ PATH 和 MPI_LIBRARIES。这些变量包括较新 CMake 版本中 MPI_INCLUDE_PATH 和 MPI_LIBRARIES 的语言,因此 C、C++ 和 Fortran 有不同的路径。现在,只需使用以下命令运行测试

mkdir build && cd build
cmake ..
make
make test

ctest
也可以使用

ctest –output-on-failure
您应该会得到如下结果:

Running tests…
Test project /Users/brobey/Programs/RunDiff
Start 1: mpitest.ctest
1/1 Test #1: mpitest.ctest ……………….. Passed 30.24 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 30.24 sec
该测试基于睡眠功能和计时器,因此可能通过也可能不通过。测试结果在 Testing/Temporary/* 中。

在该测试中,我们比较了应用程序单次运行的输出结果。另外,在测试脚本中存储一个运行的黄金标准文件也是一种很好的做法。这种比较可以检测出会导致新版本应用程序与早期版本结果不同的变化。一旦出现这种情况,就会引起警觉;请检查新版本是否仍然正确。如果是,则应更新黄金标准。

测试套件应尽可能多地测试代码的各个部分。代码覆盖率这一指标量化了测试套件完成任务的程度,用占源代码行数的百分比来表示。测试开发人员有一句老话:代码中没有测试的部分就是坏的,因为即使现在没有,最终也会坏。在并行化代码的过程中,所有的变化都会造成代码的破坏,这是不可避免的。虽然高代码覆盖率很重要,但对于我们的并行化工作来说,更关键的是要对并行化代码的各个部分进行测试。许多编译器都有生成代码覆盖率统计数据的功能。对于 GCC,gcov 是剖析工具,而对于英特尔,则是 Codecov。我们来看看 GCC 是如何工作的。

使用 GCC 进行代码覆盖:

在编译和链接时添加 -fprofile-arcs 和 -ftest-coverage 标记
在一系列测试中运行仪器化的可执行文件
运行 gcov 获取每个文件的覆盖率。注意:对于使用 CMake 的联编,请在源文件名中添加额外的 .c 扩展名;例如,gcov CMakeFiles/stream_triad.dir/stream_triad.c.c 处理 CMake 添加的扩展名。
您将得到类似下面的输出结果

88.89% of 9 source lines executed in file .c
Creating .c.gcov
gcov 输出文件包含一个列表,每一行都以执行次数作为前缀。

了解不同类型的代码测试

测试系统也有不同种类。在本节中,我们将介绍以下类型:

回归测试-定期运行,防止代码倒退。通常每晚或每周使用 cron 作业调度程序在指定时间启动作业。

单元测试-在开发过程中测试子程序或其他小部分代码的运行。

持续集成测试-越来越受欢迎,这些测试会在代码提交时自动触发运行。

提交测试–可以在很短时间内通过命令行运行的小型测试集,在提交前使用。

所有这些测试类型对一个项目都很重要,因此应同时使用,而不是只依赖其中一种。测试对于并行程序尤为重要,因为在开发周期的早期发现错误意味着在运行 6 小时后就不用再调试 1000 个处理器了。

单元测试最好在开发代码时创建。单元测试的真正爱好者使用测试驱动开发(TDD),即首先创建测试,然后编写代码以通过这些测试。将这些类型的测试纳入并行代码开发,包括测试它们在并行语言中的运行和实现。在这一层面发现问题要容易得多。

提交测试是应添加到项目中的首批测试,在代码修改阶段会大量使用。这些测试应对代码中的所有例程进行测试。有了这些随时可用的测试,团队成员就可以在提交到版本库之前运行这些测试。我们建议开发人员在提交前通过 Bash 或 Python 脚本或 makefile 等命令行调用这些测试。

示例:使用 CMake 和 CTest 进行提交测试的开发工作流程

要在 CMakeLists.txt 中进行提交测试,请创建下表所示的三个文件。使用前一个测试中的 Timeit.c,但将睡眠间隔从 10 改为 30。

使用 CTest 创建提交测试

blur_short.CTest

!/bin/sh

make
blur_long.ctest

!/bin/sh

./TimeIt
CMakeLists.txt

cmake_minimum_required (VERSION 3.0)
project (TimeIt)

Enables CTest functionality in CMake

enable_testing()

add_executable(TimeIt TimeIt.c)

Add two tests, one with commit in the name

add_test(NAME blur_short_commit WORKING_DIRECTORY ${CMAKE_BINARY_DIRECTORY}
COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/blur_short.ctest)
add_test(NAME blur_long WORKING_DIRECTORY ${CMAKE_BINARY_DIRECTORY}
COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/blur_long.ctest)

Custom target “commit_tests” to run all tests with commit in the name

add_custom_target(commit_tests COMMAND ctest -R commit DEPENDS TimeIt)

A custom command, distclean, to remove files that are created

add_custom_target(distclean COMMAND rm -rf CMakeCache.txt CMakeFiles
CTestTestfile.cmake Makefile Testing cmake_install.cmake)
提交测试可通过 ctest -R commit 或通过 make commit_tests 在 CMakeLists.txt 中添加的自定义目标来运行。make test 或 ctest 命令会运行包括长测试在内的所有测试,长测试需要一段时间。commit test 命令会挑出名称中包含 commit 的测试,从而得到一组涵盖关键功能的测试,但运行速度会更快一些。现在的工作流程是

编辑源代码: vi mysource.c

编译代码:make

运行提交测试: make commit_tests

提交代码更改:git commit

重复上述步骤。持续集成测试由提交到主代码库的代码调用。这是防止提交错误代码的额外措施。这些测试可以与提交测试相同,也可以更广泛。用于此类测试的顶级持续集成工具有

Jenkins (https://www.jenkins.io)

用于 GitHub 和 Bitbucket 的 Travis CI (https://travis-ci.com)

GitLab CI (https://about.gitlab.com/stages-devops-lifecycle/continuous-integration/)

CircleCI (https://circleci.com)

回归测试通常通过 cron 作业设置为通宵运行。这意味着测试套件可能比其他测试类型的测试套件更广泛。这些测试时间可能较长,但应在早上报告前完成。由于运行时间较长和报告的周期性,内存检查和代码覆盖等附加测试通常作为回归测试运行。回归测试的结果通常会随着时间的推移而被跟踪,“通过墙 ”被认为是项目良好的标志。

理想测试系统的进一步要求

虽然前面描述的测试系统足以满足大多数目的,但对于大型高性能计算项目来说,还有更多的要求。这些类型的高性能计算项目可能会有大量的测试套件,也可能需要在批处理系统中运行,以获取更多资源。

https://sourceforge.net/projects/ ctsproject/ 网站上的协作测试系统(CTS)提供了一个针对这些需求开发的系统实例。该系统使用 Perl 脚本来运行一组固定的测试服务器(通常为 10 台),与批处理系统并行启动测试。每个测试完成后,系统会启动下一个测试。这就避免了一次性向系统发送大量任务。CTS 系统还能自动检测批处理系统和 MPI 类型,并为每个系统调整脚本。报告系统使用 cron 作业,测试在夜间提前启动。跨平台报告在早上启动,然后发送出去。

举例说明: 针对高性能计算项目的 Krakatau 场景测试套件

在对应用程序进行审查后,您发现图像检测应用程序有很大的用户群。因此,您的团队决定在每次提交前设置大量回归测试,以避免对用户造成影响。运行时间较长的内存正确性测试通宵运行,每周对性能进行跟踪。然而,海浪模拟是新项目,用户较少,但您希望确保已验证的问题能继续给出相同的答案。由于提交测试时间过长,因此需要每周运行一个简短版本和完整版本。

对于这两个应用,都会设置一个持续集成测试来构建代码并运行一些较小的测试。灰羽模型刚刚开始开发,因此您决定使用单元测试来检查每段新添加的代码。

2.1.3 查找并修复内存问题

良好的代码质量至关重要。并行化往往会导致代码缺陷的出现;这可能是未初始化内存或内存覆盖。

未初始化内存是指在设置内存值之前访问的内存。当你为程序分配内存时,它会获取这些内存位置中的任何值。如果在设置之前使用内存,就会导致不可预测的行为。

当数据被写入不属于变量的内存位置时,就会发生内存覆盖。写入超出数组或字符串边界的数据就是一个例子。

要抓住这类问题,我们建议使用内存正确性工具来彻底检查你的代码。其中最好的工具之一就是免费提供的 Valgrind 程序。Valgrind 是一个工具框架,它通过合成 CPU 执行指令,在机器代码级别上运行。Valgrind 旗下开发了许多工具。第一步是使用软件包管理器在系统中安装 Valgrind。如果你运行的是最新版本的 macOS,你可能会发现 Valgrind 需要几个月的时间才能移植到新内核。最好的办法是在另一台电脑、旧版 MacOS 或虚拟机或 Docker 镜像上运行 Valgrind。

要运行 Valgrind,请像往常一样执行程序,并在前面插入 valgrind 命令。对于 MPI 作业,valgrind 命令放在 mpirun 之后、可执行文件名之前。Valgrind 与 GCC 编译器配合使用效果最佳,这是因为 GCC 开发团队采用了 Valgrind,努力消除可能导致诊断输出混乱的误报。建议在使用英特尔编译器时,不进行矢量化编译,以避免矢量指令警告。您还可以尝试使用第 17.5 节中列出的其他内存正确性工具。

使用 Valgrind Memcheck 查找内存问题
Memcheck 工具是 Valgrind 工具套件中的默认工具。它拦截每一条指令,检查是否存在各种内存错误,并在运行开始、运行过程中和运行结束时生成诊断报告。这可以将运行速度降低一个数量级。如果你以前没有使用过它,就要做好大量输出的准备。一个内存错误会导致许多其他错误。最好的策略是从第一个错误开始,修复它,然后再运行一次。要了解 Valgrind 如何工作,请尝试下面示例代码。要执行 Valgrind,请在可执行文件名之前插入 valgrind 命令,如

valgrind <./my_app>

mpirun -n 2 valgrind <./myapp>

include

int main(int argc, char *argv[]){
int ipos, ival;
int *iarray = (int ) malloc(10sizeof(int));
if (argc == 2) ival = atoi(argv[1]);
for (int i = 0; i<=10; i++){ iarray[i] = ipos; }
for (int i = 0; i<=10; i++){
if (ival == iarray[i]) ipos = i;
}
}
ipos 未赋值。

用 gcc -g -o test test.c 编译这段代码,然后用 valgrind-leak-check=full ./test 2 运行它。 Valgrind 的输出穿插在程序的输出中,可以用带有双等号 (==) 的前缀来识别。下面显示的是本例输出中一些比较重要的部分:

=14324== Invalid write of size 4
==14324== at 0x400590: main (test.c:7)
==14324==
==14324== Conditional jump or move depends on uninitialized value(s)
==14324== at 0x4005BE: main (test.c:9)
==14324==

该输出显示了多个内存错误报告。最难理解的是未初始化内存报告。Valgrind 在第 9 行报告了该错误,当时决定使用未初始化的值。实际上,错误发生在第 7 行,在这一行中,iarray 被设置为 ipos,而 ipos 没有被赋值。在一个更复杂的程序中,可能需要仔细分析才能确定错误的根源。

2.1.4 提高代码的可移植性

最后一项代码编写要求是提高代码在更多编译器和操作系统中的可移植性。可移植性始于基础 HPC 语言,一般为 C、C++ 或 Fortran。每种语言都有编译器实现标准,并定期发布新标准。但这并不意味着编译器可以轻易实现这些标准。通常情况下,编译器供应商从发布到完全实施的时间会很长。例如,Polyhedron Solutions 网站 (http://mng.bz/yYne) 报道称,没有 Linux Fortran 编译器完全执行 2008 年标准,只有不到一半的编译器完全执行 2003 年标准。当然,重要的是编译器是否实现了你想要的功能。C 和 C++ 编译器通常在实现新标准方面更与时俱进,但滞后时间仍会给积极进取的开发团队带来问题。此外,即使实现了这些功能,也并不意味着它们能在各种环境下工作。

使用各种编译器进行编译有助于发现编码错误或识别代码在语言解释上的 “边缘”。可移植性为使用特定环境中的最佳工具提供了灵活性。例如,Valgrind 最适合使用 GCC,而线程正确性工具 Intel® Inspector 则最适合使用英特尔编译器编译应用程序。可移植性也有助于并行语言的使用。例如,CUDA Fortran 只能使用 PGI 编译器。基于 GPU 指令的语言 OpenACC 和 OpenMP(使用目标指令)的现有实现只适用于一小部分编译器。幸运的是,用于 CPU 的 MPI 和 OpenMP 已广泛应用于许多编译器和系统中。在此,我们需要明确 OpenMP 有三种不同的功能: 1) 通过 SIMD 指令进行矢量化;2) 通过原始 OpenMP 模型进行 CPU 线程化;3) 通过新的目标指令将数据卸载到加速器,通常是 GPU。

举例说明: Krakatau 场景和代码可移植性

您的图像检测应用程序只能用 GCC 编译器编译。您的并行化项目添加了 OpenMP 线程。您的团队决定让它使用英特尔编译器编译,这样您就可以使用英特尔检查器查找线程竞赛条件。灰羽模拟是用 Fortran 编写的,目标是在 GPU 上运行。根据对当前 GPU 语言的研究,您决定将 PGI 作为开发编译器之一,以便使用 CUDA Fortran。

2.2 剖析: 探测系统能力与应用程序性能之间的差距
剖析可确定硬件性能,并将其与应用程序性能进行比较。性能与当前性能之间的差距会产生性能改进的潜力。

剖析过程的第一部分是确定限制应用程序性能的因素。我们将在 3.1 节中详细介绍应用程序可能存在的性能限制。简而言之,目前大多数应用程序都受到内存带宽或与内存带宽密切相关的限制。少数应用程序可能会受到可用浮点运算(flops)的限制。我们将在第 3.2 节中介绍计算理论性能限制的方法。我们还将介绍一些基准程序,这些程序可以测量硬件限制条件下可实现的性能。

一旦了解了潜在性能,就可以对应用程序进行剖析。我们将在第 3.3 节介绍一些剖析工具的使用过程。应用程序的当前性能与硬件能力之间的差距将成为并行化下一步的改进目标。

2.3 规划: 成功的基础
有了收集到的有关应用程序和目标平台的信息,就可以将一些细节纳入计划中了。下图显示了这一步骤的部分内容。由于并行化需要花费大量精力,因此在开始实施步骤之前,最好对先前的工作进行研究。

过去很可能遇到过类似的问题。你会发现近年来发表了许多关于并行项目和技术的研究文章。但最丰富的信息来源之一是已发布的基准和迷你应用程序。有了迷你应用程序,您不仅可以研究,还可以学习实际代码。

2.3.1 利用基准和迷你应用程序进行探索

高性能计算社区开发了许多基准、内核和示例应用程序,用于基准测试系统、性能实验和算法开发。我们将在第 17.4 节列出其中一些。您可以使用基准来帮助选择最适合您应用的硬件,而迷你应用则可以帮助您选择最佳算法和编码技术。

基准旨在突出硬件性能的特定特性。既然您已经了解了自己应用程序的性能极限,就应该查看最适合自己情况的基准。如果您在以线性方式访问的大型数组上进行计算,那么流基准是合适的。如果您的内核是迭代矩阵求解器,那么高性能共轭梯度(HPCG)基准可能更好。迷你应用更侧重于一类科学应用中的典型操作或模式。

这些基准或迷你应用程序中是否有与您正在开发的并行应用程序类似的,这一点很值得研究。如果有,研究它们如何进行类似的操作可以节省很多精力。通常情况下,为了探索如何获得最佳性能、移植到其他并行语言和平台或量化性能特征,已经对代码做了大量工作。

目前,基准和迷你应用程序主要来自科学计算领域。我们将在示例中使用其中的一些,我们也鼓励你将它们用作实验和示例代码。这些示例演示了许多关键操作和并行实现。

示例: 幽灵单元更新

许多基于网格的应用程序在分布式内存实现中将网格分布到不同的处理器上。因此,这些应用程序需要使用相邻处理器的值更新网格边界。

这种操作称为幽灵单元更新。桑迪亚国家实验室的理查德-巴雷特开发了 MiniGhost 微型应用程序,以试验执行此类操作的不同方法。MiniGhost 微型应用程序是 Mantevo 微型应用程序套件的一部分,可从 https://mantevo.org/default.php 获取。

2.3.2 核心数据结构的设计和代码模块化

数据结构的设计会对应用程序产生长远的影响。这也是需要事先做出的决定之一,因为事后改变设计会变得很困难。在第 4 章中,我们将介绍一些重要的考虑因素,并通过一个案例分析不同数据结构的性能。

首先,重点关注数据和数据移动。这是当今硬件平台的主要考虑因素。这也会导致有效的并行实施,在并行实施中,谨慎的数据移动变得更加重要。如果我们将文件系统和网络也考虑在内,数据移动将主导一切。

2.3.3 算法: 为并行重新设计

此时,您应评估应用程序中的算法。能否为并行编码修改这些算法?是否有可扩展性更好的算法?例如,您的应用程序中可能有一部分代码只占运行时间的 5%,但其算法缩放比例为 N^2,而其余代码的缩放比例为 N,其中 N 为单元格或其他数据组件的数量。随着问题规模的扩大,5% 很快就会变成 20%,甚至更高。很快,它就会主导运行时间。要识别这类问题,您可能需要剖析一个更大的问题,然后查看运行时间的增长而不是绝对百分比。

举例说明: 灰羽模型的数据结构

您的灰羽流模型正处于早期开发阶段。有几种建议的数据结构和功能步骤分解。您的团队决定花一周时间分析这些备选方案,然后将其固定在代码中,因为他们知道将来很难更改。其中一项决定是使用哪种多材料数据结构,由于许多材料只出现在网格的一小块区域,是否有好的方法来利用这一点。您决定探索一种稀疏数据存储数据结构,以节省内存(第 4.3.2 节将讨论一些稀疏数据存储数据结构)并加快代码运行速度。

举例说明: 波浪模拟代码的算法选择

波浪模拟代码的并行化工作预计将增加 OpenMP 和矢量化。您听说过这两种并行方法的不同实现方式。您指派两名小组成员查阅最近的论文,以了解最有效的方法。团队中的一位成员对其中一个难度较大、算法复杂的例程的并行化表示担忧。目前的技术看起来无法直接并行化。您同意了他的意见,并要求该小组成员研究与当前算法不同的其他算法。

2.4 实施: 实现
我认为这一步就像徒手搏斗。在战壕里,逐行、逐环、逐例地将代码转换为并行代码。这就是你在 CPU 和 GPU 上所学到的所有并行实现知识发挥作用的地方。如图所示,本书其余部分的大部分内容都将涉及这一内容。有关并行编程语言的章节(CPU 的第 6-8 章和 GPU 的第 9-13 章)将为您开启开发这方面专业知识的旅程。

在实施步骤中,跟踪您的总体目标非常重要。此时,您可能已经决定了并行语言,也可能尚未决定。即使你已经决定了,你也应该愿意在深入实施过程中重新评估你的选择。选择项目方向的一些初步考虑因素包括

您对速度的要求是否适中?您应该在第 6 章和第 7 章中探讨矢量化和共享内存(OpenMP)并行性。

是否需要更多内存来扩展?如果是这样,您需要在第 8 章中探索分布式内存并行性。

您需要大幅提速吗?那么 GPU 编程值得在第 9-13 章中进行研究。

实施这一步骤的关键是将工作分解成易于管理的小块,并在团队成员之间进行分工。既有例程速度提升一个数量级的兴奋,也有整体影响很小的意识,还有很多工作要做。坚持不懈和团队合作对于实现目标非常重要。

举例说明: 并行语言的重新评估

你们为波浪模拟代码添加 OpenMP 和矢量化的项目进展顺利。典型计算的速度提高了一个数量级。但是,随着应用程序速度的加快,您的用户希望运行更大的问题,而他们却没有足够的内存。您的团队开始考虑增加 MPI 并行性,以访问有更多内存可用的其他节点。

2.5 提交 高质量收尾
提交步骤是这部分工作的收尾,它通过仔细的检查来验证代码的质量和可移植性是否得到了保证。下图显示了这一步骤的组成部分。这些检查的范围取决于应用程序的性质。对于有许多用户的生产应用程序,测试要彻底得多。

注意 此时,发现相对较小的问题要比在上千台处理器上运行六天后再调试复杂问题要容易得多。

团队必须认同提交流程,并共同遵守。建议召开一次团队会议,制定供所有人遵守的程序。在创建程序时,可以借鉴最初为提高代码质量和可移植性而使用的流程。最后,应定期重新评估提交流程,并根据当前项目的需要进行调整。

例如:重新评估团队的代码开发流程

您的波浪模拟应用程序团队已经完成了在应用程序中添加 OpenMP 的第一批工作。但现在,应用程序偶尔会死机,而且没有任何解释。团队中的一位成员意识到这可能是线程竞赛条件造成的。你们的团队实施了一个额外的步骤来检查这些条件,作为提交过程的一部分。

2.6 进一步探索
在本章中,我们只是初步了解了如何处理新项目以及可用工具的功能。如需了解更多信息,请浏览相关资源并尝试下面章节中的一些练习。

2.6.1 补充阅读

掌握更多有关当今分布式版本控制工具的专业知识对项目大有裨益。团队中至少有一名成员应研究网络上讨论如何使用所选版本控制系统的众多资源。如果您使用 Git,以下来自 Manning 的书籍是很好的资源:

Mike McQuaid,《Git in Practice》(Manning,2014 年)。

Rick Umali,《 Learn Git in a Month of Lunches 》(Manning,2015 年)。

测试在并行开发工作流程中至关重要。单元测试可能是最有价值的,但也是最难实施的。Manning 有一本书对单元测试进行了更深入的讨论:

Vladimir Khorikov,《Unit Testing Principles, Practices, and Patterns》(Manning,2020 年)。

浮点运算和精度是一个不受重视的话题,尽管它对每位计算科学家都很重要。以下是关于浮点运算的精彩阅读和概述:

David Goldberg, “What every computer scientist should know about floating-point arithmetic,” ACM Computing Surveys (CSUR) 23, no: 5-48.

2.7 总结
代码准备是并行工作的重要组成部分。每个开发人员都会惊讶于为项目准备代码所花费的精力。但这段时间花得很值,因为它是并行项目取得成功的基础。

你应该提高并行代码的质量。代码质量必须比典型的串行代码高出一个数量级。这种对质量的要求部分是由于大规模调试的困难,部分是由于并行过程中暴露出的缺陷,或者仅仅是由于每行代码执行的迭代次数太多。也许这是因为遇到缺陷的概率很小,但当一千个处理器运行代码时,发生缺陷的概率就会增加一千倍。

剖析步骤对于确定优化和并行工作的重点非常重要。第 3 章将详细介绍如何剖析应用程序。

有一个总体项目计划,每个开发迭代还有另一个单独的计划。这两个计划都应包括一些研究内容,如迷你应用程序、数据结构设计和新的并行算法,以便为下一步奠定基础。

在提交步骤中,我们需要制定流程以保持良好的代码质量。这应该是一项持续性的工作,而不是等到代码投入生产或现有用户群开始遇到大型、长时间运行模拟的问题时再进行。

声明:文中观点不代表本站立场。本文传送门:https://eyangzhen.com/424982.html

联系我们
联系我们
分享本页
返回顶部