Находки в опенсорсе
10.8K subscribers
11 photos
1 video
3 files
820 links
Привет!

Меня зовут Никита Соболев. Я занимаюсь опенсорс разработкой полный рабочий день.

Тут я рассказываю про #python, #c, опенсорс и тд.
Поддержать: https://boosty.to/sobolevn
РКН: https://vk.cc/cOzn36

Связь: @sobolev_nikita
Download Telegram
Argument Clinic

https://devguide.python.org/development-tools/clinic/

Если вы когда-нибудь смотрели исходники питона, то вы замечали внутри вот такие комментарии (взял за пример `sum()`):


/*[clinic input]
sum as builtin_sum

iterable: object
/
start: object(c_default="NULL") = 0

Return the sum of a 'start' value (default: 0) plus an iterable of numbers.
[clinic start generated code]*/

static PyObject *
builtin_sum_impl(PyObject *module, PyObject *iterable, PyObject *start)
/*[clinic end generated code: output=df758cec7d1d302f input=162b50765250d222]*/
{
// ...
}


Есть достаточно понятная проблема: нужно как-то иметь возможность передавать аргументы из Python кода в C код. Учитывая, что бывает много всяких видов Python и C функций (`METH_FASTCALL`, METH_O и тд), то все становится не так уж и просто.

AC позволяет делать достаточно просто описание сигнатуры функции при помощи специального DSL в комментариях.
И даже больше:
- Он генерирует сигнатуру сишной функции со всеми параметрами сразу после тега [clinic start generated code]
- Он хранит последнее состояние в /*[clinic end generated code: output=df758cec7d1d302f input=162b50765250d222]*/
- А еще он создает макросы вида:


PyDoc_STRVAR(builtin_sum__doc__,
"sum($module, iterable, /, start=0)\n"
"--\n"
"\n"
"Return the sum of a \'start\' value (default: 0) plus an iterable of numbers");

#define BUILTIN_SUM_METHODDEF \
{"sum", _PyCFunction_CAST(builtin_sum), METH_FASTCALL|METH_KEYWORDS, builtin_sum__doc__},


Чтобы потом использовать их для добавления методов в модули / классы:


static PyMethodDef builtin_methods[] = {
BUILTIN_SUM_METHODDEF
// ...
};


Как оно внутри?

- Есть большая либа внутри питона для работы с AC (с тестами и mypy)
- Есть make clinic для вызова данной либы на код, который вы меняете
- Можно кастомизировать выполнение либы на питоне, создавая питон код внутри C комментариев
- Мы используем AC даже для C-API тестов
- Сам генератор использует публичный C-API для выдергивания агрументов из переданных объектов. Код генерируется страшный, но читаемый, для примера кусок из файла Python/clinic/bltinmodule.c.h:


static PyObject *
builtin_sum(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
{
PyObject *return_value = NULL;
#if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)

// ...

static const char * const _keywords[] = {"", "start", NULL};
static _PyArg_Parser _parser = {
.keywords = _keywords,
.fname = "sum",
.kwtuple = KWTUPLE,
};
PyObject *argsbuf[2];
Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1;
PyObject *iterable;
PyObject *start = NULL;

args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, 1, 2, 0, argsbuf);
if (!args) {
goto exit;
}
iterable = args[0];
if (!noptargs) {
goto skip_optional_pos;
}
start = args[1];
skip_optional_pos:
return_value = builtin_sum_impl(module, iterable, start);

exit:
return return_value;
}


С ним значительно удобнее, чем писать такое руками!

---

Кстати, скоро мы с моими друзьями с Хабра делаем совместную движуху: https://vibe.habr.com/?utm_source=opensource_findings

В программе:
- Общение с разными ребятами, кто занимается карьерой
- Игра в карьерную настолку
- Специальные активности, чтобы понять, какие вайбы в работе подходят именно вам
2👍3910🤡2😱1
Аллокаторы в СPython: PyArena

Один из самых простых аллокаторов в питоне. Исходники.

По сути данный аллокатор является небольшой оберткой поверх PyMem_Malloc, но с интересной особенностью. Если PyMem_Malloc имеет PyMem_Free для освобождения памяти каждого конкретного объекта, то PyArena имеет только _PyArena_Free(PyArena *arena) для освобождения сразу всей арены со всеми объектами, которые являются ее частью.

