Параллельные вычисления · Раздел 1
Введение в параллельные вычисления

Сколько ядер нужно, чтобы программа стала быстрее в два раза?

Больше двух. А иногда и двадцати не хватит. Эта страница объясняет почему: законы Амдала и Густафсона можно покрутить руками, а в конце браузер сам проведёт эксперимент на ядрах вашего компьютера.

Ядер в этом браузере: — Замеры на Java: OpenJDK 21, 2 ядра
Стена частоты. Чёрным — максимальная тактовая частота серийных процессоров Intel и AMD (левая шкала, логарифмическая). После 2004 года рост остановился на 3–5 ГГц. Синим — максимальное число ядер в настольных процессорах (правая шкала): транзисторы ушли туда.
Перед началом1.0

На пальцах: кухня вместо формул

Частота. Представьте одного повара. Двадцать лет подряд он каждый год начинал двигаться быстрее, и любое блюдо готовилось быстрее само собой. С 2004 года быстрее он не может: перегреется. Зато поваров на кухне стало 24. Теперь, чтобы ужин был готов раньше, работу надо между ними делить.

Закон Амдала. Готовим ужин: нарезать овощи (30 минут) и сварить суп (30 минут). Овощи режут хоть десять поваров, каждому по кусочку, и вместе они управятся за 3 минуты. А суп варится 30 минут, сколько поваров вокруг кастрюли ни стой. Один повар готовит ужин 60 минут, десять — 33. Не в десять раз быстрее, а меньше чем в два. Суп — это та часть работы, которую нельзя разделить. Подвигайте ползунок и посмотрите, сколько поваров стоят без дела.

1 повар
овощи 30 мин
суп 30 мин
4 повара
овощи
суп 30 мин
Ужин готов через
37,5 мин
Быстрее, чем один повар
1,6×
Поваров без дела
2,4 из 4

Закон Густафсона. А если задача не «этот ужин быстрее», а «накормить больше гостей за тот же час»? Десять поваров нарежут в десять раз больше овощей, пока варится тот же суп. Вот тут они окупаются почти полностью. Переключите режим выше: время не меняется, а гостей становится всё больше.

Потоков больше, чем ядер. Десять поваров на кухне, где всего две доски для нарезки. Восемь стоят и ждут, да ещё мешают друг другу пройти. Так ведёт себя программа, которая запустила больше потоков, чем у процессора ядер: быстрее не становится, а иногда становится медленнее.

Эксперимент. Расчётам верить нельзя, нужен секундомер. Если десять поваров не дали ускорения, диагнозов два: либо суп слишком долгий (большая неделимая часть), либо повара толкаются (накладные расходы). Лечатся они по-разному, и параграф 1.5 показывает, как их различить.

Где это в бэкенде сегодня

На кухнеВ теорииВ веб-сервисе
ПовараЯдра, потоки pПул потоков Tomcat, ForkJoinPool, консьюмеры Kafka, поды в Kubernetes
Суп, который варится 30 минутПоследовательная доля fОдин synchronized, одна база под блокировкой, одна партиция Kafka, цепочка вызовов A → B → C
Десять поваров, а ужин всего вдвое быстрееПотолок Амдала 1/f«Подняли 20 подов, а RPS вырос в 3 раза». Все ждут одну базу
Больше гостей за тот же часГустафсонГоризонтальное масштабирование: не быстрее один запрос, а больше запросов в секунду
Поваров больше, чем досокПотоков больше, чем ядер200 потоков на 4 ядрах: CPU-задачи мешают друг другу; задачи, которые ждут сеть, — нормально
Повара толкаютсяНакладные расходы, Карп–ФлэттПереключение контекста, борьба за lock, GC, сериализация

Правило для микросервисов. Самый медленный неделимый участок задаёт потолок всей системы. Пока он есть, добавлять инстансы почти бесполезно: сначала убирают «суп», потом зовут поваров.

Параграф1.1

Почему ядра, а не частота

Закон Мура говорит о транзисторах: их число на кристалле удваивается примерно каждые два года. До середины 2000-х это давало и рост тактовой частоты, и однопоточная программа ускорялась сама, без усилий программиста.

Около 2004 года частота упёрлась в стену. Потребляемая мощность растёт быстрее частоты, и кристалл на 5 ГГц нельзя охладить обычным воздухом. Транзисторы продолжают дешеветь, но тратятся теперь на новые ядра, кэш и векторные блоки.

Отсюда главный вывод раздела: программа, которая использует одно ядро, на новом процессоре почти не быстрее, чем на старом. Чтобы использовать современный процессор, программа должна быть параллельной.

Pentium 4, 2004
3,8 ГГц · 1 ядро
Core i9, 2023
6,0 ГГц · 24 ядра
Рост частоты за 19 лет
1,6×
Рост числа ядер
24×
Параграф1.2

Классификация Флинна

Майкл Флинн в 1966 году предложил делить вычислительные системы по двум признакам: сколько потоков команд и сколько потоков данных они обрабатывают одновременно. Получается четыре класса. На схемах синим текут команды, коричневым данные. Наведите на класс, чтобы рассмотреть его поближе.

Один поток данных
Много потоков данных
Один поток команд

SISDобычный процессор

Один поток команд обрабатывает один элемент данных за раз. Обычный цикл на одном ядре.

