Граница с C и zero-copy

Buffer protocol — договор о том, как отдать чужому коду память без копирования. На нём стоит весь научный стек, и он же объясняет, почему numpy параллелится потоками, а обычный цикл нет.

L1 · 9 минL2 · 11 мин📱 телефонсверено · CPython 3.11 + numpy 2.4, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
buf = bytearray(b"x" * 10_000_000)

# сколько стоит взять половину?
#   bytes(buf[:5_000_000])
#   memoryview(buf)[:5_000_000]
срез bytearray:  0.0185 c
срез memoryview: 0.000007 c
Одна и та же половина. В 2800 раз.
Фрагмент B
import array, numpy as np
arr = array.array("q", range(10))
n = np.frombuffer(arr, dtype=np.int64)
n[0] = 999
print(arr[0])
999
Записали в numpy-массив — изменился объект стандартной библиотеки.
Фрагмент C
import time, threading

def work(n=8_000_000):
    t = 0
    for i in range(n):
        t += i
    return t

t0 = time.perf_counter(); work(); work()
seq = time.perf_counter() - t0

ths = [threading.Thread(target=work) for _ in range(2)]
t0 = time.perf_counter()
for t in ths: t.start()
for t in ths: t.join()
par = time.perf_counter() - t0

print(f"последовательно: {seq:.2f} c")
print(f"два потока:      {par:.2f} c")
последовательно: 0.54 c
два потока:      0.50 c
Два ядра, два потока, нулевой выигрыш.
Фрагмент D
import time, threading
import numpy as np

M = np.random.rand(1600, 1600)
def work(): return M @ M

def wall(n_threads, repeat=5):
    best = float("inf")
    for _ in range(repeat):                     # best-of-5: одиночный замер здесь врёт
        ths = [threading.Thread(target=work) for _ in range(n_threads)]
        t0 = time.perf_counter()
        for t in ths: t.start()
        for t in ths: t.join()
        best = min(best, time.perf_counter() - t0)
    return best

work()                                          # прогрев
one, two = wall(1), wall(2)
print(f"одна задача:      {one:.3f} c")
print(f"две в двух потоках: {two:.3f} c")
print(f"ускорение:        {2*one/two:.2f}×")
одна задача:      0.119 c
две в двух потоках: 0.123 c
ускорение:        1.93×
Тот же интерпретатор, те же потоки — ускорение в 1.9 раза.

2Механизм

Из урока 03 известно, что список объектов и непрерывный буфер — разные вещи. Здесь — как этот буфер передаётся между библиотеками и что происходит на границе с C.

Buffer protocol — соглашение: объект умеет отдать указатель на свою память плюс описание раскладки (тип элемента, размер, форма, шаги).

memoryview — питоновский способ этим воспользоваться: объект, который смотрит в чужую память и ничего не копирует.

Расширение может отпустить GIL на время своей работы: макросы Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS вокруг участка, не трогающего объекты Python.

Следствие 1 · срез без копирования

Фрагмент A. Срез bytearray выделяет пять мегабайт и копирует их — это работа, пропорциональная размеру. Срез memoryview создаёт объект с указателем и смещением: константное время независимо от размера. Отсюда и 2800 раз в замере, и то, что разрыв растёт с объёмом данных.

Практический вывод прямой: разбор бинарного формата, чтение заголовков, нарезка пакетов — всё, где данные читаются кусками, должно идти через memoryview. Иначе каждый срез копирует.

Следствие 2 · библиотеки говорят между собой без копий

Фрагмент B. array.array реализует buffer protocol, numpy умеет его читать — и np.frombuffer создаёт массив поверх той же памяти. Запись через один объект видна через другой, потому что объект памяти один.

Отсюда и вырос весь научный стек. numpy, torch, pandas, arrow, pillow, PIL-буферы, CUDA-массивы не знают друг о друге ничего, но умеют обмениваться данными без копирования — потому что все реализуют один протокол. Это единственная причина, по которой стек вообще собирается из независимых библиотек: без общего договора о памяти каждая передача массива в сто мегабайт стоила бы копии.

Следствие 3 · GIL отпускается, и это меняет всё

Фрагменты C и D — самая важная пара в уроке. Один и тот же интерпретатор, те же два потока, разный результат.

Чистый Python не ускоряется: байткод исполняется под GIL, и потоки лишь чередуются (урок 09, следствие 3). А numpy ускоряется — потому что внутри операции над массивом он отпускает GIL и считает в C, не трогая объекты Python. Пока один поток умножает массив, второй тоже может работать.

задачаодна задачадве в двух потоках
цикл на Python0.24 с0.48 с
операция numpy0.121 с0.125 с

