fix(xla): stop emitting the terminating EOS token as output - #968
Conversation
Real-checkpoint before/after on GB10Both measurements use the same fixture, the same binary lineage and the same Environment: GB10 / Grace-Blackwell, driver 580.159.03, CUDA 13.0, rustc 1.93.1, Prompt
After the fix the XLA CLI matches the eager CLI byte for byte and token for token, Checkpoint noteThe fixture is The originally reported symptom ( |
Every OpenXLA path handed the terminating EOS id to its consumers as if it were generated output, while the eager MLX paths drop it. On GB10 the eager CLI returns `White` (1 token) and the XLA CLI returned `White<|im_end|>` (2 tokens); the XLA server showed the same with finish_reason=stop and completion_tokens one too high. The eager BatchScheduler tests merged_eos before recording or detokenizing, so the id never reaches generated_tokens or the incremental detokenizer. Every XLA path recorded or emitted first and tested after: XlaBatchEngine::pump pushed EngineEvent::Token before computing finish_reason at both the admission and decode sites, and the four single-sequence greedy loops plus XlaReferenceEngine::generate pushed into their output vector before the EOS test. XlaServeWorker counts one generated token per Token event and detokenizes whatever it receives, so the same defect produced both the leaked text and the inflated count. Introduce commit_token, which returns both whether a sampled token is client-visible and why the sequence ended, and route both pump sites through it. Collapse the four greedy loops onto one drive_greedy_loop so the contract is stated once, and keep that loop free of the session so it is unit-testable without a device. A token that ends generation by reaching the cap is still real output: it is collected and it is still handed to the streaming callback, which is why the callback is invoked before the budget is tested rather than after. finish_reason keeps its existing precedence of EOS over Length. The decode-width metric now counts a Stop finish as the one sampled token whose id was withheld, so suppressing the id does not silently drop a decode step from /metrics. Closes #963 Refs #566, #932, #916
f8f49df to
ffa41ae
Compare
Amended before merge: a regression I introduced in the first pushReviewing my own diff, if out.len() >= max_new_tokens || !on_token(current) { break; }
Fixed by invoking the callback first, and the ordering requirement is now stated in if !on_token(current) || out.len() >= max_new_tokens { break; }To make this testable without a device I split the loop out of the session as
The real-checkpoint before/after table above was measured with the callback in the |
Summary
The OpenXLA backend handed the terminating EOS id to its consumers as if it were
generated output. The eager MLX paths drop it. That produced a visible control token
in user-facing content and a completion-token count one too high, on both the CLI
and the OpenAI-compatible server, for every model served through this backend.
Measured on GB10 with
molmo2-4b, promptAnswer with one word only. What color is snow?,-n 12:WhiteMLXCEL_BACKEND=xlaCLI, beforeWhite<|im_end|>MLXCEL_BACKEND=xlaCLI, afterWhiteWhat changed
The eager
BatchSchedulertestsmerged_eosbefore recording or detokenizing, sothe id never reaches
generated_tokensor the incremental detokenizer. Every XLApath recorded or emitted first and tested after. This makes the ordering match.
mlxcel-xla/src/batch.rs: newcommit_tokenreturns both whether a sampled tokenis client-visible and why the sequence ended, and both
XlaBatchEngine::pumpsites (admission and decode) use it.
finish_reason's existing "EOS wins overLength" precedence is untouched, and a token that ends generation by reaching the
cap is still emitted.
mlxcel-xla/src/lib.rs: the four single-sequence greedy generators now share onedrive_greedydriver that applies the same contract once, instead of four copiesof a push-then-test loop.
mlxcel-xla/src/batch.rs:XlaReferenceEngine::generatelikewise, so a referencetoken sequence is directly comparable with an eager one.
server/batch/xla_worker.rs: the decode-width metric is nowsampled_token_count, which counts aStopfinish as the one sampled token whoseid was withheld. Without this the throughput metric would silently lose one step
per sequence.
Why this matters beyond the visible symptom
Under the reduction-order contract just decided for #932, a family PR is gated on
per-operator parity plus greedy token-exactness against MLX. Before this fix
every XLA family would have failed that gate on one trailing token, regardless of
its numerics, so this lands first.
Validation
Unit tests, no device required:
commit_token_withholds_the_terminating_eos_id— suppressed and reportedStop,including as the very first (prefill) token and when it is also the cap-th token.
commit_token_emits_a_length_terminated_final_token— a non-EOS token thatexhausts the budget is still emitted and reported
Length.stop_finish_without_a_token_event_returns_empty_content_and_no_completion_tokens— a bare
Finished{Stop}yields empty content,completion_tokens=0, andfinish_reason=stop.sampled_token_count_charges_a_withheld_eos_to_the_decode_step— the metric doesnot shrink.
cargo test -p mlxcel-xla --lib: 252 passed.cargo test --release --lib --features xla-iree server::batch::xla_worker: 9 passed.cargo fmt --all --checkandgit diff --checkclean.Real checkpoint on CUDA (GB10, driver 580.159.03, CUDA 13.0, MLX pin
b7c3dd6d27f4,MLX_CUDA_ARCHITECTURES=121,MLX_ENABLE_TF32=0, release):the CLI table above, and the XLA server's non-streaming and streaming content.
Existing callers that pass an empty EOS set (
tests/xla_prepared_prefill.rs,tests/xla_gemma3n_prefill.rs) are unaffected: with no EOS id the loops emitexactly
max_new_tokenstokens and issue the same number of decode steps as before.Out of scope
SampleParamscarries no per-request stop token ids andXlaBatchEnginebuilds itsEOS set from the checkpoint config alone, so request-level
stop_token_idsare nothonoured by the XLA engine at all. That is a separate gap, noted in the issue and
not addressed here.
Closes #963
Refs #566, #932, #916