Если вы думаете, что 64-битная инструкция DIV - это хороший способ деления на два, то неудивительно, что вывод asm компилятора превзойдет ваш рукописный код, даже с -O0
(быстрая компиляция, без дополнительной оптимизации и сохранение / перезагрузка в памяти после / перед каждым оператором C, чтобы отладчик мог изменять переменные).
См. Руководство по оптимизации сборки Agner Fog, чтобы узнать, как написать эффективный ассемблер. У него также есть таблицы инструкций и руководство по микроархам для конкретных деталей для конкретных процессоров. Смотрите такжеx86 пометьте вики для большего количества ссылок
Смотрите также этот более общий вопрос об избиении компилятора рукописным asm: Является ли встроенный язык ассемблера медленнее, чем собственный код C ++? , TL: DR: да, если вы делаете это неправильно (как этот вопрос).
Обычно вы можете позволить компилятору делать свое дело, особенно если вы пытаетесь написать C ++, который может эффективно компилироваться . Также посмотрите, быстрее ли сборка, чем скомпилированные языки? , Один из ответов содержит ссылки на эти аккуратные слайды, показывающие, как различные компиляторы C оптимизируют некоторые действительно простые функции с помощью интересных трюков. Доклад Мэтта Годболта на CppCon2017 « Что мой компилятор сделал для меня в последнее время? Откручивание крышки компилятора »в том же духе.
even:
mov rbx, 2
xor rdx, rdx
div rbx
На Intel Haswell div r64
это 36 моп, с задержкой 32-96 циклов и пропускной способностью один на 21-74 цикла. (Плюс 2 мопа для настройки RBX и нулевого RDX, но выполнение по порядку может быть выполнено раньше). Инструкции с большим количеством пиков, такие как DIV, микрокодируются, что также может привести к узким местам на входе. В этом случае задержка является наиболее важным фактором, потому что она является частью цепочки зависимостей, переносимых циклами.
shr rax, 1
делает то же самое беззнаковое деление: это 1 моп, с задержкой 1 с и может работать 2 за такт.
Для сравнения, 32-битное деление быстрее, но все же ужасно против сдвигов. idiv r32
составляет 9 моп, задержка 22-29 с и одна на 8-11 с пропускной способности на Haswell.
Как вы можете видеть из -O0
вывода asm gcc ( проводника компилятора Godbolt ), он использует только инструкции смены . Clang -O0
компилирует наивно, как вы думали, даже используя 64-битный IDIV дважды. (При оптимизации компиляторы используют оба выхода IDIV, когда источник выполняет деление и модуль с одинаковыми операндами, если они вообще используют IDIV)
GCC не имеет полностью наивного режима; он всегда трансформируется через GIMPLE, что означает, что некоторые «оптимизации» не могут быть отключены . Это включает в себя распознавание деления на константу и использование сдвигов (степень 2) или мультипликативного обратного с фиксированной точкой (не степень 2), чтобы избежать IDIV (см. Ссылку div_by_13
на приведенную выше строчку).
gcc -Os
(оптимизировать по размеру) действительно использует IDIV для деления без степени 2, к сожалению, даже в тех случаях, когда мультипликативный обратный код только немного больше, но намного быстрее.
Помогаем компилятору
(резюме для этого случая: использовать uint64_t n
)
Прежде всего, интересно только посмотреть на оптимизированный вывод компилятора. ( -O3
). -O0
Скорость в принципе не имеет смысла.
Посмотрите на ваш вывод asm (на Godbolt или посмотрите, как удалить «шум» из вывода сборки GCC / clang? ). Когда компилятор не создает оптимальный код в первую очередь: написание исходного кода на C / C ++ таким образом, который ведет компилятор к созданию лучшего кода, обычно является лучшим подходом . Вы должны знать asm и знать, что эффективно, но вы применяете эти знания косвенно. Компиляторы также являются хорошим источником идей: иногда clang делает что-то классное, и вы можете держать gcc в руках то же самое: посмотрите этот ответ и то, что я сделал с не развернутым циклом в коде @ Veedrac ниже.)
Этот подход переносим, и через 20 лет какой-нибудь будущий компилятор сможет скомпилировать его с тем, что эффективно на будущем оборудовании (x86 или нет), возможно, с использованием нового расширения ISA или автоматической векторизацией. Рукописный ассемблер x86-64 от 15 лет назад обычно не был бы оптимально настроен для Skylake. Например, сравните и ответвите макрослияние не существовало тогда. То, что сейчас оптимально для ручной сборки asm для одной микроархитектуры, может быть не оптимальным для других текущих и будущих процессоров. В комментариях к ответу @ johnfound обсуждаются основные различия между AMD Bulldozer и Intel Haswell, которые сильно влияют на этот код. Но по идее g++ -O3 -march=bdver3
и g++ -O3 -march=skylake
поступит правильно. (Или -march=native
.) Или -mtune=...
просто настроить без использования инструкций, которые другие процессоры могут не поддерживать.
Мне кажется, что наведение компилятора на asm, которое хорошо для текущего процессора, о котором вы заботитесь, не должно быть проблемой для будущих компиляторов. Надеемся, что они лучше, чем нынешние компиляторы, находят способы преобразования кода и могут найти способ, который будет работать для будущих процессоров. Несмотря на это, будущий x86, вероятно, не будет ужасен во всем, что хорошо на нынешнем x86, и будущий компилятор избежит любых специфичных для asm ловушек при реализации чего-то вроде перемещения данных из вашего C-источника, если он не увидит чего-то лучшего.
Рукописный asm является черным ящиком для оптимизатора, поэтому постоянное распространение не работает, когда встраивание делает ввод постоянной времени компиляции. Другие оптимизации также влияют. Прочтите https://gcc.gnu.org/wiki/DontUseInlineAsm перед использованием asm. (И избегайте встроенного asm в стиле MSVC: входы / выходы должны проходить через память, что увеличивает накладные расходы .)
В этом случае : ваш n
тип имеет подпись, а gcc использует последовательность SAR / SHR / ADD, которая дает правильное округление. (IDIV и арифметическое смещение «округляют» по-разному для отрицательных входов, см. SAR insn set ref ручной ввод ). (IDK, если gcc попытался и не смог доказать, что n
не может быть отрицательным, или что. Переполнение со знаком - это неопределенное поведение, так что он должен был это сделать.)
Вы должны были использовать uint64_t n
, так что это может просто SHR. И поэтому он переносим на системы, где long
только 32-битный (например, x86-64 Windows).
Кстати, оптимизированный вывод asm для gcc выглядит довольно хорошо (используя )unsigned long n
: внутренний цикл, в который он встроен, main()
делает это:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
Внутренний цикл не имеет ответвлений, и критический путь цепочки зависимостей, переносимых циклами:
- 3-х компонентный LEA (3 цикла)
- cmov (2 цикла на Haswell, 1c на Broadwell или позже).
Итого: 5 циклов за итерацию, узкое место задержки . Внеочередное выполнение позаботится обо всем остальном параллельно с этим (теоретически: я не тестировал счетчики производительности, чтобы увидеть, действительно ли он работает на 5c / iter).
Вход FLAGS cmov
(созданный TEST) генерируется быстрее, чем вход RAX (из LEA-> MOV), поэтому он не находится на критическом пути.
Точно так же MOV-> SHR, который производит вход RDI CMOV, вне критического пути, потому что это также быстрее, чем LEA. MOV на IvyBridge и более поздних версиях имеет нулевую задержку (обрабатывается во время переименования регистра). (Это все еще занимает моп и слот в конвейере, так что это не бесплатно, просто нулевая задержка). Дополнительное MOV в цепочке депо LEA является частью узкого места на других процессорах.
Cmp / jne также не является частью критического пути: он не переносится циклом, потому что управляющие зависимости обрабатываются с предсказанием ветвлений + спекулятивным выполнением, в отличие от зависимостей данных на критическом пути.
Бить компилятор
GCC проделал довольно хорошую работу здесь. Он может сохранить один байт кода, используя inc edx
вместоadd edx, 1
, потому что никому нет дела до P4 и его ложных зависимостей для инструкций по частичному изменению флага.
Он также может сохранить все инструкции MOV, и TEST: SHR устанавливает CF = сдвинутый бит, поэтому мы можем использовать cmovc
вместо test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Посмотрите ответ @ johnfound для другого хитрого трюка: удалите CMP, ответвляя на результат флага SHR, а также используйте его для CMOV: ноль, только если n было 1 (или 0) для начала. (Забавный факт: SHR с count! = 1 на Nehalem или более ранней версии вызывает остановку, если вы читаете результаты флага . Вот как они сделали его однократным. Однако специальное кодирование shift-by-1 подойдет.)
Отказ от MOV совсем не помогает с задержкой в Haswell ( может ли MOV x86 действительно быть «бесплатным»? Почему я вообще не могу воспроизвести это? ). Это действительно помогает существенно на процессорах Intel , как предварительно IVB, и AMD Bulldozer семьи, где МОВ не нулевой задержкой. Потерянные инструкции MOV компилятора влияют на критический путь. Complex-LEA и CMOV BD имеют более низкую задержку (2c и 1c соответственно), так что это большая доля задержки. Кроме того, узкие места пропускной способности становятся проблемой, потому что у этого есть только два целочисленных канала ALU. См. Ответ @ johnfound , где он получил результаты синхронизации с процессором AMD.
Даже в Haswell эта версия может немного помочь, избегая некоторых случайных задержек, когда некритический моп украл порт выполнения с одного на критическом пути, задерживая выполнение на 1 цикл. (Это называется конфликтом ресурсов). Он также сохраняет регистр, который может помочь при n
параллельном выполнении нескольких значений в цикле с чередованием (см. Ниже).
Задержка LEA зависит от режима адресации , от процессоров семейства Intel SnB. 3c для 3 компонентов ( [base+idx+const]
что требует двух отдельных добавлений), но только 1c с 2 или менее компонентами (одно добавление). Некоторые процессоры (например, Core2) выполняют даже 3-компонентный LEA за один цикл, а семейство SnB - нет. Хуже того, семейство Intel SnB стандартизирует задержки, поэтому нет 2с моп , иначе 3-компонентный LEA будет только 2с как Bulldozer. (3-компонентный LEA работает медленнее на AMD, но не так сильно).
Таким образом , lea rcx, [rax + rax*2]
/ inc rcx
только 2с задержки, быстрее , чем lea rcx, [rax + rax*2 + 1]
на Intel SnB семейства процессоров , таких как Haswell. Безубыточность на BD, а хуже на Core2. Это действительно стоит дополнительного UOP, что обычно не стоит того, чтобы экономить задержку 1С, но задержка является основным узким местом, и Haswell имеет достаточно широкий конвейер для обработки дополнительной пропускной способности UOP.
Ни gcc, ни icc, ни clang (на godbolt) не использовали вывод CF SHR, всегда используя AND или TEST . Глупые компиляторы. : P Это отличные образцы сложной техники, но умный человек часто может победить их в небольших задачах. (Конечно, если подумать об этом в тысячи-миллионы раз дольше! Компиляторы не используют исчерпывающие алгоритмы для поиска всех возможных способов выполнения задач, потому что это может занять слишком много времени при оптимизации большого количества встроенного кода, что и является они делают лучше всего. Они также не моделируют конвейер в целевой микроархитектуре, по крайней мере, не так подробно, как IACA или другие инструменты статического анализа; они просто используют некоторую эвристику.)
Простое развертывание цикла не поможет ; это узкое место цикла в задержке цепочки зависимостей, переносимой циклом, а не в издержках цикла / пропускной способности. Это означает, что он будет хорошо работать с гиперпоточностью (или любым другим видом SMT), так как у ЦП есть много времени для чередования инструкций из двух потоков. Это означало бы распараллеливание цикла main
, но это нормально, потому что каждый поток может просто проверить диапазон n
значений и получить в результате пару целых чисел.
Чередование вручную в пределах одного потока также может быть целесообразным . Может быть, вычислить последовательность для пары чисел параллельно, поскольку каждый из них принимает только пару регистров, и все они могут обновлять один и тот же max
/ maxi
. Это создает больше параллелизма на уровне команд .
Хитрость заключается в том, чтобы решить, стоит ли ждать, пока все n
значения не достигнут, 1
прежде чем получить другую пару начальных n
значений, или же выйти из строя и получить новую начальную точку только для той, которая достигла конечного условия, не касаясь регистров для другой последовательности. Вероятно, лучше поддерживать каждую цепочку в работе с полезными данными, в противном случае вам придется условно увеличивать ее счетчик.
Возможно, вы могли бы даже сделать это с помощью упакованного сравнения SSE, чтобы условно увеличить счетчик для векторных элементов, которые n
еще не достигнуты 1
. А затем, чтобы скрыть еще более длительную задержку реализации условного приращения SIMD, вам нужно держать больше векторов n
значений в воздухе. Может быть, стоит только с вектором 256b (4x uint64_t
).
Я думаю, что лучшая стратегия обнаружения 1
«залипания» - это маскировать вектор всех единиц, которые вы добавляете для увеличения счетчика. Поэтому после того, как вы увидели 1
в элементе, вектор приращения будет иметь ноль, а + = 0 - это неоперация.
Непроверенная идея для ручной векторизации
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Вы можете и должны реализовать это с помощью встроенных, а не рукописных асм.
Алгоритмизация / улучшение реализации:
Помимо простой реализации той же логики с более эффективным asm, ищите способы упростить логику или избежать лишней работы. например, запоминать, чтобы обнаружить общие окончания последовательностей. Или, что еще лучше, посмотрите на 8 конечных битов одновременно (ответ Гнашера)
@EOF указывает, что tzcnt
(или bsf
) можно использовать для выполнения нескольких n/=2
итераций за один шаг. Это, вероятно, лучше, чем векторизация SIMD; никакие инструкции SSE или AVX не могут это сделать. n
Тем не менее, он по-прежнему совместим с несколькими параллельными скалярами в разных целочисленных регистрах.
Так что цикл может выглядеть так:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Это может сделать значительно меньше итераций, но сдвиги с переменным счетом происходят медленно на процессорах семейства Intel SnB без BMI2. 3 моп, 2с латентность. (У них есть входная зависимость от FLAGS, потому что count = 0 означает, что флаги не изменены. Они обрабатывают это как зависимость от данных и принимают несколько мопов, потому что моп может иметь только 2 входа (в любом случае, до HSW / BDW)). Именно на это ссылаются люди, жалующиеся на сумасшедший дизайн CISC x86. Это делает процессоры x86 медленнее, чем они были бы, если бы ISA был спроектирован с нуля сегодня, даже в основном аналогичным образом. (то есть это часть «налога x86», который стоит скорость / мощность.) SHRX / SHLX / SARX (BMI2) - большой выигрыш (задержка 1 моп / 1с).
Он также помещает tzcnt (3c для Haswell и более поздних) на критический путь, поэтому он значительно удлиняет общую задержку в цепочке зависимостей, переносимых циклами. Тем не менее, он устраняет необходимость в CMOV или для подготовки реестра n>>1
. Ответ @ Veedrac преодолевает все это, откладывая tzcnt / shift для нескольких итераций, что очень эффективно (см. Ниже).
Мы можем безопасно использовать BSF или TZCNT взаимозаменяемо, потому n
что в этой точке никогда не может быть нулем. Машинный код TZCNT декодируется как BSF на процессорах, которые не поддерживают BMI1. (Бессмысленные префиксы игнорируются, поэтому REP BSF работает как BSF).
TZCNT работает намного лучше, чем BSF на процессорах AMD, которые его поддерживают, поэтому его можно использовать REP BSF
, даже если вам не нужна настройка ZF, если вход равен нулю, а не выходу. Некоторые компиляторы делают это, когда вы используете __builtin_ctzll
даже с -mno-bmi
.
Они выполняют то же самое на процессорах Intel, поэтому просто сохраните байт, если это все, что имеет значение. TZCNT в Intel (pre-Skylake) по-прежнему имеет ложную зависимость от якобы выходного операнда только для записи, как и BSF, для поддержки недокументированного поведения, при котором BSF с input = 0 оставляет свое назначение без изменений. Так что вам нужно обойти это, если не оптимизировать только для Skylake, так что нечего извлекать из дополнительного байта REP. (Intel часто выходит за рамки того, что требует руководство ISA для x86, чтобы избежать взлома широко используемого кода, который зависит от того, чего он не должен, или который запрещен задним числом. Например, Windows 9x не предполагает спекулятивной предварительной выборки записей TLB , что было безопасно когда код был написан, прежде чем Intel обновила правила управления TLB .)
В любом случае, LZCNT / TZCNT на Haswell имеют такое же ложное депо, что и POPCNT: см. Эти вопросы и ответы . Вот почему в выводе gcc asm для кода @ Veedrac вы видите, что он разрывает цепочку dep с нулями xor в регистре, который он собирается использовать в качестве места назначения TZCNT, когда он не использует dst = src. Поскольку TZCNT / LZCNT / POPCNT никогда не оставляют место назначения неопределенным или неизменным, эта ложная зависимость от вывода на процессорах Intel является ошибкой / ограничением производительности. Предположительно, стоит иметь некоторые транзисторы / мощность, чтобы они вели себя как другие мопы, которые идут в один и тот же исполнительный модуль. Единственным преимуществом является взаимодействие с еще одним ограничением uarch: они могут микросинтезировать операнд памяти с режимом индексированной адресации в Haswell, но в Skylake, где Intel удалила ложное депонирование для LZCNT / TZCNT, они «не ламинируют» индексированные режимы адресации, в то время как POPCNT все еще может микрозонить любой дополнительный режим.
Улучшения идей / кода из других ответов:
Ответ @ hidefromkgb содержит приятное замечание, что вы гарантированно сможете сделать один правый сдвиг после 3n + 1. Вы можете вычислить это еще эффективнее, чем просто пропустить проверки между этапами. Однако реализация asm в этом ответе не работает (это зависит от OF, который не определен после SHRD со счетом> 1), и медленный: ROR rdi,2
быстрее SHRD rdi,rdi,2
, а использование двух инструкций CMOV на критическом пути медленнее, чем дополнительный TEST это может работать параллельно.
Я поместил исправленный / улучшенный C (который направляет компилятор для создания лучшего asm) и проверил + работает быстрее asm (в комментариях ниже C) на Godbolt: см. Ссылку в ответе @ hidefromkgb . (Этот ответ достиг предела в 30 тыс. Символов от больших URL-адресов Godbolt, но короткие ссылки могут гнить и в любом случае были слишком длинными для goo.gl.)
Также улучшена печать вывода для преобразования в строку и создания одного write()
вместо написания одного символа за раз. Это сводит к минимуму влияние на синхронизацию всей программы perf stat ./collatz
(для записи счетчиков производительности), и я удалил некоторые из некритических асм.
@ Код Ведрак
Я получил небольшое ускорение от смещения вправо настолько, насколько мы знаем, что нужно сделать, и проверки продолжения цикла. От 7,5 с для предела = 1e8 до 7,275 с на Core2Duo (Merom) с коэффициентом развертывания 16.
код + комментарии к Godbolt . Не используйте эту версию с Clang; это делает что-то глупое с петлей отсрочки. Использование счетчика tmp, k
а затем добавление его к count
более поздним изменениям меняет то, что делает clang, но это немного вредит gcc.
См. Обсуждение в комментариях: код Veedrac отлично работает на процессорах с BMI1 (т.е. не Celeron / Pentium)