Эффективность побитового XOR в С++ по сравнению с более читабельными методами

Недавно я писал код для исследовательского проекта, над которым работаю, где очень важна эффективность. Я рассматривал возможность очистки некоторых обычных методов, в которых я работаю, и использования вместо них побитовых 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

or

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

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

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


person JSchlather    schedule 22.02.2010    source источник
comment
Если по какой-либо причине вы думаете, что пишете нечитаемый код, прокомментируйте его! Подобные оптимизации можно объяснить в паре строк.   -  person NG.    schedule 22.02.2010
comment
Конечно, если я пишу что-то подобное, я это комментирую. Мне просто интересно, есть ли в этом смысл.   -  person JSchlather    schedule 22.02.2010
comment
Вы уже запускали свой код через профайлер? Вероятно, есть некоторые оптимизации более высокого порядка, которые вы можете сделать, чтобы получить лучшую отдачу от затраченных средств.   -  person thebretness    schedule 22.02.2010
comment
Ах, я думаю, это действительно зависит от целевой платформы. Например, некоторые встроенные системы действительно отстой в % (FPGA, для которого я когда-то писал). Так что в некоторых случаях вы увидите большой выигрыш с ним, но я полагаю, что вы не увидите огромной разницы на более дорогих системах.   -  person NG.    schedule 22.02.2010
comment
Ваши представления о том, что будет работать быстрее, крайне ненадежны, особенно если вы думаете, что if (!(n_1 ^ n_2) ) и if (n_1 ^ n_1) else могут отличаться. Играйте с вещами по своему усмотрению, но проверяйте производительность. Кроме того, выполняйте подобные микрооптимизации только в том случае, если вы профилировали и обнаружили, что рассматриваемый код выполняется достаточно часто, чтобы с ним можно было заморачиваться.   -  person David Thornley    schedule 22.02.2010


Ответы (8)


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

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() немного короче, независимо от того, имеет ли это значение на самом деле, зависит от некоторого сравнительного анализа.

person Carl Norum    schedule 22.02.2010
comment
f2 выглядит как вычитание 1, условное добавление 2. - person Anon.; 22.02.2010
comment
Дело не только в том, что путь инструкции для XOR короче. На ‹i›most‹/i› оборудовании побитовые инструкции выполняются быстрее, чем целочисленная арифметика, кроме того, многие компиляторы добавляют код для обработки целочисленных переполнений и т. д. при сложении или вычитании. - person James Anderson; 22.02.2010
comment
Какое оборудование имеет более быстрые побитовые инструкции, чем целочисленная арифметика? По крайней мере, на ARM они все одинаковые. - person Carl Norum; 22.02.2010
comment
Большим преимуществом более короткого кода обычно является то, что он лучше помещается в кеш. - person David Thornley; 22.02.2010

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

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

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


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

person Anon.    schedule 22.02.2010
comment
Неправильно написал, сам виноват (исправил). Кроме того, почему ==, будет столь же, если не более эффективным? Будет ли он просто выполнять те же операции, которые я пишу, и экономить накладные расходы? - person JSchlather; 22.02.2010
comment
cmp – это одна инструкция по сборке. Если вы думаете, что что-то, связанное с большим количеством побитовых манипуляций, будет быстрее, чем эта единственная инструкция, вам действительно не следует пытаться выполнять низкоуровневую оптимизацию. - person Anon.; 22.02.2010

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

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

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

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

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

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

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

person atlaste    schedule 16.12.2013

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

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).

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

person Jerry Coffin    schedule 22.02.2010
comment
cmov отлично подходит компилятору для оптимизации подобных вещей. Сгенерированная сборка, скорее всего, будет добавлять c и d, за которыми следует условный переход в c, если условие истинно. Возможно, даже быстрее на современных процессорах (если они могут пареллизировать сложение и сравнение), чем версия на основе умножения на флаг (которая является строго последовательной и использует mult). - person Anon.; 22.02.2010
comment
Помимо cmov, предсказуемые переходы выполняются быстрее, чем умножение. Умножение на логическое значение — это то, что нужно проверить на реальных данных после того, как все остальное закончено, и вам абсолютно нечего делать. - person Potatoswatter; 22.02.2010
comment
Да, также, в зависимости от задержки мульта, c += d & -(a==b); может быть быстрее. - person Potatoswatter; 22.02.2010
comment
@Potatoswatter: Это, вероятно, самый быстрый, если вы можете жить с немного непереносимым кодом (в основном теоретическим - он просто не будет работать на оборудовании с дополнением 1 или знаком / величиной, которые сейчас довольно редки, по крайней мере для целых чисел). - person Jerry Coffin; 22.02.2010
comment
c += d & -unsigned(a==b); но я придерживаюсь ветки наверное предсказуемой и поэтому бесплатной. - person Potatoswatter; 22.02.2010
comment
@Potatoswatter: вы можете придерживаться этого, если хотите, но если вы посмотрите на приведенный выше тестовый код, вы увидите доказательство того, что это неправильно. С приведением к беззнаковому код должен работать даже на оборудовании с дополнением до 1 или знаком/величиной, но может не дать реального преимущества (но, как я уже говорил, такое оборудование достаточно редко, и мало у кого есть причины для беспокойства). - person Jerry Coffin; 22.02.2010
comment
Ваш код ничего не доказывает о коде OP. Как я уже сказал, предсказуемые ветки быстрее, чем умножение и проверка на реальных данных, потому что это единственный способ узнать, хорошо ли предсказаны ветки или нет. Когда мне представляют проблемную ветвь, я стараюсь улучшить предсказуемость, а не искать алгебраическую замену. Правильно предсказанные ветки дешевы как бесплатные, как и в нулевых циклах на большинстве машин. - person Potatoswatter; 22.02.2010
comment
@Potatoswatter: короче говоря, ты просто ошибаешься. См. (например) intel.com/Assets/PDF/manual/248966.pdf., §3.4.1.1 и amd.com/ us-en/assets/content_type/white_papers_and_tech_docs/, §6.3. О, но подождите: они написаны только Intel и AMD. Очевидно, что вы знаете о работе процессоров больше, чем они когда-либо могли! - person Jerry Coffin; 22.02.2010
comment
Хм, 3.4.1.1 Устранение ветвей Устранение ветвей повышает производительность, потому что: • Уменьшается вероятность ошибочных прогнозов. • Уменьшает количество требуемых записей в целевом буфере ветвления (BTB). Условные ветки, которые никогда не выполняются, не потребляют ресурсы BTB. 6.3 Ветви, зависящие от оптимизации случайных данных Избегайте условных ветвей, зависящих от случайных данных, поскольку их трудно предсказать. Что я говорил о предсказаниях? Ты в ударе, чувак. Опровергнуто первым предложением ОБЕИХ ссылок, которые вы дали. - person Potatoswatter; 22.02.2010
comment
@PotatoSwatter: вы утверждали: ... правильно предсказанные ветки стоят так же дешево, как и бесплатно ... Intel говорит: ... каждая ветвь имеет значение. Даже правильно предсказанные ответвления имеют негативный эффект... Ты регулярно споришь со мной и каждый раз проигрываешь. Пусть ваш разум победит ваше упрямство и учитесь на этом. - person Jerry Coffin; 22.02.2010
comment
Дешево как бесплатно не значит абсолютно бесплатно, это выражение. Вы обрезали предложение: Даже правильно предсказанные переходы отрицательно сказываются на количестве полезного кода, доставляемого процессору. Пока инструкции ветвления не достаточно многочисленны, чтобы заставить процессор голодать из-за других инструкций, они довольно дешевы и, в отличие, например, от умножения, не вводят зависимости. Это не туманное знание. - person Potatoswatter; 22.02.2010
comment
Первый аргумент: вы сказали, что числовые приведения порождают неопределенное поведение и небезопасны, а я сказал, что поведение определяется не C++, а спецификацией оборудования. Вы не могли бы привести пример, подтверждающий ваше утверждение. Я бы сказал, что вы ошиблись в этом. Второй аргумент: вы сделали пустое заявление о том, что мой код неверен, а ваш очень специализированный метод сравнения чисел FP верен, но опять же не предоставили пример или даже существенное утверждение. Я снова думаю, что ты выглядел глупо. Теперь вы утверждаете, что цепочка из трех целочисленных операций, включая множитель, дешевле, чем предсказанная ветвь + добавление. - person Potatoswatter; 22.02.2010
comment
@Potatoswatter: Итак, суть в том, что вы не признаете свою неправоту, даже если так говорит стандарт, вы не признаете свою неправоту, даже когда тестирование доказывает это, и в основном, насколько вам интересно, Тот факт, что вы придерживаетесь мнения, делает его верным независимо от того, какие факты доказывают обратное. Вы также забыли упомянуть свое совершенно идиотское заявление о том, что бинарный поиск на самом деле является бинарным деревом. - person Jerry Coffin; 22.02.2010
comment
Ну, я полагаю, кто-то, читая наши аргументы, может прийти к одному из двух выводов. Я думаю, у вас есть любопытные способы интерпретации вещей и особые привычки копировать-вставлять ошибки и объявлять себя победителем, а не представлять аргумент. Кроме того, я только что провел приведенный выше тест на своей машине (Core2, GCC 4.2 -O3, OS X), и addif1 вышел вперед с 549107 до 685495, или на 20% быстрее. (Я добавил вызов srand.) Настоящий урок здесь состоит в том, чтобы избегать преждевременной оптимизации и стараться быть научным. - person Potatoswatter; 22.02.2010

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

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

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

person Potatoswatter    schedule 22.02.2010
comment
Почему проверка на равенство будет быстрее, чем x^y? Чтобы проверить равенство, вы должны выполнить X0R. Нет абсолютно никакого способа обойти это. В машинном коде не будет ли способ сделать это, сделать X0R, а затем проверить, был ли выброшен нулевой флаг? В отличие от простого выполнения X0R и помещения результата в регистр вместо его отбрасывания. - person JSchlather; 22.02.2010
comment
@liberalkid: На практике все, что проще, чем умножение, фактически имеет ту же скорость, потому что время измеряется в циклах. Процессор тратит очень мало энергии на выполнение простых операций по сравнению с учётом параллельного выполнения инструкций. Я никогда не говорил, что любой из них будет быстрее. Не все ISA имеют операцию XOR, которая устанавливает флаги. В архитектуре с дефицитом регистров, такой как x86, сохранение ненужного результата может привести к тому, что что-то еще будет помещено в стек, что очень плохо. Также XOR (сокращение от исключающее или) пишется с о, а не с нулем. - person Potatoswatter; 22.02.2010

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

person David Kanarek    schedule 22.02.2010

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

person Juan    schedule 22.02.2010

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

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

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

person Mike Dunlavey    schedule 22.02.2010