關於現代CPU,程式設計師應當更新的知識

極客頭條 - Ted發表於2015-09-10

有人在Twitter上談到了自己對CPU的認識:

我記憶中的CPU模型還停留在上世紀80年代:一個能做算術、邏輯、移位和位操作,可以載入,並把資訊儲存在記憶體中的盒子。我隱約意識到了各種新發展,例如向量指令(SIMD),新CPU還擁有了虛擬化支援(雖然不知道這在實際使用中意味著什麼)。

我錯過了哪些很酷的發展呢?有什麼是今天的CPU可以做到而去年還做不到的呢?那兩年,五年或者十年之前的CPU又如何呢?我最感興趣的事是,哪些程式設計師需要自己動手才能充分利用的功能(或者不得不重新設計程式設計環境)。我想,這不該包括超執行緒/SMT,但我並不確定。我也對暫時CPU做不到但是未來可以做得到的事感興趣。

本文內容除非另有說明,都是指在x86和Linux環境下。歷史總在重演,很多x86上的新事物,對於超級計算機、大型機和工作站來說已經是老生常談了。

現狀

雜記

現代CPU擁有更寬的暫存器,可定址更多記憶體。在上世紀80年代,你可能已經使用過8位CPU,但現在肯定已在使用64位CPU。除了能提供更多地址空間,64位模式(對於32位和64位操作通過x867浮點避免偽隨機地獲得80位精度)提供了更多暫存器和更一致的浮點結果。自80年代初已經被引入x86的其他非常有可能用到的功能還包括:分頁/虛擬記憶體,pipelining和浮點運算。

本文將避免討論那些寫驅動程式、BIOS程式碼、做安全審查,才會用到的不尋常的底層功能,如APIC/x2APIC,SMM或NX位等。

記憶體/快取 (Memory / Caches)

在所有話題中,最可能真正影日常程式設計工作的是記憶體訪問。我的第一臺電腦是286在,那臺機器上,一次記憶體訪問可能只需要幾個時鐘週期。幾年前,我使用奔騰4,記憶體訪問需要花費超過400時鐘週期。處理器比記憶體的發展速度快得多,對於記憶體較慢問題的解決方法是增加快取,如果訪問模式可被預測,常用資料訪問速度更快,還有預取——預載入資料到快取。

幾個週期與400多個相比,聽起來很糟——慢了100倍。但一個對64位(8位元組)值塊讀取並操作的迴圈,CPU聰明到能在我需要之前就預取正確的資料,在3Ghz處理器上,以約22GB/s的速度處理,我們只丟了8%的效能而不是100倍。

通過使用小於CPU快取的可預測記憶體訪問模式和資料塊操作,在現代CPU快取架構中能發揮最大優勢。如果你想盡可能高效,這份檔案是個很好的起點。消化了這100頁PDF檔案後,接下來,你會想熟悉系統的微架構和記憶體子系統,以及學習使用類似likwid這樣的工具來分析和測驗應用程式。

TLBs

晶片裡也有小快取來處理各種事務,除非需要全力實現微優化,你並不需要知道解碼指令快取和其他有趣的小快取。最大的例外是TLB——虛擬記憶體查詢快取(通過x86上4級頁表結構完成)。頁表在L1資料快取,每個查詢有4次,或16個週期來進行一次完整的虛擬地址查詢。對於所有需要被使用者模式記憶體訪問的操作來說,這是不能接受的,從而有了小而快的虛擬地址查詢的快取。

因為第一級TLB快取必須要快,被嚴重地限制了尺寸。如果使用4K頁面,確定了在不發生TLB丟失的情況下能找到的記憶體數量。x86還支援2MB和1GB頁面;有些應用程式會通過使用較大頁面受益匪淺。如果你有一個長時間執行,且使用大量記憶體的應用程式,很值得研究這項技術的細節。

亂序執行/序列化 (Out of Order Execution / Serialization)