Отсюда правило, которое иначе выглядит фольклором: потоки в Python полезны на всём, что уходит в C или в ожидание — numpy, сжатие, хеширование, драйверы БД, сетевые вызовы. И бесполезны на всём, что остаётся в байткоде.

Следствие 4 · за что заплачено

Всё это работает потому, что C-расширение видит объекты Python напрямую: их поля, их счётчик ссылок, их слоты. Максимально дёшево — и максимально жёстко.

Раз счётчик ссылок торчит в публичном API и тысячи расширений трогают его без блокировок, убрать GIL нельзя, не сломав их все. Тот же протокол, что дал научному стеку скорость, зафиксировал глобальный лок на тридцать лет. Урок 09 объяснял, почему GIL появился; здесь видно, почему он не уходит.

3Границы модели

Где сказанное перестаёт держать

Не всякий буфер непрерывен. memoryview умеет описывать срезы с шагом и многомерные формы, и такой буфер нельзя просто передать функции, ожидающей плоский массив. Отсюда ValueError: ndarray is not C-contiguous и явные ascontiguousarray в чужом коде.

Экспортированный буфер блокирует изменение размера. Пока жив memoryview на bytearray, попытка изменить его длину даёт BufferError — иначе указатель стал бы висячим. Это единственное место, где Python ведёт себя как C с его инвалидацией итераторов.

Отпускание GIL — решение автора расширения 🔧. Не всякая функция numpy его отпускает, а мелкие операции не отпускают вовсе — накладные расходы больше выигрыша. Проверять надо замером, а не верой.

4Корень

Корень R6: расширения пишутся на C и видят объекты напрямую. Решение принято в 1991 году, когда Python задумывался как язык-клей для существующих C-библиотек, и оно оказалось самым последовательным в его истории.

Buffer protocol в нынешнем виде появился в 2006 (PEP 3118) как обобщение более старых механизмов: до него существовало несколько несовместимых способов отдать память, и numpy с PIL договаривались вручную. Новый протокол описал раскладку формально — тип, форма, шаги — и тем самым позволил произвольным библиотекам обмениваться массивами, ничего не зная друг о друге.

Именно это, а не скорость C, сделало Python языком численных вычислений. Скорость даёт любой компилируемый язык; редкость в том, что десяток независимых библиотек делит одну память без копий.

5Аналогия

memoryview — это std::span, и совпадение почти полное: невладеющее окно в чужую непрерывную память, создание и нарезка за константное время, изменение через окно видно владельцу. Если держать это в голове, весь урок сводится к одной фразе: Python наконец получил span, а buffer protocol — способ его выдать из любого типа.

Различие в безопасности и в его цене. В C++ span спокойно переживёт вектор, который перевыделился, и превратится в висячий указатель. В Python это невозможно: представление держит ссылку на владельца, а владелец не может изменить размер, пока представление живо, — попытка даёт BufferError. То есть Python платит за span счётчиком ссылок и одним запретом, а взамен убирает целый класс ошибок.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему numpy быстрее, если внутри тот же процессор?

Потому что дело не в скорости арифметики, а в том, сколько работы делается вокруг неё.

Сложение двух чисел в Python: прочитать два указателя, проверить типы, вызвать __add__, выделить новый объект под результат, инициализировать его заголовок, вернуть указатель. Сама арифметика — одна инструкция процессора из примерно сорока.

В numpy данные лежат непрерывным буфером фиксированного типа, и цикл идёт в C: типы проверяются один раз на весь массив, объекты не создаются вовсе, а компилятор применяет векторные инструкции — одна операция на четыре или восемь чисел сразу.

Три источника выигрыша, и полезно различать их:

  • Нет объектов. Миллион чисел это 8 МБ вместо 36 и ноль аллокаций вместо миллиона.
  • Нет интерпретации. Цикл в C вместо шести наносекунд на инструкцию байткода.
  • Данные подряд. Кеш процессора работает как задумано, а не как при прыжках по куче.

Отсюда важное: numpy ускоряет не «математику», а векторные операции над однородными данными. Поэлементный доступ a[i] в цикле Python — медленнее, чем по списку, потому что на каждом обращении числовой объект приходится создавать заново. Если в коде остался питоновский цикл по массиву, выигрыша не будет.

memoryview — когда он действительно нужен?

Когда данные режут на куски, а не читают целиком: срез bytes копирует, срез memoryview — нет.

import timeit
setup = "data = bytearray(10_000_000); mv = memoryview(data)"
print(timeit.timeit("data[1000:9_000_000]", setup, number=100))
print(timeit.timeit("mv[1000:9_000_000]", setup, number=100))
# разница в тысячи раз

