Avatar ·

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

Всем привет! Я изучаю C++ и ассемблер, и решил проверить гипотезу Коллатца (3n+1) для больших чисел. Написал две версии кода: одну на чистом C++ с оптимизациями, а вторую — с ручной ассемблерной вставкой для x86-64. Ожидал, что ассемблер будет быстрее, но получил обратный результат.

Вот мой C++ код (скомпилирован с -O2 в GCC):

#include <iostream>
#include <cstdint>

int collatz_steps(uint64_t n) {
    int steps = 0;
    while (n != 1) {
        if (n % 2 == 0) {
            n /= 2;
        } else {
            n = 3 * n + 1;
        }
        steps++;
    }
    return steps;
}

int main() {
    uint64_t max_n = 1000000;
    for (uint64_t i = 1; i <= max_n; ++i) {
        collatz_steps(i);
    }
    return 0;
}

А вот версия с ассемблерной вставкой (использую MSVC风格的__asm):

int collatz_asm(uint64_t n) {
    int steps = 0;
    __asm {
        mov rax, n
        xor ecx, ecx        ; steps = 0
    loop_start:
        cmp rax, 1
        je done
        test rax, 1
        jz even
        ; odd: n = 3*n + 1
        mov rbx, 3
        mul rbx
        inc rax
        inc ecx
        jmp loop_start
    even:
        shr rax, 1
        inc ecx
        jmp loop_start
    done:
        mov steps, ecx
    }
    return steps;
}

Запускал на Intel Core i7-10750H. C++ версия выполняется за ~0.8 секунды, а ассемблерная — за ~2.1 секунды. Почему так? Ведь ассемблер даёт полный контроль над кодом. Может, я что-то упустил в оптимизации?

<<>>

Почему компилятор C++ побеждает ручной ассемблер

Ваш эксперимент — классический пример того, что современные компиляторы (GCC, Clang, MSVC) генерируют код, который часто превосходит наивный ручной ассемблер. Вот основные причины:

1. Компилятор применяет продвинутые оптимизации

С флагом -O2 (или -O3) GCC делает следующее:

  • Раскрутка циклов (loop unrolling) — уменьшает количество проверок и переходов.
  • Векторизация — использует SSE/AVX инструкции для обработки нескольких чисел одновременно.
  • Удаление мёртвого кода — если результат не используется, компилятор может полностью убрать вычисления.
  • Инлайнинг — встраивает функцию collatz_steps прямо в цикл main, устраняя накладные расходы на вызов.

2. Проблемы вашего ассемблерного кода

Ваша ручная вставка содержит несколько узких мест:

  • Неправильное использование mul rbx — инструкция MUL умножает rax на rbx и сохраняет 128-битный результат в rdx:rax. После умножения rdx может быть не равен нулю, что ломает последующие операции. Нужно использовать imul rax, rax, 3 или обнулять rdx перед div/mul.
  • Избыточные переходы — jmp loop_start после каждой ветки создаёт дополнительные циклы процессора. Компилятор перестраивает код так, чтобы уменьшить количество безусловных переходов.
  • Отсутствие предсказания ветвлений — ваш код не оптимизирован для branch predictor. Компилятор использует CMOV (conditional move) или другие трюки, чтобы избежать дорогих промахов предсказания.

3. Улучшенная версия ассемблера (сравнимая с C++)

Вот исправленный вариант, который использует imul и CMOV:

int collatz_asm_fast(uint64_t n) {
    int steps = 0;
    __asm {
        mov rax, n
        xor ecx, ecx
    loop_start:
        cmp rax, 1
        je done
        ; проверка чётности
        mov rdx, rax
        and edx, 1          ; младший бит
        ; если чётное: n/2, иначе: 3n+1
        mov rbx, rax
        shr rbx, 1          ; n/2 (чётный случай)
        imul rax, rax, 3
        inc rax             ; 3n+1 (нечётный случай)
        ; выбираем правильный результат
        test edx, edx
        cmovz rax, rbx      ; если чётное (ZF=1), берём n/2
        inc ecx
        jmp loop_start
    done:
        mov steps, ecx
    }
    return steps;
}

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

  • Использует битовые трюки — например, заменяет n % 2 на n & 1.
  • Применяет конвейеризацию и выполнение вне порядка.
  • Генерирует код с учётом микроархитектуры конкретного процессора (например, для Skylake или Zen).

4. Вывод

Писать ассемблер вручную имеет смысл только в трёх случаях:

  • Вы используете специфические инструкции, которые компилятор не генерирует (например, POPCNT, SHA).
  • Вы работаете на экзотическом железе без компилятора.
  • Вы профилировали код и точно знаете, что компилятор сделал плохо — но это редкость.

В вашем случае доверьтесь компилятору: он знает о процессоре больше, чем среднестатистический программист. Для гипотезы Коллатца можно ещё ускорить C++ код, используя таблицы предвычислений или SIMD, но это уже другая история.

<<>>

Дополнение: как профилировать и улучшить C++ код

Первый ответ отлично объяснил, почему компилятор быстрее. Я добавлю несколько практических советов для тех, кто хочет выжать максимум из C++.

1. Используйте -O3 -march=native

Флаг -march=native включает все инструкции, доступные на вашем процессоре (AVX2, BMI и т.д.). Для гипотезы Коллатца это даёт прирост 10-20%.

2. Примените битовые операции вручную

Компилятор часто сам делает такие оптимизации, но явный код помогает:

int collatz_fast(uint64_t n) {
    int steps = 0;
    while (n != 1) {
        if (n & 1) {          // нечётное
            n = (n * 3 + 1) >> 1; // сразу делим на 2
            steps += 2;
        } else {
            n >>= 1;
            steps++;
        }
    }
    return steps;
}

Здесь мы объединили две операции для нечётного числа: 3n+1 всегда чётное, поэтому можно сразу разделить на 2.

3. Используйте __builtin_expect (C++20: [[likely]])

Подскажите компилятору, что числа чаще чётные:

if (__builtin_expect(n & 1, 0)) { // редко
    n = 3 * n + 1;
} else {
    n /= 2;
}

Это улучшает предсказание ветвлений на 5-10%.

4. Профилируйте с perf или valgrind

Только измерения покажут реальные узкие места. Например, вы можете обнаружить, что большую часть времени тратится на проверку n != 1, а не на сами вычисления.

Глав