Код C++ для тестирования гипотезы Collatz быстрее, чем рукописная сборка-почему?

я написал эти два решения для Проект Эйлер Q14, в сборке и на C++. Они же одинаковый подход грубой силы для тестирования Коллатца. Решение для сборки было собрано с помощью

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

C++, 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;
}

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

код C++ имеет модуль каждого члена и деление каждого четного члена, где сборка-это только одно деление на четный член.

но сборка занимает в среднем 1 секунду дольше, чем решение на C++. Почему так? Я спрашиваю главным образом из любопытства.

времени выполнения

моя система: 64 бит Linux на 1.4 GHz Intel Celeron 2955U (микроархитектура Haswell).

11 ответов


если вы считаете, что 64-битная инструкция DIV-хороший способ разделить на два, то неудивительно, что вывод ASM компилятора побил ваш рукописный код, даже с -O0 (скомпилируйте быстро, без дополнительной оптимизации и сохраните / перезагрузите в память после / перед каждым оператором C, чтобы отладчик мог изменять переменные).

посмотреть руководство по оптимизации сборки Agner Fog чтобы узнать, как писать эффективный asm. Он также имеет таблицы инструкций и руководство microarch для конкретных деталей для конкретные процессоры. См. также x86 tag wiki для получения дополнительных ссылок perf.

см. Также этот более общий вопрос об избиении компилятора с помощью рукописного asm:является ли встроенный язык сборки медленнее, чем собственный код c++?. TL: DR: да, если вы делаете это неправильно (например, этот вопрос).

обычно вы прекрасно позволяете компилятору делать свое дело, особенно если вы попробуйте написать C++, который может эффективно компилироваться. Также смотрите is сборка быстрее, чем скомпилированные языки?. Один из ответов ссылается на эти аккуратные горки показывает, как различные компиляторы C оптимизируют некоторые действительно простые функции с помощью классных трюков.


even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

На Intel Haswell,div r64 36 uops, с задержка 32-96 циклов, и объем одного в 21-74 цикла. (Плюс 2 uops для настройки RBX и нулевого RDX, но выполнение вне заказа может запустить их раньше). High-uop-count инструкции, такие как DIV, микрокодируются, что также может вызвать узкие места на переднем конце. в этом случае латентность является наиболее релевантным фактором, поскольку она является частью цепочки зависимостей с циклом.

shr rax, 1 делает то же самое беззнаковое деление: это 1 uop, с задержкой 1c, и может побежать 2 в такт.

для сравнения, 32-битное деление быстрее, но все равно ужасно против сдвигов. idiv r32 9 uops, задержка 22-29c, и один в Пропускная способность 8-11c на Haswell.


как вы можете видеть, глядя на gcc -O0 выход asm (проводник компилятора Godbolt), он использует только инструкции смены. лязг!--5--> компилируется наивно, как вы думали, даже используя 64-битный IDIV дважды. (При оптимизации компиляторы используют оба выхода IDIV, когда источник выполняет деление и модуль с теми же операндами, если они вообще используют IDIV)

GCC не имеет совершенно наивный режим;он всегда преобразуется через GIMPLE, что означает, что некоторые "оптимизации" не могут быть отключены. Это включает в себя распознавание деления на константу и использование сдвигов (мощность 2) или мультипликативный обратный с фиксированной точкой (не мощность 2), чтобы избежать IDIV (см. div_by_13 в приведенной выше ссылке godbolt).

gcc -Os (оптимизация по размеру) тут используйте IDIV для разделения non-power-of-2, к сожалению, даже в тех случаях, когда мультипликативный обратный код лишь немного больше, но намного быстрее.


помощь компилятору

(резюме для этого случая: используйте uint64_t n)

прежде всего, интересно только посмотреть на оптимизированный вывод компилятора. (-O3). -O0 скорость в принципе бессмысленно.

посмотрите на выход asm (на Godbolt или см. Как удалить " шум " из сборки GCC / clang выход?). Когда компилятор не делает оптимальный код в первую очередь:написание источника C/C++ таким образом, чтобы компилятор делал лучший код, как правило, лучший подход. Вы должны знать asm и знать, что эффективно, но вы применяете это знание косвенно. Компиляторы также являются хорошим источником идей: иногда clang сделает что-то классное, и вы можете вручную удерживать gcc, делая то же самое: см. ответ и то, что я сделал с не развернутый цикл в коде @Veedrac ниже.)

