Skip to content

Gemma and Phi-4 support with vocab-aware splitting and lstrip/rstrip tokens#46

Open
jakehlee wants to merge 13 commits into
crusoecloud:mainfrom
jakehlee:main
Open

Gemma and Phi-4 support with vocab-aware splitting and lstrip/rstrip tokens#46
jakehlee wants to merge 13 commits into
crusoecloud:mainfrom
jakehlee:main

Conversation

@jakehlee

Copy link
Copy Markdown

First, I did not see contributor guidelines or PR templates, so apologies if this is a long shot to merge.

This started as an effort to support GemmaTokenizer. While adding the Replace normalizer, the Replace and ByteFallback decoders, and byte_encode to bpe fixes compatibility, performance gains are not realized because Gemma is an SPM/metaspace pipeline. The normalizer rewrites every space (" ""▁") before a no-op space split pre-tokenizer, meaning the entire document hits the BPE engine. The cache never hits and the parallelism never triggers.

The solution is vocabulary-aware bigram splitting, where we build a lookup table of bigram pairs that don't appear in the vocabulary, and only split between pairs that cannot merge. This splits the input back into smaller chunks while maintaining token parity. We add a gate to only apply this to models without ByteLevel in the pre-tokenizer sequence to avoid unnecessary overhead.

The result is Gemma4 compatibility and tokenizer performance gains:

% ./validate_model.sh google/gemma-4-31B-it
=== Validating model: google/gemma-4-31B-it ===

--- RyokoAI/ShareGPT52K ---
   Compiling fastokens v0.2.1 (/-/fastokens)
    Finished `release` profile [optimized] target(s) in 7.94s
     Running `/-/fastokens/target/release/examples/simple_bench google/gemma-4-31B-it --dataset RyokoAI/ShareGPT52K`
[00:02:30] [========================================] 45332/45332 (0s)          
═══════════════════════════════════════════
  Benchmark Summary (45332 samples)
═══════════════════════════════════════════
  Total chars:    766126389
  Total tokens:   203044531
───────────────────────────────────────────
  HF total:        138130.18 ms
  fastokens total:  12069.41 ms
  Speedup:             11.44x
───────────────────────────────────────────
  HF avg/sample:       3.047 ms
  ft avg/sample:       0.266 ms
  HF throughput:        5.55 MB/s
  ft throughput:       63.48 MB/s
═══════════════════════════════════════════
PASS: google/gemma-4-31B-it on RyokoAI/ShareGPT52K

--- zai-org/LongBench-v2 ---
    Finished `release` profile [optimized] target(s) in 0.31s
     Running `/-/fastokens/target/release/examples/simple_bench google/gemma-4-31B-it --dataset zai-org/LongBench-v2`
[00:02:28] [========================================] 503/503 (0s)              
═══════════════════════════════════════════
  Benchmark Summary (503 samples)
═══════════════════════════════════════════
  Total chars:    446089036
  Total tokens:   141728831
───────────────────────────────────────────
  HF total:        143278.87 ms
  fastokens total:   4894.06 ms
  Speedup:             29.28x
───────────────────────────────────────────
  HF avg/sample:     284.849 ms
  ft avg/sample:       9.730 ms
  HF throughput:        3.11 MB/s
  ft throughput:       91.15 MB/s
═══════════════════════════════════════════
PASS: google/gemma-4-31B-it on zai-org/LongBench-v2

There are minor optimizations included, including an ASCII fast-path, but this is the bulk of the improvement.

In addition, I noticed Phi-4 tests were failing. This is due to Phi-4's added tokens that have "lstrip": true and "rstrip": true. Adding these flags restores parity.

% ./validate_model.sh microsoft/phi-4      
=== Validating model: microsoft/phi-4 ===

--- RyokoAI/ShareGPT52K ---
    Finished `release` profile [optimized] target(s) in 0.32s
     Running `/-/fastokens/target/release/examples/simple_bench microsoft/phi-4 --dataset RyokoAI/ShareGPT52K`
[00:03:05] [========================================] 45332/45332 (0s)          
═══════════════════════════════════════════
  Benchmark Summary (45332 samples)
═══════════════════════════════════════════
  Total chars:    766126389
  Total tokens:   208458072
───────────────────────────────────────────
  HF total:        170083.00 ms
  fastokens total:  14982.18 ms
  Speedup:             11.35x
───────────────────────────────────────────
  HF avg/sample:       3.752 ms
  ft avg/sample:       0.330 ms
  HF throughput:        4.50 MB/s
  ft throughput:       51.14 MB/s
═══════════════════════════════════════════
PASS: microsoft/phi-4 on RyokoAI/ShareGPT52K

