Python — довольно простой в освоении язык, по сравнению с некоторыми другими языками код на нём пишется очень быстро. Но в жертву приносится скорость выполнения кода.
Перепишем часть Python-кода в Rust и импортируем этот код в виде пакета Python в проект. Получим сверхбыстрый пакет Python, который сможем импортировать и использовать, как любой другой пакет. В качестве бонуса добавим многопроцессорность и в итоге получим функцию, которая примерно в 150 раз быстрее обычного кода на Python.
Обзор
Проблему решим в 6 шагов:
- Решим вопрос о том, почему функция медленная.
- Подготовим проект.
- Перепишем функцию в Rust.
- Скомпилируем код на Rust и разместим его в пакете Python.
- Импортируем пакет Python в проект.
- Выполним бенчмарк чистого Python и функции на Rust.
Пакет maturin скомпилирует Rust-код и преобразует его в готовый к работе пакет Python.
1. Решим вопрос о том, почему функция медленная
Важно понять, почему функция работает медленно. Давайте представим, что проекту требуется функция подсчёта количества простых чисел в диапазоне между двумя другими числами:
def primecounter_py(range_from:int, range_til:int) -> (int, int):
""" Returns the number of found prime numbers using range"""
check_count = 0
prime_count = 0
range_from = range_from if range_from >= 2 else 2
for num in range(range_from, range_til + 1):
for divnum in range(2, num):
check_count += 1
if ((num % divnum) == 0):
break
else:
prime_count += 1
return prime_count, check_count
Пожалуйста, обратите внимание на то, что:
- На самом деле указывать количество проверок не обязательно, но это позволяет сравнить Python и Rust далее.
- Код обеих функций далёк от оптимизированного. Здесь важно показать, что с помощью Rust можно ускорить небольшие фрагменты Python, а ещё сравнить производительность языков.
Функция primecounter_py(10, 20) вернёт 4 (11, 13, 17 и 19 — простые числа) и количество выполненных функцией проверок на простое число. Небольшие диапазоны выполняются очень быстро, но на больших вы увидите снижение производительности:
range milliseconds
1-1K 4
1-10K 310
1-25K 1754
1-50K 6456
1-75K 14019
1-100K 24194
Чем больше диапазон, тем меньше скорость функции.
Почему primecounter_py работает медленно?
Код может быть медленным по многим причинам: из-за ввода-вывода, ожидания API, оборудования или архитектуры Python как языка. У нас последний случай. Подход к обработке переменных в Python делает язык очень простым, но он страдает небольшим снижением скорости, которое очевидно, когда приходится выполнять много вычислений. С другой стороны, эта функция очень подходит для оптимизации с помощью Rust.
Проблема в конкурентности?
Множество проблем со скоростью можно решить выполнением нескольких задач одновременно. Для разделения всех задач на несколько ядер можно использовать несколько процессоров, но мы придерживаемся оптимизации на Rust, ведь так мы сможем добиться распределения выполнения более быстрой функции.
Задачи с большим количеством операций ввода-вывода, например ожидание API, можно оптимизировать при помощи потоков. Чтобы увеличить скорость выполнения таких задач, ознакомьтесь с этой статьей или статьёй ниже о том, как задействовать несколько процессоров.
2. Подготовим проект
Ниже устанавливаем зависимости и создаём все файлы и папки для кода на Rust и его сборки в пакет.
a) создание venv
Создайте виртуальную среду и активируйте её. Затем установите maturin; он поможет преобразовать код Rust в пакет Python:
python -m venv venv
source venv/bin/activate
pip install maturin
б) файлы и папки Rust
Каталог my_rust_module будет содержать код на Rust:
mkdir my_rust_module
cd my_rust_module
в) инициализация maturin
Вызовем метод .init. Вы увидите несколько вариантов на выбор, из них выберите pyo3. Пакет maturin создаст файлы и папки, структуру которых вы увидите ниже:
my_folder
|- venv
|- my_rust_module
|- .github
|- src
|- lib.rs
|- .gitignore
|- Cargo.toml
|- pyproject.toml
Самый важный файл здесь — это /my_rust_module/src/lib.rs. Именно этот файл будет содержать код, который мы собираемся превратить в пакет Python.
Обратите внимание, что maturin создал конфигурацию проекта — файл Cargo.toml. Кроме конфигурации, этот файл содержит все зависимости (например, requirements.txt). Я отредактировал его, чтобы он выглядел вот так:
[package]
name = "my_rust_module"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "my_rust_module"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.17.3", features = ["extension-module"] }
3. Перепишем функцию в Rust
Теперь мы готовы воссоздать нашу функцию Python в Rust. Не будем слишком глубоко погружаться в синтаксис Rust, а сосредоточимся на том, как заставить код на Rust работать с Python. Сначала создадим функцию на чистом Rust, затем поместим её в пакет, который сможем импортировать и использовать в Python.
Если вы никогда не видели код Rust, то приведённый ниже код может немного сбить с толку. Самое главное, что функция primecounter — это чистый Rust; она не имеет ничего общего с Python.
Откройте /my_rust_module/src/lib.rs и вставьте в него этот код:
use pyo3::prelude::*;
#[pyfunction]
fn primecounter(range_from:u64, range_til:u64) -> (u32, u32) {
/* Returns the number of found prime numbers between [range_from] and [range_til] """ */
let mut prime_count:u32 = 0;
let mut check_count:u32 = 0;
let _from:u64 = if range_from < 2 { 2 } else { range_from };
let mut prime_found:bool;
for num in _from..=range_til {
prime_found = false;
for divnum in 2..num {
check_count += 1;
if num % divnum == 0 {
prime_found = true;
break;
}
}
if !prime_found {
prime_count += 1;
}
}
return (prime_count, check_count)
}
/// Put the function in a Python module
#[pymodule]
fn my_rust_module(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(primecounter, m)?)?;
Ok(())
}
Пройдёмся по самому важному в коде:
- Функция primecounter — это чистый Rust.
- Она декорирована #[pyfunction]. Декоратор указывает на то, что мы хотим преобразовать её в функцию Python.
- Функция my_rust_module упаковывает код на Rust в модуль Python: cоздаётся pymodule.
4. Скомпилируем код на Rust и разместим его в пакете Python
Эта часть может показаться самой сложной, но maturin облегчает работу. Просто выполните эту команду:
maturin build --release
Команда компилирует весь код Rust, оборачивает его в пакет Python и записывает пакет в каталогyour_project_dir/my_rust_module/target/wheels. Далее мы установим наш Wheel-пакет.
В приведённых ниже примерах я работаю в среде Debian (через Windows WSL), что немного упрощает компиляцию кода на Rust, ведь нужные компиляторы уже установлены. Сборка на Windows тоже возможна, но, скорее всего, вы получите сообщение типа Microsoft Visual C++ 14.0 or greater is required
. Это означает, что у вас нет компилятора. Проблема решается установкой инструментов сборки C++.
5. Импортируем пакет Python в проект
Установить созданный Wheel-пакет можно командой pip install:
pip install target/wheels/my_rust_module-0.1.0-cp39-cp39-manylinux_2_28_x86_64.whl
Теперь можно рабоать с модулем:
import my_rust_module
primecount, eval_count = my_rust_module.primecounter(range_from=0, range_til=500)
# returns 95 22279
6. Выполним бенчмарк чистого Python и функции на Rust
Давайте вызовем обе версии функций с несколькими аргументами:
range Py ms py e/sec rs ms rs e/sec
1-1K 4 17.6M 0.19 417M
1-10K 310 18.6M 12 481M
1-25K 1754 18.5M 66 489M
1-50K 6456 18.8M 248 488M
1-75K 14019 18.7M 519 505M
1-100K 24194 18.8M 937 485M
Они возвращают результат и количество вычисленных ими чисел. В приведённом выше обзоре вы видите, что, когда дело доходит до вычислений в секунду, Rust превосходит Python в 27 раз.
7. Многопроцессорность
Код ниже распределяет числа, которые нам нужно вычислить, на все ядра процессора:
# batch size is determined by the range divided over the amount of available CPU's
batch_size = math.ceil((range_til - range_from) / mp.cpu_count())
# The lines below divide the ranges over all available CPU's.
# A range of 0 - 10 will be divided over 4 cpu's like:
# [(0, 2), (3, 5), (6, 8), (9, 9)]
number_list = list(range(range_from, range_til))
number_list = [number_list[i * batch_size:(i + 1) * batch_size] for i in range((len(number_list) + batch_size - 1) // batch_size)]
number_list_from_til = [(min(chunk), max(chunk)) for chunk in number_list]
primecount = 0
eval_count = 0
with mp.Pool() as
results = mp_pool.starmap(my_rust_module.primecounter, number_list_from_til)
for _count, _evals in results:
primecount += _count
eval_count += _evals
Снова попробуем найти все простые числа в диапазоне от 0 до 100 000. Теперь это означает, что нужно выполнить почти 500 млн. проверок. Как видите, Rust выполняет эти проверки за 0,88 секунды. При многопроцессорной обработке процесс завершается за 0,16 секунды — это в 5,5 раз быстрее, то есть 2,8 млрд. вычислений в секунду.
calculations duration calculations/sec
rust: 455.19M 882.03 ms 516.1M/sec
rust MP: 455.19M 160.62 ms 2.8B/sec
В сравнении с первоначальной функцией Python с единственным процессором мы увеличили количество вычислений с 18,8 млн. до 2,8 млрд. в секунду. Это означает, что наша функция теперь примерно в 150 раз быстрее.
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также
Комментарии (42)
rutexd
00.00.0000 00:00+9Я не хочу негативить, но о чем статья?
На главный вопрос - почему функция медленная - так и не дали. Что в ней далёкого от идеала, почему оно такое медленное итд. Лишь пару очень воздушных объяснений что мол видите ли виновата архитектура пайтона. А попробовать оптимизировать функцию на самом пайтоне видимо сил уже не хватило, раз она такая далёкая от, идеала.
Всё оставшиеся пункты статьи это пункты вроде - 'как сделать нативный модуль для питона'. Подойдёт для какого нибудь FAQ или ReadMe на главной странице.
Medeyko
00.00.0000 00:00+4На мой взгляд, вполне понятно, о чём статья: инструкция как быстро и просто прикрутить к программе на Python кусочки кода на Rust'е.
Конкретная функция в ней выбрана только для того, чтобы инструкция была с конкретным примером.
Вариант с оптимизацией кода обычно требует большей квалификации программиста или бОльших затрат времени, чем такое переписывание горячего кода. Поэтому такой подход выглядит целесообразным при quick'n'dirty пртотипировании. (Впрочем, и оптимизированные функции на Rust'е будут несколько быстрее, но статья, как я понял, не об этом.)
stan_volodarsky
00.00.0000 00:00+2При прототипировании уже накосячили. Счётчики могут переполнятся. Полагаю, молча.
Портирование не всегда проще оптимизации внутри языка. Пример вообще неудачный - у Питона неограниченные целые, код работает всегда. При портировании типы стали ограниченные - источник ошибок при написании (уже одну нашли) и при использовании (головная боль начнётся на больших входах). Такой себе quick&dirty.
UMenyaNeudobnieVoprosiki
00.00.0000 00:00-2Просто если не козырнуть перфомансом в тайтле (и это будет единственное, что хипстеры и ребята с неокрепшей психикой запомнят из статьи), то можно случайно увидеть какое-то плохо читаемое адище, которое уже никак не пролазит в рамки быстрого прототипирования и требует костыли и пересборку под каждую платформу. А пайтон выбран как единственная ниша, где можно хоть как-то конкурировать "из коробки" на задачах, которые IRL возможно в перфомансе нуждаются вообще в последнюю очередь или для них уже давно есть более подходящая библиотечка на сях, если речь про числодробилки, а если нет, то узкое место будет - источник этих 100к записей)
sargon5000
00.00.0000 00:00+4Автор как будто не уверен в значении терминов ядро, процессор, процесс, задача, поток и использует их наугад. С точкой читаю "В сравнении с первоначальной функцией Python с единственным процессором" — процессор? внутри функции? Ой-ой.
Или вот: "Для разделения всех задач на несколько ядер можно использовать несколько процессоров". На диво бессмысленное утверждение. У моего настольного компьютера 6 физических ядер плюс 6 виртуальных, и всего один CPU — получается, я никак не могу его использовать для ускорения расчетов, он же всего один? Может быть, автор имел в виду не процессоры, а ядра, физические и виртуальные? Или потоки? Наверно, автор хотел сказать "Для разделения всех задач на несколько потоков можно использовать несколько ядер" — утверждение столь же верное, сколь и тривиальное. Спасибо, кэп.
iBljad
00.00.0000 00:00Такой вот перевод
>Для разделения всех задач на несколько ядер можно использовать несколько процессоров
>In our case we could opt for using multiple processes to divide all tasks over multiple cores in stead of the default 1
sargon5000
00.00.0000 00:00+2Вот именно. Переводчик спутал процесс с процессором. Гугл, например, предлагает "В нашем случае мы могли бы выбрать использование нескольких процессов для разделения всех задач на несколько ядер вместо стандартного", и это имеет смысл.
WASD1
00.00.0000 00:00+2Автор как будто не уверен в значении терминов ядро, процессор, процесс, задача, поток и использует их наугад.
У моего настольного компьютера 6 физических ядер плюс 6 виртуальных, и всего один CPU
физические ядра - абстракция уровня микроархитектуры (о ней знает процессор).
виртуальные ядра - абстракция уровня архитектуры (о ней знает ваша ОС).
То есть у вас 6 физических ядер и 12 виртуальных (по 2 виртуальных ядра на каждом физическом).
А то, что у вас написано - примерно: "я купил 2 упаковки яиц и ещё 18 яиц". Мягко говоря странновато.
thevlad
00.00.0000 00:00+5Конечно раст это хорошо, но почему это именно то что нужно? Потому что популярно и модно? Почему не numba, которая подобные хотспоты должна разгонять в лет?
inferrna
00.00.0000 00:00Псс.. Потому, что так можно переманить питонистов с игрушечного языка программирования на взрослый.
deadmoroz14
00.00.0000 00:00Согласен. Rust уж очень сильно от питона отличается и требует другой квалификации.
Если и переходить, то на Julia переходить. Писать можно в питоновском стиле (и даже лучше), а производительность будет такая же, как на rust. Это если number-crunching'ом заниматься
khajiit
00.00.0000 00:00+3Первая же ошибка, повторенная и в коде на rust — проверка значений выше int(sqrt(n)) как делителей.
Pshir
00.00.0000 00:00+3Первая ошибка - это использование перебора делителей вместо решета Эратосфена. Всё остальное уже несущественно.
khajiit
00.00.0000 00:00вместо решета Эратосфена
… загруженного из файла.
DirectoriX
00.00.0000 00:00+1Вы мне напомнили про одну из универских работ по программированию.
Задание: переставить биты числа в обратном порядке.
Оптимальное решение: таблица поиска на все 256 char'ов. Если нужны 2-4-8-байтные числа -«переставлять» биты с помощью той же таблицы, побайтово.
Можно сколько угодно сидеть и оптимизировать решение «в лоб», но индексация массива всё равно быстрее.
mayorovp
00.00.0000 00:00Если уж грузить из файла, то лучше сразу список простых. С ним и работать проще, и места на 30% меньше занимает в бинарном виде...
Paul_Arakelyan
00.00.0000 00:00+5По-моему, главная глупость статьи в изначальном предположении "программист на python обязательно умеет писать на rust", и далее она же мимикрировала в знание С или других языков. Особенно в свете того, что в школе уже иногда дают python. Конечно, можно написать ТЗ тому, кто знает и умеет - но это тоже отдельное умение.
Ну и конечно, никаких попыток разобраться "почему ж оно тормозит" и "можно ли хоть что-то сделать".
Когда-то в конце 1990х добрался я до l0phtcrack 1.0 опен-сорсного, он был в разы медленнее того, что они продавали. Простая замена связных списков на массив - дала заметный прирост, далее были игры с openssl, разбиением алгоритма шифрования на части так, чтоб в кэш 486 помещался (т.е. считаем 1000 первых половинок, потом 1000 вторых половинок алгоритма), выявление зависимости быстродействия от последовательности .о в makefile... Да, я поднял быстродействие в разы, хоть и не достиг уровня коммерческой версии. Но это были всего-то поверхностные изменения, а не "погружение в алгоритм шифрования вместе с оптимизацией на ассемблере на 486/pentium".
Eugene_Rymarev
00.00.0000 00:00+4Хоть автор и делает оговорку, что код не оптимизирован - это не освобождает его от ответственности. Для Python существует множество вариантов оптимизации. В частности преобразование в машинный код во время исполнения - PyPy. И совершенно не факт, что оно не будет быстрее.
Нельзя просто так взять неоптимизированный код, перекинуть его на другой ЯП в таком же неоптимизированном виде и сказать "смотрите, оно стало быстрее!".
UPD: Увидел, что это блог "SkillFactory" - всё встало на свои места.
chaberch
00.00.0000 00:00+1Чем больше диапазон, тем меньше скорость функции.
А разве скорость это не количество операций за единицу времени? При большем входном параметре у меня процессор начинает медленнее выполнять операции?
magiavr
00.00.0000 00:00Статья не раскрывает темы. Всё же вначале нужно было выжать максимум из python, естественно начиная с алгоритма, так как в таком виде она годится только для школьника, постигающего азы программирования. И уже потом максимально оптимизировать в rust. Хотя выбор языка из принципа "модно-молодежно" тоже сомнителен.
jpegqs
Почему именно Rust? На Си будет проще и компиляторы более доступны. Может потому что это стало "стильно-модно-молодёжно", переписывать всё на Rust?
Если вы переписали часть кода на другой язык, то этот код уже не на Python. Так что "ускорить код на Python" звучит как-то сомнительно для меня.
А тема древняя как Java, там тоже выносили критичный код в нативные библиотеки, что ломает принцип Java: "write once, run anywhere", под которым язык рекламировали.
gmtd
В нативные библиотеки Java выносили код, который должен был работать напрямую с тем, к чему у Java доступа нет, например специфичным железом (считыватель ключей-таблеток) - потому это и называется Java Native Interface. А не потому, что там было быстрей.
Насколько мне известно, по бенчмаркам, несложные арифметические операции типа работы с простыми числами примерно одинаково по производительности выполняются и на компилируемых, и на современных интерпретируемых языках (типа Python, Javascript или PHP). поэтому, да, хотелось бы узнать, почему такая большая разница в скорости у автора.
masai
Даже с числами есть оверхед. Например, в Python все целые числа поддерживают длинную арифметику. При небольших вычислениях это незаметно, но если нужно написать числодробилку, то разница большая.
thevlad
Как раз в скриптах без JIT огромный оверхед, который чаще всего проявляется именно в таком tight CPU bound коде.
DirectoriX
Я осознаю, что сферический код в вакууме, который должен запускаться на всём, возможно лучше писать на C, но здесь всё же модуль питонячий, и в отрыве от Python он мало смысла имеет (это будет как минимум другой проект).
DmitryKoterov
Что значит «почему именно раст». Потому что гарантированно не покрашится и не потечет, потому что точно не будет гейзенбагов при работе с потоками. Любой одной из этих причин уже достаточно.
rutexd
Раст на порядок: надёжнее, проще, легче в освоении и самое главное - современный.
Проще отловить любые потенциальные ошибки, проще скомпилировать любой проект (попробуйте это сделать с вашим обилием компиляторов и разных тулзов вроде смейк с почти нулевого порога входа и нулевого опыта), язык намного проще доступен (не беря в расчёт лайфтаймы и турбофиш) и предоставляет современный и удобный уровень взаимодействия с пользователем. (одни инструменты карго чего только стоят или описания ошибок которые тебя не только погладят но ещё и соску дадут)
Си будет быстрее на х процентов в рантайме - но не всегда проще.
pfemidi
Rust vs C:
Я смеял и хохотался с выделенного. Против всего остального возразить нечего.
rutexd
Обычно с этого смеются только те, кто раст не осилил. ;)
candyboah
Как по мне относительно для всех.
pfemidi
Естественно! Rust вообще один из наиболее простых и понятных для освоения языков, легче него только Haskell, это всем известно!
mst_72
Категорически за! Почему нет простого (и лёгкого в понимании) примера на Haskell? Замутить нечто монадическое, типо-безопасное и легко интегрируемого в питон. Вот где профит. а то Раст... Зачем такие полумеры?
mayorovp
Он и правда легче в освоении, если считать язык освоенным когда удаётся начать писать на нём надёжный код.
На Сях начинающий свой первый код напишет, конечно же, быстро. Этот код будет содержать несколько уязвимостей use after free, переполнения буфера и обязательную проблему с кодировками.
Akuma
А что не так с турбофишем? В плане сложности. Просто странноватое написание да и только.
domix32
Не многие осиливают шаблонный код, да и не везде он имеется, в том числе и в С.
R0bur
"и самое главное - современный "
Это действительно самое главное в Rust?
rutexd
Да. Большинство идей и концептов в расте сделаны исходя из времени и планки высокого уровня. Иначе был бы си номер два.
AnthonyMikh
Rust так-то концептуально не особо новый, там идеи из теории разработки языков программирования 70-х годов. Просто прочие ЯП, как правило, базируются на ещё более старых идеях.
DarthVictor
Например, потому что для Си такие инструкции уже есть.
Hardcoin
На си будет проще? Это вряд ли. Пока код настолько простой - без разницы, но если код объемный, искать неожиданные ошибки в rust легче (многие из них в rust не возникают).
Речь о ситуации, если вы знаете языки примерно одинаково.