perf: reduce BPE tokenizer load-time memory#2199
Open
xrl1 wants to merge 3 commits into
Open
Conversation
dhat-based bench reporting peak live bytes and total churn of Tokenizer::from_file, complementing the latency-only criterion benches.
ModelWrapper::deserialize buffered the whole model into an owned Value and reparsed it, allocating the vocab twice. Read it as a borrowed RawValue, peek the `type` tag, and parse once into the concrete model. Halves load-time allocation for every model type, and preserves on-disk field order (a Value's map sorts keys), which the BPE change relies on. Enables the serde_json `raw_value` feature.
BPE loading buffered every merge into an owned Vec<(String, String)> and re-walked it in build(). BPEVisitor now resolves each merge to ids as it is parsed, borrowing tokens via Cow so only escaped tokens allocate. Merge resolution is shared with build() so both paths stay identical; when merges precede vocab it falls back to the old buffer-then-resolve. Reword Error::BadMerges to "Invalid merge rule #N" (the position is a JSON array index, not a file line). With the previous commit, loading a 128k-vocab BPE drops from 172 to 44 MiB peak. Output is byte-identical (round-trip and encode parity verified).
xrl1
force-pushed
the
feat/bpe-deserialize-mem-tk
branch
from
July 22, 2026 12:16
2fb4b46 to
fa36a76
Compare
Author
|
I saw conflicts showing up so I solved them :) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reduce BPE tokenizer load-time memory
Motivation
We have a production low-memory environment where we want to move from WordPiece to BPE, but the load-time memory increase is too high for us to adopt as-is. This PR closes most of that gap.
Context
Loading a large BPE
tokenizer.jsonviaTokenizer::from_fileallocates far more than the resulting model needs — a 128k-vocab BPE (llama-3) peaks at 172 MiB to produce a model whose live footprint is ~44 MiB. Profiling traced this to two independent sources of transient allocation on the load path, neither of which touches the encode hot path.Changes
Three commits:
bench: add load_mem heap-allocation benchmark— a dhat-based bench measuring the memory cost offrom_file(peak live bytes + churn), complementing the existing latency benches. Defaults todata/llama-3-tokenizer.json(fetched bymake data), takes an optional path env.perf(models): avoid serde_json::Value round-trip—ModelWrapper::deserializeused#[serde(flatten)] rest: serde_json::Value+from_value, parsing the entire model into an ownedValuethen reparsing it (allocating the whole vocab twice). Now captures the model as a borrowedRawValue, peeks thetypetag, andfrom_strs once into the concrete type. Applies to all model types (BPE/WordPiece/WordLevel/Unigram). Also preserves on-disk field order (aValuemap sorts keys), which enables change 3.perf(bpe): resolve merges to ids while deserializing— instead of collecting every merge into an ownedVec<(String,String)>and re-walking it inbuild(),BPEVisitorresolves each merge to ids as it is parsed (borrowing tokens viaCow), dropping strings immediately. Requiresvocabbeforemerges(guaranteed by change 2); falls back to buffer-then-resolve otherwise, so any validtokenizer.jsonstill loads.Details
The non-obvious parts of the diff:
MergesResolver(serialization.rs) — aDeserializeSeedholding a reference to the already-parsed vocab, so it resolves merges to ids as the array is read rather than buffering them first.Cow<str>for merge tokens — each merge token deserializes asCow, borrowed from the input on the common path and only allocated when it contains JSON escapes. It's used to look up ids, then dropped.MergeInputenum (model.rs) — the builder now holds eitherRaw(strings to resolve later) orResolved(already ids), so the two mutually exclusive states are type-enforced instead of tracked with parallelOptions.Field-order fallback — the fast path needs
vocab(andcontinuing_subword_prefix) beforemerges. Ifmergescomes first, it's buffered and resolved inbuild()as before. This crate always serializes vocab-first, so the fast path is what runs; the fallback just keeps arbitrary field order correct.Shared helpers (
model.rs) —resolve_merge(one pair → ids) andparse_legacy_merge(one legacy"a b"line → pair) are reused by both the fast and fallback paths, keeping them behaviorally identical.Reworded
Error::BadMerges— message changed from"Merges text file invalid at line {N}"to"Invalid merge rule #{N}"(variant name kept). For atokenizer.jsonthere is no merges file and{N}is an array index, not a line — so "line" was already wrong there, and it also collided with the positionserde_jsonappends.Results
cargo bench --bench load_memondata/llama-3-tokenizer.json(128k-vocab BPE):Load time
Less allocation and no double-parse also make loading faster.
cargo bench --bench ci_benchmark -- serialization(Criterion, sample-size 100), base vs this branch:deserialize-llama3(parse+build, no I/O)from-file-llama3(full load)from-file-albertdeserialize-robertasave-llama3(control)Correctness
Error messages
Model parsing now goes through
serde_json::from_strover the extracted model slice, which changes deserialize-error text:Unknown model type \X`instead of serde'sdata did not match any variant of untagged enum ModelUntagged`.at line L column C— correct within the model object but offset by the lines preceding"model". Previously these pointed at EOF, so this is no worse.Note on #2151
After preparing this PR I noticed that #2151 fixes an issue in the same code this PR touches (
resolve_merge/build's fixed merge scratch buffer, which can panic on a crafted oversized merge). This PR keeps that pre-existing behavior unchanged. Let me know if you'd like me to fold #2151's fix in here, or how you'd prefer to handle the potential conflict.