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

최적화 된 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. 이제 내 질문 :이 원인은 무엇입니까?



답변

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 */
}


답변

이것이 컴파일러의 본질입니다. 그들이 가장 빠르거나 최선의 길을 택한다고 가정하면, 그것은 거짓입니다. “현대 컴파일러”가 빈칸을 채우고, 최선을 다하고, 가장 빠른 코드를 만드는 등의 이유로 코드를 최적화 할 필요가 없음을 의미하는 사람은 실제로 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 등입니다.


답변

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.

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


답변