gigatoken 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +3016 -0
  3. data/Cargo.toml +135 -0
  4. data/LICENSE +21 -0
  5. data/README.md +141 -0
  6. data/exe/gigatoken +9 -0
  7. data/ext/gigatoken/Cargo.toml +24 -0
  8. data/ext/gigatoken/extconf.rb +19 -0
  9. data/ext/gigatoken/src/error.rs +20 -0
  10. data/ext/gigatoken/src/gvl.rs +122 -0
  11. data/ext/gigatoken/src/lib.rs +50 -0
  12. data/ext/gigatoken/src/sentencepiece.rs +205 -0
  13. data/ext/gigatoken/src/sources.rs +207 -0
  14. data/ext/gigatoken/src/tokenizer.rs +571 -0
  15. data/lib/gigatoken/cli/bench.rb +64 -0
  16. data/lib/gigatoken/cli/support.rb +132 -0
  17. data/lib/gigatoken/cli/validate.rb +55 -0
  18. data/lib/gigatoken/cli.rb +18 -0
  19. data/lib/gigatoken/hub.rb +242 -0
  20. data/lib/gigatoken/packed_result.rb +48 -0
  21. data/lib/gigatoken/tokenizer.rb +117 -0
  22. data/lib/gigatoken/version.rb +5 -0
  23. data/lib/gigatoken.rb +29 -0
  24. data/rust-toolchain.toml +8 -0
  25. data/src/batch.rs +1808 -0
  26. data/src/bindings/bridge.rs +396 -0
  27. data/src/bindings/hub.rs +42 -0
  28. data/src/bindings/matcher.rs +114 -0
  29. data/src/bindings/mod.rs +14 -0
  30. data/src/bindings/padding.rs +177 -0
  31. data/src/bindings/pretokenize.rs +53 -0
  32. data/src/bindings/sources.rs +273 -0
  33. data/src/bindings/train.rs +125 -0
  34. data/src/bpe/mod.rs +1217 -0
  35. data/src/bpe/pretoken_cache.rs +495 -0
  36. data/src/bpe/sentencepiece.rs +1485 -0
  37. data/src/bpe/tiktoken.rs +2555 -0
  38. data/src/bpe_train.rs +351 -0
  39. data/src/input/decompress.rs +11 -0
  40. data/src/input/file_source.rs +514 -0
  41. data/src/input/jsonl.rs +94 -0
  42. data/src/input/mod.rs +333 -0
  43. data/src/input/parquet.rs +303 -0
  44. data/src/lib.rs +578 -0
  45. data/src/load_tokenizer/hf.rs +1036 -0
  46. data/src/load_tokenizer/hub.rs +344 -0
  47. data/src/load_tokenizer/mod.rs +3 -0
  48. data/src/load_tokenizer/tiktoken.rs +87 -0
  49. data/src/main.rs +95 -0
  50. data/src/pretokenize/fast/cl100k.rs +426 -0
  51. data/src/pretokenize/fast/cl100k_family.rs +891 -0
  52. data/src/pretokenize/fast/deepseek_v3.rs +605 -0
  53. data/src/pretokenize/fast/kimi.rs +281 -0
  54. data/src/pretokenize/fast/mask.rs +1486 -0
  55. data/src/pretokenize/fast/mod.rs +446 -0
  56. data/src/pretokenize/fast/nemotron.rs +138 -0
  57. data/src/pretokenize/fast/o200k.rs +347 -0
  58. data/src/pretokenize/fast/o200k_family.rs +1734 -0
  59. data/src/pretokenize/fast/olmo3.rs +505 -0
  60. data/src/pretokenize/fast/qwen2.rs +429 -0
  61. data/src/pretokenize/fast/qwen3_5.rs +541 -0
  62. data/src/pretokenize/fast/r50k.rs +1250 -0
  63. data/src/pretokenize/mod.rs +1079 -0
  64. data/src/pretokenize/options.rs +188 -0
  65. data/src/pretokenize/pretoken.rs +20 -0
  66. data/src/pretokenize/pretokenize_traits.rs +49 -0
  67. data/src/pretokenize/reference/avx512.rs +522 -0
  68. data/src/pretokenize/reference/combinator.rs +572 -0
  69. data/src/pretokenize/reference/mod.rs +28 -0
  70. data/src/pretokenize/reference/simd.rs +852 -0
  71. data/src/pretokenize/reference/state_machine.rs +365 -0
  72. data/src/pretokenize/unicode.rs +546 -0
  73. data/src/test_hub.rs +28 -0
  74. data/src/token.rs +42 -0
  75. metadata +161 -0
