gigatoken 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +3016 -0
  3. data/Cargo.toml +135 -0
  4. data/LICENSE +21 -0
  5. data/README.md +141 -0
  6. data/exe/gigatoken +9 -0
  7. data/ext/gigatoken/Cargo.toml +24 -0
  8. data/ext/gigatoken/extconf.rb +19 -0
  9. data/ext/gigatoken/src/error.rs +20 -0
  10. data/ext/gigatoken/src/gvl.rs +122 -0
  11. data/ext/gigatoken/src/lib.rs +50 -0
  12. data/ext/gigatoken/src/sentencepiece.rs +205 -0
  13. data/ext/gigatoken/src/sources.rs +207 -0
  14. data/ext/gigatoken/src/tokenizer.rs +571 -0
  15. data/lib/gigatoken/cli/bench.rb +64 -0
  16. data/lib/gigatoken/cli/support.rb +132 -0
  17. data/lib/gigatoken/cli/validate.rb +55 -0
  18. data/lib/gigatoken/cli.rb +18 -0
  19. data/lib/gigatoken/hub.rb +242 -0
  20. data/lib/gigatoken/packed_result.rb +48 -0
  21. data/lib/gigatoken/tokenizer.rb +117 -0
  22. data/lib/gigatoken/version.rb +5 -0
  23. data/lib/gigatoken.rb +29 -0
  24. data/rust-toolchain.toml +8 -0
  25. data/src/batch.rs +1808 -0
  26. data/src/bindings/bridge.rs +396 -0
  27. data/src/bindings/hub.rs +42 -0
  28. data/src/bindings/matcher.rs +114 -0
  29. data/src/bindings/mod.rs +14 -0
  30. data/src/bindings/padding.rs +177 -0
  31. data/src/bindings/pretokenize.rs +53 -0
  32. data/src/bindings/sources.rs +273 -0
  33. data/src/bindings/train.rs +125 -0
  34. data/src/bpe/mod.rs +1217 -0
  35. data/src/bpe/pretoken_cache.rs +495 -0
  36. data/src/bpe/sentencepiece.rs +1485 -0
  37. data/src/bpe/tiktoken.rs +2555 -0
  38. data/src/bpe_train.rs +351 -0
  39. data/src/input/decompress.rs +11 -0
  40. data/src/input/file_source.rs +514 -0
  41. data/src/input/jsonl.rs +94 -0
  42. data/src/input/mod.rs +333 -0
  43. data/src/input/parquet.rs +303 -0
  44. data/src/lib.rs +578 -0
  45. data/src/load_tokenizer/hf.rs +1036 -0
  46. data/src/load_tokenizer/hub.rs +344 -0
  47. data/src/load_tokenizer/mod.rs +3 -0
  48. data/src/load_tokenizer/tiktoken.rs +87 -0
  49. data/src/main.rs +95 -0
  50. data/src/pretokenize/fast/cl100k.rs +426 -0
  51. data/src/pretokenize/fast/cl100k_family.rs +891 -0
  52. data/src/pretokenize/fast/deepseek_v3.rs +605 -0
  53. data/src/pretokenize/fast/kimi.rs +281 -0
  54. data/src/pretokenize/fast/mask.rs +1486 -0
  55. data/src/pretokenize/fast/mod.rs +446 -0
  56. data/src/pretokenize/fast/nemotron.rs +138 -0
  57. data/src/pretokenize/fast/o200k.rs +347 -0
  58. data/src/pretokenize/fast/o200k_family.rs +1734 -0
  59. data/src/pretokenize/fast/olmo3.rs +505 -0
  60. data/src/pretokenize/fast/qwen2.rs +429 -0
  61. data/src/pretokenize/fast/qwen3_5.rs +541 -0
  62. data/src/pretokenize/fast/r50k.rs +1250 -0
  63. data/src/pretokenize/mod.rs +1079 -0
  64. data/src/pretokenize/options.rs +188 -0
  65. data/src/pretokenize/pretoken.rs +20 -0
  66. data/src/pretokenize/pretokenize_traits.rs +49 -0
  67. data/src/pretokenize/reference/avx512.rs +522 -0
  68. data/src/pretokenize/reference/combinator.rs +572 -0
  69. data/src/pretokenize/reference/mod.rs +28 -0
  70. data/src/pretokenize/reference/simd.rs +852 -0
  71. data/src/pretokenize/reference/state_machine.rs +365 -0
  72. data/src/pretokenize/unicode.rs +546 -0
  73. data/src/test_hub.rs +28 -0
  74. data/src/token.rs +42 -0
  75. metadata +161 -0