Замер в этом уроке: 0.0185 с против 0.000007 с. Копирование восьми мегабайт против арифметики над указателем.

Где это решает задачу. Разбор бинарного протокола, где кадры вырезают из буфера в цикле: наивная версия копирует весь хвост на каждом кадре, то есть O(n²) по объёму. Чтение большого файла блоками с разбором заголовков. Передача среза массива в C-функцию без копии.

Две вещи, о которых обычно узнают на грабли. Первая: memoryview удерживает исходный буфер — пока жив срез на сто байт, живёт весь десятимегабайтный bytearray. Если кусочек нужен надолго, из него надо явно сделать bytes(frame).

Вторая: пока существует хоть один memoryview, исходный bytearray нельзя изменить в размере — BufferError: Existing exports of data: object cannot be re-sized. Это защита, а не каприз: буфер мог бы переехать в памяти.

И главное — это общий протокол, а не фича одного типа. Именно через него numpy, torch, arrow и pillow обмениваются массивами без копий.

Почему не все операции numpy отпускают GIL?

Потому что отпустить и вернуть GIL само по себе стоит времени, и на мелкой работе это дороже выигрыша.

Отпускание — это две операции с глобальным локом плюс возможное переключение потоков ОС. Для операции, длящейся микросекунду, накладные расходы больше самой операции; для операции на сто миллисекунд они незаметны.

Поэтому numpy отпускает GIL выборочно: в тяжёлых циклах ufunc, в матричных операциях через BLAS, в сортировках больших массивов. Мелкие операции, работа с объектными массивами (dtype=object) и всё, что трогает Python-объекты, не отпускают 🔧 — и не могут: без GIL к объектам обращаться нельзя.

Проверять надо замером, а не верой. Замер из этого урока:

задача                    одна    две в потоках   выигрыш
цикл на Python           0.24 с      0.48 с          нет
операция numpy          0.121 с     0.125 с          1.9×

Полезный признак: если операция выражена одним вызовом над большим массивом — скорее всего отпускает. Если это цикл Python вокруг мелких вызовов — точно нет.

И осторожно с BLAS: он часто сам многопоточный, и тогда замер покажет ускорение, которого не будет при OMP_NUM_THREADS=1. При сравнении вариантов эту переменную стоит фиксировать, иначе меряется не то, что кажется.

Переписать кусок на C или Rust — когда это окупается?

Позже, чем хочется. Это пятый рычаг из пяти, и первые четыре почти всегда дают больше.

Порядок из урока 21: алгоритм → структура данных → выход из объектной модели → выход из интерпретатора → переписывание. Замена списка на множество в проверке вхождения даёт 154 раза; переписывание на C — обычно от трёх до пятидесяти, и только на том куске, который переписали.

Полная цена, которую забывают посчитать:

  • Сборочная инфраструктура: компилятор в CI, колёса под каждую платформу, manylinux — весь урок 16.
  • Ошибки становятся сегфолтами вместо исключений, и падает весь процесс.
  • Управление ссылками руками: пропущенный Py_DECREF — утечка, лишний — порча памяти.
  • Второй язык в проекте: ревью, найм, поддержка.

Когда действительно оправдано: горячий цикл, который профилировщик показывает как единственное узкое место; работа, которая не выражается векторными операциями (парсеры, обход графов, специфические алгоритмы); необходимость отпустить GIL и параллелиться по ядрам.

Что попробовать раньше: numpy или polars для табличных данных, Cython с типизированными переменными (правки локальные, сборка проще), numba для численных циклов (JIT, без отдельной сборки). Rust через PyO3 — хороший выбор, когда решение уже принято: там нет ручного управления ссылками, и это снимает половину рисков из списка выше.

Итог. Buffer protocol — договор об отдаче памяти без копирования: указатель плюс описание раскладки. Отсюда memoryview, срез которого стоит константное время против копирования у bytearray — в замере 2800 раз на пяти мегабайтах. Отсюда же то, что numpy, torch, arrow и pillow обмениваются массивами, ничего не зная друг о друге: они реализуют один протокол, и это единственная причина, по которой научный стек собирается из независимых библиотек. И отсюда главное практическое: расширение может отпустить GIL на время работы в C, поэтому два потока с numpy дают 1.9×, а два потока с чистым циклом — ровно ничего. Цена договора — счётчик ссылок в публичном C API, из-за которого GIL нельзя убрать, не сломав тысячи расширений: тот же механизм, что дал стеку скорость, зафиксировал глобальный лок.
Дальше — по желанию
контрфактуалC, C++ span и RustУказатель плюс длина — и что добавляет к этому договор о раскладке

