Unapređenje performansi CPU-a Unigram tokenajzera
Ponovo smo implementirali naš Unigram tokenajzer od osnove kao usmereni projekat performansi.
Ponovo smo implementirali naš Unigram tokenajzer od osnove kao usmereni projekat performansi. Pri ulaznim dužinama u produkciji, novi enkoder smanjuje p50 latenciju za otprilike 5× u poređenju sa Hugging Face tokenizers crate-om, ~2× u poređenju sa SentencePiece-om (C++) i ~1.5× u poređenju sa IREE-ovim tokenajzerom (C), uz nula heap alokacija u stacionarnom stanju. U produkciji je ovo smanjilo iskorišćenost CPU-a u našem inferencijskom steku za 5-6× i skratilo latenciju rerankera za dvocifreni broj milisekundi.
Motivacija
Troškovi LLM inferencije se obično posmatraju kroz rad GPU-a: KV keševi, attention jezgra, rutiranje eksperata. Međutim, značajan deo produkcijskog saobraćaja se izvršava na znatno manjim modelima, koji su dva do tri reda veličine manji od srodnih (frontier) transformera. Modeli za embedding, klasifikatori i rerankeri se i dalje izvršavaju na GPU-ovima, ali je prolazni prolaz (forward pass) dovoljno kratak da sve oko njega počinje da bude važno.
Najjasniji primer je reranker koji boduje stotine dokumenta kandidata po zahtevu. Sa malim modelom, GPU izračunavanje se obično završava u roku od jednocifrenog broja milisekundi, ali svaki ulaz i dalje mora najpre da prođe kroz tokenizaciju na strani CPU-a, čime se tekst pretvara u ID-ijeve rečnika. Kada je serija (batch) velika, to može da čini značajan udeo ukupne latencije zahteva.
Ovaj rad je usmeren na XLM-RoBERTa, Unigram rečnik veličine 250 hiljada tokena obučen pomoću alata SentencePiece. Fino podešeni enkoderi iz porodice RoBERTa predstavljaju čest izbor za zadatke rangiranja, pretrage i sličnosti. U celom tekstu navodimo latenciju po jednoj niti po enkodu; u produkciji vršimo paralelizaciju na više jezgara koristeći rayon.
Tokom ovog posta tretiramo Hugging Face-ov tokenizers crate kao referencu sa kojom vršimo upoređivanje performansi, s obzirom na to da je to podrazumevani Rust tokenajzer za koji se većina timova opredeljuje. SentencePiece (C++) i IREE (C) su uključeni radi poređenja kao druge dve vodeće produkcijske implementacije.
Viterbijev algoritam
Unigram tokenizacija, koju je predstavio Kudo 2018. godine (Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates) i koja je implementirana u SentencePiece, posmatra segmentaciju kao problem putanje sa najvećom verovatnoćom: svaki token rečnika ima naučenu log-verovatnoću, a enkoder bira onu segmentaciju čiji zbir bodova tokena daje najveću vrednost. Problem se rešava pomoću Viterbijevog algoritma (1967), pri čemu pozicije bajtova predstavljaju slojeve grafa, a tokeni rečnika predstavljaju grane koje pokrivaju neprekidan opseg bajtova. Rekurzija predstavlja jednodimenzionalno dinamičko programiranje (DP) nad pozicijama bajtova:
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 IDsSpoljašnja petlja je linearna u odnosu na dužinu ulaza, ograničena na svakoj poziciji najdužim tokenom rečnika (48 UTF-8 bajtova za XLM-RoBERTa). Unutrašnji prolaz kroz stablo (trie walk) predstavlja kritičnu putanju (hot path): na ulazu veličine 16k tokena, on izvršava stotine hiljada prelaza i obavlja desetine miliona instrukcija po enkodu.
Referentna implementacija
Hugging Face-ov tokenizers v0.22 direktno implementira rekurziju, uz korišćenje heš-mape (hash-map) za obrnuto pronalaženje i heš-mapu dece u svakom čvoru stabla:
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
}I unutrašnja petlja (pojednostavljena) – svako poklapanje pri pretrazi prefiksa alocira novu nisku (string), nakon čega vrši proveru heš-mape:
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
}Kod je čitak Rust, ali se dešava mnogo radnji koje nisu vidljive na nivou izvornog koda. Sledeći odeljak detaljno razmatra na šta se vreme troši.
Napomena o pretprocesiranju
Pre nego što Viterbijev algoritam počne sa izvršavanjem, ulaz prolazi kroz Unicode normalizaciju (NFKC) i označavanje granica reči. Profilisanje pokazuje da pretprocesiranje čini manje od 5% ukupnog vremena enkodiranja u našim scenarijima, tako da se u ostatku teksta fokusiramo na Viterbijev proslérni prolaz (forward pass).
Gde referentna implementacija troši svoje vreme
Postoje dva načina da se ubrza CPU kod: izvršavanje manjeg broja instrukcija ili postizanje veće vrednosti IPC (instrukcija po ciklusu). Cachegrind meri prvo, dok perf stat meri drugo. Svaka optimizacija u ovom postu se odnosi na jedno ili drugo.
Procesori zastaju zbog četiri stvari: promašaja keša (L1d ~4 ciklusa, promašaj L3 keša ka DRAM-u traje stotine ciklusa), lanaca zavisnih učitavanja (dependent-load chains) koje vanredosni (out-of-order) mehanizam ne može da paralelizuje, pogrešnih predviđanja grananja (~15–20 ciklusa se poništava po pogrešnoj proceni) i poziva alokatora. Referentna implementacija unigram enkodera nailazi na sve četiri prepreke:
Usko grlo | Mehanizam | Izmereni uticaj |
Alokacija po poklapanju | String::from_utf8 nad poklopljenim bajtovima uz AHashMap pretragu po svakom poklapanju u stablu; ID tokena se već nalazi u listu, ali ga referentna implementacija tu ne čuva | 7.295 alokacija na 512 tokena, 14.542 na 1k, 299.171 na 16k |
Jurenje pokazivača po bajtu | AHashMap u svakom čvoru stabla, koja hešira sledeći bajt i dereferencira dva pokazivača po koraku; Node struktura takođe objedinjuje retko korišćena polja (cold fields) kao što je is_leaf, uvlačeći ih u keš pri svakoj poseti | Stopa promašaja L1d keša ostaje niska (~2,5% na 1k) zato što se aktivne stranice alokatora nalaze u L1 kešu; trošak se iskazuje kao latencija zavisnog učitavanja |
L2 keš trashing (zagušenje) na dugim ulazima | DP tabela, privremeni prostor (scratch) za prefikse i izlazna lista tokena se nanovo alociraju pri svakom pozivu | Stopa promašaja L2 keša raste sa 12% na 128 tokena do 50% na 16k, pošto kumulativni otisak alokacija premašuje L2 keš od 2 MB po jezgru |
Referentne uska grla tokenizera, mehanizmi i izmereni uticaji.
Dominantan trošak predstavlja alokacija po poklapanju. Na 1k tokena, referentna implementacija izvršava (završava) otprilike 7 miliona instrukcija po enkodu uz IPC od 2,8, pri čemu većina otpada na pozive alokatora i konverzije niski. L2 promašaji postaju bitni tek nakon ~1k tokena, kada kumulativni otisak alokacija preoptereti keš po jezgru.
Alokacija po tokenu je konstantna: oko 2 KB i ~18 alokacija po tokenu, bez obzira na veličinu ulaza. Nelinearnost latencije potiče od L2 keša: kumulativni bajtovi prelaze granicu od 2 MB oko 1k tokena, stopa promašaja raste sa 8% na ~50%, a kriva p99 latencije se savija u skladu sa tim.
Kako smo vršili merenja
Upoređujemo tri produkcijska tokenajzera, pri čemu svi izvršavaju isti Unigram algoritam nad istim XLM-RoBERTa modelom:
Mehanizam (Engine) | Jezik | Stablo (Trie) |
Hugging Face | Rust | Prefiksno stablo sa heš-mapom dece (HashMap-children prefix trie) |
SentencePiece | C++ | Darts stablo sa dvostrukim nizom (Darts double-array trie) |
IREE tokenajzer | C | Darts stablo sa dvostrukim nizom (Darts double-array trie) |
Implementacije tokenizera, jezici i trie reprezentacije korišćene u poređenju.
Povezujemo SentencePiece i IREE kroz direktan C/C++ FFI umesto njihovih Rust omotača (wrappers); omotači dodaju alokacije po pozivu i konverzije niski koje nisu deo troškova osnovne biblioteke.
Svi mehanizmi su izgrađeni uz pune optimizacije (-O3, ciljanje nativnog hardvera, optimizacija tokom povezivanja - LTO). Merenja su vršena uz fiksiranje na jedno mirno jegro na procesoru Intel Xeon Platinum 8488C. Za svaki mehanizam i veličinu ulaza prikupljamo latenciju merenu zidnim časovnikom (p50 i p99, 10.000 iteracija nakon 1.000 zagrevanja), izvršene instrukcije putem alata Valgrind cachegrind, IPC i stope promašaja keša preko perf stat hardverskih brojača, kao i heap alokacije koje meri omot globalnog alokatora oko posmatranog prozora.
Na 514 tokena (+2 preko 512 se odnosi na ubacivanje BOS/EOS tokena):
Implementacija | p50 latencija | Instrukcije | IPC | Alokacije |
Hugging Face | 349 µs | 3,60M | 2,89 | 7.295 |
SentencePiece | 128 µs | 1,83M | 3,39 | 1.559 |
IREE | 112 µs | 2,28M | 3,06 | 1 |
Latencija tokenizera, broj instrukcija, IPC i alokacije pri 514 tokena.
SP i IREE ostvaruju približno jednu trećinu latencije referentne implementacije. Oni izvršavaju upola manje instrukcija, rade pri višem IPC-u i alociraju jedan do dva reda veličine manje.
Zašto su SentencePiece i IREE brži: Stablo sa dvostrukim nizom (Double-Array Trie)
I SentencePiece i IREE koriste stablo sa dvostrukim nizom koje je uveo Aoe 1989. godine (Aoe, 1989; Karoonboonyanan). Stablo je kodovano u dva ravna celobrojna niza, base i check:
int base[N]; // base[node] = offset for this node's children
int check[N]; // check[child] == node validates the parent-child linkPretraga dece se vrši na sledeći način:
int next = base[node] + byte;
if (check[next] == node) { /* valid transition; child node = next */ }Svaki korak obilaska stabla zahteva samo dva učitavanja iz niza, jedno celobrojno sabiranje i jedno poređenje, bez heširanja, jurenja pokazivača i alokacija na heap-u po čvoru. Postupak konstrukcije smešta svaki čvor tako da njegova deca padaju na slobodna mesta u nizu provere, omogućavajući višestrukim roditeljima da dele regione sve dok se njihova deca razlikuju po bajtu koji sledi.
Za XLM-RoBERTa rečnik veličine 250k, celokupno stablo staje u ~9 MB neprekinute memorije (~780k mesta × 12 bajtova za oset (offset), proveru i ID tokena po mestu). Aktivni radni skup (working set) za jedno enkodiranje je reda veličine 100 KB, što komotno staje u L2 keš, dok hardverski prefetcher prepoznaje pravilan obrazac pristupa.
Referentna implementacija, suprotno tome, održava heš-mapu dece po čvoru, što košta otprilike četiri zavisna učitavanja po koraku bajta. Hešira se bajt, učitava se segment (bucket), prati se pokazivač do unosa u segmentu, prati se drugi pokazivač do čvora deteta na heap-u, i na kraju se konsultuje sopstvena heš-mapa za sledeći bajt. Objekti na heap-u su rasuti i većina učitavanja promašuje keš.
I SP i IREE čuvaju ID tokena direktno u čvoru stabla. Referentna implementacija stabla vraća samo poklopljene bajtove, zbog čega enkoder mora da alocira novu nisku za svako poklapanje, da je potraži u sporednoj heš-mapi kako bi povratio ID tokena, i da pročita skor iz posebnog vektora. SP vraća ID kao uskladištenu vrednost stabla i čita skor iz ravnog niza sa pokretnim zarezom:
// 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 koristi API sa stanjem (stateful cursor API) umesto vraćanja svih poklapanja u jednom pozivu, ali je model troškova isti: ID-ijevi tokena i skorovi su dostupni kao indeksi u ravnom nizu, bez alokacija i heširanja tokom samog prolaza kroz stablo. IREE alocira samo jedan izlazni bafer po enkodu; SP dodatno alocira čvor rešetke (lattice node) po poklapanju tokom DP-a, što ukupno iznosi oko 1.559 alokacija na 514 tokena.
Osnovna linija (Baseline): Port referentne implementacije bez alokacija
Pre nego što promenimo stablo, izdvajamo trošak algoritma od troška implementacije: kojom brzinom referentna implementacija radi kada se ukloni suvišan posao?
Osnovna linija zadržava stablo sa heš-mapom dece iz referentne implementacije, ali vrši dve izmene. Struktura privremenog prostora (scratch struct) u vlasništvu pozivaoca sadrži DP tabelu, pretprocesirane bajtove i izlazne ID-ijeve tokena, tako da kritična putanja ne vrši nikakve alokacije nakon zagrevanja. ID tokena i skor se čuvaju direktno u čvoru stabla, čime se uklanjaju konverzija niske po poklapanju i pretraga u sporednoj heš-mapi.
Unutrašnja petlja:
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,
}
}
}Ovo je isti algoritam, sa istim oblikom stabla i istim obrascem pristupa, samo bez alokacija i suvišnih pretraga. Rezultati na 514 tokena:
Mehanizam (Engine) | p50 | Instrukcije | IPC | Alokacije |
Hugging Face | 326 µs | 3,66M | 2,86 | 7.295 |
SentencePiece | 127 µs | 1,81M | 3,55 | 1.559 |
IREE | 111 µs | 1,58M | 3,26 | 1 |
Osnovna linija (Baseline) | 156 µs | 1,53M | 2,62 | 0 |
Referentna verzija bez alokacija u poređenju sa postojećim implementacijama tokenizera pri 514 tokena.
Uklanjanje materijalizacije niske po poklapanju i sekundarne pretrage u heš-mapi smanjuje broj instrukcija za 2,4× u poređenju sa referentnom implementacijom. IPC opada sa njima (sa 2,86 na 2,62) zato što jurenje pokazivača u heš-mapi, koje je ranije bilo skriveno iza rada alokatora, sada predstavlja dominantan trošak po bajtu.
Osnovna linija smanjuje p50 latenciju za 2–3× u odnosu na referentnu implementaciju pri svakoj veličini, a stopa promašaja L2 keša ostaje blizu 2–3% do 2k tokena, u poređenju sa 8–48% kod referentne implementacije. Preostali trošak predstavlja jurenje pokazivača u heš-mapi (HashMap), čime se bavi sledeći odeljak.
Optimizacija 1: Stablo sa dvostrukim nizom bez dodatnih apstraktnih slojeva
Stablo zasnovano na heš-mapi predstavlja jurenje pokazivača. SP i IREE rešavaju ovaj problem pomoću stabla sa dvostrukim nizom (pogledajte prethodni odeljak), i mi preuzimamo istu strukturu podataka. Razlika je u tome što su SP i IREE opšte namenske biblioteke koje podržavaju striming ulaz, izlazne opsege i više režimenih kodiranja; ta fleksibilnost ih košta dodatnog vođenja evidencije oko stabla (rešetka u SP-u, osmostepeni cevovod u IREE-u). Nama ništa od toga nije potrebno, tako da ugrađujemo stablo direktno u Viterbijevu petlju i odbacujemo ostatak.
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
}
}Korak bajta košta dva čitanja iz niza, celobrojno sabiranje i poređenje. Celokupno stablo ima oko 9 MB, ali bilo koje pojedinačno enkodiranje prolazi kroz samo ~100 KB tog stabla, tako da aktivni podaci ostaju u L2 kešu.
Mehanizam (Engine) | p50 | p99 | Instrukcije | L2 promašaj % | L3 promašaj % |
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% |
Osnovna linija (Baseline) | 155 µs | 1.170 µs | 1,53M | 2,59% | 16,66% |
+ Darts | 68 µs | 80 µs | 1,10M | 6,45% | 11,42% |
Latencija tokenizera, instrukcije i stope promašaja keša nakon usvajanja double-array trie strukture.
Latencija merena zidnim časovnikom opada 2,3× u odnosu na osnovnu liniju i 4,8× u odnosu na referentnu implementaciju. Broj instrukcija se smanjuje za još 28% u odnosu na osnovnu liniju. Uklanjanje rešetke i cevovoda nam omogućava da izvršimo 1,10M instrukcija po enkodu u poređenju sa 1,82M kod SP-a i 1,61M kod IREE-a pri istom obliku stabla; razlika potiče od mehanizama za praćenje koje oni održavaju za API-je opšte namene koji nama nisu potrebni.
Optimizacija 2: Bitmapa i ugrađeno pakovanje
Darts stablo iz Optimizacije 1 zahteva dva zavisna učitavanja po koraku bajta. Prvo bazni oset roditelja, a zatim unos provere (check) na mestu kandidata za dete kako bi se potvrdilo da je roditelj njegov vlasnik. Unos provere služi samo za potvrdu vlasništva. Na isto pitanje se može odgovoriti pomoću bit mape po čvoru koja beleži koji bajtovi imaju decu. Na osnovu konstrukcije stabla sa dvostrukim nizom, bit za bajt b u bit mapi roditelja je podešen tačno onda kada je kandidat mesto za taj bajt u vlasništvu roditelja. Bit mapa i niz proverenose iste informacije, tako da možemo u potpunosti da odbacimo niz provere iz strukture tokom izvršavanja.
Pretraga u bit mapi se kompajlira u jednu bitovnu proveru nad jednom 64-bitnom reči, što se izvršava za otprilike jedan ciklus. Srodna POPCNT instrukcija, koja broji sve postavljene bitove u registru, jeste ono što drugi dizajneri stabala indeksiranih bit mapom koriste da direktno izračunaju oset deteta iz bit mape. Nama to nije potrebno zato što Darts konstrukcija već čuva taj oset u polju osnove (base) čvora.
#[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)
}Dok smo već kod toga, pakujemo četiri polja po čvoru (bit mapu, oset, ID tokena i skor) u jednu keš liniju veličine 64 bajta. Jedna poseta čvoru sada dobija sve odjednom: skor i ID tokena koji su nam potrebni u terminalnim čvorovima, plus bit mapu i oset za sledeći korak. Niz provere se koristi samo tokom konstrukcije i izbačen je iz rasporeda u memoriji tokom izvršavanja. Indeksiranje na kritičnoj putanji se obavlja bez provera (unchecked) zato što konstrukcija stabla i struktura petlje već garantuju granice. Kompromis je prostor: sa 64 bajta po čvoru × ~780k mesta, stablo raste sa ~9 MB na ~50 MB.
Rezultati na 514 tokena:
Mehanizam (Engine) | p50 | p99 | Instrukcije | L2 pristupi | L2 promašaj % | L3 promašaj % |
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% |
Osnovna linija (Baseline) | 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% |
+ Bit mapa i ugrađeno pakovanje (+ Bitmap and Inline Packing) | 65 µs | 77 µs | 1.06M | 1,8K | 20,53% | 15,01% |
Rezultati tokenizera pri 514 tokena nakon dodavanja bitmapa i inline pakovanja.
Latencija merena zidnim časovnikom sistematski opada za još 4,5% na 514 tokena i raste sa dužinom ulaza, dostižući 6–7% na 4k i 16k. Izbacivanje niza provere pretvara korak stabla po bajtu sa dva učitavanja keš linije u jedno, što predstavlja 2,5× manje L2 pristupa po enkodu (1,8K u poređenju sa 4,6K kod običnog Darts stabla). Stopa promašaja skace sa 6% na 20%, ali je većina toga posledica imenioca: sa manje od upola manje pristupa, promašaji koje imamo zauzimaju veći udeo.
Optimizacija 3: Velike stranice za stablo
Podaci stabla zauzimaju otprilike 50 MB (780.000 čvorova od po 64 bajta). U podrazumevanom Linux procesu, ta memorija je podržana stranicama veličine 4 KB, tako da puno stablo obuhvata oko 12.000 različitih virtuelnih stranica, što daleko premašuje ono što bafer za prevod adresa prvog nivoa procesora (Translation Lookaside Buffer - TLB) može da drži (Sapphire Rapids ima 96 unosa). Svaki korak Viterbijevog algoritma dodiruje drugačiji čvor, i tokom enkodiranja od 512 tokena trošimo otprilike 9.000 ciklusa na obilaske tabele stranica, što je oko 3% budžeta po enkodu.
Rešenje je da se stablo podrži ogromnim stranicama (huge pages) veličine 2 MB umesto onih od 4 KB. Istih 50 MB sada obuhvata 25 stranica, u potpunosti stane u TLB bafer, a same direktorijum tabele stranica prestaju da se takmiče sa stablom za L2 keš.
Linux ima dva načina da podrži memoriju sa stranicama od 2 MB. Oportunistički način su transparentne ogromne stranice (transparent huge pages - THP): vaš program traži od kernela da nadogradi memorijski region sa stranica od 4 KB na stranice od 2 MB, a pozadinska nit (background thread) kernela pokušava to da uradi kada ima vremena i dostupne neprekinute fizičke memorije. Druga alternativa jeste alokacija stabla direktno iz unapred rezervisanog basena ogromnih stranica koji kernel izdvaja prilikom pokretanja sistema:
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,
)
};Basen mora da postoji na domaćinu (vm.nr_hugepages podešeno pri pokretanju). Na našim produkcijskim instancama rezervisano je 10.561 ogromnih stranica; stablo troši 24 od njih. Nakon mmap sistemskog poziva, /proc/<pid>/smaps potvrđuje region: KernelPageSize: 2048 kB, Private_Hugetlb: 49152 kB.
Dobitak iznosi 3–12% u latenciji merenoj zidnim časovnikom u zavisnosti od veličine ulaza, što je dosledno i srazmerno dužini radnog skupa:
Tokeni (Tokens) | Bez ogromnih stranica | Sa ogromnim stranicama | Δ |
130 | 15,7 µs | 15,2 µs | −3,6% |
514 | 65,4 µs | 63,1 µs | −3,4% |
1.026 | 129,5 µs | 125,3 µs | −3,3% |
4.098 | 773 µs | 679 µs | −12,0% |
16.386 | 3.808 µs | 3.554 µs | −6,8% |
Latencija tokenizera sa i bez huge pages kroz različite veličine ulaza.
Najveći dobitak je ostvaren na 4k tokena, gde je radni skup dovoljno velik da se saobraćaj tabele stranica aktivno takmičio sa podacima stabla za propusni opseg L2 keša. Iznad 4k dobitak se smanjuje zato što L3 promašaji dominiraju, a obilasci tabele stranica postaju manji udeo u ukupnom vremenu.
Završno poređenje
Kada spojimo ove tri optimizacije, ovde je prikazan konačni mehanizam izmeren u poređenju sa svakim drugim RoBERTa tokenajzerom koji smo testirali. Pored izvornih C++ SentencePiece i C IREE binarnih datoteka, testirali smo i gotove Rust omotače (wrapper crates) koje bi produkcijski korisnik izabrao, sentencepiece i iree-tokenizer. Krate sentencepiece pokazuje 1,6–1,9× veću latenciju od izvorne C++ binarne datoteke pri svakoj veličini ulaza, pri čemu režijski trošak potiče od toga što omotač alocira novu listu delova tokena, od kojih svaki drži svoju kopiju teksta dela, pri svakom enkodu. Krate iree-tokenizer prilagođava veličinu svog internog bafera svakom ulazu, čime izbegava konstantnu alokaciju po enkodu koju izvorna IREE binarna datoteka plaća bez obzira na veličinu ulaza, što je najvažnije kod kratkih ulaza gde bi alokacija inače dominirala nad stvarnim radom tokenizacije. Pri produkcijskim dužinama ulaza konstantan trošak se amortizuje, a sopstveni režijski trošak omotača po pozivu koji nastaje kopiranjem tokena nazad u Rust podiže njegovu latenciju iznad latencije izvorne binarne datoteke.
Konačni mehanizam izvršava isti Viterbijev algoritam nad istim rečnikom od 250.000 tokena, ne alocira ništa na kritičnoj putanji i daje izlaz koji je po tokenima identičan referentnom. Svaki korak bajta sada predstavlja jedno učitavanje keš linije od 64 bajta, jednu proveru bit mape i jedno celobrojno sabiranje, što je manje u odnosu na pretragu u heš-mapi, poziv alokatora i nekoliko grana za posebne slučajeve u implementaciji osnovne linije. Broj instrukcija po enkodu je opao sa 3,66M na 1,04M, što predstavlja smanjenje od 3,5×. Latencija merena zidnim časovnikom prati taj odnos kod kratkih ulaza, a dodatno se povećava kod dugih ulaza, gde alokacije po tokenu u referentnoj implementaciji počinju da preplavljuju L2 i L3 keš.
Završne misli
U praksi je ovaj rad smanjio iskorišćenost CPU-a u sloju pretprocesiranja našeg inferencijskog steka za 5-6× i skratio latenciju rerankera za dvocifreni broj milisekundi.
Glavni deo smanjenja latencije potiče od laganih izmena koje su uklonile suvišan rad: izbacivanje alokacija po enkodu, zamenjivanje stabla sa heš-mapom dece stablom sa dvostrukim nizom i uklanjanje režijskih troškova generičkog API-ja. Korišćenje postojeće bit mape po čvoru kao provere validnosti i podržavanje stabla ogromnim stranicama od 2 MB zajedno su obezbedili 10-20% uštede povrh veće promene na stablo sa dvostrukim nizom, u zavisnosti od veličine ulaza.
Gotovi Rust omotači oko biblioteka SentencePiece i IREE ilustruju istu stvar u obrnutom smeru. Povrh izvornih C i C++ tokenajzera, FFI granica i alokacije po pozivu koje omotači uvode vidljivi su u brojkama latencije. Izvorni kod u Rust-u za konačni Viterbijev enkoder dostupan je na adresi github.com/perplexityai/pplx-garden.
Reference
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
Citiranje
@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}
}