Почему 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, а не на сами вычисления.
Глав