共用方式為


撰寫更快速的Managed程式代碼:瞭解哪些專案的成本

 

Jan Gray
Microsoft CLR 效能小組

2003年6月

適用於:
   ® Microsoft .NET Framework

摘要:本文會根據測量的作業時間,提供適用於 Managed 程式代碼運行時間的低階成本模型,讓開發人員可以做出更明智的程式代碼決策,並撰寫更快速的程式代碼。 (30頁印刷頁)

下載 CLR Profiler。 (330KB)

內容

簡介(和承諾)
針對 Managed 程式代碼的成本模型
Managed 程式代碼的成本為何
結論
資源

簡介(和承諾)

實作計算的方法有很多種,有些比其他方法更好:更簡單、更簡潔、更容易維護。 有些方式很快,有些方式很慢。

不要對世界實施緩慢和脂肪的程序代碼。 您不鄙視這類程序代碼嗎? 以符合和啟動方式執行的程序代碼? 一次鎖定 UI 幾秒的程式代碼? 將 CPU 釘選或擲出磁碟的程式代碼?

別這樣。 相反,站起來和我一起承諾:

“我保證我不會運送緩慢的程序代碼。 速度是我關心的功能。 我每天都會注意程式代碼的效能。 我會定期和有條不紊地 測量 其速度和大小。 我會學習、建置或購買我需要執行這項操作的工具。 這是我的責任。

