全面掃盲:獨家解讀.NET Core中“分層JIT編譯”的內部結構
.NET運行時(CLR)主要使用JIT編譯器將可執行檔轉換為機器代碼(暫時擱置AOT編譯的場景),正如微軟公司的官方文檔所描述:在執行時,JIT編譯器將MSIL (微軟中間語言)轉換為本地代碼。在編譯期間,
在JIT編譯時,要考慮到在執行過程中可能永遠不會調用某些代碼的可能性。而不是使用時間和記憶體來轉換所有的MSIL PE檔中的本地代碼,而是在執行過程中根據需要轉換MSIL,並將生成的本地代碼存儲在記憶體中,以便在該進程的場景中進行後續調用。
這真的很簡單。但是,如果想知道更多的內容,這篇文章的其餘部分將詳細探討這個過程。
另外,人們將看到一個新特性,
它是如何工作的
但在考慮未來的計畫之前,當前的CLR如何讓JIT編譯器將一種方法從IL(中間語言)轉換為本地代碼?那麼,其實可以用視圖來表示,
該方法被JIT編譯之前:
該方法被JIT編譯之後:
需要注意的事情是:
•CLR放入了一個“預編碼”和“存根”,以便將初始方法調用轉移到PreStubWorker()方法(最終調用JIT)。這些是程式人員編寫的彙編代碼片段,只包含幾條指令。
一旦這個方法被JIT編譯成“本地代碼”,就會創建一個穩定的入口點。在CLR的剩餘生命週期中保證不會改變,所以餘下的執行時間可以保持穩定。
•“臨時入口點”不會消失,但仍然可用,
•CLR不改變JIT編譯的調用指令的位址,它只改變‘precode’中的位址。但是因為CLR中的所有方法調用都是通過預編碼進行的,所以第二次調用新的JIT編譯後的方法時,調用將以“本地代碼”結束。
作為參考,“穩定的入口點”與調用RuntimeMethodHandle.GetFunctionPointer()方法時返回的IntPtr的記憶體位置相同。
如果人們想親自看到這個過程,
最後,下面列出了所涉及的核心CLR原始程式碼的不同部分:
JIT Helpers for ‘PrecodeFixupThunk’
PrecodeFixupThunk (i386 assembly)
ThePreStub (i386 assembly)
PreStubWorker(..)
MethodDesc::DoPrestub(..)
MethodDesc::DoBackpatch(..)
MethodDesc::SetStableEntryPointInterlocked(..)
注意:這篇文章並不是要研究JIT本身是如何工作的,如果感興趣的話,可以參閱資深開發人員編寫的概述。
JIT編譯和執行引擎(EE)交互
使所有這些工作JIT和EE必須一起工作,以瞭解涉及的內容,查看這個評論,描述確定JIT編譯可以使用哪種類型的預編碼的規則。所有這些資訊都存儲在EE中,因為它是唯一一個完全瞭解某種方法的地方,所以JIT必須詢問哪種模式可以工作。
另外,JIT必須向EE詢問函數入口點的位址是什麼,這是通過以下方法完成的:
· CEEInfo::getFunctionEntryPoint(..)
o Then calls MethodDesc::TryGetMultiCallableAddrOfCode(..)
· CEEInfo::getFunctionFixedEntryPoint(..)
o Then calls MethodDesc::GetMultiCallableAddrOfCode(..)
預編碼和存根
有不同的類型或'precode'可用,'FIXUP','REMOTING'或'STUB',可以看到MethodDesc :: GetPrecodeType()中使用的規則。另外,由於它們是低級別的機制,所以它們在CPU體系結構中的實現與代碼中的注釋不同:
臨時入口點有兩個實現選項:
(1)緊湊的入口點。它們提供盡可能密集的入口點,但不能修補指向最終的代碼。未經調試的方法的調用是通過插槽進行間接調用。
(2)預編碼。預編碼將被修補以指向最終的代碼,從而可以將臨時入口點嵌入到代碼中。未被調用的方法的調用是直接調用直接跳轉。
(1)用於x86,(2)用於64位以在每個平臺上獲得最佳性能。對於ARM(1)被使用。
BOTR還提供了更多關於“預編碼”的資訊。
最後,事實證明,如果沒有遇到“stub”(或“trampolines”,“thunk”等),就不能進入CLR的內部,例如,
虛擬方法(介面)調度
跳轉存根
泛型共用
Dll導入回檔
分層編譯
在進一步討論之前,要指出的是分層編譯工作正在進行中。作為一個指示,為了讓它工作,現在必須設置一個名為COMPLUS_EXPERIMENTAL_TieredCompilation的環境變數。看來,目前的工作重點放在基礎設施上(即CLR的變化),那麼在默認啟用之前,必須進行大量的測試和性能分析。
如果想瞭解該功能的目標以及它如何適應更廣泛的“代碼版本化”流程,那麼建議閱讀一些優秀的設計文檔,包括未來的路線圖可能性。
為了說明迄今為止所涉及的情況,目前正在進行的工作有:
調試器(例如,如果在調試器連接之前,採用分層JIT編譯重新編譯該方法,並且在分層JIT編譯替代代碼時源線路中斷點停止工作,則中斷點不會被命中)
分析API - 例如分層JIT編譯:實施額外的Profiler API
診斷 - 全部通過分層JIT編譯進行跟蹤:設計/實施適當的診斷,將IL固定到ETW(Event Tracing for Windows)的本地映射
Interpreter(解譯器) - CLR有一個內置的解譯器
ReJIT的歷史
這是能夠讓CLR為用戶重新調試的一個方法,但是它只能和Profiling API一起工作,這意味著用戶必須編寫一些C/ C ++ COM代碼才能實現。另外ReJIT只允許在同一級別重新編譯該方法,所以不會產生更多的優化代碼。這主要是為了説明監視或分析工具。
它是如何工作的?
最後是如何工作,這需要查看一些圖表。首先回顧一下,一旦某個方法被JIT編譯,關閉分層編譯(與上面的圖相同),其結果將是什麼:
現在,作為比較,以下是啟用分層編譯的同一個階段:
主要區別在於分層編譯迫使方法調用通過另一個間接層次“預存”。這是為了能夠計算方法被調用的次數,然後一旦達到閾值(當前為30),“預存根”被重寫為指向“優化本地代碼”:
請注意,原始的“本地代碼”仍然可用,所以如果需要,可以恢復更改,方法調用可以返回到未優化的版本。
使用計數器
可以在prestub.cpp的這個評論中看到更多關於計數器的細節:
實質上,“存根”回檔到“分層編譯管理器”,直到“分層編譯”被觸發,一旦發生“存根”被“回補”,停止被調用。
為什麼沒有“解釋”模式?
如果人們想知道為什麼分層編譯沒有解釋模式,那麼其答案是已經採用一個解譯器,但是這不適合生產代碼嗎?這是一個很好的問題,人們猜對了,因為解譯器還不夠完善,不能運行生產代碼。如果人們希望調試和分析工具正常工作,只要有足夠的時間和精力,這一切都是可以解決的,但這並不是最容易開始的地方。
非優化和優化的JITting之間的開銷有多大的不同?
在機器上,大約65%的時間使用了非優化的jitting,優化的jitting與IL輸入大小類似,但是人們期望的結果會因工作負載和硬體而有所不同。進入第一步檢查應該會更容易收集更好的測量結果。
但是從幾個月前開始,也許Mono的新.NET解譯器會改變一些事情,誰知道呢?
為什麼不採用LLVM?
最後,為什麼不使用LLVM來編譯代碼,可以從Introduce a tiered JIT (comment)進行瞭解。
在GC(垃圾回收器)和EH(異常處理模型)中,CLR所需的LLVM支援與Java所需的LLVM支持(可能仍然存在)存在顯著差異,而且必須在優化器中加以限制。僅舉一個例子:CLR GC當前不能容忍指向物件末尾的託管指標。Java通過基類/派類生的成對報告機制處理這個問題。人們可能需要為這種成對的CLR報告提供支持,或者限制LLVM的優化器通過從不創建這些類型的指針。最重要的是,LLILC的JIT編譯速度很慢,很難確定它最終會產生什麼樣的代碼品質。
因此,弄清楚LLILC如何適應尚未存在的多層方法似乎為時尚早。現在的想法是將框架分層,並使用RyuJit作為二級JIT。隨著人們越來越多的瞭解,可能會發現確實存在更高級別的工作空間,或者至少應該更好地理解需要做些什麼才能使這些事情變得有意義。
總結
還有一個很好的副產品是微軟的.NET Open Source 和開放的work-in-progress功能。對此感興趣的人可以下載最新的代碼嘗試一下,看看它們是如何工作的。
描述確定JIT編譯可以使用哪種類型的預編碼的規則。所有這些資訊都存儲在EE中,因為它是唯一一個完全瞭解某種方法的地方,所以JIT必須詢問哪種模式可以工作。另外,JIT必須向EE詢問函數入口點的位址是什麼,這是通過以下方法完成的:
· CEEInfo::getFunctionEntryPoint(..)
o Then calls MethodDesc::TryGetMultiCallableAddrOfCode(..)
· CEEInfo::getFunctionFixedEntryPoint(..)
o Then calls MethodDesc::GetMultiCallableAddrOfCode(..)
預編碼和存根
有不同的類型或'precode'可用,'FIXUP','REMOTING'或'STUB',可以看到MethodDesc :: GetPrecodeType()中使用的規則。另外,由於它們是低級別的機制,所以它們在CPU體系結構中的實現與代碼中的注釋不同:
臨時入口點有兩個實現選項:
(1)緊湊的入口點。它們提供盡可能密集的入口點,但不能修補指向最終的代碼。未經調試的方法的調用是通過插槽進行間接調用。
(2)預編碼。預編碼將被修補以指向最終的代碼,從而可以將臨時入口點嵌入到代碼中。未被調用的方法的調用是直接調用直接跳轉。
(1)用於x86,(2)用於64位以在每個平臺上獲得最佳性能。對於ARM(1)被使用。
BOTR還提供了更多關於“預編碼”的資訊。
最後,事實證明,如果沒有遇到“stub”(或“trampolines”,“thunk”等),就不能進入CLR的內部,例如,
虛擬方法(介面)調度
跳轉存根
泛型共用
Dll導入回檔
分層編譯
在進一步討論之前,要指出的是分層編譯工作正在進行中。作為一個指示,為了讓它工作,現在必須設置一個名為COMPLUS_EXPERIMENTAL_TieredCompilation的環境變數。看來,目前的工作重點放在基礎設施上(即CLR的變化),那麼在默認啟用之前,必須進行大量的測試和性能分析。
如果想瞭解該功能的目標以及它如何適應更廣泛的“代碼版本化”流程,那麼建議閱讀一些優秀的設計文檔,包括未來的路線圖可能性。
為了說明迄今為止所涉及的情況,目前正在進行的工作有:
調試器(例如,如果在調試器連接之前,採用分層JIT編譯重新編譯該方法,並且在分層JIT編譯替代代碼時源線路中斷點停止工作,則中斷點不會被命中)
分析API - 例如分層JIT編譯:實施額外的Profiler API
診斷 - 全部通過分層JIT編譯進行跟蹤:設計/實施適當的診斷,將IL固定到ETW(Event Tracing for Windows)的本地映射
Interpreter(解譯器) - CLR有一個內置的解譯器
ReJIT的歷史
這是能夠讓CLR為用戶重新調試的一個方法,但是它只能和Profiling API一起工作,這意味著用戶必須編寫一些C/ C ++ COM代碼才能實現。另外ReJIT只允許在同一級別重新編譯該方法,所以不會產生更多的優化代碼。這主要是為了説明監視或分析工具。
它是如何工作的?
最後是如何工作,這需要查看一些圖表。首先回顧一下,一旦某個方法被JIT編譯,關閉分層編譯(與上面的圖相同),其結果將是什麼:
現在,作為比較,以下是啟用分層編譯的同一個階段:
主要區別在於分層編譯迫使方法調用通過另一個間接層次“預存”。這是為了能夠計算方法被調用的次數,然後一旦達到閾值(當前為30),“預存根”被重寫為指向“優化本地代碼”:
請注意,原始的“本地代碼”仍然可用,所以如果需要,可以恢復更改,方法調用可以返回到未優化的版本。
使用計數器
可以在prestub.cpp的這個評論中看到更多關於計數器的細節:
實質上,“存根”回檔到“分層編譯管理器”,直到“分層編譯”被觸發,一旦發生“存根”被“回補”,停止被調用。
為什麼沒有“解釋”模式?
如果人們想知道為什麼分層編譯沒有解釋模式,那麼其答案是已經採用一個解譯器,但是這不適合生產代碼嗎?這是一個很好的問題,人們猜對了,因為解譯器還不夠完善,不能運行生產代碼。如果人們希望調試和分析工具正常工作,只要有足夠的時間和精力,這一切都是可以解決的,但這並不是最容易開始的地方。
非優化和優化的JITting之間的開銷有多大的不同?
在機器上,大約65%的時間使用了非優化的jitting,優化的jitting與IL輸入大小類似,但是人們期望的結果會因工作負載和硬體而有所不同。進入第一步檢查應該會更容易收集更好的測量結果。
但是從幾個月前開始,也許Mono的新.NET解譯器會改變一些事情,誰知道呢?
為什麼不採用LLVM?
最後,為什麼不使用LLVM來編譯代碼,可以從Introduce a tiered JIT (comment)進行瞭解。
在GC(垃圾回收器)和EH(異常處理模型)中,CLR所需的LLVM支援與Java所需的LLVM支持(可能仍然存在)存在顯著差異,而且必須在優化器中加以限制。僅舉一個例子:CLR GC當前不能容忍指向物件末尾的託管指標。Java通過基類/派類生的成對報告機制處理這個問題。人們可能需要為這種成對的CLR報告提供支持,或者限制LLVM的優化器通過從不創建這些類型的指針。最重要的是,LLILC的JIT編譯速度很慢,很難確定它最終會產生什麼樣的代碼品質。
因此,弄清楚LLILC如何適應尚未存在的多層方法似乎為時尚早。現在的想法是將框架分層,並使用RyuJit作為二級JIT。隨著人們越來越多的瞭解,可能會發現確實存在更高級別的工作空間,或者至少應該更好地理解需要做些什麼才能使這些事情變得有意義。
總結
還有一個很好的副產品是微軟的.NET Open Source 和開放的work-in-progress功能。對此感興趣的人可以下載最新的代碼嘗試一下,看看它們是如何工作的。