web-dev-qa-db-ja.com

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

昨日、2つのポイント構造体(double tuplesを追加するメソッドの複数の言語(C++、C#、Java、JavaScript)のベンチマークを行った "。NET Struct Performance"というタイトルのクリストフナールの記事 を見つけました。 )。

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

自分でテストするために、C#コードを取得し(パラメーターが値で渡されるメソッドのみを呼び出すように少し簡略化して)、i7-3610QMマシン(シングルコアで3.1Ghzブースト)、8GB RAM、Win8で実行しました。 1、.NET 4.5.2を使用して、リリースビルド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つのメソッドを次に示します。これは、理由がJITtingではないことを示しています。

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

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

(更新4)

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

Test1 on the left, Test2 on the right

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

また、two変数(合計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);
}
96
Groo

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番目のストップウォッチはレジスタの再使用を許可します。

10
AndreyAkinshin

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

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

十分なおしゃべりは、変数に対するalignmentの重要性を発見したことです。プロセッサはそれを大事にしています。メモリ内で変数の位置がずれている場合、プロセッサはバイトをシャッフルして適切な順序でバイトを取得するために余分な作業を行う必要があります。 2つの明確なミスアライメントの問題があります。1つは、バイトがまだ単一のL1キャッシュライン内にある場所で、適切な位置にシフトするのに余分なサイクルがかかります。そして、余分な悪いもの、あなたが見つけたもの、バイトの一部が1つのキャッシュラインにあり、一部が別のキャッシュラインにあります。それには、2つの別々のメモリアクセスとそれらの結合が必要です。 3倍遅い。

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

32ビットのCまたはC++コンパイラーは、doubleがずれないようにするための特別な作業を行います。解決するのは簡単な問題ではありません。関数が入力されると、スタックが4に揃えられることだけが保証されているため、スタックの位置がずれることがあります。同じトリックはマネージプログラムでは機能しません。ガベージコレクターは、ローカル変数がメモリ内のどこにあるかを非常に気にかけます。 GCヒープ内のオブジェクトがまだ参照されていることを発見できるようにするために必要です。メソッドが入力されたときにスタックの位置がずれていたため、このような変数が4ずつ移動した場合、適切に処理できません。

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

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

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

75
Hans Passant

一部を絞り込みました(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);
}
5
leppie

振る舞いがさらにひどいため、Jitterにはバグがあるようです。次のコードを検討してください。

_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_ msで実行されます。ただし、if (!warmup)条件を削除すると、_3000_ msで実行されます。さらに奇妙なのは、次のコードも_900_ msで実行されることです。

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

Console出力から_a.X_および_a.Y_参照を削除しました。

何が起こっているのかわかりませんが、これはかなりバグがあり、外側のStopwatchを持っているかどうかに関係なく、問題は少し一般化されているようです。

4
InBetween