本文共 4593 字,大约阅读时间需要 15 分钟。
本节书摘来自华章计算机《高性能科学与工程计算》一书中的第3章,第3.5节,作者:(德)Georg Hager Gerhard Wellein 更多章节内容可以访问云栖社区“华章计算机”公众号查看。
在基于cache的处理器上,许多循环的优化潜力可以通过观察某些基本参数(像数据传输、算术运算和问题规模的伸缩性)很容易地估算出来。从而进一步确定优化的努力是否有意义。
3.5.1 O(N)/O(N)如果算术运算量和数据传输量(加载/写入)与问题规模(或者“循环长度”)N成比例,那么优化潜力是非常有限的。标量乘、向量加和稀疏矩阵向量乘都是这类问题的典型实例。当N取值较大时,其性能不可避免地受访存限制,并且使用编译器生成的代码就可以达到较高的性能。这是因为O(N)/O(N)循环往往很简单,正确的软件流水策略的作用非常明显。但是嵌套循环是一个不一样的问题(具体见下面的分析)。然而,即使循环不嵌套,优化空间还是经常存在的。作为一个实例,考虑下面的向量加代码:冲突被大约降低了一半,剩余的循环在外层调用本例
然而,所有这一切都假设寄存器的压力不会太大。即假设CPU有足够多的寄存器来容纳循环体内部数量相当可观的操作数。如果不能,编译器必须溢出寄存器数据到cache中,这样会降低计算性能(见2.4.5节)。再一次强调,可用的编译日志可以帮助确定这种情况。循环展开与合并在高级别优化上可由有些编译器自动实现。一个复杂的循环体可能会隐藏一些重要的优化信息,因此手工优化是必需的:通过手工优化或者编译器指令来指定像循环展开等高级别转换。如果可用,编译器指令是首要选择的优化方法。这是因为指令比较容易维护且不会导致明显的代码膨胀。可惜的是,编译器指令本质上是不可移植的。尽管同稠密矩阵向量乘相比,上节所讨论的矩阵转置算法没有直接机会优化数据传输(两个矩阵只需读写各一次),该算法同样是O(N2)/O(N2)问题的典型实例。通过在矩阵转置算法的“flipped”版本上应用循环展开与合并方法,得到了将近50%的性能提升(参见图3-8的虚线)。尽管如此,当N取值较大从而使cache不能容纳N个cache行的数据时,减少TLB未命中并不能阻止性能的下降。最好能有一个策略重用非连续cache行剩下的Lc-m个元素,从而使cache行可以很快替换出去而不需要再次加载。一个“野蛮”方法是Lc路循环展开,但是这个方法会导致写操作时更大间隔的非连续访问。同时,由于循环展开因子过大,导致循环体中寄存器使用量的增加(算术操作引起的),因此这并不是一个通用的优化方法。循环分块(loop blocking)能够在不增加寄存器使用的情况下实现cache的最优使用。该方法不会减少数据读取或者写入操作,但是会增加cache的命中率。对于深度为d的嵌套循环,分块引入了d个额外的外层循环,将原来的内层循环切分成块:
循环分块是非常普遍和强大的优化方法,并且编译器不会自动执行。最佳分块因子应该通过精心的基准测试实验获得。然而,cache的大小也可以指导最佳分块因子的选择。即,当对L1 cache分块时,内层循环中所有分块的大小总和不应该超过cache大小的一半。究竟对哪一级cache进行分块依赖于具体的算术操作,这里没有通用的指导建议。
3.5.3 O(N3)/O(N2)如果算术计算量随着问题规模的扩大以一定的因子增长而大于访存数据量,那么这类算法具有巨大的优化空间。通过上述优化技术(循环展开与合并、循环分块)的使用,这类算法性能可能不会受cache限制。这类算法展现了O(N3)/O(N2)特征,稠密矩阵矩阵乘(Matrix-Matrix Multiplication,MMM)和稠密矩阵对角化是这类算法的典型实例。开发一个精心优化的MMM程序已经超过了本书的范围,更不用说特征值计算。但是我们可以通过一个简单的实例(实际O(N2)/O(N)类型)来说明这类算法优化的基本原理。尽管A通过cache被加载了N/b次,但当前B分块被置换出cache的可能性非常低。这是因为根据LRU置换算法,当该cache行被频繁使用时是不会被置换出去的。这使得内存传输非常高效:共传输N (N/b+1)个字。因为b的取值可以比典型循环展开因子大许多,所以分块是最好的优化策略。此外,循环展开与合并方法依旧可以用来增加cache内部(in-cache)的代码平衡值。基本的N2依赖还是存在的,但是结合前因子可以说明内存受限和cache受限程序的差异。当内存带宽和访存延迟不是性能限制因素时,该代码是cache受限的。cache受限代码在特定架构上能否达到预期性能目标依赖于cache大小、cache和内存间的数据传输速度,当然还有算法本身。
O(N3)/O(N2)算法是经过精心优化、其性能可以接近硬件理论峰值性能的典型候选算法。如果循环分块和展开因子选择恰当,则对于N×N的矩阵(当N取值不会过小时),稠密矩阵向量乘的性能经常会达到峰值性能的90%。系统厂商会提供该算法的高度优化版本,一般包含在BLAS(Basic Linear Algebra Subsystem,基本线性代数子系统)库中。既然循环分块已经完成了将代码转化为cache受限的绝大部分重要工作,为什么还要应用循环展开方法?这是因为即使所有的数据都位于cache中,但许多处理器架构在每个时钟周期内也不能持续维持足够的加载和写入操作来满足算术运算的需要。例如,当前英特尔的x86架构处理器每个时钟周期内只能进行一次数据加载和一次写入操作,这就使得当内核的循环嵌套使用多于一个数据加载操作时(特别是上例中O(N2)/O(N)算法的cache受限分块),就需要使用循环展开与合并方法。这里的讨论是出于教育目的,因此没有必要手工编写和优化标准线性代数和矩阵操作。这些函数一般总是从高度优化的函数库中调用。尽管如此,这里讨论的优化方法是能够应用到许多实际代码中去的。稀疏矩阵向量乘就是一个有趣的例子(见3.6节)。转载地址:http://lkzza.baihongyu.com/