gigatoken 0.1.1 → 0.2.1
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 +29 -17
- data/ext/gigatoken/src/tokenizer.rs +116 -20
- 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: eee37e720c1b0fba0305398b731c3bd909af1a6ebc1ae7393e924c66fd629377
|
|
4
|
+
data.tar.gz: 966c52f87dcff1b0cda6218c45b0cbc5362a02655cb0c570034b4d91249a2518
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75eda453e48567dda4b39765031ed1dab4e5d12715507f0515e6742de2dd0ce67a9c4b5e4c1581664bce956b01cf8978cc9198ee3878253bd26287ba0d558aa2
|
|
7
|
+
data.tar.gz: 3a4e18c3e8603ae524cfa48752177277297c94343623571ad32bb87434eacfa3456264769f9b3daf5421ad5d615de47502607db17e6ded024e8c9b35a542b9c1
|
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.1"
|
|
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)?;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//! `Gigatoken::Error` instead of ever calling `str::from_utf8_unchecked` on
|
|
14
14
|
//! Ruby-supplied bytes.
|
|
15
15
|
|
|
16
|
-
use std::
|
|
16
|
+
use std::sync::Mutex;
|
|
17
17
|
|
|
18
18
|
use gigatoken_rs::input::file_source::DocFormat;
|
|
19
19
|
use gigatoken_rs::{EncodeState, SentencePieceBPE, sp_encode_docs_ragged, sp_encode_files_docs, sp_encode_files_docs_serial};
|
|
@@ -35,17 +35,24 @@ fn require_utf8<'a>(ruby: &Ruby, bytes: &'a [u8]) -> Result<&'a str, Error> {
|
|
|
35
35
|
#[magnus::wrap(class = "Gigatoken::Native::SentencePieceTokenizer", free_immediately, size)]
|
|
36
36
|
pub struct SentencePieceTokenizer {
|
|
37
37
|
// `SentencePieceBPE`'s encode methods take `&self` (only `EncodeState`
|
|
38
|
-
// is mutated), so
|
|
39
|
-
//
|
|
40
|
-
tokenizer:
|
|
41
|
-
|
|
38
|
+
// is mutated), so the model needs no interior mutability at all — every
|
|
39
|
+
// path here reads it, including the ones that release the GVL.
|
|
40
|
+
tokenizer: SentencePieceBPE,
|
|
41
|
+
// The one mutable piece. A `Mutex` rather than a `RefCell` so the wrapped
|
|
42
|
+
// object is `Sync`: Ruby hands the same instance to every thread, and a
|
|
43
|
+
// `RefCell` shared that way is unsound (see `BPETokenizer`'s lock).
|
|
44
|
+
state: Mutex<EncodeState>,
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
impl SentencePieceTokenizer {
|
|
48
|
+
/// Wrap a loaded model, applying the global cache budget to it and to
|
|
49
|
+
/// the single-document encode state — mirrors pyo3's `with_model`.
|
|
45
50
|
pub(crate) fn from_tokenizer(tokenizer: SentencePieceBPE) -> Self {
|
|
51
|
+
let tokenizer = crate::cache::apply_max_cache_bytes_sp(tokenizer);
|
|
52
|
+
let state = EncodeState::with_budget(tokenizer.max_cache_bytes());
|
|
46
53
|
Self {
|
|
47
|
-
tokenizer
|
|
48
|
-
state:
|
|
54
|
+
tokenizer,
|
|
55
|
+
state: Mutex::new(state),
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -54,8 +61,8 @@ impl SentencePieceTokenizer {
|
|
|
54
61
|
let bytes = unsafe { input.as_slice() };
|
|
55
62
|
let text = require_utf8(ruby, bytes)?;
|
|
56
63
|
let mut ids: Vec<u32> = Vec::new();
|
|
57
|
-
let mut state = rb_self.state.
|
|
58
|
-
rb_self.tokenizer.
|
|
64
|
+
let mut state = rb_self.state.lock().unwrap_or_else(|e| e.into_inner());
|
|
65
|
+
rb_self.tokenizer.encode_raw_cb(&mut state, text, &mut |tokens| {
|
|
59
66
|
ids.extend(tokens.iter().map(|&t| u32::from(t)))
|
|
60
67
|
});
|
|
61
68
|
Ok(ids)
|
|
@@ -80,8 +87,7 @@ impl SentencePieceTokenizer {
|
|
|
80
87
|
})
|
|
81
88
|
.collect::<Result<_, _>>()?;
|
|
82
89
|
let doc_refs: Vec<&str> = docs.iter().map(String::as_str).collect();
|
|
83
|
-
let tokenizer = rb_self.tokenizer
|
|
84
|
-
let tokenizer: &SentencePieceBPE = &tokenizer;
|
|
90
|
+
let tokenizer: &SentencePieceBPE = &rb_self.tokenizer;
|
|
85
91
|
Ok(without_gvl(|| sp_encode_docs_ragged(tokenizer, &doc_refs)))
|
|
86
92
|
}
|
|
87
93
|
|
|
@@ -126,8 +132,7 @@ impl SentencePieceTokenizer {
|
|
|
126
132
|
}
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
let tokenizer = rb_self.tokenizer
|
|
130
|
-
let tokenizer: &SentencePieceBPE = &tokenizer;
|
|
135
|
+
let tokenizer: &SentencePieceBPE = &rb_self.tokenizer;
|
|
131
136
|
let encoded: std::io::Result<(Vec<u32>, Vec<i64>)> = without_gvl(|| {
|
|
132
137
|
sources::encode_files_ragged(&source, parallel, |files, format| {
|
|
133
138
|
for ®ion in files {
|
|
@@ -162,16 +167,16 @@ impl SentencePieceTokenizer {
|
|
|
162
167
|
fn decode(ruby: &Ruby, rb_self: &Self, tokens: RArray) -> Result<RString, Error> {
|
|
163
168
|
let ids: Vec<u32> = tokens.to_vec()?;
|
|
164
169
|
let ids: Vec<_> = ids.into_iter().map(Into::into).collect();
|
|
165
|
-
let bytes = rb_self.tokenizer.
|
|
170
|
+
let bytes = rb_self.tokenizer.decode(&ids);
|
|
166
171
|
Ok(binary_string(ruby, &bytes))
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
fn vocab_size(&self) -> usize {
|
|
170
|
-
self.tokenizer.
|
|
175
|
+
self.tokenizer.vocab_size()
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
fn vocab(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
|
|
174
|
-
let tokenizer = rb_self.tokenizer
|
|
179
|
+
let tokenizer = &rb_self.tokenizer;
|
|
175
180
|
let hash = ruby.hash_new();
|
|
176
181
|
for (id, bytes) in tokenizer.vocab_entries() {
|
|
177
182
|
hash.aset(id, binary_string(ruby, bytes))?;
|
|
@@ -180,7 +185,7 @@ impl SentencePieceTokenizer {
|
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
fn merges(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
|
|
183
|
-
let tokenizer = rb_self.tokenizer
|
|
188
|
+
let tokenizer = &rb_self.tokenizer;
|
|
184
189
|
let entries = tokenizer.merge_entries();
|
|
185
190
|
let result = ruby.ary_new_capa(entries.len());
|
|
186
191
|
for (a, b) in entries {
|
|
@@ -188,6 +193,12 @@ impl SentencePieceTokenizer {
|
|
|
188
193
|
}
|
|
189
194
|
Ok(result)
|
|
190
195
|
}
|
|
196
|
+
|
|
197
|
+
/// Cached unit entries on the single-document `encode` path's state
|
|
198
|
+
/// (batch encoders are per-call); see `BPETokenizer::cache_entries`.
|
|
199
|
+
fn cache_entries(&self) -> usize {
|
|
200
|
+
self.state.lock().unwrap_or_else(|e| e.into_inner()).cache_size()
|
|
201
|
+
}
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
@@ -201,5 +212,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
|
201
212
|
class.define_method("vocab_size", method!(SentencePieceTokenizer::vocab_size, 0))?;
|
|
202
213
|
class.define_method("vocab", method!(SentencePieceTokenizer::vocab, 0))?;
|
|
203
214
|
class.define_method("merges", method!(SentencePieceTokenizer::merges, 0))?;
|
|
215
|
+
class.define_method("cache_entries", method!(SentencePieceTokenizer::cache_entries, 0))?;
|
|
204
216
|
Ok(())
|
|
205
217
|
}
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
//! the core crate's `src/lib.rs` (the `python` feature), minus the
|
|
4
4
|
//! numpy/awkward-array machinery that has no Ruby analog.
|
|
5
5
|
|
|
6
|
-
use std::
|
|
6
|
+
use std::sync::RwLock;
|
|
7
7
|
use std::collections::{HashMap, HashSet};
|
|
8
8
|
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,
|
|
@@ -352,14 +353,14 @@ fn marshal_inputs(inputs: RArray) -> Result<InputDocs, Error> {
|
|
|
352
353
|
|
|
353
354
|
#[magnus::wrap(class = "Gigatoken::Native::BPETokenizer", free_immediately, size)]
|
|
354
355
|
pub struct BPETokenizer {
|
|
355
|
-
tokenizer:
|
|
356
|
+
tokenizer: RwLock<Tokenizer>,
|
|
356
357
|
workers: WorkerPool,
|
|
357
358
|
}
|
|
358
359
|
|
|
359
360
|
impl BPETokenizer {
|
|
360
361
|
pub(crate) fn from_tokenizer(tokenizer: Tokenizer) -> Self {
|
|
361
362
|
Self {
|
|
362
|
-
tokenizer:
|
|
363
|
+
tokenizer: RwLock::new(crate::cache::apply_max_cache_bytes(tokenizer)),
|
|
363
364
|
workers: WorkerPool::new(),
|
|
364
365
|
}
|
|
365
366
|
}
|
|
@@ -379,21 +380,108 @@ 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
|
}
|
|
387
408
|
}
|
|
388
409
|
|
|
410
|
+
/// Shared access to the tokenizer, for everything that only reads it —
|
|
411
|
+
/// the batch paths, `decode`, `vocab`, `merges`, the size accessors.
|
|
412
|
+
///
|
|
413
|
+
/// Readers never exclude each other, which is what makes the long holds
|
|
414
|
+
/// safe: `encode_batch`/`encode_files` keep this across a GVL release
|
|
415
|
+
/// (they run the core pool over `&Tokenizer`), and any other Ruby thread
|
|
416
|
+
/// reading meanwhile just proceeds. The only exclusive holder is
|
|
417
|
+
/// [`Self::encode`], which is short.
|
|
418
|
+
fn read_tokenizer(&self) -> std::sync::RwLockReadGuard<'_, Tokenizer> {
|
|
419
|
+
self.tokenizer.read().unwrap_or_else(|e| e.into_inner())
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/// Encode one string, mutating the tokenizer's pretoken cache — the only
|
|
423
|
+
/// exclusive use of the lock.
|
|
424
|
+
///
|
|
425
|
+
/// Uncontended (every single-threaded caller, and the common case under
|
|
426
|
+
/// threads) this takes the fast path: grab the write guard, encode against
|
|
427
|
+
/// the Ruby string's own bytes, never release the GVL. Identical cost to
|
|
428
|
+
/// the pre-lock version plus one uncontended atomic.
|
|
429
|
+
///
|
|
430
|
+
/// Contended, the writer is waiting on a reader that will hold the lock
|
|
431
|
+
/// for as long as a batch encode takes. Blocking there while holding the
|
|
432
|
+
/// GVL would stall every other Ruby thread in the VM, so instead the input
|
|
433
|
+
/// is copied and the whole wait-and-encode moves inside `without_gvl`.
|
|
434
|
+
/// The copy is what makes that sound: no Ruby `VALUE` and no `RString`
|
|
435
|
+
/// buffer may outlive the release (see `marshal_inputs`), and the guard is
|
|
436
|
+
/// taken and dropped inside the closure, so it never crosses OS threads
|
|
437
|
+
/// even when the scheduler offloads it (see `gvl`).
|
|
389
438
|
fn encode(&self, input: RString) -> Vec<u32> {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
.encode_with_added_tokens_flat(bytes, &mut out);
|
|
396
|
-
|
|
439
|
+
if let Ok(mut tokenizer) = self.tokenizer.try_write() {
|
|
440
|
+
// SAFETY: read-only, for the duration of this synchronous call,
|
|
441
|
+
// with no GVL release in between.
|
|
442
|
+
let bytes = unsafe { input.as_slice() };
|
|
443
|
+
let mut out = Vec::new();
|
|
444
|
+
tokenizer.encode_with_added_tokens_flat(bytes, &mut out);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
self.encode_contended(input)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/// The contended half of [`Self::encode`], outlined and `#[cold]`.
|
|
452
|
+
///
|
|
453
|
+
/// Keeping this out of `encode`'s body is a measured requirement, not
|
|
454
|
+
/// tidiness: the workspace builds with `lto = "fat"`, so the core encode
|
|
455
|
+
/// routine inlines into `encode`, and inlining is sensitive to the caller's
|
|
456
|
+
/// size. Written inline, this second path measured slower on single
|
|
457
|
+
/// encodes — no lock overhead, just a flipped inlining decision. Outlined,
|
|
458
|
+
/// `encode`'s hot body is the original three lines behind a `try_write`.
|
|
459
|
+
///
|
|
460
|
+
/// Before you re-inline this "to simplify": rerun the evidence rather than
|
|
461
|
+
/// trusting a number. `ruby -Ilib bench/encode_ab.rb` with the attributes
|
|
462
|
+
/// stripped and again with them restored, and read
|
|
463
|
+
/// `docs/rb/benchmarks.md` first — no size resolves this on the hardware
|
|
464
|
+
/// measured so far. The instrument is honest (an interleaved same-build
|
|
465
|
+
/// run never calls a size faster or slower, at any size) and has power to
|
|
466
|
+
/// catch a couple-percent effect reliably, but the attributes' real
|
|
467
|
+
/// effect is small enough that even the tightest floor (medium,
|
|
468
|
+
/// well under 3%) swallows it more often than not. Removing the
|
|
469
|
+
/// attributes measures slower at medium and large, in the direction the
|
|
470
|
+
/// outlining was added to prevent, but neither delta clears the noise
|
|
471
|
+
/// floor. Keep it outlined on that direction and on the original
|
|
472
|
+
/// inline-regression measurement, not on a pinned-down magnitude.
|
|
473
|
+
#[cold]
|
|
474
|
+
#[inline(never)]
|
|
475
|
+
fn encode_contended(&self, input: RString) -> Vec<u32> {
|
|
476
|
+
// SAFETY: copied before any GVL release, so nothing Ruby-owned is
|
|
477
|
+
// captured by the closure below.
|
|
478
|
+
let owned = unsafe { input.as_slice() }.to_vec();
|
|
479
|
+
without_gvl(move || {
|
|
480
|
+
let mut tokenizer = self.tokenizer.write().unwrap_or_else(|e| e.into_inner());
|
|
481
|
+
let mut out = Vec::new();
|
|
482
|
+
tokenizer.encode_with_added_tokens_flat(&owned, &mut out);
|
|
483
|
+
out
|
|
484
|
+
})
|
|
397
485
|
}
|
|
398
486
|
|
|
399
487
|
/// Encode a batch on the core worker pool, with the GVL released for the
|
|
@@ -404,7 +492,7 @@ impl BPETokenizer {
|
|
|
404
492
|
fn encode_batch_ragged(rb_self: &Self, inputs: RArray) -> Result<(Vec<u32>, Vec<i64>), Error> {
|
|
405
493
|
let marshaled = marshal_inputs(inputs)?;
|
|
406
494
|
let doc_slices = marshaled.as_slices();
|
|
407
|
-
let tokenizer = rb_self.
|
|
495
|
+
let tokenizer = rb_self.read_tokenizer();
|
|
408
496
|
let tokenizer: &Tokenizer = &tokenizer;
|
|
409
497
|
let workers = &rb_self.workers;
|
|
410
498
|
Ok(without_gvl(|| encode_docs_ragged(workers, tokenizer, &doc_slices)))
|
|
@@ -453,7 +541,7 @@ impl BPETokenizer {
|
|
|
453
541
|
// the duration of the gather below (see the allocation above).
|
|
454
542
|
let dest = unsafe { GatherBuf::new(ptr, total_bytes) };
|
|
455
543
|
|
|
456
|
-
let tokenizer = rb_self.
|
|
544
|
+
let tokenizer = rb_self.read_tokenizer();
|
|
457
545
|
let tokenizer: &Tokenizer = &tokenizer;
|
|
458
546
|
let workers = &rb_self.workers;
|
|
459
547
|
match without_gvl(|| encode_docs_into(workers, tokenizer, &doc_slices, dest)) {
|
|
@@ -494,7 +582,7 @@ impl BPETokenizer {
|
|
|
494
582
|
};
|
|
495
583
|
|
|
496
584
|
let source = sources::resolve(ruby, source)?;
|
|
497
|
-
let tokenizer = rb_self.
|
|
585
|
+
let tokenizer = rb_self.read_tokenizer();
|
|
498
586
|
let tokenizer: &Tokenizer = &tokenizer;
|
|
499
587
|
let workers = &rb_self.workers;
|
|
500
588
|
let encoded: std::io::Result<(Vec<u32>, Vec<i64>)> = without_gvl(|| {
|
|
@@ -526,16 +614,16 @@ impl BPETokenizer {
|
|
|
526
614
|
fn decode(ruby: &Ruby, rb_self: &Self, tokens: RArray) -> Result<RString, Error> {
|
|
527
615
|
let ids: Vec<u32> = tokens.to_vec()?;
|
|
528
616
|
let ids: Vec<_> = ids.into_iter().map(Into::into).collect();
|
|
529
|
-
let bytes: Vec<u8> = rb_self.
|
|
617
|
+
let bytes: Vec<u8> = rb_self.read_tokenizer().decode(&ids).collect();
|
|
530
618
|
Ok(binary_string(ruby, &bytes))
|
|
531
619
|
}
|
|
532
620
|
|
|
533
621
|
fn vocab_size(&self) -> usize {
|
|
534
|
-
self.
|
|
622
|
+
self.read_tokenizer().vocab_size()
|
|
535
623
|
}
|
|
536
624
|
|
|
537
625
|
fn vocab(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
|
|
538
|
-
let tokenizer = rb_self.
|
|
626
|
+
let tokenizer = rb_self.read_tokenizer();
|
|
539
627
|
let hash = ruby.hash_new();
|
|
540
628
|
for (id, bytes) in tokenizer.vocab_entries() {
|
|
541
629
|
hash.aset(id, binary_string(ruby, bytes))?;
|
|
@@ -544,7 +632,7 @@ impl BPETokenizer {
|
|
|
544
632
|
}
|
|
545
633
|
|
|
546
634
|
fn merges(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
|
|
547
|
-
let tokenizer = rb_self.
|
|
635
|
+
let tokenizer = rb_self.read_tokenizer();
|
|
548
636
|
let entries = tokenizer.merge_entries();
|
|
549
637
|
let result = ruby.ary_new_capa(entries.len());
|
|
550
638
|
for (a, b) in entries {
|
|
@@ -552,12 +640,19 @@ impl BPETokenizer {
|
|
|
552
640
|
}
|
|
553
641
|
Ok(result)
|
|
554
642
|
}
|
|
643
|
+
|
|
644
|
+
/// Cached pretoken entries on this tokenizer: grows as text is encoded,
|
|
645
|
+
/// drops back toward vocab-seed level when a budgeted cache wipes (see
|
|
646
|
+
/// `Gigatoken.max_cache_bytes`).
|
|
647
|
+
fn cache_entries(&self) -> usize {
|
|
648
|
+
self.read_tokenizer().cache_entries()
|
|
649
|
+
}
|
|
555
650
|
}
|
|
556
651
|
|
|
557
652
|
pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
558
653
|
let class: RClass = native.define_class("BPETokenizer", ruby.class_object())?;
|
|
559
654
|
class.define_singleton_method("from_hf_json", function!(BPETokenizer::from_hf_json, 1))?;
|
|
560
|
-
class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken,
|
|
655
|
+
class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken, 3))?;
|
|
561
656
|
class.define_method("encode", method!(BPETokenizer::encode, 1))?;
|
|
562
657
|
class.define_method("encode_batch", method!(BPETokenizer::encode_batch, 1))?;
|
|
563
658
|
class.define_method("encode_batch_packed", method!(BPETokenizer::encode_batch_packed, 1))?;
|
|
@@ -567,5 +662,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
|
|
|
567
662
|
class.define_method("vocab_size", method!(BPETokenizer::vocab_size, 0))?;
|
|
568
663
|
class.define_method("vocab", method!(BPETokenizer::vocab, 0))?;
|
|
569
664
|
class.define_method("merges", method!(BPETokenizer::merges, 0))?;
|
|
665
|
+
class.define_method("cache_entries", method!(BPETokenizer::cache_entries, 0))?;
|
|
570
666
|
Ok(())
|
|
571
667
|
}
|
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))
|