etc./StackOverFlow

내 코드의 속도를 빠르게 하시겠습니까?

청렴결백한 만능 재주꾼 2022. 2. 7. 06:19
반응형

질문자 :Eren Ersönmez


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 레지스터를 더 유리한 방식으로 사용하도록 합니다. x64 CLR에는 이러한 차이가 없고 x86 CLR보다 빠르다는 Jon의 발견을 확인했습니다. long 형식 대신 Fibo 메서드 내에서 int 형식을 사용하여 테스트한 다음 x86 CLR이 x64 CLR만큼 빠릅니다.


업데이트: 이 문제는 Roslyn에 의해 수정된 것 같습니다. 동일한 시스템, 동일한 CLR 버전 -- VS 2013으로 컴파일할 때 문제가 위와 같이 남아 있지만 VS 2015로 컴파일하면 문제가 사라집니다.



스택 사용 최적화 이해를 전문으로 하는 Roslyn 엔지니어 중 한 명이 이것을 살펴보고 C# 컴파일러가 로컬 변수 저장소를 생성하는 방식과 JIT 컴파일러가 등록하는 방식 사이의 상호 작용에 문제가 있는 것 같다고 보고했습니다. 해당 x86 코드에서 스케줄링. 결과는 현지인의 로드 및 저장에 대한 차선의 코드 생성입니다.

우리 모두에게 불분명한 어떤 이유로 JITter가 블록이 try 보호 영역에 있다는 것을 알면 문제가 있는 코드 생성 경로를 피할 수 있습니다.

이것은 꽤 이상합니다. JITter 팀과 후속 조치를 취하고 버그를 입력하여 문제를 해결할 수 있는지 여부를 확인할 것입니다.

또한 우리는 로컬을 "임시"로 만들 수 있는 시기를 결정하기 위한 C# 및 VB 컴파일러 알고리즘에 대한 Roslyn의 개선 작업을 진행하고 있습니다. 활성화 기간. 우리는 JITter가 지역 주민을 "죽은" 상태로 만들 수 있는 시기에 대해 더 나은 힌트를 준다면 레지스터 할당 및 기타 작업을 더 잘 수행할 수 있을 것이라고 믿습니다.

이 문제를 알려주셔서 감사하고 이상한 행동에 대해 사과드립니다.


Eric Lippert

글쎄요, 당신이 시기를 정하는 방식이 제가 보기에는 꽤 끔찍해 보입니다. 전체 루프의 시간을 측정하는 것이 훨씬 더 합리적입니다.

 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 코드를 살펴봐야 합니다.

