軟件設計的哲學:第二十章 性能設計

到目前爲止,軟件設計的討論都集中在複雜性上,我們的目標是使軟件儘可能的簡單和易懂。但是,如果您正在開發一個需要快速的系統,該怎麼辦呢?性能考慮應該如何影響設計過程?本章討論如何在不犧牲乾淨設計的前提下實現高性能。最重要的思想仍然是簡單性:簡單性不僅改進了系統的設計,而且通常使系統運行得更快。

20.1 如何考慮性能

要解決的第一個問題是“在正常的開發過程中,您應該在多大程度上擔心性能?”“如果你試圖優化每條語句以獲得最大的速度,就會降低開發速度,併產生大量不必要的複雜性。此外,許多“優化”實際上並不能提高性能。另一方面,如果您完全忽略了性能問題,那麼很容易在整個代碼中出現大量顯著的效率低下;得到的系統很容易比需要的速度慢5 - 10倍。在這種“死於千刀萬剮”的情況下,以後很難再回過頭來改進性能,因爲沒有一個改進會有很大的影響。

最好的方法是介於這兩個極端之間,即使用基本的性能知識來選擇“自然有效”但又幹淨簡單的設計替代方案。關鍵是要意識到哪些操作從根本上是昂貴的。以下是一些如今相對昂貴的操作例子:

  • 網絡通信: 即使是在一個數據中心,一個雙向消息交換可以10 - 50µs,成千上萬的指令。廣域往返可能需要10-100毫秒。
  • 從I/O到輔助存儲器: 磁盤I/O操作通常需要5-10 ms,這是數百萬次的指令時間。閃存µs需要10 - 100。新興的非易失性記憶可能1µs一樣快,但這仍然是大約2000指令。
  • 動態內存分配 (C中的malloc, c++或Java中的new)通常涉及分配、釋放和垃圾收集的大量開銷。
  • 緩存丟失: 從DRAM獲取數據到片上處理器緩存需要幾百次指令;在許多程序中,總體性能由緩存丟失和計算開銷決定。

瞭解哪些東西比較昂貴的最佳方法是運行微基準測試(單獨測量單個操作成本的小程序)。在RAMCloud項目中,我們創建了一個提供微基準測試框架的簡單程序。創建這個框架花了幾天時間,但是這個框架使得在5到10分鐘內添加新的微基準成爲可能。這讓我們積累了幾十個微基準。我們使用它們來了解在RAMCloud中使用的現有庫的性能,並度量爲RAMCloud編寫的新類的性能。

一旦您對什麼是昂貴的,什麼是便宜的有了一個大致的概念,您就可以在任何可能的情況下使用這些信息來選擇便宜的操作。 在許多情況下,更有效的方法與更慢的方法一樣簡單。例如,在存儲使用鍵值查找的大型對象集合時,可以使用散列表或有序映射。這兩種方法通常都可以在庫包中獲得,而且都很簡單、易於使用。然而,哈希表的速度可以輕鬆提高5 - 10倍。因此,除非需要映射提供的排序屬性,否則應該始終使用散列表。

另一個例子是,考慮在C或C++這樣的語言中分配一個結構數組。有兩種方法可以做到這一點。一種方法是數組保存指向結構的指針,在這種情況下,必須首先爲數組分配空間,然後爲每個單獨的結構分配空間。將結構存儲在數組本身中要有效得多,因此只需爲所有內容分配一個大塊。

如果提高效率的唯一方法是增加複雜性,那麼選擇就更加困難。如果更有效的設計只增加了少量的複雜性,並且複雜性是隱藏的,因此它不會影響任何接口,那麼它可能是值得的(但是要注意:複雜性是遞增的)。如果更快的設計增加了大量的實現複雜性,或者導致了更復雜的接口,那麼最好從更簡單的方法開始,然後在性能出現問題時進行優化。但是,如果您有明確的證據表明性能在特定情況下非常重要,那麼您最好立即實現更快的方法。

