Иногда возникает странное ощущение: железо стало безумно быстрым, процессоры научились выполнять миллиарды операций в секунду, памяти стало больше, чем раньше было дискового пространства. Но почему-то программы всё равно тормозят. Открываешь простой веб-интерфейс — и ноутбук начинает шуметь вентиляторами. Запускаешь приложение для заметок — и оно ест полгигабайта RAM. Я долго думал, откуда это ощущение. Потом начал копаться: читать дизассемблер, смотреть профилировщики, запускать микробенчмарки. И постепенно стало понятно, что дело не в железе. Дело в том, как мы пишем код.
Предлагаю поговорить о кэше процессора, о том, сколько стоит случайный доступ к памяти, о том, как CPU исполняет ваш цикл.
Когда железо стало быстрее, а программы — тяжелее
Иногда полезно посмотреть на старый софт. Например, текстовый редактор из 90-х запускался почти мгновенно на компьютере с десятками мегабайт памяти. Сегодня аналогичная программа может занимать сотни мегабайт RAM и стартовать несколько секунд. Причём функционально она делает примерно то же самое.
Почему так происходит?
Проблема в том, что современная разработка стала очень абстрактной. Мы пишем код поверх десятков слоёв библиотек. Каждый слой добавляет удобство, но также добавляет накладные расходы.
Типичная современная программа выглядит примерно так:
приложение → фреймворк → runtime → виртуальная машина → системные библиотеки → ОС → драйверы → железо.
Каждый уровень скрывает детали предыдущего. И вот в этом месте возникает интересный вопрос. Когда вы пишете простой цикл, вы вообще представляете, что происходит внутри процессора? Например, такой код выглядит совершенно безобидно.
// C++ #include <vector> #include <iostream> int main() { std::vector<int> data(10000000); for (int i = 0; i < data.size(); i++) { data[i] = i; } long long sum = 0; for (int i = 0; i < data.size(); i++) { sum += data[i]; } std::cout << sum << std::endl; }
На первый взгляд всё нормально. Но если начать анализировать на уровне CPU, то окажется, что основное время здесь уходит вовсе не на арифметику. Процессор большую часть времени ждёт память. И это подводит нас к следующей проблеме.
Память стала главным узким местом
Есть один факт, который сильно удивляет новичков. Доступ к памяти может быть в сотни раз медленнее арифметической операции.
Примерные задержки современных систем:
регистры — примерно 1 цикл
L1 cache — около 4 циклов
L2 cache — около 12 циклов
L3 cache — около 40 циклов
оперативная память — 200–300 циклов
То есть если данные не попали в кэш, процессор фактически простаивает. И вот здесь начинается самое интересное. Большинство программистов вообще не думают о кэш-локальности.
Давайте рассмотрим пример.
// C++ struct Point { double x; double y; double z; }; std::vector<Point> points(10'000'000); double sum = 0; for (auto &p : points) { sum += p.x; }
Теперь другой вариант:
// C++ std::vector<double> xs(10'000'000); std::vector<double> ys(10'000'000); std::vector<double> zs(10'000'000); double sum = 0; for (auto &x : xs) { sum += x; }
На уровне алгоритма разницы почти нет. Но на уровне CPU второй вариант может работать быстрее. Причина — плотность данных в кэше. В первом случае процессор читает ненужные поля. Во втором — данные лежат компактно. Когда я впервые увидел разницу в бенчмарке, она была почти в 3 раза. И честно говоря, я тогда немного офигел.
Как фреймворки прячут реальную цену операций
Есть ещё одна вещь, о которой редко говорят. Современные фреймворки скрывают стоимость операций. Возьмём простой пример на Python.
# Python data = [i for i in range(10_000_000)] result = sum(map(lambda x: x * 2, data))
Код выглядит аккуратно. Но внутри происходит довольно много работы. Создаются объекты, вызываются функции, выполняется динамическая диспетчеризация. И если посмотреть профилировщик, можно увидеть, что львиная доля времени уходит не на математику, а на инфраструктуру языка. Иногда люди говорят: компилятор всё оптимизирует. Это правда… но только частично. Компилятор не может оптимизировать архитектуру программы. Он не может исправить плохую работу с памятью. Он не может убрать лишние абстракции, если они уже встроены в дизайн. Иногда достаточно заменить одну конструкцию — и программа начинает работать в несколько раз быстрее. Но для этого нужно понимать, что происходит под капотом.
Почему большинству разработчиков это больше не интересно
Самый странный момент во всей этой истории — отношение индустрии. Если посмотреть вакансии, почти нигде не требуют понимания архитектуры CPU. Зато требуют знание десятка фреймворков. Почему так произошло? Ответ довольно простой. Экономика разработки изменилась. Раньше компьютеры были дорогими, а программисты — относительно дешевыми. Сегодня наоборот. Время разработчика стоит дороже железа. Поэтому бизнесу проще купить ещё один сервер, чем тратить неделю на оптимизацию. Но у этого подхода есть побочный эффект. Постепенно теряется культура эффективного программирования. Я однажды видел backend-сервис, который занимал 8 гигабайт RAM и обрабатывал довольно простые запросы. После небольшого рефакторинга и исправления структуры данных потребление памяти упало до 800 мегабайт. Ничего сверхсложного. Просто немного внимания к деталям. И знаете что самое смешное? Эта оптимизация заняла пару дней.
Что происходит внутри CPU, когда вы запускаете код
Давайте немного заглянем внутрь процессора. Совре��енный CPU — это не просто последовательный исполнитель инструкций. Он выполняет их параллельно. Есть конвейеры есть предсказание ветвлений, есть переупорядочивание инструкций. Но всё это работает только если данные приходят достаточно быстро.
Рассмотрим простой пример.
// Rust fn sum_array(data: &[i32]) -> i64 { let mut sum = 0; for i in 0..data.len() { sum += data[i] as i64; } sum }
Если массив лежит последовательно в памяти, CPU может читать его очень эффективно. Но если данные разбросаны по памяти, производительность резко падает. Процессор начинает ждать память. Конвейер простаивает. Предсказатель ветвлений тоже начинает ошибаться. И в итоге программа становится в разы медленнее. Иногда это разница между миллисекундами и секундами.
Так стоит ли вообще переживать из-за производительности
Хороший вопрос. На самом деле — не всегда. Есть много задач, где производительность действительно не критична. Но есть одна проблема. Когда разработчики перестают понимать, как работает железо, они начинают писать код, который масштабируется плохо. А это уже становится проблемой на больших системах. Иногда простая архитектурная ошибка может стоить компании тысяч серверов. Поэтому мне кажется важным хотя бы иногда задавать себе простые вопросы. Что делает этот код на уровне CPU? Как он работает с памятью? Можно ли сделать проще?
И да, возможно это звучит немного олдскульно. Но честно говоря, копаться в таких вещах довольно интересно. Иногда один профилировщик может научить больше, чем десять книг по программированию.