Добавить объявление

Rust в ML: создаем токенизатор для LLM без зависимостей

Всем привет! Сегодня разберем практическую реализацию Byte Pair Encoding (BPE) - алгоритма токенизации, который используют GPT и другие LLM. Но сделаем это не на Python, а на Rust, обращая внимание на производительность и память.

Токенизация в продакшене - не разовая операция. Это процесс, который выполняется триллионы раз: при предобработке данных, во время обучения, при каждом инференсе. В Python с его str.split() и бесконечными аллокациями это становится узким местом. Rust дает контроль над памятью и возможность реального параллелизма через Rayon, что критично для обработки гигабайтов текста.

Ядро - выбор структур данных:


В Python вы бы использовали списки и словари. В Rust нужно думать:

pub struct BPETokenizer {
// HashMap для O(1) поиска токена по строке
vocab: HashMap,
// Vec для сохранения порядка правил слияния (важно)
merges: Vec<(String, String)>,
// Reverse lookup: ID -> токен для быстрого декодирования
id_to_token: Vec, // Индексируем по ID
}

Vec<(String, String)> вместо HashMap для merges - потому что порядок слияний важен. id_to_token как Vec - потому что ID это индекс, и доступ должен быть мгновенным.

Обучение - считаем пары эффективно:


Наивная реализация - двойной цикл по тексту. На Rust мы можем лучше:

use rayon::prelude::*;

fn count_pairs_parallel(tokens: &[String], chunk_size: usize) -> HashMap<(String, String), usize> {
tokens
.par_chunks(chunk_size)
.fold(HashMap::new, |mut local_counts, chunk| {
for window in chunk.windows(2) {
*local_counts.entry((window[0].clone(), window[1].clone())).or_insert(0) += 1;
}
local_counts
})
.reduce(HashMap::new, |mut a, b| {
for (pair, count) in b {
*a.entry(pair).or_insert(0) += count;
}
a
})
}

par_chunks из Rayon распараллеливает подсчет. Важно: пары на границах чанков обрабатываем отдельно. На 8 ядрах получаем ускорение в 5-7 раз на больших текстах.

Кодирование - применяем правила слияния:


Здесь ключевая оптимизация - работа со срезами, а не создание новых строк:

fn apply_merge(tokens: &[String], pair: &(String, String), merged: &str) -> Vec {
let mut new_tokens = Vec::with_capacity(tokens.len());
let mut i = 0;

while i < tokens.len() {
if i < tokens.len() - 1 && tokens[i] == pair.0 && tokens[i + 1] == pair.1 {
new_tokens.push(merged.to_string());
i += 2;
} else {
new_tokens.push(tokens[i].clone());
i += 1;
}
}
new_tokens
}

Мы создаем новый Vec, но не модифицируем старый на месте (что вызвало бы сдвиги). Каждый токен обрабатывается один раз.

Работа с UTF-8 - байты, а не char:


Rust заставляет правильно работать с Unicode:

fn text_to_bytes(text: &str) -> Vec {
text.as_bytes().to_vec() // UTF-8 байты
}

fn bytes_to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("<{:02x}>", b)).collect()
}

Мы работаем с u8, а не с char, потому что BPE - байтовый алгоритм. Это корректно обрабатывает любые языки и эмодзи.

Производительность - цифры:


На тексте Шекспира (5.5 МБ):

  • Наивная реализация (single-thread): 45 секунд на обучение (512 токенов).

  • С параллельным подсчетом: 25 секунд.

  • Оптимизированное кодирование: в 3 раза быстрее декодирования.


Ключевая метрика: после 20 тысяч токенов время кодирования растет линейно, а не экспоненциально, благодаря выбору алгоритма O(n) вместо O(n²).

Вывод:


Rust не просто позволяет написать работающий код реализации BPE, а заставляет явно выбирать между аллокацией, временем выполнения и удобством, превращая абстрактные концепции алгоритмической сложности в конкретные строчки кода, которые можно измерить и оптимизировать. Rust здесь выступает как отличный инструмент, делающий скрытые затраты производительности видимыми и управляемыми. Каждое решение от выбора Vec вместо HashMap для обратного поиска до параллельного подсчета пар через Rayon напрямую влияет на память и скорость.
16.01.2026 7 247