最近二十年,x86晶片已經能思考執行的次序(以避免因為一個停滯資源而被阻塞)。這有時會導致很奇怪的表現。x86非常嚴格的要求單一CPU,或者外部可見的狀態,像暫存器和記憶體,如果每件事都在按照順序執行都必須及時更新。

這些限制使得事情看起來像按順序執行,在大多數情況下,你可以忽略OoO(亂序)執行的存在,除非要竭力提高效能。主要的例外是,你不僅要確保事情在外部看起來像是按順序執行,實際上在內部也要真的按順序。

一個你可能關心的例子是,如果試圖用rdtsc測量一系列指令的執行時間,rdtsc將讀出隱藏的內部計數器並將結果置於edxeax這些外部可見的暫存器。

假設我們這樣做:

foo
rdtsc
bar
mov %eax, [%ebx]
baz

其中,foo,bar和baz不去碰eax,edx或[%ebx]。跟著rdtscmov會把eax值寫入記憶體某個位置,因為eax外部可見,CPU將保證rdtsc執行後mov才會執行,讓一切看起來按順序發生。

然而,因為rdtscfoobar之間沒有明顯的依賴關係 ,rdtsc可能在foo之前,在foobar之間 ,或在bar之後。甚至只要baz不以任何方式影響移mov,令也可能存在bazrdtsc之前執行的情況。有些情況下這麼做沒問題,但如果rdtsc被用來衡量foo的執行時間就不妙了。

為了精確地安排rdtsc和其他指令的順序,我們需要序列化所有執行。如何準確的做到?請參考英特爾的這份文件

記憶體/併發 (Memory / Concurrency)

上面提到的排序限制意味著相同位置的載入和儲存彼此間不能被重新排序,除此以外,x86載入和儲存有一些其他限制。特別是,對於單一CPU,不管是否是在相同的位置,儲存不會與之前的負載一起被記錄。

然而,負載可以與更早的儲存一起被記錄。例如:

mov 1, [%esp]
mov [%ebx], %eax

執行起來就像:

mov [%ebx], %eax
mov 1, [%esp]

但反之則不然——如果你寫了後者,它永遠不能像你前面寫那樣被執行。

你可能通過插入序列化指令迫使前一個例項像寫起來一樣來執行。但是這需要CPU序列化所有指令這會非常緩慢,因為它迫使CPU要等到所有指令完成序列化後才能執行任何操作。如果你只關心載入/儲存順序,另外還有一個 mfence指令只用於序列化載入和儲存。

本文不打算討論memory fence,lfence和sfence,但你可以在這裡閱讀更多關於它們的內容 。

單核載入和儲存大多是有序的,對於多核,上述限制同樣適用;如果core0在觀察core1,就可以看到所有的單核規則適用於core1的載入和儲存。然而如果core0和core1相互作用,不能保證它們的相互作用也是有序的。

例如,core0和core1通過設定為0的eax和edx開始,core0執行:

mov 1, [_foo]
mov [_foo], %eax
mov [_bar], %edx

而core1執行

mov 1, [_bar]
mov [_bar], %eax
mov [_foo], %edx

對於這兩個核來說, eax必須是1,因為第一指令和第二指令相互依賴。然而,eax有可能在兩個核裡都是0,因為core0的第三行可能在core1沒看到任何東西時執行,反之亦然。

memory barriers序列化一個核心內的儲存器訪問。Linus對於使用memory barriers而不是使用locking有這樣一段話 :

不用locking的真正代價最終不可避免。通過使用memory barriers自以為聰明的做事幾乎總是錯誤的前奏。在所有可以發生在十多種不同架構並且有著不同的記憶體排序的情況下,缺失一個小小的barrier真的很難讓你理清楚…事實上,任何時候任何人編了一個新的鎖定機制,他們總是會把它弄錯。

而事實證明,在現代的x86處理器上,使用locking來實現併發通常比使用memory barriers代價低,所以讓我們來看看鎖。

