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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d36a860419519d55bf6d7f2fe8c30aa9c4bc25befe98ce810313758be8bf5380
4
- data.tar.gz: 0fda91b895be4ef02de36cb16832628203bba09de6c53955997b69611e796bce
3
+ metadata.gz: eee37e720c1b0fba0305398b731c3bd909af1a6ebc1ae7393e924c66fd629377
4
+ data.tar.gz: 966c52f87dcff1b0cda6218c45b0cbc5362a02655cb0c570034b4d91249a2518
5
5
  SHA512:
6
- metadata.gz: 65d3987a0943a5552a8e8b8493de03df9d37ac376cfc9dfc661a6c25a38cc03cd511ed4141b98580e4bd2cff64086802ad25705f9e841fb09cae779bafce0a71
7
- data.tar.gz: 34eace98922cb0ef2cf2a4fbecc103131bdfaa26dad459254a1d458efedc28154f3b103dd3200e106f94988d2028aa66370e680c6acee284148f707a0212288b
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.9.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.1.1"
774
+ version = "0.2.1"
775
775
  dependencies = [
776
776
  "gigatoken",
777
777
  "magnus",
data/Cargo.toml CHANGED
@@ -3,7 +3,7 @@ members = [".", "ext/gigatoken"]
3
3
 
4
4
  [package]
5
5
  name = "gigatoken"
6
- version = "0.9.0"
6
+ version = "0.10.0"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
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("vocab.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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "gigatoken-rb"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -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
+ }
@@ -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::cell::RefCell;
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 this `RefCell` is never `borrow_mut`'dsee the
39
- // builder report's DISAGREEMENTS for why it's here anyway.
40
- tokenizer: RefCell<SentencePieceBPE>,
41
- state: RefCell<EncodeState>,
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: RefCell::new(tokenizer),
48
- state: RefCell::new(EncodeState::new()),
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.borrow_mut();
58
- rb_self.tokenizer.borrow().encode_raw_cb(&mut state, text, &mut |tokens| {
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.borrow();
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.borrow();
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 &region 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.borrow().decode(&ids);
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.borrow().vocab_size()
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.borrow();
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.borrow();
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::cell::RefCell;
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: RefCell<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: RefCell::new(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
- fn from_tiktoken(ruby: &Ruby, path: String) -> Result<Self, Error> {
383
- match tiktoken::load_tiktoken(&path) {
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
- // SAFETY: read-only, for the duration of this synchronous call.
391
- let bytes = unsafe { input.as_slice() };
392
- let mut out = Vec::new();
393
- self.tokenizer
394
- .borrow_mut()
395
- .encode_with_added_tokens_flat(bytes, &mut out);
396
- out
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.tokenizer.borrow();
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.tokenizer.borrow();
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.tokenizer.borrow();
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.tokenizer.borrow().decode(&ids).collect();
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.tokenizer.borrow().vocab_size()
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.tokenizer.borrow();
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.tokenizer.borrow();
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, 1))?;
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
  }
@@ -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 HuggingFace
18
- # repo id, or a .tiktoken file — see Gigatoken::Tokenizer.load.
19
- def load_tokenizer(spec)
20
- Gigatoken::Tokenizer.load(spec)
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))