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.
@@ -17,7 +17,7 @@
17
17
  //! (`~/architect/src/github.com/socketry/io-event/ext/io/event/worker_pool.c:309-316`).
18
18
  //! Without such a scheduler (or with one lacking a worker pool), `rb_nogvl`
19
19
  //! degrades to exactly today's behavior: release the GVL, block this thread.
20
- //! See `docs/rb/async-design.md` and `docs/rb/async.md` for the full design
20
+ //! See `docs/explanation/async-design.md` and `docs/how-to/run-under-async.md` for the full design
21
21
  //! and gotchas (worker pool is opt-in, defaults to one background worker).
22
22
  //!
23
23
  //! `func`/`data1` may now run on a different OS thread than the caller (the
@@ -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)?;
@@ -22,14 +22,16 @@ use magnus::{
22
22
  scan_args::{get_kwargs, scan_args},
23
23
  };
24
24
 
25
- use crate::error::raise;
25
+ use crate::error::{input_error, raise};
26
26
  use crate::gvl::without_gvl;
27
27
  use crate::sources;
28
- use crate::tokenizer::{binary_string, packed_result, ragged_result};
28
+ use crate::tokenizer::{
29
+ binary_string, packed_result, ragged_result, require_known_ids, snapshot_entry, snapshot_inputs,
30
+ };
29
31
 
30
- /// Validate that `bytes` is UTF-8, raising `Gigatoken::Error` otherwise.
32
+ /// Validate that `bytes` is UTF-8, raising `Gigatoken::InputError` otherwise.
31
33
  fn require_utf8<'a>(ruby: &Ruby, bytes: &'a [u8]) -> Result<&'a str, Error> {
32
- std::str::from_utf8(bytes).map_err(|e| raise(ruby, format!("invalid UTF-8: {e}")))
34
+ std::str::from_utf8(bytes).map_err(|e| input_error(ruby, format!("invalid UTF-8: {e}")))
33
35
  }
34
36
 
35
37
  #[magnus::wrap(class = "Gigatoken::Native::SentencePieceTokenizer", free_immediately, size)]
@@ -40,7 +42,10 @@ pub struct SentencePieceTokenizer {
40
42
  tokenizer: SentencePieceBPE,
41
43
  // The one mutable piece. A `Mutex` rather than a `RefCell` so the wrapped
42
44
  // object is `Sync`: Ruby hands the same instance to every thread, and a
43
- // `RefCell` shared that way is unsound (see `BPETokenizer`'s lock).
45
+ // `RefCell` shared that way is unsound. It is only ever taken with the
46
+ // GVL held — the batch paths build their own per-call encoders — so it
47
+ // cannot actually block, which is what `BPETokenizer`'s invariant asks
48
+ // of any lock on this side of the boundary.
44
49
  state: Mutex<EncodeState>,
45
50
  }
46
51
 
@@ -74,21 +79,27 @@ impl SentencePieceTokenizer {
74
79
  /// string is validated as UTF-8 and copied into an owned `String`
75
80
  /// before release: nothing Ruby-managed may be touched once the GVL is
76
81
  /// gone.
82
+ ///
83
+ /// The walk reads a [`snapshot_inputs`] copy of the caller's array, one
84
+ /// slot at a time — never a Rust borrow of the array's buffer held
85
+ /// across `try_convert`, which runs `to_str` and with it arbitrary Ruby
86
+ /// code, including code that re-homes the array out from under the
87
+ /// walk. Same contract as the BPE path: the result reflects the array
88
+ /// as it was at entry.
77
89
  fn encode_batch_ragged(ruby: &Ruby, rb_self: &Self, inputs: RArray) -> Result<(Vec<u32>, Vec<i64>), Error> {
78
- // SAFETY: values are read (checked-converted to `RString`, then
79
- // validated and copied into owned buffers below) before anything
80
- // else runs.
81
- let docs: Vec<String> = unsafe { inputs.as_slice() }
82
- .iter()
83
- .map(|&v| {
84
- let s = RString::try_convert(v)?;
90
+ let snapshot = snapshot_inputs(inputs);
91
+ let docs: Vec<String> = (0..snapshot.len())
92
+ .map(|i| {
93
+ let s = RString::try_convert(snapshot_entry(snapshot, i))?;
94
+ // SAFETY: validated and copied into an owned buffer right
95
+ // here, with no Ruby call in between to invalidate it.
85
96
  let bytes = unsafe { s.as_slice() };
86
97
  require_utf8(ruby, bytes).map(str::to_owned)
87
98
  })
88
99
  .collect::<Result<_, _>>()?;
89
100
  let doc_refs: Vec<&str> = docs.iter().map(String::as_str).collect();
90
101
  let tokenizer: &SentencePieceBPE = &rb_self.tokenizer;
91
- Ok(without_gvl(|| sp_encode_docs_ragged(tokenizer, &doc_refs)))
102
+ without_gvl(|| sp_encode_docs_ragged(tokenizer, &doc_refs))
92
103
  }
93
104
 
94
105
  fn encode_batch(ruby: &Ruby, rb_self: &Self, inputs: RArray) -> Result<RArray, Error> {
@@ -133,7 +144,7 @@ impl SentencePieceTokenizer {
133
144
  }
134
145
 
135
146
  let tokenizer: &SentencePieceBPE = &rb_self.tokenizer;
136
- let encoded: std::io::Result<(Vec<u32>, Vec<i64>)> = without_gvl(|| {
147
+ let encoded = without_gvl(|| {
137
148
  sources::encode_files_ragged(&source, parallel, |files, format| {
138
149
  for &region in files {
139
150
  std::str::from_utf8(region).map_err(|e| {
@@ -146,7 +157,7 @@ impl SentencePieceTokenizer {
146
157
  sp_encode_files_docs_serial(tokenizer, files, format)
147
158
  })
148
159
  })
149
- });
160
+ })?;
150
161
  encoded.map_err(|e| raise(ruby, e.to_string()))
151
162
  }
152
163
 
@@ -166,6 +177,7 @@ impl SentencePieceTokenizer {
166
177
 
167
178
  fn decode(ruby: &Ruby, rb_self: &Self, tokens: RArray) -> Result<RString, Error> {
168
179
  let ids: Vec<u32> = tokens.to_vec()?;
180
+ require_known_ids(ruby, &ids, rb_self.tokenizer.vocab_size())?;
169
181
  let ids: Vec<_> = ids.into_iter().map(Into::into).collect();
170
182
  let bytes = rb_self.tokenizer.decode(&ids);
171
183
  Ok(binary_string(ruby, &bytes))