如果設定_foo為0,並有兩個執行緒執行incl (_foo)10000次——一個單指令同一位置遞增20000次,但理論上結果可能2。搞清楚這一點是個很好的練習。

我們可以用一段簡單的程式碼試驗:

#include <stdlib.h>
#include <thread>

#define NUM_ITERS 10000
#define NUM_THREADS 2

int counter = 0;
int *p_counter = &counter;

void asm_inc() {
  int *p_counter = &counter;
  for (int i = 0; i < NUM_ITERS; ++i) {
    __asm__("incl (%0) \n\t" : : "r" (p_counter));
  }
}

int main () {
  std::thread t[NUM_THREADS];
  for (int i = 0; i < NUM_THREADS; ++i) {
    t[i] = std::thread(asm_inc);
  }
  for (int i = 0; i < NUM_THREADS; ++i) {
    t[i].join();
  }
  printf("Counter value: %i\n", counter);
  return 0;
}

clang++ -std=c++11 –pthread在我的兩臺機器上編譯得到的分佈結果如下:

圖片描述

不僅得到的結果在執行時變化,結果的分佈在不同的機器上也是不同。我們永遠沒到理論上最小的2,或就此而言,任何低於10000的結果,但有可能得到10000和20000之間的最終結果。

儘管incl是個單獨的指令,但不能保證原子性。在內部,incl是後面跟一個add後再跟一個儲存的負載。在cpu0裡的一個增加有可能偷偷的溜進cpu1裡面的負載和儲存之間執行,反之亦然。

英特爾對此的解決方案是少量的指令可以加lock字首,以保證它們的原子性。如果我們把上面程式碼的incl改成lock incl,輸出始終是20000。

為了使序列有原子性,我們可以使用xchg或cmpxchg, 它們始終被鎖定為比較和交換的基元。本文不會詳細描它是如何工作的,但如果你好奇可以看這篇David Dalrymple的文章

為了使儲存器的交流原子性,lock相對於彼此在global是有序的,而且載入和儲存對於鎖不會被重新排序相。對於記憶體排序嚴格的模型,請參考x86 TSO文件

在C或C++中:

local_cpu_lock = 1;
// .. 做些重要的事 ..
local_cpu_lock = 0;

編譯器不知道local_cpu_lock = 0不能被放在重要的中間部分。Compiler barriers與CPU memory barriers不同。由於x86記憶體模型是比較嚴格,一些編譯器的屏障在硬體層面是選擇不作為,並告訴編譯器不要重新排序。如果使用的語言比microcode,彙編,C或C++抽象層級高,編譯器很可能沒有任何型別的註釋。

記憶體/移植 (Memory / Porting)

如果要把程式碼移植到其他架構,需要注意的是,x86也許有著今天你能遇到的任何架構裡最強的記憶體模式。如果不仔細思考,它移植到有較弱擔保的架構(PPC,ARM,或Alpha),幾乎肯定得到報錯。

考慮Linus對這個例子的評論:

CPU1         CPU2
----         ----
if (x == 1)  z = y;
  y = 5;     mb();
             x = 1;

…如果我讀了Alpha架構記憶體排序保證正確,那麼至少在理論上,你真的可以得到Z = 5

mb是memory barrier(記憶體屏障)。本文不會細講,但如果你想知道為什麼有人會建立這樣一個允許這種瘋狂行為發生的規範,想一想成產成本上升打垮DEC之前,其晶片快到可以在相同的基準下通過模擬執行卻比x86更快。對於為什麼大多數RISC-Y架構做出了當時的決定請參見關於Alpha架構背後動機的論文

順便說一句,這是我很懷疑Mill架構的主要原因。暫且不論關於是否能達到他們號稱的效能,僅僅在技術上出色並不是一個合理的商業模式。

記憶體/非臨時儲存/寫結合儲存器 (Memory / Non-Temporal Stores / Write-Combine Memory)

上節所述的限制適用於可快取(即“回寫(write-back)”或WB)儲存器。在此之前,只有不可快取(UC)記憶體。

