Files
blog/content/ru/python-speedup-with-rust.md
2021-12-11 18:23:27 +04:00

480 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: Ускоряем Python используя Rust.
description: Как встроить Rust в проект на Python.
position: 2
category: 'Python'
---
# Описание проблемы
Каким бы Python удобным не был, скоростью он похвастаться никак не может.
И для некоторых задач это достаточно критично.
Например, не так давно у меня была задача достать много информации
из файлов логов. Проблема в том, что один лог-файл занимает от 3-4 ГБ. А файлов
таких много и информацию достать требуется быстро. Изначальный вариант на Python
был написан за минут 15-20, но скорость его работы занимала 30-40 минут на один файл. После переписывания одной функции на Rust, скрипт отработал за 1 минуту.
Давайте напишем свой проект со встроенной функцией на Rust.
<br>
Задача проекта будет следующей:
Дан файл лога запросов на некоторый сервер. Требуется
найти количество запросов к определённому файлу и сумму переданных байт.
Для демонстрации мы напишем 2 функции, одна будет использовать наивное решение задачи на питоне,
вторая будет выполнять парсинг на расте.
Сам файл лог имеет следующую структуру:
```
"$line_num" "-" "$method $url" "$current_time" "$bytes_sent"
```
где,
* $line_num - номер строчки;
* $method - HTTP метод;
* $url - URL до файла, содержащий один из сгенерированных идентификаторов;
* $current_time - текущее время (время выполнения запроса);
* $bytes_sent - сколько байт было отправлено.
Данный формат логов отчасти подражает формату [G-Core labs](https://gcorelabs.com/support/articles/115000511685/).
# Проект на Python
Сначала мы напишем весь функционал на Python.
Для этого создадим проект `python-rust` по [гайду из блога](/project-start).
Для создания CLI я буду использовать [typer](https://pypi.org/project/typer/).
Весь код проекта доступен в [репозитории](https://github.com/s3rius/blog_examples/tree/master/python-rust).
```bash
$ poetry add typer
```
## Генератор логов
Для тестов напишем генератор лог-файла.
Данный генератор должен работать следующим образом.
Я указываю сколько строк лог-файла я хочу увидеть,
сколько уникальных id файлов использовать и название файла, куда писать.
Функция же генерирует лог в заданном формате.
```python{}[python_rust/main.py]
import secrets
import time
import uuid
from pathlib import Path
from typing import Any
import typer
tpr = typer.Typer()
def quote(somthing: Any) -> str:
"""
Quote string.
:param somthing: any string.
:return: quoted string.
"""
return f'"{somthing}"'
@tpr.command()
def generator( # noqa: WPS210
output: Path,
lines: int = 2_000_000, # noqa: WPS303
ids: int = 1000,
) -> None:
"""
Test log generator.
:param ids: how many file id's to generate.
:param output: output file path.
:param lines: how many lines to write, defaults to 2_000_000
"""
ids_pool = [uuid.uuid4().hex for _ in range(ids)]
with open(output, "w") as out_file:
for line_num in range(lines):
item_id = secrets.choice(ids_pool)
prefix = secrets.token_hex(60)
url = f"GET /{prefix}/{item_id}.jpg"
current_time = int(time.time())
bytes_sent = secrets.randbelow(800) # noqa: WPS432
line = [
quote(line_num),
quote("-"),
quote(url),
quote(current_time),
quote(bytes_sent),
]
out_file.write(" ".join(line))
out_file.write("\n")
typer.secho("Log successfully generated.", fg=typer.colors.GREEN)
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
typer.secho("Not implemented", err=True, fg=typer.colors.RED)
def main() -> None:
"""Main program entrypoint."""
tpr()
```
Для удобства работы добавим в pyproject.toml информацию о командах.
```toml
[tool.poetry.scripts]
pyrust = "python_rust.main:main"
```
Теперь мы можем вызывать нашу программу.
```
$ pyrust --help
Usage: pyrust [OPTIONS] COMMAND [ARGS]...
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell, to
copy it or customize the installation.
--help Show this message and exit.
Commands:
generator Test log generator.
parser Parse given log file.
```
Работает отлично.
С помощью данного генератора сгенерируем файл на 8 милионов строк.
```bash
$ pyrust generator test.log --lines 8000000
Log successfully generated.
```
У нас получился файл на 1.5G. Для начального теста этого будет достаточно.
## Реализация парсера на Python
Представленный формат разделяет ифнормацию кавычками, поэтому
для сплита строк мы будем использовать встроенный в python модуль [shlex](https://docs.python.org/3/library/shlex.html).
Функция парсинга логов будет выглядить следующим образом:
```python{}[python_rust/py_parser.py]
import shlex
from pathlib import Path
from typing import Dict, Tuple
def parse_python( # noqa: WPS210
filename: Path,
) -> Dict[str, Tuple[int, int]]:
"""
Parse log file with python.
:param filename: log file.
:return: parsed data.
"""
parsed_data = {}
with open(filename, "r") as input_file:
for line in input_file:
spl = shlex.split(line)
# Splitting method and actual url.
url = spl[2].split()[1]
# Splitting url by /
# This split will turn this "/test/aaa.png"
# into "aaa".
file_id = url.split("/")[-1].split(".")[0]
file_info = parsed_data.get(file_id)
# If information about file isn't found.
if file_info is None:
downloads, bytes_sent = 0, 0
else:
downloads, bytes_sent = file_info
# Incrementing counters.
downloads += 1
bytes_sent += int(spl[4])
# Saving back.
parsed_data[file_id] = (downloads, bytes_sent)
return parsed_data
```
Давайте импортируем её, модифицируем команду парсинга и замерим скорость работы.
```python{}[python_rust/main.py]
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
if rust:
typer.secho("Not implemented", input_file, color=typer.colors.RED)
return
else:
parsed_data = parse_python(input_file)
typer.secho(
f"Found {len(parsed_data)} files", # noqa: WPS237
color=typer.colors.CYAN,
)
```
Как можно видеть из кода, мы просто подсчитываем итоговое количество найденных файлов.
Посмотрим сколько времени займёт парсинг нашего сгенерированного файла.
```bash
$ time pyrust parser "test.log"
Found 1000 files
pyrust parser test.log 2443.42s user 2.10s system 99% cpu 40:59.30 total
```
Обработка данного файла заняла 40 минут. Это довольно печальный результат.
Самое время попробовать использовать Rust.
# Интеграция Rust
Для интеграции Rust в python код я буду использовать [PyO3](https://github.com/PyO3/pyo3) для биндингов и [maturin](https://github.com/PyO3/maturin) для сборки проекта.
Начнём с инициализации проекта и установки сборщика. В корневой папке проекта
создайте папку проекта с растом.
<b-message type="is-info" has-icon>
В теории можно использовать maturin как основной инструмент сборки проекта,
но это лишает вас всех прелестей poetry. Поэтому удобнее использовать Rust в
подпроекте и указать его как зависимость в списке зависимостей своего проекта.
</b-message>
```bash
$ cargo new --lib rusty_log_parser
```
Теперь создадим `pyproject.toml` и напишем туда описание нашего python пакета.
```toml{}[pyproject.toml]
[tool.poetry]
name = "rusty_log_parser"
version = "0.1.0"
description = "Log file parser with Rust core"
authors = ["Pavel Kirilin <win10@list.ru>"]
[build-system]
requires = ["maturin>=0.12,<0.13"]
build-backend = "maturin"
```
Также поправим `Cargo.toml`.
```toml{}[Cargo.toml]
[lib]
# Название модуля
name = "rusty_log_parser"
# Обязательный тип крейта, чтобы можно было использовать его
# в питоне.
crate-type = ["cdylib"]
[dependencies]
# Библиотека биндингов для питона.
pyo3 = { version = "0.15.1", features = ["extension-module"] }
# Библиотека для сплита. Повторяет функционал shlex.
shell-words = "1.0.0"
```
После этих нехитрых изменений попробуем собрать проект.
```shell
$ cd rusty_log_parser
$ cargo build
```
Теперь напишем сам парсер логов в Rust.
```rust{}[rusty_log_parser/src/lib.rs]
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::BufRead;
use pyo3::prelude::*;
/// Parse log file in rust.
/// This function parses log file and returns HashMap with Strings as keys and
/// Tuple of two u128 as values.
#[pyfunction]
fn parse_rust(filename: &str) -> PyResult<HashMap<String, (u128, u128)>> {
let mut result_map = HashMap::new();
// Iterating over file.
for log in io::BufReader::new(File::open(filename)?).lines().flatten() {
// Splitting log string.
if let Ok(mut spl) = shell_words::split(&log) {
// Getting file id.
let file_id_opt = spl.get_mut(2).and_then(|http_req| {
// Splitting method and URL.
http_req.split(' ').into_iter().nth(1).and_then(|url| {
// Splitting by / and getting the last split.
url.split('/')
.into_iter()
.last()
// Split file id and extension.
.and_then(|item_url| item_url.split('.').into_iter().next())
// Turning &str into String.
.map(String::from)
})
});
// Getting number of bytes sent.
let bytes_sent_opt =
spl.get_mut(4)
// Parsing string to u128
.and_then(|bytes_str| match bytes_str.parse::<u128>() {
Ok(bytes_sent) => Some(bytes_sent),
Err(_) => None,
});
if file_id_opt.is_none() || bytes_sent_opt.is_none() {
continue;
}
let file_id = file_id_opt.unwrap();
let bytes_sent = bytes_sent_opt.unwrap();
match result_map.get(&file_id) {
Some(&(downloads, total_bytes_sent)) => {
result_map.insert(file_id, (downloads + 1, total_bytes_sent + bytes_sent));
}
None => {
result_map.insert(file_id, (1, bytes_sent));
}
}
}
}
Ok(result_map)
}
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule]
fn rusty_log_parser(_py: Python, m: &PyModule) -> PyResult<()> {
// Adding function to the module.
m.add_function(wrap_pyfunction!(parse_rust, m)?)?;
Ok(())
}
```
Это и будет наша функция, которую мы будем вызывать.
Теперь добавим `python` обёртку для нашего модуля.
Для этого в проекте `rusty_log_parser` надо создать
папку с тем же названием что и проект. В данном случае
это будет `rusty_log_parser`.
Внутри этой папки мы создадим `__init__.py`, в котором
опишем и задокументируем доступные функции модуля.
```python{}[rusty_log_parser/rusty_log_parser/__init__.py]
from pathlib import Path
from typing import Dict, Tuple
from .rusty_log_parser import parse_rust as _parse_rust
def parse_rust(input_file: Path) -> Dict[str, Tuple[int, int]]:
"""
Parse log file using Rust as a backend.
:param input_file: log file to parse.
:return: Parsed
"""
return _parse_rust(str(input_file.expanduser()))
```
Осталось только добавить свеженаписанный пакет как зависимости нашего проекта.
Для этого в корневой `pyproject.toml` надо добавить следующую зависимость.
```toml{}[pyproject.toml]
[tool.poetry.dependencies]
...
rusty_log_parser = { path = "./rusty_log_parser" }
```
Теперь мы можем использовать нашу функцию.
Перепишем нашу функцию парсера таким образом,
чтобы она использовала rust, когда был передан
соответствующий параметр.
```python{}[python_rust/main.py]
from rusty_log_parser import parse_rust
...
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
if rust:
parsed_data = parse_rust(input_file)
else:
parsed_data = parse_python(input_file)
typer.secho(
f"Found {len(parsed_data)} files", # noqa: WPS237
color=typer.colors.CYAN,
)
```
Теперь проведём итоговый замер.
```bash
$ time pyrust parser test.log --rust
Found 1000 files
pyrust parser test.log --rust 20.44s user 0.35s system 99% cpu 20.867 total
```
Итого виден небольшой выйигрыш во времени в 2423 секунды, из чего следует,
что реализация на Rust быстрее всего в 122 раза.
Данной статьёй я не подталкиваю всех переписывать всё на Rust,
а просто ознакамливаю с возможностями. Но если всё же хочется,
то можно и попробовать.
До новых встреч.