Repository navigation
sc-12959: thread-safe-eval — eval lock, synced encoder maps, process-global default stream - #22
Merged
Merged
Conversation
…global default stream Closes the three hazards the sc-12937 thread-shared-streams patch left open, making the fork's threading model coherent: streams visible everywhere, all evaluation serialized, O(devices) streams. An audit of the inference repo confirmed both risks are real: MLX eval safety was discipline-only (!Send traits + docs saying "the worker serializes jobs per thread" — nothing enforced it, and two engine instances on two threads could race a shared command encoder into a Metal SIGABRT), and the worker's spawn_blocking-per-job contract leaked one CommandEncoder + MTLCommandQueue per dead blocking-pool thread. thread-safe-eval.patch (5th in the apply chain, required=true): 1. detail::eval_mutex() — process-global recursive lock held for eval_impl and synchronize(Stream). Every encode path funnels through these two, so concurrent cross-thread eval serializes instead of aborting. eval()'s host-side completion wait stays outside the lock; deadlock-free (completion handlers / cpu StreamThreads never take it; lock order eval -> default-stream-storage -> all_streams -> encoder-map has no reverse edge). 2. shared_mutex on the global encoder maps (metal + cpu): shared lock on the fallback read (element reference bound inside the locked scope — references survive rehash, iterators do not), exclusive lock on registration/clear. Closes the read-during-rehash UB. 3. Process-global default stream (was per-thread): stream/encoder count is O(devices), killing the thread-churn leak structurally. Safe because of (1). ThreadLocalStream keeps per-thread semantics. Two new regression tests (test_cross_thread_stream.rs, now 6): test_default_stream_shared_across_threads (red without this patch) and test_concurrent_eval_from_many_threads (8 threads x 25 evals contending on the shared default stream). Full suite: 972 passed / 0 failed (--test-threads=1); fmt + clippy clean; all 5 patches apply through a from-scratch FetchContent rebuild; adversarial review findings fixed (iterator-deref-after-unlock, comment scope, mutex lifetime hardening). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
sc-12959: thread-safe-eval — enforce the fork's threading model (eval lock, synced encoder maps, process-global default stream)
Shortcut story sc-12959 — closes the three hazards the sc-12937
thread-shared-streams.patch(PR #20) left open, making the fork's threading model coherent: streams visible everywhere, all evaluation serialized, O(devices) streams.Why now (audit evidence)
A read-only audit of
~/Repos/inferenceconfirmed both open questions the sc-12937 review raised:!Send + !Syncand the docs say "the worker serializes jobs per thread" — but nothing enforces it. Two independently-built engine instances on two threads can eval concurrently; with streams now globally visible that races a shared command encoder into a Metal SIGABRT instead of the old catchable exception.!Sendcontract forces the worker to build-and-run each MLX model inside aspawn_blockingtask; tokio blocking-pool threads die on idle, and each dead thread stranded oneCommandEncoder+MTLCommandQueuein the global map for process lifetime.The patch (
mlx-sys/patches/thread-safe-eval.patch, 5th in the apply chain,required=true)detail::eval_mutex()(recursive, leaked against static-destruction races) taken for the body ofeval_implandmlx::core::synchronize(Stream). Every encode path funnels through these two (verified:item/tostring/save/gguf →eval();async_eval→eval_impl; the mlx-c metal surface never touches encoders), so concurrent cross-thread eval now serializes instead of aborting.eval()'s host-side completion wait runs outside the lock;synchronizedrains under it by design. Verified deadlock-free: Metal completion handlers and cpu StreamThread tasks never acquire the mutex; lock ordereval → default-stream-storage → all_streams → encoder-maphas no reverse edge.shared_mutexper backend: shared lock on the fallback read inget_command_encoder(element reference bound inside the locked scope — references survive rehash, iterators don't), exclusive lock on registration and clear. Closes the read-during-rehash UB that every thread's first MLX op could trigger.default_stream_storageisstatic(wasthread_local) behind its own mutex. Stream/encoder count is now O(devices) instead of O(threads-that-ever-touched-MLX): the spawn_blocking churn leak is gone structurally. Safe because of (1).ThreadLocalStreamkeeps per-thread semantics;set_default_streamis now process-global (matches the fork's single-eval-thread contract).Tests
Two new deterministic tests in
mlx-tests/tests/test_cross_thread_stream.rs(now 6):test_default_stream_shared_across_threads— two threads see the same default stream index. Red without this patch (per-thread defaults yield distinct indices — this is the leak mechanism).test_concurrent_eval_from_many_threads— 8 threads × 25 evals contending on the shared default stream with per-thread result assertions; exercises the eval lock in the worst case (all threads on one stream).Verification
thread-safe-eval.patchalso applies standalone to pristine v0.32.0 (contexts are pure upstream) and passes the reverse-apply idempotency check.cargo test --all --no-fail-fast -- --test-threads=1: 972 passed / 0 failed.cargo fmt --all --checkandcargo clippy -- -D warningsclean.recursive_mutexis insurance (no re-entry path exists today); metalclear_streamsremains teardown-only (no callers); the cuda backend got no mutex treatment (unreachable in this Metal-only fork).🤖 Generated with Claude Code