Зачем нужен NumPy: векторизация против циклов

Урок объясняет, откуда у NumPy берётся скорость и почему обычные списки Python проигрывают ему на числовых данных.

Векторизация — выполнение одной операции сразу над всем массивом без явного Python-цикла; именно она делает NumPy быстрым.

Проблема: Python-списки и числа

Список Python — это универсальный контейнер. Он может хранить что угодно: числа, строки, другие списки, объекты. За эту гибкость приходится платить. Каждый элемент списка — это отдельный объект Python в куче, а сам список хранит лишь указатели на эти объекты, разбросанные по памяти. Когда вы складываете два списка чисел поэлементно, интерпретатор для каждой пары вынужден: разыменовать указатель, проверить тип объекта, извлечь из него C-число, выполнить сложение, упаковать результат обратно в новый объект int или float. Десятки машинных операций на одно сложение — и всё это в медленном цикле байт-кода Python.

NumPy решает проблему радикально. Массив ndarray хранит не объекты, а «сырые» числа одного типа, уложенные подряд в один непрерывный блок памяти. Сложение двух таких массивов выполняется заранее скомпилированным циклом на C, который проходит по памяти линейно, без проверок типов на каждом шаге, и зачастую использует SIMD-инструкции процессора (одна команда — несколько чисел сразу). Отсюда и ускорение в десятки, а на больших данных — в сотни раз.

Цикл против векторизации: смысл на чистом Python

В браузерной песочнице NumPy нет, поэтому прочувствуем идею на стандартном Python. Сначала — «ручной» способ, как мы складывали бы два набора чисел без библиотек: явный цикл, поэлементно.

a = [1, 2, 3, 4, 5]
b = [10, 20, 30, 40, 50]

# Поэлементное сложение вручную: явный цикл
result = []
for x, y in zip(a, b):
    result.append(x + y)

print(result)

Вывод:

[11, 22, 33, 44, 55]

Здесь мы сами управляем циклом: тело x + y выполняется интерпретатором на каждой итерации. Логика верна, но именно этот Python-цикл и тормозит на больших данных. Важная деталь: попытка просто написать a + b для списков не сложит числа поэлементно — она склеит списки. Это частый источник путаницы у новичков:

a = [1, 2, 3]
b = [10, 20, 30]
print(a + b)        # склейка, а не сумма!
print(a * 2)        # повтор, а не умножение каждого элемента

Вывод:

[1, 2, 3, 10, 20, 30]
[1, 2, 3, 1, 2, 3]

А вот как тот же замысел выглядит в NumPy. Код ниже не запускается в песочнице (нужен numpy), поэтому он дан для чтения с готовым выводом — обратите внимание, цикла нет вовсе:

import numpy as np

a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + b)   # векторное сложение, цикл скрыт внутри C
print(a * 2)   # умножение каждого элемента
print(a ** 2)  # возведение в квадрат поэлементно

Вывод:

[11 22 33 44 55]
[ 2  4  6  8 10]
[ 1  4  9 16 25]

Один и тот же оператор + в NumPy означает «сложи поэлементно весь массив». Цикл никуда не делся — он просто переехал из вашего Python-кода в скомпилированное ядро NumPy, где работает на порядки быстрее. Это и есть векторизация: вы описываете, что сделать со всем массивом, а не как перебрать его по элементам.

Почему непрерывная память — это так важно

Скорость NumPy держится на трёх китах: однородность типа, непрерывность памяти и скомпилированные циклы. Разберём, почему непрерывность критична. Современный процессор не читает память по одному байту — он подтягивает её блоками (кэш-линиями, обычно по 64 байта) в быстрый кэш. Если данные лежат подряд, то, прочитав одно число, процессор бесплатно получает в кэш и соседние — следующие итерации цикла берут их мгновенно. Это называется пространственной локальностью.

В списке Python числа — отдельные объекты в произвольных местах кучи. Каждое обращение — это прыжок по памяти, промах кэша, ожидание загрузки из основной (медленной) RAM. В NumPy же массив из миллиона float64 — это ровно 8 000 000 байт подряд, идеальная пища для кэша и для векторных инструкций процессора.

Свойствоlist Pythonndarray NumPy
Что хранитуказатели на объектысырые числа подряд
Типы элементовлюбые, разныеодин общий dtype
Память на 1 млн intдесятки МБ~8 МБ (int64)
Поэлементная арифметикацикл Pythonцикл на C (вектор)
Локальность кэшаплохаяотличная

Грубая оценка выигрыша «на пальцах»

Чтобы ощутить масштаб, прикинем на чистом Python, сколько «элементарных шагов» делает интерпретатор в обычном цикле. Это не бенчмарк NumPy (его в песочнице не измерить), а иллюстрация, почему перенос работы в C даёт выигрыш.

n = 1_000_000

# Сумма квадратов «по-питоновски»
total = 0
for i in range(n):
    total += i * i

print("Сумма квадратов:", total)
# Каждая итерация — это разбор байт-кода, создание объектов int, и т.д.
# NumPy сделал бы (np.arange(n) ** 2).sum() одним вызовом в C.
print("Итераций в Python-цикле:", n)

Вывод:

Сумма квадратов: 333332833333500000
Итераций в Python-цикле: 1000000

