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.
@@ -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].to_vec())?;
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 `rb_ary_dup` `inputs` into a snapshot: a
219
- /// C-level shallow copy of the array's slots that runs no user code (not
220
- /// even `initialize_copy`). From that point on, every pass below —
221
- /// classification, `to_str` conversion, locking, and `InputDocs`'s Drop —
222
- /// reads exclusively from the snapshot's slots; `inputs` itself is never
223
- /// read again. This closes three hazards a direct-`inputs` version has (I19
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
- // SAFETY: `inputs.as_raw()` is a live, array-typed VALUE for the
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
- tokenizer: RwLock<Tokenizer>,
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: RwLock::new(crate::cache::apply_max_cache_bytes(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(raise(
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(raise(ruby, e.to_string())),
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
- raise(
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(raise(ruby, e.to_string())),
451
+ Err(e) => Err(model_error(ruby, format!("{path}: {e}"))),
407
452
  }
408
453
  }
409
454
 
410
- /// Shared access to the tokenizer, for everything that only reads it —
411
- /// the batch paths, `decode`, `vocab`, `merges`, the size accessors.
412
- ///
413
- /// Readers never exclude each other, which is what makes the long holds
414
- /// safe: `encode_batch`/`encode_files` keep this across a GVL release
415
- /// (they run the core pool over `&Tokenizer`), and any other Ruby thread
416
- /// reading meanwhile just proceeds. The only exclusive holder is
417
- /// [`Self::encode`], which is short.
418
- fn read_tokenizer(&self) -> std::sync::RwLockReadGuard<'_, Tokenizer> {
419
- self.tokenizer.read().unwrap_or_else(|e| e.into_inner())
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 one string, mutating the tokenizer's pretoken cache — the only
423
- /// exclusive use of the lock.
424
- ///
425
- /// Uncontended (every single-threaded caller, and the common case under
426
- /// threads) this takes the fast path: grab the write guard, encode against
427
- /// the Ruby string's own bytes, never release the GVL. Identical cost to
428
- /// the pre-lock version plus one uncontended atomic.
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
- /// Contended, the writer is waiting on a reader that will hold the lock
431
- /// for as long as a batch encode takes. Blocking there while holding the
432
- /// GVL would stall every other Ruby thread in the VM, so instead the input
433
- /// is copied and the whole wait-and-encode moves inside `without_gvl`.
434
- /// The copy is what makes that sound: no Ruby `VALUE` and no `RString`
435
- /// buffer may outlive the release (see `marshal_inputs`), and the guard is
436
- /// taken and dropped inside the closure, so it never crosses OS threads
437
- /// even when the scheduler offloads it (see `gvl`).
438
- fn encode(&self, input: RString) -> Vec<u32> {
439
- if let Ok(mut tokenizer) = self.tokenizer.try_write() {
440
- // SAFETY: read-only, for the duration of this synchronous call,
441
- // with no GVL release in between.
442
- let bytes = unsafe { input.as_slice() };
443
- let mut out = Vec::new();
444
- tokenizer.encode_with_added_tokens_flat(bytes, &mut out);
445
- return out;
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 `try_write`.
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/rb/benchmarks.md` first — no size resolves this on the hardware
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
- without_gvl(move || {
480
- let mut tokenizer = self.tokenizer.write().unwrap_or_else(|e| e.into_inner());
481
- let mut out = Vec::new();
482
- tokenizer.encode_with_added_tokens_flat(&owned, &mut out);
483
- out
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::without_gvl`). Each input string is
489
- /// borrowed zero-copy where `marshal_inputs` finds it sound to, and
490
- /// copied into an owned buffer otherwise; either way, only raw byte
491
- /// slices — never a Ruby `VALUE` — are captured once the GVL is gone.
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.read_tokenizer();
496
- let tokenizer: &Tokenizer = &tokenizer;
564
+ let tokenizer = &rb_self.tokenizer;
497
565
  let workers = &rb_self.workers;
498
- Ok(without_gvl(|| encode_docs_ragged(workers, tokenizer, &doc_slices)))
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.read_tokenizer();
545
- let tokenizer: &Tokenizer = &tokenizer;
611
+ let tokenizer = &rb_self.tokenizer;
546
612
  let workers = &rb_self.workers;
547
- match without_gvl(|| encode_docs_into(workers, tokenizer, &doc_slices, dest)) {
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.read_tokenizer();
586
- let tokenizer: &Tokenizer = &tokenizer;
661
+ let tokenizer = &rb_self.tokenizer;
587
662
  let workers = &rb_self.workers;
588
- let encoded: std::io::Result<(Vec<u32>, Vec<i64>)> = without_gvl(|| {
589
- sources::encode_files_ragged(&source, parallel, |files, format| {
590
- Ok(if parallel {
591
- encode_files_docs(workers, tokenizer, files, format)
592
- } else {
593
- encode_files_docs_serial(workers, tokenizer, files, format)
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.read_tokenizer().decode(&ids).collect();
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.read_tokenizer().vocab_size()
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 tokenizer = rb_self.read_tokenizer();
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 this tokenizer: grows as text is encoded,
645
- /// drops back toward vocab-seed level when a budgeted cache wipes (see
646
- /// `Gigatoken.max_cache_bytes`).
647
- fn cache_entries(&self) -> usize {
648
- self.read_tokenizer().cache_entries()
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
 
@@ -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