在RAMCloud項目中,我們的總體目標之一是爲通過數據中心網絡訪問存儲系統的客戶機提供儘可能低的延遲。因此,我們決定使用特殊的硬件進行聯網,這使得RAMCloud可以繞過內核,直接與網絡接口控制器通信來發送和接收數據包。儘管增加了複雜性,但我們還是做出了這個決定,因爲我們從以前的度量中知道,基於內核的網絡速度太慢,無法滿足我們的需求。在RAMCloud系統的其餘部分中,我們能夠簡單地進行設計;“正確”解決這個大問題使許多其他事情變得更容易。

通常,簡單的代碼比複雜的代碼運行得更快。如果您已經定義了特殊情況和異常,那麼就不需要代碼來檢查這些情況,並且系統運行得更快。深度類比淺層類更有效,因爲它們爲每個方法調用完成了更多的工作。淺層類會導致更多的層交叉,並且每個層交叉都會增加開銷。

20.2 修改前的測量

但是假設您的系統仍然太慢,即使您已經按照上面的描述設計了它。人們很容易根據自己對什麼是慢的直覺,匆忙地開始調整性能。不要這樣做。程序員對性能的直覺是不可靠的。即使對於有經驗的開發人員也是如此。如果您開始基於直覺進行更改,那麼您將浪費時間在實際上並沒有提高性能的事情上,並且可能會使系統在此過程中變得更加複雜。

在進行任何更改之前,請度量系統的現有行爲。這有兩個目的。首先,度量將確定性能調優將產生最大影響的位置。僅僅度量頂級系統性能是不夠的。這可能會告訴你係統太慢了,但它不會告訴你原因。您需要更深入地度量,以詳細地確定影響整體性能的因素;目標是確定系統當前花費大量時間的少數非常具體的地方,以及您有改進的想法的地方。度量的第二個目的是提供一個基線,這樣您就可以在進行更改之後重新度量性能,以確保性能確實得到了改進。如果這些更改在性能上沒有產生可度量的差異,那麼就將它們取消(除非它們使系統變得更簡單)。除非它提供了顯著的加速,否則保持複雜性是沒有意義的。

20.3 圍繞關鍵路徑進行設計

現在,讓我們假設您已經仔細地分析了性能,並確定了一段足夠慢到影響整個系統性能的代碼。提高其性能的最佳方法是進行“基本的”更改,如引入緩存,或使用不同的算法方法(例如,平衡樹與列表)。我們決定繞過RAMCloud中的網絡通信內核,這是一個基本解決方案的例子。如果您可以確定一個基本的修復,那麼您可以使用前面章節中討論的設計技術來實現它。

不幸的是,有時會出現沒有根本解決辦法的情況。這就引出了本章的核心問題,即如何重新設計現有的代碼段,使其運行得更快。這應該是你最後的選擇,這種情況不應該經常發生,但是在某些情況下,它可以產生很大的影響。關鍵思想是圍繞關鍵路徑設計代碼。

首先要問自己,在通常情況下,執行所需任務所需執行的最小代碼量是多少。忽略任何現有的代碼結構。假設您正在編寫一個只實現關鍵路徑的新方法,這是在最常見情況下必須執行的最小代碼量。當前的代碼可能混雜着特殊情況;在這個練習中忽略它們。當前代碼可能在關鍵路徑上通過多個方法調用;想象一下,您可以將所有相關的代碼放在一個方法中。當前的代碼還可以使用各種變量和數據結構;只考慮關鍵路徑所需的數據,並假設對關鍵路徑最方便的數據結構是什麼。例如,將多個變量組合成一個值可能是有意義的。假設您可以完全重新設計系統,以最小化必須爲關鍵路徑執行的代碼。讓我們稱這個代碼爲“理想代碼”。

理想的代碼可能會與現有的類結構發生衝突,而且可能不實際,但它提供了一個很好的目標:這代表了代碼所能達到的最簡單、最快速的目標。下一步是尋找一個新的設計,儘可能接近理想,同時仍然有一個乾淨的結構。您可以應用本書前幾章中的所有設計思想,但是附加了保持理想代碼(大部分)完整的約束。您可能需要向理想狀態中添加一些額外的代碼,以實現乾淨的抽象;例如,如果代碼涉及到哈希表查找,則可以向通用哈希表類引入額外的方法調用。根據我的經驗,我們幾乎總能找到一種簡潔而又接近理想的設計。

