Repository navigation
perf(server): incremental detokenization and streaming overhead cuts - #713
Conversation
Three host-side per-token costs on the serving path are reduced (issue #633). Incremental detokenizer: StreamingDecodeState previously re-decoded the entire token history on every generated token (tokenizer.decode(&all_ids) on the byte-fallback and regular emit paths), which is O(N) per token and O(N^2) over a full generation and sits on the single generation thread. It now re-decodes only a bounded suffix window: two decodes anchored at a shared prefix_offset (the already-emitted window prefix, and that prefix plus the newest tokens), emitting the delta past the shared prefix. Because both decodes share the left context the shared prefix cancels out, so the emitted delta is byte-identical to a whole-history decode. read_offset only advances to a UTF-8-complete boundary, so an incomplete multibyte sequence (byte-fallback <0xXX> runs or split byte-level pieces) is held until it completes and is never streamed as U+FFFD. This mirrors the mlx-lm NaiveStreamingDetokenizer and the llama.cpp/vLLM incremental detokenizers. A MAX_HELD_TOKENS cap bounds the window on degenerate never-completing output, and a strip_prefix guard keeps the delta slice panic-safe when a decoder retroactively rewrites already-emitted text (the tokenizers ByteFallback decoder renders a whole byte run as U+FFFD once it holds an incomplete byte). The public StreamingDecodeState surface is unchanged, so the batched, single, speculative, diffusion, XLA, and CLI decode call sites need no changes. The now-redundant token_piece byte-fallback buffer, parse_byte_fallback_token, and safe_emit_boundary helpers are removed; their behavior (never emit incomplete UTF-8) is preserved by the window guard and covered end-to-end by the streaming tests. Streaming-path diet: the chat streaming callback locked three separate mutexes per token (tool-call accumulator, logprobs buffer, stream filter). They are collapsed into one StreamCallbackState behind a single Mutex, so the hot callback locks once per token; emitted chunks are collected and sent after the lock is released. The lock is required only for the Send bound on the spawn_blocking closure (an Arc<RefCell> would not be Send) and is never contended. The SSE wire format is byte-identical. Tokenize off the generation thread: prompt tokenization moved out of BatchScheduler::enqueue_request (which runs on the scheduler thread and stalled concurrent decode ticks for a long prompt) to the request-dispatch path. ModelProvider now holds an optional pre-tokenizer and encodes the prompt in send_generate_request_with_cancellation on the HTTP-side thread, passing the ids through ModelRequest::Generate. The scheduler uses them when present and falls back to tokenizing itself otherwise. tokenize_prompt_for_generation is the single source of truth for the add_special convention (suppressed on a literal <bos>/<s> prefix), so both paths are byte-identical. Legacy, XLA, diffusion, and test paths keep the None fallback. Adds byte-exactness regression tests (Korean, mixed CJK/emoji/ASCII, regular-piece plus multibyte, prompt-context seam, mid-multibyte truncation, and a fuzz comparison against a one-shot decode) beside the code.
|
Orchestrator E2E verification on the branch build (Apple M1 Ultra, Result: korean / cjk-mixed / byte-fallback / long are byte-identical between stream and non-stream. The emoji case differed, and both differences trace to pre-existing behavior, not this PR:
Net: no streaming byte-exactness regression attributable to this PR in E2E; the strong invariant (accumulated stream text == one-shot decode of final ids) is pinned by the new unit/fuzz tests. |
The incremental windowed detokenizer (issue #633) removed StreamingDecodeState's per-piece byte-fallback inspection, so token_piece no longer has a production caller. Update its doc and the shared-function `// Used by:` discovery comment to name the remaining test caller instead of StreamingDecodeState, and fix the inner comment that still described a full (rather than windowed) re-decode.
Implementation Review SummaryIntentCut three host-side per-token serving costs (issue #633): O(N)-per-token detokenization, three per-token chat-stream mutexes, and prompt tokenization on the scheduler thread. The core is a windowed incremental detokenizer mirroring vLLM/mlx-lm, kept byte-exact. FindingsHIGH: streaming loses trailing complete text when the last content token ends mid-multibyte. Recommended fix: have LOW (fixed): stale Verified clean
Checks
|
The windowed detokenizer holds back the whole window while its tail is an incomplete UTF-8 sequence, and a single token can carry complete text plus a trailing incomplete byte (byte-level tokenizers such as Qwen do this). If generation stops on such a token (max_tokens, EOS, or a stop string), StreamingDecodeState::flush appended that complete text to generated_text but returned nothing, so non-streaming clients (result.text) saw it while streaming clients never received it. This regressed against the removed safe_emit_boundary, which emitted the complete prefix eagerly. flush now returns the flushed delta (Option<String>) and is marked must_use so a caller cannot silently drop it again. Every streaming finish site forwards it as one final GenerateEvent::Token before Done: both BatchScheduler finish paths (max-tokens fast path and the finished-sweep), the speculative-burst finalize, the diffusion worker, and the XLA worker's Finished branch (after the stop-matcher tail, since the detok tail is the last output). The XLA stop-string finalize keeps dropping its post-stop tail (finish_truncated, no flush) and the max_tokens==0 fast path has nothing to flush. The CLI print paths already recover the tail via a separate decode(generated_ids) + strip_prefix and are unchanged. Tests: a ByteLevel stub whose single token decodes to "abc" plus the first byte of a 3-byte char proves flush returns the held "abc" tail when generation stops, and that the same token stream completing mid-stream stays byte-exact. The byte-fallback stream helper now folds the flush return into the streamed chunks (modeling a correct client), and the incomplete-at-end regression test asserts the U+FFFD tail is streamed exactly once as the flush delta and equals result.text. Refs #633
The parallel-tool-call token-position comment moved during the chat.rs streaming refactor and kept its bare #1170 form, which check_cross_repo_refs.py flags because a bare #NNN auto-links to lablup/mlxcel rather than upstream mlx-lm. Qualify it as ml-explore/mlx-lm#1170 so the link resolves correctly; no behavior change. Validation: - python3 scripts/ci/check_cross_repo_refs.py clean after the fix (script diffs against HEAD, verified the fixed line no longer matches the bare-ref pattern) - cargo fmt --all -- --check clean - cargo clippy --lib --tests --features metal,accelerate -- -D warnings clean - cargo test --release --features metal,accelerate --lib -- server::model_provider::model_worker::tests server::routes::chat::tests server::streaming::tests: 66 passed, 0 failed Refs #633
Summary
Reduces three host-side per-token costs on the serving path (issue #633): O(N)-per-token detokenization, three per-token mutex acquisitions in the chat streaming callback, and prompt tokenization on the scheduler thread. The dominant win is the incremental detokenizer, which turns the quadratic-with-output-length detok cost into O(window) per token while staying byte-exact.
What changed
1. Incremental detokenizer (
src/server/model_worker.rs)StreamingDecodeStatepreviously calledtokenizer.decode(&all_ids, false)over the full token history on every generated token (regular emit path and byte-fallback re-sync), which is O(N) per token and O(N^2) over a full generation, on the single generation thread. It now re-decodes only a bounded suffix window: two decodes anchored at the sameprefix_offset(the already-emitted window prefix, and that prefix plus the newest tokens), emitting the delta past the shared prefix. Since both decodes share the left context, the shared prefix cancels out and the delta is byte-identical to a whole-history decode.read_offsetonly advances to a UTF-8-complete boundary, so an incomplete multibyte sequence (byte-fallback<0xXX>runs or split byte-level pieces) is held until it completes and is never streamed as U+FFFD. Mirrors the mlx-lmNaiveStreamingDetokenizerand the llama.cpp/vLLM incremental detokenizers.A
MAX_HELD_TOKENScap bounds the window on degenerate never-completing output, and astrip_prefixguard keeps the delta slice panic-safe when a decoder retroactively rewrites already-emitted text (thetokenizersByteFallbackdecoder renders an entire byte run as U+FFFD once it holds an incomplete byte). The publicStreamingDecodeStatesurface is unchanged, so the batched, single, speculative, diffusion, XLA, and CLI decode call sites are untouched. The now-redundanttoken_piecebyte-fallback buffer,parse_byte_fallback_token, andsafe_emit_boundaryhelpers are removed; their behavior (never emit incomplete UTF-8) is preserved by the window guard and covered end-to-end by the streaming tests.2. Streaming-path diet (
src/server/routes/chat.rs)The chat streaming callback locked three separate mutexes per token (tool-call accumulator, logprobs buffer, stream filter). They are collapsed into one
StreamCallbackStatebehind a singleMutex, so the hot callback locks once per token; emitted chunks are collected and sent after the lock is released. The lock is required only for theSendbound on thespawn_blockingclosure (anArc<RefCell>would not beSend) and is never contended. The SSE wire format is byte-identical.3. Tokenize off the generation thread (
src/server/model_provider.rs,src/server/batch/scheduler.rs)Prompt tokenization moved out of
BatchScheduler::enqueue_request(scheduler thread, which stalled concurrent decode ticks for a long prompt) to the request-dispatch path.ModelProviderholds an optional pre-tokenizer and encodes the prompt insend_generate_request_with_cancellation(HTTP-side thread), passing the ids throughModelRequest::Generate. The scheduler uses them when present and falls back to tokenizing itself otherwise.tokenize_prompt_for_generationis the single source of truth for theadd_specialconvention (suppressed on a literal<bos>/<s>prefix), so both paths are byte-identical. Legacy, XLA, diffusion, and test paths keep theNonefallback.Test plan
Hardware: Apple M1 Ultra (macOS), features
metal,accelerate. Model:models/qwen2.5-0.5b-bf16(bf16, ByteLevel tokenizer,clean_up_tokenization_spaces=false).Measured here:
Detok is O(window), not O(N). Single greedy 4096-token stream, tok/s bucketed by output position (custom SSE arrival timer):
mainb73be08)The length-dependent degradation drops from 18.5% to 6.4% (the residual is GPU warmup/thermal, not detok). Tail throughput +12% (160.4 -> 179.6), overall decode +11% (163.0 -> 181.2).
scripts/bench_serving_concurrency.py --concurrency 1 --max-tokens 4096corroborates the overall figure (162.8 -> 180.9 aggregate tok/s).Byte-exact streaming. New unit tests beside the code assert the accumulated streamed text equals a one-shot
tokenizer.decodeof the final ids across Korean, mixed CJK/emoji/ASCII, regular-piece + multibyte, a non-empty prompt seam, mid-multibyte truncation, and a 300-case fuzz. All existing byte-fallback streaming regressions and theserver::routes::chat/server::streamingtests pass.cargo test --release --features metal,accelerate --lib -- server::model_provider::model_worker::tests-> 24 passed, 0 failed.Combined
server::model_provider::model_worker::tests server::routes::chat::tests server::streaming::tests-> all pass.cargo check --lib --tests --features metal,accelerate,cargo clippy --lib --tests --features metal,accelerate -- -D warnings,cargo fmt --all -- --check-> clean.Pending (not cheaply reproducible on this hardware; recorded per acceptance criteria):
scripts/bench_serving_concurrency.pyfires N identical requests and does not model the mixed case. Suggested command for a follow-up run:python3 scripts/bench_serving_concurrency.py --concurrency 8 --prompt-tokens 8192 --max-tokens 64while separately timing a short request's TTFT.Review follow-up: stream the held detokenizer tail
The incremental detokenizer holds back the whole window while its tail is an incomplete UTF-8 sequence, and a single byte-level token (Qwen-style) can carry complete text plus a trailing incomplete byte. If generation stopped on such a token,
StreamingDecodeState::flushappended that complete text togenerated_text(seen by the non-streamingresult.text) but returned nothing, so streaming clients dropped it.flushnow returns the flushed delta (Option<String>,#[must_use]) and every streaming finish site forwards it as one finalGenerateEvent::TokenbeforeDone: bothBatchSchedulerfinish paths, the speculative-burst finalize, the diffusion worker, and the XLA worker'sFinishedbranch (after the stop-matcher tail). The XLA stop-string finalize still drops its post-stop tail and the CLI print paths already recover the tail via a separatedecode(generated_ids)+strip_prefix, so both are unchanged.New/updated tests: a ByteLevel stub whose single token decodes to
abc+ the first byte of a 3-byte char provesflushreturns the heldabctail on stop and stays byte-exact when the char completes mid-stream; the byte-fallback stream helper now folds theflushreturn into the streamed chunks and the incomplete-at-end regression asserts the U+FFFD tail is streamed exactly once and equalsresult.text.cargo test --release --features metal,accelerate --lib -- server::model_provider::model_worker::tests server::routes::chat::tests server::streaming::tests-> 66 passed, 0 failed.cargo check --lib --tests --bins,cargo clippy --lib --tests -- -D warnings,cargo fmt --all -- --checkclean.Closes #633