왜 GCC는 거의 동일한 C 코드에 대해 이렇게 완전히 다른 어셈블리를 생성합니까?


184

최적화 된 ftol함수를 작성하는 동안 에서 매우 이상한 동작을 발견했습니다 GCC 4.6.1. 먼저 코드를 보여 드리겠습니다 (명확하게하기 위해 차이점을 표시했습니다).

fast_trunc_one, C :

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;                       /* diff */
    } else {
        r = mantissa >> exponent;                        /* diff */
    }

    return (r ^ -sign) + sign;                           /* diff */
}

fast_trunc_two, C :

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent) ^ -sign;             /* diff */
    } else {
        r = (mantissa >> exponent) ^ -sign;              /* diff */
    }

    return r + sign;                                     /* diff */
}

똑같은 것 같아? 글쎄, GCC는 동의하지 않는다. gcc -O3 -S -Wall -o test.s test.c이것으로 컴파일 한 후 어셈블리 출력입니다.

fast_trunc_one, 생성 :

_fast_trunc_one:
LFB0:
    .cfi_startproc
    movl    4(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %edx
    andl    $8388607, %edx
    sarl    $23, %eax
    orl $8388608, %edx
    andl    $255, %eax
    subl    %eax, %ecx
    movl    %edx, %eax
    sarl    %cl, %eax
    testl   %ecx, %ecx
    js  L5
    rep
    ret
    .p2align 4,,7
L5:
    negl    %ecx
    movl    %edx, %eax
    sall    %cl, %eax
    ret
    .cfi_endproc

fast_trunc_two, 생성 :

_fast_trunc_two:
LFB1:
    .cfi_startproc
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_offset 3, -8
    movl    8(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %ebx
    movl    %eax, %edx
    sarl    $23, %ebx
    andl    $8388607, %edx
    andl    $255, %ebx
    orl $8388608, %edx
    andl    $-2147483648, %eax
    subl    %ebx, %ecx
    js  L9
    sarl    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_remember_state
    .cfi_def_cfa_offset 4
    .cfi_restore 3
    ret
    .p2align 4,,7
L9:
    .cfi_restore_state
    negl    %ecx
    sall    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_restore 3
    .cfi_def_cfa_offset 4
    ret
    .cfi_endproc

그것은 극단적 인 차이입니다. 이것은 실제로 프로필에도 fast_trunc_one나타나고보다 약 30 % 빠릅니다 fast_trunc_two. 이제 내 질문 :이 원인은 무엇입니까?


1
테스트 목적을 위해 나는 요점을 만들어 여기에 당신이 / 복사 소스를 붙여 다른 시스템 / GCC의 버전에서 버그를 재현 할 수 있는지 쉽게 할 수있는.
orlp

12
테스트 케이스를 자체 디렉토리에 넣으십시오. 로 컴파일하십시오 -S -O3 -da -fdump-tree-all. 중간 표현의 스냅 샷이 많이 생성됩니다. 그것들을 나란히 살펴보십시오 (번호가 매겨져 있음) 첫 번째 경우 누락 된 최적화를 찾을 수 있습니다.
zwol

1
제안이 : 모든 변경 intunsigned int와 차이 사라지다되는지 확인합니다.
zwol

5
두 함수는 약간 다른 수학을하는 것 같습니다. 결과는 동일하지만 식은 (r + shifted) ^ sign과 동일하지 않습니다 r + (shifted ^ sign). 최적화 기가 혼란 스럽습니까? FWIW, MSVC 2010 (16.00.40219.01)은 서로 거의 동일한 목록을 생성합니다. gist.github.com/2430454
DCoder

1
@ DCoder : 오 젠장! 나는 그것을 발견하지 못했습니다. 그러나 차이점에 대한 설명은 아닙니다. 배제 된 새 버전으로 질문을 업데이트하겠습니다.
orlp

답변:


256

OP의 수정 사항과 동기화되도록 업데이트되었습니다.

코드를 수정하여 GCC가 첫 번째 사례를 최적화하는 방법을 확인했습니다.

왜 그렇게 다른지 이해하기 전에 먼저 GCC가 어떻게 최적화하는지 이해해야합니다 fast_trunc_one().

믿거 나 말거나 다음과 같이 fast_trunc_one()최적화하고 있습니다.

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

이것은 원래 fast_trunc_one()레지스터 이름과 모든 것과 정확히 동일한 어셈블리를 생성합니다 .

xor에 대한 어셈블리 가 없습니다 fast_trunc_one(). 그게 나를 위해 줬어.


어떻게 요?


1 단계: sign = -sign

먼저 sign변수를 살펴 보겠습니다 . 이후로 sign = i & 0x80000000;가능한 값은 두 가지뿐입니다 sign.

  • sign = 0
  • sign = 0x80000000

이제 두 경우 모두 sign == -sign. 따라서 원래 코드를 다음과 같이 변경하면

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;
    } else {
        r = mantissa >> exponent;
    }

    return (r ^ sign) + sign;
}

