Раздел 23 · Rust

Аппаратная модель: кэши, MESI и false sharing

lead~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Аппаратная модель: кэши, MESI и false sharing

Прошлый урок убрал замок совсем и оставил голые атомики: compare_exchange в цикле, пары release-acquire, барьеры. Мы пользовались ими как абстракцией, но ни разу не спросили, во сколько обходится одна атомарная операция и почему. Сегодня последний шаг блока: спускаемся ниже атомиков, к самому железу. Разберём, что такое строка кэша и почему два соседних поля могут уронить производительность в десять раз, как процессор поддерживает согласованность кэшей по протоколу MESI, откуда у атомиков цена, и почему один и тот же код на x86 и на ARM ведёт себя по-разному. Тогда замкнётся весь блок: от Send и Sync до физики кэшей. Это прямое продолжение иерархии памяти из CS:APP, только теперь с поправкой на несколько ядер.

Зачем спускаться к железу

До сих пор мы рассуждали в терминах модели памяти языка: Acquire, Release, happens-before. Это абстракция, и она намеренно не говорит, сколько стоит синхронизация и почему SeqCst дороже Relaxed. Ответы лежат на уровень ниже, в устройстве процессора. И там же прячется коварный класс багов, которые модель памяти не ловит вообще: твой код корректен, гонок нет, а он всё равно еле ползёт. Чтобы понимать такие случаи, надо знать три вещи: как устроен кэш, как ядра договариваются о согласованности, и чем модели памяти разных архитектур отличаются.

Кэш и строка кэша

Из CS:APP ты помнишь иерархию памяти: регистры, потом кэши L1, L2, L3, потом оперативная память. Чем ближе к ядру, тем быстрее и меньше. L1 отвечает за пару наносекунд, поход в RAM стоит сотню. Ключевая деталь, которая нас сегодня волнует: кэш оперирует не байтами и не отдельными переменными, а блоками фиксированного размера.

Строка кэша это блок, которым кэш живёт. На современных процессорах это 64 байта. Когда ты читаешь один u8, процессор тянет в кэш всю 64-байтную строку, в которой этот байт лежит. Это обычно благо: соседние данные оказываются под рукой, и последовательный проход по массиву летит, потому что после первого промаха остальные элементы строки уже в кэше. На этом стоит вся выгода кэш-дружелюбных структур данных.

Но ровно эта же гранулярность создаёт проблему, как только за соседние данные берутся разные ядра. Потому что и согласованность кэшей отслеживается на уровне строки, а не переменной.

MESI и когерентность кэшей

У каждого ядра свой L1. Если ядро A и ядро B оба держат копию одной строки и A её меняет, B обязано увидеть изменение, иначе вся наша модель памяти рассыпалась бы. За это отвечает аппаратный протокол.

Когерентность кэшей поддерживает протокол MESI. Каждая строка в кэше ядра помечена одним из четырёх состояний, по первым буквам которых и назван протокол:

СостояниеЧто значит
Modifiedстрока изменена здесь, в памяти устаревшее значение, копий в других ядрах нет
Exclusiveстрока только у меня, совпадает с памятью, можно писать без спроса
Sharedкопии есть у нескольких ядер, всем можно только читать
Invalidстрока недействительна, при обращении нужен заново

Правило простое: чтобы записать в строку, ядро обязано получить её в состоянии Modified, а для этого копии той же строки у всех остальных ядер переводятся в Invalid. Этот обмен сообщениями между ядрами не бесплатен. Пока ядро A добивается эксклюзивного владения строкой, оно ждёт. Если строку дёргают по очереди несколько ядер, она мечется между их кэшами, и каждый такой перелёт это десятки наносекунд. Вот откуда у атомиков цена: атомарная запись это всегда перевод строки в Modified, то есть инвалидация чужих копий и межъядерный трафик. Relaxed дешевле SeqCst не потому, что меньше «синхронизирует» абстрактно, а потому что требует от процессора меньше барьеров и упорядочивающих гарантий поверх этого трафика.

False sharing

Теперь соберём две идеи вместе: когерентность работает на уровне строки, а строка это 64 байта, в которые влезает много переменных. Что будет, если два ядра молотят по двум разным переменным, которые случайно легли в одну строку кэша?

False sharing (ложное разделение) это и есть та ловушка. Логически переменные независимы, ни одной гонки данных тут нет, код абсолютно корректен. Но железу всё равно: оно видит одну строку, и запись ядра A в свою переменную переводит всю строку в Modified, инвалидируя копию у ядра B. Ядро B при следующей записи в свою переменную обнаруживает строку Invalid, тянет её обратно, инвалидируя копию у A. Строка играет в пинг-понг между ядрами, и два независимых счётчика работают так, будто дерутся за один.

use std::sync::atomic::{AtomicU64, Ordering};

// Оба счётчика лягут в одну 64-байтную строку: 16 байт подряд.
struct Counters {
    a: AtomicU64, // ядро A молотит сюда
    b: AtomicU64, // ядро B молотит сюда
}
// Два потока крутят fetch_add по своему полю. Гонки нет, но строка
// мечется между ядрами на каждой записи. На бенчмарке это в разы
// медленнее, чем если бы поля жили в разных строках.

Лечится false sharing разведением горячих полей по разным строкам кэша. Самый прямой способ выровнять тип по границе строки атрибутом представления:

use std::sync::atomic::AtomicU64;

#[repr(align(64))] // тип занимает целую строку кэша, ничего чужого рядом
struct CachePadded(AtomicU64);