在這個過程中發生的最重要的事情之一是從關鍵路徑中刪除特殊情況。當代碼運行緩慢時,通常是因爲它必須處理各種情況,而代碼的結構簡化了對所有不同情況的處理。每個特殊情況都會以附加條件語句和/或方法調用的形式向關鍵路徑添加少量代碼。每一項添加都會使代碼變慢一點。在重新設計性能時,儘量減少必須檢查的特殊情況的數量。理想情況下,在開頭有一個if語句,它用一個測試檢測所有的特殊情況。在正常情況下,只需要進行這個測試,然後就可以執行關鍵路徑,而不需要對特殊情況進行額外的測試。如果初始測試失敗(這意味着發生了特殊情況),代碼可以轉移到關鍵路徑之外的一個獨立位置來處理它。對於特殊情況,性能並沒有那麼重要,所以您可以爲了簡單性而不是性能來構造特殊情況的代碼。

20.4 一個示例:RAMCloud緩衝區

讓我們考慮一個例子,在這個例子中,RAMCloud存儲系統的緩衝區類被優化爲爲最常見的操作實現大約2倍的加速。

RAMCloud使用緩衝區對象來管理可變長度的內存數組,例如用於遠程過程調用的請求和響應消息。緩衝區的設計目的是減少內存複製和動態存儲分配帶來的開銷。緩衝區存儲的似乎是一個字節的線性數組,但爲了提高效率,它允許將底層存儲劃分爲多個不連續的內存塊,如圖20.1所示。緩衝區是通過附加數據塊來創建的。每個塊要麼是外部的,要麼是內部的。如果一個塊是外部的,它的存儲屬於調用者;緩衝區保持對該存儲的引用。外部塊通常用於大塊,以避免內存拷貝。如果塊是內部的,則緩衝區擁有塊的存儲;調用者提供的數據被複制到緩衝區的內部存儲中。每個緩衝區都包含一個小的內置分配,這是一個可用來存儲內部塊的內存塊。如果這個空間被耗盡,那麼緩衝區將創建額外的分配,在緩衝區被銷燬時必須釋放這些分配。對於內存複製成本可以忽略的小塊,內部塊非常方便。圖20.1顯示了一個有5個塊的緩衝區:第一個塊是內部的,後面兩個是外部的,最後兩個塊是內部的。

圖20.1:一個Buffer對象使用一個內存塊集合來存儲一個線性字節數組。內部塊由緩衝區擁有,在緩衝區被銷燬時釋放;外部塊不屬於緩衝區。

緩衝區類本身代表了一種“基本修復”,因爲它消除了在沒有它的情況下可能需要的昂貴內存副本。例如,在RAMCloud存儲系統中組裝包含短標頭和大型對象內容的響應消息時,RAMCloud使用一個具有兩個塊的緩衝區。第一個塊是包含頭的內部塊;第二個塊是一個外部塊,它引用RAMCloud存儲系統中的對象內容。可以在緩衝區中收集響應,而不需要複製大型對象。

除了允許不連續塊的基本方法之外,我們沒有嘗試優化原始實現中的緩衝區類的代碼。然而,隨着時間的推移,我們注意到緩衝區在越來越多的情況下被使用;例如,在執行每個遠程過程調用期間至少創建四個緩衝區。最後,很明顯,加速緩衝區的實現可能會對整個系統的性能產生顯著的影響。我們決定看看能否改進緩衝區類的性能。

緩衝區最常見的操作是使用內部塊爲少量新數據分配空間。例如,在爲請求和響應消息創建標題時就會發生這種情況。我們決定使用這個操作作爲優化的關鍵路徑。在最簡單的情況下,可以通過擴大緩衝區中最後一個現有塊來分配空間。但是,只有在最後一個現有塊是內部的,並且在其分配中有足夠的空間容納新數據時,纔有可能這樣做。理想的代碼將執行一次檢查以確認簡單方法是可行的,然後調整現有塊的大小。

