ndarray изнутри: shape, dtype, strides и порядок памяти
Урок разбирает анатомию массива NumPy: единый буфер данных и метаданные, которые превращают линейную память в N-мерную таблицу.
ndarray — это пара из непрерывного блока байтов (данные) и набора метаданных (
shape,dtype,strides), которые описывают, как читать этот блок как N-мерный массив.
Память одномерна, а массивы — нет
Любая память компьютера линейна: это просто длинная лента байтов. Но мы хотим работать с матрицами, тензорами, изображениями — многомерными структурами. NumPy решает это элегантно: данные всегда лежат в одном непрерывном одномерном блоке, а «многомерность» — это лишь способ интерпретации, заданный метаданными. Сам блок данных при многих операциях вообще не трогается; меняются только метаданные. Это и есть секрет дешёвых reshape, транспонирования и срезов.
У массива есть несколько ключевых атрибутов. Рассмотрим их на массиве 3×4 (код для чтения, в песочнице numpy нет):
import numpy as np
a = np.array([[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]], dtype=np.int64)
print(a.shape) # форма: размеры по каждой оси
print(a.ndim) # число измерений (осей)
print(a.size) # всего элементов
print(a.dtype) # тип элемента
print(a.itemsize) # байт на один элемент
print(a.nbytes) # всего байт под данные
print(a.strides) # шаги в байтах по каждой оси
Вывод:
(3, 4) 2 12 int64 8 96 (32, 8)
| Атрибут | Значение | Смысл |
shape | (3, 4) | 3 строки, 4 столбца |
ndim | 2 | двумерный массив |
size | 12 | 3 × 4 элемента |
itemsize | 8 | int64 = 8 байт |
nbytes | 96 | 12 × 8 байт данных |
strides | (32, 8) | шаг по строке и по столбцу |
Зачем вообще знать про метаданные
Может показаться, что shape, dtype и strides — это внутренняя кухня, до которой пользователю нет дела. На практике именно понимание этих атрибутов отличает того, кто пишет быстрый и корректный код, от того, кто борется с загадочными багами. Когда вы понимаете, что массив — это «буфер плюс инструкция, как его читать», вам становятся очевидны три вещи: почему одни операции мгновенны, а другие копируют гигабайты; почему срез может незаметно изменить оригинал; и почему один и тот же массив бывает «быстрым» и «медленным» в зависимости от того, вдоль какой оси вы его обходите. Все три темы — сквозные для этого курса, и все они растут из устройства ndarray.
Strides: как индекс превращается в адрес
strides — самое важное и самое недооценённое понятие. Это кортеж: сколько байтов нужно пропустить в памяти, чтобы перейти к следующему элементу вдоль каждой оси. Адрес элемента с индексом (i, j) вычисляется по формуле:
смещение = i · strides[0] + j · strides[1]
Для нашего массива int64 размером 3×4 в обычном (C) порядке: strides = (32, 8). Чтобы перейти к соседнему элементу в строке (j → j+1), сдвигаемся на 8 байт — ровно один int64, ведь в памяти строка лежит подряд. Чтобы перейти к следующей строке (i → i+1), сдвигаемся на 32 байта — это 4 элемента × 8 байт, то есть перепрыгиваем целую строку. Посчитаем смещение элемента (1, 2) — это число 7:
# Память массива 3x4 как плоская лента (C-порядок)
flat = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
itemsize = 8 # int64
strides = (4 * itemsize, itemsize) # (32, 8)
i, j = 1, 2 # хотим элемент a[1, 2]
offset_bytes = i * strides[0] + j * strides[1]
flat_index = offset_bytes // itemsize
print("Смещение в байтах:", offset_bytes)
print("Индекс в плоской ленте:", flat_index)
print("Значение:", flat[flat_index])
Вывод:
Смещение в байтах: 48 Индекс в плоской ленте: 6 Значение: 7
Мы воспроизвели логику NumPy руками: зная strides, любой N-мерный индекс мгновенно переводится в позицию в одномерном блоке. Именно поэтому транспонирование бесплатно — NumPy просто меняет местами числа в кортеже strides, не трогая данные. Срез тоже дёшев: он создаёт новый объект с другим смещением начала и, возможно, другими strides, но указывает на тот же буфер.
Что значит «дешёвый» reshape
Раз форма — это лишь интерпретация общего буфера, многие операции изменения формы сводятся к замене кортежа shape и пересчёту strides, без касания самих данных. Превратить вектор из 12 чисел в матрицу 3×4 — значит сказать «теперь читай этот же буфер как 3 строки по 4 элемента». Данные не двигаются ни на байт. Поэтому reshape непрерывного массива практически бесплатен независимо от его размера: хоть десять элементов, хоть десять миллионов. Эту идею мы разовьём в разделе про форму, но корень её — здесь: shape и strides отделены от данных, и менять их дёшево.
C-порядок против Fortran-порядка
Один и тот же блок данных можно «читать» по-разному. Есть две классические схемы раскладки:
- C-порядок (row-major), по умолчанию: быстрее всего меняется последняя ось. Строки лежат подряд: сначала вся первая строка, потом вся вторая. Так устроен язык C.
- Fortran-порядок (column-major): быстрее всего меняется первая ось. Столбцы лежат подряд. Так устроены Fortran и MATLAB.
Для матрицы 2×3 со значениями 1..6 разница видна в порядке байтов в памяти:
matrix = [[1, 2, 3],
[4, 5, 6]]
# C-порядок: строка за строкой
c_order = []
for row in matrix:
for x in row:
c_order.append(x)
# Fortran-порядок: столбец за столбцом
f_order = []
for col in range(3):
for row in range(2):
f_order.append(matrix[row][col])
print("C-порядок (row-major): ", c_order)
print("F-порядок (col-major): ", f_order)
Вывод:
C-порядок (row-major): [1, 2, 3, 4, 5, 6] F-порядок (col-major): [1, 4, 2, 5, 3, 6]
Это та же матрица и те же числа, но порядок в памяти разный. В NumPy порядок задаётся параметром order='C' или order='F' при создании. Логически массивы неотличимы — отличается лишь раскладка байтов, а значит, и strides.
Почему порядок памяти влияет на скорость
Если вы суммируете матрицу по строкам, а она лежит в C-порядке, то каждая строка читается линейно — идеально для кэша. Если же суммировать по столбцам тот же C-массив, обращения «прыгают» через всю строку (большой stride), кэш промахивается, и операция замедляется. Поэтому общее правило: обходите массив вдоль той оси, по которой он непрерывен. Для C-массива самая «дешёвая» ось — последняя.
Насколько это важно на практике? На больших матрицах (миллионы элементов) разница между обходом «вдоль» и «поперёк» непрерывной оси легко достигает нескольких раз — не потому что операций больше, а потому что процессор простаивает в ожидании данных из медленной основной памяти. Кэш-линия в типичном процессоре — 64 байта, то есть 8 чисел float64. Читая непрерывный массив, вы используете все 8 за одну загрузку. Читая «поперёк» с большим шагом, вы из каждой загруженной линии берёте лишь одно число, а остальные 7 тратятся впустую. Эффективная пропускная способность памяти падает в разы. Это незаметно на маленьких данных, но становится определяющим на больших — и объясняет, почему две математически одинаковые операции могут отличаться по времени в несколько раз.
Отсюда следует ещё одно правило, к которому мы вернёмся в разделе про производительность: если алгоритм позволяет, организуйте вычисления так, чтобы внутренний (самый частый) цикл шёл вдоль непрерывной оси. Иногда ради этого выгодно заранее транспонировать данные или хранить их в Fortran-порядке — если основная нагрузка идёт по столбцам.
Связанное понятие — непрерывность (contiguity). Свежесозданный массив непрерывен. Но срез вроде a[:, ::2] или транспонирование делают его «прерывистым»: данные те же, а strides уже не совпадают с простой раскладкой. Проверить можно через флаги:
import numpy as np
a = np.arange(12).reshape(3, 4)
print(a.flags['C_CONTIGUOUS']) # True — обычный массив
print(a.T.flags['C_CONTIGUOUS']) # False — транспонированный
print(a.T.flags['F_CONTIGUOUS']) # True — зато он Fortran-непрерывный
print(a.T.strides) # strides просто переставлены
Вывод:
True False True (8, 32)
Обратите внимание: у a.T strides — это (8, 32), то есть исходные (32, 8), переставленные местами. Никакого копирования данных при транспонировании не произошло.
Зачем нужны нестандартные strides: трюк с нулевым шагом
Самое красивое следствие модели strides — возможность задать нулевой шаг по оси. Если stride вдоль оси равен нулю, то увеличение индекса по этой оси не двигает указатель в памяти: все «разные» элементы вдоль неё указывают на одно и то же место. Так NumPy реализует broadcasting (о нём — целый раздел далее): чтобы «растянуть» строку на всю матрицу, он не копирует её сто раз, а создаёт представление с нулевым stride по оси повторения. Память не растёт, а логически массив выглядит как полноразмерный. Это превращает потенциально дорогую операцию (размножить вектор) в бесплатную игру с метаданными.
Тот же механизм объясняет, почему NumPy так экономен. Многие операции, которые в наивной реализации потребовали бы копирования, в NumPy сводятся к перенастройке strides: транспонирование меняет их порядок, broadcasting обнуляет некоторые из них, срез с шагом умножает их. Данные при этом остаются на месте. Понимание, что strides — это «ручка управления» тем, как читается общий буфер, даёт интуицию для всего остального материала курса.
Подводные камни
- Считать strides «индексами». Strides измеряются в байтах, а не в элементах. Для перевода в элементы делите на
itemsize. - Ждать непрерывности после среза/транспонирования. Такие массивы часто не C-непрерывны; функции, требующие непрерывности, могут втихую сделать копию через
np.ascontiguousarray. - Обходить C-массив по «дорогой» оси. Суммирование C-массива по столбцам медленнее, чем по строкам, из-за промахов кэша при больших данных.
Лучшие практики
- Запомните формулу адреса через strides — она объясняет, почему reshape, транспонирование и срезы дёшевы.
- Не меняйте
orderбез причины: по умолчанию C-порядок, и большинство кода рассчитано на него. - Если профилировщик показывает, что узкое место — обход массива, проверьте, идёте ли вы вдоль непрерывной оси.
Итог
- ndarray = один непрерывный блок данных + метаданные shape, dtype, strides.
- strides (в байтах) переводят N-мерный индекс в смещение в одномерном буфере — отсюда дешёвые reshape и транспонирование.
- C-порядок раскладывает данные по строкам, Fortran-порядок — по столбцам; логически массив один, раскладка разная.
- Скорость зависит от того, идёте ли вы вдоль непрерывной оси: для C-массива это последняя ось.