C передаёт массив как указатель плюс длину, и это ровно то, что делает buffer protocol — только у C нет договора о том, что там за элементы. Отсюда весь класс ошибок с несовпадением типов, который buffer protocol снимает: он несёт формат элемента строкой и проверяет его при экспорте.

std::span из C++20 — почти дословный memoryview: невладеющее представление непрерывной памяти, конструируется бесплатно, срезается бесплатно. И проблема та же: span переживает владельца — получишь висячий указатель. Python от этого защищён двумя механизмами: memoryview держит ссылку на владельца (урок 09), а изменение размера при живом представлении запрещено.

Rust решает то же самое срезами и заимствованием, но проверяет компилятором: срез не может пережить владельца, потому что это не компилируется. Цена — модель владения, которую надо держать в голове; выигрыш — та же безопасность без счётчика ссылок.

Три способа отдать память: без гарантий, с гарантией времени жизни в рантайме, с гарантией на этапе компиляции.

🐛 багКопия там, где ожидали представлениеРазбор бинарного протокола срезами bytes — квадратичная работа на ровном месте

Парсер читает кадры из буфера. Логика верная, объём копирования — нет.

def parse_frames(data: bytes):
    frames = []
    while data:
        length = int.from_bytes(data[:4], "big")
        frames.append(data[4:4+length])
        data = data[4+length:]          # ← копия остатка на каждой итерации
    return frames

Последняя строка копирует весь непрочитанный хвост при каждом кадре. Для буфера в N байт и K кадров это порядка N·K скопированных байт — на десяти мегабайтах и тысяче кадров получается несколько гигабайт копирования, при том что данных всего десять мегабайт.

Ревью пропускает, потому что код читается идеально и на маленьких входах работает мгновенно. Проявляется на реальном размере, и профилировщик показывает время внутри среза — то есть в строке, которая выглядит бесплатной.

def parse_frames(data: bytes):
    mv = memoryview(data)
    frames, pos = [], 0
    while pos < len(mv):
        length = int.from_bytes(mv[pos:pos+4], "big")
        frames.append(mv[pos+4:pos+4+length])
        pos += 4 + length
    return frames

Ни одного копирования: все срезы — окна в исходный буфер. Замер из фрагмента A даёт масштаб выигрыша — константа против линейного времени на каждый срез.

Оговорка, которая тоже выводится из механизма: возвращённые кадры держат весь исходный буфер живым (урок 09). Если из десяти мегабайт нужны сто байт, которые переживут остальное, — там копию сделать надо, и осознанно, через bytes(frame).

⚡ 1.9×Потоки на том, что уходит в CGIL отпускается внутри numpy — и ThreadPoolExecutor внезапно работает

«Потоки в Python бесполезны для вычислений» — верно ровно наполовину, и вторая половина стоит денег.

задачаодна задачадве в двух потокахвыигрыш
цикл на Python0.24 с0.48 снет
операция numpy0.121 с0.125 с1.9×
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(heavy_numpy_op, chunks))

Работает это только потому, что numpy отпускает GIL внутри операции. То же верно для zlib и gzip, hashlib, pillow, чтения и записи файлов, драйверов БД, lxml — везде, где тяжёлая часть выполняется в C.

Почему потоки, а не процессы. Процессы тоже дадут параллелизм, но данные придётся сериализовать и переслать: массив в сто мегабайт через multiprocessing — это pickle туда и обратно. Потоки делят память бесплатно, и на массивах это часто перевешивает.

Как проверить свой случай за минуту: замерить последовательно и в два потока. Ускорилось — расширение отпускает GIL, можно масштабировать. Не ускорилось — либо всё в байткоде, либо конкретная функция GIL не отпускает; тогда процессы или переписывание горячего куска.

Правило: перед выбором процессов измерь потоки. Они дешевле, и на численном коде часто достаточны.

💻 терминалПроверь в REPLЧетыре фрагмента плюс BufferError, C-contiguous и разделяемая память

Прогони фрагменты, сверяясь с предсказанием. Затем:

buf = bytearray(b"abcdef")
mv = memoryview(buf)
buf.extend(b"xyz")
# Что произойдёт и почему именно так?

import numpy as np
a = np.arange(12).reshape(3, 4)
print((a.flags["C_CONTIGUOUS"], a.T.flags["C_CONTIGUOUS"]))
# Почему транспонирование ломает непрерывность, если данные те же?

