Как работают вероятные/маловероятные макросы в ядре Linux и в чем их польза?
-
02-07-2019 - |
Вопрос
Я копался в некоторых частях ядра Linux и нашел такие вызовы:
if (unlikely(fd < 0))
{
/* Do something */
}
или
if (likely(!err))
{
/* Do something */
}
Я нашел их определение:
#define likely(x) __builtin_expect((x),1)
#define unlikely(x) __builtin_expect((x),0)
Я знаю, что они для оптимизации, но как они работают?И насколько можно ожидать снижения производительности/размера от их использования?И стоит ли заморачиваться (и, возможно, терять переносимость), по крайней мере, в коде с узким местом (конечно, в пользовательском пространстве).
Решение
Они являются подсказкой компилятору о необходимости выдавать инструкции, которые заставят предсказание ветвления отдать предпочтение «вероятной» стороне инструкции перехода.Это может быть большой победой: если прогноз верен, это означает, что инструкция перехода практически бесплатна и займет ноль циклов.С другой стороны, если прогноз неверен, это означает, что конвейер процессора необходимо очистить, а это может занять несколько циклов.Пока прогноз верен в большинстве случаев, это, как правило, положительно сказывается на производительности.
Как и все подобные оптимизации производительности, вы должны делать это только после тщательного профилирования, чтобы убедиться, что код действительно находится в узком месте и, вероятно, учитывая микроприроду, что он выполняется в узком цикле.Обычно разработчики Linux достаточно опытны, поэтому я думаю, они бы это сделали.На самом деле их не слишком заботит переносимость, поскольку они нацелены только на gcc и имеют очень четкое представление о сборке, которую они хотят сгенерировать.
Другие советы
Это макросы, которые подсказывают компилятору, в каком направлении может идти ветвь.Макросы расширяются до расширений, специфичных для GCC, если они доступны.
GCC использует их для оптимизации прогнозирования ветвей.Например, если у вас есть что-то вроде следующего
if (unlikely(x)) {
dosomething();
}
return x;
Затем он может реструктурировать этот код, чтобы он выглядел примерно так:
if (!x) {
return x;
}
dosomething();
return x;
Преимущество этого состоит в том, что когда процессор выполняет ветвь в первый раз, возникают значительные накладные расходы, поскольку он мог спекулятивно загружать и выполнять код дальше.Когда он определяет, что примет ветку, он должен сделать это недействительным и начать с целевой ветки.
Большинство современных процессоров теперь имеют своего рода прогнозирование ветвей, но это помогает только в том случае, если вы уже проходили через ветвь раньше, и ветвь все еще находится в кеше прогнозирования ветвей.
Существует ряд других стратегий, которые компилятор и процессор могут использовать в этих сценариях.Более подробную информацию о том, как работают предикторы ветвей, можно найти в Википедии: http://en.wikipedia.org/wiki/Branch_predictor
Давайте декомпилируем и посмотрим, что с этим делает GCC 4.8.
Без __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;
}
Компилируйте и декомпилируйте с помощью 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
и возврат для улучшения прогнозирования ветвей, как упоминалось в других ответах.
Итак, это в основном то же самое, что:
int i = !time(NULL);
if (i)
goto printf;
puts:
puts("a");
return 0;
printf:
printf("%d\n", i);
goto puts;
Эта оптимизация не была выполнена с -O0
.
Но удачи в написании примера, который работает быстрее с __builtin_expect
чем без, В наши дни процессоры действительно умные.Мои наивные попытки здесь.
Они заставляют компилятор выдавать соответствующие подсказки ветвей там, где их поддерживает оборудование.Обычно это означает просто изменение нескольких битов в коде операции инструкции, поэтому размер кода не изменится.ЦП начнет извлекать инструкции из предсказанного места, очистит конвейер и начнет все сначала, если при достижении ветки окажется, что это неправильно;в случае, если подсказка верна, это сделает ветку намного быстрее — насколько быстрее будет зависеть от аппаратного обеспечения;и насколько это повлияет на производительность кода, будет зависеть от того, в какой части времени подсказка верна.
Например, на процессоре PowerPC ветвь без подсказки может занять 16 тактов, правильно подсказанная — 8, а неправильная — 24.Во внутренних циклах хорошие хинтинги могут иметь огромное значение.
Переносимость на самом деле не является проблемой - предположительно, определение находится в заголовке каждой платформы;вы можете просто ничего не определять «вероятно» и «маловероятно» для платформ, которые не поддерживают статические подсказки ветвей.
long __builtin_expect(long EXP, long C);
Эта конструкция сообщает компилятору, что Expression Exp, скорее всего, будет иметь значение C.Возвращаемое значение — EXP.__builtin_expect предназначен для использования в условном выражении.Почти во всех случаях он будет использоваться в контексте логических выражений, и в этом случае гораздо удобнее определить два помощника -макроса:
#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)
Эти макросы затем можно использовать, как в
if (likely(a > 1))
(общий комментарий - другие ответы раскрывают детали)
Нет никаких причин, по которым вы должны терять мобильность при их использовании.
У вас всегда есть возможность создать простой «встроенный» макрос с нулевым эффектом, который позволит вам компилировать на других платформах с помощью других компиляторов.
Вы просто не получите преимуществ от оптимизации, если используете другие платформы.
Судя по комментарию Коди, это не имеет никакого отношения к Linux, а является подсказкой компилятору.Что произойдет, будет зависеть от архитектуры и версии компилятора.
Эта особенность Linux несколько неправильно используется в драйверах.Как osgx указывает в семантика горячего атрибута, любой hot
или cold
Функция, вызываемая в блоке, может автоматически указать, вероятно ли условие или нет.Например, dump_stack()
отмечен cold
так что это лишнее,
if(unlikely(err)) {
printk("Driver error found. %d\n", err);
dump_stack();
}
Будущие версии gcc
может выборочно встроить функцию на основе этих подсказок.Были также предположения, что это не так. boolean
, но оценка как в вероятно, и т. д.Как правило, предпочтительнее использовать какой-либо альтернативный механизм, например cold
.Нет смысла использовать его где-либо, кроме горячих путей.То, что компилятор будет делать на одной архитектуре, может совершенно отличаться на другой.
Во многих выпусках Linux вы можете найти complier.h в /usr/linux/, вы можете просто включить его для использования.И еще мнение, вряд ли() полезнее вероятно(), потому что
if ( likely( ... ) ) {
doSomething();
}
его также можно оптимизировать во многих компиляторах.
И кстати, если вы хотите понаблюдать за детальным поведением кода, вы можете сделать следующее:
gcc -c test.c objdump -d test.o> obj.s
Затем, открыв obj.s, вы сможете найти ответ.
Это подсказки компилятору для создания префиксов подсказок в ветвях.На x86/x64 они занимают один байт, поэтому вы получите увеличение максимум на один байт для каждой ветви.Что касается производительности, то она полностью зависит от приложения — в большинстве случаев в наши дни предсказатель ветвей на процессоре их игнорирует.
Редактировать:Забыл об одном месте, с которым они действительно могут помочь.Это может позволить компилятору изменить порядок графа потока управления, чтобы уменьшить количество ветвей, выбранных для «вероятного» пути.Это может заметно улучшить циклы, в которых вы проверяете несколько вариантов выхода.
Это функции GCC, позволяющие программисту подсказать компилятору, какое наиболее вероятное условие ветвления будет в данном выражении.Это позволяет компилятору создавать инструкции ветвления так, чтобы в наиболее распространенном случае для выполнения требовалось наименьшее количество инструкций.
Способ построения инструкций ветвления зависит от архитектуры процессора.