在工程实践中,算法实现常伴随着处理器选型以及代码优化两方面的工作。本文将从算法设计本身和基于特定处理器平台的算法实现这两个角度,列举出几个关键的评估维度。通过这些维度的衡量,我们可以一探处理器运算性能的极限,并做出更加优化的算法实现策略。
算法复杂度
算法复杂度是为了在理念层面上比较两种算法而设计的,仅依据算法本身的内容来比较算法。人们希望借助于算法复杂度分析,来了解如果给算法一个不同的输入,它将如何表现。
考量一个算法的表现,我们可以把注意力放在度量算法性能的上限上来,也即算法如何面对最坏的情况?它何时会遇到具有挑战性的艰难任务?这一分析方法称之为“最坏情况分析”;同时,我们希望在进行算法复杂度分析时,先忽略特定的编程语言和编译器之间的差异,将分析重点集中在算法本身的思想上。另外,在趋于极限情况时,算法的表现将主要受高阶因素项的影响,关注这一点便可以简化问题的分析。这一分析方法称之为“渐进行为分析”。
以上两点,要求我们做算法分析时要保持大局观,其基本思路是:
-
关注运行时间的增长趋势。
-
忽略那些依赖于机器的常量和低阶因子。
算法复杂度常从两个维度来评估:时间复杂度和空间复杂度。我们使用时间复杂度来度量算法运行的性能,使用空间复杂度来度量算法要使用的存储器空间大小。
时间复杂度
时间复杂度用O(f(n))表示,函数f(n)描述了算法计算量随着计算规模n的变化而变化的趋势,当n趋于无穷大时,通过f(n)我们就能知道算法在极端(最坏)情况下的计算(时间)消耗情况。
与时间复杂度关联紧密的一个量是时间频度T(n),它用于描述一个算法中的语句执行次数。我们可以认为,对时间频度T(n)做渐进行为表示,得到的就是时间复杂度O(f(n))。因此,在求算法的时间复杂度时,通常是先计算得到算法的T(n)表达式,再进一步求得算法的O(f(n))表示。
以如下的矩阵与向量乘法为例:
for(i=0; i<row_len; i++);
{
temp = 0;
for(j=0; j<col_len; j++)
{
temp += matrix[i][j] * vecter[j];
}
product[i] = temp;
}
设row_len = col_len = n,并不考虑循环跳转所需的指令时间。在内层循环中,包含一次乘法,一次加法,两次读操作和一次写操作;在外层循环中,包含两次写操作。因此该算法的时间频度可表示为T(n) = 5n^2 + 2n。考虑到时间复杂度忽略低阶和常数项的影响,该算法的时间复杂度为O(n^2)。
需要注意的一点是,对于T(n)仅含一个常数项的算法,它的时间复杂度表示为O(1)。
空间复杂度
空间复杂度是指算法在计算机内执行时所需存储空间的度量,与时间复杂度的分析思想类似,空间复杂度也用O(f(n))表示。
一个算法在计算机存储器上所占用的存储空间,包括存储算法本身所占用的存储空间,算法的输入输出数据所占用的存储空间和算法在运行过程中临时占用的存储空间这三个方面。空间复杂度不考虑存储算法占用的空间,同时,由于算法的输入输出数据所占用的存储空间是由要解决的问题决定的,它不随算法的不同而改变,在分析空间复杂度时一般也不予考虑。因此,我们一般所讨论的空间复杂度,是用于衡量算法正常占用内存开销外的辅助存储单元规模。
以上面时间复杂度分析所用的例子来说,临时的存储空间只有一个temp变量,而matrix、vecter、product三者均为输入输出变量,固该算法的空间复杂度为O(1)。
实现复杂度
尽管算法复杂度能够在抽象层面上衡量算法的性能,但在分析算法在具体处理器平台上的表现时,它却不是一个很好的度量标准。原因有:
-
时间复杂度忽略低阶和常数项的影响,而两个算法复杂度相同的算法有可能在实现上会表现出成倍的性能差异,这些差异和编程语言、编译器平台、处理器的指令集、处理器的功能架构等有关;
-
空间复杂度也忽略低阶和常数项的影响,且不能表征算法访问存储器的次数,更没有考虑到处理器访存层次结构的影响;
-
不同算法在其实现时所关注的限制因素不同,仅关注时间复杂度或空间复杂度有时是没有意义的。比如,从来没有优秀的开发人员使用时间复杂度来衡量网络服务器或数据库服务器的性能;
-
时间复杂度不能很好地度量并行算法,并行算法需要的数据/任务划分、通信等都超出了时间复杂度的考量范围。因此一旦并行算法的这些特性成为了影响性能的重要因素,时间复杂度分析通常会得出与实际不符的结论。
由于以上分析的这些原因,实际应用中,算法复杂度提高但是实现更快的例子很多。为更好地度量算法在实现时的性能,文献2的作者刘文志提出了实现复杂度的概念。鉴于某个特定的处理器上算法的实际性能基本上由运行时的计算,仿存和指令决定,他将实现复杂度分三个维度来评估:计算复杂度、访存复杂度和指令复杂度。
在做实现复杂度分析时,应注意以下几点:
-
脱离抽象回归具体,评估实现复杂度时已不能像算法复杂度那样做渐进行为分析,而应该尽可能地还原处理器实现的细节。
-
对于具体的代码而言,依据在某种处理器上运行的性能瓶颈不同而采用实现复杂度的不同方面来度量。对于一个计算限制算法,应当使用计算复杂度和指令复杂度。对于一个存储器限制的算法,应当使用访存复杂度来分析。
计算复杂度
计算复杂度衡量的是每一个控制流的计算数量,它评估处理器对指定算法的计算能力。在并行算法上应用计算复杂度分析时,如果控制流之间的计算量并不均衡,同时考虑控制流之间最大计算复杂度和最小计算复杂度,它们的比例就可大致估计负载不均衡的程度。
与时间复杂度只考虑指令数量不同的是,计算复杂度还需要考虑指令的吞吐量或延迟,将它们加权平均。计算复杂度权重可基于延迟或吞吐量确定,但实际都可转化为吞吐量。
通常可以把处理器分为为延迟优化设计和为吞吐量优化设计两类。比如ARM cortex-r系列处理器,它比较注重对事件的响应速度,因此单条指令的延迟是很短的,我们可以把它归为基于延迟设计的处理器。又比如NVIDIA的GPU,它拥有众多的并行处理核心,单次可以做多个并行计算,但它每次计算的延迟相对又是比较长的,我们可以把它归为基于吞吐量优化的处理器。但有时这两种归类方法也会变得模糊,因为越来越多延迟性很好的处理器也加入了指令级并行能力。总之,究竟是以延迟还是吞吐量来考量计算复杂度,应该结合处理器的特点以及算法的需求来共同决定,选择最贴近于现实目的那一个。
例如我们要实现下面的计算:
int a, b, c;
c = a + b;
其计算复杂度公式为O(2n*α + n*β + n*γ),其中α表示加载指令的吞吐量倒数,β表示加法指令的吞吐量倒数,γ表示存储指令的吞吐量倒数。如果代码运行在为延迟优化的处理器上,只需将α、β、γ替换成时钟周期数即可。
以Intel Haswell架构为例,假设数据均在一级缓存中,则α=3,β=0.25,γ=3,固计算复杂度为O(9n + 0.25n),可以看出计算复杂度主要由访存带来,这意味着这个算法的瓶颈是访存。
指令复杂度
指令复杂度衡量执行代码关键路径所需要的时钟周期数。它与计算复杂度的区别是:计算复杂度没有考虑处理器能并行执行多个不同指令的情况,而指令复杂度考虑;计算复杂度适合用于同一处理器平台的不同算法或不同控制流之间的比较,而指令复杂度适合于同一算法的不同实现策略间的比较,因此常用于指导实现性能优化。
执行代码关键路径的确定方法在信号君之前的文章 《TI C6000 优化进阶:循环最重要!》中有过介绍,这里不做赘述。另外,个人认为文献2中提出的计算复杂度和指令复杂度的概念,可以对应于这篇文章中讲到的未分配资源限和已分配资源限,只是表述有所不同。
借文献2作者刘文志的话强调:代码计算效率优化的过程,就是指令复杂度降低的过程!
访存复杂度
访存复杂度衡量算法访问缓存层次的字节数量。如在《计算机系统中与存储有关的那些事》中介绍的,计算机系统中的存储空间往往是一个多层次结构,访存复杂度并不是对算法在各层中的访问性能做加和,而是由瓶颈所在的存储层次决定。如果算法是一级缓存密集型,则需要分析一级缓存访问字节数量;如果算法是多核心共享缓存密集型,则需要分析所有处理器核心访问共享缓存的总数量。
访存复杂度通常使用缓存层次的带宽来表示。计算访存复杂度时,由于缓存缺失(cache miss)带来的额外开销也应该计算在内。
有四种与存储有关的带宽值得介绍下:
-
存储器的峰值带宽:由硬件规格决定,通常可由硬件参数计算得到。如双路双通道DDR3-2333内存,其峰值带宽为2(双路)*2(双通道)*2.333*8(64位内存控制器) = 74.6GB/s。
-
可获得的存储器带宽:由于硬件设计方面可能存在不足,可获得的存储器带宽通常要小于存储器的峰值带宽,其大小一般由程序测试获得。
-
程序发挥的存储器带宽:某个特定程序在硬件上发挥的带宽,该值可以通过硬件计数器获得,顶级软件开发人员也可手工计算出来。
-
程序的有效访存带宽:表示程序最小要访问的数据量与程序计算时间的比值,可由算法计算获得。该值能表示访存优化能达到的上限。
其中,访存复杂度分析的是程序发挥的存储器带宽。程序发挥的存储器带宽与可获得的存储器带宽比例,表示程序已经发挥了硬件能够提供的带宽的比例。程序有效访存带宽和程序发挥的存储器带宽比例,表示程序访问存储器的效率。
探求计算性能的极限
针对固定的算法和处理平台,性能优化工作往往存在如下图所示的规律:
当性能优化到一定程度,再想往上提升就需要花更多的时间做人为的指令调度和程序结构调整的尝试。但不管怎么优化,性能优化一定存在如图中所示的一道红线,它是计算性能的极限,是性能优化不可能逾越的终点。
同时,从不同维度如指令、访存等来看,各维度又分别存在性能优化的天花板。比较容易能想到的是,性能优化的红线一定比所有维度的天花板都低。
追求极致的性能优化工作都应该试图去接近红线。在实践中就是预先评估算法瓶颈所在,从这一维度出发找到性能优化的天花板,然后不断试图接近它。你也许可以使用计算复杂度来确定并行计算中负载均衡的性能天花板;也许可以使用指令复杂度来确定计算效率的天花板;也许还可以使用访存复杂度来确定访存优化的天花板……当你撞上了天花板,那么恭喜你,这也是红线!
比较的科学
从学术研究到工程实践,从工作到生活休闲,比较无处不在。日常生活中,当我们做出选此而非彼的决定时,大部分时候都是顺着感性的引导,也就是我们各自的偏好。那么,当我们需要做出一个严谨、理性、可量化的选择时,我们又该如何科学地去评估面前的选项呢?
在写完这篇对算法的评估内容后,我想到答案也许就是这三条:
-
选取一个评估维度;
-
如果可以,找到该维度下的极限;
-
如果有必要综合多个维度做比较,计算维度的加权和。
首先,同一个维度的比较,其结果才是有意义的。代数中“基”、参数估计中的优化准则等,其本质就是一个比较的维度。而如果能在特定维度下确立一个极限,我们就能清晰地知道当前选项所处的位置。就像通信中有“香农限”,参数估计中有“克拉美罗界”。至于多个维度的加权和,那就需要你我来调参咯。
参考资料
【1】翻译 | 浅析算法复杂度分析--信号君.
【2】刘文志. 并行算法设计与性能优化[M].北京:机械工业出版社,2015.
【3】米歇尔.杜波依斯等著, 范东睿等译. 并行计算机组成与设计[M].北京:机械工业出版社,2012.
【4】王琤. 计算性能的极限——探求计算密集应用优化的天花板.pdf
【5】Analysis of algorithms--Wikipedia.
·END·
欢迎来我的微信公众号做客:信号君
专注于信号处理知识、高性能计算、现代处理器&计算机体系
技术成长 | 读书笔记 | 认知升级
幸会~