원본과 정확히 동일한 어셈블리를 생성합니다 fast_trunc_one(). 어셈블리를 아끼지 않겠지 만 등록 이름과 모두 동일합니다.


2 단계 : 수학 축소 :x + (y ^ x) = y

sign0또는 두 값 중 하나만 사용할 수 있습니다 0x80000000.

  • x = 0, 다음 x + (y ^ x) = y다음 사소한 보유.
  • 추가 및 조정 기준 0x80000000은 동일합니다. 부호 비트를 뒤집습니다. 따라서 x + (y ^ x) = y때도 보유 x = 0x80000000합니다.

따라서로 x + (y ^ x)줄어 듭니다 y. 코드는 다음과 같이 단순화됩니다.

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent);
    } else {
        r = (mantissa >> exponent);
    }

    return r;
}

다시, 이것은 정확히 동일한 어셈블리 (레지스터 이름과 모두)로 컴파일됩니다.


위의 버전은 마침내 다음과 같이 줄어 듭니다.

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

GCC가 어셈블리에서 생성하는 것과 거의 똑같습니다.


그렇다면 컴파일러가 왜 fast_trunc_two()같은 것을 최적화하지 않습니까?

핵심 부분 fast_trunc_one()x + (y ^ x) = y최적화입니다. 에서 표현 분기에 분할되고있다.fast_trunc_two()x + (y ^ x)

나는이 최적화를하지 않기 위해 GCC를 혼동하기에 충분하다고 생각합니다. ( ^ -sign지점 밖으로 들어 올려 r + sign마지막에 병합해야 합니다.)

예를 들어, 다음과 같은 어셈블리를 생성합니다 fast_trunc_one().

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = ((mantissa << -exponent) ^ -sign) + sign;             /* diff */
    } else {
        r = ((mantissa >> exponent) ^ -sign) + sign;              /* diff */
    }

    return r;                                     /* diff */
}

4
편집, 개정 2에 답한 것 같습니다. 현재 개정판은 두 가지 예를 뒤집었고 코드를 약간 변경했습니다 ... 혼란 스럽습니다.
Mysticial

2
@nightcracker 걱정 마세요. 현재 버전과 동기화되도록 답변을 업데이트했습니다.
Mysticial

1
@Mysticial : 최종 문 (그것이 가장 중요한 질문에 대답하지 않는 응답 무효을 새로운 버전으로 더 이상 사실입니다 "왜 GCC는 근본적으로 다른 어셈블리를 생성 않습니다" .)
orlp

11
답변이 다시 업데이트되었습니다. 그것이 만족 스러운지 확실하지 않습니다. 그러나 관련 GCC 최적화가 어떻게 작동하는지 정확히 알지 못하면 훨씬 더 잘 할 수 있다고 생각하지 않습니다.
Mysticial

4
@Mysticial : 엄밀히 말하면, 부호있는 타입이이 코드에서 잘못 사용되는 한, 여기에서 컴파일러가 수행하는 거의 모든 변환은 동작이 정의되지 않은 경우에 있습니다 ...
R .. GitHub STOP HELPING ICE

63

이것이 컴파일러의 본질입니다. 그들이 가장 빠르거나 최선의 길을 택한다고 가정하면, 그것은 거짓입니다. "현대 컴파일러"가 빈칸을 채우고, 최선을 다하고, 가장 빠른 코드를 만드는 등의 이유로 코드를 최적화 할 필요가 없음을 의미하는 사람은 실제로 gcc가 3.x에서 팔에 적어도 4.x. 이 시점에서 4.x는 3.x를 따라 잡았을 지 모르지만 초기에는 더 느린 코드를 생성했습니다. 실제로 코드를 작성하는 방법을 배울 수 있으므로 컴파일러가 열심히 일할 필요가 없으며 결과적으로보다 일관되고 기대되는 결과를 얻을 수 있습니다.

여기서 버그는 실제로 생산 된 것이 아니라 생산 된 것에 대한 기대입니다. 컴파일러가 동일한 출력을 생성하도록하려면 동일한 입력을 공급하십시오. 수학적으로 동일하지는 않지만, 동일하지는 않지만, 실제로 동일하거나, 다른 경로가 없으며, 한 버전에서 다른 버전으로 작업을 공유하거나 분배하지 않습니다. 이것은 코드 작성 방법을 이해하고 컴파일러가 수행하는 작업을 확인하는 데있어 좋은 연습입니다. 하나의 프로세서 대상에 대한 하나의 gcc 버전이 언젠가 모든 컴파일러와 모든 코드에 대한 규칙이라는 특정 결과를 생성했다고 가정하는 실수를 저 지르지 마십시오. 무슨 일이 일어나고 있는지 느끼려면 많은 컴파일러와 많은 대상을 사용해야합니다.

