Learning Platform
Глоссарий Troubleshooting
Урок 04.04 · 28 мин
Начальный
Branch predictionCPU pipelinePerformance

CPU pipeline: 14 шагов одной инструкции

Современные CPU выполняют инструкции конвейерно. Одна инструкция (например, add, mov, cmp) проходит через ~10-20 стадий: fetch, decode, register rename, schedule, execute, write-back, retire. Каждая стадия занимает 1 такт.

Чтобы не простаивать, CPU держит несколько инструкций одновременно в разных стадиях pipeline. К тому моменту, когда первая инструкция дошла до execute, вторая на decode, третья на fetch. Это даёт throughput до 4-5 инструкций за такт на современных CPU.

CPU pipeline: 5 инструкций одновременно

Pipeline превращает 'одна инструкция за 14 тактов' в 'одна инструкция за такт'. Только если есть, что подавать дальше.

t=1i1: fetchПервая инструкция начинает идти по pipeline
t=2i1: decode, i2: fetchВторая стартует, пока первая декодируется
t=3i1: exec, i2: decode, i3: fetchТри инструкции на разных стадиях
t=4i1: WB, i2: exec, i3: decode, i4: fetchЧетыре одновременно
t=5i1: retire ... i5: fetchPipeline полностью загружен — 1 инструкция/такт

Проблема: что fetch-ить после ветвления?

Pipeline работает идеально, пока CPU знает, какая инструкция следующая. Но что делать при ветвлении?

if x > 100:
    do_a()
else:
    do_b()

На уровне ассемблера это:

cmp x, 100
je else_branch
call do_a
jmp end
else_branch:
call do_b
end:

После cmp и je (jump if equal) CPU не знает, куда дальше — пока je не выполнится в стадии execute. Но что делать в stages fetch, decode? Что fetch-ить?

Если ждать — pipeline stalls (простой). Если ошибиться — приходится сбросить pipeline (flush) и начать заново с правильной ветки. Flush стоит ~14-20 тактов на современных CPU.

Решение: branch predictor

CPU оснащён branch predictor — отдельным блоком, который угадывает, куда пойдёт ветвление, ещё до его выполнения. Predictor отслеживает историю каждой условной инструкции: «эта ветка обычно идёт по true, эта — по false, эта чередуется».

Современные predictors имеют точность ~95-99% на типичных программах. Это значит, что pipeline почти всегда заполнен правильно — без stalls.

Branch prediction: успех vs провал

Удачное предсказание = pipeline без перерывов. Промах = 14-20 тактов в утиль.

Predictor угадалPipeline продолжает работатьСпекулятивные инструкции уже частично выполнены — просто принимаем их результаты
0 тактов потериFree winПолный throughput, как будто условия не было
Predictor ошибсяPipeline flushВсе спекулятивно начатые инструкции отбрасываются. Pipeline пустеет.
14-20 тактов потериBranch misprediction penaltyПерезаполнить pipeline с правильного места

Стоимость misprediction:

  • 5 ns при 14-стадийном pipeline на 3 GHz CPU.
  • 10-20% performance hit на ветвистом коде, если predictor работает плохо.

Знаменитый эксперимент: sorted vs unsorted

В 2012 на Stack Overflow появился знаменитый вопрос: «Почему обработка sorted-массива быстрее, чем unsorted?». Это самый цитируемый пример эффекта branch prediction.

Идея: пройти по массиву и сложить только элементы больше некоего порога.

import timeit
import random
import array

def sum_above(arr, threshold):
    s = 0
    for x in arr:
        if x > threshold:
            s += x
    return s

N = 1_000_000
threshold = 128

# Случайные данные
random.seed(42)
unsorted = array.array('i', [random.randint(0, 255) for _ in range(N)])

# Те же данные, но отсортированные
sorted_arr = array.array('i', sorted(unsorted))

t_unsorted = min(timeit.repeat(
    lambda: sum_above(unsorted, threshold),
    number=3, repeat=3
)) / 3

t_sorted = min(timeit.repeat(
    lambda: sum_above(sorted_arr, threshold),
    number=3, repeat=3
)) / 3

print(f"Unsorted: {t_unsorted*1000:.0f} ms")
print(f"Sorted:   {t_sorted*1000:.0f} ms")
print(f"Speedup:  {t_unsorted/t_sorted:.2f}x")

Почему sorted быстрее? Потому что в unsorted массиве условие x > 128 непредсказуемо чередуется true/false. Branch predictor проседает к ~50% accuracy (хуже монетки), и каждый misprediction — 14-20 тактов в утиль.

В sorted массиве условие сначала всегда false (маленькие значения), потом всегда true (большие). Predictor угадывает безошибочно — pipeline никогда не сбрасывается.

WARNING

Чистый Python настолько медленный сам по себе, что overhead интерпретатора маскирует эффект branch prediction. Типичная разница на Python — 1.1-1.4x. На C/Rust/numpy с тем же кодом разница доходит до 3-6x. Чтобы увидеть чистый эффект, перепишем через numpy в следующем замере.

Воспроизведение через numpy

Numpy выполняет операции в C, без overhead интерпретатора — там эффект branch prediction виден чище. Хотя numpy сам пытается использовать векторизованные операции (SIMD), где ветвления нет, но в более сложных случаях эффект есть:

import numpy as np
import time

N = 10_000_000
np.random.seed(42)

