如何使用.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/數據集分組的最佳結果,並用黃色背景突出顯示了按數據集分組的整體最佳結果。

Results summary

https://hotforknowledge.com/2024/01/13/7-1brc-in-dotnet-even-faster-than-java-cpp/results_details.png

可能如預期的那樣,C++對於默認數據集來說是最快的。然而,C++與.NET和Java之間的細微差別,即便是我也覺得有些出乎意料。我確實預料到了.NET會擊敗Java。這並非是第一次發生這種情況。在2016年,Aeron.NET客戶端1.0版本就比Java快,我當時就在現場。

至於Rust,它很可能會成爲總體的領導者。我們只需要等待直到實現是正確的。在撰寫本文時,它還沒有做到。

最終,所有結果應該會趨於某個物理極限和理想的CPU利用率。那麼,一個有趣的問題將是,開發這樣的代碼付出了什麼代價。對我來說,達到當前這個點相當容易,而且代碼非常簡單。

擴展數據集

默認的數據生成器只有少量氣象站名稱,最大長度低於AVX向量大小。這兩個屬性都有助於帶來極端的性能提升。然而,規格說明中提到,可能有多達1萬個獨特的氣象站,它們的名稱最多包含100個UTF8字節。

“我鼓勵參賽者嘗試一下,並將其作爲優化的目標。能夠看到自己位於排行榜頂端無疑是令人興奮的,但設計一個能夠適應超出416個最大長度爲26個字符的車站名稱的解決方案更有趣(也更有用!)”

以上是Marko Topolnik的話,他最近提交了一個更通用的生成器。

爲了更公平的比較,我使用了兩個數據集:

原始的默認數據集是用create_measurements.sh生成的。它的大小超過12GB。這個數據集只有416個氣象站名稱,最大長度爲26個字符。

擴展的數據集包含了1萬個隨機的氣象站名稱,長度可以達到規格所允許的最大值。它是用create_measurements3.sh生成的,大小超過17GB。詳情見上面的引用鏈接。

在表格的底部,你可以看到一個單獨的部分,用於展示那些在默認數據集上表現良好但無法正確處理1萬個數據的結果。這表明這些實現使用了超出規則說明的一些假設,並且不公平地過度優化了特定的情況。例如,最快的Rust版本的作者明確表示它不適用於1萬個數據。他更喜歡先編寫快速的代碼,然後再使其正確。

就我而言,我努力從一開始就編寫最通用的代碼。名稱可以是任意長度,數字可以是任意非科學計數的浮點數,行尾可以是\r\n。就在一週前,我甚至還能用這樣的代碼超越頂級Java結果。

在Java再次變得更快之後(也是在短時間內),我查看了規則,但沒有查看數據。對我來說,數字範圍的限制是最重要的,但氣象站名稱仍然可以是任意長度。代碼會處理衝突,但對於真實世界輸入的氣象站名稱應該很少發生衝突。不過我必須承認,有可能創建人爲數據,這些數據將會發生衝突,並將O(1)的哈希表查找變成O(N)的線性搜索。但即使在這種情況下,它仍然會工作,並且可能比參考實現還要快。也許我稍後會爲了好玩而嘗試這樣做。

方法論

性能測試是在一個安靜的6C/12T Alder Lake CPU上進行的,該CPU的頻率固定在2.5 GHz(關閉睿頻功能),搭配32GB DDR4 3200內存,運行Debian 12系統,並且在Proxmox的特權LXC容器中進行測試。由於基準頻率是固定的,散熱狀況非常好(< 35°C),即使在持續100%負載下也不會發生降頻現象。

時間測量使用了hyperfine --warmup 1 --runs 5 './1brc path/to/file'命令。由於系統中沒有噪聲,結果非常穩定。更多細節請查看結果表下方的鏈接。

對於前兩名的.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上以\n結束,或在Windows上以\r\n結束。重複1B次。

關鍵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

Utf8Span可能是實現高性能的最重要思想。它是一個結構體,存儲了映射文件中UTF8段的指針和長度。數據從未被複制,即使當span作爲字典中的鍵使用時也是如此。它從未從UTF8轉換成UTF16,直到最後在排序和打印最終結果時才轉換。

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);
}

爲了高效地進行哈希表查找,EqualsGetHashCode 成爲最重要的方法。

Span.SequenceEqual()API 通常難以超越,但該調用不會內聯,對於小數據來說太重了。後來我找到了一種簡單的加速方法,但這需要對分塊以及 Equals 本身進行更改。

平均值/最小值/最大值的高效更新

要計算運行平均值,我們需要存儲總和和計數。這裏沒有什麼有趣的,我們都知道,自從編程幼兒園時代起,不是嗎?

更新 最小值/最大值 在數學上甚至更簡單。只需檢查新值是否 小於/大於 之前的 最小值/最大值 ,並相應地更新它們。然而,CPU不喜歡if語句,分支預測錯誤的成本很高。然而,如果你再多想一點從統計學角度來看,對於任何穩定的過程,實際上覆蓋 最小值/最大值 的機會隨着每一次觀測迅速下降。即使是股票價格,它們不是穩定的,也不會每天、每月或每年都達到歷史新高。溫度據說“平均來說”是穩定的,並且至少在幾個世紀的尺度上是穩定的。

