Originally created by: Akarsh-Hegde
The MLX classifier model (Qwen3.5-9B-OptiQ-4bit) holds ~7.1 GB of Metal unified memory while resident, but classification is bursty (the daemon polls every 60s and only classifies when there's pending work). The server eager-loaded the model at startup and pinned it for the whole process life — so an otherwise-idle Mac carried ~7 GB indefinitely. This is the single largest contributor to Meridian's memory footprint.
A subtlety that matters for how you verify this: ps/Activity Monitor's per-process Memory column cannot see the model — it's Metal unified memory. They undercount it by ~6.5 GB. The honest number is mx.get_active_memory().
Lazy-load the model on first request and evict it after MLX_IDLE_EVICT_S (default 120s) idle. del + gc.collect() + mx.clear_cache() reclaims the full 7.1 GB; cold reload is ~3–4s. A model_session() in-flight guard guarantees the evictor never frees the model mid-inference.
run_task_linker_mlx.py — model_session() (in-flight tracking) + maybe_evict_idle() (the reclaim) + model_resident(); cache-miss load made atomic.server.py — drop the eager startup load; background idle-evictor task; wrap all four inference paths in model_session(); /info now reports model_resident + active_memory_gb (the only honest footprint).health/mlx.rs — meridian doctor now reports resident state + GB honestly instead of claiming "model loaded" after an eviction; mlx_ready comments corrected.MLX_IDLE_EVICT_S in .env.example + CLAUDE.md.TestModelEviction.Tunable: raise MLX_IDLE_EVICT_S to keep the model warm longer; 0 pins it (old behavior).
mx.get_active_memory() vs ps) stage resident mlx_active(REAL) ps / Activity Monitor
─────────────────────────────────────────────────────────────────────────────
A server up, no request False 0.0 GB 71 MB ← lazy: nothing loaded
B POST /classify (load) True 7.1 GB 1,724 MB ← model resident
C ~30s idle → evicted False 0.0 GB 192 MB ← 7.1 GB freed
Idle footprint of the classifier: 7.1 GB → ~0. Note how ps/Activity Monitor never shows the 7.1 GB and even lags the eviction — that's why meridian doctor//info now report mx.get_active_memory().
The first classify after an eviction now blocks ~4s for the reload. Verified safe:
| Concern | Finding |
|---|---|
| Classify timeout | call_mlx_server → classification_timeout_s = 120s ≫ ~4s reload |
| Connect timeout (5s) | only covers the instant local TCP connect; load happens server-side after the request is accepted |
| Readiness gate | mlx_ready() keys on loaded_at (server-up), so an evicted model still reads "ready" → pipeline proceeds and lazy-loads — no skipped sessions, no deadlock |
py_compile)mx.get_active_memory()TestModelEviction assertions pass (in-flight tracking, TTL gating, disabled-when-0, no-op-when-in-flight, evict-when-idle)_get_model cache contract preserved (load-once + import-error path)cargo check + cargo clippy clean; full pre-push suite (fmt, clippy, cargo test, UI build/tests, security audit) green🤖 Generated with Claude Code
Originally posted by: adityaharishch
🔍 Code review — idle-evict the MLX classifier model
Reviewed the full diff plus the surrounding concurrency model (the Rust
llm_gate,mlx_ready, and every_get_model/model_sessioncall site). Strong PR — the design is correct and the memory win is real and well-evidenced. No blocking issues; findings below are one question + nits.Verified correct (the parts that matter)
model_session()bumps_in_flightunder the lock, before_get_model()runs, so even a cold load is covered;maybe_evict_idle()bails on_in_flight > 0. Confirmed all four production inference paths are wrapped (server.py:188/420/549,run_task_linker_mlx.py:748) and the only direct_get_model()is the one insidemodel_sessionitself — no unwrapped production use.acquire(blocking=False)skips a tick instead of blocking, and runs in a threadpool so it never touches the event loop.mlx_readysemantics are unchanged — the code already keyed onloaded_at(server-up), so an evicted model still reads "ready" and the pipeline lazy-reloads. The reload (~4s) sits comfortably under the 120s classify timeout. The comment-only clarification is accurate.mx.get_active_memory()+ the autouseclear_model_cachefixture isolating_model_cachebetween tests.Findings
1. (Question) The server's "no concurrent generation" invariant lives entirely outside the server.
_in_flightgates eviction, not concurrency — N threadpool workers can callmodel(...)at once. The only thing preventing that is the daemon's single globalllm_gate(one-permit semaphore, acquired by classify / worklog-synth / coding-agent-summarise). But non-gated callers bypass it: the eval harnessDirectHttpStrategy, a manualcurl, or/openai/chat/completions. Run any of those concurrently with the daemon and two generations overlap on the shared MLX model. This is pre-existing (the server never serialized), so not a regression — but since this PR newly documents the invariant inmodel_session's docstring, it's the natural moment to either (a) confirm nothing non-gated runs alongside the daemon, or (b) add a server-sideSemaphore(1)around_generateso the server is self-safe regardless of caller. You explicitly chose not to add a second serialization point — reasonable — so flagging as confirm-or-harden, your call.2. (Nit)
active_memory_gbis process-wide, not model-specific.mx.get_active_memory()returns total active Metal memory for the process; the docstring calls it "the model's footprint." Fine as a proxy (the model dominates), but slightly imprecise — e.g. it'd include any transient allocation during a load. Consider "process Metal active memory (≈ the model when resident)".3. (Nit) Very low
MLX_IDLE_EVICT_Scan thrash mid-burst. If the TTL drops below the gap between sessions within a classification burst, the model evicts + reloads (~4s) repeatedly. Default 120s is safe; a one-line caveat in the.env.example/CLAUDE.mdnote ("values below ~30s risk reload thrash between sessions") would steer tuners away from the footgun.4. (Nit)
MLX_IDLE_EVICT_S=0still spawns a no-op evictor task. The lifespan creates the task unconditionally in the non-Apple branch;_idle_evictorthen returns immediately whenttl <= 0. Harmless, butif _mlx._IDLE_EVICT_S > 0:around thecreate_taskwould avoid the throwaway task + cancel.Cleared (so they aren't re-raised)
_in_flightis incremented before_get_model(), so the evictor sees it busy.model_sessionleaks_in_flightif_get_model()raises" — false;@contextmanagerruns thefinallyas the generator unwinds, so the counter is balanced on load failure._model_cacheacross cases" — false; the autouseclear_model_cachefixture clears it before and after each test.🤖 Assisted review via Claude Code.
Originally posted by: Akarsh-Hegde
✅ Conflict resolved + review nits addressed
Pushed
c7e0844(merge ofmaininto this branch).Merge conflict
One conflict, in
services/agents/run_task_linker_mlx.py— the import block.main(via [#289]) addedimport datetime as _dt; this branch addedimport gc. Kept both, alphabetised. Verified post-merge that both symbols are still used (_dtin_local_day,gcinmaybe_evict_idle) and that no function definitions were duplicated — [#289]'s plan-focus path (_fetch_plan_focus/_local_day/_fetch_pm_tasks(focus_keys=…)) and this PR's eviction path (model_session/maybe_evict_idle/model_active_memory_gb) coexist cleanly. Everything else auto-merged.Review findings addressed
MLX_IDLE_EVICT_S=0spawned a no-op evictor task.server.pynow onlycreate_task(_idle_evictor)when_IDLE_EVICT_S > 0, with a distinct "idle-eviction disabled" log line for the0case — no throwaway task to cancel on shutdown.active_memory_gbdocstring imprecise.model_active_memory_gb()docstring now says process-wide Metal active memory (≈ the model when resident — it dominates, though a transient load allocation can briefly inflate it) rather than implying a model-specific figure..env.exampleand theCLAUDE.mdenv table: avoidMLX_IDLE_EVICT_Sbelow ~30s, since a TTL shorter than the gap between sessions in a classification burst causes repeated mid-burst evict+reload (~3s) thrash.Findings reviewed, no change needed
llm_gate, which the prior fix commit (67f7fa1) already documented inmodel_session(). Keeping a single serialization point (no second server-sideSemaphore(1)) — the deliberate choice flagged as "your call". Non-gated callers (evalDirectHttpStrategy, manualcurl) remain a pre-existing consideration, not a regression of this PR.server.py:92"statement has no effect". Already resolved by 67f7fa1, which rewrote shutdown toevictor.cancel()+await evictorundercontextlib.suppress(CancelledError). Current line is an effectfulawait.test_run_task_linker_mlx.py. The flagged lines are this PR's newTestModelEviction, which already uses the file's dominantimport agents.run_task_linker_mlx as mconvention (44 occurrences). The minorityfrom agents.run_task_linker_mlx import SessionClassificationlines are pre-existingTestSessionClassificationSchematests this PR doesn't touch — rewriting them would be out-of-scope churn, so left as-is.Validation
cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test, and UI tests all pass (pre-push suite). Python syntax verified; the eviction tests weren't modified.🤖 Changes made via Claude Code.
Related
Tickets:
#289Ticket changed by: Akarsh-Hegde
Originally posted by: adityaharishch
🎉 This PR is included in version 1.54.1 🎉
The release is available on:
v1.54.1Your semantic-release bot 📦🚀