c# - exception種類 - try語法




Try-catch加速我的代碼? (4)

我寫了一些代碼來測試try-catch的影響,但看到了一些令人驚訝的結果。

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

在我的電腦上,這一直打印出大約0.96的值。

當我用這樣的try-catch塊封裝Fibo()中的for循環時:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

現在它一直打印出0.69 ... - 它實際上運行得更快! 但為什麼?

注意:我使用發布配置對其進行編譯,並直接運行EXE文件(在Visual Studio之外)。

編輯: Jon Skeet的優秀分析表明,try-catch在某種程度上導致x86 CLR以更有利的方式使用CPU寄存器(並且我認為我們還不明白為什麼)。 我證實了Jon的發現,即x64 CLR沒有這種差異,並且它比x86 CLR更快。 我還測試了在Fibo方法中使用int類型而不是long類型,然後x86 CLR與x64 CLR一樣快。

更新:看起來這個問題已經被Roslyn修復。 相同的機器,相同的CLR版本 - 使用VS 2013進行編譯時問題仍然存在,但是使用VS 2015編譯時問題會消失。


Jon的反彙編顯示,這兩個版本之間的區別在於,快速版本使用一對寄存器( esi,edi )來存儲緩慢版本不存在的一個局部變量。

JIT編譯器對包含try-catch塊的代碼與不包含代碼的代碼的寄存器使用做出了不同的假設。 這導致它做出不同的寄存器分配選擇。 在這種情況下,這有利於使用try-catch塊的代碼。 不同的代碼可能會導致相反的效果,所以我不會將其視為通用加速技術。

最後,很難判斷哪個代碼最快運行。 像寄存器分配和影響它的因素是這樣的低級實現細節,我不明白任何特定的技術如何可靠地生成更快的代碼。

例如,請考慮以下兩種方法。 他們從一個真實的例子改編而成:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

一個是另一個的通用版本。 用StructArray替換泛型將使方法相同。 因為StructArray是一個值類型,所以它得到它自己的通用方法的編譯版本。 然而,實際運行時間比專用方法長得多,但僅限於x86。 對於x64,時序非常相似。 在其他情況下,我也觀察到了x64的差異。


一位專門理解堆棧使用優化的Roslyn工程師看了一眼,並向我報告,C#編譯器生成局部變量存儲的方式與JIT編譯器註冊方式之間的交互似乎存在問題在相應的x86代碼中進行調度。 結果是在當地人的加載和存儲上代碼生成不理想。

由於某些原因,我們都不清楚,當JITter知道塊處於try-protected區域時,會避免有問題的代碼生成路徑。

這很奇怪。 我們會跟進JITter團隊,看看我們是否可以得到一個錯誤輸入,以便他們可以解決這個問題。

此外,我們正在為Roslyn改進C#和VB編譯器的算法,以確定何時可以使本地人“短暫” - 也就是只是推送並彈出堆棧,而不是在棧上分配特定位置激活的持續時間。 我們相信,JITter能夠更好地完成寄存器分配,而且如果我們能夠更好地提供有關本地人何時可以“死”的更好的提示。

感謝您將這引起我們的注意,並為奇怪的行為道歉。


這看起來像內聯變糟的情況。 在x86內核上,抖動具有用於通用存儲本地變量的ebx,edx,esi和edi寄存器。 ecx寄存器在靜態方法中變得可用,它不需要存儲 。 eax寄存器通常用於計算。 但是這些是32位寄存器,對於long類型的變量,它必須使用一對寄存器。 edx:用於計算的eax和用於存儲的edi:ebx。

在緩慢版本的反彙編中,哪個是突出的,既不使用edi也不使用ebx。

當抖動找不到足夠的寄存器來存儲局部變量時,它必須生成代碼來從堆棧幀中加載和存儲它們。 這會降低代碼速度,它會阻止名為“寄存器重命名”的處理器優化,這是一種使用多個寄存器副本並允許超標量執行的內部處理器核心優化技巧。 它允許幾條指令同時運行,即使它們使用相同的寄存器。 沒有足夠的寄存器是x86內核的常見問題,在x64中有8個額外的寄存器(r9到r15)。

