Skip to content

Commit 8331046

Browse files
Tokenizer: byte-level BPE from GGUF, verified exact against HF tokenizers (#2)
- engine/core/tokenizer: added-token split, NFC, hand-ported Qwen2 split regex, GPT-2 byte mapping, BPE merges, decode. Unverified tokenizer.ggml.pre values are refused. - engine/core/unicode: UTF-8 codec, letter/number/whitespace classes, NFC. Tables generated from Python unicodedata 16.0.0 by tools/gen_unicode_tables.py (no ICU). - tests: test_unicode (CI), golden_tokenizer checking pre-token pieces, ids and decoded bytes. The Qwen3 vocab-only GGUF (5.9 MB) and 481 cases are committed so it runs in CI. 481 committed cases and 20,081 random cases: 0 mismatches on split, encode and decode. Nine of ten single-rule mutations fail the test; the tenth is equivalent. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c14a5ea commit 8331046

17 files changed

Lines changed: 6013 additions & 4 deletions

‎.gitattributes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.gguf binary
2+
engine/core/unicode_tables.inc linguist-generated=true
3+
tests/golden/**/cases.tsv linguist-generated=true

‎CMakeLists.txt‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ add_library(onebit_core STATIC
2121
engine/core/gguf.cpp
2222
engine/core/dequant.cpp
2323
engine/core/model_config.cpp
24-
engine/core/parallel.cpp)
24+
engine/core/parallel.cpp
25+
engine/core/unicode.cpp
26+
engine/core/tokenizer.cpp)
2527
target_include_directories(onebit_core PUBLIC engine/core)
2628
find_package(Threads REQUIRED)
2729
target_link_libraries(onebit_core PUBLIC Threads::Threads)
@@ -31,7 +33,7 @@ target_include_directories(onebit_cpu PUBLIC engine/backends/cpu)
3133
target_link_libraries(onebit_cpu PUBLIC onebit_core)
3234