圖20.2顯示了關鍵路徑的原始代碼,它從方法Buffer::alloc開始。在最快的情況下,Buffer::alloc調用Buffer:: allocateAppend,它調用Buffer::Allocation::allocateAppend。從性能的角度來看,這段代碼有兩個問題。第一個問題是,許多特殊情況是單獨檢查的:

  • Buffer::allocateAppend檢查緩衝區當前是否有任何分配。
  • 代碼檢查兩次,看看當前分配是否有足夠的空間容納新數據:一次是在Buffer:: allocation::allocateAppend中,另一次是在Buffer::allocateAppend測試其返回值時。
  • Buffer::alloc測試Buffer::allocAppend的返回值,再次確認分配成功。

此外,與嘗試直接展開最後一個塊不同,代碼分配新空間時不考慮最後一個塊。然後Buffer::alloc檢查該空間是否恰好與最後一個塊相鄰,在這種情況下,它將新空間與現有塊合併。這會導致額外的檢查。總的來說,這段代碼測試了關鍵路徑中的6個不同條件。

原始代碼的第二個問題是它有太多層,所有層都很淺。這既是性能問題,也是設計問題。除了原始的Buffer::alloc調用外,關鍵路徑還執行兩個額外的方法調用。每個方法調用都需要額外的時間,而且每個調用的結果都必須由調用者進行檢查,這會導致需要考慮更多的特殊情況。第7章討論了當您從一個層傳遞到另一個層時,抽象通常應該如何變化,但是圖20.2中的所有三個方法都具有相同的簽名,並且它們提供了本質上相同的抽象;這是一個危險信號。:allocateAppend幾乎是一個通過方法;它唯一的貢獻是在需要時創建一個新的分配。額外的層使代碼更慢,也更復雜。

爲了解決這些問題,我們對緩衝區類進行了重構,使其設計以性能最關鍵的路徑爲中心。我們不僅考慮了上面的分配代碼,還考慮了其他幾種常見的執行路徑,比如檢索當前存儲在緩衝區中的數據的總字節數。對於這些關鍵路徑中的每一個,我們都試圖確定在普通情況下必須執行的最小代碼量。然後我們圍繞這些關鍵路徑設計了其他的類。我們還應用了本書中的設計原則來簡化類。例如,我們消除了淺層並創建了更深層的內部抽象。重構類比原始版本(1476行代碼,而原始版本是1886行)小20%。

圖20.2:使用內部塊在緩衝區末尾分配新空間的原始代碼。

圖20.3:在緩衝區的內部塊中分配新空間的新代碼。

圖20.3顯示了在緩衝區中分配內部空間的新關鍵路徑。新代碼不僅更快,而且更容易閱讀,因爲它避免了膚淺的抽象。整個路徑在一個方法中處理,它使用一個測試來排除所有的特殊情況。新代碼引入了一個新的實例變量extraAppendBytes,以簡化關鍵路徑。這個變量跟蹤緩衝區中的最後一個塊之後有多少未使用的空間可用。如果沒有可用的空間,或者緩衝區中的最後一塊不是內部塊,或者緩衝區根本不包含塊,那麼extraAppendBytes爲零。圖20.3中的代碼表示處理這種常見情況的最少可能的代碼量。

注意:只要需要,對totalLength的更新可以通過重新計算各個塊的總緩衝區長度來消除。但是,對於具有許多塊的大型緩衝區,這種方法將非常昂貴,並且獲取總的緩衝區長度是另一種常見的操作。因此,我們選擇向alloc添加少量的額外開銷,以確保緩衝區長度總是立即可用。

新代碼的速度大約是舊代碼的兩倍:使用內部存儲將1字節字符串追加到緩衝區所需的總時間從8.8 ns降至4.75 ns。由於修訂,許多其他緩衝操作也加快了速度。例如,構造一個新緩衝區、在內部存儲中追加一個小塊以及銷燬緩衝區所需的時間從24納秒減少到12納秒。

20.5 結論

這一章中最重要的是,乾淨的設計和高性能是兼容的。重寫的緩衝區類將其性能提高了2倍,同時簡化了其設計並將代碼大小減少了20%。複雜的代碼往往很慢,因爲它做的是無關的或冗餘的工作。另一方面,如果您編寫乾淨、簡單的代碼,那麼您的系統可能足夠快,因此您不必首先擔心性能問題。在少數確實需要優化性能的情況下,關鍵還是簡單性:找到對性能最重要的關鍵路徑,並使它們儘可能簡單。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章