抖動將盡其所能應用另一代碼生成優化,它會嘗試內聯您的Fibo()方法。 換句話說,不要調用該方法,而是在Main()方法中為內聯方法生成代碼。 非常重要的優化,例如,免費提供C#類的屬性,為它們提供字段的性能。 它避免了調用方法和設置堆棧幀的開銷,節省了幾納秒。

有幾個規則可以確定何時可以內聯一個方法。 他們沒有完全記錄,但已在博客文章中提到。 一個原則是當方法體太大時不會發生。 這從內聯失敗中獲益,它會產生太多的代碼,並不適合在L1指令緩存中使用。 這裡適用的另一個硬性規則是,當一個方法包含try / catch語句時,它不會被內聯。 背後的背景是異常的實現細節,它們捎帶回到Windows對內置支持基於堆棧幀的SEH(結構異常處理)。

寄存器分配算法在抖動中的一種行為可以通過使用此代碼來推斷。 它似乎意識到抖動何時嘗試內聯一種方法。 一個規則似乎使用只有edx:eax寄存器對可以用於具有long類型局部變量的內聯代碼。 但不是edi:ebx。 毫無疑問,因為這對調用方法的代碼生成太不利,edi和ebx都是重要的存儲寄存器。

所以你得到快速版本是因為抖動知道方法體包含try / catch語句。 它知道它永遠不能內聯,所以很容易使用edi:ebx來存儲長變量。 你得到了緩慢的版本,因為抖動並不知道內聯不起作用。 它只是為方法體生成代碼後才發現的。

然後,這個缺陷是它沒有返回並重新生成該方法的代碼。 鑑於時間的限制,這是可以理解的。

這種減速在x64上不會發生,因為其中一個有8個寄存器。 另一個是因為它可以在一個寄存器中存儲一個長(如rax)。 當你使用int而不是long時,減速不會發生,因為抖動在選擇寄存器方面有更多的靈活性。


那麼,你對時間進行計時的方式對我來說看起來非常討厭。 整個循環的時間會更加明智:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

這樣你就不會受到微小時間,浮點運算和累積誤差的影響。

做了這些改變後,看看“非catch”版本是否仍然比“catch”版本慢。

編輯:好吧,我自己嘗試過 - 我看到了相同的結果。 很奇怪。 我想知道try / catch是否禁用了一些不良內聯,但是使用[MethodImpl(MethodImplOptions.NoInlining)]並沒有幫助...

基本上你需要查看cordbg下的優化JIT代碼,我懷疑...

編輯:多一點的信息:

  • 把try / catch放在n++; 線仍然可以提高性能,但不會像將其放在整個區塊中一樣
  • 如果你捕捉到一個特定的異常(在我的測試中是ArgumentException ),它仍然很快
  • 如果你在catch塊中打印異常,它仍然很快
  • 如果在catch塊中重新拋出異常,它會再次變慢
  • 如果你使用finally塊而不是catch塊,它會再次變慢
  • 如果你使用finally塊 catch塊,它很快

奇怪的...

編輯:好的,我們已經拆卸...

這是使用C#2編譯器和.NET 2(32位)CLR,用mdbg進行反彙編(因為我的機器上沒有cordbg)。 即使在調試器下,我仍然可以看到相同的性能效果。 快速版本在變量聲明和返回語句之間的所有內容上都使用try塊,只需一個catch{}處理程序。 顯然,慢版本是相同的,除非沒有try / catch。 調用代碼(即Main)在這兩種情況下都是相同的,並且具有相同的程序集表示形式(所以它不是內聯問題)。

快速版本的反彙編代碼:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

反彙編緩慢版本的代碼:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

在每種情況下, *顯示調試器在簡單的“步入”中輸入的位置。

編輯:好的,我現在已經查看了代碼,我想我可以看到每個版本是如何工作的......我相信較慢的版本會更慢,因為它使用更少的寄存器和更多的堆棧空間。 對於n的小數值可能更快 - 但是當循環佔用大部分時間時,速度會變慢。

可能try / catch塊會強制更多的寄存器被保存和恢復,所以JIT也會將這些寄存器用於循環......這恰好可以提高整體性能。 目前還不清楚JIT是否在“正常”代碼中使用了不多的寄存器是合理的決定。

編輯:剛在我的x64機器上試過這個。 x64 CLR比這個代碼上的x86 CLR快得多(速度快3-4倍),而在x64下,try / catch塊並沒有明顯的區別。





performance-testing