Параллельный поиск, BFS и связные компоненты

Урок про параллелизм в поиске и обходах графов: где он естественен, а где упирается в зависимости.

Параллельный BFS обрабатывает граф уровнями (фронтами): все вершины текущего фронта раскрываются одновременно, порождая следующий фронт.

Параллельный поиск в массиве

Найти, есть ли значение в неотсортированном массиве, легко параллелится: режем массив на куски, каждое ядро ищет в своём, и если кто-то нашёл — сигналим всем остановиться. Это map + reduce (логическое ИЛИ результатов). Минимум из массива, максимум, количество совпадений — всё это редукции. А вот бинарный поиск в отсортированном массиве параллелится плохо: он и так O(log n), делить нечего, каждый шаг зависит от предыдущего.

# параллельный поиск минимума как редукция (эмуляция по кускам)
data = [9, 3, 7, 1, 8, 2, 6, 4]
workers = 2
chunk = len(data) // workers
local_min = []
for w in range(workers):
    part = data[w*chunk:(w+1)*chunk]
    local_min.append(min(part))       # каждое ядро -> локальный минимум
    print(f"ядро {w}: {part} -> min {local_min[-1]}")
print("глобальный минимум:", min(local_min))   # редукция минимумов

Вывод:

ядро 0: [9, 3, 7, 1] -> min 1
ядро 1: [8, 2, 6, 4] -> min 2
глобальный минимум: 1

BFS по уровням

Обход в ширину естественно ложится на параллелизм: вершины одного уровня (фронта) не зависят друг от друга, их можно раскрывать одновременно. Алгоритм идёт волнами: фронт уровня 0 — стартовая вершина; параллельно раскрываем всех её соседей — это фронт уровня 1; параллельно раскрываем их соседей — фронт уровня 2; и так далее. Параллелизм есть внутри уровня, но уровни идут строго по порядку (нужен барьер между ними).

from collections import deque

graph = {0: [1, 2], 1: [3], 2: [3, 4], 3: [5], 4: [5], 5: []}

def bfs_levels(g, start):
    visited = {start}
    frontier = [start]
    level = 0
    while frontier:
        print(f"уровень {level}: {frontier}")   # весь фронт раскрываем параллельно
        nxt = []
        for u in frontier:
            for v in g[u]:
                if v not in visited:
                    visited.add(v)
                    nxt.append(v)
        frontier = nxt
        level += 1

bfs_levels(graph, 0)

Вывод:

уровень 0: [0]
уровень 1: [1, 2]
уровень 2: [3, 4]
уровень 3: [5]

Проблемы параллельного BFS

Идея проста, но дьявол в деталях. Когда несколько вершин фронта одновременно открывают одного и того же соседа, нужно атомарно решить, кто его «застолбит» — иначе вершину добавят в следующий фронт дважды (гонка). Кроме того, размеры фронтов сильно скачут: в социальном графе один уровень может содержать миллионы вершин, а соседний — десятки, что рушит баланс нагрузки. Поэтому реальные параллельные BFS используют хитрые приёмы (direction-optimizing BFS, который переключается между «сверху-вниз» и «снизу-вверх» в зависимости от размера фронта).

Связные компоненты

Найти связные компоненты параллельно сложнее обычного DFS (DFS принципиально последователен — глубокая рекурсия). Параллельные подходы используют итеративный «label propagation»: каждой вершине дают метку (свой номер), затем многократно параллельно обновляют метку до минимальной среди соседей; за несколько раундов метки сходятся, и одинаковая метка = одна компонента. Это снова shared-память + редукция, повторяемая до стабилизации.

Как работает под капотом

BFS на GPU хранит фронт как массив вершин; на каждом уровне тысячи потоков параллельно перебирают рёбра фронта и атомарными операциями помечают новые вершины. Барьер между уровнями обязателен. DFS на GPU почти не используют — его глубокая последовательная природа плохо ложится на массовый параллелизм.

Графовые алгоритмы — отрезвляющий контрпример к оптимизму параллелизма. Сложить массив легко: данные регулярны, доступ предсказуем, нагрузка ровная. Граф же нерегулярен: у одной вершины два соседа, у другой миллион; фронт BFS то взрывается, то схлопывается; обращения к памяти прыгают по всему графу, убивая кэш. Поэтому реальное ускорение параллельного BFS часто скромно, несмотря на обилие формальной независимости. Этот разрыв между «теоретически параллельно» и «практически быстро» — важный урок: наличие независимых задач необходимо, но не достаточно; нужны ещё регулярность доступа к памяти и сбалансированность нагрузки.

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

  • Параллелить DFS «в лоб» — его рекурсивная глубина последовательна, выигрыша почти нет.
  • Забыть атомарность при пометке вершин в BFS — одна вершина попадёт в фронт несколько раз.
  • Игнорировать скачки размера фронта — дисбаланс нагрузки убьёт ускорение.

Итоги

  • Поиск минимума/максимума/наличия в массиве — это редукция, отлично параллелится.
  • BFS параллелится по уровням: фронт раскрывается одновременно, между уровнями — барьер.
  • Главные трудности BFS — атомарная пометка вершин и скачки размера фронта; DFS плохо параллелится.
Проверьте себя
1. Как параллелится поиск минимума в массиве?
AЧерез бинарный поиск
BКак редукция: локальные минимумы кусков, затем минимум из них
CТолько последовательно
DЧерез сортировку
2. Как устроен параллельный BFS?
AРаскрывает граф в глубину
BОбрабатывает уровни (фронты): вершины одного фронта раскрываются одновременно, между уровнями барьер
CСортирует вершины
DИспользует только один поток
3. Почему DFS плохо параллелится?
AОн требует сортировки
BЕго глубокая рекурсивная природа последовательна
CОн использует слишком много памяти
DОн неверный на графах