加快程序速度的多种方法
有很多方法可以让你的程序运行得更快。一些方法是基于使用更高效的库,一些方法是以某种更有效的方式使用标准库和语言,而其他方法则包括使用更有效的算法。但有时,即使我们已经应用了所有可能的优化手段,代码性能仍不能令人满意。这时就需要我们调查代码并确保以最有效的方式使用所有可用硬件资源。
在这篇文章中,我们介绍了旨在以有效方式使用硬件的软件开发技术,我们将探索 Parallelware Analyzer 工具如何通过充分利用硬件来帮助您提高代码性能。我们将讨论高效使用硬件的几个方面:使用所有可用的CPU内核、使用 GPU 等加速器、矢量化、内存优化和高级硬件功能。
将工作负载分配到多个CPU内核
多核 CPU 现在无处不在。即使是最便宜的手机也至少有两个 CPU 内核,笔记本电脑也将有 2 到 8 个 CPU 内核,高端 CPU 将有数百个内核。这里存在着许多程序从未利用过的巨大的并行性潜力。
使串行代码在多核上运行的一种非常方便的方法是利用 OpenMP。OpenMP 是所有主要编译器(例如 GCC、CLANG、Intel 的编译器或 Microsoft 的编译器)都支持的开放标准。C、C++ 和 Fortran 语言也都支持。它允许用户使用编译器的 pragma 指令来修饰代码,告诉编译器如何分发到多个 CPU 核上。
这是一个用 OpenMP pragmas 修饰的循环示例。编译指令由Parallelware Analyzer 自动添加:
当代码执行到带有编译器编译指令的代码部分时,代码将由多个 CPU 内核执行。编译器负责运行代码所需的一切——线程创建、数据同步和线程同步。
我们写了大量关于使用 OpenMP 将关键循环分配到多个 CPU 内核的文章。在 Canny 图像处理算法的示例中,程序的 OpenMP 版本在具有 8 个线程的系统上运行速度比串行版本快 3.4 倍。在 NP CG 基准测试的另一个示例中,该程序在具有四个线程的系统上运行速度比原始程序快 2.7 倍。
Parallelware Analyzer通过提供人类可读与可操作的建议来指导开发工程师优化代码的过程。例如,它检测代码中可以分布到多个 CPU 内核的计算。如果您有兴趣使用 OpenMP 执行此操作,它还可以通过自动插入适当的 pragmas 来帮助您加快计算。
将工作负载分配给加速器
许多现代计算机系统还包含可用于加速计算的加速器设备。例如,现代台式机和笔记本电脑具有 GPU、可编程图形加速器,用于 2D 或 3D 渲染。高性能计算机通常具有加速器设备来加速科学计算。
加速器是大规模并行架构的一部分,它们在解决许多类型的问题方面非常有效。加速器的编程方式有很多种,底层的如 CUDA 和 OpenCL;高层的例如 OpenMP 或 OpenACC。我们的产品 Parallelware Analyzer 可帮助开发人员使用 OpenACC 和 OpenMP 自动将他们的代码库移植到加速器。
OpenACC 是一种使用硬件加速器的非常简单的方法。与上一节中的 OpenMP 类似,它依赖编译器编译指令和编译器支持来编译将在加速器上运行的代码。
下面是一个带有 OpenACC 指令的循环示例,由 Parallelware Analyzer 自动生成,它将计算分配到 GPU:
上面的代码片段计算了 π 的值并在 GPU 上执行。它执行在加速器上运行的所有必要步骤:将输入数据移动到加速器、计算、将结果放到主内存。
而当变量N为 20 亿时,这个循环在 GPU 上完成需要 0.9 秒,在 CPU 上需要 2.4 秒。这是一个使用加速器的好方法!
您可以使用Parallelware Analyzer检测可以分配到加速器设备(例如 GPU)的计算。此外,它还提供了针对PWR009和PWR015等 GPU 的优化建议。还可以通过生成将工作负载分配到 GPU 所需的 OpenMP 和 OpenACC 指令来提供帮助。
使用CPU的矢量化功能
现代 CPU 包含在固定长度向量上工作的向量单元(例如,四个双精度数的向量或八个整数的向量),并且可以在一条指令中对一个向量执行单个操作。如果向量化可用,CPU 可以例如从内存中加载四个双精度数,执行四次加法并将四个结果存储回内存,同时在一个双精度数上执行相同的操作。
编译器通常会自动创建矢量化代码,但是这里有很多障碍,我们已经写过。以一个朴素的矩阵乘法算法为例:
编译器不会自动对上述循环进行向量化,因为对b[k][j]的访问不是按顺序通过内存的,即 CPU 正在以n为步幅访问矩阵b中的元素。一种称为循环交换的技术可以帮助提高性能。这是修改后的源代码:
在这个例子中,我们首先对j上的循环执行循环裂变,并把它分成两个循环。第一个循环执行c[i][j](第 3 行)的初始化,第二个循环执行了计算。接下来,我们互换了j的循环和k的循环。通过这种转换,在最里面的循环中,我们只有不断的内存访问(总是访问相同的内存位置a[i][k]:)或顺序访问(访问相邻的内存位置:c[i][j]和b[k][j])。
原始代码在 2400×2400 整数矩阵上执行需要 14.4 秒。修改后的版本耗时3.8s,速度上的大幅度提升!这一切都归功于循环交换和矢量化。
就像多核 CPU 和分配到 GPU 一样,Parallelware Analyzer 可以帮助进行矢量化。例如,它提供了有关如何重构代码以启用自动矢量化(例如PWR020)或如何提高矢量化性能(例如PWR019)的提示。此外,它还可以帮助您为 OpenMP 或特定于编译器的指令(例如 gcc、clang、icc)生成矢量化指令。
针对内存子系统进行优化
现代 CPU 速度非常快,但它们用来加载和存储数据的内存要慢得多。因此,现代 CPU 通常必须等待从内存中获取数据。这在非顺序内存访问模式1的情况下尤其明显:跨步内存访问模式和随机内存访问模式。减少需要从内存中获取的数据量、转移到顺序内存访问模式或增加局部性(通过访问缓存中已经存在的数据),这些努力都会产生性能改进,有时可能是显著的。
考虑以下矩阵转置的示例:
从性能的角度来看,对数组的访问a[j][i]是低效的,因为数组的元素是用 stride 访问的n。一种称为循环平铺或循环阻塞的技术可以帮助提高性能。在现代 CPU 上,每次访问数组的元素时,都会将一些相邻元素加载到数据缓存中,并且它们的访问成本非常低。循环平铺通过对小数据块进行操作来利用这一事实。
原始循环
瓦片大小 = 2 的循环瓦片
b[0][0] = a[0][0]b[0][1] = a[1][0]b[0][2] = a[2][0]b[0][ 3] = a[3][0]
b[0][0] = a[0][0]b[0][1] = a[1][0]b[1][0] = a[0][1]b[1][ 1] = a[1][1]
b[0][4] = a[4][0]b[0][5] = a[5][0]b[0][6] = a[6][0]b[0][ 7] = a[7][0]
b[0][2] = a[2][0]b[0][3] = a[3][0]b[1][2] = a[2][1]b[1][ 3] = a[3][1]
由于内存缓存,访问a[0][0]也使得访问a[0][1]速度非常快。平铺版本利用了这一点,因为它在访问a[0][1]后很快就会访问a[0][0]。
带有循环平铺的矩阵转置算法看起来(平铺大小 = 4)如下所示:
在 10000 x 10000 元素的矩阵上,执行原始算法需要 860 毫秒,执行循环平铺版本需要 288 毫秒。
还有许多其他方法可以优化内存子系统,这里只是几个例子:
-
使用循环交换移动到更好的内存访问模式。
-
减小结构/或类的大小。将很少使用的成员删除到其他结构。
-
采用数组结构。这种转换通常会导致有问题的循环被矢量化。
-
减小随机访问数据结构的大小(例如,使用更小的数据类型或更小的指针)。
Parallelware Analyzer 提供有关如何应用这些技术和其他技术来优化内存子系统代码的提示,例如PWR010或PWR016建议。它还提供了一些报告来深入了解代码中的内存使用情况,包括内存访问模式。
针对CPU的分支预测单元进行优化
现代 CPU 经过优化以保持高吞吐量的指令。它们遵循流水线设计,理想情况下,要处理的指令流永远不应该被中断。条件代码和分支会带来问题,因为在条件分支之后执行的指令取决于条件。因此,下一个确切的分支指令不能被流水线化,因为在条件指令被完全评估之前它们是未知的。
为了帮助解决这个问题,CPU 具有高级分支预测单元,试图预测分支是否被采用。基于预测,CPU 在预测的分支目的地开始执行指令,这样计算就不会停止。如果预测结果是正确的,这意味着 CPU 并没有闲置并且已经完成了一些工作。如果错误,CPU 需要恢复已执行指令的结果并重新开始。恢复由于错误预测而发生的结果意味着 CPU 将浪费一些时间。
大多数时候,分支预测器工作得很好!它可以预测简单的分支,例如始终为真或始终为假,但它也可以预测具有复杂历史的分支,例如交替真/假或真百次和假一次(循环发生)。
当分支不可预测(发生在随机数据上)并且条件为真的可能性为 50% 时,分支预测器的工作效果不佳。在这种情况下,您可以预期 50% 的成功预测率,并且这种代码有很多需要改进的地方。
以下面的代码为例(受Canny 图像处理示例的启发):
由于循环迭代之间可能存在数据依赖性,上述循环是不可矢量化的,因为i[i]和in[i-1]可以引用out数组的相同元素。如果条件cond[i] > 0是不可预测的,有 50% 的可能性为真,CPU 将失去许多周期来做无用的工作。
在这些情况下,我们可以尝试使上述代码无分支。这是相同代码的示例,但没有 `if` 语句:
对于不可预测的情况,原始版本需要 516 毫秒来执行一个大小为 1 亿个元素的数组。无分支版本耗时 123 毫秒,比原始方法快 4.2 倍。
Parallelware Analyzer 目前不提供分支预测优化相关的建议,但我们计划在未来解决这个问题,以加速无法使用向量化优化的循环。
概括
在这篇文章中,我们介绍了一些技术,这些技术可以让您通过优化使用可用硬件来加速您的代码。我们从利用额外的 CPU 和加速器硬件开始,转向使用 CPU 的矢量化功能,探索内存子系统的有效使用,并完成分支预测器的优化。我们给出的例子展示了每种技术的可能性。
Parallelware Analyzer 可以通过多种方式为您提供帮助!它通过提供人类可读与可操作的建议来指导您完成优化代码的过程。它可以检测编译器无法自动矢量化的低效代码,并提出修复建议以及降低代码速度的低效内存访问模式。此外,它可以自动重写您的代码,以利用额外的 CPU 内核和加速器设备。我们的愿景是使其成为市场上编写快速和高效代码的最佳工具!
附加信息
示例中使用的所有源代码都可以在我们的存储库中获得,也可以在最初执行实验的页面上获得。这篇文章的所有测试都是在 Ubuntu 20.04 上的 AMD Ryzen 7 4800H CPU 上执行的,具有 16 核、16 GB RAM 内存和 GeForce GTX 1650 Ti GPU。我们禁用了处理器频率调整以减少运行时差异。