Вы наверняка видели множество статей на тему "Python, Rust - производительность, бла-бла-бла... Вот, реализуем foo2plus2". Вся беда в том, что все эти статьи демонстрируют очень простые примеры уровня "hello-world". Напротив, в этой статье я хочу рассказать о том, как я проектирую комплексные расширения и почему я принимаю те или иные проектные решения.
Статья является переводом и адаптацией моей же статьи на Medium. Возможно, кто-то предпочтет прочесть в оригинале - ссылка позволяет прочитать без подписки.
На данный момент я написал четыре библиотеки для Python на Rust (1, 2, 3, 4) и приобрел определенный опыт, но все еще не чувствую, что достиг той квалификации, которая позволяет утверждать, что правильно, а что нет. Некоторые из моих подходов вдохновлены другими людьми, другие являются результатом анализа и долгих попыток рефакторинга кода, и все же, я не уверен, что мои решения являются лучшими из возможных.
Кроме того, я не очень опытный разработчик на Rust, поэтому в моем коде могут встречаться забавные вещи - будьте готовы к фейспалмам, если вы сильно прошарены в Rust.
Благодарности
Если порыться в моих репозиториях, то можно обнаружить эволюцию подхода к разработке расширений Rust для Python: например, в Similari многие решения странные, но именно в ходе данного проекта у меня произошел существенный скачок вперед, когда я получил PR от Андрея Ткаченко.
Другая команда продемонстрировала, как использовать Sphinx для документирования расширений, и я написал об этом в соответствующей статье. Я провел несколько часов, погружаясь в среду Manylinux и особенности ее использование в Maturin GitHub Action, в результате чего появилась статья, демонстрирующая, как создавать переносимые расширения Rust для Python с помощью GitHub CI и сборке в Docker.
Хочу сказать "спасибо" всем тем, кто помог мне продвинуться в данной теме: все вы проделали огромную работу, создавая свой код без общепринятых подходов и обширной документации.
Благодарю следующие коллективы и людей за участие в моем прогрессе:
PyO3 team — за их усилия по предоставлению разработчикам превосходного инструмента для создания расширений Python на Rust;
Andrei Tkachenko — за демонстрацию возможностей использования
transmute
при разработке расширений Python на Rust и за огромный вклад в развитие Similari;Pola.rs project team — идеи по дизайну и фрагменты кода;
Pometry/Raphtory team — за идеи по документированию расширений Python на Rust.
Предварительные условия
Это не вводная статья. Я предполагаю, что вы знаете Rust и можете написать расширение типа "hello world" для Python на Rust с помощью PyO3. Я предполагаю, что вы знаете, что такое GIL и как он работает.
Специфика модели управления памятью
Rust и Python подходят по разному к управлению памятью, что иногда превращает проектирование сложных структур данных и способов их взаимодействия между собой в нетривиальную задачу: в Rust модель памяти основана на владении, а в Python - на ссылках на объекты в куче.
Наиболее близкой структурой, представляющей модель памяти Python, в Rust является:
type WrappedMutex = Arc<Mutex<T>>;
// or
type WrappedRwLock = Arc<RwLock<T>>;
Однако, в Python вышеуказанный мьютекс представлен GIL - глобальная блокировка, общая для всех объектов в рантайме, в то время как в Rust возможно осуществлять изолированную блокировку только нужных для работы объектов.
Одна из ключевых проблем при проектировании объектной модели совместимой с Python заключается в том, что передать ссылку (&mut
или &
) из Rust в Python невозможно: для этого необходимо использовать Arc<T>
.
В PyO3 реализованы некоторые решения, позволяющие передавать аллоцированные в Python объекты в Rust-функции по ссылке, но обратная операция невозможна: вы не можете просто сохранить ссылку на Rust-объект в памяти Python. PyO3 поддерживает передачу объектов Rust в Python по значению, оборачивая их в объекты Python (PyObject
).
Понимание моделей памяти и их особенностей их взаимодействия между собой важно для правильного проектирования кода, особенно если он освобождает GIL, поскольку освобождение GIL требует от объекта реализации трейта Ungil
, который, по сути, является Send
.
Если вы хотите одновременно использовать объект в Python и Rust, необходимо использовать обертку для него на базе Arc<T>
или Arc<Mutex<T>>.
Пример объекта, используемого только в Python:
use pyo3::prelude::*;
#[pyclass]
struct Object {
x: i32,
}
#[pymethods]
impl Object {
fn inc(&mut self) {
self.x += 1;
}
}
Ссылка на экземпляр данного класса может быть временно передана в функцию Rust, вызываемую из Python, например:
#[pyfunction]
fn callme(o: &mut Object) {
o.inc();
}
Или вы можете передать объект в Rust как копию (Clone
) - объект в пространстве памяти Python не пропадет:
#[pyfunction]
fn callme(mut o: Object) {
o.inc();
}
Пример объекта, используемого одновременно из Rust и Python:
use parking_lot::Mutex;
use pyo3::prelude::*;
use std::sync::Arc;
struct Object {
x: i32,
}
impl Object {
fn inc(&mut self) {
self.x += 1;
}
}
#[pyclass]
#[pyo3(name = "Object")]
#[derive(Clone)]
struct ObjectWrapper(Arc<Mutex<Object>>);
#[pymethods]
impl ObjectWrapper {
fn inc(&self) {
let mut bound = self.0.lock();
bound.inc();
}
}
Теперь вы можете иметь ссылки на один и тот же объект как в Python, так и в Rust, например:
thread_local! {
static OBJECT: ObjectWrapper = ObjectWrapper(Arc::new(Mutex::new(Object { x: 0 })))
}
#[pyfunction]
fn get_shared_object() -> ObjectWrapper {
OBJECT.with(|o| o.clone())
}
#[pyfunction]
fn callme(o: &mut ObjectWrapper) {
o.inc();
}
Его можно одновременно использовать из Python и Rust (в двух потоках, например). Mutex<T>
защищает его от гонки, а Arc<T>
обеспечивает механизм совместного использования. Вместо мьютекса можно использовать другие примитивы синхронизации, например RwLock<T>
.
Нельзя безопасно написать функцию, возвращающую в Python ссылку на объект, выделенный Rust. Но можно вернуть Python ссылку на объект, выделенный в памяти Python:
fn callme<'p>(slf: PyRef<'p, Self>, _py: Python<'p>) -> PyResult<PyRef<'p, Self>> {
let o = slf.deref();
...
Ok(slf)
}
Сфера применения вышеуказанного кода ограничена, но можно, например, использовать для реализации билдеров или для реализации with
.
Структура проекта
При работе с PyO3, структура проекта сильно влияет на вашу производительность и удобство работы с кодом . Приступая к работе над расширением, вооружившись руководством по PyO3, вы обычно начинаете с проекта, сочетающего в себе как Rust-код, так и Python-аннотации, что приводит к тесной связи с Python - вы не сможете компилировать код без Python и тестировать его без инициализации среды выполнения Python.
В принципе, у вас есть два подхода к структурированию кода:
с использованием пространств имен и feature-флагов, размещая код, связанный с Python, за флагами (этот подход реализован в Similari);
с использованием отдельных крейтов, объединенных в одно рабочее пространство (Savant-RS, RocksQ).
Изначально я придерживался первого подхода, но сейчас считаю, что второй подход гораздо лучше. Вот как выглядит проект RocksQ в рамках подхода с разными крейтами:
Итак, как вы видите, у меня два крейта: в queue_rs
я реализовал логику и функциональность, он ничего не знает о Python. Во втором - только код, связанный с Python. Я считаю, что такая компоновка является оптимальной по ряду причин. Давайте обсудим их.
Продуктивность при работе над кодом с логикой на Rust. Я хочу, чтобы мой код быстро компилировался и тестировался без использования среды исполнения Python и ее типов данных. Такая компоновка проекта позволяет разрабатывать код с логикой с использованием только нативных типов.
Зависимость от PyO3 увеличивает время компиляции и часто делает невозможной реализацию тестов без инициализации среды Python, что неудобно, и может приводить к инетересным эффектам, поскольку GIL сериализует код, ваши тесты могут приобретать неожиданное изменчивое от запуска к запуску поведение.
Например, при использовании
PyBytes
необходимо иметь рабочую среду Python для создания объекта данного типа. Я же не хочу иметь дело с этими лишними операциями при работе с логикой.
Следуя вышеизложенному, я пишу свой код с учетом особенностей модели памяти, но без типов, связанных с Python, и зависимости от PyO3.
Мои бенчмарки, размещенные в крейте с логикой, работают только с кодом на Rust, поэтому я уверен в том, что они проверяют скорость работы оптимизированного кода, а не транзакционные издержки, связанные с передачей данных между Python и Rust. В свою очередь, в крейте связи с Python вы можете размещать тесты, направленные на тестирование e2e поведения.
Однажды с помощью такого подхода я выяснил, что передача в Rust больших массивов
Vec<u8>
убивает производительность, и начал использоватьPyBytes
в своем коде, связанном с PyO3.
Продуктивность при работе над кодом, связанным с Python. Когда я работаю над кодом, связанным с Python, мне необходимо реализовывать только преобразование между типами Python и типами Rust, но не логику. Время компиляции уменьшается, поскольку крейте с логикой изменений не проиходит.
Особенно время компиляции беспокоит меня при работе над документацией - я очень часто меняю аннотации и перекомпилирую код для того, чтобы посмотреть как аннотации будут выглядеть после обработки Sphix. Мои соображения по созданию документации для расширений Rust читайте в соответствующей статье. Предложенная схема помогает мне значительно сократить время компиляции.
Структурирование кодовой базы. Я всегда знаю, где найти код - если он связан с Python, то в проекте *_py
, в противном случае - в проекте *_rs
. Это помогает уменьшить хаос, который возникает в случае, когда в одном файле встречается много кода, не относящегося к логике, но обеспечивающего взаимодействие с Python.
Именование объектов кода. Rust не поддерживает переопределения имен функций; обычно это означает, что если вам нужна одна и та же функция для внутренних целей (только для Rust) и для экспорта в Python, то одну из них требуется назвать как-то по-хитрому.
Раньше мне приходилось делать что-то в духе:
fn callme(x: i64) -> i64 {
...
}
#[pyfunction]
#[pyo3(name = "callme")]
fn callme_py(x: i64) -> i64 {
callme(x)
}
Это работает, но порождает беспорядочный код. В предлагаемом подходе это выглядит следующим образом:
use lib_rs as rust;
#[pyfunction]
fn callme(x: i64) -> i64 {
rust::callme(x)
}
Рано или поздно кто-то захочет получить сборку без Python. Так было с Similari, люди хотели получить код без зависимости от Python. Это стало проблемой. В результате Andrei Tkachenko предложил большой PR, поместив функциональность, связанную с Python, за feature-флаг.
Обертки Python для объектов
Теперь, зная мой подход, вы, наверное, начали думать, что у меня дублируется больше кода, чем требуется. Да, это так, вместо аннотирования одного объекта с #[pyclass]
у меня две реализации:
pub struct Object {
x: i32,
}
impl Object {
pub fn inc(&mut self) {
self.x += 1;
}
}
use pyo3::prelude::*;
use lib_rs as rust;
#[pyclass]
struct Object(rust::Object);
#[pymethods]
impl Object {
fn inc(&mut self) {
self.0.inc()
}
}
Тем не менее, я нахожу подход очень удобным, поскольку код в крейте *_py
является абсолютно шаблонным и легко генерируется GitHub Copilot практически без доработок.
Обертка объекта в Python представляет собой кортежную структуру с единственным аргументом
Обычно я не использую традиционные структуры Rust в своих крейтах *_py
: только кортежные структуры. Ранее я писал что-то вроде:
struct Object {
inner: rust::Object
}
Однако теперь я вижу недостатки такой компоновки:
Я обнаружил, что иногда я использую "inner", иногда "object", "internal" и т.д. Это вносит хаос.
Код выглядит менее унифицированным, при кортежной компоновке обращение к внутреннему объекту всегда происходит через
.0.
.Вы можете легко использовать
std::mem::transmute
???? с кортежеподобными структурами.Для Option и Result можно использовать простую упаковку вида:
#[pyfunction]
fn callme() -> Option<Object> {
if ... {
None
} else {
let res_opt = Some(rust::Object);
res_opt.map(Object) // here it is
}
}
Магия transmute. Расположение в памяти X
и struct Y(X)
в Rust одинаково, поэтому с помощью transmute
можно "безопасно" преобразовывать кортежные структуры Python в кортежные структуры Rust на месте, без перераспределения памяти. Особенно это актуально, когда необходимо преобразовать большой вектор (Vec<T>
) между rust::Object
и Python'овским Object
.
Не поймите меня неправильно, я не пропагандирую transmute
- это небезопасная функция, но она может помочь преобразовать большие объекты памяти на месте практически без затрат.
struct X {
i: i64
}
struct W(X)
let x = X { i: 1 };
let w: W = unsafe { std::mem::transmute(x) };
let x: X = unsafe { std::mem::transmute(w) };
let vx = vec![X { i: 1 }];
let wx: Vec<W> = unsafe { std::mem::transmute(vx) };
Перечисления с вариантами без полей
Когда мне нужны такие перечисления, я дублирую их в двух крейтах и реализую в своем крейте *_py
trait From
для обоих направлений:
// in lib_rs
enum X {
A,
B,
}
// in lib_py
enum X {
A,
B
}
impl From<rust::X> for X {
...
}
impl From<X> for rust::X {
...
}
Честно говоря, эти перечисления не дают мне покоя. Такие перечисления прекрасно поддерживаются PyO3, и я могу просто использовать для них #[pyclass]
, но это требует либо переноса их в отдельный крейт с зависимостью от PyO3, либо добавления зависимости от PyO3 в мой крейт *_rs
. На данный момент я решил остановиться на приведенном выше решении.
Возможно, стоит попробовать помещать их в отдельный крейт enums
, который будет зависимостью для lib_rs
и lib_py
. Я еще не решил.
Enum с вариантами, содержащими поля
В настоящее время они не поддерживаются PyO3. Я работаю с ними так, как показано ниже, создавая отдельные объекты при возврате соответствующего варианта:
use pyo3::prelude::*;
mod rust {
pub enum Pet {
Dog { name: String, age: u8 },
Hamster { name: String },
}
}
#[pyclass]
struct Pet(rust::Pet);
#[pyclass]
struct Dog {
name: String,
age: u8,
}
#[pyclass]
struct Hamster {
name: String,
}
#[pymethods]
impl Pet {
fn is_dog(&self) -> bool {
matches!(&self.0, Dog { name: _, age: _ })
}
fn is_hamster(&self) -> bool {
matches!(&self.0, Hamster { name: _ })
}
fn as_dog(&self) -> Option<Dog> {
if let rust::Pet::Dog { name, age } = &self.0 {
Some(Dog {
name: name.clone(),
age: *age,
})
}
None
}
fn as_hamster(&self) -> Option<Hamster> {
if let rust::Pet::Hamster { name } = &self.0 {
Some(Hamster { name: name.clone() })
}
None
}
}
Работа с исключениями Python
С помощью PyO3 можно легко преобразовать Rust Result
(я использую anyhow
) в Python PyResult
, вариант Err
которого является Python-исключением:
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
mod rust {
use anyhow::Result;
pub fn u8_overflow_plus(add: u8, to: u8) -> Result<u8> {
if add > u8::MAX - to {
bail!("overflow")
} else {
Ok(add + to)
}
}
}
#[pyfunction]
fn u8_overflow_plus(add: u8, to: u8) -> PyResult<u8> {
rust::u8_overflow_plus(add, to)
.map_err(|e| PyValueError::new_err(format!("u8_overflow_plus: {}", e)))
}
// in your Python code
//
// try:
// u8_overflow_plus(255, 255)
// except ValueError:
// # impossible
Я очень часто использую Result
в своем коде. Реализация в PyO3 очень элегантная: вы можете использовать модель кодирования Rust, которая прозрачно отображается на исключения в Python. Я нахожу это волшеным: если он возвращает Ok(smth)
, то получается обычное значение Python; если возвращает Err(e)
- код Python вызывает исключение.
Работа с аргументами по умолчанию для функций и методов
Python поддерживает аргументы по умолчанию, и я довольно часто использую их для упрощения API. Аргументы по умолчанию можно использовать в конструкторах, методах и функциях.
Конструкторы:
#[pymethods]
impl PersistentQueueWithCapacity {
#[new]
#[pyo3(signature=(path, max_elements = 1_000_000_000))]
fn new(path: &str, max_elements: usize) -> PyResult<Self> {
...
}
Методы (self пропускаем в описании сигнатуры):
#[pymethods]
impl PersistentQueueWithCapacity {
#[pyo3(signature=(n = 1_000_000_000))]
fn set(&mut self, n: usize) {
...
}
Функции:
#[pyfunction]
#[pyo3(signature=(x=0, i=1))]
fn inc(x: i64, i: i64) -> i64 {
...
}
// can be called in python
// inc()
// inc(1)
// inc(i=10)
// inc(x=1, i=10)
// inc(1, 10)
Конструирование объектов
Обычно вы помечаете метод символом #[new]
, чтобы указать, что он используется как __init__
в Python. Если объект конструируется в Rust, вы можете вообще не иметь публично доступного конструктора.
При построении перечислений я обычно использую несколько иной подход, основанный на #[staticmethod]
. Вспомним наше перечисление Pet
из предыдущего примера:
use pyo3::prelude::*;
mod rust {
pub enum Pet {
Dog { name: String, age: u8 },
Hamster { name: String },
}
}
#[pyclass]
struct Pet(rust::Pet);
#[pyclass]
struct Dog {
name: String,
age: u8,
}
#[pyclass]
struct Hamster {
name: String,
}
#[pymethods]
impl Pet {
#[staticmethod]
fn dog(name: String, age: u8) -> Self {
Self(rust::Pet::Dog { name, age })
}
#[staticmethod]
fn hamster(name: String) -> Self {
Self(rust::Pet::Hamster { name })
}
}
Теперь в языке Python объекты могут быть построены следующим образом:
dog = Pet.dog("Pike", 3)
hamster = Pet.hamster("Wheely")
Этот подход можно использовать и в том случае, если для регулярной структуры требуется несколько конструкторов, в зависимости от способа создания объекта.
Работа с GIL
Освобождение GIL не всегда является хорошей идеей. Когда код выполняется быстро, я не оптимизирую операции с GIL. Эмпирическим путем я обнаружил, что код обычно становится медленнее, если функция завершается быстрее, чем за 1000нс
, и я освобождаю GIL внутри нее. Иногда я не уверен, нужно ли освобождать GIL или нет. В такой ситуации я реализую функцию, позволяя пользователю выбрать поведение:
#[pyo3(signature = (items, no_gil = true))]
fn push(&mut self, items: Vec<&PyBytes>, no_gil: bool) -> PyResult<()> {
let data = items.iter().map(|e| e.as_bytes()).collect::<Vec<&[u8]>>();
Python::with_gil(|py| {
let mut f = move || {
self.0
.push(&data)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to push item: {}", e)))
};
if no_gil {
py.allow_threads(f)
} else {
f()
}
})
}
Иногда, я добавляю еще трассировку тайминга работы с GIL, чтобы позже решить освобождать GIL или нет в конкретном случае.
Вы можете задаться вопросом, почему освобождение GIL может быть плохим вариантом? Позвольте мне объяснить это на примере псевдокода. Когда мы не освобождаем GIL, все работает следующим образом:
ACQUIRE_GIL <<<<<
CALL function
DO STUFF
ENDCALL
RELEASE_GIL
Когда освобождаем:
ACQUIRE_GIL <<<<<
CALL function
RELEASE_GIL
DO STUFF
ACQUIRE_GIL <<<<<
ENDCALL
RELEASE_GIL
Видно, что операций ACQUIRE GIL
две, а не одна, как могло бы сначала показаться. Каждая операция ACQUIRE GIL
- это блокирующая операция захвата глобального мьютекса GIL, которая ожидает его доступности. При увеличении параллелизма это приводит к замедлению работы.
Просто имейте в виду, что когда ваш код вызвался, он уже готов к работе и уже потратил время на захват GIL. Я обычно освобождаю GIL только тогда, когда это повышает производительность параллелизма, а операции внутри функции занимают существенное время.
PyBytes вместо Vec<u8>
Раньше при работе с двоичными данными я использовал следующие конструкции (в PyO3 нельзя передавать &Vec<u8>
или &[u8]
):
#[pyfunction]
fn bypass_and_ret(v: Vec<u8>) -> Vec<u8> {
v
}
Он медлителен при передаче больших байтовых буферов, однако дает гибкость: можно передавать Python bytes
или [1, 2, 3]
. В настоящее время при работе с большими двоичными данными я предпочитаю использовать &PyBytes
(да, его можно передавать через &
):
use pyo3::prelude::*;
use pyo3::types::PyBytes;
#[pyfunction]
fn bypass_and_ret(v: &PyBytes) -> PyObject {
let data = v.as_bytes();
Python::with_gil(|py| PyObject::from(PyBytes::new(py, data)))
}
Вас не должно беспокоить, что внутри повторно захватывается GIL: поскольку он уже нам принадлежит, то захват мгновенный.
Версия библиотеки
Обычно я помещаю функцию version()
в пакет, чтобы легко проверять, что версия верна и соответствует ожидаемой. Обычно я проверяю версию в вызывающем коде, чтобы убедиться, что код на Python и Rust синхронизированы.
mod rust {
pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") }
}
#[pyfunction]
fn version() -> String { rust::version() }L
Довольно занудная штука - использовать это в каждой либе, но, как говорилось в рекламе: "Попробовал раз, ем и сейчас". Такой уж я человек :)
Вложенные пакеты Python
У PyO3 есть проблемы с вложенными пакетами Python. Погуглив и почитав обсуждения, я обнаружил, что следующий подход отлично работает:
#[pymodule]
fn rocksq_blocking(_: Python, m: &PyModule) -> PyResult<()> {
Ok(())
}
#[pymodule]
fn rocksq_nonblocking(_: Python, m: &PyModule) -> PyResult<()> {
Ok(())
}
#[pymodule]
fn rocksq(py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(version, m)?)?;
m.add_wrapped(wrap_pymodule!(rocksq_blocking))?;
m.add_wrapped(wrap_pymodule!(rocksq_nonblocking))?;
let sys = PyModule::import(py, "sys")?;
let sys_modules: &PyDict = sys.getattr("modules")?.downcast()?;
sys_modules.set_item("rocksq.blocking", m.getattr("rocksq_blocking")?)?;
sys_modules.set_item("rocksq.nonblocking", m.getattr("rocksq_nonblocking")?)?;
Ok(())
}
Ведение логов
В PyO3 используется отдельный крейт (pyo3-log
), предоставляющий возможность направлять журналы Rust в систему протоколирования Python (из документации по PyO3):
use log::info;
use pyo3::prelude::*;
#[pyfunction]
fn log_something() {
// This will use the logger installed in `my_module` to send the `info`
// message to the Python logging facilities.
info!("Something!");
}
#[pymodule]
fn my_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// A good place to install the Rust -> Python logger.
pyo3_log::init();
m.add_function(wrap_pyfunction!(log_something))?;
Ok(())
}
Все выглядит просто замечательно до того момента, пока вы не поймете, что для этого необходимо получать GIL каждый раз, когда вы что-то логируете.
В своих проектах, когда это необходимо, я реализую обратную схему: Я предоставляю функцию log
, реализованную на Rust в Python, а Python использует ее напрямую или реализует аппендер журнала для отправки своих сообщений в систему протоколирования Rust. Пример такого подхода к протоколированию можно посмотреть здесь.
Строковое представление объектов
Чтобы помочь пользователям разобраться во внутренней структуре объекта, вы можете предоставить им методы __repr__
и __str__
. Если ваша структура реализует трейт Debug
, то вы можете использовать следующий подход:
impl Struct {
fn __repr__(&self) -> String {
format!("{:?}", &self.0)
}
fn __str__(&self) -> String {
self.__repr__()
}
}
Методы позволяют пользователю использовать стандартные подходы Python, чтобы понять структуру и состояние объекта.
Хеширование объектов
Если вам необходимо, чтобы ваш объект использовался в качестве хэш-ключа, вам необходимо предоставить реализацию для __hash__
:
impl Struct {
fn __hash__(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
hasher.finish()
}
}
Когда я хочу запретить использовать объекты в качестве ключей, я использую:
#[classattr]
const __hash__: Option<Py<PyAny>> = None;
Реализация операций с массивами для объекта
Я использовал эту механику только один раз в своих проектах, когда оборачивал векторы в объект для эффективной передачи между Rust и Python без дублирования памяти. В моем случае я имел следующую структуру:
#[pyclass]
#[derive(Debug, Clone)]
pub struct AttributeValuesView(Arc<Vec<rust::AttributeValue>>)
Моя цель была в том, чтобы предоставить API для доступа к отдельным AttributeValue
с помощью синтаксиса attribute_value[i]
. Для того чтобы это стало возможным, необходимо реализовать две (только чтение) или три (чтение-запись) dunder-функции:
#[pymethods]
impl AttributeValuesView {
fn __getitem__(&self, index: usize) -> PyResult<AttributeValue> {
let v = self
.0
.get(index)
.ok_or(PyIndexError::new_err("index out of range"))
.map(|x| x.clone())?;
Ok(AttributeValue(v))
}
fn __len__(&self) -> PyResult<usize> {
Ok(self.0.len())
}
}
Если вам необходимо реализовать установку элемента, то вам также понадобится __setitem__
dunder:
fn __setitem__(&mut self, idx: isize, value: impl FromPyObject) -> PyResult<()>
Замечательно, что в PyO3 есть раздел, посвященный настройкам классов, где можно найти и другие полезные функции.
Документирование
Долгое время я пытался найти подход к документированию расширений, чтобы помочь пользователям наблюдать только те части, которые доступны в Python, не заглядывая в исходный код Rust.
Исчерпывающее руководство по документированию таких расширений с помощью Sphinx вы найдете в моей отдельной статье. Я не публикую его здесь, чтобы сэкономить размер статьи.
Сборка и публикация кода в PYPI
При использовании нативных расширений нельзя просто установить с произвольной версией Python. Расширение должно быть собрано и слинковано с нужными версиями заранее или на этапе установки. Для автоматизации этого процесса можно использовать систему сборки с помощью Docker и GitHub Actions (или другой системы CI/CD).
Руководство по сборке таких расширений с помощью GitHub Actions вы найдете в моей отдельной статье. Я не публикую его здесь, чтобы сэкономить размер статьи.
Демонстрационный проект
Я подготовил небольшой проект RocksQ (персистентная очередь на основе RocksDB), в котором можно найти много вещей, реализованных в соответствии с моим руководством.
Заключение
Я надеюсь, что это руководство поможет вам писать расширения для Python на Rust с меньшими трудностями и высокой производительностью. Если у вас есть свои советы и рекомендации, идеи и соображения, я буду рад их услышать.
JohnScience
Меня это интересует, но мне хотелось бы исполнять async Rust. Оценил бы, если бы ты рассмотрел этот вариант.
ivankudryavtsev Автор
Async Rust живет в неком рантайме внутри Rust. Вы можете запустить рантайм в треде, например, и отправлять туда задачи. В общем, вопрос сильно обширный, но вот это может вам подать идею:
https://github.com/insight-platform/RocksQ/blob/main/queue_rs/src/nonblocking.rs
ну и соответствующую реализацию в Python смотрите. В целом async Python и async Rust никак не связаны. Если вы умеете взаимодействовать с async рантаймом из Rust, то проблем быть не должно.
ivankudryavtsev Автор
Вот прям пример с async кодом у себя нашел: https://github.com/insight-platform/savant-rs/blob/main/etcd_dynamic_state/src/parameter_storage.rs
Как это мапится в Python - ищите в проекте.