gigatoken 0.1.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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +3016 -0
  3. data/Cargo.toml +135 -0
  4. data/LICENSE +21 -0
  5. data/README.md +141 -0
  6. data/exe/gigatoken +9 -0
  7. data/ext/gigatoken/Cargo.toml +24 -0
  8. data/ext/gigatoken/extconf.rb +19 -0
  9. data/ext/gigatoken/src/error.rs +20 -0
  10. data/ext/gigatoken/src/gvl.rs +122 -0
  11. data/ext/gigatoken/src/lib.rs +50 -0
  12. data/ext/gigatoken/src/sentencepiece.rs +205 -0
  13. data/ext/gigatoken/src/sources.rs +207 -0
  14. data/ext/gigatoken/src/tokenizer.rs +571 -0
  15. data/lib/gigatoken/cli/bench.rb +64 -0
  16. data/lib/gigatoken/cli/support.rb +132 -0
  17. data/lib/gigatoken/cli/validate.rb +55 -0
  18. data/lib/gigatoken/cli.rb +18 -0
  19. data/lib/gigatoken/hub.rb +242 -0
  20. data/lib/gigatoken/packed_result.rb +48 -0
  21. data/lib/gigatoken/tokenizer.rb +117 -0
  22. data/lib/gigatoken/version.rb +5 -0
  23. data/lib/gigatoken.rb +29 -0
  24. data/rust-toolchain.toml +8 -0
  25. data/src/batch.rs +1808 -0
  26. data/src/bindings/bridge.rs +396 -0
  27. data/src/bindings/hub.rs +42 -0
  28. data/src/bindings/matcher.rs +114 -0
  29. data/src/bindings/mod.rs +14 -0
  30. data/src/bindings/padding.rs +177 -0
  31. data/src/bindings/pretokenize.rs +53 -0
  32. data/src/bindings/sources.rs +273 -0
  33. data/src/bindings/train.rs +125 -0
  34. data/src/bpe/mod.rs +1217 -0
  35. data/src/bpe/pretoken_cache.rs +495 -0
  36. data/src/bpe/sentencepiece.rs +1485 -0
  37. data/src/bpe/tiktoken.rs +2555 -0
  38. data/src/bpe_train.rs +351 -0
  39. data/src/input/decompress.rs +11 -0
  40. data/src/input/file_source.rs +514 -0
  41. data/src/input/jsonl.rs +94 -0
  42. data/src/input/mod.rs +333 -0
  43. data/src/input/parquet.rs +303 -0
  44. data/src/lib.rs +578 -0
  45. data/src/load_tokenizer/hf.rs +1036 -0
  46. data/src/load_tokenizer/hub.rs +344 -0
  47. data/src/load_tokenizer/mod.rs +3 -0
  48. data/src/load_tokenizer/tiktoken.rs +87 -0
  49. data/src/main.rs +95 -0
  50. data/src/pretokenize/fast/cl100k.rs +426 -0
  51. data/src/pretokenize/fast/cl100k_family.rs +891 -0
  52. data/src/pretokenize/fast/deepseek_v3.rs +605 -0
  53. data/src/pretokenize/fast/kimi.rs +281 -0
  54. data/src/pretokenize/fast/mask.rs +1486 -0
  55. data/src/pretokenize/fast/mod.rs +446 -0
  56. data/src/pretokenize/fast/nemotron.rs +138 -0
  57. data/src/pretokenize/fast/o200k.rs +347 -0
  58. data/src/pretokenize/fast/o200k_family.rs +1734 -0
  59. data/src/pretokenize/fast/olmo3.rs +505 -0
  60. data/src/pretokenize/fast/qwen2.rs +429 -0
  61. data/src/pretokenize/fast/qwen3_5.rs +541 -0
  62. data/src/pretokenize/fast/r50k.rs +1250 -0
  63. data/src/pretokenize/mod.rs +1079 -0
  64. data/src/pretokenize/options.rs +188 -0
  65. data/src/pretokenize/pretoken.rs +20 -0
  66. data/src/pretokenize/pretokenize_traits.rs +49 -0
  67. data/src/pretokenize/reference/avx512.rs +522 -0
  68. data/src/pretokenize/reference/combinator.rs +572 -0
  69. data/src/pretokenize/reference/mod.rs +28 -0
  70. data/src/pretokenize/reference/simd.rs +852 -0
  71. data/src/pretokenize/reference/state_machine.rs +365 -0
  72. data/src/pretokenize/unicode.rs +546 -0
  73. data/src/test_hub.rs +28 -0
  74. data/src/token.rs +42 -0
  75. metadata +161 -0
