第3章 算法性能和程序性能的度量与分析

分析程序的性能,告诉代码优化人员发掘了多少计算机的计算能力,即程序还有多少潜在的优化空间。算法的性能分析能够指导算法设计,而算法或程序性能的度量是分析的基础。目前常用的表达算法性能的量有时间复杂度和空间复杂度,而表达程序性能的量有:时间、FLOTS/CPE/IPC等。

由于时间和空间复杂度无法准确衡量算法实现的复杂度,因此本章提出了实现复杂度概念。

时间和空间复杂度关注抽象算法的性能,而实现复杂度更关注于如何估计算法的具体实现性能

3.1 算法分析的性能度量标准

如果能够在编写代码前就依据某些标准来最优化算法,就会节约大量算法选择时间。

通常使用时间复杂度来度量算法运行的性能,使用空间复杂度来度量算法使用的存储空间的大小。

实现复杂度进一步细化为计算复杂度、访存复杂度和指令复杂度。

3.1.1 时间复杂度和空间复杂度

1. 时间复杂度

        通常使用时间复杂度来衡量算法需要的大致时间尺度,比如算法操作n个数据,每个数据大约 要运算2n次,则时间复杂度为O(n^{2})。

for (int row = 0; row < nRows; row++)
{
    T temp = (T)0;
    for (int col = 0; col < nCols; col++)
        temp += matrix[row * nCols + col] * vector[col];

    producr[row] += temp;
}

从计算矩阵向量乘法的时间复杂度过程,我们可以得知,时间复杂度有以下几个方面特别注意:

        1) 只关注计算对性能的影响,而不关注访存

        2) 只关注最高阶计算量对性能的影响,而忽略低阶计算量对算法性能的影响;

        3) 只关注运算的阶,而忽略对阶的比例系数对算法性能的影响;

        4) 不关注不同的计算对性能的计算;

时间复杂度是一个很好的大致评价算法优劣的标准,但不是一个很好的性能优化度量标准,主要原因如下:

        1. 时间复杂度只考虑计算对性能的影响,而不考虑数据读写对性能的影响。从冯诺依曼架构来看,数据读写为计算提供所需要的输入/输出,不考虑数据读写明显忽略性能的一个重要指标;从计算机架构来说,目前数据读写越来越成为多核向量处理器性能的瓶颈(内存墙)。

        2. 不同的算法具有不同的限制因素。有些算法的大部分性能限制在读取网络数据上,有些算法限制在TLB上。

        3. 不考虑处理器执行不同指令的速度差异。通常生产商将更多的晶体管用于常见指令,以提高这些指令的性能,这导致同一处理器上不同指令的性能并不相同。比如Intel Haswell处理器一个周期可执行4次整数加法,而对于浮点加法一个周期只能执行1次。

        4. 忽略常数和低阶项对性能的影响。在某些问题规模上,O(n)性能比时间复杂度 O(n^{2})的算法要差的情况。

        5. 不能很好地度量并行算法。并行算法需要的数据/任务划分、通信等都超过了时间复杂度的范围。

2. 空间复杂度

空间复杂度用来表示算法运行需要的存储器空间。和时间复杂度类似,空间复杂度也忽略低阶和常数的影响,也忽略常数的影响,且无法衡量程序运行时要访问多少次存储器,更没有考虑缓存层次结构的影响。空间复杂度能够衡量程序运行时需要的大致数据量,其相对意义大于绝对意义。

3.1.2 实现复杂度

在某个特定的处理器上,程序的实际性能基本上由运行时计算、访存和指令决定,因此本文提出了计算复杂度,访存复杂度和指令复杂度的概念。

对于具体的代码而言,依据在某种处理器上运行的性能瓶颈不同而采用实现复杂度的不同方面来度量。

对于一个计算限制的算法,应当使用计算复杂度和指令复杂度

对于一个存储器限制的算法,应当使用访存复杂度来分析。

1. 计算复杂度

        计算复杂度可用来衡量每一个控制流的计算数量,不但包括加载存储指令,还需要考虑指令的吞吐量或延迟,通过加权平均来表示性能。

        由于现代多核向量处理器具有不同的特点,比如X86 CPU是为延迟优化的,在考虑计算复杂度时权重应该以延迟为准;而GPU是为吞吐量优化的,考虑计算复杂度时权重应当以吞吐量倒数为准。但这也并不绝对。

        以输入两个长度为n的32位浮点数组a,b,对两个数组中的每个元素a[i],b[i]做加法,并以保存结果这一简单运算为例。其计算复杂度公式为:

O(2n*\alpha + \beta * n + \gamma * n)

\alpha:表示加载的吞吐量的倒数,即加载指令的吞吐量的倒数

\beta:加法指令的吞吐量的倒数

 \gamma:存储指令的吞吐量的倒数

        如果代码运行在一台延迟优化的处理器上,只需加\alpha ,\beta ,\gamma,替换成延迟时钟周期即可。

        以Intel Haswell架构为例,假设数据都在一级缓存上,此时\alpha为3,表示从一级缓存中加载数据的延迟为3个周期;\beta为0.25,表示加法指令的吞吐量为4;\gamma为3,表示从存储数据到一级缓存的延迟为3个周期。故计算复杂度O(2 * 3 * n + 1/4 * n + 3 * n) = O(9n + 0.25n),从中可用看出,绝大部分计算复杂度是由访存带来的,这意味着这个算法的瓶颈是访存。

        计算复杂度还和硬件架构有关。如果算法的某种指令可以被SIMD单元处理且程序的确会使用SIMD指令,其吞吐量倒数就会考虑一次可SIMD的操作数量。继续以向量加法为例,如果使用128 SSE指令的话,其计算复杂度为:

O(2 * \alpha * n * 32 / 128 + \beta * n * 32 / 128 + \gamma * n * 32 / 128) = O(0.5\alpha n + 0.25\beta n  + 0.25\gamma  n)

         如果某条分支指令需要被SIMD单元执行两次,则需要相应的吞吐量的倒数除以2。

        在并行算法上应用计算复杂度分析时,如果控制流之间的计算量并不均衡,那么需要同时考虑控制流之间的最大计算复杂度和最小计算复杂度,它们的比例就可以大致估计负载不均衡的程度。

        例如,假设一个有两个线程的并行算法,两个线程的计算复杂度分别为O(0.5n)和O(2n),故计算时间由O(2n)决定,应用负载均衡算法后,最好的性能提升是 2 * 2 /(2 + 0.5) = 1.6倍

2. 访存复杂度

        访存复杂度衡量算法访存缓存层次的字节数量,通常不是直接的相加,而是由瓶颈在缓存层次的哪一级决定。比如,如果算法是内存密集型,只需要分析内存访问字节数量;如果是一级缓存密集型,则需要分析一级缓存访问字节数量,如果算法是多核心共享缓存密集型,则需要分析所有处理器核心访问共享缓存的总数量。

        计算访存复杂度时,由于缓存容量大小,替换策略和缓存线大小导致的额外开销也应当计算在内。

        对于访存复杂度,通常使用缓存层次的带宽来表示,比如算法发挥的一级缓存的带宽是多少,二级缓存带宽是多少。

        对于一个具体的程序来说,通常具有以下几种带宽定义:

                1) 存储器的峰值带宽: 该值由硬件规格决定,通常可由硬件参数计算出来。如双路双通道DDR3-233内存,其峰值带宽为:2(双路) * 2(双通道)* 2.333(内存等效频率) * 8(64位内存控制器) = 74.6 GB/s。

                2) 可获得的存储器带宽:该值亦由硬件决定,不过其大小由stream程序测试获得。由于硬件设计方面可能存在不足,可获得的存储器带宽小于存储器的峰值带宽。

                3) 程序发挥的存储器带宽:此值是硬件为某个特定程序发挥的带宽,一般来说,此值可通过硬件计数器获得,但是某些顶级的软件开发人员也能手工计算出来。

                4) 程序的有效访存带宽:此值由算法计算获得,其表示程序要访问的数据量与程序计算时间的比值。其值还表示访存优化能够到达的上限,对程序访问带宽的优化不可能比程序的有效访存带宽还小。

        访存复杂度分析的是程序所发挥的存储器带宽,因为其值是程序在硬件的存储器上执行时的实际访存字节数。

        可获得的存储器带宽与存储器的峰值带宽的比例可以反映存储器处理访存的效率。比如Intel Xeon Phi处理器的峰值显存带宽约为350GB/s,而可获得的显存带宽大约为170GB/s。对于主流GPU,其值在70%~80%。而在Intel X86处理器上,此值通常能够达到90%。

        程序发挥的存储器带宽与可获得的存储器带宽的比例表示程序已经发挥硬件能够提供的带宽的比例,它意味着存储器还能够提供的带宽余值,如果值接近1,表示存储器的带宽已经被耗尽,下一步优化应当考虑如何减少访存次数和被浪费的存储器带宽。

        程序的有效访存带宽和程序发挥的存储器的比例表示程序访问存储器的效率,本书称之为访存效率。程序的访存效率越高表示浪费的带宽越小,即访存模式越优秀。

