Использование bools в расчетах, чтобы избежать веток

Вот небольшое любопытство микрооптимизации, которое я придумал:

struct Timer {
bool running{false};
int ticks{0};

void step_versionOne(int mStepSize) {
if(running) ticks += mStepSize;
}

void step_versionTwo(int mStepSize) {
ticks += mStepSize * static_cast<int>(running);
}
};

Похоже, два метода практически одинаковы. Избегает ли вторая версия ветвления (и, следовательно, быстрее, чем первая версия), или любой компилятор может выполнить такую ​​оптимизацию с -O3?

8

Решение

Да, ваш трюк позволяет избежать веток и делает это быстрее … иногда.

Я написал бенчмарк, который сравнивает эти решения в различных ситуациях, а также мои собственные:

ticks += mStepSize & -static_cast<int>(running)

Мои результаты следующие:

Off:
branch: 399949150
mul:    399940271
andneg: 277546678
On:
branch: 204035423
mul:    399937142
andneg: 277581853
Pattern:
branch: 327724860
mul:    400010363
andneg: 277551446
Random:
branch: 915235440
mul:    399916440
andneg: 277537411

Off это когда таймеры выключены. В этом случае решения занимают примерно одинаковое время.

On это когда они включены. Разветвление раствора в два раза быстрее.

Pattern это когда они в шаблоне 100110. Производительность похожа, но ветвление немного быстрее.

Random когда ветка непредсказуема. В этом случае умножение происходит более чем в 2 раза.

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

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

Код бенчмарка:

#include<array>
#include<iostream>
#include<chrono>

struct Timer {
bool running{false};
int ticks{0};

void branch(int mStepSize) {
if(running) ticks += mStepSize;
}

void mul(int mStepSize) {
ticks += mStepSize * static_cast<int>(running);
}

void andneg(int mStepSize) {
ticks += mStepSize & -static_cast<int>(running);
}
};

void run(std::array<Timer, 256>& timers, int step) {
auto start = std::chrono::steady_clock::now();
for(int i = 0; i < 1000000; i++)
for(auto& t : timers)
t.branch(step);
auto end = std::chrono::steady_clock::now();
std::cout << "branch: " << (end - start).count() << std::endl;
start = std::chrono::steady_clock::now();
for(int i = 0; i < 1000000; i++)
for(auto& t : timers)
t.mul(step);
end = std::chrono::steady_clock::now();
std::cout << "mul:    " << (end - start).count() << std::endl;
start = std::chrono::steady_clock::now();
for(int i = 0; i < 1000000; i++)
for(auto& t : timers)
t.andneg(step);
end = std::chrono::steady_clock::now();
std::cout << "andneg: " << (end - start).count() << std::endl;
}

int main() {
std::array<Timer, 256> timers;
int step = rand() % 256;

run(timers, step); // warm up
std::cout << "Off:\n";
run(timers, step);
for(auto& t : timers)
t.running = true;
std::cout << "On:\n";
run(timers, step);
std::array<bool, 6> pattern = {1, 0, 0, 1, 1, 0};
for(int i = 0; i < 256; i++)
timers[i].running = pattern[i % 6];
std::cout << "Pattern:\n";
run(timers, step);
for(auto& t : timers)
t.running = rand()&1;
std::cout << "Random:\n";
run(timers, step);
for(auto& t : timers)
std::cout << t.ticks << ' ';
return 0;
}
3

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

Does the second version avoid a branch

если вы скомпилируете свой код, чтобы получить вывод на ассемблере, g++ -o test.s test.cpp -Sвы обнаружите, что во второй функции ветки действительно избегают.

and consequently, is faster than the first version

я выполнял каждую из твоих функций 2147483647 или же INT_MAX количество раз, когда в каждой итерации я случайно назначил логическое значение running член вашего Timer структура, используя этот код:

