Kodumu hızlandırmayı dene-yakala?


1503

Try-catch'in etkisini test etmek için bazı kodlar yazdım, ancak bazı şaşırtıcı sonuçlar gördüm.

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

Bilgisayarımda, bu sürekli 0.96 civarında bir değer yazdırıyor.

Ben for döngüsü Fibo () içinde böyle bir try-catch blok ile sarın:

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

Şimdi sürekli 0.69 yazdırıyor ... - aslında daha hızlı çalışıyor! Ama neden?

Not: Bunu Release yapılandırmasını kullanarak derledim ve doğrudan EXE dosyasını (Visual Studio dışında) çalıştırdım.

DÜZENLEME: Jon Skeet'in mükemmel analizi , try-catch'in bir şekilde x86 CLR'nin CPU kayıtlarını bu özel durumda daha uygun bir şekilde kullanmasına neden olduğunu gösteriyor (ve nedenini henüz anlamadığımızı düşünüyorum). Jon'un x64 CLR'nin bu farkı olmadığını ve x86 CLR'den daha hızlı olduğunu tespit ettiğini doğruladım. Ayrıca intFibo yöntemi içinde longtürler yerine türler kullanarak test yaptım ve sonra x86 CLR, x64 CLR kadar hızlıydı.


GÜNCELLEME: Görünüşe göre bu sorun Roslyn tarafından giderildi. Aynı makine, aynı CLR sürümü - VS 2013 ile derlendiğinde sorun yukarıdaki gibi kalır, ancak VS 2015 ile derlendiğinde sorun ortadan kalkar.


111
@Lloyd, “aslında daha hızlı çalışıyor! Ama neden?” Sorusuna cevap almaya çalışıyor.
Andreas Niedermair

137
Yani, şimdi "Yutma İstisnaları" kötü bir uygulama olmaktan iyi bir performans optimizasyonuna geçti: P
Luciano

2
Bu kontrolsüz veya kontrol edilmiş bir aritmetik bağlamda mı?
Random832

7
@ taras.roshko: Eric'e bir kötülük yapmak istemese de, bu gerçekten bir C # sorusu değil - bu bir JIT derleyici sorusu. Nihai zorluk, x86 JIT'in try / catch bloğu ile olduğu kadar çok kayıt kullanmamasının nedenini bulmaktır .
Jon Skeet

63
Tatlı, bu yüzden bu deneme yakalamalarını içersek daha da hızlı gidebiliriz değil mi?
Chuck Pinkert

Yanıtlar:


1053

Biri Roslyn yığını kullanımının anlaşılması optimizasyonu konusunda uzmanlaşmış mühendisler C # derleyicisi yerel değişken mağazaları oluşturur şekilde arasındaki etkileşimde bir sorun ve bir yol var gibi görünüyor bana bu ve raporlar bir göz attım JIT derleyicisi kayıt yapar karşılık gelen x86 kodunda zamanlama. Sonuç, yerlilerin yükleri ve depolarında yetersiz kod üretilmesidir.

Nedense hepimiz için belirsiz, JITter bloğun deneme korumalı bir bölgede olduğunu bildiğinde sorunlu kod oluşturma yolundan kaçınılır.

Bu oldukça garip. JITter ekibini takip edeceğiz ve bunu düzeltmeleri için bir hata girip giremeyeceğimizi göreceğiz.

Ayrıca, yerlilerin ne zaman "geçici" yapılabileceğini belirlemek için C # ve VB derleyicilerinin algoritmalarında Roslyn için iyileştirmeler üzerinde çalışıyoruz - yani, yığında belirli bir konum tahsis etmek yerine, yığına sadece itildi ve atıldı aktivasyon süresi. JITter'ın daha iyi bir kayıt tahsisi işi yapabileceğine ve daha önce yerlilerin ne zaman "ölü" olabileceğine dair daha iyi ipuçları verirsek, inanıyoruz.

Bunu dikkatimize sunduğunuz için teşekkürler ve tuhaf davranış için özür dileriz.


8
Her zaman neden C # derleyicisinin bu kadar yabancı yerliler oluşturduğunu merak ettim. Örneğin, yeni dizi başlatma ifadeleri her zaman bir yerel oluşturur, ancak hiçbir zaman yerel oluşturmak için gerekli değildir. JITter'ın ölçülebilir derecede daha yüksek performans kodu üretmesine izin veriyorsa, belki de C # derleyicisi gereksiz yerliler oluşturma konusunda biraz daha dikkatli olmalıdır ...
Timwi

