gigatoken 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +3016 -0
  3. data/Cargo.toml +135 -0
  4. data/LICENSE +21 -0
  5. data/README.md +141 -0
  6. data/exe/gigatoken +9 -0
  7. data/ext/gigatoken/Cargo.toml +24 -0
  8. data/ext/gigatoken/extconf.rb +19 -0
  9. data/ext/gigatoken/src/error.rs +20 -0
  10. data/ext/gigatoken/src/gvl.rs +122 -0
  11. data/ext/gigatoken/src/lib.rs +50 -0
  12. data/ext/gigatoken/src/sentencepiece.rs +205 -0
  13. data/ext/gigatoken/src/sources.rs +207 -0
  14. data/ext/gigatoken/src/tokenizer.rs +571 -0
  15. data/lib/gigatoken/cli/bench.rb +64 -0
  16. data/lib/gigatoken/cli/support.rb +132 -0
  17. data/lib/gigatoken/cli/validate.rb +55 -0
  18. data/lib/gigatoken/cli.rb +18 -0
  19. data/lib/gigatoken/hub.rb +242 -0
  20. data/lib/gigatoken/packed_result.rb +48 -0
  21. data/lib/gigatoken/tokenizer.rb +117 -0
  22. data/lib/gigatoken/version.rb +5 -0
  23. data/lib/gigatoken.rb +29 -0
  24. data/rust-toolchain.toml +8 -0
  25. data/src/batch.rs +1808 -0
  26. data/src/bindings/bridge.rs +396 -0
  27. data/src/bindings/hub.rs +42 -0
  28. data/src/bindings/matcher.rs +114 -0
  29. data/src/bindings/mod.rs +14 -0
  30. data/src/bindings/padding.rs +177 -0
  31. data/src/bindings/pretokenize.rs +53 -0
  32. data/src/bindings/sources.rs +273 -0
  33. data/src/bindings/train.rs +125 -0
  34. data/src/bpe/mod.rs +1217 -0
  35. data/src/bpe/pretoken_cache.rs +495 -0
  36. data/src/bpe/sentencepiece.rs +1485 -0
  37. data/src/bpe/tiktoken.rs +2555 -0
  38. data/src/bpe_train.rs +351 -0
  39. data/src/input/decompress.rs +11 -0
  40. data/src/input/file_source.rs +514 -0
  41. data/src/input/jsonl.rs +94 -0
  42. data/src/input/mod.rs +333 -0
  43. data/src/input/parquet.rs +303 -0
  44. data/src/lib.rs +578 -0
  45. data/src/load_tokenizer/hf.rs +1036 -0
  46. data/src/load_tokenizer/hub.rs +344 -0
  47. data/src/load_tokenizer/mod.rs +3 -0
  48. data/src/load_tokenizer/tiktoken.rs +87 -0
  49. data/src/main.rs +95 -0
  50. data/src/pretokenize/fast/cl100k.rs +426 -0
  51. data/src/pretokenize/fast/cl100k_family.rs +891 -0
  52. data/src/pretokenize/fast/deepseek_v3.rs +605 -0
  53. data/src/pretokenize/fast/kimi.rs +281 -0
  54. data/src/pretokenize/fast/mask.rs +1486 -0
  55. data/src/pretokenize/fast/mod.rs +446 -0
  56. data/src/pretokenize/fast/nemotron.rs +138 -0
  57. data/src/pretokenize/fast/o200k.rs +347 -0
  58. data/src/pretokenize/fast/o200k_family.rs +1734 -0
  59. data/src/pretokenize/fast/olmo3.rs +505 -0
  60. data/src/pretokenize/fast/qwen2.rs +429 -0
  61. data/src/pretokenize/fast/qwen3_5.rs +541 -0
  62. data/src/pretokenize/fast/r50k.rs +1250 -0
  63. data/src/pretokenize/mod.rs +1079 -0
  64. data/src/pretokenize/options.rs +188 -0
  65. data/src/pretokenize/pretoken.rs +20 -0
  66. data/src/pretokenize/pretokenize_traits.rs +49 -0
  67. data/src/pretokenize/reference/avx512.rs +522 -0
  68. data/src/pretokenize/reference/combinator.rs +572 -0
  69. data/src/pretokenize/reference/mod.rs +28 -0
  70. data/src/pretokenize/reference/simd.rs +852 -0
  71. data/src/pretokenize/reference/state_machine.rs +365 -0
  72. data/src/pretokenize/unicode.rs +546 -0
  73. data/src/test_hub.rs +28 -0
  74. data/src/token.rs +42 -0
  75. metadata +161 -0
