Зачем нужен 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 Python | ndarray 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.