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