WordPiece via MPHF + bounded longest-match (PTRHash stream experiment)#2179
Draft
ArthurZucker wants to merge 8 commits into
Draft
WordPiece via MPHF + bounded longest-match (PTRHash stream experiment)#2179ArthurZucker wants to merge 8 commits into
ArthurZucker wants to merge 8 commits into
Conversation
Route WordPiece's pipeline path through the ptr_hash MPHF (BucketVocabStore) instead of AHashMap, and bound the candidate length by the longest vocab token (a match can never be longer). ~1.3x faster than the legacy AHashMap greedy path on a BERT-like vocab (22k tokens, longest 14B), byte-exact. - BucketVocabStore::longest_prefix_match: streamed longest-prefix probe over ptr_hash index_stream (prefetch). Kept for the x86 / large-vocab case; on aarch64 ptr_hash's prefetch is a no-op so scalar wins there. - PipelineWordPiece: scalar bounded longest-first early-exit over BucketVocabStore.get_bytes (fastest on aarch64); wired into PipelineModel. - benches/wordpiece.rs: decomposes the win (bounding / AHashMap->MPHF / streaming) with a byte-exact gate across all variants.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
PipelineWordPiece routes the pipeline WordPiece path through the ptr_hash MPHF (BucketVocabStore) and, at each position, probes only the vocab's distinct token byte-lengths (descending, first hit = greedy longest) instead of shrinking one char at a time. Byte-exact with the legacy AHashMap path, verified token-for-token on the full bert-base-uncased vocab over ~200k real words. benches/wordpiece.rs decomposes the win (bounding / AHashMap->MPHF / streaming / length-set / first-byte length-set) with a byte-exact gate; loads a real vocab + corpus via WP_VOCAB / WP_CORPUS env vars. Investigation notes (kept ahash, no store change): - Fx pre-hash is ~2x faster in isolation but produces 64-bit collisions on the real bert vocab -> silently drops a token -> wrong split. ahash stays. - first-byte-bucketed length sets probe fewer lengths but are within noise of the flat union list on real data; not worth the Vec<Vec> indirection. - index_stream prefetch is a no-op on aarch64, so streaming loses there.
Profiling on the real bert-base-uncased vocab: the WordPiece model spent ~half its time in a per-word heap alloc (a fresh scratch Vec per tokenize_pipeline call). Reuse a thread-local scratch buffer → zero alloc after warmup, ~2x on the model lookup. Real WordPiece probes only ~1.2 lengths/word, so the distinct-length probe stays the win; MPHF vs AHashMap backing measured identical (within 2%), so the MPHF stays for its ~3x smaller footprint. Also drop the ptr_hash index_stream path (BucketVocabStore::longest_prefix_match + STREAM_PREFETCH): no win on aarch64 (prefetch is a no-op there) and it gave up the greedy early-exit. Remove the wordpiece micro-bench; the CI pipeline bench on bert covers this end-to-end.
Completes the previous commit (which only caught the bench deletion via a bad add): - PipelineWordPiece: reuse a thread-local scratch buffer instead of a per-word Vec. Profiling on the real bert vocab showed the per-word alloc was ~half the model lookup cost; reusing it is ~2x. Real WordPiece probes only ~1.2 lengths/word. - BucketVocabStore: drop the index_stream longest_prefix_match + STREAM_PREFETCH (no win on aarch64, gave up greedy early-exit). - Cargo.toml: remove the deleted wordpiece bench entry.
Shave the non-lookup cost the profile flagged (byte-exact, still MPHF + distinct-length): - max-chars guard: only run the O(len) chars().count() when byte-len could exceed the cap (bytes >= chars), so normal words skip the decode. - word-initial candidates (start==0, no prefix) probe the word slice directly — no copy; only continuations touch the scratch buffer. - keep the constant continuing-prefix in the scratch across the word; continuations rewrite only the word bytes (truncate+append), not the prefix. - BucketVocabStore: lay the byte slab out in sorted token order so tokens sharing a prefix are contiguous → a word's successive length-probes verify against nearby slab bytes (build-time only; no query cost).
Add a [u16;256] max-token-length-per-first-byte table (word-initial + ##-continuation), L1-resident, no indirection. At each position: if no vocab token starts with the byte at word[start] (table == 0) emit [UNK] with zero get_bytes probes; otherwise cap the probe window to the longest token that could match there. bert-base-uncased has no Hangul, so every Korean word was probing ~all lengths at start=0 (all misses) before falling to unk — the model measured 78 ns/byte of pure waste. Now Korean is byte-exact at ~0.8 ns/byte (~90x). English is unchanged and byte-exact (~7.3 ns/byte): the flat union length-set still does the real probing, so there's no Vec<Vec> per-position indirection.
PipelineWordPiece no longer hand-rolls its own length-set / first-byte / reject probe — it reuses `buckets::Buckets` (the added-vocabulary matcher), which already had all of it and more: first-byte reject, longest-common-prefix u64 reject, post-byte length sub-lists (a second discriminator, tighter than my per-first-byte), MPHF verify, and a SIMD scan. Expose `Buckets::longest_at` — the anchored longest-match-at-a-position primitive WordPiece needs (`match_bytes` scans text; this doesn't). Word-initial candidates match the raw word bytes (no copy); continuations match `["##"] + word[start..]` in a thread-local scratch capped at the longest ## token. Byte-exact vs the legacy path on the full bert vocab (English + Korean): ~8 ns/byte English, ~0.7 ns/byte Korean (out-of-vocab scripts reject with zero probes).
…nt-byte discrimination
The Buckets refactor merged all tokens into one matcher and routed continuations
through the '#' bucket by matching "##"+word. bert has a plain "#" token, so that
bucket's common prefix collapses to empty (plen = min(lcp, min_len-1) = min(1,0) = 0)
and every continuation was keyed by post-byte '#' — i.e. probed the length list of
ALL ~5800 '##' tokens, longest-first, before hitting. That's what kept kor_Hang slow:
the bert normalizer NFD-decomposes Hangul syllables into jamo (U+11xx, lead 0xE1) and
bert has ~35 jamo tokens, so the model does real greedy WordPiece over jamo, and every
jamo continuation paid ~13 dead length probes.
Fix: two matchers.
- `initial`: all tokens, keyed by first byte, used only at start==0 (raw-word match,
stays legacy-exact even for a literal "##" word).
- `cont`: '##' tokens with the prefix stripped, keyed by the CONTENT byte, used at
start>0. The content byte now selects a short per-byte length list, and matching
word[start..] directly also drops the "##"+word scratch copy (removes the
thread-local buffer and max_cont_word entirely).
Isolated jamo model path: 12.99 -> 5.36 ns/byte (2.4x), now near the MPHF-confirm floor
(~1 get_bytes per jamo). Byte-exact: pipeline_wordpiece_matches_legacy extended with a
plain "#" token (reproduces the collapsed bucket) and literal-# words.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Routes the WordPiece pipeline path through the
ptr_hashMPHF (BucketVocabStore) instead ofAHashMap, submitting the per-position length-probes against the MPHF and bounding the candidate window by the longest vocab token. NewPipelineWordPiecemirrorsPipelineBPE.~1.3× faster than the legacy
AHashMapgreedy path on a BERT-like vocab, byte-exact (asserted).Why / the idea
Legacy WordPiece probes
word[start..end]forend = len .. start+1one at a time, each a dependent hash lookup. The request was to submit those checks against the PTRHash MPHF as a stream and let prefetch/ILP hide the latency. I built that primitive and measured it honestly — the result is more nuanced than "streaming wins":Bench decomposition (
cargo bench -p tk-encode --bench wordpiece, aarch64 M-series, 22k-token vocab, longest 14B)get_bytes, bounded, scalarindex_stream, boundedAHashMap.get→BucketVocabStore.get_byteson the same bounded greedy loop is 1.41× (V3 vs V2).index_stream) is slower than scalar on aarch64 (V4 < V3):ptr_hash's software prefetch is commented out on aarch64 (a no-op), and streaming also gives up the greedy early-exit. Its win is x86/x86_64-only and still needs validation on a real x86 host (Intel SDE only emulates functionally, not timing).PipelineWordPiecetherefore ships the scalar bounded path (fastest on arm);BucketVocabStore::longest_prefix_match(the streamed primitive) is kept and benched for the x86/large-vocab case.Changes
BucketVocabStore::longest_prefix_match— streamed longest-prefix probe (index_stream::<16, true>,MINIMAL=trueto match the store's minimal[0,n)slots), byte-verified.PipelineWordPiece(+from_wordpiece) — MPHF-backed, bounded longest-first early-exit; wired intoPipelineModel::WordPiece.benches/wordpiece.rs— self-contained (no fixtures), 4-way decomposition + byte-exact gate.pipeline_wordpiece_matches_legacy(byte-exact vs the legacyAHashMappath).Plan / status
BucketVocabStorePipelineWordPiece, byte-exact, wired inwindows(2)) + char-atom vocab lookups (per-step neighbor lookups stay dependent; expect a smaller win)Notes for reviewers
max_token_lenbounding is exact (no vocab token exceeds it), so output is identical to the legacy greedy path.PipelineTokenizer benchmark
5 / 6 models supported — ~10 kB inputs · single thread
b3cf95add · 2026-07-10 10:04 UTC· Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz · 8 coresbert-base-uncased — normalizer-heavy WordPiece · geomean ×4.49
deepseek-v4 — split-heavy byte-level BPE, 3 chained regexes · geomean ×2.59
gpt2 — standalone ByteLevel pre-tokenizer · geomean ×7.75
llama-2 — model-bounded BPE, no pre-tokenizer · geomean ×3.01
llama-3 — split-heavy byte-level BPE, single regex · geomean ×3.92
Not yet supported:
t5-base