下面是一個簡單的模擬,顯示了 最小值/最大值 分支所佔比例的運行情況。請注意,X軸是對數的。平均來說,僅在10次觀測後,兩個分支都不會被採用。

Min/Max branching probabilities

最小值/最大值分支概率

這個分析告訴我們使用分支而不是更重的無分支位運算。我最終嘗試了無分支的選項,但我有統計直覺,並且在第一個以及最終實現中都使用了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 默認字典

Dictionary<TKey,TValue> 幾乎總是足夠好的選擇,也不是首先需要擔心的事情。在我的案例中,它是 Dictionary<Utf8Span,Summary>。.NET 的 JIT(即時編譯器)在沒有我做任何額外努力的情況下,內聯了對 Utf8SpanEqualsGetHashCode 方法的調用。

還有一個非常好但不廣爲人知的高性能工具類 CollectionsMarshal,用於通過引用訪問字典值。其方法 GetValueRefOrAddDefault 對於更新摘要數據特別有幫助。

通過取得摘要值的引用,我們避免了將其複製和更新到棧上/棧中,然後使用常規 API 再複製回字典。記住,Summary 是一個可變的結構體,對其引用調用方法不會導致複製。同時想象一下,如果 Summary 是一個類,那麼即使使用相同的 GetValueRefOrAddDefault,人們也必須檢查空值並創建新實例的不必要開銷。一個默認的結構體無需額外步驟即可準備存儲數據。

// 沒有結構體複製
ref var summary = ref CollectionsMarshal.GetValueRefOrAddDefault(result, nameUtf8Sp, out bool exists);
// 對於類:分支、分配、代碼大小。謝謝,不用了。在 .NET 中,值類型規則。
// if (summary is null) summary = new Summary(); 
summary.Apply(value, !exists); // 這個方法在上面展示過

字節解析

對於解析字節,我只是使用了 .NET 的 Span.IndexOfdouble.Parse() API。

其他一切

性能僅取決於每個線程內的 ProcessChunk。對於其他一切,我們可以編寫任何懶惰或簡單的代碼。例如,我喜歡使用 LINQ/PLINQ 管道,尤其是當我能夠創建一個長的和懶惰的計算時。但我可以很容易地用一個 for 循環打破這樣的管道,而不需要多想,因爲這對性能或可讀性都無關緊要。例如,在實際的第一次提交中,聚合是在循環中進行的,僅僅因爲這樣想起來更簡單,但完成後它被複制粘貼到了 .Aggregate() 方法中。

我很驚訝有些人準備就僅僅使用 (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)

在對代碼進行性能分析後,我發現 double.Parse() 佔用了大約57%的運行時間。字典查找佔了大約24%。

我添加了一個通用的浮點數解析器,它有一個快速路徑,但在檢測到任何不規則情況時會回退到原始方法。所有的 [-]?[0-9]+[.][0-9]+ 浮點數都會走這個實現的快速路徑。

這幾乎使性能翻了一番!還有一些其他的微優化,只需點擊每個部分開頭的“與上一版本的差異”鏈接,即可查看所有更改。