一個關於UC記憶體有趣的事情是,所有載入和儲存都被設計希望能在匯流排上載入或儲存。對於沒有快取或者幾乎沒有板載快取的處理器,這麼做完全合理。

記憶體/NUMA

非一致記憶體訪問(NUMA),即對於不同處理器來說,記憶體訪問延遲和頻寬各有不同。因為NUMA或ccNUMA如此普遍,以至於是被預設為採用的。

這裡要求的是共享記憶體的執行緒應該在同一個socket上,記憶體對映I/O重執行緒應該確保它與最接近的I/O裝置的socket對話。

曾幾何時,只有記憶體。然後CPU相對於記憶體速度太快以致於人們想增加一個快取。快取與後備儲存器(記憶體)不一致是一個壞訊息,因此快取必須保持它堅持著什麼的資訊,所以它才知道是否以及何時它需要向後備儲存寫東西。

這不算太糟糕,而一旦你獲得了兩個有自己快取的核心,情況就變複雜了。為了保持作為無快取的情況下相同的程式設計模型,快取必須相互之間以及與後備儲存器是一致的。由於現有的載入/儲存指令在其API中沒有什麼允許他們說“對不起!這個載入因為別的cpu在使用你想用的地址而失敗了” ,最簡單的方式是讓每個CPU每次要載入或儲存東西的時候發一個資訊到匯流排上。我們已經有了這個兩個CPU都可以連線的記憶體匯流排,所以只要要求另一個CPU在其資料快取有修改時做出回覆(並失去相應的快取行)。

在大多數情況下,每個CPU只涉及其他CPU不關心的資料,所以有一些浪費的匯流排流量。但不算糟糕,因為一旦CPU拿出一條訊息說“你好!我要佔有這個地址並修改資料”,可以假定在其他的CPU要求前完全擁有該地址,雖然不是總會發生。

對於4核CPU,依然可以工作,雖然位元組浪費相比有點多。但其中每個CPU對其他每一個CPU的響應失敗比例遠遠超出4個CPU總和,既因為匯流排被飽和,也因為快取將得到飽和(快取的物理尺寸/成本是以同時的讀和寫數量 O(n^2) ,並且速度與大小負相關)。

這個問題“簡單”的解決方法是有一個單獨的集中目錄記錄所有的資訊,而不是做N路的對等廣播。反正因為現在我們正在一個晶片上包2-16個核心,每個晶片(socket)對每個核的快取狀態有個單一目錄跟蹤是很自然的事。

不僅解決了每個晶片的問題,而且需要通過某種方式讓晶片相互交談。不幸的是,當我們擴充套件這些系統即使對於小型系統匯流排速度也快到真的很難驅動一個訊號遠到連線一堆晶片和都在一條匯流排上的記憶體。最簡單的解決辦法就是讓每個插座都擁有一個儲存器區域,所以每一個socket並不需要被連線到的儲存器每一個部分。因為它很明確哪個目錄擁有特定的一段記憶體,這也避免了目錄需要一個更高階別的目錄的複雜性。

這樣做的缺點是,如果佔用一個socket並且想要一些被別的socket擁有的memory,會有顯著的效能損失。為簡單起見,大多數“小”(<128核)系統使用環形匯流排,因此效能損失的不僅僅是通過一系列跳轉達到memory付出的直接延遲/頻寬處罰,他也用光了有限的資源(環狀匯流排)和減慢了其他socekt的訪問速度。

理論上來講,OS會透明處理,但往往低效 。

Context Switches/系統呼叫(Syscalls)

在這裡,syscall是指Linux的系統呼叫,而不是x86的SYSCALL或者SYSENTER指令。

所有現代處理器具有一個副作用是,Context Switches代價昂貴,這會導致系統呼叫代價高昂。Livio Soares和Michael Stumm的論文對此做了詳細討論。我在下文將用一些他們的資料。下圖為Xalan上的酷睿i7每一個時鐘可以多少指令(IPC):