data/src/bpe/mod.rs ADDED
@@ -0,0 +1,1217 @@
1
+ pub(crate) mod pretoken_cache;
2
+ pub mod sentencepiece;
3
+ pub mod tiktoken;
4
+
5
+ /// Ask the kernel for 2 MiB pages over `[ptr, ptr + bytes)` before first
6
+ /// touch. Huge pages cut the fault count of faulting in a multi-GB buffer
7
+ /// ~500x, and Zen drops software prefetches that miss the TLB, so both the
8
+ /// pretoken-cache table and the batch gather's copies want the coverage.
9
+ /// `MADV_HUGEPAGE` only hints page sizing; no-op off Linux (and where THP
10
+ /// is unavailable). Lives here (not `batch`) so the bin target's module
11
+ /// tree, which includes `bpe` but not `batch`, sees one copy too.
12
+ pub(crate) fn madvise_hugepage(ptr: *mut u8, bytes: usize) {
13
+ #[cfg(target_os = "linux")]
14
+ {
15
+ // madvise demands a page-aligned start, and Vec/malloc pointers to
16
+ // mmap-served allocations sit 16 bytes past the page boundary (the
17
+ // allocator header) — passed through raw, every Vec-backed call
18
+ // here returned EINVAL and the hint was silently dead (only the
19
+ // 2 MiB-`aligned_alloc` table pointer ever worked). Align inward:
20
+ // trimming the sub-page head is harmless, the kernel flags whole
21
+ // VMAs and backs 2 MiB-aligned extents regardless.
22
+ const PAGE: usize = 4096;
23
+ let start = (ptr as usize + PAGE - 1) & !(PAGE - 1);
24
+ let end = (ptr as usize).saturating_add(bytes);
25
+ if end > start {
26
+ // SAFETY: the range lies within one live allocation, and the
27
+ // hint does not read or write the memory.
28
+ unsafe {
29
+ libc::madvise(start as *mut libc::c_void, end - start, libc::MADV_HUGEPAGE);
30
+ }
31
+ }
32
+ }
33
+ #[cfg(not(target_os = "linux"))]
34
+ let _ = (ptr, bytes);
35
+ }
36
+
37
+ use crate::token::TokenId;
38
+ use eyre::{Result, anyhow};
39
+ use std::collections::HashMap;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // ByteRemapping — shared between tokenizer types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ #[derive(Clone)]
46
+ pub struct ByteRemapping {
47
+ /// Maps each byte value to the token ID of its single-byte vocab entry.
48
+ /// The IDs need not be < 256 (e.g. DeepSeek puts its byte tokens at
49
+ /// 3..=258, after the special tokens).
50
+ mapping: Vec<TokenId>,
51
+ }
52
+
53
+ /// Whether `b` can appear anywhere in a valid UTF-8 byte stream. `0xC0`/`0xC1`
54
+ /// are overlong two-byte leads and `0xF5..=0xFF` encode code points beyond
55
+ /// U+10FFFF, so none of them ever occur in valid UTF-8.
56
+ fn is_valid_utf8_byte(b: u8) -> bool {
57
+ !matches!(b, 0xC0 | 0xC1 | 0xF5..=0xFF)
58
+ }
59
+
60
+ impl ByteRemapping {
61
+ /// Build the byte → token-ID table by scanning `vocab` for single-byte
62
+ /// entries (lowest ID wins). Returns `None` when the mapping is the
63
+ /// identity (token ID == byte value), and an error if some byte value
64
+ /// that can appear in valid UTF-8 has no single-byte token.
65
+ ///
66
+ /// A vocab may legitimately omit single-byte tokens for the bytes that
67
+ /// never occur in valid UTF-8 (`0xC0`, `0xC1`, and `0xF5..=0xFF` — overlong
68
+ /// and out-of-range lead bytes). Byte-level vocabularies trained only on
69
+ /// UTF-8 text — e.g. ModernBERT / GPT-NeoX — drop them. Since such a byte
70
+ /// can never reach the merge loop from valid input, its absence is not an
71
+ /// error; we fill it with a placeholder ID so the table can never yield an
72
+ /// out-of-range `TokenId` even if fed malformed bytes.
73
+ pub fn from_byte_vocab(vocab: &[impl AsRef<[u8]>]) -> Result<Option<Self>> {
74
+ const UNSET: u32 = u32::MAX;
75
+ let mut mapping = vec![TokenId::from(UNSET); 256];
76
+ for (id, entry) in vocab.iter().enumerate() {
77
+ if let &[b] = entry.as_ref()
78
+ && mapping[b as usize].0 == UNSET
79
+ {
80
+ mapping[b as usize] = TokenId::from(id as u32);
81
+ }
82
+ }
83
+ if let Some(missing) = mapping
84
+ .iter()
85
+ .enumerate()
86
+ .position(|(b, t)| t.0 == UNSET && is_valid_utf8_byte(b as u8))
87
+ {
88
+ return Err(anyhow!(
89
+ "Byte remapping failed: no single-byte vocab entry for byte {missing:#04x}"
90
+ ));
91
+ }
92
+ // Fill the tolerated (never-in-UTF-8) gaps with a safe placeholder so
93
+ // an unexpected malformed byte indexes a valid token instead of OOB.
94
+ for t in mapping.iter_mut() {
95
+ if t.0 == UNSET {
96
+ *t = TokenId::from(0);
97
+ }
98
+ }
99
+ Ok(mapping
100
+ .iter()
101
+ .enumerate()
102
+ .any(|(b, t)| t.0 != b as u32)
103
+ .then_some(ByteRemapping { mapping }))
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // PairRankTable — flat pair-rank lookups for the merge hot path
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /// Merge-pair lookup structure replacing the hashbrown `merges` map on the
112
+ /// cache-miss merge path. Two levels, both immutable after construction (so
113
+ /// forks share one copy via `Arc` instead of cloning the map):
114
+ ///
115
+ /// - a dense `byte × byte` grid covering every pair whose sides are both
116
+ /// below the initial-symbol range (256 for GPT-2, 512 for DeepSeek-style
117
+ /// layouts). All round-1 lookups — roughly half of all lookups per miss,
118
+ /// since initial symbols are byte tokens — become one shift-or index and
119
+ /// one L1/L2 load: no hash, no probe.
120
+ /// - a flat open-addressed table of all merges: power-of-two slots at
121
+ /// ≤ 1/2 load, linear probing, one `u64` slot packing key and value
122
+ /// (`(key << 21) | merged_id`; keys are 42 bits and merged IDs 21, so a
123
+ /// packed slot uses 63 bits and `u64::MAX` can never collide) so a probe
124
+ /// touches one 8-byte load per step — the hit's value rides on the same
125
+ /// line as its key. hashbrown pays a tuple hash, a ctrl-byte line, a
126
+ /// NEON group match, and a second line for the bucket per lookup; this
127
+ /// pays one multiply, one load, one compare — and the common mid-merge
128
+ /// *miss* usually terminates on the first empty slot.
129
+ ///
130
+ /// Values are merged token IDs (`u32::MAX` = no merge), matching the
131
+ /// tiktoken-style convention that merged ID order == merge priority.
132
+ /// [`Self::build`] returns `None` for vocabularies that violate its packing
133
+ /// invariants; callers then keep using the hashbrown map.
134
+ pub(crate) struct PairRankTable {
135
+ /// `dense[(a << dense_log2) | b]` = merged ID for pairs with both sides
136
+ /// `< 2^dense_log2`, `u32::MAX` when they do not merge.
137
+ dense: Box<[u32]>,
138
+ dense_log2: u32,
139
+ /// Flat table slots, `((a << 21 | b) << 21) | merged_id` packed;
140
+ /// `u64::MAX` = empty (real slots fit 63 bits, so the sentinel can
141
+ /// never collide, and its key field `MAX >> 21` exceeds every real
142
+ /// 42-bit key, so the hit compare rejects it for free).
143
+ slots: Box<[u64]>,
144
+ /// `slots.len() - 1` (length is a power of two).
145
+ mask: usize,
146
+ /// `64 - log2(slots.len())`: the hash keeps the top bits.
147
+ shift: u32,
148
+ }
149
+
150
+ /// Every token ID must fit a 21-bit key lane (covers any vocab < 2M IDs).
151
+ const PAIR_ID_BITS: u32 = 21;
152
+
153
+ impl PairRankTable {
154
+ /// Build the two-level table, or `None` when this vocabulary cannot use
155
+ /// it (IDs too large for the packed key, or pathological clustering in
156
+ /// the flat table) — the caller then falls back to the hashbrown map.
157
+ pub(crate) fn build<S: std::hash::BuildHasher>(
158
+ merges: &HashMap<(TokenId, TokenId), TokenId, S>,
159
+ byte_remapping: Option<&ByteRemapping>,
160
+ vocab_len: usize,
161
+ ) -> Option<Self> {
162
+ // Key packing needs every ID that can appear as a merge-loop symbol
163
+ // (byte-token IDs and merged IDs are all vocab IDs) below 2^21. The
164
+ // per-merge check is defensive: `merges` is caller-supplied and need
165
+ // not be consistent with `vocab_len`.
166
+ if vocab_len > 1 << PAIR_ID_BITS {
167
+ return None;
168
+ }
169
+ let id_limit = 1u32 << PAIR_ID_BITS;
170
+ if merges
171
+ .iter()
172
+ .any(|(&(a, b), &m)| a.0 >= id_limit || b.0 >= id_limit || m.0 >= id_limit)
173
+ {
174
+ return None;
175
+ }
176
+
177
+ // Dense level covering every pair with both sides < 2^11 — the
178
+ // initial byte tokens (0..255 for GPT-2, 3..258 for DeepSeek) AND
179
+ // the earliest ~1.8k merged IDs, which by merge order are the most
180
+ // frequent symbols in the merge loop. Round-1 lookups plus the bulk
181
+ // of mid-merge lookups (hits and, crucially, no-merge misses, which
182
+ // the flat table answers only after probing to an empty slot)
183
+ // become one shift-or index and one load into a 16 MiB, L3-resident,
184
+ // Arc-shared grid. A vocab with byte tokens above the grid keeps
185
+ // correctness — its round-1 lookups just fall through to the flat
186
+ // table.
187
+ let max_initial = byte_remapping
188
+ .map_or(255, |br| br.mapping.iter().map(|t| t.0).max().unwrap_or(0));
189
+ let dense_log2 = (32 - max_initial.leading_zeros()).clamp(11, 11);
190
+ let mut dense = vec![u32::MAX; 1usize << (2 * dense_log2)].into_boxed_slice();
191
+
192
+ // Flat level holds all merges (including the dense subset, so it is
193
+ // a complete map on its own) at ≤ 1/2 load.
194
+ let n_slots = (merges.len().max(1) * 2).next_power_of_two().max(64);
195
+ let shift = 64 - n_slots.trailing_zeros();
196
+ let mask = n_slots - 1;
197
+ let mut slots = vec![u64::MAX; n_slots].into_boxed_slice();
198
+ for (&(a, b), &m) in merges {
199
+ if (a.0 | b.0) >> dense_log2 == 0 {
200
+ dense[((a.0 as usize) << dense_log2) | b.0 as usize] = m.0;
201
+ }
202
+ let key = ((a.0 as u64) << PAIR_ID_BITS) | b.0 as u64;
203
+ let mut idx = (key.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> shift) as usize;
204
+ let mut displacement = 0usize;
205
+ while slots[idx] != u64::MAX {
206
+ idx = (idx + 1) & mask;
207
+ displacement += 1;
208
+ // Ugly clustering (never seen on real vocabs at this load
209
+ // factor): give up rather than degrade every miss lookup.
210
+ if displacement > 64 {
211
+ return None;
212
+ }
213
+ }
214
+ // Merged IDs are < 2^21 (checked above), so key and value pack
215
+ // into 63 bits without overlap.
216
+ slots[idx] = (key << PAIR_ID_BITS) | m.0 as u64;
217
+ }
218
+
219
+ Some(PairRankTable { dense, dense_log2, slots, mask, shift })
220
+ }
221
+
222
+ /// Merged token ID of the pair `(a, b)`, or `u32::MAX` when it does not
223
+ /// merge — the same convention as probing the merges map.
224
+ #[inline(always)]
225
+ pub(crate) fn rank(&self, a: TokenId, b: TokenId) -> u32 {
226
+ if (a.0 | b.0) >> self.dense_log2 == 0 {
227
+ let idx = ((a.0 as usize) << self.dense_log2) | b.0 as usize;
228
+ // SAFETY: both IDs < 2^dense_log2, so idx < 2^(2*dense_log2)
229
+ // == dense.len().
230
+ return unsafe { *self.dense.get_unchecked(idx) };
231
+ }
232
+ let key = ((a.0 as u64) << PAIR_ID_BITS) | b.0 as u64;
233
+ let mut idx = (key.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> self.shift) as usize;
234
+ loop {
235
+ // SAFETY: idx starts < slots.len() (shift keeps log2(len) bits)
236
+ // and stays masked.
237
+ let slot = unsafe { *self.slots.get_unchecked(idx) };
238
+ // One load answers both hit and value: the key rides in the
239
+ // slot's top 43 bits, the merged ID in the low 21. The empty
240
+ // sentinel's key field (u64::MAX >> 21) exceeds every real
241
+ // 42-bit key, so it never false-hits.
242
+ if slot >> PAIR_ID_BITS == key {
243
+ return (slot & ((1 << PAIR_ID_BITS) - 1)) as u32;
244
+ }
245
+ if slot == u64::MAX {
246
+ return u32::MAX;
247
+ }
248
+ idx = (idx + 1) & self.mask;
249
+ }
250
+ }
251
+
252
+ /// `prefetcht0` the first line [`Self::rank`] would load for `(a, b)`:
253
+ /// the dense cell when both sides sit in the grid, else the first
254
+ /// sparse probe slot. The short-merge loop's refresh lookups sit on a
255
+ /// serial dependency chain and their slot-load consumers are ~5-7% of
256
+ /// cold cycles on Zen 5 (profiling/zen5_st_profile.md §4.3); both
257
+ /// refresh pairs are known before the list surgery, so issuing their
258
+ /// prefetches first overlaps the load latency with the surgery and
259
+ /// with each other. Address math mirrors `rank` exactly (same bounds:
260
+ /// dense idx < dense.len(), slot idx < slots.len() via `shift`), so
261
+ /// every prefetched address lies within the table's allocations.
262
+ /// x86_64 only; a no-op elsewhere (aarch64 uses the NEON merge core).
263
+ #[inline(always)]
264
+ pub(crate) fn prefetch_rank(&self, a: TokenId, b: TokenId) {
265
+ #[cfg(target_arch = "x86_64")]
266
+ // SAFETY: `_mm_prefetch` does not dereference; both index
267
+ // computations are the bounded ones from `rank` (see its SAFETY
268
+ // comments), so the address stays inside the live allocation.
269
+ unsafe {
270
+ use core::arch::x86_64::{_MM_HINT_T0, _mm_prefetch};
271
+ let addr = if (a.0 | b.0) >> self.dense_log2 == 0 {
272
+ let idx = ((a.0 as usize) << self.dense_log2) | b.0 as usize;
273
+ self.dense.as_ptr().add(idx) as *const i8
274
+ } else {
275
+ let key = ((a.0 as u64) << PAIR_ID_BITS) | b.0 as u64;
276
+ let idx = (key.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> self.shift) as usize;
277
+ self.slots.as_ptr().add(idx) as *const i8
278
+ };
279
+ _mm_prefetch(addr, _MM_HINT_T0);
280
+ }
281
+ #[cfg(not(target_arch = "x86_64"))]
282
+ let _ = (a, b);
283
+ }
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Shared BPE merge functions
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /// Reusable scratch buffers for [`bpe_merge_symbols_with_scratch`]. Holding one
291
+ /// of these across calls means the hot encode loop performs no per-pretoken
292
+ /// allocations for the linked list or the merge heap.
293
+ #[derive(Default)]
294
+ pub struct MergeScratch {
295
+ next: Vec<u32>,
296
+ prev: Vec<u32>,
297
+ heap: Vec<std::cmp::Reverse<u64>>,
298
+ }
299
+
300
+ /// Pack a heap entry as `(merged_token << 32) | position`. `u64` ordering then
301
+ /// matches the old `(TokenId, usize)` tuple ordering (token ID first, position
302
+ /// as tie-break) while halving the element size the heap has to shuffle.
303
+ #[inline(always)]
304
+ fn pack_merge_entry(merged: TokenId, pos: u32) -> u64 {
305
+ ((merged.0 as u64) << 32) | pos as u64
306
+ }
307
+
308
+ /// Apply BPE merges to an already-initialized symbol sequence.
309
+ /// Priority is determined by the merged token's ID (lower = first).
310
+ /// This is correct for tiktoken-style tokenizers where vocab ID equals merge rank.
311
+ ///
312
+ /// Uses a min-heap + doubly-linked list for O(n log n) performance.
313
+ pub fn bpe_merge_symbols<S: std::hash::BuildHasher>(
314
+ merges: &HashMap<(TokenId, TokenId), TokenId, S>,
315
+ symbols: &mut Vec<TokenId>,
316
+ ) {
317
+ bpe_merge_symbols_with_scratch(merges, symbols, &mut MergeScratch::default());
318
+ }
319
+
320
+ /// Like [`bpe_merge_symbols`], but reuses caller-provided scratch buffers so
321
+ /// repeated calls (one per cache-missing pretoken) do not allocate. Merges
322
+ /// `symbols` in place.
323
+ pub fn bpe_merge_symbols_with_scratch<S: std::hash::BuildHasher>(
324
+ merges: &HashMap<(TokenId, TokenId), TokenId, S>,
325
+ symbols: &mut Vec<TokenId>,
326
+ scratch: &mut MergeScratch,
327
+ ) {
328
+ bpe_merge_symbols_by_rank(
329
+ &|a, b| merges.get(&(a, b)).map_or(u32::MAX, |m| m.0),
330
+ symbols,
331
+ scratch,
332
+ );
333
+ }
334
+
335
+ /// Shared merge loop, generic over the pair-rank lookup: `get_rank` returns
336
+ /// the merged token's ID (== merge priority for tiktoken-style vocabs) or
337
+ /// `u32::MAX` when the pair does not merge.
338
+ // Out of line: this only runs on pretoken-cache misses (~0.7% of
339
+ // pretokens on OWT), and inlining its bulk into the encode loop costs
340
+ // more in I-cache and register pressure there than a call costs here.
341
+ #[inline(never)]
342
+ pub(crate) fn bpe_merge_symbols_by_rank(
343
+ get_rank: &impl Fn(TokenId, TokenId) -> u32,
344
+ symbols: &mut Vec<TokenId>,
345
+ scratch: &mut MergeScratch,
346
+ ) {
347
+ use std::cmp::Reverse;
348
+ use std::collections::BinaryHeap;
349
+
350
+ let n = symbols.len();
351
+ if n < 2 {
352
+ return;
353
+ }
354
+
355
+ // For short sequences (the overwhelming majority of pretokens), a linear
356
+ // rank scan has far lower constants than heap + linked list.
357
+ if n <= SMALL_MERGE_MAX {
358
+ bpe_merge_symbols_small(get_rank, symbols);
359
+ return;
360
+ }
361
+
362
+ // Doubly-linked list via u32 index arrays (pretokens are far shorter than
363
+ // 2^32 symbols).
364
+ const NONE: u32 = u32::MAX;
365
+ let next = &mut scratch.next;
366
+ let prev = &mut scratch.prev;
367
+ next.clear();
368
+ next.extend(1..n as u32);
369
+ next.push(NONE);
370
+ prev.clear();
371
+ prev.push(NONE);
372
+ prev.extend(0..n as u32 - 1);
373
+
374
+ // Min-heap of packed (merged_token_id, position). Lower ID = higher
375
+ // priority. Seed with all initial pairs, then heapify in O(n) instead of
376
+ // pushing one at a time.
377
+ let mut seeds = std::mem::take(&mut scratch.heap);
378
+ seeds.clear();
379
+ for i in 0..n - 1 {
380
+ let m = get_rank(symbols[i], symbols[i + 1]);
381
+ if m != u32::MAX {
382
+ seeds.push(Reverse(pack_merge_entry(TokenId(m), i as u32)));
383
+ }
384
+ }
385
+ let mut heap: BinaryHeap<Reverse<u64>> = BinaryHeap::from(seeds);
386
+
387
+ while let Some(Reverse(entry)) = heap.pop() {
388
+ let pos = (entry & u32::MAX as u64) as usize;
389
+ let expected_merged = (entry >> 32) as u32;
390
+ // Validate: pos must still be active and its right neighbor must exist
391
+ let right = next[pos];
392
+ if right == NONE {
393
+ continue;
394
+ }
395
+ let right = right as usize;
396
+ // Check the pair still matches (it may have been invalidated by an
397
+ // earlier merge). A no-merge result (u32::MAX) never equals the
398
+ // expected merged ID, since only real merges are pushed.
399
+ let merged = get_rank(symbols[pos], symbols[right]);
400
+ if merged == expected_merged {
401
+ // Apply the merge
402
+ symbols[pos] = TokenId(merged);
403
+ let right_right = next[right];
404
+ next[pos] = right_right;
405
+ if right_right != NONE {
406
+ prev[right_right as usize] = pos as u32;
407
+ }
408
+ // `next[right] = NONE` invalidates any stale heap entries at
409
+ // `right`; nothing reads `prev[right]` once it is unlinked.
410
+ next[right] = NONE;
411
+
412
+ // Re-check pair with left neighbor
413
+ let left = prev[pos];
414
+ if left != NONE {
415
+ let m = get_rank(symbols[left as usize], symbols[pos]);
416
+ if m != u32::MAX {
417
+ heap.push(Reverse(pack_merge_entry(TokenId(m), left)));
418
+ }
419
+ }
420
+ // Re-check pair with new right neighbor
421
+ if next[pos] != NONE {
422
+ let m = get_rank(symbols[pos], symbols[next[pos] as usize]);
423
+ if m != u32::MAX {
424
+ heap.push(Reverse(pack_merge_entry(TokenId(m), pos as u32)));
425
+ }
426
+ }
427
+ }
428
+ }
429
+ // The pop loop drained the heap; keep its capacity for the next call.
430
+ scratch.heap = heap.into_vec();
431
+
432
+ // Compact surviving symbols in place. Surviving indices are strictly
433
+ // increasing and the k-th survivor has index >= k, so writes never
434
+ // overtake reads.
435
+ let mut write = 0;
436
+ let mut i = 0;
437
+ loop {
438
+ symbols[write] = symbols[i];
439
+ write += 1;
440
+ if next[i] == NONE {
441
+ break;
442
+ }
443
+ i = next[i] as usize;
444
+ }
445
+ symbols.truncate(write);
446
+ }
447
+
448
+ /// Sequences up to this length use the linear-scan merge instead of the heap.
449
+ const SMALL_MERGE_MAX: usize = 32;
450
+
451
+ /// BPE merge for short sequences (n <= SMALL_MERGE_MAX), in the style of
452
+ /// tiktoken's `byte_pair_merge`: keep a per-position rank (the merged token ID
453
+ /// of the pair starting there, or `u32::MAX`), find the minimum by linear scan,
454
+ /// merge, and recompute only the two affected neighbor ranks. The scan over a
455
+ /// few stack-resident `u32`s beats a `BinaryHeap`'s sift traffic at these
456
+ /// sizes, and merge priority (lowest merged ID, then lowest position) is
457
+ /// identical to the heap version's ordering.
458
+ ///
459
+ /// One of three deliberately separate small-merge cores with disjoint
460
+ /// domains: this Vec-based one handles 16..=32 symbols via
461
+ /// `bpe_merge_symbols_by_rank` (the long-pretoken miss path); pretokens of
462
+ /// <= 15 symbols go straight to `bpe_merge_symbols_short_scalar` /
463
+ /// `bpe_merge_symbols_short_neon`. Unifying them was measured as a
464
+ /// regression risk to the tuned short-miss path.
465
+ fn bpe_merge_symbols_small(
466
+ get_rank: &impl Fn(TokenId, TokenId) -> u32,
467
+ symbols: &mut Vec<TokenId>,
468
+ ) {
469
+ let n = symbols.len();
470
+ debug_assert!((2..=SMALL_MERGE_MAX).contains(&n));
471
+ // Stack-resident doubly-linked list, so a merge is O(1) pointer updates
472
+ // instead of shifting the tail (which lowers to a libc memmove call).
473
+ // Sentinels: next[last] == n, prev[0] == u8::MAX; both fail `< n` checks.
474
+ let mut next = [0u8; SMALL_MERGE_MAX];
475
+ let mut prev = [0u8; SMALL_MERGE_MAX];
476
+ for i in 0..n {
477
+ next[i] = (i + 1) as u8;
478
+ prev[i] = (i as u8).wrapping_sub(1);
479
+ }
480
+ // ranks[i] = priority of merging the pair starting at active position i;
481
+ // MAX when there is no merge or the position was merged away.
482
+ let mut ranks = [u32::MAX; SMALL_MERGE_MAX];
483
+ for i in 0..n - 1 {
484
+ ranks[i] = get_rank(symbols[i], symbols[i + 1]);
485
+ }
486
+ loop {
487
+ let mut best = u32::MAX;
488
+ let mut best_i = 0;
489
+ for (i, &rank) in ranks[..n - 1].iter().enumerate() {
490
+ if rank < best {
491
+ best = rank;
492
+ best_i = i;
493
+ }
494
+ }
495
+ if best == u32::MAX {
496
+ break;
497
+ }
498
+ let i = best_i;
499
+ symbols[i] = TokenId::from(best);
500
+ // Unlink the right element of the merged pair.
501
+ let dead = next[i] as usize;
502
+ let new_right = next[dead] as usize;
503
+ next[i] = new_right as u8;
504
+ ranks[dead] = u32::MAX;
505
+ // Refresh the two pairs now touching the merged symbol.
506
+ if new_right < n {
507
+ prev[new_right] = i as u8;
508
+ ranks[i] = get_rank(symbols[i], symbols[new_right]);
509
+ } else {
510
+ ranks[i] = u32::MAX;
511
+ }
512
+ let left = prev[i] as usize;
513
+ if left < n {
514
+ ranks[left] = get_rank(symbols[left], symbols[i]);
515
+ }
516
+ }
517
+ // Compact survivors in place: list indices are strictly increasing, so
518
+ // writes never overtake reads.
519
+ let mut write = 0;
520
+ let mut i = 0;
521
+ while i < n {
522
+ symbols[write] = symbols[i];
523
+ write += 1;
524
+ i = next[i] as usize;
525
+ }
526
+ symbols.truncate(write);
527
+ }
528
+
529
+ /// Symbol capacity of the stack-array short merges: short pretokens are
530
+ /// ≤ 15 bytes, so at most 15 initial symbols.
531
+ pub(crate) const SHORT_MERGE_MAX: usize = 16;
532
+
533
+ /// [`bpe_merge_symbols_small`] over a caller-owned stack array instead of
534
+ /// the `Vec` scratch (no bounds/capacity code, no extend memmove) — the
535
+ /// short-pretoken miss path's merge. Returns the merged length.
536
+ ///
537
+ /// The non-aarch64 core of the short-miss domain (<= 15 symbols, called
538
+ /// from the tiktoken encode loop); aarch64 uses
539
+ /// [`bpe_merge_symbols_short_neon`] when a [`PairRankTable`] is available.
540
+ /// Kept separate from the Vec-based [`bpe_merge_symbols_small`] (16..=32
541
+ /// symbols): the domains are disjoint and unification was measured as a
542
+ /// regression risk to this tuned miss path.
543
+ ///
544
+ /// `prefetch_rank` is [`PairRankTable::prefetch_rank`] when a table backs
545
+ /// `get_rank`, and a no-op closure for the HashMap fallback — the merge
546
+ /// scan itself stays identical either way (restructuring it was measured
547
+ /// negative; the prefetch only reorders when the refresh loads issue).
548
+ pub(crate) fn bpe_merge_symbols_short_scalar(
549
+ get_rank: impl Fn(TokenId, TokenId) -> u32,
550
+ prefetch_rank: impl Fn(TokenId, TokenId),
551
+ symbols: &mut [TokenId; SHORT_MERGE_MAX],
552
+ n: usize,
553
+ ) -> usize {
554
+ debug_assert!((2..=SHORT_MERGE_MAX - 1).contains(&n));
555
+ // Stack-resident doubly-linked list; see `bpe_merge_symbols_small`.
556
+ let mut next = [0u8; SHORT_MERGE_MAX];
557
+ let mut prev = [0u8; SHORT_MERGE_MAX];
558
+ for i in 0..n {
559
+ next[i] = (i + 1) as u8;
560
+ prev[i] = (i as u8).wrapping_sub(1);
561
+ }
562
+ // ranks[i] = priority of merging the pair starting at active position i;
563
+ // MAX when there is no merge or the position was merged away.
564
+ let mut ranks = [u32::MAX; SHORT_MERGE_MAX];
565
+ for i in 0..n - 1 {
566
+ ranks[i] = get_rank(symbols[i], symbols[i + 1]);
567
+ }
568
+ loop {
569
+ let mut best = u32::MAX;
570
+ let mut best_i = 0;
571
+ for (i, &rank) in ranks[..n - 1].iter().enumerate() {
572
+ if rank < best {
573
+ best = rank;
574
+ best_i = i;
575
+ }
576
+ }
577
+ if best == u32::MAX {
578
+ break;
579
+ }
580
+ let i = best_i;
581
+ // Both refresh pairs are fixed the moment `best` is chosen:
582
+ // (best, symbols[new_right]) and (symbols[left], best). Their rank
583
+ // lines are the serial chain's next loads (their consumers are
584
+ // ~5-7% of cold cycles, profiling/zen5_st_profile.md §4.3), so
585
+ // request both before the list surgery. List indices are strictly
586
+ // increasing, so new_right > i and the surgery's `prev[new_right]`
587
+ // store cannot alias `prev[i]` — reading `left` early is safe.
588
+ // `symbols[new_right]` / `symbols[left]` are read only behind the
589
+ // same `< n` guards the refresh uses: active positions holding
590
+ // live tokens, never garbage lanes.
591
+ let dead = next[i] as usize;
592
+ let new_right = next[dead] as usize;
593
+ let left = prev[i] as usize;
594
+ if new_right < n {
595
+ prefetch_rank(TokenId(best), symbols[new_right]);
596
+ }
597
+ if left < n {
598
+ prefetch_rank(symbols[left], TokenId(best));
599
+ }
600
+ symbols[i] = TokenId(best);
601
+ // Unlink the right element of the merged pair.
602
+ next[i] = new_right as u8;
603
+ ranks[dead] = u32::MAX;
604
+ // Refresh the two pairs now touching the merged symbol.
605
+ if new_right < n {
606
+ prev[new_right] = i as u8;
607
+ ranks[i] = get_rank(symbols[i], symbols[new_right]);
608
+ } else {
609
+ ranks[i] = u32::MAX;
610
+ }
611
+ if left < n {
612
+ ranks[left] = get_rank(symbols[left], symbols[i]);
613
+ }
614
+ }
615
+ // Compact survivors in place: list indices are strictly increasing, so
616
+ // writes never overtake reads.
617
+ let mut write = 0;
618
+ let mut i = 0;
619
+ while i < n {
620
+ symbols[write] = symbols[i];
621
+ write += 1;
622
+ i = next[i] as usize;
623
+ }
624
+ write
625
+ }
626
+
627
+ /// [`bpe_merge_symbols_short_scalar`] with a branchless NEON min-rank scan.
628
+ /// Rank and position share one lane, `pr[i] = (rank << 8) | i`, so the
629
+ /// vector minimum selects the lowest rank with ties broken by the lowest
630
+ /// position — exactly the scalar scan's order. Real ranks are < 2^21 (a
631
+ /// [`PairRankTable`] build invariant, which is why this variant requires
632
+ /// one), so packed lanes are < 2^29 and "no merge" (`u32::MAX << 8`, from
633
+ /// the wrapping shift of the MAX sentinel) sorts above every real lane.
634
+ /// The scan covers 16 lanes with inactive lanes parked at `u32::MAX` —
635
+ /// four loads, three `vmin`s, one `vminv`, zero data-dependent branches on
636
+ /// a core where the scalar scan's `rank < best` mispredicts freely. Every
637
+ /// live lane index is `< n` (pairs start below `n` and merges only clear
638
+ /// lanes), so when `n <= 8` the scan reads just the first two vectors —
639
+ /// the width branch is hoisted out of the loop and fixed per call.
640
+ ///
641
+ /// The aarch64 core of the short-miss domain (<= 15 symbols, called from
642
+ /// the tiktoken encode loop); other arches use
643
+ /// [`bpe_merge_symbols_short_scalar`], and 16..=32-symbol sequences take
644
+ /// the Vec-based [`bpe_merge_symbols_small`] — the three are deliberately
645
+ /// not unified (disjoint domains; see `bpe_merge_symbols_short_scalar`).
646
+ #[cfg(target_arch = "aarch64")]
647
+ pub(crate) fn bpe_merge_symbols_short_neon(
648
+ table: &PairRankTable,
649
+ symbols: &mut [TokenId; SHORT_MERGE_MAX],
650
+ n: usize,
651
+ ) -> usize {
652
+ use core::arch::aarch64::{vld1q_u32, vminq_u32, vminvq_u32};
653
+ debug_assert!((2..=SHORT_MERGE_MAX - 1).contains(&n));
654
+ /// Every packed value at or above this has rank u32::MAX (no merge).
655
+ const NO_MERGE_FLOOR: u32 = u32::MAX << 8;
656
+ let pack = |rank: u32, i: usize| (rank << 8) | i as u32;
657
+ // Stack-resident doubly-linked list; see `bpe_merge_symbols_small`.
658
+ let mut next = [0u8; SHORT_MERGE_MAX];
659
+ let mut prev = [0u8; SHORT_MERGE_MAX];
660
+ for i in 0..n {
661
+ next[i] = (i + 1) as u8;
662
+ prev[i] = (i as u8).wrapping_sub(1);
663
+ }
664
+ // pr[i] = packed (rank, position) of the pair starting at active
665
+ // position i; the only array the scan reads.
666
+ let mut pr = [u32::MAX; SHORT_MERGE_MAX];
667
+ for i in 0..n - 1 {
668
+ pr[i] = pack(table.rank(symbols[i], symbols[i + 1]), i);
669
+ }
670
+ // Lanes at index >= n stay u32::MAX forever (initial pairs sit below
671
+ // n - 1; the loop below only writes lanes < n), so short pretokens
672
+ // need only the first half of the scan.
673
+ let narrow = n <= 8;
674
+ loop {
675
+ // SAFETY: pr is 16 contiguous u32s; vld1q_u32 has no alignment
676
+ // requirement beyond u32's.
677
+ let best = unsafe {
678
+ let p = pr.as_ptr();
679
+ let m01 = vminq_u32(vld1q_u32(p), vld1q_u32(p.add(4)));
680
+ let m = if narrow {
681
+ m01
682
+ } else {
683
+ let m23 = vminq_u32(vld1q_u32(p.add(8)), vld1q_u32(p.add(12)));
684
+ vminq_u32(m01, m23)
685
+ };
686
+ vminvq_u32(m)
687
+ };
688
+ if best >= NO_MERGE_FLOOR {
689
+ break;
690
+ }
691
+ let i = (best & 0xFF) as usize;
692
+ symbols[i] = TokenId(best >> 8);
693
+ // Unlink the right element of the merged pair.
694
+ let dead = next[i] as usize;
695
+ let new_right = next[dead] as usize;
696
+ next[i] = new_right as u8;
697
+ pr[dead] = u32::MAX;
698
+ // Refresh the two pairs now touching the merged symbol.
699
+ if new_right < n {
700
+ prev[new_right] = i as u8;
701
+ pr[i] = pack(table.rank(symbols[i], symbols[new_right]), i);
702
+ } else {
703
+ pr[i] = u32::MAX;
704
+ }
705
+ let left = prev[i] as usize;
706
+ if left < n {
707
+ pr[left] = pack(table.rank(symbols[left], symbols[i]), left);
708
+ }
709
+ }
710
+ // Compact survivors in place.
711
+ let mut write = 0;
712
+ let mut i = 0;
713
+ while i < n {
714
+ symbols[write] = symbols[i];
715
+ write += 1;
716
+ i = next[i] as usize;
717
+ }
718
+ write
719
+ }
720
+
721
+ /// AVX-512 port of [`bpe_merge_symbols_short_neon`]: the whole 4-load,
722
+ /// 3-`vmin`, 1-`vminv` scan collapses to one 64-byte `pr` load and one
723
+ /// `vpminud` reduction tree — all 16 lanes in a single zmm, so there is no
724
+ /// narrow/wide split to hoist (lanes at index >= n are parked at
725
+ /// `u32::MAX` and never win). Packing, list surgery, and tie-break order
726
+ /// are identical to the NEON and scalar variants (see
727
+ /// [`bpe_merge_symbols_short_neon`] for the lane-packing invariants).
728
+ ///
729
+ /// NOT dispatched by the miss path: measured ~1% slower than the scalar
730
+ /// scan on cold encode_st (Zen 5 / 9800X3D, gpt2, 100 MB and 1 GB OWT,
731
+ /// interleaved min-of-5, runtime-dispatched baseline build). The x86
732
+ /// horizontal reduce is a 4-dependent-op chain plus a vector→GPR `vmovd`
733
+ /// on the merge loop's serial chain, the `target_feature` boundary costs
734
+ /// a real call per short merge (NEON needs no gate and inlines), and the
735
+ /// scalar scan's `rank < best` branch predicts well on Zen 5 at typical
736
+ /// n ≈ 4-6 — the mispredict pressure that made the NEON scan win on M4
737
+ /// is absent. Kept, with the AVX2 tier below, as a tested reference
738
+ /// (differential-covered by `short_merges_match_vec_merge_loop`); see
739
+ /// profiling/x86_port_plan.md §6.
740
+ #[cfg(target_arch = "x86_64")]
741
+ #[cfg_attr(not(test), allow(dead_code))]
742
+ #[target_feature(enable = "avx512f")]
743
+ fn bpe_merge_symbols_short_avx512(
744
+ table: &PairRankTable,
745
+ symbols: &mut [TokenId; SHORT_MERGE_MAX],
746
+ n: usize,
747
+ ) -> usize {
748
+ use core::arch::x86_64::{_mm512_loadu_si512, _mm512_reduce_min_epu32};
749
+ debug_assert!((2..=SHORT_MERGE_MAX - 1).contains(&n));
750
+ /// Every packed value at or above this has rank u32::MAX (no merge).
751
+ const NO_MERGE_FLOOR: u32 = u32::MAX << 8;
752
+ let pack = |rank: u32, i: usize| (rank << 8) | i as u32;
753
+ // Stack-resident doubly-linked list; see `bpe_merge_symbols_small`.
754
+ let mut next = [0u8; SHORT_MERGE_MAX];
755
+ let mut prev = [0u8; SHORT_MERGE_MAX];
756
+ for i in 0..n {
757
+ next[i] = (i + 1) as u8;
758
+ prev[i] = (i as u8).wrapping_sub(1);
759
+ }
760
+ // pr[i] = packed (rank, position) of the pair starting at active
761
+ // position i; the only array the scan reads.
762
+ let mut pr = [u32::MAX; SHORT_MERGE_MAX];
763
+ for i in 0..n - 1 {
764
+ pr[i] = pack(table.rank(symbols[i], symbols[i + 1]), i);
765
+ }
766
+ loop {
767
+ // SAFETY: pr is 16 contiguous u32s (64 bytes); the unaligned-load
768
+ // intrinsic has no alignment requirement.
769
+ let best = unsafe {
770
+ _mm512_reduce_min_epu32(_mm512_loadu_si512(pr.as_ptr() as *const _))
771
+ };
772
+ if best >= NO_MERGE_FLOOR {
773
+ break;
774
+ }
775
+ let i = (best & 0xFF) as usize;
776
+ symbols[i] = TokenId(best >> 8);
777
+ // Unlink the right element of the merged pair.
778
+ let dead = next[i] as usize;
779
+ let new_right = next[dead] as usize;
780
+ next[i] = new_right as u8;
781
+ pr[dead] = u32::MAX;
782
+ // Refresh the two pairs now touching the merged symbol.
783
+ if new_right < n {
784
+ prev[new_right] = i as u8;
785
+ pr[i] = pack(table.rank(symbols[i], symbols[new_right]), i);
786
+ } else {
787
+ pr[i] = u32::MAX;
788
+ }
789
+ let left = prev[i] as usize;
790
+ if left < n {
791
+ pr[left] = pack(table.rank(symbols[left], symbols[i]), left);
792
+ }
793
+ }
794
+ // Compact survivors in place.
795
+ let mut write = 0;
796
+ let mut i = 0;
797
+ while i < n {
798
+ symbols[write] = symbols[i];
799
+ write += 1;
800
+ i = next[i] as usize;
801
+ }
802
+ write
803
+ }
804
+
805
+ /// AVX2 tier of the short merge (Haswell+, Zen 1-3): two 32-byte `pr`
806
+ /// loads and one `vpminud`, then a 4-step horizontal min (extract + 3
807
+ /// shuffled mins) in place of NEON's `vminv`. The NEON narrow split is
808
+ /// kept: `n <= 8` reads one ymm and skips the cross-vector min. Packing,
809
+ /// list surgery, and tie-break order are identical to the other variants.
810
+ ///
811
+ /// NOT dispatched — same measured-slower verdict as the AVX-512 tier
812
+ /// above (whose doc has the numbers and the why).
813
+ #[cfg(target_arch = "x86_64")]
814
+ #[cfg_attr(not(test), allow(dead_code))]
815
+ #[target_feature(enable = "avx2")]
816
+ fn bpe_merge_symbols_short_avx2(
817
+ table: &PairRankTable,
818
+ symbols: &mut [TokenId; SHORT_MERGE_MAX],
819
+ n: usize,
820
+ ) -> usize {
821
+ use core::arch::x86_64::*;
822
+ debug_assert!((2..=SHORT_MERGE_MAX - 1).contains(&n));
823
+ /// Every packed value at or above this has rank u32::MAX (no merge).
824
+ const NO_MERGE_FLOOR: u32 = u32::MAX << 8;
825
+ let pack = |rank: u32, i: usize| (rank << 8) | i as u32;
826
+ // Stack-resident doubly-linked list; see `bpe_merge_symbols_small`.
827
+ let mut next = [0u8; SHORT_MERGE_MAX];
828
+ let mut prev = [0u8; SHORT_MERGE_MAX];
829
+ for i in 0..n {
830
+ next[i] = (i + 1) as u8;
831
+ prev[i] = (i as u8).wrapping_sub(1);
832
+ }
833
+ // pr[i] = packed (rank, position) of the pair starting at active
834
+ // position i; the only array the scan reads.
835
+ let mut pr = [u32::MAX; SHORT_MERGE_MAX];
836
+ for i in 0..n - 1 {
837
+ pr[i] = pack(table.rank(symbols[i], symbols[i + 1]), i);
838
+ }
839
+ // Lanes at index >= n stay u32::MAX forever, so short pretokens need
840
+ // only the first ymm of the scan; the width branch is hoisted out of
841
+ // the loop and fixed per call.
842
+ let narrow = n <= 8;
843
+ loop {
844
+ // SAFETY: pr is 16 contiguous u32s; unaligned loads.
845
+ let best = unsafe {
846
+ let p = pr.as_ptr() as *const __m256i;
847
+ let m = if narrow {
848
+ _mm256_loadu_si256(p)
849
+ } else {
850
+ _mm256_min_epu32(_mm256_loadu_si256(p), _mm256_loadu_si256(p.add(1)))
851
+ };
852
+ // Horizontal min of 8 u32 lanes: fold 256 -> 128, then two
853
+ // shuffled mins; lane 0 holds the minimum.
854
+ let m128 = _mm_min_epu32(
855
+ _mm256_castsi256_si128(m),
856
+ _mm256_extracti128_si256::<1>(m),
857
+ );
858
+ let m128 = _mm_min_epu32(m128, _mm_shuffle_epi32::<0b01_00_11_10>(m128));
859
+ let m128 = _mm_min_epu32(m128, _mm_shuffle_epi32::<0b00_00_00_01>(m128));
860
+ _mm_cvtsi128_si32(m128) as u32
861
+ };
862
+ if best >= NO_MERGE_FLOOR {
863
+ break;
864
+ }
865
+ let i = (best & 0xFF) as usize;
866
+ symbols[i] = TokenId(best >> 8);
867
+ // Unlink the right element of the merged pair.
868
+ let dead = next[i] as usize;
869
+ let new_right = next[dead] as usize;
870
+ next[i] = new_right as u8;
871
+ pr[dead] = u32::MAX;
872
+ // Refresh the two pairs now touching the merged symbol.
873
+ if new_right < n {
874
+ prev[new_right] = i as u8;
875
+ pr[i] = pack(table.rank(symbols[i], symbols[new_right]), i);
876
+ } else {
877
+ pr[i] = u32::MAX;
878
+ }
879
+ let left = prev[i] as usize;
880
+ if left < n {
881
+ pr[left] = pack(table.rank(symbols[left], symbols[i]), left);
882
+ }
883
+ }
884
+ // Compact survivors in place.
885
+ let mut write = 0;
886
+ let mut i = 0;
887
+ while i < n {
888
+ symbols[write] = symbols[i];
889
+ write += 1;
890
+ i = next[i] as usize;
891
+ }
892
+ write
893
+ }
894
+
895
+ /// Vocabulary entries as `(id, bytes)` pairs in ID order, skipping IDs with
896
+ /// no assigned content. Shared by both tokenizer types' `vocab_entries`.
897
+ pub(crate) fn vocab_entries(
898
+ vocab: &[std::sync::Arc<[u8]>],
899
+ ) -> impl Iterator<Item = (u32, &[u8])> {
900
+ vocab
901
+ .iter()
902
+ .enumerate()
903
+ .filter(|(_, bytes)| !bytes.is_empty())
904
+ .map(|(id, bytes)| (id as u32, bytes.as_ref()))
905
+ }
906
+
907
+ /// Pack a ranked-merge pair key into one `u64`: hashing it is a single
908
+ /// multiply instead of a two-round tuple hash, and the merge loop probes this
909
+ /// map ~2x per symbol.
910
+ #[inline(always)]
911
+ pub fn ranked_merge_key(a: TokenId, b: TokenId) -> u64 {
912
+ ((a.0 as u64) << 32) | b.0 as u64
913
+ }
914
+
915
+ /// Ranked-merge variant of [`bpe_merge_symbols_small`]: allocation-free BPE
916
+ /// for short symbol sequences (the overwhelming majority of cache-missing
917
+ /// units), with priority taken from the merge table's explicit rank. Also
918
+ /// the stack-buffer miss path of the ByteLevel tokenizer for rank-mapped
919
+ /// vocabularies (see `ranked_merges` there). Merges `symbols` in place and
920
+ /// returns the surviving count; the caller truncates or slices to it.
921
+ pub(crate) fn bpe_merge_symbols_ranked_slice<S: std::hash::BuildHasher>(
922
+ merges: &HashMap<u64, (TokenId, u32), S>,
923
+ symbols: &mut [TokenId],
924
+ ) -> usize {
925
+ let get = |a: TokenId, b: TokenId| -> (TokenId, u32) {
926
+ merges
927
+ .get(&ranked_merge_key(a, b))
928
+ .map_or((TokenId::from(0u32), u32::MAX), |&m| m)
929
+ };
930
+ let n = symbols.len();
931
+ debug_assert!((2..=SMALL_MERGE_MAX).contains(&n));
932
+ // Stack-resident doubly-linked list; see `bpe_merge_symbols_small`.
933
+ let mut next = [0u8; SMALL_MERGE_MAX];
934
+ let mut prev = [0u8; SMALL_MERGE_MAX];
935
+ for i in 0..n {
936
+ next[i] = (i + 1) as u8;
937
+ prev[i] = (i as u8).wrapping_sub(1);
938
+ }
939
+ // For the pair starting at active position i: its merge priority and
940
+ // merged token. Rank u32::MAX = no merge (or merged away).
941
+ let mut ranks = [u32::MAX; SMALL_MERGE_MAX];
942
+ let mut merged = [TokenId::from(0u32); SMALL_MERGE_MAX];
943
+ for i in 0..n - 1 {
944
+ (merged[i], ranks[i]) = get(symbols[i], symbols[i + 1]);
945
+ }
946
+ loop {
947
+ let mut best = u32::MAX;
948
+ let mut best_i = 0;
949
+ for (i, &rank) in ranks[..n - 1].iter().enumerate() {
950
+ if rank < best {
951
+ best = rank;
952
+ best_i = i;
953
+ }
954
+ }
955
+ if best == u32::MAX {
956
+ break;
957
+ }
958
+ let i = best_i;
959
+ symbols[i] = merged[i];
960
+ // Unlink the right element of the merged pair.
961
+ let dead = next[i] as usize;
962
+ let new_right = next[dead] as usize;
963
+ next[i] = new_right as u8;
964
+ ranks[dead] = u32::MAX;
965
+ // Refresh the two pairs now touching the merged symbol.
966
+ if new_right < n {
967
+ prev[new_right] = i as u8;
968
+ (merged[i], ranks[i]) = get(symbols[i], symbols[new_right]);
969
+ } else {
970
+ ranks[i] = u32::MAX;
971
+ }
972
+ let left = prev[i] as usize;
973
+ if left < n {
974
+ (merged[left], ranks[left]) = get(symbols[left], symbols[i]);
975
+ }
976
+ }
977
+ // Compact survivors in place.
978
+ let mut write = 0;
979
+ let mut i = 0;
980
+ while i < n {
981
+ symbols[write] = symbols[i];
982
+ write += 1;
983
+ i = next[i] as usize;
984
+ }
985
+ write
986
+ }
987
+
988
+ /// Apply BPE merges using explicit merge ranks for priority (lower rank = first).
989
+ /// The merge table maps `(token_a, token_b) → (merged_token, rank)`.
990
+ ///
991
+ /// Uses a min-heap + doubly-linked list for O(n log n) performance instead of
992
+ /// the naive O(n × merges) scan.
993
+ /// This is only needed by SentencePiece-style tokenizers.
994
+ pub fn bpe_merge_symbols_ranked<S: std::hash::BuildHasher>(
995
+ merges: &HashMap<u64, (TokenId, u32), S>,
996
+ symbols: &mut Vec<TokenId>,
997
+ ) {
998
+ use std::cmp::Reverse;
999
+ use std::collections::BinaryHeap;
1000
+
1001
+ let n = symbols.len();
1002
+ if n < 2 {
1003
+ return;
1004
+ }
1005
+
1006
+ // Short sequences (the overwhelming majority of word units) skip the
1007
+ // heap and its allocations entirely.
1008
+ if n <= SMALL_MERGE_MAX {
1009
+ let new_len = bpe_merge_symbols_ranked_slice(merges, symbols);
1010
+ symbols.truncate(new_len);
1011
+ return;
1012
+ }
1013
+
1014
+ // Doubly-linked list via index arrays. NONE = no neighbor.
1015
+ const NONE: usize = usize::MAX;
1016
+ let mut next: Vec<usize> = (1..n).chain(std::iter::once(NONE)).collect();
1017
+ let mut prev: Vec<usize> = std::iter::once(NONE).chain(0..n - 1).collect();
1018
+ let mut token = symbols.clone();
1019
+
1020
+ // Min-heap of (rank, position). Position refers to the left symbol of the pair.
1021
+ let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::new();
1022
+
1023
+ // Seed with all initial pairs
1024
+ let mut i = 0;
1025
+ while i < n {
1026
+ let j = next[i];
1027
+ if j == NONE {
1028
+ break;
1029
+ }
1030
+ if let Some(&(_, rank)) = merges.get(&ranked_merge_key(token[i], token[j])) {
1031
+ heap.push(Reverse((rank, i)));
1032
+ }
1033
+ i = j;
1034
+ }
1035
+
1036
+ while let Some(Reverse((rank, pos))) = heap.pop() {
1037
+ // Validate: pos must still be active and its right neighbor must exist
1038
+ let right = next[pos];
1039
+ if right == NONE {
1040
+ continue;
1041
+ }
1042
+ // Check the pair still matches (it may have been invalidated by an earlier merge)
1043
+ let pair = ranked_merge_key(token[pos], token[right]);
1044
+ match merges.get(&pair) {
1045
+ Some(&(merged_token, r)) if r == rank => {
1046
+ // Apply the merge: replace token[pos], remove right
1047
+ token[pos] = merged_token;
1048
+ let right_right = next[right];
1049
+ next[pos] = right_right;
1050
+ if right_right != NONE {
1051
+ prev[right_right] = pos;
1052
+ }
1053
+ // Mark right as deleted
1054
+ next[right] = NONE;
1055
+ prev[right] = NONE;
1056
+
1057
+ // Re-check pair with left neighbor
1058
+ let left = prev[pos];
1059
+ if left != NONE
1060
+ && let Some(&(_, rank)) = merges.get(&ranked_merge_key(token[left], token[pos])) {
1061
+ heap.push(Reverse((rank, left)));
1062
+ }
1063
+ // Re-check pair with new right neighbor
1064
+ if next[pos] != NONE
1065
+ && let Some(&(_, rank)) = merges.get(&ranked_merge_key(token[pos], token[next[pos]])) {
1066
+ heap.push(Reverse((rank, pos)));
1067
+ }
1068
+ }
1069
+ _ => continue, // Stale entry, skip
1070
+ }
1071
+ }
1072
+
1073
+ // Collect surviving symbols via linked list traversal
1074
+ symbols.clear();
1075
+ let mut i = 0;
1076
+ // Find the head (first element with prev == NONE that's still in the list)
1077
+ // Since we never remove index 0's prev link, index 0 is always the head
1078
+ loop {
1079
+ symbols.push(token[i]);
1080
+ if next[i] == NONE {
1081
+ break;
1082
+ }
1083
+ i = next[i];
1084
+ }
1085
+ }
1086
+
1087
+ /// Tokenize a single pretoken by mapping each byte to TokenId(byte_value)
1088
+ /// then applying BPE merges (priority by merged token ID).
1089
+ pub fn simple_bpe_merge<S: std::hash::BuildHasher>(
1090
+ merges: &HashMap<(TokenId, TokenId), TokenId, S>,
1091
+ pre_token: &[u8],
1092
+ ) -> Vec<TokenId> {
1093
+ let mut symbols: Vec<TokenId> = pre_token.iter().map(|&b| TokenId::from(b as u32)).collect();
1094
+ bpe_merge_symbols(merges, &mut symbols);
1095
+ symbols
1096
+ }
1097
+
1098
+ // Re-export the main types so existing `use crate::bpe::Tokenizer` still works.
1099
+ pub use sentencepiece::SentencePieceBPE;
1100
+ pub use tiktoken::Tokenizer;
1101
+
1102
+ #[cfg(test)]
1103
+ mod tests {
1104
+ use super::*;
1105
+
1106
+ /// Minimal deterministic RNG (xorshift64*) so the differential tests
1107
+ /// need no dev-dependency.
1108
+ struct Rng(u64);
1109
+ impl Rng {
1110
+ fn next(&mut self) -> u64 {
1111
+ self.0 ^= self.0 << 13;
1112
+ self.0 ^= self.0 >> 7;
1113
+ self.0 ^= self.0 << 17;
1114
+ self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
1115
+ }
1116
+ fn below(&mut self, n: u64) -> u64 {
1117
+ self.next() % n
1118
+ }
1119
+ }
1120
+
1121
+ fn random_merges(
1122
+ rng: &mut Rng,
1123
+ n_merges: usize,
1124
+ id_range: u32,
1125
+ ) -> HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> {
1126
+ let mut merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1127
+ for i in 0..n_merges {
1128
+ let a = TokenId(rng.below(id_range as u64) as u32);
1129
+ let b = TokenId(rng.below(id_range as u64) as u32);
1130
+ merges.entry((a, b)).or_insert(TokenId(256 + i as u32));
1131
+ }
1132
+ merges
1133
+ }
1134
+
1135
+ /// The flat table must agree with the hashbrown map on every merge pair
1136
+ /// and return the no-merge sentinel everywhere else.
1137
+ #[test]
1138
+ fn pair_rank_table_matches_map() {
1139
+ let mut rng = Rng(0x1234_5678_9ABC_DEF0);
1140
+ let id_range = 4096u32;
1141
+ let merges = random_merges(&mut rng, 3000, id_range);
1142
+ let table = PairRankTable::build(&merges, None, id_range as usize).expect("build");
1143
+ for (&(a, b), &m) in &merges {
1144
+ assert_eq!(table.rank(a, b), m.0, "pair ({}, {})", a.0, b.0);
1145
+ }
1146
+ for _ in 0..100_000 {
1147
+ let a = TokenId(rng.below(id_range as u64) as u32);
1148
+ let b = TokenId(rng.below(id_range as u64) as u32);
1149
+ let expected = merges.get(&(a, b)).map_or(u32::MAX, |m| m.0);
1150
+ assert_eq!(table.rank(a, b), expected, "pair ({}, {})", a.0, b.0);
1151
+ }
1152
+ // Oversized IDs must refuse the table, not corrupt it.
1153
+ assert!(PairRankTable::build(&merges, None, (1 << PAIR_ID_BITS) + 1).is_none());
1154
+ let mut big = merges.clone();
1155
+ big.insert((TokenId(1 << PAIR_ID_BITS), TokenId(0)), TokenId(300));
1156
+ assert!(PairRankTable::build(&big, None, id_range as usize).is_none());
1157
+ }
1158
+
1159
+ /// The stack-array short merges (scalar and NEON) must produce exactly
1160
+ /// the sequence of the Vec-based merge loop — same merges, same order,
1161
+ /// same tie-breaks — across random symbol sequences and merge tables.
1162
+ #[test]
1163
+ fn short_merges_match_vec_merge_loop() {
1164
+ let mut rng = Rng(0xDEAD_BEEF_0BAD_F00D);
1165
+ for trial in 0..2000 {
1166
+ let id_range = 300 + (trial % 7) as u32 * 500;
1167
+ let merges = random_merges(&mut rng, 200 + trial % 800, id_range);
1168
+ let table =
1169
+ PairRankTable::build(&merges, None, id_range as usize + 1024).expect("build");
1170
+ let n = 2 + rng.below(14) as usize; // 2..=15
1171
+ let init: Vec<TokenId> = (0..n)
1172
+ .map(|_| TokenId(rng.below(id_range as u64) as u32))
1173
+ .collect();
1174
+
1175
+ let mut reference = init.clone();
1176
+ bpe_merge_symbols_with_scratch(&merges, &mut reference, &mut MergeScratch::default());
1177
+
1178
+ let mut scalar = [TokenId(0); SHORT_MERGE_MAX];
1179
+ scalar[..n].copy_from_slice(&init);
1180
+ let len = bpe_merge_symbols_short_scalar(
1181
+ |a, b| table.rank(a, b),
1182
+ |a, b| table.prefetch_rank(a, b),
1183
+ &mut scalar,
1184
+ n,
1185
+ );
1186
+ assert_eq!(&scalar[..len], &reference[..], "scalar diverged: {init:?}");
1187
+
1188
+ #[cfg(target_arch = "aarch64")]
1189
+ {
1190
+ let mut neon = [TokenId(0); SHORT_MERGE_MAX];
1191
+ neon[..n].copy_from_slice(&init);
1192
+ let len = bpe_merge_symbols_short_neon(&table, &mut neon, n);
1193
+ assert_eq!(&neon[..len], &reference[..], "neon diverged: {init:?}");
1194
+ }
1195
+
1196
+ // Exercise both x86 tiers explicitly (not just the dispatcher's
1197
+ // pick) so an AVX-512 box still validates the AVX2 arm.
1198
+ #[cfg(target_arch = "x86_64")]
1199
+ {
1200
+ if std::arch::is_x86_feature_detected!("avx512f") {
1201
+ let mut v = [TokenId(0); SHORT_MERGE_MAX];
1202
+ v[..n].copy_from_slice(&init);
1203
+ // SAFETY: runtime AVX-512F detection right above.
1204
+ let len = unsafe { bpe_merge_symbols_short_avx512(&table, &mut v, n) };
1205
+ assert_eq!(&v[..len], &reference[..], "avx512 diverged: {init:?}");
1206
+ }
1207
+ if std::arch::is_x86_feature_detected!("avx2") {
1208
+ let mut v = [TokenId(0); SHORT_MERGE_MAX];
1209
+ v[..n].copy_from_slice(&init);
1210
+ // SAFETY: runtime AVX2 detection right above.
1211
+ let len = unsafe { bpe_merge_symbols_short_avx2(&table, &mut v, n) };
1212
+ assert_eq!(&v[..len], &reference[..], "avx2 diverged: {init:?}");
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+ }