gigatoken 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/Cargo.toml +7 -3
- data/README.md +22 -5
- data/benches/common/mod.rs +105 -0
- data/benches/encode.rs +79 -0
- data/benches/encode_doc.rs +60 -0
- data/benches/encode_st.rs +105 -0
- data/benches/encode_st_sp.rs +51 -0
- data/benches/pretokenize.rs +151 -0
- data/benches/pretokenize_profile.rs +36 -0
- data/benches/pretokenize_profile_all.rs +52 -0
- data/benches/simdutf_transcode.rs +82 -0
- data/benches/unicode.rs +88 -0
- data/ext/gigatoken/Cargo.toml +1 -1
- data/ext/gigatoken/src/error.rs +29 -9
- data/ext/gigatoken/src/gvl.rs +170 -36
- data/ext/gigatoken/src/lib.rs +18 -3
- data/ext/gigatoken/src/sentencepiece.rs +27 -15
- data/ext/gigatoken/src/tokenizer.rs +225 -131
- data/lib/gigatoken/cli/bench.rb +10 -4
- data/lib/gigatoken/cli/support.rb +40 -8
- data/lib/gigatoken/cli/validate.rb +2 -1
- data/lib/gigatoken/encodings.rb +9 -4
- data/lib/gigatoken/hub.rb +240 -38
- data/lib/gigatoken/packed_result.rb +12 -4
- data/lib/gigatoken/tokenizer.rb +85 -15
- data/lib/gigatoken/version.rb +1 -1
- data/lib/gigatoken.rb +17 -1
- data/rust-toolchain.toml +1 -1
- data/src/batch.rs +294 -56
- data/src/bpe/tiktoken.rs +30 -5
- metadata +25 -1
|
@@ -3,17 +3,15 @@
|
|
|
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::sync::RwLock;
|
|
7
6
|
use std::collections::{HashMap, HashSet};
|
|
8
7
|
use std::os::raw::c_long;
|
|
8
|
+
use std::sync::atomic::Ordering;
|
|
9
|
+
use std::sync::{Mutex, MutexGuard, TryLockError};
|
|
9
10
|
|
|
10
11
|
use gigatoken_rs::load_tokenizer::hf::HfTokenizer;
|
|
11
12
|
use gigatoken_rs::load_tokenizer::{hf, tiktoken};
|
|
12
13
|
use gigatoken_rs::pretokenize::PretokenizerType;
|
|
13
|
-
use gigatoken_rs::{
|
|
14
|
-
GatherBuf, GatherOutcome, Tokenizer, WorkerPool, encode_docs_into, encode_docs_ragged,
|
|
15
|
-
encode_files_docs, encode_files_docs_serial,
|
|
16
|
-
};
|
|
14
|
+
use gigatoken_rs::{GatherBuf, GatherOutcome, Tokenizer, WorkerPool};
|
|
17
15
|
use magnus::{
|
|
18
16
|
Error, RArray, RClass, RHash, RModule, RString, Ruby, Value, function, method, prelude::*,
|
|
19
17
|
rb_sys::{AsRawValue, FromRawValue},
|
|
@@ -21,14 +19,29 @@ use magnus::{
|
|
|
21
19
|
};
|
|
22
20
|
use rb_sys::{RSTRING_PTR, rb_ary_dup, rb_str_locktmp, rb_str_set_len, rb_str_unlocktmp};
|
|
23
21
|
|
|
24
|
-
use crate::error::raise;
|
|
25
|
-
use crate::gvl::without_gvl;
|
|
22
|
+
use crate::error::{input_error, model_error, raise};
|
|
23
|
+
use crate::gvl::{without_gvl, without_gvl_cancellable};
|
|
26
24
|
use crate::sources;
|
|
27
25
|
|
|
28
26
|
pub(crate) fn binary_string(ruby: &Ruby, bytes: &[u8]) -> RString {
|
|
29
27
|
ruby.enc_str_new(bytes, ruby.ascii8bit_encoding())
|
|
30
28
|
}
|
|
31
29
|
|
|
30
|
+
/// Reject a `decode` argument holding an id the vocabulary does not reach.
|
|
31
|
+
/// Both backends' cores answer such an id with no bytes rather than indexing
|
|
32
|
+
/// out of bounds (see the core's `Tokenizer::decode`), so the only place that
|
|
33
|
+
/// can tell the caller what went wrong is here, at the boundary the id came
|
|
34
|
+
/// in through. Shared with `crate::sentencepiece`.
|
|
35
|
+
pub(crate) fn require_known_ids(ruby: &Ruby, ids: &[u32], vocab_size: usize) -> Result<(), Error> {
|
|
36
|
+
match ids.iter().find(|&&id| id as usize >= vocab_size) {
|
|
37
|
+
Some(id) => Err(input_error(
|
|
38
|
+
ruby,
|
|
39
|
+
format!("token id {id} is outside the vocabulary (0...{vocab_size})"),
|
|
40
|
+
)),
|
|
41
|
+
None => Ok(()),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/// Reinterpret a `Vec<u32>` as raw bytes in the host's native byte order.
|
|
33
46
|
/// Safe: `u32` has no padding or niches, so any of its byte patterns is a
|
|
34
47
|
/// valid `u8`, and `u8`'s alignment (1) never exceeds `u32`'s.
|
|
@@ -43,7 +56,7 @@ pub(crate) fn ragged_result(ruby: &Ruby, flat: Vec<u32>, lens: Vec<i64>) -> Resu
|
|
|
43
56
|
let mut offset = 0usize;
|
|
44
57
|
for len in lens {
|
|
45
58
|
let len = len as usize;
|
|
46
|
-
result.push(flat[offset..offset + len].
|
|
59
|
+
result.push(ruby.ary_from_iter(flat[offset..offset + len].iter().copied()))?;
|
|
47
60
|
offset += len;
|
|
48
61
|
}
|
|
49
62
|
Ok(result)
|
|
@@ -207,45 +220,60 @@ impl Drop for InputDocs {
|
|
|
207
220
|
/// that can allocate and trigger GC compaction), since compaction rewrites a
|
|
208
221
|
/// moved element's slot in place. `Value`'s own `TryConvert` is an
|
|
209
222
|
/// infallible identity conversion, so this can't fail.
|
|
210
|
-
fn snapshot_entry(snapshot: RArray, index: usize) -> Value {
|
|
223
|
+
pub(crate) fn snapshot_entry(snapshot: RArray, index: usize) -> Value {
|
|
211
224
|
snapshot.entry(index as isize).expect("Value's TryConvert is infallible")
|
|
212
225
|
}
|
|
213
226
|
|
|
227
|
+
/// `rb_ary_dup` `inputs` into a snapshot: a C-level shallow copy of the
|
|
228
|
+
/// array's slots that runs no user code (not even `initialize_copy`). Every
|
|
229
|
+
/// pass that follows reads the snapshot's slots via [`snapshot_entry`], never
|
|
230
|
+
/// `inputs` again, which is what closes three hazards a direct-`inputs`
|
|
231
|
+
/// walk has (I19 Verdict):
|
|
232
|
+
///
|
|
233
|
+
/// 1. a `to_str` conversion for one element can run arbitrary Ruby code,
|
|
234
|
+
/// including code that mutates or replaces later elements of the caller's
|
|
235
|
+
/// array out from under an in-progress pass;
|
|
236
|
+
/// 2. while the GVL is released for the encode itself, another Ruby thread
|
|
237
|
+
/// can replace or clear the caller's slots, making a borrowed string
|
|
238
|
+
/// collectible mid-encode;
|
|
239
|
+
/// 3. holding one Rust borrow of the caller's array buffer across a `to_str`
|
|
240
|
+
/// call is unsound if that call resizes the array.
|
|
241
|
+
///
|
|
242
|
+
/// None of these can reach the snapshot: no Ruby code holds a reference to
|
|
243
|
+
/// it, so nothing can mutate, resize or replace its slots from Ruby, and the
|
|
244
|
+
/// returned `RArray` stays alive as a conservatively-scanned stack root for
|
|
245
|
+
/// as long as the caller keeps it — which is also, incidentally, why
|
|
246
|
+
/// compaction never relocates the snapshot array itself. Its *slots* remain
|
|
247
|
+
/// ordinary, precisely-marked Ruby state, though, and compaction does
|
|
248
|
+
/// rewrite a slot in place when that element's RVALUE moves, hence
|
|
249
|
+
/// [`snapshot_entry`] rather than a cached `Value`.
|
|
250
|
+
///
|
|
251
|
+
/// One consequence is now a pinned public contract for both backends'
|
|
252
|
+
/// `encode_batch`: the result reflects the input array as it was *at the
|
|
253
|
+
/// call's entry*. A pathological `to_str` that mutates the caller's array
|
|
254
|
+
/// mid-marshal can no longer change which documents get encoded — the
|
|
255
|
+
/// caller's own mutations remain visible to the caller afterwards, just no
|
|
256
|
+
/// longer to this encode.
|
|
257
|
+
pub(crate) fn snapshot_inputs(inputs: RArray) -> RArray {
|
|
258
|
+
// SAFETY: `inputs.as_raw()` is a live, array-typed VALUE for the
|
|
259
|
+
// duration of this synchronous, GVL-held call; `rb_ary_dup` only reads
|
|
260
|
+
// it and allocates a fresh Array via a C-level shallow slot copy that
|
|
261
|
+
// runs no user code, so nothing here can run arbitrary Ruby code or
|
|
262
|
+
// raise. Its result is always an Array, per the Ruby C API.
|
|
263
|
+
RArray::from_value(unsafe { Value::from_raw(rb_ary_dup(inputs.as_raw())) })
|
|
264
|
+
.expect("rb_ary_dup's result is always an Array")
|
|
265
|
+
}
|
|
266
|
+
|
|
214
267
|
/// Marshal `inputs` (an Array of Strings, or objects converting to one via
|
|
215
268
|
/// `to_str`) into `InputDocs`, borrowing zero-copy wherever it's sound
|
|
216
269
|
/// instead of copying.
|
|
217
270
|
///
|
|
218
|
-
/// The first thing this does is
|
|
219
|
-
///
|
|
220
|
-
///
|
|
221
|
-
///
|
|
222
|
-
///
|
|
223
|
-
///
|
|
224
|
-
/// Verdict): (1) a `to_str` conversion for one element can run arbitrary
|
|
225
|
-
/// Ruby code, including code that mutates or replaces later elements of the
|
|
226
|
-
/// caller's array out from under an in-progress classify/lock pass; (2)
|
|
227
|
-
/// while the GVL is released for the encode itself, another Ruby thread can
|
|
228
|
-
/// replace or clear the caller's slots, making a borrowed string
|
|
229
|
-
/// collectible mid-encode; (3) holding one Rust borrow of the caller's
|
|
230
|
-
/// array buffer across a `to_str` call is unsound if that call resizes the
|
|
231
|
-
/// array. None of these can reach the snapshot: no Ruby code holds a
|
|
232
|
-
/// reference to it (so nothing can mutate, resize, or replace its slots
|
|
233
|
-
/// from Ruby), and the snapshot `RArray` local stays alive as a
|
|
234
|
-
/// conservatively-scanned stack root the whole call through — via
|
|
235
|
-
/// `InputDocs::snapshot`, kept in the calling frame including across the
|
|
236
|
-
/// `without_gvl` window — which is also, incidentally, why compaction never
|
|
237
|
-
/// relocates the snapshot array itself. The snapshot's *slots* remain
|
|
238
|
-
/// ordinary, precisely-marked Ruby state, though, and compaction does
|
|
239
|
-
/// rewrite a slot in place when that element's RVALUE moves — so every pass
|
|
240
|
-
/// below re-reads a slot fresh (`snapshot_entry`) rather than reusing a
|
|
241
|
-
/// `Value` obtained before a Ruby-code-running call, and never holds a
|
|
242
|
-
/// `snapshot.as_slice()` borrow across one.
|
|
243
|
-
///
|
|
244
|
-
/// One consequence is now a pinned public contract: `encode_batch`'s result
|
|
245
|
-
/// reflects the input array as it was *at this call's entry*. A
|
|
246
|
-
/// pathological `to_str` that mutates the caller's array mid-marshal can no
|
|
247
|
-
/// longer change which documents get encoded — the caller's own mutations
|
|
248
|
-
/// remain visible to the caller afterwards, just no longer to this encode.
|
|
271
|
+
/// The first thing this does is take a [`snapshot_inputs`] copy of the
|
|
272
|
+
/// caller's array; from that point on, every pass below — classification,
|
|
273
|
+
/// `to_str` conversion, locking, and `InputDocs`'s Drop — reads exclusively
|
|
274
|
+
/// from the snapshot's slots, and `inputs` itself is never read again. The
|
|
275
|
+
/// snapshot stays alive for the whole call, including across the
|
|
276
|
+
/// `without_gvl` window, as `InputDocs::snapshot`.
|
|
249
277
|
///
|
|
250
278
|
/// - a heap (non-embedded) `RString` that's frozen is borrowed unlocked —
|
|
251
279
|
/// its immutability is itself the guard, and `rb_str_locktmp` isn't legal
|
|
@@ -274,13 +302,7 @@ fn snapshot_entry(snapshot: RArray, index: usize) -> Value {
|
|
|
274
302
|
/// `Value` read before a `to_str` call can go stale under compaction before
|
|
275
303
|
/// the lock pass gets to it.
|
|
276
304
|
fn marshal_inputs(inputs: RArray) -> Result<InputDocs, Error> {
|
|
277
|
-
|
|
278
|
-
// duration of this synchronous, GVL-held call; `rb_ary_dup` only reads
|
|
279
|
-
// it and allocates a fresh Array via a C-level shallow slot copy that
|
|
280
|
-
// runs no user code, so nothing here can run arbitrary Ruby code or
|
|
281
|
-
// raise. Its result is always an Array, per the Ruby C API.
|
|
282
|
-
let snapshot = RArray::from_value(unsafe { Value::from_raw(rb_ary_dup(inputs.as_raw())) })
|
|
283
|
-
.expect("rb_ary_dup's result is always an Array");
|
|
305
|
+
let snapshot = snapshot_inputs(inputs);
|
|
284
306
|
let len = snapshot.len();
|
|
285
307
|
|
|
286
308
|
enum Classified {
|
|
@@ -351,16 +373,39 @@ fn marshal_inputs(inputs: RArray) -> Result<InputDocs, Error> {
|
|
|
351
373
|
})
|
|
352
374
|
}
|
|
353
375
|
|
|
376
|
+
/// The invariant every path below holds: **no code path blocks on a lock
|
|
377
|
+
/// while holding the GVL.** A thread that parks with the GVL in hand stops
|
|
378
|
+
/// the whole VM — including the thread it is waiting for, which needs the
|
|
379
|
+
/// GVL to finish and release what it wants. So: the readers take no lock at
|
|
380
|
+
/// all, `encode` takes the single-document worker with `try_lock` and moves
|
|
381
|
+
/// the wait inside `without_gvl` when that fails, and `cache_entries`'
|
|
382
|
+
/// blocking lock is inside `without_gvl` too.
|
|
354
383
|
#[magnus::wrap(class = "Gigatoken::Native::BPETokenizer", free_immediately, size)]
|
|
355
384
|
pub struct BPETokenizer {
|
|
356
|
-
|
|
385
|
+
/// The loaded model, never mutated after construction — the loaders do
|
|
386
|
+
/// all of their mutating (`apply_max_cache_bytes`, `from_tiktoken`'s
|
|
387
|
+
/// special tokens) before it gets here — so the batch paths, `decode`,
|
|
388
|
+
/// `vocab`, `vocab_size`, `merges` and `cache_entries` just borrow it.
|
|
389
|
+
/// It is also the prototype the workers below fork from, which
|
|
390
|
+
/// `WorkerPool`'s type-level invariant requires stay unmutated.
|
|
391
|
+
tokenizer: Tokenizer,
|
|
392
|
+
/// The single-document `encode` path's own worker: a fork of the
|
|
393
|
+
/// prototype (model tables shared by `Arc`, its own pretoken cache),
|
|
394
|
+
/// behind a `Mutex` because Ruby hands one instance to every thread.
|
|
395
|
+
/// Same shape as `WorkerPool`'s serial worker — forked lazily, so a
|
|
396
|
+
/// tokenizer that only ever batches never pays for one, and rebuilt if
|
|
397
|
+
/// a panic poisons it — but its own worker rather than that one, since
|
|
398
|
+
/// a sequential `encode_files` can hold the pool's for minutes and
|
|
399
|
+
/// `encode` must never wait that long for a `try_lock`.
|
|
400
|
+
single: Mutex<Option<Tokenizer>>,
|
|
357
401
|
workers: WorkerPool,
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
impl BPETokenizer {
|
|
361
405
|
pub(crate) fn from_tokenizer(tokenizer: Tokenizer) -> Self {
|
|
362
406
|
Self {
|
|
363
|
-
tokenizer:
|
|
407
|
+
tokenizer: crate::cache::apply_max_cache_bytes(tokenizer),
|
|
408
|
+
single: Mutex::new(None),
|
|
364
409
|
workers: WorkerPool::new(),
|
|
365
410
|
}
|
|
366
411
|
}
|
|
@@ -371,12 +416,12 @@ impl BPETokenizer {
|
|
|
371
416
|
let bytes = unsafe { data.as_slice() };
|
|
372
417
|
match hf::load_hf_slice(bytes) {
|
|
373
418
|
Ok(HfTokenizer::Bpe(tokenizer)) => Ok(Self::from_tokenizer(tokenizer)),
|
|
374
|
-
Ok(HfTokenizer::SentencePiece(_)) => Err(
|
|
419
|
+
Ok(HfTokenizer::SentencePiece(_)) => Err(model_error(
|
|
375
420
|
ruby,
|
|
376
421
|
"SentencePiece tokenizer.json data loads as a SentencePieceTokenizer, not a \
|
|
377
422
|
BPETokenizer — use Gigatoken::Native.load_hf_json instead",
|
|
378
423
|
)),
|
|
379
|
-
Err(e) => Err(
|
|
424
|
+
Err(e) => Err(model_error(ruby, e.to_string())),
|
|
380
425
|
}
|
|
381
426
|
}
|
|
382
427
|
|
|
@@ -392,7 +437,7 @@ impl BPETokenizer {
|
|
|
392
437
|
special_tokens: HashMap<String, u32>,
|
|
393
438
|
) -> Result<Self, Error> {
|
|
394
439
|
let scheme = PretokenizerType::from_name(&pretokenizer).ok_or_else(|| {
|
|
395
|
-
|
|
440
|
+
model_error(
|
|
396
441
|
ruby,
|
|
397
442
|
format!(
|
|
398
443
|
"unknown pretokenizer scheme {pretokenizer:?}; expected one of {}",
|
|
@@ -403,64 +448,81 @@ impl BPETokenizer {
|
|
|
403
448
|
let special_tokens: Vec<(String, u32)> = special_tokens.into_iter().collect();
|
|
404
449
|
match tiktoken::load_tiktoken(&path, scheme, special_tokens) {
|
|
405
450
|
Ok(tokenizer) => Ok(Self::from_tokenizer(tokenizer)),
|
|
406
|
-
Err(e) => Err(
|
|
451
|
+
Err(e) => Err(model_error(ruby, format!("{path}: {e}"))),
|
|
407
452
|
}
|
|
408
453
|
}
|
|
409
454
|
|
|
410
|
-
///
|
|
411
|
-
///
|
|
412
|
-
///
|
|
413
|
-
///
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
455
|
+
/// Take the single-document worker, or `None` if another thread holds
|
|
456
|
+
/// it. A poisoned worker panicked mid-encode and its cache may be
|
|
457
|
+
/// inconsistent, so it is dropped and re-forked — what `WorkerPool`
|
|
458
|
+
/// does with its own.
|
|
459
|
+
fn try_take_worker(&self) -> Option<MutexGuard<'_, Option<Tokenizer>>> {
|
|
460
|
+
match self.single.try_lock() {
|
|
461
|
+
Ok(guard) => Some(guard),
|
|
462
|
+
Err(TryLockError::Poisoned(poisoned)) => {
|
|
463
|
+
let mut guard = poisoned.into_inner();
|
|
464
|
+
*guard = None;
|
|
465
|
+
Some(guard)
|
|
466
|
+
}
|
|
467
|
+
Err(TryLockError::WouldBlock) => None,
|
|
468
|
+
}
|
|
420
469
|
}
|
|
421
470
|
|
|
422
|
-
/// Encode
|
|
423
|
-
///
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
471
|
+
/// Encode `bytes` on the worker held by `guard`, forking it from the
|
|
472
|
+
/// prototype on first use.
|
|
473
|
+
fn encode_on(&self, guard: &mut Option<Tokenizer>, bytes: &[u8]) -> Vec<u32> {
|
|
474
|
+
let mut out = Vec::new();
|
|
475
|
+
guard
|
|
476
|
+
.get_or_insert_with(|| self.tokenizer.fork())
|
|
477
|
+
.encode_with_added_tokens_flat(bytes, &mut out);
|
|
478
|
+
out
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Encode one string on the single-document worker, mutating that
|
|
482
|
+
/// worker's pretoken cache.
|
|
429
483
|
///
|
|
430
|
-
///
|
|
431
|
-
///
|
|
432
|
-
///
|
|
433
|
-
///
|
|
434
|
-
///
|
|
435
|
-
///
|
|
436
|
-
///
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
484
|
+
/// Uncontended — every single-threaded caller, and the common case under
|
|
485
|
+
/// threads — this is one `try_lock` plus the encode, against the Ruby
|
|
486
|
+
/// string's own bytes, with the GVL held throughout. Ruby only switches
|
|
487
|
+
/// threads at its own checkpoints and this call has none, so the only
|
|
488
|
+
/// way to lose the `try_lock` is against a thread that took the worker
|
|
489
|
+
/// with the GVL released: [`Self::encode_contended`] or
|
|
490
|
+
/// [`Self::cache_entries`].
|
|
491
|
+
fn encode(&self, input: RString) -> Result<Vec<u32>, Error> {
|
|
492
|
+
match self.try_take_worker() {
|
|
493
|
+
Some(mut guard) => {
|
|
494
|
+
// SAFETY: read-only, for the duration of this synchronous
|
|
495
|
+
// call, with no GVL release in between.
|
|
496
|
+
let bytes = unsafe { input.as_slice() };
|
|
497
|
+
Ok(self.encode_on(&mut guard, bytes))
|
|
498
|
+
}
|
|
499
|
+
None => self.encode_contended(input),
|
|
446
500
|
}
|
|
447
|
-
|
|
448
|
-
self.encode_contended(input)
|
|
449
501
|
}
|
|
450
502
|
|
|
451
503
|
/// The contended half of [`Self::encode`], outlined and `#[cold]`.
|
|
452
504
|
///
|
|
505
|
+
/// The holder needs no GVL to finish, so waiting for it with the GVL
|
|
506
|
+
/// held would stall the VM for a whole document's encode. Instead the
|
|
507
|
+
/// input is copied and the wait moves inside `without_gvl`, where it
|
|
508
|
+
/// polls with `try_lock` rather than parking — so a pending interrupt
|
|
509
|
+
/// still cancels it (the token comes from `gvl`'s unblock function).
|
|
510
|
+
/// The copy is what makes that sound: no Ruby `VALUE` and no `RString`
|
|
511
|
+
/// buffer may outlive the release (see `marshal_inputs`), and the guard
|
|
512
|
+
/// is taken and dropped inside the closure, so it never crosses OS
|
|
513
|
+
/// threads even when the scheduler offloads it (see `gvl`).
|
|
514
|
+
///
|
|
453
515
|
/// Keeping this out of `encode`'s body is a measured requirement, not
|
|
454
516
|
/// tidiness: the workspace builds with `lto = "fat"`, so the core encode
|
|
455
517
|
/// routine inlines into `encode`, and inlining is sensitive to the caller's
|
|
456
518
|
/// size. Written inline, this second path measured slower on single
|
|
457
519
|
/// encodes — no lock overhead, just a flipped inlining decision. Outlined,
|
|
458
|
-
/// `encode`'s hot body is the original three lines behind a `
|
|
520
|
+
/// `encode`'s hot body is the original three lines behind a `try_lock`.
|
|
459
521
|
///
|
|
460
522
|
/// Before you re-inline this "to simplify": rerun the evidence rather than
|
|
461
523
|
/// trusting a number. `ruby -Ilib bench/encode_ab.rb` with the attributes
|
|
462
524
|
/// stripped and again with them restored, and read
|
|
463
|
-
/// `docs/
|
|
525
|
+
/// `docs/explanation/benchmarks.md` first — no size resolves this on the hardware
|
|
464
526
|
/// measured so far. The instrument is honest (an interleaved same-build
|
|
465
527
|
/// run never calls a size faster or slower, at any size) and has power to
|
|
466
528
|
/// catch a couple-percent effect reliably, but the attributes' real
|
|
@@ -472,30 +534,38 @@ impl BPETokenizer {
|
|
|
472
534
|
/// inline-regression measurement, not on a pinned-down magnitude.
|
|
473
535
|
#[cold]
|
|
474
536
|
#[inline(never)]
|
|
475
|
-
fn encode_contended(&self, input: RString) -> Vec<u32> {
|
|
537
|
+
fn encode_contended(&self, input: RString) -> Result<Vec<u32>, Error> {
|
|
476
538
|
// SAFETY: copied before any GVL release, so nothing Ruby-owned is
|
|
477
539
|
// captured by the closure below.
|
|
478
540
|
let owned = unsafe { input.as_slice() }.to_vec();
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
541
|
+
without_gvl_cancellable(|| {
|
|
542
|
+
|cancel| loop {
|
|
543
|
+
if let Some(mut guard) = self.try_take_worker() {
|
|
544
|
+
return Some(self.encode_on(&mut guard, &owned));
|
|
545
|
+
}
|
|
546
|
+
if cancel.load(Ordering::Relaxed) {
|
|
547
|
+
return None;
|
|
548
|
+
}
|
|
549
|
+
std::thread::yield_now();
|
|
550
|
+
}
|
|
484
551
|
})
|
|
485
552
|
}
|
|
486
553
|
|
|
487
554
|
/// Encode a batch on the core worker pool, with the GVL released for the
|
|
488
|
-
/// parallel encode itself (see `gvl::
|
|
489
|
-
///
|
|
490
|
-
///
|
|
491
|
-
///
|
|
555
|
+
/// parallel encode itself (see `gvl::without_gvl_cancellable`, which
|
|
556
|
+
/// also makes an interrupt arriving mid-batch cancel it at the next
|
|
557
|
+
/// document boundary). Each input string is borrowed zero-copy where
|
|
558
|
+
/// `marshal_inputs` finds it sound to, and copied into an owned buffer
|
|
559
|
+
/// otherwise; either way, only raw byte slices — never a Ruby `VALUE` —
|
|
560
|
+
/// are captured once the GVL is gone.
|
|
492
561
|
fn encode_batch_ragged(rb_self: &Self, inputs: RArray) -> Result<(Vec<u32>, Vec<i64>), Error> {
|
|
493
562
|
let marshaled = marshal_inputs(inputs)?;
|
|
494
563
|
let doc_slices = marshaled.as_slices();
|
|
495
|
-
let tokenizer = rb_self.
|
|
496
|
-
let tokenizer: &Tokenizer = &tokenizer;
|
|
564
|
+
let tokenizer = &rb_self.tokenizer;
|
|
497
565
|
let workers = &rb_self.workers;
|
|
498
|
-
|
|
566
|
+
without_gvl_cancellable(|| {
|
|
567
|
+
|cancel| workers.encode_docs_ragged_cancellable(tokenizer, &doc_slices, cancel)
|
|
568
|
+
})
|
|
499
569
|
}
|
|
500
570
|
|
|
501
571
|
fn encode_batch(ruby: &Ruby, rb_self: &Self, inputs: RArray) -> Result<RArray, Error> {
|
|
@@ -537,14 +607,20 @@ impl BPETokenizer {
|
|
|
537
607
|
// frozen, not wrapped), so nothing else can read or write through it
|
|
538
608
|
// concurrently.
|
|
539
609
|
let ptr = unsafe { RSTRING_PTR(string.as_raw()) as *mut u32 };
|
|
540
|
-
// SAFETY: `ptr` is valid for `total_bytes` disjoint u32 writes for
|
|
541
|
-
// the duration of the gather below (see the allocation above).
|
|
542
|
-
let dest = unsafe { GatherBuf::new(ptr, total_bytes) };
|
|
543
610
|
|
|
544
|
-
let tokenizer = rb_self.
|
|
545
|
-
let tokenizer: &Tokenizer = &tokenizer;
|
|
611
|
+
let tokenizer = &rb_self.tokenizer;
|
|
546
612
|
let workers = &rb_self.workers;
|
|
547
|
-
|
|
613
|
+
let docs: &[&[u8]] = &doc_slices;
|
|
614
|
+
let gathered = without_gvl_cancellable(|| {
|
|
615
|
+
// SAFETY: `ptr` is valid for `total_bytes` disjoint u32 writes
|
|
616
|
+
// for the duration of the gather below (see the allocation
|
|
617
|
+
// above). A cancelled attempt leaves the destination partly
|
|
618
|
+
// written and unusable; the retry (see `without_gvl_cancellable`)
|
|
619
|
+
// starts a fresh gather over the same, still unexposed, buffer.
|
|
620
|
+
let dest = unsafe { GatherBuf::new(ptr, total_bytes) };
|
|
621
|
+
move |cancel| workers.encode_docs_into_cancellable(tokenizer, docs, dest, cancel)
|
|
622
|
+
})?;
|
|
623
|
+
match gathered {
|
|
548
624
|
GatherOutcome::Committed(total_tokens, lens) => {
|
|
549
625
|
// SAFETY: `encode_docs_into` only returns `Committed` once
|
|
550
626
|
// every one of `total_tokens` u32s at `ptr` has been
|
|
@@ -582,18 +658,28 @@ impl BPETokenizer {
|
|
|
582
658
|
};
|
|
583
659
|
|
|
584
660
|
let source = sources::resolve(ruby, source)?;
|
|
585
|
-
let tokenizer = rb_self.
|
|
586
|
-
let tokenizer: &Tokenizer = &tokenizer;
|
|
661
|
+
let tokenizer = &rb_self.tokenizer;
|
|
587
662
|
let workers = &rb_self.workers;
|
|
588
|
-
let encoded
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
663
|
+
let encoded = without_gvl_cancellable(|| {
|
|
664
|
+
|cancel| {
|
|
665
|
+
// `sources::encode_files_ragged`'s callback owes it a
|
|
666
|
+
// `(flat, lens)`, so a cancelled encode reports itself here
|
|
667
|
+
// instead of through the return value. The tokens it hands
|
|
668
|
+
// back are a partial run, thrown away with the `None`.
|
|
669
|
+
let mut cancelled = false;
|
|
670
|
+
let encoded = sources::encode_files_ragged(&source, parallel, |files, format| {
|
|
671
|
+
let tokens = if parallel {
|
|
672
|
+
workers.encode_files_docs_cancellable(tokenizer, files, format, cancel)
|
|
673
|
+
} else {
|
|
674
|
+
workers
|
|
675
|
+
.encode_files_docs_serial_cancellable(tokenizer, files, format, cancel)
|
|
676
|
+
};
|
|
677
|
+
cancelled = tokens.is_none();
|
|
678
|
+
Ok(tokens.unwrap_or_default())
|
|
679
|
+
});
|
|
680
|
+
(!cancelled).then_some(encoded)
|
|
681
|
+
}
|
|
682
|
+
})?;
|
|
597
683
|
encoded.map_err(|e| raise(ruby, e.to_string()))
|
|
598
684
|
}
|
|
599
685
|
|
|
@@ -613,27 +699,26 @@ impl BPETokenizer {
|
|
|
613
699
|
|
|
614
700
|
fn decode(ruby: &Ruby, rb_self: &Self, tokens: RArray) -> Result<RString, Error> {
|
|
615
701
|
let ids: Vec<u32> = tokens.to_vec()?;
|
|
702
|
+
require_known_ids(ruby, &ids, rb_self.tokenizer.vocab_size())?;
|
|
616
703
|
let ids: Vec<_> = ids.into_iter().map(Into::into).collect();
|
|
617
|
-
let bytes: Vec<u8> = rb_self.
|
|
704
|
+
let bytes: Vec<u8> = rb_self.tokenizer.decode(&ids).collect();
|
|
618
705
|
Ok(binary_string(ruby, &bytes))
|
|
619
706
|
}
|
|
620
707
|
|
|
621
708
|
fn vocab_size(&self) -> usize {
|
|
622
|
-
self.
|
|
709
|
+
self.tokenizer.vocab_size()
|
|
623
710
|
}
|
|
624
711
|
|
|
625
712
|
fn vocab(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
|
|
626
|
-
let tokenizer = rb_self.read_tokenizer();
|
|
627
713
|
let hash = ruby.hash_new();
|
|
628
|
-
for (id, bytes) in tokenizer.vocab_entries() {
|
|
714
|
+
for (id, bytes) in rb_self.tokenizer.vocab_entries() {
|
|
629
715
|
hash.aset(id, binary_string(ruby, bytes))?;
|
|
630
716
|
}
|
|
631
717
|
Ok(hash)
|
|
632
718
|
}
|
|
633
719
|
|
|
634
720
|
fn merges(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
|
|
635
|
-
let
|
|
636
|
-
let entries = tokenizer.merge_entries();
|
|
721
|
+
let entries = rb_self.tokenizer.merge_entries();
|
|
637
722
|
let result = ruby.ary_new_capa(entries.len());
|
|
638
723
|
for (a, b) in entries {
|
|
639
724
|
result.push((binary_string(ruby, a), binary_string(ruby, b)))?;
|
|
@@ -641,11 +726,20 @@ impl BPETokenizer {
|
|
|
641
726
|
Ok(result)
|
|
642
727
|
}
|
|
643
728
|
|
|
644
|
-
/// Cached pretoken entries on
|
|
645
|
-
/// drops back toward vocab-seed level
|
|
646
|
-
/// `Gigatoken.max_cache_bytes`).
|
|
647
|
-
|
|
648
|
-
|
|
729
|
+
/// Cached pretoken entries on the single-document `encode` path's
|
|
730
|
+
/// worker: grows as text is encoded, drops back toward vocab-seed level
|
|
731
|
+
/// when a budgeted cache wipes (see `Gigatoken.max_cache_bytes`). Before
|
|
732
|
+
/// that worker's first encode there is no fork yet, so this reports the
|
|
733
|
+
/// prototype's seed — the count the fork will start from.
|
|
734
|
+
///
|
|
735
|
+
/// Blocks for the worker rather than skipping a busy one, which is why
|
|
736
|
+
/// it releases the GVL first: the holder is an encode that needs no GVL
|
|
737
|
+
/// to finish, and waiting for it with the GVL held would stall the VM.
|
|
738
|
+
fn cache_entries(&self) -> Result<usize, Error> {
|
|
739
|
+
without_gvl(|| {
|
|
740
|
+
let guard = self.single.lock().unwrap_or_else(|e| e.into_inner());
|
|
741
|
+
guard.as_ref().unwrap_or(&self.tokenizer).cache_entries()
|
|
742
|
+
})
|
|
649
743
|
}
|
|
650
744
|
}
|
|
651
745
|
|
data/lib/gigatoken/cli/bench.rb
CHANGED
|
@@ -20,30 +20,36 @@ module Gigatoken
|
|
|
20
20
|
option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
|
|
21
21
|
|
|
22
22
|
def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, pretokenizer: nil, **)
|
|
23
|
+
Support.check_usage!(files, doc_separator)
|
|
23
24
|
limit = Support.parse_size(limit_bytes)
|
|
24
25
|
out.puts "#{label("cpu")}: #{Support.cpu_info}"
|
|
25
26
|
|
|
26
27
|
gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
|
|
27
28
|
|
|
29
|
+
# Only the batch path materializes the documents; the native paths
|
|
30
|
+
# leave this nil and count their bytes off the clock below.
|
|
31
|
+
docs = nil
|
|
28
32
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
33
|
if packed
|
|
30
34
|
encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: parallel, packed: true)
|
|
31
|
-
n_bytes = files.sum { |file| File.size(file) }
|
|
32
35
|
n_tokens = encoded.token_count
|
|
33
36
|
elsif parallel
|
|
34
37
|
docs = Support.subset_docs(Support.split_docs(files, doc_separator), limit)
|
|
35
38
|
encoded = gt_tokenizer.encode_batch(docs)
|
|
36
|
-
n_bytes = docs.sum(&:bytesize)
|
|
37
39
|
n_tokens = encoded.sum(&:length)
|
|
38
40
|
else
|
|
39
41
|
encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: false)
|
|
40
|
-
n_bytes = files.sum { |file| File.size(file) }
|
|
41
42
|
n_tokens = encoded.sum(&:length)
|
|
42
43
|
end
|
|
43
44
|
seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
44
45
|
|
|
46
|
+
# The throughput is over the bytes encoded, which for a compressed
|
|
47
|
+
# file are its decompressed bytes. Counting those means reading the
|
|
48
|
+
# file, so the native paths count off the clock; the batch path
|
|
49
|
+
# already holds the documents it encoded.
|
|
50
|
+
n_bytes = docs ? docs.sum(&:bytesize) : Support.input_bytesize(files)
|
|
45
51
|
out.puts report("gigatoken", seconds, n_bytes, n_tokens)
|
|
46
|
-
rescue Gigatoken::Error => e
|
|
52
|
+
rescue Gigatoken::Error, SystemCallError => e
|
|
47
53
|
err.puts "error: #{e.message}"
|
|
48
54
|
exit(1)
|
|
49
55
|
end
|