33
@Timwi: Kesinlikle. Optimize edilmemiş kodda, derleyici, hata ayıklamayı kolaylaştırdığı için büyük bir terk ile gereksiz yerliler üretir. Optimize edilmiş kodda, mümkünse gereksiz geçiciler kaldırılmalıdır. Maalesef, geçici olarak eleme optimizasyon cihazını yanlışlıkla optimize ettiğimiz yıllar boyunca birçok hata yaşadık. Bahsedilen mühendis, Roslyn için tüm bu kodu sıfırdan tamamen yeniden yapıyor ve sonuç olarak Roslyn kod üretecinde çok daha iyi optimize edilmiş davranışlara sahip olmalıyız.
Eric Lippert

24
Bu konuda hiç bir hareket oldu mu?
Robert Harvey

10
Görünüşe göre Roslyn bunu düzeltti.
Eren Ersönmez

56
Buna "JITter böceği" deme fırsatını kaçırdınız.
mbomb007

734

Şeyleri zamanlama şeklim bana çok kötü geliyor. Sadece tüm döngüyü zamanlamak çok daha mantıklı olurdu:

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

Bu şekilde küçük zamanlamalar, kayan nokta aritmetiği ve biriken hataların merhametine kapılmıyorsunuz.

Bu değişikliği yaptıktan sonra, "yakalamayan" sürümün "yakalama" sürümünden hala daha yavaş olup olmadığına bakın.

EDIT: Tamam, kendim denedim - ve aynı sonucu görüyorum. Çok tuhaf. Ben denemek / yakalamak bazı kötü inlining devre dışı bırakıyor olup olmadığını merak, ama [MethodImpl(MethodImplOptions.NoInlining)]bunun yerine kullanarak yardımcı olmadı ...

Temelde cordbg altında optimize JITted koduna bakmak gerekir, şüpheliyim ...

DÜZENLEME: Birkaç bilgi daha:

  • Deneme / yakalamayı sadece n++;çizginin etrafına koymak performansı hala iyileştirir, ancak tüm bloğun etrafına koymak kadar değil
  • Belirli bir istisnayı yakalarsanız ( ArgumentExceptiontestlerimde) hala hızlı
  • Özel durumu catch bloğunda yazdırırsanız hala hızlı
  • Yakalama bloğundaki istisnayı yeniden yazarsanız tekrar yavaş olur
  • Bir catch bloğu yerine son olarak bir blok kullanırsanız tekrar yavaşlar
  • Sonunda bir blok ve bir yakalama bloğu kullanırsanız, hızlı

Tuhaf...

EDIT: Tamam, sökme var ...

Bu benim makinemde cordbg yok gibi mdbg ile sökme, C # 2 derleyici ve .NET 2 (32 bit) CLR kullanıyor. Hata ayıklayıcı altında bile aynı performans etkilerini görüyorum. Hızlı sürüm try, değişken bildirimleri ve return deyimi arasındaki her şeyi, sadece bir catch{}işleyici ile bir blok kullanır . Açıkçası yavaş sürümü denemek / yakalamak dışında aynıdır. Çağıran kod (yani Ana) her iki durumda da aynıdır ve aynı montaj temsilcisine sahiptir (bu nedenle satır içi bir sorun değildir).

