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
@@ -0,0 +1,495 @@
1
+ //! Open-addressing cache for short (≤ 15 byte) pretoken encodings. Three
2
+ //! properties of the encode loop drive the design (measured on 1 GB OWT,
3
+ //! Zen 2):
4
+ //!
5
+ //! - The table holds ~1.3M unique pretokens (~99.4% hit rate), far beyond
6
+ //! L2/L3, so a lookup in the Zipf tail is a random DRAM access. hashbrown
7
+ //! spends two cache lines per probe (control bytes + entry); this table's
8
+ //! 32-byte entries are self-contained and bucketed into line-aligned
9
+ //! pairs, so a probe touches exactly one line, and the two prefetch
10
+ //! flavors let the encode loop stage that line ([`Self::prefetch_l2`] a
11
+ //! chunk ahead, [`Self::prefetch`] a few probes ahead) instead of
12
+ //! stalling on it.
13
+ //! - 228M output tokens / 208M pretokens: ~90% of pretokens encode to ONE
14
+ //! token and ~98% to at most two. The value is a packed `u64` plus an
15
+ //! extension word (see `tiktoken::pack_val_inline`) holding up to four
16
+ //! tokens inline — one dependent load, and no second random access into
17
+ //! the token arena.
18
+ //! - At ~64 MB the table also blows the dTLB through 4 KiB pages, so the
19
+ //! backing memory is 2 MiB-aligned and `MADV_HUGEPAGE`d — with THP
20
+ //! available the whole table sits in a few dozen dTLB entries. (Note:
21
+ //! processes launched under `PR_SET_THP_DISABLE` — some sandboxes and
22
+ //! session managers do this — silently get 4 KiB pages anyway.)
23
+ //!
24
+ //! Linear probing over aligned pairs: a bucket is slots `idx` and `idx + 1`
25
+ //! with `idx` even, so both share one 64 B line, and [`Self::probe_pair`]
26
+ //! resolves the overwhelmingly common displacement-0/1 hit branch-free from
27
+ //! that single line. Inserts fill the first empty slot of the walk; growth
28
+ //! doubles at 3/4 load. Key 0 marks empty slots: a real key always has its
29
+ //! nonzero length in the top byte (`pack_pretoken_key` tags length; empty
30
+ //! pretokens pack to key 0, which the encode loop routes to the long map,
31
+ //! never here).
32
+
33
+ use std::alloc::{Layout, alloc, dealloc, handle_alloc_error};
34
+ use std::ptr::NonNull;
35
+
36
+ /// One slot: the packed pretoken key plus its packed encoding — `val`
37
+ /// (count, spill flag, tokens 1-2) and `ext` (tokens 3-4, see
38
+ /// `tiktoken::pack_val_inline`). Exactly 32 bytes: two slots per cache
39
+ /// line, never straddling one.
40
+ #[derive(Clone, Copy)]
41
+ #[repr(C)]
42
+ struct Entry {
43
+ key: u128,
44
+ val: u64,
45
+ ext: u64,
46
+ }
47
+
48
+ const _: () = assert!(std::mem::size_of::<Entry>() == 32);
49
+
50
+ const EMPTY_KEY: u128 = 0;
51
+
52
+ /// The table's slot array: a manually managed, zeroed (== all-empty,
53
+ /// since `EMPTY_KEY` is 0), 2 MiB-aligned allocation marked
54
+ /// `MADV_HUGEPAGE`. A plain `Box<[Entry]>` can neither over-align nor
55
+ /// keep dealloc's layout in sync with an over-aligned alloc.
56
+ struct Slots {
57
+ ptr: NonNull<Entry>,
58
+ cap: usize,
59
+ }
60
+
61
+ impl Slots {
62
+ const HUGE_PAGE: usize = 2 * 1024 * 1024;
63
+
64
+ fn new_zeroed(cap: usize) -> Self {
65
+ let layout = Self::layout(cap);
66
+ // SAFETY: layout has nonzero size (cap >= 1).
67
+ let raw = unsafe { alloc(layout) };
68
+ let Some(ptr) = NonNull::new(raw as *mut Entry) else {
69
+ handle_alloc_error(layout)
70
+ };
71
+ // Hint huge pages BEFORE first touch. `alloc_zeroed` on a 2 MiB-
72
+ // aligned layout is aligned_alloc + an explicit memset that faults
73
+ // the whole fresh mapping in as 4 KiB pages, after which the hint
74
+ // is a no-op for this run (khugepaged collapses far too slowly to
75
+ // matter): the table then walks the dTLB on every probe, and Zen
76
+ // drops software prefetches that miss the dTLB — measured +15%
77
+ // cold / +7% warm encode from this ordering alone (see
78
+ // profiling/zen5_st_profile.md §3). Madvised first, the zeroing
79
+ // write below faults it in as 2 MiB pages.
80
+ super::madvise_hugepage(raw, layout.size());
81
+ // SAFETY: raw is a live allocation of exactly layout.size() bytes.
82
+ unsafe { std::ptr::write_bytes(raw, 0, layout.size()) };
83
+ Self { ptr, cap }
84
+ }
85
+
86
+ fn layout(cap: usize) -> Layout {
87
+ let size = cap * std::mem::size_of::<Entry>();
88
+ // Huge-page alignment only once the table outgrows one huge page;
89
+ // small tables (fresh tokenizers encoding little text) stay modest.
90
+ // Floor of 64 so an even-indexed pair always shares one cache line.
91
+ let align = Self::HUGE_PAGE.min(size.next_power_of_two()).max(64);
92
+ Layout::from_size_align(size, align).expect("table layout overflow")
93
+ }
94
+
95
+ #[inline(always)]
96
+ unsafe fn get(&self, idx: usize) -> &Entry {
97
+ debug_assert!(idx < self.cap);
98
+ // SAFETY: caller guarantees idx < cap.
99
+ unsafe { &*self.ptr.as_ptr().add(idx) }
100
+ }
101
+
102
+ #[inline(always)]
103
+ unsafe fn get_mut(&mut self, idx: usize) -> &mut Entry {
104
+ debug_assert!(idx < self.cap);
105
+ // SAFETY: caller guarantees idx < cap.
106
+ unsafe { &mut *self.ptr.as_ptr().add(idx) }
107
+ }
108
+ }
109
+
110
+ impl Drop for Slots {
111
+ fn drop(&mut self) {
112
+ // SAFETY: allocated in `new_zeroed` with this exact layout.
113
+ unsafe { dealloc(self.ptr.as_ptr() as *mut u8, Self::layout(self.cap)) };
114
+ }
115
+ }
116
+
117
+ // SAFETY: Slots owns its allocation exclusively, like Box<[Entry]>.
118
+ unsafe impl Send for Slots {}
119
+ unsafe impl Sync for Slots {}
120
+
121
+ /// Request the line holding `p` into L1 (`L1 = true`) or L2 only — the
122
+ /// shared ladder behind [`ShortPretokenCache::prefetch_l2`] and
123
+ /// [`ProbeView::prefetch`]. No-op on arches without a prefetch hint.
124
+ #[inline(always)]
125
+ fn prefetch_line<const L1: bool>(p: *const Entry) {
126
+ #[cfg(target_arch = "x86_64")]
127
+ // SAFETY: prefetch has no memory effects; any address is allowed.
128
+ unsafe {
129
+ use core::arch::x86_64::{_MM_HINT_T0, _MM_HINT_T1, _mm_prefetch};
130
+ if L1 {
131
+ _mm_prefetch(p as *const i8, _MM_HINT_T0);
132
+ } else {
133
+ _mm_prefetch(p as *const i8, _MM_HINT_T1);
134
+ }
135
+ }
136
+ #[cfg(target_arch = "aarch64")]
137
+ // SAFETY: prefetch has no memory effects; any address is allowed.
138
+ unsafe {
139
+ if L1 {
140
+ core::arch::asm!(
141
+ "prfm pldl1keep, [{p}]",
142
+ p = in(reg) p,
143
+ options(nostack, preserves_flags, readonly)
144
+ );
145
+ } else {
146
+ core::arch::asm!(
147
+ "prfm pldl2keep, [{p}]",
148
+ p = in(reg) p,
149
+ options(nostack, preserves_flags, readonly)
150
+ );
151
+ }
152
+ }
153
+ #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
154
+ let _ = p;
155
+ }
156
+
157
+ pub(crate) struct ShortPretokenCache {
158
+ slots: Slots,
159
+ /// `cap - 1` (capacity is a power of two).
160
+ mask: usize,
161
+ len: usize,
162
+ }
163
+
164
+ impl ShortPretokenCache {
165
+ fn with_pow2_capacity(cap: usize) -> Self {
166
+ debug_assert!(cap.is_power_of_two() && cap >= 2);
167
+ Self { slots: Slots::new_zeroed(cap), mask: cap - 1, len: 0 }
168
+ }
169
+
170
+ /// A table sized to hold at least `n` entries without growing (same
171
+ /// 3/4-load threshold as [`Self::insert`], with a 2^16-slot
172
+ /// floor), starting from at least `min_slots` slots. The
173
+ /// vocab-seeding path inserts ~50k entries up front; sizing for them
174
+ /// avoids rehashing mid-seed. `min_slots` lets a parallel worker start
175
+ /// at the final capacity expected for its share of the input (see
176
+ /// `Tokenizer::fork_sized`), skipping the doubling-rehash churn of a
177
+ /// cold run — a starting point only, the table still grows past it at
178
+ /// 3/4 load. Either way the table is constructed exactly once, at the
179
+ /// max of the two requirements.
180
+ pub(crate) fn with_at_least(n: usize, min_slots: usize) -> Self {
181
+ let mut cap = min_slots.max(1 << 16).next_power_of_two();
182
+ while (n + 1) * 4 > cap * 3 {
183
+ cap *= 2;
184
+ }
185
+ Self::with_pow2_capacity(cap)
186
+ }
187
+
188
+ /// Address of `h`'s home pair; both slots share the addressed line.
189
+ #[inline(always)]
190
+ fn pair_ptr(&self, h: u64) -> *const Entry {
191
+ // SAFETY: the masked even index is <= mask - 1 < cap.
192
+ unsafe { self.slots.ptr.as_ptr().add((h as usize) & self.mask & !1) }
193
+ }
194
+
195
+ /// Request the probe's cache line into L2 only. The encode loop calls
196
+ /// this a full chunk (hundreds of cycles) before the probe — enough to
197
+ /// cover DRAM — without evicting the span walker's L1 working set the
198
+ /// way a chunk's worth of L1 prefetches would.
199
+ #[inline(always)]
200
+ pub(crate) fn prefetch_l2(&self, h: u64) {
201
+ prefetch_line::<false>(self.pair_ptr(h));
202
+ }
203
+
204
+ /// A raw, `Copy` snapshot of the probe parameters for the emit loop's
205
+ /// hot path. Holding one across a chunk keeps the table base and mask
206
+ /// in registers instead of being reloaded from `self` every iteration
207
+ /// (the slow path's `&mut self` calls otherwise force the reload).
208
+ /// Invalidated by [`Self::insert`] (which may grow the table): callers
209
+ /// must take a fresh view after any insert.
210
+ pub(crate) fn probe_view(&self) -> ProbeView {
211
+ ProbeView { base: self.slots.ptr.as_ptr(), pair_mask: self.mask & !1 }
212
+ }
213
+
214
+ /// Look up `key`, walking pairs from its home bucket. Inserts fill the
215
+ /// first empty slot of the walk, so any pair holding an empty slot
216
+ /// terminates it. A miss (`Err`) also reports where the key belongs —
217
+ /// the first empty slot of the walk, which is exactly what
218
+ /// [`Self::first_empty`] would find (every pair before the
219
+ /// terminating one was full), discovered by loads the lookup performs
220
+ /// anyway. [`Self::insert_at`] then skips re-walking the chain. The
221
+ /// slot stays valid until the next insert or grow: lookups never
222
+ /// mutate, and the encode miss path computes the entry's value
223
+ /// without touching the table.
224
+ pub(crate) fn get_or_slot(&self, key: u128, h: u64) -> Result<(u64, u64), usize> {
225
+ debug_assert_ne!(key, EMPTY_KEY);
226
+ let mut idx = (h as usize) & self.mask & !1;
227
+ loop {
228
+ // SAFETY: idx is masked and even, so idx + 1 <= mask.
229
+ let e0 = unsafe { self.slots.get(idx) };
230
+ let e1 = unsafe { self.slots.get(idx + 1) };
231
+ if e0.key == key {
232
+ return Ok((e0.val, e0.ext));
233
+ }
234
+ if e1.key == key {
235
+ return Ok((e1.val, e1.ext));
236
+ }
237
+ if e0.key == EMPTY_KEY {
238
+ return Err(idx);
239
+ }
240
+ if e1.key == EMPTY_KEY {
241
+ return Err(idx + 1);
242
+ }
243
+ idx = (idx + 2) & self.mask;
244
+ }
245
+ }
246
+
247
+ /// First empty slot of `h`'s pair walk (load < 1 guarantees one).
248
+ fn first_empty(&self, h: u64) -> usize {
249
+ let mut idx = (h as usize) & self.mask & !1;
250
+ loop {
251
+ // SAFETY: idx is masked and even, so idx + 1 <= mask.
252
+ unsafe {
253
+ if self.slots.get(idx).key == EMPTY_KEY {
254
+ return idx;
255
+ }
256
+ if self.slots.get(idx + 1).key == EMPTY_KEY {
257
+ return idx + 1;
258
+ }
259
+ }
260
+ idx = (idx + 2) & self.mask;
261
+ }
262
+ }
263
+
264
+ /// Insert a key known to be absent (the encode loop only inserts after
265
+ /// a [`Self::get_or_slot`] miss).
266
+ pub(crate) fn insert(&mut self, key: u128, h: u64, val: u64, ext: u64) {
267
+ debug_assert_ne!(key, EMPTY_KEY);
268
+ if (self.len + 1) * 4 > self.slots.cap * 3 {
269
+ self.grow();
270
+ }
271
+ let idx = self.first_empty(h);
272
+ // SAFETY: first_empty returns an in-bounds index.
273
+ unsafe { *self.slots.get_mut(idx) = Entry { key, val, ext } };
274
+ self.len += 1;
275
+ }
276
+
277
+ /// [`Self::insert`] with the destination already known from a
278
+ /// [`Self::get_or_slot`] miss on the same `key`/`h` (with no insert or
279
+ /// grow in between), skipping the `first_empty` chain walk. A growth
280
+ /// pass invalidates `slot`, so that branch recomputes it.
281
+ pub(crate) fn insert_at(&mut self, slot: usize, key: u128, h: u64, val: u64, ext: u64) {
282
+ debug_assert_ne!(key, EMPTY_KEY);
283
+ let mut slot = slot;
284
+ if (self.len + 1) * 4 > self.slots.cap * 3 {
285
+ self.grow();
286
+ slot = self.first_empty(h);
287
+ }
288
+ debug_assert_eq!(slot, self.first_empty(h));
289
+ // SAFETY: get_or_slot and first_empty return in-bounds indices.
290
+ unsafe { *self.slots.get_mut(slot) = Entry { key, val, ext } };
291
+ self.len += 1;
292
+ }
293
+
294
+ /// Insert `key`, overwriting its value if the key is already present
295
+ /// (the plain [`Self::insert`] assumes absence). Cold loader-phase
296
+ /// entry point for the vocab-seed sync (`set_added_tokens`' overwrite
297
+ /// and restore loops and `fork_sized`'s added-token re-apply), where
298
+ /// an added token's content can duplicate an already-seeded vocab
299
+ /// byte string and must take over its entry.
300
+ pub(crate) fn replace(&mut self, key: u128, h: u64, val: u64, ext: u64) {
301
+ debug_assert_ne!(key, EMPTY_KEY);
302
+ let mut idx = (h as usize) & self.mask & !1;
303
+ loop {
304
+ // SAFETY: idx is masked and even, so idx + 1 <= mask.
305
+ let (k0, k1) = unsafe { (self.slots.get(idx).key, self.slots.get(idx + 1).key) };
306
+ if k0 == key {
307
+ // SAFETY: idx is in bounds (masked above).
308
+ unsafe { *self.slots.get_mut(idx) = Entry { key, val, ext } };
309
+ return;
310
+ }
311
+ if k1 == key {
312
+ // SAFETY: idx + 1 <= mask (masked, even idx).
313
+ unsafe { *self.slots.get_mut(idx + 1) = Entry { key, val, ext } };
314
+ return;
315
+ }
316
+ if k0 == EMPTY_KEY || k1 == EMPTY_KEY {
317
+ // Absent: a fresh insert (with its own growth check).
318
+ self.insert(key, h, val, ext);
319
+ return;
320
+ }
321
+ idx = (idx + 2) & self.mask;
322
+ }
323
+ }
324
+
325
+ #[cold]
326
+ fn grow(&mut self) {
327
+ let new_cap = self.slots.cap * 2;
328
+ let old = std::mem::replace(&mut self.slots, Slots::new_zeroed(new_cap));
329
+ self.mask = new_cap - 1;
330
+ for i in 0..old.cap {
331
+ // SAFETY: i < old.cap.
332
+ let e = *unsafe { old.get(i) };
333
+ if e.key == EMPTY_KEY {
334
+ continue;
335
+ }
336
+ // Must be the same hash the inserts' `h` came from.
337
+ let idx = self.first_empty(crate::pretokenize::pretoken_key_hash(e.key));
338
+ // SAFETY: first_empty returns an in-bounds index.
339
+ unsafe { *self.slots.get_mut(idx) = e };
340
+ }
341
+ }
342
+
343
+ pub(crate) fn len(&self) -> usize {
344
+ self.len
345
+ }
346
+
347
+ pub(crate) fn capacity(&self) -> usize {
348
+ self.slots.cap
349
+ }
350
+ }
351
+
352
+ /// See [`ShortPretokenCache::probe_view`]. The pointer is borrowed from
353
+ /// the cache's live allocation; a view taken before an insert may dangle
354
+ /// after it (inserts can grow), so views are chunk-scoped.
355
+ #[derive(Clone, Copy)]
356
+ pub(crate) struct ProbeView {
357
+ base: *const Entry,
358
+ /// `slot mask & !1`, pre-folded: the emit loop computes a pair address
359
+ /// from this on every probe AND every prefetch, and the fold keeps one
360
+ /// ALU op (and one live temp in a loop that already spills) off the
361
+ /// probe-address critical path.
362
+ pair_mask: usize,
363
+ }
364
+
365
+ impl ProbeView {
366
+ /// Address of `h`'s home pair; both slots share the addressed line.
367
+ #[inline(always)]
368
+ fn pair_ptr(&self, h: u64) -> *const Entry {
369
+ // SAFETY: the masked even index is <= pair_mask <= cap - 2.
370
+ unsafe { self.base.add((h as usize) & self.pair_mask) }
371
+ }
372
+
373
+ /// Request the probe's cache line into L1, a few probes ahead of
374
+ /// [`Self::probe_pair`] (covers the L2 hit latency; the line was staged
375
+ /// into L2 by [`ShortPretokenCache::prefetch_l2`] a chunk earlier).
376
+ #[inline(always)]
377
+ pub(crate) fn prefetch(&self, h: u64) {
378
+ prefetch_line::<true>(self.pair_ptr(h));
379
+ }
380
+
381
+ /// Branchless probe of `key`'s home pair: both compares fold into one
382
+ /// `found` flag and two selects, touching exactly one cache line. On
383
+ /// `!found` the returned value lanes are another entry's (the emit
384
+ /// loop's predicate discards them); keys displaced past their pair and
385
+ /// genuine misses both come back `!found` — the slow path disambiguates
386
+ /// via [`ShortPretokenCache::get_or_slot`]. Callers must not pass `key == 0`
387
+ /// expecting a miss: empty slots compare equal to it (the emit
388
+ /// predicate carries its own `key != 0` term).
389
+ ///
390
+ /// The selects run over unconditionally loaded `val`/`ext` of BOTH
391
+ /// slots so they are register-value selects. Every pure-Rust spelling
392
+ /// (`if`, mask arithmetic) gets canonicalized by LLVM into an address
393
+ /// select — csel of a slot pointer feeding a second, dependent load —
394
+ /// putting an extra L1 latency on the probe's critical path (the next
395
+ /// thing waiting on `val` is the emit store and the cursor advance,
396
+ /// the loop's only carried dependency), so on aarch64 the select is
397
+ /// two asm `csel`s. Loading all four words up front costs two more
398
+ /// loads per probe from the same already-touched line, all issued in
399
+ /// parallel, none dependent on the compares.
400
+ #[inline(always)]
401
+ pub(crate) fn probe_pair(&self, key: u128, h: u64) -> (u64, u64, bool) {
402
+ let p = self.pair_ptr(h);
403
+ // SAFETY: pair_ptr's index is masked and even, so idx + 1 <= mask;
404
+ // the base is live for the view's chunk (see type docs).
405
+ let (e0, e1) = unsafe { (&*p, &*p.add(1)) };
406
+ let m0 = e0.key == key;
407
+ let m1 = e1.key == key;
408
+ #[cfg(target_arch = "aarch64")]
409
+ let (val, ext) = {
410
+ let (mut val, mut ext) = (e0.val, e0.ext);
411
+ // SAFETY: register-only conditional selects; no memory access,
412
+ // no stack use (NZCV is clobbered, which the default options
413
+ // already declare).
414
+ unsafe {
415
+ core::arch::asm!(
416
+ "cmp {m}, #0",
417
+ "csel {val}, {val}, {v1}, ne",
418
+ "csel {ext}, {ext}, {x1}, ne",
419
+ m = in(reg) m0 as u64,
420
+ val = inout(reg) val,
421
+ ext = inout(reg) ext,
422
+ v1 = in(reg) e1.val,
423
+ x1 = in(reg) e1.ext,
424
+ options(pure, nomem, nostack),
425
+ );
426
+ }
427
+ (val, ext)
428
+ };
429
+ #[cfg(target_arch = "x86_64")]
430
+ let (val, ext) = {
431
+ // LLVM canonicalizes every pure-Rust spelling into an
432
+ // address-cmov feeding a dependent load — the extra L1 latency
433
+ // this function exists to avoid; the asm pins register-value
434
+ // `cmovne`s over the four unconditionally loaded words instead.
435
+ // cmov is baseline x86-64 (no feature gate); evidence in
436
+ // profiling/x86_port_plan.md §1.1.
437
+ let (mut val, mut ext) = (e1.val, e1.ext);
438
+ // SAFETY: register-only test + conditional moves; no memory
439
+ // access, no stack use.
440
+ unsafe {
441
+ core::arch::asm!(
442
+ "test {m}, {m}",
443
+ "cmovne {val}, {v0}",
444
+ "cmovne {ext}, {x0}",
445
+ m = in(reg) m0 as u64,
446
+ val = inout(reg) val,
447
+ ext = inout(reg) ext,
448
+ v0 = in(reg) e0.val,
449
+ x0 = in(reg) e0.ext,
450
+ options(pure, nomem, nostack),
451
+ );
452
+ }
453
+ (val, ext)
454
+ };
455
+ #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
456
+ let (val, ext) = {
457
+ let sel = (m0 as u64).wrapping_neg();
458
+ (
459
+ (e0.val & sel) | (e1.val & !sel),
460
+ (e0.ext & sel) | (e1.ext & !sel),
461
+ )
462
+ };
463
+ (val, ext, m0 | m1)
464
+ }
465
+ }
466
+
467
+ #[cfg(test)]
468
+ mod tests {
469
+ use super::*;
470
+ use crate::pretokenize::pretoken_key_hash;
471
+
472
+ /// The encode miss path inserts at the slot its failed lookup
473
+ /// reported. Drive that exact flow across several growth passes and
474
+ /// verify every entry stays retrievable — i.e. `get_or_slot`'s miss
475
+ /// slot always agrees with where `insert` would have placed the key.
476
+ #[test]
477
+ fn get_or_slot_insert_at_roundtrip() {
478
+ let mut cache = ShortPretokenCache::with_pow2_capacity(64);
479
+ // Multiplier chosen to scatter keys; count forces multiple grows
480
+ // from the 64-slot start (grow threshold is 3/4 load).
481
+ let keys: Vec<u128> = (1u128..=500).map(|i| i.wrapping_mul(0x9E37_79B9)).collect();
482
+ for (i, &key) in keys.iter().enumerate() {
483
+ let h = pretoken_key_hash(key);
484
+ match cache.get_or_slot(key, h) {
485
+ Ok(_) => panic!("key {i} present before insert"),
486
+ Err(slot) => cache.insert_at(slot, key, h, i as u64, !(i as u64)),
487
+ }
488
+ }
489
+ assert_eq!(cache.len(), keys.len());
490
+ for (i, &key) in keys.iter().enumerate() {
491
+ let h = pretoken_key_hash(key);
492
+ assert_eq!(cache.get_or_slot(key, h), Ok((i as u64, !(i as u64))), "key {i}");
493
+ }
494
+ }
495
+ }