fix(tokenizers): pre-tokenize raw text before byte-encoding; add StarCoder/SmolLM pre-type family - #417
Conversation
…NCOMPLETE (#237) Partial work. Do NOT merge as-is: enabling pre-tokenization currently makes SmolLM tokenization WORSE (4400 tokens vs llama.cpp's 3904), because of a deeper defect described below. What is correct here: - GetRegexes returns an ordered PIPELINE, not one expression. llama.cpp's regex_exprs is a list applied in sequence; the StarCoder/SmolLM family needs two stages (isolate every digit with \p{N}, then its main pattern), so collapsing to a single expression cannot be right. - Adds the StarCoder/SmolLM block covering the eight pre-types llama.cpp falls through one case: starcoder, refact, command-r, smollm, codeshell, exaone, minerva, mellum2. Note command-r was previously mapped to the Llama-3 regex, which is wrong. - PreTokenize preserves UNMATCHED spans. The old code encoded only regex matches, silently dropping anything unmatched. Safe for the GPT-2 expression (ends in |\s+, matches everything) but not for this family's pattern, which deliberately omits that alternative. The blocking defect (why this is WIP): Encode converts text to GPT-2 byte-level Unicode BEFORE applying the regex. In that encoding a space becomes U+0120, so \s, ' ?' and \s+(?!\S) can never match — every whitespace-dependent alternative in every one of these patterns is dead. llama.cpp applies its regexes to the RAW text and byte-encodes each segment afterwards. Fixing that is a restructure of Encode, not a table change, and it affects every model that currently uses pre-tokenization — not only the unmapped ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…237) Three defects, found while validating the perplexity harness (#231) against llama-perplexity on wikitext-2. 1. Pre-tokenization ran AFTER the GPT-2 byte-level mapping. That mapping sends byte 0x20 to U+0120, not to a literal space, so `\s`, a leading ` ?` and `\s+(?!\S)` could never match -- every whitespace-dependent alternative in every pattern was silently dead. llama.cpp splits raw text and byte-encodes each segment afterwards; Encode now does the same. 2. `tokenizer.ggml.pre = smollm` was unmapped, and an unmapped type returns null, which Encode treated as "no pre-tokenization" -- BPE merges ran across the whole input. Adds the StarCoder/SmolLM block covering the eight pre-types llama.cpp falls through one case: starcoder, refact, command-r, smollm, codeshell, exaone, minerva, mellum2. command-r was previously mapped to the Llama-3 regex, which is wrong. 3. GetRegexes returns an ordered PIPELINE, not one expression. llama.cpp's regex_exprs is a list applied in sequence and this family needs two stages (isolate every digit with \p{N}, then the main pattern). PreTokenize also preserves UNMATCHED spans -- the old code encoded only regex matches, which silently dropped anything unmatched. Safe for the GPT-2 expression (ends in |\s+) but not for this family's, which omits that alternative by design. Verification is full token-stream equality against llama.cpp build 8683, not equal counts -- counts matched exactly (3904 both) while 30 ids differed, so a count assertion would have passed throughout: SmolLM-135M Q8_0 (smollm) 3904 tokens, 0 mismatches over 16.4 KB wikitext-2 Llama-3.2-3B Q8_0 (llama3) 945 tokens, 0 mismatches (llama.cpp's leading BOS is a caller concern, not the pre-tokenizer) Unit suite: 1167 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes tiktoken-style BPE pre-tokenization to mirror llama.cpp semantics by running regex pre-tokenization on raw text (before GPT-2 byte-level Unicode mapping), and by modeling tokenizer.ggml.pre as an ordered regex pipeline (including the StarCoder/SmolLM family).
Changes:
- Apply pre-tokenization on raw text, then byte-encode + BPE each segment to prevent whitespace-dependent patterns from being silently disabled.
- Extend
tokenizer.ggml.premapping to include StarCoder/SmolLM-family pre-types and represent them as a two-stage pipeline. - Update unit tests to validate pipeline mapping and to guard the raw-text whitespace matching invariant.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Tokenizers/BpeTokenizerTests.cs | Updates tests for pipeline-based pre-tokenization and adds a regression test for the raw-text ordering bug. |
| src/DotLLM.Tokenizers/Bpe/TiktokenPreTokenizer.cs | Changes pre-type mapping from a single regex to a regex pipeline; adds StarCoder/SmolLM-family mapping. |
| src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs | Reworks encoding to pre-tokenize raw text first, then byte-encode + BPE per segment; preserves unmatched spans in pipeline splitting. |
| src/DotLLM.Tokenizers/Bpe/BpeTokenizer.cs | Wires the new GetRegexes pipeline into tiktoken tokenizer construction. |
Comments suppressed due to low confidence (1)
src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs:212
PreTokenizeinitializes the spans list with capacitytext.Length, which can allocate a very large backing array for long prompts even though the number of spans is usually far smaller. Using a capped initial capacity avoids unnecessary memory pressure.
var spans = new List<(int Start, int Length)>(text.Length) { (0, text.Length) };
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// <summary> | ||
| /// Compiled pre-tokenization regex that splits input at word/punctuation boundaries | ||
| /// before BPE merges. Null means no pre-tokenization (whole text = one segment). | ||
| /// </summary> | ||
| private readonly Regex? _preRegex; | ||
| private readonly Regex[]? _preRegexes; | ||
|
|
| return EncodeRawSegment(text.AsSpan()); | ||
|
|
||
| var spans = PreTokenize(text.AsSpan(), _preRegexes); | ||
| var result = new List<int>(text.Length); |
From review feedback. The field's comment still described a single regex after this PR changed it to `Regex[]?`, and did not say what null means. Both matter to a future reader: - null/empty is not "a sensible default", it is NO pre-tokenization — merges then run across boundaries the model was trained to respect, producing a stream that mostly matches the reference and diverges at a few sites. That is precisely the bug this PR fixes, so leaving it undocumented invites its reintroduction. - It is an array because a pre-type maps to an ordered pipeline, not one expression. The StarCoder/SmolLM family isolates digits before its main pattern; collapsing that to a single regex silently mis-tokenizes. The reviewer also flagged `new List<int>(text.Length)` over-reserving. That is real but is the subject of #413 / PR #419, where I measured it: sizing by an estimated token count saves a further 2.66x on prose yet costs more on a vocabulary emitting ~1 token per character. It is a heuristic trade, so it is not being smuggled into this correctness fix.
|
Both addressed in 579c79f.
So it is a heuristic trade rather than a strict improvement, which is why it is not being smuggled Worth noting the two PRs interact, since it is not obvious from either alone: once this lands, the Tokenizer tests 228/228. |
…y to dev (#237) Ports upstream PR kkokosa#417 onto dev. dev already had the split-before-byte-encode ordering (main did not), so this carries only the parts dev lacks: - GetRegexes returns an ordered PIPELINE, not one expression. llama.cpp's regex_exprs is a list applied in sequence; the StarCoder/SmolLM family needs two stages (\p{N} to isolate digits, then the main pattern). - The StarCoder/SmolLM block: starcoder, refact, command-r, smollm, codeshell, exaone, minerva, mellum2 -- the eight pre-types llama.cpp falls through one case. `smollm` was absent entirely, and an unmapped type means NO pre-tokenization, so SmolLM ran with BPE merges crossing every boundary. `command-r` was mapped to the Llama-3 regex, which is wrong. - PreTokenize preserves UNMATCHED spans; the old code encoded only regex matches, silently dropping the rest. Harmless for the GPT-2 expression (ends in |\s+) but not for this family's, which omits that alternative by design. dev's own additions are preserved: the allocation-free ByteMapIntoSpan path, the gpt-4o/llama4 pre-type (now a one-stage pipeline), and CreateTiktokenWithRegex (a single regex is wrapped as a one-stage pipeline). Verified by full token-stream equality against llama.cpp build 8683 -- not equal counts, which matched exactly (3904 both) while 30 ids differed: SmolLM-135M Q8_0 (smollm) 3904 tokens, 0 mismatches Llama-3.2-3B Q8_0 (llama3) 945 tokens, 0 mismatches Tokenizer tests on dev: 298 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconciles a genuine divergence: while this session merged issue/231's perplexity harness directly from its fork branch, a concurrent session on Strix independently ported the SAME feature back from upstream (kkokosa/ dotLLM#418, meaning it was also contributed upstream), plus a separate new feature never on origin/dev before now -- issue #237's pre-tokenizer pipeline (BpeTokenizer.cs/Gpt2TiktokenEncoding.cs/TiktokenPreTokenizer.cs, StarCoder/SmolLM family support). Reconciled via git bundle (no push access from Strix to transfer the diverged history directly -- HTTPS credential store isn't available non-interactively over the SSH+PowerShell channel this session uses). Only one real conflict: Program.cs's perplexity command example (kept the --stride variant -- verified PerplexityCommand.Settings actually has a Stride option, so the simpler upstream-ported example was the stale one). Everything else merged clean -- the #231 content itself was byte-identical between both paths (confirms both ports landed the same end state). Full DotLLM.Tokenizers/Cli build clean; pre-tokenizer test suite (95 tests) passes with zero failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tracked by jamesburton#237 (fork issue tracker) — deliberately not using a
Closeskeyword, since #237 here is an unrelated issue in this repo.Summary
BPE pre-tokenization was applied to byte-encoded text rather than raw text, which silently disabled every whitespace-dependent alternative in every pre-tokenizer pattern. Alongside that,
smollmwas unmapped (and an unmapped type means no pre-tokenization at all), and the pre-type table could not express a multi-stage pipeline.Found while validating a perplexity harness (jamesburton#231) against
llama-perplexityon wikitext-2.The three defects
1. Split-then-encode ordering.
Gpt2TiktokenEncoding.Encodeconverted input to GPT-2 byte-level Unicode first, then ran the pre-tokenization regex over the result. In that encoding byte0x20maps toU+0120, not to a literal space — so\s, a leading' ?', and\s+(?!\S)could never match. llama.cpp splits the raw text and byte-encodes each segment afterwards;Encodenow does the same. This affected every model using pre-tokenization, not just the unmapped ones.2.
smollmunmapped, andnullmeans "no pre-tokenization".SmolLM-135M.Q8_0.ggufdeclarestokenizer.ggml.pre = smollm, which the table did not handle, so BPE merges ran across the entire input with no boundaries. Adds the StarCoder/SmolLM block covering the eight pre-types llama.cpp falls through a single case:starcoder,refact,command-r,smollm,codeshell,exaone,minerva,mellum2.command-rwas previously mapped to the Llama-3 regex, which is wrong.3. A pre-type is a pipeline, not one expression. llama.cpp's
regex_exprsis an ordered list, each expression further splitting the previous stage's segments. This family genuinely needs two stages:GetRegex→GetRegexesaccordingly.PreTokenizealso preserves unmatched spans: the old code encoded only regex matches, silently dropping anything unmatched. That is harmless for the GPT-2 expression, which ends in|\s+and matches everything, but not for this family's pattern, which omits that alternative by design — dropping characters there corrupts the stream rather than merely re-splitting it.Verification
Full token-stream equality against llama.cpp build 8683 (
d0a6dfeb2) — not equal counts. That distinction is the point: counts matched exactly (3904 both) while 30 ids differed, so any count-based assertion would have passed throughout and the bug would have stayed invisible.smollmllama3over a 16.4 KB slice of
wikitext-2wiki.test.raw. llama.cpp prepends BOS for Llama-3 (add_bos_token = true); that is a caller-level concern, not a pre-tokenizer one, and is excluded from the comparison.Both families are covered because defect #1 meant the previously-"working" ones were equally suspect.
Unit tests: 1167 passed, 0 failed, 36 skipped.
Why this matters beyond perplexity
Any text with a spaced apostrophe —
Teddy 's Story, ubiquitous in wikitext-raw-style corpora — tokenized differently from the reference. That affects generation quality, prompt fidelity, and every comparison against llama.cpp or HF. It degrades quietly: the output is almost right, which is the hardest kind of defect to notice.Measured on our side, it moved a 15-chunk perplexity from
24.8439to23.7864against llama.cpp's24.0137.Note for reviewers
A silent
nullfor an unrecognized pre-type remains a poor default — it disables pre-tokenization rather than failing loudly. Left as-is here to keep the change focused, but worth revisiting.