GPU Tests / Check GPU Availability (push) Successful in 1s
CI / Build (ubuntu-latest) (push) Failing after 6s
CI / Build (macos-latest) (push) Failing after 11s
Documentation / Build User Guide (push) Successful in 7s
CI / Clippy Check (push) Failing after 21s
CI / Format Check (push) Failing after 6s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
CI / Build CPU-Only (Explicit) (push) Failing after 6s
GPU Tests / Metal Tests (push) Has been skipped
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
Performance Benchmarks / Run Benchmarks (push) Successful in 28s
Documentation / Build API Documentation (push) Failing after 25s
EAGLE (rtx-inference/src/eagle.rs, ~610 lines, mirrors medusa.rs conventions): EagleDraftHead autoregressive FFN with Concat/Add/ Attention feature fusion, EagleHeads draft model with draft/ draft_steps (per-step top-k for candidate trees) and teacher-forced training_loss; implements the speculative::EagleDraftModel trait so it plugs into the orchestration layer. 38 unit tests. Tokenizer (rtx-inference/src/tokenizer.rs): ServingTokenizer enum — Vocab (HuggingFace tokenizers, loadable from tokenizer.json) or ByteLevel fallback preserving previous behavior. rtx-serving-api's AppState and rtx-streaming's token generator now encode/decode through it (with_engine_and_tokenizer / set_tokenizer added; existing signatures unchanged). Also fixes two pre-existing compile errors in rtx-streaming (missing import, stray .await) that blocked its lib tests entirely. Tests: rtx-inference 328 pass, rtx-serving-api 193 pass, rtx-streaming 53 pass (2 pre-existing mock-server connection failures unrelated to these changes). Co-Authored-By: Claude Fable 5 <[email protected]>
149 lines
5.2 KiB
Rust
149 lines
5.2 KiB
Rust
//! Serving-boundary tokenization.
|
|
//!
|
|
//! Historically the HTTP/streaming serving layers tokenized text by mapping
|
|
//! each UTF-8 byte to a token id (`bytes().map(i32::from)`) and reversed it
|
|
//! with `String::from_utf8_lossy`. That byte-level scheme is lossless and
|
|
//! reversible but not vocabulary-aware, so it wastes context and produces
|
|
//! nonsense token counts for anything downstream that expects real
|
|
//! sub-word tokens.
|
|
//!
|
|
//! [`ServingTokenizer`] makes tokenization pluggable: load a real
|
|
//! `tokenizers`-crate vocabulary (`tokenizer.json`) via
|
|
//! [`ServingTokenizer::from_file`], or fall back to the original byte-level
|
|
//! behavior via [`ServingTokenizer::byte_level`] (the default).
|
|
|
|
use std::path::Path;
|
|
|
|
/// Tokenizer used at the serving boundary (HTTP inference, streaming).
|
|
///
|
|
/// Defaults to byte-level encoding for backward compatibility; construct a
|
|
/// [`ServingTokenizer::Vocab`] from a `tokenizer.json` file to use a real
|
|
/// vocabulary tokenizer instead.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ServingTokenizer {
|
|
/// Real vocabulary tokenizer backed by the `tokenizers` crate.
|
|
Vocab(Box<tokenizers::Tokenizer>),
|
|
/// Byte-level fallback: each UTF-8 byte is one token id. Lossless and
|
|
/// reversible, but not vocabulary-aware.
|
|
ByteLevel,
|
|
}
|
|
|
|
impl Default for ServingTokenizer {
|
|
fn default() -> Self {
|
|
Self::ByteLevel
|
|
}
|
|
}
|
|
|
|
impl ServingTokenizer {
|
|
/// Byte-level tokenizer: each UTF-8 byte is one token id.
|
|
#[must_use]
|
|
pub fn byte_level() -> Self {
|
|
Self::ByteLevel
|
|
}
|
|
|
|
/// Load a real vocabulary tokenizer from a `tokenizer.json` file.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the file cannot be read or does not describe a
|
|
/// valid `tokenizers` configuration.
|
|
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
|
|
let tokenizer = tokenizers::Tokenizer::from_file(path.as_ref())
|
|
.map_err(|e| format!("failed to load tokenizer from {:?}: {e}", path.as_ref()))?;
|
|
Ok(Self::Vocab(Box::new(tokenizer)))
|
|
}
|
|
|
|
/// Encode text into token ids.
|
|
///
|
|
/// The `ByteLevel` variant maps each UTF-8 byte to its numeric value,
|
|
/// matching the historical serving-boundary behavior exactly.
|
|
#[must_use]
|
|
pub fn encode(&self, text: &str) -> Vec<i32> {
|
|
match self {
|
|
Self::ByteLevel => text.bytes().map(i32::from).collect(),
|
|
Self::Vocab(tokenizer) => match tokenizer.encode(text, false) {
|
|
Ok(encoding) => encoding.get_ids().iter().map(|&id| id as i32).collect(),
|
|
Err(_) => Vec::new(),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Decode token ids back into text.
|
|
///
|
|
/// The `ByteLevel` variant clamps each id to a `u8` and reconstructs the
|
|
/// string with `String::from_utf8_lossy`, matching the historical
|
|
/// serving-boundary behavior exactly.
|
|
#[must_use]
|
|
pub fn decode(&self, ids: &[i32]) -> String {
|
|
match self {
|
|
Self::ByteLevel => {
|
|
let bytes: Vec<u8> = ids.iter().map(|&t| t.clamp(0, 255) as u8).collect();
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
}
|
|
Self::Vocab(tokenizer) => {
|
|
let ids: Vec<u32> = ids.iter().map(|&id| id.max(0) as u32).collect();
|
|
tokenizer.decode(&ids, true).unwrap_or_default()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn byte_level_round_trip_ascii() {
|
|
let tok = ServingTokenizer::byte_level();
|
|
let ids = tok.encode("Hello, world!");
|
|
assert_eq!(tok.decode(&ids), "Hello, world!");
|
|
}
|
|
|
|
#[test]
|
|
fn byte_level_round_trip_multibyte_utf8() {
|
|
let tok = ServingTokenizer::byte_level();
|
|
let text = "héllo wörld — 日本語";
|
|
let ids = tok.encode(text);
|
|
assert_eq!(tok.decode(&ids), text);
|
|
}
|
|
|
|
#[test]
|
|
fn byte_level_default_matches_byte_level_constructor() {
|
|
let default_tok = ServingTokenizer::default();
|
|
assert!(matches!(default_tok, ServingTokenizer::ByteLevel));
|
|
}
|
|
|
|
/// Builds a tiny in-memory WordLevel tokenizer over a 5-word vocab and
|
|
/// verifies encode/decode round-trips for a sentence made purely of
|
|
/// vocab words.
|
|
#[test]
|
|
fn vocab_tokenizer_round_trip() {
|
|
use std::collections::HashMap;
|
|
use tokenizers::models::wordlevel::WordLevel;
|
|
use tokenizers::pre_tokenizers::whitespace::Whitespace;
|
|
|
|
let mut vocab: HashMap<String, u32> = HashMap::new();
|
|
vocab.insert("[UNK]".to_string(), 0);
|
|
vocab.insert("the".to_string(), 1);
|
|
vocab.insert("quick".to_string(), 2);
|
|
vocab.insert("brown".to_string(), 3);
|
|
vocab.insert("fox".to_string(), 4);
|
|
|
|
let model = WordLevel::builder()
|
|
.vocab(vocab)
|
|
.unk_token("[UNK]".to_string())
|
|
.build()
|
|
.expect("valid wordlevel model");
|
|
|
|
let mut tokenizer = tokenizers::Tokenizer::new(model);
|
|
tokenizer.with_pre_tokenizer(Some(Whitespace));
|
|
|
|
let serving = ServingTokenizer::Vocab(Box::new(tokenizer));
|
|
|
|
let text = "the quick brown fox";
|
|
let ids = serving.encode(text);
|
|
assert_eq!(ids, vec![1, 2, 3, 4]);
|
|
assert_eq!(serving.decode(&ids), text);
|
|
}
|
|
}
|