Параллелизм: многоядерность и SIMD

Урок показывает, как процессоры делают несколько дел одновременно: несколькими ядрами, потоками и векторными командами.

Параллелизм — одновременное выполнение нескольких вычислений. На уровне железа он бывает разным: несколько ядер, несколько потоков на ядро (SMT) и обработка нескольких данных одной командой (SIMD).

Зачем параллелизм

Поднимать тактовую частоту стало трудно: процессоры упёрлись в тепловую стену около 2005 года. Дальнейший рост производительности пошёл «вширь» — несколько ядер вместо одного быстрого. Но это меняет правила: чтобы использовать N ядер, программу надо распараллелить, иначе лишние ядра простаивают.

Виды аппаратного параллелизма

ВидИдеяПример
Многоядерностьнесколько полноценных ядер на чипе8-ядерный CPU
SMT / Hyper-Threadingодно ядро тянет 2 потока, заполняя простои2 потока на ядро
SIMDодна команда обрабатывает вектор данныхсложить 8 чисел разом
ILP (внутри ядра)суперскаляр/OoO — несколько команд за тактсм. раздел 8

SIMD: одна команда — много данных

SIMD (Single Instruction, Multiple Data) — векторные команды, которые применяют одну операцию сразу к набору чисел. Вместо четырёх отдельных сложений — одно векторное. Это основа ускорения графики, звука, машинного обучения. Промоделируем идею (на чистом Python — настоящие SIMD-команды тут недоступны, но логика та же):

# скалярно: складываем поэлементно в цикле (4 операции)
def add_scalar(a, b):
    return [a[i] + b[i] for i in range(len(a))]

# SIMD-идея: ОДНА векторная операция над всем вектором сразу
def add_simd(a, b):
    return list(map(lambda x, y: x + y, a, b))

a = [1, 2, 3, 4]
b = [10, 20, 30, 40]
print("скалярно (4 операции):", add_scalar(a, b))
print("SIMD   (1 операция):  ", add_simd(a, b))
print("на железе SIMD делает это за 1 такт вместо 4")

Вывод:

скалярно (4 операции): [11, 22, 33, 44]
SIMD   (1 операция):   [11, 22, 33, 44]
на железе SIMD делает это за 1 такт вместо 4

Как работает под капотом: когерентность кэшей

У каждого ядра свой кэш L1/L2. Что, если ядро A изменило переменную x в своём кэше, а ядро B держит старую копию x в своём? Возникнет рассогласование. Протокол когерентности кэшей (например, MESI) следит за этим: когда одно ядро пишет в строку, копии в других ядрах помечаются недействительными. Это обеспечивает иллюзию единой памяти, но стоит трафика между ядрами.

состояния строки в MESI:
  M (Modified)  - изменена, только у этого ядра
  E (Exclusive) - только у этого ядра, не изменена
  S (Shared)    - копии у нескольких ядер (только чтение)
  I (Invalid)   - недействительна

ядро A пишет x -> строка x в ядре B становится Invalid

Закон, ограничивающий параллелизм

Не всё ускоряется числом ядер: если часть программы принципиально последовательна, она ограничивает выигрыш (это закон Амдала — следующий урок). Поэтому «больше ядер» помогает только хорошо распараллеливаемым задачам.

Глубже в тему

Поворот индустрии к многоядерности около 2005 года — это не выбор моды, а вынужденная капитуляция перед физикой, и важно понимать причину. Десятилетиями производительность росла за счёт тактовой частоты, но энергопотребление растёт примерно как куб частоты (динамическая мощность пропорциональна частоте и квадрату напряжения, а для роста частоты нужно поднимать напряжение). К началу 2000-х процессоры упёрлись в «тепловую стену»: дальнейший рост частоты выделял столько тепла, что отвести его стало невозможно. Закон Мура продолжал давать всё больше транзисторов на кристалле, но Деннардовское масштабирование (позволявшее одновременно уменьшать транзисторы и поднимать частоту без роста удельной мощности) сломалось. Транзисторы были — а тратить их на частоту больше не получалось. Выход нашли в том, чтобы тратить растущий транзисторный бюджет «вширь»: вместо одного всё более быстрого ядра — несколько ядер умеренной частоты. Это переложило бремя ускорения с аппаратуры на программистов, которым теперь приходится распараллеливать код.