@@ -0,0 +1,571 @@
1
+ //! `Gigatoken::Native::BPETokenizer`: a `gigatoken::Tokenizer` plus the
2
+ //! `WorkerPool` that backs batch encoding. Mirrors the pyo3 `BPETokenizer` in
3
+ //! the core crate's `src/lib.rs` (the `python` feature), minus the
4
+ //! numpy/awkward-array machinery that has no Ruby analog.
5
+
6
+ use std::cell::RefCell;
7
+ use std::collections::{HashMap, HashSet};
8
+ use std::os::raw::c_long;
9
+
10
+ use gigatoken_rs::load_tokenizer::hf::HfTokenizer;
11
+ use gigatoken_rs::load_tokenizer::{hf, tiktoken};
12
+ use gigatoken_rs::{
13
+ GatherBuf, GatherOutcome, Tokenizer, WorkerPool, encode_docs_into, encode_docs_ragged,
14
+ encode_files_docs, encode_files_docs_serial,
15
+ };
16
+ use magnus::{
17
+ Error, RArray, RClass, RHash, RModule, RString, Ruby, Value, function, method, prelude::*,
18
+ rb_sys::{AsRawValue, FromRawValue},
19
+ scan_args::{get_kwargs, scan_args},
20
+ };
21
+ use rb_sys::{RSTRING_PTR, rb_ary_dup, rb_str_locktmp, rb_str_set_len, rb_str_unlocktmp};
22
+
23
+ use crate::error::raise;
24
+ use crate::gvl::without_gvl;
25
+ use crate::sources;
26
+
27
+ pub(crate) fn binary_string(ruby: &Ruby, bytes: &[u8]) -> RString {
28
+ ruby.enc_str_new(bytes, ruby.ascii8bit_encoding())
29
+ }
30
+
31
+ /// Reinterpret a `Vec<u32>` as raw bytes in the host's native byte order.
32
+ /// Safe: `u32` has no padding or niches, so any of its byte patterns is a
33
+ /// valid `u8`, and `u8`'s alignment (1) never exceeds `u32`'s.
34
+ fn u32_vec_as_bytes(values: &[u32]) -> &[u8] {
35
+ unsafe { std::slice::from_raw_parts(values.as_ptr().cast::<u8>(), std::mem::size_of_val(values)) }
36
+ }
37
+
38
+ /// Marshal a ragged `(flat, lens)` encode result into a ragged Ruby Array of
39
+ /// Arrays, one per document — the shape `encode_batch`/`encode_files` return.
40
+ pub(crate) fn ragged_result(ruby: &Ruby, flat: Vec<u32>, lens: Vec<i64>) -> Result<RArray, Error> {
41
+ let result = ruby.ary_new_capa(lens.len());
42
+ let mut offset = 0usize;
43
+ for len in lens {
44
+ let len = len as usize;
45
+ result.push(flat[offset..offset + len].to_vec())?;
46
+ offset += len;
47
+ }
48
+ Ok(result)
49
+ }
50
+
51
+ /// Freeze `string` (already ASCII-8BIT-encoded and holding exactly the
52
+ /// packed token bytes, native byte order — every realistic Ruby platform is
53
+ /// little-endian, and `IO::Buffer#get_value(s)`'s own `:u32` format is
54
+ /// always little-endian, so the two agree without any byte-swapping) and
55
+ /// wrap it zero-copy as `[IO::Buffer, lens]` (per Ruby's own docs: "If the
56
+ /// string is frozen, it will create a read-only buffer which cannot be
57
+ /// modified"). magnus 0.8 has no `IO::Buffer` wrapper (verified against the
58
+ /// installed magnus-0.8.2 source), so the buffer is built via `funcall`.
59
+ /// `lens` is a plain per-document Ruby Array — small, ergonomics over
60
+ /// purity. Shared by `packed_result` (the copy path) and the BPE packed
61
+ /// paths' zero-copy gather (`BPETokenizer::encode_batch_packed`).
62
+ fn finish_packed(ruby: &Ruby, string: RString, lens: Vec<i64>) -> Result<RArray, Error> {
63
+ string.freeze();
64
+ let io_buffer: RClass = ruby
65
+ .class_object()
66
+ .const_get::<_, RClass>("IO")?
67
+ .const_get("Buffer")?;
68
+ let buffer: Value = io_buffer.funcall("for", (string,))?;
69
+
70
+ let lens_ary = ruby.ary_new_capa(lens.len());
71
+ for len in lens {
72
+ lens_ary.push(len)?;
73
+ }
74
+
75
+ let result = ruby.ary_new_capa(2);
76
+ result.push(buffer)?;
77
+ result.push(lens_ary)?;
78
+ Ok(result)
79
+ }
80
+
81
+ /// Marshal a ragged `(flat, lens)` encode result into `[IO::Buffer, lens]`
82
+ /// by copying `flat`'s raw bytes into a fresh `RString` (see
83
+ /// `finish_packed`). Used directly by the SentencePiece packed paths (whose
84
+ /// gather has no overlapped-commit machinery to hand a destination to — see
85
+ /// `encode_chunks_into` in the core crate) and as the BPE packed paths'
86
+ /// fallback when the zero-copy gather's reservation overruns (see
87
+ /// `BPETokenizer::encode_batch_packed`).
88
+ pub(crate) fn packed_result(ruby: &Ruby, flat: Vec<u32>, lens: Vec<i64>) -> Result<RArray, Error> {
89
+ let string = binary_string(ruby, u32_vec_as_bytes(&flat));
90
+ finish_packed(ruby, string, lens)
91
+ }
92
+
93
+ /// One document's bytes as marshaled by `marshal_inputs`: either an owned
94
+ /// copy, or a zero-copy borrow of a heap `RString`'s own buffer.
95
+ enum DocSource {
96
+ Owned(Vec<u8>),
97
+ Borrowed { ptr: *const u8, len: usize },
98
+ }
99
+
100
+ /// Whether `value` (already confirmed to be a `T_STRING`) stores its bytes
101
+ /// in a separate heap allocation (`RSTRING_NOEMBED` set) rather than inside
102
+ /// the RVALUE itself. Heap buffers don't move under GC compaction (only the
103
+ /// RVALUE header can); embedded ones live inside the header and do — so only
104
+ /// a heap string's buffer is safe to borrow across a GVL release. Neither
105
+ /// magnus nor rb-sys expose a public query for this; this reads `RBasic`'s
106
+ /// `flags` field directly, the same technique rb-sys's own (private)
107
+ /// stable-API string accessors use internally (`rstring_ptr` in
108
+ /// rb-sys-0.9.128's `src/stable_api/ruby_4_0.rs`). Verified live against
109
+ /// Ruby 4.0.6: Variable Width Allocation raises the embed threshold well
110
+ /// past the classic ~23 bytes (bisected empirically: 512 B still embeds,
111
+ /// 1024 B doesn't), so this flag — not a size heuristic — is the only
112
+ /// reliable test.
113
+ fn is_heap_rstring(value: rb_sys::VALUE) -> bool {
114
+ // SAFETY: every Ruby object's memory begins with an `RBasic` (flags,
115
+ // klass) header regardless of its concrete type's trailing fields, and
116
+ // `value` is a live `T_STRING` VALUE for the duration of this
117
+ // synchronous, GVL-held call.
118
+ let flags = unsafe { (*(value as *const rb_sys::RBasic)).flags };
119
+ flags & (rb_sys::ruby_rstring_flags::RSTRING_NOEMBED as rb_sys::VALUE) != 0
120
+ }
121
+
122
+ /// Lift `rstr`'s current buffer out as a `DocSource::Borrowed`. Caller must
123
+ /// have already confirmed `rstr` is a heap `RString` that is either frozen
124
+ /// (permanently immutable) or successfully locked via `rb_str_locktmp` (any
125
+ /// mutation attempt for as long as the lock holds raises rather than
126
+ /// touching the buffer) — either guard keeps the buffer stable and
127
+ /// unmutated for as long as the borrow lives, decoupled from any Rust
128
+ /// borrow of `rstr` itself, across the `without_gvl` release that follows.
129
+ fn borrowed_doc(rstr: RString) -> DocSource {
130
+ // SAFETY: see above — the buffer neither moves nor mutates while the
131
+ // caller's guard (frozen, or this call's lock) holds.
132
+ let bytes = unsafe { rstr.as_slice() };
133
+ DocSource::Borrowed {
134
+ ptr: bytes.as_ptr(),
135
+ len: bytes.len(),
136
+ }
137
+ }
138
+
139
+ /// Marshaled batch inputs, ready for `as_slices` to hand to the core encode.
140
+ /// `snapshot` is the `rb_ary_dup` copy `marshal_inputs` takes of the
141
+ /// caller's input Array at entry (see its doc comment) — classification,
142
+ /// `to_str` conversion, locking, and this Drop's unlock all read
143
+ /// exclusively from `snapshot`'s slots, never the caller's original array.
144
+ /// Dropping this unlocks every string `marshal_inputs` locked, by
145
+ /// re-reading the *current* value out of `snapshot` at each locked index
146
+ /// rather than reusing any `VALUE` captured before a possible GVL release:
147
+ /// GC compaction can move a locked heap string's RVALUE header while the
148
+ /// GVL is released (only its separate, malloc'd byte buffer is guaranteed
149
+ /// to stay put), and `snapshot` — a precisely-marked Ruby Array, kept alive
150
+ /// and pinned in place by conservative scanning of this struct's stack
151
+ /// frame for the whole call — is the only reference that stays correct
152
+ /// across that.
153
+ struct InputDocs {
154
+ snapshot: RArray,
155
+ should_unlock: Vec<bool>,
156
+ docs: Vec<DocSource>,
157
+ }
158
+
159
+ impl InputDocs {
160
+ fn as_slices(&self) -> Vec<&[u8]> {
161
+ self.docs
162
+ .iter()
163
+ .map(|doc| match *doc {
164
+ DocSource::Owned(ref bytes) => bytes.as_slice(),
165
+ // SAFETY: see `borrowed_doc` and this struct's own doc
166
+ // comment — the buffer is stable and unmutated for as long
167
+ // as `self` (and thus its lock, if any) is alive, which
168
+ // outlives every use of the slices this returns.
169
+ DocSource::Borrowed { ptr, len } => unsafe { std::slice::from_raw_parts(ptr, len) },
170
+ })
171
+ .collect()
172
+ }
173
+ }
174
+
175
+ impl Drop for InputDocs {
176
+ fn drop(&mut self) {
177
+ if !self.should_unlock.iter().any(|&locked| locked) {
178
+ return;
179
+ }
180
+ let mut unlocked = HashSet::new();
181
+ for (i, &locked) in self.should_unlock.iter().enumerate() {
182
+ if !locked {
183
+ continue;
184
+ }
185
+ // Re-read this slot fresh (see this struct's doc comment for
186
+ // why a value captured earlier isn't safe to reuse here).
187
+ let value = snapshot_entry(self.snapshot, i);
188
+ let Some(rstr) = RString::from_value(value) else {
189
+ continue;
190
+ };
191
+ let raw = rstr.as_raw();
192
+ if unlocked.insert(raw) {
193
+ // A failure here would mean the string was already
194
+ // unlocked out from under us — not something we can
195
+ // recover from in `drop`, and not expected to happen since
196
+ // we're the ones who locked it and dedup by `raw` above.
197
+ let _ = magnus::rb_sys::protect(|| unsafe { rb_str_unlocktmp(raw) });
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /// Read the `Value` currently at `index` in `snapshot`, fresh — via the
204
+ /// bounds-checked `rb_ary_entry` C accessor, never by reusing a `Value` read
205
+ /// before a Ruby-code-running call (`to_str` conversion, or any other call
206
+ /// that can allocate and trigger GC compaction), since compaction rewrites a
207
+ /// moved element's slot in place. `Value`'s own `TryConvert` is an
208
+ /// infallible identity conversion, so this can't fail.
209
+ fn snapshot_entry(snapshot: RArray, index: usize) -> Value {
210
+ snapshot.entry(index as isize).expect("Value's TryConvert is infallible")
211
+ }
212
+
213
+ /// Marshal `inputs` (an Array of Strings, or objects converting to one via
214
+ /// `to_str`) into `InputDocs`, borrowing zero-copy wherever it's sound
215
+ /// instead of copying.
216
+ ///
217
+ /// The first thing this does is `rb_ary_dup` `inputs` into a snapshot: a
218
+ /// C-level shallow copy of the array's slots that runs no user code (not
219
+ /// even `initialize_copy`). From that point on, every pass below —
220
+ /// classification, `to_str` conversion, locking, and `InputDocs`'s Drop —
221
+ /// reads exclusively from the snapshot's slots; `inputs` itself is never
222
+ /// read again. This closes three hazards a direct-`inputs` version has (I19
223
+ /// Verdict): (1) a `to_str` conversion for one element can run arbitrary
224
+ /// Ruby code, including code that mutates or replaces later elements of the
225
+ /// caller's array out from under an in-progress classify/lock pass; (2)
226
+ /// while the GVL is released for the encode itself, another Ruby thread can
227
+ /// replace or clear the caller's slots, making a borrowed string
228
+ /// collectible mid-encode; (3) holding one Rust borrow of the caller's
229
+ /// array buffer across a `to_str` call is unsound if that call resizes the
230
+ /// array. None of these can reach the snapshot: no Ruby code holds a
231
+ /// reference to it (so nothing can mutate, resize, or replace its slots
232
+ /// from Ruby), and the snapshot `RArray` local stays alive as a
233
+ /// conservatively-scanned stack root the whole call through — via
234
+ /// `InputDocs::snapshot`, kept in the calling frame including across the
235
+ /// `without_gvl` window — which is also, incidentally, why compaction never
236
+ /// relocates the snapshot array itself. The snapshot's *slots* remain
237
+ /// ordinary, precisely-marked Ruby state, though, and compaction does
238
+ /// rewrite a slot in place when that element's RVALUE moves — so every pass
239
+ /// below re-reads a slot fresh (`snapshot_entry`) rather than reusing a
240
+ /// `Value` obtained before a Ruby-code-running call, and never holds a
241
+ /// `snapshot.as_slice()` borrow across one.
242
+ ///
243
+ /// One consequence is now a pinned public contract: `encode_batch`'s result
244
+ /// reflects the input array as it was *at this call's entry*. A
245
+ /// pathological `to_str` that mutates the caller's array mid-marshal can no
246
+ /// longer change which documents get encoded — the caller's own mutations
247
+ /// remain visible to the caller afterwards, just no longer to this encode.
248
+ ///
249
+ /// - a heap (non-embedded) `RString` that's frozen is borrowed unlocked —
250
+ /// its immutability is itself the guard, and `rb_str_locktmp` isn't legal
251
+ /// on a frozen string in the first place (verified live: it raises
252
+ /// `FrozenError`);
253
+ /// - a heap `RString` that isn't frozen (including a "chilled" literal —
254
+ /// verified live on Ruby 4.0.6: it reports `frozen? == false` and
255
+ /// `rb_str_locktmp` succeeds on it exactly like any other mutable string)
256
+ /// is borrowed under a dedup'd, `protect`-wrapped `rb_str_locktmp`; if the
257
+ /// lock is refused (already locked by other code), it's copied instead —
258
+ /// never borrow a string whose lock isn't held;
259
+ /// - an embedded `RString`, or a `to_str` conversion result (a new object
260
+ /// reachable only from this call, never borrowable), is always copied.
261
+ ///
262
+ /// A first pass classifies every slot, doing every `to_str` conversion (the
263
+ /// only step that can run arbitrary Ruby code and allocate) along the way;
264
+ /// a slot that's already a heap, non-frozen `RString` is left `NeedsLock`
265
+ /// rather than resolved immediately, since a *later* slot's `to_str` call
266
+ /// can still run before this pass finishes. That first pass's `NeedsLock`
267
+ /// verdict is a hint, not truth, by the time the second (lock) pass gets to
268
+ /// it: a later `to_str` conversion can freeze or mutate the string in the
269
+ /// meantime (e.g. `clear` can re-embed a heap string), so the lock pass
270
+ /// re-reads and re-classifies each `NeedsLock` index's snapshot slot from
271
+ /// scratch instead of trusting the first pass. Only indices are carried
272
+ /// between the two passes — never a bare `RString`/`Value` — since a
273
+ /// `Value` read before a `to_str` call can go stale under compaction before
274
+ /// the lock pass gets to it.
275
+ fn marshal_inputs(inputs: RArray) -> Result<InputDocs, Error> {
276
+ // SAFETY: `inputs.as_raw()` is a live, array-typed VALUE for the
277
+ // duration of this synchronous, GVL-held call; `rb_ary_dup` only reads
278
+ // it and allocates a fresh Array via a C-level shallow slot copy that
279
+ // runs no user code, so nothing here can run arbitrary Ruby code or
280
+ // raise. Its result is always an Array, per the Ruby C API.
281
+ let snapshot = RArray::from_value(unsafe { Value::from_raw(rb_ary_dup(inputs.as_raw())) })
282
+ .expect("rb_ary_dup's result is always an Array");
283
+ let len = snapshot.len();
284
+
285
+ enum Classified {
286
+ Done(DocSource),
287
+ NeedsLock,
288
+ }
289
+
290
+ let mut classified = Vec::with_capacity(len);
291
+ for i in 0..len {
292
+ let value = snapshot_entry(snapshot, i);
293
+ let item = match RString::from_value(value) {
294
+ Some(rstr) if !is_heap_rstring(rstr.as_raw()) => {
295
+ Classified::Done(DocSource::Owned(unsafe { rstr.as_slice() }.to_vec()))
296
+ }
297
+ Some(rstr) if rstr.is_frozen() => Classified::Done(borrowed_doc(rstr)),
298
+ Some(_) => Classified::NeedsLock,
299
+ None => {
300
+ let converted = RString::try_convert(value)?;
301
+ Classified::Done(DocSource::Owned(unsafe { converted.as_slice() }.to_vec()))
302
+ }
303
+ };
304
+ classified.push(item);
305
+ }
306
+
307
+ let mut should_unlock = vec![false; len];
308
+ let mut docs = Vec::with_capacity(len);
309
+ let mut lock_cache: HashMap<rb_sys::VALUE, bool> = HashMap::new();
310
+ for (i, item) in classified.into_iter().enumerate() {
311
+ let source = match item {
312
+ Classified::Done(source) => source,
313
+ Classified::NeedsLock => {
314
+ // Re-read and re-classify: a `to_str` conversion that ran
315
+ // after this index was first classified may have frozen or
316
+ // mutated this exact string (see this fn's doc comment).
317
+ let value = snapshot_entry(snapshot, i);
318
+ let rstr = RString::from_value(value).expect(
319
+ "this slot classified as a String in the first pass, and nothing but this \
320
+ call's Rust frame holds a reference to the snapshot to replace it",
321
+ );
322
+ if rstr.is_frozen() {
323
+ borrowed_doc(rstr)
324
+ } else if !is_heap_rstring(rstr.as_raw()) {
325
+ DocSource::Owned(unsafe { rstr.as_slice() }.to_vec())
326
+ } else {
327
+ let raw = rstr.as_raw();
328
+ let locked = *lock_cache
329
+ .entry(raw)
330
+ .or_insert_with(|| magnus::rb_sys::protect(|| unsafe { rb_str_locktmp(raw) }).is_ok());
331
+ if locked {
332
+ should_unlock[i] = true;
333
+ borrowed_doc(rstr)
334
+ } else {
335
+ // SAFETY: read-only; the lock attempt failing
336
+ // doesn't change anything about the buffer's
337
+ // validity here.
338
+ DocSource::Owned(unsafe { rstr.as_slice() }.to_vec())
339
+ }
340
+ }
341
+ }
342
+ };
343
+ docs.push(source);
344
+ }
345
+
346
+ Ok(InputDocs {
347
+ snapshot,
348
+ should_unlock,
349
+ docs,
350
+ })
351
+ }
352
+
353
+ #[magnus::wrap(class = "Gigatoken::Native::BPETokenizer", free_immediately, size)]
354
+ pub struct BPETokenizer {
355
+ tokenizer: RefCell<Tokenizer>,
356
+ workers: WorkerPool,
357
+ }
358
+
359
+ impl BPETokenizer {
360
+ pub(crate) fn from_tokenizer(tokenizer: Tokenizer) -> Self {
361
+ Self {
362
+ tokenizer: RefCell::new(tokenizer),
363
+ workers: WorkerPool::new(),
364
+ }
365
+ }
366
+
367
+ fn from_hf_json(ruby: &Ruby, data: RString) -> Result<Self, Error> {
368
+ // SAFETY: the slice is only read, and only for the duration of this
369
+ // synchronous call (no GVL release, no Ruby allocation in between).
370
+ let bytes = unsafe { data.as_slice() };
371
+ match hf::load_hf_slice(bytes) {
372
+ Ok(HfTokenizer::Bpe(tokenizer)) => Ok(Self::from_tokenizer(tokenizer)),
373
+ Ok(HfTokenizer::SentencePiece(_)) => Err(raise(
374
+ ruby,
375
+ "SentencePiece tokenizer.json data loads as a SentencePieceTokenizer, not a \
376
+ BPETokenizer — use Gigatoken::Native.load_hf_json instead",
377
+ )),
378
+ Err(e) => Err(raise(ruby, e.to_string())),
379
+ }
380
+ }
381
+
382
+ fn from_tiktoken(ruby: &Ruby, path: String) -> Result<Self, Error> {
383
+ match tiktoken::load_tiktoken(&path) {
384
+ Ok(tokenizer) => Ok(Self::from_tokenizer(tokenizer)),
385
+ Err(e) => Err(raise(ruby, e.to_string())),
386
+ }
387
+ }
388
+
389
+ 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
397
+ }
398
+
399
+ /// Encode a batch on the core worker pool, with the GVL released for the
400
+ /// parallel encode itself (see `gvl::without_gvl`). Each input string is
401
+ /// borrowed zero-copy where `marshal_inputs` finds it sound to, and
402
+ /// copied into an owned buffer otherwise; either way, only raw byte
403
+ /// slices — never a Ruby `VALUE` — are captured once the GVL is gone.
404
+ fn encode_batch_ragged(rb_self: &Self, inputs: RArray) -> Result<(Vec<u32>, Vec<i64>), Error> {
405
+ let marshaled = marshal_inputs(inputs)?;
406
+ let doc_slices = marshaled.as_slices();
407
+ let tokenizer = rb_self.tokenizer.borrow();
408
+ let tokenizer: &Tokenizer = &tokenizer;
409
+ let workers = &rb_self.workers;
410
+ Ok(without_gvl(|| encode_docs_ragged(workers, tokenizer, &doc_slices)))
411
+ }
412
+
413
+ fn encode_batch(ruby: &Ruby, rb_self: &Self, inputs: RArray) -> Result<RArray, Error> {
414
+ let (flat, lens) = Self::encode_batch_ragged(rb_self, inputs)?;
415
+ ragged_result(ruby, flat, lens)
416
+ }
417
+
418
+ /// The packed analog of `encode_batch`: gathers straight into a
419
+ /// pre-allocated Ruby string's backing store instead of copying `flat`
420
+ /// into a fresh one afterward (see `packed_result`), eliminating the
421
+ /// packed path's whole-result copy. The destination is allocated with
422
+ /// the GVL still held (Ruby object allocation requires it — this is why
423
+ /// `encode_files_packed` cannot take the same route: its total input
424
+ /// size is only known after loading files, which happens inside a
425
+ /// single `without_gvl` call in `sources::encode_files_ragged`) and
426
+ /// stays unexposed to Ruby (not frozen, not wrapped) until the gather
427
+ /// below has fully completed, so nothing else can observe it mid-write.
428
+ /// `string` itself is never captured by the `without_gvl` closure (only
429
+ /// the raw `dest` pointer is — see `GatherBuf`'s `Send` impl); it stays
430
+ /// alive as an ordinary local across the call, conservatively reachable
431
+ /// from this frame the same way any pointer obtained via `RSTRING_PTR`
432
+ /// and used across a released-GVL blocking call already is (the pattern
433
+ /// Ruby's own C extensions use for e.g. blocking `read(2)` into a
434
+ /// string's buffer). Falls back to `packed_result`'s copy path on the
435
+ /// same NFC-expansion overflow escape `encode_docs_into` documents.
436
+ fn encode_batch_packed(ruby: &Ruby, rb_self: &Self, inputs: RArray) -> Result<RArray, Error> {
437
+ let marshaled = marshal_inputs(inputs)?;
438
+ let doc_slices = marshaled.as_slices();
439
+ let total_bytes: usize = doc_slices.iter().map(|d| d.len()).sum();
440
+
441
+ // A token consumes >= 1 input byte, so total_bytes tokens (4 bytes
442
+ // each) is the same reservation bound the core's owned gather uses
443
+ // — see `encode_docs_into`. `str_buf_new` is already "binary"
444
+ // (ASCII-8BIT) encoded per Ruby's own C API docs for
445
+ // rb_str_buf_new (ruby/internal/intern/string.h).
446
+ let string = ruby.str_buf_new(total_bytes * std::mem::size_of::<u32>());
447
+ // SAFETY: `string` was just allocated with exactly this many tokens
448
+ // of capacity and is not yet exposed to Ruby (not returned, not
449
+ // frozen, not wrapped), so nothing else can read or write through it
450
+ // concurrently.
451
+ let ptr = unsafe { RSTRING_PTR(string.as_raw()) as *mut u32 };
452
+ // SAFETY: `ptr` is valid for `total_bytes` disjoint u32 writes for
453
+ // the duration of the gather below (see the allocation above).
454
+ let dest = unsafe { GatherBuf::new(ptr, total_bytes) };
455
+
456
+ let tokenizer = rb_self.tokenizer.borrow();
457
+ let tokenizer: &Tokenizer = &tokenizer;
458
+ let workers = &rb_self.workers;
459
+ match without_gvl(|| encode_docs_into(workers, tokenizer, &doc_slices, dest)) {
460
+ GatherOutcome::Committed(total_tokens, lens) => {
461
+ // SAFETY: `encode_docs_into` only returns `Committed` once
462
+ // every one of `total_tokens` u32s at `ptr` has been
463
+ // written.
464
+ unsafe {
465
+ rb_str_set_len(
466
+ string.as_raw(),
467
+ (total_tokens * std::mem::size_of::<u32>()) as c_long,
468
+ );
469
+ }
470
+ finish_packed(ruby, string, lens)
471
+ }
472
+ // The reservation overran (NFC-expansion pathologies) — `string`
473
+ // is discarded unused; fall back to the classic copy path.
474
+ GatherOutcome::Fallback(flat, lens) => packed_result(ruby, flat, lens),
475
+ }
476
+ }
477
+
478
+ /// Encode every document named by `source` (a TextFileSource,
479
+ /// JsonlFileSource, or ParquetFileSource) with the GVL released for the
480
+ /// whole run — loading the files and the encode itself. Files are cut
481
+ /// into chunks at document boundaries and encoded by the core worker
482
+ /// pool in one fused pass (`batch::encode_files_docs`, the same core the
483
+ /// pyo3 bindings' `encode_files` uses — see `sources::encode_files_ragged`).
484
+ /// `parallel: false` loads and encodes everything on the calling thread
485
+ /// instead, with identical output, never touching the worker pool.
486
+ fn encode_files_ragged(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<(Vec<u32>, Vec<i64>), Error> {
487
+ let args = scan_args::<(Value,), (), (), (), RHash, ()>(args)?;
488
+ let (source,) = args.required;
489
+ let kw = get_kwargs::<_, (), (Option<Value>,), ()>(args.keywords, &[], &["parallel"])?;
490
+ let (parallel,) = kw.optional;
491
+ let parallel = match parallel {
492
+ Some(v) if !v.is_nil() => bool::try_convert(v)?,
493
+ _ => true,
494
+ };
495
+
496
+ let source = sources::resolve(ruby, source)?;
497
+ let tokenizer = rb_self.tokenizer.borrow();
498
+ let tokenizer: &Tokenizer = &tokenizer;
499
+ let workers = &rb_self.workers;
500
+ let encoded: std::io::Result<(Vec<u32>, Vec<i64>)> = without_gvl(|| {
501
+ sources::encode_files_ragged(&source, parallel, |files, format| {
502
+ Ok(if parallel {
503
+ encode_files_docs(workers, tokenizer, files, format)
504
+ } else {
505
+ encode_files_docs_serial(workers, tokenizer, files, format)
506
+ })
507
+ })
508
+ });
509
+ encoded.map_err(|e| raise(ruby, e.to_string()))
510
+ }
511
+
512
+ fn encode_files(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<RArray, Error> {
513
+ let (flat, lens) = Self::encode_files_ragged(ruby, rb_self, args)?;
514
+ ragged_result(ruby, flat, lens)
515
+ }
516
+
517
+ /// The packed analog of `encode_files`: same encode core (including the
518
+ /// `parallel:` nil-equals-omitted contract), but the flat token ids are
519
+ /// handed to Ruby as one `IO::Buffer` instead of being re-chunked into
520
+ /// per-document Arrays (see `packed_result`).
521
+ fn encode_files_packed(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<RArray, Error> {
522
+ let (flat, lens) = Self::encode_files_ragged(ruby, rb_self, args)?;
523
+ packed_result(ruby, flat, lens)
524
+ }
525
+
526
+ fn decode(ruby: &Ruby, rb_self: &Self, tokens: RArray) -> Result<RString, Error> {
527
+ let ids: Vec<u32> = tokens.to_vec()?;
528
+ let ids: Vec<_> = ids.into_iter().map(Into::into).collect();
529
+ let bytes: Vec<u8> = rb_self.tokenizer.borrow().decode(&ids).collect();
530
+ Ok(binary_string(ruby, &bytes))
531
+ }
532
+
533
+ fn vocab_size(&self) -> usize {
534
+ self.tokenizer.borrow().vocab_size()
535
+ }
536
+
537
+ fn vocab(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
538
+ let tokenizer = rb_self.tokenizer.borrow();
539
+ let hash = ruby.hash_new();
540
+ for (id, bytes) in tokenizer.vocab_entries() {
541
+ hash.aset(id, binary_string(ruby, bytes))?;
542
+ }
543
+ Ok(hash)
544
+ }
545
+
546
+ fn merges(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
547
+ let tokenizer = rb_self.tokenizer.borrow();
548
+ let entries = tokenizer.merge_entries();
549
+ let result = ruby.ary_new_capa(entries.len());
550
+ for (a, b) in entries {
551
+ result.push((binary_string(ruby, a), binary_string(ruby, b)))?;
552
+ }
553
+ Ok(result)
554
+ }
555
+ }
556
+
557
+ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
558
+ let class: RClass = native.define_class("BPETokenizer", ruby.class_object())?;
559
+ 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))?;
561
+ class.define_method("encode", method!(BPETokenizer::encode, 1))?;
562
+ class.define_method("encode_batch", method!(BPETokenizer::encode_batch, 1))?;
563
+ class.define_method("encode_batch_packed", method!(BPETokenizer::encode_batch_packed, 1))?;
564
+ class.define_method("encode_files", method!(BPETokenizer::encode_files, -1))?;
565
+ class.define_method("encode_files_packed", method!(BPETokenizer::encode_files_packed, -1))?;
566
+ class.define_method("decode", method!(BPETokenizer::decode, 1))?;
567
+ class.define_method("vocab_size", method!(BPETokenizer::vocab_size, 0))?;
568
+ class.define_method("vocab", method!(BPETokenizer::vocab, 0))?;
569
+ class.define_method("merges", method!(BPETokenizer::merges, 0))?;
570
+ Ok(())
571
+ }
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module Gigatoken
6
+ module CLI
7
+ # `gigatoken bench TOKENIZER FILES...` — measure encode throughput in
8
+ # MB/s and Mtok/s, mirroring upstream's Python CLI `gigatoken bench`
9
+ # output shape.
10
+ class Bench < Dry::CLI::Command
11
+ desc "Measure the time to encode FILES with TOKENIZER"
12
+
13
+ argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
14
+ argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
15
+
16
+ option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
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
+ option :parallel, type: :boolean, default: true, desc: "encode on the worker pool instead of the fused serial core path"
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
+
21
+ def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, **)
22
+ limit = Support.parse_size(limit_bytes)
23
+ out.puts "#{label("cpu")}: #{Support.cpu_info}"
24
+
25
+ gt_tokenizer = Support.load_tokenizer(tokenizer)
26
+
27
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ if packed
29
+ encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: parallel, packed: true)
30
+ n_bytes = files.sum { |file| File.size(file) }
31
+ n_tokens = encoded.token_count
32
+ elsif parallel
33
+ docs = Support.subset_docs(Support.split_docs(files, doc_separator), limit)
34
+ encoded = gt_tokenizer.encode_batch(docs)
35
+ n_bytes = docs.sum(&:bytesize)
36
+ n_tokens = encoded.sum(&:length)
37
+ else
38
+ encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: false)
39
+ n_bytes = files.sum { |file| File.size(file) }
40
+ n_tokens = encoded.sum(&:length)
41
+ end
42
+ seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
43
+
44
+ out.puts report("gigatoken", seconds, n_bytes, n_tokens)
45
+ rescue Gigatoken::Error => e
46
+ err.puts "error: #{e.message}"
47
+ exit(1)
48
+ end
49
+
50
+ private
51
+
52
+ def label(name)
53
+ format("%9s", name)
54
+ end
55
+
56
+ def report(name, seconds, n_bytes, n_tokens)
57
+ mb = n_bytes / 1e6
58
+ mtok = n_tokens / 1e6
59
+ format("%s: %8.3f s | %10.2f MB at %8.2f MB/s | %8.2f Mtok at %7.2f Mtok/s",
60
+ label(name), seconds, mb, mb / seconds, mtok, mtok / seconds)
61
+ end
62
+ end
63
+ end
64
+ end