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

Я написал эти два решения для 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).

Ответы

Ответ 1

Если вы считаете, что 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

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

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

Всего: 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)

Ответ 2

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

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

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

Ответ 3

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

Ответ 4

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

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

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

    • [even] [нечетный]
    • [нечетный] [даже]
    • [даже] [даже]

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

[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 от Peter Cordes на Godbolt. (примечание редактора: Извините за то, что вы положили мои вещи в свой ответ, но мой ответ попал в предел 30k char из ссылок Godbolt + текст!)

Ответ 5

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

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

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

Ответ 6

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

test rax, 1
jpe even

... имеет 50% -ный шанс неверно предсказать ветку, и это будет дорого.

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

Ответ 7

Даже не глядя на сборку, наиболее очевидной причиной является то, что /= 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;
}

Ответ 8

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

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

Для многих чисел он не будет переполняться.

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

Ответ 9

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

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

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

Ответ 10

Для проблемы 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))

Ответ 11

Простой ответ:

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

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

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

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

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