perf(tokenizers): reuse one BPE merge queue per Encode call (#413) - #419
perf(tokenizers): reuse one BPE merge queue per Encode call (#413)#419jamesburton wants to merge 2 commits into
Conversation
The per-segment `new PriorityQueue<BgramEntry, (int,int)>` is now hoisted into `Encode` and cleared between segments, so its backing array grows once to the largest segment instead of being allocated — and regrown during merging — for every pre-token. Measured with GC.GetAllocatedBytesForCurrentThread over 20 warmed iterations, Llama-3.1-8B vocabulary (`tokenizer.ggml.pre = llama-bpe`), 32,768 chars: before 2,530,584 B/call (77.23 B/char) after 157,680 B/call (4.81 B/char) 16.0x less `EnsureCapacity` after `Clear` is load-bearing. Letting the queue reach its size by doubling allocates the whole chain of intermediate arrays; without it a single-segment input regressed from 32.73 to 64.88 B/char — worse than the per-segment allocation being removed. Two notes on the issue's framing, both from measurement: - The attribution holds only for a vocabulary whose pre-type maps to a regex. `TokenizerAllocationBenchmarks` uses SmolLM, whose `tokenizer.ggml.pre` is `smollm` and is not in the pre-type table, so `_preRegex` is null and the whole input is ONE segment with ONE queue. That benchmark therefore shows this change as exactly neutral (33,608 / 268,088 / 1,072,471 bytes, unchanged). Its ~32 B/char is a single queue sized to the whole text, which is also why the 32k case reaches the LOH and collects Gen2. - The `new List<int>(gpt2Text.Length)` over-reserve is real but is not fixed here. Sizing it by an estimated token count instead saves a further 2.66x on prose, but costs more on a vocabulary that emits ~1 token per character, where growth by doubling exceeds the over-reserve it avoids. That is a heuristic trade rather than a strict improvement, so it belongs in its own change. No behaviour change: the queue is a local, so concurrent Encode calls cannot share it, and token ids are unchanged.
There was a problem hiding this comment.
Pull request overview
This PR reduces per-call allocations in the tiktoken/GPT-2 BPE encoder by reusing a single merge PriorityQueue across all pre-tokenized segments within an Encode call, aligning with the optimization proposed in issue #413.
Changes:
- Hoists the BPE merge
PriorityQueueto theEncodecall scope and reuses it across segments by clearing/reseeding it (FillQueue). - Refactors segment-encoding helpers to accept a caller-supplied scratch queue, avoiding per-segment queue allocations.
- Adds a unit test intended to guard against state leakage when reusing the queue across segments.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs |
Reuses one merge queue per Encode call via a new FillQueue helper and updated segment encode helpers. |
tests/DotLLM.Tests.Unit/Tokenizers/BpeTokenizerTests.cs |
Adds a regression test to detect incorrect behavior from queue reuse across segments/calls. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // | ||
| // The list is sized by an estimate of the TOKEN count, not the character count: | ||
| // reserving one slot per character over-reserves by roughly the 4:1 | ||
| // characters-per-token ratio, and at 32k characters that is a 128 KB int[] on | ||
| // the LOH. Growth from a low estimate costs a few doublings; over-reserving | ||
| // costs an LOH allocation on every call. |
| // The merge queue is now reused across segments, so a segment's leftovers could in | ||
| // principle leak into the next. Encoding the same text as one call and as its pre-token | ||
| // pieces concatenated must agree, and repeat calls on one instance must be stable. |
…med (#413) From review feedback, both points self-inflicted: - The comment above `new List<int>(gpt2Text.Length)` described sizing by an estimated TOKEN count — the variant I measured and then deliberately dropped from this PR. It now records the actual capacity and why the estimate was left out (2.66x less garbage on prose, but worse on a vocabulary emitting ~1 token per character, so a heuristic trade rather than a strict win). - `PreTokenizedEncode_IsUnchangedByQueueReuse` claimed to compare whole-text encoding against the pre-token pieces concatenated, and did not. Rather than soften the comment, the assertion is now there: each piece is encoded through its own tokenizer instance — hence its own never-reused queue — and the concatenation must equal the single call. That is the actual leak check for sharing one queue across segments; the previous assertions only covered repeat-call stability and round-trip. It passes, which is independent evidence that the reuse carries no state between segments.
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 2d12a3b — both self-inflicted. Stale comment (fixed). Correct: the comment described sizing by an estimated token count, which Test comment vs test (fixed by adding the assertion). You are right that it claimed a comparison That is the actual leak check for sharing one queue across segments, which the previous assertions Tests 39/39 in the touched class. |
Reuses one BPE merge queue per
Encodecall instead of allocating one per pre-tokenizedsegment, per the "possible change" in #413.
Measured with
GC.GetAllocatedBytesForCurrentThreadover 20 warmed iterations(ArrayPool at steady state, so this is per-call garbage), Llama-3.1-8B vocabulary
(
tokenizer.ggml.pre = llama-bpe):Those figures include a second change I have since dropped from this PR; the queue
hoist alone accounts for 2,530,584 → 157,680 B (4.81 B/char) at 32,768 chars, 16.0x.
See "what is not here" below.
EnsureCapacityafterClearis load-bearing, not tidiness. Letting the queue reach itssize by doubling allocates the whole chain of intermediate arrays, and a single-segment
input regressed from 32.73 to 64.88 B/char — worse than the per-segment allocation being
removed. Worth keeping if this is ever refactored.
The attribution needs one correction
The acceptance criteria asked for the attribution to be confirmed or refuted before any
change landed. It is conditionally confirmed — and the condition is not currently met
by the benchmark that motivated the issue.
TokenizerAllocationBenchmarksuses SmolLM-135M, whosetokenizer.ggml.preissmollm.That value is not in
TiktokenPreTokenizer's table, soGetRegexreturns null,_preRegexis null, and
Encodetakesreturn EncodeSegment(gpt2Text)— the whole input is onesegment with one queue. There are no per-segment queues in that measurement at all.
I reproduced the issue's numbers exactly and instrumented the segment count:
So the ~32 B/char on that benchmark is one queue sized to the entire text — about 32 bytes
per element at roughly one element per character. That also explains the Gen2 column the
issue noticed: at 32,768 characters the backing array is ~1 MB and goes straight to the LOH.
This change is exactly neutral on that benchmark (33,608 / 268,088 / 1,072,471, unchanged),
which is why the numbers above use a
llama-bpevocabulary instead.Two follow-ups this implies, both outside this PR:
[Params], or asecond model — otherwise it measures a path most models do not take.
smollmbeing unmapped is a correctness bug, not just a benchmarking artifact: withoutpre-tokenization, merges cross boundaries the model was trained to respect. That is moe(perf+vulkan): CPU quantized-direct + thread pool + Q6_K Vulkan indexed-expert + docs #237 /
PR fix(tokenizers): pre-tokenize raw text before byte-encoding; add StarCoder/SmolLM pre-type family #417. Once it lands, this benchmark's own numbers change substantially — I measured
32.73 → 83.13 B/char, because the segmented path then allocates a queue per pre-token.
Applying this PR on top brings it to 16.76 B/char, below where it stands today.
What is not here
The
new List<int>(gpt2Text.Length)over-reserve is real. Sizing it by an estimated tokencount saves a further 2.66x on prose — but it costs more on a vocabulary that emits ~1
token per character, where growth by doubling exceeds the over-reserve avoided (I measured
40.0 → 43.0 B/char on such a case). That is a heuristic trade, not a strict improvement, so
it should be its own change with its own measurement rather than riding along here.
result.ToArray()needs theITokenizer.Encodesignature to change, as the issue notes.Tests
PreTokenizedEncode_IsUnchangedByQueueReusecovers the risk this change introduces — areused queue leaking state between segments — via repeat calls, a fresh-instance comparison
and a decode round-trip. Existing tokenizer tests (220) pass unchanged.
I deliberately did not add an allocation-threshold unit test. The cost removed here is
the queue's growth during merging, so it only exists for a vocabulary with a real merge
table; the synthetic 256-byte vocab in the unit tests has none and measures identically with
and without this change (655,512 bytes either way). Such a test would pass whether or not the
fix were present.
Closes #413