этот подход переносим, и через 20 лет какой-то будущий компилятор может скомпилировать его на все, что эффективно на будущем оборудовании (x86 или нет), возможно, используя новое расширение ISA или авто-векторизацию. Рукописный x86-64 asm от 15 лет назад, как правило, не будет оптимально настроен для Skylake. например, compare & branch macro-fusion тогда не существовало. что оптимально сейчас для ручной asm для одной микроархитектуры может не быть оптимально для других текущих и будущих процессоров. комментарии на ответ @johnfound это обсудите основные различия между AMD Bulldozer и Intel Haswell, которые оказывают большое влияние на этот код. Но теоретически,g++ -O3 -march=bdver3 и g++ -O3 -march=skylake поступит правильно. (Или -march=native.) Или -mtune=... просто настроить, без использования инструкций, которые другие процессоры могут не поддерживать.

я чувствую, что руководство компилятором asm хорошо для текущего процессора, о котором вы заботитесь не должно быть проблемой для будущих компиляторов. Они, надеюсь, лучше, чем текущие компиляторы, находят способы преобразования кода и могут найти способ, который работает для будущих процессоров. Несмотря на это, будущий x86, вероятно, не будет ужасным во всем, что хорошо на текущем x86, и будущий компилятор будет избегать каких-либо ошибок asm при реализации чего-то вроде движения данных из вашего источника C, если он не видит что-то лучше.

написанный от руки asm черный ящик для оптимизатор, поэтому постоянное распространение не работает, когда inlining делает вход константой времени компиляции. Другие оптимизации также затронуты. Читать https://gcc.gnu.org/wiki/DontUseInlineAsm перед использованием asm. (И избегайте встроенного asm в стиле MSVC: входы / выходы должны проходить через память что добавляет накладные расходы.)

в этом случае: ваш n имеет подписанный тип, и gcc использует последовательность SAR / SHR / ADD, которая дает правильный округление. (IDIV и арифметический сдвиг "круглый" по-разному для отрицательных входов, см. SAR insn set ref ручной ввод). (IDK, если gcc попытался и не смог доказать это n не может быть отрицательным, или что. Signed-overflow-это неопределенное поведение, поэтому оно должно было быть в состоянии.)

вы должны использовать uint64_t n, поэтому он может просто SHR. И поэтому он переносится в системы, где long только 32-разрядный (например, x86-64 Windows).


BTW, ССАГПЗ оптимизация выход 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

внутренний цикл не имеет ветвей, а критический путь цепочки зависимостей с петлей:

  • 3-компонентный LEA (3 цикла)
  • cmov (2 цикла на Haswell, 1c на Broadwell или более поздней версии).

Итого: 5 циклов на итерацию, задержка узкое место. Выполнение вне порядка заботится обо всем остальном параллельно с этим (теоретически: я не тестировал счетчики perf, чтобы увидеть, действительно ли он работает на 5c/iter).

флаги ввода cmov (произведенный тестом) быстрее производить, чем вход RAX (от LEA->MOV), поэтому он не находится на критическом пути.

аналогично, MOV - >SHR, который производит вход RDI CMOV, находится вне критического пути, потому что он также быстрее, чем LEA. MOV on IvyBridge и позже имеет нулевую задержку (обрабатывается во время регистрации-переименования). (Он по-прежнему занимает uop и слот в конвейере, поэтому он не свободен, просто нулевая задержка). Дополнительный MOV в цепи LEA dep является частью узкого места на других процессорах.

cmp / jne также не является частью критического пути: он не переносится по циклу, потому что зависимости управления обрабатываются с предсказанием ветви + спекулятивным выполнением, в отличие от зависимостей данных на критическом пути.


избиение компилятор

GCC проделал здесь неплохую работу. Он может сохранить один байт кода, используя inc edx вместо add edx, 1, потому что никто не заботится о P4 и его ложных зависимостях для инструкций частичного изменения флага.

