Skip to content

Match Hugging Face whitespace splitting in the Llama 3 pre-tokenizer - #1138

Open
Yuri Khrustalev (ykhrustalev) wants to merge 2 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/llama3-whitespace-split
Open

Yuri Khrustalev (ykhrustalev) wants to merge 2 commits into
microsoft:mainfrom
ykhrustalev:ykhrustalev/llama3-whitespace-split

Conversation

@ykhrustalev

@ykhrustalev Yuri Khrustalev (ykhrustalev) commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

In \s*[\r\n]+, part of the Llama 3 split regex (also used by Qwen2.5, Qwen3, Phi-4, SmolLM3, gpt-oss, LFM2.5 and others), the regex engine backtracks \s*, so the match ends at the last \r or \n of a whitespace run:

text Hugging Face tokenizers main
"x\n \n y" x, \n \n, , y x, \n, \n, y
"y \n z" y, \n, , z y, \n, z

Match_LLAMA3_Pattern_5 only matched a run that ends in a newline, or took just the newlines at the start of a run. So the split differs only for a whitespace run that has a newline after a space or tab and more whitespace after its last newline: a whitespace-only line (a blank line that keeps its indentation, as in code), or trailing spaces before an indented line. Empty blank lines already match on main. For example, qwen2.5 on "def f():\n x = 1\n \n return x":

  • main: [..., 16, 198, 1066, 262, ...]
  • Hugging Face tokenizers and this PR: [..., 16, 7213, 262, ...]

Fix

Match the whitespace run up to its last \r or \n, as the regex does. New test Llama3RegexWhitespaceTest (expected output from Hugging Face pre_tokenize_str); it fails on main.

