Типичные ошибки: false sharing, дисбаланс, лишняя синхронизация
Урок собирает грабли, на которые наступает почти каждый, кто впервые распараллеливает код.
False sharing (ложное разделение) — потеря производительности, когда разные ядра пишут в разные переменные, случайно попавшие в одну строку кэша, заставляя аппаратуру гонять эту строку между ядрами.
False sharing — тихий убийца
Память кэшируется не байтами, а строками (обычно 64 байта). Если два ядра пишут в соседние элементы массива, попавшие в одну строку кэша, аппаратура обязана синхронизировать эту строку: ядро A пишет — строка ядра B объявляется устаревшей, и наоборот. Данные логически независимы, но физически делят строку кэша — отсюда «ложное» разделение. Результат: код корректен, но работает в разы медленнее, чем должен, и без видимой причины.
Строка кэша (64 байта): [ счётчик_ядра0 | счётчик_ядра1 | ... ] ядро 0 пишет -> строка ядра 1 устарела -> пересылка ядро 1 пишет -> строка ядра 0 устарела -> пересылка пинг-понг строки между ядрами = катастрофа
Лечение: разнести данные ядер по разным строкам кэша (padding/выравнивание) или копить результат в локальной переменной и записать в общий массив один раз в конце.
# демонстрация лечения: вместо частой записи в общий массив -
# каждый "поток" копит локально и пишет один раз (нет пинг-понга)
data = list(range(1, 9))
threads = 2
chunk = len(data) // threads
results = [0, 0]
for t in range(threads):
local = 0 # локальный аккумулятор (своя строка кэша)
for x in data[t*chunk:(t+1)*chunk]:
local += x # пишем в локальную, не в общую память
results[t] = local # единственная запись в общий массив
print("частичные:", results, "-> сумма:", sum(results))
Вывод:
частичные: [10, 26] -> сумма: 36
Дисбаланс нагрузки
Уже обсуждали: общее время определяет самый загруженный воркер. Перекос возникает, когда работа поделена по числу, а не по стоимости, или когда задачи имеют непредсказуемую длительность при статическом распределении. Лечение — динамическая балансировка (master-worker, work stealing, schedule(dynamic)).
Чрезмерная синхронизация
Каждая блокировка, барьер, атомарная операция сериализует код: пока один поток в критической секции, остальные ждут. Слишком частые или слишком крупные критические секции превращают параллельную программу обратно в последовательную. Часто разработчик «на всякий случай» оборачивает в блокировку то, что не нуждается в защите, и теряет всё ускорение. Лечение: минимизировать критические секции, использовать локальные накопители и редукцию вместо общей переменной, выбирать lock-free структуры там, где оправдано.
Неверная гранулярность
Слишком мелкие задачи — overhead на их создание/координацию превышает пользу. Слишком крупные — ядра простаивают, балансировка грубая. Лечение — порог (cutoff) и подбор размера порции под конкретную машину.
| Ошибка | Симптом | Лечение |
| False sharing | Медленно без причины | Padding, локальные аккумуляторы |
| Дисбаланс | Часть ядер простаивает | Динамическая балансировка |
| Лишняя синхронизация | Ускорение близко к 1 | Минимум критических секций, reduction |
| Плохая гранулярность | Overhead или простой | Порог, подбор размера порции |
Как работает под капотом
Все четыре ошибки объединяет одно: они невидимы в логике алгоритма и проявляются только на реальном железе. False sharing — артефакт кэш-когерентности, дисбаланс — следствие данных, синхронизация — стоимость протокола блокировок, гранулярность — взаимодействие с планировщиком. Поэтому параллельный код обязательно профилируют: измеряют реальное ускорение и эффективность, а не доверяют интуиции. Часто «очевидно параллельный» код даёт ускорение 1.2× вместо 8× именно из-за одной из этих грабель.
Объединяющий вывод этих ошибок таков: корректный параллельный код и быстрый параллельный код — не одно и то же. Программа может давать абсолютно правильный ответ и при этом работать медленнее последовательной версии, потому что ядра дерутся за строки кэша, ждут на блокировках или простаивают из-за перекоса. Ни компилятор, ни тесты этого не покажут — ответ-то верный. Единственный надёжный детектор — измерение: запустить, замерить ускорение, и если оно far от ожидаемого, искать виновника среди этих грабель. Привычка профилировать, а не верить интуиции, отличает того, кто пишет параллельный код, от того, кто пишет быстрый параллельный код.
Частые ошибки (мета)
- Не профилировать, а верить, что код «должен быть быстрым» — реальность часто иная.
- Лечить дисбаланс добавлением ядер — это не помогает, нужна балансировка.
- Защищать блокировкой данные, которые в защите не нуждаются — теряется параллелизм.
Итоги
- False sharing — пинг-понг строки кэша между ядрами из-за соседних переменных; лечится padding/локальными аккумуляторами.
- Дисбаланс нагрузки решается динамической балансировкой.
- Чрезмерная синхронизация сериализует код; минимизируйте критические секции.
- Гранулярность подбирают порогом; параллельный код обязательно профилируют.