标量串行软件通常有两种并行化方法:
部分并行化:适用于热点集中、代码少、代码模块化好的软件。大多数程序软件都符合80%-20%的定律。
整体并行化:适用于热点分散、整体并行性好的软件。整体并行化需要了解软件的整体流程逻辑、逻辑架构、物理架构、数据分布、数据相关性和数据处理。
在某些情况下,局部并行化完成后,没有并行化的部分成为瓶颈。为了性能,此时可能需要转化为整体并行化。
10.1 找出计算软件的热点
1. 如何选择测试数据集的大小
通常使用的数据集大小通常可以改变,以改变程序允许的时间。由于以下原因,允许不同的数据集测量不同的热点;
结果因分析工具分析而不准确;
在数据量相对较小的情况下,一些函数的运行时间只是小于分析工具的灵敏度;
在不同的数据集中,程序允许不同的路径。
2. 使用哪种分析工具
分析工具:gprof,valgrind和intel Vtune等。笔者优先使用gprof,不是因为准确,而是因为GCC自带。
如果作者怀疑测量不准确,一般会使用其他工具进行验证,必要时也会手动插入测量代码。
10.2 判断热点是否并行化
找出热点后,不要马上开始并行化,还要知道两个问题的答案:
热点能并行化吗?
并行化热点投资需要多少,能否收回投资?
1. 热点是否并行
如果热点没有并行性,那么就需要考虑是否采用其他的具有并行性的算法或者是能够通过重构算法使其具有并行性。
大多数并行性来自循环(数据并行)或函数(任务并行)。如果循环代码不依赖,则循环应并行;相反,需要重建代码以消除依赖。
若循环数据依赖,但可通过通信方式(如锁、原子函数等)并行,且通信成本很小。
若多个函数作用于不同的数据,则认为任务可以并行使用;
若多个函数作用于同一数据,但可通过通信解决冲突,且通信成本很小,也可视为并行。
并行性是否也与使用的软硬件环境有关,更准确地说,它与环境使用的并行模式有关。数据、任务、装配线并行。
数据、任务、装配线并行可以相互转换。
// 数据并行 void func(const float* __restrict__ data, size_t len) { for (int i = 0; i < len; i ) process(data[i]); } // 任务并行 void func(const float* __restrict__ data, size_t len) { int start = len / 2; p(data, start); p(d start, len - len / 2); } void p(const float* __restrict__ data, size_t len) { for (int i = 0; i < len; i ) process(data[i]); } ///流水线并行 void func(const float* __restrict__ data, size_t len) { float prev = data[0]; float cur; for (int i = 1; i < len; i ) { cur = data[i]; process(prev); prev = cur; } process(prev); }
2. 能否量化热点?
如果热点并行,则需要考虑是否能量化热点。如果你能量化热点,你就能得到更好的性能改进。此外,大多数量化代码的稳定性优于多线程代码。
不同的向量多核处理器支持不同类型和有限的向量指令。因此,如果需要考虑向量热代码,也应考虑目标平台上是否支持主要操作指令。
3. 平行成本和收入是否匹配
事实上,软件开发的估值一直是一个难题,无数的项目因为资金不可持续而不得不终止。
10.3 并实现设计算法
在设计算法时,需要选择软硬件开发平台,如目标代码在哪个硬件平台上运行,使用什么编程环境。
10.3.1 向量化或并行化选择哪种工具
如果热代码的向量化并行,则应考虑向量处理器;如果不好,不应考虑多线程并行处理器的向量化性能,而只考虑多线程性能好的处理器。
如果对运行平台有限制,例如运行ARM。
若应用对延迟和吞吐量要求不高,GPU这是个不错的选择。若应用对延迟和吞吐量要求较高,FPGA可能更好。
关于编程语言,现实是几乎所有的向量化和并行程序都使用C或Fortan一些开发人员也试图使用它C 代码的高编码效率和可扩展性。
笔者认为,向量化和并行开发人员需要有选择地使用C 语言特征,并尽量确保代码与C兼容。
对于遗留代码,最好的选择是编译导语句类型的并行化方案。
10.3.2 重构热代码
遗留代码通常不是并行编写的。笔者建议将热点代码重构成易于并行化的模式,然后并行化。
为了更好地利用并行计算硬件的强大计算能力,可能需要改变原软件的数据组织模式和局部计算逻辑,也可以先重构。
重建热点代码的主要目的是使热点代码更容易并行化。
// 循环拆分前 for (int i = 0; i < n; i ) { a[i 1] = b[i] c; d[i] = a[i] e; } // 循环拆分后 for (int i = 0; i < n; i ) a[i 1] = b[i] c; for (int i = 0; i < n; i ) d[i] = a[i] e;
重构热点代码的次要目的在于使得并行化后的代码性能更好,因此重构后的代码要能够更好地映射到目标硬件上。
// 重构前 for (int i = 0; i < n; i ) { short int r = rgb_buf[3 * i]; short int g = rgb_buf[3 * i 1]; short int b = rgb_buf[3 * i 2]; } // 重构后 for (int i = 0; i < n; i ) { short int r = r_buf[i]; short int g = g_buf[i]; short int b = b_buf[i]; }
10.3.3 算法是基于硬件实现的
软件开发发挥硬件的性能,软件开发人员仍然需要使用友好的硬件来编写代码。
从硬件指令集的实现来看,统一指令在不同的硬件上有不同的吞吐量和延迟;即使是同一向量处理器实现同一功能的不同指令的延迟和吞吐量也不同。
不仅要考虑使用硬件的特点,还要考虑并行软件和原软件的接口。
10.4 将实现代码嵌入原软件
通常有两种方法:混合编译和动态链接库
10.4.1 混合编译
混合编译适用于同一语言,因为并行代码和原始软件使用相同的编译系统,但并行代码使用更多的库。此时,并行代码可以单独编译成编译系统的中间语言或目标代码(如重置二进制或汇编代码)。然后将编译后的中间代码链接到原始软件编译中。
下面是混合编译MPI部分并行代码:
mpicc -c xx.c
g yy.c xx.o -lmpi -o yy
通常库的混合使用表现为以下几种情况:
如果使用C 调用C编写的库函数需要在声明调用函数时增加extern "C"前缀,然后直接链接库。
若使用C调用C 库函数的编写B,您可以编写另一个函数A代理访问B,函数A的声明和实现都增加了extern "C"前缀。
如果使用Fortan调用C,因为Fortan名称破坏机制是在函数名后增加下划线。
若需使用C调用Fortan中A函数,使用A_调用即可。
事实上,混合编译最困难的问题是数据类型,因为不同的编译器对数据类型的长度、对齐和大小端有不同的要求。
作者曾经遇到过一个问题:C和C 在混合编程项目中,忘记使用结构声明extern "C"修改,导致调试半个月。面对象语言C 不同的编译器以不同的方式组织对象的虚拟函数表,其混合编译更加困难。
10.4.2 动态链接库
更改动态链接库时,无需重新编译程序,只需编译动态链接库即可,运行时甚至可以更换动态链接库接库。
Linux下动态链接库的后缀名是so,下面的没拿过来将dl.c文件编程成动态链接库libdl.so。其中-fPIC表示生成与位置无关的代码。
gcc -shared -fPIC -o libdl.so dl.c
Linux下的动态链接库名字必须以lib开始。使用链接库:
gcc -o man man.c -ldl.so -L
其中-l选项指定链接的动态库的名字,不包括前缀lib。-L指定动态链接库所在的目录。如果动态链接库所在目录在系统环境变量LD_LIBRARY_PATH中,则不用显示指定-L选项指定动态编译库所在的目录。
与混合编译类似,不同的语言在处理数据类型和函数名的时候采用了不同的规则,使用动态编译库时需要注意这一点。
10.6 本章小结
通常并行化遗留代码的基本步骤是:
1. 找出软件的计算热点
2. 判断热点是否可并行化;
3. 设计算法并实现;
4. 将实现后的算法嵌入原软件
5. 重新测试
在找出软件的计算热点时,需要注意选择测试数据集的大小和使用何种分析工具;
判断热点是否可并行化需要注意:热点是否有足够的并行性,进而需要分析热点是否能够被向量化,还需要评估并行化的代价公司是否能够承担。
在设计算法实现时,需要注意选择合适的实现工具,在实现前要提前重构原始代码,最后要根据硬件来实现算法。
通常将实现后代码嵌入原软件的两种方法:混合编译和使用动态并行库。