Second commit, for a Phi-3 string that the first commit alone moved from right to wrong: BpeModel::Load removed every \r from the string merges of tokenizer.json, as it does for the lines of a merges.txt file. A JSON string is not a text line, so the \r is part of the token. The merges with a \r (40 in the Llama 2 / Phi-3 tokenizer.json, 740 in gemma, 24 in chatglm) were lost, and 17 of them overwrote the rank of an existing merge (▁ {\r became ▁ {, which moved ▁ { from rank 224 to 6776). Now they are kept. The merges.txt path, which the merges attribute of the ONNX tokenizer ops also uses, is unchanged and still removes \r from each line. New test CarriageReturnInJsonMerges (phi-3-mini, expected ids from Hugging Face); it fails on main and on the first commit alone. For example, "a\n \r":

  • main: [263, 13, 29871, 30004]
  • Hugging Face tokenizers and this PR: [263, 13, 6756]

Testing

Built with ./build.sh -DOCOS_ENABLE_C_API=ON on Linux (Ubuntu 22.04, gcc 11.4, x86_64) and macOS (27.0.1, Apple clang 21, arm64). The numbers are the same on both machines unless a line says otherwise.

  • ctest: Linux 4/4 targets pass, also after a merge with current main (a1cf9e0). macOS 3/4: pp_api_test fails only in ImageDecoderTest.TestJpegEncoderDecoder, the same as on main.
  • C API vs HF tokenizers 0.23.3, 74,280 strings per seed (40 hand-picked, 20,000 random, and every string of 1 to 4 characters over a 15-character alphabet), seeds 0 / 7 (main = a8b95b9, the base of this branch):
tokenizer differ from HF, main this PR right -> wrong
qwen2.5, qwen3, v5/qwen2.5-synthetic 1388 / 1348 1143 / 1107 0 / 0
phi-4-mini, phi-4-mini-reasoning, models/phi-4, gpt-oss 882 / 892 615 / 635 0 / 0
phi-4-base 1589 / 1632 1355 / 1405 0 / 0
tokenizer/phi-3-small-cvt 748 / 766 500 / 525 0 / 0
v5/smollm3 752 / 771 501 / 526 0 / 0
LFM2.5-2.6B, LFM2.5-8B-A1B 708 / 725 461 / 482 0 / 0
phi-3-mini, phi-3-medium, phi-3-vision 6942 / 6951 5297 / 5337 0 / 0
gemma 4084 / 3972 444 / 429 0 / 0
chatglm 1162 / 1136 236 / 208 0 / 0
  • No changed ids: chatglm-6b, gemma-3, gemma-4, nvidia-mistral, phi-2, clip, t5-small, xlm-roberta-base, nmt, whisper, phi-3-small (tiktoken), added-tokens, unigram-no-unk, and the LFM2.5-1.2B / 350M / 230M / Audio / VL, LFM2 and d1-omni tokenizers.
  • llama2 and phi-3 (no usable HF reference for these test files): 1720 / 1687 and 1720 / 1688 strings change, all of them strings that also change for phi-3-mini (1720 / 1688).
  • On top of the first commit, the carriage-return commit changes ids only for the 7 tokenizers with a \r in a string merge (chatglm, gemma, llama2, phi-3, phi-3-mini / medium / vision), with 0 right -> wrong: 1683 / 1651 strings for phi-3-mini (differ from HF 6905 / 6915 with the first commit alone, 5297 / 5337 with both), 3678 / 3582 for gemma, 932 / 933 for chatglm. It also fixes the phi-3 string "...0\n \r\x0b..." that the first commit alone moved from right to wrong.

Not changed here: U+0085 (NEL) is \s for Hugging Face but not for these matchers. For LFM2.5-2.6B and LFM2.5-8B-A1B, all 461 / 482 remaining differences contain U+0085. Treating U+0085 as whitespace too fixed 461 to 525 more strings for each of qwen2.5, phi-4-mini, phi-4-base, phi-3-small-cvt and LFM2.5-2.6B / 8B-A1B, but moved 7 / 2 phi-3-mini strings from right to wrong, so it is left out.

In \s*[\r\n]+, the regex engine backtracks \s* to the last \r or \n of a
whitespace run, so "  \n  x" splits as "  \n", " ", " x". The matcher
only handled runs that end in a newline, or took just the leading
newlines of a run.

So the split changes only for a whitespace run that has a newline after
a space or tab and more whitespace after its last newline: a
whitespace-only line (a blank line that keeps its indentation, as in
code), or trailing spaces before an indented line. Empty blank lines
already match on main. For example, qwen2.5 on
"def f():\n    x = 1\n    \n    return x" gives [..., 16, 198, 1066,
262, ...] on main and [..., 16, 7213, 262, ...] with this change, the
same as Hugging Face tokenizers.
Copilot AI balanced review requested due to automatic review settings October 19, 2026 17:52
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The focused correction matches the intended regex behavior, and the regression test covers both reported failures.

No open issues 🎉

What changed in this PR

Corrects Llama 3 pre-tokenizer whitespace splitting to match Hugging Face behavior.

Changes:

  • Matches whitespace through the last carriage return or newline in a run.
  • Adds a regression test covering both reported splitting failures.
File Description
test/​pp_api_test/​test_tokenizer_impl.cc Adds whitespace-splitting regression coverage.
operators/​tokenizer/​bpe_utils.hpp Corrects newline matching within whitespace runs.

Review effort: Balanced

@kunal-vaishnavi

Copy link
Copy Markdown
Contributor

/azp run onnxruntime-extensions.CI

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kunal-vaishnavi kunal-vaishnavi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we also address the Phi-3 bug that you mentioned in the PR description?

BpeModel::Load removed every '\r' from the string merges of
tokenizer.json, as it does for the lines of a merges.txt file. A JSON
string is not a text line, so a '\r' there is part of a token. The
merges with a '\r' (40 in the Llama 2 / Phi-3 tokenizer.json, 740 in
Gemma, 24 in ChatGLM) were lost, and some of them overwrote the rank of
an existing merge: "▁ {\r" became "▁ {". For example, phi-3-mini gave
[263, 13, 29871, 30004] for "a\n \r" where Hugging Face gives
[263, 13, 6756].

The merges.txt path, which the merges attribute of the ONNX tokenizer
ops also uses, still removes '\r' from each line.
@ykhrustalev

Copy link
Copy Markdown
Contributor Author

Done in 2bb7f4b: string merges from tokenizer.json now keep their \r (the merges.txt path is unchanged), and a phi-3-mini test checks "a\n \r" against Hugging Face. Only the tokenizers that have \r merges change, all toward Hugging Face: out of 74,280 strings, the ones that differ from HF go from 6905 to 5297 for phi-3-mini, 4084 to 444 for gemma and 1162 to 236 for chatglm, and no string goes from right to wrong.

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.

3 participants