Понимание эффективности прогнозирования ветвлений

#performance #x86 #x86-64 #cpu-architecture #branch-prediction

#Производительность #x86 #x86-64 #архитектура процессора #прогнозирование ветвлений

Вопрос:

Я попытался измерить стоимость прогнозирования ответвлений, я создал небольшую программу.

Это создает небольшой буфер в стеке, заполняемый случайным значением 0/1. Я могу установить размер буфера с помощью N . Код повторно вызывает ответвления для одних и тех же 1<<N случайных чисел.

Теперь я ожидал, что если 1<<N значение достаточно велико (например, > 100), то предиктор ветвления не будет эффективным (поскольку он должен предсказывать> 100 случайных чисел). Тем не менее, это результаты (на компьютере с 5820 кб), по мере N роста программа становится медленнее:

 N   time
=========
8   2.2
9   2.2
10  2.2
11  2.2
12  2.3
13  4.6
14  9.5
15  11.6
16  12.7
20  12.9
  

Для справки, если буфер инициализирован нулями (используйте прокомментированный init ), время более или менее постоянно, оно варьируется в пределах 1,5-1,7 для N 8 .. 16.

Мой вопрос: может ли предиктор ветвлений быть эффективным для прогнозирования такого большого количества случайных чисел? Если нет, то что здесь происходит?

(Еще одно объяснение: код выполняет 2 ^ 32 ветви, независимо от N . Итак, я ожидал, что код выполняется с одинаковой скоростью, независимо от N , потому что ветвь вообще не может быть предсказана. Но, похоже, что если размер буфера меньше 4096 ( N <=12), что-то ускоряет код. Может ли предсказание ответвлений быть эффективным для 4096 случайных чисел?)

Вот код:

 #include <cstdint>
#include <iostream>

volatile uint64_t init[2] = { 314159165, 27182818 };
// volatile uint64_t init[2] = { 0, 0 };
volatile uint64_t one = 1;

uint64_t next(uint64_t s[2]) {
    uint64_t s1 = s[0];
    uint64_t s0 = s[1];
    uint64_t result = s0   s1;
    s[0] = s0;
    s1 ^= s1 << 23;
    s[1] = s1 ^ s0 ^ (s1 >> 18) ^ (s0 >> 5);
    return resu<
}

int main() {
    uint64_t s[2];
    s[0] = init[0];
    s[1] = init[1];

    uint64_t sum = 0;

#if 1
    const int N = 16;

    unsigned char buffer[1<<N];
    for (int i=0; i<1<<N; i  ) buffer[i] = next(s)amp;1;

    for (uint64_t i=0; i<uint64_t(1)<<(32-N); i  ) {
        for (int j=0; j<1<<N; j  ) {
            if (buffer[j]) {
                sum  = one;
            }
        }
    }
#else
    for (uint64_t i=0; i<uint64_t(1)<<32; i  ) {
        if (next(s)amp;1) {
            sum  = one;
        }
    }

#endif
    std::cout<<sum<<"n";
}
  

(Код также содержит небуферизованную версию, используйте #if 0 . Он работает примерно с той же скоростью, что и буферизованная версия с N=16 )

Вот разборка внутреннего цикла (скомпилирована с clang. Он генерирует один и тот же код для всех N между 8 .. 16, отличается только количество циклов. Clang дважды разворачивал цикл):

   401270:       80 3c 0c 00             cmp    BYTE PTR [rsp rcx*1],0x0
  401274:       74 07                   je     40127d <main 0xad>
  401276:       48 03 35 e3 2d 00 00    add    rsi,QWORD PTR [rip 0x2de3]        # 404060 <one>
  40127d:       80 7c 0c 01 00          cmp    BYTE PTR [rsp rcx*1 0x1],0x0
  401282:       74 07                   je     40128b <main 0xbb>
  401284:       48 03 35 d5 2d 00 00    add    rsi,QWORD PTR [rip 0x2dd5]        # 404060 <one>
  40128b:       48 83 c1 02             add    rcx,0x2
  40128f:       48 81 f9 00 00 01 00    cmp    rcx,0x10000
  401296:       75 d8                   jne    401270 <main 0xa0>
  

Комментарии:

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

2. Я запустил ваш код на Haswell и воспроизвел ваши результаты. Также метод TMA показывает, что плохие предположения составляют менее 5% от всех слотов выдачи при N<=10 и увеличиваются до 46,1% при N = 16.

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

4. @Brendan: Да. Но этот вопрос о том, что прогнозирование 4096 случайных результатов действительно является «чрезмерно счастливым случаем»? Для меня это казалось очень маловероятным (вот почему я не потрудился проверить perf stat . Если бы я проверил, этот вопрос не существовал бы). Но, как оказалось, это действительно так. Текущий процессор прогнозирования ветвлений настолько хорош, что может запоминать 4096 результатов. Это было неожиданностью для меня. 20 лет назад предикторы ветвлений были «сильно / слабо» * «приняты / не приняты». Теперь это может сделать намного-намного больше.

5. @Brendan: это никогда не бывает «чистой нерелевантной фантазией». Просто упомяну контрпример: интерпретаторы. Очень часто они идут по одному и тому же пути много раз. И ответ на ваш первый комментарий: «и нет смысла выполнять код дважды, если ничего не изменилось (вы можете сохранить результат / ы с прошлого раза)». Это неправильно. Обратите внимание, здесь только шаблон ветвления тот же. Данные могут отличаться (но следовать по одному и тому же пути). Точно так же, как когда интерпретатор запускает байтовый код. Но, в любом случае, этот вопрос был о понимании результатов теста, а не о том, реалистично это или нет.

Ответ №1:

Предсказание ветвлений может быть таким эффективным. Как предлагает Питер Кордес, я проверил ошибки в ветвлении с помощью perf stat . Вот результаты:

 N   time          cycles  branch-misses (%)      approx-time
===============================================================
8    2.2   9,084,889,375         34,806 ( 0.00)    2.2
9    2.2   9,212,112,830         39,725 ( 0.00)    2.2
10   2.2   9,264,903,090      2,394,253 ( 0.06)    2.2
11   2.2   9,415,103,000      8,102,360 ( 0.19)    2.2
12   2.3   9,876,827,586     27,169,271 ( 0.63)    2.3
13   4.6  19,572,398,825    486,814,972 (11.33)    4.6
14   9.5  39,813,380,461  1,473,662,853 (34.31)    9.5
15  11.6  49,079,798,916  1,915,930,302 (44.61)   11.7
16  12.7  53,216,900,532  2,113,177,105 (49.20)   12.7
20  12.9  54,317,444,104  2,149,928,923 (50.06)   12.9

Note: branch-misses (%) is calculated for 2^32 branches
  

Как вы можете видеть, когда N<=12 предсказатель ответвлений может предсказать большинство ответвлений (что удивительно: предсказатель ответвлений может запомнить результат 4096 последовательных случайных ответвлений!). Когда N>12 количество пропусков ветвей начинает расти. В N>=16 он может правильно предсказать только ~ 50%, что означает, что он так же эффективен, как случайное подбрасывание монеты.

Затраченное время можно приблизительно оценить, посмотрев на столбец время и количество пропусков ответвлений (%): я добавил последний столбец, approx-time . Я вычислил это следующим образом: 2.2 (12.9-2.2)*branch-misses %/100 . Как вы можете видеть, approx-time равно time (без учета ошибки округления). Таким образом, этот эффект может быть прекрасно объяснен предсказанием ветвлений.

Первоначальным намерением было рассчитать, сколько циклов стоит пропуск ветвления (в данном конкретном случае — как и в других случаях, это число может отличаться):

 (54,317,444,104-9,084,889,375)/(2,149,928,923-34,806) = 21.039 = ~21 cycles.
  

Комментарии:

1. Штраф за неправильное предсказание ответвлений не может быть охарактеризован одним числом, потому что это зависит от того, сколько времени требуется для перезапуска интерфейса и сколько незавершенной работы все еще находится в процессе выполнения в RS до неверно предсказанного перехода в момент обнаружения неправильного предсказания. Штраф в 21 цикл кажется мне немного завышенным и, вероятно, указывает на наличие проблем с интерфейсом. Кроме того, в вашем анализе не учитывалась стоимость потенциального неправильного прогнозирования последней итерации внутреннего цикла.

2. @HadiBrais: Спасибо за ваш комментарий. Да, стоимость пропусков ветвей зависит от многих факторов. Меня интересовало приблизительное значение. Например, как это связано со стоимостью деления с плавающей запятой. Что быстрее: использование трудно прогнозируемой ветви или fp-разделение. Да, я не рассматривал неправильные прогнозы последней итерации, потому что это не слишком сильно влияет на результат (менее 1% для случая N = 8). Я немного отредактировал свой ответ, чтобы сказать, что рассчитанная стоимость указана только для этого конкретного случая.

3. Ну, задержка разделения также значительно варьируется в зависимости от входных операндов. Стоимость неправильного прогнозирования определяется как увеличение времени выполнения по сравнению со случаем, когда неправильного прогнозирования не произошло. Итак, если вы хотите измерить стоимость неправильного прогнозирования в данном конкретном случае, лучший способ сделать это, следуя определению, сравнить время выполнения с гнездом цикла с одинаковым количеством внутренних и внешних итераций, но условие if (buffer[j]) всегда верно (легко предсказывается)…

4. …Это позволяет оценить стоимость одной внутренней итерации, когда if (buffer[j]) правильно прогнозируется. Умножьте это на количество правильных прогнозов if (buffer[j]) и вычтите результат из общего времени выполнения. Что остается, так это сумма затрат на все неправильные прогнозы. Наконец, разделите это количество на количество раз, когда ветвление if (buffer[j]) было неверно предсказано. Результатом является средняя стоимость неправильного прогнозирования if (buffer[j]) .

5. @HadiBrais: «задержка разделения также значительно варьируется в зависимости от входных операндов». Хм, что вы имеете в виду под этим? float vs double или что-то еще? Я рассчитал стоимость так, как вы говорите, у меня получилось ~ 22 цикла (22.074).