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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/Cargo.toml +7 -3
- data/README.md +9 -3
- 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 +169 -35
- data/ext/gigatoken/src/lib.rs +18 -3
- data/ext/gigatoken/src/sentencepiece.rs +27 -15
- data/ext/gigatoken/src/tokenizer.rs +223 -129
- 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 +232 -37
- data/lib/gigatoken/packed_result.rb +6 -2
- data/lib/gigatoken/tokenizer.rb +78 -12
- data/lib/gigatoken/version.rb +1 -1
- data/lib/gigatoken.rb +17 -1
- data/src/batch.rs +294 -56
- data/src/bpe/tiktoken.rs +30 -5
- metadata +25 -1
|
@@ -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);
|
data/benches/unicode.rs
ADDED
|
@@ -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);
|
data/ext/gigatoken/Cargo.toml
CHANGED
data/ext/gigatoken/src/error.rs
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
-
//! `Gigatoken::Error`
|
|
2
|
-
//! this extension is required — looked up
|
|
3
|
-
//! than cached, so no magnus `Value` needs a
|
|
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(
|
|
11
|
+
.const_get(name)
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|
data/ext/gigatoken/src/gvl.rs
CHANGED
|
@@ -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:
|
|
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 `
|
|
65
|
-
/// an ordinary Rust panic, which magnus's own `method!`/`function!`
|
|
66
|
-
/// trampolines already wrap in `catch_unwind` and convert into a fatal
|
|
67
|
-
/// exception (`magnus::error::Error::from_panic`) — the same outcome
|
|
68
|
-
/// other panicking native method already gets, just carried safely
|
|
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
|
|
81
|
-
// for the duration of that (synchronous) call, and this is the
|
|
82
|
-
// place it's dereferenced.
|
|
83
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
97
|
-
///
|
|
98
|
-
|
|
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 =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|
data/ext/gigatoken/src/lib.rs
CHANGED
|
@@ -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(
|
|
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)?;
|