어제 저는 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
말도 안돼! 그리고 그것은 Stopwatch
1 초 후에 끝나는 것을 분명히 볼 수 있기 때문에 잘못된 결과를주는 것과는 다릅니다.
아무도 여기서 무슨 일이 일어날 지 말해 줄 수 있습니까?
(최신 정보)
다음은 동일한 프로그램에있는 두 가지 방법으로, 이유가 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의 의견에 따라 두 가지 방법에 대해 최적화 된 분해를 확인했으며 다소 다릅니다.
이것은 컴파일러가 이중 필드 정렬이 아닌 첫 번째 경우에 재미있게 행동하기 때문일 수 있음을 보여주는 것 같습니다.
또한 두 개의 변수 (총 오프셋 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);
}
답변
업데이트 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이 스택을 사용하도록하고 두 번째 스톱워치는 레지스터를 다시 사용할 수 있도록합니다.
답변
프로그램의 “빠른”버전을 항상 얻을 수있는 매우 간단한 방법이 있습니다. 프로젝트> 속성> 빌드 탭에서 “32 비트 선호”옵션을 선택 취소하고 플랫폼 대상 선택이 AnyCPU인지 확인합니다.
32 비트를 선호하지 않습니다. 불행히도 C # 프로젝트에서는 항상 기본적으로 켜져 있습니다. 역사적으로 Visual Studio 도구 집합은 32 비트 프로세스에서 훨씬 더 잘 작동했습니다. 이는 Microsoft가 해결해온 오래된 문제였습니다. 이 옵션을 제거해야 할 때, 특히 VS2015는 새로운 x64 지터와 Edit + Continue에 대한 보편적 인 지원을 통해 64 비트 코드에 대한 마지막 몇 가지 실제 장애물을 해결했습니다.
충분한 수다쟁이, 당신이 발견 한 것은 변수에 대한 정렬 의 중요성입니다 . 프로세서는 그것에 대해 많은 관심을 가지고 있습니다. 변수가 메모리에서 잘못 정렬 된 경우 프로세서는 올바른 순서로 바이트를 가져 오기 위해 추가 작업을 수행하여 바이트를 섞어 야합니다. 두 가지 뚜렷한 오정렬 문제가 있습니다. 하나는 바이트가 여전히 단일 L1 캐시 라인 내부에 있으며 올바른 위치로 이동하는 데 추가 사이클이 필요한 경우입니다. 그리고 여분의 나쁜 것, 당신이 찾은 것, 바이트의 일부는 한 캐시 라인에 있고 일부는 다른 캐시 라인에 있습니다. 이를 위해서는 두 개의 개별 메모리 액세스가 필요하고 서로 붙입니다. 세 배 느립니다.
double
및 long
유형은 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에 대해 더 자신감을 가질 때 다음 버전 일 수도 있습니다.
답변
일부 범위를 좁혔습니다 (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);
}
답변
동작이 더 현명하기 때문에 지터에 약간의 버그가있는 것 같습니다. 다음 코드를 고려하십시오.
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);
}
참고 출력 에서 a.X
및 a.Y
참조를 제거했습니다 Console
.
나는 무슨 일이 일어나고 있는지 전혀 모르겠지만 이것은 나에게 꽤 버그 냄새가 나고 외부가 Stopwatch
있는지 여부 와 관련이 없으며 문제가 좀 더 일반화 된 것처럼 보입니다.