import numpy as np
a = np.arange(10)
b = a[::2]
b[0] = 99
print(a[0])
# Копия или представление? А как проверить наверняка?

import array, sys
arr = array.array("q", range(1000))
mv = memoryview(arr)
print((sys.getsizeof(mv), mv.nbytes))
# Почему два числа так расходятся?

Первый вопрос — самый важный: BufferError здесь не ошибка использования, а механизм защиты от висячего указателя.

исходникиmemoryview в стандартной библиотекеГде zero-copy применён осознанно и что это дало

Lib/socket.py, метод recv_into и readinto в файловых объектах. Вся линия *_into в стандартной библиотеке существует ради следствия 1: читать не «в новый объект», а в уже выделенный буфер. Для сервера, обрабатывающего тысячи соединений, это разница между аллокацией на каждый пакет и переиспользованием одного буфера.

Lib/multiprocessing/shared_memory.py. Здесь buffer protocol решает задачу, которую иначе не решить: разделяемая память между процессами, поверх которой numpy делает массив без копирования. Прочитай и заметь, что вся библиотека — это тонкая обёртка над системным вызовом плюс экспорт буфера; тяжёлая часть уже есть в протоколе.

Modules/_io/bufferedio.c. Буферизованный ввод-вывод на C: видно и работу с сырой памятью, и места, где вокруг системного вызова стоят Py_BEGIN_ALLOW_THREADS. Именно эти макросы делают файловый ввод-вывод в потоках параллельным — следствие 3 в исходнике.

L2Как выглядит буфер и что значит отпустить GILPy_buffer, шаги и непрерывность; макросы вокруг критической секции

Из L1 ты знаешь про договор. Здесь — из чего он состоит.

Структура Py_buffer несёт: указатель на данные, ссылку на объект-владельца, общий размер в байтах, размер элемента, строку формата ("q" для int64, "d" для double), число измерений, массив формы и массив шагов. Шаги — это сколько байт до следующего элемента по каждому измерению; именно они позволяют описать транспонированную матрицу без перекладывания данных.

import array
arr = array.array("q", range(10))
m = memoryview(arr)
print((m.format, m.itemsize, m.nbytes, m.shape, m.strides))
# → ('q', 8, 80, (10,), (8,))

Непрерывность — производное свойство: буфер C-непрерывен, если шаги ровно такие, какими были бы при плотной укладке. Транспонирование меняет шаги и непрерывность теряется, хотя данные не двигались — отсюда вопрос из раздела «Проверь».

Отпускание GIL выглядит в коде расширения так:

Py_BEGIN_ALLOW_THREADS
/* здесь нельзя трогать ни один PyObject:
   ни читать поля, ни менять счётчик ссылок */
result = compute_over_raw_memory(ptr, n);
Py_END_ALLOW_THREADS

Макросы сохраняют состояние потока, отпускают лок и забирают обратно. Условие ровно одно и жёсткое: внутри нельзя касаться объектов Python. Поэтому numpy отпускает GIL на векторной операции над сырым буфером и не отпускает там, где приходится вызывать питоновский колбэк — например, в np.vectorize над обычной функцией.

Практический ориентир: если операция работает над dtype-массивом целиком, скорее всего GIL отпущен; если внутри есть object-массив или питоновский вызов — нет.

L3Где это в исходниках CPythonmemoryobject.c, ceval_gil.h, PEP 3118

Тег v3.11.15.

  • Objects/memoryobject.c — реализация memoryview целиком, включая проверки непрерывности и подсчёт экспортов, из-за которого возникает BufferError.
  • Include/cpython/object.h — структура Py_buffer и слоты bf_getbuffer, bf_releasebuffer. Двадцать строк, задающих весь протокол.
  • Python/ceval_gil.h — что именно делают Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS.
  • PEP 3118 — сам протокол; в мотивации перечислены несовместимые механизмы, которые он заменил.
связиКуда это ведётL03, L09, L19 · контраст со span в C++ и срезами Rust
корень R6 · C API как способ расширения
← основа L03 · Контейнеры — почему непрерывный буфер и список объектов это разные вещи
← основа L09 · Подсчёт ссылок — почему GIL появился и почему представление держит владельца
↔ контраст std::span в C++: то же окно, но может пережить владельца
↔ контраст Срезы Rust: та же безопасность, проверенная компилятором вместо счётчика
→ дальше L16 · Пакеты и переписывание — ABI, колёса и когда выносить код в C или Rust
→ дальше L19 · GIL — почему то, что здесь помогает, там оказывается ограничением
источникиЧто почитатьPEP 3118 🟧 · memoryview 🟦 · memoryobject.c 🟥