Skip to content

Commit d8aa32b

Browse files
committed
merge: row/TOKENIZER-TEKKEN -- recognize and split the Mistral Tekken pre-tokenizer (#283)
Closes #168. External contribution from Filip Sajdak (@filipsajdak). The PR's red agent-record and pr-size checks were a FORK ARTIFACT, not a defect: both checkers refused with "base must be an ancestor of head" because the branch had not been rebased onto current main, so neither could compute a commit range at all. Every substantive job -- build, cuda-fat- build, build-test-cpu, build-test-cpu-arm64, build-test-vulkan, device-leakage, documentation-checkpoint, commit-protocol-tag -- was green. The sanitize-cpu (address,undefined) failure is the pre-existing main baseline (test_load_direct_upload, test_llama_embedding_fold, test_laguna_nvfp4_loader, test_openai_api_server, test_capi), identical on every open PR. Merging locally supplies the ancestry those two checkers wanted. Tekken is the first pattern here whose letter rule is CASE-AWARE. It is tiktoken's o200k_base pat_str with exactly two edits -- no (?i:'s|'t|...) contraction group, and single-codepoint \p{N} instead of \p{N}{1,3} -- so an uppercase run ends a piece when a lowercase run follows ("HelloWorld" -> "Hello" + "World"). Because the two letter classes OVERLAP on {Lm, Lo, M}, this needs genuine ordered-alternation backtracking and could not fold into MatchLetterRun's single-predicate scan; MatchTekkenAlt walks the greedy give-back stops longest-first, which is exactly what the regex engine picks. Two seams widened rather than bent: - A separate narrow LetterCase table (Lu/Lt=upper, Ll=lower, Lm/Lo in BOTH), because UCat collapses all of L* into kLetter and cannot express the split. UCat and every existing consumer keep their exact bytes. - The single marks_aware flag splits into marks_in_run and marks_excluded. Tekken carries \p{M} inside its letter classes (like kQwen2) while its punct negation omits \p{M} (like kLlama3) -- a combination no other pattern has, which is why one flag sufficed until now. Both derived flags evaluate identically to the old one for kQwen2 and for every other pattern, so this is non-regressive by construction. Verified before merging rather than taken on trust: - tools/gen_unicode_data.py reproduces the checked-in include/vllm/tokenizer/unicode_data.h and src/.../unicode_data.cpp BYTE-FOR-BYTE (md5 identical after a local regen, unidata 15.0.0, 1861 letter-case ranges). The generated table is not hand-edited. - The goldens are oracle-derived, not authored: gen_pretok_goldens.py drives the real HF tokenizers Split pre-tokenizer with the regex quoted verbatim from mistralai/Mistral-Nemo-Instruct-2407 tokenizer.json, over ~90 cases including 60 seeded random strings. - DetectPattern keys on an exact regex-string match, so a checkpoint that does not match falls through to today's behavior. No existing model can change class. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
2 parents 0e7df2f + b8dc6bf commit d8aa32b

10 files changed

Lines changed: 1017 additions & 15 deletions

File tree

docs/USAGE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,31 @@ Two more example binaries ship alongside it:
129129
- `tokenize` ([`examples/tokenize/main.cpp`](../examples/tokenize/main.cpp)), a
130130
tokenizer smoke tool taking `<tokenizer.json | model.gguf> <corpus.txt>`.
131131

132+
### Which HF tokenizers load
133+
134+
A checkpoint's `tokenizer.json` is accepted when its `pre_tokenizer` is one this
135+
build recognises. Recognition is by exact regex or pipeline shape, not by model
136+
name, so a checkpoint from any vendor loads if it carries one of these:
137+
138+
| family | shape | examples |
139+
|---|---|---|
140+
| Qwen3.6 | one `Split` regex, single-codepoint `\p{N}`, `\p{M}` folded into letter runs | Qwen3.6-27B |
141+
| Qwen2/Qwen3 classic | as above without `\p{M}` awareness | Qwen3-0.6B, Qwen3-Coder |
142+
| Llama-3 | `\p{N}{1,3}` digit groups, no `\p{M}` awareness | Llama-3 family |
143+
| Tekken (Mistral) | case-aware letter runs, single-codepoint `\p{N}`, `/` in the punct tail | Mistral-Nemo-Instruct-2407 |
144+
| GPT-2 byte-level | `ByteLevel(use_regex=true)` with no explicit `Split` | OPT, GPT-2 |
145+
| DeepSeek | a seven-stage `Sequence` pipeline, not one alternation | DeepSeek-V2/V3 |
146+
| SentencePiece | `Metaspace` + byte-fallback vocab | Mistral-7B-v0.3 |
147+
148+
An unrecognised one fails loudly at load with `tokenizer: unrecognized
149+
pre-tokenizer split regex: <regex>`, rather than tokenizing incorrectly. If you
150+
hit that, the printed regex is what a new pattern would have to match.
151+
152+
Note that Mistral ships **two** unrelated tokenizer families: Mistral-7B-v0.3 is
153+
SentencePiece, while Mistral-Nemo is Tekken, a byte-level BPE whose regex is
154+
tiktoken's `o200k_base` with the contraction group removed and `\p{N}{1,3}`
155+
reduced to `\p{N}`. Support for one says nothing about the other.
156+
132157
### How much memory a Vulkan load needs
133158

134159
On a unified-memory device (a DGX Spark) the Vulkan heap and system RAM are the

include/vllm/tokenizer/pretokenizer.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ enum class SplitPattern {
2727
// case-SENSITIVE contractions, a plain ` ?` space prefix instead of
2828
// the `[^\r\n\p{L}\p{N}]?` prefix, UNBOUNDED `\p{N}+` digit runs,
2929
// no `[\r\n]*` punct tail and no `\s*[\r\n]+` rule at all.
30+
kTekken, // Mistral Tekken family (Mistral-Nemo, and the Tekken-v3/v7
31+
// checkpoints that share its tokenizer.json shape). The ONLY
32+
// pattern here whose letter rule is CASE-AWARE: two alternatives,
33+
// [\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+ then
34+
// the same pair with the quantifiers swapped, so an uppercase run
35+
// ends a piece when a lowercase run follows ("HelloWorld" ->
36+
// "Hello" + "World"). It is tiktoken's o200k_base pat_str with
37+
// exactly two edits: NO (?i:'s|'t|...) contraction group, and
38+
// single-codepoint \p{N} instead of \p{N}{1,3}. Marks sit INSIDE
39+
// both letter classes (like kQwen2) while the punct negation is
40+
// [^\s\p{L}\p{N}] with no \p{M} (like kLlama3) -- a combination no
41+
// other pattern has. Its punct run also ends [\r\n/]*, absorbing a
42+
// '/' that follows a newline.
3043
kDeepSeek, // DeepSeek family (DeepSeek-V2/V2-Lite/V3). STRUCTURALLY UNLIKE
3144
// every pattern above: not ONE alternation regex but a HF
3245
// `Sequence` PIPELINE of seven pre-tokenizers, each further

include/vllm/tokenizer/unicode_data.h

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Generator: tools/gen_unicode_data.py
33
// Regenerate: python3 tools/gen_unicode_data.py
44
// Unicode data version (Python unicodedata.unidata_version): 15.0.0
5-
// Category ranges: 1563; whitespace ranges: 10.
5+
// Category ranges: 1563; whitespace ranges: 10; letter-case ranges: 1861.
66
// Semantics mirror HF tokenizers byte-level BPE: categories are the major
77
// Unicode general-category classes (unassigned -> kOther); IsWhitespace is
88
// python str.isspace().
@@ -29,6 +29,20 @@ enum class UCat : uint8_t {
2929

3030
UCat Category(uint32_t cp);
3131

32+
// CASE class of a letter. Needed only by SplitPattern::kTekken, whose letter
33+
// alternatives are [\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}] and
34+
// [\p{Ll}\p{Lm}\p{Lo}\p{M}]; UCat collapses all of L* into kLetter and
35+
// cannot express that. Deliberately a separate narrow table, so UCat and every
36+
// existing consumer of it are unchanged. kNotLetter for anything outside L*.
37+
enum class LetterCase : uint8_t {
38+
kNotLetter = 0,
39+
kUpper = 1, // Lu, Lt -- the UPPER class only
40+
kLower = 2, // Ll -- the LOWER class only
41+
kCaseless = 3, // Lm, Lo -- present in BOTH classes
42+
};
43+
44+
LetterCase LetterCaseOf(uint32_t cp);
45+
3246
// python str.isspace() semantics (per HF byte-level pretokenization):
3347
// includes 0x1C-0x1F, NEL (0x85) and NBSP (0xA0); excludes ZWSP (0x200B),
3448
// Mongolian vowel separator (0x180E) and BOM (0xFEFF).

src/vllm/tokenizer/pretokenizer.cpp

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141

4242
#include "vllm/tokenizer/pretokenizer.h"
4343

44+
#include <cstddef>
4445
#include <cstdint>
46+
#include <string_view>
47+
#include <vector>
4548

4649
#include "vllm/tokenizer/unicode_data.h"
4750

@@ -125,6 +128,87 @@ size_t MatchLetterRun(std::string_view t, size_t pos, bool marks_in_run) {
125128
return p;
126129
}
127130

131+
// Tekken rules 1-2, the CASE-AWARE letter alternatives:
132+
// [^\r\n\p{L}\p{N}]? [\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]* [\p{Ll}\p{Lm}\p{Lo}\p{M}]+
133+
// [^\r\n\p{L}\p{N}]? [\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+ [\p{Ll}\p{Lm}\p{Lo}\p{M}]*
134+
// The two classes OVERLAP on {Lm, Lo, M}, so this needs real backtracking and
135+
// cannot be folded into MatchLetterRun's single-predicate scan.
136+
bool InTekkenUpperClass(uint32_t cp) {
137+
if (Category(cp) == UCat::kMark) return true; // \p{M} is in BOTH classes
138+
const LetterCase lc = LetterCaseOf(cp);
139+
return lc == LetterCase::kUpper || lc == LetterCase::kCaseless;
140+
}
141+
142+
bool InTekkenLowerClass(uint32_t cp) {
143+
if (Category(cp) == UCat::kMark) return true; // \p{M} is in BOTH classes
144+
const LetterCase lc = LetterCaseOf(cp);
145+
return lc == LetterCase::kLower || lc == LetterCase::kCaseless;
146+
}
147+
148+
// Scans a maximal run of `pred` from `from`, recording every codepoint
149+
// boundary so the caller can give characters back (regex backtracking).
150+
void ScanRun(std::string_view t, size_t from, bool (*pred)(uint32_t),
151+
std::vector<size_t>& stops) {
152+
stops.clear();
153+
stops.push_back(from);
154+
size_t p = from;
155+
while (p < t.size()) {
156+
const Cp c = DecodeAt(t, p);
157+
if (!pred(c.cp)) break;
158+
p = c.end;
159+
stops.push_back(p);
160+
}
161+
}
162+
163+
// `upper_first_required`: alternative 2 needs [U]+ then [L]*; alternative 1
164+
// needs [U]* then [L]+. Returns the match end, or 0 for no match.
165+
size_t MatchTekkenAlt(std::string_view t, size_t from, bool upper_first_required,
166+
std::vector<size_t>& scratch) {
167+
ScanRun(t, from, InTekkenUpperClass, scratch);
168+
// [U]* / [U]+ are greedy, so walk the give-back positions longest-first;
169+
// the FIRST that lets the second half match is what the regex engine picks.
170+
for (size_t i = scratch.size(); i-- > 0;) {
171+
const size_t split = scratch[i];
172+
const size_t upper_len = i; // codepoints consumed by the upper run
173+
if (upper_first_required && upper_len == 0) break; // [U]+ needs >= 1
174+
size_t p = split;
175+
size_t lower_len = 0;
176+
while (p < t.size()) {
177+
const Cp c = DecodeAt(t, p);
178+
if (!InTekkenLowerClass(c.cp)) break;
179+
p = c.end;
180+
++lower_len;
181+
}
182+
if (!upper_first_required && lower_len == 0) continue; // [L]+ needs >= 1
183+
if (p > from) return p;
184+
}
185+
return 0;
186+
}
187+
188+
size_t MatchTekkenLetterRun(std::string_view t, size_t pos,
189+
std::vector<size_t>& scratch) {
190+
// Alternation is ORDERED and `X?` is greedy, so the engine tries, in order:
191+
// alt1-with-prefix, alt1-without, alt2-with-prefix, alt2-without.
192+
for (int alt = 0; alt < 2; ++alt) {
193+
const bool upper_first_required = alt == 1;
194+
for (int use_prefix = 1; use_prefix >= 0; --use_prefix) {
195+
size_t start = pos;
196+
if (use_prefix != 0) {
197+
const Cp c0 = DecodeAt(t, pos);
198+
const UCat cat = Category(c0.cp);
199+
if (c0.cp == U'\r' || c0.cp == U'\n' || cat == UCat::kLetter ||
200+
cat == UCat::kNumber || c0.end >= t.size()) {
201+
continue; // this codepoint cannot serve as the optional prefix
202+
}
203+
start = c0.end;
204+
}
205+
const size_t end = MatchTekkenAlt(t, start, upper_first_required, scratch);
206+
if (end != 0) return end;
207+
}
208+
}
209+
return 0;
210+
}
211+
128212
// Rule 3: \p{N} (Qwen, max_digits=1) | \p{N}{1,3} (Llama-3, max_digits=3;
129213
// greedy, so long digit runs split into groups of three from the left).
130214
size_t MatchNumbers(std::string_view t, size_t pos, int max_digits) {
@@ -141,10 +225,13 @@ size_t MatchNumbers(std::string_view t, size_t pos, int max_digits) {
141225

142226
// Rule 4: ` ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*` (Qwen3.6; marks_excluded=true)
143227
// ` ?[^\s\p{L}\p{N}]+[\r\n]*` (Llama-3; marks_excluded=false)
228+
// ` ?[^\s\p{L}\p{N}]+[\r\n/]*` (Tekken; slash_in_tail=true)
144229
// Optional single literal ASCII space, then >=1 codepoints that are not
145230
// regex-space/letters/numbers (nor marks, for Qwen), then any trailing \r/\n
146-
// bytes. If only the space matched, the rule fails as a whole.
147-
size_t MatchPunctRun(std::string_view t, size_t pos, bool marks_excluded) {
231+
// bytes -- and '/' too for Tekken. If only the space matched, the rule fails
232+
// as a whole.
233+
size_t MatchPunctRun(std::string_view t, size_t pos, bool marks_excluded,
234+
bool slash_in_tail) {
148235
size_t p = pos;
149236
if (t[p] == ' ') ++p;
150237
const size_t run_begin = p;
@@ -159,7 +246,10 @@ size_t MatchPunctRun(std::string_view t, size_t pos, bool marks_excluded) {
159246
p = c.end;
160247
}
161248
if (p == run_begin) return 0;
162-
while (p < t.size() && IsNewlineByte(t[p])) ++p;
249+
while (p < t.size() &&
250+
(IsNewlineByte(t[p]) || (slash_in_tail && t[p] == '/'))) {
251+
++p;
252+
}
163253
return p;
164254
}
165255

@@ -516,15 +606,28 @@ std::vector<std::pair<size_t, size_t>> Pretokenize(std::string_view text,
516606
// UNIQUE to the Qwen3.6 regex. Classic Qwen2/Qwen3 and Llama-3 both treat
517607
// marks like ordinary punct-run codepoints. Number grouping is single-digit
518608
// for BOTH Qwen variants; only Llama-3 groups \p{N}{1,3}.
519-
const bool marks_aware = pattern == SplitPattern::kQwen2;
609+
// kTekken needs these two APART: marks live inside its letter classes (like
610+
// kQwen2) while its punct negation is [^\s\p{L}\p{N}] with no \p{M} (like
611+
// kLlama3). No other pattern mixes them, which is why one flag sufficed
612+
// until now.
613+
const bool tekken = pattern == SplitPattern::kTekken;
614+
const bool marks_in_run = pattern == SplitPattern::kQwen2 || tekken;
615+
const bool marks_excluded = pattern == SplitPattern::kQwen2;
520616
const int max_digits = pattern == SplitPattern::kLlama3 ? 3 : 1;
521617
std::vector<std::pair<size_t, size_t>> spans;
618+
std::vector<size_t> scratch; // reused give-back stops for the Tekken scan
522619
size_t pos = 0;
523620
while (pos < text.size()) {
524-
size_t end = MatchContraction(text, pos);
525-
if (end == 0) end = MatchLetterRun(text, pos, /*marks_in_run=*/marks_aware);
621+
// Tekken has NO (?i:'s|'t|...) alternative at all.
622+
size_t end = tekken ? 0 : MatchContraction(text, pos);
623+
if (end == 0) {
624+
end = tekken ? MatchTekkenLetterRun(text, pos, scratch)
625+
: MatchLetterRun(text, pos, marks_in_run);
626+
}
526627
if (end == 0) end = MatchNumbers(text, pos, max_digits);
527-
if (end == 0) end = MatchPunctRun(text, pos, /*marks_excluded=*/marks_aware);
628+
if (end == 0) {
629+
end = MatchPunctRun(text, pos, marks_excluded, /*slash_in_tail=*/tekken);
630+
}
528631
if (end == 0) end = MatchWsNewlines(text, pos);
529632
if (end == 0) end = MatchWsNotBeforeNonSpace(text, pos);
530633
if (end == 0) end = MatchWs(text, pos);

src/vllm/tokenizer/tokenizer.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ constexpr const char* kQwen36Regex =
2626
R"((?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+)";
2727
constexpr const char* kClassicQwen2Regex =
2828
R"((?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+)";
29+
// Mistral Tekken (mistralai/Mistral-Nemo-Instruct-2407 and the other Tekken
30+
// checkpoints). Byte-equal to tiktoken's o200k_base pat_str except that the
31+
// optional (?i:'s|'t|...) group is absent from both letter alternatives and
32+
// numbers are \p{N} rather than \p{N}{1,3}.
33+
constexpr const char* kTekkenRegex =
34+
R"([^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n/]*|\s*[\r\n]+|\s+(?!\S)|\s+)";
2935

3036
[[noreturn]] void Fail(const std::string& msg) {
3137
throw std::runtime_error("tokenizer: " + msg);
@@ -350,6 +356,7 @@ SplitPattern DetectPattern(const json& doc) {
350356
}
351357
if (re == kQwen36Regex) return SplitPattern::kQwen2;
352358
if (re == kClassicQwen2Regex) return SplitPattern::kQwen2Classic;
359+
if (re == kTekkenRegex) return SplitPattern::kTekken;
353360
if (re.find(R"(\p{N}{1,3})") != std::string::npos) {
354361
return SplitPattern::kLlama3;
355362
}

0 commit comments

Comments
 (0)