如何使用.NET在2.2秒內處理10億行數據(1brc挑戰)
當前位置:點晴教程→知識管理交流
→『 技術文檔交流 』
譯者注#在上周我就關注到了在github上有1brc這樣一個挑戰,當時看到了由Victor Baybekov提交了.NET下最快的實現,當時計劃抽時間寫一篇文章解析他的代碼實現,今天突然看到作者自己寫了一篇文章,我感覺非常不錯,在這里分享給大家。 這篇文章是關于.NET開發者Victor Baybekov參加的一個名為"One Billion Row Challenge"的編程挑戰,他使用.NET語言編寫了一個實現,這個實現的性能不僅打敗了Java,甚至超過了C++。 這個挑戰的目標是處理一億行數據,并提供對數據的快速查詢。原始版本只允許Java參與,但其他語言的開發者也希望參與其中,因此挑戰對其他語言開放。Victor Baybekov的實現不僅在特定的數據集上表現優秀,而且在處理更通用的數據上也表現出色。他使用.NET的原因是,它的運行速度快且易于使用。 文章中,Victor Baybekov詳細介紹了他的優化過程,包括使用內存映射文件,優化哈希函數,使用輸入規范,使用自定義字典,優化內部循環等。他還強調了.NET的速度和易用性,同時提到了.NET提供的不安全選項,并不會使代碼自動變得不安全。 對于.NET開發者來說,這篇文章提供了很多關于如何優化代碼性能的實用信息。對于非.NET開發者來說,這篇文章也是一次了解.NET強大性能的好機會。 總的來說,這篇文章非常專業,為.NET開發者提供了一種思路,即通過使用.NET的功能和優化代碼,可以實現非常高的性能。同時,這篇文章也證明了.NET在處理大量數據時的優秀性能和易用性。 正文#在處理真實輸入數據時,.NET平臺上的十億行挑戰比Java更快,甚至比C++還要快。 上周,GitHub上因為Gunnar Morling發起的“十億行挑戰”而熱鬧非凡。最初這是一個僅限Java參與的比賽,但后來其他語言的開發者也想加入這場樂趣。如果你不了解這個挑戰及其規則,請先閱讀這些鏈接。 https://github.com/gunnarmorling/1brc https://github.com/gunnarmorling/1brc/discussions/categories/show-and-tell 我也被這個挑戰深深吸引了。截至撰寫本文時,我編寫的是目前最快的托管1BRC實現版本,它不僅在大家優化的特定數據集上表現出色,而且在更通用的數據上也有很好的性能。更重要的是,我的結果在默認數據上非常接近整體最優的C++版本,并且在通用數據的情況下超過了它。 https://github.com/buybackoff/1brc 在下面的“結果”部分,我展示了不同語言和數據集的不同計時結果。在 “我的#1BRC之旅” 中,我展示了我的優化歷程和性能時間線。然后我討論了為什么.NET在編寫這類代碼時既快速又易用。最后,我描述了我如何在日常工作中編寫高性能的.NET代碼,并邀請你如果對現代且快速的.NET感興趣,就來申請加入我們。 結果#除了我的代碼之外,我還在我的家庭實驗室中專門搭建了一個基準測試服務器。它擁有固定的CPU頻率并且能夠提供非常穩定的結果。我投入了大量的精力來比較不同實現的性能。對于.NET和Java,我測量了同一代碼的JIT和AOT性能。 我沒有添加排名,因為結果會根據數據的不同而有所不同。我用粗體突出顯示了按語言/JIT-AOT/數據集分組的最佳結果,并用黃色背景突出顯示了按數據集分組的整體最佳結果。 可能如預期的那樣,C++對于默認數據集來說是最快的。然而,C++與.NET和Java之間的細微差別,即便是我也覺得有些出乎意料。我確實預料到了.NET會擊敗Java。這并非是第一次發生這種情況。在2016年,Aeron.NET客戶端1.0版本就比Java快,我當時就在現場。 至于Rust,它很可能會成為總體的領導者。我們只需要等待直到實現是正確的。在撰寫本文時,它還沒有做到。 最終,所有結果應該會趨于某個物理極限和理想的CPU利用率。那么,一個有趣的問題將是,開發這樣的代碼付出了什么代價。對我來說,達到當前這個點相當容易,而且代碼非常簡單。 擴展數據集#默認的數據生成器只有少量氣象站名稱,最大長度低于AVX向量大小。這兩個屬性都有助于帶來極端的性能提升。然而,規格說明中提到,可能有多達1萬個獨特的氣象站,它們的名稱最多包含100個UTF8字節。
為了更公平的比較,我使用了兩個數據集: 原始的默認數據集是用 擴展的數據集包含了1萬個隨機的氣象站名稱,長度可以達到規格所允許的最大值。它是用 在表格的底部,你可以看到一個單獨的部分,用于展示那些在默認數據集上表現良好但無法正確處理1萬個數據的結果。這表明這些實現使用了超出規則說明的一些假設,并且不公平地過度優化了特定的情況。例如,最快的Rust版本的作者明確表示它不適用于1萬個數據。他更喜歡先編寫快速的代碼,然后再使其正確。 就我而言,我努力從一開始就編寫最通用的代碼。名稱可以是任意長度,數字可以是任意非科學計數的浮點數,行尾可以是 在Java再次變得更快之后(也是在短時間內),我查看了規則,但沒有查看數據。對我來說,數字范圍的限制是最重要的,但氣象站名稱仍然可以是任意長度。代碼會處理沖突,但對于真實世界輸入的氣象站名稱應該很少發生沖突。不過我必須承認,有可能創建人為數據,這些數據將會發生沖突,并將O(1)的哈希表查找變成O(N)的線性搜索。但即使在這種情況下,它仍然會工作,并且可能比參考實現還要快。也許我稍后會為了好玩而嘗試這樣做。 方法論#性能測試是在一個安靜的6C/12T Alder Lake CPU上進行的,該CPU的頻率固定在2.5 GHz(關閉睿頻功能),搭配32GB DDR4 3200內存,運行Debian 12系統,并且在Proxmox的特權LXC容器中進行測試。由于基準頻率是固定的,散熱狀況非常好(< 35°C),即使在持續100%負載下也不會發生降頻現象。 時間測量使用了 對于前兩名的.NET結果,我多次運行了基準測試,甚至為此重新啟動了機器。確實,在默認數據上,根據JIT與AOT的不同,它們的排名有所不同。對于我的代碼來說,AOT略有不利,但對于Cameron Aavik的代碼來說,AOT顯著提高了性能。 我的#1BRC之旅#我咳嗽已經超過2周了。新年期間咳得很厲害,以至于我在1月2日到3日請了假。1月3日,我喝著加了姜和蜂蜜的熱茶,刷著Twitter。我看到了Kevin Gosse關于這個挑戰的推文,我很喜歡這個想法。但我也清楚,這可能是一條通向深不見底的迷宮的入口,在那迷宮的底部,隱約能感受到曾經浪費時間的回憶。 然而,任務非常簡單。我決定測量一下我寫一個非常簡單但仍然快速的實現需要多長時間。當時是下午1:01,到下午3:17,我就完成了第一個版本,在我的測試機上處理默認數據集/10K數據集分別需要13.5/18.0秒。然后,我開始瘋狂地優化它。 通用版本,適用于任何輸入#起初,我甚至沒有嘗試針對規格進行優化。只是一個名稱和一個浮點數,中間用分號隔開,每行一個測量值,在Linux上以 關鍵Idea#提交時的文件: https://github.com/buybackoff/1brc/tree/f1b81f8a590a8a42d5be8358e6ba30489e678592/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/82a17bc..f1b81f8?diff=split&w= 時間:13.490 / 17.991 (10K) 我的實現的關鍵思想直到最后都沒有改變。魔鬼隱藏在最微小的細節中。 內存映射文件#使用mmap是顯而易見的,因為我之前在高性能場景下多次使用它,比如IPC環形緩沖區。它非常簡單易用,所有復雜性都由操作系統管理。最近數據庫社區就是否使用mmap還是手動內存管理,即LMDB與其他方式之間進行了激烈的討論。順便說一句,我是LMDB的大粉絲,甚至為其編寫了最快的.NET封裝。 盡管如此,為了避免munmap的慢速時間,我在這里嘗試了不使用mmap的方法。結果確實慢了一些,但并不太多。僅將文件復制到內存中最多需要大約200毫秒的CPU帶寬,再加上不可避免的開銷,這就很能說明問題了。 Utf8Span#
public readonly unsafe struct Utf8Span : IEquatable<Utf8Span>{ private readonly byte* _pointer; private readonly int _len; // 構造器 public ReadOnlySpan<byte> Span => new ReadOnlySpan<byte>(_pointer, _len); public bool Equals(Utf8Span other) => Span.SequenceEqual(other.Span); // 真是太懶了!連_hash中免費可用的額外熵都沒用上。 // 但它在默認數據集上運行得相當不錯。 public override int GetHashCode() { // 使用前幾個字節作為哈希值 if (_len > 3) return *(int*)_pointer; if (_len > 1) return *(short*)_pointer; if (_len > 0) return *_pointer; return 0; } public override bool Equals(object? obj) => obj is Utf8Span other && Equals(other); public override string ToString() => new string((sbyte*)_pointer, 0, _len, Encoding.UTF8);} 為了高效地進行哈希表查找,
平均值/最小值/最大值的高效更新#要計算運行平均值,我們需要存儲總和和計數。這里沒有什么有趣的,我們都知道,自從編程幼兒園時代起,不是嗎? 更新 最小值/最大值 在數學上甚至更簡單。只需檢查新值是否 小于/大于 之前的 最小值/最大值 ,并相應地更新它們。然而,CPU不喜歡if語句,分支預測錯誤的成本很高。然而,如果你再多想一點從統計學角度來看,對于任何穩定的過程,實際上覆蓋 最小值/最大值 的機會隨著每一次觀測迅速下降。即使是股票價格,它們不是穩定的,也不會每天、每月或每年都達到歷史新高。溫度據說“平均來說”是穩定的,并且至少在幾個世紀的尺度上是穩定的。 下面是一個簡單的模擬,顯示了 最小值/最大值 分支所占比例的運行情況。請注意,X軸是對數的。平均來說,僅在10次觀測后,兩個分支都不會被采用。 最小值/最大值分支概率#這個分析告訴我們使用分支而不是更重的無分支位運算。我最終嘗試了無分支的選項,但我有統計直覺,并且在第一個以及最終實現中都使用了if語句。無分支代碼使得執行變得后端受限(如 perf stat 所見)。 public struct Summary{ // 注意,最初它們甚至不是浮點數 public double Min; public double Max; public double Sum; public long Count; public double Average => Sum / Count; public void Apply(double value, bool isFirst) { // 第一個值總是會更新最小值/最大值 if (value < Min || isFirst) Min = value; if (value > Max || isFirst) Max = value; Sum += value; Count++; }} .NET 默認字典#
還有一個非常好但不廣為人知的高性能工具類 通過取得摘要值的引用,我們避免了將其復制和更新到棧上/棧中,然后使用常規 API 再復制回字典。記住, // 沒有結構體復制 ref var summary = ref CollectionsMarshal.GetValueRefOrAddDefault(result, nameUtf8Sp, out bool exists); // 對于類:分支、分配、代碼大小。謝謝,不用了。在 .NET 中,值類型規則。 // if (summary is null) summary = new Summary(); summary.Apply(value, !exists); // 這個方法在上面展示過 字節解析#對于解析字節,我只是使用了 .NET 的 其他一切#性能僅取決于每個線程內的 我很驚訝有些人準備就僅僅使用 (P/)LINQ 的事實進行爭論,僅僅因為他們聽說它很慢。他們顯然不夠了解 .NET,也沒有區分熱路徑和冷路徑。 var result = SplitIntoMemoryChunks() // 將整個 mmap 分成每個 CPU 相等的塊 .AsParallel().WithDegreeOfParallelism(_threads) // 分配到所有 CPU 核心 .Select((tuple => ProcessChunk(tuple.start, tuple.length))) // 在每個 CPU 上執行 ProcessChunk 工作。 .Aggregate((result, chunk) => { /* 合并結果 ... */ }) ; 優化的浮點數解析#提交時的文件: https://github.com/buybackoff/1brc/tree/273def1abf9c9cc365b4309a3bd8d081a3eb7951/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/f1b81f8..273def1?diff=split&w= 時間:6.551 / 10.720 (10K) 在對代碼進行性能分析后,我發現 我添加了一個通用的浮點數解析器,它有一個快速路徑,但在檢測到任何不規則情況時會回退到原始方法。所有的 這幾乎使性能翻了一番!還有一些其他的微優化,只需點擊每個部分開頭的“與上一版本的差異”鏈接,即可查看所有更改。 [MethodImpl(MethodImplOptions.AggressiveInlining)]private double ParseNaive(ReadOnlySpan<byte> span){ double sign = 1; bool hasDot = false; ulong whole = 0; ulong fraction = 0; int fractionCount = 0; for (int i = 0; i < span.Length; i++) { var c = (int)span[i]; if (c == (byte)'-' && !hasDot && sign == 1 && whole == 0) { sign = -1; } else if (c == (byte)'.' && !hasDot) { hasDot = true; } else if ((uint)(c - '0') <= 9) { var digit = c - '0'; if (hasDot) { fractionCount++; fraction = fraction * 10 + (ulong)digit; } else { whole = whole * 10 + (ulong)digit; } } else { // 遇到任何不規則情況就回退到完整實現 return double.Parse(span, NumberStyles.Float); } } return sign * (whole + fraction * _powersPtr[fractionCount]);} 優化的哈希函數#提交時的文件: https://github.com/buybackoff/1brc/tree/e23c2bf8dace1450ad0411feaf54488795ec0fcb/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/273def1..e23c2bf?diff=split&w= 時間:6.313 / 10.384 (10K) 它不再像初始版本那樣懶惰,它包含了長度和最初的幾個字節的組合。免費獲得了超過3%的收益。 如果哈希總是零,我們使用線性搜索,有一些評論和最壞情況下的測量。 public override int GetHashCode(){ // 這里我們使用前4個字符(如果是ASCII)和長度來計算哈希。 // 最壞的情況是前綴,如 Port/Saint 且長度相同, // 這對于人類地理名稱來說相當罕見。 // .NET 字典顯然會因為沖突而變慢,但仍然可以工作。 // 如果我們只保留 `*_pointer`,運行時間仍然合理,大約9秒。 // 僅使用 `if (_len > 0) return (_len * 820243) ^ (*_pointer);` 耗時5.8秒。 // 僅返回0 - 最糟糕的哈希函數和線性搜索 - 運行時間慢了12倍,為56秒。 // 魔術數字820243是包含2024的最大快樂素數,來自 https://prime-numbers.info/list/happy-primes-page-9 if (_len > 3) return (_len * 820243) ^ (*(int*)_pointer); // 只添加了 ^ 之前的部分 if (_len > 1) return *(short*)_pointer; if (_len > 0) return *_pointer; return 0;} 在這個改變之后,我開始研究哪些規則可能對性能有用。 使用輸入規則#挑戰的規則說明名字總是少于100個UTF8字節,最多有10K個獨特的名字,溫度在-99.9到99.9之間( 我認為針對規則進行優化是完全可以接受的。可能有真正的氣象站產生這樣的數據,而代碼在我出生前就已經寫好了。然而,我不喜歡人們開始針對特定的數據集/生成器進行優化。因此,在這次比較中,我沒有接受那些不能處理10K數據集的實現。即使使用規格,我的代碼也支持任何名字長度。 將數字解析為整數#提交時的文件: https://github.com/buybackoff/1brc/tree/e5d34c92a82a446d876089a1e1872da54bf64ebb/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/e23c2bf..e5d34c9?diff=split&w= 時間:5.229 / 8.627 (10K) 僅僅利用溫度在-99.9到99.9之間的事實。我們只有4種情況,可以為此進行優化: ...;-99.9 ...;-9.9 ...;9.9 ...;99.9 設置字典容量#提交時的文件: https://github.com/buybackoff/1brc/tree/3644b251cda38abd620bda644efda12951020042/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/e5d34c9..3644b25?diff=split&w=# 時間:4.341 / 8.951 (10K) 這真是太傻了!但在我迫切需要提升性能的時候,這就像罐頭食品一樣珍貴。僅僅一行代碼/改動五個字符就能獲得17%的性能提升。 優化的IndexOf#提交時的文件: https://github.com/buybackoff/1brc/tree/7fdd17a755665910ecfabb4667b5bda277531e39/1brc 時間:4.040 / 8.609 (10K) 在剩余部分小于32字節時,手動AVX2在Span中搜索字節,并回退到 // 在 Utf8Span 內部 [MethodImpl(MethodImplOptions.AggressiveInlining)] internal int IndexOf(int start, byte needle) { if (Avx2.IsSupported) { var needleVec = new Vector<byte>(needle); Vector<byte> vec; while (true) { if (start + Vector<byte>.Count >= Length) goto FALLBACK; var data = Unsafe.ReadUnaligned<Vector<byte>>(Pointer + start); vec = Vector.Equals(data, needleVec); if (!vec.Equals(Vector<byte>.Zero)) break; start += Vector<byte>.Count; } var matches = vec.AsVector256(); var mask = Avx2.MoveMask(matches); int tzc = BitOperations.TrailingZeroCount((uint)mask); return start + tzc; } FALLBACK: int indexOf = SliceUnsafe(start).Span.IndexOf(needle); return indexOf < 0 ? Length : start + indexOf; } 積極的使用本機整數#提交時的文件: https://github.com/buybackoff/1brc/tree/d6c8e48b07821a05a1582f0e98f949360e3b4bd9/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/7fdd17a..d6c8e48?diff=split&w= 時間:3.693 / 8.604 (10K) 在本機環境中,使用size_t本機大小類型作為偏移和長度是正常的,因為CPU處理本機字更快。在.NET中,大多數公共API接受32位int。CPU必須每次將其擴展為nint。但內部.NET本身使用本機整數。 例如,這是帶有注釋的 // 優化的基于字節的SequenceEquals。這個的“length”參數被聲明為nuint而不是int, // 因為我們也用它來處理除byte以外的類型,其中長度一旦通過sizeof(T)縮放就會超過2Gb。 [Intrinsic] // 對常量長度展開 public static unsafe bool SequenceEqual(ref byte first, ref byte second, nuint length) { bool result; // 使用nint進行算術運算以避免不必要的64->32->64截斷 if (length >= (nuint)sizeof(nuint)) 使用自定義字典#提交時的文件: https://github.com/buybackoff/1brc/tree/8841e83e2abfb5f57a872cbea4c979c9b9e49178/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/d6c8e48..8841e83?diff=split&w= 時間:3.272 / 8.232 (10K) 直到這一點,我仍然使用的是默認的.NET字典。但由于規格說明最多有10K個獨特的名字,我可以利用這個規則。 詳細信息以后再補充。 快速 Utf8Span.Equals#提交時的文件: https://github.com/buybackoff/1brc/tree/9ed39221ec7db8f89e8e2a0702d43a184cc5e879/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/8841e83..9ed3922?diff=split&w= 時間:2.773 / 6.635 (10K) 我花了一些努力嘗試擊敗 為了確保安全,我確保最后一個大塊不是在文件末尾結束,而是至少在距離末尾 優化內循環#提交時的文件: https://github.com/buybackoff/1brc/tree/1051e06052d5a8a95fa0aee461e37d969532aa65/1brc 與上一版本的差異: https://github.com/buybackoff/1brc/compare/9ed3922..1051e06?diff=split&w= 時間:2.204 / 4.811 (10K)
詳細信息待定 性能時間線#以下是討論上述每次更改后性能演變的時間線。 .NET 非常快#.NET 非常快。而且每個新版本都在變得更快。有些人開玩笑說,對于 .NET 的最佳性能優化就是更新它 - 對于大多數用戶來說,這可能是真的。 每次發布新版本時,.NET 團隊的 Stephen Toub 都會發表一篇巨大的博客文章,介紹自上次發布以來的每一個微小性能改進。這些文章的龐大體量表明,他們非常關心性能的提升。 不安全代碼#.NET 允許你直接使用指針。這使得它類似于 C 語言。如果內循環受 CPU 限制,所有數組都可以被固定并在沒有邊界檢查的情況下訪問,或者我們可以直接像在這個 1BRC 案例中那樣直接處理本地內存。 另外,.NET 提供了一個較新的 Unsafe 類,它本質上與舊的 unsafe 關鍵字 + 指針做同樣的事情,但使用托管引用。這允許跳過固定數組,同時仍然是 GC 安全的。 不安全選項的存在并不會自動使代碼不安全。有“不安全的 Unsafe”和“安全的 Unsafe”。例如,如果我們確保數組邊界,但不能使 JIT 省略邊界檢查(如在自定義字典案例和 易用的向量化函數#.NET 有非常容易使用的 SIMD 內在函數。我們可以直接使用 SSE2/AVX2/BMI API,或者使用跨平臺跨架構的 .NET 的范圍#.NET 不強迫我們每次都編寫低級的不安全 SIMD 代碼。當性能不重要時,我們可以只使用 LINQ。這很好。即使在這個 1BRC 挑戰中也是如此。真的。 C# 與 F##F# 在默認數據集和10K數據集上都展現出了不俗的性能。我與 F# 的關系頗為復雜。博客上的一篇長篇文章講述了我為何放棄 F# 轉而選擇 C# 的原因。主要是因為性能問題(包括生成的代碼和工具的性能),盡管我喜歡 F# 的語法和社區。 然而,F# 的速度之快并不讓我感到驚訝。它一直在穩步提升,或許有一天我會再次使用 F#. 例如,可恢復代碼和可恢復狀態機是我一直在關注的非常強大的功能。.NET 原生支持的 在這里,我不得不提到,我也通過一系列在2020年的提交,大幅提高了 F# 性能,使其核心的 當然,正如作者所承認的,Frank Krueger 的 F# 實現遠非典型的函數式 F# 代碼。但是,如果你已經在使用 F# 代碼,而且不想碰 C#,你也可以在 F# 中寫類似 C 的代碼。只是不要過度,把它隱藏在純函數里,然后對外保密。 😉 高性能 .NET 代碼作為日常工作#在 ABC Arbitrage,我有機會每天都處理性能關鍵的 .NET 代碼。公司多年前從 C++ 遷移到 .NET,這在可維護性、靈活性、性能和成本方面都是巨大的成功。像1BRC中看到的優化在我們的代碼中并不少見。當然,并非我們所有的代碼都是那樣的。我們還有很多易讀的現代 C# 代碼,甚至 LINQ 也不是禁止的,除非它在交易路徑上。我們總是跟上最新的 .NET 發展,通常在新的主要版本發布后幾周內就會在生產環境中使用(例如,我們已經“長時間”使用 .NET 8 了)。 我們在 ABC 使用并貢獻了許多開源項目,并且我們也維護一些。由 Olivier Coanet 維護的著名高性能線程間消息傳遞庫 Disruptor-net 的 .NET 移植版本是我們交易平臺的核心,處理著每一個市場行情和每一個交易訂單。我貢獻了一些類似上文討論的微優化。由 Lucas Trzesniewski 作為主要貢獻者的輕量級點對點服務總線 Zebus,自2013年以來一直在 ABC Arbitrage 的生產環境中運行,每天處理數億條消息,并協調整個基礎設施。由 Lucas、Romain Verdier 和其他人貢獻的日志庫 ZeroLog,速度之快且零分配,以至于我們甚至可以在最延遲敏感的路徑上輕松使用它。公司倉庫中還有其他項目,以及我們現任和前任同事的許多其他貢獻。我們真正擁抱開源 😍 如果你喜歡使用現代 .NET 編寫高性能代碼,并且想在巴黎享受一點樂趣,為何不加入我們呢?我們有幾個開放的 .NET 職位。如果你感到沖動,就在 dotnet📧abc-arbitrage.com 向我們發送申請吧。
原文信息#原文鏈接:https://hotforknowledge.com/2024/01/13/7-1brc-in-dotnet-even-faster-than-java-cpp/ 轉自博客園https://www.cnblogs.com/InCerry/p/17964592/7-1brc-in-dotnet-even-faster-than-java-cpp 該文章在 2024/1/29 10:43:36 編輯過 |
關鍵字查詢
相關文章
正在查詢... |