Стоит чётко различать три вида аппаратного параллелизма из урока, потому что их часто путают, а работают они на разных уровнях. Многоядерность — это несколько полноценных независимых ядер, каждое со своим конвейером; они исполняют разные потоки команд (task-level parallelism). SMT (Hyper-Threading) хитрее: одно физическое ядро притворяется двумя логическими, чередуя на своих исполнительных устройствах команды двух потоков, чтобы заполнить простои (пока один поток ждёт промах кэша, другой считает). SMT даёт прирост меньше, чем отдельное ядро (обычно 20–30%), потому что потоки делят одни ресурсы, но это почти бесплатно по транзисторам. SIMD же — совсем другой уровень: параллелизм не по задачам, а по данным, внутри одного потока, когда одна команда обрабатывает целый вектор чисел. Эти виды не взаимоисключающие, а складываются: современный чип имеет много ядер, каждое с SMT, и каждое умеет SIMD.

SIMD заслуживает отдельного внимания, потому что это рабочая лошадка современных вычислений, хотя её редко программируют вручную. Идея проста: вместо четырёх отдельных команд сложения над четырьмя парами чисел — одна векторная команда, складывающая все четыре пары разом за тот же такт. Расширения вроде SSE, AVX (на x86) и NEON (на ARM) предоставляют такие команды над векторами из 4, 8, 16 и более элементов. Это основа ускорения обработки изображений (один пиксель — одна операция, миллионы пикселей подряд), звука, физических симуляций и, что особенно важно сегодня, машинного обучения, где умножение матриц — это море одинаковых независимых операций. Компиляторы умеют автоматически «векторизовать» простые циклы, но для пиковой производительности библиотеки пишут с явным использованием SIMD-интринсиков. Это прямое продолжение идеи параллелизма по данным, доведённой в GPU до тысяч элементов.

Многоядерность дарит не только мощь, но и фундаментальную головную боль — согласованность кэшей, и без понимания false sharing легко написать «параллельный» код, который медленнее однопоточного. У каждого ядра свой кэш L1/L2, и когда два ядра работают с общими данными, их кэши могут разойтись: ядро A изменило переменную x у себя, а ядро B держит устаревшую копию. Протокол когерентности (MESI и его родня) решает это, отслеживая состояние каждой кэш-строки и инвалидируя чужие копии при записи. Но за корректность платят трафиком: каждая запись в разделяемую строку гоняет сообщения между ядрами. Коварен эффект false sharing: две разные переменные, которыми независимо пользуются два ядра, случайно попали в одну кэш-строку (64 байта), и хотя логически конфликта нет, протокол когерентности гоняет строку между ядрами при каждой записи, как мяч в пинг-понге, обрушивая производительность. Лечится это выравниванием данных по границам кэш-строк (padding). Урок отсюда: параллелизм бесплатен лишь в теории; на практике общая память и когерентность ставят тонкие ловушки, и грамотный параллельный код учитывает физику кэшей.

Частые ошибки

  • Путать ядра и потоки. Ядро — физический вычислитель; SMT-поток делит одно ядро, заполняя его простои, и даёт меньший прирост, чем отдельное ядро.
  • Думать, что SIMD — это многоядерность. SIMD — параллелизм по данным внутри одного ядра, а не несколько ядер.
  • Игнорировать когерентность. Частая запись в общую переменную из разных ядер (false sharing) резко замедляет код из-за трафика когерентности.

Итог

  • Рост производительности идёт «вширь»: многоядерность, SMT, SIMD.
  • SIMD применяет одну команду к вектору данных — основа графики и ML.
  • Протоколы когерентности (MESI) поддерживают согласованность кэшей разных ядер ценой трафика.
Проверьте себя
1. Что такое SIMD?
AНесколько ядер на чипе
BОдна команда, обрабатывающая сразу несколько элементов данных (вектор)
CПротокол когерентности
DТип кэша
2. Зачем нужны протоколы когерентности кэшей (например, MESI)?
AЧтобы ускорить АЛУ
BЧтобы кэши разных ядер не содержали противоречивых копий одной ячейки
CЧтобы увеличить тактовую частоту
DЧтобы предсказывать переходы
3. Почему рост производительности процессоров с ~2005 года пошёл «вширь» (многоядерность)?
AПамять подешевела
BДальнейший рост тактовой частоты упёрся в тепловую стену
CПоявился SIMD
DЗакончились транзисторы