Началось всё с того, что я смотрел ролик про оптимизацию и увидел знакомый по книжкам пример кода, который демонстрирует важность успеха предсказателя ветвлений (branch predictor). Суть в том, что в функции есть ветвление и если предсказатель предскажет неверно, то будет потрачено множество тактов процессора впустую. Оптимизированная версия функции всегда рассчитывает два результата, но не имеет ветвлений.
Так вот, делать было нечего и я решил проверить как это работает на самом деле.
Для этого я взял google benchmark и составил два одинаковых теста на каждую из функций.
BM_GetProduct1 - функция с ветвлением, BM_GetProduct2 - без ветвлений (оптимизированная).
Когда я запустил программу, результаты меня сильно удивили.
CountOfElements | GetProduct1, ns | GetProduct2, ns |
|---|---|---|
16 | 9.12 | 9.22 |
64 | 36.6 | 44.7 |
512 | 418 | 437 |
4096 | 3'605 | 3'592 |
32768 | 104'858 | 28'929 |
65536 | 211'673 | 57'684 |
Первая и (предположительно) медленная версия уверенно выигрывает у "оптимизированной" на массиве данных до 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% случаев.
Но почему же первый вариант выигрывает на малых данных, а потом резко сдаёт?
Напрашивается вывод, что это как-то зависит от длины вектора... но ведь оба варианта получают одинаковые данные...
Может быть есть накладные расходы, которые не растут линейно?
Давайте попробуем отсортировать вектор и повторить тест!
CountOfElements | GetProduct1, ns | GetProduct2, ns |
|---|---|---|
16 | 8.96 | 9.04 |
64 | 35.8 | 44.4 |
512 | 422 | 433 |
4096 | 3563 | 3578 |
32768 | 28644 | 28688 |
65536 | 57497 | 57435 |
Первый вариант работает не хуже, а то и лучше "оптимизированного" варианта. Сначала функция обрабатывает все отрицательные значения (ветка 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;
}
}
}
А вот и результаты:
CountOfElements | GetProduct1, ns | GetProduct2, ns |
|---|---|---|
16 | 58.1 | 11.0 |
64 | 214 | 46.4 |
512 | 1649 | 438 |
4096 | 13133 | 3589 |
32768 | 104506 | 28825 |
65536 | 208442 | 57613 |
Совсем другое дело! А я уже было подумал, что для маленьких данный выгоднее вариант с ветвлением! Версия без ветвления определённо работает быстрее на данных любого размера, при условии, что данные случайны.
Приведу сравнительную таблицу, насколько могут обманывать некорректно выполненные замеры:
Размер | казалось | на самом деле | Ошибка |
|---|---|---|---|
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 |
Итак, с результатами замеров надо быть крайне осторожным и не спешить с выводами — особенности тестирования могут значительно исказить картину.
