Добавили потоки - стало хуже: как "ложное разделение" кеш‑линии крадёт производительность
Есть ошибки, которые не валят программу и не оставляют следов в логах. Они выглядят почти мистически: чем усерднее вы распараллеливаете вычисления, тем медленнее становится результат. Типичный симптом - счётчик, который в одном потоке отрабатывает около секунды, а в четырёх потоках внезапно требует три. Блокировок нет, общих переменных "вроде бы" тоже нет: каждый поток честно пишет в свой элемент массива или своё поле структуры. Почему же ускорения не случается?
Ключ к разгадке в том, что процессор синхронизирует память не "по переменным", а крупными фиксированными блоками - кеш‑линиями. Для x86 (Intel/AMD) стандартный размер кеш‑линии обычно 64 байта. И если две независимые переменные физически оказались внутри одного такого блока, то для железа они превращаются в единый объект когерентности: запись в один байт заставляет ядра согласовывать состояние всей линии целиком.
Минимальный пример, который "ломает" масштабирование
Представьте программу примерно на несколько десятков строк: создаём `THREADS` потоков, и каждый миллиард раз увеличивает свой счётчик в массиве `c[]`. Общих данных нет: поток номер 0 трогает только `c[0]`, поток 1 - только `c[1]` и так далее. Задача кажется идеально параллельной.
Но внезапно выходит так: при `THREADS = 4` время больше, чем при `THREADS = 1`, даже если общее число инкрементов одинаковое. Четыре ядра выполняют ту же работу в разы медленнее - при том, что потоки "не пересекаются ни по одному байту" на уровне исходного кода.
Что происходит внутри процессора
Четыре 64-битных счётчика занимают 32 байта. Это означает, что весь массив из четырёх `uint64_t` легко помещается в одну кеш‑линию (64 байта на Intel/AMD). А кеш‑линия - минимальная единица, которой ядра обмениваются друг с другом: когерентность отслеживается для всей линии, а не для отдельных переменных внутри.
Чтобы выполнить запись (даже в один байт), ядру нужно получить кеш‑линию в исключительное владение. При этом копии этой же линии в кешах других ядер становятся недействительными. Итоговая картина выглядит так:
1. Поток 0 пишет в `c[0]` → забирает линию себе → "выбивает" её из кешей остальных ядер.
2. Почти сразу поток 1 пишет в `c[1]` → вынужден забрать ту же линию → выбивает её у потока 0.
3. Поток 2 делает то же самое, затем поток 3 - и круг повторяется миллиард раз.
Логически данные независимы. Физически же потоки непрерывно гоняют одну и ту же кеш‑линию между ядрами. Такая передача стоит на порядок дороже, чем работа с локальным кешем. Разделения данных вроде бы нет, а платим мы как за жёсткую конкуренцию - отсюда термин "ложное разделение" (false sharing).
Когда стоит подозревать ложное разделение
Если добавление потоков:
- не ускоряет код или даже замедляет,
- при этом в горячем пути нет явных блокировок, мьютексов и атомиков,
- кеш‑линии должны стать одной из первых гипотез.
Показательный косвенный признак - аномально высокая доля промахов по кешу в микробенчмарке, который трогает буквально десятки байт. Если программа активно "спотыкается" на кеше, хотя объём данных крошечный, значит, проблема почти наверняка в когерентности и миграции линий между ядрами.
Как найти виновника в реальном коде
Общие счётчики промахов не всегда скажут, где именно начинается беда: они показывают симптом, но не адрес конфликта. В Linux есть инструмент, который как раз ловит ситуации, когда одно ядро забирает *изменённую* кеш‑линию у другого и группирует события по адресам. В отчёте обычно видно, что львиная доля конфликтов приходится на одну линию, а внутри неё обращения отличаются смещениями вроде 0, 8, 16, 24 - то есть это те самые четыре счётчика по 8 байт.
Важная деталь: некоторые процессоры агрессивно подтягивают соседние линии (предвыборка), поэтому конфликт может "размазаться" сразу на две линии подряд. Для таких случаев полезен режим анализа "двойной линии", когда события группируются не по 64 байтам, а по паре соседних линий - это помогает, если одного выравнивания оказалось недостаточно.
Чтобы понять, как именно компилятор разложил поля структуры по памяти, удобно использовать утилиты, показывающие смещения и границы кеш‑линий. Это позволяет без отладчика увидеть, что два "независимых" поля на самом деле живут в одной линии и постоянно выбивают друг друга из кеша.
Починка: развести данные по разным кеш‑линиям
Лечение простое по идее: сделать так, чтобы каждый поток писал в отдельную кеш‑линию. В C это обычно достигается выравниванием и/или добавлением "набивки" (padding), чтобы соседние счётчики оказались далеко друг от друга.
После такого изменения скорость часто почти линейно приближается к ожидаемой: если четыре потока действительно независимы, можно получить ускорение, близкое к четырёхкратному, и параллельно увидеть, что промахи и конфликты по кешу приходят в норму.
Почему нельзя просто "везде поставить выравнивание на 64"
Потому что выравнивание стоит денег - прежде всего в памяти и в эффективности кешей. Счётчик на 8 байт, выровненный по 64, фактически занимает всю линию: большая часть структуры превращается в "дыры". Это бьёт не только по объёму потребляемой RAM, но и по работе L1/L2: в один и тот же объём кеша начинает помещаться меньше полезных данных, возрастает давление на кеш и на пропускную способность памяти.
На практике это означает: выравнивать стоит только те поля/объекты, которые реально участвуют в горячей межпоточной записи. "Профилактическое выравнивание всего подряд" легко ухудшит ситуацию в другом месте - там, где важнее плотная упаковка и локальность.
Размер кеш‑линии не всегда 64 байта
Жёстко зашитое значение 64 - частая ошибка. На Apple Silicon (серия M) кеш‑линия обычно 128 байт, встречаются архитектуры и с 256. Код, который "исцелили" выравниванием на 64, на другой платформе может продолжить страдать: два счётчика всё равно окажутся в пределах одной линии, просто более крупной.
В C++ начиная с C++17 есть стандартные константы, которые помогают писать переносимый код: одна отражает "разрушительное" взаимодействие (на что выравнивать, чтобы разнести конфликтующие записи), другая - "конструктивное" (сколько данных разумно держать рядом ради локальности). В системном коде встречаются и платформенные макросы для выравнивания по границе кеш‑линии.
Если "набивку" добавить некуда
Иногда структура фиксирована: ABI, протокол, общий формат данных, требования сериализации - и распихать поля по разным линиям нельзя. В таких случаях применяют обходные манёвры:
- Разнести горячие счётчики в отдельный массив/структуру, которая не участвует в сериализации и может быть выровнена как надо.
- Сделать массив "структур на поток" (per-thread / per-core), а итог суммировать в конце. Это часто быстрее, чем атомики и постоянная борьба за одну линию.
- Использовать батчинг: поток копит локальный счётчик и реже обновляет общую статистику (например, раз в N операций). Конфликт остаётся, но происходит гораздо реже.
- Пересмотреть разбиение работы: иногда проще менять стратегию параллелизма, чем воевать с конкретной структурой данных.
Ложное разделение против настоящего
Важно отличать false sharing от реального разделения данных (true sharing). При настоящем разделении несколько потоков действительно должны читать/писать одну и ту же переменную - это логическая необходимость алгоритма (очередь, общий индекс, флаг, состояние). Тогда проблема - в конкуренции за конкретный объект данных, и лечится она сменой алгоритма, уменьшением частоты синхронизации, lock-free подходами или шардированием.
При ложном разделении логической общности нет: потоки независимы, но железо вынуждает их синхронизироваться из-за неудачной раскладки памяти. Здесь выравнивание и переразмещение данных дают максимальный эффект.
Если perf под рукой нет: практические признаки
Даже без специализированных инструментов можно заподозрить кеш‑линию по поведению:
- Ускорение ухудшается именно при увеличении числа потоков, особенно когда потоков примерно столько же, сколько ядер.
- Замедление усиливается при активной записи (инкременты, накопление статистики, частые обновления полей).
- Если заменить запись на чтение - становится заметно лучше (потому что проблема прежде всего в "exclusive ownership" при записи).
- Небольшие изменения в расположении полей структуры (добавили новое поле, поменяли порядок) внезапно меняют производительность - классический намёк на "попали/не попали в линию".
Простой тест: разнести подозрительные поля вручную (пусть даже грубо, добавив временный padding) и сравнить время. Если стало резко лучше - вы почти наверняка нашли false sharing.
То же самое в мирах со сборщиком мусора
В Java, C#, Go и других языках с GC ложное разделение тоже встречается, хотя выглядит иначе. Объекты и поля могут оказаться рядом в памяти из-за аллокатора, а массивы примитивов - особенно коварны: элементы лежат подряд, и разные потоки, обновляющие разные индексы, могут биться за одну линию.
Типичный пример - массив счётчиков по потокам/шардам, где индексы соседние: логически всё разнесено, физически - плотная упаковка. Лечения похожи: паддинг, разнесение элементов, специализированные структуры (в некоторых платформах есть аннотации/обёртки для "контеншн‑устойчивых" счётчиков), либо стратегия "локально копим - потом агрегируем".
Как понять, что это точно про вас
Проверьте свой код на несколько распространённых ловушек:
1. Статистика и метрики: per-thread счётчики лежат рядом в массиве или структуре.
2. Пулы потоков: у каждого воркера есть своё поле состояния, но воркеры хранятся в одном массиве структур.
3. Очереди задач: отдельные индексы head/tail или флаги разных очередей упакованы в одну структуру без выравнивания.
4. Часто обновляемые флаги: разные потоки пишут в разные `bool`/`int`, которые компилятор уложил вплотную.
Если в этих местах вы видите частые записи и ухудшение масштабирования - начните с проверки раскладки памяти и границ кеш‑линий.
Итог
Ложное разделение - это "налог на неудачную упаковку данных", который проявляется тем сильнее, чем активнее вы распараллеливаете запись. Процессор поддерживает когерентность на уровне кеш‑линий, и несколько независимых переменных, оказавшихся в одной линии, могут превратить идеальную параллельную задачу в дорогостоящую пересылку одного и того же блока между ядрами. Лечится это разнесением горячих записываемых данных по разным линиям - но применять выравнивание нужно точечно, помня о цене в памяти и о том, что размер линии зависит от архитектуры.


