Skip to content

perf(bpe): prove the whole-pretoken fold per entry instead of trusting the flag (onto #2241) - #2302

Merged
ArthurZucker merged 3 commits into
perf/bpe-mergefrom
perf/bpe-provable-fold-2241
Aug 6, 2026
Merged

ArthurZucker merged 3 commits into
perf/bpe-mergefrom
perf/bpe-provable-fold-2241

Conversation

@ArthurZucker

Copy link
Copy Markdown
Collaborator

Same change as #2301, ported onto perf/bpe-merge (#2241) so it lands on the branch that already carries the content-byte gate from #2294.

The idea

ignore_merges lets a pretoken that is itself a vocabulary entry be emitted as that entry, skipping the merge loop. It is declared in the config, so llama-3 gets it and gpt2 does not — even though 93.1% of English pretokens are a single vocabulary entry that provably cannot encode to anything else.

The property is checkable, so it need not be assumed. At load, run the merge engine on each entry's own text and record whether it reduces back to itself.

fn fold_id(&self, sequence: &str) -> Option<u32> {
    let id = self.vocab.get_bytes(sequence.as_bytes())?;
    match &self.fold_bits {
        None if self.ignore_merges => Some(id),   // config asked for it: fold every hit
        None => None,
        Some(bits) => (bits.at(id as usize / 64) >> (id % 64) & 1 == 1).then_some(id),
    }
}

Configs that already declare ignore_merges are untouched — the proof only runs when the config did not ask, so this can only enable a case that was already safe.

Why a bitset and not a bool

The bool version measured exactly flat. On gpt2 one entry of 50,257 disagrees: <|endoftext|> decomposes to ['<','|','end','of','text','|','>']. A global flag therefore reads "not provable" and the other 50,256 entries keep paying for it. That entry is a special token consumed by the added-vocabulary frame before the model, so in practice it never reaches this path — but "in practice" is not a proof, and a bitset does not need one. 6.3 KB for gpt2; a disagreeing entry costs only itself.

No new table

One probe into the vocabulary MPHF already held, plus one bit test. The alternative for this workload is a large pretoken cache — gigatoken's is ~64 MB (1.3M entries, 99.4% hit rate) against these 6.3 KB.

Numbers

Measured on the #2279 encode path (identical change), gpt2, ns/byte, 10 kB documents each encoded once with the cache carried. Median of 7 interleaved rounds; chinese/hindi/thai over 11.

corpus before after speedup pretokens that are one vocab entry
chat-mistral 4.01 2.65 1.51× 94.8%
chat-chatml 3.88 2.74 1.42× —
english 5.47 4.01 1.36× 93.1%
math-latex 4.69 3.97 1.18× —
code 3.11 2.69 1.16× 88.5%
agentic-swe 3.16 2.92 1.08× 80.6%
chinese 10.99 10.89 1.01× 37.6%
hindi 2.61 2.61 1.00× 11.9%
thai 5.48 5.93 0.92× —

Load cost: +7 ms median (61 → 68 ms), one merge per entry.

Thai loses 8% to probes that miss. Gating the probe on the content-byte gate was tried and is a wash (0.96× against the ungated fold): the miss path then computes the gate twice, once in fold_id and once in merge_word, which costs about what the skipped probe saved. A cheaper reject is still open.

Exactness

Full benchmark matrix on the #2279 path, 7 models × 30 corpora: 174 cells byte-exact against tokenizers 0.23.1, 29 mismatched — the same 29 albert (Unigram) cells that already fail before the change. No new mismatch. tk-encode suite passes here (338 tests) with a new test pinning ids for strings that mix folded words, merged words and a literal <|endoftext|>.

Two approaches this replaces

Both measured and rejected:

  • Dense 512×512 grid (inline u64 instead of u16 index + values): 0.89–1.00×. Only ~4% of cells are live, so the value array stays hot in L2 and the indirection is already right.
  • Incremental rank array (8.0 lookups per merge → ~2): 1.10× on english; removing the 7 panic_bounds_check branches it introduced added nothing. 86.6% of lookups were already grid hits from a ~100 KB table, so they were cheap. The merges were the cost, and the fold removes them rather than speeding them up.

…g the flag

`ignore_merges` lets a pretoken that is itself a vocabulary entry be emitted as
that entry, skipping the merge loop. It is declared in the config, so models
trained expecting it (llama-3) get it and gpt2 does not -- even though ~93% of
English pretokens are a single vocabulary entry that provably cannot encode to
anything else.

The property is checkable, so it need not be assumed. At load, run the merge
engine on each entry's own text and record whether it reduces back to itself.
Configs that already declare `ignore_merges` are untouched; the proof only runs
when the config did not ask, so this can only enable a case that was safe.

A bitset, not a bool. A single flag is all-or-nothing and one entry is enough
to lose it: on gpt2 exactly one of 50,257 disagrees -- `<|endoftext|>`, which
decomposes to seven tokens -- so a global flag reads "not provable" and the
other 50,256 keep paying for it. Measured with a bool first: exactly flat.
Per-entry bits cost 6.3 KB for gpt2 and a disagreeing entry costs only itself.

Encoding is one probe into the vocabulary MPHF already held, then one bit test.
No new table: the alternative for this workload is a large pretoken cache, and
gigatoken's is ~64 MB against these 6.3 KB.

Measured on the #2279 encode path (same change, ported here), gpt2, ns/byte,
10 kB documents each encoded once with the cache carried, median of 7
interleaved rounds:

    chat-mistral   4.01 -> 2.65   1.51x
    chat-chatml    3.88 -> 2.74   1.42x
    english        5.47 -> 4.01   1.36x
    math-latex     4.69 -> 3.97   1.18x
    code           3.11 -> 2.69   1.16x
    agentic-swe    3.16 -> 2.92   1.08x
    chinese       10.99 -> 10.89  1.01x
    hindi          2.61 -> 2.61   1.00x
    thai           5.48 -> 5.93   0.92x

The gain tracks how often a pretoken is a whole vocabulary entry: 94.8% on
chat-mistral and 93.1% on english against 37.6% on chinese and 11.9% on hindi.
Thai loses 8% to probes that miss; gating the probe on the content-byte gate
was tried and is a wash, because the miss path then computes the gate twice.

Load cost is +7 ms median (61 -> 68 ms), the proof being one merge per entry.

Exactness: the fold changes what the model emits, so it is the one thing that
must be checked. Full benchmark matrix on the #2279 path, 7 models x 30
corpora: 174 cells byte-exact against tokenizers 0.23.1 and 29 mismatched --
the same 29 albert (Unigram) cells that already fail before this change.
@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.

The proof's answer was kept in a separate 6.3 KB bitset consulted after the
vocabulary probe. It does not need to exist: an id needs 31 bits at most, the
lookup already loads the entry to verify the key, and this crate already packs
flags beside an id -- a merge value is a product id in the low 30 bits with
SAFE_MASK above it. Same convention here: FOLD_BIT is bit 31 of the stored id,
VOCAB_ID_MASK is the rest.

So the fold answer arrives on a load that already happened. No side table to
allocate, no second structure to keep in step with the vocabulary, and no
second lookup on the encode path.

`get_bytes_foldable` returns both halves; `get_bytes` masks, so no caller sees
the flag. `content`/`byte_content` mask too -- the bit must never escape this
type, and an unmasked export would have leaked it into `get_vocab()`. Build
asserts every id fits under the flag.

The model keeps one `fold_by_flag` bool to tell "proven per entry" from "the
config declared ignore_merges, so fold every hit".

No behaviour change: 338 tests pass, including the test that pins ids for
strings mixing folded words, merged words and a literal `<|endoftext|>`.
Comment thread tokenizers/tk-encode/src/models/bpe/model.rs Outdated
@ArthurZucker
ArthurZucker marked this pull request as ready for review August 6, 2026 06:43
@ArthurZucker
ArthurZucker merged commit 1719095 into perf/bpe-merge Aug 6, 2026
35 of 42 checks passed
@ArthurZucker
ArthurZucker deleted the perf/bpe-provable-fold-2241 branch August 6, 2026 06:43
ArthurZucker added a commit that referenced this pull request Aug 6, 2026
* wip / ai: scratch pool

* scratch pool iteration

* implement word cache

* lint

* change defaults + max length + capacity bump

* more sensible values

* rewrite without hashmap

* iteration

* iteration

* fmt

* review comments

* draft first commit

* nits

* proper bpe only benches

* proper features

* update

* update what we balckbox

* comments

* commetn

* first drafts

* fix glue

* draft table builds

* start mphf draft

* impl get

* fix mphf

* nits

* fixes

* slowly but surely

* fixes

* table tests pass

* create top merges

* update

* up

* progress in the conversion table build

* update

* fixes

* dman

* ...

* eager flag was missing

* update

* design a manual example

* with / without thief

* remove some shit

* more simplifications

* single test move it

* draft engine

* remove complication

* remove one flag that was not worth it

* current status

* monomorphize for perfs

* fixes

* rename

* start drafting the two tier merge

* todo is getting fixed

* nit

* SparseFold for table!

* move stuff around

* multipass bpe

* scratches

* isolate pipeline bpe

* remove unused mergemap

* u16 is faster

* add convert.rs

* update

* less branches

* update

* it was not worth it

* finally

* nits, renaming and moving files here and there

* renaming

* fix more of the renaming

* pipeline vs legacy

* first fix for the gate

* update the local bench

* up

* bit better benches

* update

* revert unrelated changes

* more reverts

* more reversion!

* cleanup

* nits

* more clippy

* perf(merge bpe): improvement suggestions (#2275)

* rewrite + document multipass

* rename

* perf: save a lookup when there is no merge

* perf: compiler gotchas

* Idiomacy:
- Use a trait generic instead of a const generic
- 2 Specialized struct instead of mangling all into SymbolSink
- fn that had no reason to live in impl PipelineBpe are moved oout

* review: address comments on merge_multipass

- reword the copy/merge cursor step, drop the redundant invariant line
- show the trailing dead slots in the pass diagrams instead of shrinking
  the boxes, with a legend for the padding
- apply the suggested SAFE-bit wording
- move MergeState above merge_once, its only user
- has_merged -> was_merged, known_pair_value -> cached_pair_value
- inline copy_last into merge_once
- reframe the step doc around the lookup it wins back

---------

Co-authored-by: Arthur <arthur.zucker@gmail.com>

* fix(bpe): the byte gate classifies the delimiter, not the word (#2294)

* fix(bpe): the byte gate classifies the delimiter, not the word

`merge_word` indexes `byte_to_mode` on `sequence.as_bytes()[0]`, but a
pre-tokenizer hands the model its delimiter attached to the front, so byte 0
describes the delimiter rather than the word. ByteLevel gives `"Ġword"`
(`Ġ` = U+0120 = `C4 A0`), Metaspace gives `"▁word"` (`▁` = U+2581 = `E2 96 81`)
— both land above 0x7F and take GATE_MULTI(8), even when the word behind them
is pure ASCII and belongs on GATE_ASCII(24).

`build_byte_to_gate` already special-cases a literal leading space down to
GATE_MULTI, with a comment noting that the first byte "says nothing about the
script of the rest" — so the hazard was known. The multibyte spellings of the
same delimiter never got the same treatment, and forcing them to MULTI is the
wrong direction anyway: the answer is to classify the content.

`content_start` skips one leading delimiter (`▁`, `Ġ`, or ASCII whitespace) and
`merge_word` indexes the gate there. A delimiter-only word keeps offset 0,
where MULTI remains the right conservative answer.

Reach, on 200 kB of the english corpus with the gpt2 pre-tokenizer: 30.6% of
words sit in the gate's decision band (9..24 bytes) AND are Ġ-prefixed with
ASCII content, so they change engine. On code.txt only 0.9% do.

Measured, gpt2/english, 10 kB chunks, best-of-3 warm, three interleaved A/B
rounds to cancel drift:

    with fix     97.2  98.3  98.7 MB/s
    without      89.4  91.0  85.3 MB/s

Non-overlapping: +10.7%. code/dense/russian/chinese are flat, consistent with
their much smaller affected fraction.

Byte-exact by construction — the gate only chooses between two merge engines
that must already agree — and confirmed: identical token counts on every
model/corpus probed. `cargo test -p tk-encode` 311/313, the one failure
(`normalizers::precompiled::pipeline_precompiled_matches_legacy`, a missing
data fixture) reproduces unchanged without this commit.

NOT a fix for llama-2. Its config has `pre_tokenizer: None`, so a 10 kB chunk
arrives as a single word whose length exceeds both gates; it always took the
two-tier queue and still does. llama-2's cost is the merge over one unbounded
span, which is a separate problem.

* Update tokenizers/tk-encode/src/models/bpe/merge_hot_cold_queue.rs

* Update tokenizers/tk-encode/src/models/bpe/merge_hot_cold_queue.rs

* Perf/bpe merge review (#2297)

* chore(node): sync Cargo.lock with tk-encode's libc dependency

* refactor(bpe): flatten the filename prefixes, group the released-crate BPE under legacy/

* refactor(bpe): split the tables file, document the packed merge value once

* refactor(bpe): move code next to its users

* refactor(bpe): one name per concept

* refactor(bpe): collapse the three copies of the char-as-bytes push

* perf(bpe): prove the whole-pretoken fold per entry instead of trusting the flag (onto #2241) (#2302)

* perf(bpe): prove the whole-pretoken fold per entry instead of trusting the flag

`ignore_merges` lets a pretoken that is itself a vocabulary entry be emitted as
that entry, skipping the merge loop. It is declared in the config, so models
trained expecting it (llama-3) get it and gpt2 does not -- even though ~93% of
English pretokens are a single vocabulary entry that provably cannot encode to
anything else.

The property is checkable, so it need not be assumed. At load, run the merge
engine on each entry's own text and record whether it reduces back to itself.
Configs that already declare `ignore_merges` are untouched; the proof only runs
when the config did not ask, so this can only enable a case that was safe.

A bitset, not a bool. A single flag is all-or-nothing and one entry is enough
to lose it: on gpt2 exactly one of 50,257 disagrees -- `<|endoftext|>`, which
decomposes to seven tokens -- so a global flag reads "not provable" and the
other 50,256 keep paying for it. Measured with a bool first: exactly flat.
Per-entry bits cost 6.3 KB for gpt2 and a disagreeing entry costs only itself.

Encoding is one probe into the vocabulary MPHF already held, then one bit test.
No new table: the alternative for this workload is a large pretoken cache, and
gigatoken's is ~64 MB against these 6.3 KB.

Measured on the #2279 encode path (same change, ported here), gpt2, ns/byte,
10 kB documents each encoded once with the cache carried, median of 7
interleaved rounds:

    chat-mistral   4.01 -> 2.65   1.51x
    chat-chatml    3.88 -> 2.74   1.42x
    english        5.47 -> 4.01   1.36x
    math-latex     4.69 -> 3.97   1.18x
    code           3.11 -> 2.69   1.16x
    agentic-swe    3.16 -> 2.92   1.08x
    chinese       10.99 -> 10.89  1.01x
    hindi          2.61 -> 2.61   1.00x
    thai           5.48 -> 5.93   0.92x

The gain tracks how often a pretoken is a whole vocabulary entry: 94.8% on
chat-mistral and 93.1% on english against 37.6% on chinese and 11.9% on hindi.
Thai loses 8% to probes that miss; gating the probe on the content-byte gate
was tried and is a wash, because the miss path then computes the gate twice.

Load cost is +7 ms median (61 -> 68 ms), the proof being one merge per entry.

Exactness: the fold changes what the model emits, so it is the one thing that
must be checked. Full benchmark matrix on the #2279 path, 7 models x 30
corpora: 174 cells byte-exact against tokenizers 0.23.1 and 29 mismatched --
the same 29 albert (Unigram) cells that already fail before this change.

* perf(bpe): pack the foldable flag into the id, dropping the side bitset

The proof's answer was kept in a separate 6.3 KB bitset consulted after the
vocabulary probe. It does not need to exist: an id needs 31 bits at most, the
lookup already loads the entry to verify the key, and this crate already packs
flags beside an id -- a merge value is a product id in the low 30 bits with
SAFE_MASK above it. Same convention here: FOLD_BIT is bit 31 of the stored id,
VOCAB_ID_MASK is the rest.

So the fold answer arrives on a load that already happened. No side table to
allocate, no second structure to keep in step with the vocabulary, and no
second lookup on the encode path.

`get_bytes_foldable` returns both halves; `get_bytes` masks, so no caller sees
the flag. `content`/`byte_content` mask too -- the bit must never escape this
type, and an unmasked export would have leaked it into `get_vocab()`. Build
asserts every id fits under the flag.

The model keeps one `fold_by_flag` bool to tell "proven per entry" from "the
config declared ignore_merges, so fold every hit".

No behaviour change: 338 tests pass, including the test that pins ids for
strings mixing folded words, merged words and a literal `<|endoftext|>`.

* Apply suggestion from @ArthurZucker

* docs(bpe): say where the hot queue's heap comes from

hot_push/hot_pop are std's BinaryHeap sift_up/sift_down_range, vendored and
specialised: min-order over a raw u64 key, so no Reverse wrapper around the
packed (rank, index). Note the "hole" form too -- shift into the hole, write
once -- since it reads like an unrolled swap otherwise.

---------

Co-authored-by: SBrandeis <33657802+SBrandeis@users.noreply.github.com>
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