其實我不是很會寫文章,想要把技術性文章寫的有意思就更難了。不過這一段日子總是有一種沖動想要寫點什么,把自己了解的有關BlackfinC語言優化和系統優化方面的技巧和知識寫下來,和正在從事這方面工作朋友們分享,也許有些幫助,也算是對自己過去一段時間工作的總結。 在文章開始之前,我想先問讀者一個問題:您的DSP代碼里有多少是匯編,這些匯編里有多少是您自己寫的? 曾幾何時匯編編程是DSP工程師的一張名片。很多人到現在談起匯編編程還是頗為自豪的,搞得你想說自己不會都要鼓起點勇氣——那眼神是恨不得把你送回火星去。這主要是因為在最開始的時候DSP上的C語言編譯器不是很普遍,編譯器的水平也還在起步階段,很難用到DSP相應的硬件特性,編譯效率值得商榷。而且那時DSP應用場景和復雜度遠不比今天,基本上限制在數字信號處理的典型算法上,FFT,FIR,IIR濾波器,等等。這些函數和濾波器的實現相對今天的應用比較簡單,用匯編語言也容易突出DSP的硬件特性。還有一個原因是那時候DSP普遍都跑的很慢,基本上在幾十兆的水平。這也限制了C語言的使用。試想一下一段C代碼跑的比匯編慢十倍,幾十兆的DSP一下就變幾兆了。 但是今天再來看這所有的一切是完全不一樣了。首先是DSP的應用范圍越來越廣,客戶越來越多的希望用同一顆芯片,在同一個平臺上實現更多的設計和應用。這對DSP的設計,DSP和MCU的融合都帶來重大影響。DSP和MCU之間也不是過往那井水不犯河水的安寧。隨著DSP和MCU的主頻先后突破1GHz,在很多應用中DSP和MCU相伴相生的場景也開始被一顆強壯的芯代替,或者DSP或者MCU。在這樣的應用中,操作系統,文件系統,USB協議棧,TCP/IP,海量數據存儲,樣樣都會用到。數字信號處理也從骨灰級的濾波器變成全系列音視頻處理,OFDM基帶處理,天線陣列信號處理,彩色圖像重建…試想一下這些應用哪一個不是成千上萬行代碼。匯編語言在編程復雜度,可移植性和可維護性上真的是遇到了前所未有的挑戰。而與此相對應的是C語言和C語言編譯器的蓬勃發展。今天您可以很容易找到上面提到所有這些應用和算法的C語言實現,而C語言編譯器在編譯效率和成熟度上都有很大的突破。也讓C語言在DSP上的應用得以受到愈來愈高的重視。 但是C語言本身并不是為DSP定義的——C語言在PC上的默認條件在嵌入式處理器上不成立,比方說存儲空間無限,比方說內存連續,更不要說如何綁定DSP特殊的硬件支持。所以要充分發揮DSP的能力,C語言優化是一下一張DSP工程師的名片。不會C語言優化,OK,你可以回火星,地球很危險。 1. 拳譜總綱 閑話不表。在深入到細節之前,我想先從宏觀的角度討論一下C語言優化一些大的原則。就好像我們在學七傷拳之前先來背背拳譜總綱,提綱挈領很重要。這些原則可以用圖1來說明。 圖1:C語言優化性能曲線。 對整條性能曲線可以做這樣的總結,1)最佳性能產生在C和匯編按一定比例分配的情況下,80-20可以作為一個參考;2)將所有代碼都轉為匯編并不會帶來性能的進一步提高;3)在C語言編譯器的幫助下,將大多數控制代碼保留在C語言范疇中是可能的;4)要想達到最佳性能,那些消耗cycle最多的代碼應轉化為C語言可以調用的匯編函數。簡單說就是讓C和匯編語言做各自擅長的事情,在動態平衡中達到最佳性能。內事不決問張昭,外事不決問周瑜,各司其職。 在DSP性能大幅提高的今天,如果可以如圖中B點那樣用Optimized C將C語言在DSP上的性能提高到%70以上,很有可能對于大多數應用場景就已經足夠了,并不是一定要接觸匯編語言的。這個從A點到B點的過程也正是這篇文章要討論的重點。 2. 是騾子是馬您先別溜 說到這里,有很多朋友等不及要開始做優化了:打開程序,一條語句、一條語句立刻看起來。很多時候我們在工作中都遇到這樣的情況,所以第一刻就要喊停,等我先講講一些容易被忽略的東西。 首先最容易被忽略的是數據類型。通常編譯器對ANSIC所有數據類型都是支持的,但是硬件呢,是不是對所有的數據類型都很有效的支持呢?舉個例子,很多DSP都有專門針對16-bit定點運算的指令,特別是一些并行指令。如果在算法中可以將數據類型設計為16-bit就可以充分利用到這些指令。Blackfin每個cycle可以做2個16-bit乘法,而每個32-bit乘法則要消耗3個cycle。這中間有6倍的差距,是值得我們考慮的。另外定點芯片不直接支持浮點操作,如果算法中有浮點類型和浮點運算,則首先應該考慮在不影響動態范圍和精度的基礎上進行定點化。因為在定點芯片上每個浮點操作都可能消耗成百上千個cycle來得到近似的結果。對于小數類型,Blackfin直接支持1.15和1.31小數類型的操作,這給程序員很大的靈活度。所以我們首先要盡可能依托當前DSP最擅長的操作來確認數據類型被支持的程度,并對算法進行調整。 另一個容易被忽略的地方是算法本身。也就是被采用的算法本身是不是已經是最高效,最優的。考慮一下正在用的排序算法是不是還有余地改進;要用的正弦波形是計算還是查表;又或者整個算法或者部分可以被更高效的算法代替。這樣的考慮往往可以達到事半功倍的效果,就好像換了三趟公交去看朋友,下車一抬頭發現有條地鐵直達。 在現代高性能DSP中通常都有比較深的指令流水線。流水線的作用是把一個cycle里要做的事情分在多個步驟里來做。對于高主頻的芯片而言,流水線的深度是很重要的,它從某種程度上決定了可能的最高主頻速度。每一個節拍,指令流水線上不同功能單元同時并行運作,每條指令按順序流經這些功能單元。可惜事物總有兩面性,當流水線遇到了條件跳轉,它的另外一面就充分暴露出來了。那就是在跳轉的時候,當前指令之后已經在流水線里的指令全部都要被清空,然后再讓要跳轉到的目的指令重新進入流水線。如果流水線的深度是N,那么這里損失的cycle通常為N-1。流水線越深,損失越大。如果不巧這個條件跳轉在循環里面,這個N-1的損失就會被放大了。用一些方式替代條件跳轉可以減輕這樣的損失,比方說盡可能的使用條件執行和條件賦值,或者max和min語句,因為這些語句的執行通常可以在DSP的匯編級找到對應的單周期語句。另外就是要盡可能的避免在循環中使用條件跳轉。 除法運算是我們需要注意的一種操作,因為通常除法在DSP中都是一段近似算法來實現的。比如說在Blackfin提供兩種除法近似,精度較低的一種需要大約40 cycle而32bit除法則需要大致400cycle。想想一個1000次的for循環里如果有3次除法,您就大致知道您的程序會跑多慢了。所以我們要在算法中考慮到除法的影響和可能的替代方式,例如利用不等式原則可以把除法變成乘法,又或者模2的除法可以變成移位。當然了,我在這里提到的替代,包括針對前面的數據類型,算法和條件跳轉,都是遵循“盡可能”的原則,沒有絕對的意思。優化的后程序效率的高低就是體現在這個盡可能上。 3. 編譯器,睡在上鋪的兄弟 這一刻,你不是一個人在戰斗…,這話聽起來好像有點耳熟。如果把C語言優化比作是程序員在進行的一場戰斗的話,程序員并不孤獨,因為我們有一個隱形的戰友,就是編譯器,而編譯器的優化功能就是我們最有力的武器。以VisualDSP++為例,通常新建的工程C語言優化缺省是不打開的,程序員可以按照程序運行的需要打開優化。這個從不優化到優化的過程實際上反映了VisualDSP++編譯器在處理C語言程序過程中的兩步走。 在優化開關沒有打開的情況下,編譯器對C代碼的處理是一一對應的直譯,就是把C代碼一句一句按照先后順序翻譯為相應的一條或者多條匯編語句。在直譯的同時,編譯器也會注意到對中間變量和中間結果的保護——不管他們接下來會不會被用到,他們都會被寫入存儲器,盡管這樣做會增加很多冗余。經過這樣的直譯,一段C代碼對應的匯編代碼可能是多一個數量級的。一個典型的例子是,只有兩條乘累加指令的for循環代碼對應的匯編代碼是幾十條之多。可想而知,這樣不經優化的代碼執行速度是很慢的。一個參考數據是打開優化開關以后的代碼運行速度平均可以提高20倍。也就是說,一個600MHz的芯片,不打開優化,相當于主頻降到30MHz。所以絕大多數情況下我們要打開編譯器的優化開關。 編譯器的第二步走,就是對直譯產生的代碼進行優化,這個過程就是充分利用DSP的硬件實現指令和事件最大可能并行的過程。這里的并行既有運算單元本身的并行也有運算單元和其他功能單元的并行。以Blackfin為例,每一個core里都有兩個乘法器和加法器。編譯器在優化的時候第一個層次的并行是運算的并行,就是盡可能同時使用兩個運算單元,做乘法就盡可能做到兩個乘法器同時運算,做加法就盡可能做到兩個加法器同時運算。接下來一個層次的并行是指令的并行,就是運算單元和memory存取、或者其他功能單元之間的并行,仍以Blackfin為例,在同一個cycle中,可以有兩個乘累加和兩個數據的存或取并發執行。這些并行都是DSP硬件本身支持的,編譯器優化的工作就是充分利用DSP的硬件能力。 循環是編譯器在第二步走的過程中重點處理的對象。這比較好理解,因為那些大量消耗cycle的代碼往往是在循環當中的。下面我就結合編譯器對循環的處理,來看看在優化的過程中程序員要怎么和編譯器并肩戰斗。編譯器對循環處理的目標就是希望在每一次循環中盡可能的并行。為了實現這個目標,編譯器采取的措施就是不停的打開循環、降低循環次數,增加循環內的指令個數,提高指令之間并發的幾率。舉個例子,一個100次的循環中有一個乘累加,編譯器打開循環,將循環次數降低一半,循環內每次就會出現兩個乘累加,編譯器就有可能安排Blackfin的兩個乘累加單元同時運算,從而將執行的效率提高一倍,這個優化過程叫做矢量化(Vectorization)。如果這個循環中還有加法、減法、存數、取數,或者其他運算,編譯器還會安排這些指令和乘累加并發,或者這些指令之間并發,這個優化的過程也是實現軟件流水線的過程(SoftwarePipeline)——在優化后的代碼中往往出現當前的運算和以往的存數或者未來的取數并行。編譯器對循環的打開可能是多次的,直到編譯器有足夠的指令可以充分安排并發。 說到這里我們對這位睡在上鋪的兄弟已經有一些了解了,那么程序員在這個優化的過程中應該做什么呢?這就要從矢量化和軟件流水線受到的限制談起。剛才提到在優化過程中編譯器一個重要的操作就是打開循環,如果循環次數是2的N次方例如8,16,32…,編譯器就可以很舒服的按照需要多次打開循環。但如果在上面的例子里循環次數是101,編譯器是無法打開循環的,對這個循環的優化就不能有效的展開。這個時候就需要程序員做工作了:我們可以將循環里面的運算在循環外實現一次,讓循環次數變為100,從而給編譯器兩次打開循環的機會(2x2x25)。矢量化和軟件流水線對操作數的存放也是有要求的。首先,對memory中操作數讀取和計算結果存放必須是順序(地址遞增或者遞減)的,如果是亂序或者隨機的,不管是運算的并行和是指令的并行都很難實現。我們在編寫程序和對C程序進行優化的時候就要注意到盡可能安排數據訪問的順序性。其次,根據操作數的寬度,程序員還要注意保證數據的2字對齊或者4字對齊。這有助于在指令并行執行時對操作時的有效讀取。程序員可以通過在定義數據(組)的時候用編譯器提供的相應編譯選項來實現數據的對齊。在進行矢量化和軟件流水線的過程中往往要對程序執行的順序做局部調整,這種調整對程序整體來說雖然是微調,但在某些情況下改變原始程序執行的順序會影響到程序執行結果的正確性。最典型的情況就是運算的操作數和結果之間存在某種聯系和依賴。比方說數組中靠后的成員數值取決于靠前的成員運算的結果,這意味著數組成員之間有依賴性,不獨立,從而不能實現并行計算。這在for循環中經常體現為一個運算的兩個操作數指針可能是指向同一個數組的不同位置。數據獨立性是到目前位置我們看到影響客戶C代碼優化效率最嚴重的因素。 編譯器在進行優化的時候永遠都遵循一個基本原則,那就是優化不能影響程序運行的正確性。所以當編譯器發現矢量化和軟件流水線需要滿足的那些條件不確定的時候,它的行為往往是保守的。這是一種寧可放棄性能也要保證正確性的態度,無可厚非。該出手時就出手,到了程序員幫編譯器一把的時候了。因為編譯器面對的這些不確定性,在程序員看來通常是確定,一定,以及肯定的。以前面數據獨立性的問題為例,編譯器很難判斷當前for循環中兩個指針pa,pb在運行的時候是不是會指向同一個數組,因為對編譯器來說它們只是兩個指針,對它們后面實際操作的對象毫無頭緒。而程序員卻可能清楚的知道這段程序處理的兩個數組是定義在兩段不同的物理內存上的,也就是說這兩個指針不會指向同一段地址,數據的獨立性是有保證的。這個時候我們就可以通過相應的編譯選項通知編譯器:下面這個for循環里的數據是獨立的,放心大膽的優化吧。這里提到的編譯選項,包括前面說的關于循環次數,數據對齊,以及存儲位置等其他編譯選項都可以在VisualDSP++關于C語言編譯器的手冊中找到。 了解了編譯器的工作方式,針對矢量化和軟件流水線對代碼和數據存儲的要求,在C語言范圍內對相關代碼進行調整,并通過編譯選項將有利于優化的確定信息通知編譯器,依托C語言編譯器的能力實現代碼的高效優化,就是程序員在這里要做的工作。 4. 打完收工,還是剛剛開始 我們已經簡單的談了C語言優化,特別是性能曲線從A點到B點應該遵循的主旨和一些技巧。個人認為,嵌入式系統上高效的C代碼優化不是在代碼寫好以后才開始的一個獨立的步驟,而應該是在系統設計和編寫代碼的時候就已經開始考慮硬件平臺有效執行的因素,妥善安排算法,精度,數據類型,存儲空間和性能之間的關系。再加上靈活應用上面提到的技巧,可以做到事半功倍。由于篇幅的限制,這里只能提綱挈領的講一講。有興趣的讀者可以訪問http://www.analog.com/zh/embedde ... ng/fca.html#ADEV001 到此為止,C語言優化告一段落,而嵌入式系統的優化才剛剛開始。片內片外代碼和數據的分配,主頻和外頻的選擇,系統帶寬和DMA的使用,這些都會影響到優化后的代碼在嵌入式系統里最終的性能。火星人的地球之旅,才剛剛開始。 |