天天看點

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

寫在前面

好久沒有寫部落格了,一直在不斷地探索響應式DDD,又get到了很多新知識,解惑了很多老問題,最近讀了Martin Fowler大師一篇非常精彩的部落格

The LMAX Architecture

,裡面有一個術語Mechanical Sympathy,姑且翻譯成軟硬體協同程式設計(Hardware and software working together in harmony),很有感悟,說的是要把程式設計與底層硬體協同起來,這樣對于開發低延遲、高并發的系統特别地重要,為什麼呢,今天我們就來講講CPU的高速緩存。

電腦的緩存系統

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

電腦的緩存系統分了很多層級,從外到内依次是主記憶體、三級高速緩存、二級高速緩存、一級高速緩存,是以,在我們的腦海裡,覺點磁盤的讀寫速度是很慢的,而記憶體的讀寫速度确是快速的,的确如此,從上圖磁盤和記憶體距離CPU的遠近距離就看出來。這裡先說明一個概念,主記憶體被所有CPU共享;三級緩存被同一個插槽内的CPU所共享;單個CPU獨享自己的一級、二級緩存,即高速緩存。CPU是真正做事情的地方,它會先從高速緩存中去擷取所需的資料,如果找不到,再去三級緩存中查找,如果還是找不到最終就去會主記憶體查找,如果每一次都這樣來來回回地取資料,那麼無疑是非常耗時。如果能夠把資料緩存到高速緩存中就好了,這樣不僅CPU第一次就可以直接從高速緩存中命中資料,而且每個CPU都獨占自己的高速緩存,多線程下也不存在臨界資源的問題,這才是真正的低延遲,但是這個地方對我們而言根本不透明,腫麼辦?

探索高速緩存的構造

我們先來看一張使用魯大師檢測的處理器資訊截圖,如下:

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

從上圖可以看到,CPU高速緩存(一、二級)的存儲單元為Line,大小為64 bytes,也就是說無論我們的資料大小是多少,高速緩存都是以64 bytes為機關緩存資料,比如一個8位的long類型數組,即使隻有第一位有資料,每次高速緩存加載資料的時候,都會順帶把後面7位資料也一起加載(因為數組内元素的記憶體位址是連續的),這就是底層硬體CPU的工作機制,是以我們要利用這個天然的優勢,讓資料獨占整個緩存行,這樣CPU命中的緩存行中就一定有我們的資料。

示例

使用不同的線程數,對一個long類型的數值計數500億次。

備注:統計分析圖表和總結在最後。

1. 一般的實作方式

大多數程式員都會這樣子構造資料,老鐵沒毛病。

代碼

///// <summary>
///// CPU僞共享高速緩存行條目(僞共享)
///// </summary>
public class FalseSharingCacheLineEntry
{
    public long Value = 0L;
}           

單線程

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 1508.56 毫秒。

雙線程

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 4460.40 毫秒。

三線程

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 7719.02 毫秒。

四線程

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 10404.30 毫秒。

2. 獨占緩存行,直接命中高速緩存。

2.1 直接填充

/// <summary>
/// CPU高速緩存行條目(直接填充)
/// </summary>
public class CacheLineEntry
{
    protected long P1, P2, P3, P4, P5, P6, P7;
    public long Value = 0L;
    protected long P9, P10, P11, P12, P13, P14, P15;
}           

為了保證高速緩存行中一定有我們的資料,是以前後都填充7個long。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 1516.33 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 1529.97 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 1563.65 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 1616.12 毫秒。

2.2 記憶體布局填充

作為一個C#程式員,必須寫出優雅的代碼,可以使用StructLayout、FieldOffset來控制class、struct的記憶體布局。

備注:就是上面直接填充的優雅實作方式而已。

/// <summary>
/// CPU高速緩存行條目(控制記憶體布局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    private long _value;

    public long Value
    {
        get => _value;
        set => _value = value;
    }
}           

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 2008.12 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 2046.33 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 2081.75 毫秒。

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

平均響應時間 = 2163.092 毫秒。

3. 統計分析

.NET高性能程式設計之C#玩轉CPU高速緩存(附示例)

上面的圖表已經一目了然了吧,一般實作方式的持續時間随線程數呈線性增長,多線程下表現的非常糟糕,而通過直接、記憶體布局方式填充了資料後,響應時間與線程數的多少沒有無關,達到了真正的低延遲。其中直接填充資料的方式,效率最高,記憶體布局方式填充次之,在四線程的情況下,一般實作方式持續時間為10.4秒多,直接填充資料的方式為1.6秒,記憶體布局填充方式為2.2秒,延遲還是比較明顯,為什麼會有這麼大的差距呢?

刨根問底

在C#下,一個long類型占8 byte,對于一般的實作方式,在多線程的情況下,隸屬于每個獨立線程的資料會共用同一個緩存行,是以隻要有一個線程更新了緩存行的資料,那麼整個緩存行就自動失效,這樣就導緻CPU永遠無法直接從高速緩存中命中資料,每次都要經過一、二、三級緩存到主記憶體中重新擷取資料,時間就是被浪費在了這樣的來來回回中。而對資料進行填充後,隸屬于每個獨立線程的資料不僅被緩存到了CPU的高速緩存中,而且每個資料都獨占整個緩存行,其他的線程更新資料,并不會導緻自己的緩存行失效,是以每次CPU都可以直接命中,不管是單線程也好,還是多線程也好,隻要線程數小于等于CPU的核數都和單線程一樣的快速,正如我們經常在一些性能測試軟體,都會看到的建議,線程數最好小于等于CPU核數,最多為CPU核數的兩倍,這樣壓測的結果才是比較準确的,現在明白了吧。

最後來看一下大師們總結的未命中緩存的測試結果

從CPU到 大約需要的 CPU 周期 大約需要的時間
主存 約60-80納秒
QPI 總線傳輸 (between sockets, not drawn) 約20ns
L3 cache 約40-45 cycles 約15ns
L2 cache 約10 cycles, 約3ns
L1 cache 約3-4 cycles 約1ns
寄存器

源碼參考:

https://github.com/justmine66/MDA/blob/master/tests/MDA.Test.Disruptor/FalseSharingTest.cs

延伸閱讀

Magic cache line padding

補充

感謝@ firstrose同學主動測試後的提醒,大家應該向他學習,帶着疑惑看部落格,不明白的自己動手測試。對于記憶體布局填充方式,去掉屬性後,經過測試性能與直接填充方式幾乎無差别了,不過本示例代碼僅僅作為一個測試參考,主要目的是給大家布道如何利用CPU高速緩存工作機制,通過緩存行的填充來避免假共享,進而寫出真正低延遲的代碼。

/// <summary>
/// CPU高速緩存行條目(控制記憶體布局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    public long Value;
}           

總結

編寫單、多線程下表現都相同的代碼,曆來都是非常困難的,需要不斷地從深度、廣度上積累知識,學無止境,無癡迷,不成功,希望大家能有所收獲。

寫在最後

如果有什麼疑問和見解,歡迎評論區交流。

如果你覺得本篇文章對您有幫助的話,感謝您的【推薦】。

如果你對.NET高性能程式設計感興趣的話可以【關注我】,我會定期的在部落格分享我的學習心得。

歡迎轉載,請在明顯位置給出出處及連結。