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 столбца
ndim2двумерный массив
size123 × 4 элемента
itemsize8int64 = 8 байт
nbytes9612 × 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-массива это последняя ось.
Проверьте себя
1. Что хранит атрибут strides у ndarray?
AЧисло элементов вдоль каждой оси
BСколько байтов нужно пропустить в памяти, чтобы перейти к следующему элементу вдоль каждой оси
CАдреса всех элементов массива
DТип данных каждого измерения
2. Почему транспонирование массива в NumPy выполняется практически мгновенно?
ANumPy заранее хранит транспонированную копию
BДанные физически переписываются очень быстрым циклом на C
CМеняются только метаданные (переставляются strides), сам блок данных не трогается
DТранспонирование всегда возвращает копию небольшого размера
3. В массиве C-порядка (row-major) какая ось обходится быстрее всего с точки зрения кэша?
AПервая ось (строки)
BПоследняя ось, вдоль которой данные лежат непрерывно
CЛюбая ось одинаково быстро
DЗависит только от dtype, а не от порядка
Поддержать проект