単純なベンチマークでの奇妙なパフォーマンスの増加


97

昨日 、2つのポイント構造体(タプル)を追加するメソッドのいくつかの言語(C ++、C#、Java、JavaScript)のベンチマークとなった「.NET Struct Performance」というタイトルのChristoph Nahrの記事を見つけましたdouble

結局のところ、C ++バージョンは実行に約1000ミリ秒(1e9反復)かかりますが、C#は同じマシン上で〜3000ミリ秒を下回ることはできません(x64ではパフォーマンスがさらに低下します)。

自分でテストするために、C#コード(およびパラメーターが値で渡されるメソッドのみを呼び出すように少し簡略化)を取り、i7-3610QMマシン(シングルコアの場合は3.1Ghzブースト)、8GB RAM、Win8で実行しました。 1、.NET 4.5.2を使用して、RELEASEビルド32ビット(私のOSは64ビットであるためx86 WoW64)。これは簡略版です:

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

Point単純に次のように定義されます。

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

これを実行すると、記事と同様の結果が得られます。

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

最初の奇妙な観察

メソッドをインライン化する必要があるので、構造体を完全に削除し、全体を単純にインライン化すると、コードがどのように機能するのか疑問に思いました。

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

そして、実質的に同じ結果が得られました(数回の再試行後、実際には1%遅くなります)。つまり、JIT-terはすべての関数呼び出しを最適化しているようです。

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

また、ベンチマークはstructパフォーマンスを測定していないようであり、実際には基本的なdouble計算のみを測定しているように見えます(他のすべてが最適化された後)。

奇妙なもの

ここで奇妙な部分が来ます。ループの外別のストップウォッチを追加しただけの場合(はい、数回の再試行後、このクレイジーなステップに絞り込みました)、コードは3倍速く実行されます

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

ばかげている!そしてStopwatch、1秒後に終了することがはっきりとわかるので、間違った結果が出ているようには見えません。

誰かここで何が起こっているのか教えてもらえますか?

(更新)

同じプログラムの2つの方法を以下に示します。これは理由がJITでないことを示しています。

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

出力:

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

これがペーストビンです。.NET 4.xで32ビットリリースとして実行する必要があります(これを確認するためにコードにいくつかのチェックがあります)。

(更新4)

@Hansの回答に対する@usrのコメントに続いて、両方のメソッドの最適化された逆アセンブリを確認しましたが、どちらもかなり異なります。

左側がTest1、右側がTest2

これは、違いが、ダブルフィールドアライメントではなく、コンパイラが最初のケースでおかしな動作をしていることが原因である可能性があることを示しているようです。

また、2つの変数(合計8バイトのオフセット)を追加しても、速度は同じですが、Hans Passantによるフィールドアライメントの言及とは関係がないようです。

// 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のほかに、コンパイラーの最適化にも依存します。最新のRyujitはより多くの最適化を実行し、限られたSIMD命令のサポートさえ導入しました。
Felix K.

3
Jon Skeetが構造体の読み取り専用フィールドにパフォーマンスの問題を発見しました:マイクロ最適化:読み取り専用フィールドの驚くべき非効率性。プライベートフィールドを非読み取り専用にしてみてください。
dbc

2
@dbc:ローカルdouble変数のみを使用してテストを行い、structsは使用しないため、構造体のレイアウト/メソッド呼び出しの非効率性を排除しました。
Groo、

3
32ビットでのみ発生するようですが、RyuJITを使用すると、どちらの場合も1600msが発生します。
leppie 2015

2
私は両方の方法の分解を見てきました。見て面白いものは何もありません。Test1は明らかな理由なしに非効率的なコードを生成します。JITバグまたは仕様。Test1では、JITは各反復のdoubleをロードしてスタックに格納します。x86 floatユニットは80ビットの内部精度を使用するため、これは正確な精度を確保するためです。関数の最上部にあるインライン化されていない関数呼び出しにより、再び高速になることがわかりました。
usr

回答:


10

Update 4は問題を説明しています。最初のケースでは、JITは計算された値(ab)をスタックに保持します。2番目のケースでは、JITはそれをレジスターに保持します。

実際、が原因でTest1動作が遅くなりStopwatchます。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);
    }
}