gcc는 매우 불쾌합니다. 커튼 뒤를보고, gcc의 내장을 보거나, 대상을 추가하거나 직접 무언가를 수정하십시오. 덕트 테이프와 와이어 링 와이어로 간신히 고정됩니다. 중요한 장소에서 추가 또는 제거 된 코드 줄이 무너져 내립니다. 사용 가능한 코드를 생성했다는 사실은 다른 기대를 충족시키지 못한 이유에 대해 걱정하는 대신 기뻐해야 할 것입니다.

다른 버전의 gcc가 생산하는 것을 보셨습니까? 3.x 및 4.x, 특히 4.5 대 4.6 대 4.7 등? 그리고 다른 타겟 프로세서, x86, arm, mips 등 또는 x86의 다른 풍미가 사용하는 네이티브 컴파일러, 32 비트 대 64 비트 등이라면? 그런 다음 다른 대상에 대한 llvm (clang)?

Mystical은 코드를 분석 / 최적화하는 문제를 해결하는 데 필요한 사고 과정에서 훌륭한 작업을 수행했으며, 컴파일러가 "현대 컴파일러"를 기대하지는 않습니다.

수학 속성에 들어 가지 않고이 형식의 코드

if (exponent < 0) {
  r = mantissa << -exponent;                       /* diff */
} else {
  r = mantissa >> exponent;                        /* diff */
}
return (r ^ -sign) + sign;                           /* diff */

컴파일러를 A로 인도 할 것입니다 : 그 형식으로 구현하고 if-then-else를 수행 한 다음 공통 코드로 수렴하여 끝내십시오. 또는 B : 함수의 꼬리 끝이므로 브랜치를 저장합니다. 또한 r을 사용하거나 저장하는 데 방해가되지 않습니다.

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}

그런 다음 Mystical이 지적한 것처럼 부호 변수가 작성된대로 코드에서 모두 사라집니다. 컴파일러가 부호 변수가 사라지는 것을 보지 못하므로 직접 작성해야하고 컴파일러가 그것을 알아 내려고 강요해서는 안됩니다.

이것은 gcc 소스 코드를 파헤칠 수있는 완벽한 기회입니다. 옵티마이 저가 한 경우에는 다른 것을 보았고 다른 경우에는 다른 것을 보았습니다. 그런 다음 다음 단계를 수행하여 gcc가 해당 사례를 볼 수 없는지 확인하십시오. 일부 개인 또는 그룹이 최적화를 인식하고 의도적으로 배치했기 때문에 모든 최적화가 있습니다. 이 최적화가 존재하고 누군가가 거기에 배치해야 할 때마다 (그리고 테스트 한 다음 미래에 유지해야 할 때마다) 작동하십시오.

더 적은 코드가 더 빠르고 더 많은 코드가 더 느리다고 가정하지 말고, 사실이 아닌 예제를 작성하고 찾는 것은 매우 쉽습니다. 코드가 많을수록 코드가 적을수록 더 빠를 수 있습니다. 처음부터 시연했듯이 더 많은 코드를 만들어 그 경우 분기 또는 루핑 등을 저장하고 더 빠른 코드를 얻을 수 있습니다.

결론은 컴파일러에 다른 소스를 공급하고 동일한 결과를 기대한다는 것입니다. 문제는 컴파일러 출력이 아니라 사용자의 기대입니다. 특정 컴파일러와 프로세서의 경우 한 줄의 코드가 추가되어 전체 기능을 크게 느리게하는 방법을 쉽게 보여줄 수 있습니다. 예를 들어 왜 a = b + 2; a = b + c + 2까지; _fill_in_the_blank_compiler_name_이 근본적으로 다르고 느린 코드를 생성합니까? 물론 컴파일러라는 대답은 입력에 다른 코드를 제공했기 때문에 컴파일러가 다른 출력을 생성하는 것이 완벽하게 유효합니다. (비 관련된 두 줄의 코드를 바꾸고 출력이 크게 변경되는 경우가 더 낫습니다.) 입력의 복잡성 및 크기와 출력의 복잡성 및 크기 사이에는 예상되는 관계가 없습니다.

for(ra=0;ra<20;ra++) dummy(ra);

60 ~ 100 줄의 어셈블러를 생산했습니다. 루프를 풀었습니다. 나는 줄을 세지 않았다. 생각한다면, 결과를 함수 호출에 대한 입력에 복사하고 함수 호출을 최소 세 번 수행해야한다. 따라서 대상에 따라 적어도 60 명령, 루프 당 4이면 80, 루프 당 5이면 100 등입니다.


