Являются ли побитовые операторы медленнее, чем обычные циклы, такие как цикл for?

Занимают ли побитовые операторы, такие как & (AND), больше времени выполнения, чем обычные циклы for?

Сегодня я отвечал на вопрос степени двойки в LeetCode. Мой код был таким:

if (n > 0 && (n & (n - 1)) == 0) {
    return true;
}
return false;

Это дало мне время работы 5 мс. Я попытался отправить повторно, чтобы посмотреть, есть ли какие-либо различия во времени выполнения, но все было то же самое.

Итак, я написал свой код, используя цикл for:

for (int i = 0; i <= 30; i++){
    int ans = pow(2, i);
    if (n == ans){
        return true;
    }
}
return false;

Время выполнения стало 0 мс.

Я надеялся на более быстрое выполнение моего кода побитового оператора, но вместо этого получил самый медленный.

Единственный ответ — это профилирование кода и проверка сгенерированной сборки, все остальное — всего лишь предположения.

Jack 06.07.2024 17:09
n - 1 не является побитовой операцией.
François Andrieux 06.07.2024 17:12

Несвязано: первую версию можно упростить до return !(n > 0 && (n & (n - 1))); или return std::has_single_bit(n);.

Ted Lyngmo 06.07.2024 17:15

Скорее всего, эти тайминги вообще ничего не значат, кроме того, что сайт плохо проводит бенчмаркинг. 5 мс совершенно нереально для этого куска кода и ровно 0 тоже невозможно. Второй код также ведет себя не так, как первый. В частности, pow(2, i) не делает того, что вы думаете. pow — это операция с плавающей запятой, а не целочисленная. Более того, если второй код был правильным, нет оснований полагать, что компилятор не создаст из него ту же сборку, что и первый. В общем, в этом вопросе нет ничего, кроме предположений.

user17732522 06.07.2024 17:16

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

Nate Eldredge 06.07.2024 17:38

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

Thomas Matthews 06.07.2024 20:42

Вызов std::pow(2,i) можно заменить на (1 << i). На большинстве процессоров сдвиг обычно составляет одну инструкцию. Вызов функции состоит минимум из 3 инструкций, а также требует затрат на прогнозирование/оценку ветвей. Некоторые компиляторы могут выполнять эту оптимизацию при более высоких настройках оптимизации.

Thomas Matthews 06.07.2024 20:47
pow, с другой стороны, нужно обрабатывать действительно неприятные вещи, такие как число «пи» в степени «е». Любое простое возведение в степень с целыми числами обычно приводит к полному убийству производительности, к тому же является неточным, если только компилятор и реализация pow не очень, очень умны.
user4581301 07.07.2024 02:05
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
8
174
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Измерение с помощью pow():

  • Как уже отмечалось, бенчмаркинг Leetcode совсем не точен.

  • Мы можем смоделировать каждую функцию и просто рассчитать время с помощью std::chrono::steady_clock::now(); и увидеть общий обзор измерений, который показывает, что решение bitwise() работает намного быстрее:

#include <iostream>
#include <chrono>
#include <cmath>

bool bitwise(const int n)
{
    return n > 0 && (n & (n - 1)) == 0;
}

bool forloop(const int n)
{
    for (int i = 0; i <= 30; i++)
    {
        int ans = std::pow(2, i);
        if (n == ans)
        {
            return true;
        }
    }
    return false;
}

template <typename Func>
long long timeit_with_steady_clock(Func F, int it)
{
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < it; i++)
    {
        F(i);
    }
    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
}

int main()
{
    const int it = 10000000;
    auto A = timeit_with_steady_clock(bitwise, it);
    auto B = timeit_with_steady_clock(forloop, it);
    std::cout << A << " ms vs. " << B << " ms" << "\n";

    return 0;
}


Принты

28 ms vs. 6705 ms

Измерение без использования pow():

  • Если мы удалим pow(), решение bitwise() будет примерно в 17 раз быстрее, чем решение forloop(), что хорошо объяснено в этом ответе:
#include <iostream>
#include <chrono>

bool bitwise(const int n)
{
    return n > 0 && (n & (n - 1)) == 0;
}

// Without using pow(): 
bool forloop(const int n)
{
    for (int i = 0; i <= 30; i++)
    {
        int ans = 1;
        if (n == ans)
        {
            return true;
        }
        ans *= 2;
    }
    return false;
}

template <typename Func>
long long timeit_with_steady_clock(Func F, int it)
{
    auto start = std::chrono::steady_clock::now();
    for (int i = 0; i < it; i++)
    {
        F(i);
    }
    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
}

int main()
{
    const int it = 100000000;
    auto A = timeit_with_steady_clock(bitwise, it);
    auto B = timeit_with_steady_clock(forloop, it);
    std::cout << A << " ms vs. " << B << " ms - Ratio of forloop/bitwise is " << B / A << "\n";

    return 0;
}

Принты

280 ms vs. 4860 ms - Ratio of forloop/bitwise is 17

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

  • Разумно рассчитать время для первого кода будет практически невозможно без дополнительного контекста. Это займет всего несколько циклов. Например, загрузка значения n из памяти или даже просто кеша займет больше времени, чем фактическая операция. Значения, с которыми функция вызывается последовательно, могут привести к значительным различиям в прогнозировании ветвей, если в результирующей сборке есть ветвь. Второй код будет медленнее, но только потому, что текущие компиляторы, похоже, не выполняют ту конкретную замену цикла, которую я предложил. Это время может быть где-то в наносекундах, но не будет информативным. – пользователь17732522

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

  • Лучше избегатьhigh_resolution_clock и вместо этого использовать steady_clock. – опустошение кучи

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