圖片描述

系統呼叫的14000週期後,程式碼仍不是全速執行。

下面是幾個不同的系統呼叫的足跡表,無論是直接成本(指令和週期),還是間接成本(快取和TLB驅逐的數量)。

圖片描述

有些系統呼叫引起了40多次的TLB回收!對於具有64項D-TLB的晶片,幾乎掃蕩光了TLB。快取回收不是毫無代價。

系統呼叫的高成本是人們對於高效能的程式碼轉而進行使用指令碼化的系統呼叫(例如epoll, 或者recvmmsg)究其原因,人們需要高效能I/O經常使用使用者空間的I/O stack。Context Switches的成本就是為什麼高效能的程式碼往往是一個核心一個執行緒(甚至是固定執行緒上一個單執行緒),而不是每個邏輯任務一個執行緒的原因。

這種高代價也是VDSO在後面驅動,把一些簡單的不需要任何升級特權的系統呼叫放進簡單的使用者空間庫呼叫。

SIMD

基本上所有現代的x86 CPU都支援SSE,128位寬的向量暫存器和指令。因為要完成多次相同的操作很常見,英特爾增加了指令,可以讓你像為2個64位塊一樣對128位資料塊操作,或者4個32位的塊,8個16位塊等。ARM用不同的名字(NEON)支援同樣的事情,而且支援的指令也很相似。

通過使用SIMD指令獲得了2倍,4倍加速這是很常見的,如果你已經有了一個計算繁重的工作這絕對值得期待。
編譯器足夠到可以分辨常見的可以實現向量化模式的簡單的程式碼,就像下面程式碼,會自動使用現代編譯器的向量指令:

for (int i = 0; i < n; ++i) {
  sum += a[i];
}

但是,如果你不手寫組合語言,編譯器經常會產生非優化的程式碼 ,特別是對SIMD程式碼,所以如果你很關心儘可能的得到最佳效能,你就要看看反彙編並檢查你編譯器的優化錯誤。

電源管理

有現代CPU都有很多花哨的電源管理功能用來在不同的場景優化電源使用。這些的結果是“跑去閒置”,因為儘可能快的完成工作,然後讓CPU回去睡覺是最節能的方式。

儘管有很多做法已經被證明進行特定的微優化可以對電源消耗有利,但把這些微優化應用在實際的工作負載中通常會比預期的收益小 。

GPU/GPGPU

相比其他部分我不是很夠資格來談論這些。幸運的是,Cliff Burdick自告奮勇地寫了下面這節:

2005年之前,圖形處理單元(GPU)被限制在一個只允許非常有限硬體控制量的API。由於庫變得更加靈活,程式設計師開始使用處理器處理更常用的任務,如線性代數例程。GPU的並行架構可以通過發射數百併發執行緒在大量的矩陣塊中工作。然而,程式碼必須使用傳統的圖形API,並仍被限制於可以控制多少硬體。Nvidia和ATI注意到了這點併發布了可以使顯示卡界外的人更熟悉的API來獲得更多的硬體訪問的框架。該庫得到了普及,今天的GPU同CPU一起被廣泛用於高效能運算(HPC)。

相比於處理器,GPU硬體主要有幾個差別,概述如下:

處理器

在頂層,一個GPU處理器包含一個或多個資料流多重處理器(SMs)。現代GPU的每個流的多重理器通常包含超過100個浮點單元,或在GPU的世界通常被稱為核。每個核心通常主頻在800MHz左右,雖然像CPU一樣,具有更高的時脈頻率但較少核心的處理器也存在。GPU的處理器缺乏自己同行CPU的許多特色,包括更大的快取和分支預測。在核的不同層,SMs,和整體處理器之間,通訊變得越來越慢。出於這個原因,在GPU上表現良好的問題通常是高度平行的,但有一些資料能夠在小數目的執行緒間共用。我們將在下面的記憶體部分解釋為什麼。

