上节主要介绍在资源受限的ARM设备上,在各种类型的操作系统上的选择,在C语言编程角度,如何构建代码才能更好的指导编译器compiler进行优化,诸如数据对齐data alignment,数据类型data type的选择,C语言函数调用的参数传递方式,以及编译器对结构体和数组的基本处理方式,下节则主要介绍编译器的使用规则,如何指导编译器进行合理的优化,以及系统级的NEON优化,从cache使用到系统功耗控制等。
关键字:ARM Cache 系统 优化 C语言 效率 功耗控制 系统架构 编译器 efficient NEON
C编译器并非无所不知
简单地说, C编译器并不能根据程序员的代码就完全理解程序员的真实意图,而且通常为了保证程序的正确执行,通常编译器会做 "最坏的"假设。最明显和最著名的例子是"指针的混叠走样"。这意味着编译器必须做假设通过任何指针的写都可能改变任何一个内存的地址,这对编译器的优化有非常严重的影响。
其他的例子就是编译器必须假定全局数据是易挥发的(volatile),在其他函数内,循环计数也是可能会随时被修改的。好消息是在大多数情况下,程序员可以很容易给编译器提供额外的信息来帮助编译器优化。在其他情况下,你也可以改写你的代码以更好的表达你的意图和更好的传达特定的条件。例如如果你知道某一特定循环将总是至少执行一次,那么do-while循环将会是比for(;;)是一个更好的选择。这是因为对C语言的for循环在第一次迭代循环前需要测试是否终止。编译器会因此被迫在两个地方重复测试for的起始和结束,以保证功能的正确。也会你会说现代的分支预测硬件支持会减少这些循环前后的复杂的分支调整,但是总体上最好的还是通过给编译器更多的指导来减少这些不必要的分支。 ARM编译器里还有很多关键字来给代码加上很多指导信息,如下面的__pure, __restrict以及__promise关键字。
__pure: 关键字表明函数没有负面影响,没有对全局数据的访问,即结果只取决于输入参数,两次相同的输入得到的输出也是相同的。
__restrict:该声明用该指针指向区域的写操作不会改变其他指针或者引用指向的数据。这个关键字对于循环优化尤为有用因为它增加了编译器的自由度,编译器就可以采取一些变换,如unroll等。
__promise: 表明在程序的特定范围内,某个条件一直为真,如下面例子中的表达式:
__promise intrinsic这里告诉编译器循环计数器在那个循环内,循环计数器是大于0的,并且能被8整除。这就能让编译器把for循环转化为do-while,并且可以进行把循环展开至多8次而不用担心循环边界问题。这种方式尤其适用于NEON处理器的向量化操作。
C编译器并非无所不能
C编译器不能完全的理解程序员的意图,同样C编译器也不是什么事情都能做。C编译器不能产生很多指令,尤其是最近ARM架构中引入的指令,这主要因为这些指令的语义跟C语言并不完全一致。熟练的程序员可以手工鞋汇编代码来使用这些新指令,但是使用ARM C编译器提供的丰富的intrinsic函数将更为简单些。下面的例子是使用 ARMv6以后引入的SMUSD和SMUADX指令实现的复数乘法,
一下的代码是汇编的输出
如果编译器能inline内联这些函数,也就没有函数调用的开销了,这也是使用内敛的函数实现相对于写汇编的实现的优势,即保持代码的可移植性和可读性。
NEON编译器的NEON支持
C编译器还能通过intrinsic函数和内联的数据类型来直接访问NEON多媒体处理器的操作。以下是一个数组乘法的直接实现,左边的C代码实现,右侧的是对应的汇编语言。汇编代码只列出了循环核。
下面的一对是相同的循环使用NEON intrinsics的实现和相应的汇编代码。需要注意的是该循环已经展开4次来反映NEON的数据加载、乘法和存储,每次处理都是4个32-bit的带宽。这大幅降低了执行周期。而循环的额外开销也由迭代次数降低而减少。
从以上的汇编,如果仔细看的话,你会发现编译器并没有产生和C代码完全一致的代码,这些指令的次序有所改变,这是编译器为了减少interlock从而最大化吞吐。Interlock是由指令的流水线stall产生的。这也是使用intrinsic相对于手写汇编的优势,你可以利用编译器的特性来把C代码周边的环境考虑进来做针对目标平台的优化。
Data Cache使用
大多数应用程序员往往把cache当做操作系统OS层面需要考虑的问题。当然,cache的配置与管理是操作系统负责的,应用程序一般不允许干涉cache操作。但这并不是说应用程序应该完全忽视系统还存在cache这个事实,理解cache的结构来优化代码将可以提供巨大的性能提升。在写代码时考虑cache如何操作这些数据将利于代码的性能一致性。
数据结构的对齐到cache行边界将非常利于数据cache line的pre-load,cache需要基于数据访问的时间和空间连续性,因而更新数据的时候是按照cache行来更新的,C编译器提供了一个对齐数据到2的幂次的关键字如下所示:
int myarray[16] __attribute__((aligned(64)));
一些非常常见的算法还可以写成cache友好(cache-friendly)方式以提高性能。众所周知,当数据被连续访问多次,这时cache的性能将非常高,因为这些连续访问的数据此时已经在cache内了,可以被Core重用(当前,前提是此时的连续访问的数据大小没有超过cache的总大小)。像矩阵乘法这种常见的算法因为其数据访问次序会给cache性能带来一定的麻烦,下面是一个简单的矩阵乘法函数的实现,
从实现中可以看出,数组a是被按照行连续访问的因为其最右边的索引变化最快,同理b数组也是连续访问的,但是数组c确实按照列访问的,这种按照列跳着读取数据的方式确实不是cache友好的,因为这种按照列顺次读取的会经常更新cache数据因为会导致后面即将要用到的数据从cache空间被清除出去。虽然应用程序开发时,cache表现往往都是隐含的,但这种性能的损失确实会带来功耗的增加,因为cache的miss导致对外存的访问次数增加,而且这些访问都是burst突发的,因而会增加DDR功耗。有些数据的访问模式确实非常不利于cache的reuse,这时需要考虑其他的实现尽可能的避免这种数据访问。如在一个write-allocate的cache系统中,大量数据的写会让cache里堆满了后面不会用到的数据,这些数据一般不会用到,当然一般的cache系统都是可配的read-allocate的。现在的一些高级的ARM cache控制器已经能够处理这种write-allocate的 情况,当出现大量的鞋操作时暂时关闭write-allocate模式,这种自动的调整cache参数是完全透明的,但是如果写代码时能考虑cache的特性,cache的架构,还是对高性能代码非常有用的。
全局数据访问
ARM构架的特点是你不能指定一个完整的32位的地址作为内存访问的地址,这是由于ARM的指令字长决定的。因而通常访问一个变量的内存地址需要被放置在一个寄存器或者至少一个起始地址在寄存器中然后加上一个简单的偏移量。这导致了对于每个这样的全局变量编译器在编译时必须在运行时存储和加载基指针来访问外部全局变量。如果一个函数访问外部全局变量非常频繁时,编译器需要假定它们在独立的编译单元,因此不能确定在运行时这些全局变量是否能共享同一基址寄存器。因而每个全局变量都需要一个独立的基址指针。 如果你能让编译器推断一群全局变量能共用一个存储器基地址时,他们可以通过基址的不同偏移来访问。要做到这一点,最简单的方法就是缩小全局变量的范围,只在需要用到的模块里声明,然而不需要全局变量的应用程序少之又少,这并不是一个很切合实际的解决方案。 最常见的解决方案是将全局变量或者相关的全局变量组成结构体。这些结构体在编译时可以保证放在一个基址加偏移的地址的。
System power management系统功耗管理
现在我们转到操作系统层次的更广泛的系统问题。在大多数系统里操作系统控制着比如时钟频率、工作电压、单独core的功率控制状态等。应用程序通常不允许进行这些控制的。有一个最基本的关于功耗的问题一直广为争论:是先用最快的速度完成计算的工作,然后最长时间的进入休眠状态还是把让处理器一直工作在电压和频率都降低的低功耗状态下更为节约功耗。现在这些争论往往更着眼于日益增长的系统的静态功耗。从历史上看,静态功耗(主要是渗漏)已经大大小于动态功率的消耗。然而芯片结构变得越来越小,泄漏的增加这一事实使的静态功耗日益成为能耗的主要贡献者。现在的结论就是最好是迅速完成任务,然后关机停止 (避免泄漏),而不是继续执行更长的时间。
一个合理的尺度
我们需要的是一个度量来结合功耗和一个特定的计算需要的运行时间。这样一个度量常常被称为"能量延迟积"或EDP(Energy Delay Product.图3所示)。虽然这样的度量标准已经广泛应用于电路设计很多年,但目前软件开发领域尚无公认的方法来推导或使用这样一种度量。
图3. 能量延迟积
上面的例子显示[2]在决定cache缓存大小上EDP度量所起的辅助作用。很明显一个更大的缓存会增加功耗。然而EDP度量表明有一个的在64KB大小附近有一个比较合理的位置能获得更高的性能和功耗平衡。
管理子系统 sub-systems
在一个单芯片系统里我们必须确保额外的计算引擎(如NEON)与外部外设(串口和类似的设备)只在需要的时候才启动。这是操作系统开发者需要考虑的调度问题,也是芯片厂商需要提供管理这些设备的特性。操作系统几乎都需要根据特定的硬件平台进行定制,例如飞思卡尔的i.MX51芯片包含一个NEON的监控器,党用不到NEON时会自动关闭。当碰到没有定义的指令时会通过中断唤醒该协处理器。
在多核系统,我们可以自己选择开关单一的核心以匹配系统的负载需求。单一Core的关闭开启都是系统决定的,现在的ARM 对称多核SMP Linux支持一下特性:
1) CPU热拔插 hotplug ;
2) 负荷平衡以及动态的优先级调整;
3) 智能并且cach优化的调度算法;
4) 每个cpu core都能动态电压和频率调整Dynamic Voltage and Frequency Scaling (DVFS);
5) 每个CPU都有独立的功耗状态管理机制;
内核为通用的外部电源管理控制器配置了一个接口。这个接口需要针对特定平台台来选择可使用的特性。如TI的OMAP4平台提供了再一个范围的电压和频率间调整的选项,通过运行评分("Operating Performance Points")系统会自动选择最适合的功耗方案。这样设备的功耗根据系统负载不同可以从600微瓦到600 mW。
程序员需要做什么
在多核系统中,硬件的高性能也许让我们决定一切都交给操作系统把,然而在写代码和配置操作系统时如果能考虑如下因素是非常重要的。
1) 系统效率(System efficiency): 智能和动态的任务优先级调度;负载平衡;
2) 计算效率(Computation efficiency):数据,任务和函数级别的并行;减少同步开销overhead
3) 数据效率(Data efficiency): 有效利用存储系统特性,谨慎维护cache一致性以避免cache颠簸和错误的core间共享。
总结
1) 合理配置工具和硬件平台; 2) 仔细写代码和合理配置配置cache以尽可能减少外部内存访问;3) 速度优化以及合理利用NEON等运算加速器以减少指令执行数;
References:
[1] Reducing Energy Consumption by Balancing Caches and Scratchpads on ARM Cores, Mario Mattei, University of Siena
[2] Wattch: A framework for Architectural-Level Power Analysis and Optimizations, Brooks et al, Princeton University
[3] Evaluating the performance of multi-core processors – Part 1, Max Domeika, Embedded. com
http://www.blog.163.com/houh-1984
http://www.eetimes.com/design/embedded/4210470/Efficient-C-Code-for-ARM-Devices
关键字:ARM Cache 系统 优化 C语言 效率 功耗控制 系统架构 编译器 efficient NEON
上节主要介绍在资源受限的ARM设备上,在各种类型的操作系统上的选择,在C语言编程角度,如何构建代码才能更好的指导编译器compiler进行优化,诸如数据对齐data alignment,数据类型data type的选择,C语言函数调用的参数传递方式,以及编译器对结构体和数组的基本处理方式,下节则主要介绍编译器的使用规则,如何指导编译器进行合理的优化,以及系统级的NEON优化,从cache使用到系统功耗控制等。