fix: normalise logits_processors row slots dropped to None by batch merge - #1799
Merged
jundot merged 2 commits intoJun 11, 2026
Merged
Conversation
…erge GenerationBatch.extend() re-creates None row slots on heterogeneous continuous-batch merges (`any([[], []])` is False), undoing the insert-side empty-list shape from jundot#1747 whenever a request without active processors joins a batch serving a grammar-constrained one. The next _step then crashes with "'NoneType' object is not iterable", and the corruption recovery re-merges the same rows, looping until the retry budget fails every in-flight request. Normalise every per-row slot at the _patched_generation_batch_step chokepoint, covering both the insert and merge origins, and pin the invariant with unit, source-level, and integration regression tests. Fixes jundot#1798
efortin
force-pushed
the
fix/logits-processors-extend-none-slots
branch
from
June 10, 2026 16:18
127f546 to
8c0b900
Compare
Owner
|
Thanks for tracking this down. I verified the root cause against the pinned mlx-lm path: empty per-row processor lists can be turned back into None during prompt-batch merging and then reach GenerationBatch._step, so normalizing per-row slots at the _step chokepoint is the right fix. The targeted non-integration tests pass locally. This looks good to me, and I am going to merge it. |
efortin
added a commit
to efortin/omlx
that referenced
this pull request
Jun 11, 2026
jundot#1799 made the generation step crash-safe by normalising None row slots, but the positional drift behind it was still there: a stale or offset slot left in samplers/logits_processors by batch extend/filter/split shifts every row after it, so a request can decode with another request's - or no - sampler and processors. Under concurrent mixed load this silently disables grammar constraints (json_schema responses come back as prose) and thinking budgets (unbounded reasoning), with no error anywhere. Record at insert time what each uid must run, and realign the positional lists from that registry at the step chokepoint - the same place as the jundot#1799 normalisation. A rate-limited warning fires whenever a drift is actually corrected, so the silent corruption becomes observable. The registry is a bounded OrderedDict so a missed cleanup can never grow it unbounded. Repro (concurrent plain + constrained request, 0.3s apart): 10/10 thinking_budget violations and 5/5 grammar violations on main; 0/15 with this fix. Solo and sequential behavior unchanged, byte-identical at temperature 0.
efortin
added a commit
to efortin/omlx
that referenced
this pull request
Jun 13, 2026
jundot#1799 made the generation step crash-safe by normalising None row slots, but the positional drift behind it was still there: a stale or offset slot left in samplers/logits_processors by batch extend/filter/split shifts every row after it, so a request can decode with another request's - or no - sampler and processors. Under concurrent mixed load this silently disables grammar constraints (json_schema responses come back as prose) and thinking budgets (unbounded reasoning), with no error anywhere. Record at insert time what each uid must run, and realign the positional lists from that registry at the step chokepoint - the same place as the jundot#1799 normalisation. A rate-limited warning fires whenever a drift is actually corrected, so the silent corruption becomes observable. The registry is a bounded OrderedDict so a missed cleanup can never grow it unbounded. Repro (concurrent plain + constrained request, 0.3s apart): 10/10 thinking_budget violations and 5/5 grammar violations on main; 0/15 with this fix. Solo and sequential behavior unchanged, byte-identical at temperature 0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
While running mixed workloads against a single model — a regular chat completion decoding next to a structured
response_format: json_schemarequest — the server reliably falls into the corruption-recovery loop and ends up killing every in-flight request withCache corruption not recoverable after retries: 'NoneType' object is not iterable. Single requests are fine, clearing caches changes nothing, and it reproduces on the first mixed wave every time. Full analysis and a standalone repro script are in #1798.It turns out this is not cache state at all. #1747 made sure we always hand
logits_processorstoBatchGenerator.insert()as per-row lists ([[]], neverNone), which fixed the single-request crash from #934. But when the scheduler merges two running batches, mlx-lm'sGenerationBatch.extend()rebuilds the row slots on its own:any([[], []])isFalse, so the empty-list shape we pass at insert time collapses right back toNoneslots whenever a batch with no active processor merges with a grammar-constrained one. The next_stepthen iteratesself.logits_processors[e]over aNoneslot and raises. Recovery clears the caches, re-prefills, re-merges the same rows… and crashes again, every ~1.5 s, until the retry budget gives up and fails everyone. That is why it presents as unrecoverable cache corruption when it is really a batch-merge shape bug.Fix
Normalise the row slots — not just the whole list — in
_patched_generation_batch_step, right before the original step runs. That is the one chokepoint both origins (insert and merge) funnel through, and it also protects our own grammar-accept loop right below, which iterates the same slots. The insert-side wrapping from #1747 stays as-is.Tests
[[]]"fix shape" joining throughextend()— crashes without this patch, passes with it (gated behindVLLM_MLX_INTEGRATION=1like the existing qwen3.6-35b-a3b and qwen3.6-27b infinite loop #934 tests)Verification
Before: a first wave of 3 streaming chats (thinking on) + 3 structured requests joining mid-decode deterministically killed 8/8 in-flight requests on the 0.4.3 bundle (6/8 on source @ e892ef4) in ~8 s, with the recovery loop visible in the log.
After: same harness, 4 rounds / 32 mixed concurrent requests — zero corruption markers in the debug log, zero request errors, every stream completes normally.
pytest tests/test_scheduler_logits_processors.py -v -m "not integration"→ 6 passed.Fixes #1798