struct Counters {
    a: CachePadded, // своя строка
    b: CachePadded, // своя строка, инвалидации друг друга не задевают
}

#[repr(align(64))] заставляет тип начинаться на границе 64 байт и занимать строку целиком, поэтому поля a и b гарантированно лягут в разные строки, и инвалидация одной не задевает другую. Руками это писать не обязательно: в экосистеме есть готовый crossbeam_utils::CachePadded<T>, который делает ровно то же самое и сам знает размер строки под конкретную архитектуру. Важна не обёртка, а правило: горячие общие переменные, в которые пишут разные потоки, разводи по строкам кэша. И, как всегда с производительностью, не угадывай, а меряй: false sharing виден на бенчмарке и в профилировщике (резко растущий межъядерный трафик), а на глаз по коду его не отличить от безобидного соседства полей.

Заметь оборотную сторону. Выравнивание раздувает структуру: вместо 16 байт Counters теперь занимает 128. Поэтому паддить всё подряд глупо, это бьёт по кэш-локальности. Паддят прицельно те поля, по которым реально дерутся разные ядра.

Барьеры процессора и стоимость атомиков

Когерентность гарантирует, что записи в одну ячейку все ядра увидят согласованно. Но наши порядки памяти из урока про атомики обещают больше: они упорядочивают записи в разные ячейки относительно друг друга. За это отвечает другой механизм, аппаратные барьеры.

Барьер памяти это инструкция, которая запрещает процессору переупорядочивать обращения к памяти через себя. Зачем это нужно: ради скорости процессор не пишет в кэш сразу, а складывает записи в store buffer и продолжает работать, а реальная запись доезжает до кэша позже. Из-за этого, без барьеров, другое ядро может увидеть записи не в том порядке, в каком их сделали. Release на записи это обещание «всё, что я писал раньше, доедет до памяти не позже этой записи», и компилятор превращает его в нужный барьер. Acquire зеркально не даёт последующим чтениям уехать вверх. SeqCst дороже всех, потому что требует единого глобального порядка и обычно выливается в самый тяжёлый барьер, полный слив буфера.

Отсюда практический вывод, к которому мы шли весь блок: проси у атомика ровно тот порядок, который нужен. Relaxed там, где это чистый счётчик без публикации данных (как в clone у нашего Arc); release-acquire там, где один поток публикует данные, а другой их забирает; SeqCst только когда без единого глобального порядка логика рушится. Лишний SeqCst это не «на всякий случай безопаснее», это лишний барьер и потерянная скорость на каждом обращении.

x86 против ARM

Самая частая ловушка переносимости: код, который безошибочно работает на ноутбуке с x86, начинает ловить редкие гонки на ARM-сервере или на телефоне. Дело не в баге, который «вылез», а в том, что архитектуры дают разные гарантии по умолчанию.

x86 (TSO) это сильная модель памяти: процессор почти ничего не переупорядочивает, кроме послабления со store buffer. Практическое следствие: Acquire и Release на x86 компилируются в обычные load и store без единой лишней инструкции, барьер нужен фактически только для SeqCst. Поэтому если ты по ошибке написал Relaxed там, где нужен был Acquire, на x86 код, скорее всего, всё равно заработает, чисто случайно. И это худший вид удачи.

ARM (слабая модель) переупорядочивает чтения и записи к разным адресам куда свободнее. Тут каждый Acquire и Release это настоящая барьерная инструкция, которую компилятор обязан вставить. И тут же твой случайно работавший на x86 Relaxed показывает истинное лицо: процессор переставляет операции, и редкая гонка наконец проявляется. Мораль ровно та, что мы повторяли весь блок: правильность определяется моделью памяти языка, а не тем, что показал прогон на твоём железе. Пиши порядки по смыслу (что должно быть видно раньше чего), а не по тому, что «и так прошло на x86».

Заметь красивую инверсию. На x86 порядки почти бесплатны, но забытый порядок прощается и копит технический долг. На ARM каждый порядок стоит барьера, зато ошибку видно сразу. Поэтому привычка ставить минимально достаточный порядок осознанно одинаково полезна для обеих: на ARM она экономит барьеры, на x86 спасает от случайной корректности.

Что унести из урока

Кэш живёт строками по 64 байта, и когерентность по MESI отслеживается на уровне строки: чтобы записать, ядро переводит строку в Modified, инвалидируя чужие копии, и этот межъядерный трафик и есть физическая цена атомиков. Отсюда false sharing: две независимые переменные в одной строке гоняют её пинг-понгом между ядрами, корректность цела, а скорость падает в разы; лечится прицельным выравниванием горячих полей по строке через #[repr(align(64))] или CachePadded. Порядки памяти превращаются компилятором в аппаратные барьеры поверх когерентности, поэтому SeqCst дороже Relaxed, и просить надо минимально достаточный порядок. А разница x86 (сильная TSO) и ARM (слабая модель) объясняет, почему правильность меряют моделью памяти языка, а не прогоном на одной машине: на x86 забытый порядок прощается случайно, на ARM сразу ловит гонку.

На этом блок про многопоточность и атомики закрыт. Ты прошёл путь от Send и Sync через Mutex и Arc, собрал спинлок и futex руками, обосновал каждый Ordering и спустился до строк кэша и барьеров процессора. Дальше начинается следующий блок и третий проект курса: сети с нуля и свой бинарный игровой протокол с предсказанием и реконсиляцией. Там всё это пригодится разом, потому что сетевой движок это и есть конкурентность плюс работа с байтами под нагрузкой.

Домашка