gigatoken 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Cargo.lock +3016 -0
- data/Cargo.toml +135 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/gigatoken +9 -0
- data/ext/gigatoken/Cargo.toml +24 -0
- data/ext/gigatoken/extconf.rb +19 -0
- data/ext/gigatoken/src/error.rs +20 -0
- data/ext/gigatoken/src/gvl.rs +122 -0
- data/ext/gigatoken/src/lib.rs +50 -0
- data/ext/gigatoken/src/sentencepiece.rs +205 -0
- data/ext/gigatoken/src/sources.rs +207 -0
- data/ext/gigatoken/src/tokenizer.rs +571 -0
- data/lib/gigatoken/cli/bench.rb +64 -0
- data/lib/gigatoken/cli/support.rb +132 -0
- data/lib/gigatoken/cli/validate.rb +55 -0
- data/lib/gigatoken/cli.rb +18 -0
- data/lib/gigatoken/hub.rb +242 -0
- data/lib/gigatoken/packed_result.rb +48 -0
- data/lib/gigatoken/tokenizer.rb +117 -0
- data/lib/gigatoken/version.rb +5 -0
- data/lib/gigatoken.rb +29 -0
- data/rust-toolchain.toml +8 -0
- data/src/batch.rs +1808 -0
- data/src/bindings/bridge.rs +396 -0
- data/src/bindings/hub.rs +42 -0
- data/src/bindings/matcher.rs +114 -0
- data/src/bindings/mod.rs +14 -0
- data/src/bindings/padding.rs +177 -0
- data/src/bindings/pretokenize.rs +53 -0
- data/src/bindings/sources.rs +273 -0
- data/src/bindings/train.rs +125 -0
- data/src/bpe/mod.rs +1217 -0
- data/src/bpe/pretoken_cache.rs +495 -0
- data/src/bpe/sentencepiece.rs +1485 -0
- data/src/bpe/tiktoken.rs +2555 -0
- data/src/bpe_train.rs +351 -0
- data/src/input/decompress.rs +11 -0
- data/src/input/file_source.rs +514 -0
- data/src/input/jsonl.rs +94 -0
- data/src/input/mod.rs +333 -0
- data/src/input/parquet.rs +303 -0
- data/src/lib.rs +578 -0
- data/src/load_tokenizer/hf.rs +1036 -0
- data/src/load_tokenizer/hub.rs +344 -0
- data/src/load_tokenizer/mod.rs +3 -0
- data/src/load_tokenizer/tiktoken.rs +87 -0
- data/src/main.rs +95 -0
- data/src/pretokenize/fast/cl100k.rs +426 -0
- data/src/pretokenize/fast/cl100k_family.rs +891 -0
- data/src/pretokenize/fast/deepseek_v3.rs +605 -0
- data/src/pretokenize/fast/kimi.rs +281 -0
- data/src/pretokenize/fast/mask.rs +1486 -0
- data/src/pretokenize/fast/mod.rs +446 -0
- data/src/pretokenize/fast/nemotron.rs +138 -0
- data/src/pretokenize/fast/o200k.rs +347 -0
- data/src/pretokenize/fast/o200k_family.rs +1734 -0
- data/src/pretokenize/fast/olmo3.rs +505 -0
- data/src/pretokenize/fast/qwen2.rs +429 -0
- data/src/pretokenize/fast/qwen3_5.rs +541 -0
- data/src/pretokenize/fast/r50k.rs +1250 -0
- data/src/pretokenize/mod.rs +1079 -0
- data/src/pretokenize/options.rs +188 -0
- data/src/pretokenize/pretoken.rs +20 -0
- data/src/pretokenize/pretokenize_traits.rs +49 -0
- data/src/pretokenize/reference/avx512.rs +522 -0
- data/src/pretokenize/reference/combinator.rs +572 -0
- data/src/pretokenize/reference/mod.rs +28 -0
- data/src/pretokenize/reference/simd.rs +852 -0
- data/src/pretokenize/reference/state_machine.rs +365 -0
- data/src/pretokenize/unicode.rs +546 -0
- data/src/test_hub.rs +28 -0
- data/src/token.rs +42 -0
- metadata +161 -0
|
@@ -0,0 +1,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
|