私のコンピューター上の結果:

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 |

ご覧のとおり:

  • WithoutStopwatchすばやく動作します(a = a + bレジスタを使用するため)
  • WithStopwatch動作が遅い(a = a + bスタックを使用するため)
  • WithTwoStopwatches再びすばやく動作します(a = a + bレジスタを使用するため)

JIT-x86の動作は、さまざまな条件に大きく依存します。何らかの理由で、最初のストップウォッチがJIT-x86にスタックの使用を強制し、2番目のストップウォッチがレジスターを再び使用できるようにします。


これは本当に原因を説明するものではありません。私のテストを確認すると、追加のテストの方がStopwatch実際に速く実行されているように見えます。ただし、Mainメソッドで呼び出される順序を入れ替えると、他のメソッドが最適化されます。
Groo、2015

75

プログラムの「高速」バージョンを常に取得する非常に簡単な方法があります。[プロジェクト]> [プロパティ]> [ビルド]タブで、[32ビット優先]オプションのチェックを外し、プラットフォームターゲットの選択がAnyCPUであることを確認します。

あなたは本当に32ビットを好まない、残念ながらC#プロジェクトではデフォルトで常にオンになっています。歴史的に、Visual Studioツールセットは、32ビットプロセスではるかにうまく機能しました。これは、Microsoftが欠けている古い問題です。そのオプションを削除する時が来たとき、VS2015は特に、最新のx64ジッターとEdit + Continueのユニバーサルサポートにより、64ビットコードへの最後のいくつかの実際の障害に対処しました。

十分おしゃべりですが、発見したのは、変数のアライメントの重要性です。プロセッサーはそれを大いに気にします。変数がメモリ内で誤って配置されている場合、プロセッサはバイトをシャッフルして正しい順序に配置するために追加の作業を行う必要があります。2つの明確な不整合の問題があります。1つは、バイトが単一のL1キャッシュライン内にあり、正しい位置にシフトするために余分なサイクルが必要になることです。そして、さらに悪いのは、バイトの一部が1つのキャッシュラインにあり、一部が別のキャッシュラインにある、あなたが見つけたものです。そのためには、2つの個別のメモリアクセスとそれらの接着が必要です。3倍遅い。

タイプは32ビットプロセスでのトラブルメーカーです。サイズは64ビットです。そして、このように4でミスアライメントされる可能性があり、CLRは32ビットアライメントしか保証できません。64ビットプロセスの問題ではなく、すべての変数が8に揃えられることが保証されています。また、C#言語がそれらの変数をアトミックであると約束できない根本的な理由もあります。また、1000を超える要素があるときにdoubleの配列がラージオブジェクトヒープに割り当てられるのはなぜですか。LOHは8のアラインメント保証を提供します。また、ローカル変数を追加すると問題が解決した理由を説明します。オブジェクト参照が4バイトなので、double変数を4 だけ移動してアラインメントします。偶然です。doublelong

32ビットのCまたはC ++コンパイラーは、doubleが誤って整列されないようにするための追加の作業を行います。厳密に解決するのは簡単な問題ではありません。関数に入るときにスタックがずれる可能性があります。ただし、4に位置揃えされることが唯一の保証であるため、そのような関数のプロローグは、8に位置揃えするために追加の作業を行う必要があります。同じトリックはマネージプログラムでは機能しません。ガベージコレクターは、ローカル変数がメモリ内のどこに配置されているかを非常に重視します。GCヒープ内のオブジェクトがまだ参照されていることを検出できるようにするために必要です。メソッドに入ったときにスタックがずれていたため、このような変数が4だけ移動しても適切に処理できません。

これは、SIMD命令を簡単にサポートできない.NETジッターの根本的な問題でもあります。それらには、プロセッサがそれ自体では解決できない種類のはるかに強いアライメント要件があります。SSE2には16のアラインメントが必要です。AVXには32のアラインメントが必要です。マネージコードではそれを取得できません。

