Перейти к основному содержимому

Инженерия параллельных алгоритмов

Разработчику Архитектору

Инженерный подход​

Перенос последовательной программы на кластер без переработки редко даёт ускорение — остаются глобальные массивы, лишние синхронизации и зависимости, которые один поток "проглатывал" бесплатно. Параллельная программа начинается с постановки и измерений, а запись на конкретном языке — один из последних шагов. Сначала — последовательный прототип на русском псевдокоде или блок-схеме (базовая информатика), затем граф зависимостей (статья 5), и только потом — OpenMP, MPI или GPU.

Это проектирование с измеримыми целями:

  1. Корректность — тот же результат, что последовательная версия (бит-в-бит или в пределах ε для float).
  2. Производительность — speedup, укладываемость в SLA.
  3. Масштабируемость — поведение при росте p и объёма данных.
  4. Сопровождаемость — читаемость, отладка, воспроизводимость.

Распараллеливание цикла, который занимает 2 % времени профиля, даёт сложность и риск гонок без заметного ускорения. Порядок работы — профиль (perf, VTune) → граф зависимостей → псевдокод → код (практика).


Постановка задачи​

Перед кодом зафиксируйте:

ВопросЗачем
Какой объём данных сейчас и через год?Выбор weak vs strong scaling
CPU-bound или memory/network-bound?OpenMP vs MPI vs GPU
Какое железо (узлы, NUMA, GPU)?Mapping
Допустима ли погрешность?Аппроксимация, mixed precision
Есть ли детерминизм для воспроизводимости?Порядок reduction, RNG

Классификация по типу параллелизма​

Data parallelism (по данным)​

Одна и та же операция применяется к многим элементам данных независимо.


Псевдокод​

АЛГОРИТМ МАСШТАБИРОВАТЬ_ВЕКТОР(n, a, x, y)
параллельно для i от 0 до n − 1
y[i] := a * x[i] + y[i]
конец параллельно
КОНЕЦ
СтрокаЗависимости
y[i] := …Индекс i уникален у итерации — нет гонки записи между потоками
x[i], y[i]Чтение и запись только "своей" ячейки

Подходит для плотных массивов, BLAS, обработки изображений, слоёв нейросетей.


Справочно на C++ (OpenMP)​

#pragma omp parallel for
for (int i = 0; i < n; ++i)
y[i] = a * x[i] + b;

#pragma omp parallel for — директива компилятора: создать потоки и раздать итерации i. Подробный разбор — в статье 11.


Task parallelism (по задачам)​

Разные роли или этапы выполняются разными исполнителями.


Псевдокод (конвейер из трёх стадий)​

АЛГОРИТМ КОНВЕЙЕР_ОБРАБОТКИ
запустить поток А: пока есть записи
запись := РАЗОБРАТЬ(вход)
передать в очередь1(запись)
запустить поток Б: пока есть записи в очередь1
запись := ПРОВЕРИТЬ(очередь1)
передать в очередь2(запись)
запустить поток В: пока есть записи в очередь2
СОХРАНИТЬ(очередь2)
дождаться завершения А, Б, В
КОНЕЦ

Схема потоков —

Поток1: разбор → Поток2: проверка → Поток3: сохранение

Подходит, когда стадии разнородны (I/O и вычисления), а не когда одна формула на миллион одинаковых ячеек.


Pipelining​

Перекрытие стадий на непрерывном потоке данных — пока итерация k на стадии "считать", итерация k−1 уже на стадии "записать" (аналогия с конвейером CPU).

для k от 1 до K
// на такте t разные k одновременно на разных стадиях
стадия1(k); стадия2(k−1); стадия3(k−2)

Divide and conquer​

Рекурсивное разбиение задачи (сортировка слиянием, быстрая сортировка, FFT) — подзадачи уходят на разные процессоры, затем слияние результатов.

АЛГОРИТМ СОРТИРОВКА_РАЗДЕЛЯЙ(n, массив)
если n ≤ порог то
ПОСЛЕДОВАТЕЛЬНО_ОТСОРТИРОВАТЬ(массив)
иначе
(лево, право) := РАЗДЕЛИТЬ(массив)
параллельно
СОРТИРОВКА_РАЗДЕЛЯЙ(лево)
СОРТИРОВКА_РАЗДЕЛЯЙ(право)
конец параллельно
СЛИТЬ(лево, право, массив)
конец если
КОНЕЦ

Geometric / domain decomposition​

Область (сетка 2D/3D) режется на подобласти. Каждый процессор владеет блоком; для соседних ячеек нужны значения с границы — halo (ghost layer).

для каждого процессора P с блоком B
ОБНОВИТЬ_ВНУТРЕННОСТЬ(B)
ОБМЕНЯТЬ_HALO_С_СОСЕДЯМИ(P)
ОБНОВИТЬ_ГРАНИЦЫ(B, halo)

Этапы разработки​