3335
# Unit tests: synthetic data only, so they run anywhere (including CI).
34-
foreach(t test_gguf test_dequant)
36+
foreach(t test_gguf test_dequant test_unicode)
3537
add_executable(${t} tests/${t}.cpp)
3638
target_include_directories(${t} PRIVATE tests)
3739
target_link_libraries(${t} PRIVATE onebit_core)
@@ -49,3 +51,12 @@ set(ENGINE_GOLDEN_DIR "" CACHE PATH "Golden logits directory for the golden test
4951
if(ENGINE_GOLDEN_MODEL AND ENGINE_GOLDEN_DIR)
5052
add_test(NAME golden_cpu COMMAND golden_cpu ${ENGINE_GOLDEN_MODEL} ${ENGINE_GOLDEN_DIR})
5153
endif()
54+
55+
# Tokenizer golden test: encode/decode/pre-token split against HF tokenizers.
56+
# Runs everywhere: the vocab-only GGUF and the cases are committed
57+
# (tools/golden/make_tokenizer_golden.py, docs/tokenizer.md).
58+
add_executable(golden_tokenizer tests/golden_tokenizer.cpp)
59+
target_link_libraries(golden_tokenizer PRIVATE onebit_core)
60+
add_test(NAME golden_tokenizer_qwen3
61+
COMMAND golden_tokenizer ${CMAKE_SOURCE_DIR}/tests/golden/qwen3-tokenizer/vocab.gguf
62+
${CMAKE_SOURCE_DIR}/tests/golden/qwen3-tokenizer/cases.tsv)

‎docs/PORTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ and what has to be true before it lands here. Refs are branches or commits in th
55

66
| Component | Source | State at source | Gate to land here |
77
|---|---|---|---|
8-
| CPU reference | `src/gguf_reader.cpp`, `src/tokenizer.cpp`, `tools/qwen36_full_ref.py` | **landed** (qwen3; see docs/cpu-reference.md). Tokenizer not yet | matches HF transformers fp32 logits on Qwen3-0.6B |
8+
| CPU reference | `src/gguf_reader.cpp`, `src/tokenizer.cpp`, `tools/qwen36_full_ref.py` | **landed** (qwen3; docs/cpu-reference.md). Tokenizer **landed** (docs/tokenizer.md) | matches HF transformers fp32 logits on Qwen3-0.6B |
99
| Arch registry | `src/model_registry.cpp` + `Testing/census_*.json` | 569 tokens map 2,030 HF arch strings (mapping only) | data file + separate verified list |
1010
| NPU backend | `engine/npu/src/npu_engine_universal.cpp` (`I8Ctx::init_elf`) | ELF-native, matches its own baseline | golden test vs CPU reference |
1111
| NPU ELF dispatch table | branch `backup/iso-build-elf-native-2026-09-22` (`kElfDesigns`) | ELF and xclbin modes match token for token; 0 xclbin opens | same, in this engine |

‎docs/cpu-reference.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,6 @@ cmake --build build && ctest --test-dir build --output-on-failure
5757

5858
## Not done yet
5959

60-
- **Tokenizer.** The test uses token ids from the HF tokenizer.
60+
- **Tokenizer wiring.** The tokenizer has landed (docs/tokenizer.md), but this test still feeds ids.
6161
- **Quantized types beyond Q8_0** (Q4_K, Q6_K and others). Each needs its own dequant test first.
6262
- **A longer-context golden** (hundreds of positions) to cover RoPE at larger angles.

‎docs/tokenizer.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Tokenizer
2+
3+
`engine/core/tokenizer.*` reads a byte-level BPE vocabulary from GGUF metadata
4+
(`tokenizer.ggml.*`) and follows the same steps as HF `tokenizers`:
5+
6+
1. **Added-token split.** CONTROL and USER_DEFINED tokens (`<|im_start|>`,
7+
`<think>` and others) are matched leftmost-longest in the raw text. The
8+
`parse_special=false` flag turns this off.
9+
2. **NFC normalization.** The GGUF does not record it, but the HF tokenizer
10+
applies it. Without it, accented or decomposed text encodes differently:
11+
187 of the 481 test cases change.
12+
3. **Pre-token split** with the Qwen2 regex, ported by hand one branch at a time:
13+
`(?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+`
14+
4. **Byte-level mapping** (GPT-2 `bytes_to_unicode`), then **BPE merges**:
15+
the lowest-rank pair merges first, and on a tie the leftmost pair merges first.
16+
17+
`tokenizer.ggml.pre` values other than `qwen2` are refused rather than
18+
approximated. More pre-tokenizers can be added as each gets its own golden.
19+
20+
The Unicode data (letter and number classes, combining classes, decomposition
21+
and composition) is generated by `tools/gen_unicode_tables.py` from Python
22+
`unicodedata` 16.0.0 into `engine/core/unicode_tables.inc`. It has no ICU dependency.
23+
24+
## Verification
25+
26+
`golden_tokenizer` checks three things for each case, all exact against HF
27+
`tokenizers` 0.22.2:
28+
29+
- the pre-token pieces
30+
- the token ids
31+
- the decoded bytes
32+
33+
The pieces are checked separately because BPE can hide a wrong split by
34+
producing the same ids.
35+
36+
| Case set | Cases | Result |
37+
|---|---|---|
38+
| Committed (`tests/golden/qwen3-tokenizer`, runs in CI) | 481 | all match |
39+
| Random, seed 7 (not committed) | 20,081 | all match, 0.9 s |
40+
41+
The fixed cases cover:
42+
43+
- contractions in every position and case
44+
- code, and whitespace runs with `\r\n`
45+
- CJK, Hangul, Arabic, Hebrew, Devanagari and Thai
46+
- decomposed accents and Hangul jamo (NFC)
47+
- emoji ZWJ sequences, flags, and non-breaking and ideographic spaces
48+
- special tokens, including partial ones such as `<|im_start`
49+
- long runs
50+
51+
The random cases mix 18 Unicode blocks with whitespace, contractions and
52+
special tokens.
53+
54+
Mutation check: ten single-rule deletions were tried in the split and folding
55+
code. Nine fail the test. The tenth (the `\p{N}` branch) is an equivalent
56+
mutant: a lone number character falls through to a 1-character match either way.
57+
Examples: the long-s (`ſ`) case fold gives 2 split mismatches, and the ` ?`
58+
before punctuation gives 67.
59+
60+
## Known limits
61+
62+
- BPE is quadratic in the piece length. That is fine for text, but slow on a
63+
single very long run with no split points.
64+
- Rust `regex` (used by `tokenizers`) may carry a different Unicode version
65+
than the generated tables. That only matters for characters assigned in the
66+
newest Unicode versions.
67+
- `decode(skip_special=true)` drops CONTROL tokens. HF's `skip_special_tokens`
68+
uses its own `special` flag, which differs for a few tokens (for example the
69+
`<|fim_*|>` tokens are CONTROL in the GGUF but not special in HF).

‎engine/core/tokenizer.cpp‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#include "tokenizer.h"
2+
3+
#include <algorithm>
4+
#include <climits>
5+
#include <format>
6+
#include <variant>
7+
8+
#include "unicode.h"
9+
10+
namespace onebit {
11+
12+
namespace {
13+
14+
// llama.cpp token types stored in tokenizer.ggml.token_type.
15+
constexpr int32_t kTypeControl = 3;
16+
constexpr int32_t kTypeUserDefined = 4;
17+
18+
using unicode::is_letter;
19+
using unicode::is_number;
20+
using unicode::is_whitespace;
21+
22+
bool is_newline(char32_t c) { return c == U'\r' || c == U'\n'; }
23+
24+
// Simple case folding for the letters the contraction branch can meet.
25+
// U+017F LATIN SMALL LETTER LONG S folds to 's' under (?i).
26+
char32_t fold(char32_t c) {
27+
if (c >= U'A' && c <= U'Z') return c + 32;
28+
if (c == 0x017F) return U's';
29+
return c;
30+
}
31+
32+
// Length of the pre-token starting at s[i] for the Qwen2 split pattern
33+
// (?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}
34+
// | ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+
35+
// Each branch is tried in order, as the regex engine does, and each returns
36+
// the length its greedy quantifiers settle on after backtracking.
37+
size_t qwen2_match(std::u32string_view s, size_t i) {
38+
const size_t n = s.size();
39+
auto at = [&](size_t k) -> char32_t { return k < n ? s[k] : 0; };
40+
auto is_other = [](char32_t c) { return !is_whitespace(c) && !is_letter(c) && !is_number(c); };
41+
42+
// (?i:'s|'t|'re|'ve|'m|'ll|'d)
43+
if (s[i] == U'\'' && i + 1 < n) {
44+
const char32_t a = fold(at(i + 1)), b = fold(at(i + 2));
45+
if (a == U's' || a == U't') return 2;
46+
if ((a == U'r' && b == U'e') || (a == U'v' && b == U'e')) return 3;
47+
if (a == U'm') return 2;
48+
if (a == U'l' && b == U'l') return 3;
49+
if (a == U'd') return 2;
50+
}
51+
// [^\r\n\p{L}\p{N}]?\p{L}+
52+
{
53+
size_t j = i;
54+
const char32_t c = s[i];
55+
if (!is_newline(c) && !is_letter(c) && !is_number(c) && is_letter(at(i + 1))) j = i + 1;
56+
if (j < n && is_letter(s[j])) {
57+
while (j < n && is_letter(s[j])) ++j;
58+
return j - i;
59+
}
60+
}
61+
// \p{N}
62+
if (is_number(s[i])) return 1;
63+
// ' ?[^\s\p{L}\p{N}]+[\r\n]*'
64+
{
65+
size_t j = i;
66+
if (s[i] == U' ' && i + 1 < n && is_other(s[i + 1])) j = i + 1;
67+
if (j < n && is_other(s[j])) {
68+
while (j < n && is_other(s[j])) ++j;
69+
while (j < n && is_newline(s[j])) ++j;
70+
return j - i;
71+
}
72+
}
73+
// The remaining branches all start with whitespace.
74+
size_t ws_end = i;
75+
while (ws_end < n && is_whitespace(s[ws_end])) ++ws_end;
76+
if (ws_end == i) return 1; // unreachable: every code point matches a branch above
77+
// \s*[\r\n]+ : ends just after the last newline in the whitespace run.
78+
for (size_t k = ws_end; k > i; --k)
79+
if (is_newline(s[k - 1])) return k - i;
80+
// \s+(?!\S)
81+
if (ws_end == n) return ws_end - i;
82+
if (ws_end - i >= 2) return ws_end - i - 1;
83+
// \s+
84+
return ws_end - i;
85+
}
86+
87+
// GPT-2 bytes_to_unicode: printable bytes map to themselves, the rest to 256+.
88+
std::vector<char32_t> byte_to_codepoint() {
89+
std::vector<char32_t> map(256, 0);
90+
std::vector<bool> direct(256, false);
91+
for (int b = '!'; b <= '~'; ++b) direct[b] = true;
92+
for (int b = 0xA1; b <= 0xAC; ++b) direct[b] = true;
93+
for (int b = 0xAE; b <= 0xFF; ++b) direct[b] = true;
94+
char32_t next = 256;
95+
for (int b = 0; b < 256; ++b) map[b] = direct[b] ? char32_t(b) : next++;
96+
return map;
97+
}
98+
99+
} // namespace
100+
101+
std::expected<Tokenizer, std::string> Tokenizer::from_gguf(const GgufFile& f) {
102+
auto model = f.get_string("tokenizer.ggml.model");
103+
if (model != "gpt2") return std::unexpected(std::format("tokenizer model '{}' is not supported", model.value_or("")));
104+
auto pre = f.get_string("tokenizer.ggml.pre");
105+
if (pre != "qwen2") return std::unexpected(std::format("pre-tokenizer '{}' is not supported", pre.value_or("")));
106+
107+
const GgufArray* tokens = f.get_array("tokenizer.ggml.tokens");
108+
const GgufArray* types = f.get_array("tokenizer.ggml.token_type");
109+
const GgufArray* merges = f.get_array("tokenizer.ggml.merges");
110+
if (!tokens || !types || !merges || types->size() != tokens->size())
111+
return std::unexpected(std::string("GGUF tokenizer arrays missing or inconsistent"));
112+
113+
Tokenizer t;
114+
t.tokens_.reserve(tokens->size());
115+
t.token_type_.reserve(tokens->size());
116+
for (size_t i = 0; i < tokens->size(); ++i) {
117+
const auto* s = std::get_if<std::string>(&(*tokens)[i].v);
118+
const auto* ty = std::get_if<int64_t>(&(*types)[i].v);
119+
if (!s || !ty) return std::unexpected(std::format("bad tokenizer entry {}", i));
120+
t.tokens_.push_back(*s);
121+
t.token_type_.push_back(int32_t(*ty));
122+
t.token_id_.emplace(*s, int32_t(i));
123+
if (*ty == kTypeControl || *ty == kTypeUserDefined) {
124+
auto cps = unicode::decode_utf8(*s);
125+
if (!cps || cps->empty()) return std::unexpected(std::format("added token {} is not valid UTF-8", i));
126+
t.added_.emplace_back(std::u32string(cps->begin(), cps->end()), int32_t(i));
127+
}
128+
}
129+
std::stable_sort(t.added_.begin(), t.added_.end(),
130+
[](const auto& a, const auto& b) { return a.first.size() > b.first.size(); });
131+
132+
t.merge_rank_.reserve(merges->size());
133+
for (size_t i = 0; i < merges->size(); ++i) {
134+
const auto* m = std::get_if<std::string>(&(*merges)[i].v);
135+
if (!m || std::count(m->begin(), m->end(), ' ') != 1) return std::unexpected(std::format("bad merge {}", i));
136+
t.merge_rank_.emplace(*m, int32_t(i));
137+
}
138+
139+
const auto map = byte_to_codepoint();
140+
for (int b = 0; b < 256; ++b) {
141+
unicode::append_utf8(t.byte_to_unicode_[b], map[b]);
142+
t.unicode_to_byte_[map[b]] = uint8_t(b);
143+
}
144+
for (int b = 0; b < 256; ++b)
145+
if (!t.token_id_.contains(t.byte_to_unicode_[b]))
146+
return std::unexpected(std::format("vocab has no token for byte {}", b));
147+
148+
auto id_or = [&](const char* key) {
149+
auto v = f.get_uint(key);
150+
return (v && *v < t.tokens_.size()) ? int32_t(*v) : -1;
151+
};
152+
t.bos_ = id_or("tokenizer.ggml.bos_token_id");
153+
t.eos_ = id_or("tokenizer.ggml.eos_token_id");
154+
return t;
155+
}
156+
157+
void Tokenizer::bpe(const std::string& piece, std::vector<int32_t>& out) const {
158+
// Split into the mapped characters (each 1-2 UTF-8 bytes long).
159+
std::vector<std::string> sym;
160+
for (size_t i = 0; i < piece.size();) {
161+
const size_t len = (static_cast<unsigned char>(piece[i]) < 0x80) ? 1 : 2;
162+
sym.push_back(piece.substr(i, len));
163+
i += len;
164+
}
165+
// Repeatedly merge the adjacent pair with the lowest rank (leftmost on ties).
166+
std::string key;
167+
while (sym.size() > 1) {
168+
int32_t best = INT32_MAX;
169+
size_t at = 0;
170+
for (size_t i = 0; i + 1 < sym.size(); ++i) {
171+
key.assign(sym[i]).append(" ").append(sym[i + 1]);
172+
auto it = merge_rank_.find(key);
173+
if (it != merge_rank_.end() && it->second < best) {
174+
best = it->second;
175+
at = i;
176+
}
177+
}
178+
if (best == INT32_MAX) break;
179+
sym[at] += sym[at + 1];
180+
sym.erase(sym.begin() + std::ptrdiff_t(at) + 1);
181+
}
182+
for (const auto& s : sym) out.push_back(token_id_.at(s)); // single bytes are checked at load
183+
}
184+
185+
void Tokenizer::split(std::u32string_view text, std::vector<std::string>& pieces) const {
186+
const std::vector<char32_t> norm = unicode::nfc(std::vector<char32_t>(text.begin(), text.end()));
187+
const std::u32string_view s(norm.data(), norm.size());
188+
std::string utf8;
189+
for (size_t i = 0; i < s.size();) {
190+
const size_t len = qwen2_match(s, i);
191+
utf8.clear();
192+
for (size_t k = i; k < i + len; ++k) unicode::append_utf8(utf8, s[k]);
193+
std::string& piece = pieces.emplace_back();
194+
for (unsigned char b : utf8) piece += byte_to_unicode_[b];
195+
i += len;
196+
}
197+
}
198+
199+
void Tokenizer::encode_plain(std::u32string_view text, std::vector<int32_t>& out) const {
200+
std::vector<std::string> pieces;
201+
split(text, pieces);
202+
for (const auto& piece : pieces) bpe(piece, out);
203+
}
204+
205+
std::expected<std::vector<std::string>, std::string> Tokenizer::pre_tokenize(std::string_view text) const {
206+
auto cps = unicode::decode_utf8(text);
207+
if (!cps) return std::unexpected(std::string("input is not valid UTF-8"));
208+
std::vector<std::string> pieces;
209+
split(std::u32string_view(cps->data(), cps->size()), pieces);
210+
return pieces;
211+
}
212+
213+
std::expected<std::vector<int32_t>, std::string> Tokenizer::encode(std::string_view text, bool parse_special) const {
214+
auto cps = unicode::decode_utf8(text);
215+
if (!cps) return std::unexpected(std::string("input is not valid UTF-8"));
216+
const std::u32string_view s(cps->data(), cps->size());
217+
218+
std::vector<int32_t> out;
219+
size_t start = 0;
220+
if (parse_special) {
221+
// Leftmost-longest match of added tokens, as HF's AddedVocabulary does.
222+
for (size_t i = 0; i < s.size();) {
223+
const std::pair<std::u32string, int32_t>* hit = nullptr;
224+
for (const auto& a : added_)
225+
if (s.substr(i, a.first.size()) == a.first) {
226+
hit = &a;
227+
break;
228+
}
229+
if (!hit) {
230+
++i;
231+
continue;
232+
}
233+
encode_plain(s.substr(start, i - start), out);
234+
out.push_back(hit->second);
235+
i += hit->first.size();
236+
start = i;
237+
}
238+
}
239+
encode_plain(s.substr(start), out);
240+
return out;
241+
}
242+
243+
std::expected<std::string, std::string> Tokenizer::decode(std::span<const int32_t> ids, bool skip_special) const {
244+
std::string out;
245+
for (int32_t id : ids) {
246+
if (id < 0 || size_t(id) >= tokens_.size()) return std::unexpected(std::format("token id {} out of range", id));
247+
const int32_t type = token_type_[size_t(id)];
248+
if (type == kTypeControl || type == kTypeUserDefined) {
249+
if (!(skip_special && type == kTypeControl)) out += tokens_[size_t(id)];
250+
continue;
251+
}
252+
auto cps = unicode::decode_utf8(tokens_[size_t(id)]);
253+
if (!cps) return std::unexpected(std::format("token {} is not valid UTF-8", id));
254+
for (char32_t cp : *cps) {
255+
auto it = unicode_to_byte_.find(cp);
256+
if (it == unicode_to_byte_.end()) return std::unexpected(std::format("token {} is not byte-level", id));
257+
out += char(it->second);
258+
}
259+
}
260+
return out;
261+
}
262+
263+
} // namespace onebit

0 commit comments

Comments
 (0)