Началось всё с того, что я смотрел ролик про оптимизацию и увидел знакомый по книжкам пример кода, который демонстрирует важность успеха предсказателя ветвлений (branch predictor). Суть в том, что в функции есть ветвление и если предсказатель предскажет неверно, то будет потрачено множество тактов процессора впустую. Оптимизированная версия функции всегда рассчитывает два результата, но не имеет ветвлений.
Так вот, делать было нечего и я решил проверить как это работает на самом деле.
Для этого я взял google benchmark и составил два одинаковых теста на каждую из функций.
BM_GetProduct1 - функция с ветвлением, BM_GetProduct2 - без ветвлений (оптимизированная).
Когда я запустил программу, результаты меня сильно удивили.
---------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------
BM_GetProduct1/16 9.12 ns 9.11 ns 62193800
BM_GetProduct1/64 36.6 ns 36.5 ns 19171034
BM_GetProduct1/512 418 ns 417 ns 1682832
BM_GetProduct1/4096 3605 ns 3598 ns 196197
BM_GetProduct1/32768 104858 ns 104677 ns 6754
BM_GetProduct1/65536 211673 ns 211332 ns 3209
BM_GetProduct2/16 9.22 ns 9.21 ns 75412476
BM_GetProduct2/64 44.7 ns 44.7 ns 15766219
BM_GetProduct2/512 437 ns 437 ns 1613444
BM_GetProduct2/4096 3592 ns 3587 ns 195040
BM_GetProduct2/32768 28929 ns 28882 ns 24380
BM_GetProduct2/65536 57684 ns 57610 ns 12128
Первая и (предположительно) медленная версия уверенно выигрывает у "оптимизированной" на массиве данных до 4096 элементов включительно! Но почему?
Давайте разбираться!
Код функции с ветвлением:
double get_product1(std::span<const double> input, double threshold) {
double p = 1.0;
for (auto i : input) {
if (i < threshold) {
p *= 2 * i;
} else {
p *= 1.5 * i;
}
}
return p;
}
Функция без ветвления всегда рассчитывает два результата (и это её недостаток), но зато конвейер процессора не ломается — нет условного перехода, который нужно предсказывать.
double get_product2(std::span<const double> input, double threshold) {
double p = 1.0;
for (auto i : input) {
std::array arr = {1.5 * i, 2.0 * i};
p *= arr[i < threshold];
}
return p;
}
Сами тесты выглядят одинаково для обеих функций, так что я приведу только один вариант:
static void BM_GetProduct1(benchmark::State &state) {
const auto size = state.range(0);
auto data = generateData(size);
double p = 0.0;
for (auto _ : state) {
benchmark::ClobberMemory();
benchmark::DoNotOptimize(p += get_product1(data, 0.0));
}
}
BENCHMARK(BM_GetProduct1)->Range(16, 16 * 4096);
Данные генерируются случайным образом: половина данных больше нуля, а половина - меньше.
std::vector<double> generateData(int size) {
std::random_device rd;
std::mt19937_64 gen(rd());
std::uniform_real_distribution<double> dist(-1000.0, 1000.0);
std::vector<double> data;
data.resize(size);
for (auto &d : data) {
d = dist(gen);
}
return data;
}
Итак, get_product1 получает набор случайных данных и так как они случайные, то предсказатель переходов угадывает лишь в 50% случаев.
Но почему же первый вариант выигрывает на малых данных, а потом резко сдаёт?
Напрашивается вывод, что это как-то зависит от длины вектора... но ведь оба варианта получают одинаковые данные...
Может быть есть накладные расходы, которые не растут линейно?
Давайте попробуем отсортировать вектор и повторить тест!
---------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------
BM_GetProduct1/16 8.96 ns 8.94 ns 80669588
BM_GetProduct1/64 35.8 ns 35.7 ns 19709667
BM_GetProduct1/512 422 ns 422 ns 1388010
BM_GetProduct1/4096 3563 ns 3559 ns 196867
BM_GetProduct1/32768 28644 ns 28614 ns 24503
BM_GetProduct1/65536 57497 ns 57432 ns 12093
BM_GetProduct2/16 9.04 ns 9.03 ns 76644990
BM_GetProduct2/64 44.4 ns 44.3 ns 15828673
BM_GetProduct2/512 433 ns 433 ns 1613180
BM_GetProduct2/4096 3578 ns 3574 ns 196333
BM_GetProduct2/32768 28688 ns 28650 ns 24450
BM_GetProduct2/65536 57435 ns 57345 ns 12213
Первый вариант работает не хуже, а то и лучше "оптимизированного" варианта. Сначала функция обрабатывает все отрицательные значения (ветка if), потом все положительные (ветка else), и предсказатель ошибается только один раз — в точке перехода.
Но почему же в случае неотсортированных и случайных данных get_product1 всё равно побеждает при размере данных меньше 4К?
Разгадка
Вспомним, как устроен предсказатель переходов. У него есть память, в которую он запоминает предыдущие переходы и выявляет паттерны.
Например, что всегда условие истинное или, например, шаблон истинно-истинно-ложно.
Но в нашем случае у нас точно нет паттернов, так как данные случайные!
Или всё-таки паттерны есть?
Ответ заключается в том, что google benchmark производит множество повторений вызовов get_product с одними и теми же данными, так что предсказатель переходов эти просто выучивает и они данные перестают быть случайными! Когда мы делаем очередную итерацию, он "узнаёт" эту последовательность и начинает выбирать правильную ветку исходя из своего "опыта". Но если последовательность оказывается слишком длинной, то он не может её запомнить и данные опять становятся для него случайными.
Чтобы проверить и убедиться, что эта гипотеза верна, сделаем так, чтобы короткие последовательности не повторялись для новой итерации:
static void BM_GetProduct1(benchmark::State &state) {
auto data = generateData(max_data_size);
const auto size = state.range(0);
std::vector<std::span<double>> sets;
for (int i = 0; i + size <= max_data_size; i += size) {
sets.push_back(std::span(&data[i], size));
}
double p = 0.0;
int i = 0;
for (auto _ : state) {
benchmark::ClobberMemory();
benchmark::DoNotOptimize(p += get_product1(sets[i], 0.0));
if (++i >= sets.size()) {
i = 0;
}
}
}
А вот и результаты:
---------------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------------
BM_GetProduct1/16 58.1 ns 57.9 ns 11410584
BM_GetProduct1/64 214 ns 214 ns 3277196
BM_GetProduct1/512 1649 ns 1646 ns 419308
BM_GetProduct1/4096 13133 ns 13118 ns 52947
BM_GetProduct1/32768 104506 ns 104381 ns 6656
BM_GetProduct1/65536 208442 ns 208213 ns 3307
BM_GetProduct2/16 11.0 ns 11.0 ns 63463067
BM_GetProduct2/64 46.4 ns 46.3 ns 15161975
BM_GetProduct2/512 438 ns 438 ns 1608315
BM_GetProduct2/4096 3589 ns 3584 ns 195693
BM_GetProduct2/32768 28825 ns 28785 ns 24373
BM_GetProduct2/65536 57613 ns 57544 ns 12100
Совсем другое дело! А я уже было подумал, что для маленьких данный выгоднее вариант с ветвлением! Версия без ветвления определённо работает быстрее на данных любого размера, при условии, что данные случайны.
Приведу сравнительную таблицу, насколько могут обманывать некорректно выполненные замеры:
Размер | казалось | на самом деле | Ошибка |
|---|---|---|---|
16 | 9.12 ns | 58.1 ns | 6.4x |
64 | 36.6 ns | 214 ns | 5.8x |
512 | 418 ns | 1649 ns | 3.9x |
4096 | 3605 ns | 13133 ns | 3.6x |
32768 | 104858 ns | 104506 ns | ~1x |
65536 | 211673 ns | 208442 ns | ~1x |
Итак, с результатами замеров надо быть крайне осторожным и не спешить с выводами — особенности тестирования могут значительно исказить картину.