data/src/batch.rs ADDED
@@ -0,0 +1,1808 @@
1
+ //! Parallel chunked batch encoding: the engine behind encode_batch and
2
+ //! encode_files. Documents are grouped into coarse chunks (an oversized
3
+ //! document is split internally — at pretoken-safe boundaries for BPE,
4
+ //! scanner-safe unit boundaries for SentencePiece), encoded by pooled
5
+ //! workers whose pretoken caches persist across calls, and reassembled into
6
+ //! one flat id buffer plus per-document row lengths.
7
+
8
+ use crate::Tokenizer;
9
+ use crate::bpe;
10
+ use crate::bpe::madvise_hugepage;
11
+ use crate::input::DocumentIter;
12
+ use crate::input::file_source::{DocFormat, chunk_ranges};
13
+ use std::ops::Range;
14
+ use std::cell::UnsafeCell;
15
+ use std::sync::atomic::{AtomicUsize, Ordering};
16
+ use std::sync::{Mutex, OnceLock, TryLockError};
17
+
18
+ /// Parallel chunks must hold at least this many bytes: a chunk this size
19
+ /// encodes for tens of milliseconds, so worker acquisition and rayon
20
+ /// scheduling/work-stealing overhead is noise. An input that does not fill
21
+ /// more than one chunk is encoded serially — for small inputs the thread
22
+ /// fan-out costs more than it saves.
23
+ const MIN_CHUNK_BYTES: usize = 1 << 20;
24
+
25
+ /// Results at least this large get their per-chunk buffers freed on a
26
+ /// background task after the gather returns (see `defer_drop`); smaller
27
+ /// ones drop inline.
28
+ const DEFERRED_DROP_MIN_BYTES: usize = 32 << 20;
29
+
30
+ /// Target bytes per parallel chunk: ~16 chunks per thread for work-stealing
31
+ /// load balancing, floored at MIN_CHUNK_BYTES so chunks stay coarse.
32
+ pub(crate) fn chunk_target_bytes(total_bytes: usize) -> usize {
33
+ (total_bytes / (16 * rayon::current_num_threads())).max(MIN_CHUNK_BYTES)
34
+ }
35
+
36
+ /// Append one document's token ids to `ids` and its row length to `lens`.
37
+ pub fn encode_into(tokenizer: &mut Tokenizer, doc: &[u8], ids: &mut Vec<u32>, lens: &mut Vec<i64>) {
38
+ let before = ids.len();
39
+ tokenizer.encode_with_added_tokens_flat(doc, ids);
40
+ lens.push((ids.len() - before) as i64);
41
+ }
42
+
43
+ /// SentencePiece analog of `encode_into`, using `encoder`'s pretoken cache.
44
+ pub(crate) fn sp_encode_into(
45
+ encoder: &mut bpe::sentencepiece::Encoder<'_>,
46
+ text: &str,
47
+ ids: &mut Vec<u32>,
48
+ lens: &mut Vec<i64>,
49
+ ) {
50
+ let before = ids.len();
51
+ encoder.encode_raw_cb(text, &mut |tokens| {
52
+ ids.extend(tokens.iter().map(|&t| u32::from(t)))
53
+ });
54
+ lens.push((ids.len() - before) as i64);
55
+ }
56
+
57
+ /// `sp_encode_into` for one fragment of a split document (see
58
+ /// `SpChunk::Fragment`): a non-first fragment continues a section begun in
59
+ /// an earlier fragment, so its leading section is never ▁-prefixed.
60
+ fn sp_encode_fragment_into(
61
+ encoder: &mut bpe::sentencepiece::Encoder<'_>,
62
+ text: &str,
63
+ first: bool,
64
+ ids: &mut Vec<u32>,
65
+ lens: &mut Vec<i64>,
66
+ ) {
67
+ let before = ids.len();
68
+ encoder.encode_raw_fragment_cb(text, first, &mut |tokens| {
69
+ ids.extend(tokens.iter().map(|&t| u32::from(t)))
70
+ });
71
+ lens.push((ids.len() - before) as i64);
72
+ }
73
+
74
+ /// Iterate the documents in a byte region per `format`: JSONL lines,
75
+ /// separator-delimited text, or the whole region as one document.
76
+ pub(crate) fn for_each_doc(bytes: &[u8], format: &DocFormat, mut f: impl FnMut(&[u8])) {
77
+ use crate::input::jsonl::JsonLinesSlice;
78
+ match format {
79
+ DocFormat::Jsonl { field } => {
80
+ for doc in JsonLinesSlice::new(bytes, field) {
81
+ f(doc.as_ref());
82
+ }
83
+ }
84
+ DocFormat::Text {
85
+ separator: Some(sep),
86
+ } if !sep.is_empty() => {
87
+ for doc in DocumentIter::new(bytes, sep) {
88
+ f(doc);
89
+ }
90
+ }
91
+ DocFormat::Text { .. } => f(bytes),
92
+ // Parquet rows are materialized into whole documents before encoding
93
+ // (encode_files_ragged rewrites the format to Text { separator: None }),
94
+ // so parquet bytes must never arrive here.
95
+ DocFormat::Parquet { .. } => {
96
+ unreachable!("parquet files are materialized into documents before encoding")
97
+ }
98
+ }
99
+ }
100
+
101
+ /// Work unit for parallel encoding.
102
+ pub(crate) enum EncodeChunk<'a> {
103
+ /// A run of whole documents, one output row each.
104
+ Docs(Vec<&'a [u8]>),
105
+ /// A byte region holding many documents, split during encoding
106
+ /// (JSONL lines or separator-delimited text).
107
+ Region {
108
+ bytes: &'a [u8],
109
+ format: &'a DocFormat,
110
+ },
111
+ /// A pretoken-safe fragment of one oversized document (see
112
+ /// `pretokenize::safe_split_ranges`). Fragments of a document are
113
+ /// consecutive chunks; `first` marks the document's first fragment.
114
+ Fragment { bytes: &'a [u8], first: bool },
115
+ }
116
+
117
+ /// Token output of one chunk: a flat id buffer plus one length per document
118
+ /// row. `continues` means the first length extends the previous chunk's
119
+ /// last row (a non-first fragment of a split document).
120
+ pub(crate) struct ChunkTokens {
121
+ pub(crate) ids: Vec<u32>,
122
+ pub(crate) lens: Vec<i64>,
123
+ pub(crate) continues: bool,
124
+ }
125
+
126
+ fn encode_chunk(tokenizer: &mut Tokenizer, chunk: &EncodeChunk) -> ChunkTokens {
127
+ // Reserve the output once, from a bytes-per-token estimate on the low
128
+ // side of natural language (~4.4 on OWT/GPT-2). Growing from empty
129
+ // instead re-copies roughly the final size in doublings — per chunk,
130
+ // on every chunk of a first pass.
131
+ let byte_len = match chunk {
132
+ EncodeChunk::Docs(docs) => docs.iter().map(|d| d.len()).sum::<usize>(),
133
+ EncodeChunk::Region { bytes, .. } | EncodeChunk::Fragment { bytes, .. } => bytes.len(),
134
+ };
135
+ let mut ids = Vec::with_capacity(byte_len / 4 + 16);
136
+ // Huge pages for the chunk's token output before the encode's stores
137
+ // fault it in (~2.5 MB/chunk; ordering matters — see Slots::new_zeroed).
138
+ madvise_hugepage(ids.as_mut_ptr() as *mut u8, ids.capacity() * 4);
139
+ let mut lens = Vec::new();
140
+ let mut continues = false;
141
+ match chunk {
142
+ EncodeChunk::Docs(docs) => {
143
+ for doc in docs {
144
+ encode_into(tokenizer, doc, &mut ids, &mut lens);
145
+ }
146
+ }
147
+ EncodeChunk::Region { bytes, format } => {
148
+ for_each_doc(bytes, format, |doc| {
149
+ encode_into(tokenizer, doc, &mut ids, &mut lens)
150
+ })
151
+ }
152
+ EncodeChunk::Fragment { bytes, first } => {
153
+ encode_into(tokenizer, bytes, &mut ids, &mut lens);
154
+ continues = !*first;
155
+ }
156
+ }
157
+ ChunkTokens {
158
+ ids,
159
+ lens,
160
+ continues,
161
+ }
162
+ }
163
+
164
+ /// Whether LPT chunk sizing is enabled: killed by setting `GIGATOK_NO_LPT`
165
+ /// in the environment (to any value, empty included). Read once per encode
166
+ /// call — never in per-chunk or per-pretoken loops. Both shapes are token-
167
+ /// and order-identical; the switch changes chunk sizing only, and exists
168
+ /// so future measurement can flip it without a rebuild.
169
+ fn lpt_from_env() -> bool {
170
+ std::env::var_os("GIGATOK_NO_LPT").is_none()
171
+ }
172
+
173
+ /// Group documents into parallel chunks of descending (LPT-scheduled)
174
+ /// sizes: ~2x-target chunks over the first ~80% of bytes, quarter-target
175
+ /// chunks over the last ~20%. Rayon hands out chunks in index order, so
176
+ /// whichever core draws the last chunk (on asymmetric parts, often an
177
+ /// E-core) strands the others behind a short tail instead of a full-size
178
+ /// one, while the big early chunks amortize per-chunk overhead. A document
179
+ /// larger than the target is split into consecutive Fragment chunks at
180
+ /// pretoken-safe boundaries that no added-token occurrence straddles, so
181
+ /// even a single huge document is encoded across all cores with
182
+ /// token-identical output.
183
+ ///
184
+ /// With LPT disabled (GIGATOK_NO_LPT) this restores uniform sizing:
185
+ /// head_bytes = 0 makes every Docs group aim for `target`, and
186
+ /// frag_head = usize::MAX makes every fragment take the primary split
187
+ /// size `target` (the sub-split branch is never entered), which is
188
+ /// exactly the old safe_split_ranges(doc, target) loop. The oversize
189
+ /// threshold (`doc.len() > 2 * target`) is identical in both shapes.
190
+ fn build_doc_chunks<'a>(
191
+ docs: &[&'a [u8]],
192
+ total: usize,
193
+ target: usize,
194
+ added_tokens: &[(&[u8], bool)],
195
+ lpt: bool,
196
+ ) -> Vec<EncodeChunk<'a>> {
197
+ let (head_bytes, group_big, frag_big, tail_target) = if lpt {
198
+ (
199
+ total - total / 5,
200
+ 2 * target,
201
+ 2 * target,
202
+ (target / 4).max(MIN_CHUNK_BYTES),
203
+ )
204
+ } else {
205
+ (0, target, target, target)
206
+ };
207
+ let mut chunks = Vec::new();
208
+ let mut group: Vec<&[u8]> = Vec::new();
209
+ // Bytes already assigned to chunks: positions below `head_bytes` take
210
+ // the big target, the rest the small one.
211
+ let mut emitted = 0usize;
212
+ let mut acc = 0usize;
213
+ for &doc in docs {
214
+ if doc.len() > 2 * target {
215
+ if !group.is_empty() {
216
+ chunks.push(EncodeChunk::Docs(std::mem::take(&mut group)));
217
+ emitted += acc;
218
+ acc = 0;
219
+ }
220
+ push_fragment_chunks(
221
+ &mut chunks,
222
+ doc,
223
+ if lpt {
224
+ head_bytes.saturating_sub(emitted)
225
+ } else {
226
+ usize::MAX
227
+ },
228
+ frag_big,
229
+ tail_target,
230
+ added_tokens,
231
+ );
232
+ emitted += doc.len();
233
+ continue;
234
+ }
235
+ group.push(doc);
236
+ acc += doc.len();
237
+ let group_target = if emitted < head_bytes {
238
+ group_big
239
+ } else {
240
+ tail_target
241
+ };
242
+ if acc >= group_target {
243
+ chunks.push(EncodeChunk::Docs(std::mem::take(&mut group)));
244
+ emitted += acc;
245
+ acc = 0;
246
+ }
247
+ }
248
+ if !group.is_empty() {
249
+ chunks.push(EncodeChunk::Docs(group));
250
+ }
251
+ chunks
252
+ }
253
+
254
+ /// Split one oversized document into consecutive Fragment chunks with the
255
+ /// descending sizes of `build_doc_chunks`: `big`-sized fragments over the
256
+ /// first `head_len` bytes, `tail_target`-sized fragments after.
257
+ /// Sub-splitting a tail fragment preserves boundary safety: the pretoken
258
+ /// cut check is purely local (3 bytes around the cut), and an added-token
259
+ /// occurrence is orders of magnitude shorter than the >= MIN_CHUNK_BYTES
260
+ /// distance of any sub-cut from its fragment's (already safe) edges.
261
+ fn push_fragment_chunks<'a>(
262
+ chunks: &mut Vec<EncodeChunk<'a>>,
263
+ doc: &'a [u8],
264
+ head_len: usize,
265
+ big: usize,
266
+ tail_target: usize,
267
+ added_tokens: &[(&[u8], bool)],
268
+ ) {
269
+ let mut first = true;
270
+ for r in crate::pretokenize::safe_split_ranges(doc, big, added_tokens) {
271
+ if r.start < head_len || r.len() <= tail_target {
272
+ chunks.push(EncodeChunk::Fragment {
273
+ bytes: &doc[r],
274
+ first: std::mem::take(&mut first),
275
+ });
276
+ } else {
277
+ for sub in
278
+ crate::pretokenize::safe_split_ranges(&doc[r.clone()], tail_target, added_tokens)
279
+ {
280
+ chunks.push(EncodeChunk::Fragment {
281
+ bytes: &doc[r.start + sub.start..r.start + sub.end],
282
+ first: std::mem::take(&mut first),
283
+ });
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ /// Map items serially when there is at most one (small inputs skip the
290
+ /// thread fan-out), in parallel otherwise.
291
+ pub(crate) fn map_maybe_par<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
292
+ use rayon::prelude::*;
293
+ if items.len() <= 1 {
294
+ items.iter().map(&f).collect()
295
+ } else {
296
+ items.par_iter().map(&f).collect()
297
+ }
298
+ }
299
+
300
+ /// Concatenate per-chunk row lengths into per-document row counts, merging
301
+ /// `continues` fragments into the previous document's row.
302
+ fn row_counts(chunks: &[ChunkTokens]) -> Vec<i64> {
303
+ let mut counts: Vec<i64> = Vec::new();
304
+ for chunk in chunks {
305
+ let mut lens = chunk.lens.iter().copied();
306
+ if chunk.continues
307
+ && let Some(l) = lens.next()
308
+ {
309
+ *counts
310
+ .last_mut()
311
+ .expect("continuation fragment before any document") += l;
312
+ }
313
+ counts.extend(lens);
314
+ }
315
+ counts
316
+ }
317
+
318
+ /// Free spent chunk buffers off the caller's critical path. They total as
319
+ /// much memory as the gathered result, and tearing them down is munmap page
320
+ /// teardown under the address-space write lock: inline frees convoy the
321
+ /// gather copy's first-touch faults (read lock) behind each munmap (write
322
+ /// lock), and a serial free after the copy keeps the munmaps inside the
323
+ /// timed window. A detached background task pays the same teardown CPU
324
+ /// after the caller has returned, overlapped with whatever runs next, and
325
+ /// occupies at most one pool thread. Small results
326
+ /// (< `DEFERRED_DROP_MIN_BYTES` of tokens) just drop inline: their teardown
327
+ /// is microseconds and not worth holding the memory past return.
328
+ fn defer_drop(chunks: Vec<ChunkTokens>) {
329
+ let total: usize = chunks.iter().map(|c| c.ids.len()).sum();
330
+ if total * std::mem::size_of::<u32>() >= DEFERRED_DROP_MIN_BYTES {
331
+ rayon::spawn(move || drop(chunks));
332
+ }
333
+ }
334
+
335
+ /// A destination buffer the caller already owns and will keep owning: `cap`
336
+ /// tokens starting at `ptr`, uninitialized, valid for `cap` disjoint `u32`
337
+ /// writes for as long as this value is used. Never moved, resized, or read
338
+ /// by the gather — only written through, the same way the owned path's
339
+ /// reservation is (see `encode_docs_into`, the zero-copy packed path's core
340
+ /// seam used by `ext/gigatoken/src/tokenizer.rs`).
341
+ pub struct GatherBuf {
342
+ ptr: *mut u32,
343
+ cap: usize,
344
+ }
345
+
346
+ // SAFETY: `GatherBuf::new`'s contract guarantees `ptr` is valid for `cap`
347
+ // disjoint writes for as long as this value is in use, regardless of which
348
+ // thread ends up performing those writes.
349
+ unsafe impl Send for GatherBuf {}
350
+
351
+ impl GatherBuf {
352
+ /// # Safety
353
+ /// `ptr` must be valid for `cap` non-overlapping `u32` writes for the
354
+ /// entire time this `GatherBuf` is in use, and nothing else may read or
355
+ /// write through it concurrently.
356
+ pub unsafe fn new(ptr: *mut u32, cap: usize) -> Self {
357
+ Self { ptr, cap }
358
+ }
359
+ }
360
+
361
+ /// Result of gathering into a caller-supplied `GatherBuf` (see
362
+ /// `encode_docs_into`): either every chunk landed in the destination, or its
363
+ /// bound was overrun (the same NFC-expansion escape `Committer` documents),
364
+ /// leaving the destination an unusable partial commit that the caller must
365
+ /// discard.
366
+ pub enum GatherOutcome {
367
+ /// The destination's first `.0` tokens are valid; `.1` is the
368
+ /// per-document row lengths.
369
+ Committed(usize, Vec<i64>),
370
+ /// The destination is unusable — here is the classic collect-then-
371
+ /// gather result instead, computed from the same already-encoded
372
+ /// chunks (no re-encoding needed).
373
+ Fallback(Vec<u32>, Vec<i64>),
374
+ }
375
+
376
+ /// The in-flight state of the overlapped gather: a flat id buffer reserved
377
+ /// at an upper bound BEFORE chunk sizes are known, plus a cursor over the
378
+ /// longest fully-encoded prefix of the chunk sequence.
379
+ ///
380
+ /// Run as a separate phase after the encode, the final gather's first-touch
381
+ /// faults + memcpy are a serial tail, most of whose CPU is kernel
382
+ /// fault-path contention from every thread faulting the flat buffer at
383
+ /// once. Chunk completion is near-sequential (strict in-order handout,
384
+ /// descending LPT sizes), so a worker that finishes a chunk can commit the
385
+ /// ready prefix — offsets are exact, they are sums of *completed* chunk
386
+ /// sizes — while the tail still encodes. The copy work then hides inside
387
+ /// the encode phase at ~1 thread at a time (no fault convoy), leaving only
388
+ /// a small residual drain after the last chunk.
389
+ ///
390
+ /// The upper bound: a token consumes at least one input byte, so
391
+ /// `total_bytes` tokens bounds the output; untouched reserved pages cost
392
+ /// address space only. Two escapes fall back to the collect-then-gather
393
+ /// path (`gather_flat`): the reservation itself failing (e.g. Linux
394
+ /// heuristic overcommit refusing a 4x-input VA block for a huge batch —
395
+ /// only possible for the owned path, see `Storage::Owned`; a caller-supplied
396
+ /// `GatherBuf` is already allocated, so that escape is not this path's
397
+ /// concern), and the cursor overflowing the bound, which is impossible for
398
+ /// plain byte input and reachable only when NFC normalization expands bytes
399
+ /// (composition-exclusion pathologies) — `advance` stops committing and
400
+ /// `finish`/`finish_external` report the overrun rather than write past the
401
+ /// reservation.
402
+ struct Committer {
403
+ storage: Storage,
404
+ /// Reserved capacity in tokens; commits never write at or past it.
405
+ cap: usize,
406
+ cursor: Mutex<CommitCursor>,
407
+ }
408
+
409
+ /// Where a `Committer` writes: the path's own reservation (owned, returned
410
+ /// to the caller as a `Vec<u32>` by `finish`), or a `GatherBuf` the caller
411
+ /// supplied (external, never moved or freed by `finish_external`).
412
+ enum Storage {
413
+ /// `UnsafeCell` so each `advance` derives the destination pointer fresh
414
+ /// under the cursor lock: no pointer is captured across the struct's
415
+ /// construction-time moves (a move retags the Vec's unique pointer under
416
+ /// strict aliasing models).
417
+ Owned(UnsafeCell<Vec<u32>>),
418
+ External(*mut u32),
419
+ }
420
+
421
+ struct CommitCursor {
422
+ /// Index of the first uncommitted chunk.
423
+ next: usize,
424
+ /// Tokens committed so far == sum of ids.len() over chunks[..next].
425
+ offset: usize,
426
+ /// A chunk did not fit under `cap`; committing has stopped for good.
427
+ overflowed: bool,
428
+ }
429
+
430
+ // SAFETY: the heap buffer behind `Storage::Owned` is never reallocated
431
+ // while shared (nothing pushes to the Vec; it is resized only in `finish`,
432
+ // after all shared use has ended), and `Storage::External`'s pointer is
433
+ // valid per `GatherBuf`'s contract. During the shared phase storage is used
434
+ // solely to derive the buffer pointer under the `cursor` lock, and every
435
+ // write through it lands in a disjoint, in-bounds range.
436
+ unsafe impl Send for Committer {}
437
+ unsafe impl Sync for Committer {}
438
+
439
+ impl Committer {
440
+ /// Chunks committed per `advance` call. Bounds how long one worker is
441
+ /// away from encoding (a backlog can pile up behind a long-held lock);
442
+ /// anything left over is drained by later completions or `finish`.
443
+ const MAX_DRAIN: usize = 8;
444
+
445
+ /// Reserve `cap` tokens up front, or None (→ classic gather) if the
446
+ /// allocator refuses.
447
+ fn try_new(cap: usize) -> Option<Self> {
448
+ let mut flat: Vec<u32> = Vec::new();
449
+ if cap == 0 || flat.try_reserve_exact(cap).is_err() {
450
+ return None;
451
+ }
452
+ // The commits fault this reservation in while the encode runs.
453
+ madvise_hugepage(flat.as_mut_ptr() as *mut u8, cap * std::mem::size_of::<u32>());
454
+ Some(Self {
455
+ storage: Storage::Owned(UnsafeCell::new(flat)),
456
+ cap,
457
+ cursor: Mutex::new(CommitCursor {
458
+ next: 0,
459
+ offset: 0,
460
+ overflowed: false,
461
+ }),
462
+ })
463
+ }
464
+
465
+ /// Commit directly into a caller-supplied `GatherBuf` instead of an
466
+ /// owned reservation. Always succeeds: the caller already made the
467
+ /// allocation, so "reservation refused" isn't this path's concern (only
468
+ /// the cursor-overflow escape is — see `finish_external`).
469
+ fn external(buf: GatherBuf) -> Self {
470
+ madvise_hugepage(buf.ptr as *mut u8, buf.cap * std::mem::size_of::<u32>());
471
+ Self {
472
+ storage: Storage::External(buf.ptr),
473
+ cap: buf.cap,
474
+ cursor: Mutex::new(CommitCursor {
475
+ next: 0,
476
+ offset: 0,
477
+ overflowed: false,
478
+ }),
479
+ }
480
+ }
481
+
482
+ /// The destination's base pointer, derived fresh under the cursor lock
483
+ /// by every caller (see `Storage::Owned`'s field doc for why the owned
484
+ /// case can't just capture a pointer once).
485
+ fn base_ptr(&self) -> *mut u32 {
486
+ match &self.storage {
487
+ // SAFETY: the cursor lock is held by every caller of this
488
+ // method, and the Vec struct is not mutated during the shared
489
+ // phase (see `Storage::Owned`'s field doc), so this only reads it.
490
+ Storage::Owned(cell) => unsafe { (*cell.get()).as_mut_ptr() },
491
+ Storage::External(ptr) => *ptr,
492
+ }
493
+ }
494
+
495
+ /// Copy any freshly completed prefix chunks into the flat buffer.
496
+ /// Non-blocking: if another worker is mid-commit, return to encoding —
497
+ /// the current holder (or a later completion, or `finish`) picks the
498
+ /// chunk up. A completion that lands between the holder's last check
499
+ /// and its unlock is likewise deferred, never lost.
500
+ fn advance(&self, outs: &[OnceLock<ChunkTokens>]) {
501
+ let Ok(mut cur) = self.cursor.try_lock() else {
502
+ return;
503
+ };
504
+ let base = self.base_ptr();
505
+ for _ in 0..Self::MAX_DRAIN {
506
+ if cur.overflowed {
507
+ return;
508
+ }
509
+ let Some(chunk) = outs.get(cur.next).and_then(OnceLock::get) else {
510
+ return;
511
+ };
512
+ let len = chunk.ids.len();
513
+ if self.cap - cur.offset < len {
514
+ cur.overflowed = true;
515
+ return;
516
+ }
517
+ // SAFETY: holding `cursor`, writing [offset, offset+len), which
518
+ // is within the reservation (checked above) and disjoint from
519
+ // every earlier commit (offset is monotone).
520
+ unsafe {
521
+ std::ptr::copy_nonoverlapping(chunk.ids.as_ptr(), base.add(cur.offset), len);
522
+ }
523
+ cur.offset += len;
524
+ cur.next += 1;
525
+ }
526
+ }
527
+
528
+ /// Copy the uncommitted suffix in parallel straight through `base` —
529
+ /// shared by `finish` and `finish_external`. `false` means the bound was
530
+ /// overrun (see `Committer`'s type doc): the caller falls back to the
531
+ /// classic gather, and nothing written through `base` is trusted.
532
+ fn copy_suffix(base: *mut u32, cur: &CommitCursor, chunks: &[ChunkTokens], total: usize, cap: usize) -> bool {
533
+ use rayon::prelude::*;
534
+ /// Raw destination pointer, shareable across the copy tasks. (The
535
+ /// accessor keeps closure capture at the wrapper, not the field.)
536
+ struct SyncPtr(*mut u32);
537
+ // SAFETY: only used for the disjoint in-bounds writes below.
538
+ unsafe impl Send for SyncPtr {}
539
+ unsafe impl Sync for SyncPtr {}
540
+ impl SyncPtr {
541
+ /// SAFETY: `off` must be within the reservation.
542
+ unsafe fn at(&self, off: usize) -> *mut u32 {
543
+ unsafe { self.0.add(off) }
544
+ }
545
+ }
546
+
547
+ if cur.overflowed || total > cap {
548
+ return false;
549
+ }
550
+ let rest = &chunks[cur.next..];
551
+ let mut offsets = Vec::with_capacity(rest.len());
552
+ let mut offset = cur.offset;
553
+ for chunk in rest {
554
+ offsets.push(offset);
555
+ offset += chunk.ids.len();
556
+ }
557
+ debug_assert_eq!(offset, total);
558
+ let base = SyncPtr(base);
559
+ // `with_max_len(1)` keeps the multi-MB copies stealable one by one.
560
+ rest.par_iter()
561
+ .zip(offsets)
562
+ .with_max_len(1)
563
+ .for_each(|(chunk, off)| {
564
+ // SAFETY: exclusive access (workers are joined); suffix
565
+ // ranges are disjoint from each other and from the
566
+ // committed prefix, and end at total <= cap.
567
+ unsafe {
568
+ std::ptr::copy_nonoverlapping(
569
+ chunk.ids.as_ptr(),
570
+ base.at(off),
571
+ chunk.ids.len(),
572
+ );
573
+ }
574
+ });
575
+ true
576
+ }
577
+
578
+ /// After all chunks are encoded (and the scope joined): copy the
579
+ /// uncommitted suffix, size the buffer to `total` and trim the
580
+ /// reservation. None means the bound was overrun (see type docs) —
581
+ /// caller falls back to the classic gather; the prefix copied so far is
582
+ /// discarded (chunk buffers are still intact).
583
+ fn finish(self, chunks: &[ChunkTokens], total: usize) -> Option<Vec<u32>> {
584
+ let Committer { storage, cap, cursor } = self;
585
+ let Storage::Owned(cell) = storage else {
586
+ unreachable!("finish is only called on a Committer built by try_new")
587
+ };
588
+ let mut flat = cell.into_inner();
589
+ let cur = cursor
590
+ .into_inner()
591
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
592
+ // Derived AFTER the Vec moved out of the cell: a move retags the
593
+ // Vec's unique pointer under strict aliasing models, so the suffix
594
+ // writes and the `set_len` below go through a post-move pointer.
595
+ if !Self::copy_suffix(flat.as_mut_ptr(), &cur, chunks, total, cap) {
596
+ return None;
597
+ }
598
+ // SAFETY: capacity >= cap >= total, and [0, total) was fully
599
+ // initialized by the prefix commits plus the suffix copies above.
600
+ unsafe {
601
+ flat.set_len(total);
602
+ }
603
+ // Return the unused reservation. Large allocations trim in place on
604
+ // the mainstream allocators (macOS libmalloc large entries, glibc
605
+ // mmap'd chunks via mremap): pointer-stable, no copy, and the
606
+ // untouched tail pages were never faulted so there is nothing to
607
+ // tear down. An allocator that copies instead only costs one
608
+ // memcpy; correctness is unaffected.
609
+ flat.shrink_to_fit();
610
+ Some(flat)
611
+ }
612
+
613
+ /// `finish` for a `Committer` built by `external`: the same suffix
614
+ /// copy, but there is no owned `Vec` to size or trim — the caller's
615
+ /// `GatherBuf` is the destination throughout. `true` means its first
616
+ /// `total` tokens are now valid.
617
+ fn finish_external(self, chunks: &[ChunkTokens], total: usize) -> bool {
618
+ let Committer { storage, cap, cursor } = self;
619
+ let Storage::External(ptr) = storage else {
620
+ unreachable!("finish_external is only called on a Committer built by external")
621
+ };
622
+ let cur = cursor
623
+ .into_inner()
624
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
625
+ Self::copy_suffix(ptr, &cur, chunks, total, cap)
626
+ }
627
+ }
628
+
629
+ /// Encode all chunks with pooled workers and gather them into one flat id
630
+ /// buffer plus per-document row counts — in parallel when there is more
631
+ /// than one chunk, serially otherwise. Each worker's caches are pre-sized
632
+ /// for its share of `total_bytes` (capacity hints only — see
633
+ /// `Tokenizer::fork_sized`; workers already forked on an earlier call keep
634
+ /// their warm caches).
635
+ ///
636
+ /// Chunks are handed out in strict index order through an atomic counter
637
+ /// (one pulling task per rayon thread), not `par_iter`: recursive range
638
+ /// splitting lets a thread steal a subrange of early big chunks and still
639
+ /// be *starting* a 2×-target chunk after everyone else has reached the
640
+ /// small tail, stranding the rest of the pool behind that one straggler.
641
+ /// In-order handout makes the LPT descending-size order of
642
+ /// `build_doc_chunks` a guarantee, bounding the tail at roughly one small
643
+ /// chunk — and makes chunk completion near-sequential, which is what lets
644
+ /// the gather copy overlap the encode (see `Committer`).
645
+ pub(crate) fn encode_chunks_gathered(
646
+ workers: &WorkerPool,
647
+ proto: &Tokenizer,
648
+ chunks: &[EncodeChunk],
649
+ total_bytes: usize,
650
+ ) -> (Vec<u32>, Vec<i64>) {
651
+ // A token consumes >= 1 input byte, so total_bytes tokens is the
652
+ // reservation bound (NFC expansion is caught by the overflow escape).
653
+ encode_chunks_gathered_with_cap(workers, proto, chunks, total_bytes, total_bytes)
654
+ }
655
+
656
+ /// `encode_chunks_gathered` with the committer's reservation bound passed
657
+ /// explicitly, so tests can force the overflow fallback in-process.
658
+ fn encode_chunks_gathered_with_cap(
659
+ workers: &WorkerPool,
660
+ proto: &Tokenizer,
661
+ chunks: &[EncodeChunk],
662
+ total_bytes: usize,
663
+ cap_tokens: usize,
664
+ ) -> (Vec<u32>, Vec<i64>) {
665
+ let share = total_bytes / rayon::current_num_threads().max(1);
666
+ let encode = |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c));
667
+ if chunks.len() <= 1 {
668
+ // Small inputs skip the thread fan-out — and a lone chunk's id
669
+ // buffer IS the flat result, no gather copy at all.
670
+ return match chunks.first() {
671
+ Some(chunk) => {
672
+ let out = encode(chunk);
673
+ let counts = row_counts(std::slice::from_ref(&out));
674
+ (out.ids, counts)
675
+ }
676
+ None => (Vec::new(), Vec::new()),
677
+ };
678
+ }
679
+ let next = AtomicUsize::new(0);
680
+ let outs: Vec<OnceLock<ChunkTokens>> = (0..chunks.len()).map(|_| OnceLock::new()).collect();
681
+ let committer = Committer::try_new(cap_tokens);
682
+ let tasks = rayon::current_num_threads().min(chunks.len());
683
+ rayon::scope(|s| {
684
+ for _ in 0..tasks {
685
+ s.spawn(|_| {
686
+ loop {
687
+ let i = next.fetch_add(1, Ordering::Relaxed);
688
+ let Some(chunk) = chunks.get(i) else {
689
+ // One last opportunistic drain on the way out: this
690
+ // worker's final chunk may have been skipped while
691
+ // another held the commit lock.
692
+ if let Some(c) = &committer {
693
+ c.advance(&outs);
694
+ }
695
+ break;
696
+ };
697
+ // Each index is claimed exactly once, so `set` cannot
698
+ // already be filled.
699
+ let _ = outs[i].set(encode(chunk));
700
+ if let Some(c) = &committer {
701
+ c.advance(&outs);
702
+ }
703
+ }
704
+ });
705
+ }
706
+ });
707
+ let outs: Vec<ChunkTokens> = outs
708
+ .into_iter()
709
+ .map(|slot| slot.into_inner().expect("every claimed chunk was encoded"))
710
+ .collect();
711
+ let counts = row_counts(&outs);
712
+ let total: usize = outs.iter().map(|c| c.ids.len()).sum();
713
+ match committer.and_then(|c| c.finish(&outs, total)) {
714
+ Some(flat) => {
715
+ // The copies are done; the spent chunk buffers are dead weight.
716
+ defer_drop(outs);
717
+ (flat, counts)
718
+ }
719
+ None => (gather_flat(outs), counts),
720
+ }
721
+ }
722
+
723
+ /// `encode_chunks_gathered_with_cap`, but gathering directly into a
724
+ /// caller-supplied `GatherBuf` (see `GatherBuf`) instead of a freshly
725
+ /// allocated `Vec<u32>` — the zero-copy packed path's core seam (see
726
+ /// `encode_docs_into` and `ext/gigatoken/src/tokenizer.rs`). Every chunk is
727
+ /// encoded either way, so an overrun `GatherOutcome::Fallback` carries the
728
+ /// classic gathered result rather than asking the caller to re-run the
729
+ /// encode.
730
+ pub(crate) fn encode_chunks_into(
731
+ workers: &WorkerPool,
732
+ proto: &Tokenizer,
733
+ chunks: &[EncodeChunk],
734
+ total_bytes: usize,
735
+ dest: GatherBuf,
736
+ ) -> GatherOutcome {
737
+ let share = total_bytes / rayon::current_num_threads().max(1);
738
+ let encode = |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c));
739
+ if chunks.len() <= 1 {
740
+ return match chunks.first() {
741
+ Some(chunk) => {
742
+ let out = encode(chunk);
743
+ let counts = row_counts(std::slice::from_ref(&out));
744
+ if out.ids.len() <= dest.cap {
745
+ // SAFETY: the only chunk, so the only write; in bounds
746
+ // per the check above.
747
+ unsafe {
748
+ std::ptr::copy_nonoverlapping(out.ids.as_ptr(), dest.ptr, out.ids.len());
749
+ }
750
+ GatherOutcome::Committed(out.ids.len(), counts)
751
+ } else {
752
+ GatherOutcome::Fallback(out.ids, counts)
753
+ }
754
+ }
755
+ None => GatherOutcome::Committed(0, Vec::new()),
756
+ };
757
+ }
758
+ let next = AtomicUsize::new(0);
759
+ let outs: Vec<OnceLock<ChunkTokens>> = (0..chunks.len()).map(|_| OnceLock::new()).collect();
760
+ let committer = Committer::external(dest);
761
+ let tasks = rayon::current_num_threads().min(chunks.len());
762
+ rayon::scope(|s| {
763
+ for _ in 0..tasks {
764
+ s.spawn(|_| {
765
+ loop {
766
+ let i = next.fetch_add(1, Ordering::Relaxed);
767
+ let Some(chunk) = chunks.get(i) else {
768
+ // One last opportunistic drain on the way out: this
769
+ // worker's final chunk may have been skipped while
770
+ // another held the commit lock.
771
+ committer.advance(&outs);
772
+ break;
773
+ };
774
+ // Each index is claimed exactly once, so `set` cannot
775
+ // already be filled.
776
+ let _ = outs[i].set(encode(chunk));
777
+ committer.advance(&outs);
778
+ }
779
+ });
780
+ }
781
+ });
782
+ let outs: Vec<ChunkTokens> = outs
783
+ .into_iter()
784
+ .map(|slot| slot.into_inner().expect("every claimed chunk was encoded"))
785
+ .collect();
786
+ let counts = row_counts(&outs);
787
+ let total: usize = outs.iter().map(|c| c.ids.len()).sum();
788
+ if committer.finish_external(&outs, total) {
789
+ // The copies are done; the spent chunk buffers are dead weight.
790
+ defer_drop(outs);
791
+ GatherOutcome::Committed(total, counts)
792
+ } else {
793
+ GatherOutcome::Fallback(gather_flat(outs), counts)
794
+ }
795
+ }
796
+
797
+ /// Merge per-chunk outputs into one flat id buffer and per-document row
798
+ /// counts. The flat gather copies chunk buffers in parallel into a single
799
+ /// allocation. (The SentencePiece paths gather this way; the BPE batch
800
+ /// path overlaps the copy with the encode — see `Committer` — and falls
801
+ /// back to this only when the up-front reservation is refused or overrun.)
802
+ pub(crate) fn assemble_ragged(chunks: Vec<ChunkTokens>) -> (Vec<u32>, Vec<i64>) {
803
+ let counts = row_counts(&chunks);
804
+ (gather_flat(chunks), counts)
805
+ }
806
+
807
+ /// Copy all chunk id buffers into one freshly allocated flat buffer, in
808
+ /// parallel, freeing the spent chunk buffers off the critical path.
809
+ fn gather_flat(chunks: Vec<ChunkTokens>) -> Vec<u32> {
810
+ use rayon::prelude::*;
811
+ let total: usize = chunks.iter().map(|c| c.ids.len()).sum();
812
+ let mut flat = vec![0u32; total];
813
+ // The parallel copy below faults in the whole buffer.
814
+ madvise_hugepage(flat.as_mut_ptr() as *mut u8, total * std::mem::size_of::<u32>());
815
+ let mut rest: &mut [u32] = &mut flat;
816
+ let mut slices = Vec::with_capacity(chunks.len());
817
+ for chunk in &chunks {
818
+ let (head, tail) = rest.split_at_mut(chunk.ids.len());
819
+ slices.push(head);
820
+ rest = tail;
821
+ }
822
+ // `with_max_len(1)` keeps the multi-MB copies stealable one by one.
823
+ slices
824
+ .into_par_iter()
825
+ .zip(chunks.par_iter())
826
+ .with_max_len(1)
827
+ .for_each(|(dst, chunk)| dst.copy_from_slice(&chunk.ids));
828
+ defer_drop(chunks);
829
+ flat
830
+ }
831
+
832
+ /// Persistent pool of forked tokenizer workers used by encode_batch and
833
+ /// encode_files. One slot per rayon thread, forked lazily on first use and
834
+ /// retained for the tokenizer's lifetime, so each worker's pretoken cache
835
+ /// stays warm when encoding is invoked repeatedly (e.g. in a loop).
836
+ ///
837
+ /// Invariant: the prototype tokenizer must not be mutated between encodes
838
+ /// that share a pool. Workers are forked lazily (first use of each slot)
839
+ /// and never refreshed, so mutating the prototype (`add_special_token`,
840
+ /// `set_added_tokens`, `set_pretokenizer_type`, ...) after some slots have
841
+ /// forked leaves those workers on the OLD state while slots forked later
842
+ /// (or rebuilt after a worker panic) capture the new one — a chunk's
843
+ /// tokens would then depend on which slot the handout gave it. Finish all
844
+ /// model mutation before the pool's first encode (the loaders do), or use
845
+ /// a fresh pool after mutating. The Python bindings uphold this by
846
+ /// construction: no mutator is exposed after the pyclass is built.
847
+ pub struct WorkerPool {
848
+ slots: OnceLock<Vec<Mutex<Option<Tokenizer>>>>,
849
+ /// Worker for the sequential (`parallel=false`) encode paths, kept
850
+ /// separate from `slots` so a sequential call never sizes or touches
851
+ /// the rayon pool (even `rayon::current_num_threads()` would build it).
852
+ serial: Mutex<Option<Tokenizer>>,
853
+ }
854
+
855
+ impl Default for WorkerPool {
856
+ fn default() -> Self {
857
+ Self::new()
858
+ }
859
+ }
860
+
861
+ impl WorkerPool {
862
+ pub fn new() -> Self {
863
+ Self {
864
+ slots: OnceLock::new(),
865
+ serial: Mutex::new(None),
866
+ }
867
+ }
868
+
869
+ /// Run `f` with exclusive access to a pooled worker, forking one sized
870
+ /// for `expected_bytes` of input if the slot is empty. Rayon never runs
871
+ /// more tasks concurrently than it has threads, and there is one slot
872
+ /// per thread, so a free slot always exists; the yield loop only spins
873
+ /// when non-rayon threads encode at the same time.
874
+ ///
875
+ /// `proto` must be the same, unmutated prototype on every call for a
876
+ /// given pool: forks are cached per slot and never compared against
877
+ /// `proto` again, so a mutated (or different) prototype yields stale
878
+ /// workers for already-filled slots (see the type-level invariant).
879
+ fn with_worker<R>(
880
+ &self,
881
+ proto: &Tokenizer,
882
+ expected_bytes: usize,
883
+ f: impl FnOnce(&mut Tokenizer) -> R,
884
+ ) -> R {
885
+ let slots = self.slots.get_or_init(|| {
886
+ (0..rayon::current_num_threads())
887
+ .map(|_| Mutex::new(None))
888
+ .collect()
889
+ });
890
+ loop {
891
+ for slot in slots {
892
+ match slot.try_lock() {
893
+ Ok(mut guard) => {
894
+ return f(guard.get_or_insert_with(|| proto.fork_sized(expected_bytes)));
895
+ }
896
+ Err(TryLockError::Poisoned(poisoned)) => {
897
+ // A worker panicked mid-encode; its cache may be
898
+ // inconsistent, so rebuild it from the prototype.
899
+ let mut guard = poisoned.into_inner();
900
+ *guard = None;
901
+ return f(guard.get_or_insert_with(|| proto.fork_sized(expected_bytes)));
902
+ }
903
+ Err(TryLockError::WouldBlock) => {}
904
+ }
905
+ }
906
+ std::thread::yield_now();
907
+ }
908
+ }
909
+
910
+ /// `with_worker` for the sequential paths: run `f` with the dedicated
911
+ /// serial worker (forked lazily, retained so its pretoken cache stays
912
+ /// warm across calls), without initializing the rayon-sized slots. The
913
+ /// same unmutated-prototype invariant applies. Blocks if another thread
914
+ /// is in a sequential encode on the same pool.
915
+ fn with_serial_worker<R>(
916
+ &self,
917
+ proto: &Tokenizer,
918
+ expected_bytes: usize,
919
+ f: impl FnOnce(&mut Tokenizer) -> R,
920
+ ) -> R {
921
+ let mut guard = self.serial.lock().unwrap_or_else(|poisoned| {
922
+ // A worker panicked mid-encode; its cache may be inconsistent,
923
+ // so rebuild it from the prototype.
924
+ let mut guard = poisoned.into_inner();
925
+ *guard = None;
926
+ guard
927
+ });
928
+ f(guard.get_or_insert_with(|| proto.fork_sized(expected_bytes)))
929
+ }
930
+ }
931
+
932
+ /// Shared core of encode_batch / encode_files for pre-resolved document
933
+ /// slices: chunk (splitting oversized documents at pretoken-safe
934
+ /// boundaries), encode with pooled workers, and assemble the ragged result
935
+ /// (one flat id buffer plus per-document row lengths). Public so Rust
936
+ /// benches exercise the identical parallel path as the Python bindings.
937
+ ///
938
+ /// Environment: setting `GIGATOK_NO_LPT` (to any value, empty included)
939
+ /// disables LPT chunk sizing in favor of uniform chunks — token- and
940
+ /// order-identical output, chunk shaping only; see `lpt_from_env`. The
941
+ /// variable is read once per call, never in per-chunk loops.
942
+ pub fn encode_docs_ragged(
943
+ workers: &WorkerPool,
944
+ proto: &Tokenizer,
945
+ docs: &[&[u8]],
946
+ ) -> (Vec<u32>, Vec<i64>) {
947
+ encode_docs_ragged_with(workers, proto, docs, lpt_from_env())
948
+ }
949
+
950
+ /// `encode_docs_ragged` with the LPT switch passed explicitly instead of
951
+ /// read from the environment, so tests can cover both shapes in-process
952
+ /// without mutating process env.
953
+ pub(crate) fn encode_docs_ragged_with(
954
+ workers: &WorkerPool,
955
+ proto: &Tokenizer,
956
+ docs: &[&[u8]],
957
+ lpt: bool,
958
+ ) -> (Vec<u32>, Vec<i64>) {
959
+ let total: usize = docs.iter().map(|d| d.len()).sum();
960
+ let added = proto.added_token_split_blockers();
961
+ let chunks = build_doc_chunks(docs, total, chunk_target_bytes(total), &added, lpt);
962
+ encode_chunks_gathered(workers, proto, &chunks, total)
963
+ }
964
+
965
+ /// `encode_docs_ragged`, but gathering directly into a caller-supplied
966
+ /// `GatherBuf` instead of a freshly allocated `Vec<u32>` — the zero-copy
967
+ /// packed path's core seam (see `ext/gigatoken/src/tokenizer.rs`). `dest`
968
+ /// must have capacity for at least as many tokens as `docs`'s total bytes (a
969
+ /// token consumes >= 1 input byte — the same bound `encode_docs_ragged`
970
+ /// reserves internally); see `GatherOutcome` for what happens otherwise.
971
+ pub fn encode_docs_into(
972
+ workers: &WorkerPool,
973
+ proto: &Tokenizer,
974
+ docs: &[&[u8]],
975
+ dest: GatherBuf,
976
+ ) -> GatherOutcome {
977
+ let total: usize = docs.iter().map(|d| d.len()).sum();
978
+ let added = proto.added_token_split_blockers();
979
+ let chunks = build_doc_chunks(docs, total, chunk_target_bytes(total), &added, lpt_from_env());
980
+ encode_chunks_into(workers, proto, &chunks, total, dest)
981
+ }
982
+
983
+ /// Sequential `encode_docs_ragged`: encode every document in order on the
984
+ /// calling thread with the pool's dedicated serial worker. Never touches
985
+ /// rayon — required when the caller is a forked child of a process whose
986
+ /// global rayon pool was already built (the pool's threads do not survive
987
+ /// the fork, so injecting work into it would wait forever), and what the
988
+ /// Python bindings' `parallel=false` promises. Token- and order-identical
989
+ /// to the parallel path (which `parallel_ragged_matches_serial` checks
990
+ /// against exactly this shape of serial loop).
991
+ pub fn encode_docs_ragged_serial(
992
+ workers: &WorkerPool,
993
+ proto: &Tokenizer,
994
+ docs: &[&[u8]],
995
+ ) -> (Vec<u32>, Vec<i64>) {
996
+ let total: usize = docs.iter().map(|d| d.len()).sum();
997
+ workers.with_serial_worker(proto, total, |tok| {
998
+ let mut ids: Vec<u32> = Vec::with_capacity(total / 4 + 16);
999
+ // Huge pages before the encode's stores fault the buffer in (as in
1000
+ // encode_chunk); serially the one buffer is the whole result.
1001
+ madvise_hugepage(ids.as_mut_ptr() as *mut u8, ids.capacity() * 4);
1002
+ let mut lens = Vec::with_capacity(docs.len());
1003
+ for doc in docs {
1004
+ encode_into(tok, doc, &mut ids, &mut lens);
1005
+ }
1006
+ (ids, lens)
1007
+ })
1008
+ }
1009
+
1010
+ /// Work unit for parallel SentencePiece encoding, mirroring `EncodeChunk`.
1011
+ enum SpChunk<'a> {
1012
+ /// A run of whole documents, one output row each.
1013
+ Docs(Vec<&'a str>),
1014
+ /// A scanner-safe fragment of one oversized document (see
1015
+ /// `SentencePieceBPE::safe_fragment_ranges`). Fragments of a document
1016
+ /// are consecutive chunks; `first` marks the document's first fragment.
1017
+ Fragment { text: &'a str, first: bool },
1018
+ }
1019
+
1020
+ /// Group documents into parallel chunks of roughly `target` bytes. A
1021
+ /// document larger than `2 * target` (the BPE path's oversize threshold) is
1022
+ /// split into consecutive Fragment chunks at unit boundaries the scanner
1023
+ /// proves safe, so even a single huge document is encoded across all cores
1024
+ /// with token-identical output — except on models without the raw fast path
1025
+ /// (`supports_fragment_split`), where no interior cut is provably safe and
1026
+ /// the document stays one chunk.
1027
+ fn sp_build_chunks<'a>(
1028
+ tokenizer: &bpe::SentencePieceBPE,
1029
+ texts: &[&'a str],
1030
+ target: usize,
1031
+ ) -> Vec<SpChunk<'a>> {
1032
+ let can_split = tokenizer.supports_fragment_split();
1033
+ let mut chunks = Vec::new();
1034
+ let mut group: Vec<&str> = Vec::new();
1035
+ let mut acc = 0usize;
1036
+ for &text in texts {
1037
+ if can_split && text.len() > 2 * target {
1038
+ if !group.is_empty() {
1039
+ chunks.push(SpChunk::Docs(std::mem::take(&mut group)));
1040
+ acc = 0;
1041
+ }
1042
+ let mut first = true;
1043
+ for r in tokenizer.safe_fragment_ranges(text, target) {
1044
+ chunks.push(SpChunk::Fragment {
1045
+ text: &text[r],
1046
+ first: std::mem::take(&mut first),
1047
+ });
1048
+ }
1049
+ continue;
1050
+ }
1051
+ group.push(text);
1052
+ acc += text.len();
1053
+ if acc >= target {
1054
+ chunks.push(SpChunk::Docs(std::mem::take(&mut group)));
1055
+ acc = 0;
1056
+ }
1057
+ }
1058
+ if !group.is_empty() {
1059
+ chunks.push(SpChunk::Docs(group));
1060
+ }
1061
+ chunks
1062
+ }
1063
+
1064
+ /// Encode SentencePiece chunks with a per-chunk Encoder and gather them into
1065
+ /// one flat id buffer plus per-document row counts (`row_counts` merges a
1066
+ /// continuation fragment's first row into the previous document's row).
1067
+ fn sp_encode_chunks(
1068
+ tokenizer: &bpe::SentencePieceBPE,
1069
+ chunks: &[SpChunk],
1070
+ ) -> (Vec<u32>, Vec<i64>) {
1071
+ let outs = map_maybe_par(chunks, |chunk| {
1072
+ // Reserve the output once from a bytes-per-token estimate on the
1073
+ // low side of natural language, with huge pages before the encode's
1074
+ // stores fault it in — as in `encode_chunk`.
1075
+ let byte_len = match chunk {
1076
+ SpChunk::Docs(group) => group.iter().map(|t| t.len()).sum::<usize>(),
1077
+ SpChunk::Fragment { text, .. } => text.len(),
1078
+ };
1079
+ let mut ids: Vec<u32> = Vec::with_capacity(byte_len / 4 + 16);
1080
+ madvise_hugepage(ids.as_mut_ptr() as *mut u8, ids.capacity() * 4);
1081
+ let mut encoder = tokenizer.encoder();
1082
+ let mut lens: Vec<i64> = Vec::new();
1083
+ let mut continues = false;
1084
+ match chunk {
1085
+ SpChunk::Docs(group) => {
1086
+ for text in group {
1087
+ sp_encode_into(&mut encoder, text, &mut ids, &mut lens);
1088
+ }
1089
+ }
1090
+ SpChunk::Fragment { text, first } => {
1091
+ sp_encode_fragment_into(&mut encoder, text, *first, &mut ids, &mut lens);
1092
+ continues = !*first;
1093
+ }
1094
+ }
1095
+ ChunkTokens {
1096
+ ids,
1097
+ lens,
1098
+ continues,
1099
+ }
1100
+ });
1101
+ assemble_ragged(outs)
1102
+ }
1103
+
1104
+ /// SentencePiece analog of `encode_docs_ragged`: group whole documents into
1105
+ /// parallel chunks — splitting an oversized document into scanner-safe
1106
+ /// fragments (see `sp_build_chunks`) — and encode each chunk with its own
1107
+ /// Encoder.
1108
+ pub fn sp_encode_docs_ragged(
1109
+ tokenizer: &bpe::SentencePieceBPE,
1110
+ texts: &[&str],
1111
+ ) -> (Vec<u32>, Vec<i64>) {
1112
+ let total: usize = texts.iter().map(|t| t.len()).sum();
1113
+ let chunks = sp_build_chunks(tokenizer, texts, chunk_target_bytes(total));
1114
+ sp_encode_chunks(tokenizer, &chunks)
1115
+ }
1116
+
1117
+ /// Sequential `sp_encode_docs_ragged`: one Encoder (so one pretoken cache)
1118
+ /// over all documents, on the calling thread, never touching rayon. Token-
1119
+ /// and order-identical to the parallel path, which encodes the same
1120
+ /// documents in the same order with per-chunk Encoders.
1121
+ pub fn sp_encode_docs_ragged_serial(
1122
+ tokenizer: &bpe::SentencePieceBPE,
1123
+ texts: &[&str],
1124
+ ) -> (Vec<u32>, Vec<i64>) {
1125
+ let mut encoder = tokenizer.encoder();
1126
+ let mut ids: Vec<u32> = Vec::new();
1127
+ let mut lens: Vec<i64> = Vec::with_capacity(texts.len());
1128
+ for &text in texts {
1129
+ sp_encode_into(&mut encoder, text, &mut ids, &mut lens);
1130
+ }
1131
+ (ids, lens)
1132
+ }
1133
+
1134
+ /// encode_files core for the BPE backend. With no separator each file is one
1135
+ /// document (small files are grouped, huge ones split at pretoken-safe
1136
+ /// boundaries); otherwise each file is cut into byte regions at document
1137
+ /// boundaries and documents are extracted while encoding.
1138
+ pub fn encode_files_docs(
1139
+ workers: &WorkerPool,
1140
+ proto: &Tokenizer,
1141
+ files: &[&[u8]],
1142
+ format: &DocFormat,
1143
+ ) -> (Vec<u32>, Vec<i64>) {
1144
+ if matches!(format, DocFormat::Text { separator: None }) {
1145
+ return encode_docs_ragged(workers, proto, files);
1146
+ }
1147
+ let total: usize = files.iter().map(|f| f.len()).sum();
1148
+ let target = chunk_target_bytes(total);
1149
+ let chunks: Vec<EncodeChunk> = files
1150
+ .iter()
1151
+ .flat_map(|&bytes| {
1152
+ chunk_ranges(bytes, format, target)
1153
+ .into_iter()
1154
+ .map(move |r| EncodeChunk::Region {
1155
+ bytes: &bytes[r],
1156
+ format,
1157
+ })
1158
+ })
1159
+ .collect();
1160
+ encode_chunks_gathered(workers, proto, &chunks, total)
1161
+ }
1162
+
1163
+ /// Sequential `encode_files_docs`: extract and encode every document in
1164
+ /// file order on the calling thread, never touching rayon. Document
1165
+ /// iteration matches the parallel path's chunk regions (`for_each_doc`
1166
+ /// over the same format), so the output is token- and order-identical.
1167
+ pub fn encode_files_docs_serial(
1168
+ workers: &WorkerPool,
1169
+ proto: &Tokenizer,
1170
+ files: &[&[u8]],
1171
+ format: &DocFormat,
1172
+ ) -> (Vec<u32>, Vec<i64>) {
1173
+ let total: usize = files.iter().map(|f| f.len()).sum();
1174
+ workers.with_serial_worker(proto, total, |tok| {
1175
+ let mut ids: Vec<u32> = Vec::with_capacity(total / 4 + 16);
1176
+ // Huge pages before the encode's stores fault the buffer in (as in
1177
+ // encode_chunk); serially the one buffer is the whole result.
1178
+ madvise_hugepage(ids.as_mut_ptr() as *mut u8, ids.capacity() * 4);
1179
+ let mut lens = Vec::new();
1180
+ for &bytes in files {
1181
+ for_each_doc(bytes, format, |doc| encode_into(tok, doc, &mut ids, &mut lens));
1182
+ }
1183
+ (ids, lens)
1184
+ })
1185
+ }
1186
+
1187
+ /// encode_files core for the SentencePiece backend. With no separator each
1188
+ /// file is one document (small files are grouped, huge ones split at
1189
+ /// scanner-safe boundaries); otherwise each file is cut into byte regions at
1190
+ /// document boundaries and each region's documents are encoded with a
1191
+ /// per-chunk Encoder. Documents are assumed to be valid UTF-8.
1192
+ pub fn sp_encode_files_docs(
1193
+ tokenizer: &bpe::SentencePieceBPE,
1194
+ files: &[&[u8]],
1195
+ format: &DocFormat,
1196
+ ) -> (Vec<u32>, Vec<i64>) {
1197
+ if matches!(format, DocFormat::Text { separator: None }) {
1198
+ // Whole-file documents: same grouping and oversized-document
1199
+ // fragmenting as the batch path (mirrors `encode_files_docs`).
1200
+ let texts: Vec<&str> = files
1201
+ .iter()
1202
+ // SAFETY: file contents are trusted valid UTF-8 (encode_files'
1203
+ // documented contract, like the unchecked conversion below).
1204
+ .map(|&bytes| unsafe { std::str::from_utf8_unchecked(bytes) })
1205
+ .collect();
1206
+ return sp_encode_docs_ragged(tokenizer, &texts);
1207
+ }
1208
+ let total: usize = files.iter().map(|f| f.len()).sum();
1209
+ let target = chunk_target_bytes(total);
1210
+ let chunks: Vec<(usize, Range<usize>)> = files
1211
+ .iter()
1212
+ .enumerate()
1213
+ .flat_map(|(i, &bytes)| {
1214
+ chunk_ranges(bytes, format, target)
1215
+ .into_iter()
1216
+ .map(move |r| (i, r))
1217
+ })
1218
+ .collect();
1219
+ let outs = map_maybe_par(&chunks, |(file, range)| {
1220
+ let bytes = &files[*file][range.clone()];
1221
+ let mut encoder = tokenizer.encoder();
1222
+ let mut ids: Vec<u32> = Vec::new();
1223
+ let mut lens: Vec<i64> = Vec::new();
1224
+ for_each_doc(bytes, format, |doc| {
1225
+ let text = unsafe { std::str::from_utf8_unchecked(doc) };
1226
+ sp_encode_into(&mut encoder, text, &mut ids, &mut lens);
1227
+ });
1228
+ ChunkTokens {
1229
+ ids,
1230
+ lens,
1231
+ continues: false,
1232
+ }
1233
+ });
1234
+ assemble_ragged(outs)
1235
+ }
1236
+
1237
+ /// Sequential `sp_encode_files_docs`: one Encoder over every file's
1238
+ /// documents in order, on the calling thread, never touching rayon.
1239
+ pub fn sp_encode_files_docs_serial(
1240
+ tokenizer: &bpe::SentencePieceBPE,
1241
+ files: &[&[u8]],
1242
+ format: &DocFormat,
1243
+ ) -> (Vec<u32>, Vec<i64>) {
1244
+ let mut encoder = tokenizer.encoder();
1245
+ let mut ids: Vec<u32> = Vec::new();
1246
+ let mut lens: Vec<i64> = Vec::new();
1247
+ for &bytes in files {
1248
+ for_each_doc(bytes, format, |doc| {
1249
+ let text = unsafe { std::str::from_utf8_unchecked(doc) };
1250
+ sp_encode_into(&mut encoder, text, &mut ids, &mut lens);
1251
+ });
1252
+ }
1253
+ (ids, lens)
1254
+ }
1255
+
1256
+ #[cfg(test)]
1257
+ mod tests {
1258
+ use super::*;
1259
+ use std::collections::HashMap;
1260
+
1261
+ /// The parallel chunked path — LPT descending chunk sizes, pooled
1262
+ /// pre-sized workers, overlapped Committer gather (with
1263
+ /// collect-then-gather as the fallback) — must be
1264
+ /// token-identical, in the same order, to a serial per-document
1265
+ /// encode, with LPT both on and off (GIGATOK_NO_LPT), passed
1266
+ /// explicitly so no process env is mutated. A byte-level vocab makes
1267
+ /// any misordered or dropped chunk visible in the flat buffer, not
1268
+ /// just in the counts.
1269
+ #[test]
1270
+ fn parallel_ragged_matches_serial() {
1271
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1272
+ let vocab = (0..=u8::MAX).map(|b| vec![b]).collect();
1273
+ let proto = Tokenizer::new(merges, vocab, None);
1274
+
1275
+ // Deterministic pseudo-text with plenty of alnum-space-alpha cut
1276
+ // points for safe_split_ranges.
1277
+ let mut state = 0x9E3779B97F4A7C15u64;
1278
+ let mut text = |len: usize| -> Vec<u8> {
1279
+ (0..len)
1280
+ .map(|_| {
1281
+ state = state
1282
+ .wrapping_mul(6364136223846793005)
1283
+ .wrapping_add(1442695040888963407);
1284
+ let r = (state >> 33) as usize;
1285
+ b"abcdefghijklmnopqrstuvwxyz0123456789 "[r % 40]
1286
+ })
1287
+ .collect()
1288
+ };
1289
+ // Mid-size docs that group into Docs chunks, one oversized doc
1290
+ // that splits into Fragment chunks (spanning the head/tail
1291
+ // boundary, so both fragment sizes appear), then small docs so
1292
+ // continuation rows land mid-output.
1293
+ let mut owned: Vec<Vec<u8>> = Vec::new();
1294
+ for _ in 0..30 {
1295
+ owned.push(text(300 << 10));
1296
+ }
1297
+ owned.push(text(12 << 20));
1298
+ for _ in 0..30 {
1299
+ owned.push(text(100 << 10));
1300
+ }
1301
+ let docs: Vec<&[u8]> = owned.iter().map(|d| d.as_slice()).collect();
1302
+
1303
+ let mut ids_ref: Vec<u32> = Vec::new();
1304
+ let mut lens_ref: Vec<i64> = Vec::new();
1305
+ let mut serial = proto.fork();
1306
+ for doc in &docs {
1307
+ encode_into(&mut serial, doc, &mut ids_ref, &mut lens_ref);
1308
+ }
1309
+
1310
+ for lpt in [true, false] {
1311
+ // A fresh pool per shape so each run exercises the pre-sized
1312
+ // fork (slots fork lazily on first use).
1313
+ let workers = WorkerPool::new();
1314
+ let (flat, lens) = encode_docs_ragged_with(&workers, &proto, &docs, lpt);
1315
+ assert_eq!(lens, lens_ref, "lens mismatch (lpt={lpt})");
1316
+ assert_eq!(flat, ids_ref, "ids mismatch (lpt={lpt})");
1317
+ }
1318
+
1319
+ // The sequential entry point (bindings' parallel=false) must match
1320
+ // too — it runs the reference loop through the pool's serial worker.
1321
+ let workers = WorkerPool::new();
1322
+ let (flat, lens) = encode_docs_ragged_serial(&workers, &proto, &docs);
1323
+ assert_eq!(lens, lens_ref, "lens mismatch (serial)");
1324
+ assert_eq!(flat, ids_ref, "ids mismatch (serial)");
1325
+ }
1326
+
1327
+ /// Assert token identity with a readable failure: the position of the
1328
+ /// first divergence and a short window of both streams (a plain
1329
+ /// assert_eq would print millions of ids).
1330
+ #[track_caller]
1331
+ fn assert_ids_match(tag: &str, ids: &[u32], ids_ref: &[u32]) {
1332
+ if ids == ids_ref {
1333
+ return;
1334
+ }
1335
+ let i = ids_ref
1336
+ .iter()
1337
+ .zip(ids)
1338
+ .position(|(a, b)| a != b)
1339
+ .unwrap_or_else(|| ids_ref.len().min(ids.len()));
1340
+ panic!(
1341
+ "{tag}: ids mismatch at token {i}: serial[{i}..] = {:?}, fragmented[{i}..] = {:?}",
1342
+ &ids_ref[i..(i + 8).min(ids_ref.len())],
1343
+ &ids[i..(i + 8).min(ids.len())],
1344
+ );
1345
+ }
1346
+
1347
+ /// SentencePiece parallel chunked encode — grouped documents plus
1348
+ /// oversized documents split into continuation fragments at
1349
+ /// scanner-safe unit boundaries — must be token- and order-identical
1350
+ /// to the serial one-encoder path. The cached HF models cover the raw
1351
+ /// fast-path shapes: TinyLlama (unguarded ▁ prepend), gemma-2b
1352
+ /// (SpaceRuns with `>▁</`-style crossing pieces), gemma-3 (guarded
1353
+ /// prepend, 6.4k added tokens). The input is wall-to-wall
1354
+ /// boundary-hostile content — whitespace runs, literal ▁, multi-byte
1355
+ /// UTF-8 with combining marks, split punctuation, crossing-piece text,
1356
+ /// added tokens mid-text and whitespace-adjacent — and a small explicit
1357
+ /// target forces fragment boundaries all through it.
1358
+ #[test]
1359
+ fn sp_parallel_fragmented_matches_serial() {
1360
+ let models = [
1361
+ "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
1362
+ "unsloth/gemma-2b",
1363
+ "google/gemma-3-4b-it",
1364
+ ];
1365
+ let block = concat!(
1366
+ "The quick brown fox \u{2014} jumps; over, the: lazy. dog! \n\n",
1367
+ "\t\tindent()\twide spacing here \r\nCRLF\r\n",
1368
+ "<s>raw<s> tokens </s> spaced <unk> out <bos> and <eos>\n",
1369
+ "<start_of_turn>user hello<end_of_turn> <pad>\n",
1370
+ "html-ish <b> </b> crossing > </ pieces >\u{2581}</ literal mark \u{2581}word\n",
1371
+ "日本語のテキストと émojis 🎉🚀, combining a\u{301}e\u{308}o\u{302}, nbsp\u{a0}here ½ fi ㎒\n",
1372
+ " leading and trailing \n",
1373
+ );
1374
+ let mut big = String::new();
1375
+ while big.len() < (6 << 20) {
1376
+ big.push_str(block);
1377
+ }
1378
+ let texts: Vec<&str> = vec![
1379
+ "short doc <s> one",
1380
+ block,
1381
+ &big,
1382
+ "",
1383
+ "tail doc </s>",
1384
+ block,
1385
+ ];
1386
+ for repo in models {
1387
+ let Some(path) = crate::test_hub::hf_tokenizer_json(repo) else {
1388
+ eprintln!("Skipping {repo}: tokenizer.json not in the HF cache");
1389
+ continue;
1390
+ };
1391
+ let tok = crate::load_tokenizer::hf::load_hf_sentencepiece(&path).unwrap();
1392
+ assert!(
1393
+ tok.supports_fragment_split(),
1394
+ "{repo} should take the raw fast path (fragment splitting)"
1395
+ );
1396
+ let (ids_ref, lens_ref) = sp_encode_docs_ragged_serial(&tok, &texts);
1397
+
1398
+ // The public parallel path (default target sizing).
1399
+ let (ids, lens) = sp_encode_docs_ragged(&tok, &texts);
1400
+ assert_eq!(lens, lens_ref, "{repo}: lens mismatch (default target)");
1401
+ assert_ids_match(&format!("{repo} (default target)"), &ids, &ids_ref);
1402
+
1403
+ // A small target forces ~a hundred fragment boundaries inside
1404
+ // the hostile content.
1405
+ let chunks = sp_build_chunks(&tok, &texts, 64 << 10);
1406
+ let fragments = chunks
1407
+ .iter()
1408
+ .filter(|c| matches!(c, SpChunk::Fragment { .. }))
1409
+ .count();
1410
+ assert!(
1411
+ fragments > 50,
1412
+ "{repo}: expected many fragments, got {fragments}"
1413
+ );
1414
+ let (ids, lens) = sp_encode_chunks(&tok, &chunks);
1415
+ assert_eq!(lens, lens_ref, "{repo}: lens mismatch (small target)");
1416
+ assert_ids_match(&format!("{repo} (small target)"), &ids, &ids_ref);
1417
+ }
1418
+ }
1419
+
1420
+ /// Fragment cuts vs added-token edge cases on synthetic models covering
1421
+ /// every raw-prepend shape (`Unguarded`, where a mis-placed cut would
1422
+ /// inject a spurious ▁; `GuardedAlways` + `EveryMark`; `GuardedFirst`),
1423
+ /// with lstrip- and rstrip-flagged tokens whose whitespace trimming must
1424
+ /// never straddle a cut, a space-carrying token that can straddle one,
1425
+ /// and a self-overlapping token. A tiny target tries a cut every few
1426
+ /// bytes, so every blocked interval edge in the text gets exercised.
1427
+ #[test]
1428
+ fn sp_fragment_cuts_respect_added_tokens() {
1429
+ use crate::load_tokenizer::hf::load_hf_slice;
1430
+ // Char vocab + byte fallback, no merges (unit and section boundary
1431
+ // divergence is fully visible in char-level ids), Llama-2-style
1432
+ // normalizer, and added tokens with every strip shape.
1433
+ let mut vocab_entries: Vec<String> = (0u16..=255)
1434
+ .map(|b| format!("\"<0x{b:02X}>\": {b}"))
1435
+ .collect();
1436
+ let mut next_id = 256u32;
1437
+ vocab_entries.push(format!("\"\u{2581}\": {next_id}"));
1438
+ next_id += 1;
1439
+ for c in "abcdefghijklmnopqrstuvwxyz.,!?".chars() {
1440
+ vocab_entries.push(format!("\"{c}\": {next_id}"));
1441
+ next_id += 1;
1442
+ }
1443
+ let added = [
1444
+ ("<p>", false, false),
1445
+ ("<l>", true, false),
1446
+ ("<r>", false, true),
1447
+ ("w w", false, false), // can straddle a space cut
1448
+ ("aa", false, false), // self-overlapping occurrences
1449
+ ];
1450
+ let added_json: Vec<String> = added
1451
+ .iter()
1452
+ .map(|(content, lstrip, rstrip)| {
1453
+ let id = next_id;
1454
+ next_id += 1;
1455
+ format!(
1456
+ "{{\"id\": {id}, \"content\": \"{content}\", \"lstrip\": {lstrip}, \
1457
+ \"rstrip\": {rstrip}, \"normalized\": false, \"special\": true}}"
1458
+ )
1459
+ })
1460
+ .collect();
1461
+ // Every raw-prepend shape a cut interacts with: Llama-2's Prepend
1462
+ // normalizer (`Unguarded`, where the added-token `e`-blocking is
1463
+ // load-bearing), Metaspace always+split (`GuardedAlways` +
1464
+ // `EveryMark`: cuts inside mark runs, `e` cuts allowed), and
1465
+ // Metaspace first without split (`GuardedFirst` + `SpaceRuns`).
1466
+ use crate::bpe::sentencepiece::{RawPrepend, WordSplit};
1467
+ let pipelines = [
1468
+ (
1469
+ "{\"type\": \"Sequence\", \"normalizers\": [\
1470
+ {\"type\": \"Prepend\", \"prepend\": \"\u{2581}\"}, \
1471
+ {\"type\": \"Replace\", \"pattern\": {\"String\": \" \"}, \"content\": \"\u{2581}\"}]}",
1472
+ "null",
1473
+ RawPrepend::Unguarded,
1474
+ WordSplit::SpaceRuns,
1475
+ ),
1476
+ (
1477
+ "null",
1478
+ "{\"type\": \"Metaspace\", \"replacement\": \"\u{2581}\", \
1479
+ \"prepend_scheme\": \"always\", \"split\": true}",
1480
+ RawPrepend::GuardedAlways,
1481
+ WordSplit::EveryMark,
1482
+ ),
1483
+ (
1484
+ "null",
1485
+ "{\"type\": \"Metaspace\", \"replacement\": \"\u{2581}\", \
1486
+ \"prepend_scheme\": \"first\", \"split\": false}",
1487
+ RawPrepend::GuardedFirst,
1488
+ WordSplit::SpaceRuns,
1489
+ ),
1490
+ ];
1491
+
1492
+ // Tokens adjacent to and inside whitespace runs, back to back, and
1493
+ // overlapping ("aaa" holds two "aa" occurrences); words with the
1494
+ // split punctuation; a "w w" occurrence wherever the text has one.
1495
+ let block = concat!(
1496
+ "plain words here <p> and <p><p> doubled\n",
1497
+ "lstrip near ws <l> and far<l>tight\n",
1498
+ "rstrip eats ws <r> after and<r>tight\n",
1499
+ "aaa aaaa a aa overlapping aa\n",
1500
+ "w w w w w straddling spaces\n",
1501
+ "punct, split. here! ok? more,words.now\n",
1502
+ " runs\t\tof whitespace \n\n",
1503
+ "<l> <r> <l><r> adjacent tokens\n",
1504
+ );
1505
+ let mut text = String::new();
1506
+ while text.len() < 100_000 {
1507
+ text.push_str(block);
1508
+ }
1509
+ let texts = [text.as_str()];
1510
+
1511
+ for (normalizer, pre_tokenizer, prepend, word_split) in pipelines {
1512
+ let json = format!(
1513
+ "{{\"added_tokens\": [{}], \
1514
+ \"normalizer\": {normalizer}, \
1515
+ \"pre_tokenizer\": {pre_tokenizer}, \
1516
+ \"model\": {{\"type\": \"BPE\", \"byte_fallback\": true, \
1517
+ \"vocab\": {{{}}}, \"merges\": []}}}}",
1518
+ added_json.join(", "),
1519
+ vocab_entries.join(", "),
1520
+ );
1521
+ let tok = match load_hf_slice(json.as_bytes()).unwrap() {
1522
+ crate::load_tokenizer::hf::HfTokenizer::SentencePiece(tok) => tok,
1523
+ _ => panic!("synthetic model should load as SentencePiece"),
1524
+ };
1525
+ // Pin the shape under test — a loader change that silently lands
1526
+ // on another fast path would hollow the test out.
1527
+ assert_eq!(tok.raw_prepend, Some(prepend), "unexpected raw prepend");
1528
+ assert_eq!(tok.word_split, word_split, "unexpected word split");
1529
+
1530
+ let (ids_ref, lens_ref) = sp_encode_docs_ragged_serial(&tok, &texts);
1531
+ // A cut attempt every ~48 bytes: thousands of boundaries,
1532
+ // hitting every edge of every blocked interval shape in the
1533
+ // block.
1534
+ let chunks = sp_build_chunks(&tok, &texts, 48);
1535
+ assert!(
1536
+ chunks.len() > 1000,
1537
+ "expected a cut attempt every few bytes, got {} chunks",
1538
+ chunks.len()
1539
+ );
1540
+ let (ids, lens) = sp_encode_chunks(&tok, &chunks);
1541
+ assert_eq!(lens, lens_ref, "{prepend:?}: lens mismatch");
1542
+ assert_ids_match(&format!("{prepend:?}"), &ids, &ids_ref);
1543
+ }
1544
+ }
1545
+
1546
+ /// SentencePiece parallel-vs-serial on REAL text: ~290 MB of OWT
1547
+ /// (owt_valid) as one huge document plus a few multi-MB slices, for
1548
+ /// each cached SP model. Token AND order identity against the serial
1549
+ /// one-encoder encode.
1550
+ /// `cargo test --release verify_sp_parallel_matches_serial_owt -- --ignored --nocapture`
1551
+ #[test]
1552
+ #[ignore = "reads ~290 MB of OWT; run explicitly in release mode"]
1553
+ fn verify_sp_parallel_matches_serial_owt() {
1554
+ let path = std::env::home_dir().unwrap().join("data/owt_valid.txt");
1555
+ let input = std::fs::read(&path).expect("read ~/data/owt_valid.txt");
1556
+ let text = match std::str::from_utf8(&input) {
1557
+ Ok(t) => t,
1558
+ Err(e) => std::str::from_utf8(&input[..e.valid_up_to()]).unwrap(),
1559
+ };
1560
+ // One huge doc (the whole file) plus a few multi-MB slices cut at
1561
+ // char boundaries, so grouped-doc and fragment chunks both appear.
1562
+ let mut texts: Vec<&str> = vec![text];
1563
+ let mut off = 0usize;
1564
+ for mb in [3, 7, 12] {
1565
+ let mut end = (off + (mb << 20)).min(text.len());
1566
+ while !text.is_char_boundary(end) {
1567
+ end -= 1;
1568
+ }
1569
+ texts.push(&text[off..end]);
1570
+ off = end;
1571
+ }
1572
+ for repo in [
1573
+ "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
1574
+ "unsloth/gemma-2b",
1575
+ "google/gemma-3-4b-it",
1576
+ ] {
1577
+ let Some(path) = crate::test_hub::hf_tokenizer_json(repo) else {
1578
+ eprintln!("Skipping {repo}: tokenizer.json not in the HF cache");
1579
+ continue;
1580
+ };
1581
+ let tok = crate::load_tokenizer::hf::load_hf_sentencepiece(&path).unwrap();
1582
+ let t0 = std::time::Instant::now();
1583
+ let (ids_ref, lens_ref) = sp_encode_docs_ragged_serial(&tok, &texts);
1584
+ let t_serial = t0.elapsed();
1585
+ let t0 = std::time::Instant::now();
1586
+ let (ids, lens) = sp_encode_docs_ragged(&tok, &texts);
1587
+ let t_par = t0.elapsed();
1588
+ eprintln!(
1589
+ "{repo}: {} tokens; serial {:.2?}, parallel {:.2?}",
1590
+ ids_ref.len(),
1591
+ t_serial,
1592
+ t_par
1593
+ );
1594
+ assert_eq!(lens, lens_ref, "{repo}: lens mismatch");
1595
+ assert_ids_match(repo, &ids, &ids_ref);
1596
+ }
1597
+ }
1598
+
1599
+ /// Parallel-vs-serial at scale on a REAL tokenizer: ~1 GB of OWT as a
1600
+ /// multi-doc group (small grouped docs, mid docs, oversized docs that
1601
+ /// fragment at pretoken-safe boundaries) with `<|endoftext|>` injected
1602
+ /// mid-doc and doc-final, LPT on and off. Token AND order identity
1603
+ /// against a serial per-document encode. A few seconds in release mode
1604
+ /// (both sides use the cached encode).
1605
+ /// `cargo test --release verify_parallel_ragged_matches_serial_owt_gpt2_1g -- --ignored --nocapture`
1606
+ #[test]
1607
+ #[ignore = "reads 1 GB of OWT; run explicitly in release mode"]
1608
+ fn verify_parallel_ragged_matches_serial_owt_gpt2_1g() {
1609
+ use crate::load_tokenizer::hf::load_hf_bpe;
1610
+ use std::io::Read;
1611
+ let tokenizer_path = crate::test_hub::gpt2_tokenizer_json();
1612
+ let proto = load_hf_bpe(&tokenizer_path).expect("load GPT-2 tokenizer");
1613
+ let added = proto.added_token_split_blockers();
1614
+ let sep: Vec<u8> = added.first().expect("GPT-2 has an added token").0.to_vec();
1615
+
1616
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
1617
+ let f = std::fs::File::open(&path).expect("open ~/data/owt_train.txt");
1618
+ let mut input = Vec::new();
1619
+ f.take(1_000_000_000).read_to_end(&mut input).unwrap();
1620
+ while !input.is_empty() && std::str::from_utf8(&input).is_err() {
1621
+ input.pop();
1622
+ }
1623
+ assert!(input.len() > 900_000_000, "corpus too small: {}", input.len());
1624
+
1625
+ // Doc size pattern: small (group), mid, large; every 20th doc is
1626
+ // oversized (24 MB) so it splits into Fragment chunks.
1627
+ let sizes = [64 << 10, 300 << 10, 1 << 20, 100 << 10, 3 << 20];
1628
+ let mut owned: Vec<Vec<u8>> = Vec::new(); // docs with injected added tokens
1629
+ let mut ranges: Vec<(usize, usize, bool)> = Vec::new(); // (start, end, inject)
1630
+ let mut pos = 0usize;
1631
+ let mut i = 0usize;
1632
+ while pos < input.len() {
1633
+ let want = if i % 20 == 19 { 24 << 20 } else { sizes[i % sizes.len()] };
1634
+ let end = (pos + want).min(input.len());
1635
+ // Inject the added token into every 7th doc (mid + tail).
1636
+ ranges.push((pos, end, i % 7 == 3));
1637
+ pos = end;
1638
+ i += 1;
1639
+ }
1640
+ for &(s, e, inject) in &ranges {
1641
+ if inject {
1642
+ let piece = &input[s..e];
1643
+ let mid = piece.len() / 2;
1644
+ let mut doc = Vec::with_capacity(piece.len() + 2 * sep.len());
1645
+ doc.extend_from_slice(&piece[..mid]);
1646
+ doc.extend_from_slice(&sep);
1647
+ doc.extend_from_slice(&piece[mid..]);
1648
+ doc.extend_from_slice(&sep);
1649
+ owned.push(doc);
1650
+ }
1651
+ }
1652
+ let mut docs: Vec<&[u8]> = Vec::with_capacity(ranges.len());
1653
+ let mut oi = 0usize;
1654
+ for &(s, e, inject) in &ranges {
1655
+ if inject {
1656
+ docs.push(&owned[oi]);
1657
+ oi += 1;
1658
+ } else {
1659
+ docs.push(&input[s..e]);
1660
+ }
1661
+ }
1662
+ eprintln!(
1663
+ "{} docs ({} with injected {:?}), {} bytes total",
1664
+ docs.len(),
1665
+ owned.len(),
1666
+ String::from_utf8_lossy(&sep),
1667
+ docs.iter().map(|d| d.len()).sum::<usize>()
1668
+ );
1669
+
1670
+ let mut ids_ref: Vec<u32> = Vec::new();
1671
+ let mut lens_ref: Vec<i64> = Vec::new();
1672
+ let mut serial = proto.fork();
1673
+ for doc in &docs {
1674
+ encode_into(&mut serial, doc, &mut ids_ref, &mut lens_ref);
1675
+ }
1676
+ drop(serial);
1677
+ eprintln!("serial reference: {} tokens", ids_ref.len());
1678
+
1679
+ for lpt in [true, false] {
1680
+ let workers = WorkerPool::new();
1681
+ let (flat, lens) = encode_docs_ragged_with(&workers, &proto, &docs, lpt);
1682
+ assert_eq!(lens, lens_ref, "lens mismatch (lpt={lpt})");
1683
+ if flat != ids_ref {
1684
+ let i = ids_ref
1685
+ .iter()
1686
+ .zip(&flat)
1687
+ .position(|(a, b)| a != b)
1688
+ .unwrap_or_else(|| ids_ref.len().min(flat.len()));
1689
+ panic!(
1690
+ "ids mismatch (lpt={lpt}) at token {i}: serial[{i}..] = {:?}, parallel[{i}..] = {:?}",
1691
+ &ids_ref[i..(i + 8).min(ids_ref.len())],
1692
+ &flat[i..(i + 8).min(flat.len())],
1693
+ );
1694
+ }
1695
+ eprintln!("lpt={lpt}: {} tokens identical", flat.len());
1696
+ }
1697
+ }
1698
+
1699
+ /// The overlapped gather's escape hatches must be output-identical to
1700
+ /// the committed path: cap 0 stands in for a refused up-front
1701
+ /// reservation (no committer at all), cap 1 overflows on the first
1702
+ /// commit, and a mid-range cap overflows mid-flight after a real
1703
+ /// prefix has been committed — the fallback must discard that prefix
1704
+ /// and re-gather from the (intact) chunk buffers.
1705
+ #[test]
1706
+ fn gather_fallbacks_match() {
1707
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1708
+ let vocab = (0..=u8::MAX).map(|b| vec![b]).collect();
1709
+ let proto = Tokenizer::new(merges, vocab, None);
1710
+
1711
+ let mut state = 0xD1B54A32D192ED03u64;
1712
+ let mut text = |len: usize| -> Vec<u8> {
1713
+ (0..len)
1714
+ .map(|_| {
1715
+ state = state
1716
+ .wrapping_mul(6364136223846793005)
1717
+ .wrapping_add(1442695040888963407);
1718
+ let r = (state >> 33) as usize;
1719
+ b"abcdefghijklmnopqrstuvwxyz0123456789 "[r % 40]
1720
+ })
1721
+ .collect()
1722
+ };
1723
+ let owned: Vec<Vec<u8>> = (0..20).map(|_| text(1 << 20)).collect();
1724
+ let docs: Vec<&[u8]> = owned.iter().map(|d| d.as_slice()).collect();
1725
+ let total: usize = docs.iter().map(|d| d.len()).sum();
1726
+ let added = proto.added_token_split_blockers();
1727
+ let chunks = build_doc_chunks(&docs, total, chunk_target_bytes(total), &added, true);
1728
+ assert!(chunks.len() > 1, "test must exercise the parallel path");
1729
+
1730
+ let workers = WorkerPool::new();
1731
+ let (flat_ref, lens_ref) = encode_chunks_gathered(&workers, &proto, &chunks, total);
1732
+ // Byte-level vocab: one token per byte, so any cap below `total`
1733
+ // overflows; total / 3 overflows mid-flight with a committed
1734
+ // prefix behind it.
1735
+ for cap in [0, 1, total / 3] {
1736
+ let workers = WorkerPool::new();
1737
+ let (flat, lens) =
1738
+ encode_chunks_gathered_with_cap(&workers, &proto, &chunks, total, cap);
1739
+ assert_eq!(lens, lens_ref, "lens mismatch (cap={cap})");
1740
+ assert_eq!(flat, flat_ref, "ids mismatch (cap={cap})");
1741
+ }
1742
+ }
1743
+
1744
+ /// The widened `encode_chunks_into` seam must match the owned path
1745
+ /// exactly: a `GatherBuf` big enough to hold the result commits
1746
+ /// directly into it, and one too small hits the same overflow escape as
1747
+ /// `gather_fallbacks_match` — falling back to the classic gathered
1748
+ /// result instead of writing past the buffer.
1749
+ #[test]
1750
+ fn gather_into_matches_owned() {
1751
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1752
+ let vocab = (0..=u8::MAX).map(|b| vec![b]).collect();
1753
+ let proto = Tokenizer::new(merges, vocab, None);
1754
+
1755
+ let mut state = 0xA5A5_A5A5_A5A5_A5A5u64;
1756
+ let mut text = |len: usize| -> Vec<u8> {
1757
+ (0..len)
1758
+ .map(|_| {
1759
+ state = state
1760
+ .wrapping_mul(6364136223846793005)
1761
+ .wrapping_add(1442695040888963407);
1762
+ let r = (state >> 33) as usize;
1763
+ b"abcdefghijklmnopqrstuvwxyz0123456789 "[r % 40]
1764
+ })
1765
+ .collect()
1766
+ };
1767
+ let owned: Vec<Vec<u8>> = (0..20).map(|_| text(1 << 20)).collect();
1768
+ let docs: Vec<&[u8]> = owned.iter().map(|d| d.as_slice()).collect();
1769
+ let total: usize = docs.iter().map(|d| d.len()).sum();
1770
+ let added = proto.added_token_split_blockers();
1771
+ let chunks = build_doc_chunks(&docs, total, chunk_target_bytes(total), &added, true);
1772
+ assert!(chunks.len() > 1, "test must exercise the parallel path");
1773
+
1774
+ let workers = WorkerPool::new();
1775
+ let (flat_ref, lens_ref) = encode_chunks_gathered(&workers, &proto, &chunks, total);
1776
+
1777
+ // A full-size destination: every chunk commits straight into it.
1778
+ let mut dest_buf = vec![0u32; total];
1779
+ let workers = WorkerPool::new();
1780
+ // SAFETY: `dest_buf` is `total` tokens, exclusively owned for the
1781
+ // duration of this call.
1782
+ let dest = unsafe { GatherBuf::new(dest_buf.as_mut_ptr(), total) };
1783
+ match encode_chunks_into(&workers, &proto, &chunks, total, dest) {
1784
+ GatherOutcome::Committed(n, lens) => {
1785
+ assert_eq!(lens, lens_ref, "lens mismatch (committed)");
1786
+ assert_eq!(&dest_buf[..n], &flat_ref[..], "ids mismatch (committed)");
1787
+ }
1788
+ GatherOutcome::Fallback(..) => panic!("expected a full-size dest to commit"),
1789
+ }
1790
+
1791
+ // A too-small destination (byte-level vocab: one token per input
1792
+ // byte, so total / 3 overflows mid-flight with a committed prefix
1793
+ // behind it — same shape as `gather_fallbacks_match`).
1794
+ let cap = total / 3;
1795
+ let mut small_buf = vec![0u32; cap];
1796
+ let workers = WorkerPool::new();
1797
+ // SAFETY: `small_buf` is `cap` tokens, exclusively owned for the
1798
+ // duration of this call.
1799
+ let dest = unsafe { GatherBuf::new(small_buf.as_mut_ptr(), cap) };
1800
+ match encode_chunks_into(&workers, &proto, &chunks, total, dest) {
1801
+ GatherOutcome::Fallback(flat, lens) => {
1802
+ assert_eq!(lens, lens_ref, "lens mismatch (fallback)");
1803
+ assert_eq!(flat, flat_ref, "ids mismatch (fallback)");
1804
+ }
1805
+ GatherOutcome::Committed(..) => panic!("expected a too-small dest to overflow"),
1806
+ }
1807
+ }
1808
+ }