華文網

全面掃盲:獨家解讀.NET Core中“分層JIT編譯”的內部結構

.NET運行時(CLR)主要使用JIT編譯器將可執行檔轉換為機器代碼(暫時擱置AOT編譯的場景),正如微軟公司的官方文檔所描述:在執行時,JIT編譯器將MSIL (微軟中間語言)轉換為本地代碼。在編譯期間,

代碼必須通過驗證過程,檢查MSIL和中繼資料,以確定代碼是否可以被確定為類型安全代碼。但是這個過程是如何運作的呢?

在JIT編譯時,要考慮到在執行過程中可能永遠不會調用某些代碼的可能性。而不是使用時間和記憶體來轉換所有的MSIL PE檔中的本地代碼,而是在執行過程中根據需要轉換MSIL,並將生成的本地代碼存儲在記憶體中,以便在該進程的場景中進行後續調用。

載入程式在類型被載入和初始化時創建,並附加一個存根到類型中的每個方法。當第一次調用某個方法時,存根將控制傳遞給JIT編譯器,JIT編譯器將該方法的MSIL轉換為本地代碼,並修改存根直接指向生成的本機代碼。因此,對JIT編譯方法的後續調用將直接轉到本機代碼。

這真的很簡單。但是,如果想知道更多的內容,這篇文章的其餘部分將詳細探討這個過程。

另外,人們將看到一個新特性,

它正在進入核心CLR(公共語言運行庫),稱之為“分層編譯”。這對於CLR來說是一個很大的改變,直到現在,.NET方法在第一次使用時才被編譯一次。分層編譯正在改變這種情況,允許將方法重新編譯為更加優化的版本,就像Java Hotspot編譯器一樣。

它是如何工作的

但在考慮未來的計畫之前,當前的CLR如何讓JIT編譯器將一種方法從IL(中間語言)轉換為本地代碼?那麼,其實可以用視圖來表示,

因為“一圖勝千言”。

該方法被JIT編譯之前:

該方法被JIT編譯之後:

需要注意的事情是:

•CLR放入了一個“預編碼”和“存根”,以便將初始方法調用轉移到PreStubWorker()方法(最終調用JIT)。這些是程式人員編寫的彙編代碼片段,只包含幾條指令。

一旦這個方法被JIT編譯成“本地代碼”,就會創建一個穩定的入口點。在CLR的剩餘生命週期中保證不會改變,所以餘下的執行時間可以保持穩定。

•“臨時入口點”不會消失,但仍然可用,

因為可能有其他方法可以調用它。然而,相關的“預編碼修正”已被重寫或“修補”,以指向新創建的“本地代碼”而不是PreStubWorker()。

•CLR不改變JIT編譯的調用指令的位址,它只改變‘precode’中的位址。但是因為CLR中的所有方法調用都是通過預編碼進行的,所以第二次調用新的JIT編譯後的方法時,調用將以“本地代碼”結束。

作為參考,“穩定的入口點”與調用RuntimeMethodHandle.GetFunctionPointer()方法時返回的IntPtr的記憶體位置相同。

如果人們想親自看到這個過程,

可以重新編譯CoreCLR原始程式碼,添加相關的調試資訊,或者只是使用WinDbg,並按照這篇博客文章中的步驟來做。

最後,下面列出了所涉及的核心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功能。對此感興趣的人可以下載最新的代碼嘗試一下,看看它們是如何工作的。