В чём суть многоядерности?

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


Бытовая аналогия: кухня ресторана быстрого питания

Представьте ресторан быстрого питания в обеденный час пик.

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

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

Это философия проектирования многоядерности:Когда одно ядро трудно ускорить дальше, лучше добавить больше ядер и позволить им работать параллельно.

Многоядерность не означает «сделать 4 копии всего». 4 ядра обычно совместно используют одну и ту же память, один и тот же дисковый интерфейс и одну и ту же операционную систему; они дублируются только на уровне «вычислительного блока». Точно так же, как 4 повара, совместно использующие один холодильник и один склад ингредиентов.


Одноядерные и многоядерные: сравнение архитектуры

Дилемма одноядерных процессоров

В начале 2000-х индустрия процессоров повышала производительность, постоянно увеличивая частоту — с 1 GHz до 2 GHz и до 3 GHz. Но этот путь быстро исчерпал себя.

Три основных узких места частоты одноядерных процессоров:

  • стена энергопотребления: Чем выше частота, тем экспоненциальнее растёт энергопотребление, а отвод тепла становится физическим пределом.
  • стена памяти: Процессор слишком быстр, память не успевает за ним, и большую часть времени он простаивает в ожидании данных.
  • Стена ILP: параллелизм на уровне инструкций (ILP) был использован до предела; всё меньше инструкций в одном потоке инструкций поддаётся распараллеливанию.

Примерно в 2005 году Intel и AMD последовательно отказались от чистой «гонки частот» и перешли на многоядерный путь.

Структура многоядерного процессора

Типичная внутренняя структура многоядерного процессора выглядит следующим образом:

  +-----------------------------------------------------------+
  |                         CPU 芯片                          |
  |  +-------------+  +-------------+  +-------------+  +-----+
  |  |   核心 1    |  |   核心 2    |  |   核心 3    |  |核 4 |
  |  | L1 Cache   |  | L1 Cache   |  | L1 Cache   |  | ... |
  |  | L2 Cache   |  | L2 Cache   |  | L2 Cache   |  |     |
  |  +------+------+  +------+------+  +------+------+  +--+--+
  |         |                |                |              |
  |         +----------------+----------------+--------------+
  |                          |
  |                    +-----+------+
  |                    | 共享 L3 Cache |
  |                    +-----+------+
  |                          |
  |                    +-----+------+
  |                    | 内存控制器   | ← 连接主内存 (RAM)
  |                    +------------+
  +-----------------------------------------------------------+

Ключевые особенности архитектуры:

  • Каждое ядро имеет собственные кэши L1 и L2 (приватные).
  • Несколько ядер совместно используют один кэш L3
  • Все ядра обращаются к одной и той же основной памяти через контроллер памяти.
  • Операционная система отвечает за распределение задач между разными ядрами.

Закон Амдала: предел ускорения от многоядерности.

Интуитивное представление, что «больше ядер — значит быстрее», не всегда верно. В 1967 году специалист по информатике Джин Амдал предложил знаменитый закон.

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

Формула выглядит следующим образом:

加速比 = 1 / (S + P/N)

其中:
  S = 任务中必须串行执行的比例(0~1)
  P = 任务中可以并行执行的比例(0~1),且 S + P = 1
  N = 并行处理单元的数量(核心数)

Эта формула раскрывает жестокий факт:

Даже при бесконечном числе ядер, если распараллелить можно только 50% задачи, ускорение составит не более 2x.

Вывод: когда N стремится к бесконечности, P/N стремится к 0, а ускорение стремится к 1/S.

Доля распараллеливаемой части1 ядро2 ядра4 ядра8 ядер16 ядерБесконечное число ядер (верхний предел)
50%1.00x1.33x1.60x1.78x1.88x2.00x
75%1.00x1.60x2.29x2.91x3.37x4.00x
90%1.00x1.82x3.08x4.71x6.40x10.00x
95%1.00x1.90x3.48x5.93x9.14x20.00x
99%1.00x1.98x3.88x7.48x13.91x100.00x

Обратите внимание на закономерность в этой таблице:

  • Чем выше доля параллелизма, тем значительнее ускорение на многоядерных системах.Задачу, которая распараллеливается на 99%, можно ускорить почти в 14 раз на 16 ядрах.
  • Чем больше ядер, тем меньше предельная отдача.Прирост ускорения при переходе с 2 ядер на 4 ядра намного больше, чем с 16 ядер на 32 ядра.
  • Последовательная часть — это жёсткое ограничениеКогда только 50% поддаётся распараллеливанию, даже с десятью тысячами ядер ускорение может быть не более чем в 2 раза.

