feat: add N-gram loop detection with Gemma 4 family default-on#433
Conversation
Gemma 4 has an upstream, weights-level token-repetition collapse (google-deepmind/gemma#622, vllm-project/vllm#40080) where generation degenerates into a single repeated token or short fragment that fills the token budget, most often inside the thought channel and amplified by tool declarations or grammar-constrained decoding. Sampling penalties (repeat_penalty, DRY) cannot recover once the logits collapse, so this adds a vLLM-style N-gram repetition detector that ends a generation early when the raw generated stream collapses into a short repeated pattern. Changes: - mlxcel-core gains a generic, model-agnostic loop_detection module: LoopDetectionConfig (vLLM field names max_pattern_size / min_pattern_size / min_count, all default 0 = disabled) and the pure detect_repetition_loop helper, plus a loop_detection field on SamplingConfig that defaults to a zero-overhead no-op preserving the bit-exact baseline for every model that does not opt in. - The detector is invoked at every decode stop site (the four decode loops in generate.rs and the three scheduler decode paths) right after each token is pushed, alongside the existing eos/length checks. A new FinishReason::RepetitionLoop is added with its SequenceState transitions; the OpenAI wire finish_reason stays "stop" (an early, non-error stop, matching vLLM), and the scheduler logs an info line when it fires. - Server policy lives in the control plane, not in mlxcel-core: the Gemma 4 family (Gemma4 / Gemma4VLM / Gemma4Unified) is default-on with the built-in conservative threshold (min_pattern_size=1, max_pattern_size=20, min_count=4), with no per-user configuration and no end-user toggle. Precedence, highest first: explicit per-request fields, then the global MLXCEL_LOOP_DETECTION env override, then the Gemma 4 family default-on, then disabled. Non-Gemma models stay disabled, preserving the bit-exact baseline. - Adds the three vLLM fields to the OpenAI chat SamplingParams (per-request tune/disable, including max_pattern_size=0 to opt out) and the MLXCEL_LOOP_DETECTION operator override. Documented in docs/environment-variables.md and docs/supported-models.md. Validated with unit tests (detector boundaries, resolution precedence, family default-on), a deterministic integration test that drives the real scheduler stop logic to FinishReason::RepetitionLoop, and a real-model smoke on gemma-4-e2b-it-qat-4bit: a forced repeated tail under the family default-on ended early with finish_reason "stop" (173/300 tokens, "loop detection: ending generation early" logged), the same request with max_pattern_size=0 ran to length, and qwen3-0.6b-4bit (non-Gemma) was unaffected.
…unconditional The pr-reviewer (issue #432) flagged stale internal comments in config.rs, startup.rs, and execution/sampling.rs that described the Gemma 4 family loop-detection auto-enable as gated on the documented amplifiers (tools or a json_schema response_format). The implemented policy is unconditional engine-level default-on for the family, so plain Gemma 4 chat is covered too. Comment-only, no behavior change.
detect_repetition_loop runs once per decode step, and a per-request max_pattern_size (OpenAI chat field) or the MLXCEL_LOOP_DETECTION global-env triple both build a LoopDetectionConfig with no upper bound. With min_count=2 and a large max_pattern_size the per-step tail scan grows to O(n^2) in the generated length and O(n^3) across a long generation, so a single crafted request can pin the worker thread for the life of the generation. The Gemma 4 family default (max=20) was already safe; only the client/operator override surfaces were exposed. Cap the effective scanned pattern size at MAX_EFFECTIVE_PATTERN_SIZE (64) inside the detector, the single chokepoint every path flows through (per-request, global-env, and family-default), so the bound applies uniformly without touching each entry point. effective_min_pattern_size now clamps to the capped maximum so a huge configured max paired with a min above the cap still yields a well-formed range. Per-step work is now bounded regardless of the configured values, and patterns longer than the cap (well beyond the 1-20 token collapses seen in practice, google-deepmind/gemma#622) are simply not scanned. Adds detector tests covering the huge-max case, the cap boundary, and the min-above-cap no-panic case.
Security and performance reviewReviewed the N-gram loop-detection change (detector, server resolution, request/env override surfaces, scheduler/sequence integration, finish-reason wiring). One HIGH finding addressed; all other concerns verified safe. HIGH (fixed): unbounded effective
|
Add 7 tests for `resolve_loop_detection_env` in `startup_tests.rs`, covering all parser branches: unset returns `None`; the `off`/`0`/`none`/`false`/`disabled` keywords produce `Some(disabled())`; the `on`/`default`/`true`/`enabled` keywords produce the recommended threshold; comma-separated and colon-separated numeric triples are parsed correctly; a malformed value and a wrong-field-count value each return `None`. Also extend the "Sampling parameters" section of `docs/python-client.md` to name the vLLM-compatible loop-detection fields (`max_pattern_size` / `min_pattern_size` / `min_count`) alongside the other non-OpenAI knobs so they are discoverable through the client reference.
PR finalization completeSummaryTests added (7 new, in Documentation updated ( Lint/format:
No translations to update: Verification output
Benchmark CSVs under Commit: 39be67d |
The initial v0.3.3 cut (4d5f4fb) was never tagged, and 16 more commits landed on main afterward. Fold those fixes into the v0.3.3 CHANGELOG and debian/changelog entries and set the release date to 2026-06-25: - N-gram loop detection (#433) and Nemotron-H Nano Omni audio input (#443) - Prefill masks sized from the live window under --max-kv-size trim (#418, #420, #422, #431) - Double-transpose crash on mlx-community conv checkpoints (#429) - conv1d/conv2d and nemotron audio-encoder convs fallible at the FFI boundary (#434, #439) - Gemma 4 audio placed in the user turn (#438, #440) - mistral4 MLA backbone routing and 2D MoE token flattening (#423, #425) - Bump actions/checkout from 6 to 7 (#395)
Summary
Adds vLLM-style N-gram repetition / loop detection that ends a generation early when the raw generated token stream collapses into a short repeated pattern, and turns it on by default for the Gemma 4 family. This breaks the upstream, weights-level Gemma 4 token-repetition collapse (google-deepmind/gemma#622, vllm-project/vllm#40080) where generation degenerates into a single repeated token or short fragment that fills the token budget, most often inside the thought channel and amplified by tool declarations or grammar-constrained decoding. Sampling penalties (repeat_penalty, DRY) cannot recover once the logits collapse, so distribution shaping is not enough; this is a stop condition on the raw stream instead.
Design
The detector is generic and model-family agnostic; the activation policy lives in the server control plane.
mlxcel-coregains aloop_detectionmodule:LoopDetectionConfig(vLLM field namesmax_pattern_size/min_pattern_size/min_count, all default0= disabled) and the pure, unit-testabledetect_repetition_loop(generated, cfg). For each pattern sizepinmin_pattern_size..=max_pattern_sizeit checks whether the lastp * min_counttokens are a singlep-length block repeatedmin_counttimes. This catches single-token loops (p=1) and short multi-token loops (p=2,3,...). It runs only when enabled, so a model that does not opt in pays nothing.SamplingConfiggains aloop_detection: LoopDetectionConfigfield defaulting to a zero-overhead no-op, mirroring howtoken_bias/stop_token_idspreserve the bit-exact baseline.generate.rsand the three scheduler decode paths) right after each token is pushed togenerated_tokens, alongside the existing eos/length checks, so it also catches loops inside the reasoning channel and tool-call JSON, not just the final answer. A newFinishReason::RepetitionLoopis added with itsSequenceStatetransitions; the OpenAI wirefinish_reasonstays"stop"(an early, non-error stop, matching vLLM), and the scheduler logs an info line when it fires.Activation policy (issue "Preferred activation surface")
Default-on for the Gemma 4 family with no configuration required, implemented in
request_options::resolve_loop_detection(the server request to SamplingConfig assembly), keyed off the loaded model type resolved once at startup (ModelType::Gemma4 | Gemma4VLM | Gemma4Unified). Precedence, highest first:max_pattern_size/min_pattern_size/min_counton the OpenAI chatSamplingParams); a client may tune or disable (setmax_pattern_size=0) but never has to send anything.MLXCEL_LOOP_DETECTION(off / on /MIN,MAX,COUNT), usable on any model to force-enable, tune, or force-disable.min_pattern_size=1, max_pattern_size=20, min_count=4, unconditional (does not require tools or a structured-output request, so plain Gemma 4 chat is covered).What changed
src/lib/mlxcel-core/src/loop_detection.rs(new) +loop_detection_tests.rs(new): generic detector and config, with thorough unit tests.src/lib/mlxcel-core/src/generate.rs,lib.rs:SamplingConfig.loop_detectionfield, re-exports, and the detector call at all four decode loops.src/server/batch/sequence.rs:FinishReason::RepetitionLoop+ transition-table entries + integration-style tests that drive the real scheduler guard to the new finish reason.src/server/batch/scheduler.rs: the loop guard at the three decode paths plus an info log when it fires;RepetitionLooptreated as a healthy cache-donation finish.src/server/request_options.rs(+ tests),config.rs,startup.rs: the resolution helper, theloop_detectionglobal override (MLXCEL_LOOP_DETECTION), and themodel_is_gemma4_familystartup flag.src/server/types/request.rs,src/server/routes/chat.rs,src/server/routes/native_completion.rs,src/execution/sampling.rs,src/distributed/disaggregated/serving_protocol.rs: thread the new fields through the request and sampling-config surfaces (the disaggregated handoff keeps the disabled baseline; serializing the config across the prefill/decode split is a noted follow-up).docs/environment-variables.md,docs/supported-models.md: document the params, the default-on policy, the override surfaces, and the precedence.Test plan
cargo test -p mlxcel-core --lib loop_detection::(14 passed): detector boundaries (fires at exactlymin_count, not before; multi-token loops; clamp / normalization; disabled no-op).cargo test --lib server::request_options(12 passed): resolution precedence, family default-on without amplifier, non-Gemma disabled, per-request and global overrides.cargo test --lib server::batch::sequence(30 passed): includes a deterministic integration test that drives the real scheduler stop guard (detect_repetition_loop+transition_to) toFinishReason::RepetitionLoop.cargo check --lib --testsandcargo clippy --lib --tests -- -D warningsclean;cargo fmtapplied.models/gemma-4-e2b-it-qat-4bit(releasemlxcel-server,--jinja --parallel 1):finish_reason=stop,completion_tokens=173of 300, and the server loggedloop detection: ending generation early (repetition loop) generated=173. A normal tool-laden generation completed coherently (no regression).models/qwen3-0.6b-4bit, same prompt: detection disabled by default, ran tofinish_reason=length(300 tokens), no firing. Baseline preserved.max_pattern_size: 0: detection disabled even for the family, ran tofinish_reason=length(300 tokens), no firing. Override precedence confirmed.Closes #432