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/bpe/tiktoken.rs
ADDED
|
@@ -0,0 +1,2555 @@
|
|
|
1
|
+
use crate::bpe::pretoken_cache::ShortPretokenCache;
|
|
2
|
+
use crate::bpe::{
|
|
3
|
+
ByteRemapping, MergeScratch, PairRankTable, SHORT_MERGE_MAX, bpe_merge_symbols_by_rank,
|
|
4
|
+
bpe_merge_symbols_ranked, bpe_merge_symbols_ranked_slice, bpe_merge_symbols_short_scalar,
|
|
5
|
+
bpe_merge_symbols_with_scratch, simple_bpe_merge,
|
|
6
|
+
};
|
|
7
|
+
#[cfg(target_arch = "aarch64")]
|
|
8
|
+
use crate::bpe::bpe_merge_symbols_short_neon;
|
|
9
|
+
use crate::pretokenize::{
|
|
10
|
+
FastCl100kPretokenizer, FastDeepSeekV3Pretokenizer, FastOlmo3Pretokenizer,
|
|
11
|
+
FastQwen2Pretokenizer, FastQwen35Pretokenizer, FastR50kPretokenizer, PRETOKEN_CHUNK,
|
|
12
|
+
Pretoken, PretokenSpans, PretokenizerType, SpanBatch, pack_pretoken_key, pretoken_key_hash,
|
|
13
|
+
};
|
|
14
|
+
use crate::token::TokenId;
|
|
15
|
+
use eyre::Result;
|
|
16
|
+
use std::collections::HashMap;
|
|
17
|
+
use std::fmt::{Debug, Formatter};
|
|
18
|
+
use std::sync::Arc;
|
|
19
|
+
|
|
20
|
+
/// Byte-level BPE tokenizer (tiktoken / GPT-2 style).
|
|
21
|
+
///
|
|
22
|
+
/// Initial symbols are individual bytes (0–255). Merge priority is
|
|
23
|
+
/// determined by the merged token's vocab ID (lower = first), which
|
|
24
|
+
/// equals the merge rank for tiktoken vocabularies.
|
|
25
|
+
pub struct Tokenizer {
|
|
26
|
+
// The model tables (merges, pair_ranks, vocab, vocab_inv) are immutable
|
|
27
|
+
// after construction and shared across forks behind `Arc`: parallel
|
|
28
|
+
// workers read the same few MB of tables instead of holding one deep
|
|
29
|
+
// clone each, which keeps a single copy resident per cache/cluster on
|
|
30
|
+
// the cold miss path and makes forking the tables O(1). The rare
|
|
31
|
+
// mutation (`add_special_token`) goes through `Arc::make_mut`.
|
|
32
|
+
pub(crate) merges: Arc<HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>>,
|
|
33
|
+
/// Flat pair-rank tables replacing `merges` lookups on the miss path's
|
|
34
|
+
/// merge loop; `None` for vocabularies whose IDs don't fit its packed
|
|
35
|
+
/// keys (those keep probing `merges`).
|
|
36
|
+
pair_ranks: Option<Arc<PairRankTable>>,
|
|
37
|
+
/// Explicit merge priorities for rank-mapped vocabularies (fairseq
|
|
38
|
+
/// heritage: RoBERTa/OPT/DeBERTa, whose vocab IDs are frequency-ordered
|
|
39
|
+
/// and carry no rank information). When set, `merges` is empty,
|
|
40
|
+
/// `pair_ranks` is `None`, and every merge runs through the ranked loops
|
|
41
|
+
/// with priority read from this table (`ranked_merge_key(a, b)` →
|
|
42
|
+
/// `(merged, rank)`). `None` for id-as-rank vocabularies (everything
|
|
43
|
+
/// tiktoken-style), whose fast paths are untouched.
|
|
44
|
+
ranked_merges: Option<Arc<RankedMerges>>,
|
|
45
|
+
pub(crate) vocab: Arc<Vec<Arc<[u8]>>>,
|
|
46
|
+
pub(crate) vocab_inv: Arc<HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>>,
|
|
47
|
+
pub(crate) byte_remapping: Option<ByteRemapping>,
|
|
48
|
+
/// Append-only arena of encoded token IDs. Cache entries for encodings
|
|
49
|
+
/// of 5+ tokens store `(offset, len)` slices into this vector; shorter
|
|
50
|
+
/// encodings (well over 99% of hit occurrences) live inline in the
|
|
51
|
+
/// cache entry and never touch it.
|
|
52
|
+
token_arena: Vec<TokenId>,
|
|
53
|
+
/// Pretoken cache for the common case (≤ 15 bytes, ~99.9% of
|
|
54
|
+
/// pretokens). The key packs the bytes into the low 15 bytes and the
|
|
55
|
+
/// length into the top byte of a `u128`, so lookups are a single
|
|
56
|
+
/// inlined 128-bit compare instead of a `memcmp` call. See
|
|
57
|
+
/// `pretoken_cache.rs` for why this is a custom prefetchable table
|
|
58
|
+
/// rather than a `HashMap`.
|
|
59
|
+
pretoken_cache: ShortPretokenCache,
|
|
60
|
+
/// Fallback cache for pretokens longer than 15 bytes.
|
|
61
|
+
pretoken_cache_long: HashMap<Box<[u8]>, (u32, u32), rustc_hash::FxBuildHasher>,
|
|
62
|
+
/// Scratch buffers reused across cache-missing pretokens so the merge loop
|
|
63
|
+
/// performs no per-pretoken allocations.
|
|
64
|
+
merge_scratch: MergeScratch,
|
|
65
|
+
symbol_scratch: Vec<TokenId>,
|
|
66
|
+
/// Pretokenization scheme used by [`Self::encode_with_added_tokens`].
|
|
67
|
+
pub(crate) pretokenizer_type: PretokenizerType,
|
|
68
|
+
/// Added tokens (special and non-special), matched atomically in the raw
|
|
69
|
+
/// input before pretokenization, like HuggingFace's AddedVocabulary.
|
|
70
|
+
added_tokens: Vec<AddedTokenDef>,
|
|
71
|
+
/// Leftmost-longest Aho-Corasick automaton over `added_tokens` contents
|
|
72
|
+
/// (pattern index == `added_tokens` index). A prebuilt automaton keeps the
|
|
73
|
+
/// scan fast even when an added token starts with a byte that is common in
|
|
74
|
+
/// text (ModernBERT has 23 space-run added tokens, so a first-byte
|
|
75
|
+
/// candidate scan would probe on every space). Clones share the automaton
|
|
76
|
+
/// via its internal `Arc`.
|
|
77
|
+
added_matcher: Option<aho_corasick::AhoCorasick>,
|
|
78
|
+
/// Apply NFC normalization to non-added-token segments before
|
|
79
|
+
/// pretokenization, like HuggingFace's `NFC` normalizer (e.g. Qwen2).
|
|
80
|
+
normalize_nfc: bool,
|
|
81
|
+
/// HF `ByteLevel(add_prefix_space=true)` (RoBERTa-style exports): every
|
|
82
|
+
/// non-empty added-token-split segment that does not already start with
|
|
83
|
+
/// a space gets one prepended before pretokenization.
|
|
84
|
+
add_prefix_space: bool,
|
|
85
|
+
/// HF BPE `ignore_merges`: a pretoken whose whole byte string is a
|
|
86
|
+
/// vocab entry encodes as that single ID without running the merge
|
|
87
|
+
/// loop. Matters when the vocab has whole-word entries the merges
|
|
88
|
+
/// would decompose differently (GLM-5.2 has ~97k such words); a plain
|
|
89
|
+
/// merge walk diverges from HF on those.
|
|
90
|
+
ignore_merges: bool,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// NFC-normalize a segment if needed, using `buf` as scratch on the slow path.
|
|
94
|
+
///
|
|
95
|
+
/// ASCII and already-normalized segments are returned as-is. Invalid UTF-8 is
|
|
96
|
+
/// passed through unchanged (HF only ever sees `str`, so there is no parity
|
|
97
|
+
/// behavior to match).
|
|
98
|
+
fn nfc_segment<'a>(seg: &'a [u8], buf: &'a mut String) -> &'a [u8] {
|
|
99
|
+
if seg.is_ascii() {
|
|
100
|
+
return seg;
|
|
101
|
+
}
|
|
102
|
+
let Ok(s) = std::str::from_utf8(seg) else {
|
|
103
|
+
return seg;
|
|
104
|
+
};
|
|
105
|
+
let nfc = icu::normalizer::ComposingNormalizer::new_nfc();
|
|
106
|
+
if nfc.is_normalized(s) {
|
|
107
|
+
return seg;
|
|
108
|
+
}
|
|
109
|
+
buf.clear();
|
|
110
|
+
nfc.normalize_to(s, buf)
|
|
111
|
+
.expect("writing to a String cannot fail");
|
|
112
|
+
buf.as_bytes()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Cache-value packing (shared by the short-pretoken table and decode in
|
|
116
|
+
/// the encode loop). `val` low byte: token count in bits 0-6 plus a
|
|
117
|
+
/// "spilled" flag in bit 7. Inline values (1-4 tokens; only the first ID
|
|
118
|
+
/// must fit 24 bits — true of every real vocab) carry tokens 1-2 in `val`
|
|
119
|
+
/// bits 8-31 and 32-63 and tokens 3-4 in `ext`'s two u32 lanes; spilled
|
|
120
|
+
/// values carry the token-arena offset in `val`'s high 32 bits and leave
|
|
121
|
+
/// `ext` unused.
|
|
122
|
+
const VAL_SPILL: u64 = 0x80;
|
|
123
|
+
|
|
124
|
+
#[inline(always)]
|
|
125
|
+
fn pack_val_inline(symbols: &[TokenId]) -> Option<(u64, u64)> {
|
|
126
|
+
match *symbols {
|
|
127
|
+
[a] if a.0 < (1 << 24) => Some((1 | ((a.0 as u64) << 8), 0)),
|
|
128
|
+
[a, b] if a.0 < (1 << 24) => {
|
|
129
|
+
Some((2 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32), 0))
|
|
130
|
+
}
|
|
131
|
+
[a, b, c] if a.0 < (1 << 24) => Some((
|
|
132
|
+
3 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32),
|
|
133
|
+
c.0 as u64,
|
|
134
|
+
)),
|
|
135
|
+
[a, b, c, d] if a.0 < (1 << 24) => Some((
|
|
136
|
+
4 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32),
|
|
137
|
+
c.0 as u64 | ((d.0 as u64) << 32),
|
|
138
|
+
)),
|
|
139
|
+
_ => None,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// View a `TokenId` slice as its underlying `u32`s (repr(transparent)),
|
|
144
|
+
/// so bulk emits are `extend_from_slice` memcpys instead of per-element
|
|
145
|
+
/// iterator writes.
|
|
146
|
+
#[inline(always)]
|
|
147
|
+
fn token_ids_as_u32s(toks: &[TokenId]) -> &[u32] {
|
|
148
|
+
// SAFETY: TokenId is #[repr(transparent)] over u32.
|
|
149
|
+
unsafe { std::slice::from_raw_parts(toks.as_ptr() as *const u32, toks.len()) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Unpack an inline value's four token lanes (lanes past the count are
|
|
153
|
+
/// another key's leftovers; callers truncate by the count).
|
|
154
|
+
#[inline(always)]
|
|
155
|
+
fn unpack_val_lanes(val: u64, ext: u64) -> [u32; 4] {
|
|
156
|
+
[
|
|
157
|
+
(val >> 8) as u32 & 0xFF_FFFF,
|
|
158
|
+
(val >> 32) as u32,
|
|
159
|
+
ext as u32,
|
|
160
|
+
(ext >> 32) as u32,
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// One piece of the added-token pipeline walk (see
|
|
165
|
+
/// [`Tokenizer::for_each_piece`]): a between-occurrences text segment to
|
|
166
|
+
/// pretokenize and encode (paired with its source byte offset, which only
|
|
167
|
+
/// the verify-heavy differential reads), or an added token's ID to emit
|
|
168
|
+
/// verbatim.
|
|
169
|
+
enum Piece<'a> {
|
|
170
|
+
Segment(&'a [u8], usize),
|
|
171
|
+
Added(TokenId),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Explicit merge-priority table for rank-mapped vocabularies:
|
|
175
|
+
/// `ranked_merge_key(a, b)` → `(merged, rank)`. Same shape as the
|
|
176
|
+
/// SentencePiece engine's merge table.
|
|
177
|
+
pub(crate) type RankedMerges = HashMap<u64, (TokenId, u32), rustc_hash::FxBuildHasher>;
|
|
178
|
+
|
|
179
|
+
/// One added token as configured by the loader: byte content, emitted ID, and
|
|
180
|
+
/// HF `AddedToken` whitespace-stripping flags (`lstrip` absorbs whitespace
|
|
181
|
+
/// before a match, `rstrip` absorbs whitespace after it). Content is shared
|
|
182
|
+
/// (`Arc`) so forks clone entries cheaply.
|
|
183
|
+
#[derive(Clone, Debug)]
|
|
184
|
+
pub struct AddedTokenDef {
|
|
185
|
+
pub content: Arc<[u8]>,
|
|
186
|
+
pub id: TokenId,
|
|
187
|
+
pub lstrip: bool,
|
|
188
|
+
pub rstrip: bool,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Byte offset after the leading Unicode whitespace of `bytes` (the set of
|
|
192
|
+
/// `str::trim_start`, which is what HF's `\s*` sees). Invalid UTF-8 stops the
|
|
193
|
+
/// scan.
|
|
194
|
+
fn trim_ws_start(bytes: &[u8]) -> usize {
|
|
195
|
+
let mut pos = 0;
|
|
196
|
+
while pos < bytes.len() {
|
|
197
|
+
let width = match bytes[pos] {
|
|
198
|
+
0x00..=0x7F => 1,
|
|
199
|
+
0xC0..=0xDF => 2,
|
|
200
|
+
0xE0..=0xEF => 3,
|
|
201
|
+
0xF0..=0xF7 => 4,
|
|
202
|
+
_ => break,
|
|
203
|
+
};
|
|
204
|
+
let Some(chunk) = bytes.get(pos..pos + width) else {
|
|
205
|
+
break;
|
|
206
|
+
};
|
|
207
|
+
match std::str::from_utf8(chunk) {
|
|
208
|
+
Ok(s) if s.chars().next().is_some_and(char::is_whitespace) => pos += width,
|
|
209
|
+
_ => break,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
pos
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Length of `bytes` after trimming trailing Unicode whitespace (the set of
|
|
216
|
+
/// `str::trim_end`). Invalid UTF-8 stops the scan.
|
|
217
|
+
fn trim_ws_end(bytes: &[u8]) -> usize {
|
|
218
|
+
let mut end = bytes.len();
|
|
219
|
+
while end > 0 {
|
|
220
|
+
// Back up over at most 3 continuation bytes to the character start.
|
|
221
|
+
let mut start = end - 1;
|
|
222
|
+
while start > 0 && (bytes[start] & 0xC0) == 0x80 && end - start < 4 {
|
|
223
|
+
start -= 1;
|
|
224
|
+
}
|
|
225
|
+
match std::str::from_utf8(&bytes[start..end]) {
|
|
226
|
+
Ok(s) if s.chars().next().is_some_and(char::is_whitespace) => end = start,
|
|
227
|
+
_ => break,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// Overwrite the short-cache entry of every added-token content of 1..=15
|
|
234
|
+
/// bytes that resolves in `vocab_inv` with that single ID, so a matching
|
|
235
|
+
/// pretoken encodes as the added token rather than its merge decomposition.
|
|
236
|
+
///
|
|
237
|
+
/// This function IS the cache-seed sync invariant: the short cache's
|
|
238
|
+
/// seed-level state is always "vocab seed, then these overwrites" — a pure
|
|
239
|
+
/// function of `(vocab, added_tokens)` — because both
|
|
240
|
+
/// [`Tokenizer::set_added_tokens`] (on the parent) and
|
|
241
|
+
/// [`Tokenizer::fork_sized`] (after a fork's fresh reseed) apply the
|
|
242
|
+
/// overwrites through this one body, so parent and forked workers always
|
|
243
|
+
/// agree on every short pretoken.
|
|
244
|
+
fn apply_added_token_overwrites(
|
|
245
|
+
added_tokens: &[AddedTokenDef],
|
|
246
|
+
vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
|
|
247
|
+
pretoken_cache: &mut ShortPretokenCache,
|
|
248
|
+
token_arena: &mut Vec<TokenId>,
|
|
249
|
+
) {
|
|
250
|
+
for tok in added_tokens {
|
|
251
|
+
let content = &tok.content;
|
|
252
|
+
if !(1..=15).contains(&content.len()) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let Some(&id) = vocab_inv.get(content) else {
|
|
256
|
+
continue;
|
|
257
|
+
};
|
|
258
|
+
let key = pack_pretoken_key(content).expect("length checked <= 15");
|
|
259
|
+
let h = pretoken_key_hash(key);
|
|
260
|
+
let (val, ext) = Tokenizer::pack_val(&[id], token_arena);
|
|
261
|
+
pretoken_cache.replace(key, h, val, ext);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
impl Tokenizer {
|
|
266
|
+
pub fn new(
|
|
267
|
+
merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
268
|
+
vocab: Vec<Vec<u8>>,
|
|
269
|
+
byte_remapping: Option<ByteRemapping>,
|
|
270
|
+
) -> Self {
|
|
271
|
+
let vocab = vocab.into_iter().map(Into::into).collect();
|
|
272
|
+
Self::from_tables(merges, None, vocab, byte_remapping)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Construct from an explicit-rank merge table (`ranked_merge_key(a, b)`
|
|
276
|
+
/// → `(merged, rank)`), for vocabularies whose IDs do not follow merge
|
|
277
|
+
/// order (see `ranked_merges`). Every merge runs through the ranked
|
|
278
|
+
/// loops; the id-as-rank fast paths stay off.
|
|
279
|
+
pub fn new_ranked(
|
|
280
|
+
ranked_merges: RankedMerges,
|
|
281
|
+
vocab: Vec<Vec<u8>>,
|
|
282
|
+
byte_remapping: Option<ByteRemapping>,
|
|
283
|
+
) -> Self {
|
|
284
|
+
let vocab = vocab.into_iter().map(Into::into).collect();
|
|
285
|
+
Self::from_tables(HashMap::default(), Some(ranked_merges), vocab, byte_remapping)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Shared construction tail ([`Self::new`], [`Self::new_ranked`] and
|
|
289
|
+
/// [`Self::from_ranks`]): derive `vocab_inv` and the pair-rank table
|
|
290
|
+
/// from the finished merges/vocab, seed the pretoken cache, and
|
|
291
|
+
/// assemble the tokenizer with default pipeline settings (GPT-2
|
|
292
|
+
/// pretokenization, no added tokens, no NFC).
|
|
293
|
+
fn from_tables(
|
|
294
|
+
merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
295
|
+
ranked_merges: Option<RankedMerges>,
|
|
296
|
+
vocab: Vec<Arc<[u8]>>,
|
|
297
|
+
byte_remapping: Option<ByteRemapping>,
|
|
298
|
+
) -> Self {
|
|
299
|
+
let vocab_inv: HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher> = vocab
|
|
300
|
+
.iter()
|
|
301
|
+
.cloned()
|
|
302
|
+
.zip((0..).map(TokenId::from))
|
|
303
|
+
.collect();
|
|
304
|
+
let ranked_merges = ranked_merges.map(Arc::new);
|
|
305
|
+
let pair_ranks = if ranked_merges.is_none() {
|
|
306
|
+
PairRankTable::build(&merges, byte_remapping.as_ref(), vocab.len()).map(Arc::new)
|
|
307
|
+
} else {
|
|
308
|
+
None
|
|
309
|
+
};
|
|
310
|
+
let mut token_arena = Vec::new();
|
|
311
|
+
let pretoken_cache = Self::seeded_pretoken_cache(
|
|
312
|
+
&vocab,
|
|
313
|
+
byte_remapping.as_ref(),
|
|
314
|
+
pair_ranks.as_deref(),
|
|
315
|
+
&merges,
|
|
316
|
+
ranked_merges.as_deref(),
|
|
317
|
+
false,
|
|
318
|
+
&vocab_inv,
|
|
319
|
+
&mut token_arena,
|
|
320
|
+
0,
|
|
321
|
+
);
|
|
322
|
+
Tokenizer {
|
|
323
|
+
merges: Arc::new(merges),
|
|
324
|
+
pair_ranks,
|
|
325
|
+
ranked_merges,
|
|
326
|
+
vocab_inv: Arc::new(vocab_inv),
|
|
327
|
+
vocab: Arc::new(vocab),
|
|
328
|
+
byte_remapping,
|
|
329
|
+
token_arena,
|
|
330
|
+
pretoken_cache,
|
|
331
|
+
pretoken_cache_long: HashMap::with_hasher(rustc_hash::FxBuildHasher {}),
|
|
332
|
+
merge_scratch: MergeScratch::default(),
|
|
333
|
+
symbol_scratch: Vec::new(),
|
|
334
|
+
pretokenizer_type: PretokenizerType::GPT2,
|
|
335
|
+
added_tokens: Vec::new(),
|
|
336
|
+
added_matcher: None,
|
|
337
|
+
normalize_nfc: false,
|
|
338
|
+
add_prefix_space: false,
|
|
339
|
+
ignore_merges: false,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// A short-pretoken cache pre-seeded with the BPE encoding of every
|
|
344
|
+
/// vocab entry of 1..=15 bytes: precomputed miss results, computed by
|
|
345
|
+
/// the same [`Self::merge_short`] the miss path runs, so a seeded
|
|
346
|
+
/// value is bit-identical to what a cold miss on those bytes would
|
|
347
|
+
/// have produced and cached. Any short pretoken that is a whole vocab
|
|
348
|
+
/// word then hits the cache outright, so the miss path never sees one.
|
|
349
|
+
///
|
|
350
|
+
/// Without `ignore_merges`, the seed value must be the MERGE RESULT,
|
|
351
|
+
/// not the entry's own ID: BPE encode semantics (HF `tokenizers`
|
|
352
|
+
/// without `ignore_merges`, this repo's merge loop, and the pre-cache
|
|
353
|
+
/// baseline 0e27c71) produce a whole-word token only when the merge
|
|
354
|
+
/// rules can derive it, and vocabs may contain merge-UNREACHABLE
|
|
355
|
+
/// entries — qwen3_5 has ~200 (multi-char CJK phrases, " Jap\u{f3}n",
|
|
356
|
+
/// …) that must encode as their merge decomposition. Seeding
|
|
357
|
+
/// `bytes -> [own id]` was a measured divergence from HF (see
|
|
358
|
+
/// `verify_vocab_seeded_cache_matches_merge_decomposition`). For
|
|
359
|
+
/// merge-reachable entries — all of gpt2/olmo3/qwen2/deepseek_v3 —
|
|
360
|
+
/// the merge result is the single own ID, as before. Duplicate byte
|
|
361
|
+
/// strings encode identically (the merge sees only bytes), so the
|
|
362
|
+
/// insert-if-absent dedup is purely a work-skip.
|
|
363
|
+
///
|
|
364
|
+
/// WITH `ignore_merges` the rule flips: HF emits the vocab entry's own
|
|
365
|
+
/// ID for any whole-pretoken vocab hit, so every seed value is
|
|
366
|
+
/// `[vocab_inv[bytes]]` (`vocab_inv` also resolves duplicate byte
|
|
367
|
+
/// strings to the one ID a lookup would find).
|
|
368
|
+
///
|
|
369
|
+
/// `min_slots` additionally floors the table size for a worker with a
|
|
370
|
+
/// known workload (see [`Self::fork_sized`]); the table is built once
|
|
371
|
+
/// at the max of the seed requirement and that floor, so seeding never
|
|
372
|
+
/// grows it mid-way. Values of 5+ tokens (only possible for
|
|
373
|
+
/// merge-unreachable entries) spill into `token_arena` like any other
|
|
374
|
+
/// miss.
|
|
375
|
+
fn seeded_pretoken_cache(
|
|
376
|
+
vocab: &[Arc<[u8]>],
|
|
377
|
+
byte_remapping: Option<&ByteRemapping>,
|
|
378
|
+
pair_ranks: Option<&PairRankTable>,
|
|
379
|
+
merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
380
|
+
ranked_merges: Option<&RankedMerges>,
|
|
381
|
+
ignore_merges: bool,
|
|
382
|
+
vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
|
|
383
|
+
token_arena: &mut Vec<TokenId>,
|
|
384
|
+
min_slots: usize,
|
|
385
|
+
) -> ShortPretokenCache {
|
|
386
|
+
let n_short = vocab
|
|
387
|
+
.iter()
|
|
388
|
+
.filter(|bytes| (1..=15).contains(&bytes.len()))
|
|
389
|
+
.count();
|
|
390
|
+
let mut cache = ShortPretokenCache::with_at_least(n_short, min_slots);
|
|
391
|
+
let mut buf = [TokenId(0); SHORT_MERGE_MAX];
|
|
392
|
+
for bytes in vocab {
|
|
393
|
+
if !(1..=15).contains(&bytes.len()) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
let key = pack_pretoken_key(bytes).expect("length checked <= 15");
|
|
397
|
+
let h = pretoken_key_hash(key);
|
|
398
|
+
// Duplicate byte strings seed the same value (see the doc
|
|
399
|
+
// above), so insertion order is irrelevant and the
|
|
400
|
+
// insert-if-absent check only skips redundant merges.
|
|
401
|
+
if cache.get_or_slot(key, h).is_err() {
|
|
402
|
+
let n = Self::seed_symbols_any(
|
|
403
|
+
byte_remapping,
|
|
404
|
+
pair_ranks,
|
|
405
|
+
merges,
|
|
406
|
+
ranked_merges,
|
|
407
|
+
ignore_merges,
|
|
408
|
+
vocab_inv,
|
|
409
|
+
bytes,
|
|
410
|
+
&mut buf,
|
|
411
|
+
);
|
|
412
|
+
let (val, ext) = Self::pack_val(&buf[..n], token_arena);
|
|
413
|
+
cache.insert(key, h, val, ext);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
cache
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/// Seed-level encoding of one short vocab byte string under the
|
|
420
|
+
/// current `ignore_merges` setting: the single `vocab_inv` ID when the
|
|
421
|
+
/// flag is set (HF's whole-pretoken vocab hit), the merge
|
|
422
|
+
/// decomposition otherwise. One body shared by
|
|
423
|
+
/// [`Self::seeded_pretoken_cache`] and [`Self::set_ignore_merges`] so
|
|
424
|
+
/// a fork's fresh reseed and the parent's in-place rewrite always
|
|
425
|
+
/// agree.
|
|
426
|
+
#[inline]
|
|
427
|
+
fn seed_symbols(
|
|
428
|
+
byte_remapping: Option<&ByteRemapping>,
|
|
429
|
+
pair_ranks: Option<&PairRankTable>,
|
|
430
|
+
merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
431
|
+
ignore_merges: bool,
|
|
432
|
+
vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
|
|
433
|
+
bytes: &[u8],
|
|
434
|
+
buf: &mut [TokenId; SHORT_MERGE_MAX],
|
|
435
|
+
) -> usize {
|
|
436
|
+
if ignore_merges {
|
|
437
|
+
if let Some(&id) = vocab_inv.get(bytes) {
|
|
438
|
+
buf[0] = id;
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
Self::merge_short(byte_remapping, pair_ranks, merges, bytes, buf)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// [`Self::seed_symbols`] for either merge-table shape: dispatches to
|
|
446
|
+
/// the ranked variant when `ranked_merges` is set. Load-time call sites
|
|
447
|
+
/// (cache seeding, flag flips) go through this; the per-pretoken miss
|
|
448
|
+
/// path dispatches once per pretoken instead (see
|
|
449
|
+
/// [`Self::encode_pretoken_miss`]), keeping the id-as-rank miss
|
|
450
|
+
/// codegen identical to a build without ranked support.
|
|
451
|
+
#[inline]
|
|
452
|
+
fn seed_symbols_any(
|
|
453
|
+
byte_remapping: Option<&ByteRemapping>,
|
|
454
|
+
pair_ranks: Option<&PairRankTable>,
|
|
455
|
+
merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
456
|
+
ranked_merges: Option<&RankedMerges>,
|
|
457
|
+
ignore_merges: bool,
|
|
458
|
+
vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
|
|
459
|
+
bytes: &[u8],
|
|
460
|
+
buf: &mut [TokenId; SHORT_MERGE_MAX],
|
|
461
|
+
) -> usize {
|
|
462
|
+
match ranked_merges {
|
|
463
|
+
Some(rm) => Self::seed_symbols_ranked(
|
|
464
|
+
byte_remapping,
|
|
465
|
+
rm,
|
|
466
|
+
ignore_merges,
|
|
467
|
+
vocab_inv,
|
|
468
|
+
bytes,
|
|
469
|
+
buf,
|
|
470
|
+
),
|
|
471
|
+
None => Self::seed_symbols(
|
|
472
|
+
byte_remapping,
|
|
473
|
+
pair_ranks,
|
|
474
|
+
merges,
|
|
475
|
+
ignore_merges,
|
|
476
|
+
vocab_inv,
|
|
477
|
+
bytes,
|
|
478
|
+
buf,
|
|
479
|
+
),
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/// Ranked-merge-table variant of [`Self::seed_symbols`].
|
|
484
|
+
fn seed_symbols_ranked(
|
|
485
|
+
byte_remapping: Option<&ByteRemapping>,
|
|
486
|
+
ranked_merges: &RankedMerges,
|
|
487
|
+
ignore_merges: bool,
|
|
488
|
+
vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
|
|
489
|
+
bytes: &[u8],
|
|
490
|
+
buf: &mut [TokenId; SHORT_MERGE_MAX],
|
|
491
|
+
) -> usize {
|
|
492
|
+
if ignore_merges {
|
|
493
|
+
if let Some(&id) = vocab_inv.get(bytes) {
|
|
494
|
+
buf[0] = id;
|
|
495
|
+
return 1;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
let n = bytes.len();
|
|
499
|
+
debug_assert!((1..SHORT_MERGE_MAX).contains(&n));
|
|
500
|
+
match byte_remapping {
|
|
501
|
+
Some(br) => {
|
|
502
|
+
for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
|
|
503
|
+
*dst = br.mapping[b as usize];
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
None => {
|
|
507
|
+
for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
|
|
508
|
+
*dst = TokenId(b as u32);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if n < 2 {
|
|
513
|
+
return n;
|
|
514
|
+
}
|
|
515
|
+
bpe_merge_symbols_ranked_slice(ranked_merges, &mut buf[..n])
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// BPE-encode one short pretoken (1..=15 bytes) into `buf`, returning
|
|
519
|
+
/// its token count: byte remapping, then the short merge loop. This is
|
|
520
|
+
/// exactly the computation [`Self::encode_pretoken_miss`] performs for
|
|
521
|
+
/// short keys — shared with [`Self::seeded_pretoken_cache`] so the
|
|
522
|
+
/// vocab seed can never disagree with a cold miss.
|
|
523
|
+
#[inline]
|
|
524
|
+
fn merge_short(
|
|
525
|
+
byte_remapping: Option<&ByteRemapping>,
|
|
526
|
+
pair_ranks: Option<&PairRankTable>,
|
|
527
|
+
merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
|
|
528
|
+
bytes: &[u8],
|
|
529
|
+
buf: &mut [TokenId; SHORT_MERGE_MAX],
|
|
530
|
+
) -> usize {
|
|
531
|
+
let n = bytes.len();
|
|
532
|
+
debug_assert!((1..SHORT_MERGE_MAX).contains(&n));
|
|
533
|
+
match byte_remapping {
|
|
534
|
+
Some(br) => {
|
|
535
|
+
for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
|
|
536
|
+
*dst = br.mapping[b as usize];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
None => {
|
|
540
|
+
for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
|
|
541
|
+
*dst = TokenId(b as u32);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if n < 2 {
|
|
546
|
+
return n;
|
|
547
|
+
}
|
|
548
|
+
match pair_ranks {
|
|
549
|
+
#[cfg(target_arch = "aarch64")]
|
|
550
|
+
Some(table) => bpe_merge_symbols_short_neon(table, buf, n),
|
|
551
|
+
// x86-64 stays scalar ON PURPOSE: the AVX-512/AVX2 ports of the
|
|
552
|
+
// min-rank scan (`bpe_merge_symbols_short_avx512/_avx2`, kept as
|
|
553
|
+
// tested reference) measured ~1% SLOWER on cold encode_st (Zen 5,
|
|
554
|
+
// gpt2, 100 MB and 1 GB OWT, interleaved min-of-5) — the x86
|
|
555
|
+
// horizontal reduce is a 4-step dependent chain plus a
|
|
556
|
+
// vector->GPR transfer on the serial merge chain, and the
|
|
557
|
+
// `target_feature` boundary blocks inlining, while the scalar
|
|
558
|
+
// scan's `rank < best` branches predict well on Zen 5. See
|
|
559
|
+
// profiling/x86_port_plan.md §6.
|
|
560
|
+
#[cfg(not(target_arch = "aarch64"))]
|
|
561
|
+
Some(table) => bpe_merge_symbols_short_scalar(
|
|
562
|
+
|a, b| table.rank(a, b),
|
|
563
|
+
|a, b| table.prefetch_rank(a, b),
|
|
564
|
+
buf,
|
|
565
|
+
n,
|
|
566
|
+
),
|
|
567
|
+
None => bpe_merge_symbols_short_scalar(
|
|
568
|
+
|a, b| merges.get(&(a, b)).map_or(u32::MAX, |m| m.0),
|
|
569
|
+
|_, _| {},
|
|
570
|
+
buf,
|
|
571
|
+
n,
|
|
572
|
+
),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/// Pack a cache value: inline when possible, else spilled to the arena.
|
|
577
|
+
#[inline(always)]
|
|
578
|
+
fn pack_val(symbols: &[TokenId], token_arena: &mut Vec<TokenId>) -> (u64, u64) {
|
|
579
|
+
pack_val_inline(symbols).unwrap_or_else(|| {
|
|
580
|
+
let offset = token_arena.len() as u64;
|
|
581
|
+
token_arena.extend_from_slice(symbols);
|
|
582
|
+
(VAL_SPILL | symbols.len() as u64 | (offset << 32), 0)
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/// Given a list of tokens in rank order (by merge order), reconstructs the
|
|
587
|
+
/// merges map and returns a Tokenizer.
|
|
588
|
+
///
|
|
589
|
+
/// This process is necessary to load some tokenizers found in tiktoken.
|
|
590
|
+
pub fn from_ranks(vocab: Vec<Vec<u8>>) -> Result<Self> {
|
|
591
|
+
let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
|
|
592
|
+
HashMap::with_hasher(rustc_hash::FxBuildHasher {});
|
|
593
|
+
let vocab = vocab
|
|
594
|
+
.into_iter()
|
|
595
|
+
.map(Into::into)
|
|
596
|
+
.collect::<Vec<Arc<[u8]>>>();
|
|
597
|
+
let vocab_inv: HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher> = vocab
|
|
598
|
+
.iter()
|
|
599
|
+
.cloned()
|
|
600
|
+
.zip((0..).map(TokenId::from))
|
|
601
|
+
.collect();
|
|
602
|
+
|
|
603
|
+
for (token_idx, token_bytes) in vocab.iter().cloned().enumerate() {
|
|
604
|
+
if token_bytes.len() < 2 {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
let byte_symbols: Vec<u8> = token_bytes
|
|
608
|
+
.iter()
|
|
609
|
+
.map(|b| vocab_inv.get(std::slice::from_ref(b)).unwrap().0 as u8)
|
|
610
|
+
.collect();
|
|
611
|
+
let tokenized = simple_bpe_merge(&merges, &byte_symbols);
|
|
612
|
+
assert_eq!(tokenized.len(), 2);
|
|
613
|
+
merges.insert((tokenized[0], tokenized[1]), TokenId::from(token_idx));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let byte_remapping = ByteRemapping::from_byte_vocab(&vocab)?;
|
|
617
|
+
Ok(Self::from_tables(merges, None, vocab, byte_remapping))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/// Create a new tokenizer sharing the same model data but with a
|
|
621
|
+
/// freshly seeded cache (no encoded pretokens beyond the vocab seed).
|
|
622
|
+
/// Useful for per-thread encoding in parallel.
|
|
623
|
+
pub fn fork(&self) -> Self {
|
|
624
|
+
self.fork_sized(0)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/// [`Self::fork`] with the caches pre-sized for a worker expected to
|
|
628
|
+
/// encode roughly `expected_bytes` of input. On a cold parallel run a
|
|
629
|
+
/// default-sized worker rehashes its pretoken table through 6-7
|
|
630
|
+
/// doublings — random scatter writes into a fresh zeroed allocation
|
|
631
|
+
/// each time, on every worker at once; sizing from the input share
|
|
632
|
+
/// pays for the table exactly once. The estimates are capacity hints
|
|
633
|
+
/// only: every structure still grows past them as needed, and the
|
|
634
|
+
/// clamps keep tiny inputs at the default size. The short-table size
|
|
635
|
+
/// is a floor passed through the vocab seeding, so the seed
|
|
636
|
+
/// requirement and the workload estimate resolve to one table
|
|
637
|
+
/// construction (whichever is larger).
|
|
638
|
+
pub(crate) fn fork_sized(&self, expected_bytes: usize) -> Self {
|
|
639
|
+
// Distinct short pretokens follow Heaps' law: ~1.3M at 1 GB and
|
|
640
|
+
// ~5.5M at 10 GB of OWT-like text gives distinct(n) ≈ 3.45·n^0.62.
|
|
641
|
+
// Size for the Heaps estimate at the table's 3/4 growth load with
|
|
642
|
+
// 1.4x headroom (self-paced chunk handout lets a fast core encode
|
|
643
|
+
// more than its even share; the margin holds a >2x-oversubscribed
|
|
644
|
+
// worker under the growth threshold before the table would resize).
|
|
645
|
+
// Still a capacity hint: the table grows past it at 3/4 load on
|
|
646
|
+
// corpora more diverse than the OWT calibration. Clamped to 2^22
|
|
647
|
+
// slots (128 MB) per worker.
|
|
648
|
+
let distinct = 3.45 * (expected_bytes as f64).powf(0.62);
|
|
649
|
+
let cache_slots = ((distinct * (4.0 / 3.0) * 1.4) as usize)
|
|
650
|
+
.clamp(1 << 16, 1 << 22)
|
|
651
|
+
.next_power_of_two();
|
|
652
|
+
let arena_cap = (expected_bytes / 256).min(1 << 24);
|
|
653
|
+
let long_cap = (expected_bytes / 8192).min(1 << 20);
|
|
654
|
+
let mut token_arena = Vec::with_capacity(arena_cap);
|
|
655
|
+
let mut pretoken_cache = Self::seeded_pretoken_cache(
|
|
656
|
+
&self.vocab,
|
|
657
|
+
self.byte_remapping.as_ref(),
|
|
658
|
+
self.pair_ranks.as_deref(),
|
|
659
|
+
&self.merges,
|
|
660
|
+
self.ranked_merges.as_deref(),
|
|
661
|
+
self.ignore_merges,
|
|
662
|
+
&self.vocab_inv,
|
|
663
|
+
&mut token_arena,
|
|
664
|
+
cache_slots,
|
|
665
|
+
);
|
|
666
|
+
// The vocab seed above holds the plain seed encoding of every short
|
|
667
|
+
// byte string (merge result, or own ID under `ignore_merges`);
|
|
668
|
+
// re-apply the added-token `[id]` overwrites so the fork's cache
|
|
669
|
+
// matches the parent's seed-level state (the shared function is the
|
|
670
|
+
// sync invariant — see `apply_added_token_overwrites`).
|
|
671
|
+
apply_added_token_overwrites(
|
|
672
|
+
&self.added_tokens,
|
|
673
|
+
&self.vocab_inv,
|
|
674
|
+
&mut pretoken_cache,
|
|
675
|
+
&mut token_arena,
|
|
676
|
+
);
|
|
677
|
+
Tokenizer {
|
|
678
|
+
merges: Arc::clone(&self.merges),
|
|
679
|
+
pair_ranks: self.pair_ranks.clone(),
|
|
680
|
+
ranked_merges: self.ranked_merges.clone(),
|
|
681
|
+
vocab: Arc::clone(&self.vocab),
|
|
682
|
+
vocab_inv: Arc::clone(&self.vocab_inv),
|
|
683
|
+
byte_remapping: self.byte_remapping.clone(),
|
|
684
|
+
token_arena,
|
|
685
|
+
pretoken_cache,
|
|
686
|
+
pretoken_cache_long: HashMap::with_capacity_and_hasher(
|
|
687
|
+
long_cap,
|
|
688
|
+
rustc_hash::FxBuildHasher {},
|
|
689
|
+
),
|
|
690
|
+
merge_scratch: MergeScratch::default(),
|
|
691
|
+
symbol_scratch: Vec::new(),
|
|
692
|
+
pretokenizer_type: self.pretokenizer_type,
|
|
693
|
+
added_tokens: self.added_tokens.clone(),
|
|
694
|
+
added_matcher: self.added_matcher.clone(),
|
|
695
|
+
normalize_nfc: self.normalize_nfc,
|
|
696
|
+
add_prefix_space: self.add_prefix_space,
|
|
697
|
+
ignore_merges: self.ignore_merges,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/// Loader-phase mutator: like every `Tokenizer` mutation, this must
|
|
702
|
+
/// run before any `WorkerPool` forks workers from this tokenizer —
|
|
703
|
+
/// already-forked workers keep the old state (see [`WorkerPool`]).
|
|
704
|
+
///
|
|
705
|
+
/// [`WorkerPool`]: crate::batch::WorkerPool
|
|
706
|
+
pub fn set_pretokenizer_type(&mut self, pretokenizer_type: PretokenizerType) {
|
|
707
|
+
self.pretokenizer_type = pretokenizer_type;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
pub fn pretokenizer_type(&self) -> PretokenizerType {
|
|
711
|
+
self.pretokenizer_type
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/// Enable NFC normalization of non-added-token segments before
|
|
715
|
+
/// pretokenization (HF `normalizer: {"type": "NFC"}`).
|
|
716
|
+
pub fn set_normalize_nfc(&mut self, normalize_nfc: bool) {
|
|
717
|
+
self.normalize_nfc = normalize_nfc;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/// Enable HF `ByteLevel(add_prefix_space=true)` semantics (see the
|
|
721
|
+
/// `add_prefix_space` field).
|
|
722
|
+
pub fn set_add_prefix_space(&mut self, add_prefix_space: bool) {
|
|
723
|
+
self.add_prefix_space = add_prefix_space;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/// Enable HF BPE `ignore_merges` semantics: a pretoken whose whole
|
|
727
|
+
/// byte string is a vocab entry encodes as that single ID, skipping
|
|
728
|
+
/// the merge loop.
|
|
729
|
+
///
|
|
730
|
+
/// Rewrites the vocab-seeded short-cache entries to the new flag's
|
|
731
|
+
/// seed values (own ID vs merge decomposition — see
|
|
732
|
+
/// [`Self::seed_symbols`]) and reasserts the added-token overwrites,
|
|
733
|
+
/// so the cache stays a pure function of
|
|
734
|
+
/// `(vocab, ignore_merges, added_tokens)` and matches what a fork's
|
|
735
|
+
/// fresh reseed produces.
|
|
736
|
+
///
|
|
737
|
+
/// Loader-phase mutator: must run before any `WorkerPool` forks
|
|
738
|
+
/// workers from this tokenizer — already-forked workers keep the old
|
|
739
|
+
/// state (see [`WorkerPool`]).
|
|
740
|
+
///
|
|
741
|
+
/// [`WorkerPool`]: crate::batch::WorkerPool
|
|
742
|
+
pub fn set_ignore_merges(&mut self, ignore_merges: bool) {
|
|
743
|
+
if self.ignore_merges == ignore_merges {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
self.ignore_merges = ignore_merges;
|
|
747
|
+
let mut buf = [TokenId(0); SHORT_MERGE_MAX];
|
|
748
|
+
for bytes in self.vocab.iter() {
|
|
749
|
+
if !(1..=15).contains(&bytes.len()) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
let key = pack_pretoken_key(bytes).expect("length checked <= 15");
|
|
753
|
+
let h = pretoken_key_hash(key);
|
|
754
|
+
let n = Self::seed_symbols_any(
|
|
755
|
+
self.byte_remapping.as_ref(),
|
|
756
|
+
self.pair_ranks.as_deref(),
|
|
757
|
+
&self.merges,
|
|
758
|
+
self.ranked_merges.as_deref(),
|
|
759
|
+
ignore_merges,
|
|
760
|
+
&self.vocab_inv,
|
|
761
|
+
bytes,
|
|
762
|
+
&mut buf,
|
|
763
|
+
);
|
|
764
|
+
let (val, ext) = Self::pack_val(&buf[..n], &mut self.token_arena);
|
|
765
|
+
self.pretoken_cache.replace(key, h, val, ext);
|
|
766
|
+
}
|
|
767
|
+
apply_added_token_overwrites(
|
|
768
|
+
&self.added_tokens,
|
|
769
|
+
&self.vocab_inv,
|
|
770
|
+
&mut self.pretoken_cache,
|
|
771
|
+
&mut self.token_arena,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/// Set the added tokens matched atomically by
|
|
776
|
+
/// [`Self::encode_with_added_tokens`]. Empty contents are ignored.
|
|
777
|
+
///
|
|
778
|
+
/// Loader-phase mutator: must run before any `WorkerPool` forks
|
|
779
|
+
/// workers from this tokenizer — already-forked workers keep the old
|
|
780
|
+
/// added-token set (see [`WorkerPool`]).
|
|
781
|
+
///
|
|
782
|
+
/// [`WorkerPool`]: crate::batch::WorkerPool
|
|
783
|
+
pub fn set_added_tokens(&mut self, added_tokens: Vec<AddedTokenDef>) {
|
|
784
|
+
let mut added_tokens: Vec<AddedTokenDef> = added_tokens
|
|
785
|
+
.into_iter()
|
|
786
|
+
.filter(|t| !t.content.is_empty())
|
|
787
|
+
.collect();
|
|
788
|
+
added_tokens.sort_by_key(|t| std::cmp::Reverse(t.content.len()));
|
|
789
|
+
self.added_matcher = (!added_tokens.is_empty()).then(|| {
|
|
790
|
+
aho_corasick::AhoCorasick::builder()
|
|
791
|
+
.match_kind(aho_corasick::MatchKind::LeftmostLongest)
|
|
792
|
+
.build(added_tokens.iter().map(|t| t.content.as_ref()))
|
|
793
|
+
.expect("added-token automaton construction cannot fail")
|
|
794
|
+
});
|
|
795
|
+
let outgoing = std::mem::replace(&mut self.added_tokens, added_tokens);
|
|
796
|
+
// Restore the plain seed value for the outgoing set first (only
|
|
797
|
+
// contents that resolve in `vocab_inv` were ever overwritten, so
|
|
798
|
+
// this replaces existing entries and never inserts), then apply
|
|
799
|
+
// the incoming overwrites through the shared sync-invariant body
|
|
800
|
+
// (see `apply_added_token_overwrites`).
|
|
801
|
+
for tok in &outgoing {
|
|
802
|
+
let content = &tok.content;
|
|
803
|
+
if !(1..=15).contains(&content.len()) || self.vocab_inv.get(content).is_none() {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
let key = pack_pretoken_key(content).expect("length checked <= 15");
|
|
807
|
+
let h = pretoken_key_hash(key);
|
|
808
|
+
let mut buf = [TokenId(0); SHORT_MERGE_MAX];
|
|
809
|
+
let n = Self::seed_symbols_any(
|
|
810
|
+
self.byte_remapping.as_ref(),
|
|
811
|
+
self.pair_ranks.as_deref(),
|
|
812
|
+
&self.merges,
|
|
813
|
+
self.ranked_merges.as_deref(),
|
|
814
|
+
self.ignore_merges,
|
|
815
|
+
&self.vocab_inv,
|
|
816
|
+
content,
|
|
817
|
+
&mut buf,
|
|
818
|
+
);
|
|
819
|
+
let (val, ext) = Self::pack_val(&buf[..n], &mut self.token_arena);
|
|
820
|
+
self.pretoken_cache.replace(key, h, val, ext);
|
|
821
|
+
}
|
|
822
|
+
apply_added_token_overwrites(
|
|
823
|
+
&self.added_tokens,
|
|
824
|
+
&self.vocab_inv,
|
|
825
|
+
&mut self.pretoken_cache,
|
|
826
|
+
&mut self.token_arena,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/// Register one additional added token, extending the decode vocab when
|
|
831
|
+
/// its id lies outside the base ranks (mirrors the out-of-vocab
|
|
832
|
+
/// added-token handling in the HF loader).
|
|
833
|
+
///
|
|
834
|
+
/// Loader-phase mutator: must run before any `WorkerPool` forks
|
|
835
|
+
/// workers from this tokenizer — already-forked workers keep the old
|
|
836
|
+
/// vocab, matcher, and cache seed (see [`WorkerPool`]).
|
|
837
|
+
///
|
|
838
|
+
/// [`WorkerPool`]: crate::batch::WorkerPool
|
|
839
|
+
pub fn add_special_token(&mut self, content: Vec<u8>, id: TokenId) {
|
|
840
|
+
let idx = id.0 as usize;
|
|
841
|
+
// Loader-phase mutation of the shared model tables: `make_mut`
|
|
842
|
+
// copies only when a fork holds the tables too (never during
|
|
843
|
+
// loading, where this is called).
|
|
844
|
+
let vocab = Arc::make_mut(&mut self.vocab);
|
|
845
|
+
if idx >= vocab.len() {
|
|
846
|
+
vocab.resize(idx + 1, Arc::from(Vec::new().as_slice()));
|
|
847
|
+
}
|
|
848
|
+
if vocab[idx].is_empty() {
|
|
849
|
+
vocab[idx] = content.clone().into();
|
|
850
|
+
// If `content` duplicates an already-present vocab byte
|
|
851
|
+
// string, `vocab_inv` switches to the new ID (unconditional
|
|
852
|
+
// overwrite). The short-cache overwrite that keeps a matching
|
|
853
|
+
// pretoken resolving to `vocab_inv`'s answer happens in
|
|
854
|
+
// `set_added_tokens` below, which re-derives every added-token
|
|
855
|
+
// cache overwrite from the updated `vocab_inv` — the same
|
|
856
|
+
// computation a fork's reseed + re-apply performs (see
|
|
857
|
+
// [`Self::fork_sized`]), so parent and forked workers agree.
|
|
858
|
+
Arc::make_mut(&mut self.vocab_inv).insert(vocab[idx].clone(), id);
|
|
859
|
+
}
|
|
860
|
+
let mut added = self.added_tokens.clone();
|
|
861
|
+
added.push(AddedTokenDef { content: content.into(), id, lstrip: false, rstrip: false });
|
|
862
|
+
self.set_added_tokens(added);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/// Size of the vocabulary: one greater than the largest token ID,
|
|
866
|
+
/// including added tokens (IDs with no assigned content count too).
|
|
867
|
+
pub fn vocab_size(&self) -> usize {
|
|
868
|
+
self.vocab.len()
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/// Vocabulary entries as `(id, bytes)` pairs in ID order, including
|
|
872
|
+
/// added tokens and skipping IDs with no assigned content.
|
|
873
|
+
pub fn vocab_entries(&self) -> impl Iterator<Item = (u32, &[u8])> {
|
|
874
|
+
super::vocab_entries(&self.vocab)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/// Merge rules as `(left, right)` byte pairs in merge-priority order
|
|
878
|
+
/// (priority equals the merged token's ID for tiktoken vocabularies;
|
|
879
|
+
/// rank-mapped vocabularies keep their explicit rank order).
|
|
880
|
+
pub fn merge_entries(&self) -> Vec<(&[u8], &[u8])> {
|
|
881
|
+
let mut ranked: Vec<(u32, u32, u32)> = match self.ranked_merges.as_deref() {
|
|
882
|
+
Some(rm) => rm
|
|
883
|
+
.iter()
|
|
884
|
+
.map(|(&key, &(_, rank))| ((key >> 32) as u32, key as u32, rank))
|
|
885
|
+
.collect(),
|
|
886
|
+
None => self.merges.iter().map(|(&(a, b), &m)| (a.0, b.0, m.0)).collect(),
|
|
887
|
+
};
|
|
888
|
+
ranked.sort_unstable_by_key(|&(.., priority)| priority);
|
|
889
|
+
ranked
|
|
890
|
+
.into_iter()
|
|
891
|
+
.map(|(a, b, _)| {
|
|
892
|
+
(
|
|
893
|
+
self.vocab[a as usize].as_ref(),
|
|
894
|
+
self.vocab[b as usize].as_ref(),
|
|
895
|
+
)
|
|
896
|
+
})
|
|
897
|
+
.collect()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/// Added-token contents paired with their `rstrip` flag, for
|
|
901
|
+
/// `pretokenize::safe_split_ranges`: an rstrip occurrence must not end
|
|
902
|
+
/// exactly at a chunk boundary, or the whitespace it would absorb lands
|
|
903
|
+
/// at the start of the next chunk and encodes as plain text.
|
|
904
|
+
pub fn added_token_split_blockers(&self) -> Vec<(&[u8], bool)> {
|
|
905
|
+
self.added_tokens
|
|
906
|
+
.iter()
|
|
907
|
+
.map(|t| (t.content.as_ref(), t.rstrip))
|
|
908
|
+
.collect()
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/// Find the leftmost added-token occurrence at or after `from`, taking
|
|
912
|
+
/// the longest token when several match at the same position. Returns
|
|
913
|
+
/// `(start, end, index into added_tokens)`.
|
|
914
|
+
fn find_added_token(&self, bytes: &[u8], from: usize) -> Option<(usize, usize, usize)> {
|
|
915
|
+
let m = self.added_matcher.as_ref()?.find(&bytes[from..])?;
|
|
916
|
+
Some((from + m.start(), from + m.end(), m.pattern().as_usize()))
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/// Shared piece walk of the added-token pipeline: split out added-token
|
|
920
|
+
/// occurrences and hand each piece — the (possibly NFC-normalized)
|
|
921
|
+
/// segment between occurrences, or the added token's ID — to `f` in
|
|
922
|
+
/// input order. Scheme dispatch costs one enum match per 256-pretoken
|
|
923
|
+
/// chunk fill (see [`PretokenizerType::pretokenize`] and
|
|
924
|
+
/// `FastPretokenizerDispatch::fill_spans_keyed`), which delegates to
|
|
925
|
+
/// the same out-of-line concrete fills a hardcoded pretokenizer uses.
|
|
926
|
+
fn for_each_piece(&mut self, bytes: &[u8], mut f: impl FnMut(&mut Self, Piece<'_>)) {
|
|
927
|
+
let normalize_nfc = self.normalize_nfc;
|
|
928
|
+
let mut nfc_buf = String::new();
|
|
929
|
+
let mut prefix_buf = Vec::new();
|
|
930
|
+
let mut pos = 0;
|
|
931
|
+
while pos < bytes.len() {
|
|
932
|
+
let (mut seg_end, added) = match self.find_added_token(bytes, pos) {
|
|
933
|
+
Some((start, end, idx)) => {
|
|
934
|
+
let t = &self.added_tokens[idx];
|
|
935
|
+
(start, Some((end, t.id, t.lstrip, t.rstrip)))
|
|
936
|
+
}
|
|
937
|
+
None => (bytes.len(), None),
|
|
938
|
+
};
|
|
939
|
+
// An lstrip added token absorbs the whitespace before it (HF's
|
|
940
|
+
// `\s*` on the left of the match); drop it from the segment.
|
|
941
|
+
if let Some((_, _, true, _)) = added {
|
|
942
|
+
seg_end = pos + trim_ws_end(&bytes[pos..seg_end]);
|
|
943
|
+
}
|
|
944
|
+
let mut segment = if normalize_nfc {
|
|
945
|
+
nfc_segment(&bytes[pos..seg_end], &mut nfc_buf)
|
|
946
|
+
} else {
|
|
947
|
+
&bytes[pos..seg_end]
|
|
948
|
+
};
|
|
949
|
+
if self.add_prefix_space && !segment.is_empty() && segment[0] != b' ' {
|
|
950
|
+
prefix_buf.clear();
|
|
951
|
+
prefix_buf.push(b' ');
|
|
952
|
+
prefix_buf.extend_from_slice(segment);
|
|
953
|
+
segment = &prefix_buf;
|
|
954
|
+
}
|
|
955
|
+
f(self, Piece::Segment(segment, pos));
|
|
956
|
+
match added {
|
|
957
|
+
Some((end, id, _, rstrip)) => {
|
|
958
|
+
f(self, Piece::Added(id));
|
|
959
|
+
// An rstrip added token absorbs the whitespace after it.
|
|
960
|
+
pos = if rstrip { end + trim_ws_start(&bytes[end..]) } else { end };
|
|
961
|
+
}
|
|
962
|
+
None => break,
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/// Encode raw text: split out added-token occurrences (emitted as their
|
|
968
|
+
/// single token ID), pretokenize the segments between them with this
|
|
969
|
+
/// tokenizer's pretokenization scheme, and BPE-encode each pretoken.
|
|
970
|
+
/// This mirrors the full HuggingFace `tokenizers` encode pipeline.
|
|
971
|
+
pub fn encode_with_added_tokens(&mut self, bytes: &[u8], mut f: impl FnMut(&[TokenId])) {
|
|
972
|
+
let pt = self.pretokenizer_type;
|
|
973
|
+
self.for_each_piece(bytes, |this, piece| match piece {
|
|
974
|
+
Piece::Segment(segment, _) => this.memoized_encode(pt.pretokenize(segment), &mut f),
|
|
975
|
+
Piece::Added(id) => f(&[id]),
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/// Flat variant of [`Self::encode_with_added_tokens`]: the identical
|
|
980
|
+
/// token stream appended to `out` as raw u32 ids, routed through
|
|
981
|
+
/// [`Self::memoized_encode_flat`] so segment tokens land directly in
|
|
982
|
+
/// the caller's buffer (the batch engine's per-chunk id buffer).
|
|
983
|
+
pub fn encode_with_added_tokens_flat(&mut self, bytes: &[u8], out: &mut Vec<u32>) {
|
|
984
|
+
let pt = self.pretokenizer_type;
|
|
985
|
+
self.for_each_piece(bytes, |this, piece| match piece {
|
|
986
|
+
Piece::Segment(segment, _) => this.memoized_encode_flat(pt.pretokenize(segment), out),
|
|
987
|
+
Piece::Added(id) => out.push(id.0),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/// For each pretoken in the input iterator, looks up the string in the
|
|
992
|
+
/// cache, and if not found, encodes it and inserts it into the cache.
|
|
993
|
+
/// Calls `f` with the encoded token slice for each pretoken.
|
|
994
|
+
///
|
|
995
|
+
/// A thin wrapper over the flat probe/emit machinery (see
|
|
996
|
+
/// [`Self::memoized_encode_flat`], the path the batch engine and
|
|
997
|
+
/// benches use): each chunk's tokens land in a reused L1-resident
|
|
998
|
+
/// buffer with per-pretoken end offsets recorded on the side, then `f`
|
|
999
|
+
/// receives one slice per pretoken.
|
|
1000
|
+
pub fn memoized_encode<'i>(
|
|
1001
|
+
&mut self,
|
|
1002
|
+
mut pretokens: impl PretokenSpans<'i>,
|
|
1003
|
+
mut f: impl FnMut(&[TokenId]),
|
|
1004
|
+
) {
|
|
1005
|
+
let mut batch = SpanBatch::new();
|
|
1006
|
+
let mut out: Vec<u32> = Vec::new();
|
|
1007
|
+
let mut ends = [0usize; PRETOKEN_CHUNK];
|
|
1008
|
+
loop {
|
|
1009
|
+
let cache = &self.pretoken_cache;
|
|
1010
|
+
let n = pretokens.fill_spans_keyed(&mut batch, &|h| cache.prefetch_l2(h));
|
|
1011
|
+
if n == 0 {
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
out.clear();
|
|
1015
|
+
self.probe_emit_chunk(&batch, n, &mut out, |i, w| ends[i] = w);
|
|
1016
|
+
let mut start = 0;
|
|
1017
|
+
for &end in &ends[..n] {
|
|
1018
|
+
// SAFETY: TokenId is repr(transparent) over u32, and the
|
|
1019
|
+
// recorded ends partition `out` (0 <= start <= end <= len).
|
|
1020
|
+
f(unsafe {
|
|
1021
|
+
std::slice::from_raw_parts(
|
|
1022
|
+
out.as_ptr().add(start) as *const TokenId,
|
|
1023
|
+
end - start,
|
|
1024
|
+
)
|
|
1025
|
+
});
|
|
1026
|
+
start = end;
|
|
1027
|
+
}
|
|
1028
|
+
if n < PRETOKEN_CHUNK {
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/// Flat variant of [`Self::memoized_encode`]: the identical token
|
|
1035
|
+
/// stream appended to `out` as raw u32 ids (bit-compatible with
|
|
1036
|
+
/// `TokenId`), with no per-pretoken delivery. This is the batch
|
|
1037
|
+
/// engine's output shape (`batch::encode_into` fills chunk id buffers),
|
|
1038
|
+
/// so the emit loop writes tokens straight into the final buffer.
|
|
1039
|
+
///
|
|
1040
|
+
/// Runs in chunks of `PRETOKEN_CHUNK` pretokens through two phases —
|
|
1041
|
+
/// pull spans from the pretokenizer with keys/hashes derived and probe
|
|
1042
|
+
/// lines prefetched into L2 on the way out (out of line, fused with
|
|
1043
|
+
/// the span walker — see PretokenSpans), then probe and emit. The
|
|
1044
|
+
/// phase split keeps the walker's state register-allocated in one
|
|
1045
|
+
/// tight loop and gives every probe line a chunk of latency (hundreds
|
|
1046
|
+
/// of cycles, enough to cover DRAM) before its probe.
|
|
1047
|
+
pub fn memoized_encode_flat<'i>(
|
|
1048
|
+
&mut self,
|
|
1049
|
+
mut pretokens: impl PretokenSpans<'i>,
|
|
1050
|
+
out: &mut Vec<u32>,
|
|
1051
|
+
) {
|
|
1052
|
+
let mut batch = SpanBatch::new();
|
|
1053
|
+
loop {
|
|
1054
|
+
let cache = &self.pretoken_cache;
|
|
1055
|
+
let n = pretokens.fill_spans_keyed(&mut batch, &|h| cache.prefetch_l2(h));
|
|
1056
|
+
if n == 0 {
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
self.probe_emit_chunk(&batch, n, out, |_, _| {});
|
|
1060
|
+
if n < PRETOKEN_CHUNK {
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/// Probe-and-emit for one chunk: branchless flat emit with a single
|
|
1067
|
+
/// rare data-dependent branch per pretoken. Every iteration stores the
|
|
1068
|
+
/// probed value's four token lanes unconditionally at the write cursor
|
|
1069
|
+
/// and advances by the token count only when the fast predicate (pair
|
|
1070
|
+
/// hit ∧ inline value ∧ short key, ~99% of pretokens) holds; stores
|
|
1071
|
+
/// past the cursor are dead — overwritten by a later iteration or
|
|
1072
|
+
/// truncated by the final `set_len`. Everything else — probe walks
|
|
1073
|
+
/// past the home pair, arena spills, long pretokens, misses — takes
|
|
1074
|
+
/// the `#[cold]` slow path. `record(i, cursor)` runs once per pretoken
|
|
1075
|
+
/// (per-pretoken slicing in [`Self::memoized_encode`]; a no-op closure
|
|
1076
|
+
/// in the flat variant).
|
|
1077
|
+
///
|
|
1078
|
+
/// Slack invariant: `out.capacity() >= cursor + 4 * (iterations
|
|
1079
|
+
/// left)`, established by the reserve below and re-established by the
|
|
1080
|
+
/// slow path after any reallocation, so the two 8-byte stores are
|
|
1081
|
+
/// always in bounds.
|
|
1082
|
+
#[inline(always)]
|
|
1083
|
+
fn probe_emit_chunk(
|
|
1084
|
+
&mut self,
|
|
1085
|
+
batch: &SpanBatch<'_>,
|
|
1086
|
+
n: usize,
|
|
1087
|
+
out: &mut Vec<u32>,
|
|
1088
|
+
mut record: impl FnMut(usize, usize),
|
|
1089
|
+
) {
|
|
1090
|
+
// One check up front so `i`- and `pf`-indexing of the batch arrays
|
|
1091
|
+
// below is provably in bounds (removes two per-iteration compares).
|
|
1092
|
+
assert!(n <= PRETOKEN_CHUNK);
|
|
1093
|
+
if n == 0 {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
out.reserve(4 * n);
|
|
1097
|
+
let mut w = out.len();
|
|
1098
|
+
// Loop-invariant raw cursors. The slow path's `&mut self` call is
|
|
1099
|
+
// the only thing that can move `out`'s buffer or the cache's slot
|
|
1100
|
+
// array, so both are refreshed there and nowhere else; without
|
|
1101
|
+
// these the compiler reloaded the Vec pointer, table base, and
|
|
1102
|
+
// mask from the stack on every iteration.
|
|
1103
|
+
let mut dst = out.as_mut_ptr();
|
|
1104
|
+
let mut table = self.pretoken_cache.probe_view();
|
|
1105
|
+
// Probe-stage prefetch: promote the pair's line L2 -> L1 a fixed
|
|
1106
|
+
// short distance ahead (the fill phase staged it into L2; D only
|
|
1107
|
+
// has to cover the L2 hit latency, a handful of iterations).
|
|
1108
|
+
const D: usize = 16;
|
|
1109
|
+
const _: () = assert!(D <= crate::pretokenize::SPAN_BATCH_SLACK);
|
|
1110
|
+
for i in 0..D.min(n) {
|
|
1111
|
+
table.prefetch(batch.entries[i].meta);
|
|
1112
|
+
}
|
|
1113
|
+
for i in 0..n {
|
|
1114
|
+
// Unclamped prefetch distance: the batch carries D slack
|
|
1115
|
+
// entries past a full chunk, so `i + D` always indexes into
|
|
1116
|
+
// the array and no per-pretoken bounds clamp is needed — the
|
|
1117
|
+
// load is one fixed-offset ldr off the walking entry pointer.
|
|
1118
|
+
// Tail iterations prefetch stale or zero `meta`, and long
|
|
1119
|
+
// entries a length, not a hash — either way a masked,
|
|
1120
|
+
// in-bounds table line: harmless.
|
|
1121
|
+
table.prefetch(batch.entries[i + D].meta);
|
|
1122
|
+
// One 32-byte entry: key + meta land in a single cache line
|
|
1123
|
+
// (the parallel-array layout walked three load streams here).
|
|
1124
|
+
let (key, h) = (batch.entries[i].key, batch.entries[i].meta);
|
|
1125
|
+
let (val, ext, found) = table.probe_pair(key, h);
|
|
1126
|
+
// `key != 0` folds the long-pretoken route in AND guards the
|
|
1127
|
+
// empty-slot sentinel (probe_pair matches key 0 against empty
|
|
1128
|
+
// slots); on !found the lanes below are another entry's, dead
|
|
1129
|
+
// because the cursor does not advance.
|
|
1130
|
+
let fast = found & (val & VAL_SPILL == 0) & (key != 0);
|
|
1131
|
+
// Lanes 1-2 packed into one u64 store, lanes 3-4 are `ext`
|
|
1132
|
+
// verbatim (little-endian lane order, like the raw key load in
|
|
1133
|
+
// `pack_pretoken_key`); the two u64 writes fuse into one 16 B
|
|
1134
|
+
// `stp`.
|
|
1135
|
+
let ab = ((val >> 8) & 0x00FF_FFFF) | (val & 0xFFFF_FFFF_0000_0000);
|
|
1136
|
+
// SAFETY: the slack invariant leaves >= 4 u32s past `w`.
|
|
1137
|
+
unsafe {
|
|
1138
|
+
let p = dst.add(w);
|
|
1139
|
+
(p as *mut u64).write_unaligned(ab);
|
|
1140
|
+
(p.add(2) as *mut u64).write_unaligned(ext);
|
|
1141
|
+
}
|
|
1142
|
+
w += if fast { (val & 0x7F) as usize } else { 0 };
|
|
1143
|
+
if !fast {
|
|
1144
|
+
// Cold: reconstruct the span from the entry. For key == 0
|
|
1145
|
+
// `h` is really the span length, but the slow path never
|
|
1146
|
+
// reads `h` on the long route (see probe_emit_slow), so it
|
|
1147
|
+
// passes through unfiltered — a select here got hoisted
|
|
1148
|
+
// into the hot loop as a per-pretoken cset.
|
|
1149
|
+
// SAFETY: entry `i` was written by this chunk's fill, so
|
|
1150
|
+
// `ptr` points at a live span of the input's lifetime.
|
|
1151
|
+
let bytes = unsafe { batch.span(i) };
|
|
1152
|
+
w = self.probe_emit_slow(bytes, key, h, out, w);
|
|
1153
|
+
dst = out.as_mut_ptr();
|
|
1154
|
+
table = self.pretoken_cache.probe_view();
|
|
1155
|
+
}
|
|
1156
|
+
record(i, w);
|
|
1157
|
+
}
|
|
1158
|
+
// SAFETY: w <= capacity by the slack invariant, and every element
|
|
1159
|
+
// below `w` was written (fast advances never skip lanes; the slow
|
|
1160
|
+
// path appends through Vec).
|
|
1161
|
+
unsafe { out.set_len(w) };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/// Everything [`Self::probe_emit_chunk`]'s fast predicate rejects.
|
|
1165
|
+
/// Appends this pretoken's tokens at cursor `w` and returns the new
|
|
1166
|
+
/// cursor, re-establishing the emit loop's slack invariant.
|
|
1167
|
+
///
|
|
1168
|
+
/// `h` is only meaningful (and only read) when `key != 0`: the long
|
|
1169
|
+
/// route keys on `bytes` and passes literal zeros to the miss path.
|
|
1170
|
+
/// The emit loop relies on this and forwards the batch entry's `meta`
|
|
1171
|
+
/// (the span length when `key == 0`) without filtering it.
|
|
1172
|
+
#[cold]
|
|
1173
|
+
#[inline(never)]
|
|
1174
|
+
fn probe_emit_slow(
|
|
1175
|
+
&mut self,
|
|
1176
|
+
bytes: &[u8],
|
|
1177
|
+
key: u128,
|
|
1178
|
+
h: u64,
|
|
1179
|
+
out: &mut Vec<u32>,
|
|
1180
|
+
w: usize,
|
|
1181
|
+
) -> usize {
|
|
1182
|
+
// SAFETY: elements below `w` are initialized and w <= capacity
|
|
1183
|
+
// (emit-loop invariant); Vec append methods need len in sync.
|
|
1184
|
+
unsafe { out.set_len(w) };
|
|
1185
|
+
if key != 0 {
|
|
1186
|
+
// A miss hands back the insert slot its walk found, so the
|
|
1187
|
+
// miss path's insert skips re-walking the (just-touched)
|
|
1188
|
+
// chain.
|
|
1189
|
+
match self.pretoken_cache.get_or_slot(key, h) {
|
|
1190
|
+
Ok((val, ext)) => {
|
|
1191
|
+
let len = (val & 0x7F) as usize;
|
|
1192
|
+
if val & VAL_SPILL == 0 {
|
|
1193
|
+
out.extend_from_slice(&unpack_val_lanes(val, ext)[..len]);
|
|
1194
|
+
} else {
|
|
1195
|
+
let start = (val >> 32) as usize;
|
|
1196
|
+
// SAFETY: recorded right after appending `len`
|
|
1197
|
+
// tokens at `start`; the arena never shrinks.
|
|
1198
|
+
let toks =
|
|
1199
|
+
unsafe { self.token_arena.get_unchecked(start..start + len) };
|
|
1200
|
+
out.extend_from_slice(token_ids_as_u32s(toks));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
Err(slot) => self.encode_pretoken_miss(bytes, key, h, slot, out),
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
// Long pretokens (> 15 bytes, rare) always spill to the arena;
|
|
1207
|
+
// their token counts can exceed the packed-value range, so
|
|
1208
|
+
// they bypass it entirely.
|
|
1209
|
+
match self.pretoken_cache_long.get(bytes) {
|
|
1210
|
+
Some(&(offset, len)) => {
|
|
1211
|
+
let start = offset as usize;
|
|
1212
|
+
// SAFETY: as above.
|
|
1213
|
+
let toks = unsafe {
|
|
1214
|
+
self.token_arena.get_unchecked(start..start + len as usize)
|
|
1215
|
+
};
|
|
1216
|
+
out.extend_from_slice(token_ids_as_u32s(toks));
|
|
1217
|
+
}
|
|
1218
|
+
None => self.encode_pretoken_miss(bytes, 0, 0, 0, out),
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
out.reserve(4 * PRETOKEN_CHUNK);
|
|
1222
|
+
out.len()
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/// Outlined miss path for rank-mapped vocabularies: the same cache
|
|
1226
|
+
/// bookkeeping as [`Self::encode_pretoken_miss`], with every merge
|
|
1227
|
+
/// running through the explicit-rank loops.
|
|
1228
|
+
#[cold]
|
|
1229
|
+
#[inline(never)]
|
|
1230
|
+
fn encode_pretoken_miss_ranked(
|
|
1231
|
+
&mut self,
|
|
1232
|
+
bytes: &[u8],
|
|
1233
|
+
key: u128,
|
|
1234
|
+
h: u64,
|
|
1235
|
+
slot: usize,
|
|
1236
|
+
out: &mut Vec<u32>,
|
|
1237
|
+
) {
|
|
1238
|
+
let rm = self.ranked_merges.clone().expect("caller checked ranked_merges");
|
|
1239
|
+
if key != 0 {
|
|
1240
|
+
let mut buf = [TokenId(0); SHORT_MERGE_MAX];
|
|
1241
|
+
let n = Self::seed_symbols_ranked(
|
|
1242
|
+
self.byte_remapping.as_ref(),
|
|
1243
|
+
&rm,
|
|
1244
|
+
self.ignore_merges,
|
|
1245
|
+
&self.vocab_inv,
|
|
1246
|
+
bytes,
|
|
1247
|
+
&mut buf,
|
|
1248
|
+
);
|
|
1249
|
+
let symbols = &buf[..n];
|
|
1250
|
+
let (val, ext) = Self::pack_val(symbols, &mut self.token_arena);
|
|
1251
|
+
self.pretoken_cache.insert_at(slot, key, h, val, ext);
|
|
1252
|
+
out.extend_from_slice(token_ids_as_u32s(symbols));
|
|
1253
|
+
} else {
|
|
1254
|
+
// Mirrors the long-pretoken arm of `encode_pretoken_miss`,
|
|
1255
|
+
// including the no-whole-pretoken-shortcut rule documented
|
|
1256
|
+
// there.
|
|
1257
|
+
let symbols = &mut self.symbol_scratch;
|
|
1258
|
+
symbols.clear();
|
|
1259
|
+
if self.ignore_merges
|
|
1260
|
+
&& let Some(&id) = self.vocab_inv.get(bytes)
|
|
1261
|
+
{
|
|
1262
|
+
symbols.push(id);
|
|
1263
|
+
} else {
|
|
1264
|
+
match self.byte_remapping.as_ref() {
|
|
1265
|
+
Some(br) => symbols.extend(bytes.iter().map(|&b| br.mapping[b as usize])),
|
|
1266
|
+
None => symbols.extend(bytes.iter().map(|&b| TokenId::from(b as u32))),
|
|
1267
|
+
}
|
|
1268
|
+
bpe_merge_symbols_ranked(&rm, symbols);
|
|
1269
|
+
}
|
|
1270
|
+
let len = symbols.len() as u32;
|
|
1271
|
+
let offset = self.token_arena.len() as u32;
|
|
1272
|
+
self.token_arena.extend_from_slice(symbols);
|
|
1273
|
+
self.pretoken_cache_long.insert(bytes.into(), (offset, len));
|
|
1274
|
+
out.extend_from_slice(token_ids_as_u32s(symbols));
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/// Cache-miss path of the probe/emit loop: BPE-encode `bytes`, record
|
|
1279
|
+
/// it in the table `key` routes to (the short-pretoken table, or the
|
|
1280
|
+
/// long map when `key == 0`), and append its tokens to `out`. `slot`
|
|
1281
|
+
/// is the short-cache insert position reported by the failed
|
|
1282
|
+
/// `get_or_slot` probe (meaningful only when `key != 0`); nothing
|
|
1283
|
+
/// here touches the short cache before the insert, so it stays valid.
|
|
1284
|
+
#[inline(never)]
|
|
1285
|
+
fn encode_pretoken_miss(
|
|
1286
|
+
&mut self,
|
|
1287
|
+
bytes: &[u8],
|
|
1288
|
+
key: u128,
|
|
1289
|
+
h: u64,
|
|
1290
|
+
slot: usize,
|
|
1291
|
+
out: &mut Vec<u32>,
|
|
1292
|
+
) {
|
|
1293
|
+
// Rank-mapped vocabularies take the outlined
|
|
1294
|
+
// ranked miss path; the branch is one perfectly-predicted test for
|
|
1295
|
+
// everything else, keeping this function's codegen identical to a
|
|
1296
|
+
// build without ranked support.
|
|
1297
|
+
if self.ranked_merges.is_some() {
|
|
1298
|
+
return self.encode_pretoken_miss_ranked(bytes, key, h, slot, out);
|
|
1299
|
+
}
|
|
1300
|
+
if key != 0 {
|
|
1301
|
+
// Short pretoken (≤ 15 bytes, the overwhelming majority of
|
|
1302
|
+
// misses): straight to byte symbols and the merge loop
|
|
1303
|
+
// (`merge_short`, shared with the vocab seed), in a stack
|
|
1304
|
+
// buffer instead of the `Vec` scratch. The cache is pre-seeded
|
|
1305
|
+
// with the seed encoding of every short vocab entry (see
|
|
1306
|
+
// `seeded_pretoken_cache`), so a miss here is never a whole
|
|
1307
|
+
// vocab word — but nothing depends on that: `seed_symbols`
|
|
1308
|
+
// computes the correct encoding for any bytes under either
|
|
1309
|
+
// `ignore_merges` setting.
|
|
1310
|
+
let mut buf = [TokenId(0); SHORT_MERGE_MAX];
|
|
1311
|
+
let n = Self::seed_symbols(
|
|
1312
|
+
self.byte_remapping.as_ref(),
|
|
1313
|
+
self.pair_ranks.as_deref(),
|
|
1314
|
+
&self.merges,
|
|
1315
|
+
self.ignore_merges,
|
|
1316
|
+
&self.vocab_inv,
|
|
1317
|
+
bytes,
|
|
1318
|
+
&mut buf,
|
|
1319
|
+
);
|
|
1320
|
+
let symbols = &buf[..n];
|
|
1321
|
+
let (val, ext) = Self::pack_val(symbols, &mut self.token_arena);
|
|
1322
|
+
self.pretoken_cache.insert_at(slot, key, h, val, ext);
|
|
1323
|
+
out.extend_from_slice(token_ids_as_u32s(symbols));
|
|
1324
|
+
} else {
|
|
1325
|
+
// Long pretoken (> 15 bytes, rare): remap and run the merge
|
|
1326
|
+
// loop. Without `ignore_merges`, deliberately NO
|
|
1327
|
+
// whole-pretoken reverse-vocab (`vocab_inv`) shortcut here — a
|
|
1328
|
+
// vocab entry is not guaranteed to be derivable from its own
|
|
1329
|
+
// merges (qwen3_5 has ~50 entries > 15 bytes, multi-char CJK
|
|
1330
|
+
// phrases, that HF `tokenizers` without `ignore_merges`
|
|
1331
|
+
// encodes as their merge decomposition, never as the single
|
|
1332
|
+
// ID), so any such shortcut diverges from HF and from the
|
|
1333
|
+
// pre-cache baseline. The same rule holds for short keys via
|
|
1334
|
+
// the seeded merge results above. Do not reintroduce it. With
|
|
1335
|
+
// `ignore_merges` set, the shortcut IS HF's semantics, so it
|
|
1336
|
+
// applies — gated on the flag.
|
|
1337
|
+
let symbols = &mut self.symbol_scratch;
|
|
1338
|
+
symbols.clear();
|
|
1339
|
+
if self.ignore_merges
|
|
1340
|
+
&& let Some(&id) = self.vocab_inv.get(bytes)
|
|
1341
|
+
{
|
|
1342
|
+
symbols.push(id);
|
|
1343
|
+
} else {
|
|
1344
|
+
match self.byte_remapping.as_ref() {
|
|
1345
|
+
Some(br) => symbols.extend(bytes.iter().map(|&b| br.mapping[b as usize])),
|
|
1346
|
+
None => symbols.extend(bytes.iter().map(|&b| TokenId::from(b as u32))),
|
|
1347
|
+
}
|
|
1348
|
+
match self.pair_ranks.as_deref() {
|
|
1349
|
+
Some(table) => bpe_merge_symbols_by_rank(
|
|
1350
|
+
&|a, b| table.rank(a, b),
|
|
1351
|
+
symbols,
|
|
1352
|
+
&mut self.merge_scratch,
|
|
1353
|
+
),
|
|
1354
|
+
None => bpe_merge_symbols_with_scratch(
|
|
1355
|
+
&self.merges,
|
|
1356
|
+
symbols,
|
|
1357
|
+
&mut self.merge_scratch,
|
|
1358
|
+
),
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
let len = symbols.len() as u32;
|
|
1362
|
+
let offset = self.token_arena.len() as u32;
|
|
1363
|
+
self.token_arena.extend_from_slice(symbols);
|
|
1364
|
+
self.pretoken_cache_long.insert(bytes.into(), (offset, len));
|
|
1365
|
+
out.extend_from_slice(token_ids_as_u32s(symbols));
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
pub fn decode(&self, v: &[TokenId]) -> impl Iterator<Item = u8> {
|
|
1370
|
+
v.iter()
|
|
1371
|
+
.flat_map(|&token| self.vocab[token.0 as usize].as_ref())
|
|
1372
|
+
.copied()
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/// Detailed cache stats for memory accounting (see examples/cache_memory.rs):
|
|
1376
|
+
/// (short_len, short_cap, long_len, long_cap, long_key_bytes, arena_len, arena_cap).
|
|
1377
|
+
pub fn cache_mem_stats(&self) -> (usize, usize, usize, usize, usize, usize, usize) {
|
|
1378
|
+
let long_key_bytes: usize = self.pretoken_cache_long.keys().map(|k| k.len()).sum();
|
|
1379
|
+
(
|
|
1380
|
+
self.pretoken_cache.len(),
|
|
1381
|
+
self.pretoken_cache.capacity(),
|
|
1382
|
+
self.pretoken_cache_long.len(),
|
|
1383
|
+
self.pretoken_cache_long.capacity(),
|
|
1384
|
+
long_key_bytes,
|
|
1385
|
+
self.token_arena.len(),
|
|
1386
|
+
self.token_arena.capacity(),
|
|
1387
|
+
)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
impl Debug for Tokenizer {
|
|
1392
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
1393
|
+
f.debug_struct("Tokenizer")
|
|
1394
|
+
.field("vocab_size", &self.vocab.len())
|
|
1395
|
+
.field("merges_count", &self.merges.len())
|
|
1396
|
+
.field("pair_ranks", &self.pair_ranks.is_some())
|
|
1397
|
+
.field("byte_remapping", &self.byte_remapping.is_some())
|
|
1398
|
+
.finish()
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/// Helpers shared by the test modules below.
|
|
1403
|
+
#[cfg(test)]
|
|
1404
|
+
mod test_util {
|
|
1405
|
+
use super::*;
|
|
1406
|
+
|
|
1407
|
+
pub(super) fn gpt2_path() -> std::path::PathBuf {
|
|
1408
|
+
crate::test_hub::gpt2_tokenizer_json()
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/// Uncached reference encode of one pretoken: byte remap + plain merge
|
|
1412
|
+
/// loop over the merges HashMap (no pair-rank table, no cache, no
|
|
1413
|
+
/// short-merge kernels).
|
|
1414
|
+
pub(super) fn plain_encode_pretoken(tok: &Tokenizer, pretoken: &[u8], out: &mut Vec<u32>) {
|
|
1415
|
+
let mut symbols: Vec<TokenId> = match tok.byte_remapping.as_ref() {
|
|
1416
|
+
Some(br) => pretoken.iter().map(|&b| br.mapping[b as usize]).collect(),
|
|
1417
|
+
None => pretoken.iter().map(|&b| TokenId::from(b as u32)).collect(),
|
|
1418
|
+
};
|
|
1419
|
+
crate::bpe::bpe_merge_symbols(&tok.merges, &mut symbols);
|
|
1420
|
+
out.extend(symbols.iter().map(|t| t.0));
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/// Pretoken lengths through the two-phase walker path
|
|
1424
|
+
/// (`fill_spans_keyed`), for comparison against the Iterator path.
|
|
1425
|
+
pub(super) fn two_phase_lens<'a>(mut p: impl PretokenSpans<'a>) -> Vec<usize> {
|
|
1426
|
+
let mut batch = SpanBatch::new();
|
|
1427
|
+
let mut lens = Vec::new();
|
|
1428
|
+
loop {
|
|
1429
|
+
let n = p.fill_spans_keyed(&mut batch, &|_| {});
|
|
1430
|
+
for i in 0..n {
|
|
1431
|
+
lens.push(batch.entries[i].span_len());
|
|
1432
|
+
}
|
|
1433
|
+
if n < PRETOKEN_CHUNK {
|
|
1434
|
+
return lens;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/// xorshift64: deterministic, dependency-free RNG for test inputs.
|
|
1440
|
+
pub(super) struct XorShift64(pub u64);
|
|
1441
|
+
|
|
1442
|
+
impl XorShift64 {
|
|
1443
|
+
pub(super) fn next_u64(&mut self) -> u64 {
|
|
1444
|
+
self.0 ^= self.0 << 13;
|
|
1445
|
+
self.0 ^= self.0 >> 7;
|
|
1446
|
+
self.0 ^= self.0 << 17;
|
|
1447
|
+
self.0
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
#[cfg(test)]
|
|
1453
|
+
mod tests {
|
|
1454
|
+
use super::*;
|
|
1455
|
+
use crate::load_tokenizer::tiktoken::load_tiktoken;
|
|
1456
|
+
use std::io::Read;
|
|
1457
|
+
|
|
1458
|
+
/// `add_special_token` whose content duplicates an existing vocab byte
|
|
1459
|
+
/// string must resolve to the added ID everywhere: `vocab_inv`, the
|
|
1460
|
+
/// parent's seeded cache (overwritten, not insert-if-absent), and
|
|
1461
|
+
/// forked workers (vocab reseed plus re-applied added-token
|
|
1462
|
+
/// overwrites). Regression test for the three-way disagreement where
|
|
1463
|
+
/// the parent kept the stale seed entry (old ID) while a fork's
|
|
1464
|
+
/// descending reseed picked the new ID.
|
|
1465
|
+
#[test]
|
|
1466
|
+
fn add_special_token_duplicate_content_agrees_across_forks() {
|
|
1467
|
+
let encode = |t: &mut Tokenizer, input: &[u8]| -> Vec<TokenId> {
|
|
1468
|
+
let mut out = Vec::new();
|
|
1469
|
+
t.memoized_encode(crate::pretokenize::pretokenize_as_iter(input), |tokens| {
|
|
1470
|
+
out.extend_from_slice(tokens)
|
|
1471
|
+
});
|
|
1472
|
+
out
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
// Case 1: added ID above the duplicate's ID.
|
|
1476
|
+
let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
|
|
1477
|
+
HashMap::with_hasher(rustc_hash::FxBuildHasher {});
|
|
1478
|
+
merges.insert((TokenId(104), TokenId(105)), TokenId(256)); // 'h' 'i' -> "hi"
|
|
1479
|
+
let mut vocab: Vec<Vec<u8>> = (0..=255u32).map(|b| vec![b as u8]).collect();
|
|
1480
|
+
vocab.push(b"hi".to_vec()); // id 256 = "hi"
|
|
1481
|
+
let mut tok = Tokenizer::new(merges, vocab, None);
|
|
1482
|
+
tok.add_special_token(b"hi".to_vec(), TokenId(1000));
|
|
1483
|
+
assert_eq!(tok.vocab_inv.get(b"hi".as_slice()), Some(&TokenId(1000)));
|
|
1484
|
+
let mut fork = tok.fork();
|
|
1485
|
+
assert_eq!(
|
|
1486
|
+
encode(&mut tok, b"hi"),
|
|
1487
|
+
vec![TokenId(1000)],
|
|
1488
|
+
"parent cache must resolve the duplicate to the added ID (vocab_inv's answer)"
|
|
1489
|
+
);
|
|
1490
|
+
assert_eq!(
|
|
1491
|
+
encode(&mut fork, b"hi"),
|
|
1492
|
+
vec![TokenId(1000)],
|
|
1493
|
+
"forked worker must agree with the parent"
|
|
1494
|
+
);
|
|
1495
|
+
|
|
1496
|
+
// Case 2 (mirror): added ID fills an empty placeholder BELOW the
|
|
1497
|
+
// duplicate's ID; the fork's reseed alone would pick the higher
|
|
1498
|
+
// ID (the merge result), diverging from vocab_inv and the parent.
|
|
1499
|
+
let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
|
|
1500
|
+
HashMap::with_hasher(rustc_hash::FxBuildHasher {});
|
|
1501
|
+
merges.insert((TokenId(104), TokenId(105)), TokenId(257)); // 'h' 'i' -> "hi"
|
|
1502
|
+
let mut vocab: Vec<Vec<u8>> = (0..=255u32).map(|b| vec![b as u8]).collect();
|
|
1503
|
+
vocab.push(Vec::new()); // id 256: empty placeholder
|
|
1504
|
+
vocab.push(b"hi".to_vec()); // id 257 = "hi"
|
|
1505
|
+
let mut tok = Tokenizer::new(merges, vocab, None);
|
|
1506
|
+
tok.add_special_token(b"hi".to_vec(), TokenId(256));
|
|
1507
|
+
assert_eq!(tok.vocab_inv.get(b"hi".as_slice()), Some(&TokenId(256)));
|
|
1508
|
+
let mut fork = tok.fork();
|
|
1509
|
+
assert_eq!(encode(&mut tok, b"hi"), vec![TokenId(256)]);
|
|
1510
|
+
assert_eq!(encode(&mut fork, b"hi"), vec![TokenId(256)]);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/// GPT-2 must take the PairRankTable fast path, with the table agreeing
|
|
1514
|
+
/// with the merges map, and the vocab seed must serve every short vocab
|
|
1515
|
+
/// word as its own ID: every base GPT-2 entry is merge-reachable, so
|
|
1516
|
+
/// its merge decomposition IS the single own ID, and the one
|
|
1517
|
+
/// unreachable entry (<|endoftext|>, an added token) gets the
|
|
1518
|
+
/// `set_added_tokens` `[id]` overwrite.
|
|
1519
|
+
#[test]
|
|
1520
|
+
fn gpt2_pair_rank_table_and_vocab_seed() {
|
|
1521
|
+
use crate::load_tokenizer::hf::load_hf_bpe;
|
|
1522
|
+
use crate::pretokenize::{pack_pretoken_key, pretoken_key_hash};
|
|
1523
|
+
let tokenizer = load_hf_bpe(super::test_util::gpt2_path()).expect("load GPT-2 tokenizer");
|
|
1524
|
+
|
|
1525
|
+
let table = tokenizer
|
|
1526
|
+
.pair_ranks
|
|
1527
|
+
.as_deref()
|
|
1528
|
+
.expect("GPT-2 must take the pair-rank fast path");
|
|
1529
|
+
for (&(a, b), &m) in tokenizer.merges.iter() {
|
|
1530
|
+
assert_eq!(table.rank(a, b), m.0, "pair ({}, {})", a.0, b.0);
|
|
1531
|
+
}
|
|
1532
|
+
// Dense negatives (byte × byte) and flat negatives must agree with
|
|
1533
|
+
// the map too.
|
|
1534
|
+
for a in (0..50257u32).step_by(97) {
|
|
1535
|
+
for b in (0..50257u32).step_by(89) {
|
|
1536
|
+
let expected = tokenizer
|
|
1537
|
+
.merges
|
|
1538
|
+
.get(&(TokenId(a), TokenId(b)))
|
|
1539
|
+
.map_or(u32::MAX, |m| m.0);
|
|
1540
|
+
assert_eq!(table.rank(TokenId(a), TokenId(b)), expected, "pair ({a}, {b})");
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
let mut seeded = 0usize;
|
|
1545
|
+
for (id, bytes) in tokenizer.vocab_entries() {
|
|
1546
|
+
if !(1..=15).contains(&bytes.len()) {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
let key = pack_pretoken_key(bytes).unwrap();
|
|
1550
|
+
let (val, ext) = tokenizer
|
|
1551
|
+
.pretoken_cache
|
|
1552
|
+
.get_or_slot(key, pretoken_key_hash(key))
|
|
1553
|
+
.expect("short vocab entry must be seeded");
|
|
1554
|
+
// Every real vocab ID is < 2^24, so the seed is inline: 1 token,
|
|
1555
|
+
// the entry's own ID (GPT-2 has no duplicate byte strings and,
|
|
1556
|
+
// added-token overwrites included, no entry whose cached value
|
|
1557
|
+
// differs from its own ID).
|
|
1558
|
+
assert_eq!(val, 1 | ((id as u64) << 8), "vocab entry {id}");
|
|
1559
|
+
assert_eq!(ext, 0, "vocab entry {id}");
|
|
1560
|
+
seeded += 1;
|
|
1561
|
+
}
|
|
1562
|
+
assert_eq!(tokenizer.pretoken_cache.len(), seeded);
|
|
1563
|
+
// A fork starts from the same seed, sharing the same table.
|
|
1564
|
+
let fork = tokenizer.fork();
|
|
1565
|
+
assert_eq!(fork.pretoken_cache.len(), seeded);
|
|
1566
|
+
assert!(fork.pair_ranks.is_some());
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
#[test]
|
|
1570
|
+
fn short_pretoken_cache_serves_repeated_pretokens() {
|
|
1571
|
+
use crate::pretokenize::{SpanIter, pack_pretoken_key, pretoken_key_hash};
|
|
1572
|
+
|
|
1573
|
+
let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
|
|
1574
|
+
let vocab = (0..=u8::MAX).map(|byte| vec![byte]).collect();
|
|
1575
|
+
let mut tokenizer = Tokenizer::new(merges, vocab, None);
|
|
1576
|
+
let bytes = b"hello";
|
|
1577
|
+
|
|
1578
|
+
let mut first = Vec::new();
|
|
1579
|
+
tokenizer.memoized_encode(SpanIter([Pretoken(bytes)].into_iter()), |tokens| {
|
|
1580
|
+
first.extend(tokens.iter().map(|token| token.0));
|
|
1581
|
+
});
|
|
1582
|
+
let expected: Vec<u32> = bytes.iter().map(|&byte| byte as u32).collect();
|
|
1583
|
+
assert_eq!(first, expected);
|
|
1584
|
+
|
|
1585
|
+
// The 5-token encoding is too long to inline, so it spilled to the
|
|
1586
|
+
// arena, but the cache entry serves it either way.
|
|
1587
|
+
let key = pack_pretoken_key(bytes).unwrap();
|
|
1588
|
+
let h = pretoken_key_hash(key);
|
|
1589
|
+
assert!(tokenizer.pretoken_cache.get_or_slot(key, h).is_ok());
|
|
1590
|
+
|
|
1591
|
+
let mut repeated = Vec::new();
|
|
1592
|
+
tokenizer.memoized_encode(SpanIter([Pretoken(bytes)].into_iter()), |tokens| {
|
|
1593
|
+
repeated.extend(tokens.iter().map(|token| token.0));
|
|
1594
|
+
});
|
|
1595
|
+
assert_eq!(repeated, first);
|
|
1596
|
+
// The 256 single-byte vocab entries are pre-seeded; "hello" is the
|
|
1597
|
+
// one entry the encodes added.
|
|
1598
|
+
assert_eq!(tokenizer.pretoken_cache.len(), 257);
|
|
1599
|
+
|
|
1600
|
+
// The zero key marks empty slots in the short table, so an empty
|
|
1601
|
+
// pretoken (possible through the public API) must take the long-map
|
|
1602
|
+
// path.
|
|
1603
|
+
tokenizer.memoized_encode(SpanIter([Pretoken(b"")].into_iter()), |tokens| {
|
|
1604
|
+
assert!(tokens.is_empty());
|
|
1605
|
+
});
|
|
1606
|
+
assert!(tokenizer.pretoken_cache_long.contains_key(&b""[..]));
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/// With no added tokens configured, `encode_with_added_tokens`'s piece
|
|
1610
|
+
/// walk reduces to one whole-input segment — its output must equal a
|
|
1611
|
+
/// direct `memoized_encode` of the same scheme's pretokens.
|
|
1612
|
+
#[test]
|
|
1613
|
+
fn encode_with_added_tokens_matches_memoized_encode_all_schemes() {
|
|
1614
|
+
let schemes = [
|
|
1615
|
+
PretokenizerType::GPT2,
|
|
1616
|
+
PretokenizerType::GPT4,
|
|
1617
|
+
PretokenizerType::Qwen2,
|
|
1618
|
+
PretokenizerType::Qwen35,
|
|
1619
|
+
PretokenizerType::Olmo3,
|
|
1620
|
+
PretokenizerType::DeepSeekV3,
|
|
1621
|
+
PretokenizerType::O200k,
|
|
1622
|
+
PretokenizerType::Nemotron,
|
|
1623
|
+
PretokenizerType::Kimi,
|
|
1624
|
+
];
|
|
1625
|
+
let input = "Hello, 世界! café 12345\r\ncan't stop".as_bytes();
|
|
1626
|
+
|
|
1627
|
+
for scheme in schemes {
|
|
1628
|
+
let make_tokenizer = || {
|
|
1629
|
+
let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
|
|
1630
|
+
let vocab = (0..=u8::MAX).map(|byte| vec![byte]).collect();
|
|
1631
|
+
Tokenizer::new(merges, vocab, None)
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
let mut reference = make_tokenizer();
|
|
1635
|
+
let mut expected = Vec::new();
|
|
1636
|
+
reference.memoized_encode(scheme.pretokenize(input), |tokens| {
|
|
1637
|
+
expected.extend_from_slice(tokens);
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
let mut concrete = make_tokenizer();
|
|
1641
|
+
concrete.set_pretokenizer_type(scheme);
|
|
1642
|
+
let mut actual = Vec::new();
|
|
1643
|
+
concrete.encode_with_added_tokens(input, |tokens| {
|
|
1644
|
+
actual.extend_from_slice(tokens);
|
|
1645
|
+
});
|
|
1646
|
+
assert_eq!(actual, expected, "dispatch differs for {scheme:?}");
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
#[test]
|
|
1651
|
+
fn test_merges_from_vocab() {
|
|
1652
|
+
use base64::prelude::*;
|
|
1653
|
+
let mut buf = String::new();
|
|
1654
|
+
let data_dir = std::env::home_dir().unwrap().join("data");
|
|
1655
|
+
let tiktoken_path = data_dir.join("tokenizers/r50k_base.tiktoken");
|
|
1656
|
+
std::fs::File::open(tiktoken_path)
|
|
1657
|
+
.expect("Didn't find file")
|
|
1658
|
+
.read_to_string(&mut buf)
|
|
1659
|
+
.unwrap();
|
|
1660
|
+
let vocab: Vec<Vec<u8>> = buf
|
|
1661
|
+
.lines()
|
|
1662
|
+
.enumerate()
|
|
1663
|
+
.map(|(i, line)| {
|
|
1664
|
+
let (base64_token, id_str) = line.split_once(' ').unwrap();
|
|
1665
|
+
let id = id_str.trim().parse::<u32>().unwrap();
|
|
1666
|
+
assert!(id == i as u32);
|
|
1667
|
+
|
|
1668
|
+
BASE64_STANDARD.decode(base64_token).unwrap()
|
|
1669
|
+
})
|
|
1670
|
+
.collect();
|
|
1671
|
+
for (i, token) in vocab.iter().enumerate().skip(256).take(20) {
|
|
1672
|
+
eprintln!("{i}: {:?}", String::from_utf8_lossy(token));
|
|
1673
|
+
}
|
|
1674
|
+
let tokenizer = Tokenizer::from_ranks(vocab).unwrap();
|
|
1675
|
+
|
|
1676
|
+
let merges_inv = tokenizer
|
|
1677
|
+
.merges
|
|
1678
|
+
.iter()
|
|
1679
|
+
.map(|((a, b), c)| (*c, (*a, *b)))
|
|
1680
|
+
.collect::<HashMap<TokenId, (TokenId, TokenId)>>();
|
|
1681
|
+
|
|
1682
|
+
let decode_token = |token_id: TokenId| -> String {
|
|
1683
|
+
String::from_utf8_lossy(&tokenizer.vocab[token_id.0 as usize]).into_owned()
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
eprintln!("Merges:");
|
|
1687
|
+
for i in 256..=300 {
|
|
1688
|
+
let (a, b) = *merges_inv.get(&i.into()).unwrap();
|
|
1689
|
+
eprintln!(
|
|
1690
|
+
"Merge {i}: \"{}\" + \"{}\" -> \"{}\"",
|
|
1691
|
+
decode_token(a),
|
|
1692
|
+
decode_token(b),
|
|
1693
|
+
decode_token(i.into()),
|
|
1694
|
+
)
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
#[test]
|
|
1699
|
+
fn basic_tokenization() {
|
|
1700
|
+
let text = "This is a test string. Please tokenize it!";
|
|
1701
|
+
let data_dir = std::env::home_dir().unwrap().join("data");
|
|
1702
|
+
let tiktoken_path = data_dir.join("tokenizers/r50k_base.tiktoken");
|
|
1703
|
+
let mut tokenizer = load_tiktoken(tiktoken_path).expect("Failed to load tokenizer");
|
|
1704
|
+
let pretokenize_iter = crate::pretokenize::pretokenize_as_iter(text.as_bytes());
|
|
1705
|
+
let mut output = vec![];
|
|
1706
|
+
tokenizer.memoized_encode(pretokenize_iter, |tokens| {
|
|
1707
|
+
output.extend_from_slice(tokens);
|
|
1708
|
+
});
|
|
1709
|
+
assert!(tokenizer.byte_remapping.is_some());
|
|
1710
|
+
println!("Encoded: {:?}", output);
|
|
1711
|
+
let decoded = tokenizer.decode(&output).collect::<Vec<u8>>();
|
|
1712
|
+
println!("Decoded: {:?}", String::from_utf8_lossy(&decoded));
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/// Heavy correctness differentials for the optimized encode pipeline
|
|
1717
|
+
/// (from the opt/verify-heavy campaign branch). The OWT-scale tests
|
|
1718
|
+
/// (`#[ignore]`d; each doc comment states corpus size, runtime ballpark,
|
|
1719
|
+
/// and the cargo command) check the CACHED paths (`memoized_encode` /
|
|
1720
|
+
/// `encode_with_added_tokens_flat`: packed keys, open-addressing short
|
|
1721
|
+
/// table, two-phase span walkers, branchless emit) against an UNCACHED
|
|
1722
|
+
/// reference (per-pretoken plain `bpe_merge_symbols` over the merges
|
|
1723
|
+
/// HashMap). Two fast tests probe `pack_pretoken_key` at every page
|
|
1724
|
+
/// offset and pin the vocab-seed merge-decomposition rule. Boundary fuzz
|
|
1725
|
+
/// and walker edge cases live in `walker_edge` below.
|
|
1726
|
+
#[cfg(test)]
|
|
1727
|
+
mod verify_heavy {
|
|
1728
|
+
use super::test_util::{XorShift64, gpt2_path, plain_encode_pretoken};
|
|
1729
|
+
use super::*;
|
|
1730
|
+
use crate::load_tokenizer::hf::load_hf_bpe;
|
|
1731
|
+
use std::io::Read;
|
|
1732
|
+
|
|
1733
|
+
fn load_owt(max_bytes: usize) -> Vec<u8> {
|
|
1734
|
+
let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
|
|
1735
|
+
let f = std::fs::File::open(&path).expect("open ~/data/owt_train.txt");
|
|
1736
|
+
let mut input = Vec::new();
|
|
1737
|
+
f.take(max_bytes as u64).read_to_end(&mut input).unwrap();
|
|
1738
|
+
while !input.is_empty() && std::str::from_utf8(&input).is_err() {
|
|
1739
|
+
input.pop();
|
|
1740
|
+
}
|
|
1741
|
+
input
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/// Cut `input` at the first newline at or after `at` (whole input if none).
|
|
1745
|
+
fn cut_at_newline(input: &[u8], at: usize) -> &[u8] {
|
|
1746
|
+
let at = at.min(input.len());
|
|
1747
|
+
match memchr::memchr(b'\n', &input[at..]) {
|
|
1748
|
+
Some(off) => &input[..at + off + 1],
|
|
1749
|
+
None => input,
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/// Token-for-token comparison of the full cached public path
|
|
1754
|
+
/// (`encode_with_added_tokens_flat`) against a per-pretoken plain-merge
|
|
1755
|
+
/// walk of the same piece stream. The piece walk itself
|
|
1756
|
+
/// (`for_each_piece`) is shared with production — its added-token split
|
|
1757
|
+
/// is covered independently by `join_differential`; what this checks is
|
|
1758
|
+
/// the cache/probe/emit machinery against uncached plain merges.
|
|
1759
|
+
/// Panics with byte offset, pretoken bytes, and both id streams on the
|
|
1760
|
+
/// first divergence.
|
|
1761
|
+
fn compare_cached_vs_reference(tok: &mut Tokenizer, input: &[u8], label: &str, verbose: bool) {
|
|
1762
|
+
let mut cached: Vec<u32> = Vec::new();
|
|
1763
|
+
tok.encode_with_added_tokens_flat(input, &mut cached);
|
|
1764
|
+
|
|
1765
|
+
let mut idx = 0usize;
|
|
1766
|
+
let mut scratch: Vec<u32> = Vec::new();
|
|
1767
|
+
tok.for_each_piece(input, |this, piece| match piece {
|
|
1768
|
+
Piece::Segment(segment, pos) => {
|
|
1769
|
+
let mut seg_off = 0usize;
|
|
1770
|
+
for pretoken in this.pretokenizer_type.pretokenize(segment) {
|
|
1771
|
+
scratch.clear();
|
|
1772
|
+
plain_encode_pretoken(this, pretoken.0, &mut scratch);
|
|
1773
|
+
let got = cached.get(idx..idx + scratch.len());
|
|
1774
|
+
if got != Some(&scratch[..]) {
|
|
1775
|
+
// Approximate: NFC or a prepended prefix space can
|
|
1776
|
+
// shift lengths vs the raw input.
|
|
1777
|
+
let byte_off = pos + seg_off;
|
|
1778
|
+
let ctx_start = byte_off.saturating_sub(40).min(input.len());
|
|
1779
|
+
let ctx_end = (byte_off + pretoken.0.len() + 40).min(input.len());
|
|
1780
|
+
panic!(
|
|
1781
|
+
"{label}: encode mismatch at byte offset ~{byte_off} (input len {}), token index {idx}\n \
|
|
1782
|
+
pretoken ({} bytes): {:?}\n expected ids: {:?}\n cached ids: {:?}\n context: {:?}",
|
|
1783
|
+
input.len(),
|
|
1784
|
+
pretoken.0.len(),
|
|
1785
|
+
String::from_utf8_lossy(pretoken.0),
|
|
1786
|
+
scratch,
|
|
1787
|
+
&cached[idx.min(cached.len())..(idx + scratch.len() + 4).min(cached.len())],
|
|
1788
|
+
String::from_utf8_lossy(&input[ctx_start..ctx_end]),
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
idx += scratch.len();
|
|
1792
|
+
seg_off += pretoken.0.len();
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
Piece::Added(id) => {
|
|
1796
|
+
assert_eq!(
|
|
1797
|
+
cached.get(idx).copied(),
|
|
1798
|
+
Some(id.0),
|
|
1799
|
+
"{label}: added-token id mismatch at token index {idx}"
|
|
1800
|
+
);
|
|
1801
|
+
idx += 1;
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
assert_eq!(
|
|
1805
|
+
idx,
|
|
1806
|
+
cached.len(),
|
|
1807
|
+
"{label}: cached stream has {} extra trailing tokens",
|
|
1808
|
+
cached.len() - idx
|
|
1809
|
+
);
|
|
1810
|
+
if verbose {
|
|
1811
|
+
eprintln!(
|
|
1812
|
+
"{label}: all {idx} tokens match on {:.1} MB",
|
|
1813
|
+
input.len() as f64 / 1e6
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/// Independent added-token differential: join ~1 MB corpus pieces with
|
|
1819
|
+
/// the tokenizer's first added token and check the public encode equals
|
|
1820
|
+
/// concat(plain-encode(piece), sep_id, ...) — the expected stream is
|
|
1821
|
+
/// built WITHOUT `find_added_token`, so the Aho-Corasick split itself
|
|
1822
|
+
/// is under test, not just mirrored.
|
|
1823
|
+
fn join_differential(tok: &mut Tokenizer, corpus: &[u8], label: &str) {
|
|
1824
|
+
let Some((sep, sep_id)) = tok.added_tokens.first().map(|t| (t.content.to_vec(), t.id)) else {
|
|
1825
|
+
eprintln!("{label}: no added tokens registered; skipping join differential");
|
|
1826
|
+
return;
|
|
1827
|
+
};
|
|
1828
|
+
// OWT embeds document separators (e.g. <|endoftext|>) throughout;
|
|
1829
|
+
// mask every added-token occurrence so pieces are separator-free
|
|
1830
|
+
// and the expected stream can be built without find_added_token.
|
|
1831
|
+
let mut corpus: Vec<u8> = corpus.to_vec();
|
|
1832
|
+
for t in tok.added_tokens.clone() {
|
|
1833
|
+
let content = &t.content;
|
|
1834
|
+
let hits: Vec<usize> = memchr::memmem::find_iter(&corpus, &content[..]).collect();
|
|
1835
|
+
for pos in hits {
|
|
1836
|
+
corpus[pos] = b'~';
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
let corpus = &corpus[..];
|
|
1840
|
+
let mut expected: Vec<u32> = Vec::new();
|
|
1841
|
+
let mut joined: Vec<u8> = Vec::new();
|
|
1842
|
+
let mut nfc_buf = String::new();
|
|
1843
|
+
let mut start = 0usize;
|
|
1844
|
+
let mut pieces = 0usize;
|
|
1845
|
+
while start < corpus.len() {
|
|
1846
|
+
let target = (start + (1 << 20)).min(corpus.len());
|
|
1847
|
+
let end = match memchr::memchr(b'\n', &corpus[target..]) {
|
|
1848
|
+
Some(off) => target + off + 1,
|
|
1849
|
+
None => corpus.len(),
|
|
1850
|
+
};
|
|
1851
|
+
let piece = &corpus[start..end];
|
|
1852
|
+
start = end;
|
|
1853
|
+
// Masking is single-byte, so no new occurrence can appear; but
|
|
1854
|
+
// keep the guard as a belt-and-braces skip.
|
|
1855
|
+
if tok
|
|
1856
|
+
.added_tokens
|
|
1857
|
+
.iter()
|
|
1858
|
+
.any(|t| memchr::memmem::find(piece, &t.content).is_some())
|
|
1859
|
+
{
|
|
1860
|
+
continue;
|
|
1861
|
+
}
|
|
1862
|
+
joined.extend_from_slice(piece);
|
|
1863
|
+
joined.extend_from_slice(&sep);
|
|
1864
|
+
let seg = if tok.normalize_nfc {
|
|
1865
|
+
nfc_segment(piece, &mut nfc_buf)
|
|
1866
|
+
} else {
|
|
1867
|
+
piece
|
|
1868
|
+
};
|
|
1869
|
+
for pretoken in tok.pretokenizer_type.pretokenize(seg) {
|
|
1870
|
+
plain_encode_pretoken(tok, pretoken.0, &mut expected);
|
|
1871
|
+
}
|
|
1872
|
+
expected.push(sep_id.0);
|
|
1873
|
+
pieces += 1;
|
|
1874
|
+
}
|
|
1875
|
+
let mut cached: Vec<u32> = Vec::new();
|
|
1876
|
+
tok.encode_with_added_tokens_flat(&joined, &mut cached);
|
|
1877
|
+
if cached != expected {
|
|
1878
|
+
let i = expected
|
|
1879
|
+
.iter()
|
|
1880
|
+
.zip(&cached)
|
|
1881
|
+
.position(|(a, b)| a != b)
|
|
1882
|
+
.unwrap_or_else(|| expected.len().min(cached.len()));
|
|
1883
|
+
panic!(
|
|
1884
|
+
"{label}: join differential diverged at token index {i} \
|
|
1885
|
+
(expected len {}, cached len {}):\n expected[{i}..] = {:?}\n cached[{i}..] = {:?}",
|
|
1886
|
+
expected.len(),
|
|
1887
|
+
cached.len(),
|
|
1888
|
+
&expected[i..(i + 8).min(expected.len())],
|
|
1889
|
+
&cached[i..(i + 8).min(cached.len())],
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
assert!(pieces > 0, "{label}: join differential ran on zero pieces (vacuous)");
|
|
1893
|
+
eprintln!(
|
|
1894
|
+
"{label}: join differential ok — {pieces} pieces, {} tokens, sep {:?} id {}",
|
|
1895
|
+
cached.len(),
|
|
1896
|
+
String::from_utf8_lossy(&sep),
|
|
1897
|
+
sep_id.0
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/// Token-for-token differential of the cached callback encode path
|
|
1902
|
+
/// (`memoized_encode`: packed keys, open-addressing table, inline
|
|
1903
|
+
/// values, prefetch pipeline) against the uncached reference
|
|
1904
|
+
/// (plain BPE merge per pretoken) on 50 MB of OWT. Runs in a few
|
|
1905
|
+
/// seconds in release mode.
|
|
1906
|
+
/// `cargo test --release verify_memoized_encode_matches_reference_owt_50m -- --ignored --nocapture`
|
|
1907
|
+
#[test]
|
|
1908
|
+
#[ignore = "reads 50 MB of OWT; run explicitly in release mode"]
|
|
1909
|
+
fn verify_memoized_encode_matches_reference_owt_50m() {
|
|
1910
|
+
let mut tokenizer = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
1911
|
+
let all = load_owt(50_000_000);
|
|
1912
|
+
let input = &all[..];
|
|
1913
|
+
|
|
1914
|
+
let mut cached: Vec<TokenId> = Vec::new();
|
|
1915
|
+
tokenizer
|
|
1916
|
+
.memoized_encode(crate::pretokenize::pretokenize_as_iter(input), |tokens| {
|
|
1917
|
+
cached.extend_from_slice(tokens)
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
// Uncached reference: remap bytes and run the plain merge loop.
|
|
1921
|
+
let encode_reference = |pretoken: Pretoken| -> Vec<TokenId> {
|
|
1922
|
+
let mut symbols: Vec<TokenId> = match tokenizer.byte_remapping.as_ref() {
|
|
1923
|
+
Some(br) => pretoken.iter().map(|&b| br.mapping[b as usize]).collect(),
|
|
1924
|
+
None => pretoken.iter().map(|&b| TokenId::from(b as u32)).collect(),
|
|
1925
|
+
};
|
|
1926
|
+
crate::bpe::bpe_merge_symbols(&tokenizer.merges, &mut symbols);
|
|
1927
|
+
symbols
|
|
1928
|
+
};
|
|
1929
|
+
let mut idx = 0usize;
|
|
1930
|
+
for (pi, pretoken) in crate::pretokenize::pretokenize_as_iter(input).enumerate() {
|
|
1931
|
+
let reference = encode_reference(pretoken);
|
|
1932
|
+
assert!(
|
|
1933
|
+
cached[idx..(idx + reference.len()).min(cached.len())] == reference[..],
|
|
1934
|
+
"pretoken {pi} ({:?}) diverged: cached {:?} vs reference {:?}",
|
|
1935
|
+
String::from_utf8_lossy(pretoken.0),
|
|
1936
|
+
&cached[idx..(idx + reference.len()).min(cached.len())],
|
|
1937
|
+
reference,
|
|
1938
|
+
);
|
|
1939
|
+
idx += reference.len();
|
|
1940
|
+
}
|
|
1941
|
+
assert_eq!(idx, cached.len(), "cached encode produced extra tokens");
|
|
1942
|
+
eprintln!("all {idx} tokens match on {} MB", input.len() / 1_000_000);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/// 1 GB of OWT through the public GPT-2 encode path
|
|
1946
|
+
/// (`encode_with_added_tokens_flat`) vs the uncached reference, plus a
|
|
1947
|
+
/// 100 MB added-token join differential. About half a minute in
|
|
1948
|
+
/// release mode (the uncached reference dominates).
|
|
1949
|
+
/// `cargo test --release verify_gpt2_public_encode_matches_reference_owt_1g -- --ignored --nocapture`
|
|
1950
|
+
#[test]
|
|
1951
|
+
#[ignore = "reads 1 GB of OWT; run explicitly in release mode"]
|
|
1952
|
+
fn verify_gpt2_public_encode_matches_reference_owt_1g() {
|
|
1953
|
+
let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
1954
|
+
let input = load_owt(1_000_000_000);
|
|
1955
|
+
assert!(input.len() > 900_000_000, "corpus too small: {}", input.len());
|
|
1956
|
+
compare_cached_vs_reference(&mut tok, &input, "gpt2-raw-1g", true);
|
|
1957
|
+
let mut tok2 = load_hf_bpe(gpt2_path()).unwrap();
|
|
1958
|
+
join_differential(&mut tok2, cut_at_newline(&input, 100_000_000), "gpt2-join-100m");
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/// ~200 MB of OWT through the public encode path of every non-GPT2
|
|
1962
|
+
/// tokenizer whose tokenizer.json loads (olmo3, qwen2, qwen3_5,
|
|
1963
|
+
/// deepseek_v3), vs the uncached reference; plus a 25 MB join
|
|
1964
|
+
/// differential each. About half a minute in release mode.
|
|
1965
|
+
/// `cargo test --release verify_multi_public_encode_matches_reference_owt_200m -- --ignored --nocapture`
|
|
1966
|
+
#[test]
|
|
1967
|
+
#[ignore = "reads 200 MB of OWT per tokenizer; run explicitly in release mode"]
|
|
1968
|
+
fn verify_multi_public_encode_matches_reference_owt_200m() {
|
|
1969
|
+
let input = load_owt(200_000_000);
|
|
1970
|
+
assert!(input.len() > 190_000_000, "corpus too small: {}", input.len());
|
|
1971
|
+
let mut ran = 0usize;
|
|
1972
|
+
// qwen3_5 is the load-bearing tokenizer here: its vocab has ~200
|
|
1973
|
+
// merge-unreachable entries (CJK phrases, " Jap\u{f3}n", …) that
|
|
1974
|
+
// the raw-ID vocab seed used to return as single tokens, diverging
|
|
1975
|
+
// from HF (see verify_vocab_seeded_cache_matches_merge_decomposition);
|
|
1976
|
+
// the seed now stores merge decompositions. Kept last so the clean
|
|
1977
|
+
// tokenizers report first on a regression.
|
|
1978
|
+
for (name, repo_id) in [
|
|
1979
|
+
("olmo3", "allenai/Olmo-3-1025-7B"),
|
|
1980
|
+
("qwen2", "Qwen/Qwen2-1.5B-Instruct"),
|
|
1981
|
+
("deepseek_v3", "deepseek-ai/DeepSeek-V3"),
|
|
1982
|
+
("qwen3_5", "Qwen/Qwen3.5-9B"),
|
|
1983
|
+
] {
|
|
1984
|
+
let Some(path) = crate::test_hub::hf_tokenizer_json(repo_id) else {
|
|
1985
|
+
eprintln!("{name}: {repo_id} tokenizer.json not in the HF cache; skipping");
|
|
1986
|
+
continue;
|
|
1987
|
+
};
|
|
1988
|
+
let mut tok = match load_hf_bpe(&path) {
|
|
1989
|
+
Ok(t) => t,
|
|
1990
|
+
Err(e) => {
|
|
1991
|
+
eprintln!("{name}: failed to load ({e}); skipping");
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
eprintln!(
|
|
1996
|
+
"{name}: scheme {:?}, nfc {}, {} added tokens, vocab {}",
|
|
1997
|
+
tok.pretokenizer_type,
|
|
1998
|
+
tok.normalize_nfc,
|
|
1999
|
+
tok.added_tokens.len(),
|
|
2000
|
+
tok.vocab_size()
|
|
2001
|
+
);
|
|
2002
|
+
compare_cached_vs_reference(&mut tok, &input, name, true);
|
|
2003
|
+
let mut tok2 = load_hf_bpe(&path).unwrap();
|
|
2004
|
+
join_differential(&mut tok2, cut_at_newline(&input, 25_000_000), name);
|
|
2005
|
+
ran += 1;
|
|
2006
|
+
}
|
|
2007
|
+
assert!(ran >= 3, "only {ran} tokenizers loaded — expected at least olmo3/qwen2/deepseek_v3");
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/// Regression test for the vocab-seeded cache (commit d39bca2 originally
|
|
2011
|
+
/// seeded EVERY short vocab entry as pretoken -> [own id]): BPE encode
|
|
2012
|
+
/// semantics (HF `tokenizers` without `ignore_merges`, this repo's merge
|
|
2013
|
+
/// loop, and the pre-cache baseline 0e27c71) produce a whole-vocab-word
|
|
2014
|
+
/// token only when the merge rules can derive it. qwen3_5's vocab has
|
|
2015
|
+
/// ~200 merge-unreachable entries (mostly multi-char CJK phrases, plus
|
|
2016
|
+
/// e.g. " Jap\u{f3}n"); when one appears as a whole pretoken the pipeline
|
|
2017
|
+
/// must return the merge decomposition, exactly as if it had missed —
|
|
2018
|
+
/// the seed is precomputed misses (`merge_short`), never the raw ID.
|
|
2019
|
+
/// Ground truth verified against HF `tokenizers`: encode(" Jap\u{f3}n")
|
|
2020
|
+
/// on Qwen/Qwen3.5-9B's tokenizer.json gives [604, 385, 3064] ("ĠJ",
|
|
2021
|
+
/// "ap", "ón"); the raw-ID seed returned [209344] (" Jap\u{f3}n").
|
|
2022
|
+
#[test]
|
|
2023
|
+
fn verify_vocab_seeded_cache_matches_merge_decomposition() {
|
|
2024
|
+
let Some(path) = crate::test_hub::hf_tokenizer_json("Qwen/Qwen3.5-9B") else {
|
|
2025
|
+
eprintln!("Skipping: Qwen/Qwen3.5-9B tokenizer.json not in the HF cache");
|
|
2026
|
+
return;
|
|
2027
|
+
};
|
|
2028
|
+
let mut tok = load_hf_bpe(&path).expect("load qwen3_5");
|
|
2029
|
+
let pretoken: &[u8] = " Jap\u{f3}n".as_bytes();
|
|
2030
|
+
|
|
2031
|
+
let mut cached: Vec<u32> = Vec::new();
|
|
2032
|
+
tok.encode_with_added_tokens_flat(pretoken, &mut cached);
|
|
2033
|
+
// HF `tokenizers` ground truth for this tokenizer.json.
|
|
2034
|
+
assert_eq!(
|
|
2035
|
+
cached,
|
|
2036
|
+
vec![604, 385, 3064],
|
|
2037
|
+
"seeded cache returned the merge-unreachable whole-vocab entry"
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
// Same rule on the long (> 15 byte) miss path, which used to take
|
|
2041
|
+
// a whole-pretoken vocab_inv shortcut: a merge-unreachable long
|
|
2042
|
+
// vocab entry must also encode as its decomposition. qwen3_5 id
|
|
2043
|
+
// 107517 is a 30-byte CJK phrase HF splits in 3.
|
|
2044
|
+
let long_entry = tok
|
|
2045
|
+
.vocab_entries()
|
|
2046
|
+
.find(|&(id, _)| id == 107517)
|
|
2047
|
+
.map(|(_, b)| b.to_vec())
|
|
2048
|
+
.expect("qwen3_5 vocab entry 107517");
|
|
2049
|
+
assert!(long_entry.len() > 15, "expected a long entry");
|
|
2050
|
+
let mut cached_long: Vec<u32> = Vec::new();
|
|
2051
|
+
tok.encode_with_added_tokens_flat(&long_entry, &mut cached_long);
|
|
2052
|
+
assert_ne!(
|
|
2053
|
+
cached_long,
|
|
2054
|
+
vec![107517],
|
|
2055
|
+
"long miss path returned the merge-unreachable whole-vocab entry"
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/// `pack_pretoken_key`'s unaligned-16-byte fast path vs the naive lane
|
|
2060
|
+
/// copy at EVERY page offset (both branches of the page-boundary guard),
|
|
2061
|
+
/// all lengths 0..=15.
|
|
2062
|
+
#[test]
|
|
2063
|
+
fn verify_pack_pretoken_key_all_page_offsets() {
|
|
2064
|
+
use crate::pretokenize::pack_pretoken_key;
|
|
2065
|
+
let mut buf = vec![0u8; 12288];
|
|
2066
|
+
let mut rng = XorShift64(0x0123_4567_89AB_CDEF);
|
|
2067
|
+
for b in buf.iter_mut() {
|
|
2068
|
+
*b = rng.next_u64() as u8;
|
|
2069
|
+
if *b == 0 {
|
|
2070
|
+
*b = 1; // avoid zero lanes masking length-tag mistakes
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
for start in 0..buf.len() - 16 {
|
|
2074
|
+
for n in 0..=15usize {
|
|
2075
|
+
let span = &buf[start..start + n];
|
|
2076
|
+
let key = pack_pretoken_key(span);
|
|
2077
|
+
let mut lanes = [0u8; 16];
|
|
2078
|
+
lanes[..n].copy_from_slice(span);
|
|
2079
|
+
let naive = if n == 0 {
|
|
2080
|
+
0u128
|
|
2081
|
+
} else {
|
|
2082
|
+
u128::from_le_bytes(lanes) | ((n as u128) << 120)
|
|
2083
|
+
};
|
|
2084
|
+
assert_eq!(
|
|
2085
|
+
key,
|
|
2086
|
+
Some(naive),
|
|
2087
|
+
"pack_pretoken_key mismatch at buf offset {start} (page offset {}), len {n}",
|
|
2088
|
+
(buf[start..].as_ptr() as usize) & 4095
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
// Length > 15 routes to the long map.
|
|
2093
|
+
assert_eq!(pack_pretoken_key(&buf[..16]), None);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/// Alignment-invariance sweep: the walkers' output must not depend on the
|
|
2098
|
+
/// span's heap address. (The full-suite flake of the edge-length test —
|
|
2099
|
+
/// now `walker_edge::walker_edge_length_pretokens` — motivated this: same bytes,
|
|
2100
|
+
/// different run -> different tokens can only come from address-dependent
|
|
2101
|
+
/// framing in the SIMD batch walkers.)
|
|
2102
|
+
#[cfg(test)]
|
|
2103
|
+
mod verify_alignment {
|
|
2104
|
+
use super::test_util::{XorShift64, gpt2_path, two_phase_lens};
|
|
2105
|
+
use super::*;
|
|
2106
|
+
use crate::load_tokenizer::hf::load_hf_bpe;
|
|
2107
|
+
|
|
2108
|
+
#[test]
|
|
2109
|
+
fn verify_walker_alignment_invariance() {
|
|
2110
|
+
let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
2111
|
+
// Inputs chosen to stress batch-edge machinery: long runs of one
|
|
2112
|
+
// class, class flips near multiples of 64, multi-byte chars
|
|
2113
|
+
// straddling batch edges, contractions, digit groups.
|
|
2114
|
+
let mut inputs: Vec<Vec<u8>> = Vec::new();
|
|
2115
|
+
for n in [63usize, 64, 65, 127, 128, 129, 255, 256, 300, 4096] {
|
|
2116
|
+
for fill in [&b"a"[..], b"5", b" ", b"!", b"\n", "\u{e9}".as_bytes(), "\u{597d}".as_bytes()] {
|
|
2117
|
+
let mut v = Vec::new();
|
|
2118
|
+
while v.len() < n {
|
|
2119
|
+
v.extend_from_slice(fill);
|
|
2120
|
+
}
|
|
2121
|
+
inputs.push(v);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
let mut rng = XorShift64(0x9E37_79B9_7F4A_7C15);
|
|
2125
|
+
const PIECES: &[&str] = &[
|
|
2126
|
+
" the", " a", "word", "05", " ", "\n", "'s", "n't", ",", " \u{e9}t\u{e9}",
|
|
2127
|
+
"\u{597d}\u{597d}", " 123", "...", "\t", " I'm", "\u{2014}", "e", " ",
|
|
2128
|
+
];
|
|
2129
|
+
for _ in 0..200 {
|
|
2130
|
+
let target = 80 + (rng.next_u64() % 400) as usize;
|
|
2131
|
+
let mut v = Vec::new();
|
|
2132
|
+
while v.len() < target {
|
|
2133
|
+
v.extend_from_slice(
|
|
2134
|
+
PIECES[(rng.next_u64() % PIECES.len() as u64) as usize].as_bytes(),
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
inputs.push(v);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
for (which, input) in inputs.iter().enumerate() {
|
|
2141
|
+
// Copy the same bytes at every offset 0..64 of a fresh buffer;
|
|
2142
|
+
// walker output must be identical for all of them.
|
|
2143
|
+
let mut ref_lens: Option<Vec<usize>> = None;
|
|
2144
|
+
let mut ref_ids: Option<Vec<u32>> = None;
|
|
2145
|
+
for off in 0..64usize {
|
|
2146
|
+
let mut buf = vec![0u8; off + input.len() + 64];
|
|
2147
|
+
buf[off..off + input.len()].copy_from_slice(input);
|
|
2148
|
+
let span = &buf[off..off + input.len()];
|
|
2149
|
+
let lens = two_phase_lens(FastR50kPretokenizer::new(span));
|
|
2150
|
+
let mut ids: Vec<u32> = Vec::new();
|
|
2151
|
+
tok.memoized_encode_flat(FastR50kPretokenizer::new(span), &mut ids);
|
|
2152
|
+
match (&ref_lens, &ref_ids) {
|
|
2153
|
+
(None, _) => {
|
|
2154
|
+
ref_lens = Some(lens);
|
|
2155
|
+
ref_ids = Some(ids);
|
|
2156
|
+
}
|
|
2157
|
+
(Some(rl), Some(ri)) => {
|
|
2158
|
+
assert!(
|
|
2159
|
+
&lens == rl,
|
|
2160
|
+
"input {which}: pretoken lens differ at offset {off}\n base: {rl:?}\n off{off}: {lens:?}\n input: {:?}",
|
|
2161
|
+
String::from_utf8_lossy(input)
|
|
2162
|
+
);
|
|
2163
|
+
assert!(
|
|
2164
|
+
&ids == ri,
|
|
2165
|
+
"input {which}: token ids differ at offset {off} on {:?}",
|
|
2166
|
+
String::from_utf8_lossy(input)
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
_ => unreachable!(),
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
eprintln!("alignment invariance: {} inputs x 64 offsets ok", inputs.len());
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/// Walker edge-condition tests: truncated multi-byte UTF-8 at the buffer
|
|
2178
|
+
/// end (Bug A: `decode_cp` used to read up to 3 bytes past the slice and
|
|
2179
|
+
/// return a pretoken end past `len`) and invalid-UTF-8 garbage codepoints
|
|
2180
|
+
/// (Bug B: 0xF5..=0xFF leads decoded to cp > 0x10FFFF, and `class_of`'s
|
|
2181
|
+
/// unchecked table load then read heap memory past the class table —
|
|
2182
|
+
/// nondeterministic under concurrent allocation, causing the Iterator and
|
|
2183
|
+
/// two-phase paths to split >65 KB invalid pretokens differently).
|
|
2184
|
+
/// Correctness bar: every scheme partitions ARBITRARY bytes contiguously,
|
|
2185
|
+
/// in bounds, identically on the Iterator (`next_span`) and two-phase
|
|
2186
|
+
/// (`fill_spans_keyed`) paths, deterministically.
|
|
2187
|
+
#[cfg(test)]
|
|
2188
|
+
mod walker_edge {
|
|
2189
|
+
use super::test_util::{XorShift64, gpt2_path, plain_encode_pretoken, two_phase_lens};
|
|
2190
|
+
use super::*;
|
|
2191
|
+
use crate::load_tokenizer::hf::load_hf_bpe;
|
|
2192
|
+
|
|
2193
|
+
/// Assert a scheme's pretokens are a contiguous, non-empty, in-bounds
|
|
2194
|
+
/// partition of `span` (required for encode correctness; also catches
|
|
2195
|
+
/// walkers running past the buffer on truncated UTF-8).
|
|
2196
|
+
fn check_partition<'a>(
|
|
2197
|
+
span: &'a [u8],
|
|
2198
|
+
it: impl Iterator<Item = Pretoken<'a>>,
|
|
2199
|
+
scheme: &str,
|
|
2200
|
+
) {
|
|
2201
|
+
let mut off = 0usize;
|
|
2202
|
+
for p in it {
|
|
2203
|
+
assert!(
|
|
2204
|
+
std::ptr::eq(p.0.as_ptr(), span[off..].as_ptr()),
|
|
2205
|
+
"{scheme}: non-contiguous pretoken at byte {off} of {:?}",
|
|
2206
|
+
String::from_utf8_lossy(span)
|
|
2207
|
+
);
|
|
2208
|
+
assert!(!p.0.is_empty(), "{scheme}: empty pretoken at byte {off}");
|
|
2209
|
+
off += p.0.len();
|
|
2210
|
+
assert!(
|
|
2211
|
+
off <= span.len(),
|
|
2212
|
+
"{scheme}: pretoken overruns span end ({off} > {}) on {:?}",
|
|
2213
|
+
span.len(),
|
|
2214
|
+
String::from_utf8_lossy(span)
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
assert_eq!(
|
|
2218
|
+
off,
|
|
2219
|
+
span.len(),
|
|
2220
|
+
"{scheme}: pretokens cover {off} of {} bytes of {:?}",
|
|
2221
|
+
span.len(),
|
|
2222
|
+
String::from_utf8_lossy(span)
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/// Partition check (Iterator path) plus cached encode (two-phase
|
|
2227
|
+
/// `fill_spans_keyed` path) vs the plain per-pretoken reference over
|
|
2228
|
+
/// the Iterator's pretokens — so the two walker paths are compared
|
|
2229
|
+
/// against each other on every span.
|
|
2230
|
+
fn check_scheme_encode<'a, P>(
|
|
2231
|
+
tok: &mut Tokenizer,
|
|
2232
|
+
span: &'a [u8],
|
|
2233
|
+
make: impl Fn(&'a [u8]) -> P,
|
|
2234
|
+
scheme: &str,
|
|
2235
|
+
) where
|
|
2236
|
+
P: PretokenSpans<'a>,
|
|
2237
|
+
P: Iterator<Item = Pretoken<'a>>,
|
|
2238
|
+
{
|
|
2239
|
+
check_partition(span, make(span), scheme);
|
|
2240
|
+
let mut got: Vec<u32> = Vec::new();
|
|
2241
|
+
tok.memoized_encode_flat(make(span), &mut got);
|
|
2242
|
+
let mut expected: Vec<u32> = Vec::new();
|
|
2243
|
+
for p in make(span) {
|
|
2244
|
+
plain_encode_pretoken(tok, p.0, &mut expected);
|
|
2245
|
+
}
|
|
2246
|
+
assert!(
|
|
2247
|
+
got == expected,
|
|
2248
|
+
"{scheme}: cached encode mismatch on {:?} (len {}):\n cached {:?}\n expected {:?}",
|
|
2249
|
+
String::from_utf8_lossy(span),
|
|
2250
|
+
span.len(),
|
|
2251
|
+
got,
|
|
2252
|
+
expected
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
fn check_all_schemes(tok: &mut Tokenizer, span: &[u8]) {
|
|
2257
|
+
check_scheme_encode(tok, span, FastR50kPretokenizer::new, "r50k");
|
|
2258
|
+
check_scheme_encode(tok, span, FastCl100kPretokenizer::new, "cl100k");
|
|
2259
|
+
check_scheme_encode(tok, span, FastQwen2Pretokenizer::new, "qwen2");
|
|
2260
|
+
check_scheme_encode(tok, span, FastQwen35Pretokenizer::new, "qwen3_5");
|
|
2261
|
+
check_scheme_encode(tok, span, FastOlmo3Pretokenizer::new, "olmo3");
|
|
2262
|
+
check_scheme_encode(tok, span, FastDeepSeekV3Pretokenizer::new, "deepseek_v3");
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/// Truncated multi-byte UTF-8 at the buffer end, every shape: for each
|
|
2266
|
+
/// lead-byte length (2/3/4) every truncation point (1..len-1 available
|
|
2267
|
+
/// continuation bytes missing), plus lone continuation bytes and
|
|
2268
|
+
/// invalid 0xF5..=0xFF leads, behind assorted prefixes that put the
|
|
2269
|
+
/// truncated char after a letter run / digit run / space / whitespace
|
|
2270
|
+
/// run / punctuation / another unicode char. Exactly-sized heap
|
|
2271
|
+
/// allocations so any walker overrun is an observable OOB.
|
|
2272
|
+
#[test]
|
|
2273
|
+
fn walker_truncated_utf8_tail() {
|
|
2274
|
+
let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
2275
|
+
// Leads: 2-byte (0xC3), 3-byte (0xE2, and 0xE0 low), 4-byte (0xF0,
|
|
2276
|
+
// 0xF4 high), invalid leads (0xF5, 0xF8, 0xFF), continuation (0x80,
|
|
2277
|
+
// 0xBF), and 0xC0/0xC1 (invalid 2-byte leads).
|
|
2278
|
+
let leads: &[&[u8]] = &[
|
|
2279
|
+
b"\xc3",
|
|
2280
|
+
b"\xe2",
|
|
2281
|
+
b"\xe2\x80",
|
|
2282
|
+
b"\xe0",
|
|
2283
|
+
b"\xe0\xa0",
|
|
2284
|
+
b"\xf0",
|
|
2285
|
+
b"\xf0\x9f",
|
|
2286
|
+
b"\xf0\x9f\x99",
|
|
2287
|
+
b"\xf4",
|
|
2288
|
+
b"\xf4\x8f",
|
|
2289
|
+
b"\xf4\x8f\xbf",
|
|
2290
|
+
b"\xf5",
|
|
2291
|
+
b"\xf8\x88",
|
|
2292
|
+
b"\xff",
|
|
2293
|
+
b"\xff\xff",
|
|
2294
|
+
b"\xff\xff\xff",
|
|
2295
|
+
b"\x80",
|
|
2296
|
+
b"\xbf",
|
|
2297
|
+
b"\xc0",
|
|
2298
|
+
b"\xc1",
|
|
2299
|
+
];
|
|
2300
|
+
let prefixes: &[&[u8]] = &[
|
|
2301
|
+
b"",
|
|
2302
|
+
b"a",
|
|
2303
|
+
b"hello",
|
|
2304
|
+
b"hello ",
|
|
2305
|
+
b"123",
|
|
2306
|
+
b" ",
|
|
2307
|
+
b" \n",
|
|
2308
|
+
b"!?",
|
|
2309
|
+
"é".as_bytes(),
|
|
2310
|
+
"好".as_bytes(),
|
|
2311
|
+
b"'s",
|
|
2312
|
+
b"\xff\xff\xff\xff", // complete invalid run before the tail
|
|
2313
|
+
];
|
|
2314
|
+
for &lead in leads {
|
|
2315
|
+
for &prefix in prefixes {
|
|
2316
|
+
let mut buf = Vec::with_capacity(prefix.len() + lead.len());
|
|
2317
|
+
buf.extend_from_slice(prefix);
|
|
2318
|
+
buf.extend_from_slice(lead);
|
|
2319
|
+
check_all_schemes(&mut tok, &buf);
|
|
2320
|
+
// Public path must not panic and must match the plain
|
|
2321
|
+
// reference over the Iterator's pretokens.
|
|
2322
|
+
let mut cached: Vec<u32> = Vec::new();
|
|
2323
|
+
tok.encode_with_added_tokens_flat(&buf, &mut cached);
|
|
2324
|
+
let mut expected: Vec<u32> = Vec::new();
|
|
2325
|
+
for p in FastR50kPretokenizer::new(&buf) {
|
|
2326
|
+
plain_encode_pretoken(&tok, p.0, &mut expected);
|
|
2327
|
+
}
|
|
2328
|
+
assert!(
|
|
2329
|
+
cached == expected,
|
|
2330
|
+
"public path mismatch on {:?}: cached {:?} expected {:?}",
|
|
2331
|
+
String::from_utf8_lossy(&buf),
|
|
2332
|
+
cached,
|
|
2333
|
+
expected
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/// Deterministic boundary fuzz: random spans (0-64 bytes; ASCII text,
|
|
2340
|
+
/// raw bytes incl. invalid UTF-8 and truncated tails, valid multi-byte
|
|
2341
|
+
/// UTF-8, whitespace runs) placed at the END of an exactly-sized
|
|
2342
|
+
/// allocation, through every scheme's walker (partition + cached-vs-
|
|
2343
|
+
/// plain encode) and the full public GPT-2 path. Fixed seed, no I/O
|
|
2344
|
+
/// beyond the tokenizer.
|
|
2345
|
+
#[test]
|
|
2346
|
+
fn walker_boundary_fuzz_memoized_vs_reference() {
|
|
2347
|
+
let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
2348
|
+
let mut rng = XorShift64(0x243F_6A88_85A3_08D3);
|
|
2349
|
+
const CHARS: &[&str] = &["é", "ü", "好", "日", "🙂", "ß", "—", "\u{0301}", "٣", "क"];
|
|
2350
|
+
let iters = if cfg!(debug_assertions) { 2_000 } else { 12_000 };
|
|
2351
|
+
for iter in 0..iters {
|
|
2352
|
+
let len = (rng.next_u64() % 65) as usize;
|
|
2353
|
+
let pad = (rng.next_u64() % 17) as usize;
|
|
2354
|
+
let mut buf: Vec<u8> = Vec::with_capacity(pad + len + 8);
|
|
2355
|
+
for _ in 0..pad {
|
|
2356
|
+
buf.push(rng.next_u64() as u8);
|
|
2357
|
+
}
|
|
2358
|
+
let span_start = buf.len();
|
|
2359
|
+
match rng.next_u64() % 4 {
|
|
2360
|
+
0 => {
|
|
2361
|
+
// ASCII text: letters, digits, spaces, punct, contractions
|
|
2362
|
+
const POOL: &[u8] = b" aetoAETO059'.,!-\n\t\"()s d";
|
|
2363
|
+
while buf.len() - span_start < len {
|
|
2364
|
+
buf.push(POOL[(rng.next_u64() % POOL.len() as u64) as usize]);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
1 => {
|
|
2368
|
+
// Raw bytes: full 0..=255, mostly invalid UTF-8,
|
|
2369
|
+
// truncated tails included.
|
|
2370
|
+
while buf.len() - span_start < len {
|
|
2371
|
+
buf.push(rng.next_u64() as u8);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
2 => {
|
|
2375
|
+
// Valid UTF-8 mix: fill to >= len, then trim whole
|
|
2376
|
+
// chars back to <= len so the span stays valid UTF-8.
|
|
2377
|
+
while buf.len() - span_start < len {
|
|
2378
|
+
if rng.next_u64().is_multiple_of(2) {
|
|
2379
|
+
buf.push(b' ' + (rng.next_u64() % 94) as u8);
|
|
2380
|
+
} else {
|
|
2381
|
+
buf.extend_from_slice(
|
|
2382
|
+
CHARS[(rng.next_u64() % CHARS.len() as u64) as usize].as_bytes(),
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
while buf.len() - span_start > len
|
|
2387
|
+
|| std::str::from_utf8(&buf[span_start..]).is_err()
|
|
2388
|
+
{
|
|
2389
|
+
buf.pop();
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
_ => {
|
|
2393
|
+
// Whitespace-heavy
|
|
2394
|
+
const POOL: &[u8] = b" \n\n\t\r a5.";
|
|
2395
|
+
while buf.len() - span_start < len {
|
|
2396
|
+
buf.push(POOL[(rng.next_u64() % POOL.len() as u64) as usize]);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
buf.truncate(span_start + len.min(buf.len() - span_start));
|
|
2401
|
+
let (head, span) = buf.split_at(span_start);
|
|
2402
|
+
let _ = head;
|
|
2403
|
+
check_all_schemes(&mut tok, span);
|
|
2404
|
+
// Full public path (added-token scan + NFC gate + flat emit).
|
|
2405
|
+
let mut cached: Vec<u32> = Vec::new();
|
|
2406
|
+
let mut expected: Vec<u32> = Vec::new();
|
|
2407
|
+
// GPT-2's only added token is <|endoftext|>, absent from these
|
|
2408
|
+
// spans (13-byte pattern, span <= 64 random bytes — and the
|
|
2409
|
+
// reference below would not model it).
|
|
2410
|
+
for p in FastR50kPretokenizer::new(span) {
|
|
2411
|
+
plain_encode_pretoken(&tok, p.0, &mut expected);
|
|
2412
|
+
}
|
|
2413
|
+
tok.encode_with_added_tokens_flat(span, &mut cached);
|
|
2414
|
+
assert!(
|
|
2415
|
+
cached == expected,
|
|
2416
|
+
"public path mismatch at iter {iter} on {:?}: cached {:?} expected {:?}",
|
|
2417
|
+
String::from_utf8_lossy(span),
|
|
2418
|
+
cached,
|
|
2419
|
+
expected
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
eprintln!("boundary fuzz: {iters} spans x 6 schemes ok");
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/// Pretokens at the exact edge lengths of the key-packing and cache
|
|
2426
|
+
/// machinery (15/16 for the packed u128 key, 65535/65536 and beyond
|
|
2427
|
+
/// for long runs through the walkers), in letter/digit/space/punct/
|
|
2428
|
+
/// multi-byte/invalid fills, each in an exactly-sized allocation.
|
|
2429
|
+
#[test]
|
|
2430
|
+
fn walker_edge_length_pretokens() {
|
|
2431
|
+
let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
|
|
2432
|
+
let lens: &[usize] = &[
|
|
2433
|
+
1, 2, 7, 8, 14, 15, 16, 17, 31, 32, 63, 64, 65, 127, 128, 255, 256, 4095, 4096,
|
|
2434
|
+
4097, 65_535, 65_536, 65_537, 70_003,
|
|
2435
|
+
];
|
|
2436
|
+
let fills: &[&[u8]] = &[
|
|
2437
|
+
b"a",
|
|
2438
|
+
b"5",
|
|
2439
|
+
b" ",
|
|
2440
|
+
b"!",
|
|
2441
|
+
b"\n",
|
|
2442
|
+
"\u{e9}".as_bytes(), // é (2-byte letter)
|
|
2443
|
+
"\u{597d}".as_bytes(), // 好 (3-byte letter)
|
|
2444
|
+
b"\xff", // invalid UTF-8
|
|
2445
|
+
];
|
|
2446
|
+
for &n in lens {
|
|
2447
|
+
for fill in fills {
|
|
2448
|
+
// Repeat fill to >= n bytes, then cut to n only for 1-byte
|
|
2449
|
+
// fills (multi-byte fills keep whole chars).
|
|
2450
|
+
let reps = n / fill.len() + usize::from(n % fill.len() != 0);
|
|
2451
|
+
if reps == 0 {
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
let exact = if fill.len() == 1 { n } else { reps * fill.len() };
|
|
2455
|
+
let mut buf: Vec<u8> = Vec::with_capacity(exact);
|
|
2456
|
+
while buf.len() < exact {
|
|
2457
|
+
buf.extend_from_slice(fill);
|
|
2458
|
+
}
|
|
2459
|
+
buf.truncate(exact);
|
|
2460
|
+
check_all_schemes(&mut tok, &buf);
|
|
2461
|
+
// Space-prefixed variant hits the space-fused starts.
|
|
2462
|
+
let mut buf2: Vec<u8> = Vec::with_capacity(exact + 1);
|
|
2463
|
+
buf2.push(b' ');
|
|
2464
|
+
buf2.extend_from_slice(&buf);
|
|
2465
|
+
check_all_schemes(&mut tok, &buf2);
|
|
2466
|
+
// 0xFF run with a letter tail: the exact shape of the
|
|
2467
|
+
// >65 KB nondeterminism (the last garbage 4-byte decode
|
|
2468
|
+
// straddles into the letters).
|
|
2469
|
+
if fill == b"\xff" && exact >= 4 {
|
|
2470
|
+
let mut buf3 = buf.clone();
|
|
2471
|
+
let e = buf3.len();
|
|
2472
|
+
buf3[e - 3..].copy_from_slice(b"cba");
|
|
2473
|
+
check_all_schemes(&mut tok, &buf3);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
eprintln!("edge-length pretokens ok");
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/// Bug B regression: the two spans that flaked (~1/25 full-suite runs)
|
|
2481
|
+
/// pre-fix — 65534 x 0xFF + "cba", bare and space-prefixed — walked
|
|
2482
|
+
/// repeatedly on both paths while background threads churn the heap.
|
|
2483
|
+
/// Pre-fix, `class_of` read past the class table for the garbage
|
|
2484
|
+
/// codepoints these 0xFF runs decode to, so the boundary near the run
|
|
2485
|
+
/// tail depended on whatever heap memory followed the table; the churn
|
|
2486
|
+
/// recreates the "concurrent test threads" condition deterministically
|
|
2487
|
+
/// enough that the old code fails this test within a few rounds.
|
|
2488
|
+
/// Post-fix every round must produce the identical partition on both
|
|
2489
|
+
/// paths.
|
|
2490
|
+
#[test]
|
|
2491
|
+
fn walker_ff_run_paths_agree_under_heap_churn() {
|
|
2492
|
+
// 16 spare bytes of capacity so the run is AddressSanitizer-clean:
|
|
2493
|
+
// `pack_pretoken_key`'s page-guarded 16-byte load may overread the
|
|
2494
|
+
// final short span within its page (by design — masked out, and
|
|
2495
|
+
// never crosses into an unmapped page), which ASAN's redzones
|
|
2496
|
+
// would otherwise flag on an exactly-sized allocation.
|
|
2497
|
+
// Exactly-sized allocations are covered by the other tests here.
|
|
2498
|
+
let mut a = Vec::with_capacity(65537 + 16);
|
|
2499
|
+
a.resize(65534, 0xFFu8);
|
|
2500
|
+
a.extend_from_slice(b"cba"); // len 65537
|
|
2501
|
+
let mut b = Vec::with_capacity(65538 + 16);
|
|
2502
|
+
b.push(b' ');
|
|
2503
|
+
b.extend_from_slice(&a); // len 65538
|
|
2504
|
+
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
2505
|
+
let churners: Vec<_> = (0..4)
|
|
2506
|
+
.map(|t| {
|
|
2507
|
+
let stop = stop.clone();
|
|
2508
|
+
std::thread::spawn(move || {
|
|
2509
|
+
let mut keep: Vec<Vec<u8>> = Vec::new();
|
|
2510
|
+
let mut i = 0usize;
|
|
2511
|
+
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
|
|
2512
|
+
// Vary size and content so the pages after any
|
|
2513
|
+
// fresh allocation keep changing.
|
|
2514
|
+
let sz = 1 << (12 + (i + t) % 8); // 4 KB .. 512 KB
|
|
2515
|
+
keep.push(vec![(i as u8) ^ 0x5A; sz]);
|
|
2516
|
+
if keep.len() > 8 {
|
|
2517
|
+
keep.clear();
|
|
2518
|
+
}
|
|
2519
|
+
i += 1;
|
|
2520
|
+
}
|
|
2521
|
+
})
|
|
2522
|
+
})
|
|
2523
|
+
.collect();
|
|
2524
|
+
let iter_lens = |span: &[u8]| -> Vec<usize> {
|
|
2525
|
+
FastR50kPretokenizer::new(span).map(|p| p.0.len()).collect()
|
|
2526
|
+
};
|
|
2527
|
+
let mut reference: Option<[Vec<usize>; 2]> = None;
|
|
2528
|
+
for round in 0..40 {
|
|
2529
|
+
let got = [
|
|
2530
|
+
{
|
|
2531
|
+
let (i, t) = (iter_lens(&a), two_phase_lens(FastR50kPretokenizer::new(&a)));
|
|
2532
|
+
assert_eq!(i, t, "round {round} span A: iterator vs two-phase");
|
|
2533
|
+
i
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
let (i, t) = (iter_lens(&b), two_phase_lens(FastR50kPretokenizer::new(&b)));
|
|
2537
|
+
assert_eq!(i, t, "round {round} span B: iterator vs two-phase");
|
|
2538
|
+
i
|
|
2539
|
+
},
|
|
2540
|
+
];
|
|
2541
|
+
match &reference {
|
|
2542
|
+
None => reference = Some(got),
|
|
2543
|
+
Some(r) => assert_eq!(
|
|
2544
|
+
r,
|
|
2545
|
+
&got,
|
|
2546
|
+
"round {round}: partition changed between rounds (nondeterminism)"
|
|
2547
|
+
),
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
2551
|
+
for c in churners {
|
|
2552
|
+
c.join().unwrap();
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|