он также может сохранить все инструкции MOV и тест: 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 с графом != 1 на Nehalem или ранее вызывает остановку, если Вы читаете результаты флага. Вот как они сделали это один-uop. Однако специальная кодировка shift-by-1 в порядке.)

избегание MOV не помогает с задержкой вообще на Haswell (может ли движение x86 действительно быть "свободным"? Почему я не могу воспроизвести это в все?). Это помогает значительно на процессорах, таких как Intel pre-IvB и AMD Bulldozer-family, где MOV не имеет нулевой задержки. Потерянные инструкции MOV компилятора влияют на критический путь. Комплекс BD-LEA и CMOV имеют более низкую задержку (2c и 1c соответственно), поэтому это большая часть задержки. Кроме того, узкие места пропускной способности становятся проблемой, потому что он имеет только два целочисленных канала ALU. посмотреть @johnfound ответ, где у него есть результаты сроки от процессора AMD.

даже на Haswell эта версия может немного помочь, избегая некоторых случайных задержек, когда некритический uop крадет порт выполнения от одного на критическом пути, задерживая выполнение на 1 цикл. (Это называется конфликтом ресурсов). Он также сохраняет регистр, который может помочь при выполнении нескольких n значения параллельно в чередующемся цикле (см. ниже).

задержка LEA зависит от режима адресации, на Intel SnB-семья ЦПУ. 3c для 3 компонентов ([base+idx+const], что занимает два отдельных добавления), но только 1С с 2 или менее компонентами (одно добавление). Некоторые процессоры (например, Core2) делают даже 3-компонентный LEA в одном цикле, но SnB-семейство этого не делает. Хуже,Intel SnB-family стандартизирует задержки, поэтому нет 2c uops, иначе 3-компонентный LEA будет только 2c, как бульдозер. (3-компонентный LEA также медленнее на AMD, просто не так сильно).

так lea rcx, [rax + rax*2] / inc rcx только задержка 2c, быстрее, чем lea rcx, [rax + rax*2 + 1], на процессорах Intel SnB-family, таких как Haswell. Безубыточность на BD, и хуже на Core2. Это стоит дополнительного uop, который обычно не стоит того, чтобы сохранить задержку 1c, но задержка является основным узким местом здесь, и Haswell имеет достаточно широкий конвейер для обработки дополнительной пропускной способности uop.

ни gcc, icc, ни clang (на godbolt) не использовали выход CF SHR, всегда используя AND или TEST. Глупые компиляторы. : P они большие части сложной машины, но a умный человек часто может победить их в мелких проблемах. (Конечно, если подумать об этом в тысячи-миллионы раз дольше! Компиляторы не используют исчерпывающие алгоритмы для поиска всех возможных способов, потому что это займет слишком много времени при оптимизации большого количества встроенного кода, что они делают лучше всего. Они также не моделируют конвейер в целевой микроархитектуре, по крайней мере, не так подробно, как IACA или другие инструменты статического анализа; они просто используют немного эвристики.)


простое развертывание цикла не поможет; это узкие места цикла на задержке цепи зависимостей с циклом, а не на накладных расходах / пропускной способности цикла. Это означает, что он будет хорошо работать с hyperthreading (или любым другим видом SMT), так как процессор имеет много времени для чередования инструкций из двух потоков. Это означало бы распараллеливание цикла в main, но это нормально, потому что каждый поток может просто проверить ряд n ценности и в результате получается пара целых чисел.

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

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


вы могли бы даже сделать это с SSE packed-сравнить материал, чтобы условно увеличить счетчик для векторных элементов, где n не дошло 1 еще. А затем, чтобы скрыть еще большую задержку реализации условного приращения SIMD, вам нужно будет сохранить больше векторов n значения в воздух. Возможно, стоит только с вектором 256b (4x uint64_t).

я думаю, что лучшая стратегия, чтобы сделать обнаружение 1 "sticky" -это маска вектора всех, которые вы добавляете для увеличения счетчика. Так что после вы видели 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

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

вы можете и должны реализовать это с помощью встроенных, а не рукописных asm.


улучшение Алгоритмики / реализации:

