Число ядер само по себе ничего не ускоряет. Ускорение возникает только тогда, когда работу удаётся разделить на куски, которые считаются независимо, и когда стоимость этого разделения меньше выигрыша. Большинство разочарований от многоядерных систем происходит из-за того, что первое условие выполняется частично, а второе не выполняется вовсе.

Последовательная часть задаёт потолок

Любая задача состоит из участков, которые распараллеливаются, и участков, которые обязаны выполняться строго по порядку. Чтение исходных данных, подготовка структур, финальная сборка результата, вывод — всё это чаще всего последовательно. Если такая часть занимает даже небольшую долю общего времени, она превращается в жёсткий потолок: сколько ядер ни добавляй, быстрее суммы последовательных участков задача не выполнится.

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

Стоимость разделения работы

Разбить задачу на потоки — не бесплатная операция. Нужно создать рабочие потоки или взять их из пула, раздать им куски данных, дождаться завершения, объединить результаты. Если куски мелкие, накладные расходы съедают выигрыш: потоки больше времени проводят в ожидании на барьерах и блокировках, чем в счёте.

Хуже, когда потоки конкурируют за общие данные. Захват блокировки сериализует доступ, и участок, формально распараллеленный, фактически выполняется по очереди. Отдельная неприятность — ложное разделение: два потока пишут в разные переменные, случайно попавшие в одну строку кэша, и каждая запись выбивает строку из кэша соседнего ядра. Внешне это выглядит как многопоточная версия, которая работает медленнее однопоточной, и по коду причина не видна.

Упор в память, а не в ядра

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

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

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

Что делится хорошо

Хорошо масштабируются задачи, где работа естественным образом распадается на независимые куски примерно одинакового объёма. Рендер изображения делится по тайлам или по кадрам. Сборка проекта — по единицам компиляции. Перекодирование видео — по группам кадров. Обработка набора файлов — по файлам. Численные расчёты на регулярных сетках делятся по областям с обменом только на границах.

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

Что не делится

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

Игровая логика занимает промежуточное положение. Часть работы выносится в отдельные потоки — загрузка ресурсов, звук, физика, подготовка команд отрисовки, — но остаётся ведущий поток, который сводит кадр воедино и задаёт темп. Когда он не успевает, добавление ядер не помогает; помогает более высокая скорость одного потока.

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

Как понять, во что упирается конкретная задача

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

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