it-roy-ru.com

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

Я написал эти два решения для Project Euler 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++ имеет модуль каждого термина и деление каждого четного термина, где Assembly - это только одно деление на четное условие.

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

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

Моя система: 64-битный Linux на 1,4 ГГц Intel Celeron 2955U (микроархитектура Haswell).

782
jeffer son

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

См. Руководство по оптимизации сборки Agner Fog , чтобы узнать, как написать эффективный asm. У него также есть таблицы инструкций и руководство по микроархам для конкретных деталей для конкретных процессоров. Смотрите также x86 вики-тэг для получения дополнительной ссылки.

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

Обычно вы можете позволить компилятору делать свое дело, особенно если вы пытаетесь написать C++, который может эффективно компилироваться. Также смотрите быстрее ли сборка, чем скомпилированные языки? . Один из ответов содержит ссылки на эти аккуратные слайды , показывающие, как различные компиляторы 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 делает то же самое беззнаковое деление: это 1 моп, с задержкой 1 с и может работать 2 за такт.

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


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

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

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


Помогаем компилятору

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

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

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

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

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

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

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

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


Кстати, gcc's оптимизированный вывод 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 циклов за итерацию, узкое место задержки. Внеочередное выполнение позаботится обо всем остальном параллельно с этим (теоретически: я не тестировал счетчики производительности, чтобы увидеть, действительно ли он работает на 5c/iter).

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

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

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


Бить компилятор

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

Он также может сохранить все инструкции MOV, и TEST: SHR устанавливает CF = сдвинутый бит, поэтому мы можем использовать cmovc вместо test/cmovz.

 ### Hand-optimized version of what gcc does
.L9:                       #do{
    lea     rcx, [rax+1+rax*2] # rcx = 3*n + 1
    shr     rax, 1         # n>>=1;    CF = n&1 = n%2
    cmovc   rax, rcx       # n= (n&1) ? 3*n+1 : n/2;
    inc     edx            # ++count;
    cmp     rax, 1
    jne     .L9            #}while(n!=1)

Посмотрите ответ @ johnfound для другого хитрого трюка: удалите CMP, ответвляя на результат флага SHR, а также используйте его для CMOV: ноль, только если n было 1 (или 0) для начала. (Забавный факт: SHR с количеством! = 1 на Nehalem или более раннем вызывает остановку, если вы читаете результаты флага . Вот как они сделали это однократным. Специальное кодирование shift-by-1 прекрасно , хоть.)

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

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

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

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

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


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

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

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


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

Я думаю, что лучшая стратегия обнаружения "липкого" 1 - это маскировать вектор всех единиц, которые вы добавляете для увеличения счетчика. Итак, после того, как вы увидели 1 в элементе, вектор приращения будет иметь ноль, а + = 0 - это неоперация.

Непроверенная идея для ручной векторизации

# starting with YMM0 = [ n_d, n_c, n_b, n_a ]  (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1):  increment vector
# ymm5 = all-zeros:  count vector

.inner_loop:
    vpaddq    ymm1, ymm0, xmm0
    vpaddq    ymm1, ymm1, xmm0
    vpaddq    ymm1, ymm1, set1_epi64(1)     # ymm1= 3*n + 1.  Maybe could do this more efficiently?

    vprllq    ymm3, ymm0, 63                # shift bit 1 to the sign bit

    vpsrlq    ymm0, ymm0, 1                 # n /= 2

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

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

Он также помещает tzcnt (3c для Haswell и более поздних) на критический путь, поэтому он значительно удлиняет общую задержку в цепочке зависимостей, переносимых циклами. Тем не менее, он устраняет необходимость в CMOV или для подготовки регистра, содержащего n>>1. @ Ответ Veedrac преодолевает все это, откладывая tzcnt/shift для нескольких итераций, что очень эффективно (см. Ниже).

Мы можем безопасно использовать BSF или TZCNT взаимозаменяемо, потому что n никогда не может быть нулевым в этой точке. Машинный код TZCNT декодируется как BSF на процессорах, которые не поддерживают BMI1. (Бессмысленные префиксы игнорируются, поэтому REP BSF работает как BSF).

TZCNT работает намного лучше, чем BSF на процессорах AMD, которые его поддерживают, поэтому рекомендуется использовать REP BSF, даже если вам не нужно устанавливать ZF, если вход равен нулю, а не выходу. Некоторые компиляторы делают это, когда вы используете __builtin_ctzll даже с -mno-bmi.

Они выполняют то же самое на процессорах Intel, поэтому просто сохраните байт, если это все, что имеет значение. TZCNT в Intel (pre-Skylake) все еще имеет ложную зависимость от якобы выходного операнда только для записи, как и BSF, для поддержки недокументированного поведения, при котором BSF с input = 0 оставляет свое назначение неизменным. Так что вам нужно обойти это, если не оптимизировать только для Skylake, так что нечего извлекать из дополнительного байта REP. (Intel часто выходит за рамки того, что требуется руководству x86 ISA, чтобы избежать взлома широко используемого кода, который зависит от того, чего он не должен, или который запрещен задним числом. Например, Windows 9x предполагает нет спекулятивной предварительной выборки записей TLB , что было безопасно при написании кода, до того, как Intel обновила правила управления TLB .)

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


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

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

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

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


@ код Veedrac

Я получил очень небольшое ускорение от смещения вправо настолько, насколько мы знаем , что нужно сделать, и проверки для продолжения цикла. С 7,5 с для предела = 1e8 до 7,275 с на Core2Duo (Merom) с коэффициентом развертывания 16.

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

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

1823
Peter Cordes

Утверждать, что компилятор 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 .

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

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

96
johnfound

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

PPS. Если вам нужно количество операций: в каждой итерации мы делаем ровно восемь делений на два и переменное количество (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 действительно не велико.

21
gnasher729

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

  • [первая "гипотеза" была окончательно опровергнута @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. Сдвиг вправо первого результата дает нам именно то, что мы хотим.

    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 от Питера Кордеса на Godbolt . (примечание редактора: извините за добавление моих материалов в ваш ответ, но мой ответ достиг предела 30 тыс. символов по ссылкам Godbolt + текст!)

18
hidefromkgb

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

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

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

Для решения проблемы Коллатца вы можете значительно повысить производительность, кэшируя "хвосты". Это компромисс между временем и памятью. Смотрите: памятка ( 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 __== "__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))
5
Emanuel Landeholm

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

Edit: Ваш показатель может варьироваться в зависимости от выражения "целочисленное деление быстрее, чем деление с плавающей точкой". Комментарии ниже показывают, что современные процессоры отдают предпочтение оптимизации деления 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;
}
4
Dmitry Rubanovich

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

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

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

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

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

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

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

Можем ли мы произвести 2 ^ 64? Если начальный номер 0x5555555555555555, это нечетное число, следующий номер - 3n + 1, то есть 0xFFFFFFFFFFFFFFFF + 1 = 0. Теоретически в неопределенном состоянии алгоритма, но оптимизированный ответ johnfound восстановится, выйдя на ZF = 1. cmp rax,1 Питера Кордеса заканчивается бесконечным циклом (вариант QED 1, "дешево" через неопределенное число 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 повлияли на ряды усечением (некоторые из них могут все же случайно попасть на правильное число шагов, может быть, мне лень это проверять).

4
Ped7g

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

test rax, 1
jpe even

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

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

4
Damon

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

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

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

3
gnasher729