指令重排序優化分析和volatile對編譯優化的作用
- 指令重排序
指令重排序的原因:對主存的一次訪問一般花費硬體的數百次時鐘週期。處理器通過快取(暫存器、cpu快取等)能夠從數量級上降低記憶體延遲的成本這些快取為了效能重新排列待定記憶體操作的順序。也就是說,程式的讀寫操作不一定會按照它要求處理器的順序執行。
一、編譯期重排序
編譯期重排序的典型就是通過調整指令順序,在不改變程式語義的前提下,儘可能減少暫存器的讀取、儲存次數,充分複用暫存器的儲存值。
假設第一條指令計算一個值賦給變數A並存放在暫存器中,第二條指令與A無關但需要佔用暫存器(假設它將佔用A所在的那個暫存器),第三條指令使用A的值且與第二條指令無關。那麼如果按照順序一致性模型,A在第一條指令執行過後被放入暫存器,在第二條指令執行時A不再存在,第三條指令執行時A重新被讀入暫存器,而這個過程中,A的值沒有發生變化。通常編譯器都會交換第二和第三條指令的位置,這樣第一條指令結束時A存在於暫存器中,接下來可以直接從暫存器中讀取A的值,降低了重複讀取的開銷。
二、執行期重排序
在程式執行中,程式可能會對一些經常被執行的執行進行重排序,從而提高效能。而且在硬體方面有些架構也會對一些指令進行重排序執行。
三、重排序對於流水線的意義
現代CPU幾乎都採用流水線機制加快指令的處理速度,一般來說,一條指令需要若干個CPU時鐘週期處理,而通過流水線並行執行,可以在同等的時鐘週期內執行若干條指令,具體做法簡單地說就是把指令分為不同的執行週期,例如讀取、定址、解析、執行等步驟,並放在不同的元件中處理,同時在執行單元EU中,功能單元被分為不同的元件,例如加法元件、乘法元件、載入元件、儲存元件等,可以進一步實現不同的計算並行執行。
流水線架構決定了指令應該被並行執行,而不是在順序化模型中所認為的那樣。重排序有利於充分使用流水線,進而達到超標量的效果。
四、確保順序性
儘管指令在執行時並不一定按照我們所編寫的順序執行,但毋庸置疑的是,在單執行緒環境下,指令執行的最終效果應當與其在順序執行下的效果一致,否則這種優化便會失去意義。
通常無論是在編譯期還是執行期進行的指令重排序,都會滿足上面的原則。
不同架構下的指令重排優化
從圖中,可以看到,X86僅在 Stores after loads 和 Incoherent instruction cache pipeline 中會觸發重排。
Stores after loads的含義是在對同一個地址進行讀寫操作時,寫入在讀取後面,允許重排序。即滿足弱一致性(Weak Consistency),這是最可被接受的型別,不會造成太大的影響。
---------------------------------------------------------------------------------------------------------
對於如何解決重排序,這裡有些資料:
先看一下原子性是什麼:
原子操作是不可分割的,在執行完畢不會被任何其它任務或事件中斷。在單處理器系統(UniProcessor)中, 能夠在單條指令中完成的操作都可以認為是" 原子操作",因為中斷只能發生於指令之間。這也是某些CPU指令系統中引入了test_and_set、test_and_clear等指令用於臨界資源 互斥的原因。
在對稱多處理器(Symmetric Multi-Processor)結構中就不同了,由於系統中有多個處理器在獨立地執行,即使能在單條指令中完成的操作也有可能受到干擾。 在x86 平臺上,CPU提供了在指令執行期間對匯流排加鎖的手段。CPU晶片上有一條引線#HLOCK pin,如果組合語言的程式中在一條指令前面加上字首"LOCK",經過彙編以後的機器程式碼就使CPU在執行這條指令的時候把#HLOCK pin的電位拉低,持續到這條指令結束時放開,從而把匯流排鎖住,這樣同一總線上別的CPU就暫時不能通過匯流排訪問記憶體了,保證了這條指令在多處理器環境中 的原子性。
鎖匯流排是非常損耗效能的,目前的CPU一般都採用了很好的快取一致性協議,在很多情況下能夠防止鎖匯流排的發生,這其中最著名的就是Intel CPU中使用的MESI快取一致性協議。
優化屏障/記憶體屏障
-------------------------------
對於比方說io操作而言 要避免的問題包括像指令的重排優化(包括快取記憶體的使用) 以及處理器的亂序執行解決這些問題所提出的方法也就是優化屏障和記憶體屏障
linux中的優化屏障也就是barrier巨集它所解決的問題就是指令的重排優化ldd3給出的解釋是這個函式通知編譯器插入一個記憶體屏障(注意 是記憶體屏障也間接的說明了linux中優化屏障和記憶體屏障的關係)但對硬體沒有影響。編譯後的程式碼會把當前cpu暫存器所有修改過的數值儲存到記憶體中 需要這些資料的時候再重新讀出來。對barrier的呼叫可以避免在屏障前後的編譯器優化,但硬體能完成自己的重新排序。
總結一下ulk3和ldd3在linux中優化屏障其實就是一種特殊的記憶體屏障它負責防止編譯器的指令重排和優化 但不對cpu的亂序執行負責。在看下ldd3對mb系列的函式這樣解釋:這些函式在已編譯的指令流中插入硬體記憶體屏障。。。。這些函式(指rmb wmb)都是barrier的超集。
也就是說linux中的記憶體屏障有兩種一種是軟體記憶體屏障 它們負責對編譯器起作用也就是ulk中提到的優化屏障還有一種就是上面提到的硬體記憶體屏障也就是我們通常所說的記憶體屏障它做為前者的超集不但對軟體起作用同時對硬體也起作用
記憶體屏障主要解決的問題是編譯器的優化和CPU的亂序執行。
編譯器在優化的時候,生成的彙編指令可能和c語言程式的執行順序不一樣,在需要程式嚴格按照c語言順序執行時,需要顯式的告訴編譯不需要優化,這在linux下是通過barrier()巨集完成的,它依靠volidate關鍵字和memory關鍵字,前者告訴編譯barrier()周圍的指令不要被優化,後者作用是告訴編譯器彙編程式碼會使記憶體裡面的值更改,編譯器應使用記憶體裡的新值而非暫存器裡儲存的老值。
同樣,CPU執行會通過亂序以提高效能。彙編裡的指令不一定是按照我們看到的順序執行的。linux中通過mb()系列巨集來保證執行的順序。具體做法是通過mfence/lfence指令(它們是奔4後引進的,早期x86沒有)以及x86指令中帶有序列特性的指令(這樣的指令很多,例如linux中實現時用到的lock指令,I/O指令,操作控制暫存器、系統暫存器、除錯暫存器的指令、iret指令等等)。簡單的說,如果在程式某處插入了mb()/rmb()/wmb()巨集,則巨集之前的程式保證比巨集之後的程式先執行,從而實現序列化。wmb的實現和barrier()類似,是因為在x86平臺上,寫記憶體的操作不會被亂序執行。
實際上在RSIC平臺上,這些序列工作都有專門的指令由程式設計師顯式的完成,比如在需要的地方呼叫序列指令,而不像x86上有這麼多隱性的帶有序列特性指令(例如lock指令)。所以在risc平臺下工作的朋友通常對序列化操作理解的容易些。
wmb、rmb為什麼是barrier的超集?是因為wmb和rmb都有volidate關鍵字修飾,而barrier的功能就來源於該關鍵字。volidate關鍵字能讓多大範圍的指令不重排?”讓多大範圍的指令不重排”的提法本身就是錯的。volidate實際是個點,這個點後的程式碼對應的指令不能出現在該點之前;之前的程式碼對應的指令不能出現在改點之後。
在x86平臺下,wmb和barrier是一樣的?那是因為x86的寫是順序的,不會亂序。
--------------------------------------
1)set_mb(),mb(),barrier()函式追蹤到底,就是__asm__ __volatile__("":::"memory"),而這行程式碼就是記憶體屏障。
2)__asm__用於指示編譯器在此插入彙編語句
3)__volatile__用於告訴編譯器,嚴禁將此處的彙編語句與其它的語句重組合優化。即:原原本本按原來的樣子處理這這裡的彙編。
4)memory強制gcc編譯器假設RAM所有記憶體單元均被彙編指令修改,這樣cpu中的registers和cache中已快取的記憶體單元中的資料將作廢。cpu將不得不在需要的時候重新讀取記憶體中的資料。這就阻止了cpu又將registers,cache中的資料用於去優化指令,而避免去訪問記憶體。
5)"":::表示這是個空指令。barrier()不用在此插入一條序列化彙編指令。在後文將討論什麼叫序列化指令。
6)__asm__,__volatile__,memory在前面已經解釋
在linux/include/asm-i386/system.h定義:
#define mb() __asm__ __volatile__ ("lock; addl $0,0(%%esp)": : :"memory")
7)lock字首表示將後面這句彙編語句:"addl $0,0(%%esp)"作為cpu的一個記憶體屏障。
8)addl $0,0(%%esp)表示將數值0加到esp暫存器中,而該暫存器指向棧頂的記憶體單元。加上一個0,esp暫存器的數值依然不變。即這是一條無用的彙編指令。在此利用這條無價值的彙編指令來配合lock指令,在__asm__,__volatile__,memory的作用下,用作cpu的記憶體屏障。
9)set_task_state()帶有一個memory barrier,set_task_state()肯定是安全的,但 __set_task_state()可能會快些。
使用記憶體屏障強加的嚴格的CPU記憶體事件次序,保證程式的執行看上去象是遵循順序一致性模型。在當前的實現中,wmb() 實際上是一個空操作,這是因為目前Intel的CPU系列都遵循“處理機一致性”,所有的寫操作是遵循程式順序的,不會越過前面的讀寫操作。但是,由於 Intel CPU系列可能會在將來採用更弱的記憶體一致性模型並且其他體系結構可能採用其他放鬆的一致性模型,仍然在核心裡必須適當地插入wmb()保證記憶體事件的正確次序。
Linux提供以下巨集來解決所有可能的排序問題:
|
一個例子:
#define wmb() __asm__ __volatile ("sfence":::)unsigned int a = 0;unsigned int b = 1;pthread_mutex_t lock;static void* f(void* arg){ unsigned int c,d; for(;;){ c = a; //wmb(); d = b; if (c>=d) { printf("c=%x d=%x\n",c,d); return NULL; } }}static void* g(void* arg){ for(;;){ pthread_mutex_lock (&lock); b++; a++; if (b == 0x7fffffff) { b = 1; a = 0; } pthread_mutex_unlock (&lock); }}int main(int argc, const char* argv[]){ pthread_t pid1,pid2; pthread_mutex_init (&lock, NULL); if (pthread_create(&pid1, 0, f, 0)){ printf("Create thread1 error\n"); exit(-1); } if (pthread_create(&pid2, 0, g, 0)){ printf("Create thread2 error\n"); exit(-1); } while (1) sleep(1); return 0;}
上面程式碼建立了 2 個執行緒,執行緒1 在 CPU1 上執行,執行緒2 在 CPU2 上執行。如果因為超標量的關係,在執行過程中,c=a; 和 d=b; 兩條語句互換了位置,那麼得到的結果也就和預期的相反。所以為了得到正確的結果,這裡可以採用專門的彙編指令來完成這個工作,這些指令分別是:lfence, sfence, mfence ,它們的原理都是停止流水線,並保證相關操作按照順序完成。這些指令的作用如下:
lfence : 當 CPU 遇到 lfence 指令時,停止相關流水線,直到 lfence 之前對記憶體讀取操作的指令全部完成。
sfence : 當 CPU 遇到 sfence 指令時,停止相關流水線,直到 sfence 之前對記憶體進行寫入操作的指令全部完成。
mfence : 當 CPU 遇到 mfence 指令時,停止相關流水線,直到 mfence 之前對記憶體進行讀取和寫入操作的指令全部完成。
於是,像上面的程式碼中,在 c=a; 和 d=b 加入這樣的指令,這兩條語句的執行就不會亂序了。
----------------------------------------------------
在Java中,可以採用volatile來當做記憶體屏障,防止重排序的問題
1. 確保對volatile域的讀寫操作都是直接在主存內進行,不快取到執行緒的本地記憶體中。
2. 在舊的JMM中,volatile域的操作與nonvolatile域的操作之間可以重新排序。但是在JSR133以後,規定volatile操作和其他任何記憶體操作之間都不允許進行重新排序。
3. 在新的JMM下,當執行緒A寫一個volatile變數V,然後執行緒B讀取V的時侯,任何在寫入V時對執行緒A可見的變數值,都對B可見
java中,volatile 指令前面的一些記憶體操作,會不會在這個volatile相關指令執行的時候,volatile修飾的變數寫回到記憶體中的時候,那些 no-volatile變數的記憶體是否也會寫回到記憶體中,而不是保留在java的工作記憶體中。如果jvm、jit也像上面內容一樣的插入諸如mb(),rmb()等相關的記憶體屏障指令的話,那麼no-volatile變數,也是會寫回到主記憶體中。具體的細節,只能看相關的實現了。