1. 程式人生 > >軟體設計的哲學:第二十章 效能設計

軟體設計的哲學:第二十章 效能設計

目錄

  • 20.1 如何考慮效能
  • 20.2 修改前的測量
  • 20.3 圍繞關鍵路徑進行設計
  • 20.4 一個示例:RAMCloud緩衝區
  • 20.5 結論

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

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%。複雜的程式碼往往很慢,因為它做的是無關的或冗餘的工作。另一方面,如果您編寫乾淨、簡單的程式碼,那麼您的系統可能足夠快,因此您不必首先擔心效能問題。在少數確實需要優化效能的情況下,關鍵還是簡單性:找到對效能最重要的關鍵路徑,並使它們儘可能簡