Миллион целых чисел в CPython: список list занимает заметно больше памяти, чем array.array('i') и numpy.ndarray с dtype int64. Причина в модели хранения. Список держит массив указателей на объекты, а сами числа лежат отдельно в куче. array.array и numpy укладывают значения в непрерывный буфер фиксированной ширины, без объектов-оберток на каждый элемент.
Выбор контейнера по задаче выглядит так. Массовые операции над числами, матрицы, срезы и статистика: numpy.ndarray. Компактное хранилище целых или float без внешних зависимостей: array.array. Передача буфера в C-код или в сокет без копирования: memoryview поверх любого из них. Разнородные объекты и небольшие наборы: обычный list.
Все цифры ниже даны для CPython 3.12 на 64-битной платформе. Размеры объектов, накладные расходы аллокатора и тайминги зависят от версии интерпретатора, сборки NumPy и железа, поэтому проверяйте замером на своем стенде, команды для этого приведены в статье.
Почему стандартный list не всегда подходит для числовых данных
В CPython список хранит указатели. Один указатель PyObject* на 64-битной платформе занимает 8 байт, значит миллион элементов даёт около 8 МБ только под массив ссылок, плюс небольшой заголовок объекта списка.
Дальше начинается интересное. CPython кэширует малые целые числа: в исходном коде longobject.c есть проверка попадания значения в кэш через _PY_NSMALLPOSINTS, и для таких значений новые объекты не создаются (cpython/Objects/longobject.c). Все остальные значения становятся отдельными объектами PyLongObject. Точный размер такого объекта зависит от платформы и версии: в CPython он вычисляется как offsetof(PyLongObject, ob_digit) + sizeof(digit)*size, а не через sizeof(), потому что между заголовком и цифрами возможен padding. Поэтому конкретные байты на элемент и итоговый объем списка перемерьте на своей сборке: sys.getsizeof(lst) покажет только массив указателей, а полный расход нужно оценивать с учетом объектов-чисел и накладных расходов аллокатора.
Если заполнить список одним и тем же малым числом, например [0] * 1_000_000, расход падает до размера массива указателей: все указатели смотрят на один кэшированный объект. Такой тест не показывает реальную стоимость хранения числовых данных, поэтому не используйте его как замер.
Второй проигрыш списка связан с локальностью. Объекты разбросаны по куче, и процессор на каждом шаге цикла промахивается мимо кэша, а префетчер не может предсказать следующий адрес. Непрерывный буфер читается линейно, страницы кэша используются полностью. Об устройстве адресации и формуле base + i * size читайте в материале о моделях памяти массива, а подробное сравнение трех контейнеров с таблицами байтов есть в статье про list, array и NumPy.
Сравнение памяти и скорости: list, array.array, numpy.ndarray
Ориентиры для набора из 1 млн целых чисел. Тайминги это порядок величин для скалярного доступа по индексу, их стоит перемерить на своей платформе.
| Контейнер | Память на 1 млн int | Размер элемента | Скалярный доступ | Массовые операции |
|---|---|---|---|---|
| list | десятки МБ, зависит от объектов-чисел | 8 байт указатель + объект PyLongObject | около 50 нс | цикл на Python |
| array.array('i') | около 4 МБ | 4 байта | около 60 нс | нет |
| array.array('q') | около 8 МБ | 8 байт | около 60 нс | нет |
| numpy.ndarray, int64 | около 8 МБ | 8 байт | около 100 нс | векторные, в 10-100 раз быстрее цикла |
| numpy.ndarray, int32 | около 4 МБ | 4 байта | около 100 нс | векторные |
numpy проигрывает на поэлементном доступе и выигрывает на всем, что можно выразить одной операцией над массивом. Если данные уже лежат в ndarray, а нужен быстрый скалярный цикл, переключитесь на vectorization, а не на индексацию в цикле.
Как измерить память: sys.getsizeof и tracemalloc
sys.getsizeof показывает размер самого объекта и не обходит вложенные структуры. Для списка это только массив указателей. Для array.array и ndarray результат включает буфер данных и заголовок.
import sys
import array
import numpy as np
lst = list(range(1_000_000))
arr = array.array('i', lst)
np_arr = np.array(lst, dtype=np.int64)
sys.getsizeof(lst) # только указатели, без объектов-чисел
sys.getsizeof(arr) # данные плюс заголовок
sys.getsizeof(np_arr) # данные плюс заголовок ndarray
np_arr.nbytes # 8 000 000 байт: чистый объем данных
Для numpy считайте основной метрикой nbytes: она не зависит от заголовка и совпадает с объемом, который массив занимает в памяти при C-порядке. Разницу между nbytes и полным потреблением процесса дают заголовок, выравнивание и фрагментация аллокатора.
Как измерить скорость доступа: timeit и профилирование
import timeit
setup = """
import array
import numpy as np
lst = list(range(1_000_000))
arr = array.array('i', lst)
np_arr = np.array(lst, dtype=np.int64)
idx = 500_000
"""
print(timeit.timeit("lst[idx]", setup=setup, number=1_000_000))
print(timeit.timeit("arr[idx]", setup=setup, number=1_000_000))
print(timeit.timeit("np_arr[idx]", setup=setup, number=1_000_000))
Разница между list и numpy на скалярном доступе возникает потому, что каждый np_arr[idx] создает новый объект np.int64 и уничтожает его после использования. Дополнительные аллокации и стоят те самые десятки наносекунд. На векторе разрыв меняет знак: (a + b).sum() для миллиона элементов выполняется в C-цикле и обгоняет генераторное выражение на Python в десятки раз.
Если нужны оба режима, держите данные в ndarray, а в горячем скалярном цикле работайте через .tolist() или memoryview, чтобы не платить за обертку на каждой итерации. Для двумерных данных порядок обхода влияет на промахи кэша сильнее, чем тип контейнера, об этом подробно в разборе row-major и column-major.
numpy.ndarray как поле dataclass: объявление и подводные камни
ndarray это изменяемый объект переменного размера, и это ломает два механизма dataclass: проверку значений по умолчанию и сгенерированные __eq__ с __hash__.
Инициализация и изменяемые значения по умолчанию
Так объявлять поле нельзя:
from dataclasses import dataclass
import numpy as np
@dataclass
class Bad:
data: np.ndarray = np.zeros(10) # ошибка проектирования
В Python 3.11 dataclasses поднимают ValueError с сообщением вида "mutable default ... is not allowed: use default_factory", когда значение по умолчанию изменяемое (пример ошибки в issue fairseq). Проверка опирается на хешируемость типа, а ndarray не хешируется. Точная версия, в которой эта проверка появилась, в доступных источниках не зафиксирована, поэтому перед обновлением интерпретатора проверьте поведение на своей версии командой python -c "import sys; print(sys.version)".
Важно понимать общий механизм: Python хранит значения по умолчанию как атрибуты класса, и экземпляры, не указавшие значение явно, делят одну и ту же копию. dataclasses используют обычное создание класса, поэтому наследуют это поведение, и у них нет общего способа обнаружить такое совместное использование (dataclasses — Data Classes). Отсюда и требование default_factory для изменяемых значений.
from dataclasses import dataclass, field
import numpy as np
@dataclass(eq=False)
class Batch:
data: np.ndarray = field(default_factory=lambda: np.zeros((1024, 1024), dtype=np.float32))
ids: list = field(default_factory=list)
default_factory вызывается при каждом создании экземпляра, поэтому у каждой структуры будет свой буфер. Для массива на 1 ГБ это означает, что каждый объект Batch честно выделит гигабайт: фабрику стоит держать легкой, а тяжелые данные передавать в конструктор явно.
Параметр eq=False нужен отдельно. Сгенерированный __eq__ сравнивает кортежи полей, а ndarray == ndarray возвращает массив булевых значений. Для массива длиннее одного элемента Python вызовет bool() на нем и получит ValueError о неоднозначном истинностном значении. Варианты: отключить eq и написать свой __eq__ через np.array_equal, или сравнивать хеши содержимого, если массивы большие. Заодно помните, что при eq=True и frozen=False dataclass выставляет __hash__ = None: объект не положить в set и не использовать как ключ словаря. При eq=False остается наследованный object.__hash__, то есть хеш по идентичности.
Режим frozen=True не защищает содержимое: поле нельзя переприсвоить, но элементы массива меняются на месте через batch.data[0] = 1.
Сериализация numpy-массивов в dataclass
dataclasses.asdict() рекурсивно обходит dataclasses, dict, list и tuple, а прочие объекты копирует через copy.deepcopy (dataclasses — Data Classes). Для массива это означает лишнюю полную копию данных, а json.dumps после этого все равно упадет с сообщением, что объект типа ndarray не сериализуется в JSON. Отдельно учитывайте, что deepcopy по определению копирует все и может скопировать слишком много, включая данные, которые предполагалось разделять между копиями (copy — Shallow and deep copy operations). Практичный путь: преобразовать данные в список, а метаданные сохранить отдельными полями.
import json
import numpy as np
from dataclasses import dataclass, field
@dataclass(eq=False)
class Frame:
data: np.ndarray = field(default_factory=lambda: np.zeros((2, 3)))
label: str = ""
def to_json(fr: Frame) -> str:
payload = {
"label": fr.label,
"dtype": str(fr.data.dtype),
"shape": list(fr.data.shape),
"order": "F" if fr.data.flags.f_contiguous and not fr.data.flags.c_contiguous else "C",
"data": fr.data.ravel(order="K").tolist(),
}
return json.dumps(payload)
def from_json(s: str) -> Frame:
p = json.loads(s)
data = np.array(p["data"], dtype=p["dtype"]).reshape(p["shape"], order=p["order"])
return Frame(data=data, label=p["label"])
Что важно в этом коде. dtype передается строкой, иначе int32 после round-trip превратится в int64. shape и порядок хранятся отдельно: без них reshape соберет массив в C-порядке, и метаданные о структуре потеряются. int64 со значениями больше 2^53 теряет точность в парсерах JSON, которые считают числа типом double, например в JavaScript. float64 переносится через Python float точно: json.dumps пишет кратчайшее представление, которое читается обратно в то же значение.
pickle работает с dataclass и ndarray без доработок и сохраняет dtype, форму и порядок байтов, но загрузка pickle из недоверенного источника дает исполнение произвольного кода. Для обмена файлами берите бинарный формат: np.save, np.savez или np.savez_compressed с последующим np.load(allow_pickle=False). Формат .npy хранит dtype, shape и порядок C или F, а mmap_mode='r' при загрузке включает страницы по обращению вместо чтения всего файла в память. Детали сравнения JSON, MessagePack и Parquet с примерами и разбором порядка байтов собраны в статье про сериализацию массивов.
memoryview: доступ к данным без копирования
memoryview не владеет данными. Он держит указатель на буфер и метаданные: format, itemsize, ndim, shape, strides. Копии при создании не происходит, а запись через memoryview меняет исходный объект. Потокобезопасность при этом зависит и от самого memoryview, и от объекта-экспортёра буфера (Thread Safety Guarantees).
import array
arr = array.array('i', [1, 2, 3])
mv = memoryview(arr)
mv[0] = 10
print(arr) # array('i', [10, 2, 3])
print(mv.format, mv.itemsize, mv.nbytes) # i 4 12
Buffer protocol поддерживают bytes, bytearray, array.array, numpy.ndarray, mmap и любой объект с методами __buffer__ или bf_getbuffer. Для numpy доступны многомерные срезы:
import numpy as np
A = np.arange(6, dtype=np.int64).reshape(2, 3)
mv = memoryview(A)
print(mv.shape, mv.strides) # (2, 3) (24, 8)
print(mv[0].tolist()) # [0, 1, 2]
mv.cast('B') # побайтовый доступ, нужен C-непрерывный буфер
cast('B') требует C-непрерывного буфера. Массив, полученный срезом с шагом или транспонированием, сначала приведите через np.ascontiguousarray, иначе получите TypeError.
Ключевое ограничение: пока memoryview жив, экспортер нельзя изменить по размеру. Операции, использующие buffer protocol, предотвращают изменение размера буфера. Освобождайте представление вызовом mv.release() или удалением ссылки перед изменением размера буфера. Конкретный тип исключения зависит от экспортёра, поэтому проверьте его на своей версии интерпретатора.
Передача массивов в C-расширения через buffer protocol
C-функция, которая принимает Py_buffer через PyArg_ParseTuple с форматом y*, получает указатель и длину без копирования данных. На стороне Python удобны два пути.
import array
import ctypes
buf = array.array('i', [1, 2, 3, 4])
assert buf.itemsize == ctypes.sizeof(ctypes.c_int) # совпадение ширины элемента
ptr = (ctypes.c_int * len(buf)).from_buffer(buf) # без копирования
import numpy as np import numpy.ctypeslib as npct A = np.ascontiguousarray(np.arange(8, dtype=np.int32)) ptr = npct.as_ctypes(A) # ctypes-представление без копирования
Проверяйте совпадение ширины: array.array('i') обычно дает 4 байта, но гарантию даёт itemsize, а не буква typecode. Для numpy.ctypeslib.as_ctypes массив должен быть C-непрерывным, а dtype совпадать с типом C.
Оба представления не владеют памятью. Держите ссылку на исходный объект до конца работы C-кода, иначе сборщик мусора освободит буфер и C-функция запишет данные в освобожденную область. Это классический источник порчи памяти, который не воспроизводится стабильно и тяжело ловится.
Buffer protocol сам GIL не снимает. Освобождает его C-код через Py_BEGIN_ALLOW_THREADS, и тогда возможен сценарий, когда другой поток Python меняет или удаляет массив во время работы C-функции. Фиксируйте контракт: пока C-функция владеет буфером, данные не менять, объект не удалять, размер не менять.
Профилирование памяти через tracemalloc: находим утечки и перерасход
tracemalloc снимает снимки аллокаций Python и умеет сравнивать их между собой. Базовый сценарий поиска утечки: снимок до, работа, снимок после, diff по строкам кода.
import tracemalloc
tracemalloc.start(25) # хранить 25 кадров стека
before = tracemalloc.take_snapshot()
batches = build_batches(1000) # ваш код
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, 'lineno')[:10]:
print(stat)
for stat in after.statistics('lineno')[:5]:
print(stat)
current, peak = tracemalloc.get_traced_memory()
print(current, peak)
tracemalloc.stop()
compare_to показывает прирост между снимками и сортирует строки по разнице, поэтому в топе оказывается именно тот код, который создал структуру. statistics('lineno') дает абсолютные значения. Фильтр по файлу убирает шум из библиотек:
from tracemalloc import Filter snap = after.filter_traces((Filter(True, "*pipeline.py"),))
Два ограничения, о которых стоит помнить до включения трейсинга в продакшене. Первое: сам tracemalloc заметно замедляет выделение памяти, включайте его на стенде или по флагу. Запуск без правки кода: python -X tracemalloc=25 pipeline.py или переменная окружения PYTHONTRACEMALLOC=1.
Второе: NumPy размещает свои аллокации CPU-памяти в отдельном домене np.lib.tracemalloc_domain, а не в стандартном домене Python, и такие аллокации не отображаются в стандартном домене tracemalloc (NumPy 1.13.0 Release Notes). NumPy вообще не использует стратегию доменов Python для управления памятью данных и предоставляет собственный набор C-API для изменения routines аллокации (Memory management in NumPy). Поэтому для внешней памяти используйте np_arr.nbytes как опорную величину и внешние инструменты вроде memory_profiler или heap-профайлеров уровня ОС. Общая методика сверки расчетного объема с фактическим разобрана в статье про измерение памяти под массивы.
Практические рекомендации и чек-лист
| Сценарий | Контейнер | Почему |
|---|---|---|
| Разнородные объекты, десятки тысяч элементов | list | любые типы, встроено, без зависимостей |
| Миллионы int или float без внешних библиотек | array.array | 4 или 8 байт на элемент, минимальный оверхед |
| Матричные вычисления, статистика, срезы | numpy.ndarray | векторные операции в C, срезы без копирования |
| Передача буфера в C, сокет, mmap | memoryview поверх array или ndarray | buffer protocol, нулевое копирование |
| Файл больше доступной RAM | np.load(mmap_mode='r') или mmap | страницы подгружаются по обращению |
- Для больших числовых наборов берите numpy.ndarray или array.array. Тип элемента фиксируйте явно: dtype=int32 или typecode='i' экономят половину памяти по сравнению с 64-битными вариантами, если диапазон значений это позволяет.
- В dataclass объявляйте массив через field(default_factory=...). Значение по умолчанию из готового массива создает общий буфер для всех экземпляров.
- Ставьте eq=False, если в полях есть ndarray. Иначе сравнение двух структур либо упадет с ValueError, либо вернет массив вместо булева значения.
- Для JSON конвертируйте массив через .tolist() и храните рядом dtype, shape и порядок. asdict() не решает задачу и делает лишнюю полную копию через deepcopy.
- Для больших массивов используйте .npy и .npz с np.load(allow_pickle=False). pickle оставьте для доверенных локальных файлов.
- Память проверяйте через nbytes, а фактические аллокации кода через tracemalloc с compare_to. Числа из таблиц этой статьи перемерьте на своей версии CPython и NumPy.
- В C-расширения передавайте буфер через memoryview или numpy.ctypeslib.as_ctypes и держите ссылку на владельца до завершения вызова.
- Перед изменением размера массива освобождайте memoryview через release(), иначе изменение буфера будет заблокировано.
Начните с замера на своем пайплайне: python -X tracemalloc=25 pipeline.py, затем сравните топ строк по приросту памяти с ожидаемыми nbytes. Если фактический расход выше расчетного больше чем на несколько процентов, ищите лишние копии в коде: asdict(), .tolist() в горячем цикле, срезы NumPy без ascontiguousarray и повторное создание массива внутри итерации вместо переиспользования буфера.