Linux

Как работают макросы likely/unlikely в ядре Linux и в чем их преимущество

Я смотрел код в некоторых частях ядра Linux и нашел вызовы, подобные этому:

if (unlikely(fd < 0)) {

    /* некоторый код */

}

Или:

if (likely(!err)) {

    /* некоторый код */

}

 

Я нашел их определение:

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

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

 

Я знаю, что они предназначены для оптимизации, но как они работают? И какого снижения производительности/размера можно ожидать от их использования? И стоит ли это хлопот (и потери переносимости, вероятно), по крайней мере в узком месте кода (на уровне пользователя, конечно).

 

Ответ 1

Эти макросы подсказывают компилятору, что нужно выдать инструкции, которые заставят предсказание ветвления предпочесть "вероятную" сторону инструкции перехода. Это может быть большим выигрышем, если предсказание верно, то это означает, что инструкция перехода практически бесплатна и займет ноль тактов. С другой стороны, если предсказание неверно, то это означает, что конвейер процессора должен быть очищен, а это может стоить несколько тактов. До тех пор, пока предсказание будет правильным большую часть времени, это будет благоприятно сказываться на производительности.

Как и все подобные оптимизации производительности, вы должны делать это только после тщательного профилирования, чтобы убедиться, что код действительно находится в узком месте, и, вероятно, учитывая микро архитектуру процессора, на котором код выполняется в «узком» месте цикла. Как правило, разработчики Linux довольно опытны, поэтому я полагаю, что они это сделают. Они не слишком заботятся о переносимости, поскольку используют только gcc, и у них есть конкретное представление о сборке, которую они хотят генерировать.

 

Ответ 2

Давайте проведем декомпиляцию, чтобы посмотреть, что с этим делает GCC 4.8.

Без __builtin_expect

#include "stdio.h"

#include "time.h"

 

int main() {

    /* Используйте время, чтобы предотвратить его оптимизацию. */

    int i = !time(NULL);

    if (i)

        printf("%d\n", i);

    puts("a");

    return 0;

}

Компиляция и декомпиляция с помощью GCC 4.8.2 x86_64 Linux:

gcc -c -O3 -std=gnu11 main.c

objdump -dr main.o

 

Вывод:

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

 

Порядок команд в памяти остался неизменным:  сначала printf, затем puts и возврат retq.

С __builtin_expect

 

Теперь замените if (i) на:

 

if (__builtin_expect(i, 0))

 

Вывод:

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>

 

Printf (скомпилированный в __printf_chk) был перемещен в самый конец функции, после puts и return, чтобы улучшить предсказание ветвлений, как упоминалось в других ответах. Таким образом, по сути это то же самое, что и:

int main() {

    int i = !time(NULL);

    if (i)

        goto printf;

puts:

    puts("a");

    return 0;

printf:

    printf("%d\n", i);

    goto puts;

}



Ответ 3

Это макросы, которые дают компилятору подсказки о том, в какую сторону может пойти ветвление. Макросы расширяются до специфических для GCC расширений, если они доступны. GCC использует их для оптимизации предсказания ветвлений. Например, если у вас есть что-то вроде следующего:

if (unlikely(x)) {

  dosomething();

}

return x;

 

Затем gcc может перестроить этот код так, чтобы он стал чем-то более похожим на этот:

if (!x) {

  return x;

}

dosomething();

return x;

 Преимущество этого заключается в том, что когда процессор выполняет ответвление в первый раз, возникают значительные накладные расходы, поскольку он мог спекулятивно загружать и выполнять код дальше. Когда процессор определяет, что он сделает ответвление, он должен аннулировать это и начать с цели ответвления.

Большинство современных процессоров сейчас имеют своего рода предсказание ветвлений, но это помогает только в том случае, если вы уже проходили через ветвление, и оно все еще находится в кэше предсказания ветвлений.

Существует ряд других стратегий, которые компилятор и процессор могут использовать в этих сценариях.

 

Ответ 4

Они заставляют компилятор выдавать соответствующие подсказки ветвления, если аппаратура их поддерживает. Обычно это означает лишь изменение нескольких битов в опкоде инструкции, так что размер кода не изменится. Процессор начнет выборку инструкций из предсказанного места, очистит конвейер и начнет выполнение сначала, если это окажется неверным, когда будет достигнуто ответвление; в случае, когда подсказка верна, это сделает ответвление намного быстрее насколько именно быстрее, зависит от аппаратного обеспечения; и насколько это влияет на производительность кода.

Например, на процессоре PowerPC ветвление без подсказки может занять 16 тактов, правильно подсказанное 8, а неправильно предсказанное 24. Во внутренних циклах хорошая подсказка может иметь огромное значение. Переносимость не является проблемой предположительно, определение находится в заголовке кода для каждой платформы; вы можете просто определить "вероятно" и "маловероятно" в «ничто» для платформ, которые не поддерживают статические подсказки ветвления.

Схожие статьи

Linux

Как отсортировать вывод du -h по размеру

Linux

Взломан сервер – что делать?

Архитектура Linux. Детальное описание анатомии Линукса от и до
Linux

Архитектура Linux. Детальное описание анатомии Линукса от и до

Linux

Правильная настройка отправки почты через Cron

×