Миллион итераций интерпретатора против одного вызова скомпилированной функции — вот источник разницы в скорость. На практике (np.arange(n) ** 2).sum() работает в десятки раз быстрее этого цикла и пишется в одну строку.

Три уровня, на которых NumPy выигрывает

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

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

Третий уровень — аппаратное ускорение. Уложенные подряд числа одного типа процессор обрабатывает векторными инструкциями (SSE, AVX): одна команда складывает сразу 4, 8 или 16 чисел. Плюс предсказуемый линейный доступ к памяти позволяет аппаратному предзагрузчику подтягивать данные в кэш заранее. Списки Python принципиально не дают такой возможности — их элементы разбросаны по куче.

Эти три уровня перемножаются. Поэтому реальное ускорение — не «на 20%», а в десятки и сотни раз: каждый уровень снимает свой пласт накладных расходов.

Что значит «массив фиксированного типа»

У этой скорости есть цена, и важно понимать её заранее. Массив NumPy однороден и имеет фиксированный размер. Вы не можете в один массив int64 положить строку или дробь без приведения типа, не можете дёшево добавить элемент в середину, не можете хранить «дырки». Список Python всё это умеет, потому что он — гибкий контейнер указателей. Выбирая NumPy, вы сознательно меняете гибкость на скорость и компактность. Для числовых вычислений это почти всегда правильный размен; для разнородных структурированных данных — нет.

Когда NumPy не нужен

Векторизация хороша не всегда. Если у вас разнородные данные (смесь строк, дат, вложенных структур), маленькие коллекции из пары элементов, или логика принципиально последовательная (результат шага зависит от предыдущего и не выражается через массивные операции) — обычные списки и встроенные функции Python проще и достаточно быстры. Более того, для совсем маленьких массивов NumPy может оказаться медленнее списков: на создание ndarray и вызов функции тратится фиксированный оверхед, который окупается лишь на достаточном объёме данных. NumPy раскрывается на больших однородных числовых массивах, где одну и ту же операцию надо применить ко множеству элементов, и где фиксированный оверхед вызова размазывается по миллионам элементов.

NumPy в реальном стеке: почему это must-have

Стоит понимать, что NumPy — не просто «ускоритель циклов», а де-факто язык обмена числовыми данными во всей экосистеме Python. Когда вы загружаете таблицу в pandas, под каждой колонкой лежит NumPy-массив. Когда обучаете модель в scikit-learn, она ожидает на входе ndarray признаков. Когда строите график в matplotlib, по осям откладываются NumPy-массивы. Даже более новые фреймворки глубокого обучения сознательно копируют интерфейс NumPy для своих тензоров, чтобы код выглядел знакомо. Освоив NumPy, вы получаете не одну библиотеку, а общий словарь, на котором говорит весь научный и аналитический Python.

Это объясняет, почему вложение времени в NumPy окупается особенно хорошо. Навыки векторизации, индексации, broadcasting и работы с осями переносятся практически без изменений на pandas, на тензоры нейросетей, на массивы изображений. Вы учите не «ещё одну библиотеку», а фундаментальную модель работы с числовыми данными, которая лежит в основе десятков инструментов.

Подводные камни

  • Думать «поэлементно» по привычке. Новички пишут for-цикл по NumPy-массиву — и теряют всю скорость. Если видите цикл по элементам массива, спросите себя: нельзя ли выразить это векторной операцией?
  • Путать + для списков и для массивов. Для списков это конкатенация, для массивов — поэлементная сумма. Один и тот же оператор, разный смысл.
  • Создавать массив в цикле через append. У ndarray нет дешёвого append: каждое добавление копирует весь массив. Накапливайте в списке Python, а в массив превращайте один раз в конце.

Лучшие практики

  • Импортируйте как import numpy as np — это негласный стандарт, его понимает всё сообщество.
  • Стремитесь выразить вычисление как операции над целыми массивами, а не как перебор элементов.
  • Держите в массиве данные одного смысла и типа; разнородное — это работа для pandas или обычных структур.

Итог

  • NumPy быстр, потому что хранит числа одного типа подряд в памяти и выполняет операции скомпилированным циклом на C.
  • Векторизация — это «опиши операцию над всем массивом», а не «перебери элементы вручную».
  • Непрерывная память даёт локальность кэша и работу SIMD — отсюда ускорение в десятки и сотни раз.
  • Выигрыш проявляется на больших однородных числовых данных; для мелочи и разнородного хватит чистого Python.
Проверьте себя
1. Почему NumPy выполняет поэлементное сложение массивов быстрее, чем цикл по спискам Python?
AПотому что NumPy использует многопоточность по умолчанию
BПотому что числа хранятся подряд одним типом и операция идёт скомпилированным циклом на C без проверок типов на каждом шаге
CПотому что списки Python вообще не умеют хранить числа
DПотому что NumPy кэширует результаты предыдущих сложений
2. Что выведет выражение [1, 2, 3] + [10, 20, 30] для обычных списков Python?
A[11, 22, 33]
BОшибку типа
C[1, 2, 3, 10, 20, 30]
D[10, 40, 90]
3. В каком случае переход на NumPy скорее всего НЕ даст ощутимой пользы?
AПоэлементные операции над миллионом чисел
BМатричные вычисления над большими таблицами чисел
CОбработка маленькой разнородной коллекции из строк, дат и вложенных структур
DПрименение одной математической функции ко всему большому массиву
Поддержать проект