помимо реализации той же логики с более эффективным asm, ищите способы упростить логику или избежать избыточной работы. например, memoize для обнаружения общих окончаний последовательностей. Или даже лучше, посмотрите на 8 трейлинг-бит сразу (ответ гнашера)--66-->

@EOF указывает, что tzcnt (или bsf) могут использоваться для выполнения нескольких n/=2 итерации в один шаг. Это, вероятно, лучше, чем векторизация SIMD, потому что никакая инструкция SSE или AVX не может этого сделать. Он по-прежнему совместим с выполнением нескольких скалярных ns параллельно в разных целочисленных регистрах.

таким образом, цикл может выглядеть так:

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, задержка 2С. (Они имеют входную зависимость от флагов, потому что count=0 означает, что флаги не изменяются. Они обрабатывают это как зависимость данных и принимают несколько UOP, потому что uop может иметь только 2 входа (pre-HSW/BDW в любом случае)). Это тот вид, на который ссылаются люди, жалующиеся на сумасшедший дизайн x86-CISC. Это делает процессоры x86 медленнее, чем они были бы, если бы ISA был разработан с нуля сегодня, даже в основном похожем путь. (т. е. это часть "налога x86", который стоит скорости / мощности.) SHRX/SHLX / SARX (BMI2) - большая победа (1 задержка uop / 1c).

он также помещает tzcnt (3c на Haswell и позже) на критический путь, поэтому он значительно удлиняет общую задержку цепи зависимостей, переносимых циклом. Это устраняет любую потребность в CMOV, или для подготовки реестра холдинг n>>1, хотя. @Veedrac's answer преодолевает все это, откладывая 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, так что нет ничего, чтобы получить от дополнительного байта повторения. (Intel часто выходит за рамки того, что требует руководство x86 ISA, чтобы избежать взлома широко используемого кода, который зависит от чего-то, что он не должен, или что ретроактивно запрещено. например,Windows 9x не предполагает спекулятивной предварительной выборки записей TLB, что было безопасно, когда код был написан,до того, как Intel обновила правила управления TLB.)

в любом случае, LZCNT / TZCNT на Haswell есть тот же false dep, что и POPCNT: см. это Q & A. Вот почему в asm-выходе gcc для кода @Veedrac вы видите его разрыв цепи dep с XOR-обнулением в реестре он собирается использовать в качестве пункта назначения TZCNT, когда он не использует dst=src. Поскольку TZCNT / LZCNT / POPCNT никогда не оставляют свое назначение неопределенным или немодифицированным, эта ложная зависимость от вывода на процессорах Intel является чисто ошибкой / ограничением производительности. Предположительно, это стоит некоторых транзисторов / мощности пусть они ведут себя как другие uops, которые идут в ту же единицу выполнения. Единственный программно-видимый плюс заключается во взаимодействии с другим микроархитектурным ограничением:они могут микро-сплавить операнд памяти с индексированным режимом адресации на Haswell, но на Skylake, где Intel удалила ложную зависимость для lzcnt / TZCNT, они "не ламинируют" индексированные режимы адресации, в то время как POPCNT все еще может микро-плавить любой режим addr.


улучшения идей / кода от другие ответы:

@hidefromkgb ответ имеет хорошее наблюдение, что вы гарантированно сможете сделать одну правую смену после 3n+1. Вы можете вычислить это еще более эффективно, чем просто оставляя проверки между шагами. Однако реализация asm в этом ответе нарушена (это зависит от OF, который не определен после SHRD с count > 1) и медленный: ROR rdi,2 быстрее SHRD rdi,rdi,2, и использование двух инструкций CMOV на критическом пути медленнее чем дополнительный тест, который может выполняться параллельно.

я поставил tidied / improved C (который направляет компилятор для создания лучшего asm) и протестировал+работает быстрее asm (в комментариях ниже C) на Godbolt: см. ссылку в @hidefromkgb ответ. (Этот ответ попал в предел 30K char из больших URL-адресов Godbolt, но короткие ссылки могут гнить и были слишком длинными для слизи.во всяком случае, gl.)

