Tại sao GCC tạo ra hội đồng hoàn toàn khác nhau như vậy cho gần như cùng một mã C?


184

Trong khi viết một ftolchức năng tối ưu hóa, tôi tìm thấy một số hành vi rất kỳ quặc GCC 4.6.1. Hãy để tôi chỉ cho bạn mã đầu tiên (để rõ ràng tôi đã đánh dấu sự khác biệt):

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

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

Có vẻ giống nhau phải không? GCC cũng không đồng ý. Sau khi biên dịch với gcc -O3 -S -Wall -o test.s test.cđây là đầu ra lắp ráp:

fast_trunc_one, được tạo:

_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, được tạo:

_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

Đó là một sự khác biệt cực kỳ . Điều này thực sự cũng xuất hiện trên hồ sơ, fast_trunc_onenhanh hơn khoảng 30% fast_trunc_two. Bây giờ câu hỏi của tôi: điều gì gây ra điều này?


1
Để thử nghiệm, tôi đã tạo một ý chính ở đây nơi bạn có thể dễ dàng sao chép / dán nguồn và xem liệu bạn có thể tái tạo lỗi trên các hệ thống / phiên bản khác của GCC không.
orlp

12
Đặt các trường hợp thử nghiệm trong một thư mục của riêng họ. Biên dịch chúng với -S -O3 -da -fdump-tree-all. Điều này sẽ tạo ra nhiều ảnh chụp nhanh của đại diện trung gian. Đi qua chúng (chúng được đánh số) cạnh nhau và bạn sẽ có thể tìm thấy tối ưu hóa còn thiếu trong trường hợp đầu tiên.
zwol

1
Gợi ý hai: thay đổi tất cả intthành unsigned intvà xem sự khác biệt biến mất.
zwol

5
Hai hàm dường như đang làm toán hơi khác nhau. Mặc dù kết quả có thể giống nhau, nhưng biểu thức (r + shifted) ^ signkhông giống như r + (shifted ^ sign). Tôi đoán đó là nhầm lẫn tối ưu hóa? FWIW, MSVC 2010 (16.00.40219.01) tạo ra các danh sách gần giống với nhau: gist.github.com/2430454
DCoder

1
@DCoder: Ôi chết tiệt! Tôi đã không phát hiện ra điều đó. Đó không phải là lời giải thích cho sự khác biệt mặc dù. Hãy để tôi cập nhật câu hỏi với một phiên bản mới, nơi điều này được loại trừ.
orlp

Câu trả lời:


256

Đã cập nhật để đồng bộ hóa với chỉnh sửa của OP

Bằng cách sửa đổi mã, tôi đã quản lý để xem GCC tối ưu hóa trường hợp đầu tiên như thế nào.

Trước khi chúng ta có thể hiểu tại sao chúng lại khác nhau như vậy, trước tiên chúng ta phải hiểu cách GCC tối ưu hóa fast_trunc_one().

Tin hay không, fast_trunc_one()đang được tối ưu hóa cho điều này:

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

Điều này tạo ra sự lắp ráp chính xác giống như tên gốc fast_trunc_one()- đăng ký và mọi thứ.

Lưu ý rằng không có xors trong hội đồng cho fast_trunc_one(). Đó là những gì đã cho nó đi cho tôi.


Làm sao vậy


Bước 1: sign = -sign

Đầu tiên, chúng ta hãy nhìn vào signbiến. Vì sign = i & 0x80000000;, chỉ có hai giá trị signcó thể có:

  • sign = 0
  • sign = 0x80000000

Bây giờ nhận ra rằng trong cả hai trường hợp sign == -sign,. Do đó, khi tôi thay đổi mã gốc thành nà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 ^ sign) + sign;
}

Nó tạo ra các lắp ráp chính xác như ban đầu fast_trunc_one(). Tôi sẽ cung cấp cho bạn lắp ráp, nhưng nó là giống hệt nhau - đăng ký tên và tất cả.


Bước 2: Giảm toán học:x + (y ^ x) = y

signchỉ có thể lấy một trong hai giá trị, 0hoặc 0x80000000.

  • Khi nào x = 0, sau x + (y ^ x) = yđó tầm thường giữ.
  • Thêm và xored bởi 0x80000000là như nhau. Nó lật bit dấu. Do đó x + (y ^ x) = ycũng giữ khi x = 0x80000000.

Do đó, x + (y ^ x)giảm xuống y. Và mã đơn giản hóa điều nà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;
}

Một lần nữa, điều này biên dịch thành hội đồng chính xác - đăng ký tên và tất cả.


Phiên bản trên cuối cùng cũng giảm được điều này:

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

đó là khá nhiều chính xác những gì GCC tạo ra trong hội đồng.


Vậy tại sao trình biên dịch không tối ưu hóa fast_trunc_two()cho cùng một thứ?

Phần quan trọng trong fast_trunc_one()x + (y ^ x) = ytối ưu hóa. Trong fast_trunc_two()các x + (y ^ x)biểu thức được chia qua các chi nhánh.

Tôi nghi ngờ rằng có thể đủ để nhầm lẫn GCC để không thực hiện tối ưu hóa này. (Nó sẽ cần phải kéo ^ -signra khỏi nhánh và hợp nhất nó vào r + signcuối.)

Ví dụ, điều này tạo ra lắp ráp tương tự như 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
Chỉnh sửa, có vẻ như tôi đã trả lời sửa đổi hai. Bản sửa đổi hiện tại đã lật hai ví dụ và thay đổi mã một chút ... điều này thật khó hiểu.
Bí ẩn

2
@nightcracker Không phải lo lắng. Tôi đã cập nhật câu trả lời của mình để đồng bộ hóa với phiên bản hiện tại.
Bí ẩn

1
@Mysticial: tuyên bố cuối cùng của bạn không còn đúng với phiên bản mới, khiến câu trả lời của bạn bị vô hiệu (nó không trả lời câu hỏi quan trọng nhất, "Tại sao GCC tạo ra hội đồng hoàn toàn khác biệt như vậy" .)
orlp

11
Trả lời cập nhật lại. Tôi không chắc nó có đủ thỏa mãn hay không. Nhưng tôi không nghĩ rằng tôi có thể làm tốt hơn nhiều mà không biết chính xác cách tối ưu hóa GCC có liên quan hoạt động.
Bí ẩn

4
@Mysticial: Nói đúng ra, miễn là loại đã ký được sử dụng sai trong mã này, gần như tất cả các biến đổi mà trình biên dịch đang thực hiện ở đây là trong trường hợp hành vi không được xác định ...
R .. GitHub DỪNG GIÚP ICE

63

Đây là bản chất của trình biên dịch. Giả sử họ sẽ đi con đường nhanh nhất hoặc tốt nhất, là khá sai lầm. Bất cứ ai ngụ ý rằng bạn không cần phải làm gì với mã của mình để tối ưu hóa vì "trình biên dịch hiện đại" điền vào chỗ trống, làm công việc tốt nhất, tạo mã nhanh nhất, v.v. Thật ra tôi thấy gcc trở nên tồi tệ hơn từ 3.x đến 4.x trên cánh tay ít nhất. 4.x có thể đã bắt kịp tới 3.x vào thời điểm này, nhưng ngay từ đầu nó đã tạo ra mã chậm hơn. Với thực tế, bạn có thể tìm hiểu cách viết mã của mình để trình biên dịch không phải làm việc vất vả và kết quả là tạo ra kết quả phù hợp và mong đợi hơn.

Lỗi ở đây là sự mong đợi của bạn về những gì sẽ được sản xuất, không phải những gì thực sự được sản xuất. Nếu bạn muốn trình biên dịch tạo cùng một đầu ra, hãy cung cấp cho nó cùng một đầu vào. Về mặt toán học không giống nhau, không giống nhau, nhưng thực tế là giống nhau, không có đường dẫn khác nhau, không có hoạt động chia sẻ hoặc phân phối từ phiên bản này sang phiên bản khác. Đây là một bài tập tốt để hiểu cách viết mã của bạn và xem trình biên dịch làm gì với nó. Đừng phạm sai lầm khi cho rằng bởi vì một phiên bản gcc cho một mục tiêu của bộ xử lý một ngày đã tạo ra một kết quả nhất định đó là quy tắc cho tất cả các trình biên dịch và tất cả mã. Bạn phải sử dụng nhiều trình biên dịch và nhiều mục tiêu để cảm nhận về những gì đang diễn ra.

gcc khá khó chịu, tôi mời bạn nhìn phía sau tấm màn, nhìn vào ruột của gcc, cố gắng thêm mục tiêu hoặc tự sửa đổi một cái gì đó. Nó hầu như không được tổ chức với nhau bằng băng keo và dây điện. Một dòng mã bổ sung được thêm hoặc xóa ở những nơi quan trọng và nó bị vỡ vụn. Thực tế là nó đã tạo ra mã có thể sử dụng được là điều đáng hài lòng, thay vì lo lắng về lý do tại sao nó không đáp ứng được những kỳ vọng khác.

Bạn đã xem những phiên bản khác nhau của gcc sản xuất? 3.x và 4.x nói riêng 4.5 so với 4.6 so với 4.7, v.v? và đối với các bộ xử lý đích khác nhau, x86, arm, mips, v.v. hoặc các hương vị khác nhau của x86 nếu đó là trình biên dịch gốc bạn sử dụng, 32 bit so với 64 bit, v.v? Và sau đó llvm (clang) cho các mục tiêu khác nhau?

Mystical đã thực hiện một công việc tuyệt vời trong quá trình suy nghĩ cần thiết để giải quyết vấn đề phân tích / tối ưu hóa mã, hy vọng một trình biên dịch sẽ đưa ra bất kỳ điều gì trong số đó, tốt, không mong đợi đối với bất kỳ "trình biên dịch hiện đại" nào.

Không đi vào các thuộc tính toán học, mã của mẫu này

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

sẽ dẫn trình biên dịch đến A: triển khai nó ở dạng đó, thực hiện if-then-other sau đó hội tụ mã chung để kết thúc và trả về. hoặc B: lưu một nhánh vì đây là đầu đuôi của hàm. Cũng không bận tâm với việc sử dụng hoặc tiết kiệm r.

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

Sau đó, bạn có thể nhận được khi Mystical chỉ ra biến dấu hiệu biến mất tất cả cùng nhau cho mã như được viết. Tôi không mong đợi trình biên dịch sẽ thấy biến ký hiệu biến mất vì vậy bạn nên tự mình làm điều đó và không bắt buộc trình biên dịch phải cố gắng tìm ra nó.

Đây là một cơ hội hoàn hảo để đào sâu vào mã nguồn gcc. Có vẻ như bạn đã tìm thấy một trường hợp trong đó trình tối ưu hóa nhìn thấy một điều trong một trường hợp sau đó một điều khác trong một trường hợp khác. Sau đó thực hiện bước tiếp theo và xem nếu bạn không thể có được gcc để xem trường hợp đó. Mọi tối ưu hóa đều có vì một số cá nhân hoặc nhóm đã nhận ra sự tối ưu hóa và cố tình đặt nó ở đó. Để tối ưu hóa này có mặt và hoạt động mỗi khi ai đó phải đặt nó ở đó (và sau đó kiểm tra nó, và sau đó duy trì nó trong tương lai).

Chắc chắn không cho rằng ít mã hơn nhanh hơn và nhiều mã chậm hơn, rất dễ tạo và tìm các ví dụ về điều đó không đúng. Nó có thể thường xuyên hơn không phải là trường hợp ít mã hơn nhanh hơn mã. Như tôi đã chứng minh từ đầu mặc dù bạn có thể tạo thêm mã để lưu phân nhánh trong trường hợp đó hoặc lặp, v.v. và có kết quả thực là mã nhanh hơn.

