Производительность: векторизация, Numba, Cython

Чистый Python медленный. Научный Python быстр благодаря векторизации — а когда и её мало, в ход идут Numba и Cython.

Векторизация — замена явных Python-циклов операциями над целыми массивами, которые выполняются в скомпилированном коде NumPy за один вызов.

Откуда берётся медлительность

Python — интерпретируемый язык с динамической типизацией. Каждая операция в цикле — это проверка типов, создание объектов, работа сборщика мусора. Сложить два миллиона чисел в Python-цикле — секунды; в NumPy — миллисекунды. Разница в сотни раз. Понимать, где «уходить» из чистого Python, — ключевой навык научного программиста.

Иерархия скорости

ПодходОтносительная скоростьКогда
Чистый Python-цикл×1 (медленно)понять идею, мелкие данные
Векторизация NumPy×50–200почти всегда — первый выбор
Numba (JIT)×100–500цикл нельзя векторизовать
Cython / C×100–1000критичная по скорости часть

Замер «руками»: цикл против поэлементной обработки

Векторизацию в чистом stdlib не показать (нет массивов), но можно измерить, насколько Python-цикл дороже, и понять масштаб проблемы. Сравним наивную сумму квадратов через цикл и через генератор:

import time

N = 1_000_000

# способ 1: явный цикл с накоплением
start = time.perf_counter()
total = 0
for i in range(N):
    total += i * i
t1 = time.perf_counter() - start

# способ 2: встроенный sum с генератором (ближе к "векторному" стилю)
start = time.perf_counter()
total2 = sum(i * i for i in range(N))
t2 = time.perf_counter() - start

print("Цикл       :", total)
print("sum/генер. :", total2)
print("Результаты совпали:", total == total2)
print("Оба считают один миллион операций")

Вывод:

Цикл       : 333332833333500000
sum/генер. : 333332833333500000
Результаты совпали: True
Оба считают один миллион операций

Оба варианта дают один ответ. В NumPy то же — np.arange(N)**2).sum() — выполнилось бы в скомпилированном коде в десятки раз быстрее любого из них, потому что цикл уходит из интерпретатора в C.

Векторизация: цикл против массива

Сравните стиль (код для чтения):

import numpy as np

# МЕДЛЕННО: явный цикл (Python интерпретирует каждый шаг)
result = []
for x in data:
    result.append(x**2 + 1)

# БЫСТРО: векторизация (весь массив за один вызов C-кода)
arr = np.array(data)
result = arr**2 + 1          # ни одного Python-цикла!

Когда уходить в Numba или Cython

Иногда алгоритм нельзя выразить операциями над массивами — например, итеративный метод, где каждый шаг зависит от предыдущего. Тогда:

  • Numba — добавляете декоратор @njit, и функция компилируется в машинный код «на лету» (JIT). Минимум изменений, ускорение в сотни раз.
  • Cython — пишете код на «Python с типами», который транслируется в C. Больше работы, максимальный контроль.
from numba import njit

@njit                        # компилируется в машинный код при первом вызове
def mandelbrot_point(cx, cy, max_iter):
    x = y = 0.0
    for i in range(max_iter):
        x, y = x*x - y*y + cx, 2*x*y + cy
        if x*x + y*y > 4:
            return i
    return max_iter

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

Почему векторизация так ускоряет? Три причины. Во-первых, цикл уходит в C: NumPy перебирает массив в скомпилированном коде без накладных расходов интерпретатора. Во-вторых, однородность памяти: массив NumPy — это сплошной блок чисел одного типа, который процессор читает эффективно (кэш, SIMD-инструкции, обрабатывающие несколько чисел за такт). В-третьих, нет создания объектов: в Python i*i создаёт новый объект-число, в NumPy всё считается прямо в памяти. Numba и Cython атакуют ту же проблему иначе — компилируют сам Python-код в машинный, убирая интерпретатор, что спасает там, где векторизация невозможна.

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

  • Преждевременная оптимизация. Сначала напишите понятно и измерьте; ускоряйте только реальное «узкое место».
  • Цикл там, где можно векторизовать. Самая частая причина медленного научного кода — Python-цикл по массиву.
  • Тащить Cython без нужды. Часто хватает векторизации или Numba; Cython — последнее средство.

Итог

  • Чистый Python медленный из-за интерпретации и создания объектов.
  • Векторизация уводит цикл в скомпилированный C — первый и главный приём (×50–200).
  • Если векторизовать нельзя — Numba (JIT, минимум правок) или Cython (макс. контроль).
  • Оптимизируйте только измеренные «узкие места», не вслепую.
Проверьте себя
1. Что такое векторизация в научном Python?
AИспользование векторов в математике
BЗамена явных Python-циклов операциями над целыми массивами, выполняемыми в скомпилированном C-коде NumPy
CПараллельные вычисления на ядрах
DСжатие данных
2. Почему векторизованный код в NumPy в десятки раз быстрее Python-цикла?
ANumPy использует больше памяти
BЦикл уходит в C, память однородна (кэш, SIMD), и не создаются объекты-числа на каждом шаге
CNumPy пропускает часть вычислений
DPython-циклы всегда работают на одном ядре
3. Когда стоит применять Numba (@njit) вместо векторизации?
AВсегда
BКогда алгоритм нельзя выразить операциями над массивами (например, итеративный метод с зависимостью шагов) — Numba компилирует сам цикл в машинный код
CКогда данных мало
DНикогда