2 行程式碼,將 .NET 執行時間降低 87%
作者 | STEVE GORDON
譯者 | 彎月,責編 | 屠敏
頭圖 | CSDN 下載自東方 IC
出品 | CSDN(ID:CSDNnews)
以下為譯文:
長期以來,我一直在致力於提高效能,並且努力避免在關鍵程式碼路徑中進行記憶體分配。例如,使用Span<T>在解析資料時避免記憶體分配,以及使用ArrayPool避免為臨時緩衝區分配陣列。這樣的修改雖然對效能有好處,但會增加新版本程式碼的維護難度。
在本文中,我想展示的效能最佳化並不需要大量複雜的程式碼修改。有時候,有些簡單的修改也能在提升效能上有出色的表現。下面我們就來看一個這樣的例子。
找出最佳化的物件
最近,我在研究Elasticsearch.NET客戶端程式碼庫。我對庫中某些熱路徑的效能感到好奇。
給應用程式效能分析方面的新手解釋一下,熱路徑就是在正常的使用過程中被頻繁呼叫的一系列方法。例如,Web應用程式中可能有一個端點,與所有其他端點相比,該端點在生產環境中被呼叫的頻率更高。那麼,該端點對應的方法很可能是應用程式中熱路徑的開始。相應地,它呼叫的各種方法也可能位於熱路徑上。再舉一個例子,迴圈內的程式碼,如果迴圈執行數百或數千次,則可能會對其他方法產生大量呼叫。
在最佳化應用程式效能時,通常首先應該關注熱路徑,由於被呼叫的頻率很高,因此對它們做出的改進能夠給效能帶來最顯著的影響。改進呼叫次數僅佔10%的程式碼,產生的收益也要小得多。
.NET有兩個相關的Elasticsearch客戶端。NEST是支援強型別查詢的高階客戶端,位於底層客戶端Elasticsearch.NET之上。
NEST名稱空間內有一個抽象的RequestBase類,該類派生出的子類都是強型別的請求型別。每個可以用的Elasticsearch HTTP API端點都有一個強型別的請求類。請求的主要特徵是它包含與其相關的API端點的一個或多個URL。
定義多個URL的原因是,許多ElasticSearch的API都可以使用基本路徑或包含特定資源識別符號的路徑進行呼叫。例如,Elasticsearch中有一個端點可以查詢叢集執行狀況。該端點可以透過URL“_cluster/health”執行整個叢集的一般健康檢查;也可以在路徑中加入索引名稱“_cluster/health/{索引}”來針對特定索引執行健康檢查。
在邏輯上,這些URL由庫中的同一個請求類處理。在建立請求時,消費者可以提供一個可選的請求值,以指定特定索引。在這種情況下,必須在執行時構建URL,透過使用者提供的索引名稱替換URL中的{索引}部分。如果請求沒有提供索引名稱,則使用較短的URL “_cluster/health”。
因此,在請求被髮送的時候,最終的URL必須已經確定並且構建好了。首先從可能的URL列表中找出要使用的URL模式。這個過程需要使用強型別請求物件指定的請求值。在URL模式匹配完成後,就可以生成最終的URL了。必要時還可以使用帶有標記的URL模式,利用呼叫者程式碼提供的路由值替換可選的標記,從而建立最終的URL字串。
該URL構建的核心主要包含在UrlLookup類中,該類包括一個ToUrl方法,如下所示:
public string ToUrl(ResolvedRouteValuesvalues){ var sb = new StringBuilder(_length); var i = 0; for (var index = 0; index < _tokenized.Length; index++) { var t = _tokenized[index]; if (t[0] == '@') { if (values.TryGetValue(_parts[i],out var v)) { if (string.IsOrEmpty(v)) throw newException($"'{_parts[i]}' defined but is empty on url: {_route}"); sb.Append(Uri.EscapeDataString(v)); } else throw new Exception($"Novalue provided for '{_parts[i]}' on url: {_route}"); i++; } else sb.Append(t); } return sb.ToString;}上述程式碼首先建立了StringBuilder例項。然後,遍歷帶有標記的URL中的每個字串。URL路徑中的標記元素儲存在字串陣列欄位“_tokenized”中。在每次迭代中,如果字串值以“@”字元開頭,則表明需要用相應的值替換它。然後搜尋路由的值,找出與當前標記名稱匹配的值,儲存在“_parts”陣列中。如果找到匹配項,則在對URI進行轉義後將其值附加到URL StringBuilder中(第15行)。
對於不需要替換路徑中的任何部分,則無需修改即可將它們直接附加到StringBuilder上(第21行)。
當所有帶有標記的值都被新增並替換之後,就可以呼叫StringBuilder的ToString方法,返回最終的字串。每次客戶端傳送請求時,這段程式碼都會被呼叫,因此是庫中的熱路徑。
下面我們來考慮:如何對其進行最佳化,以提高執行速度,並減少資源分配?
現在這段程式碼使用的是StringBuilder,這是良好的實踐,在需要將補丁數量的字串連線到一起時,可以避免字串分配。有幾種使用Span<T>的方法可以減少字串分配的次數。但是,新增Span<T>或其他技巧(如利用ArrayPools提供零分配緩衝區),會增加程式碼複雜度。由於這個庫被許多呼叫者使用,因此這種做法也許值得。
在日常的程式設計工作中,除非你的服務處於極端的使用/負載狀態,否則這種最佳化可能有點過。如果你熟悉Span<T>之類的高效能技巧,那麼可能會情不自禁朝著最佳最佳化(即零分配)努力。這樣的想法會讓你對應該優先考慮的簡單改動視而不見。
當回顧ToUrl方法並透過邏輯流程進行思考時,我有了一個想法。對於某些情況,可以有另外兩種方法,實現簡單但能有效地提升效能。再看一下上面的程式碼,你能否找到簡單的提升效能的改進?提示:只需在方法開頭加上幾行。
讓我們再次考慮叢集健康的示例,它有兩個URL模式,“ _cluster/health”和“ _cluster/health/{index}”。
後者要求路徑的最後一部分使用使用者提供的索引名稱替換。但是前者並沒有任何替換的要求。對於絕大多數端點來說,只有一小部分情況需要使用路由的值替換路徑中的一部分。明白我的意思了嗎?
我的想法是,某些情況下ToUrl方法完全不需要構建URL。這樣就根本不需要使用(更不需要記憶體分配)StringBuilder示例,也不需要生成新的URL字串。既然URL不需要替換,那麼其中就只包含完整的原始URL路徑字串。那麼,直接返回就可以了。
最佳化程式碼
在進行任何最佳化之前,我需要先做兩件事。首先,我需要檢查現有程式碼是否有足夠的單元測試。任何重構都有可能破壞當前的行為。如果沒有測試,我就會先根據目前的行為編寫一些測試。在最佳化之後,如果測試依然能夠透過,就說明沒有破壞任何東西。為了簡潔起見,本文將省略測試,相信許多開發人員都已經非常熟悉了。
最佳化之前需要做的第二件事就是,在已有程式碼上建立評測基準,這樣之後就可以確定程式碼改動是否能夠提升效能,並定量地測量效能的提升。對效能做出假設是危險的,最安全的做法就是用科學的方法來確保。首先建立理論,測量已有的行為,然後進行試驗(程式碼最佳化),最終再次測量,以驗證假設。編寫效能測試指令碼的方法也許你並不熟悉,你可以參考我關於.NET效能測試的文章(https://www.stevejgordon.co.uk/introduction-to-benchmarking-csharp-code-with-benchmark-dot-net)。
在此ToUrl示例中,基準測試非常直觀。
namespace BenchmarksDev{ internal class Program => private static void Main(string[] args) =>BenchmarkRunner.Run<UrlLookupBenchmarks>; [MemoryDiagnoser] public class UrlLookupBenchmarks { private static readonly UrlLookup ClusterHealth = newUrlLookup("_cluster/health"); private static readonly UrlLookup ClusterHealthIndex = newUrlLookup("_cluster/health/{index}"); private static readonly ResolvedRouteValues EmptyRouteValues = newResolvedRouteValues; private static readonly ResolvedRouteValues IndexRouteValue = newResolvedRouteValues { { "index", "a"} }; private string _url; [Benchmark] public void Health => _url = ClusterHealth.ToUrl(EmptyRouteValues); [Benchmark] public void HealthIndex => _url =ClusterHealthIndex.ToUrl(IndexRouteValue); }}其中一些靜態欄位用於設定效能測試的型別,以及需要的輸入。我們不希望測量效能的測試產生額外的開銷。接下來是兩個效能測試,分別用於兩個URL模式。我們希望最佳化那個不需要替換路由值的模式,但也有必要對另一種情況進行測試。我們不希望在改進一個的同時對另一個產生負面影響。
更改任何程式碼之前,首次執行的結果如下:
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated ||------------|---------:|---------:|---------:|-------:|------:|------:|----------:|| Health | 41.60 ns | 0.637 ns | 0.596 ns | 0.0381 | - | - | 160 B || HealthIndex | 85.60 ns | 0.851 ns |0.796 ns | 0.0457 | - | - | 192 B |這為我們提供了一個基準,供我們完成工作後進行比較。
在ToUrl方法中,我們希望在不需要進行替換時,略過根據路徑構建URL的過程。只需要新增兩行程式碼即可實現。
if (values.Count == 0 &&_tokenized.Length == 1 && _tokenized[0][0] != '@') return _tokenized[0];只需要在方法開頭新增這兩行(如果你喜歡在return語句周圍新增大括號,那麼就新增4行)。這段程式碼執行三個邏輯檢查。如果它們都返回true,我們就知道不需要任何替換,可以直接返回。第一個檢查可以確保使用者沒有提供路由值。如果使用者提供了路由值,就應該假設需要進行某種替換。接下來我們檢查標記的數字是否包含一個元素,以及該元素的首字母不是“@”字元。
標準的叢集健康檢查請求不會提供索引名稱,那麼這些條件就會滿足,可以直接從標記陣列的0號位置返回“_cluster/health”字串。
這些額外的程式碼並不複雜。大多數開發人員都可以順利閱讀並理解其目的。為了完整起見,我們還可以將所有條件重構成一個小的方法或區域性函式,這樣就可以給它起一個名字,讓程式碼不言自明。本文省略這些內容。
現在程式碼修改完了,而且單元測試仍然能夠透過,下面我們重新執行基準測試來比較一下結果。
| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 |Allocated ||------------|----------:|----------:|----------:|-------:|------:|------:|----------:|| Health | 5.352 ns | 0.0611 ns |0.0510 ns | - | - | - | - || HealthIndex | 84.470 ns | 0.5005 ns |0.4437 ns | 0.0457 | - | - | 192 B |第二個效能測試“HealthIndex”沒有發生任何變化,因為部分URL需要替換,所以像以前一樣整個方法都會執行。但是,第一個效能測試“Health”中更直接的情況改進了許多。該程式碼路徑上不再有任何分配,因此減少了100%!我們不再分配StringBuilder,也不建立新字串,而是直接返回原始字串,在這裡,原始字串的記憶體已經分配過了。
節省160個位元組似乎並沒有太讓人興奮,但是考慮到客戶端每傳送一個請求這段程式碼都會呼叫一次,因此節省的量非常可觀。10個請求(不需要替換的請求)就可以節省1Kb無用的記憶體分配。如果客戶非常頻繁地使用Elasticsearch,這個改進就非常值得。
執行時間也減少了87%,因為在這種情況下唯一需要執行的程式碼就是條件檢查和返回。這些改進在熱路徑上非常成功,對於所有呼叫該方法的人都有益。由於這是一個客戶端庫,所以客戶也會看到好處,只需要使用包含此最佳化的最新版客戶端即可。
總結
在本文中,我們介紹了並非所有效能最佳化都需要複雜的實現。在文中的示例中,我們透過條件檢查避免執行需要分配記憶體的程式碼,從而優化了NEST庫的ToUrl方法。儘管可以使用Span<T>從理論上進行一些更廣泛的最佳化,但我們優先考慮了可以快速獲得性能提升的方法,這不會帶來複雜性,也不會加重維護程式碼的負擔。為了確保示例中的程式碼改動確實可以提升效能,我們使用了基準來衡量程式碼變更前後的效果。儘管例子中沒有介紹,但我們應該執行單元測試,以避免在這個方法中引入迴歸問題。
希望透過這個示例,你可以在自己的程式碼中找出只需簡單的修改就能快速提升效能的地方。在尋求值得最佳化的程式碼時,請優先考慮熱路徑,並從簡單的地方開始,嘗試解決能快速提升效能的問題,然後再轉向更復雜的最佳化。對於大多數程式碼庫來說,類似於本文的某些修改應該是合理的,而更高階的最佳化可能會加重維護的負擔。就像本文的示例一樣,某些最佳化工作可能非常簡單,只需使用條件檢查避免某些程式碼的執行即可。
原文:https://www.stevejgordon.co.uk/dotnet-performance-optimisations-dont-have-to-be-complex
作者:STEVE GORDON,微軟MVP。
本文為 CSDN 翻譯,轉載請註明來源出處。