Прогнозирование ветвлений и оптимизация прогнозирования ветвлений

Мой код делает частые вызовы функции с несколькими (непредсказуемыми) ветвями. Когда я выполнил профилирование, я обнаружил, что это незначительное узкое место, причем большая часть процессорного времени используется на условных JMP.

Рассмотрим следующие две функции, где оригинал имеет несколько явных ветвей.

void branch_example_original(void* mem, size_t s)
{
if(!(s & 7)) {
/* logic in _process_mem_64 inlined */
}
else if(!(s & 3)) {
/* logic in _process_mem_32 inlined */
}
else if(!(s & 1)) {
/* logic in _process_mem_16 inlined */
}
else {
/* logic in _process_mem_8 inlined */
}
}

Вот новая функция, где я попытался удалить ветви, вызывающие узкое место.

void branch_example_new(void* mem, size_t s)
{
const fprocess_mem mem_funcs[] = {_process_mem_8, _process_mem_16, _process_mem_32, _process_mem_64};
const uint32_t magic = 3 - !!(s & 7) - !!(s & 3) - !!(s & 1);
mem_funcs[magic](mem, size >> magic);
}

Однако когда я профилировал новый код, производительность увеличилась всего на ~ 20%, а сам CALL (для функции в массиве mem_funcs) занял очень много времени.

Является ли второй вариант просто более неявным условным условием, поскольку ЦП все еще не может предсказать функцию, которая будет вызвана? Правильно ли я полагаю, что это связано с предсказанием цели перехода?

Почему это происходит, и есть ли другие решения для этого?

Редактировать:

Спасибо за идеи, но я хотел бы объяснить, почему это также происходит.

9

Решение

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

Да, для безусловных непрямых ветвлений требуется, чтобы процессор-цель ударил по буферу ветвлений, чтобы выяснить, откуда следует выбирать код. Современные процессоры сильно конвейерны, и им нужно извлекать код намного раньше, чем они выполняют, если они собираются избежать пузырей в канале, где им нечего делать. Приходится ждать до magic рассчитывается слишком поздно, чтобы избежать пузыря выборки инструкции. Счетчики производительности покажут промахи BTB как ошибочный прогноз отрасли, я думаю.

Как я предложил в комментарии, если вы можете, вы должны реструктурировать свой код, чтобы сделать скалярное введение и очистить вокруг векторизованного цикла. Вступление обрабатывает элементы до тех пор, пока вы не достигнете выровненного элемента. Цикл очистки обрабатывает случаи, когда осталось ненулевое количество элементов для обработки после последнего полного вектора. Тогда вам не нужно делать скалярный цикл только потому, что размер или выравнивание первого элемента не были идеальными.


В зависимости от того, что вы обрабатываете, если можно повторить работу и перекрытие, то вы можете сделать запуск без ответвлений, который выполняет невыровненный фрагмент, а остальные выравнивают. Некоторые библиотеки, вероятно, препятствуют memset что-то вроде этого:

// not shown: check that count >= 16
endp = dest + count;
unaligned_store_16B( dest );    // e.g. x86 movdqu
dest+=16;
dest &= ~0xf;  // align by 16, first aligned write overlaps by up to 15B
for ( ; dest < endp-15 ; dest+=16) {
aligned_store_16B( dest );  // e.g. x86 movdqa
}
// handle the last up-to-15 bytes from dest to endp similarly.

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

Обратите внимание, что большинство функций с одним буфером не повторяются. например на месте a[i] *= 2, или же sum+=a[i] нужно избегать обработки одного и того же ввода дважды. Обычно со скалярным циклом, пока вы не доберетесь до выровненного адреса. a[i] &= 0x7f, или же maxval = max(a[i], maxval) Хотя исключения.


Функции с двумя независимыми указателями, которые могут быть смещено на разные суммы хитрее Вы должны быть осторожны, чтобы не изменить их относительное смещение с помощью маскировки. memcpy это самый простой пример функции, которая обрабатывает данные из src в буфер dest. memcpy должен работать, если (src+3) %16 == 0 а также (dest+7) %16 ==0, Если вы не можете накладывать ограничения на вызывающих абонентов, лучшее, что вы можете сделать в целом, — это выравнивать каждую загрузку или каждое хранилище в основном цикле.

На x86 невыровненные инструкции по перемещению (movdqu и друзья) так же быстро, как версия, необходимая для выравнивания когда адрес выровнен. Поэтому вам не нужна отдельная версия цикла для особого случая, когда src и dest имеют одинаковое (неправильное) выравнивание, а нагрузки и хранилища могут быть выровнены. IIRC, это верно для Intel Nehalem и более новых процессоров, а также для недавних AMD.

// check count >= 16
endp = dest + count;
unaligned_copy_16B( dest, src );  // load with movdqu, store with movdqu
// src+=16; dest+=16;  // combine this with aligning dest, below

dest_misalign = dest & 0xf;  // number of bytes the first aligned iteration will overlap
src  += 16 - dest_misalign;  // src potentially still misaligned
dest += 16 - dest_misalign;  // dest aligned

for ( ; dest <= endp-16 ; src+=16, dest+=16) {
tmpvec = unaligned_load_16B( src ); // x86 movdqu is fast if src is aligned
aligned_store_16B( dest, tmpvec );  // x86 movdqa
}
// handle the last dest to endp bytes.

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

Если вы не выполняете memcpy, может быть преимуществом выравнивание src, чтобы загрузка могла сложиться в другую инструкцию как операнд памяти. Это сохраняет инструкцию, а во многих случаях также сохраняет Intel Intel UOP внутри.

Для случая, когда src и dest имеют разные выравнивания, я не проверял, быстрее ли делать выровненные загрузки и не выровненные хранилища, или наоборот. Я выбрал выровненные магазины из-за потенциальной выгоды пересылки магазина для коротких буферов. Если буфер dest выровнен и имеет длину только пару векторов и будет сразу же считан снова, то выровненные нагрузки из dest будут останавливаться на ~ 10 циклов (Intel SnB), если нагрузка пересекает границу между двумя предыдущими хранилищами, которые не ‘ Я еще не дошел до кеша L1. (т.е. пересылка магазина не удалась). Увидеть http://agner.org/optimize/ для получения информации о деталях низкого уровня, таких как это (особенно руководство по микроархам).

Перенаправление хранилища из memcpy для загрузки в следующем цикле произойдет только в том случае, если буферы малы (может быть, до 64B?), Или если ваш следующий цикл начнет чтение с конца буфера (который все еще будет в кеше, даже если начало уже выселено). В противном случае, хранилища до начала буфера переместятся из буфера хранилища в L1, поэтому пересылка хранилища не вступит в игру.

Возможно, что для больших буферов с различным выравниванием выравниваемые нагрузки и невыровненные хранилища будут работать лучше. Я просто делаю что-то здесь, но это может быть правдой, если не выровненные магазины могут быстро удалиться, даже если они пересекают строку кэша или строку страницы. Конечно, невыровненные загрузки не могут быть удалены, пока данные не будут загружены. Чем больше инструкций по загрузке / хранению в полете, тем меньше вероятность того, что кеш потеряет все данные. (Вы потенциально можете использовать больше буферов загрузки / хранения ЦП.) Опять же, чистая спекуляция. Я пытался гуглить, были ли невыровненные магазины лучше или хуже, чем не выровненные загрузки, но только получали отклики о том, как их делать, и штрафы за несоосность, которые применяются к обоим.

7

Другие решения

Вы можете попробовать что-то вроде этого:

switch(s & 7) {
case 0:
/* _process_mem_64 */
break;
case 1:
case 3:
case 5:
case 7:
/* _process_mem_8 */
break;
case 2:
case 6:
/* _process_mem_16 */
break;
case 4:
/* _process_mem_32 */
break;
}

Это включает только один переход в таблицу переходов и не требует инструкции вызова.

4

Современный процессор имеет не только предсказание ветвления, но и предсказание скачка. Например, если вы вызываете виртуальную функцию, она может предсказать, что фактическая функция будет такой же, как и в предыдущем вызове, и начать выполнение до того, как указатель на функцию будет фактически прочитан — если прогноз перехода был неверным, все становится медленным.

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

3
По вопросам рекламы ammmcru@yandex.ru
Adblock
detector