Это история о том, как мы несколько недель искали странные скачки latency в продакшене и в итоге уткнулись в поведение кэша процессора. Не в аллокатор, не в GC, не в сеть. В кэш. В статье — реальные эксперименты, код, метрики, гипотезы, которые не подтвердились, и довольно неприятные выводы о том, насколько процессор может быть непредсказуемым, когда система нагружена по-взрослому.

Иногда кажется, что кэш процессора — это просто прозрачная магия. Он ускоряет всё, и если код написан нормально, то проблем быть не должно. Я тоже так думал. Пока в продакшене не начали происходить странные вещи: p99 latency у сервиса стабильно держался около 12 мс, а потом внезапно прыгал до 80–100 мс без видимой причины. CPU не забит, GC молчит, сеть ровная. И самое раздражающее — воспроизвести локально не получалось.
Вы когда-нибудь сталкивались с ситуацией, когда метрики говорят, что всё хорошо, а пользователи пишут, что всё плохо? Вот это как раз такой случай.
Мы начали копать. И чем глубже копали, тем чаще всплывало слово cache miss.
Где всё началось: странные пики и ложные следы
Сервис — C++ backend, highload, много мелких запросов, in-memory данные, минимальная работа с диском. Типичная архитектура: несколько воркеров на ядро, lock-free очереди, шардирование по ключу. Никакой экзотики.
Метрики показали странную картину:
средняя загрузка CPU около 55 процентов
system time не растёт
page faults почти ноль
но p99 прыгает волнами
Сначала грешили на NUMA. Привязали потоки к узлам, проверили memory policy — без изменений. Потом подумали на false sharing. Переписали структуры, добавили выравнивание:
// C++ struct alignas(64) Counter { std::atomic<uint64_t> value; char padding[64 - sizeof(std::atomic<uint64_t>)]; };
Это действительно немного сгладило p95, но p99 остался с теми же шипами.
Тогда мы подключили perf и начали смотреть на события:
perf stat -e cache-references,cache-misses,cycles,instructions ./service
И тут стало интересно. В моменты пиков latency число cache-misses вырастало почти в 3 раза. Причём не L1, а именно last-level cache.
Почему? Данные у нас в памяти, доступ по хешу, никаких больших сканирований. Или мы так думали.
Микроэксперимент: изолируем кэш и ломаем предсказуемость
Чтобы убедиться, что это не случайность, я написал маленький синтетический тест. Задача — создать структуру данных, которая помещается в L3, и нагружать её конкурентным доступом.
// C++ #include <vector> #include <thread> #include <random> #include <atomic> constexpr size_t SIZE = 32 * 1024 * 1024; // 32 MB std::vector<uint64_t> data(SIZE); std::atomic<bool> run{true}; void worker(int id) { std::mt19937_64 gen(id); std::uniform_int_distribution<size_t> dist(0, SIZE - 1); while (run.load(std::memory_order_relaxed)) { auto idx = dist(gen); data[idx] += 1; } } int main() { std::vector<std::thread> threads; for (int i = 0; i < 16; ++i) { threads.emplace_back(worker, i); } std::this_thread::sleep_for(std::chrono::seconds(10)); run.store(false); for (auto& t : threads) t.join(); }
Размер выбран так, чтобы находиться на границе L3 конкретного процессора. И что оказалось: при определённом числе потоков начинается резкий рост latency на уровне отдельных операций. При этом CPU не загружен на 100 процентов.
Кэш начинает вести себя как поле боя. Линии вытесняются, переезжают между ядрами, а протокол когерентности превращается в скрытого убийцу производительности.
Вы задумывались, сколько стоит одна миграция cache line между ядрами? На практике — десятки наносекунд. В масштабах одной операции — ерунда. В масштабах миллиона операций в секунду — катастрофа.
Side-effects, о которых мы не думали
В продакшене картина оказалась сложнее. У нас была структура, похожая на шардированный хеш-мап. Каждый шард логически независим. Но физически — они лежали рядом в памяти.
И вот что происходило. Когда один шард начинал активно обновляться, соседние cache lines тоже вытеснялись. Причём не потому, что к ним обращались, а потому что они делили те же set’ы в L3.
Это эффект конфликтных промахов. Даже если данные помещаются в кэш целиком, они могут конкурировать за одни и те же set’ы.
Мы написали инструмент, который логировал адреса горячих структур и раскладывал их по cache set’ам. Упрощённо это выглядело так:
// C++ uintptr_t addr = reinterpret_cast<uintptr_t>(&data[i]); size_t cache_line = addr / 64; size_t cache_set = cache_line % 2048; // для конкретной архитектуры std::cout << "Set: " << cache_set << std::endl;
И да, несколько ключевых структур попадали в одинаковые наборы. Это чистая геометрия адресного пространства, о которой редко думаешь, когда пишешь бизнес-логику.
Мы перераспределили память с дополнительным паддингом и случайным смещением. После этого пики сократились примерно на 40 процентов. Не исчезли полностью, но стали предсказуемее.
Когда кэш начинает влиять на безопасность
Отдельный пласт — побочные эффекты, которые можно использовать для атак. Мы ради интереса повторили простой cache timing эксперимент внутри кластера.
Один процесс измеряет время доступа к определённой памяти. Второй — пытается влиять на кэш, вытесняя линии. Даже без shared memory можно наблюдать разницу во времени через last-level cache.
Простейший пример:
// C #include <x86intrin.h> uint64_t measure(volatile uint8_t* addr) { unsigned int aux; uint64_t start = __rdtscp(&aux); (void)*addr; uint64_t end = __rdtscp(&aux); return end - start; }
Если доступ к addr быстрый — линия в кэше. Медленный — вытеснена. Это фундамент для целого класса атак. И да, в облаке это становится особенно неприятным.
Мы не нашли реальной уязвимости у себя, но сам факт, что соседний контейнер потенциально может влиять на latency через shared L3, заставил иначе смотреть на изоляцию.
Что мы в итоге сделали и какие выводы
Полностью победить кэш невозможно. Это часть железа. Но мы сделали несколько практических шагов:
жёсткий pin потоков к ядрам
разнесение горячих структур с учётом cache line и set’ов
ограничение числа потоков на сокет
нагрузочные тесты с perf в CI
Самое важное — мы начали воспринимать кэш как часть архитектуры, а не как прозрачный ускоритель.
Если у вас в продакшене странные пики latency, и всё остальное уже проверено — загляните в сторону cache-misses. Иногда проблема не в коде как таковом, а в том, как он раскладывается по кремнию.
Честно говоря, до этого кейса я считал разговоры о микроархитектуре чем-то для олимпиадников и авторов статей про ассемблер. Теперь это часть моей ежедневной паранойи.
А вы когда-нибудь проверяли, как ваши структуры ложатся в L3?