SIMDвекторные блоки, GPU

Одна команда применяется сразу ко многим данным. Так работают AVX-инструкции и графические процессоры.

Много потоков команд

MISDредкий класс

Разные команды над одними данными. Встречается в отказоустойчивых системах: три алгоритма считают одно и то же, результат выбирается голосованием.

MIMDмногоядерный процессор

Разные команды над разными данными. Потоки Java на многоядерном процессоре относятся сюда, как и кластеры.

поток командпоток данных
Параграф1.3

Закон Амдала

В любой программе есть часть, которую нельзя распараллелить: чтение входа, инициализация, слияние результатов. Обозначим её долю f. Джин Амдал в 1967 году заметил, что именно она ограничивает ускорение, сколько бы процессоров ни было.

S(p) = 1 / (f + (1 − f) / p),   S(∞) = 1 / f

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

послед.
парал.
0время на 8 ядрах: 21 % от T₁T₁ = 100 %
Ускорение S(p)
4,71×
Эффективность E(p)
59 %
Потолок 1 / f
10×
Ядер «впустую»
3,3
Ускорение от числа ядер. Серая пунктирная линия — идеал S = p. Кривая выгибается к потолку 1/f; точка — текущее положение ползунков.
4,7 из 8 ядер работают. Каждая клетка — ядро: синие дают ускорение, серые простаивают.
Параграф1.4

Закон Густафсона

Джон Густафсон в 1988 году возразил: на практике, получив больше процессоров, люди не решают ту же задачу быстрее, а решают задачу побольше за то же время. Прогноз погоды считают не за минуту вместо часа, а с более мелкой сеткой за тот же час.

S(p) = p − f · (p − 1)

Тогда последовательная часть остаётся прежней, а параллельная растёт вместе с числом ядер, и ускорение почти линейно. Законы не противоречат друг другу: они описывают разные ситуации, которые называют сильным и слабым масштабированием.

Работа выполнена
1×
Время
100 %
Ускорение
—
Формула
Амдал

Параграф1.5

Метрики: эксперимент на ваших ядрах

Теория проверяется замером. Ускорение S = T₁ / Tₚ, эффективность E = S / p. Есть и обратная метрика Карпа–Флэтта: по замеренному ускорению она восстанавливает последовательную долю. Если она постоянна, мешает последовательный код; если растёт с числом потоков, мешают накладные расходы или нехватка ядер.

Кнопка ниже запускает вычисление π как интеграла ∫₀¹ 4/(1+x²) dx на 1, 2, 4… потоках (Web Workers) прямо в этом браузере. У каждого потока своя дорожка: видно, как они делят работу. Считать будет несколько секунд.

Потоков будет проверено: до —
Этап
ожидание
Время прогона
0,000 с
π по ходу счёта (совпавшие знаки — синим)
—
Здесь появятся дорожки потоков: у каждого Web Worker своя полоса прогресса.
До запуска здесь мои замеры на Java (2 ядра, n = 2·10⁸, f = 0). После запуска график покажет ваш браузер.
ПотокиT, мсSEКарп–Флэтт
121121,00100 %—
210841,9597 %0,027
4 (ядер 2)10751,9649 %0,345

Java 21, ExecutorService, медиана из 5 запусков после 3 прогревочных.

Что ждать. Пока потоков не больше, чем ядер, ускорение растёт почти линейно, а Карп–Флэтт держится около нуля. Как только потоков становится больше ядер, время перестаёт падать, а Карп–Флэтт резко растёт: это не последовательный код, это потоки делят одни и те же ядра. На дорожках это видно: полосы ползут рывками.
Почему числа скачут. Браузер делит ядра с другими вкладками и системой, а ноутбуки снижают частоту под нагрузкой. Поэтому каждая точка — медиана из трёх запусков, и первый запуск делается «вхолостую» для прогрева JIT.
Параграф1.6

Как это записывается на Java

Потоки Java выполняются как потоки операционной системы на разных ядрах, поэтому именно на Java удобно проверять законы этого раздела. Три способа разложить одну и ту же сумму на ядра, от явного к декларативному.

Ядро вычисления

static double kernel(long from, long to, double h) {
    double sum = 0.0;
    for (long i = from; i < to; i++) {
        double x = (i + 0.5) * h;
        sum += 4.0 / (1.0 + x * x);
    }
    return sum;
}

Одна и та же функция во всех вариантах, иначе JIT скомпилирует их по-разному, и сравнение будет нечестным.

// диапазон делится на p равных блоков
try (var pool = Executors.newFixedThreadPool(p)) {
    List<Future<Double>> parts = new ArrayList<>();
    for (int k = 0; k < p; k++) {
        long from = n * k / p, to = n * (k + 1) / p;
        parts.add(pool.submit(() -> kernel(from, to, h)));
    }
    double sum = 0;
    for (var f : parts) sum += f.get();
    return sum * h;
}

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

Наведите:
СпособT(1), мсT(2), мсУскорение|π − π*|
Последовательный цикл2209—1,007,5·10⁻¹⁵
ExecutorService215911101,943,1·10⁻¹⁵
ForkJoinPool19599812,004,4·10⁻¹⁶
parallel stream196610481,884,4·10⁻¹⁶