간단한 벤치 마크에서 놀라운 성능 향상


97

어제 저는 Christoph Nahr의 ".NET Struct Performance"라는 기사를 발견했습니다.이 기사 는 2 포인트 구조체 ( double튜플) 를 추가하는 방법에 대해 여러 언어 (C ++, C #, Java, JavaScript)를 벤치마킹했습니다 .

결과적으로 C ++ 버전은 실행하는 데 약 1000ms (1e9 반복)가 걸리는 반면 C #은 동일한 컴퓨터에서 ~ 3000ms 미만으로 도달 할 수 없으며 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산술 만 측정하는 것 같습니다 (다른 모든 것이 최적화 된 후).

이상한 물건

이제 이상한 부분이 나옵니다. 루프 외부다른 스톱워치를 추가하기 만하면 (예, 여러 번 재 시도한 후이 미친 단계로 범위를 좁혔습니다) 코드가 세 배 더 빠르게 실행됩니다 .

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

말도 안돼! 그리고 그것은 Stopwatch1 초 후에 끝나는 것을 분명히 볼 수 있기 때문에 잘못된 결과를주는 것과는 다릅니다.

아무도 여기서 무슨 일이 일어날 지 말해 줄 수 있습니까?

(최신 정보)

다음은 동일한 프로그램에있는 두 가지 방법으로, 이유가 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

여기 페이스트 빈이 있습니다. .NET 4.x에서 32 비트 릴리스로 실행해야합니다 (이를 확인하기 위해 코드에 몇 가지 검사가 있습니다).

(업데이트 4)

@Hans의 답변에 대한 @usr의 의견에 따라 두 가지 방법에 대해 최적화 된 분해를 확인했으며 다소 다릅니다.

왼쪽에 Test1, 오른쪽에 Test2

이것은 컴파일러가 이중 필드 정렬이 아닌 첫 번째 경우에 재미있게 행동하기 때문일 수 있음을 보여주는 것 같습니다.

또한 두 개의 변수 (총 오프셋 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 2015-08-20

2
@dbc : 지역 double변수 만 사용하고 structs가 없는 테스트를 수행 했으므로 구조체 레이아웃 / 메소드 호출 비 효율성을 배제했습니다.
Groo

3
RyuJIT를 사용하면 32 비트에서만 발생하는 것으로 보이며 두 번 모두 1600ms를 얻습니다.
leppie 2015-08-20

2
두 가지 방법의 분해를 살펴 보았습니다. 흥미로운 것은 없습니다. Test1은 명백한 이유없이 비효율적 인 코드를 생성합니다. JIT 버그 또는 의도적으로. Test1에서 JIT는 스택에 대한 각 반복에 대한 double을로드하고 저장합니다. x86 float 단위는 80 비트 내부 정밀도를 사용하기 때문에 정확한 정밀도를 보장 할 수 있습니다. 함수 맨 위에 인라인되지 않은 함수 호출이 있으면 다시 빠르게 진행된다는 것을 알았습니다.
usr

답변:


10

업데이트 4 는 문제를 설명합니다. 첫 번째 경우 JIT는 계산 된 값 ( a, b)을 스택에 유지합니다 . 두 번째 경우에는 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이 스택을 사용하도록하고 두 번째 스톱워치는 레지스터를 다시 사용할 수 있도록합니다.


이것은 실제로 원인을 설명하지 않습니다. 내 테스트를 확인하면 추가 테스트가 Stopwatch실제로 더 빨리 실행 되는 것처럼 보입니다 . 그러나 Main메서드 에서 호출되는 순서를 바꾸면 다른 메서드가 최적화됩니다.
Groo

75

프로그램의 "빠른"버전을 항상 얻을 수있는 매우 간단한 방법이 있습니다. 프로젝트> 속성> 빌드 탭에서 "32 비트 선호"옵션을 선택 취소하고 플랫폼 대상 선택이 AnyCPU인지 확인합니다.

32 비트를 선호하지 않습니다. 불행히도 C # 프로젝트에서는 항상 기본적으로 켜져 있습니다. 역사적으로 Visual Studio 도구 집합은 32 비트 프로세스에서 훨씬 더 잘 작동했습니다. 이는 Microsoft가 해결해온 오래된 문제였습니다. 이 옵션을 제거해야 할 때, 특히 VS2015는 새로운 x64 지터와 Edit + Continue에 대한 보편적 인 지원을 통해 64 비트 코드에 대한 마지막 몇 가지 실제 장애물을 해결했습니다.

충분한 수다쟁이, 당신이 발견 한 것은 변수에 대한 정렬 의 중요성입니다 . 프로세서는 그것에 대해 많은 관심을 가지고 있습니다. 변수가 메모리에서 잘못 정렬 된 경우 프로세서는 올바른 순서로 바이트를 가져 오기 위해 추가 작업을 수행하여 바이트를 섞어 야합니다. 두 가지 뚜렷한 오정렬 문제가 있습니다. 하나는 바이트가 여전히 단일 L1 캐시 라인 내부에 있으며 올바른 위치로 이동하는 데 추가 사이클이 필요한 경우입니다. 그리고 여분의 나쁜 것, 당신이 찾은 것, 바이트의 일부는 한 캐시 라인에 있고 일부는 다른 캐시 라인에 있습니다. 이를 위해서는 두 개의 개별 메모리 액세스가 필요하고 서로 붙입니다. 세 배 느립니다.

doublelong유형은 32 비트 프로세스에서 문제 업체입니다. 크기는 64 비트입니다. 따라서 4만큼 잘못 정렬 될 수 있으며 CLR은 32 비트 정렬 만 보장 할 수 있습니다. 64 비트 프로세스에서는 문제가되지 않습니다. 모든 변수는 8에 맞춰집니다. 또한 C # 언어가 그것들을 원자 적이라고 약속 할 수없는 근본적인 이유이기도합니다 . 그리고 왜 이중 배열은 1000 개 이상의 요소가있을 때 Large Object Heap에 할당됩니다. LOH는 8의 정렬 보장을 제공합니다. 그리고 지역 변수를 추가하여 문제를 해결 한 이유를 설명합니다. 객체 참조는 4 바이트이므로 double 변수를 4만큼 이동하여 이제 정렬합니다. 사고로.

32 비트 C 또는 C ++ 컴파일러는 double 이 잘못 정렬되지 않도록 추가 작업을 수행합니다 . 해결해야 할 단순한 문제는 아니지만, 함수가 4에 정렬된다는 보장 만 있다면 함수가 입력 될 때 스택이 잘못 정렬 될 수 있습니다. 이러한 함수의 프롤로그는 8에 정렬되도록 추가 작업을 수행해야합니다. 같은 트릭이 관리되는 프로그램에서 작동하지 않습니다. 가비지 수집기는 정확히 지역 변수가 메모리에있는 위치에 대해 많은 관심을 기울입니다. GC 힙의 개체가 여전히 참조되고 있음을 발견 할 수 있도록 필요합니다. 메소드를 입력 할 때 스택이 잘못 정렬 되었기 때문에 이러한 변수가 4만큼 이동하는 경우 제대로 처리 할 수 ​​없습니다.

이는 또한 SIMD 명령을 쉽게 지원하지 못하는 .NET 지터의 근본적인 문제이기도합니다. 프로세서가 자체적으로 해결할 수없는 정렬 요구 사항이 훨씬 더 높습니다. SSE2는 16의 정렬이 필요하고 AVX는 32의 정렬이 필요합니다. 관리 코드에서는이를 얻을 수 없습니다.

마지막으로, 이로 인해 32 비트 모드에서 실행되는 C # 프로그램의 성능을 예측할 수 없게됩니다. 개체의 필드로 저장된 double 또는 long에 액세스 하면 가비지 수집기가 힙을 압축 할 때 perf가 크게 변경 될 수 있습니다. 메모리에서 개체를 이동하는 이러한 필드는 이제 갑자기 잘못 / 정렬 될 수 있습니다. 물론 매우 무작위로, 머리를 긁는 사람이 될 수 있습니다. :)

음, 간단한 수정은 없지만 하나의 64 비트 코드가 미래입니다. Microsoft가 프로젝트 템플릿을 변경하지 않는 한 지터 강제를 제거하십시오. Ryujit에 대해 더 자신감을 가질 때 다음 버전 일 수도 있습니다.


1
이중 변수가 등록 될 수있을 때 (그리고 Test2에있을 때) 정렬이 어떻게 작동하는지 확실하지 않습니다. Test1은 스택을 사용하고 Test2는 사용하지 않습니다.
usr

2
이 질문은 내가 추적하기에는 너무 빨리 변하고 있습니다. 테스트 결과에 영향을 미치는 테스트 자체를 조심해야합니다. 사과와 오렌지를 비교하려면 테스트 메서드에 [MethodImpl (MethodImplOptions.NoInlining)]을 입력해야합니다. 이제 최적화 프로그램이 두 경우 모두 FPU 스택에 변수를 유지할 수 있음을 알 수 있습니다.
Hans Passant 2015-08-20

4
오, 사실입니다. 메서드 정렬이 생성 된 명령어에 영향을 미치는 이유는 무엇입니까?! 루프 본문에는 차이가 없어야합니다. 모두 레지스터에 있어야합니다. 정렬 프롤로그는 무관해야합니다. 여전히 JIT 버그처럼 보입니다.
usr

3
나는 대답을 상당히 수정해야한다. 나는 내일까지 그것을 얻을 것이다.
Hans Passant 2015-08-20

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)조건을 추가 하면 동일한 효과가 있습니다 (문제를 재현하기 위해 테스트를 빌드하는 동안 우연히 발견됨). Consoleif (!warmup) { Console.WriteLine(...); }
InBetween

@InBetween : 뭔가 수상한 걸 봤어요. 또한 구조체에서만 발생합니다.
leppie

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외부 스톱워치 케이스와 동일하게 ms 단위로 실행됩니다 . 그러나 if (!warmup)조건 을 제거하면 3000ms 단위로 실행됩니다 . 더 이상한 점은 다음 코드도 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.X및에 대한 호출을 제거 a.Y하면 연산 결과가 사용되지 않기 때문에 컴파일러는 루프 내부의 거의 모든 것을 자유롭게 최적화 할 수 있습니다.
Groo 2015.08.20

@Groo : 예, 합리적으로 보이지만 우리가보고있는 다른 이상한 행동을 고려할 때는 그렇지 않습니다. 제거 a.X하고 a.Y그것을 빨리 당신이 포함 때보 다 이동하게되지 않은 if (!warmup)상태 또는 영업 이익의 outerSw그것 그냥 어떤 버그 것은 (차선 속도의 코드 실행을하고 제거, 거리의 최적화되지 것을 의미한다, 3000대신 MS 900밀리 초).
InBetween

2
아, 그래, warmup사실 일 때 속도 향상이 일어난 줄 알았는데 그 경우에는 선이 인쇄되지 않아서 실제로 인쇄 되는 경우 참조 a. 그럼에도 불구하고 벤치마킹 할 때마다 항상 메서드의 끝 부분에서 계산 결과를 참조하고 있는지 확인하고 싶습니다.
Groo
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.