gigatoken 0.1.1 → 0.2.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 +4 -4
- data/Cargo.lock +2 -2
- data/Cargo.toml +1 -1
- data/README.md +43 -2
- data/ext/gigatoken/Cargo.toml +1 -1
- data/ext/gigatoken/src/cache.rs +37 -0
- data/ext/gigatoken/src/lib.rs +25 -0
- data/ext/gigatoken/src/sentencepiece.rs +12 -1
- data/ext/gigatoken/src/tokenizer.rs +33 -4
- data/lib/gigatoken/cli/bench.rb +4 -3
- data/lib/gigatoken/cli/support.rb +7 -4
- data/lib/gigatoken/cli/validate.rb +4 -3
- data/lib/gigatoken/encodings/PROVENANCE.md +110 -0
- data/lib/gigatoken/encodings/cl100k_base.tiktoken +100256 -0
- data/lib/gigatoken/encodings/o200k_base.tiktoken +199998 -0
- data/lib/gigatoken/encodings/r50k_base.tiktoken +50256 -0
- data/lib/gigatoken/encodings.rb +103 -0
- data/lib/gigatoken/tokenizer.rb +48 -11
- data/lib/gigatoken/version.rb +1 -1
- data/lib/gigatoken.rb +15 -0
- data/src/bindings/cache.rs +38 -0
- data/src/bindings/mod.rs +3 -1
- data/src/bindings/pretokenize.rs +11 -0
- data/src/bpe/pretoken_cache.rs +32 -1
- data/src/bpe/sentencepiece.rs +137 -5
- data/src/bpe/tiktoken.rs +784 -31
- data/src/lib.rs +52 -35
- data/src/load_tokenizer/hf.rs +1 -0
- data/src/load_tokenizer/tiktoken.rs +17 -49
- data/src/main.rs +6 -2
- data/src/pretokenize/options.rs +16 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 071b3abb52387367f392e88e50d92c7413f5f6c70dae24accf6d378ad6e78fd8
|
|
4
|
+
data.tar.gz: 26da76745e877b6ee30821ab5fcae50aefdb6ecddcd660e9b8c7b48b4d94bd06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 402d1e74262ffd0d4ff8d6f6162c944747b75c567249f788fdf46d2c5e09cd001f1cd66d037156848023fb5aad62087c73def5c4d3134d2aba7a898575ce2631
|
|
7
|
+
data.tar.gz: c43c48249a2c7bd0d8146e20d830975dae6e3acec93b1bc42e125fdd72050da38d02d0a10068b6ad208c85bd1790a54629b3b2394c1b12762feddce834909995
|
data/Cargo.lock
CHANGED
|
@@ -735,7 +735,7 @@ dependencies = [
|
|
|
735
735
|
|
|
736
736
|
[[package]]
|
|
737
737
|
name = "gigatoken"
|
|
738
|
-
version = "0.
|
|
738
|
+
version = "0.10.0"
|
|
739
739
|
dependencies = [
|
|
740
740
|
"aho-corasick",
|
|
741
741
|
"arrow-array",
|
|
@@ -771,7 +771,7 @@ dependencies = [
|
|
|
771
771
|
|
|
772
772
|
[[package]]
|
|
773
773
|
name = "gigatoken-rb"
|
|
774
|
-
version = "0.
|
|
774
|
+
version = "0.2.0"
|
|
775
775
|
dependencies = [
|
|
776
776
|
"gigatoken",
|
|
777
777
|
"magnus",
|
data/Cargo.toml
CHANGED
data/README.md
CHANGED
|
@@ -48,17 +48,50 @@ tok.vocab_size # => 50257
|
|
|
48
48
|
tok.special_tokens # => {"<|endoftext|>" => 50256}
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`load` takes a `tokenizer.json` path, a directory holding one, a HuggingFace Hub repo id, or a `.tiktoken` mergeable-ranks file, and dispatches on shape. Hub downloads run over socketry's `async-http` — no Python anywhere. Know what you have? Skip the dispatch:
|
|
51
|
+
`load` takes a `tokenizer.json` path, a directory holding one, a packaged tiktoken encoding name (`r50k_base`, `cl100k_base`, `o200k_base`), a HuggingFace Hub repo id, or a `.tiktoken` mergeable-ranks file, and dispatches on shape. Hub downloads run over socketry's `async-http` — no Python anywhere. Know what you have? Skip the dispatch:
|
|
52
52
|
|
|
53
53
|
```ruby
|
|
54
54
|
Gigatoken::Tokenizer.from_file("tokenizer.json")
|
|
55
55
|
Gigatoken::Tokenizer.from_hub("openai-community/gpt2", revision: "main")
|
|
56
|
-
Gigatoken::Tokenizer.from_tiktoken("
|
|
56
|
+
Gigatoken::Tokenizer.from_tiktoken("cl100k_base.tiktoken", pretokenizer: "gpt4", special_tokens: {"<|endoftext|>" => 100257})
|
|
57
57
|
Gigatoken::Tokenizer.from_json(File.binread("tokenizer.json"))
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
A `.tiktoken` file holds mergeable ranks only — its pretokenization scheme and special tokens live in the code that defines the encoding, not the file — so `pretokenizer:` is a required keyword (one of `Gigatoken::Native.pretokenizer_names`: `gpt2`/`r50k`, `gpt4`/`cl100k`, `qwen2`, `qwen35`, `olmo3`, `deepseek_v3`, `o200k`, `nemotron`, `kimi`) and `special_tokens:` defaults to none. Nothing is guessed: an unknown scheme raises `Gigatoken::Error` naming the valid ones, and `Tokenizer.load` on a `.tiktoken` path with no `pretokenizer:` raises rather than silently picking one.
|
|
61
|
+
|
|
60
62
|
SentencePiece-BPE models (Llama, Gemma, Mistral — any `tokenizer.json` with `byte_fallback: true`) load through the same entry points and pick the right backend automatically. One difference: the SentencePiece core decodes text, so it validates input and raises `Gigatoken::Error` on invalid UTF-8 instead of guessing.
|
|
61
63
|
|
|
64
|
+
### Packaged tiktoken encodings
|
|
65
|
+
|
|
66
|
+
`r50k_base`, `cl100k_base`, `o200k_base`, and `o200k_harmony` are vendored directly — mergeable ranks, pretokenizer scheme, and special-token table all shipped inside the gem (`lib/gigatoken/encodings/`; see `PROVENANCE.md` there for exact source URLs and hashes) — so all four resolve by name through both entry points entirely offline: no network access, no writable cache directory. `o200k_harmony` vendors no new file at all: it reuses `o200k_base.tiktoken`'s ranks and the `o200k` scheme verbatim, differing only in its special-token table (10 named control tokens — `<|start|>`, `<|message|>`, `<|end|>`, `<|return|>`, and so on — plus 1081 reserved slots; see `PROVENANCE.md` for the exact table). It's also the one packaged encoding not checked against `tiktoken_ruby`: that gem's 0.0.17 harmony table drops `<|endofprompt|>` where `openai/tiktoken` 0.9.0 keeps it at id 200018, so the oracle is the outlier here — `spec/gigatoken/differential_spec.rb` proves harmony instead by reduction to `o200k_base` plus a pinned special-token table.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
Gigatoken::Tokenizer.from_encoding("cl100k_base")
|
|
70
|
+
Gigatoken::Tokenizer.load("cl100k_base") # same result — packaged names are
|
|
71
|
+
# checked before the Hub-repo-id shape
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`p50k_base` and `p50k_edit` are deliberately not packaged: both load the same non-dense ranks (id 50256 is left free for `<|endoftext|>`), and the rank loader rejects non-dense ranks. Both entry points raise `Gigatoken::Error` explaining that, rather than `load` falling through to the Hub for a name that happens to look like a legacy repo id.
|
|
75
|
+
|
|
76
|
+
`encode` on a packaged tokenizer honours its special-token table: text containing `<|endoftext|>` (or any other literal special-token string) is tokenized as that special token, not as ordinary text. That matches [`tiktoken`](https://github.com/openai/tiktoken)'s `encode_with_special_tokens`, not its plain `encode`, which treats the same literal as ordinary text — a difference worth knowing if you're tokenizing untrusted input. To get tiktoken's non-honouring default instead, build a tokenizer from the same rank file with an empty special-token table:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
entry = Gigatoken::Encodings["cl100k_base"]
|
|
80
|
+
Gigatoken::Tokenizer.from_tiktoken(entry[:rank_file], pretokenizer: entry[:pretokenizer], special_tokens: {})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Encode-cache budget
|
|
84
|
+
|
|
85
|
+
Each tokenizer's pretoken cache is capped process-globally (512 MiB per worker by default) so long-lived processes — a Rails worker, say — don't grow it unbounded; a full cache wipes back toward its seed level and refills, which costs a bit of re-computation but never changes encode output. Tune it before building tokenizers you want the new budget to apply to:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Gigatoken.max_cache_bytes # => 536870912 (512 MiB)
|
|
89
|
+
Gigatoken.max_cache_bytes = 64 << 20 # only tokenizers built after this see the new budget
|
|
90
|
+
Gigatoken.max_cache_bytes = nil # unbounded
|
|
91
|
+
|
|
92
|
+
tok.cache_entries # => cached pretoken/unit count right now
|
|
93
|
+
```
|
|
94
|
+
|
|
62
95
|
### Tokenize files without leaving Rust
|
|
63
96
|
|
|
64
97
|
`encode_files` reads and tokenizes files entirely on the native side — document contents never materialize as Ruby objects. `.gz` and `.zst` decompress transparently.
|
|
@@ -97,6 +130,14 @@ gigatoken validate openai-community/gpt2 owt_train.txt --doc-separator "<|endoft
|
|
|
97
130
|
|
|
98
131
|
`bench` reports MB/s and Mtok/s (`--packed` for the fused packed path, `--no-parallel` for the serial core). `validate` confirms native split-and-encode agrees with a Ruby-side split through `encode_batch`.
|
|
99
132
|
|
|
133
|
+
TOKENIZER also takes a bare `.tiktoken` file, which is where `--pretokenizer` comes in: the file carries mergeable ranks only, so the split regex has to come from the caller, same as `from_tiktoken` above. `--pretokenizer` takes one of the scheme names listed above for `pretokenizer:`:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
gigatoken bench lib/gigatoken/encodings/cl100k_base.tiktoken README.md --pretokenizer gpt4
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Leave it off against a `.tiktoken` TOKENIZER and both commands raise `Gigatoken::Error` naming the valid schemes instead of crashing; for every other TOKENIZER shape (`tokenizer.json`, a packaged name, a Hub repo id) `--pretokenizer` is accepted but ignored.
|
|
140
|
+
|
|
100
141
|
## Development
|
|
101
142
|
|
|
102
143
|
```bash
|
data/ext/gigatoken/Cargo.toml
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//! The process-global cache-budget knob, read once per tokenizer
|
|
2
|
+
//! construction. Mirrors the pyo3 mechanism in the core crate's
|
|
3
|
+
//! `src/bindings/cache.rs`, which is `pub(crate)` in a different crate and
|
|
4
|
+
//! so cannot be called directly from here — the encode-cache semantics
|
|
5
|
+
//! themselves live in `gigatoken_rs::Tokenizer::set_max_cache_bytes` and
|
|
6
|
+
//! `gigatoken_rs::SentencePieceBPE::set_max_cache_bytes`.
|
|
7
|
+
|
|
8
|
+
use gigatoken_rs::{SentencePieceBPE, Tokenizer};
|
|
9
|
+
use std::sync::Mutex;
|
|
10
|
+
|
|
11
|
+
/// The budget applied to tokenizers constructed after the last
|
|
12
|
+
/// `set_max_cache_bytes` call; `None` = unbounded.
|
|
13
|
+
static MAX_CACHE_BYTES: Mutex<Option<usize>> = Mutex::new(Some(Tokenizer::DEFAULT_MAX_CACHE_BYTES));
|
|
14
|
+
|
|
15
|
+
/// Apply the global setting to a freshly constructed tokenizer (which
|
|
16
|
+
/// already carries the built-in default, hence the `!=` skip).
|
|
17
|
+
pub(crate) fn apply_max_cache_bytes(mut tokenizer: Tokenizer) -> Tokenizer {
|
|
18
|
+
let configured = *MAX_CACHE_BYTES.lock().unwrap();
|
|
19
|
+
if configured != tokenizer.max_cache_bytes() {
|
|
20
|
+
tokenizer.set_max_cache_bytes(configured);
|
|
21
|
+
}
|
|
22
|
+
tokenizer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// SentencePiece analog of [`apply_max_cache_bytes`].
|
|
26
|
+
pub(crate) fn apply_max_cache_bytes_sp(mut model: SentencePieceBPE) -> SentencePieceBPE {
|
|
27
|
+
model.set_max_cache_bytes(*MAX_CACHE_BYTES.lock().unwrap());
|
|
28
|
+
model
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub(crate) fn set_max_cache_bytes(max_bytes: Option<usize>) {
|
|
32
|
+
*MAX_CACHE_BYTES.lock().unwrap() = max_bytes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pub(crate) fn get_max_cache_bytes() -> Option<usize> {
|
|
36
|
+
*MAX_CACHE_BYTES.lock().unwrap()
|
|
37
|
+
}
|
data/ext/gigatoken/src/lib.rs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use gigatoken_rs::load_tokenizer::hf::{self, HfTokenizer};
|
|
2
|
+
use gigatoken_rs::pretokenize::PretokenizerType;
|
|
2
3
|
use magnus::{Error, Module, RString, Ruby, Value, function};
|
|
3
4
|
|
|
4
5
|
// XZM-WORKAROUND: macOS 26's xzm malloc zone SIGTRAPs on multi-GB Rust chunk
|
|
@@ -7,6 +8,7 @@ use magnus::{Error, Module, RString, Ruby, Value, function};
|
|
|
7
8
|
#[global_allocator]
|
|
8
9
|
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|
9
10
|
|
|
11
|
+
mod cache;
|
|
10
12
|
mod error;
|
|
11
13
|
mod gvl;
|
|
12
14
|
mod sentencepiece;
|
|
@@ -23,6 +25,26 @@ fn crate_version() -> String {
|
|
|
23
25
|
env!("CARGO_PKG_VERSION").to_string()
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/// The pretokenizer scheme names `BPETokenizer.from_tiktoken` and
|
|
29
|
+
/// `Tokenizer.load` accept — the single source of truth `Gigatoken::Tokenizer.load`
|
|
30
|
+
/// names in its "no scheme for a .tiktoken path" error, so that list can
|
|
31
|
+
/// never drift from the one `PretokenizerType::from_name` actually accepts.
|
|
32
|
+
fn pretokenizer_names() -> Vec<&'static str> {
|
|
33
|
+
PretokenizerType::NAMES.to_vec()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// The process-global encode-cache budget in bytes per worker, applied to
|
|
37
|
+
/// tokenizers constructed afterward; `None` removes the bound. Mirrors
|
|
38
|
+
/// pyo3's `set_max_cache_bytes`/`get_max_cache_bytes` (`src/bindings/cache.rs`
|
|
39
|
+
/// in the core crate) over the same public core API.
|
|
40
|
+
fn set_max_cache_bytes(max_bytes: Option<usize>) {
|
|
41
|
+
cache::set_max_cache_bytes(max_bytes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn get_max_cache_bytes() -> Option<usize> {
|
|
45
|
+
cache::get_max_cache_bytes()
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/// Load a tokenizer from in-memory HuggingFace `tokenizer.json` contents.
|
|
27
49
|
/// Returns a `SentencePieceTokenizer` when the model uses `byte_fallback`, a
|
|
28
50
|
/// `BPETokenizer` otherwise — the same split as pyo3's `load_hf_json` and
|
|
@@ -43,6 +65,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
43
65
|
let native = gigatoken.define_module("Native")?;
|
|
44
66
|
native.define_module_function("crate_version", function!(crate_version, 0))?;
|
|
45
67
|
native.define_module_function("load_hf_json", function!(load_hf_json, 1))?;
|
|
68
|
+
native.define_module_function("pretokenizer_names", function!(pretokenizer_names, 0))?;
|
|
69
|
+
native.define_module_function("set_max_cache_bytes", function!(set_max_cache_bytes, 1))?;
|
|
70
|
+
native.define_module_function("get_max_cache_bytes", function!(get_max_cache_bytes, 0))?;
|
|
46
71
|
sources::init(ruby, native)?;
|
|
47
72
|
tokenizer::init(ruby, native)?;
|
|
48
73
|
sentencepiece::init(ruby, native)?;
|
|
@@ -42,10 +42,14 @@ pub struct SentencePieceTokenizer {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
impl SentencePieceTokenizer {
|
|
45
|
+
/// Wrap a loaded model, applying the global cache budget to it and to
|
|
46
|
+
/// the single-document encode state — mirrors pyo3's `with_model`.
|
|
45
47
|
pub(crate) fn from_tokenizer(tokenizer: SentencePieceBPE) -> Self {
|
|
48
|
+
let tokenizer = crate::cache::apply_max_cache_bytes_sp(tokenizer);
|
|
49
|
+
let state = EncodeState::with_budget(tokenizer.max_cache_bytes());
|
|
46
50
|
Self {
|
|
47
51
|
tokenizer: RefCell::new(tokenizer),
|
|
48
|
-
state: RefCell::new(
|
|
52
|
+
state: RefCell::new(state),
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -188,6 +192,12 @@ impl SentencePieceTokenizer {
|
|
|
188
192
|
}
|
|
189
193
|
Ok(result)
|
|
190
194
|
}
|
|
195
|
+
|
|
196
|
+
/// Cached unit entries on the single-document `encode` path's state
|
|
197
|
+
/// (batch encoders are per-call); see `BPETokenizer::cache_entries`.
|
|
198
|
+
fn cache_entries(&self) -> usize {
|
|
199
|
+
self.state.borrow().cache_size()
|
|
200
|
+
}
|
|
191
201
|
}
|
|
192
202
|
|
|
193
203
|
pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
@@ -201,5 +211,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
|
201
211
|
class.define_method("vocab_size", method!(SentencePieceTokenizer::vocab_size, 0))?;
|
|
202
212
|
class.define_method("vocab", method!(SentencePieceTokenizer::vocab, 0))?;
|
|
203
213
|
class.define_method("merges", method!(SentencePieceTokenizer::merges, 0))?;
|
|
214
|
+
class.define_method("cache_entries", method!(SentencePieceTokenizer::cache_entries, 0))?;
|
|
204
215
|
Ok(())
|
|
205
216
|
}
|
|
@@ -9,6 +9,7 @@ use std::os::raw::c_long;
|
|
|
9
9
|
|
|
10
10
|
use gigatoken_rs::load_tokenizer::hf::HfTokenizer;
|
|
11
11
|
use gigatoken_rs::load_tokenizer::{hf, tiktoken};
|
|
12
|
+
use gigatoken_rs::pretokenize::PretokenizerType;
|
|
12
13
|
use gigatoken_rs::{
|
|
13
14
|
GatherBuf, GatherOutcome, Tokenizer, WorkerPool, encode_docs_into, encode_docs_ragged,
|
|
14
15
|
encode_files_docs, encode_files_docs_serial,
|
|
@@ -359,7 +360,7 @@ pub struct BPETokenizer {
|
|
|
359
360
|
impl BPETokenizer {
|
|
360
361
|
pub(crate) fn from_tokenizer(tokenizer: Tokenizer) -> Self {
|
|
361
362
|
Self {
|
|
362
|
-
tokenizer: RefCell::new(tokenizer),
|
|
363
|
+
tokenizer: RefCell::new(crate::cache::apply_max_cache_bytes(tokenizer)),
|
|
363
364
|
workers: WorkerPool::new(),
|
|
364
365
|
}
|
|
365
366
|
}
|
|
@@ -379,8 +380,28 @@ impl BPETokenizer {
|
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
|
|
383
|
-
|
|
383
|
+
/// Load from a .tiktoken rank file with the named pretokenizer scheme
|
|
384
|
+
/// and a {content => id} mapping of special tokens. The file carries
|
|
385
|
+
/// neither, so both are the caller's to supply — see
|
|
386
|
+
/// `Gigatoken::Tokenizer.from_tiktoken`, which knows them for the
|
|
387
|
+
/// encodings OpenAI publishes.
|
|
388
|
+
fn from_tiktoken(
|
|
389
|
+
ruby: &Ruby,
|
|
390
|
+
path: String,
|
|
391
|
+
pretokenizer: String,
|
|
392
|
+
special_tokens: HashMap<String, u32>,
|
|
393
|
+
) -> Result<Self, Error> {
|
|
394
|
+
let scheme = PretokenizerType::from_name(&pretokenizer).ok_or_else(|| {
|
|
395
|
+
raise(
|
|
396
|
+
ruby,
|
|
397
|
+
format!(
|
|
398
|
+
"unknown pretokenizer scheme {pretokenizer:?}; expected one of {}",
|
|
399
|
+
PretokenizerType::NAMES.join(", ")
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
})?;
|
|
403
|
+
let special_tokens: Vec<(String, u32)> = special_tokens.into_iter().collect();
|
|
404
|
+
match tiktoken::load_tiktoken(&path, scheme, special_tokens) {
|
|
384
405
|
Ok(tokenizer) => Ok(Self::from_tokenizer(tokenizer)),
|
|
385
406
|
Err(e) => Err(raise(ruby, e.to_string())),
|
|
386
407
|
}
|
|
@@ -552,12 +573,19 @@ impl BPETokenizer {
|
|
|
552
573
|
}
|
|
553
574
|
Ok(result)
|
|
554
575
|
}
|
|
576
|
+
|
|
577
|
+
/// Cached pretoken entries on this tokenizer: grows as text is encoded,
|
|
578
|
+
/// drops back toward vocab-seed level when a budgeted cache wipes (see
|
|
579
|
+
/// `Gigatoken.max_cache_bytes`).
|
|
580
|
+
fn cache_entries(&self) -> usize {
|
|
581
|
+
self.tokenizer.borrow().cache_entries()
|
|
582
|
+
}
|
|
555
583
|
}
|
|
556
584
|
|
|
557
585
|
pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
558
586
|
let class: RClass = native.define_class("BPETokenizer", ruby.class_object())?;
|
|
559
587
|
class.define_singleton_method("from_hf_json", function!(BPETokenizer::from_hf_json, 1))?;
|
|
560
|
-
class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken,
|
|
588
|
+
class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken, 3))?;
|
|
561
589
|
class.define_method("encode", method!(BPETokenizer::encode, 1))?;
|
|
562
590
|
class.define_method("encode_batch", method!(BPETokenizer::encode_batch, 1))?;
|
|
563
591
|
class.define_method("encode_batch_packed", method!(BPETokenizer::encode_batch_packed, 1))?;
|
|
@@ -567,5 +595,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
|
567
595
|
class.define_method("vocab_size", method!(BPETokenizer::vocab_size, 0))?;
|
|
568
596
|
class.define_method("vocab", method!(BPETokenizer::vocab, 0))?;
|
|
569
597
|
class.define_method("merges", method!(BPETokenizer::merges, 0))?;
|
|
598
|
+
class.define_method("cache_entries", method!(BPETokenizer::cache_entries, 0))?;
|
|
570
599
|
Ok(())
|
|
571
600
|
}
|
data/lib/gigatoken/cli/bench.rb
CHANGED
|
@@ -10,19 +10,20 @@ module Gigatoken
|
|
|
10
10
|
class Bench < Dry::CLI::Command
|
|
11
11
|
desc "Measure the time to encode FILES with TOKENIZER"
|
|
12
12
|
|
|
13
|
-
argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
|
|
13
|
+
argument :tokenizer, required: true, desc: "tokenizer.json path or directory, packaged encoding name (e.g. \"cl100k_base\"), HuggingFace repo id, or .tiktoken file (requires --pretokenizer)"
|
|
14
14
|
argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
|
|
15
15
|
|
|
16
16
|
option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
|
|
17
17
|
option :limit_bytes, default: "none", desc: "cap the bytes benchmarked, e.g. 100MB; 'none' for everything (parallel mode only — ignored with --no-parallel)"
|
|
18
18
|
option :parallel, type: :boolean, default: true, desc: "encode on the worker pool instead of the fused serial core path"
|
|
19
19
|
option :packed, type: :boolean, default: false, desc: "time the fused native file path with a packed IO::Buffer result instead of per-document Ruby arrays (ignores --limit-bytes)"
|
|
20
|
+
option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
|
|
20
21
|
|
|
21
|
-
def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, **)
|
|
22
|
+
def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, pretokenizer: nil, **)
|
|
22
23
|
limit = Support.parse_size(limit_bytes)
|
|
23
24
|
out.puts "#{label("cpu")}: #{Support.cpu_info}"
|
|
24
25
|
|
|
25
|
-
gt_tokenizer = Support.load_tokenizer(tokenizer)
|
|
26
|
+
gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
|
|
26
27
|
|
|
27
28
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
29
|
if packed
|
|
@@ -14,10 +14,13 @@ module Gigatoken
|
|
|
14
14
|
private_constant :SIZE_PATTERN
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
|
-
# Load TOKENIZER: a tokenizer.json path/directory, a
|
|
18
|
-
# repo id, or a .tiktoken file
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Load TOKENIZER: a tokenizer.json path/directory, a packaged
|
|
18
|
+
# tiktoken encoding name, a HuggingFace repo id, or a .tiktoken file
|
|
19
|
+
# — see Gigatoken::Tokenizer.load. `pretokenizer:` is forwarded
|
|
20
|
+
# as-is; it's required for a bare .tiktoken path (which carries no
|
|
21
|
+
# scheme of its own) and ignored for the other shapes.
|
|
22
|
+
def load_tokenizer(spec, pretokenizer: nil)
|
|
23
|
+
Gigatoken::Tokenizer.load(spec, pretokenizer: pretokenizer)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
# Parse a decimal byte size like "100MB", "2.5GB", or "1000000";
|
|
@@ -13,13 +13,14 @@ module Gigatoken
|
|
|
13
13
|
class Validate < Dry::CLI::Command
|
|
14
14
|
desc "Check that encode_files agrees with a Ruby-side split plus encode_batch on FILES"
|
|
15
15
|
|
|
16
|
-
argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
|
|
16
|
+
argument :tokenizer, required: true, desc: "tokenizer.json path or directory, packaged encoding name (e.g. \"cl100k_base\"), HuggingFace repo id, or .tiktoken file (requires --pretokenizer)"
|
|
17
17
|
argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
|
|
18
18
|
|
|
19
19
|
option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
|
|
20
|
+
option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
|
|
20
21
|
|
|
21
|
-
def call(tokenizer:, files:, doc_separator: nil, **)
|
|
22
|
-
gt_tokenizer = Support.load_tokenizer(tokenizer)
|
|
22
|
+
def call(tokenizer:, files:, doc_separator: nil, pretokenizer: nil, **)
|
|
23
|
+
gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
|
|
23
24
|
|
|
24
25
|
via_files = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator))
|
|
25
26
|
via_batch = gt_tokenizer.encode_batch(Support.split_docs(files, doc_separator))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Vendored tiktoken encodings — provenance
|
|
2
|
+
|
|
3
|
+
The `.tiktoken` files in this directory are OpenAI's published BPE mergeable-rank
|
|
4
|
+
tables, vendored verbatim so `gigatoken` can resolve these encodings by name with
|
|
5
|
+
no network access and no writable cache directory.
|
|
6
|
+
|
|
7
|
+
Each file is a plain text table: one `base64(token_bytes) rank` pair per line. It
|
|
8
|
+
carries **mergeable ranks only** — the pretokenizer split regex and the special
|
|
9
|
+
tokens belong to the encoding's *definition*, not to the file, and live in code
|
|
10
|
+
(see the table below).
|
|
11
|
+
|
|
12
|
+
They live under `lib/` rather than a top-level `data/` directory because this
|
|
13
|
+
repo's `.gitignore` ignores `/data/` ("downloaded test data"); these are shipped
|
|
14
|
+
gem payload, not test fixtures.
|
|
15
|
+
|
|
16
|
+
## Files
|
|
17
|
+
|
|
18
|
+
| File | Bytes | sha256 |
|
|
19
|
+
|------|------:|--------|
|
|
20
|
+
| `r50k_base.tiktoken` | 835,554 | `306cd27f03c1a714eca7108e03d66b7dc042abe8c258b44c199a7ed9838dd930` |
|
|
21
|
+
| `cl100k_base.tiktoken` | 1,681,126 | `223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7` |
|
|
22
|
+
| `o200k_base.tiktoken` | 3,613,922 | `446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d` |
|
|
23
|
+
|
|
24
|
+
## Source
|
|
25
|
+
|
|
26
|
+
Retrieved **2026-08-10** over HTTPS from OpenAI's public encodings endpoint:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
https://openaipublic.blob.core.windows.net/encodings/r50k_base.tiktoken
|
|
30
|
+
https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken
|
|
31
|
+
https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
These are the same URLs `openai/tiktoken` itself fetches from, in
|
|
35
|
+
[`tiktoken_ext/openai_public.py`](https://github.com/openai/tiktoken/blob/main/tiktoken_ext/openai_public.py).
|
|
36
|
+
|
|
37
|
+
## Authenticity
|
|
38
|
+
|
|
39
|
+
Verified three independent ways at retrieval time:
|
|
40
|
+
|
|
41
|
+
1. **Transport** — HTTPS directly from `openaipublic.blob.core.windows.net`, the
|
|
42
|
+
origin OpenAI publishes and `tiktoken` itself downloads from.
|
|
43
|
+
2. **Publisher checksum** — each measured sha256 above matches the `expected_hash`
|
|
44
|
+
OpenAI publishes for that file in `openai_public.py`, fetched separately from
|
|
45
|
+
`github.com/openai/tiktoken`. Two independent channels agree on the bytes.
|
|
46
|
+
3. **Behavioral, against a third-party implementation** — loaded through
|
|
47
|
+
`gigatoken` with the pretokenizer and special tokens below, every token id
|
|
48
|
+
matched [`tiktoken_ruby`](https://github.com/IAPark/tiktoken_ruby) 0.0.17
|
|
49
|
+
(which embeds its own copy of these ranks) across a corpus covering ASCII,
|
|
50
|
+
CJK, ZWJ emoji sequences, combining accents, whitespace runs, source code,
|
|
51
|
+
and URLs. Resulting `vocab_size`: 50257 / 100277 / 200019.
|
|
52
|
+
|
|
53
|
+
## Encoding definitions
|
|
54
|
+
|
|
55
|
+
Transcribed from `openai_public.py` (each encoding's `pat_str` and
|
|
56
|
+
`special_tokens`), cross-checked against upstream gigatoken's own port in
|
|
57
|
+
`gigatoken/_load/tiktoken.py`. The scheme names are `PretokenizerType::NAMES`
|
|
58
|
+
values (`src/pretokenize/options.rs`).
|
|
59
|
+
|
|
60
|
+
| Encoding | Pretokenizer scheme | Special tokens |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `r50k_base` | `gpt2` | `<\|endoftext\|>`=50256 |
|
|
63
|
+
| `cl100k_base` | `gpt4` | `<\|endoftext\|>`=100257, `<\|fim_prefix\|>`=100258, `<\|fim_middle\|>`=100259, `<\|fim_suffix\|>`=100260, `<\|endofprompt\|>`=100276 |
|
|
64
|
+
| `o200k_base` | `o200k` | `<\|endoftext\|>`=199999, `<\|endofprompt\|>`=200018 |
|
|
65
|
+
|
|
66
|
+
`p50k_base` is deliberately absent: its ranks are not dense (50256 is left free
|
|
67
|
+
for `<|endoftext|>`), and the rank loader rejects non-dense ranks with
|
|
68
|
+
`"ranks must be dense"`. `p50k_edit` loads the same `p50k_base.tiktoken` ranks
|
|
69
|
+
and is absent for the identical reason.
|
|
70
|
+
|
|
71
|
+
## `o200k_harmony`
|
|
72
|
+
|
|
73
|
+
Packaged with **no new vendored file**: it reuses `o200k_base.tiktoken`'s
|
|
74
|
+
mergeable ranks and the `o200k` pretokenizer scheme verbatim. Confirmed
|
|
75
|
+
against `openai/tiktoken` 0.9.0 that `o200k_harmony()` in `openai_public.py`
|
|
76
|
+
calls `mergeable_ranks` with the same rank file and uses the same `pat_str` as
|
|
77
|
+
`o200k_base()` — only the special-token table differs.
|
|
78
|
+
|
|
79
|
+
That table (1091 entries: 10 named control tokens, 1081
|
|
80
|
+
`<|reserved_N|>` slots) is transcribed, not derived, from `o200k_harmony()`:
|
|
81
|
+
base specials `<|endoftext|>` 199999 and `<|endofprompt|>` 200018, then
|
|
82
|
+
`<|startoftext|>` 199998, `<|endoftext|>` 199999, `<|reserved_200000|>`
|
|
83
|
+
200000, `<|reserved_200001|>` 200001, `<|return|>` 200002, `<|constrain|>`
|
|
84
|
+
200003, `<|reserved_200004|>` 200004, `<|channel|>` 200005, `<|start|>`
|
|
85
|
+
200006, `<|end|>` 200007, `<|message|>` 200008, `<|reserved_200009|>` 200009,
|
|
86
|
+
`<|reserved_200010|>` 200010, `<|reserved_200011|>` 200011, `<|call|>`
|
|
87
|
+
200012, then `<|reserved_N|>` for `N` in `200013..201087`. The reserved range
|
|
88
|
+
is **not** contiguous from 200000 — the named control tokens sit inside
|
|
89
|
+
200000..200012, leaving reserved slots only at `{200000, 200001, 200004,
|
|
90
|
+
200009, 200010, 200011} ∪ [200013, 201087]`. A table built as "200000..201087
|
|
91
|
+
minus the named ids" invents `<|reserved_200002|>` and drops
|
|
92
|
+
`<|reserved_200018|>`; the transcription above avoids both.
|
|
93
|
+
|
|
94
|
+
**Not verified against `tiktoken_ruby`** (unlike the three encodings above):
|
|
95
|
+
`tiktoken_ruby` 0.0.17's own `o200k_harmony` table drops `<|endofprompt|>` —
|
|
96
|
+
it encodes the literal as six ordinary-text tokens rather than `[200018]` —
|
|
97
|
+
while treating `<|reserved_200018|>` as the sole literal at that id.
|
|
98
|
+
`openai/tiktoken` 0.9.0 keeps both `<|endofprompt|>` and
|
|
99
|
+
`<|reserved_200018|>`, at the same id, as Python dict construction preserves
|
|
100
|
+
both keys. `openai/tiktoken` is authoritative here; `tiktoken_ruby` is the
|
|
101
|
+
outlier. See `spec/gigatoken/differential_spec.rb` for the measurement and
|
|
102
|
+
the reduction-plus-pinning proof used in place of a `tiktoken_ruby`
|
|
103
|
+
comparison.
|
|
104
|
+
|
|
105
|
+
## Licence
|
|
106
|
+
|
|
107
|
+
The `tiktoken` project and its published encoding files are MIT licensed,
|
|
108
|
+
Copyright (c) 2022 OpenAI, Shantanu Jain. See
|
|
109
|
+
<https://github.com/openai/tiktoken/blob/main/LICENSE>. The files are vendored
|
|
110
|
+
here unmodified.
|