Меня учили, что сдвиг в двоичном коде намного эффективнее, чем умножение на 2 ^ k. Поэтому я хотел поэкспериментировать, и я использовал следующий код, чтобы проверить это:
#include <time.h>
#include <stdio.h>
int main() {
clock_t launch = clock();
int test = 0x01;
int runs;
//simple loop that oscillates between int 1 and int 2
for (runs = 0; runs < 100000000; runs++) {
// I first compiled + ran it a few times with this:
test *= 2;
// then I recompiled + ran it a few times with:
test <<= 1;
// set back to 1 each time
test >>= 1;
}
clock_t done = clock();
double diff = (done - launch);
printf("%f\n",diff);
}
Для обеих версий распечатка была примерно 440000, сдавай или снимай 10000. Не было (визуально, по крайней мере) существенной разницы между выходами двух версий. Итак, мой вопрос, есть ли что-то не так с моей методологией? Должна ли вообще быть визуальная разница? Это как-то связано с архитектурой моего компьютера, компилятором или чем-то еще?
c
efficiency
bitwise-operators
NicholasFolk
источник
источник
gcc -S
кода кодtest *= 2
фактически компилируется в значение «shll $1, %eax
Когда вызывается сgcc -O3 -S
», даже цикла нет. Два часовых вызова находятся на одной линии:callq _clock
movq %rax, %rbx
callq _clock
Ответы:
Как сказано в другом ответе, большинство компиляторов автоматически оптимизируют умножения для битовых сдвигов.
Это очень общее правило при оптимизации: большинство «оптимизаций» фактически вводят компиляцию в заблуждение относительно того, что вы на самом деле имеете в виду, и могут даже снизить производительность.
Оптимизируйте только тогда, когда вы заметили проблему с производительностью и измерили ее. (и большая часть кода, который мы пишем, выполняется не так часто, поэтому нам не нужно беспокоиться)
Большим недостатком оптимизации является то, что «оптимизированный» код часто гораздо менее читабелен. Так что в вашем случае, всегда идти на умножение, когда вы хотите умножить. И идти на сдвиг бит, когда вы хотите переместить биты.
источник
Компилятор распознает константы и при необходимости преобразует умножения в сдвиги.
источник
Будет ли смещение быстрее умножения, зависит от архитектуры вашего процессора. Когда-то во времена Pentium и ранее, сдвиг был часто быстрее, чем умножение, в зависимости от количества 1 бит в вашем множителе. Например, если ваш множитель был 320, это 101000000, два бита.
Но если у вас было более двух битов ...
На маленьком микроконтроллере, таком как PIC18, с умножением на один цикл, но без сдвига ствола , умножение происходит быстрее, если вы сдвигаетесь более чем на 1 бит
Обратите внимание, что это противоположно тому, что было на старых процессорах Intel.
Но это все не так просто. Если я правильно помню, благодаря своей суперскалярной архитектуре Pentium мог обрабатывать либо одну инструкцию умножения, либо две инструкции сдвига одновременно (при условии, что они не зависели друг от друга). Это означает, что если вы хотите умножить две переменные на степень 2, то смещение может быть лучше.
источник
У вас есть несколько проблем с вашей тестовой программой.
Во-первых, вы на самом деле не используете значение
test
. В стандарте C нет никакого значения, что значениеtest
имеет значение. Оптимизатор это совершенно бесплатно, чтобы удалить его. Как только его удалили, ваш цикл фактически пуст. Единственным видимым эффектом было бы установитьruns = 100000000
, ноruns
также не используется. Таким образом, оптимизатор может (и должен!) Удалить весь цикл. Легко исправить: также распечатать вычисленное значение. Обратите внимание, что достаточно определенный оптимизатор может оптимизировать цикл (он полностью зависит от констант, известных во время компиляции).Во-вторых, вы делаете две операции, которые отменяют друг друга. Оптимизатор может заметить это и отменить их . Снова оставив пустую петлю, удалили. Это совершенно сложно исправить. Вы можете переключиться на
unsigned int
(так что переполнение не является неопределенным поведением), но это, конечно, просто приводит к 0. И простые вещи (например, скажем,test += 1
) оптимизатору достаточно легко понять, и это происходит.Наконец, вы предполагаете, что
test *= 2
на самом деле будет скомпилировано с умножением. Это очень простая оптимизация; если битовое смещение быстрее, оптимизатор будет использовать его вместо этого. Чтобы обойти это, вам нужно использовать что-то вроде встроенной сборки, специфичной для реализации.Или, я полагаю, просто проверьте таблицу данных вашего микропроцессора, чтобы увидеть, какая из них быстрее.
Когда я проверил вывод сборки при компиляции вашей программы с
gcc -S -O3
использованием версии 4.9, оптимизатор фактически просмотрел все простые варианты выше и еще несколько. Во всех случаях он удалял цикл (присваивая константуtest
), оставались только вызовыclock()
, преобразование / вычитание иprintf
.источник
gcc -O3
(теперь с 7.3) все еще полностью удаляет цикл. (Обязательно переключайтесь на long вместо int, если требуется, иначе он оптимизирует его в бесконечный цикл из-за переполнения).Я думаю, что для спрашивающего было бы более полезно иметь более дифференцированный ответ, потому что я вижу несколько неисследованных предположений в вопросах и в некоторых ответах или комментариях.
Результирующая относительная среда выполнения сдвига и умножения не имеет ничего общего с C. Когда я говорю «С», я имею в виду не экземпляр конкретной реализации, такой как та или иная версия GCC, а язык. Я не собираюсь воспринимать это как абсурд, но для примера приведу крайний пример: вы могли бы реализовать полностью совместимый со стандартами компилятор C и умножить его на час, а на сдвиг - миллисекунды - или наоборот. Я не знаю каких-либо таких ограничений производительности в C или C ++.
Вы можете не заботиться об этой детали в аргументации. Возможно, вы намеревались просто проверить относительную производительность выполнения сдвигов по сравнению с умножением, и вы выбрали C, потому что он обычно воспринимается как язык программирования низкого уровня, поэтому можно ожидать, что его исходный код будет переводиться в соответствующие инструкции более напрямую. Такие вопросы очень распространены, и я думаю, что хороший ответ должен указать на то, что даже в C ваш исходный код не переводится в инструкции так же прямо, как вы можете думать в данном случае. Я дал вам некоторые возможные результаты компиляции ниже.
Здесь комментарии, которые ставят под сомнение полезность замены этой эквивалентности в реальном программном обеспечении. Вы можете увидеть некоторые в комментариях к вашему вопросу, например, от Эрика Липперта. Это соответствует реакции, которую вы обычно получаете от более опытных инженеров в ответ на такую оптимизацию. Если вы используете двоичные сдвиги в производственном коде в качестве общего средства умножения и деления, люди, скорее всего, будут сжиматься в вашем коде и иметь некоторую эмоциональную реакцию («Я слышал, что это бессмысленное утверждение о JavaScript ради бога») это может не иметь смысла для начинающих программистов, если они не будут лучше понимать причины этих реакций.
Эти причины, в первую очередь, представляют собой сочетание пониженной читаемости и бесполезности такой оптимизации, как вы, возможно, уже поняли, сравнивая их относительную производительность. Однако я не думаю, что у людей была бы такая сильная реакция, если бы замена сдвига на умножение была единственным примером такой оптимизации. Подобные ваши вопросы часто возникают в разных формах и в разных контекстах. Я думаю, что более старшие инженеры на самом деле реагируют так сильно, по крайней мере, я иногда, на то, что существует потенциал для гораздо более широкого спектра вреда, когда люди широко применяют такие микрооптимизации по всей базе кода. Если вы работаете в такой компании, как Microsoft, на большой базе кода, вы потратите много времени на чтение исходного кода других инженеров или на попытки найти в нем определенный код. Это может быть даже ваш собственный код, который вы будете пытаться осмыслить через несколько лет, особенно в самые неподходящие времена, например, когда вам нужно исправить производственный сбой после вызова, поступившего на пейджер. В пятницу вечером мы собираемся отправиться на веселую вечеринку с друзьями ... Если вы потратите столько времени на чтение кода, вы по достоинству оцените его как можно более читабельный. Представьте себе, что вы читаете ваш любимый роман, но издатель решил выпустить новое издание, в котором они используют abbrv. все, что вам нужно, это svs spc. Это похоже на реакцию других инженеров на ваш код, если вы посыпаете их такой оптимизацией. Как указывали другие ответы, лучше четко указать, что вы имеете в виду,
Тем не менее, даже в таких условиях вы можете решить вопрос об интервью, в котором вы должны знать эту или другую эквивалентность. Знание их неплохо, и хороший инженер знал бы об арифметическом эффекте двоичного сдвига. Обратите внимание, что я не сказал, что это делает хорошего инженера, но что хороший инженер знал бы, по моему мнению. В частности, вы все еще можете найти какого-нибудь менеджера, обычно в конце цикла собеседования, который широко улыбнется вам в ожидании удовольствия, чтобы раскрыть вам этот хитрый инженерный «трюк» в вопросе кодирования и доказать, что он / она Раньше тоже был или является одним из опытных инженеров, а не «просто» менеджер. В таких ситуациях просто попытайтесь выглядеть впечатленным и поблагодарите его / ее за полезное интервью.
Почему вы не видели разницу в скорости в C? Наиболее вероятный ответ заключается в том, что оба результата привели к одному и тому же ассемблерному коду:
Можно как скомпилировать в
На GCC без оптимизаций, т. Е. Используя флаг "-O0", вы можете получить это:
Как видите, передача «-O0» в GCC не означает, что он не будет достаточно умным в отношении того, какой код он создает. В частности, обратите внимание, что даже в этом случае компилятор избегал использования инструкции умножения. Вы можете повторить тот же эксперимент со сдвигами на другие числа и даже умножением на числа, которые не являются степенями двух. Скорее всего, на вашей платформе вы увидите комбинацию сдвигов и дополнений, но не умножения. Для компилятора кажется небольшим совпадением то, что он явно избегает использования умножений во всех тех случаях, если умножения и сдвиги действительно имеют одинаковую стоимость, не так ли? Но я не хочу давать предположения для доказательства, поэтому давайте двигаться дальше.
Вы можете повторить тест с помощью приведенного выше кода и посмотреть, заметите ли вы разницу в скорости сейчас. Даже тогда вы тестируете не сдвиг против умножения, как вы можете видеть по отсутствию умножения, но код, который был сгенерирован GCC с определенным набором флагов для операций C сдвига и умножения в конкретном случае . Итак, в другом тесте вы можете отредактировать код сборки вручную и вместо этого использовать в коде команду «imul» для метода «multiply».
Если вы хотите победить некоторые из этих умений компилятора, вы можете определить более общий метод сдвига и умножения и в итоге получите что-то вроде этого:
Который может дать следующий код сборки:
Здесь, наконец, мы имеем, даже на самом высоком уровне оптимизации GCC 4.9, выражение в инструкциях по сборке, которое вы, возможно, ожидали, когда изначально устанавливали свой тест. Я думаю, что сам по себе может быть важным уроком оптимизации производительности. Мы можем видеть разницу, которую он сделал, чтобы заменить переменные на конкретные константы в нашем коде, с точки зрения умений, которые компилятор может применить. Микро-оптимизации, такие как смещение-умножение, - это некоторые очень низкоуровневые оптимизации, которые компилятор обычно может легко выполнить сам. Другие оптимизации, которые оказывают гораздо большее влияние на производительность, требуют понимания замысла кода.это часто не доступно компилятору или может быть предположено только некоторой эвристикой. Это то место, куда вы, как инженер-программист, приходите, и это, как правило, обычно не подразумевает замену умножения сдвигами. Это включает в себя такие факторы, как избегание избыточного вызова службы, которая производит ввод-вывод и может блокировать процесс. Если вы идете на свой жесткий диск или, не дай бог, в удаленную базу данных за некоторыми дополнительными данными, которые вы могли бы получить из того, что у вас уже есть в памяти, время, которое вы тратите на ожидание, перевешивает выполнение миллиона инструкций. Теперь, я думаю, что мы немного отошли от вашего первоначального вопроса, но я думаю, указав это на вопросника, особенно если мы предполагаем, что кто-то, кто только начинает понимать перевод и выполнение кода,
Итак, какой из них будет быстрее? Я думаю, что это хороший подход, который вы выбрали для проверки разницы в производительности. В общем, легко быть удивленным производительностью некоторых изменений кода во время выполнения. Современные технологии используют множество методов, и взаимодействие между программным обеспечением также может быть сложным. Даже если вам нужно получить положительные результаты производительности для определенного изменения в одной ситуации, я думаю, что опасно делать вывод, что изменения такого типа всегда будут приносить преимущества производительности. Я думаю, что запускать такие тесты один раз опасно: «Хорошо, теперь я знаю, какой из них быстрее!» а затем без разбора применить ту же оптимизацию к производственному коду без повторения ваших измерений.
Так что, если сдвиг быстрее, чем умножение? Есть, конечно, признаки того, почему это было бы правдой. GCC, как вы можете видеть выше, кажется (даже без оптимизации) считает, что избегать прямого умножения в пользу других инструкций - хорошая идея. Intel 64 и IA-32 Архитектуры Optimization Справочное руководство даст вам представление об относительной стоимости команд процессора. Другой ресурс, более сфокусированный на задержке инструкций и пропускной способности, - http://www.agner.org/optimize/instruction_tables.pdf., Обратите внимание, что они не являются хорошим предиктором абсолютного времени выполнения, но выполняют инструкции относительно друг друга. В тесном цикле, когда ваш тест имитирует, показатель «пропускная способность» должен быть наиболее актуальным. Это количество циклов, к которым исполнительный блок обычно привязывается при выполнении данной инструкции.
Так что, если сдвиг не быстрее, чем умножение? Как я уже говорил выше, современные архитектуры могут быть довольно сложными, а такие вещи, как предсказание ветвлений, кэширование, конвейеризация и параллельное выполнение, могут затруднить прогнозирование относительной производительности двух логически эквивалентных фрагментов кода за раз. Я действительно хочу подчеркнуть это, потому что именно здесь я недоволен большинством ответов на подобные вопросы, а также тем, что многие люди прямо говорят, что это просто неправда (больше), что смещение происходит быстрее, чем умножение.
Нет, насколько мне известно, мы не изобрели какой-то секретный инженерный соус в 1970-х или когда-либо, чтобы внезапно аннулировать разницу в стоимости единицы умножения и сдвига битов. Общее умножение с точки зрения логических элементов и, разумеется, с точки зрения логических операций, все еще сложнее, чем смещение со сдвигом бочки во многих сценариях на многих архитектурах. То, как это приводит к общему времени выполнения на настольном компьютере, может быть немного непрозрачным. Я не знаю наверняка, как они реализованы в конкретных процессорах, но вот объяснение умножения: действительно ли целочисленное умножение - такая же скорость, как сложение на современном CPU
В то время как здесь объяснение барреля Shifter . Документы, на которые я ссылался в предыдущем параграфе, дают другое представление об относительной стоимости операций через посредство инструкций процессора. Похоже, что у инженеров Intel часто возникают подобные вопросы: на форумах Intel для разработчиков часовые циклы для целочисленного умножения и добавления в процессор Core 2 Duo
Да, в большинстве реальных сценариев, и почти наверняка в JavaScript, попытка использовать эту эквивалентность ради производительности, вероятно, бесполезная задача. Однако даже если мы принудительно использовали инструкции умножения, а затем не увидели никакой разницы во времени выполнения, это объясняется, в частности, характером метрики стоимости, которую мы использовали, а не потому, что нет разницы в стоимости. Сквозное время выполнения - это одна метрика, и если она единственная, о которой мы заботимся, все хорошо. Но это не значит, что все различия в стоимости между умножением и сдвигом просто исчезли. И я думаю, что это, конечно, не очень хорошая идея - передавать эту идею спрашивающему, косвенно или иным образом, который, очевидно, только начинает понимать факторы, влияющие на время выполнения и стоимость современного кода. Инжиниринг всегда связан с компромиссами. Расследование и объяснение того, какие компромиссы сделали современные процессоры для демонстрации времени выполнения, которое мы, как пользователи, в конечном итоге видим, могут дать более дифференцированный ответ. И я думаю, что более дифференцированный ответ, чем «это просто больше не соответствует действительности», оправдан, если мы хотим, чтобы меньше инженеров проверяли микрооптимизированный код, уничтожая читабельность, потому что требуется более общее понимание природы таких «оптимизаций» для определить его различные, разнообразные воплощения, чем просто ссылаться на некоторые конкретные случаи как устаревшие.
источник
То, что вы видите, является эффектом оптимизатора.
Задача оптимизаторов состоит в том, чтобы сделать полученный скомпилированный код меньшим или более быстрым (но редко одновременно в одно и то же время ... но, как и многие другие вещи ... ЭТО ЗАВИСИМО от того, что это за код).
В PRINCIPLE любой вызов библиотеки умножения или, зачастую, даже использование аппаратного умножителя будет медленнее, чем просто выполнение побитового сдвига.
Итак ... если наивный компилятор сгенерирует вызов библиотеки для операции * 2, то, конечно, он будет работать медленнее, чем битовый сдвиг *.
Однако существуют оптимизаторы, чтобы выявлять шаблоны и выяснять, как сделать код меньше / быстрее / чем угодно. И то, что вы видели, это то, что компилятор обнаружил, что * 2 - это то же самое, что и сдвиг.
Просто ради интереса я только сегодня смотрел на сгенерированный ассемблер для некоторых операций, таких как * 5 ... на самом деле не на это, а на другие вещи, и по пути я заметил, что компилятор превратил * 5 в:
Таким образом, оптимизатор моего компилятора был достаточно умен (по крайней мере для определенных небольших констант), чтобы генерировать встроенные сдвиги и добавлять вместо вызовов универсальную библиотеку умножения.
Искусство оптимизаторов компиляторов - это отдельная тема, наполненная магией, и действительно правильно понятая примерно 6 людьми на всей планете :)
источник
Попробуйте синхронизировать это с:
Компилятор должен распознавать, что значение
test
после каждой итерации цикла остается неизменным, а окончательное значениеtest
не используется, и полностью исключать цикл.источник
Умножение представляет собой комбинацию сдвигов и дополнений.
В случае, который вы упомянули, я не думаю, что имеет значение, оптимизирует ли это компилятор или нет - «умножить
x
на два» можно реализовать так:x
одного места влево.x
вx
.Это все основные атомарные операции; один не быстрее, чем другой.
Измените его на «умножить
x
на четыре» (или любое2^k, k>1
другое), и оно будет немного другим:x
двух мест влево.x
кx
и назвать егоy
, добавитьy
кy
.На базовой архитектуре, это просто видеть , что сдвиг является более эффективным - принимая один по сравнению с двумя операций, так как мы не можем добавить
y
кy
пока мы не знаем , чтоy
есть.Попробуйте последнее (или любое
2^k, k>1
) с соответствующими параметрами, чтобы вы не могли оптимизировать их, чтобы они были одинаковыми при реализации. Вы должны найти сдвиг быстрее, принимаяO(1)
по сравнению с повторным добавлением вO(k)
.Очевидно, что если мультипликатор не является степенью двойки, необходима комбинация сдвигов и сложений (одна, где число каждого не равно нулю).
источник
Умножение значений со знаком или без знака на две степени эквивалентно сдвигу влево, и большинство компиляторов выполнят замену. Разделение значений без знака или значений со знаком, которые компилятор может доказать, никогда не бывает отрицательным , эквивалентно сдвигу вправо, и большинство компиляторов выполнят эту замену (хотя некоторые не достаточно сложны, чтобы доказать, что значения со знаком не могут быть отрицательными) ,
Однако следует отметить, что деление потенциально отрицательных знаковых значений не эквивалентно смещению вправо. Выражение как
(x+8)>>4
не эквивалентно(x+8)/16
. Первые в 99% компиляторов будут отображать значения от -24 до -9 до -1, от -8 до +7 до 0 и от +8 до +23 до 1 [округление чисел почти симметрично до нуля]. Последний будет отображать от -39 до -24 до -1, от -23 до +7 до 0 и от +8 до +23 до +1 [крайне асимметрично, и, вероятно, не то, что предполагалось]. Обратите внимание, что даже если значения не должны быть отрицательными, использование>>4
, скорее всего, даст более быстрый код, чем/16
если компилятор не сможет доказать, что значения не могут быть отрицательными.источник
Еще немного информации, которую я только что проверил.
На x86_64 код операции MUL имеет задержку 10 циклов и пропускную способность 1/2 цикла. MOV, ADD и SHL имеют задержку 1 цикл с пропускной способностью 2,5, 2,5 и 1,7 цикла.
Умножение на 15 потребует минимум 3 операции SHL и 3 операции ADD и, возможно, пару MOV.
https://gmplib.org/~tege/x86-timing.pdf
источник
Ваша методология ошибочна. Ваша проверка приращения цикла и сама проверка состояния занимают так много времени.
base
).s1
).s2
)Если все идет правильно,
base-s2
должно быть в 10 раз большеbase-s1
. Иначе что-то еще вступает в игру здесь.Теперь я сам попробовал это и решил, что если петли вызывают проблемы, почему бы не удалить их полностью. Итак, я пошел дальше и сделал это:
И вот у вас есть ваш результат
1 миллион сменных операций менее чем за 1 миллисекунду? ,
Я сделал то же самое для умножения на 64 и получил тот же результат. Так что, вероятно, компилятор полностью игнорирует операцию, поскольку другие упоминали, что значение test никогда не изменяется.
источник