3. 指令复杂度

       相比计算复杂度和访存复杂度而言,指令复杂度分析实现算法的具体程序所生成的指令数量、类型、是否可以同时执行等等。

        从某种程度来说,指令复杂度的分析是人脑对程序指令在具体处理器上执行的分析,因此非常复杂和困难,也更容易出错,笔者通常使用一些编译器工具协助分析。

        通常使用执行代码关键路径所需要的时钟周期数来表示指令复杂度。指令复杂度是计算复杂度在具体计算平台的表现。

        由于指令复杂度和处理器、编译器及运行时环境密切相关,故并没有一个统一的标准来度量指令复杂度。

        由于处理器能够同时执行不同的指令,计算复杂度没有考虑这一情况,而指令复杂度考虑,因此指令复杂度分析的结果通常比计算复杂度分析的结果要小。

        由于同一代码在不同硬件、不同编译器、不同编译选项配置条件下,生成的指令系列可能并不相同,因此指令复杂度不适合交流。但对于顶级代码优化人员来说,指令复杂度是优化的终极法宝。代码优化的过程,就是指令复杂度降低的过程

        指令复杂度分析通常关注耗时最长的循环,通常分析此循环内部的指令数量,不同指令是否能够同时发射、执行等,通过这些信息来重构代码,以便生成的指令能够更好地在处理器上执行。

3.2 程序和指令的性能度量标准

        目前常用的度量程序、代码段和指令性能的标准有时间、FLOATS、吞吐量和延迟等。对于不同类型的内核(指程序耗时的核心计算部分),应当选用不同的评价标准。

        如果在一个延迟优化的机器上使用吞吐量来评价性能的意义就不会比延迟好。对于一个存储器限制的内核使用FLOATS就可能得出错误的结论。

        1. 时间

                判断程序优劣的最简单的方式就是计算程序的运行时间,在同一台机器上,运行时间短的程序一般来说是更优的。不过不能一概而论,比较决定程序运行速度的因素很多,比如算法、机器的指令集、使用的语言、编译器是否优秀等。

                计时的方法很多,如标准的C库的time,clock系列函数,CUDA的事件,Linux下的gettimeofday,Windows下的GetTickCount。

                在linux系统下,笔者习惯使用gettimeofday函数,其原型如下:        

gettimeofday(struct timeval*, struct timezone*);

struct timeval
{
    long tv_sec;
    long time_usec;
};

struct timezone
{
    int tz_minuteswest;
    int tz_dsttime;
};

                通常使用clock和getTimeOfMSecond函数就已经足够,如果需要纳秒级的计时,可以使用Linux的clock_gettime函数。

                clock函数并不适合准确的测试程序的运行时间,更不应当用来测试多核并行程序运行的时间。对于多线程程序来说,由于多线程的影响导致最后的计时结果通常是实际执行时间的倍数,这个倍数会大致等于核数。

        2. FLOPS

                FLOPS表示硬件每秒执行的浮点运算的数量,通常使用的单位FLOPS。

                从定义上看,要提高一个程序的FLOPS有两种方法:

                        1) 在时间不增加的情况下,增加程序的浮点运算数量;

                        2) 在程序的浮点运算数量保持不变的情况下,减少程序的运行时间;

                现代处理器都有专门用来处理浮点运算的"浮点运算器"(FPU),FLOPS所测实际上就是FPU的执行速度。

                由于FLOPS不但不区分运算指令,还不区分IO、缓存与计算,因此大多数程序的实际性能通常远小于硬件的FLOPS峰值性能。

        3. CPI(Clock Per Iteration)

                通常程序最耗时的部分就是小循环,CPI表示每次循环耗费的时钟周期数,能够表示每次循环的平均代价。

