Python — довольно простой в освоении язык, по сравнению с некоторыми другими языками код на нём пишется очень быстро. Но в жертву приносится скорость выполнения кода.


Перепишем часть Python-кода в Rust и импортируем этот код в виде пакета Python в проект. Получим сверхбыстрый пакет Python, который сможем импортировать и использовать, как любой другой пакет. В качестве бонуса добавим многопроцессорность и в итоге получим функцию, которая примерно в 150 раз быстрее обычного кода на Python.


Обзор


Проблему решим в 6 шагов:


  1. Решим вопрос о том, почему функция медленная.
  2. Подготовим проект.
  3. Перепишем функцию в Rust.
  4. Скомпилируем код на Rust и разместим его в пакете Python.
  5. Импортируем пакет Python в проект.
  6. Выполним бенчмарк чистого 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

Пожалуйста, обратите внимание на то, что:


  1. На самом деле указывать количество проверок не обязательно, но это позволяет сравнить Python и Rust далее.
  2. Код обеих функций далёк от оптимизированного. Здесь важно показать, что с помощью 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(())
}

Пройдёмся по самому важному в коде:


  1. Функция primecounter — это чистый Rust.
  2. Она декорирована #[pyfunction]. Декоратор указывает на то, что мы хотим преобразовать её в функцию Python.
  3. Функция 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-пакет.


Для пользователей Windows

В приведённых ниже примерах я работаю в среде 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 раз быстрее.




