Basit karşılaştırmada garip performans artışı


97

Dün , Christoph Nahr tarafından yazılan ".NET Struct Performance" başlıklı ve iki nokta yapısı ( doubletuples) ekleyen bir yöntem için çeşitli dilleri (C ++, C #, Java, JavaScript) karşılaştıran bir makale buldum .

Anlaşıldığı üzere, C ++ sürümünün çalıştırılması yaklaşık 1000 ms sürerken (1e9 yineleme), C # aynı makinede ~ 3000 ms'nin altına inemez (ve x64'te daha da kötü performans gösterir).

Kendim test etmek için, C # kodunu aldım (ve sadece parametrelerin değere göre geçtiği yöntemi çağırmak için biraz basitleştirdim) ve bir i7-3610QM makinesinde (tek çekirdek için 3.1Ghz artış), 8GB RAM, Win8 üzerinde çalıştırdım. 1, .NET 4.5.2 kullanarak, 32-bit RELEASE (işletim sistemim 64-bit olduğundan x86 WoW64) oluşturun. Bu basitleştirilmiş versiyondur:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

İle Pointbasitçe olarak tanımlanan:

public struct Point 
{
    private readonly double _x, _y;

    public Point(double x, double y) { _x = x; _y = y; }

    public double X { get { return _x; } }

    public double Y { get { return _y; } }
}

Çalıştırmak, makaledeki sonuçlara benzer sonuçlar verir:

Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms

İlk garip gözlem

Yöntemin satır içi olması gerektiğinden, yapıları tamamen kaldırırsam ve her şeyi bir araya getirsem kodun nasıl performans göstereceğini merak ettim:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    public static void Main()
    {
        // not using structs at all here
        double ax = 1, ay = 1, bx = 1, by = 1;

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
        {
            ax = ax + by;
            ay = ay + bx;
        }
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            ax, ay, sw.ElapsedMilliseconds);
    }
}

Ve pratik olarak aynı sonucu elde etti (aslında birkaç denemeden sonra% 1 daha yavaş), bu da JIT-ter'in tüm işlev çağrılarını optimize ederek iyi bir iş çıkardığı anlamına geliyor:

Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms

Bu aynı zamanda, karşılaştırmanın herhangi bir structperformansı ölçmediği ve aslında yalnızca temel doublearitmetiği ölçtüğü anlamına gelir (her şey optimize edildikten sonra).

Garip şeyler

Şimdi garip kısım geliyor. Döngünün dışında yalnızca başka bir kronometre eklersem (evet, birkaç denemeden sonra bu çılgın adımı daralttım), kod üç kat daha hızlı çalışır :

public static void Main()
{
    var outerSw = Stopwatch.StartNew();     // <-- added

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    outerSw.Stop();                         // <-- added
}

Result: x=1000000001 y=1000000001, Time elapsed: 961 ms

Saçma! Ve bu Stopwatchbana yanlış sonuçlar vermek gibi değil çünkü bir saniye sonra bittiğini açıkça görebiliyorum.

Biri bana burada neler olabileceğini söyleyebilir mi?

(Güncelleme)

İşte aynı programda, sebebin JITting olmadığını gösteren iki yöntem:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Test1();
        Test2();

        Console.WriteLine();

        Test1();
        Test2();
    }

    private static void Test1()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    private static void Test2()
    {
        var swOuter = Stopwatch.StartNew();

        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);

        swOuter.Stop();
    }
}

Çıktı:

Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms

Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms

İşte bir pastebin. NET 4.x üzerinde 32 bitlik bir sürüm olarak çalıştırmanız gerekir (bunu sağlamak için kodda birkaç denetim vardır).

(Güncelleme 4)

@ Usr'nin @Hans'ın cevabıyla ilgili yorumlarını takiben, her iki yöntem için de optimize edilmiş demontajı kontrol ettim ve oldukça farklılar:

Solda Test1, sağda Test2

Görünüşe göre bu fark, derleyicinin çift alan hizalamasından ziyade ilk durumda komik davranmasından kaynaklanıyor olabilir.

Ayrıca, iki değişken eklersem (toplam ofset 8 bayt), yine de aynı hız artışını elde ederim - ve artık Hans Passant'ın alan hizalama sözüyle ilgili görünmüyor:

// this is still fast?
private static void Test3()
{
    var magical_speed_booster_1 = "whatever";
    var magical_speed_booster_2 = "whatever";

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster_1);
    GC.KeepAlive(magical_speed_booster_2);
}

1
JIT'in yanı sıra, derleyicinin optimizasyonlarına da bağlıdır, en yeni Ryujit daha fazla optimizasyon yapar ve hatta sınırlı SIMD talimatları desteği sunar.
Felix K.

3
Jon Skeet, yapılarda salt okunur alanlarla ilgili bir performans sorunu buldu: Mikro optimizasyon: salt okunur alanların şaşırtıcı verimsizliği . Özel alanları salt okunur hale getirmeyi deneyin.
dbc

2
@dbc: Yalnızca yerel doubledeğişkenlerle bir test yaptım , structs yok , bu yüzden yapı düzeni / yöntem çağrısı verimsizliklerini ekarte ettim.
Groo

3
RyuJIT ile sadece 32 bitte gerçekleşiyor gibi görünüyor, her iki seferde 1600 ms alıyorum.
leppie

2
Her iki yöntemin de sökülmesine baktım. Görülecek ilginç bir şey yok. Test1, görünürde bir neden olmaksızın verimsiz kod üretir. JIT hatası veya tasarım gereği. Test1'de JIT, yığına her yineleme için çiftleri yükler ve depolar. Bu, x86 kayan birim 80 bit dahili hassasiyet kullandığından kesin hassasiyeti sağlamak için olabilir. İşlevin üst kısmındaki satır içi olmayan herhangi bir işlev çağrısının onu tekrar hızlı hale getirdiğini buldum.
usr

Yanıtlar:


10

Güncelleme 4 sorunu açıklar: ilk durumda, JIT hesaplanan değerleri ( a, b) yığında tutar; ikinci durumda, JIT bunu kayıtlarda tutar.

Aslında, Test1yavaş yavaş nedeniyle çalışır Stopwatch. Ben dayalı aşağıdaki asgari kriter yazdı BenchmarkDotNet :

[BenchmarkTask(platform: BenchmarkPlatform.X86)]
public class Jit_RegistersVsStack
{
    private const int IterationCount = 100001;

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithoutStopwatch()
    {
        double a = 1, b = 1;
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}", a);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithStopwatch()
    {
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // fadd        qword ptr [ebp-14h]
            // fstp        qword ptr [ebp-14h]
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithTwoStopwatches()
    {
        var outerSw = new Stopwatch();
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }
}

Bilgisayarımdaki sonuçlar:

BenchmarkDotNet=v0.7.7.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit  [RyuJIT]
Type=Jit_RegistersVsStack  Mode=Throughput  Platform=X86  Jit=HostJit  .NET=HostFramework

             Method |   AvrTime |    StdDev |       op/s |
------------------- |---------- |---------- |----------- |
   WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 |
      WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 |
 WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 |

Gördüğümüz gibi:

  • WithoutStopwatchhızlı çalışır (çünkü a = a + bkayıtları kullanır)
  • WithStopwatchyavaş çalışır (çünkü a = a + byığını kullanır)
  • WithTwoStopwatchestekrar hızlı çalışır (çünkü a = a + bkayıtları kullanır)

JIT-x86'nın davranışı, büyük miktarda farklı koşullara bağlıdır. Bazı nedenlerden dolayı, ilk kronometre, JIT-x86'yı yığını kullanmaya zorlar ve ikinci kronometre, kayıtları tekrar kullanmasına izin verir.


Bu gerçekten sebebini açıklamıyor. Testlerimi kontrol ederseniz, eki olan testin Stopwatchaslında daha hızlı çalıştığı görülecektir . Ancak, Mainyöntemde çağrıldıkları sırayı değiştirirseniz , diğer yöntem optimize edilir.
Groo

75

Programınızın her zaman "hızlı" sürümünü edinmenin çok basit bir yolu vardır. Proje> Özellikler> Oluştur sekmesi, "32 biti tercih et" seçeneğinin işaretini kaldırın, Platform hedefi seçiminin AnyCPU olduğundan emin olun.

32-bit'i gerçekten tercih etmiyorsunuz, maalesef C # projeleri için her zaman varsayılan olarak açıktır. Geçmişte, Visual Studio araç seti, Microsoft'un aşındırdığı eski bir sorun olan 32 bit işlemlerle çok daha iyi çalıştı. Bu seçeneği kaldırmanın zamanı geldi, VS2015 özellikle son birkaç gerçek yol engelini 64 bit koda yepyeni bir x64 jitter ve Edit + Continue için evrensel destekle ele aldı.

Yeterince gevezelik, keşfettiğiniz şey değişkenler için hizalamanın önemi . İşlemci bunu çok önemsiyor. Bir değişken bellekte yanlış hizalanmışsa, işlemcinin baytları doğru sıraya getirmek için karıştırmak için fazladan iş yapması gerekir. İki farklı yanlış hizalama problemi vardır, bunlardan biri baytların hala tek bir L1 önbellek hattında olduğu ve onları doğru konuma kaydırmak için fazladan bir döngüye mal olduğu durumdur. Ve ekstra kötü olanı, bulduğunuz, baytların bir kısmının bir önbellek satırında ve diğerinin parçası olduğu yerde. Bu, iki ayrı bellek erişimi ve bunları birbirine yapıştırmayı gerektirir. Üç kat daha yavaş.

doubleVe longtürleri 32 bit sürecine Kargaflay bulunmaktadır. 64 bit boyutundadırlar. Ve böylece 4 ile yanlış hizalanabilir, CLR yalnızca 32 bit hizalamayı garanti edebilir. 64 bitlik bir süreçte sorun değildir, tüm değişkenlerin 8'e hizalanması garanti edilir. Ayrıca C # dilinin onlara atomik olma sözü verememesinin altında yatan neden . Ve neden 1000'den fazla öğeye sahip olduklarında Büyük Nesne Yığınında double dizileri tahsis edilir. LOH, 8'lik bir hizalama garantisi sağlar. Ve yerel bir değişken eklemenin sorunu neden çözdüğünü açıklar, bir nesne referansı 4 bayttır, böylece çift değişkeni 4 kadar hareket ettirdi , şimdi hizalanıyor. Kazayla.

Bir 32-bit C veya C ++ derleyici ekstra çalışma sağlamak için gelmez çift teneke yanlış hizalanmış edilemez. Çözülmesi tam olarak basit bir problem değil, tek garantinin 4'e hizalanması olduğu göz önüne alındığında, bir fonksiyon girildiğinde yığın yanlış hizalanabilir. Böyle bir fonksiyonun önsözünün 8'e hizalanması için fazladan iş yapması gerekir. Aynı numara yönetilen bir programda işe yaramaz, çöp toplayıcı, bellekte tam olarak yerel bir değişkenin nerede bulunduğuna çok önem verir. GC yığınındaki bir nesneye hala başvurulduğunu keşfedebilmesi için gereklidir. Yönteme girildiğinde yığın yanlış hizalandığından, böyle bir değişkenin 4'e taşınmasıyla düzgün şekilde başa çıkamaz.

Bu aynı zamanda, SIMD talimatlarını kolayca desteklemeyen .NET titremelerinin altında yatan problemdir. İşlemcinin kendi kendine çözemeyeceği türden çok daha güçlü hizalama gereksinimleri vardır. SSE2 16 hizalama gerektirir, AVX 32 hizalama gerektirir. Bunu yönetilen kodda elde edemezsiniz.

Son olarak, aynı zamanda, bunun 32-bit kipte çalışan bir C # programının performansını çok öngörülemez hale getirdiğine dikkat edin. Bir eriştiğinizde çift veya uzun sonra perf bir nesnenin bir alan olarak saklanan en büyük ölçüde değişebileceğini çöp toplayıcısı yığın sıkıştırır zaman. Hafızadaki nesneleri hareket ettiren, böyle bir alan artık aniden yanlış / hizalı hale gelebilir. Elbette çok rastgele, oldukça kafa karıştırıcı olabilir :)

Pekala, basit bir düzeltme yok, ancak 64 bitlik bir kod gelecek. Microsoft proje şablonunu değiştirmediği sürece jitter zorlamayı kaldırın. Belki bir sonraki versiyon, Ryujit hakkında daha emin hissettiklerinde.


1
Çift değişkenler kaydedildiğinde (ve Test2'de) hizalamanın bununla nasıl oynandığından emin değilim. Test1 yığını kullanır, Test2 kullanmaz.
usr

2
Bu soru takip edemeyeceğim kadar hızlı değişiyor. Testin sonucunu etkileyen testin kendisine dikkat etmelisiniz. Elmaları portakallarla karşılaştırmak için test yöntemlerine [MethodImpl (MethodImplOptions.NoInlining)] koymanız gerekir. Artık optimize edicinin değişkenleri her iki durumda da FPU yığınında tutabildiğini göreceksiniz.
Hans Passant

4
Omg, bu doğru. Metot hizalamasının neden üretilen talimatlar üzerinde herhangi bir etkisi var ?! Döngü gövdesi için herhangi bir fark olmamalıdır. Hepsi kayıtlı olmalıdır. Hizalama prologu alakasız olmalıdır. Hala bir JIT hatası gibi görünüyor.
usr

3
Cevabı önemli ölçüde gözden geçirmeliyim, serseri. Yarın ona ulaşacağım.
Hans Passant

2
@HansPassant, JIT kaynaklarını araştıracak mısınız? Bu eğlenceli olur. Bu noktada tek bildiğim, bunun rastgele bir JIT hatası olduğu.
usr

5

Biraz daralttı (yalnızca 32 bit CLR 4.0 çalışma zamanını etkiliyor gibi görünüyor).

var f = Stopwatch.Frequency;Her şeyi değiştiren yerin yerleştirildiğine dikkat edin .

Yavaş (2700 ms):

static void Test1()
{
  Point a = new Point(1, 1), b = new Point(1, 1);
  var f = Stopwatch.Frequency;

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Hızlı (800 ms):

static void Test1()
{
  var f = Stopwatch.Frequency;
  Point a = new Point(1, 1), b = new Point(1, 1);

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Kodu dokunmadan Stopwatchdeğiştirmek de hızı büyük ölçüde değiştirir. Yöntemin imzasını değiştirmek ve çıktıya Test1(bool warmup)bir koşul eklemek Console: if (!warmup) { Console.WriteLine(...); }aynı etkiye sahiptir (sorunu yeniden üretmek için testlerimi oluştururken buna tökezledi).
57'de

@InBetween: Gördüm, şüpheli bir şey var. Ayrıca yalnızca yapılarda olur.
leppie

4

Jitter'de bir hata var gibi görünüyor çünkü davranış daha da sert. Aşağıdaki kodu göz önünde bulundurun:

public static void Main()
{
    Test1(true);
    Test1(false);
    Console.ReadLine();
}

public static void Test1(bool warmup)
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    if (!warmup)
    {
        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Bu 900, dıştaki kronometre durumunda olduğu gibi ms cinsinden çalışacaktır . Ancak, if (!warmup)koşulu kaldırırsak, 3000ms cinsinden çalışacaktır . Daha da garip olan, aşağıdaki kodun 900ms cinsinden de çalışacak olmasıdır :

public static void Test1()
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
        0, 0, sw.ElapsedMilliseconds);
}

Not Çıktıdan a.Xve a.Yreferansları kaldırdım Console.

Neler olduğu hakkında hiçbir fikrim yok, ama bu benim için oldukça hatalı kokuyor ve dış Stopwatchgörünüşe sahip olup olmamayla ilgili değil, konu biraz daha genel görünüyor.


Eğer aramaları kaldırdığınızda a.Xve a.Yoperasyonun sonuçları kullanılmayan çünkü derleyici, muhtemelen döngü içinde hemen hemen her şeyi çöpe optimize etmek serbesttir.
Groo

@Groo: evet, bu makul görünüyor ama gördüğümüz diğer garip davranışları hesaba kattığınızda değil. Kaldırmak a.Xve koşulu veya OP'leri a.Ydahil ettiğinizden daha hızlı gitmesini if (!warmup)sağlamıyor outerSw; bu, hiçbir şeyi optimize etmemesi anlamına gelir, yalnızca kodun optimum olmayan bir hızda çalışmasını sağlayan her türlü hatayı ortadan kaldırır ( 3000ms yerine 900ms).
Inbetween

2
Ah, tamam, ben ne zaman hız gelişimi oldu sandım warmupdoğruydu, ama bu durumda, satır bile, baskılı değil o durumda bu yüzden yok aslında başvuruları basılı olsun a. Yine de, ne zaman kıyaslama yapsam, her zaman yöntemin sonuna yakın bir yerde hesaplama sonuçlarını referans aldığımdan emin olmak isterim.
Groo
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.