梗概 SALVIA 0.5.2 的優化經歷是一個“跌宕起伏”的過程。這個過程的結果很簡單: 在Core 2 Duo T5800(2.0GHz x 2)上,Sponza的性能提升了60%,ComplexMesh性能提升了26%。 背景 SALVIA的整個渲染流程主要是以下幾部分: 根據Index Buffer獲得需要進行變換的頂點; 將頂點利用Vertex Shader進行變換; 將變換後的頂點,輸出成若干個float4; 將三角形光柵化。SALVIA的光柵化是將三角形拆分成4x4的像素塊若干,不滿的塊有掩碼來處理; 將像素進行插值; 插完值後把像素送到Pixel Shader中處理一趟; 處理完的結果用Blend Shader塞到Back buffer裡面去。 用於測試的場景: Sponza 26萬個面,20個左右的Diffuse紋理(1024x1024); PartOfSponza 約200個面,4個Diffuse紋理(1024x1024); ComplexMesh 兩萬個面,無紋理,有個能量保守的光照。 最初的版本(V1231)中,性能的主要瓶頸在插值階段,各種耗時林林總總占了一半以上(50% - 70%)。 相比之下其他階段對性能的影響要麼有限,要麼沒有多少優化空間。所以最近一周的優化,就都集中在了“插值”上。 插值算法 線性的插值算法常見的實現有兩種, 第一種是拿UV插值,第二種是用ddx和ddy累積。 UV是先計算像素的u和v(基本方法是用面積比,不記得就復習一下中學幾何吧),然後用插值公式: pixel = v0 * u + v1 * v + v2 * (1-u-v) 後者的步驟是選一個主頂點,然後計算這個頂點的ddx和ddy,最後用 pixel = v0 + ddx * offset_x + ddy * offset_y 計算出相應頂點。 但是在圖形學中,我們還需要對插值進行透視修正,獲得在3D空間中線性的插值結果。 我們將步驟修正到透視空間: 先將v0,v1,v2弄到透視空間中,變成projected_v0, projected_v1, projected_v2 對於UV的插值是 pixel = ( projected_v0*u + projected_v1*v + projected_v2 * (1-u-v) ) / pixel_w 對於用ddx和ddy的累積公式是: pixel = ( projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y ) / pixel_w 插值算法的選擇 何詠(Graphixer)大神之前也寫了一個渲染器,比我快許多(大概是4-6倍),用的是UV; gameKnife大神兩個禮拜寫成的渲染器,速度比我用五年寫出來的半成品要快7倍,用的辦法是Lerp到Scanline上,再Lerp到像素。 SALVIA采用了累積法: struct transformed_vertex { float4 attributes[MAX_ATTRIBUTE_COUNT]; }; transformed_vertex projected_corner; // 計算角點的坐標 projected_scanline_start = projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y; // 像素的透視修正值 float inv_w; // 最終輸出的4x4個像素 pixel_input px_in[4][4]; for(int i = 0; i < 4; ++i) { projected_pixel = projected_scanline_start; for(int j = 0; j < 4; ++j) { // 透視空間轉換到線性空間並輸出到px_in中 px_in[i][j] = unproject( projected_pixel ); // 累加x方向上的值(透視空間) projected_pixel += projected_ddx; } // 累加y方向上的值(透視空間) projected_scanline_start += projected_ddy; } 本輪優化之前對插值算法的優化嘗試 注意那個MAX_ATTRIBUTE_COUNT,這個值通常比較大,在v1231中,它是32。 不過,顯然我們不需要對所有的屬性進行計算。敏敏在這裡運用了一點小小的技巧進行了優化:只計算必要的屬性。同時,為了減少分支的使用,他甚至用 template <int N> void sub_n(out, v0, v1 ) { for(int i = 0; i < N; ++i) { out.attributes[i] = v0.attributes[i] – v1.attributes[i]; } } 並配合函數指針的方法,以促使編譯器展開循環,減少分支。 不過從實際生成的匯編來看,這個部分並沒有被展開到期望的形式,可能是編譯器認為x86的Branch Predication性能已經足夠高了吧。 這個“優化”在v1231中就已經具備了。 首輪優化:unproject函數,operator += 與 operator = 第一個Profiling是用BenchmarkPartOfSponza和Sponza跑的;unproject,operator +=和operator = 加在一起大約占用了15-20%的時間。單獨的unproject 最初的實現就是普通的標量。既不要求對齊,也沒有使用SIMD。 所以當然會以為用了SIMD後,優化效果會很好。於是在v1232中,中間頂點和像素輸入的分配都以16字節對齊,unproj,+=和=也都使用了SSE進行了重寫。 從跑分來看,PartOfSponza性能提升了20%。但是,在測試ComplexMesh和Sponza時,並未發現幀率有顯著提升。 其實在進行優化之前,何詠就告誡過我,因為現代CPU的一些技術,比方說超標量啥的,四個數據寬度的SSE和標量運算相比,就只有50%的性能差距。 並且這些函數的指令已經極為簡單,瓶頸也很明確的落在計算指令上。例如Unproject優化後,性能焦點就落在_mm_mul_ps上(3.7%),幾無優化余地。 二輪優化:插值算法的調整 在進行第二輪優化之前同樣運行了一次Profiling。因為對PartOfSponza性能基本滿意,因此這次優化的目標主要在Sponza上。 排名前幾位的小函數,分別是sub_n,unproj,+= 和tex2D。對sub_n例行優化後,性能沒什麼變化。當然,這也是意料之中的事情了。 因此,第二輪優化便著重考慮在插值算法本身上。 在優化之前,我嘗試對代碼成本做個粗略的評估: 在現有算法下,假設每個像素有N個需要插值的屬性,則平均每個像素有 (corner)3N/16個讀 + 2N/16個乘法 + 2N/16個加法 + N/16個寫 (x:+=)2N個讀 + N個加法 + N個寫 (x:*) N個讀 + 1個標量除法 + N個乘法 + N個寫 (y:+=)2N/4個讀 + N/4個加法 + N/4個寫 (y:=) N/4個讀 + N/4個寫 因為每個都是函數指針,所以這些都是優化不掉的。因此首先將一些操作合並了一下,比如把+= 和*合並以減少一下讀寫操作。只可惜效果也不是很明顯。 第二刀就砍到算法的頭上。因為累加本身是為了減少乘法的運用,但是這可能帶來了多余的存取開銷。 因此直接套用公式: pixel = ( projected_v0 + projected_ddx * offset_x + projected_ddy * offset_y ) / pixel_w 這樣就有:3N讀,2N乘法,2N加法,N個乘法和N個寫(假設寄存器夠用的話)。不算Corner的計算成本,這樣比較一下,就等於是3N/4個讀,N/2+N個寫,N/4個加法來換取2N個乘法的時間。本來以為作為IO瓶頸的應用,這樣可以提高一些性能。不過結果證實這個買賣實在是很不劃算,整體性能不增反減。 三輪優化:減少內存占用,柳暗花明 雖然所有的操作只針對已使用的屬性,但是空間上還是浪費了許多。 考慮到內存占用較大也會導致一些性能損失,於是將MAX_ATTRIBUTE_COUNT從32下調到了8。 結果令人大跌眼鏡。性能瞬間提升了20-30%之多。 再加上SSE也不知道為什麼開始發力了,使用上之後性能大約又有了10-15%的提升。 我猜測可能是因為換頁頻率下降,以及Cache的命中率提升。不過手上沒有VTune這種工具,所以也不太好驗證。 四輪優化:精度敏感性下降的額外紅利 在這輪優化之後,PartOfSponza出現了精度問題。因為視錐體的上下左右四個面都沒有Clip,所以可能會出現非常大的三角形。這樣累積的時候一旦起始點選擇的不好,就會出現比較大的誤差。在之前版本中,使用/fp: precise來減少這一問題出現的機會。但是因為使用了SSE,也讓這個問題再難解決。因此我選用了一些辦法,來改善精度問題。在大問題都修正以後,換用/fp: fast來編譯整個SALVIA,最終也獲得了0-10%左右的性能收益。