/home/noah/src/realizar/src/apr/tokenizer.rs
Line | Count | Source |
1 | | //! BPE Tokenizer for APR models (PMAT-802) |
2 | | //! |
3 | | //! Byte Pair Encoding tokenizer supporting APR v2 format models. |
4 | | //! |
5 | | //! ## Tokenizer Types |
6 | | //! |
7 | | //! - `BpeTokenizer`: Full tokenizer with encode/decode, requires tokenizer.json |
8 | | //! - `SimpleTokenizer`: Decode-only tokenizer using embedded vocabulary (GH-156) |
9 | | //! |
10 | | //! For APR models, prefer `SimpleTokenizer` via `AprV2Model::load_embedded_tokenizer()` |
11 | | //! as it uses the vocabulary embedded in the .apr file - no sibling files needed. |
12 | | |
13 | | use std::collections::HashMap; |
14 | | use super::AprV2Model; |
15 | | |
16 | | // ============================================================================ |
17 | | // SimpleTokenizer (GH-156): Decode-only tokenizer from embedded APR vocabulary |
18 | | // ============================================================================ |
19 | | |
20 | | /// Simple decode-only tokenizer for APR models with embedded vocabulary. |
21 | | /// |
22 | | /// Unlike `BpeTokenizer`, this doesn't require tokenizer.json - it uses |
23 | | /// the vocabulary embedded directly in the APR file's metadata section. |
24 | | /// |
25 | | /// # Example |
26 | | /// |
27 | | /// ```rust,ignore |
28 | | /// let model = AprV2Model::load("model.apr")?; |
29 | | /// if let Some(tokenizer) = model.load_embedded_tokenizer() { |
30 | | /// let text = tokenizer.decode(&[1, 2, 3]); |
31 | | /// } |
32 | | /// ``` |
33 | | #[derive(Debug, Clone)] |
34 | | pub struct SimpleTokenizer { |
35 | | /// Vocabulary: index = token ID, value = token string |
36 | | pub id_to_token: Vec<String>, |
37 | | /// Beginning-of-sequence token ID (optional) |
38 | | pub bos_token_id: Option<u32>, |
39 | | /// End-of-sequence token ID (optional) |
40 | | pub eos_token_id: Option<u32>, |
41 | | } |
42 | | |
43 | | impl SimpleTokenizer { |
44 | | /// Create a new simple tokenizer from vocabulary |
45 | | #[must_use] |
46 | 8 | pub fn new(vocab: Vec<String>, bos_id: Option<u32>, eos_id: Option<u32>) -> Self { |
47 | 8 | Self { |
48 | 8 | id_to_token: vocab, |
49 | 8 | bos_token_id: bos_id, |
50 | 8 | eos_token_id: eos_id, |
51 | 8 | } |
52 | 8 | } |
53 | | |
54 | | /// Decode token IDs to text |
55 | | /// |
56 | | /// Handles byte-level BPE encoding (Ġ = space prefix, Ċ = newline, etc.) |
57 | | #[must_use] |
58 | 4 | pub fn decode(&self, token_ids: &[u32]) -> String { |
59 | 4 | AprV2Model::decode_tokens(&self.id_to_token, token_ids) |
60 | 4 | } |
61 | | |
62 | | /// Get vocabulary size |
63 | | #[must_use] |
64 | 3 | pub fn vocab_size(&self) -> usize { |
65 | 3 | self.id_to_token.len() |
66 | 3 | } |
67 | | |
68 | | /// Check if token ID is end-of-sequence |
69 | | #[must_use] |
70 | 4 | pub fn is_eos(&self, token_id: u32) -> bool { |
71 | 4 | self.eos_token_id.map_or(false, |eos| token_id2 == eos2 ) |
72 | 4 | } |
73 | | |
74 | | /// Check if token ID is beginning-of-sequence |
75 | | #[must_use] |
76 | 4 | pub fn is_bos(&self, token_id: u32) -> bool { |
77 | 4 | self.bos_token_id.map_or(false, |bos| token_id2 == bos2 ) |
78 | 4 | } |
79 | | } |
80 | | |
81 | | // ============================================================================ |
82 | | // BpeTokenizer: Full encode/decode from tokenizer.json |
83 | | // ============================================================================ |
84 | | |
85 | | /// BPE Tokenizer for encoding and decoding text |
86 | | #[derive(Debug, Clone)] |
87 | | pub struct BpeTokenizer { |
88 | | /// Token string to ID mapping |
89 | | pub token_to_id: HashMap<String, u32>, |
90 | | /// ID to token string mapping (index = ID) |
91 | | pub id_to_token: Vec<String>, |
92 | | /// BPE merge rules (first, second) pairs |
93 | | pub merge_rules: Vec<(String, String)>, |
94 | | /// Beginning-of-sequence token ID |
95 | | pub bos_id: Option<u32>, |
96 | | /// End-of-sequence token ID |
97 | | pub eos_id: Option<u32>, |
98 | | } |
99 | | |
100 | | impl BpeTokenizer { |
101 | | /// Encode text to token IDs |
102 | 9 | pub fn encode(&self, text: &str) -> Vec<u32> { |
103 | 9 | bpe_encode(text, &self.token_to_id, &self.merge_rules) |
104 | 9 | } |
105 | | |
106 | | /// Decode token IDs to text |
107 | 7 | pub fn decode(&self, token_ids: &[u32]) -> String { |
108 | 7 | AprV2Model::decode_tokens(&self.id_to_token, token_ids) |
109 | 7 | } |
110 | | } |
111 | | |
112 | | /// Byte-level BPE encoding |
113 | 21 | pub(crate) fn bpe_encode(text: &str, vocab: &HashMap<String, u32>, merges: &[(String, String)]) -> Vec<u32> { |
114 | | // Convert text to byte-level tokens (GPT-2/Qwen style) |
115 | | // Each byte maps to a special unicode char in range U+0100-U+01FF or similar |
116 | 21 | let mut tokens: Vec<String> = text |
117 | 21 | .chars() |
118 | 39 | .map21 (|c| { |
119 | | // Convert character to byte-level BPE token |
120 | | // Space becomes Ġ (U+0120 = 288), newline becomes Ċ, etc. |
121 | 39 | if c == ' ' { |
122 | 4 | "Ġ".to_string() |
123 | 35 | } else if c == '\n' { |
124 | 1 | "Ċ".to_string() |
125 | 34 | } else if c == '\t' { |
126 | 1 | "ĉ".to_string() |
127 | 33 | } else if c.is_ascii() { |
128 | 30 | c.to_string() |
129 | | } else { |
130 | | // For non-ASCII, encode as bytes |
131 | 3 | let mut buf = [0u8; 4]; |
132 | 3 | let s = c.encode_utf8(&mut buf); |
133 | 3 | s.chars() |
134 | 3 | .map(|byte_char| byte_to_bpe_char(byte_char as u8)) |
135 | 3 | .collect() |
136 | | } |
137 | 39 | }) |
138 | 21 | .collect(); |
139 | | |
140 | | // Apply BPE merges iteratively |
141 | 26 | for (first5 , second5 ) in merges { |
142 | 5 | let merged = format!("{}{}", first, second); |
143 | | loop { |
144 | 10 | let mut found = false; |
145 | 10 | let mut i = 0; |
146 | 16 | while i + 1 < tokens.len() { |
147 | 6 | if &tokens[i] == first && &tokens[i + 1] == second5 { |
148 | 5 | tokens[i].clone_from(&merged); |
149 | 5 | tokens.remove(i + 1); |
150 | 5 | found = true; |
151 | 5 | }1 |
152 | 6 | i += 1; |
153 | | } |
154 | 10 | if !found { |
155 | 5 | break; |
156 | 5 | } |
157 | | } |
158 | | } |
159 | | |
160 | | // Convert tokens to IDs |
161 | 21 | tokens |
162 | 21 | .iter() |
163 | 34 | .filter_map21 (|t| vocab.get(t).copied()) |
164 | 21 | .collect() |
165 | 21 | } |
166 | | |
167 | | /// Convert byte to BPE character representation |
168 | 267 | pub fn byte_to_bpe_char(b: u8) -> String { |
169 | | // GPT-2/Qwen byte-level BPE uses specific unicode mappings |
170 | | // This is a simplified version - real tokenizers use a full byte-to-unicode table |
171 | 262 | match b { |
172 | 1 | b' ' => "Ġ".to_string(), |
173 | 2 | b'\n' => "Ċ".to_string(), |
174 | 2 | b'\t' => "ĉ".to_string(), |
175 | 262 | _ if b.is_ascii_graphic() || b165 .is_ascii_alphanumeric165 ()97 => (b as char)97 .to_string97 (), |
176 | 165 | _ => format!("<0x{:02X}>", b), |
177 | | } |
178 | 267 | } |
179 | | |
180 | | /// RMS normalization |
181 | 0 | pub(crate) fn rms_norm(x: &[f32], weight: &[f32], eps: f32) -> Vec<f32> { |
182 | 0 | let hidden_dim = weight.len(); |
183 | 0 | let seq_len = x.len() / hidden_dim; |
184 | 0 | let mut output = Vec::with_capacity(x.len()); |
185 | | |
186 | 0 | for s in 0..seq_len { |
187 | 0 | let start = s * hidden_dim; |
188 | 0 | let slice = &x[start..start + hidden_dim]; |
189 | | |
190 | | // Compute RMS |
191 | 0 | let sum_sq: f32 = slice.iter().map(|&v| v * v).sum(); |
192 | 0 | let rms = (sum_sq / hidden_dim as f32 + eps).sqrt(); |
193 | | |
194 | | // Normalize and scale |
195 | 0 | for (i, &v) in slice.iter().enumerate() { |
196 | 0 | output.push((v / rms) * weight.get(i).copied().unwrap_or(1.0)); |
197 | 0 | } |
198 | | } |
199 | 0 | output |
200 | 0 | } |
201 | | |
202 | | /// Matrix multiplication with SIMD dot products |
203 | | /// [seq, in_dim] @ [out_dim, in_dim]^T -> [seq, out_dim] |
204 | 0 | pub(crate) fn matmul(x: &[f32], w: &[f32], seq_len: usize, in_dim: usize, out_dim: usize) -> Vec<f32> { |
205 | 0 | let mut output = vec![0.0; seq_len * out_dim]; |
206 | | |
207 | 0 | for s in 0..seq_len { |
208 | 0 | let x_start = s * in_dim; |
209 | 0 | let x_end = x_start + in_dim; |
210 | 0 | if x_end > x.len() { |
211 | 0 | continue; // Skip if out of bounds |
212 | 0 | } |
213 | 0 | let x_row = &x[x_start..x_end]; |
214 | | |
215 | 0 | for o in 0..out_dim { |
216 | 0 | let w_start = o * in_dim; |
217 | 0 | let w_end = w_start + in_dim; |
218 | 0 | if w_end > w.len() { |
219 | 0 | continue; // Skip if out of bounds |
220 | 0 | } |
221 | 0 | let w_row = &w[w_start..w_end]; |
222 | | // SIMD dot product |
223 | 0 | output[s * out_dim + o] = simd_dot(x_row, w_row); |
224 | | } |
225 | | } |
226 | 0 | output |
227 | 0 | } |
228 | | |
229 | | /// Transpose a matrix from [rows, cols] to [cols, rows] for GEMM compatibility. |
230 | | /// Weight matrices are stored as [out_dim, in_dim] but GEMM needs [in_dim, out_dim]. |
231 | | #[cfg(feature = "cuda")] |
232 | | fn transpose_matrix(m: &[f32], rows: usize, cols: usize) -> Vec<f32> { |
233 | | let mut transposed = vec![0.0f32; rows * cols]; |
234 | | for r in 0..rows { |
235 | | for c in 0..cols { |
236 | | // m[r, c] -> transposed[c, r] |
237 | | let src_idx = r * cols + c; |
238 | | let dst_idx = c * rows + r; |
239 | | if src_idx < m.len() && dst_idx < transposed.len() { |
240 | | transposed[dst_idx] = m[src_idx]; |
241 | | } |
242 | | } |
243 | | } |
244 | | transposed |
245 | | } |
246 | | |
247 | | /// SIMD-accelerated dot product |
248 | | #[inline] |
249 | 0 | pub(crate) fn simd_dot(a: &[f32], b: &[f32]) -> f32 { |
250 | | #[cfg(target_arch = "x86_64")] |
251 | | { |
252 | 0 | if is_x86_feature_detected!("avx2") { |
253 | | // SAFETY: AVX2 feature is runtime-checked above, simd_dot_avx2 requires AVX2 |
254 | 0 | return unsafe { simd_dot_avx2(a, b) }; |
255 | 0 | } |
256 | | } |
257 | | // Scalar fallback |
258 | 0 | a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() |
259 | 0 | } |
260 | | |
261 | | #[cfg(target_arch = "x86_64")] |
262 | | #[target_feature(enable = "avx2", enable = "fma")] |
263 | 0 | unsafe fn simd_dot_avx2(a: &[f32], b: &[f32]) -> f32 { |
264 | | use std::arch::x86_64::{ |
265 | | _mm256_castps256_ps128, _mm256_extractf128_ps, _mm256_fmadd_ps, _mm256_loadu_ps, |
266 | | _mm256_setzero_ps, _mm_add_ps, _mm_add_ss, _mm_cvtss_f32, _mm_movehl_ps, _mm_shuffle_ps, |
267 | | }; |
268 | | |
269 | 0 | let n = a.len().min(b.len()); |
270 | 0 | let chunks = n / 8; |
271 | | |
272 | | // SAFETY: This entire fn is unsafe with target_feature(avx2, fma) |
273 | | // All intrinsics are safe to call given the target_feature guarantee |
274 | | // The unsafe block is required for Rust 2024 edition compliance |
275 | | unsafe { |
276 | 0 | let mut sum = _mm256_setzero_ps(); |
277 | | |
278 | 0 | for i in 0..chunks { |
279 | 0 | let av = _mm256_loadu_ps(a.as_ptr().add(i * 8)); |
280 | 0 | let bv = _mm256_loadu_ps(b.as_ptr().add(i * 8)); |
281 | 0 | sum = _mm256_fmadd_ps(av, bv, sum); |
282 | 0 | } |
283 | | |
284 | | // Horizontal sum |
285 | 0 | let hi = _mm256_extractf128_ps(sum, 1); |
286 | 0 | let lo = _mm256_castps256_ps128(sum); |
287 | 0 | let sum128 = _mm_add_ps(lo, hi); |
288 | 0 | let sum64 = _mm_add_ps(sum128, _mm_movehl_ps(sum128, sum128)); |
289 | 0 | let sum32 = _mm_add_ss(sum64, _mm_shuffle_ps(sum64, sum64, 1)); |
290 | 0 | let mut result = _mm_cvtss_f32(sum32); |
291 | | |
292 | | // Handle remainder (scalar) |
293 | 0 | for i in (chunks * 8)..n { |
294 | 0 | result += a.get(i).copied().unwrap_or(0.0) * b.get(i).copied().unwrap_or(0.0); |
295 | 0 | } |
296 | | |
297 | 0 | result |
298 | | } |
299 | 0 | } |
300 | | |
301 | | /// Simplified multi-head attention (no RoPE, causal mask) |
302 | 0 | pub(crate) fn simple_attention( |
303 | 0 | q: &[f32], |
304 | 0 | k: &[f32], |
305 | 0 | v: &[f32], |
306 | 0 | seq_len: usize, |
307 | 0 | num_heads: usize, |
308 | 0 | num_kv_heads: usize, |
309 | 0 | head_dim: usize, |
310 | 0 | ) -> Vec<f32> { |
311 | 0 | let hidden_dim = num_heads * head_dim; |
312 | 0 | let kv_dim = num_kv_heads * head_dim; |
313 | 0 | let heads_per_kv = num_heads / num_kv_heads; |
314 | 0 | let scale = 1.0 / (head_dim as f32).sqrt(); |
315 | | |
316 | 0 | let mut output = vec![0.0; seq_len * hidden_dim]; |
317 | | |
318 | 0 | for s in 0..seq_len { |
319 | 0 | for h in 0..num_heads { |
320 | 0 | let kv_h = h / heads_per_kv; |
321 | | |
322 | | // Compute attention scores for this head |
323 | 0 | let mut scores = vec![0.0; seq_len]; |
324 | 0 | for t in 0..=s { |
325 | | // Causal: only attend to past |
326 | 0 | let mut score = 0.0; |
327 | 0 | for d in 0..head_dim { |
328 | 0 | let q_val = q |
329 | 0 | .get(s * hidden_dim + h * head_dim + d) |
330 | 0 | .copied() |
331 | 0 | .unwrap_or(0.0); |
332 | 0 | let k_val = k |
333 | 0 | .get(t * kv_dim + kv_h * head_dim + d) |
334 | 0 | .copied() |
335 | 0 | .unwrap_or(0.0); |
336 | 0 | score += q_val * k_val; |
337 | 0 | } |
338 | 0 | scores[t] = score * scale; |
339 | | } |
340 | | |
341 | | // Softmax |
342 | 0 | let max_score = scores[..=s] |
343 | 0 | .iter() |
344 | 0 | .cloned() |
345 | 0 | .fold(f32::NEG_INFINITY, f32::max); |
346 | 0 | let mut sum = 0.0; |
347 | 0 | for score in &mut scores[..=s] { |
348 | 0 | *score = (*score - max_score).exp(); |
349 | 0 | sum += *score; |
350 | 0 | } |
351 | 0 | for score in &mut scores[..=s] { |
352 | 0 | *score /= sum; |
353 | 0 | } |
354 | | |
355 | | // Weighted sum of values |
356 | 0 | for d in 0..head_dim { |
357 | 0 | let mut val = 0.0; |
358 | 0 | for t in 0..=s { |
359 | 0 | let v_val = v |
360 | 0 | .get(t * kv_dim + kv_h * head_dim + d) |
361 | 0 | .copied() |
362 | 0 | .unwrap_or(0.0); |
363 | 0 | val += scores[t] * v_val; |
364 | 0 | } |
365 | 0 | output[s * hidden_dim + h * head_dim + d] = val; |
366 | | } |
367 | | } |
368 | | } |
369 | | |
370 | 0 | output |
371 | 0 | } |
372 | | |