Skip to content

bpe: do not slice merge tokens at a byte offset that may not be a boundary - #2398

Open
apollo-2006 wants to merge 1 commit into
huggingface:mainfrom
apollo-2006:fix-bpe-merge-prefix-slicing
Open

apollo-2006 wants to merge 1 commit into
huggingface:mainfrom
apollo-2006:fix-bpe-merge-prefix-slicing

Conversation

@apollo-2006

Copy link
Copy Markdown

BpeBuilder::build strips the continuing subword prefix off the right hand side of every merge by byte offset, without checking that the token actually carries the prefix:

let b_len = b.len() - prefix_len;
let merge_len = a.len() + b_len;
buffer[a.len()..merge_len].copy_from_slice(&b.as_bytes()[prefix_len..]);
// SAFETY: buffer contains a concatenation of two valid UTF-8 strings, so it is itself valid UTF-8, even considering prefix_len
let new_token = unsafe { from_utf8_unchecked(&buffer[..merge_len]) };

The safety comment does not hold. continuing_subword_prefix and the merge list are both deserialized from the tokenizer JSON and nothing relates them, so a file that is merely malformed rather than malicious reaches three separate failures.

1. The offset can land inside a character

With prefix "ab" and the merge ("x", "日"), the right hand side is E6 97 A5 and does not start with "ab", but two bytes are removed anyway. The remainder is the lone continuation byte A5, so the bytes handed to from_utf8_unchecked are:

bytes = [78, A5]
from_utf8 rejects these bytes: invalid utf-8 sequence of 1 bytes from index 1

Constructing a str from those bytes violates its validity invariant. Note that Miri does not check UTF-8 validity of &str, so this does not show up under cargo miri test; the output above is from running str::from_utf8 on the exact slice the unsafe call receives.

2. The subtraction underflows

b.len() - prefix_len has no lower bound check. A 4-byte prefix against a 1-byte token:

panicked at src/models/bpe/model.rs:265:29: attempt to subtract with overflow

Debug panics, release wraps.

3. The scratch buffer can be too small

buffer is sized to the longest vocabulary entry, but the merged token need not be in the vocabulary, so merge_len can exceed it:

panicked at src/models/bpe/model.rs:266:23: range end index 4 out of range for slice of length 3

Fix

Use str::strip_prefix, which removes the prefix only when it is actually present and therefore always cuts on a character boundary, and build the token in a reused String so growth is handled automatically. That addresses all three, and the unsafe block is no longer needed at all.

The allocation behaviour is the same as before: one buffer reused across every merge, String::with_capacity(max_len) in place of vec![0; max_len].

Behaviour

Well formed vocabularies are unaffected. When b carries the prefix, strip_prefix removes exactly the same bytes the old slice did, so the merged token is byte for byte identical. The existing test_bpe_with_continuing_subword_prefix covers that path and is unchanged.

Where behaviour does change is the malformed cases above, which previously produced a corrupted token, a panic, or undefined behaviour, and now produce the ordinary MergeTokenOutOfVocabulary error.

Tests

Three regression tests, one per case. Against the unfixed code each fails with its own distinct error:

test without this change
test_bpe_prefix_not_present_on_merge assertion fails, error names the corrupted token
test_bpe_prefix_longer_than_token attempt to subtract with overflow
test_bpe_merged_token_longer_than_longest_vocab_entry range end index 4 out of range for slice of length 3

Verified with the full test data downloaded (make test resources, including the GPT-2 vocab and merges, so a real 50k-entry byte-level BPE goes through the changed path): 258 passed, 0 failed. cargo fmt --check clean and cargo clippy --all-targets --all-features -- -D warnings clean.

…ndary

`BpeBuilder::build` strips the continuing subword prefix off the right hand
side of every merge by byte offset, without checking that the token actually
carries the prefix:

    let b_len = b.len() - prefix_len;
    let merge_len = a.len() + b_len;
    buffer[a.len()..merge_len].copy_from_slice(&b.as_bytes()[prefix_len..]);
    // SAFETY: buffer contains a concatenation of two valid UTF-8 strings, so
    // it is itself valid UTF-8, even considering prefix_len
    let new_token = unsafe { from_utf8_unchecked(&buffer[..merge_len]) };

The safety comment does not hold. Both `continuing_subword_prefix` and the
merge list come from the tokenizer JSON, and nothing relates them, so three
things go wrong on input that is merely malformed rather than malicious.

1. The offset can land inside a multi-byte character. For prefix "ab" and
   merge ("x", "\u{65e5}"), which is E6 97 A5 and does not start with "ab",
   `&b.as_bytes()[2..]` is the lone continuation byte A5. The bytes handed to
   `from_utf8_unchecked` are then [78, A5], which `str::from_utf8` rejects
   with "invalid utf-8 sequence of 1 bytes from index 1". Building a `str`
   from those bytes violates its validity invariant.

2. `b.len() - prefix_len` underflows when the prefix is longer than the
   token. A 4-byte prefix against a 1-byte token panics with "attempt to
   subtract with overflow" in debug, and wraps in release.

3. `buffer` is sized to the longest vocabulary entry, but the merged token
   need not be in the vocabulary at all, so `merge_len` can exceed it:
   "range end index 4 out of range for slice of length 3".

Strip the prefix with `str::strip_prefix`, which only removes it when it is
actually there and therefore always cuts on a character boundary, and build
the token in a reused `String` so growth is handled and no unchecked
conversion is needed. The `unsafe` block goes away entirely.

Well formed vocabularies are unaffected: when `b` carries the prefix the
result is byte for byte what it was. `test_bpe_with_continuing_subword_prefix`
covers that and is unchanged.

Adds three regression tests, one per case above. Each fails without this
change with its own distinct error.
Copilot AI lite review requested due to automatic review settings September 10, 2026 14:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@apollo-2006

Copy link
Copy Markdown
Author

@ArthurZucker hows your morning been? 🙂

The PR removes the shared buffer plus from_utf8_unchecked path in BpeBuilder (model.rs:265-268 on main today), so a merge of two tokens is never assembled by slicing at a byte offset that may not fall on a char boundary, and it adds three regression tests covering a multibyte pair.

It still applies: tokenizers/src/models/bpe/model.rs on main has not changed since 2026-04-23 (the per-thread BPE cache), and GitHub still reports the PR as mergeable.

What I could not work out from the outside is whether main is the right target anymore. #2178 landed on 2026-09-15 against feat/train_encode_split, and the BPE model there lives under tokenizers/tk-encode/src/models/bpe/, where I do not see the from_utf8_unchecked buffer pattern at all. I am not claiming that branch deliberately fixes this, only that the pattern is absent from it while main still carries it.

So: do you still want BPE bugfixes on main while that rewrite is in flight, or should I retarget this at feat/train_encode_split and close it against main? Either is fine by me, I just do not want it sitting in the queue against the wrong base.

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