Почему (a * b!= 0) быстрее, чем (a!= 0 && b!= 0) в Java?

Я пишу какой-то код на Java, где в какой-то момент поток программы определяется тем, являются ли две переменные int "a" и "b" ненулевыми (примечание: a и b никогда не отрицательный и никогда не должен находиться в пределах целых чисел переполнения).

Я могу оценить его с помощью

if (a != 0 && b != 0) { /* Some code */ }

Или, альтернативно,

if (a*b != 0) { /* Some code */ }

Потому что я ожидаю, что часть кода будет работать миллионы раз за запуск, мне было интересно, какой из них будет быстрее. Я сделал эксперимент, сравнив их с огромным беспорядочно сгенерированным массивом, и мне также было любопытно узнать, как разреженность массива (доля данных = 0) повлияет на результаты:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

И результаты показывают, что если вы ожидаете, что "a" или "b" будет равно 0 больше, чем ~ 3% времени, a*b != 0 будет быстрее, чем a!=0 && b!=0:

Графический график результатов a и b отличных от нуля

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

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

График a или b отличный от нуля

Мы видим тот же эффект предсказания ветвления, как и ожидалось, интересно, что график несколько перевернут вдоль оси X.

Update

1- Я добавил !(a==0 || b==0) к анализу, чтобы узнать, что произойдет.

2- Я также включил a != 0 || b != 0, (a+b) != 0 и (a|b) != 0 из любопытства, узнав о предсказании ветвления. Но они не логически эквивалентны другим выражениям, потому что только OR b должен быть ненулевым, чтобы возвращать true, поэтому они не предназначены для сравнения эффективности обработки.

3- Я также добавил фактический тест, который я использовал для анализа, который просто выполняет итерацию произвольной переменной int.

4 Некоторые люди предлагали включить a != 0 & b != 0 в отличие от a != 0 && b != 0 с предсказанием того, что он будет вести себя более близко к a*b != 0, потому что мы удалим эффект предсказания ветвления. Я не знал, что & можно использовать с булевыми переменными, я думал, что он используется только для двоичных операций с целыми числами.

Примечание. В контексте, что я рассматривал все это, int overflow не является проблемой, но это определенно важное соображение в общих контекстах.

Процессор: Intel Core i7-3610QM @2.3 ГГц

Версия Java: 1.8.0_45
Java (TM) SE Runtime Environment (сборка 1.8.0_45-b14)
Java HotSpot (TM) 64-разрядная серверная VM (сборка 25.45-b02, смешанный режим)

Ответ 1

Я игнорирую вопрос о том, что ваш бенчмаркинг может быть испорчен, а результат - по номинальной стоимости.

Является ли это компилятором или находится на аппаратном уровне?

Это последнее, я думаю:

  if (a != 0 && b != 0)

будет скомпилирован для двух нагрузок памяти и двух условных ветвей

  if (a * b != 0)

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

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

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

(Примечание: приведенное выше объяснение упрощено.Для более точного объяснения вам нужно взглянуть на литературу, предоставленную производителем ЦП для кодировщиков языка ассемблера и авторов компиляторов. Страница Wikipedia на Предикторы отрасли - хороший фон.)


Однако есть одна вещь, о которой вам нужно быть осторожным в этой оптимизации. Существуют ли какие-либо значения, когда a * b != 0 даст неправильный ответ? Рассмотрим случаи, когда вычисление продукта приводит к переполнению целых чисел.


UPDATE

Ваши графики, как правило, подтверждают мои слова.

  • В случае условной ветки a * b != 0 имеется эффект "предсказания ветвления", и это выводится на графиках.

  • Если вы проецируете кривые выше 0,9 по оси X, это выглядит так: 1) они будут встречаться около 1,0 и 2), точка встречи будет примерно такой же, как у X = 0.0.


ОБНОВЛЕНИЕ 2

Я не понимаю, почему кривые отличаются для случаев a + b != 0 и a | b != 0. В логике предсказателей ветвей может быть что-то умное. Или это может указать что-то еще.

(Обратите внимание, что такие вещи могут быть специфическими для конкретного номера модели чипа или даже версии. Результаты ваших тестов могут отличаться в других системах.)

Однако оба они имеют преимущество в работе для всех неотрицательных значений a и b.

Ответ 2

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

  • (a*b)!=0 будет делать неправильную вещь для значений, которые переполняются, и (a+b)!=0 дополнительно сделает неправильную вещь для положительных и отрицательных значений, которые суммируются до нуля, поэтому вы не можете использовать ни одно из этих выражений в общий случай, даже если они работают здесь.

  • (a|b)!=0 и (a+b)!=0 проверяются, если любое значение отличное от нуля, а (a*b)!=0 и a != 0 && b != 0 проверяются, если оба значения не равны нулю. Два типа условий не будут соответствовать одному проценту данных.

  • VM будет оптимизировать выражение в течение первых нескольких циклов внешнего цикла (fraction), когда fraction равно 0, когда ветки почти никогда не выполняются. Оптимизатор может делать разные вещи, если вы начинаете fraction на 0,5.

  • Если VM не может устранить некоторые из проверок границ массива здесь, в выражении есть только четыре ветки только из-за проверок границ и что усложняющий фактор при попытке выяснить, что происходит на низкий уровень. Вы можете получить разные результаты, если вы разделите двумерный массив на два плоских массива, изменив nums[0][i] и nums[1][i] на nums0[i] и nums1[i].

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

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

  • System.currentTimeMillis() находится в большинстве систем не более, чем +/- 10 мс. System.nanoTime() обычно более точен.

Как вы видите много неопределенностей, и всегда трудно сказать что-то определенное с помощью таких микро-оптимизаций, потому что трюк, который быстрее на одной виртуальной машине или процессоре, может быть медленнее на другом. Если ваша виртуальная машина является HotSpot, имейте в виду, что существуют еще две разновидности, причем виртуальная машина "Клиент" имеет разную (более слабую) оптимизацию по сравнению с виртуальной машиной "Сервер".

Если вы можете разобрать машинный код, сгенерированный виртуальной машиной, сделайте это, а не пытайтесь угадать, что он делает!

Ответ 3

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

Поскольку две ветки и связанное с ними предсказание ветвления являются вероятным виновником, мы можем уменьшить ветвление до одной ветки, не изменяя вообще логику.

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

Он также может работать

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

Причина в том, что по правилам короткого замыкания, если первое логическое значение ложно, второе не должно оцениваться. Он должен выполнить дополнительную ветвь, чтобы избежать оценки nums[1][i], если nums[0][i] было ложным. Теперь вам может быть безразлично, что nums[1][i] получает оценку, но компилятор не может быть уверен, что он не выкинет из диапазона или null ref, когда вы это сделаете. Уменьшая блок if до простых bools, компилятор может быть достаточно умным, чтобы понять, что оценка второго логического значения без необходимости не будет иметь отрицательных побочных эффектов.

Ответ 4

Когда мы принимаем умножение, даже если одно число равно 0, то произведение равно 0. При написании

    (a*b != 0)

Он оценивает результат продукта, тем самым устраняя первые несколько вхождений итерации, начиная с 0. В результате сравнения меньше, чем при условии

   (a != 0 && b != 0)

Где каждый элемент сравнивается с 0 и оценивается. Следовательно, требуемое время меньше. Но я считаю, что второе условие может дать вам более точное решение.

Ответ 5

Вы используете рандомизированные входные данные, что делает ветки непредсказуемыми. На практике ветки часто (~ 90%) предсказуемы, поэтому в реальном коде веткистый код, вероятно, будет быстрее.

Это сказало. Я не вижу, как a*b != 0 может быть быстрее, чем (a|b) != 0. Обычно целочисленное умножение является более дорогостоящим, чем побитовое ИЛИ. Но такие вещи иногда становятся странными. Например, пример "Пример 7: Аппаратные сложности" из Галерея эффектов кэша процессора.