float result = 0.0f;
for (int i = 0; i < num; i++)
{
    float temp = data[i];
    result += temp;
}

               假设测试发现上面的代码在某个具体配置下耗时T个时钟周期,那么其CPI为T/num。

               常用的性能优化手段循环展开的效果却无法用CPI表示,而且CPI无法判循环的优化是否已经达到系统的界限或还有多少优化可能。

                性能优化通常要减少程序的CPI,但是这并非绝对。在循环次数不变的前提下,要减少CPI,只能是减少执行循环的周期数。通常使用的办法有:减少循环内指令数,使用时钟周期数更小的指令。

                循环的CPI通常由其依赖链(指整个循环中相互依赖的运算或访存,也称为关键路径)决定,减少循环CPI的过程就是减少或去掉循环内依赖链的过程。

        4. 延迟

                延迟是指一条指令、一个操作从开始发射到完全结束所经历的时间。

                Intel在其编程手册中给出了许多指令的延迟,但是NVIDIA和ARM并不会给出其处理器上指令的延迟。(C++ pogramming guide Charpter 5 gived)

                对于某条具体的指令来说,减少其延迟是处理器生产商的责任;对于代码段来说,通常可由减少指令数量、使用延迟更小的指令等方法来减少代码的整体延迟

                相对指令延迟来说,还需要关注指令发射时间,因为在乱序执行的硬件上,发射时间也是指令性能能够到达的一个上限。

                现代X86 CPU是基于延迟优化的架构(虽然有乱序执行、流水线和SIMD等指令级并行技术)。对于运行在X86 CPU上的程序来说,减少获取操作数和指令的延迟是非常重要的,这导致了现代CPU的缓存越来越大,指令集越来越丰富。

                现代CPU采用的延迟优化技术有:乱序执行、缓存和多线程等。

        5. 吞吐量

                对于计算来说,吞吐量是指在单位时间内,硬件能够完成的操作数目;对于访存来说,吞吐量表示在单位时间内,硬件能够读写的字节数量。通常有两种方式表示吞吐量:
                        1) 每时钟周期指令数量(IPC);
                        2) 每秒执行指令数量,对于浮点数据就意味着FLOPS;

                从表面定义来看,吞吐量和延迟是倒数关系,实际上并非如此。由于现代CPU和GPU有非常长的流水线和多个执行单元,因此硬件平均完成一个操作的时间可能远远小于其延迟。虽然每个指令需要多个周期才能完成,但是能够到达在一个周期内完成多个指令的效果。

                由于对吞吐量优化的技术不要求尽快得到数据,而要求在单位时间内做更多的运算,因此硬件往往采用更多频率更低的计算单元,采用“大海战术”取胜。

                由于这种小而简单的单元工作频率和电压都比较低,因此耗能少,能够在给定的能耗下集成更多的核心,进而在不增加功耗的前提下提高整体的吞吐量。

                吞吐量倒数表示平均延迟下来某种指令至少需要的时钟周期数,是一种下限,而延迟则是一个上限。

                对于性能优化来说,使用瓶颈指令(耗时最多的指令)的吞吐量倒数乘以数量,就可大致估计一段代码最低运行时钟周期数。

        6. IPC(Instruction Per Clock)

                IPC表示每时钟执行的指令数目,是指令吞吐量的一种具体表示。对于程序来说,处理器的运行频率和指令的IPC决定了性能。处理器执行某种指令的吞吐量的计算公式是:指令吞吐量 = IPC * 处理器频率;

                在使用IPC时,应注意一下几点:

                        1) 不同的程序不能使用IPC来比较性能。对没有改变性能瓶颈的同一程序的不同优化版本,通常IPC越大,程序性能越好。

                        2) IPC很难衡量一个复杂程序的性能。同一程序在不同的机器上,不同的编译器上被翻译成多少种,多少条指令也不一定相同,因此执行程序时IPC最大的硬件可能是因为生成的指令数量最多。

                处理器的指令发射IPC也非常重要。因为一个或多个核心共享发射单元,因此在指令种类丰富的情况下,发射IPC很容易成为瓶颈。

                IPC还表示了一种架构执行某种指令的峰值性能。例如,在一个执行某种指令IPC为2的处理器架构上,由该指令限制的程序不能获得大于2的IPC。

        7. little定律

                little定律认为要发挥硬件执行某种指令的计算能力所需要的的并行度等于指令的延迟乘以指令的吞吐量。并行度 = 操作性能(吞吐量,带宽) * 操作延迟。

                并行度指没有依赖的指令数量。比如,如果循环内有8条不相关的乘加指令,那么此循环内乘加指令为8。并行度大意味着需要更多的不相关的指令,这要求软件开发人员发掘算法的并行性。

                 假设现代2路X86 CPU(每路6核)的带宽为50GB/s,内存的延迟大约是200个处理器周期,在一个主频3.0GHz的机器上,这意味着需要50 / 3 * 200 = 3333个字节。如果一条缓存线64字节,这意味着需要53条缓存线,平均每个核心需要53 / 6 / 2 = 4.4条无依赖的缓存访问。

                Intel Haswell处理器的一级缓存带宽为每核心每周期64字节,延迟为3个周期,并行度为192字节。假设使用256位SIMD操作,在需要192 / (256 / 8) = 6条SIMD读指令在流水线上执行。

                假设现代GPU的显存带宽为200GB/s,显存的延迟大约是700个时钟周期,在一个主频为0.7GHz的GPU上,这意味着需要同时读取200 / 0.7 * 700 = 200200字节。假设一条缓存线长度为128字节,意味着需要1564条缓存线。Kepler K20拥有14个SM,这表示每个SM同时要有110条缓存线访问在流水线上才能完全利用显存带宽。

                Intel Haswell处理器的浮点乘加指令吞吐量为每核心每周期2条,延迟为每条指令5周期,故需要的并行度为10。如果没有使用超线程技术,那么要达到浮点乘加的峰值,循环内就需要10条没有依赖的乘加指令。