unsorted = np.random.randint(0, 256, size=N, dtype=np.int32)
sorted_arr = np.sort(unsorted.copy())

# Условная сумма через numpy boolean masking
def sum_above_np(arr, threshold):
    return int(arr[arr > threshold].sum())

# Замер
times_u = []
times_s = []
for _ in range(5):
    t = time.perf_counter()
    sum_above_np(unsorted, 128)
    times_u.append(time.perf_counter() - t)

    t = time.perf_counter()
    sum_above_np(sorted_arr, 128)
    times_s.append(time.perf_counter() - t)

print(f"Unsorted (numpy mask): {min(times_u)*1000:.1f} ms")
print(f"Sorted   (numpy mask): {min(times_s)*1000:.1f} ms")
print(f"Speedup:               {min(times_u)/min(times_s):.2f}x")

На numpy mask-based approach разница часто меньше, потому что numpy использует SIMD-инструкции, которые умеют делать parallel branch through masking. Но если написать скалярный numpy с ручным циклом — эффект ярче. Или через ctypes:

# Альтернатива через ctypes (если знакомо)
# Можно скомпилировать .c-файл с тем же алгоритмом и вызвать оттуда
# На C разница sorted vs unsorted легко 4-6x

Что это значит для DE

Branch prediction в DE-задачах проявляется в неожиданных местах:

  1. if/else в hot loop — фильтрация в pipeline. Если условие непредсказуемо — медленнее.
  2. JOIN на random data — каждое сравнение в hash bucket — условный jump. Branch predictor проседает.
  3. Sort небольшими порциями + scan — иногда быстрее full scan, потому что условия в sorted predictable.
  4. Sparse data processing — если 95% значений — нули, condition if x != 0 сильно предсказуемо.

Это объясняет, почему колоночные базы (ClickHouse, Snowflake, DuckDB) так быстрые на аналитических запросах: они работают векторизованно (без явных if-ов в hot path) и применяют операции через bitmask, где branch prediction не нужен.

Попробуй сам: насколько шумной должна быть условие, чтобы предсказатель сдался?

Поэкспериментируем: для разных «процентов true» в condition — сколько занимает работа?

import timeit
import random
import array

def sum_conditional(arr):
    s = 0
    for x in arr:
        if x == 1:    # условие — данные содержат много нулей или единиц
            s += 1
    return s

N = 1_000_000

for true_pct in [0, 10, 30, 50, 70, 90, 100]:
    random.seed(42)
    arr = array.array('i', [1 if random.random() < true_pct/100 else 0 for _ in range(N)])
    t = min(timeit.repeat(lambda: sum_conditional(arr), number=3, repeat=3)) / 3
    print(f"true_pct={true_pct:>3}%: {t*1000:>5.0f} ms")
TIP

Угадайте: при каком проценте true работа самая медленная?

Типичный вывод (Python):

true_pct=  0%:   72 ms  -- predictor легко угадывает 'всегда false'
true_pct= 10%:   77 ms  -- редкое true, легко предсказать 'почти всегда false'
true_pct= 30%:   85 ms  -- начинает быть сложно
true_pct= 50%:   88 ms  -- самое тяжёлое: 50/50, predictor бессилен
true_pct= 70%:   84 ms  -- симметрично 30%
true_pct= 90%:   76 ms  -- легко 'почти всегда true'
true_pct=100%:   72 ms  -- легко 'всегда true'

Видите U-образную кривую с минимумом в 50%? Это прямое наблюдение branch prediction. Predictor хорошо работает на extreme bias (0 или 100%) и сдаётся в середине. На Python эффект слабый (~20%); на C это были бы те самые 3-6x.

Шпаргалка по branch prediction

Что нужно запомнить

Эта тема часто всплывает в обсуждениях производительности columnar databases и SIMD.

Pipeline ~14 стадиймного инструкций одновременноThroughput до 4-5 инструкций за такт
Branch predictorугадывает ветвление наперёдТочность 95-99% на типичных программах
Misprediction14-20 тактов в утильPipeline flush — спекулятивные инструкции отбрасываются
Sorted data быстрее на условияхpredictor легко угадываетВсе true или все false подряд — 100% accuracy
50/50 conditions медленнее всегохуже монеткиPredictor не может найти pattern
Columnar DB / SIMDизбегают branches через masksVectorized execution — branches заменяются bitwise операциями

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

Vectorized execution: как columnar DB избегает branch penalties
Проверка знанийKnowledge check
Почему обработка sorted массива в условном цикле может быть быстрее обработки тех же данных в unsorted порядке?
ОтветAnswer
Это эффект branch prediction. CPU работает конвейером (pipeline) — много инструкций одновременно в разных стадиях. После условного перехода CPU должен угадать, куда идти дальше, и спекулятивно начать выполнять. В sorted данных условие 'x > threshold' идёт сначала всегда false, потом всегда true — branch predictor легко это угадывает, accuracy ~100%, pipeline никогда не сбрасывается. В unsorted условие случайно чередуется — predictor бессилен, accuracy падает до ~50% (хуже монетки), каждая ошибка стоит pipeline flush в 14-20 тактов. На C/Rust эффект 3-6x на одинаковом big-O. На чистом Python эффект сглажен overhead интерпретатора (~1.2x).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что такое CPU pipeline и зачем CPU использует branch prediction?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5