Смотрим:


struct _arena {
/* Pointer to the first block allocated for the arena, never NULL.
It is used only to find the first block when the arena is
being freed. */
block *a_head;

/* Pointer to the block currently used for allocation. Its
ab_next field should be NULL. If it is not-null after a
call to block_alloc(), it means a new block has been allocated
and a_cur should be reset to point it. */
block *a_cur;

/* A Python list object containing references to all the PyObject
pointers associated with this arena. They will be DECREFed
when the arena is freed. */
PyObject *a_objects;
};


Как мы видим, арена содержит два указателя на блоки. А вот и они:


typedef struct _block {
/* Total number of bytes owned by this block available to pass out.
Read-only after initialization. The first such byte starts at
ab_mem */
size_t ab_size;

/* Total number of bytes already passed out. The next byte available
to pass out starts at ab_mem + ab_offset */
size_t ab_offset;

/* An arena maintains a singly-linked, NULL-terminated list of
all blocks owned by the arena. These are linked via the
ab_next member */
struct _block *ab_next;

/* Pointer to the first allocatable byte owned by this block. Read-
only after initialization */
void *ab_mem;
} block;


И очищаем сразу все внутри арены:


void _PyArena_Free(PyArena *arena)
{
assert(arena);
// ...
block_free(arena->a_head);
Py_DECREF(arena->a_objects);
PyMem_Free(arena);
}


Обратите внимание, что у PyArena есть block'и и есть список обычных PyObject *. Что достигается за счет следующих АПИ:
- _PyArena_New – создает новую арену и выделяет память под нее. Создает пустой список под будущие объекты
- _PyArena_Free – очищает память существующей арены. Удаляет все блоки из памяти, декрефит объекты в списке, их собирает reference-counter
- _PyArena_Malloc – создает новый block нужного размера и сохраняет указатель на него в single-linked list
- _PyArena_AddObject – добавляет PyObject * в список отслеживаемых объектов и гарантирует, что он будет жить столько, сколько живет сама арена

Использование

Где нужна арена? На самом деле – много где. Сам подход с ареной – можно сравнить с lifetime из Rust. Все объекты внутри арены живут до одного общего конца.

Используется там, где объекты логически имеют общий lifetime. Например, при парсинге кода в AST. Ведь все дерево объектов в AST – имеет общий лайфтайм. Так намного проще обрабатывать ошибки, если произошло что-то плохое, мы просто убиваем всю арену. И нам не надо чистить все объекты в памяти ручками.

Крайне удобная штука.

Большая статья по теме: https://rfleury.com/p/untangling-lifetimes-the-arena-allocator

Выводы

Вот и single-linked list с алгособесов пригодился! 🌚️️️️
👍4235🔥11👏1😢1👌1
Что такое GIL в Python?

Кажется, один из золотых вопросов для всех питонистов на собеседованиях.
Обычно, на встречный вопрос "а что конкретно в питоне является GIL?" не может ответить ни один спрашивающий.

Сегодня мы закроем данный пробел в знаниях питонистов.

Global Interpreter Lock не позволяет более чем одному треду работать с Python API за раз. Его можно отключить через --disable-gil в 3.13+, но сегодня мы про такое не будем.

Обратите внимание на ключевую фразу "c Python API". С системными треды могут и должны работать в режиме настоящей параллельности, без GIL. Что и позволяет получить ускорение при использовании threading, когда C код поддерживает такой способ.

Знакомьтесь – вот структура GIL _gil_runtime_state и поведение в ceval_gil.c.

Как можно отпустить GIL?

На уровне C есть макросы: Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS, которые отпускают GIL в нужных местах. Пример из модуля mmap:


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


Или time.sleep, который тоже дает работать другим тредам, пока ждет.

Что происходит, когда мы используем данный макрос? Они разворачиваются в:


{
PyThreadState *_save;
_save = PyEval_SaveThread();
// your code here
PyEval_RestoreThread(_save);
}


PyThreadState является текущим состоянием треда в CPython. Внутри хранится много контекста. Нас особо сильно интересует часть с полями про GIL:


struct PyThreadState {
struct {
unsigned int initialized:1;
/* Has been bound to an OS thread. */
unsigned int bound:1;
/* Has been unbound from its OS thread. */
unsigned int unbound:1;
/* Has been bound aa current for the GILState API. */
unsigned int bound_gilstate:1;
/* Currently in use (maybe holds the GIL). */
unsigned int active:1;
/* Currently holds the GIL. */
unsigned int holds_gil:1;
} _status;

// Thread state (_Py_THREAD_ATTACHED, _Py_THREAD_DETACHED, _Py_THREAD_SUSPENDED).
int state;

// ...
}


Когда вызывается PyEval_SaveThread и GIL отпускается, то на самом деле мы просто помечаем текущий PyThreadState как:


tstate->_status.active = 0;
tstate->_status.unbound = 1;
tstate->_status.holds_gil = 0;
tstate->state = detached_state;


И вызываем _PyEval_ReleaseLock, который уже правильно изменит _gil_runtime_state.
Как итог – текущий стейт теряет возможность вызывать какие-либо Python АПИ. Даже, например Py_DECREF, и в тредах есть свой refcount, который работает локально, чтобы можно было его вызывать без GIL.

Как треды берут GIL?

Смотрим на thread_run из _threadmodule.c.


_PyThreadState_Bind(tstate);
PyEval_AcquireThread(tstate);
_Py_atomic_add_ssize(&tstate->interp->threads.count, 1);


Там используется PyEval_AcquireThread, который берет GIL в конкретном треде для работы с Python API.
И дальше – отпускаем.

В следующих сериях поговорим про переключение тредов, ParkingLot API, Mutex'ы и прочее.
Обсуждение: сталкивались ли вы на собесах с вопросами про GIL? Стало ли теперь понятнее?

| Поддержать | YouTube | GitHub | Чат |
63👍93🔥3916🤯8👌2🥰1🤡1
unraisable exceptions в питоне

Мы все с вами привыкли, что в питоне можно "зарайзить" исключение в любой момент: raise Exception
Но, что если в какой-то момент времени мы не можем вызывать исключение?

Простейший пример: что произойдет при запуске такого скрипта?


# ex.py
class BrokenDel:
def __del__(self):
raise ValueError('del is broken')

obj = BrokenDel()
del obj

print('done!') # будет ли выведено?


Тут может быть два варианта:
1. Или del вызовет ValueError и программа завершится
2. Или случится какая-то магия, ошибка будет вызвана, напечатается, но программа продолжится

Ну и так как мы с вами на том канале, где мы с вами, то конечно же будет второй вариант.


» ./python.exe ex.py
Exception ignored while calling deallocator <function BrokenDel.__del__ at 0x10303c1d0>:
Traceback (most recent call last):
File "/Users/sobolev/Desktop/cpython/ex.py", line 3, in __del__
raise ValueError('del is broken')
ValueError: del is broken
done!


Знакомьтесь – unraisable exceptions 🤝

Как оно работает?

В некоторых местах C кода у нас есть необходимость вызывать исключения, но нет технической возможности. Пример, как выглядит упрощенный dealloc для list?


static void
list_dealloc(PyListObject *op)
{
Py_ssize_t i;
PyObject_GC_UnTrack(op); // убираем объект из отслеживания gc
if (op->ob_item != NULL) {
i = Py_SIZE(op);
while (--i >= 0) {
Py_XDECREF(op->ob_item[i]); // уменьшаем счетчик ссылок каждого объекта в списке
}
op->ob_item = NULL;
}
PyObject_GC_Del(op);
}


А, как вы можете знать, чтобы в C коде вызвать ошибку, нужно сделать две вещи:
- Взывать специальное АПИ вроде PyErr_SetString(PyExc_ValueError, "some text")
- И вернуть NULL как PyObject * из соответствующих АПИ, показывая, что у нас ошибка. Если вернуть NULL нельзя, то мы не можем поставить ошибку. А тут у нас void и вернуть вообще ничего нельзя. Потому приходится использовать вот такой подход с unraisable exception

Ошибку мы "вызываем" через специальные АПИ:
- PyErr_WriteUnraisable
- PyErr_FormatUnraisable

Они создают ошибку, но не выкидывают её обычным способом, а сразу отправляют в специальный хук-обработчик.
В питоне оно используется где-то 150 раз. То есть – прям часто. Примеры:

- Ошибки при завершении интерпретатора, попробуйте сами:


import atexit
def foo():
raise Exception('foo')
atexit.register(foo)


- Ошибки внутри sys.excepthook
- Ошибки внутри gc
- Ошибки внутри логики установки ошибок (вдруг память кончилась, например) 🌚️️️️
- И многое другое

Пользовательское АПИ

Ну и конечно же, есть специальный хук для обработки таких ошибок: sys.unraisablehook

Например, pytest использует кастомный хук, чтобы валить тесты при возникновении такой ситуации. Что логично.

Обсуждение: знали ли вы про такую особенность? Приходилось ли где-то в мониторинге особо настраивать?

| Поддержать | YouTube | GitHub | Чат |
7🤯111🔥36👍284👎1🤡1
Три типа объектов в Питоне

В питоне часто любят обсуждать "мутабельные" и "иммутабельные" объекты, но крайне редко объясняют, в чем же на самом деле разница. Сегодня мы посмотрим на такое со стороны C.

PyObject

Все мы знаем, что в питоне все объект или PyObject *, который упрощенно выглядит так (в FT сборке он посложнее):


struct _object {
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
}


То есть: у нас есть только счетчик ссылок на объект и указатель на его тип. Первое меняется очень часто, если объект не immortal. Второе тоже можно менять в некоторых случаях:


>>> class A:
... __slots__ = ()

>>> class B:
... __slots__ = ()

>>> a = A()
>>> type(a)
<class '__main__.A'>
>>> a.__class__ = B
>>> type(a)
<class '__main__.B'>


Получается, что большинство объектов мутабельные уже на данном уровне.

Но, в целом есть три типа объектов, разные по уровню мутабельности:
1. Такие как None: ob_refcnt не меняется (immortal), тип менять нельзя, ведь Py_TPFLAGS_IMMUTABLETYPE установлен (static type), размер неизменный 0 для всех потенциальных значений
2. Такие как int: ob_refcnt может меняться для больших чисел (маленькие инты - immortal), тип менять нельзя, размер нельзя менять, но он будет разный для разных чисел:


>>> sys.getsizeof(1)
28
>>> sys.getsizeof(10000000000000)
32


3. Такие как list: ob_refcnt всегда меняется, тип менять нельзя, размер меняется

Отдельно нужно отметить, что пользовательские классы обычно еще более мутабельны, потому что их тип менять можно.
Но, вопрос в другом: а где вообще хранится размер объекта и его внутренности? Раз в PyObject ничего такого нет.

C-API

В C-API питона есть два полезных макроса: PyObject_HEAD для объектов фиксированного размера и PyObject_VAR_HEAD для объектов, которые могут менять размер.


struct PyVarObject {
PyObject ob_base;
Py_ssize_t ob_size; /* Number of items in variable part */
};

#define PyObject_HEAD PyObject ob_base;
#define PyObject_VAR_HEAD PyVarObject ob_base;


Хотим поменять размер объекта? Увеличиваем ob_size, аллоцируем новую память (если нужно) под новые объекты внутри.

Итоговые объекты используют примерно такую логику:


typedef struct {
PyObject_VAR_HEAD
/* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
PyObject **ob_item;

/* ob_item contains space for 'allocated' elements. The number
* currently in use is ob_size.
*/
Py_ssize_t allocated;
} PyListObject;


То есть: на самом деле все объекты list (и многие другие) не просто PyObject, они:
- Имеют свой собственный тип: PyListObject
- Имеют общую абстракцию для размерности: PyVarObject
- Имеют общую абстракцию для типа и счетчика ссылок: PyObject

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

Итог

1. None не имеет внутреннего состояния вообще (не использует ничего)
2. int может иметь разный размер, но не может изменяться, потому использует PyObject_HEAD (раньше был PyObject_VAR_HEAD, там сложная история):


typedef struct _PyLongValue {
uintptr_t lv_tag; /* Number of digits, sign and flags */
digit ob_digit[1];
} _PyLongValue;

struct _longobject {
PyObject_HEAD
_PyLongValue long_value;
};


3. list может иметь разный размер и может изменятся, потому использует PyObject_VAR_HEAD, как я показывал выше

Обсуждение: как вы думаете, как работает len() для list?

