Name and Version
Name and Version:
- b8189
- macOS 15.5, Apple Silicon (M4 Max, 64GB unified memory)
- Operating systems: macOS
- GGML backends: Metal
This took a lot to find and repro, I do hope it's very helpful and thanks for looking into it.
Problem description & steps to reproduce:
Two related issues with router mode memory management that make --models-max unreliable on
memory-constrained systems.
Issue 1: --models-max race condition with concurrent requests
With --models-max 2 and 5 GGUF models in the directory, sending concurrent requests for different models loads 3+ models simultaneously, ignoring the limit entirely. After all requests complete, the loaded count never settles back to 2.
Root cause
In server-models.cpp, the load() method calls unload_lru() outside the mutex, then acquires the lock afterward:
void server_models::load(const std::string &name) {
// ...
unload_lru(); // ← checks count WITHOUT lock
std::lock_guard<std::mutex> lk(mutex); // ← lock acquired AFTER check
// proceeds to load without re-checking capacity
}
unload_lru() checks count_active >= models_max without synchronization. When multiple requests arrive concurrently, they all observe count_active < models_max and all proceed to load, exceeding the limit.
This is a classic TOCTOU (time-of-check-time-of-use) race.
Steps to reproduce
# 1. Place 5+ GGUF models in a directory
# 2. Start router with limit of 2
llama-server --models-dir /path/to/models --models-max 2 --port 8086 -ngl 99
# 3. Send 5 concurrent requests for different models
for model in ModelA ModelB ModelC ModelD ModelE; do
curl -s http://127.0.0.1:8086/v1/chat/completions \
-H "Content-Type: application/json" \
-d
"{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5}" & done wait
# 4. Check loaded models — expect 2, observe 3+
curl -s http://127.0.0.1:8086/v1/models | jq '.data[] | select(.status.value == "loaded") | .id'
Observed behavior
| Time after requests |
Child processes |
Peak RSS |
Models loaded |
| 5s |
3 |
15.5 GB |
3 |
| 15s |
4 |
18.2 GB |
3 |
| 60s |
4 |
12.5 GB |
3 (never drops to 2) |
Sequential requests work correctly — the limit is enforced when requests arrive one at a time.
Expected behavior
The router should serialize or gate model loads so that at most --models-max models are loaded simultaneously. Requests for unloaded models should queue when at capacity, not bypass the limit.
Suggested fix
Move unload_lru() inside the critical section, or re-check capacity after acquiring the lock:
void server_models::load(const std::string &name) {
if (!has_model(name)) {
throw std::runtime_error("model name=" + name + " is not found");
}
std::lock_guard<std::mutex> lk(mutex); // ← lock FIRST
unload_lru(); // ← then check/evict under lock
// proceed to load
}
Issue 2: -cram budget duplicated per child process, not shared
The -cram value is passed to each child process independently. With --models-max 2 -cram 15000, the intended total cache budget is 15 GB, but the actual allocation is 2 × 15 GB = 30 GB.
Visible in the /v1/models response — each model entry's status.args shows the full --cache-ram
value:
{
"id": "SomeModel-Q4_K_M",
"status": {
"value": "loaded",
"args": ["...", "--cache-ram", "15000", "..."]
}
}
This compounds with Issue 1: when 3+ models load instead of 2, each with the full cache budget, peak memory far exceeds what was intended.
Expected: -cram should either be divided across --models-max children automatically, or this behavior should be clearly documented so callers can pass cram / models_max themselves.
Combined impact
On a 64 GB Apple Silicon Mac with 5 models and --models-max 2 -cram 15000:
- Expected peak: ~6 GB (2 models + shared 15 GB cache budget)
- Actual peak: 18+ GB (3+ models loaded, each with 15 GB cache)
This causes severe memory pressure and makes router mode unreliable for its intended purpose of limiting resource usage on unified-memory systems.
Operating systems
Mac
GGML backends
Metal
Hardware
M2 Max / 32GB
Models
Any combination can be used to see the issue stated. I was working with Qwen 3 models during repro.
Problem description & steps to reproduce
Steps to reproduce
# 1. Place 5+ GGUF models in a directory
# 2. Start router with limit of 2
llama-server --models-dir /path/to/models --models-max 2 --port 8086 -ngl 99
# 3. Send 5 concurrent requests for different models
for model in ModelA ModelB ModelC ModelD ModelE; do
curl -s http://127.0.0.1:8086/v1/chat/completions \
-H "Content-Type: application/json" \
-d
"{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5}" & done wait
# 4. Check loaded models — expect 2, observe 3+
curl -s http://127.0.0.1:8086/v1/models | jq '.data[] | select(.status.value == "loaded") | .id'
First Bad Commit
No response
Relevant log output
I don't have logs but ram usage is the key issue. If you require logs please let me know.
Name and Version
Name and Version:
This took a lot to find and repro, I do hope it's very helpful and thanks for looking into it.
Problem description & steps to reproduce:
Two related issues with router mode memory management that make
--models-maxunreliable onmemory-constrained systems.
Issue 1:
--models-maxrace condition with concurrent requestsWith
--models-max 2and 5 GGUF models in the directory, sending concurrent requests for different models loads 3+ models simultaneously, ignoring the limit entirely. After all requests complete, the loaded count never settles back to 2.Root cause
In
server-models.cpp, theload()method callsunload_lru()outside the mutex, then acquires the lock afterward:unload_lru()checkscount_active >= models_maxwithout synchronization. When multiple requests arrive concurrently, they all observecount_active < models_maxand all proceed to load, exceeding the limit.This is a classic TOCTOU (time-of-check-time-of-use) race.
Steps to reproduce
Observed behavior
Sequential requests work correctly — the limit is enforced when requests arrive one at a time.
Expected behavior
The router should serialize or gate model loads so that at most
--models-maxmodels are loaded simultaneously. Requests for unloaded models should queue when at capacity, not bypass the limit.Suggested fix
Move
unload_lru()inside the critical section, or re-check capacity after acquiring the lock:Issue 2:
-crambudget duplicated per child process, not sharedThe
-cramvalue is passed to each child process independently. With--models-max 2 -cram 15000, the intended total cache budget is 15 GB, but the actual allocation is 2 × 15 GB = 30 GB.Visible in the
/v1/modelsresponse — each model entry'sstatus.argsshows the full--cache-ramvalue:
{ "id": "SomeModel-Q4_K_M", "status": { "value": "loaded", "args": ["...", "--cache-ram", "15000", "..."] } }This compounds with Issue 1: when 3+ models load instead of 2, each with the full cache budget, peak memory far exceeds what was intended.
Expected:
-cramshould either be divided across--models-maxchildren automatically, or this behavior should be clearly documented so callers can passcram / models_maxthemselves.Combined impact
On a 64 GB Apple Silicon Mac with 5 models and
--models-max 2 -cram 15000:This causes severe memory pressure and makes router mode unreliable for its intended purpose of limiting resource usage on unified-memory systems.
Operating systems
Mac
GGML backends
Metal
Hardware
M2 Max / 32GB
Models
Any combination can be used to see the issue stated. I was working with Qwen 3 models during repro.
Problem description & steps to reproduce
Steps to reproduce
First Bad Commit
No response
Relevant log output
I don't have logs but ram usage is the key issue. If you require logs please let me know.