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.
- checksums.yaml +7 -0
- data/Cargo.lock +3016 -0
- data/Cargo.toml +135 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/gigatoken +9 -0
- data/ext/gigatoken/Cargo.toml +24 -0
- data/ext/gigatoken/extconf.rb +19 -0
- data/ext/gigatoken/src/error.rs +20 -0
- data/ext/gigatoken/src/gvl.rs +122 -0
- data/ext/gigatoken/src/lib.rs +50 -0
- data/ext/gigatoken/src/sentencepiece.rs +205 -0
- data/ext/gigatoken/src/sources.rs +207 -0
- data/ext/gigatoken/src/tokenizer.rs +571 -0
- data/lib/gigatoken/cli/bench.rb +64 -0
- data/lib/gigatoken/cli/support.rb +132 -0
- data/lib/gigatoken/cli/validate.rb +55 -0
- data/lib/gigatoken/cli.rb +18 -0
- data/lib/gigatoken/hub.rb +242 -0
- data/lib/gigatoken/packed_result.rb +48 -0
- data/lib/gigatoken/tokenizer.rb +117 -0
- data/lib/gigatoken/version.rb +5 -0
- data/lib/gigatoken.rb +29 -0
- data/rust-toolchain.toml +8 -0
- data/src/batch.rs +1808 -0
- data/src/bindings/bridge.rs +396 -0
- data/src/bindings/hub.rs +42 -0
- data/src/bindings/matcher.rs +114 -0
- data/src/bindings/mod.rs +14 -0
- data/src/bindings/padding.rs +177 -0
- data/src/bindings/pretokenize.rs +53 -0
- data/src/bindings/sources.rs +273 -0
- data/src/bindings/train.rs +125 -0
- data/src/bpe/mod.rs +1217 -0
- data/src/bpe/pretoken_cache.rs +495 -0
- data/src/bpe/sentencepiece.rs +1485 -0
- data/src/bpe/tiktoken.rs +2555 -0
- data/src/bpe_train.rs +351 -0
- data/src/input/decompress.rs +11 -0
- data/src/input/file_source.rs +514 -0
- data/src/input/jsonl.rs +94 -0
- data/src/input/mod.rs +333 -0
- data/src/input/parquet.rs +303 -0
- data/src/lib.rs +578 -0
- data/src/load_tokenizer/hf.rs +1036 -0
- data/src/load_tokenizer/hub.rs +344 -0
- data/src/load_tokenizer/mod.rs +3 -0
- data/src/load_tokenizer/tiktoken.rs +87 -0
- data/src/main.rs +95 -0
- data/src/pretokenize/fast/cl100k.rs +426 -0
- data/src/pretokenize/fast/cl100k_family.rs +891 -0
- data/src/pretokenize/fast/deepseek_v3.rs +605 -0
- data/src/pretokenize/fast/kimi.rs +281 -0
- data/src/pretokenize/fast/mask.rs +1486 -0
- data/src/pretokenize/fast/mod.rs +446 -0
- data/src/pretokenize/fast/nemotron.rs +138 -0
- data/src/pretokenize/fast/o200k.rs +347 -0
- data/src/pretokenize/fast/o200k_family.rs +1734 -0
- data/src/pretokenize/fast/olmo3.rs +505 -0
- data/src/pretokenize/fast/qwen2.rs +429 -0
- data/src/pretokenize/fast/qwen3_5.rs +541 -0
- data/src/pretokenize/fast/r50k.rs +1250 -0
- data/src/pretokenize/mod.rs +1079 -0
- data/src/pretokenize/options.rs +188 -0
- data/src/pretokenize/pretoken.rs +20 -0
- data/src/pretokenize/pretokenize_traits.rs +49 -0
- data/src/pretokenize/reference/avx512.rs +522 -0
- data/src/pretokenize/reference/combinator.rs +572 -0
- data/src/pretokenize/reference/mod.rs +28 -0
- data/src/pretokenize/reference/simd.rs +852 -0
- data/src/pretokenize/reference/state_machine.rs +365 -0
- data/src/pretokenize/unicode.rs +546 -0
- data/src/test_hub.rs +28 -0
- data/src/token.rs +42 -0
- 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
|
+
}
|