| Поддержать | YouTube | GitHub | Чат |
6👍7522🔥4💩4🕊1
Находки в опенсорсе
Аллокаторы в СPython: PyArena Один из самых простых аллокаторов в питоне. Исходники. По сути данный аллокатор является небольшой оберткой поверх PyMem_Malloc, но с интересной особенностью. Если PyMem_Malloc имеет PyMem_Free для освобождения памяти каждого…
Аллокаторы в СPython: база

Тема аллокаторов иногда питонистам кажется сложной, потому что в питоне мы их не вызываем явно. Оттого с ними не очень знакомы, так давайте исправлять и знакомиться!

Зачем вообще нужно много разных аллокаторов? Все они делают одно и то же: выделяют память в куче (heap). В зависимости от наших вариантов использования данной памяти - выделять и освобождать её нужно очень по-разному.

Где-то множество мелких объектов, которые часто создаются и очищаются. Где-то несколько больших, которые должны умирать все вместе. Где-то мы работаем в рамках одного потока, где-то несколько потоков будут запрашивать / высвобождать память параллельно.

Например: при парсинге AST мы используем PyArena аллокатор. Он выделяет сразу много памяти, сразу вычищает все за один раз. Что идеально подходит для парсинга.

Но, для рантайма - задачи, конечно же другие. Там есть долгоживущие объекты, есть много мелких краткоживущих, есть довольно большие, есть маленькие. Для таких задач используют "general purpose allocators". Которые в среднем хороши во всем.

Дизайн аллокаторов в CPython

Питон знает, как его будут использовать. Потому поверх базовых GPA есть собственные надстройки.

Документация:
- https://docs.python.org/3/c-api/allocation.html
- https://docs.python.org/3/c-api/memory.html

В CPython есть: malloc, pymalloc, mimalloc и некоторые их варианты для дебага.

Они разделены на три "домена" для аллокаторов, то с чем они работают, какие задачи решают:
- Raw: для выделения памяти для общих задач, например под сишные буферы или IO. Может работать без PyThreadState
- Mem: для выделения памяти для общих задач, но уже с PyThreadState, например под Python буферы, подходит для мелких объектов
- Object: для выделения памяти под конкретные мелкие объекты

Разработчики C-extensions должны понимать, когда какой использовать и под какие задачи.
К счастью, разработчикам на питоне - такое нужно только для любопытства.

А вот таблица, какие реальные аллокаторы используют те или иные C-API функции в разных режимах:


PyMem_RawMalloc -> malloc
PyMem_Malloc -> pymalloc
PyObject_Malloc -> pymalloc


Она правда немного устарела и не отражает Free-Threading сборки, которые требуют mimalloc 🌚
Кто первый успеет сделать PR с исправлением - тот молодец!
О mimalloc мы как-нибудь отдельно поговорим, там нужно рассказывать сильно глубже, в том числе про GC и PyGC_Head.

Зачем питону свой аллокатор?

В CPython есть (был? для free-threading он не используется и не будет) свой аллокатор: pymalloc, основная задача которого – работа с маленькими Python объектами.
Про него полностью тоже нужно писать большой отдельный пост.

Что вообще важно в аллокаторе?
- Стратегия выделения памяти под новый запрос
- Работа с округлениями размера памяти и выравнивание
- Дефрагментация памяти
- Стратегия очистки памяти


struct arena_object {
uintptr_t address;
pymem_block* pool_address;
uint nfreepools;
uint ntotalpools;
struct pool_header* freepools;
struct arena_object* nextarena;
struct arena_object* prevarena;
};


Но кратко про pymalloc можно сказать следующее:
- Он создает арены по 1MB
- Внутри арены разделены на пулы по 16KB
- Внутри пулы поделены на блоки фиксированного размера

Зачем? Чтобы не аллоцировать часто маленькие кусочки памяти. Что дорого.

Можно ли управлять аллокаторами?

Да! Есть опции для сборки: --without-mimalloc, --without-pymalloc
И даже переменная окружения PYTHONMALLOC, которая позволяет указать, какой аллокатор использовать для всех случаев. Зачем? Прежде всего для дебага. Но можно потестить, вдруг будет давать буст по скорости или потреблению памяти в ваших вариантах использования.

Обсуждение: какой ваш любимый аллокатор? И почему jemalloc?

| Поддержать | YouTube | GitHub | Чат |
1🔥70👍289👏1👌1🕊1