Hızlı sürüm için demonte kod:

 [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

Yavaş sürüm için demonte kod:

 [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

Her durumda, *hata ayıklayıcının basit bir "adım adım" içine girdiği şovlar.

DÜZENLEME: Tamam, şimdi kodu inceledim ve her sürümün nasıl çalıştığını görebildiğimi düşünüyorum ... ve daha az kayıt ve daha fazla yığın alanı kullandığı için daha yavaş sürümün daha yavaş olduğuna inanıyorum. Bunun küçük değerleri için nmuhtemelen daha hızlıdır - ancak döngü zamanın çoğunu aldığında daha yavaştır.

Muhtemelen try / catch bloğu daha fazla kaydediciyi kaydedilmeye ve geri yüklenmeye zorlar , böylece JIT döngü için de kullanır ... bu da genel performansı iyileştirir. JIT'in "normal" kodda çok fazla kayıt kullanmamasının makul bir karar olup olmadığı açık değildir .

EDIT: Bunu sadece x64 makinemde denedim. X64 CLR, bu koddaki x86 CLR'den çok daha hızlıdır (yaklaşık 3-4 kat daha hızlı) ve x64 altında try / catch bloğu fark edilir bir fark yaratmaz.


4
@GordonSimpson, ancak yalnızca belirli bir istisnanın yakalandığı durumda, diğer tüm istisnalar yakalanmayacaktır, bu nedenle denememek için hipotezinize yüklenen her şey yine de gerekli olacaktır.
Jon Hanna

45
Kayıt tahsisinde bir fark gibi görünüyor. Hızlı sürüm esi,edi, yığın yerine uzun ürünlerden birini kullanmayı başarır . Kullandığı ebxyavaş versiyonu kullanan sayaç olarak esi.
Jeffrey Sax

13
@JeffreySax: Sadece hangi kayıtların kullanıldığı değil, kaç kayıt kullanıldığı. Yavaş sürüm, daha az kayıta dokunarak daha fazla yığın alanı kullanır. Neden olduğu hakkında hiçbir fikrim yok ...
Jon Skeet

2
Kayıtlar ve yığınlar açısından CLR istisna çerçeveleri nasıl ele alınır? Birini ayarlamak bir şekilde bir kaydı kullanmak için serbest bırakmış olabilir mi?
Random832

4
IIRC x64, x86'dan daha fazla kaydediciye sahiptir. Gördüğünüz hızlanma, x86 altındaki ek kayıt kullanımını zorla deneme / yakalama ile tutarlı olacaktır.
Dan Is Fiddling By Firelight

116

Jon'un demontajı, iki sürüm arasındaki farkın, hızlı sürümün esi,edi, yavaş sürümün olmadığı yerel değişkenlerden birini saklamak için bir çift register ( ) kullanmasıdır.

JIT derleyicisi, try-catch bloğu içeren kodlara karşılık olmayan kod kullanımı için kayıt kullanımı ile ilgili farklı varsayımlar yapar. Bu, farklı kayıt ayırma seçimleri yapmasına neden olur. Bu durumda, kodu try-catch bloğu ile destekler. Farklı kod ters etkiye yol açabilir, bu yüzden bunu genel amaçlı bir hızlandırma tekniği olarak saymazdım.

Sonunda, hangi kodun en hızlı çalışacağını söylemek çok zor. Kayıt tahsisi ve onu etkileyen faktörler, herhangi bir belirli tekniğin nasıl daha hızlı kod üretebileceğini göremediğim kadar düşük seviyeli uygulama detaylarıdır.

Örneğin, aşağıdaki iki yöntemi göz önünde bulundurun. Gerçek hayattan bir örnekten uyarlandılar:

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

Biri diğerinin genel bir versiyonudur. Genel türün değiştirilmesiStructArray yöntemlerin aynı olmasını sağlar. StructArrayBir değer türü olduğundan , genel yöntemin kendi derlenmiş sürümünü alır. Ancak gerçek çalışma süresi, özel yöntemlerden önemli ölçüde daha uzundur, ancak yalnızca x86 için geçerlidir. X64 için zamanlamalar hemen hemen aynıdır. Diğer durumlarda, x64 için de farklılıklar gözlemledim.


6
Bununla birlikte ... Try / Catch kullanmadan farklı kayıt ayırma seçeneklerini zorlayabilir misiniz? Ya bu hipotez için bir test olarak mı yoksa hız için genel bir ince ayar denemesi olarak mı?
WernerCD

1
Bu özel durumun farklı olmasının birkaç nedeni vardır. Belki de denemek. Belki de değişkenlerin içsel bir kapsamda yeniden kullanılması gerçeğidir. Özel sebep ne olursa olsun, aynı kod farklı bir programda çağrılsa bile korunmaya güvenemeyeceğiniz bir uygulama detayıdır.
Jeffrey Sax

4
@WernerCD C ve C ++ 'nın (A) birçok modern derleyici tarafından göz ardı edildiğini ve (B) C #' a koymamaya karar verildiğini gösteren bir anahtar kelimeye sahip olduğunu söyleyebilirim, bunun bizim bir şey olmadığını öne sürüyor ' daha doğrudan bir şekilde göreceğim.
Jon Hanna

2
@WernerCD - Sadece montajı kendiniz
yazarsanız

72

Bu, kötüye giden bir inlining vakası gibi görünüyor. Bir x86 çekirdeğinde, jitter, yerel değişkenlerin genel amaçlı depolanması için ebx, edx, esi ve edi kaydına sahiptir. Ecx yazmacı statik yönteminde kullanılabilir hale, bu depolamak zorunda değildir bu . Hesaplamalar için sıklıkla eax kaydı gereklidir. Ancak bunlar 32 bitlik kayıtlardır, uzun tipteki değişkenler için bir çift kayıt kullanmalıdır. Hangi edx: eax için hesaplamalar ve edi: ebx için depolama.

Yavaş versiyonun demontajında ​​göze çarpan şey, ne edi ne de ebx kullanılmıyor.

Jitter, yerel değişkenleri saklamak için yeterli kayıt bulamadığında, bunları yığın çerçevesinden yüklemek ve depolamak için kod oluşturmalıdır. Kodu yavaşlatan, bir kaydın birden çok kopyasını kullanan ve süper skaler yürütmeye izin veren bir dahili işlemci çekirdeği optimizasyon hilesi olan "kayıt yeniden adlandırma" adlı bir işlemci optimizasyonunu önler. Bu, aynı kaydı kullandıklarında bile birkaç talimatın aynı anda çalışmasına izin verir. Yeterli kayıt olmaması, 8 ekstra kayıt (r9 ila r15) olan x64'te ele alınan x86 çekirdeğinde yaygın bir sorundur.

Jitter, başka bir kod oluşturma optimizasyonu uygulamak için elinden geleni yapacaktır, Fibo () yönteminizi satır içi yapmaya çalışacaktır. Başka bir deyişle, yönteme çağrı yapmayın, Main () yönteminde satır içi yöntemin kodunu oluşturun. Biri için, bir alanın mükemmelliğini veren bir C # sınıfının özelliklerini ücretsiz yapan oldukça önemli optimizasyon. Yöntemin çağrılması ve yığın çerçevesinin ayarlanması yükünü önler, birkaç nanosaniye tasarruf eder.

Bir yöntemin tam olarak ne zaman satır içine alınabileceğini belirleyen birkaç kural vardır. Bunlar tam olarak belgelenmemiştir ancak blog yayınlarında belirtilmiştir. Bir kural, yöntem gövdesi çok büyük olduğunda gerçekleşmeyeceğidir. Bu, satır içi işlemden elde edilen kazancı yener, L1 komut önbelleğine sığmayan çok fazla kod üretir. Burada geçerli olan bir başka zor kural, bir yöntemin bir try / catch deyimi içerdiğinde satır içine alınmamasıdır. Bunun arkasındaki arka plan, istisnaların bir uygulama detayıdır, Windows'ın yığın çerçeve tabanlı SEH (Yapı İstisna İşleme) için yerleşik desteğine piggy-back yaparlar.

Değişkendeki yazmaç ayırma algoritmasının bir davranışı, bu kodla oynamaktan çıkarılabilir. Jitterun bir yöntemi ne zaman satır içine almaya çalıştığının farkında gibi görünüyor. Kurallardan biri, uzun yerel değişkenlere sahip satır içi kod için yalnızca edx: eax kayıt çiftinin kullanılabileceği görülmektedir. Ama edi değil: ebx. Şüphesiz, bu çağrı yöntemi için kod üretimi için çok zararlı olacağından, hem edi hem de ebx önemli depolama kayıtlarıdır.

Hızlı sürümü alırsınız, çünkü jitter yöntem gövdesinin try / catch deyimlerini içerdiğini bilir. Asla satır içine alınamayacağını bilir, bu nedenle uzun değişken için depolama için edi: ebx'i kolayca kullanır. Yavaş versiyonu var çünkü jitter, satır içi işlemenin işe yaramayacağını bilmiyordu. Sadece yöntem gövdesi için kod üretildikten sonra öğrenilir.

Kusur, geri dönmemesi ve yöntemin kodunu yeniden oluşturmamasıdır . Hangi anlaşılması gereken zaman kısıtlamaları göz önüne alındığında.

Bu yavaşlama x64'te gerçekleşmez, çünkü biri için 8 kayıt daha vardır. Bir diğeri için sadece bir kayıtta (rax gibi) uzun süre saklayabildiğinden. Ve uzun yerine int kullandığınızda yavaşlama meydana gelmez, çünkü jitter kayıt toplamada çok daha fazla esnekliğe sahiptir.


21

Bunu bir yorum olarak koymuş olurum, çünkü bu durumun büyük olasılıkla olacağından emin değilim, ama hatırladığım gibi, bir try / hariç ifadesi, çöp imha mekanizmasında bir değişiklik içeriyor derleyici, nesne belleği ayırmalarını yığının dışında yinelemeli bir şekilde temizlediği için çalışır. Bu durumda temizlenecek bir nesne olmayabilir veya for döngüsü, çöp toplama mekanizmasının farklı bir toplama yöntemini uygulamak için yeterli olarak tanıdığı bir kapak oluşturabilir. Muhtemelen hayır, ama başka bir yerde tartıştığını görmemiştim, bir söz değer düşündüm.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.