gigatoken 0.3.0 → 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.
@@ -0,0 +1,36 @@
1
+ //! Profiling target for the `FastR50kPretokenizer` hot loop in isolation: a plain
2
+ //! single-pass `main` (no criterion, no BPE encode) that `black_box`es every
3
+ //! yielded pretoken slice, so the slice production can't be optimized away.
4
+
5
+ use gigatoken_rs::pretokenize::FastR50kPretokenizer;
6
+ use std::hint::black_box;
7
+ use std::time::Instant;
8
+
9
+ mod common;
10
+ fn main() {
11
+ let input = common::load_owt_input(None);
12
+ let size_gb = input.len() as f64 / 1e9;
13
+
14
+ // Feed the entire buffer to one pretokenizer in a single pass — this matches
15
+ // the real encode path (`pretokenize_as_iter(text.as_bytes())`), which does
16
+ // not pre-split on newlines.
17
+ let buf: &[u8] = &input;
18
+
19
+ eprintln!("Pretokenizing (fast_scalar, single-threaded, whole buffer)...");
20
+ let start = Instant::now();
21
+ let mut total_tokens: usize = 0;
22
+ // Hand each real pretoken slice to black_box so the bounds computation can't
23
+ // be optimized down to a counter.
24
+ let mut iter = FastR50kPretokenizer::new(buf);
25
+ for pretoken in iter {
26
+ black_box(pretoken);
27
+ total_tokens += 1;
28
+ }
29
+ let elapsed = start.elapsed().as_secs_f64();
30
+ let throughput_gb = size_gb / elapsed;
31
+
32
+ eprintln!(
33
+ "{total_tokens} tokens in {elapsed:.2}s — {throughput_gb:.2} GB/s ({:.0} MB/s)",
34
+ throughput_gb * 1000.0
35
+ );
36
+ }
@@ -0,0 +1,52 @@
1
+ //! Per-scheme variant of `pretokenize_profile`: same single-pass loop over
2
+ //! OWT with every yielded pretoken black_boxed, scheme selected via the
3
+ //! SCHEME env var (r50k | cl100k | olmo3 | qwen2 | qwen3_5 | deepseek_v3).
4
+ //! r50k is also what ByteLevel tokenizers like ModernBERT resolve to. Used
5
+ //! for interleaved A/B runs of the mask-scanner schemes.
6
+
7
+ use gigatoken_rs::pretokenize::{
8
+ FastCl100kPretokenizer, FastDeepSeekV3Pretokenizer, FastOlmo3Pretokenizer,
9
+ FastQwen2Pretokenizer, FastQwen35Pretokenizer, FastR50kPretokenizer,
10
+ };
11
+ use std::hint::black_box;
12
+ use std::time::Instant;
13
+
14
+ mod common;
15
+
16
+ macro_rules! drive {
17
+ ($ty:ty, $buf:expr) => {{
18
+ let mut total_tokens: usize = 0;
19
+ let mut iter = <$ty>::new($buf);
20
+ while let Some(pretoken) = iter.next() {
21
+ black_box(pretoken);
22
+ total_tokens += 1;
23
+ }
24
+ total_tokens
25
+ }};
26
+ }
27
+
28
+ fn main() {
29
+ let input = common::load_owt_input(None);
30
+ let size_gb = input.len() as f64 / 1e9;
31
+ let buf: &[u8] = &input;
32
+ let scheme = std::env::var("SCHEME").unwrap_or_else(|_| "r50k".to_string());
33
+
34
+ eprintln!("Pretokenizing ({scheme}, single-threaded, whole buffer)...");
35
+ let start = Instant::now();
36
+ let total_tokens = match scheme.as_str() {
37
+ "r50k" => drive!(FastR50kPretokenizer, buf),
38
+ "cl100k" => drive!(FastCl100kPretokenizer, buf),
39
+ "olmo3" => drive!(FastOlmo3Pretokenizer, buf),
40
+ "qwen2" => drive!(FastQwen2Pretokenizer, buf),
41
+ "qwen3_5" => drive!(FastQwen35Pretokenizer, buf),
42
+ "deepseek_v3" => drive!(FastDeepSeekV3Pretokenizer, buf),
43
+ other => panic!("unknown SCHEME {other:?}"),
44
+ };
45
+ let elapsed = start.elapsed().as_secs_f64();
46
+ let throughput_gb = size_gb / elapsed;
47
+
48
+ eprintln!(
49
+ "{total_tokens} tokens in {elapsed:.2}s — {throughput_gb:.2} GB/s ({:.0} MB/s)",
50
+ throughput_gb * 1000.0
51
+ );
52
+ }
@@ -0,0 +1,82 @@
1
+ use criterion::{Criterion, Throughput, criterion_group, criterion_main};
2
+ use rayon::prelude::*;
3
+ use std::hint::black_box;
4
+
5
+ const TARGET_BENCH_SIZE: usize = 100_000_000; // ~100 MB
6
+
7
+ /// Load OWT data, truncated to a UTF-8-safe boundary near `max_bytes`.
8
+ fn load_owt(max_bytes: usize) -> Vec<u8> {
9
+ let data_dir = std::env::home_dir().unwrap().join("data");
10
+ let all_bytes =
11
+ std::fs::read(data_dir.join("owt_train.txt")).expect("Could not read ~/data/owt_train.txt");
12
+ let mut end = max_bytes.min(all_bytes.len());
13
+ while end > 0 && std::str::from_utf8(&all_bytes[..end]).is_err() {
14
+ end -= 1;
15
+ }
16
+ all_bytes[..end].to_vec()
17
+ }
18
+
19
+ fn simdutf_transcode_benches(c: &mut Criterion) {
20
+ let input = load_owt(TARGET_BENCH_SIZE);
21
+ let input_len = input.len() as u64;
22
+ eprintln!("Benchmark input size: {:.1} MB", input_len as f64 / 1e6);
23
+
24
+ let mut group = c.benchmark_group("simdutf_transcode");
25
+ group.throughput(Throughput::Bytes(input_len));
26
+ group.sample_size(10);
27
+
28
+ for num_threads in [1, 2, 4, 8, 12, 16] {
29
+ let pool = rayon::ThreadPoolBuilder::new()
30
+ .num_threads(num_threads)
31
+ .build()
32
+ .unwrap();
33
+
34
+ let chunk_size = input.len() / num_threads;
35
+
36
+ // Pre-compute chunk boundaries aligned to UTF-8 char boundaries.
37
+ let mut boundaries = Vec::with_capacity(num_threads + 1);
38
+ boundaries.push(0);
39
+ for i in 1..num_threads {
40
+ let mut pos = chunk_size * i;
41
+ while pos < input.len() && input[pos] & 0b1100_0000 == 0b1000_0000 {
42
+ pos += 1;
43
+ }
44
+ boundaries.push(pos);
45
+ }
46
+ boundaries.push(input.len());
47
+
48
+ // Pre-allocate one destination buffer per thread.
49
+ let mut dst_bufs: Vec<Vec<u32>> = boundaries
50
+ .windows(2)
51
+ .map(|w| vec![0u32; simdutf::utf32_length_from_utf8(&input[w[0]..w[1]])])
52
+ .collect();
53
+
54
+ group.bench_function(format!("utf8_to_utf32_{num_threads}t"), |b| {
55
+ b.iter(|| {
56
+ pool.install(|| {
57
+ let total: usize = boundaries
58
+ .par_windows(2)
59
+ .zip(&mut dst_bufs)
60
+ .map(|(w, dst)| {
61
+ let chunk = &input[w[0]..w[1]];
62
+ let written = unsafe {
63
+ simdutf::convert_valid_utf8_to_utf32(
64
+ chunk.as_ptr(),
65
+ chunk.len(),
66
+ dst.as_mut_ptr(),
67
+ )
68
+ };
69
+ black_box(written)
70
+ })
71
+ .sum();
72
+ black_box(total);
73
+ })
74
+ });
75
+ });
76
+ }
77
+
78
+ group.finish();
79
+ }
80
+
81
+ criterion_group!(benches, simdutf_transcode_benches);
82
+ criterion_main!(benches);
@@ -0,0 +1,88 @@
1
+ use criterion::{Criterion, criterion_group, criterion_main};
2
+ use icu::properties::{CodePointMapDataBorrowed, props::EnumeratedProperty};
3
+ use std::hint::black_box;
4
+
5
+ use rand::{self, Rng, RngExt};
6
+
7
+ // pub fn fibonacci(n: u64) -> u64 {
8
+ // let mut a = 0;
9
+ // let mut b = 1;
10
+ // for _ in 0..n {
11
+ // let c = a + b;
12
+ // a = b;
13
+ // b = c;
14
+ // }
15
+ // a
16
+ // }
17
+
18
+ // Removed dependency since icu is ~95% faster
19
+ // use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory};
20
+ // pub fn unicode_properties_classify(c: char) -> bool {
21
+ // c.general_category_group() == GeneralCategoryGroup::Letter
22
+ // }
23
+
24
+ pub fn icu4x_classify(c: char) -> bool {
25
+ icu::properties::props::GeneralCategoryGroup::Letter
26
+ .contains(icu::properties::props::GeneralCategory::for_char(c))
27
+ }
28
+
29
+ pub fn icu4x_classify_table(c: char) -> bool {
30
+ let gc: CodePointMapDataBorrowed<icu::properties::props::GeneralCategory> =
31
+ icu::properties::CodePointMapData::new();
32
+ icu::properties::props::GeneralCategoryGroup::Letter.contains(gc.get(c))
33
+ }
34
+
35
+ pub fn criterion_benchmark(c: &mut Criterion) {
36
+ // c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
37
+ let mut group = c.benchmark_group("unicode_classify");
38
+
39
+ let chars_input: Vec<char> = rand::rng()
40
+ .sample_iter::<char, _>(rand::distr::StandardUniform)
41
+ .take(4096)
42
+ .collect();
43
+ group.bench_with_input("icu4x", chars_input.as_slice(), |b, chars: &[char]| {
44
+ b.iter(|| {
45
+ for c in chars {
46
+ icu4x_classify(*c);
47
+ }
48
+ });
49
+ });
50
+ // group.bench_with_input(
51
+ // "unicode_properties",
52
+ // chars_input.as_slice(),
53
+ // |b, chars: &[char]| {
54
+ // b.iter(|| {
55
+ // for c in chars {
56
+ // unicode_properties_classify(*c);
57
+ // }
58
+ // });
59
+ // },
60
+ // );
61
+ group.bench_with_input(
62
+ "icu4x table",
63
+ chars_input.as_slice(),
64
+ |b, chars: &[char]| {
65
+ b.iter(|| {
66
+ for c in chars {
67
+ icu4x_classify_table(*c);
68
+ }
69
+ });
70
+ },
71
+ );
72
+
73
+ // c.bench_function("unicode classify letter", |b| {
74
+ // b.iter_batched(
75
+ // || {},
76
+ // |chars: Vec<char>| {
77
+ // for c in chars {
78
+ // unicode_classify(c);
79
+ // }
80
+ // },
81
+ // criterion::BatchSize::SmallInput,
82
+ // )
83
+ // });
84
+ // c.bench_function("unicode pretokenize")
85
+ }
86
+
87
+ criterion_group!(benches, criterion_benchmark);
88
+ criterion_main!(benches);
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "gigatoken-rb"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -1,20 +1,40 @@
1
- //! `Gigatoken::Error` is defined in Ruby (`lib/gigatoken.rb`), loaded before
2
- //! this extension is required — looked up fresh at each raise site rather
3
- //! than cached, so no magnus `Value` needs a GC-registered static home.
1
+ //! `Gigatoken::Error` and its subclasses are defined in Ruby
2
+ //! (`lib/gigatoken.rb`), loaded before this extension is required — looked up
3
+ //! fresh at each raise site rather than cached, so no magnus `Value` needs a
4
+ //! GC-registered static home.
4
5
 
