PEP-734: Субинтерпретаторы в Python 3.14

от автора

Привет! Меня зовут Никита Соболев, я core-разработчик языка программирования CPython, а так же автор серии видео про его устройство.

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

Под катом будет про: новые питоновские API для ускорения и паралеллизации ваших програм, про управление памятью, про дублирование данных. Ну и много C кода!

Чтобы разобраться в вопросе и рассказать вам, я сделал несколько важных шагов: прочитал почти весь код данной фичи, начал коммить в субинтерпретаторы и взял интервью у автора данного проекта. Интервью доступно с русскими и английскими субтитрами. А еще я добавил кучу контекста прямо в видео. Ставьте на паузу и читайте код.

Если вам такое интересно или целиком незнакомо – добро пожаловать!


Что добавили в 3.14?

В Python добавили две важные новые части. Первая часть – concurrent.interpreters

>>> from concurrent import interpreters  >>> interp = interpreters.create() >>> interpreters.list_all() [Interpreter(0), Interpreter(1)]  >>> def worker(arg: int) -> None: ...     print('do some work', arg) ...      >>> interp.call(worker, 1)  # запускаем код в сабинтерпретаторе do some work 1

Она являет собой Python АПИ для удобной работы с субинтерпретаторами. Его пока не так много, но уже можно делать полезные вещи. Скорее всего – вам оно не пригодится прямо в таком виде. Данное АПИ скорее для разработчиков библиотек. Аналогия: как часто вы используете модуль threading напрямую?

Вторая часть – concurrent.futures.InterpreterPoolExecutor, который является аналогом concurrent.futures.ThreadPoolExecutor. Можно запускать какую-то работу полностью паралелльно:

>>> from concurrent.futures import InterpreterPoolExecutor  >>> CPUS = 4 ... with InterpreterPoolExecutor(CPUS) as executor: ...     list(executor.map(worker, [1, 2, 3, 4, 5, 6])) ...      do some work 1 do some work 2 do some work 4 do some work 3 do some work 5 do some work 6 [None, None, None, None, None, None]

Тут уже интереснее. Данный АПИ можно и нужно использовать, если хочется распараллелить что-то на много субинтерпретаторов.

А теперь поговорим – как оно работает?

Сравнение субинтерпретаторов с threading / multiprocessing / free-threading / asyncio

Многозадачность (точнее её отсутсвие) – долгое время была слабой стороной CPython. Было предпринято много разных попыток обойти ограничения GIL и решить проблему для разных случаев.

Первый способ – threading (в режиме с GIL по-умолчанию), который замечательно работает, если вы вызывали какой-то бинарный код, который через C-API отпускал GIL. Как можно отпустить GIL?

Например, модуль mmap делает так:

Py_BEGIN_ALLOW_THREADS m_obj->data = mmap(NULL, map_size, prot, flags, fd, offset); Py_END_ALLOW_THREADS

Так как у нас есть специальное C-API для управления PyThreadState, любой может вызывать необходимые функции и сделать программу быстрее. Но так делают не все. А еще так нельзя делать в Python коде. Оттого threading в CPython имел ограниченную полезность. Более того: треды требуют примитивов синхронизации для контроля доступа к данным.

Потому появился второй способ – multiprocessing. Который уже создавал новые полноценные Python процессы со своей собственной памятью. Да, можно кое-что шарить. Но все равно затраты на создания нового процесса и *N потребление памяти – существенная проблема. Например, если ваш датасет и так весит 2ГБ, его очень сложно взять и продублировать в соседний процесс на еще +2ГБ.

Следующий способ – asyncio. Который потребовал от нас переписать весь питон, получить проблему цвета функций, но не решил фундаментальных проблем. Он все еще работал в рамках одного GIL, в рамках одного потока, в рамках одного процесса. Никак не помогал с CPU-bound задачами. Финальным гвоздем в крышку гроба asyncio стало то, что не было продумано, как оно на самом деле должно работать. Отсутствие понятных примитивов, явных cancelation-scopes, интроспекции (добавят в 3.14). И еще неоптимальная производительность, много плохих АПИ, досягаемые пределы масштабирования, сложная ментальная модель, плохая интеграция с потоками, и сложности с примитивами синхронизации.

