Uzlabot unigram tokenizer CPU veiktspēju
Mēs no jauna izstrādājām savu Unigram tokenizer no nulles kā mērķtiecīgu veiktspējas projektu.
Mēs no jauna izstrādājām savu Unigram tokenizer no nulles kā mērķtiecīgu veiktspējas projektu. Pie produkcijas ievades garumiem jaunais kodētājs samazina p50 latentumu par aptuveni 5× salīdzinājumā ar Hugging Face tokenizers crate, ~2× salīdzinājumā ar SentencePiece (C++) un ~1.5× salīdzinājumā ar IREE tokenizer (C), ar nulles stacionārā stāvokļa kaudzes alokācijām. Produkcijā tas samazināja CPU izmantošanu mūsu secināšanas steks par 5-6× un samazināja rerankera latentumu par divciparu milisekundēm.
Motivācija
LLM secināšanas izmaksas parasti tiek definētas kā GPU darbs: KV kešatmiņas, uzmanības kodoli, ekspertu maršrutēšana. Taču nozīmīga produkcijas datu plūsmas daļa darbojas ar daudz mazākiem modeļiem, kas ir par divām līdz trim lieluma pakāpēm mazāki nekā robežlīniju transformatori. Iegulšanas modeļi, klasifikatori un pārrankotāji joprojām darbojas uz GPU, bet pārsūtīšanas solis ir pietiekami īss, lai viss ap to sāktu iegūt nozīmi.
Visuzskatāmākais piemērs ir pārrankotājs, kas novērtē simtiem kandidātu dokumentu uz vienu pieprasījumu. Ar nelielu modeli GPU skaitļošana parasti beidzas viena cipara milisekundēs, taču katrai ievadei joprojām vispirms jāiziet caur CPU puses tokenizāciju, pārvēršot tekstu vārdnīcas ID. Kad pakete ir liela, tas var veidot nozīmīgu daļu no kopējā pieprasījuma latentuma.
Šis darbs ir vērsts uz XLM-RoBERTa — 250K tokenu Unigram vārdnīcu, kas apmācīta ar SentencePiece. Smalki noregulēti RoBERTa saimes kodētāji ir izplatīta izvēle ranžēšanas, izgūšanas un līdzības uzdevumiem. Mēs visā tekstā ziņojam par vienpavediena latentumu uz vienu kodēšanu; produkcijā mēs paralelizējam starp kodoliem, izmantojot rayon.
Visā šajā ierakstā mēs uzskatām Hugging Face tokenizers crate par atsauci, pret kuru veicam salīdzinošo testēšanu, jo tas ir noklusējuma Rust tokenizer, kuru izmanto lielākā daļa komandu. SentencePiece (C++) un IREE (C) ir iekļauti salīdzināšanai kā divas citas galvenās produkcijas implementācijas.
Viterbi algoritms
Unigram tokenizācija, ko 2018. gadā ieviesa Kudo (Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates) un ieviesa SentencePiece, segmentāciju strukturē kā visiespējamākā ceļa problēmu: katram vārdnīcas tokenam ir iemācīta žurnāla varbūtība, un kodētājs izvēlas segmentāciju, kuras tokenu rādītāji summējas līdz augstākajai vērtībai. Problēma tiek atrisināta ar Viterbi algoritmu (1967), kur baitu pozīcijas ir grafiku slāņi un vārdnīcas toniki ir malas, kas aptver nepārtrauktu baitu diapazonu. Rekurence ir viendimensijas DP pāri baitu pozīcijām:
best = [-inf] * (n + 1)
best[0] = 0
back = [None] * (n + 1)
for i in 0..n:
if best[i] == -inf:
continue
if byte i is a UTF-8 continuation byte:
continue
walk the vocabulary trie from byte position i:
for each matching token t ending at position j:
cand = best[i] + score(t)
if cand > best[j]:
best[j] = cand
back[j] = (i, token_id(t))
if no token matched, fall back to <unk>
backtrack from position n using back[] to recover token IDsĀrējais cikls ir lineārs attiecībā pret ievades garumu, un katrā pozīcijā to ierobežo garākais vārdnīcas tokens (48 UTF-8 baiti XLM-RoBERTa gadījumā). Iekšējā trie staigāšana ir karstais ceļš: 16k tokenu ievadē tā veic simtiem tūkstošu pāreju un izbeidz desmitiem miljonu instrukciju uz vienu kodējumu.
Atsauces implementācija
Hugging Face tokenizers v0.22 tieši implementē rekursiju, izmantojot jaucējtabulu apgrieztajai meklēšanai un jaucējtabulas bērnus katrā trie mezglā:
vocab: Vec<(String, f64)> // indexed by token ID
token_to_ids: AHashMap<String, u32> // reverse lookup
trie: Trie<u8> // byte-level prefix trie
struct Node<Label> {
is_leaf: bool,
children: AHashMap<Label, Node<Label>>, // heap-allocated map per node
}Un iekšējais cikls (vienkāršots) – katra prefiksa meklēšanas sakritība alocē jaunu virkni un pēc tam pārbauda jaucējtabulu:
fn encode_optimized(&self, sentence: &str) -> Result<Vec<String>> {
// alloc
let mut best = vec![BestPathNode::default(); sentence.len() + 1];
let mut starts_at = 0;
while starts_at < sentence.len() {
for tok_bytes in self.trie.common_prefix_search(sentence.bytes().skip(starts_at)) {
// alloc per match
let token: String = String::from_utf8(tok_bytes).unwrap();
// hash lookup
let id = self.token_to_ids.get(&token).unwrap();
let score = self.vocab[*id as usize].1;
// DP update at best[starts_at + tok_bytes.len()]
// ...
}
starts_at += mblen;
}
// backtrace into Vec<String>, one allocation per output token
}Kods ir salasāms Rust, taču tiek paveikts daudz darba, kas ir neredzams avota līmenī. Nākamajā sadaļā ir aprakstīts, kur pazūd laiks.
Piezīme par priekšapstrādi
Pirms Viterbi palaišanas ievade iziet cauri Unicode normalizācijai (NFKC) un vārdu robežu marķēšanai. Profilēšana uzrāda, ka priekšapstrāde aizņem mazāk nekā 5% no kopējā kodēšanas laika mūsu scenārijos, tāpēc atlikušajā ieraksta daļā mēs koncentrējamies uz Viterbi pārsūtīšanas soli.
Kur atsauces implementācija tērē savu laiku
Ir divi veidi, kā paātrināt CPU kodu: izpildīt mazāk instrukciju vai iegūt augstāku IPC. Cachegrind mēra pirmo, perf stat mēra otro. Katra optimizācija šajā ierakstā attiecas uz vienu vai otru.
CPU apstājas četru lietu dēļ: kešatmiņas trūkumu (L1d ~4 cikli, L3 trūkums līdz DRAM ir simtiem), atkarīgas slodzes ķēžu, kuras ārpus secības dzinējs nevar paralelizēt, zaru nepareizas paredzēšanas (ap 15-20 cikliem tiek iztīrīti uz katru nepareizo minējumu) un alokatora izsaukumu. Atsauces unigram dzinēja implementācija skar visas četras:
Sastrēgumvietas | Mehānisms | Izmērītā ietekme |
Alokācija uz sakritību | String::from_utf8 uz saskaņotajiem baitiem plus AHashMap meklēšana uz katru trie sakritību; tokena ID jau atrodas lapā, bet atsauce to tur neuzglabā | 7295 alokācijas pie 512 tokeniem, 14 542 pie 1k, 299 171 pie 16k |
Rādītāja dzīšanās uz baitu | AHashMap katrā trie mezglā, veicot nākamā baita jaucējkodu un atsaucot divus rādītājus uz soli; Node struktūra arī apvieno aukstos laukus, piemēram, is_leaf, ievelkot tos kešatmiņā katrā apmeklējumā | L1d trūkumu līmenis saglabājas zems (~2.5% pie 1k), jo alokatora karstās lapas atrodas L1; izmaksas parādās kā atkarīgās slodzes latentums |
L2 trāpīšana garām garām ievadēm | DP tabula, prefiksa melnraksts un izvades tokenu saraksts tiek no jauna alocēti katrā izsaukumā | L2 trūkumu līmenis pieaug no 12% pie 128 tokeniem līdz 50% pie 16k, jo kumulatīvā alokācijas pēda pārsniedz katra kodola 2 MB L2 |
Atsauces tokenizatora šaurās vietas, mehānismi un izmērītā ietekme.
Dominējošās izmaksas ir alokācija uz sakritību. Pie 1k tokeniem atsauces implementācija izbeidz (pabeidz) aptuveni 7M instrukciju uz vienu kodējumu ar IPC 2.8, lielāko daļu no tām alokatora izsaukumos un virkņu konvertācijās. L2 trūkumi ir svarīgi tikai pēc ~1k tokeniem, kad kumulatīvā alokācijas pēda pārpilda katra kodola kešatmiņu.
Alokācija uz vienu tokenu ir konstante: aptuveni 2 KB un ~18 alokācijas uz vienu tokenu neatkarīgi no ievades lieluma. Latentuma nonlinearitāte rodas no L2: kumulatīvie baiti šķērso 2 MB ap 1k tokeniem, trūkumu līmenis pieaug no 8% līdz ~50%, un p99 līkne izliecas, lai sakristu.
Kā mēs mērījām
Mēs salīdzinām trīs produkcijas tokenizatorus, kuri visi izmanto to pašu Unigram algoritmu pret to pašu XLM-RoBERTa modeli:
Dzinējs | Valoda | Trie |
Hugging Face | Rust | HashMap-children prefiksa trie |
SentencePiece | C++ | Darts dubultā masīva trie |
IREE tokenizer | C | Darts dubultā masīva trie |
Salīdzinājumā izmantotās tokenizatoru implementācijas, valodas un trie attēlojumi.
Mēs saistām SentencePiece un IREE, izmantojot tiešu C/C++ FFI, nevis to Rust ietvarus; ietvari pievieno izsaukuma alokācijas un virkņu konvertācijas, kas nav daļa no pamata bibliotēkas izmaksām.
Visi dzinēji ir būvēti ar pilnām optimizācijām (O3, vietējo mērķēšanu, saistīšanas laika optimizāciju). Mērījumi tiek veikti, piespraužot vienam klusam kodolam Intel Xeon Platinum 8488C. Katram dzinējam un ievades lielumam mēs apkopojam sienas pulksteņa latentumu (p50 un p99, 10 000 iterācijas pēc 1000 iesildīšanās), instrukcijas, kas izbeigtas, izmantojot Valgrind cachegrind, IPC un kešatmiņas trūkumu rādītājus no perf stat aparatūras skaitītājiem, un kaudzes alokācijas, ko saskaita globālais alokatora ietvars ap izmērīto logu.
Pie 514 tokeniem (+2 virs 512 ir BOS/EOS injekcija):
Implementācija | p50 latentums | Instrukcijas | IPC | Alokācijas |
Hugging Face | 349 µs | 3.60M | 2.89 | 7295 |
SentencePiece | 128 µs | 1.83M | 3.39 | 1559 |
IREE | 112 µs | 2.28M | 3.06 | 1 |
Tokenizatora latentums, instrukciju skaits, IPC un alokācijas pie 514 tokeniem.
SP un IREE sasniedz aptuveni trešdaļu no atsauces latentuma. Tie izbeidz aptuveni divas reizes mazāk instrukciju, darbojas ar augstāku IPC un alocē par vienu līdz divām lieluma pakāpēm mazāk.
Kāpēc SentencePiece un IREE ir ātrāki: Dubultā masīva trie
SentencePiece un IREE abi izmanto dubultā masīva trie, ko 1989. gadā ieviesa Aoe (Aoe, 1989; Karoonboonyanan). Trie ir kodēts divos plakanos veselu skaitļu masīvos: base un check:
int base[N]; // base[node] = offset for this node's children
int check[N]; // check[child] == node validates the parent-child linkBērna meklēšana ir šāda:
int next = base[node] + byte;
if (check[next] == node) { /* valid transition; child node = next */ }Katrai trie šķērsošanas darbībai ir nepieciešami tikai divi masīva nolasījumi, viena vesela skaitļa saskaitīšana un viena salīdzināšana, bez jaucējkoda, bez rādītāja dzīšanas un bez mezgla kaudzes alokācijas. Konstrukcija novieto katru mezglu tā, ka tā bērni iekrīt brīvos slotos validācijas masīvā, ļaujot vairākiem vecākiem koplietot reģionus, ja vien viņu bērni atšķiras ar baitu, kuram tie seko.
XLM-RoBERTa 250K vārdnīcai visa trie ietilpst ~9 MB nepārtrauktā atmiņā (~780K sloti × 12 baiti offset, validācijai un token ID uz slotu). Viena kodējuma karstās darba kopas lielums ir ap 100 KB, kas ērti atrodas L2, un aparatūras priekšielādētājs fiksē regulāro piekļuves modeli.
Turpretim atsauce uztur bērnu jaucējtabulu katram mezglam, kas maksā aptuveni četras atkarīgas slodzes uz katru baitu soli. Veic baitu jaucējkodu, ielādē kausu, seko rādītājam uz kausa ierakstu, seko citam rādītājam uz bērna mezglu kaudzē, pēc tam konsultējas ar savu jaucējtabulu par nākamo baitu. Kaudzes objekti izkliedējas, un vairums slodžu netrāpa kešatmiņā.
SP un IREE abi glabā tokena ID tieši trie mezglā. Atsauces trie implementācija atgriež tikai saskaņotos baitus, tāpēc kodētājam ir jāalocē jauna virkne katrai sakritībai, jāmeklē tā blakus jaucējtabulā, lai atgūtu tokena ID, un jālasa rādītājs no atsevišķa vektora. SP atgriež ID kā trie saglabāto vērtību un nolasa rādītāju no plakanā pludiņa masīva:
// unigram_model.cc:567
const size_t num_nodes = trie_->commonPrefixSearch(
begin, trie_results.data(), trie_results.size(),
static_cast<int>(end - begin));
for (size_t k = 0; k < num_nodes; ++k) {
const int id = trie_results[k].value; // already in the trie
const float score = GetScoreInlined(id); // flat float[] indexed by id
Lattice::Node *node = lattice->Insert(begin_pos, length);
node->id = id;
node->score = score;
}IREE izmanto stāvokļa kursora API, nevis atgriež visas sakritības vienā izsaukumā, bet izmaksu modelis ir tāds pats: tokenu ID un rādītāji ir sasniedzami kā plakano masīvu indeksi, bez alokācijas vai jaucējkoda pašā trie pastaigā. IREE alocē tikai vienu izvades buferi uz kodējumu; SP papildus alocē režģa mezglu uz sakritību DP laikā, kopumā sasniedzot aptuveni 1559 alokācijas pie 514 tokeniem.
Bāzes līnija: nulles alokācijas atsauces ports
Pirms trie mainīšanas mēs izolējam algoritma izmaksas no implementācijas izmaksām: cik ātri darbojas atsauce ar noņemto nevajadzīgo darbu?
Bāzes līnija saglabā atsauces jaucējtabulas bērnu trie, bet veic divas izmaiņas. Zvanītājam piederoša melnraksta struktūra satur DP tabulu, iepriekš apstrādātus baitus un izvades tokenu ID, tāpēc karstais ceļš neveic alokācijas pēc iesildīšanās. Tokena ID un rādītājs tiek saglabāti tieši trie mezglā, noņemot virknes konvertāciju uz sakritību un sekundārās jaucējtabulas meklēšanu.
Iekšējais cikls:
for pos in 0..n {
if scratch.prep[pos] & 0xC0 == 0x80 { continue; }
let s0 = scratch.best_score[pos];
if s0 == f32::NEG_INFINITY { continue; }
let mut node = 0u32;
for end in pos..n {
let byte = scratch.prep[end];
match self.nodes[node as usize].children.get(&byte) {
Some(&child) => {
node = child;
let n = &self.nodes[node as usize];
if n.is_terminal {
let total = s0 + n.score;
if total > scratch.best_score[end + 1] {
scratch.best_score[end + 1] = total;
scratch.best_start[end + 1] = pos as u32;
scratch.best_id[end + 1] = n.token_id;
}
}
}
None => break,
}
}
}Šis ir tas pats algoritms ar to pašu trie formu un to pašu piekļuves modeli, tikai bez alokācijām un liekām meklēšanām. Rezultāti pie 514 tokeniem:
Dzinējs | p50 | Instrukcijas | IPC | Alokācijas |
Hugging Face | 326 µs | 3.66M | 2.86 | 7295 |
SentencePiece | 127 µs | 1.81M | 3.55 | 1559 |
IREE | 111 µs | 1.58M | 3.26 | 1 |
Bāzes līnija | 156 µs | 1.53M | 2.62 | 0 |
Nulles alokāciju bāzlīnija salīdzinājumā ar esošajām tokenizatoru implementācijām pie 514 tokeniem.
Virknes materializācijas noņemšana uz sakritību un sekundārās jaucējtabulas meklēšana samazina instrukcijas par 2.4× salīdzinājumā ar atsauci. IPC samazinās kopā ar tiem (2.86 → 2.62), jo jaucējtabulas rādītāja dzīšanās, kas iepriekš bija paslēpta aiz alokatora darba, tagad ir dominējošās izmaksas uz baitu.
Bāzes līnija samazina p50 latentumu par 2–3× salīdzinājumā ar atsauci jebkurā izmērā, un tās L2 trūkumu līmenis paliek tuvu 2–3% līdz 2k tokenu pret atsauces 8–48%. Atlikušās izmaksas ir HashMap rādītāja dzīšanās, ko risina nākamā sadaļa.
Optimizācija 1: dubultā masīva trie bez papildu abstrakcijām
Jaucējtabulas trie ir rādītāja dzīšanās. SP un IREE to atrisina ar dubultā masīva trie (skatīt iepriekšējo sadaļu), un mēs aizņemamies to pašu datu struktūru. Atšķirība ir tāda, ka SP un IREE ir vispārējas nozīmes bibliotēkas, kas atbalsta straumēšanas ievadi, izvades nobīdes un vairākus kodēšanas režīmus; šī elastība maksā papildu grāmatvedību ap trie (režģis SP, astoņu posmu cauruļvads IREE). Mums nav nepieciešams nekas no tā, tāpēc mēs iekļaujam trie tieši Viterbi ciklā un atmetam pārējo.
fn child(&self, node: u32, byte: u8) -> Option<u32> {
let base = self.base[node as usize];
let next = base + byte as i32;
if self.check[next as usize] == node as i32 {
Some(next as u32)
} else {
None
}
}Baita solis maksā divus masīva nolasījumus, vesela skaitļa saskaitīšanu un salīdzināšanu. Visa trie ir ap 9 MB, bet jebkurš atsevišķs kodējums izstaigā tikai ~100 KB no tā, tāpēc karstie dati paliek L2.
Dzinējs | p50 | p99 | Instrukcijas | L2 trūkums% | L3 trūkums% |
Hugging Face | 327 µs | 3,340 µs | 3.66M | 8.17% | 14.66% |
SentencePiece | 127 µs | 1,140 µs | 1.82M | 4.74% | 4.07% |
IREE | 111 µs | 132 µs | 1.61M | 5.82% | 3.87% |
Bāzes līnija | 155 µs | 1,170 µs | 1.53M | 2.59% | 16.66% |
+ Darts | 68 µs | 80 µs | 1.10M | 6.45% | 11.42% |
Tokenizatora latentums, instrukcijas un kešatmiņas kļūdu līmenis pēc dubultmasīva trie ieviešanas.
Sienas pulkstenis samazinās par 2.3× no bāzes līnijas un 4.8× no atsauces. Instrukcijas samazinās par vēl 28% no bāzes līnijas. Režģa un cauruļvada samazināšana ļauj mums izbeigt 1.10M instrukciju uz kodējumu pret SP 1.82M un IREE 1.61M pie tās pašas trie formas; atšķirība ir grāmatvedība, ko tie saglabā vispārējas nozīmes API, kas mums nav nepieciešami.
Optimizācija 2: bitkartes un iekļautā blīvēšana
Darts trie no 1. optimizācijas prasa divas atkarīgas slodzes uz baitu soli. Vispirms vecāka bāzes nobīde, pēc tam check ieraksts kandidāta bērna slotā, lai apstiprinātu, ka vecākam tas pieder. Check ieraksts tikai apstiprina piederību. Uz to pašu jautājumu var atbildēt ar mezglam raksturīgu bitu karti, kas reģistrē, kuriem baitiem ir bērni. Saskaņā ar dubultā masīva trie konstrukciju, bits baitam b vecāka bitu kartē ir iestatīts tieši tad, kad šī baita kandidātu slots pieder vecākam. Bitu karte un check masīvs satur vienu un to pašu informāciju, tāpēc mēs varam pilnībā atmest check masīvu no izpildlaika struktūras.
Bitu kartes meklēšana tiek kompilēta par vienu bitu testu pret vienu 64 bitu vārdu, kas darbojas apmēram ciklā. Saistītā POPCNT instrukcija, kas skaita visus iestatītos bitus reģistrā, ir tas, ko citas ar bitu karti indeksētas trie konstrukcijas izmanto, lai tieši aprēķinātu bērna nobīdi no bitu kartes. Mums tas nav vajadzīgs, jo Darts konstrukcija jau glabā šo nobīdi mezgla base laukā.
#[repr(C, align(64))]
struct NodeHot {
bitmap: [u64; 4], // 32 B — "which bytes have children"
base: i32, // 4 B — child slot offset
token_id: i32, // 4 B — vocab id (-1 if not terminal)
score: f64, // 8 B — Viterbi log-prob
_pad: [u8; 16], // 16 B — pad to one cache line
}
// Lookup: one cache-line load, one bit test, one add.
fn child(&self, parent: u32, byte: u8) -> Option<u32> {
let h = &self.node_hot[parent as usize];
if !h.bitmap.test(byte) { return None; }
Some(h.base.wrapping_add(byte as i32) as u32)
}Kamēr mēs to darām, mēs iepakojam četrus laukus uz mezglu (bitu karte, base, token ID un score) vienā 64 baitu kešatmiņas rindā. Viens mezgla apmeklējums tagad ievelk visu: score un token ID, kas mums nepieciešami gala mezglos, kā arī bitu karti un base nākamajam solim. Check masīvs tiek izmantots tikai konstrukcijas laikā un ir atmests no izpildlaika izkārtojuma. Karstā ceļa indeksēšana ir neatzīmēta, jo trie konstrukcija un cilpas struktūra jau garantē robežas. Kompromiss ir Telpa: ar 64 baitiem uz mezglu × ~780K slotiem trie aug no ~9 MB līdz ~50 MB.
Rezultāti pie 514 tokeniem:
Dzinējs | p50 | p99 | Instrukcijas | L2 piekļuves | L2 trūkums% | L3 trūkums% |
Hugging Face | 327 µs | 3,340 µs | 3.66M | 15.0K | 8.17% | 14.66% |
SentencePiece | 127 µs | 1,140 µs | 1.82M | 7.5K | 4.74% | 4.07% |
IREE | 111 µs | 132 µs | 1.61M | 4.7K | 5.82% | 3.87% |
Bāzes līnija | 155 µs | 1,170 µs | 1.53M | 6.1K | 2.59% | 16.66% |
+ Darts | 68 µs | 80 µs | 1.10M | 4.6K | 6.45% | 11.42% |
+ Bitu karte un iekļautā iepakošana | 65 µs | 77 µs | 1.06M | 1.8K | 20.53% | 15.01% |
Tokenizatora rezultāti pie 514 tokeniem pēc bitkartes un iekļautās iesaiņošanas pievienošanas.
Sienas pulkstenis sistemātiski samazinās par vēl 4.5% pie 514 tokeniem un pieaug ar ievades garumu, sasniedzot 6-7% pie 4k un 16k. Check masīva nomešana pārvērš katra baita trie soli no divām kešatmiņas rindas slodzēm uz vienu, kas ir 2.5× mazāk L2 piekļuvju uz kodējumu (1.8K pret vienkāršo Darts 4.6K). Trūkumu līmenis lec no 6% līdz 20%, bet lielākā daļa no tā ir saucējs: ar mazāk nekā uz pusi mazāk piekļuvju trūkumi, kas mums ir, aizņem lielāku daļu.
Optimizācija 3: milzīgas lapas triecienam
Trie dati ir aptuveni 50 MB (780 000 mezglu pa 64 baitiem katrs). Noklusējuma Linux procesā šī atmiņa tiek nodrošināta ar 4 KB lapām, tāpēc pilna trie aptver aptuveni 12 000 atšķirīgu virtuālo lapu, kas ir tālu pagātnē tam, ko CPU pirmā līmeņa datu Translation Lookaside Buffer var turēt (Sapphire Rapids ir 96 ieraksti). Katrs Viterbi solis skar citu mezglu, un vairāk nekā 512 tokenu kodējumā mēs sadedzinām aptuveni 9000 ciklu lapu tabulas pastaigās, kas ir apmēram 3% no budžeta uz vienu kodējumu.
Risinājums ir nodrošināt trie ar 2 MB milzīgām lapām, nevis 4 KB lapām. Tas pats 50 MB tagad aptver 25 lapas, pilnībā ietilpst Translation Lookaside Buffer, un pašas lapu tabulas direktoriji pārtrauc konkurēt ar trie par L2 kešatmiņu.
Linux ir divi veidi, kā nodrošināt atmiņu ar 2 MB lapām. Oportūnistiskais veids ir caurspīdīgās milzīgās lapas (THP): jūsu programma lūdz kodolam jaunināt atmiņas reģionu no 4 KB uz 2 MB lapām, un fona kodola pavediens mēģina to izdarīt, kad tam ir laiks un pieejama blakus esošā fiziskā atmiņa. Otra alternatīva ir alocēt trie tieši no iepriekš rezervēta milzīgu lapu baseina, ko kodols atliek sāknēšanas laikā:
let raw = unsafe {
libc::mmap(
std::ptr::null_mut(),
alloc_len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_HUGETLB,
-1,
0,
)
};Baseinam ir jāpastāv resursdatorā (vm.nr_hugepages iestatīts sāknēšanas laikā). Mūsu produkcijas instancēs ir rezervētas 10 561 milzīgas lapas; trie patērē 24 no tām. Pēc mmap, /proc/<pid>/smaps apstiprina reģionu: KernelPageSize: 2048 kB, Private_Hugetlb: 49152 kB.
Ieguvums ir 3–12% sienas pulkstenis visos ievades garumos, kas ir konsekvents un proporcionāls darba kopas garumam:
Tokeni | Bez milzīgām lapām | Ar milzīgām lapām | Δ |
130 | 15.7 µs | 15.2 µs | −3.6% |
514 | 65.4 µs | 63.1 µs | −3.4% |
1026 | 129.5 µs | 125.3 µs | −3.3% |
4098 | 773 µs | 679 µs | −12.0% |
16 386 | 3808 µs | 3554 µs | −6.8% |
Tokenizatora latentums ar un bez lielajām lapām dažādos ievades izmēros.
Lielākais ieguvums ir pie 4k tokeniem, kur darba kopa ir pietiekami liela, lai lapu tabulas trafiks aktīvi konkurētu ar trie datiem par L2 joslas platumu. Pārsniedzot 4k, ieguvums sarūk, jo L3 trūkumi dominē un lapu tabulas pastaigas kļūst par mazāku kopējā laika daļu.
Galīgais salīdzinājums
Apvienojot trīs optimizācijas, šeit ir galvenais dzinējs, kas mērīts pret katru citu RoBERTa tokenizatoru, ko mēs testējām. Līdzās vietējiem C++ SentencePiece un C IREE binārajiem failiem mēs arī testējām gatavās Rust ietvaru bibliotēkas, kuras izmantotu produkcijas lietotājs: sentencepiece un iree-tokenizer. Sentencepiece pakotne mēra par 1.6-1.9× augstāku latentumu nekā vietējais C++ binārais fails jebkurā ievades garumā, un pieskaitāmās izmaksas rodas no tā, ka ietvars alocē jaunu tokenu gabalu sarakstu, no kuriem katrs satur savu gabala teksta kopiju, katrā kodējumā. Iree-tokenizer pakotne izmēra savu iekšējo buferi katrai ievadei, kas novērš pastāvīgu alokāciju uz katru kodējumu, ko vietējais IREE binārais fails maksā neatkarīgi no ievades lieluma, un tas ir vissvarīgāk īsām ievadēm, kur alokācija pretējā gadījumā dominētu faktiskajā tokenizācijas darbā. Produkcijas ievades garumos pastāvīgās izmaksas amortizējas, un ietvara paša izsaukuma pieskaitāmās izmaksas, kopējot tokenus atpakaļ Rust, paceļ tā latentumu virs vietējā binārā faila latentuma.
Galīgais dzinējs palaiž to pašu Viterbi virs tās pašas 250 000 tokenu vārdnīcas, nealocē neko uz karstā ceļa un ražo tokeniem identisku izvadi pret atsauci. Katrs baitu solis tagad ir viena 64 baitu kešatmiņas rindas ielāde, viena bitu kartes pārbaude un viena vesela skaitļa saskaitīšana, kas ir mazāk nekā HashMap pārbaude, alokatora izsaukums un vairāki īpašu gadījumu zari bāzes līnijas implementācijā. Instrukcijas uz vienu kodējumu samazinājās no 3.66M uz 1.04M, kas ir 3.5× kritums. Sienas pulkstenis atbilst šai attiecībai īsās ievadēs un vēl vairāk palielinās garās ievadēs, kur atsauces tokenu alokācijas sāk pārpildīt L2 un L3.
Noslēguma domas
Praksē šis darbs samazināja CPU izmantošanu mūsu secināšanas steka priekšapstrādes slānī par 5-6× un samazināja rerankera latentumu par divciparu milisekundēm.
Lielākā daļa latentuma samazinājuma nāca no vieglajiem izmaiņām, kas novērsa izšķērdēto darbu: atmetot alokācijas uz kodējumu, aizstājot HashMap bērnu trie ar dubulto masīvu un noņemot vispārējā API pieskaitāmās izmaksas. Esošās mezgla bitu kartes izmantošana kā derīguma pārbaude un trie nodrošināšana ar 2 MB milzīgām lapām kopā veidoja 10-20% papildus lielākajai pārejai uz dubultā masīva trie, atkarībā no ievades lieluma.
Gatavie Rust ietvari ap SentencePiece un IREE ilustrē to pašu punktu pretēji. Papildus vietējiem C un C++ tokenizatoriem FFI robeža un izsaukuma alokācijas, ko ietvari ievieš, ir redzamas latentuma skaitļos. Galīgā Viterbi kodētāja Rust avots ir pieejams vietnē github.com/perplexityai/pplx-garden.
Atsauces
Hugging Face tokenizers — https://github.com/huggingface/tokenizers
SentencePiece — https://github.com/google/sentencepiece
IREE tokenizer — https://github.com/iree-org/iree
Kudo, T. (2018). Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates. https://arxiv.org/abs/1804.10959
Viterbi, A. J. (1967). Error bounds for convolutional codes and an asymptotically optimum decoding algorithm. IEEE Transactions on Information Theory. https://en.wikipedia.org/wiki/Viterbi_algorithm
Aoe, J. (1989). An efficient digital search algorithm by using a double-array structure. IEEE Transactions on Software Engineering. https://ieeexplore.ieee.org/document/31365/
Karoonboonyanan, T. An Implementation of Double-Array Trie. https://linux.thai.net/~thep/datrie/datrie.html
POPCNT (x86) — https://www.felixcloutier.com/x86/popcnt
Huge pages — https://docs.kernel.org/admin-guide/mm/hugetlbpage.html
Citāts
@online{perplexity2026ImprovingUnigramTokenizerCpuPerformance,
author = {{Perplexity} and Vladimir Zaytsev and Bex Brooker and Daisy Lee and Roshan Shankar},
title = {{Improving unigram tokenizer CPU performance}},
date = {2026-05-20},
url = {https://www.perplexity.ai/hub/blog/improving-unigram-tokenizer-cpu-performance}
}