Вступление: Миф, который живёт десятилетиями
Если вы когда-нибудь заглядывали на Stack Overflow в раздел C++, то наверняка видели вопрос: «Is < faster than <=?». Этот вопрос собрал тысячи просмотров и десятки страниц обсуждений. И неудивительно — тема оптимизации кода будоражит умы программистов всех уровней.
Логика кажется железобетонной: оператор < сравнивает два числа и возвращает результат, а <= делает то же самое, но дополнительно проверяет равенство. Значит, < должен работать быстрее, верно? Особенно это важно для циклов, где операция сравнения выполняется миллионы раз.
Сегодня мы раз и навсегда разберёмся с этим мифом. Вы узнаете:
- Как на самом деле работают операторы на уровне процессора.
- Почему компилятор — ваш лучший друг (или враг, если вы ему не доверяете).
- Когда микрооптимизация имеет смысл, а когда — пустая трата времени.
Как работают операторы < и <= на уровне ассемблера?
Чтобы понять, есть ли разница, нужно заглянуть «под капот» — в то, во что компилятор превращает ваш C++ код. Рассмотрим две простые функции:
bool less_than(int a, int b) {
return a < b;
}
bool less_or_equal(int a, int b) {
return a <= b;
}
Скомпилируем их с помощью g++ (без оптимизаций, флаг -O0). Вот что мы увидим в ассемблерном листинге (x86-64):
less_than(int, int):
cmp edi, esi ; сравнить a и b
setl al ; установить al=1 если a < b (меньше)
ret
less_or_equal(int, int):
cmp edi, esi ; сравнить a и b
setle al ; установить al=1 если a <= b (меньше или равно)
ret
Видите? Единственная разница — инструкции setl (set if less) и setle (set if less or equal). Обе инструкции выполняются за один такт процессора. На современных CPU (Intel Core i7, AMD Ryzen) они имеют идентичную задержку — 1 цикл.
Вывод №1: На уровне машинных инструкций между < и <= нет разницы в производительности. Точка.
А что насчёт циклов? Тут-то разница должна быть!
Чаще всего спор разгорается вокруг циклов. Сравните два подхода:
// Вариант 1: с <
for (int i = 0; i < n; ++i) {
// делаем что-то
}
// Вариант 2: с <=
for (int i = 0; i <= n - 1; ++i) {
// делаем то же самое
}
Интуиция подсказывает: второй вариант делает лишнее вычисление n - 1. Но давайте посмотрим, что происходит после оптимизации компилятором.
Запустим бенчмарк с g++ -O2 для n = 1 000 000 000:
#include <iostream>
#include <chrono>
int main() {
const long long n = 1'000'000'000;
volatile long long sink = 0; // чтобы компилятор не выкинул цикл
auto start = std::chrono::high_resolution_clock::now();
for (long long i = 0; i < n; ++i) {
sink += i;
}
auto end = std::chrono::high_resolution_clock::now();
std::cout << "Время (<): "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
start = std::chrono::high_resolution_clock::now();
for (long long i = 0; i <= n - 1; ++i) {
sink += i;
}
end = std::chrono::high_resolution_clock::now();
std::cout << "Время (<=): "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
return 0;
}
Результаты на моей машине (i7-10750H, Linux, g++ 11.3):
<: 1823 ms<=: 1821 ms
Разница — в пределах погрешности измерений. Практически идентично.
Почему компилятор делает их равными?
Современные компиляторы (g++, clang, MSVC) на уровнях оптимизации -O1, -O2, -O3 выполняют мощные преобразования циклов. Они:
- Заменяют
i <= n-1наi < n(и наоборот), если это безопасно. - Используют индуктивные переменные — компилятор может заменить сравнение с переменной на сравнение с константой.
- Применяют развёртку циклов, что делает единичные сравнения незначимыми.
Более того, многие процессоры имеют конвейерную архитектуру и предсказание ветвлений. Сравнение — настолько дешёвая операция, что её стоимость «растворяется» в общем потоке выполнения.
Вывод №2: Компилятор умнее нас. Доверяйте ему, если только вы не пишете код для очень специфичного железа (микроконтроллеры, старые архитектуры).
Когда разница всё-таки может проявиться?
Есть пара сценариев, где выбор оператора может повлиять на производительность, но они связаны не со скоростью самого сравнения.
1. Целочисленное переполнение
Рассмотрим пример:
unsigned int n = 0;
for (unsigned int i = 0; i <= n - 1; ++i) { // Внимание!
// ...
}
Если n = 0, то n - 1 даёт максимальное значение unsigned int (4294967295). Цикл выполнится 4 миллиарда раз вместо нуля! Это катастрофа по времени, но проблема не в операторе, а в логической ошибке.
Использование < с i < n при n = 0 просто не запустит цикл. Поэтому безопаснее использовать < для счётчиков