C#於多執行緒狀態的增速方式(CacheLine)

無意間在網路上看到一個以前都沒有注意到的坑,就是當我們把程式設計成多執行緒狀態,其實在CPU內部運算盡管是多核心,但其CPU Cache的存取其實還是共用的,這意味著如果你再多執行緒之間共享某個物件實例,在更替其欄位值(或相關的參數異動)時,其實CPU內部的各執行緒只是不斷地在洗(重載)內部的Cache而已。

重載意味著必須重新耗用大量的執行週期等待,這也代表著你設計的多執行緒程式在CPU中的運算裡,Cache的增速機制已經完全失效了。在程式設計的領域裡面,這樣的結果叫做「False Sharing」。

那C#要怎麼控制物件在記憶體中位址呢?

在這邊要先提一下CPU內所謂的Cache Line機制,也就是說無論你的CPU中的Cache是幾K,其實他是用每一條管線來切割看待之,也就是每條Cache Line都是64Bytes。

在一般的情況下,C#的設計裡你並不需要去理會物件在記憶體中的配置,否則你乾脆回去寫C++算了,但其實.NET還是留了一條路讓語言可以有機會管理記憶體的配置,那就是System.Runtime.InteropServices命名空間裡面的:StructLayout與FieldOffset啦。(注意:FieldOffset只能套用在Field喔!)例如:

//把ParaA欄位偏移 8 Bytes
[System.Runtime.InteropServices.FieldOffset(8)]
int ParaA;

//把ParaB欄位偏移 16 Bytes
[System.Runtime.InteropServices.FieldOffset(16)]
int ParaB;

//把ParaC欄位偏移 8 Bytes
[System.Runtime.InteropServices.FieldOffset(8)]
int ParaC;  //這個寫法很pointer,ParaC取出來的值其實就是ParaA

CPU Cache Line的計算方式

假設某程式有四個工作要丟給CPU(假定有四個核心),欲共用一個物件但存取不同欄位(long: 8 Bytes),每個欄位自我累加一千萬次,那麼我們的Cache Line的偏移量計算如下(故意讓每個Cache Line前56 Bytes被忽略,用以佔滿64 Bytes):

Cache Line Size 
|---64Bytes--|

Cache Line: 0
0      56    63
|--56--|--8--|

Cache Line: 1
64    120    127 
|--56--|--8--|

Cache Line: 2
128   184    191
|--56--|--8--|

Cache Line: 3
192   248    255
|--56--|--8--|

範例程式碼如下:

public class NonPadding
{
  public long Core1;
  public long Core2;
  public long Core3;
  public long Core4;
}

//具備偏移物件
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public class Padding
{
  [System.Runtime.InteropServices.FieldOffset(56)]
  public long Core1;
  [System.Runtime.InteropServices.FieldOffset(120)]
  public long Core2;
  [System.Runtime.InteropServices.FieldOffset(184)]
  public long Core3;
  [System.Runtime.InteropServices.FieldOffset(248)]
  public long Core4;
}

class Program
{
  static void Main(string[] args)
  {
    var oTasks = new System.Collections.Generic.List<System.Threading.Tasks.Task>();
    int iTimes = 10_000_000;

    Console.WriteLine("---- Normal ----");
    var oNormal = new NonPadding();
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oNormal.Core1++; }
      oSW.Stop();
      WriteLine($"Core1: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oNormal.Core2++; }
      oSW.Stop();
      WriteLine($"Core2: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oNormal.Core3++; }
      oSW.Stop();
      WriteLine($"Core3: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oNormal.Core4++; }
      oSW.Stop();
      WriteLine($"Core4: {oSW.ElapsedMilliseconds}ms");
    }));

    System.Threading.Tasks.Task.WaitAll(oTasks.ToArray());
    oTasks.Clear();

    Console.WriteLine("---- Padding ----");
    var oPadding = new Padding();
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oPadding.Core1++; }
      oSW.Stop();
      WriteLine($"Core1: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oPadding.Core2++; }
      oSW.Stop();
      WriteLine($"Core2: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oPadding.Core3++; }
      oSW.Stop();
      WriteLine($"Core3: {oSW.ElapsedMilliseconds}ms");
    }));
    oTasks.Add(System.Threading.Tasks.Task.Run(() =>
    {
      var oSW = System.Diagnostics.Stopwatch.StartNew();
      for (var t = 0; t < iTimes; t++) { oPadding.Core4++; }
      oSW.Stop();
      WriteLine($"Core4: {oSW.ElapsedMilliseconds}ms");
    }));

    System.Threading.Tasks.Task.WaitAll(oTasks.ToArray());
    oTasks.Clear();

    ReadKey();
  }
}

執行結果的畫面大概長的如下:

---- Normal ----
Core1: 137ms
Core4: 190ms
Core2: 213ms
Core3: 215ms
---- Padding ----
Core1: 29ms
Core3: 31ms
Core2: 32ms
Core4: 33ms

以上的結果我是使用Intel i7-4790 @ 3.6GHz跑出來的結果,可以發現結果大約增速6~7倍速度,如果將以上的觀念用在實際的運算上,可謂是效果驚人啊!

附註:有些CPU的CacheLine並非64Bytes,要動態求取的話,可以參考網路的相關文章。

C# FieldOffset StructLayout CPU CacheLine SpeedUp MultiCore FalseSharing FalseShare