Điểm mấu chốt là bạn đã cung cấp một trình biên dịch nguồn khác nhau và mong đợi kết quả tương tự. Vấn đề không phải là đầu ra của trình biên dịch mà là sự mong đợi của người dùng. Nó khá dễ dàng để chứng minh cho một trình biên dịch và bộ xử lý cụ thể, việc thêm một dòng mã làm cho toàn bộ một chức năng chậm hơn đáng kể. Ví dụ tại sao thay đổi a = b + 2; đến a = b + c + 2; gây ra _fill_in_theetric_compiler_name_ tạo mã hoàn toàn khác và chậm hơn? Câu trả lời tất nhiên là trình biên dịch được cung cấp mã khác nhau trên đầu vào để nó hoàn toàn hợp lệ để trình biên dịch tạo đầu ra khác nhau. (thậm chí tốt hơn là khi bạn hoán đổi hai dòng mã không liên quan và khiến đầu ra thay đổi đáng kể) Không có mối quan hệ mong đợi giữa độ phức tạp và kích thước của đầu vào với độ phức tạp và kích thước của đầu ra.

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

Nó được sản xuất ở đâu đó giữa 60 - 100 dòng lắp ráp. Nó không kiểm soát được vòng lặp. Tôi đã không đếm các dòng, nếu bạn nghĩ về nó, nó phải thêm, sao chép kết quả vào đầu vào cho lệnh gọi hàm, thực hiện cuộc gọi hàm, tối thiểu ba thao tác. vì vậy tùy thuộc vào mục tiêu có lẽ ít nhất là 60 hướng dẫn, 80 nếu bốn trên mỗi vòng lặp, 100 nếu năm trên mỗi vòng lặp, v.v.


Tại sao bạn phá hoại câu trả lời của bạn? Oded dường như cũng không đồng ý với chỉnh sửa ;-).
Peter - Tái lập lại

@ PeterA.Schneider tất cả các câu trả lời của anh ấy dường như đã bị phá hoại vào cùng một ngày. Tôi nghĩ ai đó có dữ liệu tài khoản (bị đánh cắp?) Của mình đã làm điều đó.
trinity420

23

Mysticial đã đưa ra một lời giải thích tuyệt vời, nhưng tôi nghĩ tôi đã thêm, FWIW, rằng thực sự không có gì cơ bản về lý do tại sao một trình biên dịch sẽ tối ưu hóa cho cái này chứ không phải cái khác.

clangVí dụ, trình biên dịch của LLVM cung cấp cùng một mã cho cả hai hàm (ngoại trừ tên hàm), đưa ra:

_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

Mã này không ngắn như phiên bản gcc đầu tiên từ OP, nhưng không dài bằng phiên bản thứ hai.

Mã từ trình biên dịch khác (mà tôi sẽ không đặt tên), biên dịch cho x86_64, tạo mã này cho cả hai hàm:

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                         

Điều hấp dẫn ở chỗ nó tính toán cả hai mặt của ifvà sau đó sử dụng một động thái có điều kiện ở cuối để chọn đúng.

Trình biên dịch Open64 tạo ra các mục sau:

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                  

và tương tự, nhưng không giống nhau, mã cho fast_trunc_two.

Dù sao, khi nói đến tối ưu hóa, đó là xổ số - đó là những gì ... Không phải lúc nào cũng dễ dàng để biết lý do tại sao mã của bạn được biên dịch theo bất kỳ cách cụ thể nào.


10
Trình biên dịch bạn sẽ không đặt tên cho một siêu trình biên dịch tuyệt mật?
orlp

4
trình biên dịch Top Secret có lẽ là Intel icc. Tôi chỉ có biến thể 32 bit nhưng nó tạo ra mã rất giống với biến này.
Janus Troelsen

5
Tôi cũng tin đó là ICC. Trình biên dịch biết rằng bộ xử lý có khả năng song song mức lệnh và do đó cả hai nhánh có thể được tính toán đồng thời. Chi phí di chuyển có điều kiện thấp hơn nhiều so với chi phí dự đoán chi nhánh sai.
Filip Navara
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.