Закон Амдала объясняет, почему "больше ядер не обязательно означает быстрее". Если ваша программа в основном состоит из последовательной логики — например, этап вычислений должен ждать результата предыдущего этапа — то сколько бы ядер ни было, они могут только наблюдать и не могут помочь.


Интерактивная демонстрация: калькулятор закона Амдала

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

Пример

"""
Калькулятор закона Амдала (демонстрационный пример)
Рассчитайте ускорение при различных долях параллельной части и количестве ядер
Вывести сравнительную таблицу и анализ тенденций
"""


def amdahl_speedup(parallel_fraction, num_cores):
    """
Основная формула закона Амдала
parallel_fraction: доля, которая может выполняться параллельно (0.0 ~ 1.0)
num_cores: количество ядер
Вернуть ускорение
    """

    if num_cores == 1:
        return 1.0
    serial_fraction = 1.0 - parallel_fraction
    speedup = 1.0 / (serial_fraction + parallel_fraction / num_cores)
    return speedup


def amdahl_limit(parallel_fraction):
    """
Вычислить теоретический верхний предел ускорения по закону Амдала (при стремлении числа ядер к бесконечности)
Когда N→∞, P/N→0, ускорение → 1/S
    """

    serial_fraction = 1.0 - parallel_fraction
    if serial_fraction == 0:
        return float('inf')  # Полностью параллелизуемая, теоретически может быть ускорена бесконечно
    return 1.0 / serial_fraction


def print_comparison_table(parallel_fractions, core_counts):
    """Вывести полную таблицу сравнения ускорения."""
    # Заголовок таблицы
    header = f"{'Ядра':>6} |"
    for pf in parallel_fractions:
        header += f" {pf*100:>5.0f}% параллелизуемо |"
    print("=" * (12 + 12 * len(parallel_fractions)))
    print(Закон Амдала - Таблица сравнения ускорения для многоядерных систем)
    print("=" * (12 + 12 * len(parallel_fractions)))
    print(header)
    print("-" * (12 + 12 * len(parallel_fractions)))

    for cores in core_counts:
        row = f" {cores:>5} |"
        for pf in parallel_fractions:
            speedup = amdahl_speedup(pf, cores)
            row += f" {speedup:>6.2f}x |"
        print(row)

    # Теоретический верхний предел
    print("-" * (12 + 12 * len(parallel_fractions)))
    limit_row = f" {'Верхний предел':>5} |"
    for pf in parallel_fractions:
        limit = amdahl_limit(pf)
        if limit == float('inf'):
            limit_row += f" нет верхнего предела |"
        else:
            limit_row += f" {limit:>6.2f}x |"
    print(limit_row)
    print("=" * (12 + 12 * len(parallel_fractions)))