Появление free-threading не стало неожиданностью. Потому что треды дешевы в создании, не требуют копирования данных (что, с другой стороны, может приводить к мутабельному доступу, гонкам, дедлокам и прочему веселью многопоточности). Теперь можно выключать GIL и получать накладные расходы на каждый объект при использовании free-threading. А так же нужно полностью обмазывать свой код threading.Lock и прочими мьютексами. Но все равно будут места, даже прямо в builtins, которые нельзя физически спрятать под одну критическую секцию (мьютекс). Что означает, что гонки будут даже в билтинах. Пример: обычные итераторы в режиме --disable-gil:

import concurrent.futures  N = 10000 for _ in range(100):     it = iter(range(N))     with concurrent.futures.ThreadPoolExecutor() as executor:         data = set(executor.map(lambda _: next(it), range(N)))         assert len(data) == N, f"Expected {N} distinct elements, got {len(data)}"  # Traceback (most recent call last): # File "<python-input-0>", line 8, in <module> #   assert len(data) == N, f"Expected {N} distinct elements, got {len(data)}" #          ^^^^^^^^^^^^^^ # AssertionError: Expected 10000 distinct elements, got 9999

Стреляем себе в ноги с N рук!

Так что – субинтерпретаторы выглядят очень хорошим решением. Они достаточно дешевы в запуске (но можно сделать еще быстрее и лучше в будущем, о чем есть в видео), каждый из них работает со своим GIL, внутри пользовательского кода нет локов и мутации данных, подходит для CPU и IO bound задач, есть шаринг данных без копирования (как просто переиспользование иммутабельных и immortal типов, таких как int, так и специальная магия вокруг использования memoryview), и будет еще больше способов в ближайших релизах.

Пример магии с memoryview и любыми другими баферами:

>>> from concurrent import interpreters >>> interp = interpreters.create() >>> queue = interpreters.create_queue()  >>> b = bytearray(b'123') >>> m = memoryview(b) >>> queue.put_nowait(m) >>> interp.exec('(m := queue.get_nowait()); print(m); m[:] = b"456"')  # changing memory directly <memory at 0x103274940>  >>> b  # was changed in another interpreter! bytearray(b'456')

Там один и тот же объект! Копирования не было. Что позволит нам шарить, например, np.array или любые другие баферы. Выглядит очень перспективно. Ну и, конечно, сверху можно накрутить и модель акторов (о чем тоже говорим в видео), и CSP как в Go.

Что такое субинтерпретаторы?

Чтобы понять, что такое субинтерпретаторы, сначала нужно понять – что такое интерпретатор в CPython 🙂

Когда мы запускаем новый python процесс, мы начинаем выполнять код из файла pylifecycle.c, который как раз и запускает интерпретатор, а так же обрабатывает все другие вопросы жизненного цикла. Там есть вот такая функция:

