本部分介绍了ADI Blackfin DSP上的C编程的基本概念,嵌入式平台的可移植性和基本的编程规范;下一部分主要介绍用于DSP kernel的基本的优化方式。
Part 1. DSP的C编程基本原则
Next 如何优化DSP kernel
简介
有人说DSP要获得足够高的性能要求以满足更加严苛的实时条件必须要采用汇编,这话还是很有道理的。TI的C编译器算是业内做的非常好的了,宣传的还是如果C写的比较漂亮能达到85%+的汇编性能。但是相比汇编,C编码毕竟开发更为灵活,更容易掌握,更方便多平台的移植和维护。所以为了time to market和后期的升级维护,更多的代码实现还是C语言的,即使是对MIPs寸土寸金的嵌入式实现。稍许的性能损失就带来更多的方便和快捷还是值得的。
即便作为嵌入式平台最popular的语言,但对于DSP嵌入式实现,尤其针对需要大量运算的信号处理算法实现,C还有尤其自身的不足的
-
首先, ANSI C 并不是为信号处理算法实现设计的语言, C更多的实现还是针对系统控制而不是数学算法实现,因而很多情况下并不能很好的表述 DSP算法;
-
其次, DSP处理器有很多的灵活设计以满足特殊的应用场合,如特殊的 circular循环和 bit-reverse位反转寻址,如特殊的语音和图像信号处理指令以及通信中常用的维特比 viterbi指令等。因而相比汇编, C语言编程就不能很好的利用 DSP的这些特殊指令和硬件实现,从而很不适应对 performance要求非常严格的实时场合;
-
另外,即便为了适应 DSP的特殊硬件结构而写 C code来实现算法,也很难让这些晦涩的 C实现让编译器很好的进行优化。不过为了既有 C的结构不失灵活性,而又能利用 DSP的特殊指令,一般的芯片编程都会包含 intrinsics这种内联函数式的指令。
基于以上种种,C编译出来的code性能总是会或多或少的比手写汇编要差,为了既能保持C的灵活性,而又能把code写的让compiler很好的优化,这就需要对C-code做相应的优化。首先引入一些基本概念。
应用与处理器
调整优化C代码一直都是必要的,除非你选择了一个效率足够高的算法。那样的话你就需要非常了解不同的应用情况下应该如何选择不同的算法以及各种算法的不同实现方式。当然,使用C语言实现算法也非常容易做实验来调整算法。另外,你还需要了解DSP处理器的能力,处理器的特性最终将决定你能达到的最高性能。所以你需要明白处理器的能力以设定代码的最终的性能要求。特别需要理解处理器的特别之处,如一些处理器提供的针对Viterbi维特比译码的优化指令,比特服用或者向量化的乘累加(MAC)指令等,你还需要考虑处理器的memory系统,考察片内和片外的空间是否足够,考虑总线带宽是否满足你要处理传输的数据带宽需求。 一旦你完全清楚你要实现的处理器平台,你就可以评估你的算法怎么对应于你的处理器的底层实现。你可能需要看assembly code来决定如何优化编译器,如何指导编译器优化你的代码。
熟知C
C语言提供了一个一致的运算模型,这样的话C程序员可以认为他的程序在各个平台的表现完全相同,当然针对单精度和双精度浮点运算而言,每种处理器的浮点模型不同,对64位浮点的表达方式、运算次序不同都将会导致结果的迥异。但在一个没有原生浮点的算法环境下,编译器将调用模拟的浮点库,这样算法的性能将直线下降。C语言还假设了一个足够大的flat memory模型,即内存的访问都是完全相同的,不考虑内存访问的额外开销,但实际硬件平台上,内存的访问的不规则将直接影响程序的性能。因而单纯的follow C的准则会严重的影响性能。更重要的是,可移植性的C语言是平台相关的,比如最基本的int数据类型的位宽在不同的平台就不同,可能是16bit,也可能是24bit,还可以是32bit。自然如果假设数据类型位宽和实际平台不一致,将直接导致算法错误。
通常C和DSP的特性的严重匹配表现在累加器、向量SIMD操作以及分数fractional运算。这些加快信号处理的硬件特性往往在原生ANSI C代码中式不支持的,所以C程序员就需要针对特定的硬件平台来考虑特定的优化了。 另外需要记住的是代码的通用和明确往往是个矛盾,比如数据的访问,如果考虑通用性,那么就要使用间接指针,这样会导致编译器给出一个保守的优化策略,如下面的memcpy函数
memcpy( struct->Ptr1, &(ShortArray[*PtrIndex]) , num );
在这种情况下,编译器无法得知数据地址的关系,为了防止overlap或者非对齐的数据访问,编译器会产生一段非常慢但是安全的功能正确的代码,也许每次只能拷贝一个半字的数据:
Cycle 1: Load 16 bits
Cycle 2: Store 16 bits
…
你可以指定对齐和混叠的情况来告诉编译器做更好的优化,用临时的局部指针来代替间接的指针:
memcpy( IntArray1, IntArray2 , num );
这时编译器会做字的加载和向量化,在通用的DSP上,这样会带来8倍的性能提升:
Cycle 1: Load 32 bits
Cycle 2: Load 32 bits || Store 32 bits
…
偶尔你可以通过让C代码更优雅来加快代码的执行,如上面的memcpy的例子。然而更经常的是指定程序利用特定的硬件资源来加速,这样代码速度会提升,但是代价是代码size增加,代码更为复杂更难于维护和移植,这也就是为了性能需要付出的代价。你需要平衡你的优化目标以避免任何地方都要求最高性能的诱惑。
关于可移植性
为了保持C代码的可移植性,应该考虑如下的优化步骤:
1. 使用编译器来进行优化,即加入可选的编译器选项,让编译器做最好的优化,如果你没有DSP的背景的话,你将习惯于通过优化编译选项来优化你的代码,在信号处理的循环内,也许通过编译器的优化你能或得20倍的性能提升。但是你必须非常清楚通过编译器优化的局限性,这不会改变你的算法,不会对你的code有任何的改变,或者重新组织数据的排列,更为要命的是,编译器往往都是保守的,总是首先保证正确性,然后才是性能的优化,你作为程序员的作用就是保证编译器能得到安全第一的快速代码,否则编译器将始终保持保守。
2. 第二个就是将可移植的C程序转化为一些更明确但仍然可移植的代码。比如加入一些#pragma来指导编译器的优化,这些hardware-specific的语句将直接影响编译器的优化,同时对于其他的编译器而言只是注释而已。这允许你创建处理器相关的优化代码同时维持一定程度的可移植性。你也可以使用可移植的内建函数(如ITU / ETSI GSM的分数操作在很多DSP中都有)、内存的限定符qualifier、restrict限制和const指定等等。一个很好的例子是把循环控制的变量变成常量。这使得更容易为编译器进行矢量优化,编译器可以视循环情况来决定更好的优化策略,保证性能同时避免更大的prologs和epilogs代码。
3. 最后,也是很不情愿地一步就是开始修改代码以符合特定的目标处理器。这可以一直进行到asm的语句插入,虽然可以使用汇编,但是更好的方式还是保留一份C的代码以验证正确性。
DSP处理器上的优化更多的应该是指导编译器而不是构造复杂的代码。图 1列出来一个简单的C循环 以及VisualDSP++编译器的输出左侧是优化关闭的编译输出,右侧是优化时能的编译输出。
图 1. 源码以及编译器优化关闭与使能时的输出.
这就是你想看到的结果,对于Blackfin处理器而言,它一个周期能处理两个乘累加以及两个数据的load,正如优化代码所示。这就是为什么必须了解处理器的架构了,至少你能指导汇编得到的结果是否已经满足它的潜在处理能力了。
有用的工具
加速一个程序的运行最重要的事情就是要引导你的工作。一个熟练的程序员可以在任何随即位置打开任何C语言程序并在其中发现可以改善的部分。除非你有一个非常聪明的方法,否则你可能优化代码若干天,然而性能还是没有任何进展。为了避免这种让人困惑的局面,你必须专注于程序消耗最大的运算能力的部分,这些消耗周期较多部分的代码往往随着你的优化过程以及你要实现的硬件平台不同而有所改变,所以,对代码的剖析(和约束)是必要的。
最好的复杂度剖析工具将进行在程序将要运行的硬件平台进行采样而不需要另外的监控程序。这些采样的周期数会包含内存访问的周期仿真,以及其他的引起stall的情况。得到在具体的硬件平台上的真实性能数据将从一开始就节省许多后续开发中解决问题的时间。
作为一个例子,图2显示了ADI的某一款评估板给出的统计抽样数据结果。在左边的窗格列出的是函数的运行时间,而右边显示的信息是类似line-by-line的profile数据。这些一目了然的周期数或者百分比可以让你迅速定位你的hotspot代码。
当你需要更多的信息时,可以进一步分析汇编代码的性能。分析汇编代码可能是一个复杂而又艰巨的任务。为了让考察汇编变得简单,需要仔细观察它周边的指令,小心代码中的流水线pipeline stall的信息。例如图3中我们可以看出,第一条指令的平均时间为0.04%的总运行时间。然而,第二行汇编代码占全部周期数的0.17%,是其他单一指令运行时间的4倍。当然在ADI的处理器中还有会5倍于一般的指令的指令,即跳转指令。当你看到这种因为流水线阻塞导致的性能损失时,你应该分析问题,看是否有办法重新安排代码以填充流水线。
图 3. Profiler data for assembly code.
图3中第一个stall是因为加载指令的目标寄存器值需要一个时间间隔才能使用。(而代码中试图用一个指针加载后就立即用新值。)C编译器将试图重新安排一下你的指令来尽可能的占用那些延时周期,但是否能有效的优化来填充流水线会受到是否能找到没有dependency的指令操作。图3中的第二个stall指令是一个call的跳转。现代的DSP编程时,你必须记住,为了DSP能有更高的主频,你就需要更长的流水线。这意味着如果你改变流水线的执行,如一个有条件的分支跳转,你就会损失性能。为了理解pipeline stall,你可能需要pipeline viewer来帮助。图4显示的是simulator模拟器上输出的流水线堵塞信息。幸运的是,C程序员通常不需要考虑stall这种细节上的实现。图4中的黄色还指示了stall,进行流水线的优化就是要填充这些空闲的执行周期,因为这些空闲对应的就是一条汇编指令。
![](http://img8.ph.126.net/qiA-uQd4jj1EsGqKYFg8Ag==/2556355738503212508.jpg)
图 4. Analog Device's pipeline viewer.
Reference
http://www.eetimes.com/design/signal-processing-dsp/4017021/Programming-and-optimizing-C-code-part-1
http://www.blog.163.com/houh-1984/
本部分介绍了ADI Blackfin DSP上的C编程的基本概念,嵌入式平台的可移植性和基本的编程规范;下一部分主要介绍用于DSP kernel的基本的优化方式