5
6
  use magnus::{exception::ExceptionClass, prelude::*, Error, RModule, Ruby};
6
7
 
7
- fn error_class(ruby: &Ruby) -> Result<ExceptionClass, Error> {
8
+ fn error_class(ruby: &Ruby, name: &str) -> Result<ExceptionClass, Error> {
8
9
  ruby.class_object()
9
10
  .const_get::<_, RModule>("Gigatoken")?
10
- .const_get("Error")
11
+ .const_get(name)
11
12
  }
12
13
 
13
- /// Raise `Gigatoken::Error` with `message` (core load/encode failures surface
14
- /// through this — never a Rust panic across the Ruby boundary).
15
- pub fn raise(ruby: &Ruby, message: impl Into<String>) -> Error {
16
- match error_class(ruby) {
14
+ fn raise_as(ruby: &Ruby, name: &str, message: impl Into<String>) -> Error {
15
+ match error_class(ruby, name) {
17
16
  Ok(class) => Error::new(class, message.into()),
18
17
  Err(e) => e,
19
18
  }
20
19
  }
20
+
21
+ /// Raise `Gigatoken::Error` with `message` — what a failure that is neither
22
+ /// the caller's document nor the model itself surfaces through (never a Rust
23
+ /// panic across the Ruby boundary). Prefer [`input_error`] or [`model_error`]
24
+ /// where one of them fits.
25
+ pub fn raise(ruby: &Ruby, message: impl Into<String>) -> Error {
26
+ raise_as(ruby, "Error", message)
27
+ }
28
+
29
+ /// Raise `Gigatoken::InputError`: a document the tokenizer cannot take —
30
+ /// invalid UTF-8 on the SentencePiece path, an id outside the vocabulary in
31
+ /// `decode`.
32
+ pub fn input_error(ruby: &Ruby, message: impl Into<String>) -> Error {
33
+ raise_as(ruby, "InputError", message)
34
+ }
35
+
36
+ /// Raise `Gigatoken::ModelError`: a tokenizer that cannot be loaded — bad or
37
+ /// hostile JSON, an unknown pretokenizer scheme, a malformed `.tiktoken`.
38
+ pub fn model_error(ruby: &Ruby, message: impl Into<String>) -> Error {
39
+ raise_as(ruby, "ModelError", message)
40
+ }
@@ -36,15 +36,27 @@
36
36
  //! `VALUE` or thread-local state, so running one OS thread over instead of
37
37
  //! another changes nothing about its safety, and the types involved satisfy
38
38
  //! `Send`/`Sync` on their own merits.
39
+ //!
40
+ //! Interrupts. `rb_nogvl` ends by checking interrupts and raises any that
41
+ //! are pending — a `Timeout`, `Thread#kill`, `Interrupt`, an `Async`
42
+ //! timeout — by longjmping out of itself, over every Rust frame in between.
43
+ //! Both entry points here therefore call it under `magnus::rb_sys::protect`
44
+ //! (see [`run`]), so the raise comes back as an ordinary `Error` and every
45
+ //! guard, `InputDocs` and Ruby String lock the caller holds is released by
46
+ //! ordinary Rust unwinding on the way out. [`without_gvl_cancellable`] goes
47
+ //! one further and supplies a real unblock function, so a pending interrupt
48
+ //! cancels the work in flight instead of waiting for it.
39
49
 
40
50
  use std::any::Any;
41
51
  use std::ffi::c_void;
42
52
  use std::os::raw::c_int;
43
53
  use std::panic::{self, AssertUnwindSafe};
54
+ use std::sync::atomic::{AtomicBool, Ordering};
44
55
 
56
+ use magnus::Error;
45
57
  use rb_sys::rb_nogvl;
46
58
 
47
- /// `RB_NOGVL_OFFLOAD_SAFE` (`ruby/thread.h:84` in a current Ruby checkout;
59
+ /// `RB_NOGVL_OFFLOAD_SAFE` (`ruby/thread.h:73` in Ruby 4.0.7's own headers;
48
60
  /// introduced by Ruby's `Fiber::Scheduler#blocking_operation_wait` support,
49
61
  /// first released in Ruby 3.4.0). Defined locally rather than taken from
50
62
  /// `rb_sys::` bindings: `rb-sys` bindgens its constants from the *building*
@@ -61,62 +73,184 @@ const RB_NOGVL_OFFLOAD_SAFE: c_int = 0x4;
61
73
  /// value, or a caught panic payload to re-raise once we're back on ordinary
62
74
  /// (non-`extern "C"`) Rust stack frames. Unwinding a panic directly across
63
75
  /// the `extern "C"` trampoline `rb_nogvl` calls into is undefined behavior;
64
- /// catching it here and resuming it from `without_gvl` below turns that into
65
- /// an ordinary Rust panic, which magnus's own `method!`/`function!` call
66
- /// trampolines already wrap in `catch_unwind` and convert into a fatal Ruby
67
- /// exception (`magnus::error::Error::from_panic`) — the same outcome any
68
- /// other panicking native method already gets, just carried safely across
69
- /// the extra C boundary this one call adds.
76
+ /// catching it here and resuming it from [`Slot::take`] below turns that
77
+ /// into an ordinary Rust panic, which magnus's own `method!`/`function!`
78
+ /// call trampolines already wrap in `catch_unwind` and convert into a fatal
79
+ /// Ruby exception (`magnus::error::Error::from_panic`) — the same outcome
80
+ /// any other panicking native method already gets, just carried safely
81
+ /// across the extra C boundary this one call adds.
70
82
  enum Outcome<R> {
71
83
  Value(R),
72
84
  Panic(Box<dyn Any + Send + 'static>),
73
85
  }
74
86
 
87
+ /// The one thing `rb_nogvl`'s callback and [`run`] share: the callback takes
88
+ /// the closure out of `input` and leaves its result in `output`. The rule is
89
+ /// that nothing travels through `rb_nogvl`'s *return value* — the caller owns
90
+ /// this slot, in an ordinary Rust frame.
91
+ ///
92
+ /// That rule is what makes the raise `rb_nogvl` performs on its way out
93
+ /// leak-free. The `protect` closure in [`run`] holds the single FFI call and
94
+ /// owns nothing at all, so the longjmp skips no destructor that matters:
95
+ /// whatever is in flight is either here — in a frame that unwinds normally
96
+ /// afterwards — or in one of the caller's own frames, which unwind with it.
97
+ /// It is also the only way to tell "the callback ran" from "it never did",
98
+ /// which a fiber scheduler cancelling the offloaded operation before its
99
+ /// worker pool picks it up makes a real case (io-event's
100
+ /// `worker_pool_work_wait`, `ext/io/event/worker_pool.c`).
101
+ struct Slot<F, R> {
102
+ input: Option<F>,
103
+ output: Option<Outcome<R>>,
104
+ }
105
+
106
+ impl<F, R> Slot<F, R> {
107
+ fn new(f: F) -> Self {
108
+ Self {
109
+ input: Some(f),
110
+ output: None,
111
+ }
112
+ }
113
+
114
+ /// The callback's value, or `None` if it never ran. A panic it caught
115
+ /// resumes here, on an ordinary Rust frame.
116
+ fn take(&mut self) -> Option<R> {
117
+ match self.output.take() {
118
+ Some(Outcome::Value(value)) => Some(value),
119
+ Some(Outcome::Panic(payload)) => panic::resume_unwind(payload),
120
+ None => None,
121
+ }
122
+ }
123
+ }
124
+
75
125
  unsafe extern "C" fn call_without_gvl<F, R>(arg: *mut c_void) -> *mut c_void
76
126
  where
77
127
  F: FnOnce() -> R + Send,
78
128
  R: Send,
79
129
  {
80
- // SAFETY: `arg` is the `*mut Option<F>` handed to `rb_nogvl` below, valid
81
- // for the duration of that (synchronous) call, and this is the only
82
- // place it's dereferenced.
83
- let closure = unsafe { (*(arg as *mut Option<F>)).take() }
130
+ // SAFETY: `arg` is the `*mut Slot<F, R>` handed to `rb_nogvl` by `run`,
131
+ // valid for the duration of that (synchronous) call, and this is the
132
+ // only place it's dereferenced.
133
+ let slot = unsafe { &mut *(arg as *mut Slot<F, R>) };
134
+ let closure = slot
135
+ .input
136
+ .take()
84
137
  .expect("without_gvl callback invoked more than once");
85
- let outcome = match panic::catch_unwind(AssertUnwindSafe(closure)) {
138
+ slot.output = Some(match panic::catch_unwind(AssertUnwindSafe(closure)) {
86
139
  Ok(value) => Outcome::Value(value),
87
140
  Err(payload) => Outcome::Panic(payload),
88
- };
89
- Box::into_raw(Box::new(outcome)) as *mut c_void
141
+ });
142
+ std::ptr::null_mut()
143
+ }
144
+
145
+ /// Run `slot`'s closure with the GVL released, under `rb_protect`.
146
+ ///
147
+ /// `rb_nogvl` finishes by checking interrupts and raising any that are
148
+ /// pending, by longjmping out of itself. `protect` catches that and returns
149
+ /// it as an `Error` — including the `Tag::Fatal` a `Thread#kill` jumps with,
150
+ /// which magnus resumes with `rb_jump_tag` once the caller's frames have
151
+ /// unwound. The closure below is deliberately trivial: one FFI call and a
152
+ /// `nil`, owning nothing the longjmp could strand.
153
+ fn run<F, R>(
154
+ slot: &mut Slot<F, R>,
155
+ ubf: rb_sys::rb_unblock_function_t,
156
+ data2: *mut c_void,
157
+ ) -> Result<(), Error>
158
+ where
159
+ F: FnOnce() -> R + Send,
160
+ R: Send,
161
+ {
162
+ let arg = slot as *mut Slot<F, R> as *mut c_void;
163
+ let nil: rb_sys::VALUE = rb_sys::Qnil.into();
164
+ magnus::rb_sys::protect(|| {
165
+ // SAFETY: `arg` points at `slot`, which outlives this synchronous
166
+ // call; the callback is the only reader of it (see `Slot`).
167
+ unsafe {
168
+ rb_nogvl(
169
+ Some(call_without_gvl::<F, R>),
170
+ arg,
171
+ ubf,
172
+ data2,
173
+ RB_NOGVL_OFFLOAD_SAFE,
174
+ )
175
+ };
176
+ nil
177
+ })?;
178
+ Ok(())
179
+ }
180
+
181
+ /// `rb_nogvl`'s unblock function: Ruby calls it from another thread when an
182
+ /// interrupt is pending for this one, and a fiber scheduler calls it to
183
+ /// cancel an offloaded operation (`rb_fiber_scheduler_blocking_operation_cancel`
184
+ /// "marks it as cancelled and calls the unblock function", Ruby 4.0.7's
185
+ /// `ruby/fiber/scheduler.h:455-457` — which is how an `Async` timeout reaches
186
+ /// an encode running on io-event's worker pool). It does exactly one thing:
187
+ /// set the flag the core's encode loops poll (`src/batch.rs`), so the encode
188
+ /// stops at the next document boundary instead of running to completion.
189
+ unsafe extern "C" fn set_cancel(arg: *mut c_void) {
190
+ // SAFETY: `arg` is the `&AtomicBool` `without_gvl_cancellable` passed as
191
+ // `data2`, living in its frame for the whole `rb_nogvl` call — the only
192
+ // window in which Ruby may call this.
193
+ unsafe { &*(arg as *const AtomicBool) }.store(true, Ordering::Relaxed);
90
194
  }
91
195
 
92
196
  /// Run `f` with the GVL released: other Ruby threads may run while `f`
93
197
  /// executes, and — under a fiber scheduler with a worker pool — the calling
94
198
  /// fiber yields to the reactor while `f` runs on a background thread. `f`
95
199
  /// must not touch any Ruby object (`VALUE`) — only plain Rust data — per the
96
- /// Ruby C API's contract for this call. A panic inside `f` is caught and
97
- /// re-raised here rather than left to unwind across the C trampoline.
98
- pub fn without_gvl<F, R>(f: F) -> R
200
+ /// Ruby C API's contract for this call. An interrupt that arrives meanwhile
201
+ /// is delivered once `f` has finished, as an `Err`; use
202
+ /// [`without_gvl_cancellable`] for work that can stop early.
203
+ pub fn without_gvl<F, R>(f: F) -> Result<R, Error>
99
204
  where
100
205
  F: FnOnce() -> R + Send,
101
206
  R: Send,
102
207
  {
103
- let mut slot = Some(f);
104
- let arg = &mut slot as *mut Option<F> as *mut c_void;
105
- let result = unsafe {
106
- rb_nogvl(
107
- Some(call_without_gvl::<F, R>),
108
- arg,
109
- None,
110
- std::ptr::null_mut(),
111
- RB_NOGVL_OFFLOAD_SAFE,
112
- )
113
- };
114
- // SAFETY: `result` is the `Box::into_raw(Box::new(Outcome<R>))` pointer
115
- // produced by the callback above, which always runs exactly once before
116
- // `rb_nogvl` returns.
117
- let outcome = *unsafe { Box::from_raw(result as *mut Outcome<R>) };
118
- match outcome {
119
- Outcome::Value(value) => value,
120
- Outcome::Panic(payload) => panic::resume_unwind(payload),
208
+ let mut slot = Slot::new(f);
209
+ run(&mut slot, None, std::ptr::null_mut())?;
210
+ Ok(match slot.take() {
211
+ Some(value) => value,
212
+ // A fiber scheduler cancelled the offloaded operation before its
213
+ // worker pool ever started it, and nothing was raised (that would
214
+ // have come back as an `Err` above). The closure is still in the
215
+ // slot and still ours to run: do that here, GVL and all, rather
216
+ // than invent a result.
217
+ None => slot.input.take().expect("the callback left the closure")(),
218
+ })
219
+ }
220
+
221
+ /// [`without_gvl`] for work that can stop early: `attempt` builds the
222
+ /// closure, which gets a cancellation token to poll and returns `None` if it
223
+ /// saw the token set and cut its work short. An interrupt arriving during
224
+ /// the run sets that token through [`set_cancel`], so the caller sees the
225
+ /// interrupt instead of waiting out the whole batch.
226
+ ///
227
+ /// `attempt` is a factory, not the closure itself, because a cancelled run
228
+ /// sometimes has to be redone: Ruby calls the unblock function for every
229
+ /// interrupt, including ones that never raise (a trap handler, a
230
+ /// `Thread#wakeup`), and returning the half-encoded batch those produce
231
+ /// would be a silently truncated result. The redo runs uninterruptibly —
232
+ /// plain [`without_gvl`], which is exactly how this path behaved before
233
+ /// cancellation existed — so a chatty signal handler can cost a batch one
234
+ /// extra pass, never an unbounded number of them.
235
+ pub fn without_gvl_cancellable<A, F, R>(mut attempt: A) -> Result<R, Error>
236
+ where
237
+ A: FnMut() -> F,
238
+ F: FnOnce(&AtomicBool) -> Option<R> + Send,
239
+ R: Send,
240
+ {
241
+ let cancel = AtomicBool::new(false);
242
+ let f = attempt();
243
+ let mut slot = Slot::new(|| f(&cancel));
244
+ run(
245
+ &mut slot,
246
+ Some(set_cancel),
247
+ &cancel as *const AtomicBool as *mut c_void,
248
+ )?;
249
+ if let Some(Some(value)) = slot.take() {
250
+ return Ok(value);
121
251
  }
252
+ let f = attempt();
253
+ let never_cancelled = AtomicBool::new(false);
254
+ without_gvl(|| f(&never_cancelled))
255
+ .map(|value| value.expect("nothing sets the token of an uncancellable run"))
122
256
  }
@@ -1,3 +1,4 @@
1
+ use gigatoken_rs::input::file_source::load_file;
1
2
  use gigatoken_rs::load_tokenizer::hf::{self, HfTokenizer};
2
3
  use gigatoken_rs::pretokenize::PretokenizerType;
3
4
  use magnus::{Error, Module, RString, Ruby, Value, function};
@@ -26,9 +27,9 @@ mod sentencepiece;
26
27
  mod sources;
27
28
  mod tokenizer;
28
29
 
29
- use error::raise;
30
+ use error::{model_error, raise};
30
31
  use sentencepiece::SentencePieceTokenizer;
31
- use tokenizer::BPETokenizer;
32
+ use tokenizer::{binary_string, BPETokenizer};
32
33
 
33
34
  // The gigatoken core crate exposes no version constant of its own, so this
34
35
  // is the ext crate's (gigatoken-rb's) version — see the builder report.
@@ -66,7 +67,20 @@ fn load_hf_json(ruby: &Ruby, data: RString) -> Result<Value, Error> {
66
67
  match hf::load_hf_slice(bytes) {
67
68
  Ok(HfTokenizer::Bpe(tokenizer)) => Ok(ruby.into_value(BPETokenizer::from_tokenizer(tokenizer))),
68
69
  Ok(HfTokenizer::SentencePiece(tokenizer)) => Ok(ruby.into_value(SentencePieceTokenizer::from_tokenizer(tokenizer))),
69
- Err(e) => Err(raise(ruby, e.to_string())),
70
+ Err(e) => Err(model_error(ruby, e.to_string())),
71
+ }
72
+ }
73
+
74
+ /// One file's contents as a binary String, decompressed by extension the way
75
+ /// the native file sources do it (`.gz`, `.zst`/`.zstd`, plain — see the core's
76
+ /// `load_file`). The CLI's Ruby-side split reads through here so both sides of
77
+ /// `gigatoken validate` see the same bytes with one decoder between them
78
+ /// (`lib/gigatoken/cli/support.rb`); the encode paths never touch it, which is
79
+ /// why it keeps the GVL.
80
+ fn read_input(ruby: &Ruby, path: String) -> Result<RString, Error> {
81
+ match load_file(std::path::Path::new(&path)) {
82
+ Ok(file) => Ok(binary_string(ruby, file.as_bytes())),
83
+ Err(e) => Err(raise(ruby, format!("{path}: {e}"))),
70
84
  }
71
85
  }
72
86
 
@@ -77,6 +91,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
77
91
  native.define_module_function("crate_version", function!(crate_version, 0))?;
78
92
  native.define_module_function("load_hf_json", function!(load_hf_json, 1))?;
79
93
  native.define_module_function("pretokenizer_names", function!(pretokenizer_names, 0))?;
94
+ native.define_module_function("read_input", function!(read_input, 1))?;
80
95
  native.define_module_function("set_max_cache_bytes", function!(set_max_cache_bytes, 1))?;
81
96
  native.define_module_function("get_max_cache_bytes", function!(get_max_cache_bytes, 0))?;
82
97
  sources::init(ruby, native)?;