Эффективность Bitwise XOR в c ++ по сравнению с более читаемыми методами - PullRequest
8 голосов
/ 22 февраля 2010

Недавно я написал код для исследовательского проекта, над которым я работаю, где эффективность очень важна. Я подумывал о том, чтобы очистить некоторые обычные методы, в которых я что-то делаю, и вместо этого использовать битовые XOR. Что мне интересно, так это то, изменится ли это (если я выполняю эту операцию, скажем, несколько миллионов раз) или же после того, как я использую 03 в g ++.

Два примера, которые приходят на ум:

У меня был случай, когда (я работаю с чисто положительными числами) мне нужно было изменить n на n-1, если n было нечетным, или n на (n + 1), если n было четным. Я подумал, что у меня есть несколько вариантов:

if(n%2) // or (n%2==0) and flip the order
    n=n-1
else
    n=n+1

или

n=n+2*n%2-1; //This of course was silly, but was the only non-bitwise 1 line I could come up with

Наконец:

n=n^1;

Все методы явно делают одно и то же, но я чувствовал, что третий будет наиболее эффективным.

Следующий пример относится к более общей ноте. Скажем, я сравниваю два натуральных числа, будет ли одно из них работать лучше, чем другие. Или эта разница не будет заметна, даже если я выполню эту операцию несколько миллионов раз:

if(n_1==n_2)
if(! (n_1 ^ n_2) )
if( n_1 ^ n_2) else \do work here

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

Исправлено: в правильной постановке задачи.

Ответы [ 8 ]

9 голосов
/ 22 февраля 2010

Это достаточно легко проверить, просто запустите ваш дизассемблер. Взгляните:

f.c:

unsigned int f1(unsigned int n)
{
  n ^= 1;  
  return n;
}

unsigned int f2(unsigned int n)
{
  if (n % 2)
    n=n-1;
  else
    n=n+1;

  return n;
}

Сборка и разборка:

$ cc -O3 -c f.c 
$ otool -tV f.o 
f.o:
(__TEXT,__text) section
_f1:
00  pushq   %rbp
01  movq    %rsp,%rbp
04  xorl    $0x01,%edi
07  movl    %edi,%eax
09  leave
0a  ret
0b  nopl    _f1(%rax,%rax)
_f2:
10  pushq   %rbp
11  movq    %rsp,%rbp
14  leal    0xff(%rdi),%eax
17  leal    0x01(%rdi),%edx
1a  andl    $0x01,%edi
1d  cmovel  %edx,%eax
20  leave
21  ret

Похоже, f1() немного короче, независимо от того, имеет ли это значение в реальности, зависит от некоторого сравнения.

4 голосов
/ 16 декабря 2013

Я вроде как не согласен с большинством ответов здесь, поэтому я все еще вижу себя отвечающим на вопрос 2010 года: -)

XOR практически говорит о самой быстрой операции, которую может выполнить процессор, и хорошая часть в том, что все процессоры поддерживают его. Причина этого довольно проста: вентиль XOR может быть создан только с 4 вентилями NAND или 5 вентилями NOR - это означает, что его легко создать, используя ткань из кремния. Неудивительно, что все известные мне процессоры могут выполнять вашу операцию XOR за 1 такт (или даже меньше).

Если вам нужно выполнить XOR для нескольких элементов в массиве, современные процессоры x64 также поддерживают XOR для нескольких элементов одновременно, например, f.ex. SIMD инструкции по Intel.

Альтернативное решение, которое вы выбираете, использует if-then-else. Правда, большинство компиляторов способны понять эту простую вещь ... но зачем рисковать и каковы последствия?

Следствием того, что ваш компилятор не понял этого, являются ошибки прогнозирования ветвлений. Одна ошибка предсказания ветвления легко займет 17 тактов. Если вы посмотрите на скорость выполнения инструкций процессора, то обнаружите, что ветки очень плохо влияют на вашу производительность, особенно при работе со случайными данными.

Обратите внимание, что это также означает, что если вы неправильно построите свой тест, данные испортят ваши измерения производительности.

Итак, в заключение: сначала подумайте, затем запрограммируйте, а затем профиль, а не наоборот. И используйте XOR.

4 голосов
/ 22 февраля 2010

Мне нужно было изменить n на n-1, если n было четным, или n на (n + 1), если n было нечетным.

В этом случае, независимо от эффективности, n = n ^ 1 является неправильным .

Для вашего второго случая == будет столь же эффективным (если не более), чем любой другой.


В общем, когда дело доходит до оптимизации, вы должны самостоятельно протестировать ее . Если потенциальная оптимизация не стоит делать сравнительный анализ, ее не стоит делать.

2 голосов
/ 22 февраля 2010

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

if(n%2) // or (n%2==0) and flip the order
    n=n-1
else
    n=n+1

как могло бы для n ^= 1;, но я не проверял ничего подобного в последнее время достаточно, чтобы сказать с какой-либо определенностью.

Что касается вашего второго вопроса, я сомневаюсь, что он имеет какое-то значение - сравнение на равенство быстро закончится для любого из этих методов. Если вам нужна скорость, главное - вообще не задействовать ветку - например, что-то вроде:

if (a == b)
    c += d;

можно записать как: c += d * (a==b);. Если взглянуть на язык ассемблера, второй будет выглядеть немного беспорядочно (с уродливым искажением, чтобы получить результат сравнения из флагов в обычный регистр), но все равно часто работает лучше, избегая ветвей.

Редактировать: По крайней мере компиляторы, которые мне пригодятся (gcc и MSVC), не генерируют cmov для if, но они генерируют sete для * (a==b). Я расширил код до чего-то тестируемого.

Edit2: Так как Potatoswatter открыл другую возможность, используя побитовое и вместо умножения, я решил проверить это вместе с другими. Вот код с этим добавленным:

#include <time.h>
#include <iostream>
#include <stdlib.h>

int addif1(int a, int b, int c, int d) { 
    if (a==b) 
        c+=d;
    return c;
}

int addif2(int a, int b, int c, int d) {
    return c += d * (a == b);
}

int addif3(int a, int b, int c, int d) {
    return c += d & -(a == b);
}

int main() {
    const int iterations = 50000;
    int x = rand();
    unsigned tot1 = 0;
    unsigned tot2 = 0;
    unsigned tot3 = 0;

    clock_t start1 = clock();
    for (int i=0; i<iterations; i++) {
        for (int j=0; j<iterations; j++) 
            tot1 +=addif1(i, j, i, x);
    }
    clock_t stop1 = clock();

    clock_t start2 = clock();
    for (int i=0; i<iterations; i++) {
        for (int j=0; j<iterations; j++) 
            tot2 +=addif2(i, j, i, x);
    }
    clock_t stop2 = clock();

    clock_t start3 = clock();
    for (int i=0; i<iterations; i++) {
        for (int j=0; j<iterations; j++) 
            tot3 +=addif3(i, j, i, x);
    }
    clock_t stop3 = clock();

    std::cout << "Ignore: " << tot1 << "\n";
    std::cout << "Ignore: " << tot2 << "\n";
    std::cout << "Ignore: " << tot3 << "\n";

    std::cout << "addif1: " << stop1-start1 << "\n";
    std::cout << "addif2: " << stop2-start2 << "\n";
    std::cout << "addif3: " << stop3-start3 << "\n";    
    return 0;
}

Теперь действительно интересная часть: результаты для третьей версии весьма интересные. Для MS VC ++ мы получаем примерно то, что большинство из нас, вероятно, ожидают:

Ignore: 2682925904
Ignore: 2682925904
Ignore: 2682925904
addif1: 4814
addif2: 3504
addif3: 3021

Использование & вместо * дает определенное улучшение - почти столько же улучшений, сколько * дает if. С gcc результат немного другой:

Ignore: 2680875904
Ignore: 2680875904
Ignore: 2680875904
addif1: 2901
addif2: 2886
addif3: 7675

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

Плохой результат с третьей версией кода, скомпилированного с gcc, возвращает нас к тому, что я сказал, чтобы начать [и заканчивает это редактирование]:

Как я уже сказал для начала, «единственный способ узнать наверняка - это проверить», но, по крайней мере, в этом ограниченном тестировании умножение постоянно превосходит if. Может существовать некоторая комбинация компилятора, флагов компилятора, ЦП, шаблона данных, счетчика итераций и т. Д., Которые благоприятствуют if по сравнению с умножением - нет сомнений, что разница достаточно мала тест, идущий в другом направлении, вполне правдоподобен. Тем не менее, я считаю, что это техника, которую стоит знать; для основных компиляторов и процессоров это кажется достаточно эффективным (хотя, безусловно, больше полезно с MSVC, чем с gcc).

[возобновление edit2:] результат с gcc с использованием & демонстрирует степень, в которой 1) микрооптимизации могут быть / являются специфичными для компилятора, и 2) насколько реальные результаты могут отличаться от ожиданий.

1 голос
/ 22 февраля 2010

Является ли n^=1 быстрее, чем if ( n%2 ) --n; else ++n;? Да. Я не ожидал бы, что компилятор оптимизирует это. Поскольку побитовая операция гораздо более краткая, возможно, стоит ознакомиться с XOR и, возможно, добавить комментарий к этой строке кода.

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

Является ли x^y быстрее, чем x==y? Нет. Делать обходными путями вообще нехорошо.

0 голосов
/ 22 февраля 2010

n ^= 1 и n1==n2, вероятно, лучшее, что вы можете сделать, но на самом деле, если вы стремитесь к максимальной эффективности, не смотрите на код, ища такие мелочи.

Вот пример того, как по-настоящему настроить производительность.

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

0 голосов
/ 22 февраля 2010

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

0 голосов
/ 22 февраля 2010

Хороший компилятор оптимизирует n%2, но вы всегда можете проверить созданную сборку, чтобы увидеть. Если вы видите разделение, начните оптимизировать его самостоятельно, потому что разделение примерно такое же медленное, как и оно.

...