1. Последовательный прототип (корректность)
2. Профилирование — где 90% времени
3. Граф зависимостей — что параллелить
4. Выбор модели (OpenMP / MPI / GPU)
5. Декомпозиция + минимизация обмена
6. Параллельная реализация
7. Верификация (сравнение с эталоном)
8. Benchmark на разных p
9. Tuning (affinity, block size, overlap)

Не пропускайте шаг 2: правило 90/10 работает и здесь.


Декомпозиция при параллелизме по данным​

1D block decomposition​

Массив длины n на p процессоров.


Псевдокод​

АЛГОРИТМ РАЗБИТЬ_1D(n, p, rank)
размер_блока := ⌈n / p⌉
начало := rank * размер_блока
конец := min(начало + размер_блока, n) − 1
вернуть диапазон [начало .. конец]
КОНЕЦ

Процесс с номером rank обрабатывает только свой диапазон индексов.


2D block (матрицы)​

Каждый процессор — подматрица; для умножения C = A·B — классическая схема.


Блочная декомпозиция с локализацией​

Локализация подобластей — каждый процессор владеет своим блоком данных и по возможности считает только над ним, подгружая тонкий слой границы (halo) у соседей.

Цель: данные, которые часто используются вместе, лежат на том же узле, что и вычисление.

  • First-touch на NUMA: первый доступ выделяет страницу на текущем узле.
  • Block size под L3 cache (например 64×64 double ≈ 32 KiB).
  • Halo cells — ghost layer для обмена с соседями в сеточных методах.
┌───────┬───────┐
│ block │ halo→ │
│ mine │ │
├───────┼───────┤
│ ↓ │ block │
│ halo │ neigh │
└───────┴───────┘

Общие рекомендации​

  1. Начинайте с coarse-grained — крупные задачи на поток; мелкий параллелизм убивает на overhead.
  2. Minimize sharing — локальные переменные, reduction вместо lock на каждый элемент.
  3. Избегайте false sharing — выравнивание структур по cache line (64 B).
  4. Batch communications — одно большое MPI-сообщение лучше сотни мелких.
  5. Overlap compute and comm — MPI_Isend + работа, пока данные в пути.
  6. Deterministic reductions — фиксированный порядок суммирования для воспроизводимости.
  7. Тестируйте на p=1 — regression против sequential.
  8. Документируйте предположения — "требует n % p == 0", "только shared memory".

Связь с анализом графов​

Матрица следования показывает легальные параллельные группы; временной анализ — сколько процессоров нужно и нижняя граница времени; Амдаль — потолок из-за последовательных участков.


MPI — полосы строк и halo (гибрид с OpenMP)​

Каждый MPI-процесс (rank) владеет полосой строк двумерной сетки. Для 5-точечного stencil (значение ячейки зависит от соседей сверху и снизу) нужны ghost rows — копии граничных строк соседних процессов.


Псевдокод​

АЛГОРИТМ STENCIL_1D_ПО_СТРОКАМ
rank, size := номер и число MPI-процессов
сосед_верх := rank − 1 // если rank = 0 — соседа нет
сосед_низ := rank + 1 // если rank = size−1 — соседа нет

ОТПРАВИТЬ_И_ПОЛУЧИТЬ(нижняя_строка_блока → ghost_снизу, сосед_низ)
ОТПРАВИТЬ_И_ПОЛУЧИТЬ(верхняя_строка_блока → ghost_сверху, сосед_верх)

параллельно для j от 1 до n_локально
для каждого столбца i
новое[j,i] := f(старое[j,i], ghost_сверху, ghost_снизу, соседи по i)
конец параллельно
КОНЕЦ
ШагЗачем
Halo-обменБез соседних строк формула stencil неполная на границе блока
параллельно для jВнутри одного rank — OpenMP по строкам локального блока

Справочно на C (MPI + OpenMP)​

Код ITЗагрузка примера кода…

ЭлементСмысл
rank > 0 ? rank - 1 : MPI_PROC_NULLТернарный оператор C: если соседа нет, MPI "пропускает" сторону обмена
MPI_PROC_NULLКонстанта "процесс-получатель отсутствует"
&local[1]Адрес второй строки локального массива — граница для отправки
MPI_DOUBLEТип элементов — вещественное double
#pragma omp parallel forПотоки внутри одного MPI-процесса

Паттерн — Sendrecv с соседями, затем OpenMP внутри rank; типичный гибрид на кластере (практика).


Дерево reduction​

rank 0: sum0 ──┐
rank 1: sum1 ──┼──► local on pairs ──► MPI_Allreduce ──► global
rank 2: sum2 ──┤
rank 3: sum3 ──┘

Не суммируйте через rank 0 в цикле Send — latency O(p); MPI_Allreduce — O(log p) по глубине дерева на большинстве реализаций.


Антипаттерны​

АнтипаттернПроблема
Parallel for на 100 элементовOverhead > выигрыш
Lock на каждый incrementContention
Barrier каждую итерациюСериализация
"Параллелить всё" без профиляСложность без speedup
Игнор NUMAУдалённая память

Что дальше​