왜 답을 잃어 버렸습니까? Oded도 편집에 동의하지 않는 것 같았습니다 ;-).
피터-복원 모니카

@ PeterA.Schneider의 모든 답변은 같은 날짜에 파손 된 것 같습니다. 계정 데이터가 도난당한 사람이 그런 것 같습니다.
trinity420

23

Mysticial은 이미 훌륭한 설명을했지만 FWIW는 컴파일러가 왜 최적화하지 않는지에 대한 근본적인 이유는 없다고 생각했습니다.

clang예를 들어 LLVM의 컴파일러는 두 함수 (함수 이름 제외)에 대해 다음과 같은 코드를 제공합니다.

_fast_trunc_two:                        ## @fast_trunc_one
        movl    %edi, %edx
        andl    $-2147483648, %edx      ## imm = 0xFFFFFFFF80000000
        movl    %edi, %esi
        andl    $8388607, %esi          ## imm = 0x7FFFFF
        orl     $8388608, %esi          ## imm = 0x800000
        shrl    $23, %edi
        movzbl  %dil, %eax
        movl    $150, %ecx
        subl    %eax, %ecx
        js      LBB0_1
        shrl    %cl, %esi
        jmp     LBB0_3
LBB0_1:                                 ## %if.then
        negl    %ecx
        shll    %cl, %esi
LBB0_3:                                 ## %if.end
        movl    %edx, %eax
        negl    %eax
        xorl    %esi, %eax
        addl    %edx, %eax
        ret

이 코드는 OP의 첫 번째 gcc 버전만큼 짧지 않지만 두 번째 버전만큼 길지 않습니다.

x86_64 용으로 컴파일하는 다른 컴파일러 (명명하지 않음)의 코드는 두 함수 모두에 대해 이것을 생성합니다.

fast_trunc_one:
        movl      %edi, %ecx        
        shrl      $23, %ecx         
        movl      %edi, %eax        
        movzbl    %cl, %edx         
        andl      $8388607, %eax    
        negl      %edx              
        orl       $8388608, %eax    
        addl      $150, %edx        
        movl      %eax, %esi        
        movl      %edx, %ecx        
        andl      $-2147483648, %edi
        negl      %ecx              
        movl      %edi, %r8d        
        shll      %cl, %esi         
        negl      %r8d              
        movl      %edx, %ecx        
        shrl      %cl, %eax         
        testl     %edx, %edx        
        cmovl     %esi, %eax        
        xorl      %r8d, %eax        
        addl      %edi, %eax        
        ret                         

이것은 양쪽의 양쪽을 계산 if한 다음 끝에 조건부 이동을 사용하여 올바른 것을 선택 한다는 점에서 매력적입니다 .

Open64 컴파일러는 다음을 생성합니다.

fast_trunc_one: 
    movl %edi,%r9d                  
    sarl $23,%r9d                   
    movzbl %r9b,%r9d                
    addl $-150,%r9d                 
    movl %edi,%eax                  
    movl %r9d,%r8d                  
    andl $8388607,%eax              
    negl %r8d                       
    orl $8388608,%eax               
    testl %r8d,%r8d                 
    jl .LBB2_fast_trunc_one         
    movl %r8d,%ecx                  
    movl %eax,%edx                  
    sarl %cl,%edx                   
.Lt_0_1538:
    andl $-2147483648,%edi          
    movl %edi,%eax                  
    negl %eax                       
    xorl %edx,%eax                  
    addl %edi,%eax                  
    ret                             
    .p2align 5,,31
.LBB2_fast_trunc_one:
    movl %r9d,%ecx                  
    movl %eax,%edx                  
    shll %cl,%edx                   
    jmp .Lt_0_1538                  

에 대한 유사하지만 동일하지 않은 코드 fast_trunc_two.

어쨌든, 최적화와 관련하여, 그것은 복권입니다-그것이 무엇입니까 ... 왜 코드가 특정 방식으로 컴파일되는지 이유를 항상 쉽게 알 수는 없습니다.


10
컴파일러는 일급 비밀 슈퍼 컴파일러의 이름을 지정하지 않습니까?
orlp

4
일급 비밀 컴파일러는 아마 인텔 일 것입니다 icc. 나는 32 비트 변형 만 가지고 있지만 이것과 매우 유사한 코드를 생성합니다.
Janus Troelsen

5
또한 ICC라고 생각합니다. 컴파일러는 프로세서가 명령 레벨 병렬 처리가 가능하므로 두 분기를 동시에 계산할 수 있음을 알고 있습니다. 조건부 이동의 오버 헤드는 잘못된 분기 예측의 오버 헤드보다 훨씬 낮습니다.
Filip Navara
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.