記憶體(Memory)

現代GPU記憶體被分為3類:全域性記憶體,共享記憶體和暫存器。全域性儲存器是GDDR通常GPU盒子上廣告宣稱約為2-12GB大小,並具有通過300-400GB /秒的速度。全域性儲存器在處理器上的所有SMS所有執行緒都能被訪問,並且也是記憶體卡上最慢的型別。共享記憶體,正如其名所指,是同一個SM中的所有執行緒之間共享記憶體。它通常至少是全域性儲蓄器兩倍的速度,但對不同SM的執行緒之間是不被允許進行訪問的。暫存器很像在CPU上的暫存器,他們是GPU上訪問資料最快的方式,但它們只在每個本地執行緒,資料對於其他正在執行的不同執行緒是不可見的。共享記憶體和全域性記憶體對他們如何能夠被訪問都有很嚴格的規定,對不遵守這些規則的行為有嚴重效能下降的處罰。為了達到上述吞吐量,記憶體訪問必須在同執行緒組間執行緒之間完整的合併。類似於CPU讀入一個單一的快取行,如果對齊合適的話,GPU對於單一的訪問可以有快取行可以服務一個組裡的所有執行緒。然而,最壞的狀況是一組裡所有執行緒訪問不同的快取行,每個執行緒都要求一個獨立的記憶體讀。這通常意味著快取行中的資料不被執行緒使用,並且儲存器的可用吞吐量下降。類似的規則同樣適用於共享記憶體,有一些例外,我們將不在這裡涵蓋。

執行緒模型 (Threading Model)

GPU執行緒在一個單指令多執行緒(SIMT)方式下執行,並且每個執行緒以組的形式在硬體中以預定義大小(通常32)執行。這最後一部分有很多的影響;該組中的每個執行緒必須同一時間在同一指令下工作。如果任何一組中的執行緒的需要從他人那裡獲得程式碼的發散路徑(例如一個if語句)的程式碼,所有不參與該分支的執行緒會到該分支結束才能開始。作為一個簡單的例子:

if (threadId < 5) {
   // Do something
}
// Do More

在上面的程式碼中,這個分支會導致我們的32個執行緒中的27組暫停執行,直到分支結束。你可以想象,如果多組執行緒執行這段程式碼,整體效能會因大部分的核心處於閒置狀態將受到很大打擊。只有當執行緒整組被鎖定才能使硬體允許交換另外一組的核來執行。

介面(Interfaces)

現代GPU必須有一個CPU同CPU和GPU記憶體之間進行資料複製的傳送和接收,並啟動GPU並且編碼。在最高吞吐量的情況下,一個有著16個通道的PCIe 3.0匯流排可達到約13-14GB / s的速度。這可能聽起來很高,但相對於存在GPU本身的記憶體速度,他們慢了一個數量級。事實上,圖形處理器變得更強大以致於PCIe匯流排日益成為一個瓶頸。為了看到任何GPU超過CPU的效能優勢,GPU的必須裝有大量的工作,以使GPU需要執行的工作的時間遠遠的高於資料傳送與接收的時間。

較新的GPU具備一些功能可以動態的在GPU程式碼裡分配工作而不需要再回到CPU推出的GPU程式碼中動態的工作,而無需返回到CPU,單目前他的應用相當有侷限性。

GPU結論

由於CPU和GPU之間主要的架構差異,很難想象任何一個完全取代另一個。事實上,GPU很好的補充了CPU的並行工作,使CPU可以在GPU執行時獨立完成其他任務。AMD公司正在試圖通過他們的“非均相體系結構”(HSA)合併這兩種技術,但用現有的CPU程式碼,並決定如何將處理器的CPU和GPU部分分割開來將是一個很大的挑戰,不僅僅對於處理器來說,對於編譯器也是。

虛擬化

除非你正在編寫非常低階的程式碼直接處理虛擬化,英特爾植入的虛擬化指令通常不是你需要思考的問題。