static PyStatus pycore_create_interpreter(_PyRuntimeState *runtime,                           const PyConfig *src_config,                           PyThreadState **tstate_p) {     PyStatus status;     PyInterpreterState *interp;     status = _PyInterpreterState_New(NULL, &interp);     if (_PyStatus_EXCEPTION(status)) {         return status;     }     assert(interp != NULL);     assert(_Py_IsMainInterpreter(interp));     _PyInterpreterState_SetWhence(interp, _PyInterpreterState_WHENCE_RUNTIME);     interp->_ready = 1;      status = _PyConfig_Copy(&interp->config, src_config);     if (_PyStatus_EXCEPTION(status)) {         return status;     }      /* Auto-thread-state API */     status = _PyGILState_Init(interp);     if (_PyStatus_EXCEPTION(status)) {         return status;     }      // ...   }

Она создаст вам основной – главный – интерпретатор, его состояние, GIL и все другие необходимые для запуска вещи. Теперь давайте внимательнее посмотрим на те самые «состояния», которые она создает / использует. Их два главных: PyThreadState (состояние потока) и PyInterpreterState (состояние интерпретатора), которое привязано к состоянию потока. Ну что я рассказываю. Вот дефиниции:

typedef struct _ts PyThreadState; typedef struct _is PyInterpreterState;  struct _ts {     /* See Python/ceval.c for comments explaining most fields */      PyThreadState *prev;     PyThreadState *next;     PyInterpreterState *interp;      /* The global instrumentation version in high bits, plus flags indicating        when to break out of the interpreter loop in lower bits. See details in        pycore_ceval.h. */     uintptr_t eval_breaker;      /* Currently holds the GIL. Must be its own field to avoid data races */     int holds_gil;      // ... };

Внутри можно увидеть много полезного и системного. В том числе указатель на PyInterpreterState, который уже в себе содержет больше прикладных вещей. Например: свои builtins и sys, свои импорты, свой GIL в _gil (или общий в ceval в режиме PyInterpreterConfig_SHARED_GIL, см _PyEval_InitGIL) и все остальное необходимое для работы:

struct _is {     struct _ceval_state ceval;        struct _gc_runtime_state gc;      // Dictionary of the sys module     PyObject *sysdict;      // Dictionary of the builtins module     PyObject *builtins;      struct _import_state imports;      /* The per-interpreter GIL, which might not be used. */     struct _gil_runtime_state _gil;      /* cross-interpreter data and utils */     _PyXI_state_t xi;      // ... };

Разобрались: вот что на самом деле такое «интерпретатор». Теперь мы понимаем, что если таких состояний будет несколько, то мы сможем создать несколько независимых (почему они будут независимыми — мы поговорим чуть позже) интерпретаторов. C-API для такого доступно уже с Python 3.10. Смотрим: https://docs.python.org/dev/c-api/init.html#sub-interpreter-support

PyInterpreterConfig config = {     .use_main_obmalloc = 0,     .allow_fork = 0,     .allow_exec = 0,     .allow_threads = 1,     .allow_daemon_threads = 0,     .check_multi_interp_extensions = 1,     .gil = PyInterpreterConfig_OWN_GIL, };  PyThreadState *tstate = NULL;  // <-  PyStatus status = Py_NewInterpreterFromConfig(&tstate, &config); if (PyStatus_Exception(status)) {     Py_ExitStatusException(status); }

В примере выше мы так же создаем новые PyThreadState и PyInterpreterState. Что в переводе с CPython на русский означает: мы создаем новый поток и новый интерпретатор внутри данного потока. И вот тут ключевая разница с «просто Python потоком». Из-за того, что здесь мы вольны выбирать тип GIL (в примере — мы используем PEP-684 Per-Interpreter GIL), то потоки будут управляться шедулером OS, а не обычной логикой GIL питона. И работать полностью паралелльно и изолировано!

Теперь осталось понять: почему оно изолировано? Как интерпертаторы не мешают друг другу? Как они достигают изоляции?

Изоляция субинтерпретаторов

Самый простой, но и самый сложный момент. Чтобы изоляция работала, нам потребовалось переписать ВСЕ встроенные C-модули, ВСЕ встроенные C-классы питона. Гигантский объем работы, который затрагивал буквально всю стандартную библиотеку. Ну и если авторы других C-extensions хотят поддерживать SubInterpreters (или Free-Threading, кстати, тоже), им тоже нужно все переписать 🌚️️️️️️

Данная изоляция получилась благодаря нескольким факторам:

Разберем все на примерах. Начнем с PEP-489 – двухфазная инициализация модулей. Будем смотреть на mmap как на пример. Как он выглядел раньше?

static struct PyModuleDef mmapmodule = {  // !!!     PyModuleDef_HEAD_INIT,     "mmap",     NULL,     -1,     NULL,     NULL,     NULL,     NULL,     NULL };  PyMODINIT_FUNC PyInit_mmap(void) {     PyObject *dict, *module;      if (PyType_Ready(&mmap_object_type) < 0)         return NULL;      module = PyModule_Create(&mmapmodule);     if (module == NULL)         return NULL;     dict = PyModule_GetDict(module);     if (!dict)         return NULL;     PyDict_SetItemString(dict, "error", PyExc_OSError);     PyDict_SetItemString(dict, "mmap", (PyObject*) &mmap_object_type);      // ...      return module; }

Создание одного общего для всех модуля в PyInit_mmap? Нам такое не подходит!

Сейчас его дефиниция выглядит так:

static int mmap_exec(PyObject *module) {     if (PyModule_AddObjectRef(module, "error", PyExc_OSError) < 0) {         return -1;     }      PyObject *mmap_object_type = PyType_FromModuleAndSpec(module,                                                   &mmap_object_spec, NULL);     if (mmap_object_type == NULL) {         return -1;     }     int rc = PyModule_AddType(module, (PyTypeObject *)mmap_object_type);     Py_DECREF(mmap_object_type);     if (rc < 0) {         return -1;     }      // ...      return 0; }  static PyModuleDef_Slot mmap_slots[] = {     {Py_mod_exec, mmap_exec},     {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},     {Py_mod_gil, Py_MOD_GIL_NOT_USED},     {0, NULL} };  static struct PyModuleDef mmapmodule = {     .m_base = PyModuleDef_HEAD_INIT,     .m_name = "mmap",     .m_size = 0,     .m_slots = mmap_slots, };  PyMODINIT_FUNC PyInit_mmap(void) {     return PyModuleDef_Init(&mmapmodule); }

Что изменилось?

  1. Несколько изменилась дефиниция static struct PyModuleDef mmapmodule, теперь мы указываем там С-слоты будущего модуля. Самый важный для нас Py_mod_exec

  2. Теперь в функцию mmap_exec, которая указана как специальный слот {Py_mod_exec, mmap_exec}, приходит уже кем-то созданный модуль, мы его просто там инициализируем. Ранее мы создавали PyObject * модуля прямо в функции PyInit_mmap из его статического глобального объекта module = PyModule_Create(&mmapmodule). Что было не повторяемо для новых копий. Именно новый АПИ с exec позволяет нам создавать новую собственную копию модуля по запросу.

  3. PyType_Ready в теле модуля больше не вызывается и создание mmap_object_spec тоже изменилось, что пригодится нам в секции про Heap Types

Функция PyInit_mmap(void) осталась для, скорее, вопросов обратной совместимости и некоторых компиляторов. Теперь там вызывается PyModuleDef_Init.

Коммит на изменения: https://github.com/python/cpython/commit/3ad52e366fea37b02a3f619e6b7cffa7dfbdfa2e

Обратите внимание, что мы явно указываем в слотах, что субинтерпретаторы поддерживаются: {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}. Если бы мы попытались импортировать модуль, который не поддерживается явно, получили бы ошибку:

>>> from concurrent.interpreters import create  >>> interp = create() >>> interp.exec('import _suggestions') Traceback (most recent call last):   File "<python-input-3>", line 1, in <module>     interp.exec('import _suggestions')     ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^   File "/Users/sobolev/Desktop/cpython/Lib/concurrent/interpreters/__init__.py", line 212, in exec     raise ExecutionFailed(excinfo) concurrent.interpreters.ExecutionFailed: ImportError: module _suggestions does not support loading in subinterpreters  Uncaught in the interpreter:  Traceback (most recent call last):   File "<script>", line 1, in <module> ImportError: module _suggestions does not support loading in subinterpreters

Вторая часть: изоляция стейта модуля. Убираем все глобальные переменные в специальное место. Посмотрим на примере модуля _csv. Вот так было раньше:

static PyObject *error_obj;     /* CSV exception */ static PyObject *dialects;      /* Dialect registry */ static long field_limit = 128 * 1024;   /* max parsed field size */ static PyTypeObject Dialect_Type; // ...

Выглядит очень просто. Никаких тебе сложных АПИ. Но тут все использует глобальное состояние. А нам такое – нельзя!

Вот так стало сейчас, часть первая:

typedef struct {     PyObject *error_obj;   /* CSV exception */     PyObject *dialects;   /* Dialect registry */     PyTypeObject *dialect_type;     PyTypeObject *reader_type;     PyTypeObject *writer_type;     Py_ssize_t field_limit;   /* max parsed field size */     PyObject *str_write; } _csvstate;  static struct PyModuleDef _csvmodule;  static inline _csvstate* get_csv_state(PyObject *module) {     void *state = PyModule_GetState(module);     assert(state != NULL);     return (_csvstate *)state; }  static int _csv_clear(PyObject *module) {     _csvstate *module_state = PyModule_GetState(module);     Py_CLEAR(module_state->error_obj);     Py_CLEAR(module_state->dialects);     Py_CLEAR(module_state->dialect_type);     Py_CLEAR(module_state->reader_type);     Py_CLEAR(module_state->writer_type);     Py_CLEAR(module_state->str_write);     return 0; }  static int _csv_traverse(PyObject *module, visitproc visit, void *arg) {     _csvstate *module_state = PyModule_GetState(module);     Py_VISIT(module_state->error_obj);     Py_VISIT(module_state->dialects);     Py_VISIT(module_state->dialect_type);     Py_VISIT(module_state->reader_type);     Py_VISIT(module_state->writer_type);     return 0; }  static void _csv_free(void *module) {     (void)_csv_clear((PyObject *)module); }

Тут мы просто определяем сам стейт _csvstate, описываем, как его можно получить get_csv_state, как его очистить _csv_clear, как его обходить в GC _csv_traverse. Вторая часть связана со слотами модуля, размерами стейта и его созданием:

static struct PyModuleDef _csvmodule = {     PyModuleDef_HEAD_INIT,     "_csv",     csv_module_doc,     sizeof(_csvstate), // !!! размер стейта     csv_methods,     csv_slots,     _csv_traverse,  // Py_tp_traverse     _csv_clear,  // Py_tp_clear     _csv_free  // Py_tp_free };  static int csv_exec(PyObject *module) {     PyObject *temp;     _csvstate *module_state = get_csv_state(module);      temp = PyType_FromModuleAndSpec(module, &Dialect_Type_spec, NULL);     // Инициализация стейта тут:     module_state->dialect_type = (PyTypeObject *)temp;     if (PyModule_AddObjectRef(module, "Dialect", temp) < 0) {         return -1;     }      // ... }

Самая важная строчка тут: sizeof(_csvstate), которая показывает, сколько нужно памяти для хранения стейта модуля внутри самого объекта модуля.

Коммит на изменение: https://github.com/python/cpython/commit/6a02b384751dbc13979efc1185f0a7c1670dc349 и https://github.com/python/cpython/commit/e7672d38dc430036539a2b1a279757d1cc819af7

Как результат – каждый модуль имеет свое изолированное состояние. При создании новой копии модуля для субинтерпретатора – будет создаваться его новое внутреннее состояние.

Ну и последнее – Heap Types. Наша задача снова сделать типы не общими, а копируемыми. Убираем static типы и делаем их изолированными. Продолжим смотреть на модуль _csv, было:

static PyTypeObject Dialect_Type = {     PyVarObject_HEAD_INIT(NULL, 0)     "_csv.Dialect",                         /* tp_name */     sizeof(DialectObj),                     /* tp_basicsize */     0,                                      /* tp_itemsize */     /*  methods  */     (destructor)Dialect_dealloc,            /* tp_dealloc */     0,                                      /* tp_vectorcall_offset */     (getattrfunc)0,                         /* tp_getattr */     (setattrfunc)0,                         /* tp_setattr */     0,                                      /* tp_as_async */     (reprfunc)0,                            /* tp_repr */     0,                                      /* tp_as_number */     0,                                      /* tp_as_sequence */     0,                                      /* tp_as_mapping */     (hashfunc)0,                            /* tp_hash */     (ternaryfunc)0,                         /* tp_call */     (reprfunc)0,                                /* tp_str */     0,                                      /* tp_getattro */     0,                                      /* tp_setattro */     0,                                      /* tp_as_buffer */     Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */     Dialect_Type_doc,                       /* tp_doc */     0,                                      /* tp_traverse */     0,                                      /* tp_clear */     0,                                      /* tp_richcompare */     0,                                      /* tp_weaklistoffset */     0,                                      /* tp_iter */     0,                                      /* tp_iternext */     0,                                          /* tp_methods */     Dialect_memberlist,                     /* tp_members */     Dialect_getsetlist,                     /* tp_getset */     0,                                          /* tp_base */     0,                                          /* tp_dict */     0,                                          /* tp_descr_get */     0,                                          /* tp_descr_set */     0,                                          /* tp_dictoffset */     0,                                          /* tp_init */     0,                                          /* tp_alloc */     dialect_new,                                /* tp_new */     0,                                          /* tp_free */ };

Один большой статический глобальный объект! Ужас!

Стало:

static PyType_Slot Dialect_Type_slots[] = {     {Py_tp_doc, (char*)Dialect_Type_doc},     {Py_tp_members, Dialect_memberlist},     {Py_tp_getset, Dialect_getsetlist},     {Py_tp_new, dialect_new},     {Py_tp_methods, dialect_methods},     {Py_tp_dealloc, Dialect_dealloc},     {Py_tp_clear, Dialect_clear},     {Py_tp_traverse, Dialect_traverse},     {0, NULL} };  PyType_Spec Dialect_Type_spec = {     .name = "_csv.Dialect",     .basicsize = sizeof(DialectObj),     .flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC |               Py_TPFLAGS_IMMUTABLETYPE),     .slots = Dialect_Type_slots, };  static int csv_exec(PyObject *module) {     PyObject *temp;     _csvstate *module_state = get_csv_state(module);      // Creating a real type from spec:     temp = PyType_FromModuleAndSpec(module, &Dialect_Type_spec, NULL);     module_state->dialect_type = (PyTypeObject *)temp;     if (PyModule_AddObjectRef(module, "Dialect", temp) < 0) {         return -1;     }     // ... }

Теперь, вместо глобального состояния, мы создаем тип из спецификации внутри Py_mod_exec при помощи PyType_FromModuleAndSpec, сохраняем тип в стейт модуля в module_state->dialect_type. И используем уже его, где нужно. За счет чего получаем полную изоляцию – каждый субинтерпретатор будет иметь свои модули и свои типы. Удобно!

К замерам!

Какая же статья без синтентических бенчмарков с ошибками и неточностями? Вот и я так подумал. Присаживайтесь поудобнее, смотрите код бенчмарков, комментируйте. Вот тест CPU-bound задачи разными способами:

def worker_cpu(arg: tuple[int, int]):     start, end = arg     fact = 1     for i in range(start, end + 1):         fact *= i

Получаем:

Regular: Mean +- std dev: 163 ms +- 1 ms Threading with GIL: Mean +- std dev: 168 ms +- 2 ms Threading NoGIL: Mean +- std dev: 48.7 ms +- 0.6 ms Multiprocessing: Mean +- std dev: 73.4 ms +- 1.5 ms Subinterpreters: Mean +- std dev: 44.8 ms +- 0.5 ms

Субинтерпретаторы показывают лучшее время! Вот так мы их вызывали:

import os from concurrent.futures import InterpreterPoolExecutor  WORKLOADS = [(1, 5), (6, 10), (11, 15), (16, 20)]  CPUS = os.cpu_count() or len(WORKLOADS)  def bench_subinterpreters():     with InterpreterPoolExecutor(CPUS) as executor:         list(executor.map(worker, WORKLOADS))

И для IO-bound задач:

def worker_io(arg: tuple[int, int]):     start, end = arg     with httpx.Client() as client:         for i in range(start, end + 1):             client.get(f'http://jsonplaceholder.typicode.com/posts/{i}')

Результаты:

Regular: Mean +- std dev: 1.45 sec +- 0.03 sec Threading with GIL: Mean +- std dev: 384 ms +- 17 ms (~1/4 от 1.45s) Threading NoGIL: Mean +- std dev: 373 ms +- 20 ms Multiprocessing: Mean +- std dev: 687 ms +- 32 ms Subinterpreters: Mean +- std dev: 547 ms +- 13 ms

Тут – free-threading значительно быстрее, но и субинтерпретаторы дают почти x3 ускорение от базового значения. Отдельно сравним с версией для asyncio:

async def bench_async():     start, end = 1, 20     async with httpx.AsyncClient() as client:         await asyncio.gather(*[             client.get(f'http://jsonplaceholder.typicode.com/posts/{i}')             for i in range(start, end + 1)         ])

Которая, конечно, будет победителем с 166 ms +- 13 ms.

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

import random import numpy as np  data = np.array([  # PyBuffer     random.randint(1, 1024)     for _ in range(1_000_000_0) ], dtype=np.int32) mv = memoryview(data)   # TODO: multiprocessing can't pickle it  def worker_numpy(arg: tuple[Any, int, int]):     # VERY inefficient way of summing numpy array, just to illustate     # the potential possibility:     data, start, end = arg     sum(data[start:end])  worker = worker_numpy chunks_num = os.cpu_count() * 2 + 1 chunk_size = int(len(data) / num_workers) WORKLOADS = [     (mv, chunk_size * i, chunk_size * (i + 1))     for i, cpu in enumerate(range(chunks_num)) ]

Да, мы выбрали самый плохой способ сложения nparray, но нам важен не алгоритм сложения, а сама возможность совершать любые вычислительные операции: шеринг данных, параллелизация процесса. Мы создаем несколько чанков для сложения, удостовериваемся, что оно поддерживает PyBuffer протокол через memoryview, и отправляем считаться разными способами. Получаем:

..................... Regular: Mean +- std dev: 109 ms +- 1 ms ..................... Threading: Mean +- std dev: 112 ms +- 1 ms Multiprocessing: DISQUALIFIED, my macbook exploded ..................... Subinterpreters: Mean +- std dev: 58.4 ms +- 3.1 ms

Что показывает данный бенчмарк? Что мы можем довольно быстро пошарить бафер и сделать с ним какую-то логику. И даже без каких либо оптимизаций и со всеми накладными расходами – работает в два раза быстрее стока. Особо забавно сравнивать с multiprocessing, который чуть не взорвал мне ноут. И я не смог дождаться конца выполнения.

Есть ли способ улучшить производительность субинтерпретаторов? Конечно! Скоро увидим!

Завершение

Вот такая получилась фича. Многое мы не успели обсудить в данной статье. Например: нюансы пепо-принятия, Channels, Queues, concurrent.futures.InterpreterPool и его текущие проблемы. Однако, нельзя впихнуть в одну статью все, даже если очень хочется. Так что, остаемся на связи в следующих статьях!

Материалы, которые я использовал для написания данной статьи:

А еще можно глянуть статью одного из участников нашего чата на данную тему, с большим погружением в детали реализации: https://habr.com/ru/companies/timeweb/articles/922314

Всем большое спасибо за интерес к деталям питона, прочитать такую статью было не просто! Вы крутые!

Если вам хочется больше жести:

  • Можно подписать на мой ТГ канал, где такого очень много: https://t.me/opensource_findings

  • Посмотреть видео про глубокий питон на моем канале: https://www.youtube.com/@sobolevn

  • Поддержать мою работу над разработкой ядра CPython, видео и статьями. Если вы хотите больше хорошего технического контента: https://boosty.to/sobolevn

До новых встреч в кишках питона!


ссылка на оригинал статьи https://habr.com/ru/articles/928054/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *