sail / sail_scripts /kernel /src /tokenizer.rs
muterornament's picture
Industrialize: Backup sovereign training pipeline and native kernel
0f6a233 verified
Raw
History Blame Contribute Delete
10.8 kB
//! Rust BPE Tokenizer Kernel
//!
//! A memory-safe, high-speed BPE tokenizer backed by an AHash map.
//! This is 3–5x faster than the Python HuggingFace tokenizer for
//! batch processing because:
//! - No GIL contention
//! - Rayon parallel batch processing
//! - AHash: faster, DoS-safe HashMap vs Python dict
//! - Zero allocation on cache hits (pre-allocated Vec)
//!
//! The tokenizer is meant for preprocessing data into .arrow files.
//! During inference, the HuggingFace tokenizer is used for full compatibility.
use pyo3::prelude::*;
use rayon::prelude::*;
use ahash::AHashMap;
use std::sync::Arc;
use crate::errors::KernelError;
// ── Data types ───────────────────────────────────────────────────────────────
/// A vocabulary entry: token string β†’ token ID
pub type Vocab = AHashMap<String, u32>;
/// A merges pair: (left_id, right_id) β†’ merged_id
pub type MergePairs = AHashMap<(u32, u32), u32>;
/// Tokenized sequence (bounded Vec, no unbounded allocations)
pub type TokenIds = Vec<u32>;
// ── RustTokenizer PyO3 class ─────────────────────────────────────────────────
/// Memory-safe BPE tokenizer exposed to Python.
///
/// Usage:
/// from sail_kernel import RustTokenizer
/// tok = RustTokenizer(vocab_dict, merge_list, unk_id=0, pad_id=1)
/// ids = tok.encode("Hello world!") # single
/// batch = tok.encode_batch(["Hello", "World"]) # parallel
#[pyclass]
pub struct RustTokenizer {
/// Shared vocab (Arc = safe multi-thread reference counted)
vocab: Arc<Vocab>,
/// Reverse vocab: id β†’ token string
id_to_token: Arc<Vec<String>>,
/// BPE merge pairs
merges: Arc<MergePairs>,
/// Special token IDs
unk_id: u32,
pad_id: u32,
eos_id: u32,
bos_id: u32,
/// Maximum token length (prevents quadratic BPE on adversarial input)
max_token_len: usize,
}
#[pymethods]
impl RustTokenizer {
/// Create a new tokenizer from a vocab dict and merges list.
///
/// Args:
/// vocab (dict[str, int]): Token β†’ ID mapping
/// merges (list[tuple[str, str]]): BPE merge pairs as (left, right)
/// unk_id (int): ID for unknown tokens
/// pad_id (int): ID for padding
/// eos_id (int): End-of-sequence token ID
/// bos_id (int): Beginning-of-sequence token ID
#[new]
#[pyo3(signature = (vocab, merges, unk_id=0, pad_id=1, eos_id=2, bos_id=3))]
pub fn new(
vocab: std::collections::HashMap<String, u32>,
merges: Vec<(String, String)>,
unk_id: u32,
pad_id: u32,
eos_id: u32,
bos_id: u32,
) -> PyResult<Self> {
// Convert to AHashMap for speed
let vocab_map: Vocab = vocab.into_iter().collect();
let vocab_size = vocab_map.len();
// Build reverse vocab map
let mut id_to_token_map: Vec<String> = vec!["<UNK>".to_string(); vocab_size + 16];
for (token, id) in &vocab_map {
let id = *id as usize;
if id < id_to_token_map.len() {
id_to_token_map[id] = token.clone();
}
}
// Build merge pairs: resolve string pairs to ID pairs
let mut merge_map: MergePairs = AHashMap::new();
for (left_str, right_str) in &merges {
let merged = format!("{}{}", left_str, right_str);
if let (Some(&left_id), Some(&right_id), Some(&merged_id)) = (
vocab_map.get(left_str.as_str()),
vocab_map.get(right_str.as_str()),
vocab_map.get(merged.as_str()),
) {
merge_map.insert((left_id, right_id), merged_id);
}
}
Ok(RustTokenizer {
vocab: Arc::new(vocab_map),
id_to_token: Arc::new(id_to_token_map),
merges: Arc::new(merge_map),
unk_id,
pad_id,
eos_id,
bos_id,
max_token_len: 512,
})
}
/// Encode a single string to token IDs. Releases GIL.
///
/// Args:
/// text (str): Input text
/// add_special_tokens (bool): Whether to prepend BOS and append EOS
/// max_length (int): Maximum sequence length (truncates if exceeded)
///
/// Returns:
/// list[int]: Token IDs
#[pyo3(signature = (text, add_special_tokens=true, max_length=2048))]
pub fn encode(
&self,
py: Python<'_>,
text: String,
add_special_tokens: bool,
max_length: usize,
) -> Vec<u32> {
let vocab = Arc::clone(&self.vocab);
let merges = Arc::clone(&self.merges);
let unk_id = self.unk_id;
let bos_id = self.bos_id;
let eos_id = self.eos_id;
let max_tok = self.max_token_len;
py.allow_threads(move || {
encode_bpe(&text, &vocab, &merges, unk_id, bos_id, eos_id,
add_special_tokens, max_length, max_tok)
})
}
/// Encode a batch of strings in parallel (no GIL for the entire batch).
///
/// This is the recommended high-throughput entry point.
///
/// Args:
/// texts (list[str]): Batch of input strings
/// add_special_tokens (bool): Whether to add BOS/EOS
/// max_length (int): Truncate sequences to this length
///
/// Returns:
/// list[list[int]]: Batch of token ID sequences
#[pyo3(signature = (texts, add_special_tokens=true, max_length=2048))]
pub fn encode_batch(
&self,
py: Python<'_>,
texts: Vec<String>,
add_special_tokens: bool,
max_length: usize,
) -> Vec<Vec<u32>> {
let vocab = Arc::clone(&self.vocab);
let merges = Arc::clone(&self.merges);
let unk_id = self.unk_id;
let bos_id = self.bos_id;
let eos_id = self.eos_id;
let max_tok = self.max_token_len;
py.allow_threads(move || {
texts.par_iter()
.map(|text| {
encode_bpe(text, &vocab, &merges, unk_id, bos_id, eos_id,
add_special_tokens, max_length, max_tok)
})
.collect()
})
}
/// Decode token IDs back to a string.
pub fn decode(&self, ids: Vec<u32>) -> String {
ids.iter()
.filter_map(|&id| self.id_to_token.get(id as usize))
.cloned()
.collect::<Vec<_>>()
.join("")
}
/// Returns the vocabulary size.
pub fn vocab_size(&self) -> usize {
self.vocab.len()
}
}
// ── BPE encoding (pure Rust, no unsafe) ─────────────────────────────────────
/// Core BPE encoding logic. Bounded by max_length and max_token_len.
fn encode_bpe(
text: &str,
vocab: &Vocab,
merges: &MergePairs,
unk_id: u32,
bos_id: u32,
eos_id: u32,
add_special: bool,
max_length: usize,
max_token_len: usize,
) -> TokenIds {
// Pre-allocate with a generous estimate to avoid reallocations
let mut ids: TokenIds = Vec::with_capacity(text.len() / 3 + 4);
if add_special {
ids.push(bos_id);
}
// Character-level initial segmentation (safe: iterates Unicode scalar values)
let chars: Vec<String> = text.chars().map(|c| c.to_string()).collect();
let mut pieces: Vec<u32> = chars.iter()
.map(|c| vocab.get(c.as_str()).copied().unwrap_or(unk_id))
.collect();
// BPE merge loop β€” O(n * merges) with early exit on no merges
// Bounded by max_token_len to prevent quadratic behavior
let max_iters = max_token_len.min(pieces.len());
for _ in 0..max_iters {
let mut best: Option<(usize, u32)> = None;
// Find the highest-priority merge pair
for i in 0..pieces.len().saturating_sub(1) {
let pair = (pieces[i], pieces[i + 1]);
if let Some(&merged) = merges.get(&pair) {
// Use first found merge (merges are priority-ordered)
best = Some((i, merged));
break;
}
}
match best {
None => break, // No more applicable merges
Some((pos, merged_id)) => {
// Safe: pos and pos+1 are validated by the loop bounds
pieces[pos] = merged_id;
pieces.remove(pos + 1);
}
}
}
ids.extend_from_slice(&pieces);
if add_special {
ids.push(eos_id);
}
// Safe truncation (never panics)
ids.truncate(max_length);
ids
}
// ── Unit Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_simple_vocab() -> Vocab {
let mut v = AHashMap::new();
v.insert("<PAD>".to_string(), 0u32);
v.insert("<UNK>".to_string(), 1u32);
v.insert("<BOS>".to_string(), 2u32);
v.insert("<EOS>".to_string(), 3u32);
v.insert("h".to_string(), 4u32);
v.insert("e".to_string(), 5u32);
v.insert("l".to_string(), 6u32);
v.insert("o".to_string(), 7u32);
v.insert("he".to_string(), 8u32);
v.insert("hel".to_string(), 9u32);
v
}
#[test]
fn test_encode_basic() {
let vocab = make_simple_vocab();
let merges = AHashMap::new();
let result = encode_bpe("hello", &vocab, &merges, 1, 2, 3, true, 128, 512);
assert_eq!(result[0], 2, "First token is BOS");
assert_eq!(result[result.len() - 1], 3, "Last token is EOS");
}
#[test]
fn test_unk_for_missing_chars() {
let vocab = make_simple_vocab();
let merges = AHashMap::new();
let result = encode_bpe("xyz", &vocab, &merges, 1, 2, 3, false, 128, 512);
assert!(result.iter().all(|&id| id == 1), "All unknown chars β†’ UNK id");
}
#[test]
fn test_truncation() {
let vocab = make_simple_vocab();
let merges = AHashMap::new();
let text = "hello".repeat(100);
let result = encode_bpe(&text, &vocab, &merges, 1, 2, 3, true, 10, 512);
assert!(result.len() <= 10, "Result is truncated to max_length");
}
#[test]
fn test_empty_string() {
let vocab = make_simple_vocab();
let merges = AHashMap::new();
let result = encode_bpe("", &vocab, &merges, 1, 2, 3, true, 128, 512);
// Should be [BOS, EOS]
assert_eq!(result, vec![2, 3]);
}
}