(真的。那麼,你承諾了嗎? 適合您。

那麼,如何 您撰寫最快的、最緊密的程式代碼日和日出? 這是一個有意識地選擇節儉的方式,以偏向奢侈、膨脹的方式,一次又一次地考慮後果的問題。 任何指定的程式代碼頁面會擷取數十項這類小決策。

但是,如果您不知道哪些專案成本,您無法在替代專案之間進行明智的選擇:如果您不知道哪些專案成本,就無法撰寫有效率的程序代碼。

在美好的時代,它更容易。 好的 C 程式設計人員知道。 C 中的每個運算符和作業,無論是指派、整數或浮點數學、取值或函數調用,都會將一對一對應到單一基本機器作業。 True 是表示,有時需要數個機器指令,才能將正確的操作數放在正確的緩存器中,有時單一指令可以擷取數個 C 作業(著名的 *dest++ = *src++;),但您通常可以撰寫(或讀取)一行 C 程式代碼,並知道時間的去向。 針對程式代碼和數據,C 編譯程式是 WYWIWYG,「您所撰寫的內容就是您得到的內容」。 (例外狀況是 和 是函數調用。如果您不知道函式的成本,則不知道該怎麼做。

在 20 世紀 90 年代,為了享受數據抽象、面向物件程式設計和程式代碼重複使用的許多軟體工程和生產力優勢,計算機軟體產業已從 C 轉換到C++。

C++是 C 的超集,而且是「隨用隨付」—如果您不使用新功能,則新功能不會花費任何成本,因此 C 程式設計專業知識,包括一個內部化成本模型,直接適用。 如果您採用一些可運作的 C 程式代碼並重新編譯 C++ ,則運行時間和空間額外負荷不應該變更太多。

另一方面,C++引進許多新的語言功能,包括建構函式、解構函式、new、delete、single、multiple 和 virtual 繼承、轉換、成員函式、虛擬函式、多載運算符、成員指標、對象數位、例外狀況處理和相同組合,這會產生非微不足道的隱藏成本。 例如,虛擬函式會為每個呼叫花費兩個額外的間接成本,並將隱藏的 vtable 指標字段新增至每個實例。 或者,請考慮此無害的程序代碼:

{ complex a, b, c, d; … a = b + c * d; }

編譯為大約十三個隱含成員函式調用 (希望內嵌)。

九年前,我們在我的文章 C++中探索了這個問題:在胡德下。 我寫道:

「請務必瞭解程式設計語言的實作方式。 這種知識驅散了「編譯程式在這裡做什麼」的恐懼和奇跡?賦予使用新功能的信心;並提供偵錯和學習其他語言功能的深入解析。 它也可讓您感受到不同程式碼選擇的相對成本,這些選擇是每天撰寫最有效率的程式代碼所需的。

現在我們將探討Managed程式碼。 本文探討受控執行 低階 時間和空間成本,因此我們 可以在日常編碼中 做出更明智的取捨。

並履行我們的承諾。

為什麼是Managed程式代碼?

對於絕大多數原生程式代碼開發人員而言,受控程式代碼是執行其軟體的更好、更具生產力的平臺。 它會移除整個類別的錯誤,例如堆積損毀和陣列索引超出界限的錯誤,這通常會導致令人沮喪的深夜偵錯會話。 它支援安全行動程式代碼(透過程式代碼存取安全性)和 XML Web 服務等新式需求,相較於過時的 Win32/COM/ATL/MFC/VB,.NET Framework 是一種令人耳目一新的全新板狀設計,您可以在其中以較少的精力完成更多工作。

針對您的使用者社群,受控程式代碼可讓您更豐富、更健全的應用程式,透過更好的軟體生活。

撰寫更快速Managed程式碼的秘密為何?

只是因為您能以較少的努力完成更多工作,並不是一種授權,可以明智地放棄對程式代碼的責任。 首先,你必須承認自己:“我是一個新手。你是個新手 我也是新手 我們都是管理代碼土地的寶貝。 我們都在學習繩索, 包括什麼東西成本。

當談到豐富且方便的 .NET Framework 時,就像我們在 Candy Store 中的孩子一樣。 “哇,我不必做那些乏味 strncpy 的東西,我只能'+'字符串在一起! 哇,我可以用幾行程式代碼載入一 MB 的 XML! Whoo-hoo!

這一切都太簡單了。 確實如此簡單。 如此容易燃燒 MB 的 RAM 剖析 XML 資訊集,只是為了從其中提取一些元素。 在 C 或 C++它是如此痛苦,你會考慮兩次,也許你會在一些類似 SAX 的 API 上建置狀態機器。 使用 .NET Framework,您只要在一個 gulp 中載入整個資訊集即可。 也許你甚至做一遍又一遍。 然後,您的應用程式似乎不再這麼快。 也許它有一組許多 MB 的工作集。 也許你應該兩次思考那些容易的方法的成本...

不幸的是,在我看來,目前的 .NET Framework 檔並未充分詳細說明 Framework 類型和方法的效能影響,甚至不會指定哪些方法可能會建立新的物件。 效能模型化不是容易涵蓋或文件的主題;但是,“不知道”使我們更難做出明智的決定。

因為我們都是這裡的新手,因為我們不知道什麼成本,而且由於成本沒有清楚記載,我們該怎麼辦?

測量它。 秘密是 測量它,並 警惕。 我們都必須習慣測量事物的成本。 如果我們去衡量什麼成本的麻煩,那麼我們不會不小心呼叫一個低迷的新方法,花費我們 假設 成本的十倍的新方法。

(順便說一句,若要深入瞭解 BCL(基類連結庫)或 CLR 本身的效能基礎,請考慮看看 共用來源 CLI,也就是羅托。 旋翼程式代碼會與 .NET Framework 和 CLR 共用血緣。 這在整個程序代碼中並不一樣,但即便如此,我保證對羅托的深思熟慮的研究將給你新的見解,瞭解 CLR 的幕後發生的事情。 但請務必先檢閱 SSCLI 授權!

知識

如果你渴望成為倫敦的計程車司機,你首先必須贏得 知識。 學生學習了幾個月來,以紀念倫敦數千條小街道,並學習最好的路線從地方到地方。 他們每天都在滑板車上出去偵察,加強他們的書學習。

同樣地,如果您想要成為高效能的 Managed 程式代碼開發人員,您必須取得 Managed 程式代碼知識。 您必須瞭解每個低階作業成本。 您必須瞭解委派和程式代碼存取安全性成本等功能。 您必須瞭解您使用的類型和方法成本,以及您正在撰寫的類型和方法。 而且發現哪些方法對於您的應用程式來說可能太昂貴,這並不很傷人,因此請避免它們。

知識不在任何書裡, 唉唉。 您必須 滑板車並探索—也就是說,請啟動 csc、ildasm、VS.NET 調試程式、CLR Profiler、分析工具、一些效能定時器等等,並查看您的程式代碼在時間和空間中的成本。

針對 Managed 程式代碼的成本模型

除了初步概觀之外,讓我們考慮 Managed 程式代碼的成本模型。 如此一來,您將能夠查看分葉方法,並一目了然地指出哪些運算式和語句的成本更高:當您撰寫新程式代碼時,您將能夠做出更明智的選擇。

(這不會解決呼叫 .NET Framework 方法或方法的可轉移成本。這將不得不等待另一篇文章在另一天。

我先前指出,大部分 C 成本模型仍適用於C++案例。 同樣地,大部分的 C/C++成本模型仍然適用於 Managed 程式代碼。

那怎麼可能? 您知道 CLR 執行模型。 您可以使用數種語言之一撰寫程式代碼。 您會將其編譯為 CIL (Common Intermediate Language) 格式,並封裝成元件。 您執行主要應用程式元件,並開始執行 CIL。 但是,不像舊版的位元組程式代碼解釋器一樣,大小變慢嗎?

Just-In-Time 編譯程式

不,不是。 CLR 會使用 JIT (Just-In-Time) 編譯程式,將 CIL 中的每個方法編譯成原生 x86 程式代碼,然後執行機器碼。 雖然會先呼叫每個方法的 JIT 編譯延遲很小,但呼叫的每個方法都會執行純原生程式代碼,而沒有解譯額外負荷。

不同於傳統的離線C++編譯程式,JIT 編譯程式所花費的時間是「時鐘時間」延遲,在每個使用者的臉上,因此 JIT 編譯程式沒有詳盡優化傳遞的奢侈。 即便如此,JIT 編譯程式所執行的優化清單令人印象深刻:

  • 常數折疊
  • 常數和複製傳播
  • 一般子表達式消除
  • 迴圈不因變數的程式代碼動作
  • 無效存放區和死碼刪除
  • 緩存器配置
  • 內嵌方法
  • 循環取消卷動(具有小主體的小迴圈)

結果與傳統的機器碼相當,至少在同一個棒球場

至於數據,您將使用實值型別或參考型別的組合。 實值型別,包括整數型別、浮點類型、列舉和結構,通常存在於堆疊上。 它們和局部變數和結構在 C/C++一樣小而快速。 如同 C/C++,您應該避免將大型結構當作方法自變數或傳回值傳遞,因為複製額外負荷可能相當昂貴。

堆積中的參考型別和Boxed實值型別。 它們是由對象參考來尋址,這些參考只是計算機指標,就像 C/C++中的物件指標一樣。

因此,抖動的Managed程式代碼可能很快。 我們在下面討論的一些例外狀況下,如果您對原生 C 程序代碼中某些表達式的成本有直覺的感覺,您就不會在 Managed 程式代碼中將其成本模型化為對等專案。

我也應該提到 NGEN,這是「預先」將 CIL 編譯成機器碼元件的工具。 雖然 NGEN'ing 您的元件目前對運行時間沒有重大影響(好或壞),但它可以減少載入許多 AppDomain 和進程的共用元件的總工作集。 (OS 可以在所有客戶端之間共用 NGEN'd 程式代碼的一個複本;而吉特程式代碼通常目前不會跨 AppDomains 或進程共用。但另見 LoaderOptimizationAttribute.MultiDomain

自動記憶體管理

Managed 程式代碼最重要的離別(與原生)是自動記憶體管理。 您設定新的物件,但 CLR 垃圾收集行程 (GC) 會在無法連線時自動為您釋放它們。 GC 現在和一次又一次地執行,通常通常無法察覺地停止您的應用程式只有一兩毫秒,偶爾會更長的時間。

其他幾篇文章會討論垃圾收集行程的效能影響,我們不會在這裡重新回顧它們。 如果您的應用程式遵循這些其他文章中的建議,垃圾收集的整體成本可能微不足道,運行時間的幾百分之一,競爭或優於傳統C++物件 newdelete。 建立和稍後自動回收物件的分攤成本已足夠低,因此每秒可以建立數千萬個小型物件。

但是,物件配置仍然不會 免費。 物件佔用空間。 猖獗的物件配置會導致更頻繁的垃圾收集週期。

更糟的是,不必要地保留對無用物件圖形的參考會讓它們保持運作。 我們有時會看到具有可悲的 100+ MB 工作集的適度程式,其作者否認其罪責,而是將其效能不佳歸因於一些神秘、不明(因此難以處理)的 Managed 程式代碼本身的問題。 這是悲慘的。 但接著,使用 CLR Profiler 進行一個小時的研究,並變更為幾行程式代碼,將堆積使用量削減了 10 倍以上。 如果您遇到大型的工作集問題,第一個步驟是查看鏡像。

因此,不要不必要地建立物件。 僅僅因為自動記憶體管理會消除物件配置和釋放的許多複雜、麻煩和 Bug,因為它如此快速又方便,我們自然會傾向於建立越來越多的物件,就像它們生長在樹狀結構上一樣。 如果您想要撰寫非常快速的 Managed 程式代碼,請謹慎且適當地建立物件。

這也適用於 API 設計。 可以設計型別及其方法,使其 需要 用戶端建立具有野生放棄的新物件。 別這樣。

Managed 程式代碼的成本為何

現在讓我們考慮各種低階 Managed 程式代碼作業的時間成本。

表 1 在靜止的 1.1 GHz Pentium-III 執行 Windows XP 和 .NET Framework v1.1 (“Everett”) 的計算機上,呈現各種低階 Managed 程式代碼作業的近似成本,其採用一組簡單的計時迴圈。

測試驅動程式會呼叫每個測試方法,並指定要執行的反覆項目數目,並自動調整為逐一查看 218 和 230 反覆運算,視需要至少執行 50 毫秒的每個測試。 一般而言,這足以在測試中觀察數個週期的層代0垃圾收集,而測試會進行強烈的物件配置。 下表顯示平均超過10個試驗的結果,以及每個測試科目的最佳(最短時間)試驗。

每個測試循環會視需要取消註冊 4 到 64 次,以降低測試循環的額外負荷。 我檢查了針對每個測試產生的原生程序代碼,以確保 JIT 編譯程式未將測試優化,例如,在數個案例中,我修改了測試,讓中繼結果在測試迴圈期間和之後保持即時。 同樣地,我做了一些變更,以排除數個測試中的常見子表達式消除。

表 1 基本時間 (平均值和最小值) (ns)

Avg 最小值 原始 Avg 最小值 原始 Avg 最小值 原始
0.0 0.0 控制 2.6 2.6 新的 valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Int add 4.6 4.6 新的 valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Int 子 6.4 6.4 新的 valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 新的 valtype L4 10.7 10.6 伊辛斯特 (上升 2) 下降 1
35.9 35.7 Int div 23.0 22.9 新的 valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int shift 22.0 20.3 new reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26.1 23.9 new reftype L2 1.0 1.0 get 欄位
2.1 2.1 long 子 30.2 27.5 new reftype L3 1.2 1.2 get prop
34.2 34.1 長粽 34.1 30.8 new reftype L4 1.2 1.2 set 欄位
50.1 50.0 long div 39.1 34.4 new reftype L5 1.2 1.2 set prop
5.1 5.1 長班 22.3 20.3 new reftype empty ctor L1 0.9 0.9 取得此欄位
1.3 1.3 float add 26.5 23.9 new reftype empty ctor L2 0.9 0.9 取得此道具
1.4 1.4 float 子 38.1 34.7 new reftype empty ctor L3 1.2 1.2 設定此欄位
2.0 2.0 float mul 34.7 30.7 new reftype empty ctor L4 1.2 1.2 設定此道具
27.7 27.6 float div 38.5 34.3 new reftype empty ctor L5 6.4 6.3 取得虛擬道具
1.5 1.5 double add 22.9 20.7 new reftype ctor L1 6.4 6.3 設定虛擬道具
1.5 1.5 double 子 27.8 25.4 new reftype ctor L2 6.4 6.4 寫入屏障
2.1 2.0 雙穆 32.7 29.9 new reftype ctor L3 1.9 1.9 載入 int 陣列 elem
27.7 27.6 雙 div 37.7 34.1 new reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 內嵌靜態呼叫 43.2 39.1 new reftype ctor L5 2.5 2.5 載入 obj 陣列 elem
6.1 6.1 靜態呼叫 28.6 26.7 新的 reftype ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 內嵌實例呼叫 38.9 36.5 new reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 實例呼叫 50.6 47.7 新的 reftype ctor no-inl L3 3.0 3.0 unbox int
0.2 0.2 內嵌此內嵌呼叫 61.8 58.2 新的 reftype ctor no-inl L4 41.1 40.9 委派叫用
6.2 6.2 這個實例呼叫 72.6 68.5 新的 reftype ctor no-inl L5 2.7 2.7 sum 陣列 1000
5.4 5.4 虛擬呼叫 0.4 0.4 轉型 1 2.8 2.8 sum 陣列 10000
5.4 5.4 此虛擬呼叫 0.3 0.3 轉型為 0 2.9 2.8 sum 陣列 100000
6.6 6.5 介面呼叫 8.9 8.8 轉型 1 5.6 5.6 sum 陣列 1000000
1.1 1.0 inst itf 實例呼叫 9.8 9.7 投 (上升 2) 下降 1 3.5 3.5 sum list 1000
0.2 0.2 這個itf實例呼叫 8.9 8.8 轉型 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf 虛擬呼叫 8.7 8.6 轉型 3 22.0 22.0 sum list 100000
5.4 5.4 此itf虛擬呼叫       21.5 21.4 sum list 1000000

免責聲明:請勿以字面方式接受此數據。 時間測試充滿了非預期的第二訂單效果的危險。 發生機率可能會放置已抖動的程式代碼或某些重要數據,使其跨越快取行、干擾其他專案或您擁有的專案。 這有點像不確定性原則:時間與時間差異 1 奈秒左右是可觀察的極限。

另一個免責聲明:此數據僅適用於完全放入快取中的小型程式代碼和數據案例。 如果應用程式的「熱門」部分不適合在晶片上的快取中,您很可能會有一組不同的效能挑戰。 關於接近紙張結尾的快取,我們還有更多話要說。

還有另一個免責聲明:將元件和應用程式作為 CIL 元件的其中一個崇高優點是,您的程式可以每秒自動更快,而且每年都會更快—因為運行時間可以(理論上)重新調整 JIT 編譯的程式代碼,因為運行時間可以在程式執行時重新調整 JIT 編譯的程式代碼:和「較快的一年」,因為每次新版本的運行時間、更好、更聰明、更快速的演算法,都可以在優化您的程式碼時採取全新的 Stab。 因此,如果其中一些時間在 .NET 1.1 中看起來不太理想,請心中指出它們應該在產品的後續版本中有所改善。 之後,本文所報告的任何指定程式代碼機器碼序列可能會在未來 .NET Framework 版本中變更。

除了免責聲明之外,數據確實為各種基本類型的目前效能提供合理的直覺感覺。 這些數位很合理,並證實我的判斷提示,即大多數被截斷的Managed程式碼會執行「接近機器」,就像編譯的機器碼一樣。 基本整數和浮點運算速度很快,不同種類的方法呼叫較少,但(信任我)仍然與原生 C/C++相當:然而,我們也看到,原生程式代碼(轉換、陣列和欄位存放區、函式指標(委派)中通常便宜的一些作業現在成本更高。 為什麼? 我看看。

算術運算

表 2 算術運算時間 (ns)

Avg 最小值 原始 Avg 最小值 原始
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int 子 1.4 1.4 float 子
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long 子 1.5 1.5 double 子
34.2 34.1 長粽 2.1 2.0 雙穆
50.1 50.0 long div 27.7 27.6 雙 div
5.1 5.1 長班      

在舊時代,浮點數數學可能比整數數學慢一個程度。 如表 2 所示,使用新式管線浮點單位,它似乎幾乎沒有差異。 認為平均筆記本計算機現在是千兆瓦級計算機(適用於快取中的問題),這令人吃驚。

讓我們看看整數和浮點新增測試中的一行抖動程序代碼:

反組譯碼 1 Int add 和 float add

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

在這裡,我們看到已吉特的程序代碼接近最佳狀態。 在 int add 案例中,編譯程式甚至會登錄五個局部變數。 在 float add 案例中,我必須透過 h 類別靜態來使變數 a,以擊敗常見的子表達式消除。

方法呼叫

在本節中,我們會檢查方法呼叫的成本和實作。 測試主體是一個類別,T 實作介面 I,並具有各種方法。 請參閱清單 1。

列出 1 方法呼叫測試方法

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

請考慮表 3。 它 出現在第一個近似值中,方法要麼是內嵌的(抽象成本不算任何)或否(抽象成本 >5X 整數運算)。 靜態呼叫、實例呼叫、虛擬呼叫或介面呼叫的原始成本似乎沒有顯著差異。

表 3 方法呼叫時間 (ns)

Avg 最小值 原始 被呼叫者 Avg 最小值 原始 被呼叫者
0.2 0.2 內嵌靜態呼叫 inl_s1 5.4 5.4 虛擬呼叫 v1
6.1 6.1 靜態呼叫 s1 5.4 5.4 此虛擬呼叫 v1
1.1 1.0 內嵌實例呼叫 inl_i1 6.6 6.5 介面呼叫 itf1
6.8 6.8 實例呼叫 i1 1.1 1.0 inst itf 實例呼叫 itf1
0.2 0.2 內嵌此內嵌呼叫 inl_i1 0.2 0.2 這個itf實例呼叫 itf1
6.2 6.2 這個實例呼叫 i1 5.4 5.4 inst itf 虛擬呼叫 itf5
        5.4 5.4 此itf虛擬呼叫 itf5

不過,這些結果是無代表性 最佳案例,執行緊密計時迴圈的效果數百萬次。 在這些測試案例中,虛擬和介面方法呼叫月臺是單型的(例如每個呼叫月臺,目標方法不會隨著時間變更),因此快取虛擬方法和介面方法分派機制的組合(方法數據表和介面對應指標和專案),以及壯觀的公積分支預測,可讓處理器透過這些不切實際有效的工作呼叫,否則難以預測, 數據相依分支。 在實務上,任何分派機制數據的數據快取遺漏,或分支誤判(無論是強制容量遺漏或多型呼叫網站),都可以並降低數十個迴圈的虛擬和介面呼叫速度。

讓我們進一步瞭解每個方法呼叫時間。

在第一個案例中,內嵌靜態呼叫, 我們呼叫一系列的空白靜態方法 s1_inl() 等等。由於編譯程式完全內嵌所有呼叫,因此我們最終會計時空迴圈。

為了測量靜態方法呼叫的近似成本,我們會讓靜態方法 等,因此它們無法內嵌到呼叫端。

請注意,我們甚至必須使用明確的 false 述詞變數 falsePred。 如果我們寫道

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

JIT 編譯程式會消除對 dummy 的無效呼叫,並像以前一樣內嵌整個 (現在空白) 方法主體。 順便說一句,這裡的某些 6.1 ns 呼叫時間必須歸因於 (false) 述詞測試,並在呼叫的靜態方法內跳躍 s1。 (順便說一句,停用內嵌的更好方法是 CompilerServices.MethodImpl(MethodImplOptions.NoInlining) 屬性。

內嵌實例呼叫和一般實例呼叫計時使用相同的方法。 不過,由於 C# 語言規格可確保 Null 物件參考上的任何呼叫都會擲回 NullReferenceException,因此每個呼叫月臺都必須確保實例不是 Null。 這是藉由取值實例參考來完成;如果 為 null,則會產生轉換成此例外狀況的錯誤。

在反組譯碼 2 中,我們使用靜態變數 t 作為實例,因為當我們使用局部變數時

    T t = new T();

編譯程式從迴圈取出 Null 實例。

反組譯碼 2 實例方法呼叫月臺,具有 null 實例 “check”

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

內嵌此實例呼叫的案例,而且 這個實例呼叫 相同,但實例 this除外;這裡已略過 Null 檢查。

反組譯碼 3 這個實例方法會呼叫月臺

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Virtual 方法呼叫 的運作方式就像傳統C++實作一樣。 每個新引進虛擬方法的位址會儲存在類型方法數據表的新位置內。 每個衍生型別的方法數據表都符合並擴充其基底類型,而且任何虛擬方法覆寫都會以衍生類型之虛擬方法位址取代基底類型的虛擬方法位址,並取代衍生類型之方法數據表中對應位置中的虛擬方法位址。

在呼叫月臺,虛擬方法呼叫與實例呼叫相比會產生兩個額外的負載,一個是擷取方法數據表位址(一律在 *(this+0)找到),另一個則從方法數據表擷取適當的虛擬方法位址並加以呼叫。 請參閱反組譯碼 4。

反組譯碼 4 虛擬方法呼叫月臺

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

最後,我們來 介面方法呼叫(反組譯碼 5)。 這些在C++中沒有完全相同的對等專案。 任何指定的類型可以實作任意數目的介面,而且每個介面在邏輯上都需要自己的方法數據表。 為了分派介面方法,我們會查閱方法數據表、其介面對應、該對應中的介面專案,然後透過方法數據表之介面區段中的適當專案呼叫間接專案。

反組譯碼 5 介面方法呼叫月臺

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

基本時間的其餘部分,itf實例呼叫這個itf實例呼叫inst itf 虛擬呼叫這個itf虛擬呼叫 強調每當衍生類型的方法實作介面方法時,它仍可透過實例方法呼叫月臺呼叫。

例如,針對測試 這個itf實例會呼叫,透過實例(非介面)參考對接口方法實作的呼叫,介面方法已成功內嵌,成本會移至0 ns。 當您將介面方法實作呼叫為實例方法時,甚至可能內嵌。

尚未進行 Jitted 之方法的呼叫

針對靜態和實例方法呼叫(但不是虛擬和介面方法呼叫),JIT 編譯程式目前會產生不同的方法呼叫順序,取決於目標方法是否在呼叫月臺進行抖動時已經進行抖動。

如果被呼叫者(目標方法)尚未進行抖動,編譯程式會透過指標間接發出呼叫,而指標會先以 “prejit stub” 初始化。 目標方法的第一個呼叫會到達存根,這會觸發方法的 JIT 編譯、產生機器碼,以及更新指標以尋址新的原生程序代碼。

如果被呼叫者已經進行抖動,則已知其原生程式代碼位址,因此編譯程式會發出對其的直接呼叫。

新物件建立

新的物件建立包含兩個階段:物件配置和物件初始化。

針對參考型別,物件會配置在垃圾收集堆積上。 對於實值型別,無論是堆棧駐地或內嵌在另一個參考或實值型別內,實值型別物件都會在封入結構的一些常數位移中找到,不需要配置。

對於典型的小型參考型別物件,堆積配置非常快速。 在每次垃圾收集之後,除了存在釘選的物件之外,來自層代 0 堆積的即時物件會壓縮並升階為第 1 代,因此記憶體配置器有一個漂亮的大型連續可用記憶體競技場來處理。 大部分的物件配置只會產生指標遞增和界限檢查,這比一般 C/C++免費清單配置器(malloc/operator new)便宜。 垃圾收集行程甚至會考慮您計算機的快取大小,以嘗試將 gen 0 物件保留在快取/記憶體階層的快速甜蜜點。

由於慣用的 Managed 程式代碼樣式是配置大部分存留期較短的物件,並快速回收它們,因此我們也包含這些新物件的垃圾收集的攤銷成本。

請注意,垃圾收集行程不會花時間哀悼死去的物件。 如果對象已經死了,GC 就不會看到它,不走它,也不會給它一個納米秒的想法。 GC 只關心生活福利。

(例外狀況:可完成的死物件是特殊案例。GC 會追蹤這些物件,並特別將死亡的可完成物件提升到下一代擱置的完成。這很昂貴,在最壞的情況下,可以可轉移地升階大型死物件圖形。因此,除非絕對必要,否則請勿讓物件完成;如果您必須考慮使用 Dispose Pattern,請盡可能呼叫 GC.SuppressFinalizer。除非您 Finalize 方法需要,否則請勿保存可完成物件與其他對象的參考。

當然,大型短期物件的分攤 GC 成本大於小型短期物件的成本。 每個物件配置都會讓我們更接近下一個垃圾收集週期:較大的物件會這麼做,讓小型物件更快。 遲早(或晚了),重新計算的時刻將到來。 GC 迴圈,特別是層代 0 集合,非常快速,但不是免費的,即使絕大多數的新物件都無效:若要尋找(標記)實時物件,首先必須暫停線程,然後逐步執行堆棧和其他數據結構,以收集根對象參考到堆積。

(也許更明顯,較大的物件符合與較小物件相同的快取數量。快取遺漏效果很容易主宰程式代碼路徑長度效果。

配置對象的空間之後,它仍會維持初始化它(建構它)。 CLR 保證所有對象參考都會預先初始化為 null,而且所有基本純量類型都會初始化為 0、0.0、false 等。因此,在使用者定義的建構函式中,不需要重複執行此動作。當然,請放心。但請注意,JIT 編譯程式目前不一定會將備援存放區優化。

除了將實例欄位清零之外,CLR 也會初始化對象的內部實作欄位:方法數據表指標和對象標頭字,其前面是方法數據表指標。 陣列也會取得 Length 字段,而對象陣列會取得 Length 和元素類型欄位。

然後 CLR 會呼叫物件的建構函式,如果有的話。 不論使用者定義還是編譯程式產生的每個類型建構函式,都會先呼叫其基底型別的建構函式,然後執行使用者定義的初始化,如果有的話。

理論上,對於深層繼承案例而言,這可能很昂貴。 如果 E 擴充 D 擴充 C 擴充 B 擴充 A (extends System.Object),則初始化 E 一律會產生五個方法呼叫。 實際上,事情並不那麼糟糕,因為編譯程式會內嵌在空基底類型建構函式時呼叫無所事事。

參考表 4 的第一個數據行,觀察我們可以在大約 8 個 int-add-times 中建立和初始化具有四個 int 字段的結構 D。 反組譯碼 6 是從三個不同的計時循環產生的程式代碼,建立 A、C 和 E' 。 (在每個迴圈中,我們修改每個新的實例,讓 JIT 編譯程式無法優化所有專案。

表 4 值和參考型別物件建立時間 (ns)

Avg 最小值 原始 Avg 最小值 原始 Avg 最小值 原始
2.6 2.6 新的 valtype L1 22.0 20.3 new reftype L1 22.9 20.7 new rt ctor L1
4.6 4.6 新的 valtype L2 26.1 23.9 new reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 新的 valtype L3 30.2 27.5 new reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 新的 valtype L4 34.1 30.8 new reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 新的 valtype L5 39.1 34.4 new reftype L5 43.2 39.1 new rt ctor L5
      22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

反組譯碼 6 實值型別物件建構

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

接下來的五次時間 (新的 reftype L1, ...新的 reftype L5) 適用於五個參考型別的繼承層級, A、...、E、sans 使用者定義建構函式:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

比較參考型別時間與實值型別時間,我們看到每個實例的攤銷配置和釋放成本大約是測試計算機上的 20 ns(20X int 加時)。 速度很快—持續配置、初始化和回收大約 5000 萬個短期物件。 對於大小為五個字段的物件,配置和集合只佔物件建立時間的一半。 請參閱反組譯碼 7。

反組譯碼 7 參考型別物件建構

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

最後三組五次計時會顯示此繼承類別建構案例的變化。

  1. 新的 rt 空白 ctor L1,...,新的 rt 空白 ctor L5: 每個類型 A...,E 具有空的使用者定義建構函式。 這些全都內嵌在內,產生的程式代碼與上述程式代碼相同。

  2. 新的 rt ctor L1, ..., new rt ctor L5: 每個類型 A, ..., E 具有使用者定義的建構函式,其實例變數設定為 1:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

編譯程式會將每個巢狀基類建構函式呼叫內嵌至 new 月臺。 (反組譯碼 8)。

反組譯碼 8 深層內嵌的建構函式

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. 新的 rt no-inl L1,...,新的 rt no-inl L5: 每個類型 A...,...,E 具有使用者定義的建構函式,已刻意寫入太貴,無法內嵌。 此案例會模擬使用深層繼承階層和鬆散建構函式建立複雜物件的成本。

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

表 4 中的最後五次計時會顯示呼叫巢狀基底建構函式的額外額外負荷。

插曲:CLR 分析工具示範

現在,如需 CLR Profiler 的快速示範。 CLR Profiler 先前稱為「配置分析工具」,會使用CLR分析 API,在應用程式執行時,收集事件數據,特別是呼叫、傳回和物件配置和垃圾收集事件。 (CLR Profiler 是「侵入性」分析工具,這表示很不幸地使已分析的應用程式變慢。收集事件之後,您可以使用 CLR Profiler 來探索應用程式的記憶體配置和 GC 行為,包括階層式呼叫圖形與記憶體配置模式之間的互動。

CLR Profiler 值得學習,因為對於許多「效能挑戰」的 Managed 程式代碼應用程式,瞭解您的數據配置配置檔可提供減少工作集的重要見解,因此提供快速節儉的元件和應用程式。

CLR Profiler 也可以顯示哪些方法配置比預期更多的記憶體,而且可以發現您無意中保留對無用物件圖形的參考,否則由 GC 回收。 (常見的問題設計模式是軟體快取或查閱表格,這些專案已不再需要,或稍後可安全地重新建構。當快取讓物件圖形活著超過其有用生活時,這是悲慘的。相反地,請務必為不再需要的對象參考 Null。

圖 1 是計時測試驅動程式執行期間堆積的時間軸檢視。 鋸齒圖樣表示配置數千個對象實例 C(洋紅)、D(紫色),以及 E(藍色)。 每隔幾毫秒,我們會在新的物件(層代 0) 堆積中咀嚼另一個 ~150 KB 的 RAM,垃圾收集行程會短暫地執行以回收它,並將任何實時物件升階為 gen 1。 值得注意的是,即使在這種侵入性(緩慢)分析環境中,在間隔100毫秒(2.8秒到2.9秒),我們經歷了約8代0 GC 迴圈。 然後,在 2.977 秒,為另一個 E 實例騰出空間,垃圾收集行程會執行第 1 代垃圾收集,這會從較低的起始位址收集並壓縮 gen 1 堆積,因此鋸齒會繼續。

圖 1 CLR 分析工具時間線檢視

請注意,物件愈大(E 大於 D 大於 C),第 0 代堆積填滿的速度愈快,GC 迴圈愈頻繁。

轉換和實例類型檢查

安全、安全、可驗證 管理代碼的基礎是類型安全。 如果可以將對象轉換成不是的型別,就能直接危害 CLR 的完整性,因此在不受信任的程式代碼的憐悯下加以處理。

表 5 轉換和 isinst Times (ns)

Avg 最小值 原始 Avg 最小值 原始
0.4 0.4 轉型 1 0.8 0.8 isinst up 1
0.3 0.3 轉型為 0 0.8 0.8 isinst down 0
8.9 8.8 轉型 1 6.3 6.3 isinst down 1
9.8 9.7 投 (上升 2) 下降 1 10.7 10.6 伊辛斯特 (上升 2) 下降 1
8.9 8.8 轉型 2 6.4 6.4 isinst down 2
8.7 8.6 轉型 3 6.1 6.1 isinst down 3

表 5 顯示這些強制類型檢查的額外負荷。 從衍生型別轉換成基底型別的轉換一律是安全的,而且是免費的;而從基底型別轉換成衍生型別的轉換必須經過型別檢查。

(已核取的) 轉換會將物件參考轉換成目標型別,或擲回 InvalidCastException

相反地,isinst CIL 指令是用來實作 C# as 關鍵詞:

bac = ac as B;

如果 ac 不是 B 或衍生自 B,則結果會 null,而不是例外狀況。

清單 2 示範其中一個轉換計時迴圈,反組譯碼 9 會顯示一個轉換成衍生類型的產生的程序代碼。 若要執行轉換,編譯程式會發出對協助程式例程的直接呼叫。

列出 2 循環以測試轉換計時

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

反組譯碼 9 向下轉換

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

性能

在 Managed 程式代碼中,屬性是一對方法、屬性 getter 和屬性 setter,其作用就像物件的欄位。 get_ 方法會擷取 屬性;set_ 方法會將 屬性更新為新的值。

除此之外,屬性的行為和成本,就像一般實例方法和虛擬方法一樣。 如果您使用 屬性來只擷取或儲存實例欄位,通常會內嵌,就像任何小型方法一樣。

表 6 顯示擷取(並新增)和儲存一組整數實例欄位和屬性所需的時間。 取得或設定屬性的成本確實與直接存取基礎欄位相同,除非 屬性宣告為虛擬,在此情況下,成本大約是虛擬方法呼叫的成本。 這並不奇怪。

表 6 字段和屬性時間 (ns)

Avg 最小值 原始
1.0 1.0 get 欄位
1.2 1.2 get prop
1.2 1.2 set 欄位
1.2 1.2 set prop
6.4 6.3 取得虛擬道具
6.4 6.3 設定虛擬道具

寫入屏障

CLR 垃圾收集行程充分利用了「世代假設」——大多數新物件都死於年輕,以將收集額外負荷降到最低。

堆積會以邏輯方式分割成世代。 最新的物件存在於層代 0 (gen 0) 中。 這些物件尚未在集合中存留。 在 Gen 0 集合期間,GC 會決定從 GC 根集連線到哪一個 gen 0 物件,其中包括計算機快取器中的物件參考、堆疊上的類別靜態欄位物件參考等等。可轉移的可觸達物件為「即時」,並升階為「複製」至第 1 代。

因為堆積大小總計可能是數百 MB,而 gen 0 堆積大小可能只有 256 KB,因此將 GC 物件圖形追蹤的範圍限制為 gen 0 堆積,對於達到 CLR 非常短暫的收集暫停時間而言,這是一項優化。

不過,可以將第 0 代物件的參考儲存在 gen 1 或 gen 2 對象的物件參考欄位中。 由於我們不會在 gen 0 集合期間掃描 gen 1 或 gen 2 物件,如果這是指定第 0 代物件的唯一參考,則 GC 可能會錯誤地回收該物件。 我們不能讓這種情況發生!

相反地,堆積中所有物件參考欄位的所有存放區都會產生 寫入屏障。 這是記事程序代碼,可有效率地將新一代對象參考儲存到較舊世代物件的欄位中。 這類舊的對象參考欄位會新增至後續 GC 的 GC 根集。

per-object-reference-field-store 寫入屏障額外負荷相當於簡單方法呼叫的成本(表 7)。 這是原生 C/C++程式代碼中不存在的新費用,但通常要支付超快速物件配置和 GC 的小型價格,以及自動記憶體管理的許多生產力優點。

表 7 寫入屏障時間 (ns)

Avg 最小值 原始
6.4 6.4 寫入屏障

寫入屏障在緊密的內部迴圈中可能很昂貴。 但在未來幾年內,我們可以期待進階編譯技術,以減少所採用的寫入障礙數目和總攤銷成本。

您可能會認為只有在存放區上才需要寫入屏障,才能使用參考類型的對象參考字段。 不過,在實值型別方法中,會儲存至其對象參考欄位(如果有的話),也會受到寫入屏障的保護。 這是必要的,因為實值型別本身有時可能會內嵌在位於堆積中的參考型別內。

Array 元素存取

若要診斷和排除陣列超出界限的錯誤和堆積損毀,以及保護 CLR 本身的完整性,會檢查陣列元素載入和存放區,確保索引在間隔 [0,array] 內。Length-1] 內含或擲回 IndexOutOfRangeException

我們的測試會測量載入或儲存 int[] 陣列和 A[] 陣列元素的時間。 (表8)。

表格 8 陣組存取時間 (ns)

Avg 最小值 原始
1.9 1.9 載入 int 陣列 elem
1.9 1.9 store int array elem
2.5 2.5 載入 obj 陣列 elem
16.0 16.0 store obj array elem

界限檢查需要比較陣列索引與隱含陣列。[長度] 欄位。 如反組譯碼 10 所示,在兩個指令中,我們只會檢查索引不小於 0,也不大於或等於陣列。Length—如果是,我們會分支至擲回例外狀況的行外序列。 對於物件陣列元素的載入,以及儲存到ints和其他簡單實值類型的陣列中,也是如此。 (Load obj array elem time is (微不足道) 慢, 因為其內部迴圈稍有差異。

反組譯碼 10 載入 int 陣列元素

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

透過其程式代碼品質優化,JIT 編譯程式通常會消除多餘的界限檢查。

回想先前各節,我們可以預期 對象陣列元素存放區 成本要高得多。 若要將物件參考儲存到物件參考數位中,運行時間必須:

  1. 檢查陣列索引在界限中;
  2. check 對像是陣列項目類型的實例;
  3. 執行寫入屏障(指出從陣列到物件的任何代際物件參考)。

此程式代碼序列相當長。 編譯程式不會在每個物件陣列存放區月臺發出它,而是發出對共用協助程式函式的呼叫,如反組譯碼 11 所示。 此呼叫加上這三個動作會在此案例中需要額外的時間。

反組譯碼 11 Store 物件陣列元素

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Boxing 和 Unboxing

.NET 編譯程式和CLR之間的合作關係可讓實值型別,包括int (System.Int32) 等基本型別,以像參考型別一樣參與,以對象參考的形式尋址。 這個能供性—這個語法糖—允許實值型別以物件的形式傳遞至方法,以物件的形式儲存在集合中等。

若要「box」,實值類型是建立保存其實值型別複本的參考型別物件。 這在概念上與建立具有與實值類型相同類型之未命名實例字段的類別相同。

若要「unbox」,Boxed 實值類型是從 物件將值複製到實值型別的新實例。

如表 9 所示(與表 4 相比),將 int Box a int 盒裝的攤銷時間,稍後再進行垃圾收集,相當於具現化具有一個 int 欄位之小型類別所需的時間。

表格 9 Box and Unbox int Times (ns)

Avg 最小值 原始
29.0 21.6 box int
3.0 3.0 unbox int

若要將 Boxed int 物件取消收件匣,需要明確轉換成 int。這會編譯成物件的型別比較(由其方法數據表位址表示),以及 Boxed int 方法數據表位址。 如果相等,值會從 物件複製出來。 否則會擲回例外狀況。 請參閱反組譯碼 12。

反組譯碼 12 Box 和 unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

代表

在 C 中,函式的指標是可實際儲存函式位址的基本數據類型。

C++將指標新增至成員函式。 成員函式的指標 (PMF) 代表延遲的成員函式調用。 非虛擬成員函式的位址可能是簡單的程式代碼位址,但虛擬成員函式的位址必須體現特定的虛擬成員函式呼叫—這類 PMF 的取值 虛擬函式呼叫。

若要取值C++ PMF,您必須提供實例:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

幾年前,在Visual C++ 編譯程式開發小組上,我們過去常問自己,什麼樣的野獸是裸體表達式 pa->*pmf(sans 函數調用運算符)? 我們將其稱為成員函式 系結指標,但 延遲成員函式調用 就如同apt一樣。

返回 Managed 程式代碼登陸時,委派物件就是一個潛伏的方法呼叫。 委派物件代表要呼叫的方法,以及要呼叫它的實例,或委派至靜態方法,而只是要呼叫的靜態方法。

(如我們的檔所述:委派宣告會定義參考型別,可用來封裝具有特定簽章的方法。委派實例會封裝靜態或實例方法。委派大致類似於C++中的函式指標;不過,委派是類型安全且安全。)

C# 中的委派類型是 MulticastDelegate 的衍生類型。 此類型提供豐富的語意,包括能夠建置叫用委派時要叫用的 (object,method) 配對調用清單。

委派也提供異步方法調用的設施。 定義委派類型並具現化一個,使用晚期方法呼叫初始化之後,您可以透過 BeginInvoke以同步方式叫用它(方法呼叫語法)或以異步方式叫用它。 如果呼叫 BeginInvoke,運行時間會將呼叫排入佇列,並立即傳回給呼叫者。 稍後會在線程集區線程上呼叫目標方法。

所有這些豐富的語意並不便宜。 比較表 10 和表 3,請注意委派叫用比方法呼叫慢約 8 倍。 預期會隨著時間改善。

表 10 委派叫用時間 (ns)

Avg 最小值 原始
41.1 40.9 委派叫用

快取遺漏、頁面錯誤和計算機架構的

回到“好日子”,大約1983年,處理器速度很慢(~50萬個指示/秒),相對而言,RAM 的速度夠快,但很小(大約 300 ns 的 DRAM 訪問時間),磁碟的速度很慢和大(大約 25 毫秒的存取時間在 10 MB 磁碟上)。 PC 微控制器是純量 CISC,大多數浮點都在軟體中,而且沒有快取。

經過 2003 年摩爾法,2003 年之後,處理器 快速(每個週期最多發出三個作業,3 GHz),RAM 相對緩慢(512 MB 的 100 ns 存取時間),磁碟 緩慢,巨大的 (約 10 毫秒的存取時間在 100 GB 磁碟上)。 PC 微控制器現在已順序錯亂的數據流超線程超線程追蹤快取 RISC(執行譯碼的 CISC 指令),而且有數層快取,例如,特定伺服器導向的微控制器有 32 KB 層級 1 的數據快取(也許 2 個延遲週期)、512 KB L2 數據快取和 2 MB L3 數據快取(或許有十幾個延遲週期), 全部放在晶片上。

在良好的舊時代,您可以計算您撰寫的程式代碼位元組,並計算執行所需的程式代碼週期數目。 載入或存放區花費的週期數目大約與新增相同。 新式處理器會使用跨多個函式單位的分支預測、猜測和順序失序(數據流)執行,以尋找指令層級平行處理原則,因此一次在數個方面取得進展。

現在,我們最快的計算機最多可以發出 ~9000 毫秒的作業,但在相同的微秒中,只有載入或儲存到 DRAM ~10 快取行。 在計算機架構圈中,這稱為 擊中記憶體。 快取會隱藏記憶體延遲,但只隱藏到某個點。 如果程式代碼或數據不適合快取,且/或表現出參考位置不佳,我們的9000運算每微秒超音速噴氣機會變質為10毫秒三輪負載。

(不要讓這種情況發生在您身上), 如果一個程式的工作集超過可用的實體 RAM,而程式開始採取硬式分頁錯誤,然後在每 10,000 毫秒的頁面錯誤服務(磁碟存取),我們錯失了機會,讓用戶能夠 9000 萬個 作業更接近他們的答案。 這太可怕了,我相信,從今天起,您將小心測量您的工作集(vadump),並使用 CLR Profiler 之類的工具來消除不必要的配置和不小心的物件圖形保留。

但是,這一切與瞭解 Managed 程式代碼基本類型的成本有何關係?所有專案*.*

回想表 1,受控程式代碼基本時間的全能清單,以 1.1 GHz P-III 測量,觀察每一次,即使是配置、初始化和回收具有五個明確建構函式呼叫層級的五個字段物件,比單一 DRAM 存取更快。 只有一個遺漏晶元上快取層級的負載可能需要比幾乎任何單一 Managed 程式代碼作業更久的服務。

因此,如果您對程式代碼的速度充滿熱情,請務必考慮在設計和實作演算法和數據結構時,考慮 和測量快取/記憶體階層

簡單示範的時間:要加快加總 ints 陣列的速度,還是加總對等的連結 ints 清單? 哪一個,這麼多,為什麼?

想想一分鐘。 對於 ints 之類的小型專案,每個數位元素的記憶體使用量是連結清單的四分之一。 (每個鏈接的清單節點都有兩個字的物件額外負荷和兩個單字欄位(下一個連結和 int 專案)。 這將會損害快取使用率。 為陣列方法評分一。

但是陣列周遊可能會為每個項目產生數位界限檢查。 您剛剛看到界限檢查需要一點時間。 也許這可提示縮放比例偏向鏈接清單?

反組譯碼 13 總和 int 陣列與總和連結清單

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

在談到反組譯碼 13 時,我已經堆疊了甲板,支援連結清單周遊,將它取消捲動四次,甚至移除通常的 Null 指標清單結尾檢查。 數位迴圈中的每個專案都需要六個指示,而連結清單迴圈中的每個專案只需要 11/4 = 2.75 指令。 現在,您認為哪一個速度較快?

測試條件:首先,建立一百萬個整數的陣列,以及一個簡單且傳統的連結清單,其中一百萬個 ints(1 M 個列表節點)。 然後,每個專案需要多久的時間,才能加起來前 1,000、10,000、100,000 和 1,000,000 個專案。 重複每個迴圈多次,以測量每個案例最平淡的快取行為。

哪一項較快? 猜測之後,請參閱答案:表 1 中的最後八個專案。

有趣! 當參考的數據成長超過後續快取大小時,時間會大幅變慢。 數位版本一律比連結清單版本快,即使它執行了兩倍的指示;針對 100,000 個專案,陣列版本的速度會快七倍!

為什麼如此? 首先,鏈接的清單專案較少符合任何指定的快取層級。 所有這些對象標頭和鏈接都會浪費空間。 其次,我們的新式順序異序數據流處理器可能會向前縮放,同時對陣列中的數個專案進行進度。 相反地,在目前清單節點處於快取狀態之前,處理器無法開始擷取之後節點的下一個連結。

在 100,000 個專案案例中,處理器花費 (平均) 大約 (22-3.5)/22 = 84% 其時間撥動其拇指,等待從 DRAM 讀取某些清單節點的快取行。 這聽起來很糟糕,但事情可能會 更糟。 由於連結的清單專案很小,因此其中許多專案都適合快取行。 由於我們會以配置順序周遊清單,而且因為垃圾收集行程會保留配置順序,即使它會壓縮堆積中的死物件,在快取行上擷取一個節點之後,接下來的幾個節點現在也會在快取中。 如果節點較大,或清單節點處於隨機位址順序,則造訪的每個節點都可能是完整的快取遺漏。 將 16 個字節新增至每個清單節點會將每個專案的周遊時間加倍至 43 ns;+32 個字節,67 個 ns/item;並將 64 個字節再次加倍至 146 ns/item,可能是測試電腦上的平均 DRAM 延遲。

那麼,這裡的外賣課程是什麼? 避免連結的100,000個節點清單? 沒有。 這一課是快取效果可以主宰 Managed 程式代碼與原生程式代碼低階效率的任何考慮。 如果您撰寫效能關鍵受控程式代碼,特別是管理大型數據結構的程式代碼,請記住快取效果,思考您的數據結構存取模式,並努力爭取較小的數據使用量和良好的參考位置。

順便說一句,其趨勢是記憶體牆、DRAM 存取時間的比例除以 CPU 作業時間,會隨著時間持續惡化。

以下是一些「有快取意識的設計」規則:

  • 實驗和測量您的案例,因為很難預測二階效果,而且因為經驗規則不值得列印的紙張。
  • 陣列所示範的某些數據結構會利用 隱含相鄰 來表示數據之間的關聯性。 其他連結清單則使用 明確指標(參考) 來表示關聯性。 隱含相鄰通常是較佳的,「隱含性」可節省與指標相較之下的空間;和 相鄰提供穩定的參考位置,並可能允許處理器在追逐下一個指標之前開始更多工作。
  • 某些使用模式偏好混合式結構,例如小型數位、陣列、陣列列或 B 型樹狀結構的清單。
  • 也許磁碟存取區分磁碟的排程演算法,在磁碟存取成本僅為 50,000 個 CPU 指令時所設計,現在應該回收 DRAM 存取權可能需要數千個 CPU 作業。
  • 由於 CLR 標記和精簡垃圾收集行程會保留對象的相對順序,因此 在時間中配置的物件(且在同一個線程上)通常會保留在空間中。 您可能可以使用這種現象,在常見的快取行上仔細共置 cliquish 數據。
  • 您可能想要將數據分割成經常周遊且必須符合快取的經常性元件,以及不常使用且可以「快取」的冷元件。

Do-It-Yourself 時間實驗

針對本文中的計時測量,我使用了 Win32 高解析度性能計數器 QueryPerformanceCounter(和 QueryPerformanceFrequency)。

它們可透過 P/Invoke 輕鬆呼叫:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

您在計時迴圈前後呼叫 QueryPerformanceCounter、減去計數、乘以 1.0e9、除以頻率、除以反覆項目數目,也就是您在 ns 中每個反覆運算的近似時間。

由於空間和時間限制,我們並未涵蓋鎖定、例外狀況處理或程式代碼存取安全性系統。 考慮這是讀者的練習。

順便說一句,我在 2003 年 VS.NET 使用反組譯碼窗口製作了本文中的反組譯碼。 然而,有一個訣竅。 如果您在 VS.NET 調試程式中執行應用程式,即使作為建置於發行模式的優化可執行檔,它也會在停用內嵌等優化的情況下,以「偵錯模式」執行。 我找到查看 JIT 編譯程式所發出優化機器碼的唯一方式,就是在調試程式 外部啟動測試應用程式 ,然後使用 Debug.Processes.Attach 附加至它。

空間成本模型?

具有諷刺意味的是,空間考慮排除了對空間的徹底討論。 然後,幾個簡短的段落。

低階考慮 (數個是 C# (預設 TypeAttributes.SequentialLayout) 和 x86 特定):

  • 實值類型的大小通常是其欄位的大小總計,其中 4 個字節或較小的欄位會對齊其自然界限。
  • 您可以使用 [StructLayout(LayoutKind.Explicit)][FieldOffset(n)] 屬性來實作等位。
  • 參考類型的大小是 8 個字節,加上其欄位的總大小、四捨五入到下一個 4 位元組界限,以及 4 位元組或較小的欄位會對齊其自然界限。
  • 在 C# 中,列舉宣告可以指定任意整數基底類型(char 除外),因此可以定義 8 位、16 位、32 位和 64 位列舉。
  • 如同 C/C++,您可以適當地調整整數位段的大小,從較大的物件中剃出幾十%的空間。
  • 您可以使用 CLR Profiler 檢查配置參考型別的大小。
  • 大型物件 (數十 KB 或更多) 是在個別的大型物件堆積中管理,以排除昂貴的複製。
  • 可完成的物件需要額外的 GC 產生來回收—謹慎使用它們,並考慮使用 Dispose 模式。

大局考慮:

  • 每個 AppDomain 目前會產生大量的空間負荷。 許多運行時間和架構結構不會跨AppDomains共用。
  • 在程式中,吉特程式代碼通常不會跨AppDomains共用。 如果特別裝載運行時間,可以覆寫此行為。 請參閱 CorBindToRuntimeExSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN 旗標的檔。
  • 在任何事件中,不會跨進程共用已抖動的程序代碼。 如果您有將載入許多進程的元件,請考慮先使用 NGEN 進行先行編譯,以共用機器碼。

反射

有人說,「如果你必須問什麼反映成本,你買不起它。 如果您讀過這一點,您知道詢問哪些專案成本,以及測量這些成本有多重要。

反映很有用且功能強大,但相較於已抖動的機器碼,它既不是快速也不小。 你被警告過 為自己測量它。

結論

現在您知道 (或多或少) 最低層級的 Managed 程式代碼成本。 您現在已具備基本瞭解,讓更聰明的實作取捨,並撰寫更快速的 Managed 程式代碼。

我們已經看到,抖動的Managed程式碼可以像機器碼一樣「踩到金屬」。。 您的挑戰是明智地撰寫程序代碼,並在架構中許多豐富且易於使用的設施中明智地選擇

有些設定效能並不重要,以及產品最重要的功能設定。 過早優化 所有邪惡的根源。 但對效率的粗心無情也是如此。 你是一個專業人士,一個藝術家,一個工匠。 因此,請確定您知道事情的成本。 如果您不知道,或即使您認為自己是定期測量它。

至於 CLR 小組,我們繼續努力提供比原生程式代碼 大幅 更有生產力的平臺,但 比機器碼快。 期待事情變得更好。 保持微調。

記住您的承諾。

資源