user17732522 06.07.2024 17:34

@user17732522 user17732522 В этом нет необходимости. Мы не рассматриваем наносекунды. Мы просто хотим чего-то простого для понимания. Обратите внимание, что в своем ответе я не сказал, что мы «тестируем», а просто «моделируем».

user24714692 06.07.2024 17:35

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

user17732522 06.07.2024 17:39

@user17732522 user17732522 Почему бы вам просто не «сравнить» его и не ответить на вопрос, чтобы мы увидели разницу во времени?

user24714692 06.07.2024 17:40

Разумно рассчитать время для первого кода будет практически невозможно без дополнительного контекста. Это займет всего несколько циклов. Например, загрузка значения n из памяти или даже просто кеша займет больше времени, чем фактическая операция. Значения, с которыми функция вызывается последовательно, могут привести к значительным различиям в прогнозировании ветвей, если в результирующей сборке есть ветвь. Второй код будет медленнее, но только потому, что текущие компиляторы, похоже, не выполняют ту конкретную замену цикла, которую я предложил. Это время может быть где-то в наносекундах, но не будет информативным.

user17732522 06.07.2024 17:47

В общем, я думаю, единственное, что можно сказать OP, это то, что цикл будет значительно медленнее, если компилятор не сможет распознать, что его можно оптимизировать для нескольких неповторяемых инструкций, чего современные компиляторы, похоже, не делают. мог бы, но легко мог бы. (Кроме того, вызовы pow, которые в любом случае неверны, если их не оптимизировать, работают на порядки медленнее, чем что-либо еще в их коде.)

user17732522 06.07.2024 17:50
Ответ принят как подходящий

Все распространенные двоичные арифметические и логические операции, за исключением деления, в современных компьютерных архитектурах занимают примерно одинаковое время (часто один цикл). Деление может быть в 10-20 раз медленнее.

Проблема здесь в хитроумном тестировании Leetcode. Второй метод требует гораздо больше работы! pow() имеет значительное время выполнения и называется 31x. Здесь нет никакой возможности победить.

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

bool better_forloop(const int n)
{
   int ans = 1;
   for (int i = 0; i <= 30; i++)
   {
      if (n == ans) return true;
      ans *= 2;
   }
   return false;
}

@TedLyngmo делает очень важное замечание, если операторы if и циклы for имеют неявную дополнительную стоимость для условной ветви, так что если вы можете вычислить результат true/false и вернуть его, то это всегда лучше.

Я изменил код, чтобы он возвращал int и превратился в классический C, чтобы в Godbolt это был минимальный код. Результаты интересны. GCC 14.1, безусловно, лучший из всех. Intel ICX в меньшей степени, а MSVC смешон даже при -O3.

Это результат ассемблера Godbolt из GCC 14.1, который оказался самой плотной и эффективной генерацией кода, которую я нашел. Первый метод 8 инструкций для любых n, второй метод 11 + n*(10+time for pow()) ~120 (мин) и максимум ~3000 (при n=30).

bitwise:
    xor     eax, eax
    test    edi, edi
    jle     .L1
    lea     eax, [rdi-1]
    test    eax, edi
    sete    al
    movzx   eax, al
.L1:
    ret
forloop:
    push    rbp
    mov     ebp, edi
    push    rbx
    xor     ebx, ebx
    sub     rsp, 8
    jmp     .L7
.L12:
    add     ebx, 1
    cmp     ebx, 31
    je      .L11
.L7:
    pxor    xmm1, xmm1
    movsd   xmm0, QWORD PTR .LC0[rip]
    cvtsi2sd        xmm1, ebx
    call    pow
    cvttsd2si       eax, xmm0
    cmp     eax, ebp
    jne     .L12
    add     rsp, 8
    mov     eax, 1
    pop     rbx
    pop     rbp
    ret
.L11:
    add     rsp, 8
    xor     eax, eax
    pop     rbx
    pop     rbp
    ret
.LC0:
    .long   0
    .long   1073741824

По быстрому и грязному предположению, каждая инструкция занимает в среднем около 1 цикла, за исключением call pow, что составляет около 100 машинных циклов. Pow приходится вычислить log 2, умножить на i, а затем вычислить экспоненту, что занимает много времени.

Другие вопросы по теме

Как извне установить соответствие ядра ЦП для процесса в системах Windows с более чем 64 ядрами?
Самый эффективный способ SELECT в кластерной таблице?
Как ускорить интерполяцию для этого конкретного примера?
Каков самый быстрый способ расчета ежедневного баланса со сложными процентами в Pandas или Spark?
Почему INumber<T>.CreateX(int n) настолько медленный по сравнению с неявным преобразованием для чисел с плавающей запятой и двойной точности?
Есть ли название для этого типа структурированных данных и как его более эффективно использовать?
Преобразование списка ИЛИ массива в диапазон
Почему массив % 1 работает на 150 % медленнее для меньших чисел, чем для больших?
Левая часть выражения LIKE должна иметь значение varchar (фактически: varbinary). Какая альтернатива преобразованию varbinary в varchar?
Есть ли какая-либо польза от использования словарного понимания, если возможен эквивалентный dict(zip(a, b))?