также улучшен вывод-печать для преобразования в строку и сделайте один write() вместо того, чтобы писать по одному символу за раз. Это минимизирует влияние на синхронизацию всей программы с perf stat ./collatz (для записи счетчиков производительности), и я де-запутал некоторые из некритических asm.


код @ Veedrac

я получил очень небольшое ускорение от правого переключения столько, сколько мы знаю необходимо сделать и проверить, чтобы продолжить цикл. От 7.5 s Для предела=1e8 вниз к 7.275 s, на Core2Duo( Merom), с раскатать коэффициент 16.

код + комментарии на Godbolt. Не используйте эту версию с clang; он делает что-то глупое с отложенным циклом. Использование счетчика tmp k и затем добавить его в count потом какой-то лязг, но немного hurts gcc.

см. обсуждение в комментариях: код Veedrac отлично на процессорах с BMI1 (т. е. не Celeron/Pentium)


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

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

(приведенный ниже код 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 это.

в моих тестах (процессор 1 GHz AMD A4-1200) вышеуказанный код примерно в четыре раза быстрее, чем код C++ из вопроса (при компиляции с -O0: госпожа 430 против госпожи 1900), и больше чем два раза быстрее (430 мс против 830 МС) , когда код c++ компилируется с -O3.

выходные данные обеих программ одинаковы: максимальная последовательность = 525 на i = 837799.


для большей производительности: простое изменение заключается в том, что после 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;
    }
}

здесь большой win: если вы посмотрите на самые низкие 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 при a время будет быстрее. Для каждого бита количество записей в таблице удвоится, и я выделяю замедление, когда таблицы больше не вписываются в кэш L1.

PPS. Если вам нужно количество операций: на каждой итерации мы делаем ровно восемь делений на два и переменное число (3n + 1) операций, поэтому очевидным методом подсчета операций будет другой массив. Но мы можем фактически вычислить количество шагов (на основе количества итераций петля.)

мы могли бы немного переопределить проблему: заменить n на (3n + 1) / 2, Если нечетно, и заменить n на n / 2, если даже. Тогда каждая итерация будет делать ровно 8 шагов, но вы можете считать, что обман :-) поэтому предположим, что были R операций n

если мы сделаем цикл до n ≤ 1,000,000 и имеют предварительно вычисленную таблицу, сколько итераций необходимо из любой начальной точки n ≤ 1,000,000, а затем вычисление r, как указано выше, округленное до ближайшего целого числа, даст правильный результат, если s действительно большой.


