Förbättra CPU-prestandan för unigram-tokeniseraren
Vi implementerade om vår Unigram-tokeniserare från grunden som ett fokuserat prestandaprojekt.
Vi implementerade om vår Unigram-tokeniserare från grunden som ett fokuserat prestandaprojekt. Vid produktionsinmatningslängder kapar den nya kodaren p50-latensen med ungefär 5× jämfört med Hugging Faces tokenizers-kratta, ~2× jämfört med SentencePiece (C++) och ~1,5× jämfört med IREE:s tokeniserare (C), med noll heap-allokeringar i stabilt läge. I produktion reducerade detta CPU-utnyttjandet i vår inferensstack med 5–6× och skalade av tvåsiffriga millisekunder från omlistningslatensen.
Motivation
LLM-inferenskostnader formuleras vanligtvis som GPU-arbete: KV-cacher, uppmärksamhetskärnor, expertroutning. Men en betydande del av produktionstrafiken körs på mycket mindre modeller, två till tre storleksordningar mindre än gränstransformatorer. Inbäddningsmodeller, klassificerare och omlistare körs fortfarande på GPU:er, men framåtkörningen är tillräckligt kort för att allt runtomkring ska börja spela roll.
Det tydligaste exemplet är en omlistare som poängsätter hundratals kandidatdokument per förfrågan. Med en liten modell slutförs GPU-beräkningen vanligtvis på ensiffriga millisekunder, men varje indata måste ändå gå igenom CPU-sidig tokenisering först, vilket förvandlar text till vokabulär-ID:n. När batchen är stor kan det utgöra en betydande del av den totala förfrågningslatensen.
Detta arbete fokuserar på XLM-RoBERTa, ett Unigram-vokabulär med 250 000 tokens som tränats med SentencePiece. Finjusterade kodare i RoBERTa-familjen är ett vanligt val för ranknings-, hämtnings- och likhetsuppgifter. Vi rapporterar enkeltrådad latens per kodning genomgående; i produktion parallelliserar vi över kärnor med hjälp av rayon.
Genom hela detta inlägg behandlar vi Hugging Faces tokenizers-kratta som den referens vi benchmarkar mot, eftersom det är standardiserad Rust-tokeniserare som de flesta team vänder sig till. SentencePiece (C++) och IREE (C) inkluderas för jämförelse som de andra två stora produktionsimplementeringarna.
Viterbialgoritmen
Unigram-tokenisering, som introducerades av Kudo 2018 (Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates) och levererades i SentencePiece, ramar in segmentering som ett problem med den mest sannolika vägen: varje vokabulärtoken har en inlärd log-sannolikhet, och kodaren väljer den segmentering vars tokenscorer summerar till det högsta värdet. Problemet löses med Viterbialgoritmen (1967), med bytepositioner som graflager och vokabulärtoken som kanter som spänner över ett sammanhängande byterintervall. Rekursen är en endimensionell DP över bytepositioner:
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 IDsDen yttre loopen är linjär i indatalängd, begränsad vid varje position av det längsta vokabulärtokenet (48 UTF-8-byte för XLM-RoBERTa). Den inre trie-vandringen är den heta sökvägen: på en indata på 16k tokens kör den hundratusentals övergångar och slutför tals av miljoner instruktioner per kodning.
Referensimplementering
Hugging Faces tokenizers v0.22 implementerar rekursen direkt, med en hash-mapp för den omvända sökningen och hash-mapp-barn vid varje trienod:
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
}Och den inre loopen (förenklad) – varje prefixsökningsträff allokerar en ny sträng och söker sedan igenom hash-mappen:
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
}Koden är läsbar Rust, men mycket arbete sker som är osynligt på källkods nivå. Nästa avsnitt bryter ned vart tiden tar vägen.
En anmärkning om förbehandling
Innan Viterbi körs går indatan igenom Unicode-normalisering (NFKC) och markering av ordgränser. Profilering placerar förbehandlingen på mindre än 5 % av den totala kodningstiden i våra scenarier, så vi fokuserar på Viterbis framåtkörning under resten av inlägget.
Vart referensimplementeringen lägger sin tid
Det finns två sätt att snabba upp CPU-kod: köra färre instruktioner eller uppnå högre IPC. Cachegrind mäter det första, perf stat mäter det andra. Varje optimering i detta inlägg landar på det ena eller det andra.
CPU:er stannar upp på fyra saker: cachemissar (L1d ~4 cykler, en L3-miss-till-DRAM är hundratals), beroende inläsningskedjor som den oordnade motorn inte kan parallellisera, felaktiga grenförutsägelser (~15–20 cykler rensade per felaktig gissning) och allokatoranrop. Referensimplementeringen av unigrammotorn drabbas av alla fyra:
Flaskhals | Mekanism | Uppmätt påverkan |
Allokering per träff | String::from_utf8 på de matchade bytena plus en AHashMap-sökning per trie-träff; token-ID:t finns redan i lövet, men referensen lagrar det inte där | 7 295 allokeringar vid 512 tokens, 14 542 vid 1k, 299 171 vid 16k |
Pekarletning per byte | En AHashMap vid varje trienod, som hash-kodar nästa byte och avrefererar två pekare per steg; Node-strukturen buntar också ihop kalla fält som is_leaf, vilket drar in dem i cachen vid varje besök | L1d-missfrekvensen förblir låg (~2,5 % vid 1k) eftersom allokatorns heta sidor finns i L1; kostnaden visas som beroende inläsningslatens |
L2-tröska vid långa indata | DP-tabell, prefix-skrap och listan över utdatatokens allokeras nyligen vid varje anrop | L2-missfrekvensen stiger från 12 % vid 128 tokens till 50 % vid 16k då det kumulativa allokeringsfotavtrycket överstiger L2 på 2 MB per kärna |
Referens för flaskhalsar i tokeniseraren, mekanismer och uppmätta effekter.
Den dominerande kostnaden är allokeringen per träff. Vid 1k tokens slutför referensimplementeringen ungefär 7 miljoner instruktioner per kodning vid en IPC på 2,8, varav de flesta i allokatoranrop och strängkonverteringar. L2-missar spelar bara roll efter ~1k tokens, när det kumulativa allokeringsfotavtrycket spiller över per-kärnans cache.
Allokeringen per token är konstant: ungefär 2 KB och ~18 allokeringar per token, oavsett indatalängd. Latensens olinjäritet kommer från L2: kumulativa byte passerar 2 MB runt 1k tokens, missfrekvensen stiger från 8 % till ~50 %, och p99-kurvan böjs därefter.
Hur vi mätte
Vi jämför tre produktionstokeniserare, som alla kör samma Unigram-algoritm mot samma XLM-RoBERTa-modell:
Motor | Språk | Trie |
Hugging Face | Rust | HashMap-barn prefixtrie |
SentencePiece | C++ | Darts dubbelmatris-trie |
IREE-tokeniserare | C | Darts dubbelmatris-trie |
Tokeniseringsimplementationer, språk och trie-representationer som användes i jämförelsen.
Vi länkar SentencePiece och IREE genom direkt C/C++ FFI snarare än deras Rust-omslag; omslagen lägger till allokeringar per anrop och strängkonverteringar som inte är en del av det underliggande bibliotekets kostnad.
Alla motorer är byggda med fullständiga optimeringar (O3, inhemsk målinriktning, länkningstidsoptimering). Mätningarna kördes låsta till en tyst kärna på en Intel Xeon Platinum 8488C. För varje motor och indatalängd samlar vi in väggklockans latens (p50 och p99, 10 000 iterationer efter 1 000 uppvärmningar), instruktioner som avslutats via Valgrind cachegrind, IPC och cachemissfrekvens från hårdvaruräknare i perf stat, samt heapallokeringar räknade av en global allokatoromslag runt det uppmätta fönstret.
Vid 514 tokens (+2 över 512 är BOS/EOS-injektering):
Implementering | p50-latens | Instruktioner | IPC | Allokeringar |
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 |
Tokeniseringsfördröjning, instruktioner, IPC och allokeringar vid 514 tokens.
SP och IREE landar på ungefär en tredjedel av referensens latens. De slutför ungefär hälften så många instruktioner, körs med högre IPC och allokerar en till två storleksordningar mindre.
Varför SentencePiece och IREE är snabbare: Dubbelmatristrien
SentencePiece och IREE använder båda den dubbelmatristrie som introducerades av Aoe 1989 (Aoe, 1989; Karoonboonyanan). Trien är kodad i två flata heltalsmatriser, base och check:
int base[N]; // base[node] = offset for this node's children
int check[N]; // check[child] == node validates the parent-child linkEn barnuppslagning är:
int next = base[node] + byte;
if (check[next] == node) { /* valid transition; child node = next */ }Varje steg i trievandringen kräver endast två matrisinläsningar, en heltalsaddition och en jämförelse, utan hash-kodning, pekarlöpning eller heapallokering per nod. Konstruktionen placerar varje nod så att dess barn hamnar på lediga platser i valideringsmatrisen, vilket gör att flera föräldrar kan dela regioner så länge deras barn skiljer sig åt på den byte de följer.
För XLM-RoBERTs 250k-vokabulär får hela trien plats i ~9 MB sammanhängande minne (~780k platser × 12 byte för offset, validering och token-ID per plats). En enda kodnings het arbetsuppsättning är i storleksordningen 100 KB, vilket bekvämt får plats i L2, och maskinvaruförhämtaren låser sig fast vid det regelbundna åtkomstmönstret.
Referensen behåller däremot en hash-mapp över barn per nod, vilket kostar ungefär fyra beroende inläsningar per bytesteg. Hasha byten, läs in hinken, följ en pekare till hinkposten, följ en annan pekare till barnnoden på heapen och konsultera sedan dess egen hash-mapp för nästa byte. Heapobjekt sprids ut och de flesta inläsningar missar cachen.
SP och IREE lagrar båda token-ID:t direkt i trienoden. Referenstriens implementering returnerar endast de matchade bytena, så kodaren måste allokera en ny sträng för varje träff, leta upp den i en sidohash-mapp för att återställa token-ID:t och läsa poängen från en separat vektor. SP returnerar ID:t som triens lagrade värde och läser poängen från en platt flyttalsmatris:
// 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 använder ett tillståndskänsligt markör-API i stället för att returnera alla träffar i ett enda anrop, men kostnadsmodellen är den samma: token-ID:n och poäng är åtkomliga som index i platta matriser, utan allokering eller hash-kodning vid själva trievandringen. IREE allokerar endast en utdatabuffert per kodning; SP allokerar dessutom en gittermod per träff under DP:n, vilket totalt uppgår till ungefär 1 559 allokeringar vid 514 tokens.
Baslinje: En nollallokeringsport av referensen
Innan vi ändrar trien isolerar vi algoritmkostnaden från implementeringskostnaden: hur snabbt körs referensen när det onödiga arbetet har tagits bort?
Baslinjen behåller referensens hash-mapp-barn-trie men gör två ändringar. En anropsägd skrapstruktur rymmer DP-tabellen, förbehandlade byte och utdata-token-ID:n, så den heta sökvägen gör inga allokeringar efter uppvärmning. Token-ID:t och poängen lagras direkt i trienoden, vilket tar bort strängkonverteringen per träff och sökningen i sidohash-mappen.
Inre loop:
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,
}
}
}Detta är samma algoritm, med samma trie-form och samma åtkomstmönster, bara utan allokeringar och utan redundanta uppslagningar. Resultat vid 514 tokens:
Motor | p50 | Instruktioner | IPC | Allokeringar |
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 |
Baslinje | 156 µs | 1,53M | 2,62 | 0 |
Baslinjen utan allokeringar jämfört med befintliga tokeniseringsimplementationer vid 514 tokens.
Borttagning av strängmaterialisering per träff och den sekundära hash-mappuppslagningen kapar instruktioner 2,4× jämfört med referensen. IPC sjunker med dem (2,86 → 2,62) eftersom hash-mappens pekarlöpning, tidigare dold bakom allokatorarbete, nu är den dominerande kostnaden per byte.
Baslinjen kapar p50-latensen med 2–3× jämfört med referensen i alla storlekar, och dess L2-missfrekvens stannar nära 2–3 % genom 2k tokens jämfört med referensens 8–48 %. Den återstående kostnaden är HashMap-pekarlöpningen, vilket nästa avsnitt adresserar.
Optimering 1: Dubbelmatristrie utan extra abstraktioner
Hash-mapptrien är en pekarlöpning. SP och IREE löser detta med en dubbelmatristrie (se föregående avsnitt), och vi lånar samma datastruktur. Skillnaden är att SP och IREE är generella bibliotek som stöder strömmande indata, utdata-offsets och flera kodningslägen; den flexibiliteten kostar dem extra bokföring runt trien (ett gitter i SP, en åttastegspipeline i IREE). Vi behöver inget av det, så vi inline:ar trien direkt i Viterbi-loopen och släpper resten.
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
}
}Ett bytesteg kostar två matrisinläsningar, en heltalsaddition och en jämförelse. Hela trien är runt 9 MB, men varje enskild kodning vandrar bara igenom ~100 KB av den, så den heta datan stannar kvar i L2.
Motor | p50 | p99 | Instruktioner | L2-miss % | L3-miss % |
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 % |
Baslinje | 155 µs | 1 170 µs | 1,53M | 2,59 % | 16,66 % |
+ Darts | 68 µs | 80 µs | 1,10M | 6,45 % | 11,42 % |
Tokeniseringsfördröjning, instruktioner och cachemissfrekvens efter införandet av dubbel-array-trädet.
Väggklockan sjunker 2,3× från baslinjen och 4,8× från referensen. Instruktionerna sjunker ytterligare 28 % från baslinjen. Att kapa gittret och pipelinen gör att vi kan slutföra 1,10 miljoner instruktioner per kodning mot SP:s 1,82 miljoner och IREE:s 1,61 miljoner vid samma trie-form; skillnaden är den bokföring de behåller för generella API:er som vi inte behöver.
Optimering 2: Bitmap och inline-packning
Darts-trien från optimering 1 behöver två beroende inläsningar per bytesteg. Först förälderns base-offset, sedan check-posten på kandidatens barnplats för att bekräfta att föräldern äger den. Check-posten validerar endast ägarskap. Samma fråga kan besvaras av en bitmapp per nod som registrerar vilka byte som har barn. Genom dubbelmatristriens konstruktion är biten för byte b i förälderns bitmapp satt exakt när kandidatplatsen för den byten ägs av föräldern. Bitmappen och check-matrisen bär samma information, så vi kan ta bort check-matrisen från körtidsstrukturen helt och hållet.
Bitmappsuppslagningen kompileras till ett enda bit-test mot ett 64-bitarsord, vilket körs på ungefär en cykel. Den relaterade instruktionen POPCNT, som räknar alla satta bitar i ett register, är vad andra bitmappsindexerade trie-designer använder för att beräkna ett barn-offset direkt från bitmappen. Vi behöver det inte eftersom Darts-konstruktionen redan lagrar det offset i nodens base-fält.
#[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)
}Medan vi ändå håller på packar vi de fyra fälten per nod (bitmapp, base, token-ID och poäng) i en enda 64-bytes cacheline. Ett enda nodbesök drar nu in allt: poängen och token-ID:t vi behöver vid slutnoder, plus bitmappen och base för nästa steg. Check-matrisen används endast under konstruktion och tas bort från körtidslayouten. Indexeringen i den heta sökvägen är okontrollerad eftersom triekonstruktion och loopstruktur redan garanterar gränserna. Avvägningen är Space: vid 64 byte per nod × ~780k platser växer trien från ~9 MB till ~50 MB.
Resultat vid 514 tokens:
Motor | p50 | p99 | Instruktioner | L2-åtkomster | L2-miss % | L3-miss % |
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 % |
Baslinje | 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 % |
+ Bitmapp och inline-packning | 65 µs | 77 µs | 1,06M | 1,8K | 20,53 % | 15,01 % |
Tokeniseringsresultat vid 514 tokens efter att bitmap och inline-paketering lagts till.
Väggklockan sjunker systematiskt med ytterligare 4,5 % vid 514 tokens och växer med indatalängden, vilket når 6–7 % vid 4k och 16k. Att ta bort check-matrisen förvandlar triesteget per byte från två cacheline-inläsningar till en, vilket är 2,5× färre L2-åtkomster per kodning (1,8K jämfört med 4,6K för vanliga Darts). Missfrekvensen hoppar från 6 % till 20 %, men det mesta av det är nämnaren: med mindre än hälften så många åtkomster tar de missar vi har en större andel.
Optimering 3: Huge pages för trie-strukturen
Triedatan är ungefär 50 MB (780 000 noder med 64 byte vardera). På en standardiserad Linux-process stöds det minnet av 4 KB-sidor, så hela trien spänner över cirka 12 000 distinkta virtuella sidor, långt bortom vad CPU:ns första nivås data Translation Lookaside Buffer kan rymma (Sapphire Rapids har 96 poster). Varje Viterbi-steg rör vid en annan nod, och över en 512-tokens kodning bränner vi ungefär 9 000 cykler i sidtabellsvandringar, cirka 3 % av per-kodningsbudgeten.
Lösningen är att stödja trien med 2 MB stora sidor i stället för 4 KB-sidor. Samma 50 MB spänner nu över 25 sidor, får plats i Translation Lookaside Buffer helt och hållet, och själva sidtabellskatalogerna slutar tävla med trien om L2-cachen.
Linux har två sätt att stödja minne med 2 MB-sidor. Det opportunistiska är transparenta stora sidor (THP): ditt program ber kärnan att uppgradera en minnesregion från 4 KB till 2 MB-sidor, och en bakgrundskärntråd försöker göra det när den har tid och sammanhängande fysiskt minne tillgängligt. Det andra alternativet är att allokera trien direkt från en förreserverad pool av stora sidor som kärnan sätter åt sidan vid uppstart:
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,
)
};Poolen måste finnas på värden (vm.nr_hugepages inställd vid uppstart). På våra produktionsinstanser är 10 561 stora sidor reserverade; trien förbrukar 24 av dem. Efter mmap bekräftar /proc/<pid>/smaps regionen: KernelPageSize: 2048 kB, Private_Hugetlb: 49152 kB.
Vinsten är 3–12 % i väggklocka över indatalängder, konsekvent och proportionell mot arbetsuppsättningens längd:
Tokens | Utan stora sidor | Med stora sidor | Δ |
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 % |
Tokeniseringsfördröjning med och utan huge pages över olika inmatningsstorlekar.
Den största vinsten är vid 4k tokens, där arbetsuppsättningen är tillräckligt stor för att sidtabellstrafik aktivt tävlade med triedata om L2-bandbredd. Utöver 4k krymper vinsten eftersom L3-missar dominerar och sidtabellsvandringar blir en mindre andel av den totala tiden.
Slutlig jämförelse
Genom att sammanföra de tre optimeringarna är här den slutliga motorn uppmätt mot varje annan RoBERTa-tokeniserare vi testade. Vid sidan av de inhemska C++ SentencePiece- och C IREE-binärfilerna benchmarkade vi även de färdiga Rust-omslagspaketen som en produktionsanvändare skulle vända sig till, sentencepiece och iree-tokenizer. Paketet sentencepiece uppmäter 1,6–1,9× högre latens än den inhemska C++-binärfilen vid varje indatalängd, där omkostnaden kommer från att omslaget allokerar en ny lista med token-delar, som var och en håller sin egen kopia av deltexten, vid varje kodning. Paketet iree-tokenizer storleksanpassar sin interna buffert till varje indata, vilket undviker en konstant allokering per kodning som den inhemska IREE-binärfilen betalar oavsett indatalängd, och det spelar störst roll vid korta indata där allokeringen annars skulle dominera det faktiska tokeniseringsarbetet. Vid produktionsinmatningslängder amorteras den konstanta kostnaden och omslagets egen omkostnad per anrop från att kopiera tokens tillbaka till Rust driver dess latens över den inhemska binärfilens.
Den slutliga motorn kör samma Viterbi över samma 250 000-tokens vokabulär, allokerar ingenting på den heta sökvägen och producerar token-exakt utdata gentemot referensen. Varje bytesteg är nu en enda 64-bytes cacheline-inläsning, ett bitmappstest och en heltalsaddition, ner från en HashMap-sökning, ett allokatoranrop och flera specialfallsregler i baslinjeimplementeringen. Instruktioner per kodning föll från 3,66M till 1,04M, ett fall på 3,5×. Väggklockan matchar det förhållandet vid korta indata och breddas ytterligare vid långa indata, där referensens allokeringar per token börjar spilla över L2 och L3.
Avslutande tankar
I praktiken reducerade detta arbete CPU-utnyttjandet i förbehandlingslagret i vår inferensstack med 5–6× och skalade av tvåsiffriga millisekunder från omlistningslatensen.
Merparten av latensminskningen kom från lätta ändringar som tog bort slösat arbete: att släppa allokeringar per kodning, ersätta HashMap-barn-trien med en dubbelmatris och skala bort den generiska API-omkostnaden. Att använda den befintliga bitmappen per nod som giltighetskontroll och stödja trien med 2 MB stora sidor bidrog sammantaget med 10–20 % ovanpå det större skiftet till en dubbelmatristrie, beroende på indatalängd.
De färdiga Rust-omslagspaketen runt SentencePiece och IREE illustrerar samma poäng bakvänt. Ovanpå inhemska C- och C++-tokeniserare är FFI-gränserna och allokeringarna per anrop som omslagen introducerar synliga i latenssiffrorna. Rust-källkoden för den slutliga Viterbi-kodaren finns tillgänglig på github.com/perplexityai/pplx-garden.
Referenser
Hugging Face tokenizers — https://github.com/huggingface/tokenizers
SentencePiece — https://github.com/google/sentencepiece
IREE-tokeniserare — 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
Stora sidor — https://docs.kernel.org/admin-guide/mm/hugetlbpage.html
Citering
@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}
}