Tại sao trình biên dịch C tối ưu hóa chuyển đổi và nếu khác nhau


9

Tôi đang làm việc trên một dự án cá nhân gần đây khi tôi tình cờ gặp một vấn đề kỳ lạ.

Trong một vòng lặp rất chặt chẽ, tôi có một số nguyên có giá trị từ 0 đến 15. Tôi cần lấy -1 cho các giá trị 0, 1, 8 và 9 và 1 cho các giá trị 4, 5, 12 và 13.

Tôi đã chuyển sang godbolt để kiểm tra một vài tùy chọn và ngạc nhiên rằng dường như trình biên dịch không thể tối ưu hóa một câu lệnh chuyển đổi giống như một chuỗi if.

Liên kết ở đây: https://godbolt.org/z/WYVBFl

Mã này là:

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}

int b(int num) {
    num &= 0xF;

    if (num == 0 || num == 1 || num == 8 || num == 9) 
        return -1;

    if (num == 4 || num == 5 || num == 12 || num == 13)
        return 1;

    return 0;
}

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
        default:
            return 0;
    }
}

Tôi đã nghĩ rằng b và c sẽ mang lại kết quả tương tự, và tôi hy vọng rằng tôi có thể đọc các bản hack bit để tự mình thực hiện một cách hiệu quả vì giải pháp của tôi (câu lệnh chuyển đổi - ở dạng khác) khá chậm.

Điều kỳ lạ, bđược biên dịch thành các bản hack bit trong khi cgần như không được tối ưu hóa hoặc giảm xuống thành một trường hợp khác atùy thuộc vào phần cứng đích.

Bất cứ ai có thể giải thích tại sao có sự khác biệt này? Cách 'chính xác' để tối ưu hóa truy vấn này là gì?

BIÊN TẬP:

Làm rõ

Tôi muốn giải pháp chuyển đổi là nhanh nhất hoặc giải pháp "sạch" tương tự. Tuy nhiên, khi được biên dịch với tối ưu hóa trên máy của tôi, giải pháp if nhanh hơn đáng kể.

Tôi đã viết một chương trình nhanh để chứng minh và TIO có kết quả giống như tôi tìm thấy ở địa phương: Dùng thử trực tuyến!

Với static inlinebảng tra cứu tăng tốc lên một chút: Hãy thử trực tuyến!


4
Tôi nghi ngờ câu trả lời là "Trình biên dịch không phải lúc nào cũng đưa ra lựa chọn lành mạnh". Tôi vừa biên dịch mã của bạn thành một đối tượng với GCC 8.3.0 -O3và nó đã biên dịch cthành một thứ có khả năng tệ hơn ahoặc b( ccó hai lần nhảy có điều kiện cộng với một vài thao tác bit, so với chỉ một bước nhảy có điều kiện và thao tác bit đơn giản hơn b), nhưng vẫn tốt hơn so với mục ngây thơ bằng các bài kiểm tra mục. Tôi không chắc chắn những gì bạn thực sự yêu cầu ở đây; thực tế đơn giản là một trình biên dịch tối ưu hóa có thể biến bất kỳ thứ nào trong số này thành bất kỳ cái nào khác nếu nó được chọn, và không có quy tắc cứng và nhanh nào cho những gì nó sẽ hoặc không làm.
ShadowRanger

Vấn đề của tôi là tôi cần nó phải nhanh, nhưng giải pháp nếu không được bảo trì quá mức. Có cách nào để có được trình biên dịch để tối ưu hóa một giải pháp sạch hơn đủ không? Bất cứ ai có thể giải thích tại sao nó không thể làm như vậy trong trường hợp này?
LambdaBeta

Tôi sẽ bắt đầu bằng cách định nghĩa ít nhất là các hàm là tĩnh hoặc tốt hơn là nội tuyến chúng.
wildplasser

@wildplasser tăng tốc, nhưng ifvẫn đập switch(tra cứu kỳ lạ thậm chí còn nhanh hơn) [TIO để theo dõi]
LambdaBeta

@LambdaBeta Không có cách nào để nói với trình biên dịch để tối ưu hóa theo một cách cụ thể. Bạn sẽ lưu ý rằng clang và msvc tạo mã hoàn toàn khác nhau cho những thứ này. Nếu bạn không quan tâm và chỉ muốn bất cứ điều gì hoạt động tốt nhất trên gcc, thì hãy chọn nó. Tối ưu hóa trình biên dịch dựa trên phương pháp phỏng đoán và những phương pháp này không mang lại giải pháp tối ưu trong mọi trường hợp; Họ đang cố gắng trở nên tốt trong trường hợp trung bình, không tối ưu trong mọi trường hợp.
Khối

Câu trả lời:


6

Nếu bạn liệt kê rõ ràng tất cả các trường hợp, gcc rất hiệu quả:

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
            case 2: case 3: case 6: case 7: case 10: case 11: case 14: case 15: 
        //default:
            return 0;
    }
}

chỉ được biên dịch trong một nhánh được lập chỉ mục đơn giản:

c:
        and     edi, 15
        jmp     [QWORD PTR .L10[0+rdi*8]]
.L10:
        .quad   .L12
        .quad   .L12
        .quad   .L9
        .quad   .L9
        .quad   .L11
        .quad   .L11
        .quad   .L9
        .quad   .L9
        .quad   .L12
etc...

Lưu ý rằng nếu không default:bị thiếu, gcc sẽ quay lại phiên bản nhánh được lồng.


1
@LambdaBeta Bạn nên cân nhắc việc không chấp nhận câu trả lời của tôi và chấp nhận câu trả lời này, bởi vì CPU Intel hiện đại có thể thực hiện hai lần đọc / chu trình bộ nhớ được lập chỉ mục song song trong khi thông lượng của thủ thuật của tôi có thể là 1 lần tra cứu / chu kỳ. Mặt khác, có lẽ bản hack của tôi phù hợp hơn với vector hóa 4 chiều với SSE2 pslld/ psradhoặc tương đương AVX2 8 chiều của chúng. Phần lớn phụ thuộc vào các đặc tính khác của mã của bạn.
Idillotexist Idillotexist

4

Trình biên dịch C có các trường hợp đặc biệt switchvì họ mong muốn các lập trình viên hiểu được thành ngữ switchvà khai thác nó.

Mã như:

if (num == 0 || num == 1 || num == 8 || num == 9) 
    return -1;

if (num == 4 || num == 5 || num == 12 || num == 13)
    return 1;

sẽ không vượt qua đánh giá bởi các lập trình viên C có thẩm quyền; ba hoặc bốn người đánh giá sẽ đồng thời kêu lên "đây phải là một switch!"

Trình biên dịch C không đáng để phân tích cấu trúc của các ifcâu lệnh để chuyển đổi sang bảng nhảy. Các điều kiện cho điều đó phải vừa phải, và số lượng biến thể có thể có trong một loạt các iftuyên bố là thiên văn học. Phân tích vừa phức tạp vừa có khả năng đưa ra kết quả âm tính (như trong: "không, chúng tôi không thể chuyển đổi các ifs này thành switch").


Tôi biết, đó là lý do tại sao tôi bắt đầu với công tắc. Tuy nhiên, giải pháp if nhanh hơn đáng kể trong trường hợp của tôi. Về cơ bản, tôi đang hỏi liệu có cách nào để thuyết phục trình biên dịch sử dụng giải pháp tốt hơn cho công tắc không, vì nó có thể tìm thấy mẫu trong ifs, nhưng không phải là công tắc. (Tôi không thích ifs cụ thể vì chúng không rõ ràng hoặc có thể bảo trì)
LambdaBeta

Nâng cao nhưng không được chấp nhận vì tình cảm chính xác là lý do tại sao tôi thực hiện câu hỏi này. Tôi muốn sử dụng công tắc, nhưng nó quá chậm trong trường hợp của tôi, tôi muốn tránh ifnếu có thể.
LambdaBeta

@LambdaBeta: Có một số lý do để tránh bảng tra cứu? Tạo nó staticsử dụng các trình khởi tạo được chỉ định C99 nếu bạn muốn làm rõ hơn một chút những gì bạn đang gán, và nó hoàn toàn ổn.
ShadowRanger

1
Tôi sẽ bắt đầu ít nhất là loại bỏ bit thấp để công việc tối ưu hóa phải làm ít hơn.
R .. GitHub DỪNG GIÚP ICE

@ShadowRanger Thật không may là vẫn chậm hơn if (xem chỉnh sửa). @R .. Tôi đã tìm ra giải pháp bitwise đầy đủ cho trình biên dịch, đó là những gì tôi đang sử dụng cho bây giờ. Thật không may trong trường hợp của tôi đây là enumcác giá trị, không phải là số nguyên trần, do đó, các bit hack không thể duy trì được.
LambdaBeta

4

Đoạn mã sau sẽ tính toán chi nhánh tra cứu của bạn, không LUT, trong ~ 3 chu kỳ đồng hồ, ~ 4 hướng dẫn hữu ích và ~ 13 byte inlinemã máy x86 có khả năng cao.

Nó phụ thuộc vào biểu diễn số nguyên bổ sung của 2.

Bạn phải, tuy nhiên, đảm bảo rằng u32s32typedefs thực sự trỏ đến 32-bit unsigned và ký số nguyên loại. stdint.hloại uint32_tint32_tsẽ phù hợp nhưng tôi không biết nếu tiêu đề có sẵn cho bạn.

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}


int d(int num){
    typedef unsigned int u32;
    typedef signed   int s32;

    // const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
    // 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
    // Hexadecimal:                   F     0     5     0     F     0     5     0
    const u32 K = 0xF050F050U;

    return (s32)(K<<(num+num)) >> 30;
}

int main(void){
    for(int i=0;i<16;i++){
        if(a(i) != d(i)){
            return !0;
        }
    }
    return 0;
}

Xem cho chính mình ở đây: https://godbolt.org/z/AcJWWf


Về việc lựa chọn hằng số

Tra cứu của bạn là 16 hằng số rất nhỏ trong khoảng từ -1 đến +1. Mỗi khớp vừa trong 2 bit và có 16 bit, chúng ta có thể trình bày như sau:

// const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
// 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
// Hexadecimal:                   F     0     5     0     F     0     5     0
u32 K = 0xF050F050U;

Bằng cách đặt chúng với chỉ số 0 gần nhất với bit có ý nghĩa nhất, một sự thay đổi duy nhất của 2*num sẽ đặt bit dấu của số 2 bit của bạn vào bit dấu của thanh ghi. Chuyển sang phải số 2 bit bằng dấu 32-2 = 30 bit - mở rộng nó thành đầy đủ int, hoàn thành thủ thuật.


Đây có thể chỉ là cách sạch nhất để làm điều đó với một magicbình luận giải thích cách tái tạo nó. Bạn có thể giải thích làm thế nào bạn đưa ra nó?
LambdaBeta

Được chấp nhận vì điều này có thể được thực hiện 'sạch' trong khi cũng nhanh chóng. (thông qua một số phép thuật tiền xử lý :) < xkcd.com/541 >)
LambdaBeta

1
!!(12336 & (1<<x))-!!(771 & (1<<x));
Đánh bại

0

Bạn có thể tạo hiệu ứng tương tự chỉ bằng số học:

// produces : -1 -1 0 0 1 1 0 0 -1 -1 0 0 1 1 0 0 ...
int foo ( int x )
{
    return 1 - ( 3 & ( 0x46 >> ( x & 6 ) ) );
}

Mặc dù, về mặt kỹ thuật, đây vẫn là một tra cứu (bitwise).

Nếu những điều trên có vẻ quá phức tạp, bạn cũng có thể làm:

int foo ( int x )
{
    int const y = x & 6;
    return (y == 4) - !y;
}
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.