Làm thế nào để các macro có khả năng / không có khả năng trong nhân Linux hoạt động và lợi ích của chúng là gì?


348

Tôi đã đào qua một số phần của nhân Linux và tìm thấy các cuộc gọi như thế này:

if (unlikely(fd < 0))
{
    /* Do something */
}

hoặc là

if (likely(!err))
{
    /* Do something */
}

Tôi đã tìm thấy định nghĩa của chúng:

#define likely(x)       __builtin_expect((x),1)
#define unlikely(x)     __builtin_expect((x),0)

Tôi biết rằng chúng là để tối ưu hóa, nhưng chúng hoạt động như thế nào? Và có thể giảm bao nhiêu hiệu suất / kích thước từ việc sử dụng chúng? Và nó có đáng để phiền phức (và có thể mất tính di động) ít nhất là trong mã tắc nghẽn (tất nhiên là trong không gian người dùng).


7
Điều này thực sự không đặc trưng cho nhân Linux hoặc về các macro, nhưng tối ưu hóa trình biên dịch. Điều này có nên được thử lại để phản ánh điều đó?
Cody Brocons

11
Bài viết Những gì mọi lập trình viên nên biết về Bộ nhớ (tr. 57) chứa một lời giải thích sâu sắc.
Torsten Marek

2
xem thêmBOOST_LIKELY
Ruggero Turra


13
Không có vấn đề về tính di động. Bạn có thể làm những việc như #define likely(x) (x)#define unlikely(x) (x)trên các nền tảng không hỗ trợ loại gợi ý này một cách tầm thường.
David Schwartz

Câu trả lời:


329

Chúng là gợi ý cho trình biên dịch để phát ra các hướng dẫn sẽ khiến dự đoán nhánh nghiêng về phía "có khả năng" của lệnh nhảy. Đây có thể là một chiến thắng lớn, nếu dự đoán là chính xác, điều đó có nghĩa là lệnh nhảy về cơ bản là miễn phí và sẽ không có chu kỳ. Mặt khác, nếu dự đoán sai, điều đó có nghĩa là đường ống xử lý cần phải được xóa và nó có thể tốn vài chu kỳ. Miễn là dự đoán là chính xác hầu hết thời gian, điều này sẽ có xu hướng tốt cho hiệu suất.

Giống như tất cả các tối ưu hóa hiệu suất như vậy, bạn chỉ nên thực hiện sau khi định hình mở rộng để đảm bảo mã thực sự bị tắc nghẽn và có thể có tính chất vi mô, rằng nó đang được chạy trong một vòng lặp chặt chẽ. Nói chung các nhà phát triển Linux khá có kinh nghiệm nên tôi sẽ tưởng tượng họ sẽ làm điều đó. Họ không thực sự quan tâm quá nhiều đến tính di động khi họ chỉ nhắm mục tiêu gcc và họ có một ý tưởng rất gần gũi về hội đồng mà họ muốn nó tạo ra.


3
Các macro này chủ yếu được sử dụng để kiểm tra lỗi. Vì lỗi ít có lẽ hoạt động bình thường. Một vài người lập hồ sơ hoặc tính toán để quyết định lá được sử dụng nhiều nhất ...
gavenkoa

51
Liên quan đến đoạn "[...]that it is being run in a tight loop", nhiều CPU có bộ dự báo nhánh , do đó, việc sử dụng các macro này chỉ giúp mã lần đầu tiên được thực thi hoặc khi bảng lịch sử được ghi đè bởi một nhánh khác có cùng chỉ mục vào bảng phân nhánh. Trong một vòng lặp chặt chẽ và giả sử một nhánh đi một chiều trong hầu hết thời gian, người dự đoán nhánh có thể sẽ bắt đầu đoán đúng nhánh rất nhanh. - bạn của bạn trong nghề giáo.
Ross Rogers

