Skip to content

perf(pipeline): cut on metaspace runs when no pre-tokenizer is declared - #2296

Closed
ArthurZucker wants to merge 3 commits into
poc/target-encodefrom
perf/metaspace-runs-pretok
Closed

perf(pipeline): cut on metaspace runs when no pre-tokenizer is declared#2296
ArthurZucker wants to merge 3 commits into
poc/target-encodefrom
perf/metaspace-runs-pretok

Conversation

@ArthurZucker

@ArthurZucker ArthurZucker commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Draft, on top of #2279 (poc/target-encode, 106327e1).

The problem

pre_tokenizer: null falls to PipelinePreTokenizer::None, which pushes one span over the whole document:

Self::None => { out.push(Span { start: 0, end: text.len() as u32 }); Ok(()) }

Two things 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 + a Prepend+Replace normalizer that writes the delimiters).

Measured, llama-2/english, 10 kB chunks:

tokens / 10 kB model stage ns / token
gpt2 2293 1.62 ns/B 7.2
llama-2 2654 33.43 ns/B 129.0

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. MetaspaceRuns cuts 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, id 1678 (▁▁▁▁) came out as 29871,29871,29871. A Split on regex ▁+ says the right thing but needs the optional fancy-regex backend and is far slower than one vectorised memchr pass.

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).

cell before after ratio per-round after
llama-2/english 23.0 72.6 3.16× 71, 74, 73
llama-2/code 24.7 62.8 2.54× 63, 65, 61
llama-2/xnli 50.7 95.6 1.89× 96, 97, 96
llama-2/russian 51.6 86.4 1.67× 86, 91, 83
llama-2/chinese 192.6 179.4 0.93× 180, 179, 179

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:

stage before after
normalize 2.90 2.83
split 0.00 1.11
model 40.51 10.33

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_chars had no ASCII fast path where convert_bytes does — it decoded UTF-8 and went through the 2-D get_char lookup 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 that to_normalizer_and_split deliberately maintains, so it is a design question rather than a patch.

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.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants