Я написал эти два решения для Project Euler Q14 в сборке и на С++. Они представляют собой одинаковый подход грубой силы для тестирования гипотезы Collatz. Сборочный раствор был собран с помощью
nasm -felf64 p14.asm && gcc p14.o -o p14
C++ был скомпилирован с
g++ p14.cpp -o p14
Сборка, p14.asm
section .data
fmt db "%d", 10, 0
global main
extern printf
section .text
main:
mov rcx, 1000000
xor rdi, rdi ; max i
xor rsi, rsi ; i
l1:
dec rcx
xor r10, r10 ; count
mov rax, rcx
l2:
test rax, 1
jpe even
mov rbx, 3
mul rbx
inc rax
jmp c1
even:
mov rbx, 2
xor rdx, rdx
div rbx
c1:
inc r10
cmp rax, 1
jne l2
cmp rdi, r10
cmovl rdi, r10
cmovl rsi, rcx
cmp rcx, 2
jne l1
mov rdi, fmt
xor rax, rax
call printf
ret
С++, p14.cpp
#include <iostream>
using namespace std;
int sequence(long n) {
int count = 1;
while (n != 1) {
if (n % 2 == 0)
n /= 2;
else
n = n*3 + 1;
++count;
}
return count;
}
int main() {
int max = 0, maxi;
for (int i = 999999; i > 0; --i) {
int s = sequence(i);
if (s > max) {
max = s;
maxi = i;
}
}
cout << maxi << endl;
}
Я знаю о оптимизации компилятора, чтобы улучшить скорость и все, но я не вижу много способов оптимизации моего решения сборки (говоря программно не математически).
Код С++ имеет модуль каждого члена и деления каждый четный член, где сборка - только одно подразделение на четный член.
Но сборка занимает в среднем 1 секунду дольше, чем решение на С++. Почему это? Я прошу из любопытства.
Изменить: Время выполнения по запросу
Моя система: 64-разрядная Linux на 1,4 ГГц Intel Celeron 2955U (микроархитектура Haswell).
g++
(unoptimized): avg 1272 мс
g++ -O3
avg 578 мс
оригинал asm (div) avg 2650 мс
Asm (shr)
avg 679 мс
@johnfound asm, собранный с помощью nasm avg 501 мс
@hidefromkgb asm avg 200 мс
@hidefromkgb asm оптимизирован @Peter Cordes avg 145 мс
@Veedrac С++ avg 81 ms с -O3
, 305ms с -O0
Если вы считаете, что 64-разрядная команда DIV - это хороший способ разделить на две части, то неудивительно, что выход asm компилятора превосходил ваш ручной код даже при -O0
(быстро компилировать, без дополнительной оптимизации и хранения/перезагружать в память после/перед каждым оператором C, чтобы отладчик мог изменять переменные).
Смотрите Agner Fog Optimizing Assembly guide, чтобы узнать, как писать эффективные asm. Он также имеет таблицы инструкций и руководство для микроархива для конкретных деталей для конкретных процессоров. См. Также x86 теги wiki для более перфекционных ссылки.
См. также более общий вопрос об избиении компилятора с помощью рукописного asm: Является ли язык встроенной сборки медленнее, чем собственный код на С++?. TL: DR: да, если вы сделаете это неправильно (например, этот вопрос).
Обычно вы прекрасно соглашаетесь на компилятор, особенно если вы попытаетесь написать С++, который может эффективно компилироваться. Также см. сборка быстрее, чем скомпилированные языки?. Один из ответов связан с этими аккуратными слайдами, показывающими, как различные компиляторы C оптимизируют некоторые действительно простые функции с помощью трюков.
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
выполняет одно и то же беззнаковое деление: It 1 uop, с задержкой 1c и может работать 2 за такт.
Для сравнения, 32-разрядное деление быстрее, но все же ужасно против сдвигов. idiv r32
- 9 часов, задержка 22-29 c и одна на пропускную способность 8-11c на Haswell.
Как вы можете видеть из gcc -O0
asm output (Godbolt explorer, он использует только команды shifts. clang -O0
компилируется наивно, как вы думали, даже используя 64-битный IDIV в два раза. (При оптимизации компиляторы используют оба выхода IDIV, когда источник выполняет деление и модуль с одинаковыми операндами, если они вообще используют IDIV)
GCC не имеет абсолютно наивного режима; он всегда преобразуется через GIMPLE, что означает, что некоторые "оптимизации" не могут быть отключены. Это включает в себя распознавание деления по константе и использование сдвигов (мощность 2) или мультипликативного обратного преобразования с фиксированной точкой (non power of 2), чтобы избежать IDIV (см. div_by_13
в приведенной выше ссылке godbolt).
gcc -Os
(оптимизация для размера) использует IDIV для разделения без полномочий 2,
к сожалению, даже в тех случаях, когда мультипликативный обратный код лишь немного больше, но намного медленнее.
(сводка для этого случая: use uint64_t n
)
Прежде всего, интересно только посмотреть на оптимизированный вывод компилятора. (-O3
). -O0
скорость в основном бессмысленна.
Посмотрите на свой выход asm (на Godbolt или посмотрите Как удалить "шум" из сборки сборки GCC/clang?). Когда компилятор не делает оптимальный код в первую очередь: Написание вашего источника C/С++ способом, который помогает компилятору создавать лучший код, как правило, лучший подход. Вы должны знать ас и знать, что эффективно, но косвенно применяете это знание. Компиляторы также являются хорошим источником идей: иногда clang будет делать что-то классное, и вы можете вручную взять gcc в то же самое: см. этот ответ и то, что я сделал с незанятым циклом в коде @Veedrac ниже.)
Этот подход переносимый, и через 20 лет какой-то будущий компилятор может скомпилировать его на то, что эффективно для будущего оборудования (x86 или нет), возможно, с использованием нового расширения ISA или автоматической векторизации. Рукописный x86-64 asm от 15 лет назад обычно не был бы оптимально настроен для Skylake. например в то время не было сопоставления макросов и макросов. То, что сейчас оптимально для ручной архитектуры asm для одной микроархитектуры, может оказаться не оптимальным для других текущих и будущих процессоров. Комментарии к ответу @johnfoundобсудите основные различия между AMD Bulldozer и Intel Haswell, которые сильно влияют на этот код. Но теоретически g++ -O3 -march=bdver3
и g++ -O3 -march=skylake
будут поступать правильно. (Или -march=native
.) Или -mtune=...
просто настроить, не используя инструкции, которые другие CPU могут не поддерживать.
Я чувствую, что руководство компилятором к asm, что хорошо для текущего процессора, о котором вы заботитесь, не должно быть проблемой для будущих компиляторов. Они, надеюсь, лучше современных компиляторов при поиске способов преобразования кода и могут найти способ, который работает для будущих процессоров. Несмотря на это, будущий x86, вероятно, не будет ужасен ни в чем хорошем на нынешнем x86, а будущий компилятор избежит любых ошибок, связанных с ASM, при реализации чего-то вроде движения данных из вашего источника C, если он не увидит что-то лучше.
Рукописный asm является черным ящиком для оптимизатора, поэтому постоянное распространение не работает, когда inlining делает вход константой времени компиляции. Другие изменения также затронуты. Перед использованием asm прочитайте https://gcc.gnu.org/wiki/DontUseInlineAsm. (И избегайте встроенного asm в стиле MSVC: входы/выходы должны проходить через память которая добавляет накладные расходы.)
В этом случае: ваш n
имеет подписанный тип, а gcc использует последовательность SAR/SHR/ADD, которая дает правильное округление. (IDIV и арифметический сдвиг "round" по-разному для отрицательных входов, см. SAR insn set ref manual entry). (IDK, если gcc попытался и не смог доказать, что n
не может быть отрицательным или что-то такое. Signed-overflow - это поведение undefined, поэтому он должен был быть способен.)
Вы должны были использовать uint64_t n
, поэтому он может просто SHR. И поэтому он переносится в системы, где long
является только 32-разрядным (например, x86-64 Windows).
BTW, gcc оптимизированный выход asm выглядит довольно неплохо (используя 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
Внутренний цикл является ветвящимся, а критический путь цепи зависимых от цикла циклов:
Всего: 5 циклов за итерацию, узкое место ожидания. Выполнение вне порядка позаботится обо всем остальном параллельно с этим (теоретически: я не тестировал с помощью счетчиков perf, чтобы увидеть, действительно ли он работает на 5c/iter).
Вход FLAGS cmov
(созданный TEST) быстрее, чем выход RAX (из LEA- > MOV), поэтому он не находится на критическом пути.
Аналогично, MOV- > SHR, который генерирует вход CMOV RDI, отключен от критического пути, поскольку он также быстрее, чем LEA. MOV на IvyBridge и позже имеет нулевую задержку (обрабатывается при переименовании регистра). (Он по-прежнему занимает uop и слот в конвейере, поэтому он не бесплатный, просто нулевая латентность). Дополнительный 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 или ранее вызывает срыв, если вы читаете результаты флага. Это то, как они сделали его одним-юпом. по-1 специальная кодировка в порядке.)
Избегание MOV не помогает с задержкой вообще на Haswell (Может ли MOV x86 действительно "бесплатно" ? Почему я не могу воспроизвести это вообще?). Это значительно помогает в таких процессорах, как Intel pre-IvB и семейство AMD Bulldozer, где MOV не имеет нулевой задержки. Компилятор впустую команды MOV действительно влияют на критический путь. BD complex-LEA и CMOV являются как более низкой задержкой (2c, так и 1c соответственно), поэтому это большая часть задержки. Кроме того, проблемы с пропускной способностью становятся проблемой, поскольку она имеет только два целых ALU-канала. См. ответ @johnfound, где он получает результаты с процессора AMD.
Даже в Haswell эта версия может немного помочь, избегая некоторых случайных задержек, когда некритический uop крадет порт выполнения от одного на критическом пути, задерживая выполнение на 1 цикл. (Это называется конфликтом ресурсов). Он также сохраняет регистр, который может помочь при выполнении нескольких n
значений параллельно в чередующемся цикле (см. Ниже).
Задержка LEA зависит от режима адресации, на процессорах Intel SnB-семейства. 3c для 3 компонентов ([base+idx+const]
, который принимает два отдельных добавления), но только 1c с 2 или менее компонентами (один добавить). Некоторые процессоры (например, Core2) выполняют даже 3-компонентный LEA за один цикл, но SnB-family этого не делает. Хуже того, Intel SnB-family стандартизирует задержки, так что нет 2c uops, в противном случае 3-компонентный LEA будет всего 2c, как Bulldozer. (3-компонентный LEA на AMD тоже медленнее, просто не так).
Итак, lea rcx, [rax + rax*2]
/inc rcx
- это только 2c-латентность, быстрее, чем lea rcx, [rax + rax*2 + 1]
, на процессорах Intel SnB-семейства, таких как Haswell. Разрыв на BD, и хуже на Core2. Это стоит лишний uop, который обычно не стоит экономить 1c латентность, но латентность является основным узким местом здесь, и Haswell имеет достаточно широкий конвейер для обработки дополнительной пропускной способности.
Ни gcc, icc, ни clang (on godbolt) не использовали выход SHR CF, всегда используя AND или TEST. Глупые компиляторы.: P Это отличные кусочки сложной техники, но умный человек может часто избивать их по мелким проблемам. (В тысячах и в миллионы раз дольше думать об этом, конечно! Компиляторы не используют исчерпывающие алгоритмы для поиска всех возможных способов делать что-то, потому что это займет слишком много времени при оптимизации большого количества встроенного кода, что и есть они делают лучше всего. Они также не моделируют трубопровод в целевой микроархитектуре, а просто используют некоторые эвристики.)
Простая развертка цикла не поможет; этот цикл является узким местом на задержке цепи зависимостей, связанной с циклом, а не на потоке/пропускной способности цикла. Это означает, что это будет хорошо с гиперпотоком (или любым другим видом SMT), поскольку процессор имеет много времени для чередования инструкций из двух потоков. Это означало бы распараллеливание цикла в main
, но это прекрасно, потому что каждый поток может просто проверять диапазон значений n
и создавать в результате пару целых чисел.
Перемещение вручную внутри одного потока может быть жизнеспособным, также. Может быть, вычислить последовательность для пары чисел параллельно, поскольку каждый из них принимает только пару регистров, и они могут все обновить те же max
/maxi
. Это создает больше уровень уровня parallelism.
Трюк решает, ждать ли до тех пор, пока все значения n
не достигнут 1
, прежде чем получить еще одну пару стартовых значений n
, или разбить и получить новую начальную точку только для того, конечное условие, не касаясь регистров для другой последовательности. Вероятно, лучше всего держать каждую цепочку в работе над полезными данными, иначе вам придется условно увеличивать счетчик.
Возможно, вы даже можете сделать это с помощью пакета SSE для упаковки, чтобы условно увеличить счетчик для векторных элементов, где n
еще не достигло 1
. И затем, чтобы скрыть еще большую задержку реализации условного прироста SIMD, вам нужно будет держать больше векторов значений n
в воздухе. Может быть, стоит только с 256b-вектором (4x uint64_t
).
Я думаю, что лучшей стратегией для обнаружения 1
"липкой" является маскировка вектора all-ones, который вы добавляете для увеличения счетчика. Итак, после того, как вы увидели в элементе 1
, вектор инкремента будет иметь нуль, а + = 0 - нет-op.
# 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
# There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure. Probably worth it
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.
Вы можете и должны реализовать это с помощью intrinsics, вместо рукописного asm.
Помимо реализации одной и той же логики с более эффективным asm, найдите способы упрощения логики или избегайте избыточной работы. например memoize для обнаружения общих окончаний последовательностей. Или еще лучше, посмотрите на 8 конечных бит сразу (gnasher ответ)
@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 uops, 2c latency. (У них есть зависимость ввода от FLAGS, потому что count = 0 означает, что флаги не модифицированы. Они обрабатывают это как зависимость данных и принимают несколько uops, потому что uop может иметь только 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: см. этот Q & A. Вот почему в gcc asm output для кода @Veedrac вы видите разрыв цепочки dep с xor-zeroing в регистре, который он собирается использовать в качестве адресата TZCNT, когда он не использует dst = src. Поскольку TZCNT/LZCNT/POPCNT никогда не покидают свой пункт назначения undefined или немодифицированы, эта ложная зависимость от выхода на процессорах Intel является исключительно ошибкой/ограничением производительности. Предположительно, это стоит каких-то транзисторов/мощности, чтобы заставить их вести себя как другие uops, идущие к одному и тому же исполнительному блоку. Единственный программно-видимый потенциал - во взаимодействии с другим микроархитектурным ограничением: они могут скомпилировать операнд памяти с индексированным режимом адресации на Haswell, но на Skylake, где Intel удалили ложную зависимость для LZCNT/TZCNT, они "не ламинируют" индексированные режимы адресации, в то время как POPCNT все еще может замаскировать любой режим addr.
@hidefromkgb answer имеет хорошее наблюдение, что вы гарантированно сможете сделать одну правую смену после 3n + 1. Вы можете вычислить это еще более эффективно, чем просто оставить проверки между шагами. Однако реализация asm в этом ответе прерывается (зависит от OF, который undefined после SHRD со счетом > 1) и медленный: ROR rdi,2
быстрее, чем SHRD rdi,rdi,2
, и используя две инструкции CMOV на критический путь медленнее, чем дополнительный TEST, который может работать параллельно.
Я поместил tidied/улучшенный C (который помогает компилятору создать лучший asm) и протестировал + работать быстрее asm (в комментариях ниже C) вверх на Godbolt: см. ссылку в @hidefromkgb answer. (Этот ответ попал в предел 30 тыс. char из больших URL-адресов Godbolt, но shortlinks может гнить и слишком долго для goo.gl.)
Также улучшена печать вывода, чтобы преобразовать в строку и сделать один write()
вместо того, чтобы писать один char за раз. Это минимизирует влияние на выбор времени всей программы с помощью perf stat ./collatz
(для записи счетчиков производительности), и я де-запутывал некоторые некритические asm.
Код @Veedrac
Я получил очень небольшое ускорение от правого сдвига, насколько мы знаем, что нужно делать, и проверку продолжения цикла. От 7.5s для limit = 1e8 до 7.275s, на Core2Duo (Merom), с коэффициентом unroll 16.
code + comments в Godbolt. Не используйте эту версию с clang; он делает что-то глупое с отсрочкой. Использование счетчика tmp k
, а затем добавление его в count
позже изменяет то, что делает clang, но это немного болит gcc.
См. обсуждение в комментариях: код Veedrac отлично работает на процессорах с BMI1 (то есть не Celeron/Pentium)
-O3
не был оптимальным. Я, вероятно, не сохранил никаких задержек, кроме как за счет избежания случайных конфликтов ресурсов, но сохранил несколько инструкций.
tzcnt
и вы заблокированы самой продолжительной последовательностью среди ваших векторных элементов в векторизованном случае) ,
Утверждение, что компилятор С++ может создавать более оптимальный код, чем грамотный ассемблер, является очень плохой ошибкой. И особенно в этом случае. Человек всегда может сделать код лучше, чем может компилятор, и эта конкретная ситуация является хорошей иллюстрацией этого утверждения.
Разница во времени, которую вы видите, связана с тем, что код сборки в вопросе очень далек от оптимального во внутренних циклах.
(приведенный ниже код 32-битный, но его можно легко преобразовать в 64-разрядный)
Например, функция последовательности может быть оптимизирована только для 5 команд:
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
Весь код выглядит так:
include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"
start:
InitializeAll
mov ecx, 999999
xor edi, edi ; max
xor ebx, ebx ; max i
.main_loop:
xor esi, esi
mov eax, ecx
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
cmp edi, esi
cmovb edi, esi
cmovb ebx, ecx
dec ecx
jnz .main_loop
OutputValue "Max sequence: ", edi, 10, -1
OutputValue "Max index: ", ebx, 10, -1
FinalizeAll
stdcall TerminateAll, 0
Для компиляции этого кода требуется FreshLib.
В моих тестах (процессор AMD A4-1200 с тактовой частотой 1 ГГц) вышеупомянутый код примерно в 4 раза быстрее, чем код С++ из вопроса (при компиляции с -O0
: 430 мс против 1900 мск), и более чем в два раза быстрее (430 мс против 830 мс), когда код С++ скомпилирован с помощью -O3
.
Выход обеих программ один и тот же: max sequence = 525 на я = 837799.
-O3
gcc, но я обнаружил все другие оптимизации, которые вы сделали для внутреннего цикла. (Но почему вы используете LEA для увеличения счетчика вместо INC? В этой точке нормально закрывать флаги и приводить к замедлению всего, кроме, возможно, P4 (ложная зависимость от старых флагов как для INC, так и для SHR). LEA может ' работать на таком количестве портов, что может привести к конфликтам ресурсов, что приведет к более частой задержке критического пути.)
Для большей производительности: простое изменение наблюдает, что после n = 3n + 1 n будет четным, поэтому вы можете сразу разделить на 2. И n не будет 1, поэтому вам не нужно проверять его. Таким образом, вы можете сохранить несколько операторов if и написать:
while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
n = (3*n + 1) / 2;
if (n % 2 == 0) {
do n /= 2; while (n % 2 == 0);
if (n == 1) break;
}
}
Здесь большой выигрыш: если вы посмотрите на самые младшие 8 бит n, все этапы, пока вы не разделите их на 2 восемь раз, не будут полностью определены этими восемью битами. Например, если последние восемь бит равны 0x01, то есть в двоичном формате ваш номер равен? 0000 0001, то следующие шаги:
3n+1 -> ???? 0000 0100
/ 2 -> ???? ?000 0010
/ 2 -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2 -> ???? ???0 0010
/ 2 -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2 -> ???? ???? ?010
/ 2 -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2 -> ???? ???? ???0
/ 2 -> ???? ???? ????
Итак, все эти шаги можно предсказать, а 256k + 1 заменить на 81k + 1. Что-то подобное произойдет для всех комбинаций. Таким образом, вы можете создать цикл с большим оператором switch:
k = n / 256;
m = n % 256;
switch (m) {
case 0: n = 1 * k + 0; break;
case 1: n = 81 * k + 1; break;
case 2: n = 81 * k + 1; break;
...
case 155: n = 729 * k + 425; break;
...
}
Запустите цикл до n ≤ 128, потому что в этой точке n может стать 1 с менее чем восемью делениями на 2, и выполнение восьми или более шагов за раз заставит вас пропустить точку, в которой вы достигнете 1 в первый раз, Затем продолжите "обычный" цикл - или подготовьте таблицу, в которой рассказывается, сколько еще шагов нужно достичь 1.
PS. Я сильно подозреваю, что предложение Питера Кордеса сделает его еще быстрее. Во всех случаях не будет никаких условных ветвей, кроме одного, и это будет предсказано правильно, кроме случаев, когда цикл фактически заканчивается. Таким образом, код будет что-то вроде
static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }
while (n > 128) {
size_t lastBits = n % 256;
n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}
На практике вы бы измерили, будет ли обработка последних 9, 10, 11, 12 бит n за раз быстрее. Для каждого бита количество записей в таблице удваивается, и я исключаю замедление, когда таблицы больше не вписываются в кеш L1.
ПФС. Если вам нужно количество операций: на каждой итерации мы выполняем ровно восемь делений на два и переменное число операций (3n + 1), поэтому очевидным методом подсчета операций будет другой массив. Но мы можем рассчитать количество шагов (в зависимости от количества итераций цикла).
Мы могли бы немного переопределить проблему: замените n на (3n + 1)/2, если нечетно, и заменим n на n/2, если четное. Тогда каждая итерация будет выполняться ровно в 8 шагов, но вы можете подумать об этом:-) Так что предположим, что были операции r n < - 3n + 1 и s операции n < - n/2. Результат будет в точности ровно n '= n * 3 ^ r/2 ^ s, так как n < - 3n + 1 означает n < - 3n * (1 + 1/3n). Взяв логарифм, найдем r = (s + log2 (n '/n))/log2 (3).
Если мы выполняем цикл до n ≤ 1,000,000 и имеем предварительно вычисленную таблицу, сколько итераций требуется от любой начальной точки n ≤ 1,000,000, тогда вычисление r, как указано выше, округленное до ближайшего целого, даст правильный результат, если s действительно большой.
На довольно несвязанной ноте: больше хаков производительности!
При перемещении последовательности мы можем получить только 3 возможных случая в 2-окрестности текущего элемента N
(показано первым):
Чтобы перепрыгнуть через эти два элемента, нужно вычислить (N >> 1) + N + 1
, ((N << 1) + N + 1) >> 1
и N >> 2
соответственно.
Докажем, что для обоих случаев (1) и (2) можно использовать первую формулу (N >> 1) + N + 1
.
Случай (1) очевиден. Случай (2) подразумевает (N & 1) == 1
, поэтому, если мы предположим (без ограничения общности), что N является 2-битным, а его биты ba
от большинства до наименее значимых, тогда a = 1
, и выполняется следующее
(N << 1) + N + 1: (N >> 1) + N + 1:
b10 b1
b1 b
+ 1 + 1
---- ---
bBb0 bBb
где B = !b
. Прямое смещение первого результата дает нам именно то, что мы хотим.
Q.E.D.: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1
.
Как доказано, мы можем перемещаться по последовательности 2 элемента за раз, используя одну тройную операцию. Еще 2 × сокращение времени.
Результирующий алгоритм выглядит следующим образом:
uint64_t sequence(uint64_t size, uint64_t *path) {
uint64_t n, i, c, maxi = 0, maxc = 0;
for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
c = 2;
while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
c += 2;
if (n == 2)
c++;
if (c > maxc) {
maxi = i;
maxc = c;
}
}
*path = maxc;
return maxi;
}
int main() {
uint64_t maxi, maxc;
maxi = sequence(1000000, &maxc);
printf("%llu, %llu\n", maxi, maxc);
return 0;
}
Здесь мы сравниваем n > 2
, потому что процесс может останавливаться на 2 вместо 1, если общая длина последовательности нечетна.
Давайте переведем это на сборку!
MOV RCX, 1000000;
DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;
@main:
XOR RSI, RSI;
LEA RDI, [RCX + 1];
@loop:
ADD RSI, 2;
LEA RDX, [RDI + RDI*2 + 2];
SHR RDX, 1;
SHRD RDI, RDI, 2; ror rdi,2 would do the same thing
CMOVL RDI, RDX; Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
CMOVS RDI, RDX;
CMP RDI, 2;
JA @loop;
LEA RDX, [RSI + 1];
CMOVE RSI, RDX;
CMP RAX, RSI;
CMOVB RAX, RSI;
CMOVB RBX, RCX;
SUB RCX, 2;
JA @main;
MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;
@itoa:
XOR RDX, RDX;
DIV RCX;
ADD RDX, '0';
PUSH RDX;
TEST RAX, RAX;
JNE @itoa;
PUSH RCX;
LEA RAX, [RBX + 1];
TEST RBX, RBX;
MOV RBX, RDI;
JNE @itoa;
POP RCX;
INC RDI;
MOV RDX, RDI;
@outp:
MOV RSI, RSP;
MOV RAX, RDI;
SYSCALL;
POP RAX;
TEST RAX, RAX;
JNE @outp;
LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;
Используйте эти команды для компиляции:
nasm -f elf64 file.asm
ld -o file file.o
Смотрите C и улучшенную/исправленную версию asm от Peter Cordes на Godbolt. (примечание редактора: Извините за то, что вы положили мои вещи в свой ответ, но мой ответ попал в предел 30k char из ссылок Godbolt + текст!)
for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
цикле достаточно того, что было бы удобнее читать, просто вытащив n=i
на на следующей строке вместо повторения этой логики в инициализаторе и в части i-=2
.
Программы на С++ переводятся в программы сборки во время генерации машинного кода из исходного кода. Было бы неправильно сказать, что сборка происходит медленнее, чем С++. Более того, генерируемый двоичный код отличается от компилятора компилятором. Таким образом, умный компилятор С++ может генерировать двоичный код более оптимальным и эффективным, чем немой код ассемблера.
Однако я считаю, что ваша методология профилирования имеет определенные недостатки. Ниже приведены общие рекомендации для профилирования:
Даже не глядя на сборку, наиболее очевидной причиной является то, что /= 2
, вероятно, оптимизирован как >>=1
, и многие процессоры имеют очень быструю операцию переключения. Но даже если процессор не имеет операции сдвига, целочисленное деление быстрее, чем деление с плавающей запятой.
Изменить: ваше перемещение может отличаться от приведенного выше выражения "целочисленное деление быстрее, чем с плавающей запятой". Приведенные ниже комментарии показывают, что современные процессоры определили приоритет оптимизации разделения fp над целым делением. Поэтому, если кто-то ищет наиболее вероятную причину ускорения, о котором спрашивает этот вопрос нити, тогда оптимизация /=2
компилятора как >>=1
будет лучшим 1-м местом для поиска.
В несвязанной заметке, если n
нечетно, выражение n*3+1
всегда будет четным. Поэтому нет необходимости проверять. Вы можете изменить эту ветвь на
{
n = (n*3+1) >> 1;
count += 2;
}
Таким образом, вся инструкция будет
if (n & 1)
{
n = (n*3 + 1) >> 1;
count += 2;
}
else
{
n >>= 1;
++count;
}
DIV r32
(32-разрядное целое число без знака) или DIV r64
(намного более медленное 64-разрядное целое число без знака). Специально для пропускной способности деление FP выполняется намного быстрее (одиночная мера вместо микрокодирования и частично конвейерная обработка), но задержка также лучше.
div r64
равен 36 моп, задержка 32-96c и один на пропускную способность 21-74c. Skylake обладает еще более высокой пропускной способностью деления FP (конвейерная передача по одному на 4c с не намного лучшей задержкой), но не намного быстрее целочисленного div. То же самое и в семействе AMD Bulldozer: DIVSD - 1M-op, задержка 9-27c, по одному на пропускную способность 4.5-11c. div r64
равен 16M-ops, задержке 16-75c, по одному на пропускную способность 16-75c.
Вы не опубликовали код, сгенерированный компилятором, поэтому здесь есть некоторые догадки, но даже не увидев его, можно сказать, что это:
test rax, 1
jpe even
... имеет 50% -ный шанс неверно предсказать ветку, и это будет дорого.
Компилятор почти наверняка выполняет оба вычисления (что нелепо больно больше, поскольку div/mod имеет довольно длинную задержку, поэтому multiply-add является "бесплатным" ) и следует за CMOV. Конечно, у него есть нулевой процент вероятности ошибочного прогноза.
Для проблемы Collatz вы можете значительно повысить производительность, кэшируя "хвосты". Это компромисс между временем и памятью. См.: memoization (https://en.wikipedia.org/wiki/Memoization). Вы также можете изучить динамические программные решения для других компромиссов времени и памяти.
Пример реализации python:
import sys
inner_loop = 0
def collatz_sequence(N, cache):
global inner_loop
l = [ ]
stop = False
n = N
tails = [ ]
while not stop:
inner_loop += 1
tmp = n
l.append(n)
if n <= 1:
stop = True
elif n in cache:
stop = True
elif n % 2:
n = 3*n + 1
else:
n = n // 2
tails.append((tmp, len(l)))
for key, offset in tails:
if not key in cache:
cache[key] = l[offset:]
return l
def gen_sequence(l, cache):
for elem in l:
yield elem
if elem in cache:
yield from gen_sequence(cache[elem], cache)
raise StopIteration
if __name__ == "__main__":
le_cache = {}
for n in range(1, 4711, 5):
l = collatz_sequence(n, le_cache)
print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))
print("inner_loop = {}".format(inner_loop))
Как общий ответ, не специально предназначенный для этой задачи: во многих случаях вы можете значительно ускорить любую программу, сделав улучшения на высоком уровне. Подобно вычислению данных один раз вместо нескольких раз, избегая ненужной работы полностью, используя кеши наилучшим образом и так далее. Эти вещи намного проще делать на языке высокого уровня.
Написание кода ассемблера можно улучшить, что делает оптимизирующий компилятор, но это тяжелая работа. И как только это будет сделано, ваш код намного сложнее изменить, поэтому гораздо сложнее добавить алгоритмические улучшения. Иногда у процессора есть функциональность, которую вы не можете использовать с языка высокого уровня, встроенная сборка часто бывает полезной в этих случаях и по-прежнему позволяет использовать язык высокого уровня.
В проблемах Эйлера, большую часть времени вам удается, создавая что-то, обнаруживая, почему это происходит медленно, создавая что-то лучшее, обнаруживая, почему оно медленное, и так далее и так далее. Это очень, очень сложно, используя ассемблер. Лучший алгоритм на половине возможной скорости обычно будет выполнять худший алгоритм на полной скорости, а получение полной скорости в ассемблере не является тривиальным.
gcc -O3
сделал код, который был в пределах 20% от оптимального для Haswell, для этого точного алгоритма. (Получение этих ускорений было основной целью моего ответа только потому, что это то, что задал вопрос, и имеет интересный ответ, а не потому, что это правильный подход.) Значительно большие ускорения были получены из преобразований, которые компилятор будет крайне маловероятно искать как откладывание правых сдвигов или выполнение 2 шагов за раз. Гораздо большие ускорения, чем это может быть в памятных / поисковых таблицах. Все еще исчерпывающее испытание, но не чисто грубая сила.
Из комментариев:
Но этот код никогда не останавливается (из-за целочисленного переполнения)!?! Ив Дауст
Для многих чисел он не будет переполняться.
Если он будет переполняться - для одного из этих неудачных начальных семян число переполнения, скорее всего, сходится к 1 без другого переполнения.
Все еще возникает интересный вопрос: есть ли какое-то количество циклов переполнения цикла?
Любая простая конечная сходящаяся серия начинается с мощности двух значений (очевидно, достаточно?).
2 ^ 64 будет переполняться до нуля, т.е. undefined бесконечный цикл по алгоритму (заканчивается только 1), но наиболее оптимальное решение в ответе закончится из-за shr rax
, производя ZF = 1.
Можем ли мы произвести 2 ^ 64? Если начальный номер 0x5555555555555555
, это нечетное число, следующее число равно 3n + 1, что равно 0xFFFFFFFFFFFFFFFF + 1
= 0
. Теоретически в undefined состоянии алгоритма, но оптимизированный ответ johnfound восстановится, выйдя на ZF = 1. cmp rax,1
Питера Кордеса закончится бесконечным циклом (вариант QED 1, "дешево" через номер undefined 0
).
Как насчет некоторого более сложного числа, которое создаст цикл без 0
?
Честно говоря, я не уверен, моя математическая теория слишком туманна, чтобы получить какую-либо серьезную идею, как справиться с ней серьезно. Но интуитивно я бы сказал, что серия будет сходиться к 1 для каждого числа: 0 < число, так как формула 3n + 1 будет медленно превращать каждый не-2 простой коэффициент исходного числа (или промежуточного) в некоторую степень 2, рано или поздно. Поэтому нам не нужно беспокоиться о бесконечном цикле для оригинальных серий, только переполнение может помешать нам.
Итак, я просто поместил несколько цифр в лист и посмотрел на 8-битные усеченные номера.
Есть три значения, переполненные до 0
: 227
, 170
и 85
(85
, идущие непосредственно на 0
, другие два продвигаются к 85
).
Но нет значения, создающего циклическое переполнение семени.
Как ни странно, я сделал чек, который является первым номером, который страдает от 8-битного усечения, и уже имеет значение 27
! Он достигает значения 9232
в правильных не усеченных рядах (первое усеченное значение 322
на 12-м шаге), а максимальное значение, достигнутое для любого из входных номеров 2-255 не усеченным способом, равно 13120
( для самого 255
) максимальное число шагов для схождения на 1
составляет около 128
(+ -2, не уверен, что "1" подсчитывается и т.д.).
Интересно (для меня) число 9232
является максимальным для многих других исходных чисел, что так особенного в этом?: -O 9232
= 0x2410
... хм.. не знаю.
К сожалению, я не могу получить глубокое понимание этой серии, почему она сходится и каковы последствия усечения их для k бит, но с условием завершения cmp number,1
можно, конечно, поставить алгоритм в бесконечный цикл с определенное значение ввода, заканчивающееся как 0
после усечения.
Но значение 27
переполняется для 8-битного случая - это своего рода предупреждение, похоже, если вы подсчитаете количество шагов для достижения значения 1
, вы получите неправильный результат для большинства чисел из общего числа k- бит набора целых чисел. Для 8-битных целых чисел 146 чисел из 256 пострадали от серии путем усечения (некоторые из них могут по-прежнему попадать в правильное количество шагов случайно, возможно, я слишком ленив, чтобы проверить).
27
с усечением 8b выглядит следующим образом: 82 41 124 62 31 94 47 142 71 214 107 66 (усечено) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (остальная часть работает без усечения). Я не понимаю тебя, прости. Он никогда не остановится, если усеченное значение будет равно некоторым из ранее достигнутых в текущем продолжающемся ряду, и я не могу найти такое значение против усечения k-бит (но я также не могу понять теорию Math, почему это выдерживает усечение 8/16/32/64 бит, просто интуитивно я думаю, что это работает).
Простой ответ:
выполнение MOV RBX, 3 и MUL RBX дорого; просто ADD RBX, RBX дважды
ADD 1, вероятно, быстрее, чем INC здесь
MOV 2 и DIV очень дороги; просто сдвиньте правую
64-разрядный код обычно заметно медленнее 32-битного кода, а проблемы с выравниванием более сложны; с такими небольшими программами вы должны их упаковывать, чтобы вы выполняли параллельные вычисления, чтобы иметь возможность быстрее, чем 32-разрядный код.
Если вы создаете сборку для своей программы на С++, вы можете увидеть, как она отличается от вашей сборки.
mul rbx
на процессоре Haswell OP составляет 2 моп с задержкой 3c (и 1 на тактовую пропускную способность). imul rcx, rbx, 3
- только 1 моп, с той же задержкой 3c. Две инструкции ДОБАВИТЬ будут 2 мопа с задержкой 2с.
-S
чтобы получить сборку, сгенерированную компилятором. Компилятор достаточно умен, чтобы понять, что модуль выполняет деление одновременно.