Issue 2960 router llm pool sizing - #3399
Conversation
Design for auto-raising the Standard LLM residency pool's effective capacity to fit distinct local candidates in active routing policies, mirroring the existing RoutingHelper reconciliation pattern.
…k#2960) Task-by-task plan implementing the approved design: config off-switch, ModelType-aware residency_limit, Router-side generation-guarded floor reconciliation, Server-side policy walk wiring it in, and e2e coverage.
…t into the router
defaults.json gained llm_pool_autosize in a prior task without regenerating the docs that mirror it. Pure mechanical diff from gen_backend_boilerplate.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…source Router::llm_candidate_floor() exposes the stored llm_candidate_floor_ directly so /health reads the Router's actual applied state instead of a freshly-recomputed value that can race a policy edit. Server now caches the per-policy breakdown at each reconcile call site instead of recomputing it on every /health request, which also drops the full registry walk from the request path. Also: warn once per generation change when a raised LLM floor exceeds max_loaded_models with auto_evict disabled, since that's the only residency governor for LLM candidates on a default install. Corrects the design spec's §6 claim that VRAM pressure always backstops the floor — it only does when auto_evict is enabled, which it isn't by default. Also swaps the two reconcile call order (floor first) at both call sites: reconcile_llm_candidate_floor is a cheap lock-and-assign, while reconcile_routing_helpers can block on an in-flight load, so the cheap one shouldn't wait behind it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the config-key row to the README Settings Reference and both /internal/set deferred-key tables, documents the /health llm_pool_autosize diagnostics block in the API reference, and corrects multi-model.md's per-type LRU/max_loaded_models claims which were only true for non-LLM types before this feature. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds test_631_candidate_floor_unions_across_policies: registers a second policy sharing both local candidates with POLICY and asserts /health's candidate_floor stays 2 (the union), which would be 4 if the floor summed per-policy counts instead of unioning. Extends test_610_cloud_candidate_routing to assert its cloud candidate contributes only 1 (not 2) to its policy's local-candidate count, and test_609_autosize_off_restores_thrash to assert candidate_floor still reflects the true count while autosize is disabled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/superpowers/{specs,plans}/ held the design spec and implementation
plan used to drive this feature's development; neither belongs in the
project's own docs tree.
… canonical site router.h and server.h re-explained the LLM candidate floor concept the comment-slop pre-commit hook already treats as a repeated-explanation smell; both now point at model_residency.h instead of restating it.
…gnizes it The hook only treats a comment as a pointer (not a restatement) when it matches "see <ref>"; a bare parenthetical file name still counted as a third independent explanation of the same concept.
SlawomirNowaczyk
left a comment
There was a problem hiding this comment.
Nice work overall. A few improvement suggestions:
1. /health reads three snapshots under three different locks — the payload can be internally inconsistent. In handle_health, max_models.llm comes from get_max_model_limits() (takes load_mutex_), candidate_floor from router_->llm_candidate_floor() (re-takes load_mutex_), and policies from llm_candidate_floor_info_mutex_. A reconcile landing between these acquisitions can produce a response where max_models.llm, candidate_floor, and the per-policy sum disagree — which is precisely the "the two never disagree" invariant the comments claim to guarantee. Consider snapshotting the floor + limit once under a single load_mutex_ hold, and folding the per-policy map into the same struct so one lock order produces one coherent view.
2. Confirm get_max_model_limits() newly acquiring load_mutex_ can't deadlock. This method was previously lock-free and is now taking load_mutex_. Please double-check no caller invokes it while already holding load_mutex_ (e.g. from within another Router method under lock) — that would be a self-deadlock on a non-recursive mutex. A quick grep of the call sites in the PR description would put this to rest.
3. The catch (const std::exception&) { continue; } in active_policy_llm_candidate_floor() silently shrinks the floor. If a candidate is transiently unresolvable, it's dropped from the union with no trace — which reintroduces exactly the reload-thrash this PR fixes, but invisibly and only sometimes. A LOG(DEBUG, ...) on the swallowed candidate would make that failure diagnosable instead of a silent regression.
4. Per-policy counts aren't de-duplicated within a policy. local_llm_count increments once per candidate in route_policy->candidates with no dedup, while the union (result.models) does dedup via the set. A policy that lists the same candidate twice will report an inflated policies[...] number in /health that's larger than its true distinct-local count (and larger than its contribution to the floor). Dedup per policy (e.g. a local std::set) so the breakdown and the union use the same counting rule.
5. The auto_evict-disabled warning fires on every reconcile, not on change. reconcile_llm_candidate_floor logs the "no VRAM backstop" WARNING whenever floor > max_loaded && !auto_evict, and reconcile runs on every models-changed notification — so unrelated model edits will re-emit the same warning repeatedly. Gate it on an actual floor transition (only warn when floor changed, or when it crosses max_loaded) to avoid log spam.
6. I believe we must be much more explicit about the memory-consumption consequences — in release notes and at runtime. With llm_pool_autosize defaulting to true and auto_evict defaulting to false, max_loaded_models silently stops being a hard cap on LLM memory for anyone with a multi-candidate local policy. A user who set max_loaded_models: 1 specifically to bound GPU/VRAM will, after upgrading, find the LLM pool holding N models with no backstop — an unbounded-memory surprise from a version bump, not a config change they made. This deserves loud, layered communication rather than a single line in the PR body:
- Release notes / CHANGELOG: call this out under a dedicated "Behavior change — may increase memory use after upgrade" heading (not buried under "enhancements"). State plainly: the LLM residency pool can now exceed
max_loaded_models; to restore a hard cap setllm_pool_autosize: false; to keep autosize but bound memory setauto_evict: true. Include the exact keys so it's copy-pasteable. - Startup log (once): when the effective LLM floor is raised above
max_loaded_models, emit a single INFO at startup summarizing it — e.g.LLM pool auto-sized to N (max_loaded_models=1) to fit router policy candidates; set llm_pool_autosize=false to disable. This is distinct from the per-reconcile WARNING in suggestion #5, which should stay change-gated. - First-run-after-upgrade notice: since the risky state only exists for users upgrading into a raised floor with
auto_evictoff, consider a one-time notice keyed off a config/version marker (e.g. aconfig_versionbump or a persisted "seen" flag), so the warning fires once on the firstlemondstart after upgrade rather than every boot. Gate it on the actual dangerous combination (floor > max_loaded_models && !auto_evict) so users who don't hit it never see noise. /healthsurfacing: thellm_pool_autosizeblock already reportscandidate_floor— consider adding an explicit boolean likeexceeds_max_loaded_models(or an advisory string) so a UI/tray client can show "LLM pool is above your configured limit; no VRAM backstop" without re-deriving it. That makes the consequence visible in the product, not just the logs. Possibly even include information on how to revert to previous behavior, e.g., "set llm_pool_autosize=false to disable"
The goal is that nobody discovers this by watching VRAM climb — they should be told once, clearly, at the moment it becomes true, with the exact off-switch in hand.
…or logging - /health's candidate_floor and policies now read one cached snapshot under one lock, so they can't disagree with each other (they still read from a different source than max_models.llm, which is expected: candidate_floor must stay the true count even when autosize is off). - Log the reason when a policy candidate is excluded from the floor instead of silently shrinking it. - Dedup candidates within a single policy's count. - The no-VRAM-backstop warning only re-fires when the floor value actually changes, not on every unrelated policy reconcile.
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for the fixes. I still see a few things that should be tackled before approval:
-
Disabling autosize / lowering the floor does not actually restore the strict limit for an already populated pool.
ensure_residency_capacity()evicts exactly one model and then admits the new one. So if autosize left e.g. 3 LLMs resident and the limit becomes 1, the next admission goes 3 -> 2 -> 3 and never converges back to 1. Even more importantly, alternating between two already-loaded candidates never reaches the capacity check at all, so switchingllm_pool_autosize=falsedoes not restore the old thrashing behavior.test_609currently hides this by callingunload_all_models()after toggling the setting. Please test the real transition with both candidates already resident and enforce the reduced ceiling correctly. -
The generation guard still does not cover the
/healthsnapshot.reconcile_llm_candidate_floor()correctly rejects an older generation, but the callback unconditionally writesllm_candidate_floor_info_afterwards. An older callback can therefore lose the Router reconcile race but still overwrite a newer/healthsnapshot. That is not just a narrow temporary consistency window — it can remain stale until the next registry change. The floor + diagnostic snapshot should be published with the same generation guard. -
The warning still ignores
llm_pool_autosize. With autosize explicitly disabled,floor > max_loaded_models && !auto_evictstill logs that the "LLM pool floor raised", although that floor is not being applied. Please include the autosize state in that condition. -
I also still agree that the memory behavior change needs explicit surfacing. With autosize default-on and
auto_evictdefault-off,max_loaded_modelsis no longer a hard LLM residency cap after upgrade. A clear release-note/runtime notice with the exactllm_pool_autosize=falseescape hatch would avoid users discovering this through increased VRAM use.
Small observability nit: lemonade_max_loaded_models is documented as the configured limit, but for LLM it now reports the effective autosized limit. The metric/help text should distinguish configured vs effective capacity.
The overall approach looks good; the first two are the main correctness blockers for me.
- Router::enforce_llm_pool_capacity() evicts LRU LLM residents down to the current effective limit, bounded so it can't spin on a busy or pinned resident. Runs at the end of every floor reconcile (a policy losing candidates) and from Server::apply_config_side_effects when llm_pool_autosize or max_loaded_models changes live via /internal/set — neither of those previously reclaimed an already-populated pool, leaving it over-limit until enough future admissions evicted it one at a time. - The no-VRAM-backstop warning now also checks llm_pool_autosize, since it was firing even when the floor wasn't actually being applied. - The /health candidate-floor cache is now generation-guarded the same way Router's own copy is, since nothing serializes concurrent policy- change callbacks against each other. - lemonade_max_loaded_models' Prometheus description now says it's the effective limit, not the configured one.
|
Thanks @SlawomirNowaczyk for checking this so carefully. I fixed most of it:
About your deadlock question (point 2): I checked it again. The two places that call About your last point on telling users about the memory change: I agree, and it matters even more now . @fl0rianr's review caught that disabling the switch didn't actually reclaim memory until this next round of fixes, worth a release-note line, but that's a release-process thing rather than something for this diff. |
|
@fl0rianr thank you for the review. All four are fixed now:
About telling users (same point you and @SlawomirNowaczyk raised): agreed. And it's more important now that the first fix above is really working. The switch actually frees memory right away now. Still think release notes is the right place for that, not this PR. |
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, this is much closer now. The previous blockers are addressed, but I still see two correctness issues before approval:
-
In-flight load race: capacity is checked before the slow backend load, then
load_mutex_is released. If the floor drops orllm_pool_autosizeis disabled during that load, the new enforcer only sees already-resident models. When the load finishes, the new server is committed without re-checking the current effective limit, so the pool can immediately end up over-limit again. Please revalidate capacity after re-acquiring the lock and before adding the loaded server. -
Busy LRU can stall convergence:
enforce_llm_pool_capacity_locked()picks the LRU without excluding busy models.evict_server(..., EVICTION_TIMEOUT)can then wait 5s, fail because the model is still busy, and the loop may pick the same busy LRU again. That can block underload_mutex_for up to N×5s while idle models could have been evicted instead. The shrink path should skip/defer busy candidates and continue with other evictable residents.
Two smaller follow-ups:
max_loaded_modelsandllm_pool_autosizeare still documented as deferred/no-immediate-side-effect keys, but they now trigger immediate eviction.- The no-VRAM-backstop warning is only evaluated during floor reconcile, so live config changes like lowering
max_loaded_modelsor re-enabling autosize can enter the risky state without emitting it.
Overall the design looks solid now; the in-flight-load race is the remaining blocker for me.
…pool - Re-validate residency capacity after re-acquiring load_mutex_ post-load (mirrors the existing routing-helper re-check right above it): a policy edit or live config change landing entirely inside the window where the lock was released for the slow backend start previously went unnoticed, letting an admission push the pool over a limit that had just shrunk. - enforce_llm_pool_capacity_locked's convergence loop now prefers an idle LRU resident over a busy one, falling back to the bounded busy-wait only when every remaining resident actually is busy — previously it could spend the full EVICTION_TIMEOUT repeatedly failing to evict a busy LRU while a perfectly evictable idle resident sat right next to it. - Moved the no-backstop warning check into enforce_llm_pool_capacity_locked itself so both callers (policy-driven reconcile and a live config change via apply_config_side_effects) evaluate it, not just the former.
…lusive session apply_routing_helper_reconcile waits for a quiet load_mutex_ slot before its eviction pass, with an explicit comment that eviction isn't safe to interleave with an in-flight load. reconcile_llm_candidate_floor and enforce_llm_pool_capacity skipped that wait, so a policy or config change could evict a resident while another thread's load_model() had released the lock for its slow backend start, or while a job's exclusive session held the router. Mirrors the same wait now.
SlawomirNowaczyk
left a comment
There was a problem hiding this comment.
Previous issues are now fixed, but a few more emerged:
1. /internal/set HTTP handler can now block for up to global_timeout (600s) on an unrelated in-flight load. apply_config_side_effects() runs on the request thread (server.cpp:7124) and calls enforce_llm_pool_capacity(), which unconditionally load_cv_.wait(...)s for !is_loading_. A config set max_loaded_models=… (or llm_pool_autosize) issued while a slow model is loading will hang the client for the whole load. This is a new blocking path on the request thread — consider a bounded wait, or scheduling the enforce asynchronously and returning immediately.
2. Docs still label max_loaded_models a "Deferred key … no immediate side effect" — that's now false. getting-started.md:651 and runtime.md:177 both list it under "Deferred keys (affect the next model load or eviction decision, no immediate side effect)", and llm_pool_autosize was added to that same table. Both keys now trigger immediate eviction via apply_config_side_effects. This is fl0rianr's still-open follow-up — move them out of the deferred table (or correct the wording) so users know these take effect live and can evict.
3. Load-path re-validation only single-evicts, so full convergence depends on a second actor. The post-load ensure_residency_capacity evicts exactly one resident; if the effective limit shrank by more than one during the load, the load path alone leaves the pool over-limit. It's only correct because every limit-lowering path (reconcile_llm_candidate_floor, apply_config_side_effects) separately runs enforce_llm_pool_capacity after is_loading_ clears. That's a real invariant but it's implicit — either converge fully in the re-validation, or add a comment/assert documenting that every limit-lowering path must call enforce_llm_pool_capacity, so a future caller that forgets doesn't silently leave the pool over-limit.
4. Stale rationale comment in set_models_changed_callback. The comment still says the floor reconcile is "a cheap lock-and-assign" ordered first so it "shouldn't sit behind" the blocking helper reconcile — but after 41e0bbe the floor reconcile also load_cv_.waits and can block just as long. The ordering justification no longer holds; update it (exactly the comment-rot the repo guidelines call out).
5. All-busy case still holds load_mutex_ for up to N×EVICTION_TIMEOUT. The idle-preference fixes the common path, but when every LLM resident is busy, enforce_llm_pool_capacity_locked falls back to the busy LRU and can block ~N×5s under load_mutex_, stalling all inference. It's bounded now (no infinite loop), but consider deferring the shrink when no idle candidate exists rather than blocking on busy evicts.
6. Memory-behavior surfacing is still deferred to "release process." Both reviewers flagged that with autosize on + auto_evict off, max_loaded_models silently stops being a hard LLM cap after upgrade. This still remains to be done. At minimum the CHANGELOG/release-note line with the llm_pool_autosize=false escape hatch must land with this PR, not after — otherwise upgraders discover it by watching VRAM climb.
…M floor - apply_config_side_effects now dispatches enforce_llm_pool_capacity() on a detached thread instead of calling it inline: it can wait behind an in-flight load or exclusive session (see the previous commit), and every other /internal/set key returns immediately today. - The post-load re-validation in load_model now runs full convergence for LLM/Standard before the single-slot admission check, not just the latter — a limit that dropped by more than one slot during the load window previously only got one eviction, not full convergence. - enforce_llm_pool_capacity_locked's convergence loop no longer falls back to blocking on a busy LRU when nothing is idle; it defers instead, since it already holds load_mutex_ and blocking there stalls every other router operation, not just this one. - Fixed a stale comment claiming floor reconcile is a cheap lock-and- assign; it's blocked on load quiescence since the previous commit. - Moved max_loaded_models/llm_pool_autosize from "deferred keys" to "immediate side effect" in the /internal/set docs — they no longer are.
…al load Loads two real models, then races a third's load against a max_loaded_models drop from 3 to 1 (autosize off, isolating this from the shared POLICY fixture's own floor of 2). Confirms the pool converges all the way to one resident, not just evicts one to make room for the third — the exact gap flagged in review.
|
@SlawomirNowaczyk thanks, all six addressed:
|
|
Looks mostly good, but the async fix introduced a new problem: The detached enforce thread is a use-after-free hazard on shutdown. std::thread([this]() { router_->enforce_llm_pool_capacity(); }).detach();This captures Two smaller things also worth fixing:
|
…l enforcement - apply_config_side_effects no longer spawns a bare detached thread for the llm_pool_autosize/max_loaded_models path. It now goes through Server::request_llm_pool_enforcement(), which dispatches on the same tracked background_sync_threads_ mechanism the destructor already joins — a bare detached thread had no such coupling, so a shutdown racing a still-running enforcement call was a genuine use-after-free on router_. - request_llm_pool_enforcement() also coalesces: a call arriving while one is already running just flags a rerun instead of spawning another thread, so a burst of rapid /internal/set calls collapses into at most one extra pass. - EvictionEngine::evaluate_servers() (already ticking every 5s under load_mutex_, already a friend of Router) now also calls enforce_llm_pool_capacity_locked() on every tick, so a convergence deferred because everything was busy gets retried once something goes idle, instead of only retrying on the next explicit policy or config change (which may never come).
|
@SlawomirNowaczyk good catches, especially #.1 fixed all three:
(Sorry for the delay. Committed locally for a bit before pushing.) |
|
Thanks. And I found two more, this time minor, things worth fixing: 1. Latent lock-ordering hazard (worth a small refactor). 2. Shutdown can block for up to |
…nforcement Scope the enforce_running_/enforce_pending_ lock to release before background_sync_mutex_ is taken, so it can no longer form a cycle with the lock order used elsewhere when a worker finishes and re-locks both. Also add Router::begin_shutdown(), called from Server::stop() before the thread-join loop, so a pending reconcile/enforce call wakes and bails out instead of blocking shutdown behind a load that's still in progress. reclaim_shutdown_ itself isn't set until ~Router() runs, which happens too late for this - router_ is still a live member at that point. Covered by a new unit test exercising begin_shutdown() against a pending reconcile, plus a manual burst-then-shutdown check.
|
@SlawomirNowaczyk Thanks again, both issues are fixed now.
Covered by a new unit test, plus the full local suite (unit + CI + live server tests). Pushed in the latest commit. |
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, this is very close now. I still see two concurrency issues before approval:
-
The callback ordering reopens the stale routing-helper race.
reconcile_llm_candidate_floor()runs first and can wait for!is_loading_beforereconcile_routing_helpers()publishes the newneeded_helper_models_. That defeats the helper reconcile's explicit guarantee to publish the authoritative set immediately during an in-flight load. Floor + helper state should ideally be published together underload_mutex_, then wait once and run both cleanup passes. -
Shutdown only breaks the LLM reconcile wait, not the routing-helper reconcile wait.
begin_shutdown()makes the floor/enforcement waits exit, butapply_routing_helper_reconcile()still waits only for load/exclusive quiescence. The same callback can therefore wake from the floor reconcile during shutdown and immediately block again in helper reconcile.
Small robustness fix: wrap the background enforce_llm_pool_capacity() worker in try/catch; an exception escaping the thread entry would call std::terminate.
Everything else from the previous reviews looks addressed. These are the remaining blockers for me.
…utdown wait, guard the enforcement worker apply_routing_helper_reconcile and reconcile_llm_candidate_floor each publish under their own lock/unlock cycle, so calling them back to back for the same policy change left a gap: a load completing between the two calls would validate against a floor from the new generation but a helper set from the old one (or vice versa). Add Router::reconcile_policy_state, which publishes both under one load_mutex_ hold and waits once, and switch the two production call sites to it. apply_routing_helper_reconcile's own wait didn't check reclaim_shutdown_, so on shutdown it could still block behind an in-flight load even after begin_shutdown() fired - add the same check already used everywhere else that waits on this condition variable. Also wrap the background enforcement worker's call in try/catch; an exception escaping a thread's top-level function calls std::terminate. Verified: full unit suite (LlmCandidateFloorTest + RoutingHelperReconcileTest, 28/28) and the full cpp-ci suite (67/67), plus the live server_router.py suite and a manual burst-then-shutdown repro against a fresh instance.
Summary
Fixes #2960
The router's LLM pool shares one
max_loaded_modelsslot count with every model type, so a policy alternating between several local candidates reloads one on every switch. This raises the LLM pool's capacity to fit the active policy's local candidates, with allm_pool_autosizeoff-switch (on by default) to fall back to today's behavior.Scope
Testing
Testing details:
Full C++ CI suite passes (67/67,
ctest -L cpp-ci). Router e2e suite passes live against a built server (18/18, 1 pre-existing unrelated skip), including new tests for PID stability while alternating candidates, correct union-not-sum floor aggregation across overlapping policies, cloud-candidate exclusion, and the off-switch restoring the old reload behavior.Documentation
Breaking Changes
Worth flagging anyway:
llm_pool_autosizedefaults to on, so existing installs with a multi-candidate local router policy will use more memory after upgrading, since the pool no longer thrashes them down to one.auto_evictis off by default too, so there's no automatic VRAM backstop out of the box — a warning gets logged when that combination happens, but it's worth a release-note mention.