3.3 程序性能优化的度量标准

3.3.1 加速比和并行效率

通常使用加速比来定义性能优化效果,表示优化前后程序的运行时间比值,计算方式如下:

                                                                S=Ts/Tp

但是无法说明并行优化的可扩展性,而并行效率表达了这一概念。并行效率定义为加速比与计算单元数的比例。

                                                                E = S/n

一般而言,如果并行效率低于0.5就说明并行优化失败的(这意味着双核的性能还比不上单核,当然如果你有几十个核,可能会认为并行效率0.5以下也是成功的)。一般并行效率在0.75以上就已经非常好的。

并行效率和可扩展形紧密相连,并行效率越高,可扩展性通常就越好。

有时并行化的加速比会大于处理器数目,这称为超线性加速。超线性加速的原因之一是缓存,因为每个核心通常有自己的一级缓存,如果单个处理器没有办法将数据全部放到缓存,那么多个处理器划分后有可能将数据全部放入缓存中;有缓存的效果超过多线程的开销时,那么就会出现超线性加速现象。

3.3.2 Amdahl定律和Gustafson定律

                        S‘ = 1 / (1 - f + f / S)

意味着程序最终可能获得的最大加速比由原始程序中串行代码运行时间的比例决定。该定律表明最大的加速比为1/(1 - f)。

Gustafson定律描述了增加处理器数目的同时,相应的增加问题的规模对加速比的影响。

强可扩展性是指在固定问题规模的前提下,随着处理器数目增加,其性能或加速比随之增加的变化情况。

而弱扩展固定每个处理器的问题规模的前提下,随着处理器数目增加,其性能或加速比基本不变化的变化情况。

强扩展性对应Amdahl,弱扩展性对应Gustafson定律。

3.4 程序性能分析实用工具

本书简单介绍Linux下剖分工具perf、gprof和valgrind。nvprof, Intel VTune工具。

TAU和Vampire可用来剖分部分MPI并行程序,其中Vampire不但支持MP,还支持OpenMP和pthread。

3.5 本章小结

常见的衡量算法性能的标准有:时间复杂度、空间复杂度和实现复杂度。本章提出的实现复杂度可分为:计算复杂度、访存复杂度和指令复杂度。

计算复杂度考虑程序中不同的计算类型对性能的影响;

访存复杂度考虑访存对性能的影响,这是时间和空间复杂度没有考虑到的;

指令复杂度则考虑到了不同指令在处理器上如何执行对性能的影响。

常见的用来衡量程序性能的指标有:时间,延迟,吞吐量、CPI、IPC和FLOTPS。

用来衡量优化一部分代码对程序整体性能的影响的主要有:Amadahl定律(强可扩展性)和Gustafon定律(弱可扩展性)。

常见的用于程序性能分析的工具有gprof、valgrind、nvprof和perf等。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值