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.
- checksums.yaml +7 -0
- data/Cargo.lock +3016 -0
- data/Cargo.toml +135 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/gigatoken +9 -0
- data/ext/gigatoken/Cargo.toml +24 -0
- data/ext/gigatoken/extconf.rb +19 -0
- data/ext/gigatoken/src/error.rs +20 -0
- data/ext/gigatoken/src/gvl.rs +122 -0
- data/ext/gigatoken/src/lib.rs +50 -0
- data/ext/gigatoken/src/sentencepiece.rs +205 -0
- data/ext/gigatoken/src/sources.rs +207 -0
- data/ext/gigatoken/src/tokenizer.rs +571 -0
- data/lib/gigatoken/cli/bench.rb +64 -0
- data/lib/gigatoken/cli/support.rb +132 -0
- data/lib/gigatoken/cli/validate.rb +55 -0
- data/lib/gigatoken/cli.rb +18 -0
- data/lib/gigatoken/hub.rb +242 -0
- data/lib/gigatoken/packed_result.rb +48 -0
- data/lib/gigatoken/tokenizer.rb +117 -0
- data/lib/gigatoken/version.rb +5 -0
- data/lib/gigatoken.rb +29 -0
- data/rust-toolchain.toml +8 -0
- data/src/batch.rs +1808 -0
- data/src/bindings/bridge.rs +396 -0
- data/src/bindings/hub.rs +42 -0
- data/src/bindings/matcher.rs +114 -0
- data/src/bindings/mod.rs +14 -0
- data/src/bindings/padding.rs +177 -0
- data/src/bindings/pretokenize.rs +53 -0
- data/src/bindings/sources.rs +273 -0
- data/src/bindings/train.rs +125 -0
- data/src/bpe/mod.rs +1217 -0
- data/src/bpe/pretoken_cache.rs +495 -0
- data/src/bpe/sentencepiece.rs +1485 -0
- data/src/bpe/tiktoken.rs +2555 -0
- data/src/bpe_train.rs +351 -0
- data/src/input/decompress.rs +11 -0
- data/src/input/file_source.rs +514 -0
- data/src/input/jsonl.rs +94 -0
- data/src/input/mod.rs +333 -0
- data/src/input/parquet.rs +303 -0
- data/src/lib.rs +578 -0
- data/src/load_tokenizer/hf.rs +1036 -0
- data/src/load_tokenizer/hub.rs +344 -0
- data/src/load_tokenizer/mod.rs +3 -0
- data/src/load_tokenizer/tiktoken.rs +87 -0
- data/src/main.rs +95 -0
- data/src/pretokenize/fast/cl100k.rs +426 -0
- data/src/pretokenize/fast/cl100k_family.rs +891 -0
- data/src/pretokenize/fast/deepseek_v3.rs +605 -0
- data/src/pretokenize/fast/kimi.rs +281 -0
- data/src/pretokenize/fast/mask.rs +1486 -0
- data/src/pretokenize/fast/mod.rs +446 -0
- data/src/pretokenize/fast/nemotron.rs +138 -0
- data/src/pretokenize/fast/o200k.rs +347 -0
- data/src/pretokenize/fast/o200k_family.rs +1734 -0
- data/src/pretokenize/fast/olmo3.rs +505 -0
- data/src/pretokenize/fast/qwen2.rs +429 -0
- data/src/pretokenize/fast/qwen3_5.rs +541 -0
- data/src/pretokenize/fast/r50k.rs +1250 -0
- data/src/pretokenize/mod.rs +1079 -0
- data/src/pretokenize/options.rs +188 -0
- data/src/pretokenize/pretoken.rs +20 -0
- data/src/pretokenize/pretokenize_traits.rs +49 -0
- data/src/pretokenize/reference/avx512.rs +522 -0
- data/src/pretokenize/reference/combinator.rs +572 -0
- data/src/pretokenize/reference/mod.rs +28 -0
- data/src/pretokenize/reference/simd.rs +852 -0
- data/src/pretokenize/reference/state_machine.rs +365 -0
- data/src/pretokenize/unicode.rs +546 -0
- data/src/test_hub.rs +28 -0
- data/src/token.rs +42 -0
- 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,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
|
+
}
|