同那些東西打交道相當混亂,可以從這裡的程式碼看到。即使對於那裡展示的非常簡單的例子,設定起用Intel的VT指令來啟動一個虛擬客戶端也需要大約1000行低階程式碼。

虛擬記憶體

如果你看一下Vish的VT程式碼,你會發現有一塊很好的程式碼專門用於頁表/虛擬記憶體。這是另一個除非你正在編寫作業系統或其他低階別的系統程式碼你不必擔心的“新”功能。使用虛擬記憶體比使用分段儲存器更簡單,但本文暫且討論到這裡。

SMT/超執行緒 (Hyper-threading)

超執行緒對於程式設計師來說大部分是透明的。一個典型的在單核上啟用SMT的增速是25%左右。對於整體吞吐量來說是好的,但它意味著每個執行緒可能只能獲得其原有效能的60%。對於您非常關心單執行緒效能的應用程式,你可能最好禁用SMT。雖然這在很大程度上取決於工作量,而且對於任何其他的變化,你應該在你的具體工作負載執行一些基準測試,看看有什麼效果最好。

所有這些複雜性新增到晶片(和軟體)的一個副作用是效能比曾經預期的要少了很多;對特定硬體基準測試的重要性相對應的有所回升。

人們常常用“計算機語言基準遊戲”作為證據來說一種語言比另一種速度更快。我試著自己重現的結果,用我的移動Haswell(相對於在結果中使用的伺服器Kentsfield),我得到的結果可以達到高達2倍的不同(相對速度)。即使在同一臺機器上執行同一個基準,Nanthan Kurz 最近向我指出一個例子 gcc -O3 比 gcc –O2 慢25%改變對C ++程式的連結順序可導致15%的效能變化 。評測基準的選定是個難題。

分行 (Branches)

傳統觀念認為使用分支是昂貴的,並且應該盡一切(大多數)的可能避免。在Haswell上,分支的錯誤預測代價是14個時鐘週期。分支錯誤預測率取決於工作量。在一些不同的東西上使用 perf stat (bzip2,top,mysqld,regenerating my blog),我得到了在0.5%和4%之間的分支錯誤預測率。如果我們假設一個正確的預測的分支費用是1個週期,這個平均成本在.995 * 1 + .005 * 14 = 1.065 cycles to .96 * 1 + .04 * 14 = 1.52 cycles之間。這不是很糟糕。

從約1995年來這實際上誇大了代價,由於英特爾加入條件移動指令,使您可以在無需一個分支的情況下有條件地移動資料。該指令曾被Linus批判的令人難忘的 ,這給了它一個不好的名聲,但是相比分支,使用cmos更有顯著的加速這是相當普遍的額外分支成本的一個現實中的例子是使用整數溢位檢查。當使用bzip2來壓縮一個特定的檔案,那會增加約30%的指令數量(所有的增量從額外分支指令得來),這導致1%的效能損失 。

不可預知的分支是不好的,但大部分的分支是可以預見的。忽略分支的費用直到你的分析器告訴你有一個熱點在如今是非常合理的。CPUs在過去十年中執行優化不好程式碼方面變好了很多,而且編譯器在優化程式碼方面也變得更好,這使得優化分支變成了不良的使用時間,除非你試圖在一些程式碼中擠出絕對最佳表現。

如果事實證明這就是你所需要做的,你最好還是使用檔案導引優化而不是試圖手動去搞這個東西。

如果你真的必須用手動做到這一點,有些編譯器指令你可以用來表示一個特定分支是否有可能被佔用與否。現代CPU忽略了分支提示說明,但它們可以幫助編譯器更好得佈局程式碼。

對齊 (Alignment)

經驗告訴我們應該拉長struct,並確資料對齊。但在Haswell的晶片上,幾乎任何你能想到的任何不跨頁的單執行緒事情的誤配準為零。有些情況下它是有用的,但在一般情況下,這是另一種無關緊要的優化因為CPU已經變得在執行不優良程式碼時好了很多。它無好處的增加了記憶體佔用的足跡也是有一點害處。

