Помощь компилятору в оптимизации условий

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

Например, следующая функция вставки временного буфера трансляции (TLB) из кода ядра Linux содержит условие if с директивой likely. Здесь автор сообщает компилятору, что очень вероятно, что поиск записи TLB не удастся.

static void tlb_entry_insert(unsigned int pd0, pte_t pd1)
{
 unsigned int idx;
 idx = tlb_entry_lkup(pd0);
 if (likely(idx & TLB_LKUP_ERR))
  write_aux_reg(ARC_REG_TLBCOMMAND, TLBGetIndex);
 write_aux_reg(ARC_REG_TLBPD1, pd1);
 write_aux_reg(ARC_REG_TLBCOMMAND, TLBWrite);
}

Точно так же разработчики могут использовать макрос unlikely, чтобы сигнализировать компилятору об оптимизации кода для взятой ветви else. См. Следующий пример кода ядра Linux о том, как используется макрос unlikely. Здесь компилятор получает указание, что одновременная очистка 32 или более страниц маловероятна. Компилятор должен оптимизировать менее 32 страниц.

void local_flush_tlb_kernel_range(unsigned long start, 
                                  unsigned long end)
{
 unsigned long flags;
if (unlikely((end - start) >= PAGE_SIZE * 32)) {
  local_flush_tlb_all();
  return;
 }
start &= PAGE_MASK;
local_irq_save(flags);
while (start < end) {
  tlb_entry_erase(start);
  start += PAGE_SIZE;
 }
utlb_invalidate();
local_irq_restore(flags);
}

Как компилятор оптимизирует вероятные и маловероятные директивы?

Изучите генерацию кода на предмет if и else

Давайте воспользуемся простым примером, чтобы понять оптимизацию. Следующий код содержит единственный if-else. Соответствующая сборка показана справа. Щелкните изображение, чтобы открыть код в Обозревателе компилятора.

Давайте проследим путь в ассемблерном коде для ветвей if и else:

Выполнение кода, когда argc ›0 (берется if-leg)

  1. sub rsp,8 - выделить место в стеке.
  2. test edi, edi - проверьте значение argc.
  3. jle .L2 - Перейти, если меньше, чем равно. Этот прыжок не выполняется.
  4. mov edi, OFFSET FLAT:.LC0 - скопируйте указатель на строку "Positive\n" в регистр edi.
  5. call puts - Вызов функции put.
  6. xor eax, eax - Установите eax на 0 (компиляторы используют саму XOR, так как это быстрее, чем инструкция по установке eax на 0).
  7. add rsp, 8 - Освободить место в стеке.
  8. ret - возврат с main.

Выполнение кода, когда argc ≤ 0 (берется else-leg)

  1. sub rsp,8 - выделить место в стеке.
  2. test edi, edi - проверьте значение argc.
  3. jle .L2 - Перейти, если меньше, чем равно. Этот прыжок сделан.
  4. .L2 - Переход к метке .L2.
  5. mov edi, OFFSET FLAT:.LC1 - скопируйте указатель на строку "Zero or Negative\n" в регистр edi.
  6. call puts - Вызов функции put.
  7. .L3 - Безусловный переход к .L3.
  8. xor eax, eax - установите eax на 0.
  9. add rsp, 8 - Освободить место в стеке.
  10. ret - возврат с main.

Из трассировки кода ясно, что компилятор оптимизировал ветвь if для выполнения кода без каких-либо ветвлений. Нога else встречает две дополнительные ветви (шаги 4 и 7 в трассе ветви else - argc ≤ 0).

Оптимизируйте ногу else, используя маловероятную директиву

Что, если приложение требует оптимизации ветви else? Это достигается за счет добавления макроса unlikely в условие if. Щелкните изображение, чтобы открыть код в Обозревателе компилятора.

Теперь мы видим, что компилятор изменил оптимизацию. Теперь нога else выполняется без выполнения ответвления. Нога if встречает две ветви.

Выполнение кода, когда argc ≤0 (берется else-leg)

  1. sub rsp,8 - выделить место в стеке.
  2. test edi, edi - проверьте значение argc.
  3. jg .L6 - Перейти, если больше. Этот прыжок не выполняется.
  4. mov edi, OFFSET FLAT:.LC1 - скопируйте указатель на строку "Zero or Negative\n" в регистр edi.
  5. call puts - Вызов функции put.
  6. xor eax, eax - установите eax на 0.
  7. add rsp, 8 - Освободить место в стеке.
  8. ret - возврат с main.

Выполнение кода, когда argc ›0 (берется if-leg)

  1. sub rsp,8 - выделить место в стеке.
  2. test edi, edi - проверьте значение argc.
  3. jg .L6 - Перейти, если больше. Этот прыжок сделан.
  4. .L6 - Выполнен переход к метке .L6.
  5. mov edi, OFFSET FLAT:.LC0 - скопируйте указатель на строку "Positive\n" в регистр edi.
  6. call puts - Вызов функции put.
  7. .L3 - Безусловный переход к .L3.
  8. xor eax, eax - установитеeax на 0.
  9. add rsp, 8 - Освободить место в стеке.
  10. ret - возврат с main.

Использование вероятной директивы оптимизирует if-часть кода

В этом примере использование макроса likely генерирует тот же код, что и отсутствие указания какой-либо директивы. If-leg оптимизирован для выполнения без каких-либо ветвлений.

Определение вероятных и маловероятных макросов

В настоящее время необходимо определить макросы likely и unlikely для вызова функции __builtin_expect.

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

вероятные и маловероятные атрибуты в C ++ 20

Если использование макросов likely и unlikely казалось немного случайным, обратите внимание на атрибуты C ++ 20 [[likely]] и [[unlikely]]. Эти атрибуты также будут поддерживаться в switch-statement.

int foo(int i) {
    switch(i) {
               case 1: handle1();
                       break;
    [[likely]] case 2: handle2();
                       break;
    }
}

Предложение C ++ 20 для [[likely]] и [[unlikely]] описывает преимущества предоставления прямых подсказок компилятору.



Влияние вероятных и маловероятных подсказок на производительность

Каков выигрыш в производительности от использования этих макросов? Давайте воспользуемся примером из предложения C ++ 20, чтобы понять разницу в производительности, вызванную обновлением функции clamp в этом примере.

Давайте рассмотрим код функции с подсказкой unlikely и без нее.

Без намека

Здесь компилятор генерирует довольно жесткий код с очень небольшим пространством для оптимизации.

  1. xor eax, eax - Возвращаемое значение по умолчанию - 0
  2. test edi, edi - проверьте, равно ли i 0
  3. cmp edi, 65535 - Сравните i с 65535
  4. mov eax, -1 - По умолчанию возвращается значение 65535
  5. cmovle eax, edi - скопируйте i в возвращаемое значение, если i ≤ 65535
  6. ret - возврат

С подсказкой (выход за пределы диапазона маловероятен)

Использование макроса unlikely позволяет компилятору выполнить оптимизацию, удалив инструкцию из вероятного пути.

  1. test edi, edi - проверьте, равно ли i 0
  2. cmp edi, 65535 - сравните i с 65535
  3. mov eax, -1 - по умолчанию возвращается значение 65535
  4. cmovle eax, edi - скопируйте i в возвращаемое значение, если i ≤ 65535
  5. ret - возврат

Из приведенного выше сравнения мы видим, что компилятор удалил по инструкции вероятного пути - xor eax, eax.

Результаты тестов

Из результатов теста на бумаге мы видим, что версия функции clamp с unlikely приводит к довольно значительному сокращению времени выполнения, если из значения диапазона от 0,1% до 10%. После этого. Как и ожидалось, придется заплатить небольшой штраф, если данные содержат значения, которые принимают маловероятный отрезок.

Когда мне следует давать компилятору явные подсказки?

В большей части кода не следует жертвовать удобочитаемостью, засоряя код макросами likely и unlikely.

  • В большинстве сценариев кэш прогнозирования ветвлений процессора довольно хорошо справляется с предсказанием вероятного перехода. Особенно это касается внутренних петель.
  • Тем не менее, наиболее важные для производительности внутренние циклы в коде могут увидеть улучшения в подсказках производительности.
  • Для записи макросов может быть полезен макрос unlikely. Это сообщает компилятору, что включение ведения журнала маловероятно. Это поможет снизить стоимость проверки уровня LOG, если операторы, которые незримо включены в большую часть вашего кода.
#define LOG(level, message)  if (unlikely(enabledLogLevel > level))\
                                sendLog(message);