Skip to content

tokenizers: TryDecode(Span<char>) zero-allocation overload + IncrementalDetokenizer adoption - #308

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/121-tokenizer-try-decode-zero-alloc
Open

tokenizers: TryDecode(Span<char>) zero-allocation overload + IncrementalDetokenizer adoption#308
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/121-tokenizer-try-decode-zero-alloc

Conversation

@jamesburton

Copy link
Copy Markdown

Summary

Addresses an item from #121. Adds a TryDecode(Span<char>) zero-allocation overload to the tokenizer interface and adopts it in IncrementalDetokenizer, removing string allocations from the steady-state streaming detokenize path.

Test results

  • New: TryDecodeZeroAllocTests (334 lines).
  • 10 files changed, 791 insertions / 44 deletions.
  • Bit-exact equivalence to the allocating overload is asserted.

Notes for review

Single-commit perf fix on main. The allocating Decode is preserved for backward compatibility.

…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IncrementalDetokenizer to decode into pooled buffers and adds deterministic cleanup via IDisposable.
  • 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.

Comment on lines +142 to 148
// 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
{
Comment on lines +486 to 492
// 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
{
Comment on lines +197 to +209
/// <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 = [];
}
Comment on lines +50 to +55
int total = 0;
for (int i = 0; i < CallsPerInvoke; i++)
{
_spm.TryDecode(_spmIds, stripBosSpace: false, _scratch, out int written);
total += written;
}
Comment on lines +283 to +286
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");
Comment on lines +214 to +220
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.
@jamesburton

Copy link
Copy Markdown
Author

Thanks for the review — all six comments assessed, pushed as 3a5d34e.

Applied

  1. TextGenerator.cs:148 / :492 — pooled buffer stranded if the detokenizer constructor throws. Fair; the constructor rents from the pool itself, so it is exactly the call most likely to fail after stopScratch is live. Both the sync and streaming paths now declare IncrementalDetokenizer? detok = null outside the try and construct it as the first statement inside, with detok?.Dispose() in the finally. That keeps the existing single try/finally (no re-indentation of the generation loop) and compiles warning-free under Nullable enable — the definite-assignment flow analysis carries the non-null state through the whole body.

  2. IncrementalDetokenizer.cs:209 — pooled char[] returned without clearing. Agreed, and cheaper than the tradeoff framing suggests. Dispose() runs once per generation, not per token, and the grow-and-re-rent paths converge logarithmically — so Return(clearArray: true) at all four sites costs nothing on the hot path. Also applied to the two stopScratch returns in TextGenerator, which hold decoded output for the same reason. No measurable change to the zero-allocation assertions (Array.Clear does not allocate).

  3. DetokenizerAllocBenchmark.cs:55 — ignored TryDecode result. Valid. Rather than adding a branch to the measured loop, GlobalSetup now asserts that _scratch fits both decodes and throws otherwise. The inputs are fixed per run, so this catches the failure case completely while leaving the benchmark bodies byte-for-byte as measured.

  4. TryDecodeZeroAllocTests.cs:286 — strict delta == 0 may be flaky. Taking your "small upper bound" option, with a bound chosen so nothing is given up: 1000 bytes across 1000 warmed calls. The minimum managed object size is 24 bytes, so staying under one byte per call mathematically rules out any per-call allocation, while absorbing one-off runtime bookkeeping. The allocating Decode baseline costs tens of bytes per call here, so the assertion still discriminates by well over an order of magnitude.

Answered rather than changed

  1. IncrementalDetokenizerTests.cs:220 — allocation-ceiling sensitivity. Half applied. This assertion is already a tolerant ceiling (2 KB / 200 calls = 10 bytes per call, versus a pre-PR baseline of ~20+), so loosening it further would start eroding its ability to catch the regression it exists for. I did take the pre-sizing suggestion: the detokenizer is now constructed with initialCapacity: 4096 against ~232 chars of produced text, so StringBuilder chunk growth cannot contribute to the measurement at all and the remaining headroom is pure noise margin. Threshold kept as-is.

Verificationdotnet test --filter "FullyQualifiedName~TryDecode|FullyQualifiedName~Detokenizer": 25 passed, 0 failed. DotLLM.Engine and DotLLM.Benchmarks both build with 0 warnings.

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.

2 participants