而且, 不要把事情頁面對齊或以其他方式排列到大的界限,否則會破壞快取效能 。

自修改程式碼 (Self-modifying code)

這是另外一個目前已經不怎麼有意義的優化了。使用自修改程式碼以減少程式碼量或增加效能曾經有意義,但由於現代的快取傾向於拆分他們的L1指令和資料快取,在一個晶片的L1快取之間修改執行的程式碼需要昂貴的通訊。

未來

下面是一些可能的變化,從最保守的推測到最大膽的推測。

事務記憶體和硬體鎖Elision (Transactional Memory and Hardware Lock Elision)

IBM已經在他們自己的POWER晶片中有這些功能。英特爾嘗試著把這些東西加到Haswell,但因為一個報錯被禁用了。

事務記憶體支援正如它聽起來這樣:事務的硬體支援。通過三個新的指令xbeginxendxabort

xbegin開始一個新的事務。一個衝突(或xabort)使處理器(包括記憶體)的架構狀態回滾到在xbegin的狀態之前.如果您使用的是通過庫或語言支援的事務記憶體,這對你來說應該透明的。如果你正在植入庫支援,你就必須弄清楚如何將有有限的硬體緩衝區大小限制的硬體支援轉換成抽象的事務。

本文打算討論Elision硬體鎖,在本質上,它被植入的機制與用於實現事務記憶體的機制非常相似,而且它是被設計來加快基於鎖的程式碼。如果你想利用HLE,看看這個文件 。

快速I/O(Fast I/O)

對於儲存和網路來說,I/O頻寬正在不斷上升,I/O延遲正在下降。問題是,I/O通常是通過系統呼叫完成。正如我們所看到的,系統呼叫的相對額外費用一直在往上走。對於儲存和網路,答案是轉移到使用者模式的I/O堆疊。

黑矽(Dark Silicon)/系統級晶片

電晶體規模化一個有趣的副作用是我們可以把很多電晶體包進一個晶片上,但它們產生如此多的熱量,如果你不希晶片融化,普通電晶體大多數時間不能開關。

這樣做的結果把包括大量時間不使用的專用硬體變得更有意義。一方面,這意味著我們得到各種專用指令,如PCMP和ADX。但這也意味著,我們正把整個曾經不整合在晶片上的裝置與晶片整合。包括諸如GPU和(用於移動裝置)無線電。

與硬體加速的趨勢相結合,這也意味著企業設計自己的晶片,或者至少自己晶片的部分變得更有意義。通過收購PA Semi公司,蘋果公司已經走出了很遠。首先,加入少量定製的加速器給停滯不前的標準的ARM架構,然後新增自定義加速器給他們自己定製的架構。由於正確的定製硬體和基準和系統設計深思熟慮的結合,iPhone 4比我的旗艦級Android手機反應還稍快,這個旗艦機比iPhone 4新了很多年,並且具有更快的處理器以及更大的記憶體。

亞馬遜挑選了原Calxeda的團隊的一部分,並僱用了一個足夠大小的硬體設計團隊。Facebook也已經挑選了ARM SoC的專家,並與高通公司在某些事情展開合作。Linus也有紀錄在案的發言,“我們將在各個方面看到更多的專用硬體” 等等。

結論

x86晶片已經擁有了很多新的功能和非常有用的小特性。在大多數情況下,要利用這些優勢你不需要知道它們具體是什麼。真正的底層通常由庫或驅動程式隱藏了起來,編譯器將嘗試照顧其餘部分。例外是,如果你真的要寫底層程式碼,這種情況下世界上已經變得更加混亂,或者如果你想在你的程式碼裡獲得絕對的最佳表現,就會更加怪異

有些事似乎必然在未來發生。但過往的經驗卻又告訴我們,大多數的預測是錯誤的,所以誰又知道呢?

相關文章