最後に重要なことですが、これにより、32ビットモードで実行されるC#プログラムのパフォーマンスが非常に予測不能になることにも注意してください。オブジェクトのフィールドとして格納されているdoubleまたはlongにアクセスすると、ガベージコレクターがヒープを圧縮するときに、perfが大幅に変化する可能性があります。これにより、メモリ内のオブジェクトが移動します。そのようなフィールドは、突然ミス/整列する可能性があります。もちろん非常にランダムですが、かなり頭を悩ますことができます:)

まあ、単純な修正はありませんが、1つの64ビットコードが未来です。Microsoftがプロジェクトテンプレートを変更しない限り、ジッター強制を削除します。おそらく次のバージョンは、Ryujitに自信を持っていると思います。


1
double変数を登録できる(およびTest2にある)ときに、これがどのように整列されるかわかりません。Test1はスタックを使用しますが、Test2は使用しません。
usr

2
この質問はあまりにも速く変化しているため、追跡することができません。テストの結果に影響を与えるテスト自体に注意する必要があります。リンゴをオレンジと比較するには、テストメソッドに[MethodImpl(MethodImplOptions.NoInlining)]を配置する必要があります。どちらの場合も、オプティマイザが変数をFPUスタックに保持できることがわかります。
Hans Passant

4
ええ、それは本当です。メソッドの配置が生成される命令に影響を与えるのはなぜですか?ループ本体に違いはありません。すべてがレジスターにある必要があります。アラインメントプロローグは無関係である必要があります。まだJITバグのようです。
usr

3
私は答えを大幅に修正しなければならない、つまらない。明日までに着きます。
Hans Passant

2
@HansPassantはJITソースを掘り下げるつもりですか?楽しそう。この時点で私が知っているのは、それがランダムなJITバグであることだけです。
usr

5

一部を絞り込みました(32ビットCLR 4.0ランタイムにのみ影響するようです)。

の配置にvar f = Stopwatch.Frequency;すべての違いがあることに注意してください。

遅い(2700ms):

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

高速(800ms):

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

触れずにコードをStopwatch変更すると、速度も大幅に変わります。メソッドのシグネチャをに変更Test1(bool warmup)し、Console出力に条件を追加します。これif (!warmup) { Console.WriteLine(...); }も同じ効果があります(問題を再現するためのテストを作成しているときにこれに遭遇しました)。
2015

@InBetween:私が見た、何かが怪しい。また、構造体でのみ発生します。
leppie 2015

4

振る舞いがさらに奇妙なため、ジッターにはいくつかのバグがあるようです。次のコードを検討してください。

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

これは900ミリ秒単位で実行され、外側のストップウォッチの場合と同じです。ただし、if (!warmup)条件を削除すると、3000ミリ秒単位で実行されます。さらに奇妙なのは、次のコードも900msで実行されることです。

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

出力から参照a.Xa.Y参照を削除したことに注意してくださいConsole

私は何が起こっているのか分かりませんが、これは私にはかなりバグがあり、アウターのStopwatch有無に関係なく、問題はもう少し一般化されているようです。


a.Xand への呼び出しを削除するとa.Y、操作の結果が使用されないため、コンパイラーはおそらくループ内のほとんどすべてを自由に最適化できます。
Groo、

@Groo:はい、それは理にかなっているように見えますが、私たちが見ている他の奇妙な動作を考慮に入れるとそうではありません。削除するa.Xa.Yif (!warmup)条件またはOP を含める場合よりも速くなりません。つまり、outerSw何も最適化されず、バグが排除されるため、コードが最適ではない速度(3000msではなく900ms)で実行されます。
2015

2
ああ、[OK]を、私は時に速度向上が起こったと思っwarmup本当だったが、その場合にはラインがさえ印刷されませんので、それはケース、実際に参照を印刷しますa。それでも、ベンチマークを行うときはいつでも、メソッドの最後近くのどこかで常に計算結果を参照していることを確認したいのです。
Groo、
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.