@@ -0,0 +1,429 @@
1
+ //! Fast pretokenizer for the Qwen2/Qwen3 regex — on aarch64 (NEON)
2
+ //! and x86_64 with AVX-512 (runtime-detected) a mask scanner via the shared `cl100k_family::batch_masks` boundary algebra
3
+ //! (single-digit mode), with the scalar `advance_pos` below as reference,
4
+ //! no-SIMD fallback, and bad-zone/tail executor:
5
+ //! `(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+`
6
+ //!
7
+ //! Differences from the cl100k scheme:
8
+ //! - `\p{N}` matches exactly ONE number char (cl100k allows up to 3)
9
+ //! - `\s*[\r\n]+` outranks the end-of-input whitespace rule: a whitespace
10
+ //! run containing a newline always splits right after its LAST newline,
11
+ //! even at EOS, and the remaining whitespace becomes a separate token
12
+ //! (cl100k keeps trailing whitespace at EOS as one token via `\s+$`)
13
+
14
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
15
+ #[cfg(target_arch = "aarch64")]
16
+ use super::cl100k_family::batch_masks;
17
+ #[cfg(target_arch = "x86_64")]
18
+ use super::cl100k_family::batch_masks_x86;
19
+ use super::mask::{MaskScheme, MaskState};
20
+ use super::{
21
+ decode_cp, is_ascii_ws, is_digit, is_letter, letter_end_at, scan_letters_from,
22
+ scan_newlines, scan_other_from,
23
+ };
24
+ use crate::pretokenize::unicode::{self, CharClass};
25
+ use crate::pretokenize::Pretoken;
26
+
27
+ pub(crate) struct Qwen2Scheme;
28
+
29
+ impl MaskScheme for Qwen2Scheme {
30
+ #[inline(always)]
31
+ fn advance(bytes: &[u8], pos: usize) -> usize {
32
+ advance_pos(bytes, pos)
33
+ }
34
+
35
+ #[cfg(target_arch = "aarch64")]
36
+ #[inline(always)]
37
+ fn batch_masks(bytes: &[u8], scan: usize) -> (u64, u64) {
38
+ // Class-table LazyLock resolved once per batch; the extended
39
+ // path's per-char classify is then a bare slice index.
40
+ let ct = unicode::ClassTable::get();
41
+ batch_masks(bytes, scan, false, move |cp| ct.class_of(cp))
42
+ }
43
+
44
+ #[cfg(target_arch = "x86_64")]
45
+ #[inline(always)]
46
+ unsafe fn batch_masks_x86<const AVX512: bool>(bytes: &[u8], scan: usize) -> (u64, u64) {
47
+ // Class-table LazyLock resolved once per batch; the extended
48
+ // path's per-char classify is then a bare slice index.
49
+ let ct = unicode::ClassTable::get();
50
+ // SAFETY: the caller detected the tier (trait contract).
51
+ unsafe { batch_masks_x86::<AVX512>(bytes, scan, false, move |cp| ct.class_of(cp)) }
52
+ }
53
+ }
54
+
55
+ /// With SIMD support (aarch64 NEON, or x86_64 AVX-512 detected at runtime),
56
+ /// iteration runs the shared cl100k-family mask scanner (see
57
+ /// `cl100k_family::batch_masks`); elsewhere every token takes the scalar
58
+ /// `advance_pos`.
59
+ pub struct FastQwen2Pretokenizer<'a> {
60
+ bytes: &'a [u8],
61
+ state: MaskState,
62
+ }
63
+
64
+ impl<'a> FastQwen2Pretokenizer<'a> {
65
+ #[inline]
66
+ pub fn new(bytes: &'a [u8]) -> Self {
67
+ Self::with_pos(bytes, 0)
68
+ }
69
+
70
+ /// Resume iteration at a byte offset previously returned by [`Self::pos`].
71
+ #[inline]
72
+ pub fn with_pos(bytes: &'a [u8], pos: usize) -> Self {
73
+ Self { bytes, state: MaskState::new(pos) }
74
+ }
75
+
76
+ /// Current position as a byte offset into the input.
77
+ #[inline]
78
+ pub fn pos(&self) -> usize {
79
+ self.state.pos
80
+ }
81
+ }
82
+
83
+ impl<'a> Iterator for FastQwen2Pretokenizer<'a> {
84
+ type Item = Pretoken<'a>;
85
+
86
+ #[inline]
87
+ fn next(&mut self) -> Option<Pretoken<'a>> {
88
+ let (start, end) = self.state.next_span::<Qwen2Scheme>(self.bytes)?;
89
+ Some(Pretoken(&self.bytes[start..end]))
90
+ }
91
+ }
92
+
93
+ super::impl_mask_pretoken_spans!(FastQwen2Pretokenizer, Qwen2Scheme);
94
+
95
+ /// Whitespace-led token starting at `start`, i.e. the alternatives
96
+ /// `\s*[\r\n]+` | `\s+(?!\S)` | `\s+`, in that priority.
97
+ /// Precondition: the letter-prefix (`[^\r\n\p{L}\p{N}]?\p{L}+`) and
98
+ /// space+punct (` ?[^\s\p{L}\p{N}]+...`) alternatives were ruled out.
99
+ #[inline(always)]
100
+ fn ws_token_end(bytes: &[u8], start: usize) -> usize {
101
+ let len = bytes.len();
102
+ let mut p = start;
103
+ let mut last_nl_end = 0usize; // 0 = run contains no \r\n
104
+ let mut last_char_start = start;
105
+ while p < len {
106
+ let b = unsafe { *bytes.get_unchecked(p) };
107
+ if b == b'\r' || b == b'\n' {
108
+ last_char_start = p;
109
+ p += 1;
110
+ last_nl_end = p;
111
+ } else if is_ascii_ws(b) {
112
+ last_char_start = p;
113
+ p += 1;
114
+ } else if b >= 0x80 {
115
+ let (cp, l) = unsafe { decode_cp(bytes, p) };
116
+ if unicode::class_of(cp) == CharClass::Whitespace {
117
+ last_char_start = p;
118
+ p += l;
119
+ } else {
120
+ break;
121
+ }
122
+ } else {
123
+ break;
124
+ }
125
+ }
126
+ if last_nl_end != 0 {
127
+ return last_nl_end; // `\s*[\r\n]+`: through the last newline, even at EOS
128
+ }
129
+ if p >= len {
130
+ return p; // `\s+(?!\S)`: lookahead succeeds at EOS
131
+ }
132
+ if last_char_start > start {
133
+ return last_char_start; // `\s+(?!\S)`: all but the last ws char
134
+ }
135
+ p // `\s+`: single whitespace char before content
136
+ }
137
+
138
+ /// Advance past one token starting at `pos`. Returns the new position.
139
+ /// `pos` must be < `bytes.len()`.
140
+ #[inline(always)]
141
+ fn advance_pos(bytes: &[u8], pos: usize) -> usize {
142
+ let b0 = unsafe { *bytes.get_unchecked(pos) };
143
+
144
+ // Hot path 1: ASCII letter — `\p{L}+` with empty prefix
145
+ if is_letter(b0) {
146
+ return scan_letters_from(bytes, pos + 1);
147
+ }
148
+
149
+ // Hot path 2: space prefix
150
+ if b0 == b' ' {
151
+ let Some(&b1) = bytes.get(pos + 1) else {
152
+ return pos + 1; // trailing lone space (`\s+(?!\S)` at EOS)
153
+ };
154
+ if is_letter(b1) {
155
+ return scan_letters_from(bytes, pos + 2); // " word"
156
+ }
157
+ if b1 < 0x80 {
158
+ if is_digit(b1) {
159
+ return pos + 1; // numbers never absorb the space
160
+ }
161
+ if is_ascii_ws(b1) {
162
+ return ws_token_end(bytes, pos);
163
+ }
164
+ // ` ?[^\s\p{L}\p{N}]+[\r\n]*`
165
+ let p = scan_other_from(bytes, pos + 2);
166
+ return scan_newlines(bytes, p);
167
+ }
168
+ let (cp, l) = unsafe { decode_cp(bytes, pos + 1) };
169
+ let p1 = pos + 1 + l;
170
+ match unicode::class_of(cp) {
171
+ CharClass::Letter => return scan_letters_from(bytes, p1),
172
+ CharClass::Whitespace => return ws_token_end(bytes, pos),
173
+ CharClass::Number => return pos + 1,
174
+ CharClass::Other => {
175
+ let p = scan_other_from(bytes, p1);
176
+ return scan_newlines(bytes, p);
177
+ }
178
+ }
179
+ }
180
+
181
+ // Non-ASCII
182
+ if b0 >= 0x80 {
183
+ let (cp, l) = unsafe { decode_cp(bytes, pos) };
184
+ let p0 = pos + l;
185
+ let class = unicode::class_of(cp);
186
+ if class == CharClass::Letter {
187
+ return scan_letters_from(bytes, p0);
188
+ }
189
+ if class == CharClass::Number {
190
+ return p0; // `\p{N}`: exactly one char
191
+ }
192
+ // Any non-letter/number char except \r\n may prefix a letter run
193
+ if let Some(p) = letter_end_at(bytes, p0) {
194
+ return scan_letters_from(bytes, p);
195
+ }
196
+ if class == CharClass::Whitespace {
197
+ return ws_token_end(bytes, pos);
198
+ }
199
+ let p = scan_other_from(bytes, p0);
200
+ return scan_newlines(bytes, p);
201
+ }
202
+
203
+ // ASCII digit: `\p{N}` matches exactly one char
204
+ if is_digit(b0) {
205
+ return pos + 1;
206
+ }
207
+
208
+ // Apostrophe: case-insensitive contractions
209
+ if b0 == b'\'' {
210
+ match bytes.get(pos + 1).map(u8::to_ascii_lowercase) {
211
+ Some(b's' | b'd' | b'm' | b't') => return pos + 2,
212
+ Some(b'l') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'l') => {
213
+ return pos + 3;
214
+ }
215
+ Some(b'v') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'e') => {
216
+ return pos + 3;
217
+ }
218
+ Some(b'r') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'e') => {
219
+ return pos + 3;
220
+ }
221
+ _ => {}
222
+ }
223
+ // U+017F LATIN SMALL LETTER LONG S case-folds to 's' under `(?i)`
224
+ if bytes.get(pos + 1) == Some(&0xC5) && bytes.get(pos + 2) == Some(&0xBF) {
225
+ return pos + 3;
226
+ }
227
+ // Not a contraction: `'` can still prefix a letter run
228
+ if let Some(p) = letter_end_at(bytes, pos + 1) {
229
+ return scan_letters_from(bytes, p);
230
+ }
231
+ let p = scan_other_from(bytes, pos + 1);
232
+ return scan_newlines(bytes, p);
233
+ }
234
+
235
+ // \r and \n are excluded from the letter-run prefix
236
+ if b0 == b'\r' || b0 == b'\n' {
237
+ return ws_token_end(bytes, pos);
238
+ }
239
+
240
+ // Other ASCII whitespace (\t, \x0b, \x0c) may prefix a letter run
241
+ if is_ascii_ws(b0) {
242
+ if let Some(p) = letter_end_at(bytes, pos + 1) {
243
+ return scan_letters_from(bytes, p);
244
+ }
245
+ return ws_token_end(bytes, pos);
246
+ }
247
+
248
+ // ASCII punctuation/symbol
249
+ if let Some(p) = letter_end_at(bytes, pos + 1) {
250
+ return scan_letters_from(bytes, p);
251
+ }
252
+ let p = scan_other_from(bytes, pos + 1);
253
+ scan_newlines(bytes, p)
254
+ }
255
+
256
+ #[cfg(test)]
257
+ mod tests {
258
+ use super::*;
259
+ use std::io::Read;
260
+
261
+ /// The Qwen2 pattern verbatim — it contains no possessive quantifiers,
262
+ /// so it runs directly under fancy-regex.
263
+ const QWEN2_REF_REGEX: &str =
264
+ r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
265
+
266
+ fn regex_tokens(s: &str) -> Vec<String> {
267
+ let re = fancy_regex::Regex::new(QWEN2_REF_REGEX).unwrap();
268
+ re.find_iter(s)
269
+ .map(|m| m.unwrap().as_str().to_string())
270
+ .collect()
271
+ }
272
+
273
+ fn fast_tokens(s: &str) -> Vec<String> {
274
+ FastQwen2Pretokenizer::new(s.as_bytes())
275
+ .map(|t| String::from_utf8_lossy(t.0).into_owned())
276
+ .collect()
277
+ }
278
+
279
+ /// Load the first `max_bytes` of ~/data/owt_train.txt, truncated to a
280
+ /// UTF-8 boundary (streamed; the full file is ~12 GB).
281
+ fn load_owt_prefix(max_bytes: usize) -> Vec<u8> {
282
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
283
+ let f = std::fs::File::open(&path).expect("Could not open ~/data/owt_train.txt");
284
+ let mut buf = Vec::with_capacity(max_bytes);
285
+ f.take(max_bytes as u64).read_to_end(&mut buf).unwrap();
286
+ while !buf.is_empty() && std::str::from_utf8(&buf).is_err() {
287
+ buf.pop();
288
+ }
289
+ buf
290
+ }
291
+
292
+ #[test]
293
+ fn qwen2_small_cases() {
294
+ let cases = [
295
+ "hello",
296
+ " hello",
297
+ "hello world",
298
+ " hello",
299
+ " hello",
300
+ "\thello",
301
+ "\t\thello",
302
+ "\nhello",
303
+ "\n\nhello",
304
+ "\n\n hello",
305
+ "!hello",
306
+ "!!hello",
307
+ "?!x",
308
+ "don't",
309
+ "DON'T",
310
+ "they'LL go",
311
+ "it'S he'Ll",
312
+ "we'Ve THEY'RE",
313
+ "'sound",
314
+ "'lx",
315
+ "'hello",
316
+ " 'hello",
317
+ " 's",
318
+ "x'0",
319
+ "123",
320
+ "1234",
321
+ "1234567",
322
+ " 123",
323
+ " 123",
324
+ "3rd",
325
+ "abc1234def",
326
+ "hello, world!",
327
+ "hi!\n\ndef",
328
+ "hi !!\n\ndef",
329
+ " !!!",
330
+ "a-b",
331
+ "a - b",
332
+ "...",
333
+ "hello\n",
334
+ "hello \n",
335
+ "hello \nx",
336
+ "hello\n x",
337
+ "hello \n\n ",
338
+ "x \n\n ",
339
+ "x ",
340
+ "x \t",
341
+ " \n hello",
342
+ "\r\nhello",
343
+ "a\r\n",
344
+ "a\r\n ",
345
+ "a\n \n",
346
+ "a \n \t",
347
+ "\n\n",
348
+ "\n\n\t",
349
+ " ",
350
+ " ",
351
+ "",
352
+ "café",
353
+ " café",
354
+ "\u{a0}word",
355
+ "voilà ¡hola!",
356
+ "١٢٣٤٥",
357
+ "e\u{301}f",
358
+ "日本語のテキスト",
359
+ " 日本語",
360
+ "1٢3x",
361
+ "tab\tsep\tvals",
362
+ "\x0bword",
363
+ "a\u{2028}b",
364
+ "a\u{2028}\n",
365
+ "price: $5.99!",
366
+ "'ſ",
367
+ "it'ſ fine",
368
+ ];
369
+ for case in cases {
370
+ assert_eq!(
371
+ fast_tokens(case),
372
+ regex_tokens(case),
373
+ "Mismatch on case {case:?}"
374
+ );
375
+ }
376
+ }
377
+
378
+ #[test]
379
+ fn qwen2_matches_regex_owt() {
380
+ const SIZE: usize = 5_000_000;
381
+ let input = load_owt_prefix(SIZE);
382
+ let text = std::str::from_utf8(&input).unwrap();
383
+ eprintln!(
384
+ "Testing qwen2 fast pretokenizer vs regex on {:.1} MB of OWT",
385
+ input.len() as f64 / 1e6
386
+ );
387
+
388
+ let re = fancy_regex::Regex::new(QWEN2_REF_REGEX).unwrap();
389
+ let mut fast_iter = FastQwen2Pretokenizer::new(&input);
390
+ let mut re_iter = re.find_iter(text);
391
+ let mut token_idx: usize = 0;
392
+ let mut recent: Vec<(String, String)> = Vec::new();
393
+
394
+ loop {
395
+ match (fast_iter.next(), re_iter.next()) {
396
+ (Some(fast_tok), Some(re_match)) => {
397
+ let re_match = re_match.expect("regex match error");
398
+ let fast_str = String::from_utf8_lossy(fast_tok.0);
399
+ let re_str = &text[re_match.start()..re_match.end()];
400
+ recent.push((fast_str.to_string(), re_str.to_string()));
401
+ if recent.len() > 10 {
402
+ recent.remove(0);
403
+ }
404
+ assert_eq!(
405
+ fast_str, re_str,
406
+ "Mismatch at token {token_idx} (byte ~{}).\n fast: {:?}\n regex: {:?}\n recent tokens: {:?}",
407
+ re_match.start(), fast_str, re_str, recent
408
+ );
409
+ }
410
+ (None, None) => break,
411
+ (Some(fast_tok), None) => panic!(
412
+ "Fast produced extra token at index {token_idx}: {:?}\n recent: {:?}",
413
+ String::from_utf8_lossy(fast_tok.0),
414
+ recent
415
+ ),
416
+ (None, Some(re_match)) => {
417
+ let re_match = re_match.expect("regex match error");
418
+ panic!(
419
+ "Regex produced extra token at index {token_idx}: {:?}\n recent: {:?}",
420
+ &text[re_match.start()..re_match.end()],
421
+ recent
422
+ );
423
+ }
424
+ }
425
+ token_idx += 1;
426
+ }
427
+ eprintln!("All {token_idx} tokens match.");
428
+ }
429
+ }