目录
一、算法优化指导思想
1.算法优化基本原则
2.算法优化方法
二、编译器优化
1,函数内联
2,消除公共子表达式
3,循环展开
4,GCC优化选项
5,ARMCC优化选项
三、ARM内存系统优化
1,数据cache优化
2,循环分块
3,内部循环优化
4,结构对齐
5,综合相关性影响
6,优化指令cache的使用
7,优化L2与外部cache的使用
8,优化TLB使用
9,数据终止优化
10,预取一个内存块的访问
四、代码级优化
1,循环终止
2,循环融合
3,减少栈和堆的使用
4,变量选择
5,指针别名
6,除法和模
7,外部数据
8,内联与嵌入式汇编
9,复杂寻址模式
10,对齐访问
11,链接优化
A 等效原则:优化前后程序实现的功能一致;
B 有效原则:优化后要比优化前运行速度快或占用存储空间小,或二者兼有;
C 经济原则:优化程序要付出较小的代价,取得较好的结果。
算法优化参考流程如下图所示:
算法性能评估指标
综合性能测试评估基本指标:时间,内存,CPU占用率,功耗
函数优化大致方向:
A消除冗余计算
B消除,优化选择,跳转分支
C低效率函数优化
D内存访问效率优化
当一个函数被调用时,会有一定的开销。如果它必须重用R14,被调用的函数必须在堆栈上存储自己的返回地址。根据过程调用标准,也可能需要指令将参数放入适当的寄存器并将寄存器推到堆栈上。当函数结束时返回到原始执行点时,可能会有开销,再次需要一个分支(以及相应的指令管道清理),并可能从堆栈中取出寄存器。当函数只包含少量指令,并且这些函数代表了总运行时的大量内容时,这种函数调用开销就会变得非常大。而且,执行分支会使用分支预测器资源,这会影响整个程序的性能。函数内联通过将对函数的调用替换为函数本身的实际代码副本(称为内联放置代码)来消除这种开销。
如果函数只在一个地方被调用,那么关键代码路径的内联总是值得优化的。另外需要考虑的是,内联可以帮助实现其他优化。显然,增加函数被调用的次数将增加函数的内联副本的数量,这将增加代码大小的成本。
GCC只在每个编译单元内执行内联。inline关键字可以用来请求特定函数必须在任何可能的地方进行内联,即使是在其他文件中。GCC文档提供了更多的细节,以及如何将其与static和extern结合使用。在考虑缓存优化时,我们将更详细地了解内联。
另一个简单的源码级优化是在后面的表达式中重用已经计算好的结果。当使用优化命令行开关打开时,这种常见的子表达式消除会自动执行,可以使代码更小、更快。然而,编译器不一定会识别出所有的情况,有时手工完成会更有用。
例:
i = a * b + c;
j = a * b * d;
编译器可以像例-2中那样处理这段代码。但必须注意的是,只有在a和b都不是易失性的情况下,它才能这样做。
tmp = a * b;
i = tmp + c;
j = tmp * d;
这减少了指令计数和周期计数。
每次循环迭代都有相应的开销。每个条件循环必须在每次迭代中包含一个循环结束的判断。此外,还有一个分支指令迭代遍历循环,这可能需要更多周期来执行。我们可以通过部分或完全展开循环来避免这个问题。
循环展开前:
for (i = 0; i < 10; i++)
{
x[i] = i;
}
循环展开后:
x[0] = 0;
x[1] = 1;
x[2] = 2;
x[3] = 3;
x[4] = 4;
x[5] = 5;
x[6] = 6;
x[7] = 7;
x[8] = 8;
x[9] = 9;
当代码以这种方式编写时,我们删除了比较和分支指令,并有一个存储和添加的序列。这显然比原始代码大,但执行起来要快得多。
通常,循环展开通常被认为可以提高程序的速度,但代价是代码大小的增加(非常短的循环除外)。然而,在实践中,在许多硬件平台上可能并不总是如此。在许多系统中,访问外部内存需要大量的周期,并提供指令缓存。循环代码通常能够很好地装入缓存。代码在第一次循环迭代时被取到缓存中,然后直接从缓存中执行。展开循环可能意味着代码只执行一次,而且因为代码比较大,所以不能很好地缓存。这种情况更可能发生在只执行一次的函数上。无论是否展开循环,频繁执行的循环都可能被缓存。另一个需要考虑的问题是,现代ARM处理器通常包括分支预测逻辑,这种逻辑可以通过在实际计算条件之前预测分支是否会被执行来隐藏管道刷新的影响。在某些情况下,可以折叠分支指令,这样它就不需要实际的处理器周期来执行。
GCC有一系列优化级别,以及启用或禁用特定优化的单独选项。总体编译器优化级别由命令行选项-On控制,其中n是所需的优化级别,如下所示:
O0:没有进行任何优化。每个源代码命令与可执行文件中的相应指令直接相关。这为源代码级调试提供了最清晰的视图。
O1:这样就可以实现最常见的优化形式,不需要对大小和速度进行决策,包括函数内联。它通常可以比O0生成更快的编译,因为生成的文件更小。
O2:这支持额外的优化,例如指令调度。同样,不会使用具有速度与大小关系的优化。
O3:这可以进行额外的优化,比如激进的函数内联,因此可以以牺牲图像大小为代价提高速度。此外,这个选项允许-ftree-vectorize -导致编译器尝试从标准C或c++自动生成NEON代码。
-funroll-loops:此选项独立于on选项,并启用循环展开。循环展开可能会增加代码大小,并不是在所有情况下都有好处。操作系统。
-Os:这选择了试图最小化图像大小的优化,甚至以速度为代价。
armcc编译器使您能够编译C和c++代码。它是一个优化编译器,具有一系列命令行选项,使您能够控制优化级别。命令行选项提供了优化级别的选择,如下所示:
-Ospace:此选项指示编译器执行优化以减少映像大小,但可能会增加执行时间。
-Otime:该选项指示编译器执行优化,以减少执行时间,但可能会增加镜像大小。 -O0:关闭大多数优化。它提供了最好的调试视图和最低级别的优化。
-O1:删除未使用的内联函数和静态函数。关闭会严重降低调试视图性能的优化。如果与——debug一起使用,该选项将提供一个令人满意的调试视图,并具有良好的代码密度。
-O2(默认)。高的优化。如果与——debug一起使用,调试视图可能不太令人满意,因为目标代码到源代码的映射并不总是清晰的。
-O3:执行与-O2相同的优化,但是与-O2相比,生成代码中的空间和时间优化更倾向于空间或时间优化。也就是说:-O3 -Otime的目标是生成比-O2 -Otime更快的代码,但可能会增加映像大小。-O3 -Ospace的目标是生成比-O2 -Ospace更小的代码,但性能可能会降低。
编写最适合系统的代码是编程艺术的关键部分。它要求您理解编译器和底层硬件将如何执行代码行中描述的任务。如果你可以用较少的外部内存来完成这项工作,你可以通过将所有东西都保存在芯片上来节省功耗。此外,通过减少访问外部内存的频率,可以改善系统的性能,使软件运行得更快,或者使处理器的时钟运行得更慢或更短,从而节省功耗。
在大多数Cortex-A系列处理器中,内存访问命中缓存和不命中缓存之间存在显著的性能差距。缓存丢失可能需要数十个周期来解析。缓存在几个周期内命中返回数据,编译器通常可以以一种隐藏延迟的方式调度指令。因此,对于大多数算法来说,确保缓存缺失最小化是最重要的可能优化。最重要的改进是那些影响1级缓存的改进。
考虑数据缓存丢失的问题。对于使用大于可用缓存大小的数据集的代码段,优化尤其重要。理解数据在内存中的排列以及它如何与数据缓存访问相对应是很重要的。代码的结构必须确保最大限度地重用已经加载到缓存中的数据。正是这种数据局部性原则,即在程序执行期间,在空间和时间上对同一缓存线的访问集中的程度,提供了最佳的性能。
循环平铺将循环迭代划分为更小的块,从而促进了数据缓存的重用。大数组被划分为更小的块(tile),这些块将访问的数组元素与缓存大小匹配。说明这种方法的经典例子是一个大的矩阵向量乘积。
考虑两个方阵a和b,大小都是1024 1024。例显示了计算矩阵向量乘积的代码。这要求您将每个数组中的每个元素与另一个数组中的每个元素相乘
for (i = 0; i < 1024; i++)
for (j = 0; j < 1024; j++)
for (k = 0; k < 1024; k++)
result[i][j] = result[i][j] + a[i][k] * b[k][j];
在这种情况下,矩阵a的内容是按顺序访问的,但矩阵b在内部循环中逐行前进。因此,您很可能会遇到每次乘法操作的缓存丢失。
很明显,计算结果矩阵中每个元素的加法的顺序不会改变结果,忽略了溢出等因素的影响。可以以提高缓存命中率的方式重写代码。在这个例子中,矩阵b的元素按如下方式访问(0,0),(1,0),(2,0)(1023,0),(0,1),(1,1)(1023,1)。元素按(0,0)、(0,1)等顺序存储在内存中。对于单词大小的元素,这意味着元素(0,0),(0,1)(0,7)将存储在同一个缓存行中。为了简单起见,我们假设矩阵的起始地址与缓存线对齐。
因此,元素(0,0)、(0,1)、(0,2)等将在同一条缓存线上;当你将(0,0)加载到缓存中时,你也会得到(0,1…7)。在内部循环完成的时候,很可能这个缓存线将被逐出。
如果您修改代码,使中间循环的两次(或实际上是四次,或八次)迭代在执行内部循环时立即执行,如例17-7所示,您可以做出很大的改进。类似地,您也可以展开外层循环两次(或四次或八次)。
for (io = 0; io < 1024; io += 8)
for (jo = 0; jo < 1024; jo += 8)
for (ko = 0; ko < 1024; ko += 8)
for (ii = 0, rresult = &result[io][jo],ra = &a[io][ko];
ii < 8;ii++, rresult += 1024, ra += 1024)
for (ki = 0, rb = &b[ko][jo];ki < 8; ki++, rb += 1024)
for (ji = 0; ji < 8; ji++)
rresult[ji] += ra[ki] * rb[ji];
现在有六个嵌套循环。外层循环的步骤为8,表示1级缓存的每行中存储了8个int大小的元素。还引入了一些额外的优化。“ji”和“ki”的顺序颠倒了,只有一个表达使用“ki”,但有两个使用“ji”。此外,您可以通过从内部循环中删除公共表达式来进行优化。在C语言中,所有的指针访问都可能导致混叠,因此,通过使用result、ra和rb访问数组元素,数组索引速度加快。
在许多程序中,都存在嵌套循环,一个非常简单的例子就是在一个二维数组中逐行执行项的代码。对于相当复杂的代码,有时可以通过重新安排循环来获得更好的性能。最好将迭代次数较少的循环作为外部循环,将迭代次数最高的循环作为最内部的循环。
这有两个潜在的优势。一个是编译器可能会展开内部循环。更重要的是,对于复杂的循环来说,嵌套循环的大小足够大,可能不会在同一时间全部保存在一级缓存中,此更改将提高总体缓存命中率。一些编译器可以在更高级别的优化时自动进行此更改。例如,GCC 4.4添加了switch- floon -interchange来实现这一点。
结构元素的有效放置和对齐并不是影响缓存效率的数据结构的唯一方面。当代码有一个大的工作集时,重要的是要有效地使用可用的缓存空间。为此,可能需要重新安排数据结构。
通常有跨越多个缓存线的数据结构,但是程序在任何特定时间只使用结构的少数部分。如果有很多这种类型的对象,可以尝试拆分结构,使其适合于缓存线。例如,可以将一个结构数组拆分为两个或多个较小结构的数组。这只有在对象本身对齐到缓存边界时才有意义。例如,考虑这样一种情况:您有一个非常大的64字节结构的实例数组(比缓存大小大得多)。在这个结构中,有一个字节大小的量,还有一个常用的函数,该函数遍历数组,只查看这个字节大小的量。这个函数将使缓存的使用效率低下,因为您将不得不加载整个缓存线路来读取8位值。如果这些8位的值被存储在它们自己的数组中(而不是作为一个更大的结构的一部分),每个缓存行填充将得到32或64个值。
支持非对齐访问,但与对齐访问相比会占用额外的周期。因此,出于性能原因,移除或减少非对齐访问是明智的。
正如我们所看到的,ARM L1缓存通常是4路的集合关联,而L2缓存通常是8路或16路的集合关联。如果数据中有超过四个位置属于同一个缓存集,就会出现性能问题,因为即使缓存的其他部分可能未被使用,也会出现重复的缓存丢失。ARM L1缓存使用物理地址而不是虚拟地址,所以对于在User模式下操作的程序员来说,要处理这个问题是很困难的。
这个问题的一个特别常见的原因是安排数据,使其处于2的幂的边界上。如果缓存大小是16KB,那么每种方式的大小都是4KB。如果您有多个排列在4KB的边界上的数据块,那么对每个块的第一次访问将进入第0行。如果代码访问了几个这样的块中的第一行,那么即使总共只使用了5条缓存线,你也会得到缓存丢失。非对齐访问可能会增加这种可能性,因为每次访问可能需要两条而不是一条缓存线。
C程序员不能直接控制代码如何使用指令缓存。分支指令之间的代码是线性的,这种顺序访问模式可以有效地使用缓存。核心的分支预测逻辑将试图最小化分支导致的延迟,因此您几乎无法提供帮助。您的主要目标是减少代码占用空间。ARM编译器和GCC在-O2和-O3上启用了许多编译器优化,用于处理循环优化和函数内联。如果代码占整个程序执行的很大一部分,那么这些优化将提高性能。特别是,函数内联有多个潜在的好处。显然,它可以通过在函数调用和退出时删除分支,以及可能的堆栈使用时删除分支,从而减少分支惩罚。同样重要的是,它使编译器能够优化更大的代码块,从而更好地优化值范围传播和消除未使用的代码。
然而,针对速度优化的修改增加了代码大小,实际上会因为缓存问题而降低性能。较大的代码不太可能装入L1缓存(或者实际上是L2缓存),额外的缓存线填充所带来的性能损失可能会超过优化的任何好处。通常最好使用armcc -Ospace或gcc Os选项来优化代码密度而不是速度。显然,使用Thumb代码也可以提高代码密度和缓存效率。
关于函数内联有一些有趣的决定,在某些情况下,人类的判断可以改进编译器的判断。一个只从一个地方调用的函数如果内联,总是会带来好处。有人可能认为内联非常小的函数总是有好处,但事实并非如此。从很多地方调用的一个小函数的实例很可能在指令缓存中被多次重用。如果重复内联相同的函数,则更有可能导致缓存丢失,并从缓存中驱逐其他可能有用的代码。Cortex-A系列处理器中的分支预测逻辑是高效的,无条件函数调用和返回所消耗的周期很少,比填充缓存线要少得多。您可能希望使用GCC函数属性noinline或always_inline来控制这种情况。
这是一个普遍问题,并不是内联函数所特有的。每当使用条件执行时,如果它是不平衡的,即表达式往往导致一个结果,而不是另一个结果,那么管道中就有可能出现错误的静态分支预测和冒泡(指令执行的延迟)。通常更好的做法是对条件块进行排序,这样经常执行的代码是线性的,而很少执行的代码必须分支到,除非实际使用,否则不会预取。与freorder-blocks优化选项一起使用的GCC属性__builtin_expect可以帮助解决这个问题。
处理器的性能监视器块(和OProfile)可以用来度量代码中的分支预测率。这里有两个影响。正确的分支预测通过避免管道刷新节省了时钟周期,但是使用更少的跳过代码的条件分支,可以使更多的程序适合L1缓存,从而提高性能。
所有关于使用L1缓存的优化也适用于L2缓存访问。最好的性能来自于一个比L2缓存小的工作数据集,并且数据被使用了不止一次;缓存只使用一次的数据几乎没有什么好处,除了可能产生更多最优的总线访问。如果数据集大于缓存大小,则可以考虑与L1缓存中描述的技术类似的技术。但是,对于外部缓存还有一个需要考虑的问题,那就是它们可能与其他内核共享,因此单个处理器的有效大小可能小于实际大小。此外,当编写在许多ARM家族上运行的泛型代码时,很难优化L2缓存的使用。这种缓存的存在并没有得到保证,而且它的大小在不同的系统之间会有很大的差异。
一般来说,转译后备缓冲区(参见第9章)的优化使用范围要比优化缓存访问小得多。要点是尽量减少使用的页面数量少(这显然给了TLB)和使用大型MMU映射(supersections或部分优先于4 kb页),因为这降低了单个转换表走的成本(一个外部内存访问,而不是两个),也意味着更大的内存在一个单独的TLB条目中表示(也给出更少的TLB错过)。然而实际上,像Linux这样的操作系统到处使用4KB的页面,所以主要的优化技术可以是单独的很少的频繁访问的代码和数据访问代码和数据(例如异常处理代码可以移动到一个不同的页面),并试图限制经常访问的页面的数量低于处理器硬件支持的最大数量。主要的优化是尝试处理每个页面的多个缓存线的数据,因此L1缓存是限制因素,而不是TLB条目。
在Linux上下文中,描述了在第一次访问内存页面时,以及第一次写入该页面时,页面错误如何产生数据中止。这意味着内核中止处理程序被调用以采取适当的操作,这有一定的性能开销。简单地说,您可以通过使用更少的页面来减少这个开销。同样,使代码更小的代码优化也会有所帮助,这将减少数据空间的大小。
ARM Cortex-A系列处理器包含复杂的缓存系统,并支持推测和无序执行,从而隐藏与内存访问相关的延迟。但是,对外部内存系统的访问通常非常慢,因此仍然会有一些损失。如果可以在需要指令或数据之前将它们预取到缓存中,就可以隐藏这种延迟。
ARM处理器使用PLD指令为数据的预加载提供支持。PLD指令是一种提示,使您能够在应用程序实际读取或写入数据之前请求将数据加载到数据缓存中。PLD操作可能产生一个缓存行填充或数据缓存缺失,独立于加载和存储指令的执行,而核心继续执行其他指令。如果得到支持和正确使用,可编程逻辑器件可以通过隐藏内存访问延迟来显著提高性能。还有一个PLI指令,它使您能够提示处理器,在不久的将来可能会从某个特定地址加载指令。这可能导致处理器将指令预加载到它的缓存中。
除了这个由程序员发起的预取之外,核心还可能支持自动数据预取。本质上,内核可以检测到一系列对内存的顺序访问。当它这样做的时候,它会在程序实际使用它们之前,自动猜测地请求下面的缓存线。
在许多系统中,使用memset()或memcpy()函数初始化或移动内存块要消耗大量的周期。优化后的ARM库通常会使用Store Multiple指令来实现这些功能,每个Store都与缓存线的边界对齐。
分析工具使您能够识别可以从优化中获益的代码段或函数,以及不同的编译器选项如何对我们的代码进行编译器优化。现在我们将考虑各种各样的源代码修改,这些修改可以在ARM上生成更快或更小的代码。
对于已经被分析器识别的循环,使用在0(零)处结束的整数循环计数器可能是合适的,而不是从0(零)开始。这是因为用于更新循环计数器的ADD或SUB指令可以免费使用与0的比较,而与非0值的比较通常需要一个显式的CMP指令。
Replace a loop that counts up to a terminating value:
for (i = 1; i<= total; i++)
with one that counts down to zero:
for (i = total; i != 0; i--)
This will remove a CMP instruction from each iteration of the loop.
为循环计数器使用int(32位)变量也是一种良好的实践。这是因为ARM本身是32位机器。它的ADD汇编语言指令在两个32位寄存器上操作。如果它执行的ADD(或其他数据处理操作)数量较小,编译器可能会插入额外的指令来处理溢出
这是多种可能的循环技术之一,您或优化编译器都可以使用这种技术。它本质上意味着合并具有相同迭代计数且没有相互依赖关系的循环。
for (i = 0; i < 10; i++)
{
x[i] = 1;
}
for (j = 0; j < 10; j++)
{
y[j] = j;
}
很明显,这可以优化为:
for (i = 0; i < 10; i++)
{
x[i] = 1;
y[j] = j;
}
值得一提的是,这种方法有时会导致性能下降,这取决于缓存的关联性和被访问数据的地址,因为缓存的影响(如抖动)。
通常,通过代码尽量减少内存使用是一个好主意。ARM处理器有一个寄存器集,它为编译器保存变量提供了一组相对有限的资源。当所有寄存器都使用当前活动变量分配时,额外的变量将溢出到堆栈中,导致内存操作和代码执行的额外周期。有很多方法可以帮助你。一个关键的规则是尝试在任何时候限制活动变量的数量。
寄存器中最多可以传递四个参数给一个函数。附加参数在堆栈上传递。因此,传递四个或更少的参数要比传递五个或更多的参数有效率得多。当然,有问题的ARM寄存器的大小是32位的,因此如果您传递一个64位变量,它将占用我们四个寄存器插槽中的两个。出于类似的原因,递归函数通常不会产生有效的处理器寄存器使用。还请记住,非静态c++函数也使用一个带有this指针的参数槽。
ARM整数寄存器是32位大小的,因此在使用32位大小的变量时最容易产生最佳代码,因为这避免了提供额外的代码来处理32位结果溢出8位或16位大小的变量的情况。
Consider the following code:
unsigned int i, j, k;
i = j+k;
The compiler would typically emit assembly code similar to:
ADD R0, R1, R2
如果这些变量是short(16位)或char(8位),编译器必须确保结果不会溢出半字或字节。对于带符号的半字(short),同样的代码可能如例17-10所示。
ADD R0, R1, R2
SXTH R0, R0
Or for unsigned halfwords as in Example 17-11.
ADD R0, R1, R2
BIC R0, R0, #0x10000
这具有将结果裁剪到定义大小的效果
尽管编译器有时可以处理循环计数器变量的不正确类型说明等问题,但通常最好首先使用正确的类型。
如果一个函数有两个指针pa和pb,它们的值相同,我们说两个指针相互别名。这引入了指令执行顺序的约束。如果两个写访问以程序顺序发生,它们必须在处理器上以相同的顺序发生,并且不能被重新排序。这也是写后读,或读后写的情况。对别名的两次读访问可以安全地重新排序。因为C中的任何指针都可以别名任何其他指针,所以编译器必须假设通过这些指针访问的内存区域可以重叠,这就防止了许多可能的优化。c++支持更多的优化,因为如果指针参数指向不同的类型,它们将不会被视为可能的别名。
C99引入了restrict关键字,该关键字指定一个特定的指针参数不能别名任何其他参数。如果您知道指针不重叠,那么使用这个关键字向编译器提供这个信息可以产生显著的改进。然而,滥用它会导致不正确的程序功能。restrict关键字限定了指针而不是被指向的对象。这并不是ARM架构特有的考虑。使用GCC时,可以通过在编译标志中添加-std= C99来启用C99标准。
在不能用C99编译的代码中,可以使用__restrict或__restrict__来启用关键字作为GCC扩展。
考虑以下简单的代码序列:
void foo(unsigned int *ptr1, unsigned int *ptr2, unsigned int *i)
{
*ptr1 += *i;
*ptr2 += *i;
}
指针可能指向相同的内存位置,这导致编译器生成的代码效率较低。在这个例子中,它必须从内存中读取值*i两次,每次添加一次,因为它不能确定改变*ptr1的值不会改变*i的值。
如果函数被声明为:
void foo(unsigned int *restrict ptr1, unsigned int *restrict ptr2, unsigned int
*restrict i)
这意味着编译器可以假设这三个指针可能不指向相同的位置,并相应地进行优化。您必须确保指针不重叠。
并不是所有的ARM处理器都有硬件支持除法。对于这些处理器,C除法通常会调用一个库例程,对于32位整数的除法需要运行几十个周期,
注意:即使在硬件上除法也比乘法慢。在性能关键的代码中,如果可能的话,几乎总是值得替换的。这必须以代码可维护性为代价。
在可能的情况下,必须避免或从循环中删除除法。用一个固定的除数(即在编译时已知的除数)来除法比用两个变量除法要快。在这种情况下,编译器可以用移位-乘对替换除法。32 32乘以固定常数,然后右移以调整最重要的子。
模运算是另一种需要注意的情况,因为这也将使用除法库例程。
The code
minutes = (minutes + 1) % 60;
将在没有硬件分割的机器上运行得更快, if coded as
if (++minutes == 60) minutes=0;
用两个循环的add和compare代替对库函数的调用
访问外部变量需要处理器执行一系列的加载指令,通过基指针获取变量的地址,然后读取实际的变量值。如果多个变量被定义为一个结构的成员,它们可以共享一个基指针,从而节省周期和指令。因此,在同一个结构中定义变量是一种良好的实践。
在某些情况下,它可以是一个有价值的优化使用汇编代码,除了c .这里的一般原则是对你在高级语言代码,使用一个分析器,以确定哪些部分将产生最大的好处如果优化,然后检查compiler-produced汇编代码寻找可能的改进。
如果某个代码段被标识为性能瓶颈,则不要立即查阅汇编语言手册。在考虑使用汇编代码之前,应该首先寻求对算法的改进,然后尝试编译器优化。即便如此,性能低下的原因往往是缓存丢失和内存访问延迟,而不是实际的程序集代码。
ARM编译器、GCC和大多数其他C编译器使用s标志来告诉编译器生成汇编代码输出。fverbose-asm命令行选项在gcc中也很有用。使用——interleave选项,ARM编译器可以生成交叉的源代码和汇编程序。
通常最好避免复杂的寻址模式。如果用于加载或存储的地址需要复杂的计算,则不可能进行双下发指令。只有使用基寄存器加偏移量(由寄存器或立即值指定),并可选地向左移动一个立即值为2的寻址模式是快速的。其他不太常用的寻址模式可以通过分割成两个可能是双重发出的指令来更快地执行。
For example:
MOV R2, R1 LSL#3; LDR R2,[R0, R2]
can be faster than
LDR R2, [R0, R1 LSL #3]
LDRH和LDRB没有额外的惩罚,但是LDRSH和LDRSB有一个单一的循环负载-使用惩罚,但是没有早期转发路径,并且如果后续指令使用所加载的值,可能会导致额外的延迟。
与已对齐的loads相比,未对齐的ldr有额外的循环惩罚,但是跨缓存线的未对齐的ldr有许多额外的循环惩罚。一般来说,与loads相比,存储不太可能使系统陷入停顿。由于合并写缓冲区,STRB和STRH的性能与STR相似。因为在装载/存储单元中有四个槽,超过四个连续挂起的装载总是会导致管道停止。
一些代码优化可以在链接中执行,而不是在构建的编译阶段,例如,未使用的部分消除和链接器反馈。可以跨多个C文件进行多文件优化,可以删除未使用的部分。类似地,多文件编译使编译器能够跨多个文件而不是单个文件执行优化。