perf(pipeline): cut on metaspace runs when no pre-tokenizer is declared - #2296
Closed
ArthurZucker wants to merge 3 commits into
Closed
perf(pipeline): cut on metaspace runs when no pre-tokenizer is declared#2296ArthurZucker wants to merge 3 commits into
ArthurZucker wants to merge 3 commits into
Conversation
A config with `pre_tokenizer: null` falls to `PipelinePreTokenizer::None`,
which pushes ONE span over the whole document. Two things then go wrong at
once: the merge runs over ~10 kB of symbols instead of a word, and the
`WordCache` keys on that whole span, so it can only ever hit on a
byte-identical repeat of the document. llama-2 ships exactly this shape --
`pre_tokenizer: null` plus a `Prepend`+`Replace` normalizer that writes the
delimiters.
Measured on llama-2/english with 10 kB chunks: 129 ns per token against
gpt2's 7.2, for token counts within 16% of each other. An 18x gap that is
span length, not merge quality -- the two-tier queue is the better algorithm
being handed a far worse problem shape.
So when the normalizer writes `▁` AND the vocabulary proves a cut at `▁`
cannot change the result, install a pre-tokenizer that makes those cuts. The
proof: BPE only ever emits vocabulary pieces, so a merge can span a `▁`
boundary only if some piece holds a `▁` after a non-`▁` character. None does
in llama-2, llama-3, mistral-nemo or gpt2 (one scan at load). That is what
makes this byte-exact rather than an approximation.
`MetaspaceRuns` cuts before each *run* of delimiters, not each delimiter. The
distinction is load-bearing: cutting before every `▁` breaks the 15
multi-delimiter pieces llama-2 carries (`▁▁` through `▁▁▁▁▁▁▁▁▁▁▁`), and
indentation tokenizes as N separate `▁` where the reference emits one piece
-- on the code corpus, id 1678 (`▁▁▁▁`) came out as 29871,29871,29871. A
`Split` on the regex `▁+` expresses the right thing but needs the optional
`fancy-regex` backend and is far slower than one vectorised `memchr` pass.
Whether the normalizer writes delimiters is probed (normalize `" a"`, look
for the mark) rather than pattern-matched on normalizer types, so it cannot
go stale when another mark-writing normalizer is added.
llama-2, disjoint-slice probe (warm on unseen text, time on unseen text), all
ids verified against `encode_fast`:
english 22.9 -> 69.1 MB/s 3.0x
code 25.1 -> 60.6 2.4x
xnli 51.1 -> 91.2 1.8x
russian 51.5 -> 84.5 1.6x
chinese 191.6 -> 152.3 0.8x REGRESSION
Stage cost, llama-2/english: the model stage drops 40.51 -> 10.33 ns/B for
1.11 ns/B of splitting; normalize is unchanged at 2.83.
gpt2 is untouched -- it declares a pre-tokenizer and never reaches this path.
KNOWN REGRESSION: Chinese loses 20%. It contains almost no `▁`, so the pass
scans the chunk and returns essentially one span: cost with no benefit. A
mark-density guard would fix it but makes the pre-tokenizer input-dependent,
which wants more thought than a bolt-on. Filed here rather than hidden.
`convert_bytes` has a tight ASCII loop -- one index into a 128-entry table per byte. `convert_chars` had none: it decoded UTF-8 per character and went through the two-dimensional `get_char` lookup (`get(cp >> 6, cp & 0x3F)`) for every one, including the pure-ASCII characters that dominate Latin text. An ASCII character is exactly one symbol and cannot decompose, so the fast loop needs only one extra condition: a miss (`u32::MAX`) falls through to the general path unchanged. llama-2/english model stage: 40.51 -> 37.95 ns/B, about 6%. Small, and not the reason char-atom models are slow -- that is span length, addressed separately -- but it is free and byte-exact.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
…s baked in (#2303) Two changes, both removing an all-or-nothing check that one vocabulary entry could defeat. 1. A declared pre-tokenizer that cannot cut is the `None` case. gemma-3 declares `Split` on the literal " " while its normalizer has already replaced every space with `▁`, so the splitter is installed, runs, and matches nothing: the model receives the whole document as one span. tokenizers 0.23.1 does the same, so the ids are right -- but every word merges in one span and the word cache can only hit on a byte-identical repeat of the document. It cannot be decided from the pre-tokenizer alone, because whether it can match depends on what the normalizer did first, so probe the pair: a declared splitter that yields one span for a multi-word sample cannot cut anything. Errs toward "it cuts", so a failed probe leaves the config untouched. 2. The cut guard is a set of bytes, not a yes/no. `metaspace_cuts_are_safe` asked whether cutting was safe anywhere, and one piece could take the answer from the whole vocabulary. gemma-3 holds exactly one piece with an interior `▁` -- `>▁</` -- out of 262,144, so the splitter was never installed. llama-2 has none, which is the only reason it worked there. `metaspace_cut_guard` returns the 256-bit set of bytes that may precede an interior `▁`. The synthesised splitter carries it and skips a cut after one of those bytes; for gemma-3 that is the single byte `>`. Skipping a cut is always sound: not cutting is what the reference does, so any subset of the safe cuts gives the reference's ids, and only cutting where a piece spans the boundary can change them. gpt2, ns/byte, 10 kB documents each encoded once with the cache carried: gemma-3/chat-mistral 17 -> 76 MB/s 4.48x gemma-3/english 17 -> 58 MB/s 3.39x gemma-3/xnli 29 -> 73 MB/s 2.48x gemma-3/code 19 -> 41 MB/s 2.17x gemma-3/chinese 70 -> 80 MB/s 1.14x llama-2 and gpt2 are unchanged: llama-2 medians over three runs are 60/52/164/88 MB/s on english/code/chinese/xnli against 58/54/163/89 before, a spread of <= 3. Their guard sets are empty, so the added test is one bitmap probe per `▁`. Exactness: full benchmark matrix, 8 models x 30 corpora, 203 cells byte-exact against tokenizers 0.23.1 and 29 mismatched -- the same albert (Unigram) cells that already failed. The new test pins ids for text containing `>` before a space, which a splitter that ignored the guard would cut through.
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.
Draft, on top of #2279 (
poc/target-encode,106327e1).The problem
pre_tokenizer: nullfalls toPipelinePreTokenizer::None, which pushes one span over the whole document:Two things go wrong at once: the merge runs over ~10 kB of symbols instead of a word, and the
WordCachekeys on that whole span — so it can only ever hit on a byte-identical repeat of the document. llama-2 ships exactly this shape (pre_tokenizer: null+ aPrepend+Replacenormalizer that writes the delimiters).Measured, llama-2/english, 10 kB chunks:
Token counts within 16% of each other, so this is span length, not merge quality — the two-tier queue is the better algorithm handed a far worse problem shape.
The change
When the normalizer writes
▁and the vocabulary proves a cut at▁cannot change the result, install a pre-tokenizer that makes those cuts.The proof. BPE only ever emits vocabulary pieces, so a merge can span a
▁boundary only if some piece holds a▁after a non-▁character. None does in llama-2, llama-3, mistral-nemo or gpt2 — one scan at load. That is what makes this byte-exact rather than an approximation.Runs, not single delimiters.
MetaspaceRunscuts before each run. Cutting before every▁breaks the 15 multi-delimiter pieces llama-2 carries (▁▁…▁▁▁▁▁▁▁▁▁▁▁): indentation then tokenizes as N separate▁where the reference emits one piece. On the code corpus, id1678(▁▁▁▁) came out as29871,29871,29871. ASpliton regex▁+says the right thing but needs the optionalfancy-regexbackend and is far slower than one vectorisedmemchrpass.Probed, not pattern-matched. Whether the normalizer writes delimiters is decided by normalizing
" a"and looking for the mark, so it cannot go stale when another mark-writing normalizer is added.Measured
Disjoint-slice probe — warm on unseen text, time on unseen text, so nothing measures memoization of the benchmark's own input. 3 interleaved A/B rounds on current heads; every cell's ids verified against
encode_fast(all EXACT).Per-round spreads do not overlap on any llama-2 cell, so these are signal rather than drift.
gpt2 is untouched — 0.99×–1.02× across english/code/chinese/russian/xnli. It declares a pre-tokenizer and never reaches this path.
Stage cost, llama-2/english — the work moves out of the model:
Known regression, not hidden
Chinese loses ~7% (192.6 → 179.4). It contains almost no
▁, so the pass scans the chunk and returns essentially one span: cost with no benefit. A mark-density guard would fix it, but it makes the pre-tokenizer input-dependent, which wants more thought than a bolt-on. This is why the PR is a draft.Second commit
convert_charshad no ASCII fast path whereconvert_bytesdoes — it decoded UTF-8 and went through the 2-Dget_charlookup for every character, including the pure-ASCII ones that dominate Latin text. Worth ~6% (40.51 → 37.95 ns/B), byte-exact, and separable if you'd rather take one.What this is not
Not a merge improvement, and not a char-atom fold — that was worth 6%, not the 18×. The remaining gap to gigatoken on llama-2/english (72.6 vs 91.7) is the normalize + split overhead it avoids by fusing the
▁rewrite into its unit scan rather than materialising a normalized string: normalize 2.83 + split 1.11 = 3.94 ns/B of our 14.3, against its ~10.9. Fusing those two passes is the next step and crosses the normalizer/pre-tokenizer split thatto_normalizer_and_splitdeliberately maintains, so it is a design question rather than a patch.