def analyze_diminishing_returns(parallel_fraction, max_cores=64):
    """Проанализировать убывающую предельную отдачу от многоядерного ускорения"""
    print(f"\nАнализ предельной выгоды (доля параллелизуемой части {parallel_fraction*100:.0f}%):)
    print(f{'Ядра':>6} | {'Ускорение':>8} | {'Прирост':>10} | {'Эффективность':>8})
    print("-" * 42)

    prev_speedup = 1.0
    cores = 1
    while cores <= max_cores:
        speedup = amdahl_speedup(parallel_fraction, cores)
        improvement = speedup - prev_speedup
        # Эффективность = фактическое ускорение / количество ядер (100% при идеальном линейном ускорении)
        efficiency = (speedup / cores) * 100
        print(f" {cores:>6} | {speedup:>8.2f}x | +{improvement:>8.3f}x | {efficiency:>7.1f}%")
        prev_speedup = speedup
        if cores == 1:
            cores = 2
        else:
            cores *= 2


def compare_serial_vs_parallel(serial_work_ms, parallel_work_ms, num_cores):
    """
Используйте время, затраченное на конкретную задачу, чтобы продемонстрировать закон Амдала
serial_work_ms: время, затраченное на последовательную часть (миллисекунды)
parallel_work_ms: время параллелизуемой части (на одном ядре, миллисекунды)
num_cores: количество доступных ядер
    """

    total_single_ms = serial_work_ms + parallel_work_ms
    parallel_fraction = parallel_work_ms / total_single_ms

    parallel_time_multi = parallel_work_ms / num_cores
    total_multi_ms = serial_work_ms + parallel_time_multi

    speedup = total_single_ms / total_multi_ms

    print(f"\nМоделирование длительности конкретной задачи:)
    print(fВремя последовательной части: {serial_work_ms} ms (не может быть ускорена))
    print(fВремя, затраченное на параллелизуемую часть: {parallel_work_ms} ms (на одном ядре))
    print(fДоля параллельной части: {parallel_fraction*100:.0f}%)
    print(f"")
    print(fОбщее время на одном ядре: {total_single_ms} ms)
    print(f" {num_cores} ядер, общее время: {total_multi_ms:.1f} ms(последовательно {serial_work_ms} + параллельно {parallel_work_ms}/{num_cores} = {parallel_time_multi:.1f})")
    print(f" Фактическое ускорение: {speedup:.2f}x")
    print(fПредел Амдала: {amdahl_limit(parallel_fraction):.2f}x)
    return speedup


# ===== Основная программа =====
print("=" * 60)
print(Калькулятор закона Амдала (пример))
print("=" * 60)

# 1. Базовая таблица сравнения
parallel_levels = [0.50, 0.75, 0.90, 0.95, 0.99]
core_list = [1, 2, 4, 8, 16, 32, 64, 128, 256]
print_comparison_table(parallel_levels, core_list)

# 2. Анализ предельной эффективности (на примере 75% параллелизуемости)
analyze_diminishing_returns(0.75, max_cores=64)

# 3. Симуляция конкретной задачи
print("\n" + "=" * 60)
print(" Симуляция реального сценария")
print("=" * 60)

# Сценарий 1: Рендеринг видео (высокопараллельный, 95%)
print("\n[Сцена 1] Задача рендеринга видео")
print(Последовательная часть: чтение файла, инициализация кодера = 10ms)
print(Параллельная часть: покадровый рендеринг = 200ms (одно ядро))
compare_serial_vs_parallel(10, 200, 16)

# Сценарий 2: Транзакция базы данных (умеренно параллелизуемая, 50%)
print("\n[Сценарий 2] Обработка транзакций базы данных")
print(" Последовательная часть: запись логов, управление блокировками = 50ms")
print(Параллельная часть: выполнение запросов = 50ms (одно ядро))
compare_serial_vs_parallel(50, 50, 16)

# Сценарий 3: Чистые вычисления (высокопараллельные, 99%)
print("\n[Сценарий 3] Научные вычисления / матричные операции)
print(Последовательная часть: агрегация результатов = 1ms)
print(Параллельная часть: умножение матриц = 1000ms (одно ядро))
compare_serial_vs_parallel(1, 1000, 16)

# 4. Рекомендуемое количество ядер
print("\n" + "=" * 60)
print(Практический совет: как определить, нужно ли вам больше ядер?)
print("=" * 60)

# Вычислить точку перегиба 'цена-производительность' при различных коэффициентах параллелизма
print(f" {'Коэффициент параллелизма':>10} | {'Ускорение 2 ядер':>10} | {'Ускорение 4 ядер':>10} | {'Ускорение 8 ядер':>10} | {'Ускорение 16 ядер':>9} | {'Рекомендуемое число ядер':>10}")
print("-" * 70)

for pf in [0.50, 0.60, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.99]:
    ratios = []
    prev = 1.0
    recommendations = []
    for cores in [2, 4, 8, 16]:
        sp = amdahl_speedup(pf, cores)
        gain = (sp / prev - 1) * 100  # Процентное улучшение относительно предыдущего уровня
        ratios.append(gain)
        prev = sp
        if gain < 10:
            recommendations.append(cores)

    # Рекомендация: улучшение менее чем на 10% не стоит апгрейда
    suggested = ">=16 ядер" if not recommendations else f{recommendations[0]} ядер достаточно

    print(f" {pf*100:>9.0f}% | {ratios[0]:>9.1f}% | {ratios[1]:>9.1f}% | {ratios[2]:>9.1f}% | {ratios[3]:>8.1f}% | {suggested:>10}")

print()
print(" Практическое правило:")
print(- Ежедневная офисная работа, просмотр веб-страниц (низкая доля параллелизма): достаточно 4-6 ядер)
print(- Компиляция программ, лёгкие игры: 6-8 ядер)
print(- Видеорендеринг, 3D-моделирование: 8-16 ядер)
print(- Научные вычисления, обучение ИИ: чем больше ядер, тем лучше (но также учитывайте видеопамять и пропускную способность памяти))

Запустив приведённый выше код, вы увидите несколько ключевых явлений:

  • Доля параллелизма определяет всё: Задача, которая распараллеливается на 50%, даже с 256 ядрами даёт ускорение менее чем в 2 раза. Но задача, распараллеливаемая на 99%, может достичь почти 14-кратного ускорения на 16 ядрах.
  • Убывающая предельная отдача: Наибольшее улучшение происходит при переходе с 1 ядра на 2 ядра; после этого каждый раз, когда количество ядер удваивается, дополнительное ускорение становится всё меньше и меньше.
  • Снижение эффективности: Когда количество ядер удваивается, эффективность (фактическое ускорение / количество ядер) продолжает снижаться. Именно поэтому слепое наращивание ядер не окупается.

Интерактивная демонстрация: анимация распределения задач по нескольким ядрам

В следующей демонстрации 12 вычислительных задач назначаются1 ядруи4 ядрамдля обработки. В режиме 1 ядра задачи ставятся в очередь и выполняются последовательно; в режиме 4 ядер четыре задачи обрабатываются параллельно одновременно. Обратите внимание на разницу в скорости заполнения полосы выполнения и времени завершения между двумя режимами.

Демонстрация анимации распределения задач по нескольким ядрам (пример)

Очередь задач
Очередь задач в ожидании (всего12одна)
Панель сравнения
Одноядерная панель

1 ядро (последовательная обработка)

Прогресс ядра 1
Многоядерная панель

4 ядра (параллельная обработка)

Прогресс ядра 1
Прогресс ядра 2
Прогресс ядра 3
Прогресс ядра 4
Таймер
Время 1 ядра
0.0 s
Время 4 ядер
0.0 s
Ускорение
—
Текст результата
Кнопка

Многоядерность в реальных приложениях

Как операционная система планирует работу многоядерных систем

Планировщик в операционной системе отвечает за назначение различных процессов и потоков различным ядрам.

Будет учитывать:

  • Балансировка нагрузки: стараться, чтобы каждое ядро было занято, избегая ситуации «одно ядро в беде, многие ядра смотрят».
  • Кэш-аффинность: стараться, чтобы один и тот же поток выполнялся на одном и том же ядре, используя собственный кэш этого ядра.
  • Управление энергопотреблением: при низкой нагрузке отключать некоторые ядра для экономии энергии.

Многоядерность против Hyper-Threading

Помимо физической многоядерности, существует также технология, называемаяHyper-Threading (SMT)。

Hyper-Threading заставляет одно физическое ядро выглядеть как два «логических ядра». Когда один поток ожидает данные (промах кэша), ядро может переключиться на другой поток, чтобы продолжить работу.

РешениеАппаратная стоимостьПрирост производительностиПрименимые сценарии
Физическая многоядерностьВысокая (требуется дублирование всего исполнительного блока)Почти вдвое (в идеальном случае)Длительные задачи с высокой нагрузкой
Гиперпоточность (SMT)Низкие (требуется только дополнительные регистры + логика управления).Около 10%~30%Полностью использовать время ожидания

Многие современные процессоры используют обе технологии одновременно. Например, Intel Core i7 может быть "4 физических ядра + гиперпоточность = 8 логических ядер".


Итоги и проверка

Кратко: многоядерность заключается в размещении нескольких ядер CPU на одном чипе для параллельного выполнения задач. Но закон Амдала гласит, что реальное ускорение зависит от доли задачи, которая может быть распараллелена, — последовательная часть определяет верхний предел ускорения, и чем больше ядер, тем меньше предельная отдача.

Вопросы для самопроверки

  1. Задача на 80% может выполняться параллельно. Какое ускорение даёт закон Амдала на 4-ядерном CPU?

    Ответ: S=0.2, P=0.8, N=4. Ускорение = 1/(0.2 + 0.8/4) = 1/(0.2 + 0.2) = 1/0.4 = 2.5x. Иначе говоря, 4 ядра дают всего 2.5-кратное ускорение, эффективность всего 62.5%.

  2. Если задача может быть распараллелена почти на 100%, каков теоретический верхний предел? Почему на практике он всё равно недостижим?

    Ответ: Теоретический верхний предел бесконечен (ускорение = числу ядер). Но на практике он ограничен: (1) накладными расходами на разделение и объединение задач; (2) задержками синхронизации и связи между ядрами; (3) конкуренцией за общие ресурсы (например, пропускную способность памяти).

  3. Почему современные серверные процессоры могут иметь 64 или даже 128 ядер, а персональные компьютеры обычно только от 4 до 16 ядер?

    Ответ: Запросы, обрабатываемые серверами (такие как веб-запросы и запросы к базам данных), естественно обладают высокой степенью параллелизма — каждый пользовательский запрос независим, поэтому доля распараллеливаемой части близка к 100%. С другой стороны, повседневные задачи на персональных компьютерах (например, отрисовка в браузере и обработка документов Office) имеют ограниченный параллелизм; слишком много ядер не принесёт пользы, а лишь увеличит энергопотребление и стоимость.

Другие расширения