Комментарии (42)


  1. jpegqs
    00.00.0000 00:00
    +18

    Почему именно Rust? На Си будет проще и компиляторы более доступны. Может потому что это стало "стильно-модно-молодёжно", переписывать всё на Rust?

    Если вы переписали часть кода на другой язык, то этот код уже не на Python. Так что "ускорить код на Python" звучит как-то сомнительно для меня.

    А тема древняя как Java, там тоже выносили критичный код в нативные библиотеки, что ломает принцип Java: "write once, run anywhere", под которым язык рекламировали.


    1. gmtd
      00.00.0000 00:00
      +1

      В нативные библиотеки Java выносили код, который должен был работать напрямую с тем, к чему у Java доступа нет, например специфичным железом (считыватель ключей-таблеток) - потому это и называется Java Native Interface. А не потому, что там было быстрей.

      Насколько мне известно, по бенчмаркам, несложные арифметические операции типа работы с простыми числами примерно одинаково по производительности выполняются и на компилируемых, и на современных интерпретируемых языках (типа Python, Javascript или PHP). поэтому, да, хотелось бы узнать, почему такая большая разница в скорости у автора.


      1. masai
        00.00.0000 00:00
        +3

        Даже с числами есть оверхед. Например, в Python все целые числа поддерживают длинную арифметику. При небольших вычислениях это незаметно, но если нужно написать числодробилку, то разница большая.


      1. thevlad
        00.00.0000 00:00
        +2

        Насколько мне известно, по бенчмаркам, несложные арифметические операции
        типа работы с простыми числами примерно одинаково по производительности
        выполняются и на компилируемых, и на современных интерпретируемых
        языках (типа Python, Javascript или PHP). поэтому, да, хотелось бы узнать, почему такая большая разница в скорости у автора.

        Как раз в скриптах без JIT огромный оверхед, который чаще всего проявляется именно в таком tight CPU bound коде.


    1. DirectoriX
      00.00.0000 00:00
      +6

      компиляторы более доступны
      А не подскажете, на какой платформе есть Python, но нет Rust?
      Я осознаю, что сферический код в вакууме, который должен запускаться на всём, возможно лучше писать на C, но здесь всё же модуль питонячий, и в отрыве от Python он мало смысла имеет (это будет как минимум другой проект).


    1. DmitryKoterov
      00.00.0000 00:00
      +19

      Что значит «почему именно раст». Потому что гарантированно не покрашится и не потечет, потому что точно не будет гейзенбагов при работе с потоками. Любой одной из этих причин уже достаточно.


    1. rutexd
      00.00.0000 00:00
      +8

      Раст на порядок: надёжнее, проще, легче в освоении и самое главное - современный.

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

      Си будет быстрее на х процентов в рантайме - но не всегда проще.


      1. pfemidi
        00.00.0000 00:00
        +11

        Rust vs C:

        Раст на порядок: надёжнее, проще, легче в освоении и самое главное — современный.

        Я смеял и хохотался с выделенного. Против всего остального возразить нечего.


        1. rutexd
          00.00.0000 00:00
          +6

          Обычно с этого смеются только те, кто раст не осилил. ;)


        1. candyboah
          00.00.0000 00:00
          +1

          Как по мне относительно для всех.


          1. pfemidi
            00.00.0000 00:00

            Естественно! Rust вообще один из наиболее простых и понятных для освоения языков, легче него только Haskell, это всем известно!


            1. mst_72
              00.00.0000 00:00
              +2

              Категорически за! Почему нет простого (и лёгкого в понимании) примера на Haskell? Замутить нечто монадическое, типо-безопасное и легко интегрируемого в питон. Вот где профит. а то Раст... Зачем такие полумеры?


        1. mayorovp
          00.00.0000 00:00
          +6

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


          На Сях начинающий свой первый код напишет, конечно же, быстро. Этот код будет содержать несколько уязвимостей use after free, переполнения буфера и обязательную проблему с кодировками.


      1. Akuma
        00.00.0000 00:00

        А что не так с турбофишем? В плане сложности. Просто странноватое написание да и только.


        1. domix32
          00.00.0000 00:00

          Не многие осиливают шаблонный код, да и не везде он имеется, в том числе и в С.


      1. R0bur
        00.00.0000 00:00
        +1

        "и самое главное - современный "

        Это действительно самое главное в Rust?


        1. rutexd
          00.00.0000 00:00

          Да. Большинство идей и концептов в расте сделаны исходя из времени и планки высокого уровня. Иначе был бы си номер два.


          1. AnthonyMikh
            00.00.0000 00:00
            +2

            Rust так-то концептуально не особо новый, там идеи из теории разработки языков программирования 70-х годов. Просто прочие ЯП, как правило, базируются на ещё более старых идеях.


    1. DarthVictor
      00.00.0000 00:00
      +3

      Например, потому что для Си такие инструкции уже есть.


    1. Hardcoin
      00.00.0000 00:00
      +1

      На си будет проще? Это вряд ли. Пока код настолько простой - без разницы, но если код объемный, искать неожиданные ошибки в rust легче (многие из них в rust не возникают).

      Речь о ситуации, если вы знаете языки примерно одинаково.


  1. rutexd
    00.00.0000 00:00
    +9

    Я не хочу негативить, но о чем статья?

    На главный вопрос - почему функция медленная - так и не дали. Что в ней далёкого от идеала, почему оно такое медленное итд. Лишь пару очень воздушных объяснений что мол видите ли виновата архитектура пайтона. А попробовать оптимизировать функцию на самом пайтоне видимо сил уже не хватило, раз она такая далёкая от, идеала.

    Всё оставшиеся пункты статьи это пункты вроде - 'как сделать нативный модуль для питона'. Подойдёт для какого нибудь FAQ или ReadMe на главной странице.


    1. Medeyko
      00.00.0000 00:00
      +4

      На мой взгляд, вполне понятно, о чём статья: инструкция как быстро и просто прикрутить к программе на Python кусочки кода на Rust'е.

      Конкретная функция в ней выбрана только для того, чтобы инструкция была с конкретным примером.

      Вариант с оптимизацией кода обычно требует большей квалификации программиста или бОльших затрат времени, чем такое переписывание горячего кода. Поэтому такой подход выглядит целесообразным при quick'n'dirty пртотипировании. (Впрочем, и оптимизированные функции на Rust'е будут несколько быстрее, но статья, как я понял, не об этом.)


      1. stan_volodarsky
        00.00.0000 00:00
        +2

        При прототипировании уже накосячили. Счётчики могут переполнятся. Полагаю, молча.

        Портирование не всегда проще оптимизации внутри языка. Пример вообще неудачный - у Питона неограниченные целые, код работает всегда. При портировании типы стали ограниченные - источник ошибок при написании (уже одну нашли) и при использовании (головная боль начнётся на больших входах). Такой себе quick&dirty.


    1. UMenyaNeudobnieVoprosiki
      00.00.0000 00:00
      -2

      Просто если не козырнуть перфомансом в тайтле (и это будет единственное, что хипстеры и ребята с неокрепшей психикой запомнят из статьи), то можно случайно увидеть какое-то плохо читаемое адище, которое уже никак не пролазит в рамки быстрого прототипирования и требует костыли и пересборку под каждую платформу. А пайтон выбран как единственная ниша, где можно хоть как-то конкурировать "из коробки" на задачах, которые IRL возможно в перфомансе нуждаются вообще в последнюю очередь или для них уже давно есть более подходящая библиотечка на сях, если речь про числодробилки, а если нет, то узкое место будет - источник этих 100к записей)


  1. sargon5000
    00.00.0000 00:00
    +4

    Автор как будто не уверен в значении терминов ядро, процессор, процесс, задача, поток и использует их наугад. С точкой читаю "В сравнении с первоначальной функцией Python с единственным процессором" — процессор? внутри функции? Ой-ой.

    Или вот: "Для разделения всех задач на несколько ядер можно использовать несколько процессоров". На диво бессмысленное утверждение. У моего настольного компьютера 6 физических ядер плюс 6 виртуальных, и всего один CPU — получается, я никак не могу его использовать для ускорения расчетов, он же всего один? Может быть, автор имел в виду не процессоры, а ядра, физические и виртуальные? Или потоки? Наверно, автор хотел сказать "Для разделения всех задач на несколько потоков можно использовать несколько ядер" — утверждение столь же верное, сколь и тривиальное. Спасибо, кэп.


    1. 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


      1. sargon5000
        00.00.0000 00:00
        +2

        Вот именно. Переводчик спутал процесс с процессором. Гугл, например, предлагает "В нашем случае мы могли бы выбрать использование нескольких процессов для разделения всех задач на несколько ядер вместо стандартного", и это имеет смысл.


    1. WASD1
      00.00.0000 00:00
      +2

       Автор как будто не уверен в значении терминов ядро, процессор, процесс, задача, поток и использует их наугад.

      У моего настольного компьютера 6 физических ядер плюс 6 виртуальных, и всего один CPU

      физические ядра - абстракция уровня микроархитектуры (о ней знает процессор).
      виртуальные ядра - абстракция уровня архитектуры (о ней знает ваша ОС).

      То есть у вас 6 физических ядер и 12 виртуальных (по 2 виртуальных ядра на каждом физическом).

      А то, что у вас написано - примерно: "я купил 2 упаковки яиц и ещё 18 яиц". Мягко говоря странновато.


  1. iBljad
    00.00.0000 00:00

    del


  1. thevlad
    00.00.0000 00:00
    +5

    Конечно раст это хорошо, но почему это именно то что нужно? Потому что популярно и модно? Почему не numba, которая подобные хотспоты должна разгонять в лет?


    1. inferrna
      00.00.0000 00:00

      Псс.. Потому, что так можно переманить питонистов с игрушечного языка программирования на взрослый.

      Также: Как при помощи Rust в 400 раз ускорить код на Rust


    1. deadmoroz14
      00.00.0000 00:00

      Согласен. Rust уж очень сильно от питона отличается и требует другой квалификации.

      Если и переходить, то на Julia переходить. Писать можно в питоновском стиле (и даже лучше), а производительность будет такая же, как на rust. Это если number-crunching'ом заниматься


  1. khajiit
    00.00.0000 00:00
    +3

    Первая же ошибка, повторенная и в коде на rust — проверка значений выше int(sqrt(n)) как делителей.


    1. Pshir
      00.00.0000 00:00
      +3

      Первая ошибка - это использование перебора делителей вместо решета Эратосфена. Всё остальное уже несущественно.


      1. khajiit
        00.00.0000 00:00

        вместо решета Эратосфена

        … загруженного из файла.


        1. DirectoriX
          00.00.0000 00:00
          +1

          Вы мне напомнили про одну из универских работ по программированию.

          Задание: переставить биты числа в обратном порядке.
          Оптимальное решение: таблица поиска на все 256 char'ов. Если нужны 2-4-8-байтные числа -«переставлять» биты с помощью той же таблицы, побайтово.

          Можно сколько угодно сидеть и оптимизировать решение «в лоб», но индексация массива всё равно быстрее.


          1. khajiit
            00.00.0000 00:00

            Тем более, что 256 байт отлично поместятся в любой кеш.


        1. mayorovp
          00.00.0000 00:00

          Если уж грузить из файла, то лучше сразу список простых. С ним и работать проще, и места на 30% меньше занимает в бинарном виде...


  1. Paul_Arakelyan
    00.00.0000 00:00
    +5

    По-моему, главная глупость статьи в изначальном предположении "программист на python обязательно умеет писать на rust", и далее она же мимикрировала в знание С или других языков. Особенно в свете того, что в школе уже иногда дают python. Конечно, можно написать ТЗ тому, кто знает и умеет - но это тоже отдельное умение.

    Ну и конечно, никаких попыток разобраться "почему ж оно тормозит" и "можно ли хоть что-то сделать".

    Когда-то в конце 1990х добрался я до l0phtcrack 1.0 опен-сорсного, он был в разы медленнее того, что они продавали. Простая замена связных списков на массив - дала заметный прирост, далее были игры с openssl, разбиением алгоритма шифрования на части так, чтоб в кэш 486 помещался (т.е. считаем 1000 первых половинок, потом 1000 вторых половинок алгоритма), выявление зависимости быстродействия от последовательности .о в makefile... Да, я поднял быстродействие в разы, хоть и не достиг уровня коммерческой версии. Но это были всего-то поверхностные изменения, а не "погружение в алгоритм шифрования вместе с оптимизацией на ассемблере на 486/pentium".


  1. Eugene_Rymarev
    00.00.0000 00:00
    +4

    Хоть автор и делает оговорку, что код не оптимизирован - это не освобождает его от ответственности. Для Python существует множество вариантов оптимизации. В частности преобразование в машинный код во время исполнения - PyPy. И совершенно не факт, что оно не будет быстрее.

    Нельзя просто так взять неоптимизированный код, перекинуть его на другой ЯП в таком же неоптимизированном виде и сказать "смотрите, оно стало быстрее!".

    UPD: Увидел, что это блог "SkillFactory" - всё встало на свои места.


  1. chaberch
    00.00.0000 00:00
    +1

    Чем больше диапазон, тем меньше скорость функции.

    А разве скорость это не количество операций за единицу времени? При большем входном параметре у меня процессор начинает медленнее выполнять операции?


  1. magiavr
    00.00.0000 00:00

    Статья не раскрывает темы. Всё же вначале нужно было выжать максимум из python, естественно начиная с алгоритма, так как в таком виде она годится только для школьника, постигающего азы программирования. И уже потом максимально оптимизировать в rust. Хотя выбор языка из принципа "модно-молодежно" тоже сомнителен.