8
@RossRogers: Điều thực sự xảy ra là trình biên dịch sắp xếp các nhánh nên trường hợp phổ biến là không lấy. Điều này nhanh hơn ngay cả khi dự đoán chi nhánh không hoạt động. Các nhánh đã thực hiện có vấn đề đối với tìm nạp lệnh và giải mã ngay cả khi chúng được dự đoán hoàn hảo. Một số CPU dự đoán tĩnh các nhánh không có trong bảng lịch sử của chúng, thường với giả định không được sử dụng cho các nhánh chuyển tiếp. CPU Intel không hoạt động theo cách đó: họ không cố kiểm tra xem mục nhập bảng dự đoán là dành cho nhánh này , dù sao họ cũng chỉ sử dụng nó. Một nhánh nóng và một nhánh lạnh có thể bí danh cùng một mục ...
Peter Cordes

12
Câu trả lời này hầu hết đã lỗi thời vì tuyên bố chính là nó giúp dự đoán nhánh và như @PeterCordes chỉ ra, trong hầu hết các phần cứng hiện đại không có dự đoán nhánh tĩnh rõ ràng hoặc rõ ràng. Trong thực tế, gợi ý được trình biên dịch sử dụng để tối ưu hóa mã, cho dù điều đó liên quan đến gợi ý nhánh tĩnh hoặc bất kỳ loại tối ưu hóa nào khác. Đối với hầu hết các kiến ​​trúc ngày nay, đó là "bất kỳ tối ưu hóa nào khác" quan trọng, ví dụ, làm cho các đường dẫn nóng tiếp giáp, lập lịch trình tốt hơn cho đường dẫn nóng, giảm thiểu kích thước của đường dẫn chậm, chỉ vector hóa đường dẫn dự kiến, v.v.
BeeOnRope

3
@BeeOnRope vì tìm nạp trước bộ đệm và kích thước từ, vẫn có một lợi thế để chạy chương trình một cách tuyến tính. Vị trí bộ nhớ tiếp theo sẽ được tìm nạp và trong bộ đệm, mục tiêu nhánh có thể hoặc không. Với CPU 64 bit, bạn lấy ít nhất 64 bit mỗi lần. Tùy thuộc vào DRAM xen kẽ, nó có thể gấp 2 lần hoặc nhiều bit được lấy.
Bryce

88

Hãy dịch ngược để xem GCC 4.8 làm gì với nó

Không có __builtin_expect

#include "stdio.h"
#include "time.h"

int main() {
    /* Use time to prevent it from being optimized away. */
    int i = !time(NULL);
    if (i)
        printf("%d\n", i);
    puts("a");
    return 0;
}

Biên dịch và dịch ngược với GCC 4.8.2 x86_64 Linux:

gcc -c -O3 -std=gnu11 main.c
objdump -dr main.o

Đầu ra:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       75 14                   jne    24 <main+0x24>
  10:       ba 01 00 00 00          mov    $0x1,%edx
  15:       be 00 00 00 00          mov    $0x0,%esi
                    16: R_X86_64_32 .rodata.str1.1
  1a:       bf 01 00 00 00          mov    $0x1,%edi
  1f:       e8 00 00 00 00          callq  24 <main+0x24>
                    20: R_X86_64_PC32       __printf_chk-0x4
  24:       bf 00 00 00 00          mov    $0x0,%edi
                    25: R_X86_64_32 .rodata.str1.1+0x4
  29:       e8 00 00 00 00          callq  2e <main+0x2e>
                    2a: R_X86_64_PC32       puts-0x4
  2e:       31 c0                   xor    %eax,%eax
  30:       48 83 c4 08             add    $0x8,%rsp
  34:       c3                      retq

Thứ tự lệnh trong bộ nhớ không thay đổi: đầu tiên printfvà sau đó putsretqtrả về.

Với __builtin_expect

Bây giờ thay thế if (i)bằng:

if (__builtin_expect(i, 0))

