try-catch의 영향을 테스트하기 위해 몇 가지 코드를 작성했지만 놀라운 결과가 나타났습니다.
static void Main(string[] args)
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;
long start = 0, stop = 0, elapsed = 0;
double avg = 0.0;
long temp = Fibo(1);
for (int i = 1; i < 100000000; i++)
{
start = Stopwatch.GetTimestamp();
temp = Fibo(100);
stop = Stopwatch.GetTimestamp();
elapsed = stop - start;
avg = avg + ((double)elapsed - avg) / i;
}
Console.WriteLine("Elapsed: " + avg);
Console.ReadKey();
}
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
return fibo;
}
내 컴퓨터에서는 약 0.96의 값을 일관되게 인쇄합니다.
try-catch 블록으로 Fibo () 내부에 for 루프를 래핑하면 다음과 같습니다.
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
try
{
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
}
catch {}
return fibo;
}
이제 지속적으로 0.69를 인쇄합니다. 실제로 실제로 더 빠르게 실행됩니다! 그런데 왜?
참고 : 릴리스 구성을 사용하여 이것을 컴파일하고 EXE 파일 (Visual Studio 외부)을 직접 실행했습니다.
편집 : Jon Skeet의 우수한 분석 에 따르면 try-catch는 x86 CLR 이이 특정 경우에 CPU 레지스터를 더 유리한 방식으로 사용하게합니다 (그리고 우리는 아직 이유를 아직 이해하지 못했다고 생각합니다). Jon은 x64 CLR에 이러한 차이가 없으며 x86 CLR보다 빠르다는 사실을 확인했습니다. 또한 int
유형 대신 Fibo 메서드 내부의 유형을 사용하여 테스트 long
한 다음 x86 CLR이 x64 CLR과 마찬가지로 빠릅니다.
업데이트 : 이 문제는 Roslyn에 의해 수정 된 것으로 보입니다. 동일한 컴퓨터, 동일한 CLR 버전-VS 2013으로 컴파일 할 때 위와 같이 문제가 유지되지만 VS 2015로 컴파일하면 문제가 사라집니다.
답변
스택 사용 최적화를 전문 으로하는 Roslyn 엔지니어 중 한 명이 이것을 살펴보고 C # 컴파일러가 로컬 변수 저장소를 생성하는 방식과 JIT 컴파일러가 등록 하는 방식 사이의 상호 작용에 문제가있는 것 같습니다. 해당 x86 코드에서 스케줄링. 결과적으로 지역 주민의 짐과 상점에서 차선책으로 코드를 생성합니다.
어떤 이유로 우리 모두에게 불분명 한 경우, 문제가있는 코드 생성 경로는 JITter가 블록이 try-protected 영역에 있음을 알면 피할 수 있습니다.
꽤 이상합니다. 우리는 JITter 팀과 함께 버그를 입력하여 문제를 해결할 수 있는지 알아볼 것입니다.
또한 Roslyn에서 로컬이 “일시적으로”만들어 질 수있는 시점 (즉, 스택의 특정 위치를 할당하지 않고 스택에서 푸시 및 팝)을 결정할 수 있도록 C # 및 VB 컴파일러의 알고리즘을 개선하기 위해 노력하고 있습니다. 활성화 기간. 우리는 JITter가 더 나은 레지스터 할당 작업을 수행 할 수 있다고 생각합니다. 현지인이 “죽은”시기에 대해 더 나은 힌트를 주면 어떨까요.
이것을 우리의 관심에 가져와 주셔서 감사합니다. 이상한 행동에 대해 사과드립니다.
답변
글쎄, 당신이 물건을 타이밍하는 방식은 나에게 꽤 불쾌하게 보입니다. 전체 루프를 시간을 맞추는 것이 훨씬 합리적입니다.
var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);
그렇게하면 작은 타이밍, 부동 소수점 산술 및 누적 오류에 얽매이지 않습니다.
변경 한 후에는 “비 캐치”버전이 여전히 “캐치”버전보다 느린 지 확인하십시오.
편집 : 좋아, 나는 그것을 직접 시도했다-나는 같은 결과를보고있다. 매우 이상합니다. try / catch가 잘못된 인라인을 비활성화했는지 궁금했지만 [MethodImpl(MethodImplOptions.NoInlining)]
대신 사용하면 도움이되지 않았습니다 …
기본적으로 cordbg에서 최적화 된 JITted 코드를 봐야합니다.
편집 : 몇 가지 추가 정보 :
- try / catch를
n++;
라인 주위에두면 여전히 전체 성능을 향상 시키지만 전체 블록 주위에 두는 것만으로는 성능이 향상되지 않습니다. ArgumentException
테스트에서 특정 예외를 발견하면 여전히 빠릅니다.- catch 블록에 예외를 인쇄하면 여전히 빠릅니다.
- catch 블록에서 예외를 다시 발생 시키면 다시 느려집니다.
- catch 블록 대신 finally 블록을 사용하면 다시 느려집니다.
- catch 블록 뿐만 아니라 finally 블록을 사용하면 빠릅니다.
기묘한…
편집 : 좋아, 우리는 분해했다 …
이것은 C # 2 컴파일러와 .NET 2 (32 비트) CLR을 사용하고 mdbg로 분해합니다 (내 컴퓨터에 cordbg가 없기 때문에). 디버거에서도 동일한 성능 효과를 볼 수 있습니다. 빠른 버전은 처리기 try
만으로 변수 선언과 return 문 사이의 모든 것을 둘러싼 블록을 사용 catch{}
합니다. 분명히 느린 버전은 try / catch를 제외하고는 동일합니다. 호출 코드 (예 : Main)는 두 경우 모두 동일하며 어셈블리 표현이 동일하므로 인라인 문제가 아닙니다.
빠른 버전을위한 디스 어셈블 된 코드 :
[0000] push ebp
[0001] mov ebp,esp
[0003] push edi
[0004] push esi
[0005] push ebx
[0006] sub esp,1Ch
[0009] xor eax,eax
[000b] mov dword ptr [ebp-20h],eax
[000e] mov dword ptr [ebp-1Ch],eax
[0011] mov dword ptr [ebp-18h],eax
[0014] mov dword ptr [ebp-14h],eax
[0017] xor eax,eax
[0019] mov dword ptr [ebp-18h],eax
*[001c] mov esi,1
[0021] xor edi,edi
[0023] mov dword ptr [ebp-28h],1
[002a] mov dword ptr [ebp-24h],0
[0031] inc ecx
[0032] mov ebx,2
[0037] cmp ecx,2
[003a] jle 00000024
[003c] mov eax,esi
[003e] mov edx,edi
[0040] mov esi,dword ptr [ebp-28h]
[0043] mov edi,dword ptr [ebp-24h]
[0046] add eax,dword ptr [ebp-28h]
[0049] adc edx,dword ptr [ebp-24h]
[004c] mov dword ptr [ebp-28h],eax
[004f] mov dword ptr [ebp-24h],edx
[0052] inc ebx
[0053] cmp ebx,ecx
[0055] jl FFFFFFE7
[0057] jmp 00000007
[0059] call 64571ACB
[005e] mov eax,dword ptr [ebp-28h]
[0061] mov edx,dword ptr [ebp-24h]
[0064] lea esp,[ebp-0Ch]
[0067] pop ebx
[0068] pop esi
[0069] pop edi
[006a] pop ebp
[006b] ret
느린 버전을위한 디스 어셈블 된 코드 :
[0000] push ebp
[0001] mov ebp,esp
[0003] push esi
[0004] sub esp,18h
*[0007] mov dword ptr [ebp-14h],1
[000e] mov dword ptr [ebp-10h],0
[0015] mov dword ptr [ebp-1Ch],1
[001c] mov dword ptr [ebp-18h],0
[0023] inc ecx
[0024] mov esi,2
[0029] cmp ecx,2
[002c] jle 00000031
[002e] mov eax,dword ptr [ebp-14h]
[0031] mov edx,dword ptr [ebp-10h]
[0034] mov dword ptr [ebp-0Ch],eax
[0037] mov dword ptr [ebp-8],edx
[003a] mov eax,dword ptr [ebp-1Ch]
[003d] mov edx,dword ptr [ebp-18h]
[0040] mov dword ptr [ebp-14h],eax
[0043] mov dword ptr [ebp-10h],edx
[0046] mov eax,dword ptr [ebp-0Ch]
[0049] mov edx,dword ptr [ebp-8]
[004c] add eax,dword ptr [ebp-1Ch]
[004f] adc edx,dword ptr [ebp-18h]
[0052] mov dword ptr [ebp-1Ch],eax
[0055] mov dword ptr [ebp-18h],edx
[0058] inc esi
[0059] cmp esi,ecx
[005b] jl FFFFFFD3
[005d] mov eax,dword ptr [ebp-1Ch]
[0060] mov edx,dword ptr [ebp-18h]
[0063] lea esp,[ebp-4]
[0066] pop esi
[0067] pop ebp
[0068] ret
각각의 경우에 *
디버거가 간단한 “step-into”에 입력 된 위치를 보여줍니다.
편집 : 좋아, 이제 코드를 살펴 보았고 각 버전의 작동 방식을 볼 수 있다고 생각합니다 … 레지스터가 적고 스택 공간이 많기 때문에 느린 버전은 느립니다. n
그것의 작은 값은 아마도 더 빠를 것입니다. 그러나 루프가 많은 시간을 차지하면 느려집니다.
try / catch 블록 은 더 많은 레지스터를 강제 로 저장 및 복원 할 수 있으므로 JIT는 루프에 대한 레지스터도 사용하므로 전반적인 성능을 향상시킵니다. JIT가 “정상”코드에서 많은 레지스터를 사용 하지 않는 것이 합리적인 결정인지는 확실 하지 않습니다 .
편집 : 방금 내 x64 컴퓨터에서 시도했습니다. x64 CLR 은이 코드에서 x86 CLR보다 훨씬 빠르며 (약 3-4 배 더 빠름), x64에서는 try / catch 블록이 눈에 띄는 차이를 만들지 않습니다.
답변
Jon의 디스 어셈블리에 따르면 두 버전의 차이점은 빠른 버전은 레지스터 쌍 ( esi,edi
)을 사용하여 느린 버전이 아닌 로컬 변수 중 하나를 저장한다는 것입니다.
JIT 컴파일러는 try-catch 블록이 포함 된 코드와 그렇지 않은 코드에 대한 레지스터 사용과 관련하여 다른 가정을합니다. 이로 인해 다른 레지스터 할당을 선택할 수 있습니다. 이 경우 try-catch 블록으로 코드를 선호합니다. 코드가 다르면 반대 효과가 발생할 수 있으므로 이것을 범용 속도 향상 기술로 간주하지 않습니다.
결국 어떤 코드가 가장 빨리 실행되는지 알기가 매우 어렵습니다. 레지스터 할당 및 여기에 영향을 미치는 요소는 특정 기술이 어떻게 더 빠른 코드를 안정적으로 생성 할 수 있는지에 대한 저수준 구현 세부 사항입니다.
예를 들어 다음 두 가지 방법을 고려하십시오. 그들은 실제 사례에서 채택되었습니다.
interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed {
public int[] Array;
public int this[int index] {
get { return Array[index]; }
set { Array[index] = value; }
}
}
static int Generic<T>(int length, T a, T b) where T : IIndexed {
int sum = 0;
for (int i = 0; i < length; i++)
sum += a[i] * b[i];
return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
int sum = 0;
for (int i = 0; i < length; i++)
sum += a[i] * b[i];
return sum;
}
하나는 다른 하나의 일반적인 버전입니다. 제네릭 형식을 바꾸면 StructArray
메서드가 동일 해집니다. StructArray
값 형식 이므로 일반 메서드의 자체 컴파일 버전을 가져옵니다. 그러나 실제 실행 시간은 특수한 방법보다 훨씬 길지만 x86에만 해당됩니다. x64의 경우 타이밍이 거의 동일합니다. 다른 경우에는 x64의 차이점도 관찰했습니다.
답변
인라인이 나빠진 것 같습니다. x86 코어에서 지터에는 로컬 변수의 범용 저장에 사용할 수있는 ebx, edx, esi 및 edi 레지스터가 있습니다. ecx 레지스터는 정적 메소드에서 사용할 수있게되므로 이것을 저장할 필요가 없습니다 . eax 레지스터는 종종 계산에 필요합니다. 그러나 이들은 32 비트 레지스터입니다. long 유형의 변수에는 레지스터 쌍을 사용해야합니다. 계산에는 edx : eax, 저장에는 edi : ebx가 있습니다.
느린 버전의 분해에서 눈에 띄는 것은 edi 나 ebx가 아닙니다.
지터가 로컬 변수를 저장하기에 충분한 레지스터를 찾지 못하면 스택 프레임에서로드하고 저장하기위한 코드를 생성해야합니다. 이로 인해 코드 속도가 느려지고 레지스터의 여러 복사본을 사용하고 수퍼 스칼라 실행을 허용하는 내부 프로세서 코어 최적화 트릭 인 “레지스터 이름 바꾸기”라는 프로세서 최적화가 방지됩니다. 동일한 레지스터를 사용하는 경우에도 여러 명령어를 동시에 실행할 수 있습니다. 충분한 레지스터가없는 것은 8 개의 추가 레지스터 (r9 ~ r15)가있는 x64에서 해결 된 x86 코어의 일반적인 문제입니다.
지터는 다른 코드 생성 최적화를 적용하기 위해 최선을 다할 것이며 Fibo () 메소드를 인라인하려고 시도 할 것입니다. 즉, 메소드를 호출하지 말고 Main () 메소드에서 메소드 인라인 코드를 생성하십시오. C # 클래스의 속성을 무료로 만들어 필드의 성능을 제공하는 매우 중요한 최적화입니다. 메소드 호출 및 스택 프레임 설정의 오버 헤드를 피하고 몇 나노초를 절약합니다.
메소드를 인라인 할 수있는시기를 정확하게 결정하는 몇 가지 규칙이 있습니다. 그들은 정확하게 문서화되지 않았지만 블로그 게시물에 언급되었습니다. 한 가지 규칙은 메소드 본문이 너무 클 때 발생하지 않는다는 것입니다. 인라인으로 인한 이득을 없애고 L1 명령 캐시에 맞지 않는 너무 많은 코드를 생성합니다. 여기에 적용되는 또 다른 어려운 규칙은 try / catch 문을 포함 할 때 메서드가 인라인되지 않는다는 것입니다. 그 배후의 배경은 예외의 구현 세부 사항이며, 스택 프레임 기반의 SEH (Structure Exception Handling)에 대한 Windows의 기본 지원을 피기 백합니다.
지터에서 레지스터 할당 알고리즘의 한 가지 동작은이 코드를 사용하여 추론 할 수 있습니다. 지터가 메소드를 인라인하려고 할 때를 알고있는 것으로 보입니다. 한 가지 규칙은 edx : eax 레지스터 쌍만 long 유형의 로컬 변수가있는 인라인 코드에 사용할 수 있다는 것을 사용하는 것으로 보입니다. 그러나 edi : ebx는 아닙니다. 호출 메소드의 코드 생성에 너무 해로울 수 있으므로 의심 할 여지없이 edi와 ebx는 중요한 스토리지 레지스터입니다.
따라서 지터가 메소드 본문에 try / catch 문이 포함되어 있다는 것을 알고 있기 때문에 빠른 버전을 얻습니다. 그것은 인라인 될 수 없다는 것을 알고 있으므로 긴 변수를 저장하기 위해 edi : ebx를 쉽게 사용합니다. 지터가 인라인이 작동하지 않는다는 것을 미리 알지 못했기 때문에 느린 버전을 사용했습니다. 메소드 본문에 대한 코드를 생성 한 후에 만 발견되었습니다 .
그러면 결함은 다시 돌아가서 메소드의 코드를 다시 생성 하지 않는다는 것 입니다. 작동해야하는 시간 제약이 주어지면 이해할 수 있습니다.
이 속도 저하는 x64에서 발생하지 않습니다. 하나의 레지스터에는 8 개의 레지스터가 더 있기 때문입니다. 다른 하나는 긴 하나의 레지스터 (rax와 같은)에 long을 저장할 수 있기 때문입니다. 지터가 레지스터를 선택할 때 훨씬 더 유연하기 때문에 int를 오래 사용하는 경우 속도 저하가 발생하지 않습니다.
답변
나는 이것이 사실 일 것이라고 확신하지 못하기 때문에 이것을 주석으로 넣었을 것입니다. 컴파일러는 스택에서 재귀적인 방식으로 객체 메모리 할당을 정리한다는 점에서 작동합니다. 이 경우 정리할 오브젝트가 없거나 for 루프가 가비지 콜렉션 메커니즘이 다른 콜렉션 메소드를 시행하기에 충분한 것으로 인식하는 클로저를 구성 할 수 있습니다. 아마도 그렇지는 않지만 다른 곳에서는 논의하지 않았으므로 언급 할 가치가 있다고 생각했습니다.