在複雜的底層網路程式中,記憶體拷 貝、字串比較和搜尋操作很容易成為效能瓶頸所在。編譯器內建的此類函數雖然做了一些通用性的最佳化工作,但因為在使用指令集方面受到相容性的約束,遠遠沒 有達到最大限度利用硬體能力的地步。而通過針對特定硬體平台的最佳化,可以大大提高此類操作的效能。下面我將以P4平台下記憶體拷貝操作為例,根據AMD提供 的一份最佳化文檔中的例子,簡要介紹一下如何通過特定指令集,最佳化記憶體頻寬的使用。雖然因為硬體限制沒有達到AMD文檔中所說memcpy函數300%的性 能提升,但在我機器上實測也有%175-%200的明顯效能提升(此資料可能根據機器情況不同)。
Optimizing Memory Bandwidth from AMD
按照眾所周 知的“摩爾”定律,CPU的運算速度每18個月翻一翻,但與此同時記憶體和外存(硬碟)的速度並無法達到同步增長。這就造成高速CPU與相對低速的記憶體和外 設之間的不同步發展,成為很多程式的瓶頸所在。而如何最大限度提升對現有硬體的利用程度,是演算法以下層面最佳化的主要途徑。對記憶體拷貝操作來說,瞭解和合理 使用Cache是最關鍵的一點。為追求效能,我們將以犧牲相容性為代價,因此以下討論和代碼都以P4及以上層級CPU為主,AMD晶片雖然實現上有所區 別,但在指令集和整體結構上相同。
首先我們來看一個最簡單的memcpy的彙編實現:
以下為引用:
;
; Flier Lu (flier@nsfocus.com)
;
; nasmw.exe -f win32 fastmemcpy.asm -o fastmemcpy.obj
;
; extern "C" {
; extern void fast_memcpy1(void *dst, const void *src, size_t size);
; }
;
cpu p4
segment .text use32
global _fast_memcpy1
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy1: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len]
rep movsb
pop edi pop esi ret
|
這裡我為了代碼可移植性,使用的是NASM格式的彙編代碼。NASM是一個非常出色的開源彙編編譯器,支援各種平台和中間格式,被開源項目廣泛使用,這樣可 以避免同時使用 VC 的嵌入式彙編和 GCC 中麻煩的 unix 風格 AT&T 格式彙編
代碼初始的cpu p4定義使用p4指令集,因為後面的很多最佳化工作使用了P4指令集和相關特性;接著的segment .text use32定義此代碼在32位程式碼片段;然後global定義標籤_fast_memcpy1為全域符號,使得C++代碼中可以LINK其.obj後訪問此 代碼;最後%define定義多個宏,用於訪問函數參數。
在C++中只需要定義fast_memcpy1函數格式並連結nasm編譯產生 的.obj檔案即可。NASM編譯時間 -f 參數指定產生中間檔案格式為 MS 的 32 位 COFF 格式,-o 參數指定輸出檔案名。
上 面這段代碼非常簡單,適合小記憶體塊的快速拷貝。實際上VC編譯器在處理小記憶體拷貝時,會自動根據情況使用 rep movsb 直接替換 memcpy 函數,通過忽略函數調用和堆棧操作,最佳化代碼長度和效能。
不過在 32 位的 x86 架構下,完全沒有必要逐位元組進行操作,使用 movsd 替換 movsb 是必然的選擇。
以下為引用:
global _fast_memcpy2
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy2: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] shr ecx, 2 ; convert to DWORD count
rep movsd
pop edi pop esi ret
|
為了展示方便,這裡假設源和目標記憶體塊本身長度都是64位元組的整數倍,並且已經4K頁對齊。前者保證單條指令不會出現跨CACHE 行訪問的情況;後者保證測試速度時不會因為跨頁操作影響測試結果。等會分析CACHE時再詳細解釋為什麼要做這種假設。
不過因為現代 CPU大多使用了很長的指令流水線,多條指令並行工作往往比一條指令效率更高,因此 AMD 文檔中給出了這樣的最佳化:
以下為引用:
global _fast_memcpy3
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy3: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] shr ecx, 2 ; convert to DWORD count
.copyloop: mov eax, dword [esi] mov dword [edi], eax
add esi, 4 add edi, 4
dec ecx jnz .copyloop
pop edi pop esi ret
|
標籤.copyloop中那段迴圈實際上完成跟rep movsd指令完全相同的工作,但是因為是多條指令,理論上CPU指令流水線可以平行處理之。故而在AMD的文檔中指出能有1.5%的效能提高,不過就我 實測效果不太明顯。相對而言,當年從486向pentium架構遷移時,這兩種方式的區別非常明顯。記得Delphi 3還是4中就只是通過做這一種最佳化,其字串處理效能就有較大提升。而目前主流CPU廠商,實際上都是通過微代碼技術,核心中使用RISC微指令類比 CISC指令集,因此現在效果並不明顯。
然後,可以通過迴圈展開的最佳化策略,增加每次處理資料量並減少迴圈次數,達到效能提升目的。
以下為引用:
global _fast_memcpy4
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy4: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] shr ecx, 4 ; convert to 16-byte size count
.copyloop: mov eax, dword [esi] mov dword [edi], eax
mov ebx, dword [esi+4] mov dword [edi+4], ebx
mov eax, dword [esi+8] mov dword [edi+8], eax
mov ebx, dword [esi+12] mov dword [edi+12], ebx
add esi, 16 add edi, 16
dec ecx jnz .copyloop
pop edi pop esi ret
|
但這種操作就 AMD 文檔上評測反而有 %1.5 效能降低,呵呵。其自己的說法是需要將讀取記憶體和寫入記憶體的操作分組,以使CPU可以一次性搞定。改稱以下分組操作就可以比_fast_memcpy3提 高3% -_-b
以下為引用:
global _fast_memcpy5
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy5: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] shr ecx, 4 ; convert to 16-byte size count
.copyloop: mov eax, dword [esi] mov ebx, dword [esi+4] mov dword [edi], eax mov dword [edi+4], ebx
mov eax, dword [esi+8] mov ebx, dword [esi+12] mov dword [edi+8], eax mov dword [edi+12], ebx
add esi, 16 add edi, 16
dec ecx jnz .copyloop
pop edi pop esi ret
|
可惜我在P4上實在測不出什麼區別,呵呵,大概P4和AMD實現流水線的思路有細微的出入吧
既然進行迴圈展 開,為什麼不乾脆多展開一些呢?雖然x86下面通用寄存器只有那麼幾個,但是現在有MMX啊,呵呵,大把的寄存器啊 改稱使用MMX寄存器後,一次載入/寫入操作可以處理64位元組的資料,呵呵,比_fast_memcpy5可以再有7%的效能提升。
以下為引用:
global _fast_memcpy6
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy6: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] ; number of QWORDS (8 bytes) assumes len / CACHEBLOCK is an integer shr ecx, 3
lea esi, [esi+ecx*8] ; end of source lea edi, [edi+ecx*8] ; end of destination neg ecx ; use a negative offset as a combo pointer-and-loop-counter
.copyloop: movq mm0, qword [esi+ecx*8] movq mm1, qword [esi+ecx*8+8] movq mm2, qword [esi+ecx*8+16] movq mm3, qword [esi+ecx*8+24] movq mm4, qword [esi+ecx*8+32] movq mm5, qword [esi+ecx*8+40] movq mm6, qword [esi+ecx*8+48] movq mm7, qword [esi+ecx*8+56]
movq qword [edi+ecx*8], mm0 movq qword [edi+ecx*8+8], mm1 movq qword [edi+ecx*8+16], mm2 movq qword [edi+ecx*8+24], mm3 movq qword [edi+ecx*8+32], mm4 movq qword [edi+ecx*8+40], mm5 movq qword [edi+ecx*8+48], mm6 movq qword [edi+ecx*8+56], mm7
add ecx, 8 jnz .copyloop
emms
pop edi pop esi
ret
|
最佳化到這個份上,常規的最佳化手段基本上已經用盡,需要動用非常手段了,呵呵。
讓我們回過頭來看看P4架構下的Cache結 構。
The IA-32 Intel Architecture Software Developer's Manual, Volume 3: System Programming Guide
Intel的系統變成手冊中第十章介紹 了IA32架構下的記憶體緩衝控制。因為CPU速度和記憶體速度的巨大差距,CPU廠商通過在CPU中內建和外置多級緩衝提高頻繁使用資料的訪問速度。一般來 說,在CPU和記憶體之間存在L1, L2和L3三級緩衝(還有幾種TLB緩衝在此不涉及),每級緩衝的速度有一個數量級左右的差別,容量也有較大差別(實際上跟$有關,呵呵),而L1緩衝更 是細分為指令緩衝和資料緩衝,用於不同的目的。就P4和Xeon的處理器來說,L1指令緩衝由Trace Cache取代,內建在NetBust微架構中;L1資料緩衝和L2緩衝則封裝在CPU中,根據CPU檔次不同,分別在8-16K和256-512K之 間;而L3緩衝只在Xeon處理器中實現,也是封裝在CPU中,512K-1M左右。
可以通過查看CPU資訊的軟體如CPUInfo查看當前機器的緩衝資訊,如我的系統為:
P4 1.7G, 8K L1 Code Cache, 12K L1 Data Cache, 256K L2 Cache。
而緩衝在實現上是若干行(slot or line)組成的,每行對應記憶體中的一個地址上的連續資料,由快取管理器控制讀寫中的資料載入和命中。其原理這裡不多羅嗦,有興趣的朋友可以自行查看 Intel手冊。需要知道的就是每個slot的長度在P4以前是32位元組,P4開始改成64位元組。而對緩衝行的操作都是完整進行的,哪怕唯讀一個位元組也需 要將整個緩衝行(64位元組)全部載入,後面的最佳化很大程度上基於這些原理。
就緩衝的工作模式來說,P4支援的有六種之多,這裡就不一一介 紹了。對我們最佳化有影響的,實際上就是寫記憶體時緩衝的表現。最常見的WT(Write-through)寫通模式在寫資料到記憶體的同時更新資料到緩衝中; 而WB(Write-back)寫回模式,則直接寫到緩衝中,暫不進行較慢的記憶體讀寫。這兩種模式在操作頻繁操作(每秒百萬次這個層級)的記憶體變數處理上 有較大效能差別。例如通過編寫驅動模組操作MTRR強行開啟WB模式,在Linux的網卡驅動中曾收到不錯的效果,但對記憶體複製的最佳化協助不大,因為我們 需要的是完全跳過對緩衝的操作,無論是緩衝定位、載入還是寫入。
好在P4提供了MOVNTQ指令,使用WC(Write- combining)模式,跳過緩衝直接寫記憶體。因為我們的寫記憶體操作是純粹的寫,寫入的資料一定時間內根本不會被使用,無論使用WT還是WB模式,都會 有冗餘的快取作業。最佳化代碼如下:
以下為引用:
global _fast_memcpy7
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy7: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] ; number of QWORDS (8 bytes) assumes len / CACHEBLOCK is an integer shr ecx, 3
lea esi, [esi+ecx*8] ; end of source lea edi, [edi+ecx*8] ; end of destination neg ecx ; use a negative offset as a combo pointer-and-loop-counter
.copyloop: movq mm0, qword [esi+ecx*8] movq mm1, qword [esi+ecx*8+8] movq mm2, qword [esi+ecx*8+16] movq mm3, qword [esi+ecx*8+24] movq mm4, qword [esi+ecx*8+32] movq mm5, qword [esi+ecx*8+40] movq mm6, qword [esi+ecx*8+48] movq mm7, qword [esi+ecx*8+56]
movntq qword [edi+ecx*8], mm0 movntq qword [edi+ecx*8+8], mm1 movntq qword [edi+ecx*8+16], mm2 movntq qword [edi+ecx*8+24], mm3 movntq qword [edi+ecx*8+32], mm4 movntq qword [edi+ecx*8+40], mm5 movntq qword [edi+ecx*8+48], mm6 movntq qword [edi+ecx*8+56], mm7
add ecx, 8 jnz .copyloop
sfence ; flush write buffer emms
pop edi pop esi
ret
|
寫記憶體的movq指令全部改為movntq指令,並在複製操作完成後,調用sfence重新整理寫緩衝,因為緩衝中內容可能已經失效 了。這樣一來在寫記憶體外的載入快取作業,以及緩衝本身的操作都被省去,大大減少了冗餘記憶體操作。按AMD的說法能有60%的效能提升,我實測也有50%左 右明顯的效能提升。
movntq和sfence等指令可以參考Intel的指令手冊:
The IA-32 Intel Architecture Software Developer's Manual, Volume 2A: Instruction Set Reference, A-M
The IA-32 Intel Architecture Software Developer's Manual, Volume 2B: Instruction Set Reference, N-Z
在最佳化完寫記憶體 後,同樣可以通過對讀記憶體的操作進行最佳化提升效能。雖然CPU在讀取資料時,會有一個自動的預讀取最佳化,但在操作連續記憶體地區時顯式要求CPU預讀資料,還 是可以明顯地最佳化效能。
以下為引用:
global _fast_memcpy8
%define param esp+8+4 %define src param+0 %define dst param+4 %define len param+8
_fast_memcpy8: push esi push edi
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] ; number of QWORDS (8 bytes) assumes len / CACHEBLOCK is an integer shr ecx, 3
lea esi, [esi+ecx*8] ; end of source lea edi, [edi+ecx*8] ; end of destination neg ecx ; use a negative offset as a combo pointer-and-loop-counter
.writeloop: prefetchnta [esi+ecx*8 + 512] ; fetch ahead by 512 bytes
movq mm0, qword [esi+ecx*8] movq mm1, qword [esi+ecx*8+8] movq mm2, qword [esi+ecx*8+16] movq mm3, qword [esi+ecx*8+24] movq mm4, qword [esi+ecx*8+32] movq mm5, qword [esi+ecx*8+40] movq mm6, qword [esi+ecx*8+48] movq mm7, qword [esi+ecx*8+56]
movntq qword [edi+ecx*8], mm0 movntq qword [edi+ecx*8+8], mm1 movntq qword [edi+ecx*8+16], mm2 movntq qword [edi+ecx*8+24], mm3 movntq qword [edi+ecx*8+32], mm4 movntq qword [edi+ecx*8+40], mm5 movntq qword [edi+ecx*8+48], mm6 movntq qword [edi+ecx*8+56], mm7
add ecx, 8 jnz .writeloop
sfence ; flush write buffer emms
pop edi pop esi
ret
|
增加一個簡單的prefetchnta指令,提示CPU在處理當前讀取記憶體操作的同時,預讀前面512位元組處的一個緩衝行64位元組 內容。這樣一來又可以有10%左右的效能提升。
最後,對正在處理的記憶體,可以通過顯式的記憶體讀取操作,強制性要求其載入到緩衝中,因為 prefetchnta指令還只是一個提示,可以被CPU忽略。這樣可以再次獲得60%左右的效能提示,我實測沒有這麼高,但是也比較明顯。
以下為引用:
global _fast_memcpy9
%define param esp+12+4 %define src param+0 %define dst param+4 %define len param+8
%define CACHEBLOCK 400h
_fast_memcpy9: push esi push edi push ebx
mov esi, [src] ; source array mov edi, [dst] ; destination array mov ecx, [len] ; number of QWORDS (8 bytes) assumes len / CACHEBLOCK is an integer shr ecx, 3
lea esi, [esi+ecx*8] ; end of source lea edi, [edi+ecx*8] ; end of destination neg ecx ; use a negative offset as a combo pointer-and-loop-counter
.mainloop: mov eax, CACHEBLOCK / 16 ; note: .prefetchloop is unrolled 2X add ecx, CACHEBLOCK ; move up to end of block
.prefetchloop: mov ebx, [esi+ecx*8-64] ; read one address in this cache line... mov ebx, [esi+ecx*8-128] ; ... and one in the previous line sub ecx, 16 ; 16 QWORDS = 2 64-byte cache lines dec eax jnz .prefetchloop
mov eax, CACHEBLOCK / 8
.writeloop: prefetchnta [esi+ecx*8 + 512] ; fetch ahead by 512 bytes
movq mm0, qword [esi+ecx*8] movq mm1, qword [esi+ecx*8+8] movq mm2, qword [esi+ecx*8+16] movq mm3, qword [esi+ecx*8+24] movq mm4, qword [esi+ecx*8+32] movq mm5, qword [esi+ecx*8+40] movq mm6, qword [esi+ecx*8+48] movq mm7, qword [esi+ecx*8+56]
movntq qword [edi+ecx*8], mm0 movntq qword [edi+ecx*8+8], mm1 movntq qword [edi+ecx*8+16], mm2 movntq qword [edi+ecx*8+24], mm3 movntq qword [edi+ecx*8+32], mm4 movntq qword [edi+ecx*8+40], mm5 movntq qword [edi+ecx*8+48], mm6 movntq qword [edi+ecx*8+56], mm7
add ecx, 8 dec eax jnz .writeloop
or ecx, ecx ; assumes integer number of cacheblocks jnz .mainloop
sfence ; flush write buffer emms
pop ebx pop edi pop esi
ret
|
至此,一個完整的記憶體複製函數的最佳化流程就結束了,通過對緩衝的瞭解和使用,一次又一次地超越自己,最終獲得一個較為令人滿意地結 果。(號稱300%效能提示,實測175%-200%,也算相當不錯了)
在編寫測試代碼的時候需要注意兩點:
一是計時精 度的問題,需要使用高精度的物理計數器,避免誤差。推薦使用rdtsc指令,然後根據CPU主頻計算時間。CPU主頻可以通過高精度計時器動態計算,我這 兒偷懶直接從註冊表裡面讀取了
代碼如下:
以下為引用:
#ifdef WIN32
typedef __int64 uint64_t;
#else
#include <stdint.h>
#endif
bool GetPentiumClockEstimateFromRegistry(uint64_t& frequency) { HKEY hKey;
frequency = 0;
LONG rc = ::RegOpenKeyEx(HKEY_LOCAL_MACHINE, "Hardware\\Description\\System\\CentralProcessor\\0", 0, KEY_READ, &hKey);
if(rc == ERROR_SUCCESS) { DWORD cbBuffer = sizeof (DWORD); DWORD freq_mhz;
rc = ::RegQueryValueEx(hKey, "~MHz", NULL, NULL, (LPBYTE)(&freq_mhz), &cbBuffer);
if (rc == ERROR_SUCCESS) frequency = freq_mhz * MEGA;
RegCloseKey (hKey); }
return frequency > 0; }
void getTimeStamp(uint64_t& timeStamp) { #ifdef WIN32 __asm { push edx push ecx mov ecx, timeStamp //_emit 0Fh // RDTSC //_emit 31h rdtsc mov [ecx], eax mov [ecx+4], edx pop ecx pop edx } #else __asm__ __volatile__ ("rdtsc" : "=A" (timeStamp)); #endif }
|
二是測試記憶體複製的緩衝區的大小,如果緩衝區過小,第一次拷貝兩個緩衝區時就會導致所有資料都被載入L2緩衝中,得出比普 通記憶體操作高一個數量級的數值。例如我的L2緩衝為256K,如果我用兩個128K的緩衝區對著拷貝,無論迴圈多少次,速度都在普通記憶體複製的10倍左 右。因此設定一個較大的值是必要的。