на довольно несвязанной заметке: больше хаков производительности!

  • [первая "гипотеза" была окончательно развенчана @ShreevatsaR; удалено]

  • при прохождении последовательности, мы можем получить только 3 возможных случая в 2-окрестности текущего элемента N (первым):

    1. [даже] [нечетный]
    2. [странное] [даже]
    3. [даже] [даже]

    перескочить мимо этих 2 элемента означает вычислить (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. Вправо-смещение первого результат дает нам именно то, чего мы хотим.

    В. Д. Е.: (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, Если общая длина последовательности нечетное.

[EDIT:]

давайте переведите это в сборку!

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 Питера Кордеса на Godbolt. (Примечание редактора: извините за то, что мои вещи в вашем ответе, но мой ответ попал в предел 30K char от godbolt links + text!)


программы на C++ переводятся в программы сборки во время генерации машинного кода из исходного кода. Было бы практически неправильно говорить, что сборка медленнее, чем C++. Кроме того, генерируемый двоичный код отличается от компилятора к компилятору. Итак, умный компилятор C++мая производить двоичный код более оптимальный и эффективный, чем код немого ассемблера.

однако я считаю, что ваша методология профилирования имеет определенные недостатки. Ниже приводятся общие руководящие принципы профилирование:

  1. убедитесь, что ваша система находится в нормальном/состоянии ожидания. Остановите все запущенные процессы (приложения), которые вы запустили или интенсивно используете CPU (или опрос по сети).
  2. ваш datasize должен быть больше по размеру.
  3. ваш тест должен выполняться в течение более 5-10 секунд.
  4. Не полагайтесь только на один образец. Выполните тест N раз. Соберите результаты и вычислите среднее или медиану результата.

из комментариев:

но этот код никогда не останавливается (из-за переполнения целого числа) !?! Ив Дауст

для многих номера не переполнения.

если это будет overflow-для одного из этих несчастливых начальных семян число переполнения, скорее всего, будет сходиться к 1 без другого переполнения.

все же это ставит интересный вопрос, есть ли какое-то переполнение-циклическое семя номер?

любая простая конечная сходящаяся серия начинается с мощности двух значений (достаточно очевидно?).

2^64 переполнится до нуля, что является неопределенным бесконечным циклом в соответствии с алгоритмом (заканчивается только 1), но самое оптимальное решение в ответе закончится из-за shr rax производить ZF=1.

можем мы произвести 2^64? Если начальное число 0x5555555555555555, это нечетное число, следующее число тогда 3n+1, которое 0xFFFFFFFFFFFFFFFF + 1 = 0. Теоретически в неопределенных состояние алгоритма, но оптимизированный ответ johnfound восстановится, выйдя на ZF=1. The cmp rax,1 Питера Кордеса закончится бесконечной петлей (QED Вариант 1," cheapo " через undefined 0 номер).

как насчет более сложного числа, которое создаст цикл без 0? Честно говоря, я не уверен, моя математическая теория слишком туманна, чтобы получить какое-либо серьезное представление о том, как с ней справиться. Но интуитивно я бы сказал, что серия будет стремиться к 1 для каждое число: 0

поэтому я просто положил несколько чисел в лист и посмотрел на 8-битные усеченные числа.

есть три значения, переполняющие 0: 227, 170 и 85 (85 идти сразу к 0, две другие продвигаются по направлению 85).

но нет никакого значения, создающего семя циклического переполнения.

как ни странно, я сделал проверку, которая является первым номером, страдающим от 8-битного усечения, и уже есть! Он достигает значения 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 повлияли на ряд усечение (некоторые из них все еще могут случайно попасть в правильное количество шагов, возможно, я слишком ленив, чтобы проверить).


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

test rax, 1
jpe even

... имеет 50% шанс mispredicting ветку, и что выйдет дорого.

компилятор почти наверняка выполняет оба вычисления (что стоит неглубоко больше, так как div/mod довольно длинная задержка, поэтому multiply-add "свободен") и следует за CMOV. Который, конечно, имеет ноль процентов шансов быть mispredicted.


даже не глядя на сборку, наиболее очевидной причиной является то, что /= 2, вероятно, оптимизирован как >>=1 и много процессоров имеют очень быструю деятельность переноса. Но даже если процессор не имеет операции сдвига, целочисленное деление быстрее, чем деление с плавающей запятой.

Edit: ваш milage может варьироваться в инструкции" целочисленное деление быстрее, чем деление с плавающей запятой " выше. Комментарии ниже показывают, что современные процессоры имеют приоритет оптимизации деления fp над целочисленным делением. Поэтому, если кто-то искал наиболее вероятную причину ускорения, о котором спрашивает вопрос этого потока, то оптимизация компилятора /=2 as >>=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;
}

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

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

в задачах Эйлера большую часть времени вам удается что-то построить, найти, почему это медленно, построить что-то лучше, найти, почему это медленно, и так далее и так далее. Это очень, очень сложно использовать ассемблер. Лучший алгоритм на половине возможной скорости обычно побеждает худший алгоритм на полной скорости, и получение полной скорости в ассемблере не тривиально.


для Проблемы Collatz вы можете получить значительное повышение производительности путем кэширования "хвостов". Это время/торговля-память. См.: мемоизация (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))

самый простой ответ:

  • делать MOV RBX, 3 и MUL rbx дорого; просто добавьте RBX, rbx дважды

  • добавить 1, вероятно, быстрее, чем INC здесь

  • MOV 2 и DIV очень дорого; просто сдвиньте вправо

  • 64-битный код обычно заметно медленнее, чем 32-битный код, и проблемы выравнивания сложнее; с небольшими программами, как это, вы должны упаковать их, так что вы делаете параллельное вычисление, чтобы иметь любой шанс быть быстрее, чем 32-битный код

Если вы создадите список сборок для своей программы на C++, вы увидите, чем она отличается от вашей сборки.