--- zai-org/LongBench-v2 ---
    Finished `release` profile [optimized] target(s) in 0.31s
     Running `/-/fastokens/target/release/examples/simple_bench microsoft/phi-4 --dataset zai-org/LongBench-v2`
[00:02:07] [========================================] 503/503 (0s)              
═══════════════════════════════════════════
  Benchmark Summary (503 samples)
═══════════════════════════════════════════
  Total chars:    446089036
  Total tokens:   123306201
───────────────────────────────────────────
  HF total:        121326.73 ms
  fastokens total:   5463.09 ms
  Speedup:             22.21x
───────────────────────────────────────────
  HF avg/sample:     241.206 ms
  ft avg/sample:      10.861 ms
  HF throughput:        3.68 MB/s
  ft throughput:       81.66 MB/s
═══════════════════════════════════════════
PASS: microsoft/phi-4 on zai-org/LongBench-v2

=== All datasets passed for microsoft/phi-4 ===

Merging this (or a variant) would be valuable, especially for vLLM. Feel free to make/request any changes for the merge.

Thank you!

jakehlee and others added 13 commits July 9, 2026 01:09
Split each text chunk on newline boundaries ([^\n]+|[\n]+) so Gemma's
metaspace pipeline no longer feeds the whole document to BPE as one chunk.
This restores the per-line cache and rayon parallelism while preserving
parity (BPE never merges across the newline/non-newline boundary). Also
removes the leftover debug println in run_merge_loop (serialized worker
threads on stdout's lock) and adds an ASCII fast-path char->token lookup.

Measured on google/gemma-4-E2B with full HF parity: 3.85x ShareGPT52K,
8.61x LongBench-v2 (was ~1.5x).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace newline splitting with vocab-aware byte-pair splitting. Build a
64 KB bigram bridge table at initialization by scanning all vocab tokens
to identify which of 256×256 byte pairs appear in any token. At encode
time, split at unbridgeable pairs (checking UTF-8 boundaries to avoid
breaking multi-byte chars). For Gemma, 81.6% of byte pairs are
unbridgeable, creating fine-grained word-level chunks that restore cache
hits and rayon parallelism even on long lines. Provably output-preserving:
if no vocab token contains byte pair xy, no BPE merge can span that
boundary.

Measured on google/gemma-4-E2B with full HF parity: 8.92x ShareGPT52K
(up from 3.85x), 27.88x LongBench-v2 (up from 8.61x). The LongBench result
approaches the theoretical byte-level ceiling on long documents.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Removed the newline-based splitting function and fallback logic, keeping
only the vocabulary-aware bigram splitting approach. This makes the
codebase fully model-agnostic for all BPE tokenizers.

Changes:
- Removed split_on_newlines() function and all related tests
- Removed fallback logic to newline splitting in encode path
- Updated AGENT.md to reflect current implementation status
- Updated comments to emphasize generic, model-agnostic approach

Rationale: Vocab-aware splitting is strictly superior (8.92x/27.88x vs
3.85x/8.61x on Gemma) and works for all BPE models by automatically
adapting to each model's vocabulary at initialization time. No
model-specific logic needed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Gate split_on_unbridgeable_bigrams behind a build-time needs_vocab_splitting
flag so it runs only when the pre-tokenizer has no ByteLevel step (metaspace
models like Gemma). ByteLevel models already chunk at word boundaries via their
regex Split, so the per-byte scan was unnecessary there. The pass is
output-preserving, so this is a pure performance/scoping change with no parity
impact; benchmarks show it neutral within noise on Qwen3 and unchanged on Gemma.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gemma Performance Improvement with Vocab-aware Splitting
Delegate ReplaceDecoder to normalizers::Replace so the pattern parsing
and literal-replace logic have a single source of truth, and drop the
unused BigramBridgeTable::stats/from_array methods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AddedTokens matched literal content only, ignoring the lstrip/rstrip
flags parsed from tokenizer.json. Tokens flagged to strip (e.g. Phi-4's
<|im_start|>/<|im_end|>, both lstrip=rstrip=true) left adjacent
whitespace as separate text, emitting an extra token vs HuggingFace and
desyncing the whole stream on inputs containing chat templates.

split() now expands a match span over adjacent Unicode whitespace per
the token's flags, mirroring HF's left-to-right resolution when two
strip tokens share a run. Tokens without strip flags are unchanged, so
other models' output is byte-for-byte identical.

Fixes microsoft/phi-4 parity on LongBench-v2 (previously failed on the
sample embedding a literal <|im_end|>\n<|im_start|> template).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement lstrip/rstrip whitespace absorption for added tokens
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.

1 participant