int main() {
const int max = std::numeric_limits<int>::max();
timestamp_t start, end, one, two;
Timer t_one, t_two;
double percent;

srand(time(NULL));

start = get_timestamp();
for(int i = 0; i < max; ++i) {
t_one.running = rand() % 2;
t_one.step_versionOne(1);
}
end = get_timestamp();
one = end - start;

std::cout << "step_versionOne      = " << one << std::endl;

start = get_timestamp();
for(int i = 0; i < max; ++i) {
t_two.running = rand() % 2;
t_two.step_versionTwo(1);
}
end = get_timestamp();
two = end - start;

percent = (one - two) / static_cast<double>(one) * 100.0;

std::cout << "step_versionTwo      = " << two << std::endl;
std::cout << "step_one - step_two  = " << one - two << std::endl;
std::cout << "one fast than two by = " << percent << std::endl;
}

и вот результаты, которые я получил:

step_versionOne      = 39738380
step_versionTwo      = 26047337
step_one - step_two  = 13691043
one fast than two by = 34.4529%

так что да, вторая функция явно быстрее и примерно на 35%. обратите внимание, что процентное увеличение производительности по времени варьировалось от 30 до 55 процентов для меньшего числа итераций, тогда как, по-видимому, на более длительном этапе оно достигает 35%. это может быть связано со спорадическим выполнением системных задач во время выполнения симуляции, которое становится намного менее спорадическим, т.е. согласованным, чем дольше вы запускаете сим (хотя это только мое предположение, я понятия не имею, правда ли это)

В общем, хороший вопрос, я кое-что узнал сегодня!


БОЛЬШЕ:


конечно, случайно генерируя runningПо сути, мы делаем предсказание ветвления бесполезным в первой функции, поэтому приведенные выше результаты не слишком удивительны. однако, если мы решим не изменять running во время итераций цикла и вместо этого оставьте значение по умолчанию, в этом случае falseПредсказание ветвлений сделает свою магию в первой функции и на самом деле будет быстрее почти на 20%, как показывают эти результаты:

step_versionOne      = 6273942
step_versionTwo      = 7809508
step_two - step_one  = 1535566
two fast than one by = 19.6628

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

почему вторая функция медленнее в этом случае? хорошо, предсказание ветвления быстро поймет, что условие в первой функции никогда не будет выполнено, и поэтому остановит проверку в первую очередь (как будто if(running) ticks += mStepSize; даже не там). с другой стороны, второй функции все равно придется выполнять эту инструкцию ticks += mStepSize * static_cast<int>(running); в каждой итерации, что делает первую функцию более эффективной.

но что если мы установим running в true? хорошо, предсказание ветвится снова, однако, на этот раз, первая функция должна будет оценить ticks += mStepSize; в каждой итерации; здесь результаты, когда running{true}:

step_versionOne      = 7522095
step_versionTwo      = 7891948
step_two - step_one  = 369853
two fast than one by = 4.68646

заметить, что step_versionTwo занимает одинаковое количество времени, будь то running постоянно true или же false, но это все еще занимает больше времени, чем step_versionTwo, однако незначительно. ну, это может быть потому, что я слишком ленив, чтобы запускать его много раз, чтобы определить, является ли он стабильно быстрым или одноразовым (случайные результаты немного меняются при каждом запуске, поскольку ОС должна работать в фоновом режиме). и это не всегда будет делать то же самое). но если это постоянно быстрее, это может быть потому, что функция два (ticks += mStepSize * static_cast<int>(running);) имеет арифметический оп больше, чем функция один (ticks += mStepSize;).

наконец, давайте скомпилируем с оптимизацией — g++ -o test test.cpp -std=c++11 -O1 и давайте вернемся running вернуться к false а затем проверьте результаты:

step_versionOne      = 704973
step_versionTwo      = 695052

более или менее то же самое. компилятор выполнит этап оптимизации и реализует running всегда false и, таким образом, для всех намерений и целей удалит тело step_versionOneпоэтому, когда вы вызываете его из цикла в main, он просто вызовет функцию и вернется.

с другой стороны, при оптимизации второй функции он поймет, что ticks += mStepSize * static_cast<int>(running); всегда будет генерировать один и тот же результат, т.е. 0так что это тоже не будет беспокоить.

В общем, если я прав (а если нет, пожалуйста, поправьте меня, я довольно новичок в этом), все, что вы получите при вызове обеих функций из main Цикл это их накладные расходы.

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

step_versionOne      = 18868782
step_versionTwo      = 18812315
step_two - step_one  = 56467
one fast than two by = 0.299261
2

По вопросам рекламы [email protected]