편집: 몇 가지 추가 정보:

  • n++; 주위에 try/catch 넣기; 라인은 여전히 성능을 향상시키지만 전체 블록 주위에 배치하는 것만큼은 아닙니다.
  • 특정 예외(내 테스트의 ArgumentException
  • catch 블록에서 예외를 인쇄하면 여전히 빠릅니다.
  • catch 블록에서 예외를 다시 throw하면 다시 느려집니다.
  • catch 블록 대신 finally 블록을 사용하면 다시 느려집니다.
  • finally 블록 과 catch 블록을 함께 사용하면 속도가 빠릅니다.

기이 한...

편집 : 좋아, 우리는 분해가 있습니다 ...

이것은 C# 2 컴파일러와 .NET 2(32비트) CLR을 사용하여 mdbg로 분해합니다(내 컴퓨터에 cordbg가 없기 때문에). 디버거에서도 동일한 성능 효과가 나타납니다. 빠른 버전은 catch{} 핸들러와 함께 변수 선언과 return 문 사이의 모든 부분에 try 블록을 사용합니다. 분명히 느린 버전은 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

각 경우에 * 는 디버거가 간단한 "스텝 인투"로 입력한 위치를 보여줍니다.

편집: 자, 이제 코드를 살펴보았고 각 버전이 어떻게 작동하는지 알 수 있을 것 같습니다... 그리고 더 적은 수의 레지스터와 더 많은 스택 공간을 사용하기 때문에 더 느린 버전이 더 느리다고 생각합니다. n 작은 값의 경우 더 빠를 수 있지만 루프가 대부분의 시간을 차지하면 더 느립니다.

아마도 try/catch 블록 더 많은 레지스터를 저장하고 복원해야 하므로 JIT는 루프에도 레지스터를 사용합니다. 이는 전반적인 성능을 향상시킵니다. JIT가 "정상" 코드에서 많은 레지스터를 사용 하지 않는 것이 합리적인 결정인지 여부는 분명하지 않습니다.

편집 : 내 x64 컴퓨터에서 이것을 시도했습니다. x64 CLR은 이 코드에서 x86 CLR보다 훨씬 빠르며(약 3-4배 빠름) x64에서 try/catch 블록은 눈에 띄는 차이를 만들지 않습니다.


Jon Skeet

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의 차이점도 관찰했습니다.


Jeffrey Sax

이것은 인라인이 잘못 된 경우처럼 보입니다. x86 코어에서 지터에는 로컬 변수의 범용 저장에 사용할 수 있는 ebx, edx, esi 및 edi 레지스터가 있습니다. ECX 레지스터는 정적 메서드에서 사용할 수있게,이를 저장할 필요가 없습니다. eax 레지스터는 종종 계산에 필요합니다. 그러나 이들은 32비트 레지스터이며, 유형이 긴 변수의 경우 한 쌍의 레지스터를 사용해야 합니다. 계산을 위한 edx:eax와 저장을 위한 edi:ebx입니다.

이것이 느린 버전의 디스어셈블리에서 눈에 띄는 점이며 edi나 ebx는 사용되지 않습니다.

지터가 로컬 변수를 저장하기에 충분한 레지스터를 찾을 수 없으면 스택 프레임에서 로드하고 저장할 코드를 생성해야 합니다. 이는 코드 속도를 늦추고 레지스터의 여러 복사본을 사용하고 슈퍼 스칼라 실행을 허용하는 내부 프로세서 코어 최적화 트릭인 "레지스터 이름 바꾸기"라는 프로세서 최적화를 방지합니다. 동일한 레지스터를 사용하는 경우에도 여러 명령어를 동시에 실행할 수 있습니다. 레지스터가 충분하지 않은 것은 x86 코어의 일반적인 문제이며 8개의 추가 레지스터(r9 ~ r15)가 있는 x64에서 해결되었습니다.

지터는 다른 코드 생성 최적화를 적용하기 위해 최선을 다하고 Fibo() 메서드를 인라인하려고 시도합니다. 즉, 메서드를 호출하지 않고 Main() 메서드에서 인라인으로 메서드에 대한 코드를 생성합니다. 하나는 C# 클래스의 속성을 무료로 만들어 필드 성능을 제공하는 매우 중요한 최적화입니다. 메서드를 호출하고 스택 프레임을 설정하는 오버헤드를 방지하고 몇 나노초를 절약합니다.

메서드를 인라인할 수 있는 시기를 정확히 결정하는 몇 가지 규칙이 있습니다. 그것들은 정확히 문서화되지 않았지만 블로그 게시물에서 언급되었습니다. 한 가지 규칙은 메서드 본문이 너무 크면 발생하지 않는다는 것입니다. 이는 인라인으로 인한 이득을 무효화하고 L1 명령어 캐시에도 맞지 않는 너무 많은 코드를 생성합니다. 여기에 적용되는 또 다른 엄격한 규칙은 try/catch 문이 포함된 경우 메서드가 인라인되지 않는다는 것입니다. 그 배경은 예외의 구현 세부 사항으로, 스택 프레임 기반인 SEH(구조적 예외 처리)에 대한 Windows의 기본 제공 지원에 피기백합니다.

지터에서 레지스터 할당 알고리즘의 한 가지 동작은 이 코드를 사용하여 유추할 수 있습니다. 지터가 메서드를 인라인하려고 할 때를 인식하는 것으로 보입니다. 하나의 규칙은 edx:eax 레지스터 쌍만 long 유형의 로컬 변수가 있는 인라인 코드에 사용할 수 있다는 것을 사용하는 것으로 보입니다. 그러나 edi:ebx는 아닙니다. 의심할 여지 없이 그것이 호출 메소드에 대한 코드 생성에 너무 해로울 것이기 때문에 edi와 ebx는 모두 중요한 저장 레지스터입니다.

따라서 지터가 메서드 본문에 try/catch 문이 포함되어 있다는 것을 미리 알고 있기 때문에 빠른 버전을 얻을 수 있습니다. 인라인될 수 없다는 것을 알고 있으므로 long 변수에 대한 저장을 위해 edi:ebx를 쉽게 사용합니다. 지터가 인라인이 작동하지 않을 것이라는 것을 미리 알지 못했기 때문에 느린 버전을 얻었습니다. 메서드 본문에 대한 코드를 생성 후에야 알아냈습니다.

결함은 돌아가지 않고 메서드에 대한 코드를 다시 생성하지 않는다는 것입니다. 작동해야 하는 시간 제약을 감안할 때 이해할 수 있는 일입니다.

x64에서는 레지스터가 8개 더 있기 때문에 이 속도 저하가 발생하지 않습니다. 하나의 레지스터(rax와 같은)에 long을 저장할 수 있기 때문입니다. 그리고 지터가 레지스터 선택에 훨씬 더 많은 유연성을 갖기 때문에 long 대신 int를 사용할 때 속도 저하가 발생하지 않습니다.


Hans Passant

나는 이것이 사실일 가능성이 정말로 확실하지 않기 때문에 이것을 주석으로 넣었을 것입니다. 그러나 내가 기억하기로는 try/except 문에는 컴파일러는 스택에서 재귀적 방식으로 개체 메모리 할당을 지운다는 점에서 작동합니다. 이 경우 정리할 객체가 없거나 for 루프가 가비지 수집 메커니즘이 다른 수집 방법을 시행하기에 충분하다고 인식하는 클로저를 구성할 수 있습니다. 아마 아닐 수도 있지만 다른 곳에서 논의되는 것을 본 적이 없기 때문에 언급할 가치가 있다고 생각했습니다.


miller the gorilla

9년이 지난 지금도 버그는 여전히 존재합니다! 다음을 사용하여 쉽게 볼 수 있습니다.

 static void Main( string[] args ) { int hundredMillion = 1000000; DateTime start = DateTime.Now; double sqrt; for (int i=0; i < hundredMillion; i++) { sqrt = Math.Sqrt( DateTime.Now.ToOADate() ); } DateTime end = DateTime.Now; double sqrtMs = (end - start).TotalMilliseconds; Console.WriteLine( "Elapsed milliseconds: " + sqrtMs ); DateTime start2 = DateTime.Now; double sqrt2; for (int i = 0; i < hundredMillion; i++) { try { sqrt2 = Math.Sqrt( DateTime.Now.ToOADate() ); } catch (Exception e) { int br = 0; } } DateTime end2 = DateTime.Now; double sqrtMsTryCatch = (end2 - start2).TotalMilliseconds; Console.WriteLine( "Elapsed milliseconds: " + sqrtMsTryCatch ); Console.WriteLine( "ratio is " + sqrtMsTryCatch / sqrtMs ); Console.ReadLine(); }

이 비율은 최신 버전의 MSVS 2019, .NET 4.6.1을 실행하는 내 컴퓨터에서 1보다 작습니다.


Markus

출처 : http:www.stackoverflow.com/questions/8928403/try-catch-speeding-up-my-code

반응형