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,344 @@
1
+ //! Minimal HuggingFace Hub file download into the standard HF cache.
2
+ //!
3
+ //! Rust port of the former pure-Python `gigatoken._load.hub`: same endpoint
4
+ //! and URL layout as `huggingface_hub.hf_hub_download`, same token discovery
5
+ //! (HF_TOKEN env var, then the token file written by `hf auth login`), and
6
+ //! the same cache directory resolution, without requiring huggingface_hub,
7
+ //! tokenizers, or transformers. Files already present in the standard HF
8
+ //! cache are served with a pure-filesystem lookup (no network); on a miss the
9
+ //! file is downloaded straight into the shared cache — under the commit hash
10
+ //! the Hub reports via `x-repo-commit`, with the branch ref recorded — so
11
+ //! huggingface_hub and later lookups serve it from the same place.
12
+
13
+ use eyre::{Context, Result, bail};
14
+ use std::fmt;
15
+ use std::io;
16
+ use std::path::PathBuf;
17
+
18
+ /// Filename suffixes of local tokenizer files (tokenizer.json contents and
19
+ /// raw sentencepiece models — the formats the hf loader reads from disk).
20
+ /// A name ending in one of these is never treated as a Hub repo id, so a
21
+ /// mistyped local path fails fast instead of hitting the network. Keep in
22
+ /// sync with `gigatoken._load.hub.TOKENIZER_FILE_SUFFIXES`.
23
+ pub const TOKENIZER_FILE_SUFFIXES: &[&str] = &[".json", ".model"];
24
+
25
+ /// Whether `name` is shaped like a HuggingFace Hub repo id: `org/name`, or a
26
+ /// bare legacy repo name like `gpt2`. At most one slash, and not something
27
+ /// that is obviously a filesystem path to a local tokenizer file.
28
+ pub fn looks_like_repo_id(name: &str) -> bool {
29
+ let word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-');
30
+ let part_ok = |part: &str, first_alnum: bool| {
31
+ let mut chars = part.chars();
32
+ let Some(first) = chars.next() else {
33
+ return false;
34
+ };
35
+ let first_ok = if first_alnum { first.is_ascii_alphanumeric() } else { word(first) };
36
+ first_ok && chars.all(word)
37
+ };
38
+ let mut parts = name.split('/');
39
+ let (org, rest) = (parts.next().unwrap_or(""), parts.next());
40
+ parts.next().is_none()
41
+ && part_ok(org, true)
42
+ && rest.is_none_or(|r| part_ok(r, false))
43
+ && !TOKENIZER_FILE_SUFFIXES.iter().any(|s| name.ends_with(s))
44
+ }
45
+
46
+ fn env_nonempty(key: &str) -> Option<String> {
47
+ std::env::var(key).ok().filter(|v| !v.is_empty())
48
+ }
49
+
50
+ /// $HF_HOME, defaulting to $XDG_CACHE_HOME/huggingface then
51
+ /// ~/.cache/huggingface — the root for both the hub cache and the token file.
52
+ fn hf_home() -> PathBuf {
53
+ env_nonempty("HF_HOME").map(PathBuf::from).unwrap_or_else(|| {
54
+ env_nonempty("XDG_CACHE_HOME")
55
+ .map(PathBuf::from)
56
+ .unwrap_or_else(|| std::env::home_dir().expect("home dir").join(".cache"))
57
+ .join("huggingface")
58
+ })
59
+ }
60
+
61
+ /// The standard HuggingFace hub cache directory, resolved like
62
+ /// huggingface_hub does it: HF_HUB_CACHE, then $HF_HOME/hub, then
63
+ /// $XDG_CACHE_HOME/huggingface/hub, then ~/.cache/huggingface/hub.
64
+ pub fn hf_hub_cache_dir() -> PathBuf {
65
+ env_nonempty("HF_HUB_CACHE")
66
+ .map(PathBuf::from)
67
+ .unwrap_or_else(|| hf_home().join("hub"))
68
+ }
69
+
70
+ /// The HuggingFace access token, discovered like huggingface_hub does it:
71
+ /// the HF_TOKEN (or legacy HUGGING_FACE_HUB_TOKEN) environment variable,
72
+ /// then the token file (HF_TOKEN_PATH, default $HF_HOME/token).
73
+ pub fn get_hf_token() -> Option<String> {
74
+ if let Some(token) = env_nonempty("HF_TOKEN")
75
+ .or_else(|| env_nonempty("HUGGING_FACE_HUB_TOKEN"))
76
+ .map(|t| t.trim().to_owned())
77
+ .filter(|t| !t.is_empty())
78
+ {
79
+ return Some(token);
80
+ }
81
+ let token_path = env_nonempty("HF_TOKEN_PATH")
82
+ .map(PathBuf::from)
83
+ .unwrap_or_else(|| hf_home().join("token"));
84
+ let token = std::fs::read_to_string(token_path).ok()?;
85
+ let token = token.trim();
86
+ (!token.is_empty()).then(|| token.to_owned())
87
+ }
88
+
89
+ /// A full git commit hash: cache snapshot directories are named by these.
90
+ fn is_commit_hash(revision: &str) -> bool {
91
+ revision.len() == 40 && revision.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
92
+ }
93
+
94
+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95
+ pub enum RepoType {
96
+ #[default]
97
+ Model,
98
+ Dataset,
99
+ Space,
100
+ }
101
+
102
+ impl RepoType {
103
+ /// The cache directory prefix (`models--org--name` etc.).
104
+ fn cache_prefix(self) -> &'static str {
105
+ match self {
106
+ RepoType::Model => "models",
107
+ RepoType::Dataset => "datasets",
108
+ RepoType::Space => "spaces",
109
+ }
110
+ }
111
+
112
+ /// The URL path prefix before the repo id.
113
+ fn url_prefix(self) -> &'static str {
114
+ match self {
115
+ RepoType::Model => "",
116
+ RepoType::Dataset => "datasets/",
117
+ RepoType::Space => "spaces/",
118
+ }
119
+ }
120
+ }
121
+
122
+ impl std::str::FromStr for RepoType {
123
+ type Err = eyre::Report;
124
+
125
+ fn from_str(s: &str) -> Result<Self> {
126
+ match s {
127
+ "model" => Ok(RepoType::Model),
128
+ "dataset" => Ok(RepoType::Dataset),
129
+ "space" => Ok(RepoType::Space),
130
+ _ => Err(eyre::eyre!("unknown repo_type {s:?}: expected \"model\", \"dataset\", or \"space\"")),
131
+ }
132
+ }
133
+ }
134
+
135
+ /// The cache directory of a repo (`models--org--name` etc.).
136
+ fn repo_cache_dir(repo_type: RepoType, repo_id: &str) -> PathBuf {
137
+ hf_hub_cache_dir().join(format!("{}--{}", repo_type.cache_prefix(), repo_id.replace('/', "--")))
138
+ }
139
+
140
+ /// Model-repo [`cached_hub_file_in`].
141
+ pub fn cached_hub_file(repo_id: &str, filename: &str, revision: &str) -> Option<PathBuf> {
142
+ cached_hub_file_in(RepoType::Model, repo_id, filename, revision)
143
+ }
144
+
145
+ /// Path of `filename` in the local HF cache, or None when not cached.
146
+ ///
147
+ /// A pure-filesystem lookup — no request is made. `revision` may be a commit
148
+ /// hash (used directly as the snapshot name) or a branch/tag name (followed
149
+ /// through the cached ref).
150
+ pub fn cached_hub_file_in(repo_type: RepoType, repo_id: &str, filename: &str, revision: &str) -> Option<PathBuf> {
151
+ let repo_dir = repo_cache_dir(repo_type, repo_id);
152
+ let commit_owned;
153
+ let commit = if is_commit_hash(revision) {
154
+ revision
155
+ } else {
156
+ commit_owned = std::fs::read_to_string(repo_dir.join("refs").join(revision)).ok()?;
157
+ commit_owned.trim()
158
+ };
159
+ let path = repo_dir.join("snapshots").join(commit).join(filename);
160
+ path.is_file().then_some(path)
161
+ }
162
+
163
+ /// Download failure with a definite HTTP cause, kept as a typed error so the
164
+ /// Python bindings can raise the matching exception (FileNotFoundError for
165
+ /// 404, PermissionError for 401/403).
166
+ #[derive(Debug)]
167
+ pub enum FetchError {
168
+ /// 404: no such repo, revision, or file.
169
+ NotFound { url: String },
170
+ /// 401/403: private or gated repo.
171
+ Unauthorized { url: String, status: u16, had_token: bool },
172
+ }
173
+
174
+ impl fmt::Display for FetchError {
175
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176
+ match self {
177
+ FetchError::NotFound { url } => {
178
+ write!(f, "{url}: HTTP 404 — no such repo with that file, and no such local file either")
179
+ }
180
+ FetchError::Unauthorized { url, status, had_token } => {
181
+ let token = if *had_token {
182
+ "the request used the discovered token"
183
+ } else {
184
+ "no token was found"
185
+ };
186
+ write!(
187
+ f,
188
+ "{url}: HTTP {status} — the repo may be private or gated ({token}; set HF_TOKEN or run \
189
+ `hf auth login`, and accept the repo's terms on huggingface.co if it is gated)"
190
+ )
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ impl std::error::Error for FetchError {}
197
+
198
+ /// Model-repo [`hub_file_in`].
199
+ pub fn hub_file(repo_id: &str, filename: &str, revision: &str) -> Result<PathBuf> {
200
+ hub_file_in(RepoType::Model, repo_id, filename, revision)
201
+ }
202
+
203
+ /// Path of `filename` from Hub repo `repo_id` at `revision`, served from the
204
+ /// standard HF cache, downloading into it first when absent.
205
+ pub fn hub_file_in(repo_type: RepoType, repo_id: &str, filename: &str, revision: &str) -> Result<PathBuf> {
206
+ if let Some(path) = cached_hub_file_in(repo_type, repo_id, filename, revision) {
207
+ return Ok(path);
208
+ }
209
+ download_into_cache(repo_type, repo_id, filename, revision)
210
+ .wrap_err_with(|| format!("downloading {filename} from Hub repo {repo_id} at revision {revision}"))
211
+ }
212
+
213
+ /// GET `endpoint/repo/resolve/revision/filename` and stream the body into the
214
+ /// cache snapshot named by the `x-repo-commit` response header, recording the
215
+ /// branch ref so later lookups (ours and huggingface_hub's) resolve it.
216
+ fn download_into_cache(repo_type: RepoType, repo_id: &str, filename: &str, revision: &str) -> Result<PathBuf> {
217
+ let endpoint = env_nonempty("HF_ENDPOINT").unwrap_or_else(|| "https://huggingface.co".to_owned());
218
+ let url = format!(
219
+ "{}/{}{repo_id}/resolve/{revision}/{filename}",
220
+ endpoint.trim_end_matches('/'),
221
+ repo_type.url_prefix()
222
+ );
223
+ let token = get_hf_token();
224
+
225
+ // Redirects are followed by hand: resolve/ URLs answer with the
226
+ // `x-repo-commit` header and a redirect to a CDN for LFS files, and the
227
+ // Authorization header must not travel to the other host.
228
+ let agent = ureq::Agent::config_builder()
229
+ .max_redirects(0)
230
+ .http_status_as_error(false)
231
+ .build()
232
+ .new_agent();
233
+ let mut request = agent.get(&url).header("User-Agent", "gigatoken");
234
+ if let Some(token) = &token {
235
+ request = request.header("Authorization", format!("Bearer {token}"));
236
+ }
237
+ let mut response = request.call().wrap_err_with(|| format!("requesting {url}"))?;
238
+ let header = |resp: &ureq::http::Response<ureq::Body>, name: &str| {
239
+ resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned)
240
+ };
241
+ let commit = header(&response, "x-repo-commit");
242
+
243
+ let mut hops = 0;
244
+ while response.status().is_redirection() {
245
+ hops += 1;
246
+ ensure_status(&url, response.status().as_u16(), token.is_some())?;
247
+ if hops > 10 {
248
+ bail!("{url}: too many redirects");
249
+ }
250
+ let location = header(&response, "location")
251
+ .ok_or_else(|| eyre::eyre!("{url}: redirect with no Location header"))?;
252
+ let next_url = absolutize(&location, &url);
253
+ // No Authorization here: the redirect target is a presigned CDN URL
254
+ // on another host (requests/huggingface_hub drop the header too).
255
+ response = agent
256
+ .get(&next_url)
257
+ .header("User-Agent", "gigatoken")
258
+ .call()
259
+ .wrap_err_with(|| format!("requesting {next_url}"))?;
260
+ }
261
+ ensure_status(&url, response.status().as_u16(), token.is_some())?;
262
+
263
+ // Snapshot directory: the commit the Hub reported, falling back to the
264
+ // requested revision (e.g. a plain file server behind HF_ENDPOINT).
265
+ let commit = commit.unwrap_or_else(|| revision.to_owned());
266
+ let repo_dir = repo_cache_dir(repo_type, repo_id);
267
+ let target = repo_dir.join("snapshots").join(&commit).join(filename);
268
+ let dir = target.parent().expect("snapshot file has a parent");
269
+ std::fs::create_dir_all(dir).wrap_err_with(|| format!("creating {}", dir.display()))?;
270
+
271
+ // Stream to a sibling temp file, then rename: concurrent downloaders
272
+ // race benignly and readers never observe a partial file.
273
+ let tmp = dir.join(format!(".{}.{}.tmp", target.file_name().unwrap().to_string_lossy(), std::process::id()));
274
+ let result = (|| -> Result<()> {
275
+ let mut file = std::fs::File::create(&tmp)?;
276
+ io::copy(&mut response.body_mut().as_reader(), &mut file)?;
277
+ file.sync_all()?;
278
+ std::fs::rename(&tmp, &target)?;
279
+ Ok(())
280
+ })();
281
+ if result.is_err() {
282
+ let _ = std::fs::remove_file(&tmp);
283
+ }
284
+ result.wrap_err_with(|| format!("writing {}", target.display()))?;
285
+
286
+ if !is_commit_hash(revision) && revision != commit {
287
+ let refs_dir = repo_dir.join("refs");
288
+ std::fs::create_dir_all(&refs_dir)
289
+ .and_then(|_| std::fs::write(refs_dir.join(revision), &commit))
290
+ .wrap_err_with(|| format!("recording ref {revision} -> {commit}"))?;
291
+ }
292
+ Ok(target)
293
+ }
294
+
295
+ fn ensure_status(url: &str, status: u16, had_token: bool) -> Result<()> {
296
+ match status {
297
+ 200..=399 => Ok(()),
298
+ 404 => Err(FetchError::NotFound { url: url.to_owned() }.into()),
299
+ 401 | 403 => Err(FetchError::Unauthorized { url: url.to_owned(), status, had_token }.into()),
300
+ _ => Err(eyre::eyre!("{url}: HTTP {status}")),
301
+ }
302
+ }
303
+
304
+ /// A redirect Location resolved against the request URL: absolute URLs pass
305
+ /// through, host-relative (`/x/y`) and path-relative ones join the base.
306
+ fn absolutize(location: &str, base: &str) -> String {
307
+ if location.contains("://") {
308
+ return location.to_owned();
309
+ }
310
+ let origin_len = base.find("://").map(|i| i + 3).unwrap_or(0);
311
+ let origin_end = base[origin_len..].find('/').map_or(base.len(), |i| origin_len + i);
312
+ if location.starts_with('/') {
313
+ format!("{}{location}", &base[..origin_end])
314
+ } else {
315
+ let dir_end = base.rfind('/').unwrap_or(base.len());
316
+ format!("{}/{location}", &base[..dir_end.max(origin_end)])
317
+ }
318
+ }
319
+
320
+ #[cfg(test)]
321
+ mod tests {
322
+ use super::*;
323
+
324
+ #[test]
325
+ fn repo_id_shapes() {
326
+ assert!(looks_like_repo_id("gpt2"));
327
+ assert!(looks_like_repo_id("openai-community/gpt2"));
328
+ assert!(looks_like_repo_id("Qwen/Qwen3.5-9B"));
329
+ assert!(!looks_like_repo_id("data/tokenizers/gpt2.json"));
330
+ assert!(!looks_like_repo_id("./gpt2"));
331
+ assert!(!looks_like_repo_id("/abs/path"));
332
+ assert!(!looks_like_repo_id("gpt2_tokenizer.json"));
333
+ assert!(!looks_like_repo_id("subdir/tokenizer.model"));
334
+ assert!(!looks_like_repo_id(""));
335
+ assert!(!looks_like_repo_id("org/"));
336
+ }
337
+
338
+ #[test]
339
+ fn location_resolution() {
340
+ assert_eq!(absolutize("https://cdn.example/x", "https://huggingface.co/a/b"), "https://cdn.example/x");
341
+ assert_eq!(absolutize("/api/x", "https://huggingface.co/a/b"), "https://huggingface.co/api/x");
342
+ assert_eq!(absolutize("y", "https://huggingface.co/a/b"), "https://huggingface.co/a/y");
343
+ }
344
+ }
@@ -0,0 +1,3 @@
1
+ pub mod hf;
2
+ pub mod hub;
3
+ pub mod tiktoken;
@@ -0,0 +1,87 @@
1
+ use crate::bpe::Tokenizer;
2
+ use crate::pretokenize::PretokenizerType;
3
+ use eyre::{Context, Result, ensure};
4
+ use std::path::Path;
5
+
6
+ /// The base64-per-line mergeable ranks of a .tiktoken/tiktoken.model file,
7
+ /// in rank order (merges are reconstructed from this list).
8
+ fn load_tiktoken_ranks(file_path: impl AsRef<Path>) -> Result<Vec<Vec<u8>>> {
9
+ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
10
+ use base64::prelude::*;
11
+ use std::io::Read;
12
+ let mut buf = String::new();
13
+ std::fs::File::open(&file_path)
14
+ .with_context(|| format!("Failed to read {}", file_path.as_ref().display()))?
15
+ .read_to_string(&mut buf)?;
16
+ buf.lines()
17
+ .enumerate()
18
+ .map(|(i, line)| {
19
+ let (base64_token, id_str) = line
20
+ .split_once(' ')
21
+ .ok_or_else(|| eyre::eyre!("line {i} has no rank field"))?;
22
+ let id = id_str.trim().parse::<u32>()?;
23
+ ensure!(id == i as u32, "rank {id} at line {i}: ranks must be dense");
24
+ Ok(BASE64_STANDARD.decode(base64_token)?)
25
+ })
26
+ .collect()
27
+ }
28
+
29
+ pub fn load_tiktoken(file_path: impl AsRef<Path>) -> Result<Tokenizer> {
30
+ let rank_vocab = load_tiktoken_ranks(file_path)?;
31
+ let n_ranks = rank_vocab.len() as u32;
32
+ let mut tokenizer = Tokenizer::from_ranks(rank_vocab)?;
33
+ // Tiktoken vocab files carry no special tokens; GPT-2-family vocabs
34
+ // (gpt2/r50k) place <|endoftext|> at the id right after the mergeable
35
+ // ranks. Register it so tiktoken- and tokenizer.json-loaded tokenizers
36
+ // encode and decode identically.
37
+ tokenizer.add_special_token(b"<|endoftext|>".to_vec(), n_ranks.into());
38
+ Ok(tokenizer)
39
+ }
40
+
41
+ /// The fields of a HuggingFace tokenizer_config.json a tiktoken-rank repo
42
+ /// needs: the special tokens (there is no tokenizer.json to carry them).
43
+ #[derive(serde::Deserialize)]
44
+ struct TokenizerConfigJson {
45
+ #[serde(default)]
46
+ added_tokens_decoder: std::collections::BTreeMap<String, AddedTokenJson>,
47
+ }
48
+
49
+ #[derive(serde::Deserialize)]
50
+ struct AddedTokenJson {
51
+ content: String,
52
+ }
53
+
54
+ /// Load a tokenizer from a tiktoken rank file plus a HuggingFace
55
+ /// `tokenizer_config.json` whose `added_tokens_decoder` carries the special
56
+ /// tokens — the layout of repos that ship no tokenizer.json (e.g. the
57
+ /// moonshotai Kimi/Moonlight line's `tiktoken.model`). The pretokenizer
58
+ /// scheme is passed by the caller: such repos define their split regex only
59
+ /// in remote code, so no shipped file can name it.
60
+ pub fn load_tiktoken_model(
61
+ model_path: impl AsRef<Path>,
62
+ config_path: impl AsRef<Path>,
63
+ pretokenizer: PretokenizerType,
64
+ ) -> Result<Tokenizer> {
65
+ let rank_vocab = load_tiktoken_ranks(model_path)?;
66
+ let n_ranks = rank_vocab.len() as u32;
67
+ let mut tokenizer = Tokenizer::from_ranks(rank_vocab)?;
68
+ tokenizer.set_pretokenizer_type(pretokenizer);
69
+ let config_path = config_path.as_ref();
70
+ let config: TokenizerConfigJson = sonic_rs::from_slice(
71
+ &std::fs::read(config_path)
72
+ .with_context(|| format!("Failed to read {}", config_path.display()))?,
73
+ )
74
+ .map_err(|e| eyre::eyre!("Failed to parse {}: {e}", config_path.display()))?;
75
+ for (id, token) in &config.added_tokens_decoder {
76
+ let id = id.parse::<u32>().with_context(|| {
77
+ format!("added_tokens_decoder id {id:?} in {}", config_path.display())
78
+ })?;
79
+ ensure!(
80
+ id >= n_ranks,
81
+ "added token {:?} (id {id}) overlaps the {n_ranks} mergeable ranks",
82
+ token.content
83
+ );
84
+ tokenizer.add_special_token(token.content.clone().into_bytes(), id.into());
85
+ }
86
+ Ok(tokenizer)
87
+ }
data/src/main.rs ADDED
@@ -0,0 +1,95 @@
1
+ #![feature(portable_simd)]
2
+
3
+ mod bpe;
4
+ mod bpe_train;
5
+ mod input;
6
+ mod load_tokenizer;
7
+ mod pretokenize;
8
+ #[cfg(test)]
9
+ mod test_hub;
10
+ mod token;
11
+
12
+ use input::MmappedFile;
13
+ use input::Resource;
14
+ use input::jsonl::JsonLinesSlice;
15
+ use std::path::PathBuf;
16
+
17
+ #[allow(deprecated)]
18
+ pub fn main() {
19
+ let args: Vec<String> = std::env::args().collect();
20
+
21
+ let tokenizer_path = args.get(1).map(PathBuf::from).unwrap_or_else(|| {
22
+ PathBuf::from(concat!(
23
+ env!("CARGO_MANIFEST_DIR"),
24
+ "/tests/scripts/tinyllama_tokenizer.json"
25
+ ))
26
+ });
27
+
28
+ let input_path = args.get(2).map(PathBuf::from).unwrap_or_else(|| {
29
+ std::env::home_dir()
30
+ .unwrap()
31
+ .join("data/dclm-baseline/shard_00000000_processed.jsonl")
32
+ });
33
+
34
+ let field = args.get(3).map(|s| s.as_str()).unwrap_or("text");
35
+
36
+ eprintln!("Tokenizer: {}", tokenizer_path.display());
37
+ eprintln!("Input: {}", input_path.display());
38
+ eprintln!("Field: {}", field);
39
+
40
+ // Load tokenizer
41
+ let tokenizer = load_tokenizer::hf::load_hf_sentencepiece(&tokenizer_path)
42
+ .expect("Failed to load tokenizer");
43
+ eprintln!("Loaded tokenizer: {:?}", tokenizer);
44
+
45
+ // Mmap input file
46
+ let mmap = MmappedFile::open(&input_path).expect("Failed to open input file");
47
+ let bytes = mmap.as_bytes();
48
+ eprintln!("Input size: {:.1} MB", bytes.len() as f64 / 1e6);
49
+
50
+ // Parse JSONL to get documents
51
+ let start = std::time::Instant::now();
52
+ let docs: Vec<_> = JsonLinesSlice::new(bytes, field)
53
+ .map(|doc| {
54
+ let b = doc.as_ref();
55
+ // Convert to string for encoding
56
+ unsafe { std::str::from_utf8_unchecked(b) }.to_string()
57
+ })
58
+ .collect();
59
+ let parse_time = start.elapsed();
60
+ let total_chars: usize = docs.iter().map(|d| d.len()).sum();
61
+ let total_text_mb = total_chars as f64 / 1e6;
62
+ eprintln!(
63
+ "Parsed {} docs ({:.1} MB text) in {:.2}s",
64
+ docs.len(),
65
+ total_text_mb,
66
+ parse_time.as_secs_f64()
67
+ );
68
+ eprintln!();
69
+
70
+ // Load r50k tokenizer for GPT-2 style encoding
71
+ let r50k_path = args.get(4).map(PathBuf::from).unwrap_or_else(|| {
72
+ std::env::home_dir()
73
+ .unwrap()
74
+ .join("data/tokenizers/r50k_base.tiktoken")
75
+ });
76
+ let mut r50k =
77
+ load_tokenizer::tiktoken::load_tiktoken(&r50k_path).expect("Failed to load r50k tokenizer");
78
+ eprintln!("Loaded r50k: {:?}", r50k);
79
+
80
+ // Encode with r50k (GPT-2 style: pretokenize + memoized BPE)
81
+ let start = std::time::Instant::now();
82
+ let mut total_tokens: usize = 0;
83
+ for doc in &docs {
84
+ r50k.memoized_encode(pretokenize::pretokenize_as_iter(doc.as_bytes()), |tokens| {
85
+ total_tokens += tokens.len();
86
+ });
87
+ }
88
+ let elapsed = start.elapsed();
89
+ eprintln!(
90
+ "r50k encode: {} tokens in {:.2}s ({:.1} MB/s)",
91
+ total_tokens,
92
+ elapsed.as_secs_f64(),
93
+ total_text_mb / elapsed.as_secs_f64(),
94
+ );
95
+ }