480 lines
16 KiB
Markdown
480 lines
16 KiB
Markdown
---
|
||
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,
|
||
а просто ознакамливаю с возможностями. Но если всё же хочется,
|
||
то можно и попробовать.
|
||
|
||
До новых встреч.
|