và chúng tôi nhận được:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       74 11                   je     21 <main+0x21>
  10:       bf 00 00 00 00          mov    $0x0,%edi
                    11: R_X86_64_32 .rodata.str1.1+0x4
  15:       e8 00 00 00 00          callq  1a <main+0x1a>
                    16: R_X86_64_PC32       puts-0x4
  1a:       31 c0                   xor    %eax,%eax
  1c:       48 83 c4 08             add    $0x8,%rsp
  20:       c3                      retq
  21:       ba 01 00 00 00          mov    $0x1,%edx
  26:       be 00 00 00 00          mov    $0x0,%esi
                    27: R_X86_64_32 .rodata.str1.1
  2b:       bf 01 00 00 00          mov    $0x1,%edi
  30:       e8 00 00 00 00          callq  35 <main+0x35>
                    31: R_X86_64_PC32       __printf_chk-0x4
  35:       eb d9                   jmp    10 <main+0x10>

Các printf(biên soạn để __printf_chk) đã được chuyển đến tận cùng của hàm, sau putsvà sự trở lại để cải thiện dự đoán rẽ nhánh như đã đề cập bởi câu trả lời khác.

Vì vậy, về cơ bản nó giống như:

int main() {
    int i = !time(NULL);
    if (i)
        goto printf;
puts:
    puts("a");
    return 0;
printf:
    printf("%d\n", i);
    goto puts;
}

Tối ưu hóa này đã không được thực hiện với -O0.

Nhưng may mắn khi viết một ví dụ chạy nhanh __builtin_expecthơn không có, CPU thực sự thông minh ngày nay . Những nỗ lực ngây thơ của tôi đang ở đây .

C ++ 20 [[likely]][[unlikely]]

C ++ 20 đã tiêu chuẩn hóa các phần tử tích hợp C ++ đó: Cách sử dụng thuộc tính có khả năng / không có khả năng của C ++ 20 trong câu lệnh if-other Họ có thể (một cách chơi chữ!) Làm điều tương tự.


71

Đây là các macro cung cấp gợi ý cho trình biên dịch về cách một nhánh có thể đi. Các macro mở rộng sang các tiện ích mở rộng cụ thể của GCC, nếu chúng có sẵn.

GCC sử dụng những điều này để tối ưu hóa cho dự đoán chi nhánh. Ví dụ, nếu bạn có một cái gì đó như sau

if (unlikely(x)) {
  dosomething();
}

return x;

Sau đó, nó có thể cấu trúc lại mã này thành một cái gì đó giống như:

if (!x) {
  return x;
}

dosomething();
return x;

Lợi ích của việc này là khi bộ xử lý lần đầu tiên có một nhánh, sẽ có chi phí đáng kể, bởi vì nó có thể đã được tải và thực thi mã theo cách đặc biệt hơn nữa. Khi nó xác định nó sẽ lấy nhánh, thì nó phải làm mất hiệu lực của nó và bắt đầu tại mục tiêu nhánh.

Hầu hết các bộ xử lý hiện đại hiện nay đều có một số loại dự đoán nhánh, nhưng nó chỉ hỗ trợ khi bạn đã đi qua nhánh trước đó và nhánh vẫn nằm trong bộ đệm dự đoán nhánh.

Có một số chiến lược khác mà trình biên dịch và bộ xử lý có thể sử dụng trong các tình huống này. Bạn có thể tìm thêm chi tiết về cách các công cụ dự đoán chi nhánh hoạt động tại Wikipedia: http://en.wikipedia.org/wiki/Branch_predictor


3
Ngoài ra, nó tác động đến dấu chân icache - bằng cách giữ các đoạn mã không chắc chắn ra khỏi đường dẫn nóng.
ngày

2
Chính xác hơn, nó có thể làm điều đó với gotos mà không cần lặp lại return x: stackoverflow.com/a/31133787/895245
Ciro Santilli 冠状 病 六四 法轮功

7

Chúng làm cho trình biên dịch phát ra các gợi ý nhánh thích hợp nơi phần cứng hỗ trợ chúng. Điều này thường chỉ có nghĩa là xoay một vài bit trong opcode lệnh, vì vậy kích thước mã sẽ không thay đổi. CPU sẽ bắt đầu tìm nạp các hướng dẫn từ vị trí dự đoán, và làm sạch đường ống và bắt đầu lại nếu điều đó bị sai khi đạt được nhánh; trong trường hợp gợi ý là chính xác, điều này sẽ làm cho nhánh nhanh hơn nhiều - chính xác là phụ thuộc vào phần cứng nhanh hơn bao nhiêu; và mức độ này ảnh hưởng đến hiệu suất của mã sẽ phụ thuộc vào tỷ lệ gợi ý thời gian là chính xác.

