tokenizers: TryDecode(Span<char>) zero-allocation overload + IncrementalDetokenizer adoption - #308
Conversation
…talDetokenizer adoption (#121) Item #12 follow-up from Gemini's review on PR #122. Adds a span-writing decode overload to the tokenizer surface and adopts it in `IncrementalDetokenizer` to eliminate the two per-step `string` allocations that remained after PR #122 amortized the O(n^2) detokenization cost. Surface - `ITokenizer.TryDecode(ReadOnlySpan<int>, bool stripBosSpace, Span<char>, out int charsWritten)` default interface method that forwards to `Decode(...)` + copy (external impls unchanged). - `IBpeEncoding.TryDecode(...)` mirrors the same default forward. - `BpeCore.TryFlushByteBuffer` — span variant of the SPM byte-run flush used by the SPM zero-alloc path. Encoding overrides - `Gpt2TiktokenEncoding`: materializes the GPT-2 byte stream once, then `Encoding.UTF8.GetCharCount` pre-checks the destination size for an atomic return — destination is byte-for-byte untouched on overflow. Existing `Decode(...)` factored over the same helper to keep both paths in sync. - `SentencePieceEncoding`: cursor write into the destination span with `MemoryExtensions.Replace` for the inline `_` -> space substitution and an in-place left-shift for the `stripBosSpace` leading-space strip. Non-atomic on overflow (per the documented contract). Adoption - `IncrementalDetokenizer`: replaces `string _windowText` with an ArrayPool `char[] _windowBuf` + `int _windowLen` (grow-and-re-rent on overflow), a matching `_tailBuf` for `TryEvictOldest`, and an `IDisposable` implementation that returns the buffers. `TextGenerator` declares the detokenizer outside its `try` block at both call sites and disposes in the `finally`. Tests + benchmarks - `TryDecodeZeroAllocTests` (15 tests): parity vs `Decode` for SPM + tiktoken across curated and random sequences with both `stripBosSpace` values; buffer- too-small contract (tiktoken atomic, SPM bounded-write); `GC.GetAllocated- BytesForCurrentThread` delta == 0 over 1000 warm calls for both encodings; baseline sanity check that the allocating `Decode` path still allocates. - `IncrementalDetokenizerTests` (10 tests): existing 9 regression-gate the buffer rewrite; new `Append_SteadyState_AllocationFloorIsBoundedAndFarBelow- PreviousPath` asserts the adopted path's per-200-call delta stays well below the pre-PR baseline (it is now 0 bytes in practice; ceiling is conservative because the committed StringBuilder chunk-grows amortized). - `DetokenizerAllocBenchmark` — BenchmarkDotNet MemoryDiagnoser baseline for the primitive `Decode` vs `TryDecode` allocation delta on both encodings. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a zero-allocation decode path to the tokenizer API and wires it through the BPE encodings and incremental detokenization flow to reduce allocations and improve per-token stop-check performance.
Changes:
- Introduces
ITokenizer.TryDecode(...)and implements it for SentencePiece and GPT-2/tiktoken BPE decode paths. - Refactors
IncrementalDetokenizerto decode into pooled buffers and adds deterministic cleanup viaIDisposable. - Adds unit tests and a BenchmarkDotNet benchmark to validate parity and reduced allocations.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Tokenizers/TryDecodeZeroAllocTests.cs | New parity + allocation-focused tests for TryDecode. |
| tests/DotLLM.Tests.Unit/Engine/IncrementalDetokenizerTests.cs | Adds allocation regression coverage for IncrementalDetokenizer.Append(). |
| src/DotLLM.Tokenizers/ITokenizer.cs | Adds TryDecode default interface method and docs. |
| src/DotLLM.Tokenizers/Bpe/SentencePieceEncoding.cs | Implements span-based zero-alloc SentencePiece decode. |
| src/DotLLM.Tokenizers/Bpe/Gpt2TiktokenEncoding.cs | Adds atomic span-based decode and factors byte materialization helper. |
| src/DotLLM.Tokenizers/Bpe/BpeTokenizer.cs | Overrides ITokenizer.TryDecode to delegate to encoding. |
| src/DotLLM.Tokenizers/Bpe/BpeCore.cs | Extends encoding interface and adds TryFlushByteBuffer helper. |
| src/DotLLM.Engine/TextGenerator.cs | Ensures IncrementalDetokenizer is disposed even on early exits/cancellation. |
| src/DotLLM.Engine/IncrementalDetokenizer.cs | Moves window/tail decoding to pooled char buffers; adds IDisposable. |
| benchmarks/DotLLM.Benchmarks/Tokenizers/DetokenizerAllocBenchmark.cs | Adds allocation benchmark comparing Decode vs TryDecode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Incremental detokenizer keeps stop-check cost O(1) amortized per token | ||
| // instead of decoding the entire generated sequence each step (O(n²)). | ||
| // Declared outside the try so the finally can deterministically return its pooled buffers. | ||
| var detok = new IncrementalDetokenizer(_tokenizer, initialCapacity: Math.Max(64, maxTokens * 4)); | ||
|
|
||
| try | ||
| { |
| // Incremental detokenizer: O(1) amortized per token for stop-check + streaming delta, | ||
| // instead of decoding the full generated sequence at every step. Lifted out of the try | ||
| // so the finally can return its pooled buffers even on cancellation. | ||
| var detok = new IncrementalDetokenizer(_tokenizer, initialCapacity: Math.Max(64, maxTokens * 4)); | ||
|
|
||
| try | ||
| { |
| /// <summary>Returns pooled buffers to <see cref="ArrayPool{T}.Shared"/>. Idempotent.</summary> | ||
| public void Dispose() | ||
| { | ||
| if (_windowBuf is { Length: > 0 } wb) | ||
| { | ||
| ArrayPool<char>.Shared.Return(wb); | ||
| _windowBuf = []; | ||
| } | ||
| if (_tailBuf is { Length: > 0 } tb) | ||
| { | ||
| ArrayPool<char>.Shared.Return(tb); | ||
| _tailBuf = []; | ||
| } |
| int total = 0; | ||
| for (int i = 0; i < CallsPerInvoke; i++) | ||
| { | ||
| _spm.TryDecode(_spmIds, stripBosSpace: false, _scratch, out int written); | ||
| total += written; | ||
| } |
| long delta = after - before; | ||
| // Expect bit-exact zero managed allocation on the steady-state hot path. | ||
| Assert.True(delta == 0, | ||
| $"Expected 0 managed bytes allocated across 1000 TryDecode calls, got {delta} bytes"); |
| long delta = after - before; | ||
| // 200 Append calls under the previous path = ~400 small string allocs ~= O(few KB). | ||
| // The new path's only steady-state allocation source is the committed StringBuilder | ||
| // chunk growth (amortized — typically zero across 200 calls once warm). Assert a | ||
| // conservative ceiling well below the previous baseline. | ||
| Assert.True(delta < 2_048, | ||
| $"Append() allocated {delta} bytes across 200 calls — expected far less than the pre-PR ~4 KB+ baseline"); |
Four of six review comments applied, two answered in place: - TextGenerator (sync + streaming): construct IncrementalDetokenizer inside the try with a nullable local, so a constructor failure still runs the finally that returns the rented stopScratch buffer. - IncrementalDetokenizer.Dispose + both grow-and-re-rent paths, and the TextGenerator stopScratch returns, now use Return(clearArray: true) so decoded model output is not left readable by an unrelated renter of ArrayPool<char>.Shared. Off the per-token path; buffers are small. - DetokenizerAllocBenchmark: validate in GlobalSetup that the scratch buffer fits both decodes, so a vocab change fails fast instead of silently benchmarking the buffer-too-small failure path. Measured loops unchanged. - TryDecodeZeroAllocTests: replace the bit-exact `delta == 0` assertion with a sub-one-byte-per-call budget (1000 bytes / 1000 calls). The minimum managed object is 24 bytes, so this still rules out any per-call allocation while not flaking on one-off runtime bookkeeping. - IncrementalDetokenizerTests: pre-size the committed StringBuilder (4096) so StringBuilder chunk growth cannot perturb the measurement; the existing 2 KB ceiling is kept rather than loosened.
|
Thanks for the review — all six comments assessed, pushed as Applied
Answered rather than changed
Verification — |
Summary
Addresses an item from #121. Adds a
TryDecode(Span<char>)zero-allocation overload to the tokenizer interface and adopts it inIncrementalDetokenizer, removing string allocations from the steady-state streaming detokenize path.Test results
TryDecodeZeroAllocTests(334 lines).Notes for review
Single-commit perf fix on
main. The allocatingDecodeis preserved for backward compatibility.