내 코드 속도를 높이려고 노력합니까?


1503

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로 컴파일하면 문제가 사라집니다.


111
@Lloyd 그는 "실제로 더 빠르게 실행됩니다! 그러나 왜?"
Andreas Niedermair

137
따라서 "예외 삼키기"는 나쁜 습관에서 좋은 성능 최적화로 넘어갔습니다. : P
Luciano

2
이것은 체크되지 않은 또는 체크 된 산술 컨텍스트에 있습니까?
Random832

7
@ taras.roshko : Eric에게 서비스를 중단하고 싶지는 않지만 이것은 실제로 C # 질문이 아닙니다. JIT 컴파일러 질문입니다. 궁극적 인 어려움은 수행과 같이 x86 JIT는 시도 / 캐치없이 많은 레지스터로 사용하지 않는 이유를 노력 은 try / catch 블록.
Jon Skeet

63
달콤합니다. 이러한 시도를 중첩하면 더 빨리 갈 수 있습니까?
척 Pinkert

답변:


1053

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

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

꽤 이상합니다. 우리는 JITter 팀과 함께 버그를 입력하여 문제를 해결할 수 있는지 알아볼 것입니다.

또한 Roslyn에서 로컬이 "일시적으로"만들어 질 수있는 시점 (즉, 스택의 특정 위치를 할당하지 않고 스택에서 푸시 및 팝)을 결정할 수 있도록 C # 및 VB 컴파일러의 알고리즘을 개선하기 위해 노력하고 있습니다. 활성화 기간. 우리는 JITter가 더 나은 레지스터 할당 작업을 수행 할 수 있다고 생각합니다. 현지인이 "죽은"시기에 대해 더 나은 힌트를 주면 어떨까요.

이것을 우리의 관심에 가져와 주셔서 감사합니다. 이상한 행동에 대해 사과드립니다.


8
나는 항상 C # 컴파일러가 왜 그렇게 많은 외부 지역을 생성하는지 궁금했습니다. 예를 들어, 새로운 배열 초기화 표현식은 항상 로컬을 생성하지만 로컬을 생성 할 필요는 없습니다. JITter가 성능이 훨씬 뛰어난 코드를 생성 할 수 있다면 C # 컴파일러는 불필요한 로컬을 생성하는 데 약간 더주의해야합니다.
Timwi

33
@Timwi : 물론입니다. 최적화되지 않은 코드에서 컴파일러는 디버깅이 더 쉬워 지므로 불필요한 로컬을 많이 버립니다. 최적화 된 코드에서 가능하면 불필요한 임시를 제거해야합니다. 불행히도 우린 임시 제거 최적화 프로그램을 실수로 최적화 해제 한 몇 년 동안 많은 버그가있었습니다. 앞서 언급 한 엔지니어는 Roslyn에 대해이 모든 코드를 처음부터 완전히 다시 수행하므로 결과적으로 Roslyn 코드 생성기에서 최적화 된 동작이 훨씬 개선되었습니다.
Eric Lippert

24
이 문제에 대한 움직임이 있었습니까?
Robert Harvey

10
Roslyn이 고친 것처럼 보입니다.
Eren Ersönmez

56
"JITter bug"이라고 부르는 기회를 놓쳤습니다.
mbomb007

734

글쎄, 당신이 물건을 타이밍하는 방식은 나에게 꽤 불쾌하게 보입니다. 전체 루프를 시간을 맞추는 것이 훨씬 합리적입니다.

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 블록이 눈에 띄는 차이를 만들지 않습니다.


4
@GordonSimpson 그러나 특정 예외 만 잡히면 다른 모든 예외는 잡히지 않으므로 시도하지 않기 위해 가설에 관련된 모든 오버 헤드가 여전히 필요합니다.
Jon Hanna

45
레지스터 할당의 차이처럼 보입니다. 빠른 버전은 esi,edi스택 대신 long 중 하나 를 사용하도록 관리합니다 . 그것은 사용 ebx느린 버전을 사용하는 카운터로 esi.
Jeffrey Sax

13
@ JeffreySax : 사용 되는 레지스터 뿐만 아니라 몇 개입니까 . 느린 버전은 더 적은 레지스터를 건드 리면서 더 많은 스택 공간을 사용합니다. 나는 왜 그런지 모르겠다.
Jon Skeet

2
CLR 예외 프레임은 레지스터와 스택 측면에서 어떻게 처리됩니까? 하나를 설정하면 어떻게 든 사용하기 위해 레지스터를 해제 할 수 있습니까?
Random832

4
IIRC x64에는 x86보다 많은 레지스터가 있습니다. 본 속도 향상은 x86에서 추가 레지스터 사용을 강제하는 try / catch와 일치합니다.
Dan은 불을 피우고 있습니다

116

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


6
그 말로 ... Try / Catch를 사용하지 않고 다른 레지스터 할당 선택을 강제 할 수 있습니까? 이 가설에 대한 테스트 또는 속도를 조정하려는 일반적인 시도로서?
WernerCD

1
이 특정 사례가 다른 이유는 여러 가지가 있습니다. 어쩌면 그것은 시도 캐치 일 것입니다. 변수가 내부 범위에서 재사용되는 것일 수도 있습니다. 구체적인 이유가 무엇이든, 정확히 동일한 코드가 다른 프로그램에서 호출 되더라도 보존 할 수없는 구현 세부 사항입니다.
Jeffrey Sax

4
@WernerCD 나는 C와 C ++가 (A) 많은 현대 컴파일러에 의해 무시되고 (B) C #에 넣지 않기로 결정했다는 것을 제안하는 키워드를 가지고 있다는 사실을 말하고 싶습니다. 더 직접적으로 볼 수 있습니다.
Jon Hanna

2
@WernerCD-어셈블리를 직접 작성하는 경우에만
OrangeDog

72

인라인이 나빠진 것 같습니다. 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를 오래 사용하는 경우 속도 저하가 발생하지 않습니다.


21

나는 이것이 사실 일 것이라고 확신하지 못하기 때문에 이것을 주석으로 넣었을 것입니다. 컴파일러는 스택에서 재귀적인 방식으로 객체 메모리 할당을 정리한다는 점에서 작동합니다. 이 경우 정리할 오브젝트가 없거나 for 루프가 가비지 콜렉션 메커니즘이 다른 콜렉션 메소드를 시행하기에 충분한 것으로 인식하는 클로저를 구성 할 수 있습니다. 아마도 그렇지는 않지만 다른 곳에서는 논의하지 않았으므로 언급 할 가치가 있다고 생각했습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.