Привет! Меня зовут Никита Соболев, я 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, кстати, тоже), им тоже нужно все переписать 🌚️️️️️️
Данная изоляция получилась благодаря нескольким факторам:
-
Двухфазной инициализации модулей: https://peps.python.org/pep-0489
-
Изоляции модулей при помощи ModuleState: https://peps.python.org/pep-0687
-
Рефакторингу статичных типов в Heap-Types: https://docs.python.org/3.11/howto/isolating-extensions.html#heap-types
Разберем все на примерах. Начнем с 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); }
Что изменилось?
-
Несколько изменилась дефиниция
static struct PyModuleDef mmapmodule, теперь мы указываем там С-слоты будущего модуля. Самый важный для насPy_mod_exec -
Теперь в функцию
mmap_exec, которая указана как специальный слот{Py_mod_exec, mmap_exec}, приходит уже кем-то созданный модуль, мы его просто там инициализируем. Ранее мы создавалиPyObject *модуля прямо в функцииPyInit_mmapиз его статического глобального объектаmodule = PyModule_Create(&mmapmodule). Что было не повторяемо для новых копий. Именно новый АПИ с exec позволяет нам создавать новую собственную копию модуля по запросу. -
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://t.me/opensource_findings/916
-
Мой пост про
concurrent.interpreters.Queue: https://t.me/opensource_findings/918 -
Multiple Interpreters in the Stdlib: https://peps.python.org/pep-0554
-
Older PEP Multiple Interpreters in the Stdlib: https://peps.python.org/pep-0734
-
A Per-Interpreter GIL: https://peps.python.org/pep-0684
-
Multi-phase extension module initialization: https://peps.python.org/pep-0489
-
A ModuleSpec Type for the Import System: https://peps.python.org/pep-0451
-
Isolating modules in the standard library: https://peps.python.org/pep-0687
-
Module State Access from C Extension Methods: https://peps.python.org/pep-0573
-
Immortal Objects, Using a Fixed Refcount: https://peps.python.org/pep-0683
-
Документация
concurrent.interpreters: https://docs.python.org/3.14/library/concurrent.interpreters.html#module-concurrent.interpreters -
Документация
InterpreterPoolExecutor: https://docs.python.org/3.14/library/concurrent.futures.html#concurrent.futures.InterpreterPoolExecutor
А еще можно глянуть статью одного из участников нашего чата на данную тему, с большим погружением в детали реализации: 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/
Добавить комментарий