Ví dụ, trên CPU PowerPC, một nhánh không định hướng có thể mất 16 chu kỳ, một 8 gợi ý chính xác và một gợi ý không chính xác 24. Trong các vòng lặp trong cùng, gợi ý tốt có thể tạo ra sự khác biệt lớn.

Tính di động không thực sự là một vấn đề - có lẽ định nghĩa nằm trong tiêu đề trên mỗi nền tảng; bạn chỉ có thể định nghĩa "có khả năng" và "không thể" thành không có gì cho các nền tảng không hỗ trợ gợi ý nhánh tĩnh.


3
Đối với bản ghi, x86 không chiếm thêm không gian cho các gợi ý nhánh. Bạn phải có tiền tố một byte trên các nhánh để chỉ định gợi ý thích hợp. Mặc dù vậy, đồng ý rằng gợi ý là một điều tốt (TM).
Cody Brocons

2
Các CPU Dang CISC và các hướng dẫn có độ dài thay đổi của chúng;)
moonshadow

3
Các CPU Dang RISC - Tránh xa các hướng dẫn 15 byte của tôi;)
Cody Brocons

7
@CodyBrocious: gợi ý nhánh được giới thiệu với P4, nhưng đã bị bỏ qua cùng với P4. Tất cả các CPU x86 khác chỉ cần bỏ qua các tiền tố đó (vì các tiền tố luôn bị bỏ qua trong các bối cảnh nơi chúng vô nghĩa). Các macro này không khiến gcc thực sự phát ra tiền tố gợi ý nhánh trên x86. Chúng giúp bạn có được gcc để bố trí chức năng của bạn với ít nhánh được thực hiện trên đường dẫn nhanh.
Peter Cordes

5
long __builtin_expect(long EXP, long C);

Cấu trúc này cho trình biên dịch biết rằng biểu thức EXP rất có thể sẽ có giá trị C. Giá trị trả về là EXP. __builtin_Exect có nghĩa là được sử dụng trong một biểu thức điều kiện. Trong hầu hết các trường hợp, nó sẽ được sử dụng trong ngữ cảnh của các biểu thức boolean trong trường hợp đó sẽ thuận tiện hơn nhiều khi định nghĩa hai macro trợ giúp:

#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)

Các macro này sau đó có thể được sử dụng như trong

if (likely(a > 1))

Tham khảo: https://www.akkadia.org/drepper/cpumemory.pdf


1
Như đã được hỏi trong một bình luận cho một câu trả lời khác - lý do cho sự đảo ngược kép trong các macro (nghĩa là tại sao sử dụng __builtin_expect(!!(expr),0)thay vì chỉ __builtin_expect((expr),0)?
Michael Firth

1
@MichaelFirth "đảo ngược kép" !!tương đương với việc tạo một cái gì đó cho a bool. Một số người thích viết theo cách này.
Ben XO

2

(nhận xét chung - câu trả lời khác bao gồm các chi tiết)

Không có lý do gì mà bạn nên mất tính di động bằng cách sử dụng chúng.

Bạn luôn có tùy chọn tạo một macro "nội tuyến" hoặc macro hiệu ứng đơn giản cho phép bạn biên dịch trên các nền tảng khác với các trình biên dịch khác.

Bạn sẽ không nhận được lợi ích của việc tối ưu hóa nếu bạn ở trên các nền tảng khác.


1
Bạn không sử dụng tính di động - các nền tảng không hỗ trợ chúng chỉ xác định chúng để mở rộng thành chuỗi trống.
sharptooth

2
Tôi nghĩ rằng hai bạn thực sự đồng ý với nhau - đó chỉ là những câu khó hiểu. (Từ vẻ của nó, bình luận của Andrew đang nói "bạn có thể sử dụng chúng mà không làm mất tính di động" nhưng sharptooth nghĩ rằng ông nói "không sử dụng chúng như họ không di động" và phản đối.)
MIRAL

2

Theo nhận xét của Cody , điều này không liên quan gì đến Linux, nhưng là một gợi ý cho trình biên dịch. Điều gì xảy ra sẽ phụ thuộc vào phiên bản kiến ​​trúc và trình biên dịch.

Tính năng đặc biệt này trong Linux được sử dụng sai trong trình điều khiển. Như osgx chỉ ra trong ngữ nghĩa của thuộc tính nóng , bất kỳ hothoặc coldhàm nào được gọi trong một khối có thể tự động gợi ý rằng điều kiện có khả năng hay không. Ví dụ, dump_stack()được đánh dấu coldđể điều này là dư thừa,

 if(unlikely(err)) {
     printk("Driver error found. %d\n", err);
     dump_stack();
 }

Các phiên bản trong tương lai gcccó thể chọn lọc nội tuyến một chức năng dựa trên những gợi ý này. Cũng có ý kiến ​​cho rằng nó không phải boolean, nhưng một số điểm rất có thể , v.v ... Nói chung, nên sử dụng một số cơ chế thay thế như thế cold. Không có lý do để sử dụng nó ở bất cứ nơi nào ngoài những con đường nóng. Những gì một trình biên dịch sẽ làm trên một kiến ​​trúc có thể hoàn toàn khác nhau trên một kiến ​​trúc khác.


2

Trong nhiều bản phát hành linux, bạn có thể tìm thấy compier.h trong / usr / linux /, bạn có thể đưa nó vào sử dụng một cách đơn giản. Và một ý kiến ​​khác, không chắc () hữu ích hơn là có khả năng (), bởi vì

if ( likely( ... ) ) {
     doSomething();
}

nó có thể được tối ưu hóa trong nhiều trình biên dịch.

Và nhân tiện, nếu bạn muốn quan sát hành vi chi tiết của mã, bạn có thể thực hiện đơn giản như sau:

gcc -c test.c objdump -d test.o> obj.s

Sau đó, mở obj.s, bạn có thể tìm thấy câu trả lời.


1

Chúng gợi ý cho trình biên dịch để tạo tiền tố gợi ý trên các nhánh. Trên x86 / x64, chúng chiếm một byte, do đó, bạn sẽ nhận được nhiều nhất là tăng một byte cho mỗi nhánh. Về hiệu năng, nó hoàn toàn phụ thuộc vào ứng dụng - trong hầu hết các trường hợp, bộ dự đoán nhánh trên bộ xử lý sẽ bỏ qua chúng, những ngày này.

Chỉnh sửa: Quên về một nơi mà họ thực sự có thể giúp đỡ. Nó có thể cho phép trình biên dịch sắp xếp lại biểu đồ luồng điều khiển để giảm số lượng nhánh được lấy cho đường dẫn 'có khả năng'. Điều này có thể có một sự cải thiện rõ rệt trong các vòng lặp trong đó bạn đang kiểm tra nhiều trường hợp thoát.


10
gcc không bao giờ tạo ra gợi ý nhánh x86 - ít nhất là tất cả các CPU Intel sẽ bỏ qua chúng. Mặc dù vậy, nó sẽ cố gắng giới hạn kích thước mã ở các khu vực không có khả năng bằng cách tránh nội tuyến và không kiểm soát vòng lặp.
alex lạ

1

Đây là các hàm GCC để lập trình viên đưa ra gợi ý cho trình biên dịch về điều kiện nhánh có khả năng nhất sẽ là gì trong một biểu thức đã cho. Điều này cho phép trình biên dịch xây dựng các hướng dẫn nhánh sao cho trường hợp phổ biến nhất sẽ lấy số lệnh ít nhất để thực thi.

Làm thế nào các hướng dẫn chi nhánh được xây dựng phụ thuộc vào kiến ​​trúc bộ xử lý.

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.