[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之間([-]?[0-9]?[0-9][.][0-9]),行總是以\n結束。

我認爲針對規則進行優化是完全可以接受的。可能有真正的氣象站產生這樣的數據,而代碼在我出生前就已經寫好了。然而,我不喜歡人們開始針對特定的數據集/生成器進行優化。因此,在這次比較中,我沒有接受那些不能處理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

與上一版本的差異: https://github.com/buybackoff/1brc/compare/3644b25..7fdd17a?diff=split&w=#diff-50d5d1069929df17bbf6f330e04035cfaafa17de2e48ab86ce2dbd0de338528aR99-R125

時間:4.040 / 8.609 (10K)

在剩餘部分小於32字節時,手動AVX2在Span中搜索字節,並回退到Span.IndexOf

// 在 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本身使用本機整數。

例如,這是帶有註釋的SpanHelpers.SequenceEqual代碼:

// 優化的基於字節的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)

我花了一些努力嘗試擊敗 Span.SequenceEqual 在小尺寸上的性能。嘗試複製實現的部分並內聯它,但沒有任何效果。然後我有了一個瘋狂的想法,允許代碼讀取超出 Utf8Span.Length 的內容。然後我可以只使用一個 AVX2 向量,將長度之後的字節設置爲零,並比較向量。這將是完全不安全的,並且會導致段錯誤,但只是在十億個觀測值中的最後一個單獨觀測值中。

爲了確保安全,我確保最後一個大塊不是在文件末尾結束,而是至少在距離末尾4 x Vector256<byte>.Count的新行開始處結束。我將剩餘部分複製到一個比數據大得多的內存緩衝區中,這是安全使用的。

優化內循環

提交時的文件: https://github.com/buybackoff/1brc/tree/1051e06052d5a8a95fa0aee461e37d969532aa65/1brc

與上一版本的差異: https://github.com/buybackoff/1brc/compare/9ed3922..1051e06?diff=split&w=

時間:2.204 / 4.811 (10K)

  • 更快的整數解析結合新行索引計算;
  • 更快的 IndexOf,也依賴於讀取超出 Utf8Span.Length 的內容;
  • 更快的 ProcessChunk 循環。

詳細信息待定

性能時間線

以下是討論上述每次更改後性能演變的時間線。

Performance timeline

.NET 非常快

.NET 非常快。而且每個新版本都在變得更快。有些人開玩笑說,對於 .NET 的最佳性能優化就是更新它 - 對於大多數用戶來說,這可能是真的。

每次發佈新版本時,.NET 團隊的 Stephen Toub 都會發表一篇巨大的博客文章,介紹自上次發佈以來的每一個微小性能改進。這些文章的龐大體量表明,他們非常關心性能的提升。

不安全代碼

.NET 允許你直接使用指針。這使得它類似於 C 語言。如果內循環受 CPU 限制,所有數組都可以被固定並在沒有邊界檢查的情況下訪問,或者我們可以直接像在這個 1BRC 案例中那樣直接處理本地內存。

另外,.NET 提供了一個較新的 Unsafe 類,它本質上與舊的 unsafe 關鍵字 + 指針做同樣的事情,但使用託管引用。這允許跳過固定數組,同時仍然是 GC 安全的。

不安全選項的存在並不會自動使代碼不安全。有“不安全的 Unsafe”和“安全的 Unsafe”。例如,如果我們確保數組邊界,但不能使 JIT 省略邊界檢查(如在自定義字典案例和 GetAtUnsafe 中),那麼爲什麼我們要支付邊界檢查的成本呢?在這種情況下,它將是安全的 Unsafe。通過謹慎使用,局部不安全的代碼可以變成全局安全的應用程序。

易用的向量化函數

.NET 有非常容易使用的 SIMD 內在函數。我們可以直接使用 SSE2/AVX2/BMI API,或者使用跨平臺跨架構的 Vector128<T>/Vector256<T> 類型。或者更通用的 Vector<T> 類型,它甚至隱藏了向量大小,並且可以在舊的 .NET 運行時上無縫工作。

.NET 的範圍

.NET 不強迫我們每次都編寫低級的不安全 SIMD 代碼。當性能不重要時,我們可以只使用 LINQ。這很好。即使在這個 1BRC 挑戰中也是如此。真的。

C# 與 F#

F# 在默認數據集和10K數據集上都展現出了不俗的性能。我與 F# 的關係頗爲複雜。博客上的一篇長篇文章講述了我爲何放棄 F# 轉而選擇 C# 的原因。主要是因爲性能問題(包括生成的代碼和工具的性能),儘管我喜歡 F# 的語法和社區。

然而,F# 的速度之快並不讓我感到驚訝。它一直在穩步提升,或許有一天我會再次使用 F#. 例如,可恢復代碼和可恢復狀態機是我一直在關注的非常強大的功能。.NET 原生支持的 task { ... } 計算表達式就利用了這一特性。

在這裏,我不得不提到,我也通過一系列在2020年的提交,大幅提高了 F# 性能,使其核心的 MapSet 數據結構(內部是 AVL 樹)的速度大大加快。

當然,正如作者所承認的,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 向我們發送申請吧。

ABC Arbitrage 是一家法國金融服務公司,專注於套利交易。成立於1995年,它主要從事股票市場的套利策略,包括統計套利、事件驅動套利和其他相關的交易策略。套利是一種利用不同市場之間的價格差異來獲利的交易策略,而ABC Arbitrage 就是在這一領域內的專家。

這家公司利用先進的數學模型和自動化交易系統來發現並執行套利機會,從而爲其客戶提供收益。它可能會參與跨市場交易、跨商品交易以及其他複雜的金融衍生品交易,以期在不同金融工具之間的價格差異中獲利。

法語是美麗而有用的,但如果你英語流利,它不是硬性要求。至少對我來說是這樣。如果你不在歐盟,但想搬到那裏,那麼通過歐盟藍卡的法律手續非常簡單。

原文信息

作者:Victor Baybekov

原文鏈接:https://hotforknowledge.com/2024/01/13/7-1brc-in-dotnet-even-faster-than-java-cpp/

.NET性能優化交流羣

相信大家在開發中經常會遇到一些性能問題,苦於沒有有效的工具去發現性能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流羣,但是由於各種原因一直都沒創建,現在很高興的在這裏宣佈,我創建了一個專門交流.NET性能優化經驗的羣組,主題包括但不限於:

  • 如何找到.NET性能瓶頸,如使用APM、dotnet tools等工具
  • .NET框架底層原理的實現,如垃圾回收器、JIT等等
  • 如何編寫高性能的.NET代碼,哪些地方存在性能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET性能問題和寶貴的性能分析優化經驗。目前一、二羣已滿,現開放三羣。

微信羣(添加我爲好友): ls1075

另外也創建了QQ羣,羣號: 687779078,歡迎大家加入。

image-20230703203249615

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