Files
llama.cpp/tools/server/server-models-sched.cpp
85cc432e2c server: add router device memory margin parameter for dynamic unloading (#21231)
* server: add --models-memory-max parameter to allow dynamically unloading models when they exceed a memory size threshold

estimate with to-be-loaded model size included

use no_alloc to get memory requirements for model load

only set model memory_mb if not previously calculated

use memory margin instead of total size limit, apply to each device separately

add server memory debug logging

move llama_context_device_memory function to llama-ext.h

fix model count exceeded check

improve memory_per_device map naming

improve variable naming, fix style

also strip models memory margin from child processes

cont : clean-up

replace device memory map with buft memory map. Use llama_get_memory_breakdown

extract duplicated check into helper function

move model memory estimation to subprocess

precompute name->buft map, map GPU host types to CPU buft

cleanup unused variable

remove duplicated init calls

* adapt to upstream changes

* wip

* common_memory_reports internface

* SERVER_ROLE_MEASURE

* server-models-sched

* fix build

* server : fix memory accounting of measure children and claim spin

A measure child runs no_alloc, so the free memory it reports does not
include its estimate: subtracting self there erased the memory held by
other processes, down to zero with --fit on, and the next load could
overshoot the margin. Subtract self only for instance reports.

A queued load only checked --models-max before claiming, so when the
memory margin blocked it behind a busy model, load() threw and the
waiter retried in a tight loop. Refuse the claim while the limits are
exceeded and no idle model can be unloaded, so the waiter sleeps on
the condition variable until one goes idle.

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
Co-authored-by: Pascal <admin@serveurperso.com>
2026-10-27 12:14:42 +01:00

594 lines
20 KiB
C++

#include "server-models-sched.h"
#include "server-models.h"
#include "fit.h"
#include <algorithm>
#include <cinttypes>
static constexpr int MEASURE_TIMEOUT = 300; // seconds, then the measure child is killed
//
// server_model_memory
//
server_model_memory::server_model_memory(uint32_t n_ctx, const std::vector<common_memory_breakdown_row> & rows) : n_ctx(n_ctx) {
for (const auto & row : rows) {
device_t d;
switch (row.kind) {
case common_memory_breakdown_row::DEVICE: d.kind = "device"; break;
case common_memory_breakdown_row::HOST: d.kind = "host"; break;
case common_memory_breakdown_row::BUFFER_TYPE: d.kind = "buffer_type"; break;
}
d.name = row.name;
d.total = row.total;
d.free = row.free;
d.model = row.model;
d.context = row.context;
d.compute = row.compute;
devices.push_back(std::move(d));
}
}
json server_model_memory::to_json() const {
json devs = json::array();
for (const auto & d : devices) {
json jd = {
{"kind", d.kind},
{"name", d.name},
{"model", d.model},
{"context", d.context},
{"compute", d.compute},
};
if (d.is_device()) {
jd["total"] = d.total;
jd["free"] = d.free;
}
devs.push_back(std::move(jd));
}
return {
{"n_ctx", n_ctx},
{"devices", devs},
};
}
server_model_memory server_model_memory::from_json(const json & j) {
server_model_memory mem;
mem.n_ctx = j.at("n_ctx").get<uint32_t>();
for (const auto & jd : j.at("devices")) {
device_t d;
d.kind = jd.at("kind").get<std::string>();
d.name = jd.at("name").get<std::string>();
d.total = json_value(jd, "total", (size_t) 0);
d.free = json_value(jd, "free", (size_t) 0);
d.model = jd.at("model").get<size_t>();
d.context = jd.at("context").get<size_t>();
d.compute = jd.at("compute").get<size_t>();
mem.devices.push_back(std::move(d));
}
return mem;
}
json server_model_memory_info::to_json() const {
return {
{"status", server_model_memory_status_to_string(status)},
{"min", min ? min->to_json() : json(nullptr)},
{"max", max ? max->to_json() : json(nullptr)},
{"effective", effective ? effective->to_json() : json(nullptr)},
};
}
//
// request queue
//
bool server_models_sched::has_capacity(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
return models.base_params.models_max <= 0
|| count_running() < (size_t) models.base_params.models_max;
}
std::string server_models_sched::pick_victim(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
std::string victim;
int64_t victim_last_used = 0;
for (const auto & m : models.mapping) {
// a busy model is mid-request, one still coming up has no request to finish
if (m.second.req_count != 0 || !m.second.meta.is_ready_or_sleep()) {
continue;
}
// already on its way out, or a queued request wants it
if (models.stopping_models.count(m.first) || find(m.first)) {
continue;
}
if (victim.empty() || m.second.meta.last_used < victim_last_used) {
victim = m.first;
victim_last_used = m.second.meta.last_used;
}
}
return victim;
}
void server_models_sched::join(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
if (entry_t * e = find(model_id)) {
e->n_waiters++;
SRV_INF("request for name=%s joined the queue, %d waiting\n", model_id.c_str(), e->n_waiters);
return;
}
queue.push_back({ model_id, 1, false });
SRV_INF("request for name=%s queued at position %zu\n",
model_id.c_str(), queue.size());
}
void server_models_sched::leave(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
for (auto it = queue.begin(); it != queue.end(); ++it) {
if (it->model_id == model_id) {
if (--it->n_waiters <= 0) {
queue.erase(it); // last one waiting for this model went away
}
return;
}
}
}
bool server_models_sched::queue_empty(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
return queue.empty();
}
bool server_models_sched::try_claim(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
if (queue.empty() || queue.front().model_id != model_id || queue.front().loading) {
return false;
}
if (!has_capacity(lk)) {
return false;
}
// over the memory margin with every running model busy: wait for one to go idle
if (memory_enabled() && limits_exceeded(lk, model_id, /* verbose */ false) && pick_victim(lk).empty()) {
return false;
}
queue.front().loading = true;
return true;
}
void server_models_sched::claim_done(std::unique_lock<std::mutex> & lk, const std::string & model_id, bool ok) {
check_lock(lk);
if (ok) {
return;
}
for (auto it = queue.begin(); it != queue.end(); ++it) {
if (it->model_id == model_id) {
it->loading = false;
return;
}
}
}
void server_models_sched::tick(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
if (models.base_params.models_max <= 0 || queue.empty()) {
return;
}
int n_running = 0;
int n_stopping = 0;
for (const auto & m : models.mapping) {
if (m.second.meta.is_running()) {
n_running++;
if (models.stopping_models.count(m.first)) {
n_stopping++;
}
}
}
int n_needed = 0;
int n_claimed = 0; // claimed the slot, but load() has not spawned yet
for (const auto & e : queue) {
if (!e.loading) {
n_needed++;
continue;
}
auto it = models.mapping.find(e.model_id);
if (it != models.mapping.end() && !it->second.meta.is_running()) {
n_claimed++;
}
}
int n_free = models.base_params.models_max - n_running + n_stopping - n_claimed;
while (n_free < n_needed) {
std::string victim = pick_victim(lk);
if (victim.empty()) {
return; // all remaining models are busy, wait for a request to end
}
SRV_INF("evicting idle LRU name=%s for a queued request\n", victim.c_str());
models.request_stop(victim);
n_free++;
}
}
server_models_sched::entry_t * server_models_sched::find(const std::string & model_id) {
for (auto & e : queue) {
if (e.model_id == model_id) {
return &e;
}
}
return nullptr;
}
void server_models_sched::check_lock(std::unique_lock<std::mutex> & lk) {
GGML_ASSERT(lk.owns_lock() && lk.mutex() == &models.mutex);
}
size_t server_models_sched::count_running() {
size_t count = 0;
for (const auto & m : models.mapping) {
if (m.second.meta.is_running()) {
count++;
}
}
return count;
}
//
// loading a model
//
bool server_models_sched::limits_exceeded(std::unique_lock<std::mutex> & lk, const std::string & name, bool verbose) {
if (!has_capacity(lk)) {
return true;
}
if (!memory_enabled()) {
return false;
}
const auto missing = deficit(name);
if (missing.empty()) {
return false;
}
bool any_running = false;
for (const auto & m : models.mapping) {
if (m.second.meta.is_running()) {
any_running = true;
break;
}
}
for (const auto & [dev, bytes] : missing) {
if (!verbose) {
break;
}
if (any_running) {
SRV_DBG("model name=%s is short by %zu MiB on %s\n", name.c_str(), bytes / (1024 * 1024), dev.c_str());
} else {
// nothing to unload: min is pessimistic, let --fit in the instance try
SRV_WRN("model name=%s is projected to be short by %zu MiB on %s with nothing else loaded, loading anyway\n",
name.c_str(), bytes / (1024 * 1024), dev.c_str());
}
}
return any_running;
}
void server_models_sched::make_room(const std::string & name) {
if (memory_enabled()) {
// the measure result decides what to unload
std::unique_lock<std::mutex> lk(models.mutex);
enqueue_measure(name, /* front */ true);
auto measured = [this, &name]() {
auto it = models.mapping.find(name);
return it == models.mapping.end() || it->second.meta.memory.is_settled();
};
if (!measured()) {
SRV_INF("waiting for the memory measurement of model name=%s\n", name.c_str());
models.cv.wait(lk, measured);
}
}
while (true) {
std::string lru_model_name;
{
std::unique_lock<std::mutex> lk(models.mutex);
if (!limits_exceeded(lk, name)) {
return;
}
lru_model_name = pick_victim(lk);
}
if (lru_model_name.empty()) {
return; // every running model is busy, load() reports the limit
}
SRV_INF("limits reached, removing LRU name=%s\n", lru_model_name.c_str());
models.unload(lru_model_name);
// wait for unload to complete
{
std::unique_lock<std::mutex> lk(models.mutex);
models.cv.wait(lk, [this, &lru_model_name]() {
return models.mapping[lru_model_name].meta.status == SERVER_MODEL_STATUS_UNLOADED;
});
}
}
}
//
// memory accounting
//
bool server_models_sched::memory_enabled() const {
return models.base_params.models_memory_margin > 0;
}
void server_models_sched::measure_unmeasured(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
for (const auto & [name, inst] : models.mapping) {
enqueue_measure(name);
}
}
void server_models_sched::on_spawned(std::unique_lock<std::mutex> & lk, const std::string & name) {
check_lock(lk);
reserve(name);
}
void server_models_sched::on_ready(std::unique_lock<std::mutex> & lk, const std::string & name, const std::optional<server_model_memory> & memory) {
check_lock(lk);
if (!memory_enabled()) {
return;
}
auto it = models.mapping.find(name);
if (it == models.mapping.end()) {
return;
}
if (!memory.has_value()) {
reserve(name); // woke up from sleep
return;
}
// use the real usage, not the estimate
auto & info = it->second.meta.memory;
info.effective = memory;
release(name);
reserve(name);
calibrate(*memory, name);
if (info.status == SERVER_MODEL_MEMORY_FAILED && info.n_attempts < 2) {
// the model was maybe not downloaded at measure time, retry
info.status = SERVER_MODEL_MEMORY_UNMEASURED;
enqueue_measure(name);
}
}
void server_models_sched::on_sleeping(std::unique_lock<std::mutex> & lk, const std::string & name) {
check_lock(lk);
release(name);
}
void server_models_sched::on_unloaded(std::unique_lock<std::mutex> & lk, const std::string & name) {
check_lock(lk);
release(name);
auto it = models.mapping.find(name);
if (it != models.mapping.end()) {
it->second.meta.memory.effective.reset();
}
}
void server_models_sched::cancel_measurements(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
measure_queue.clear();
if (measure_proc) {
SRV_INF("stopping measure process for model name=%s\n", measuring.c_str());
measure_proc->terminate();
// the monitor reaps it and clears measuring
}
}
bool server_models_sched::is_measuring(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
return !measuring.empty();
}
void server_models_sched::enqueue_measure(const std::string & name, bool front) {
if (!memory_enabled()) {
return;
}
auto it = models.mapping.find(name);
if (it == models.mapping.end() || it->second.meta.memory.status != SERVER_MODEL_MEMORY_UNMEASURED) {
return;
}
auto pos = std::find(measure_queue.begin(), measure_queue.end(), name);
if (pos != measure_queue.end()) {
if (!front) {
return; // already queued
}
measure_queue.erase(pos);
}
if (front) {
measure_queue.push_front(name);
} else {
measure_queue.push_back(name);
}
measure_next();
}
void server_models_sched::measure_next() {
if (!measuring.empty()) {
return; // one measure child at a time
}
while (!measure_queue.empty()) {
std::string name = std::move(measure_queue.front());
measure_queue.pop_front();
auto it = models.mapping.find(name);
if (it == models.mapping.end() || it->second.meta.memory.status != SERVER_MODEL_MEMORY_UNMEASURED) {
continue; // removed or measured meanwhile
}
auto & meta = it->second.meta;
// same args as the instance, --offline: never download
std::vector<std::string> child_args = meta.args;
child_args.push_back("--offline");
std::vector<std::string> child_env = models.base_env;
child_env.push_back(std::string(SERVER_ENV_ROUTER_PORT) + "=" + std::to_string(models.base_params.port));
child_env.push_back(std::string(SERVER_ENV_CHILD_ROLE) + "=" + server_role_type_to_str(SERVER_ROLE_MEASURE));
auto proc = std::make_shared<server_subproc>();
if (!proc->sproc.create(child_args, subprocess_option_no_window, child_env)) {
SRV_ERR("failed to spawn measure process for model name=%s\n", name.c_str());
meta.memory.status = SERVER_MODEL_MEMORY_FAILED;
continue;
}
SRV_INF("measuring memory for model name=%s\n", name.c_str());
meta.memory.status = SERVER_MODEL_MEMORY_MEASURING;
meta.memory.n_attempts++;
measuring = name;
measure_proc = proc;
models.watch_child(name, proc, SERVER_ROLE_MEASURE, 0, MEASURE_TIMEOUT);
return;
}
}
void server_models_sched::on_measure_exit(const std::string & name, const std::shared_ptr<server_subproc> & proc, int exit_code) {
std::lock_guard<std::mutex> lk(models.mutex);
if (measure_proc != proc) {
return; // not the child we are waiting for
}
measure_proc.reset();
measuring.clear();
auto it = models.mapping.find(name);
if (it != models.mapping.end() && it->second.meta.memory.status == SERVER_MODEL_MEMORY_MEASURING) {
// exited without a result: crashed, or killed after the timeout
SRV_WRN("measure process for model name=%s exited with status %d before reporting, memory limits will not apply to it\n",
name.c_str(), exit_code);
it->second.meta.memory.status = SERVER_MODEL_MEMORY_FAILED;
models.notify_sse("model_memory", name, it->second.meta.memory.to_json());
}
models.cv.notify_all();
measure_next();
}
void server_models_sched::handle_measured(const std::string & name, const json & payload) {
std::lock_guard<std::mutex> lk(models.mutex);
auto it = models.mapping.find(name);
if (it == models.mapping.end()) {
return;
}
auto & info = it->second.meta.memory;
const std::string result = json_value(payload, "result", std::string());
bool ok = result == "measured";
if (ok) {
try {
info.min = server_model_memory::from_json(payload.at("min"));
info.max = server_model_memory::from_json(payload.at("max"));
} catch (const std::exception & e) {
SRV_ERR("malformed memory measurement for model name=%s: %s\n", name.c_str(), e.what());
info.min.reset();
info.max.reset();
ok = false;
}
} else {
SRV_WRN("measuring memory for model name=%s failed: %s\n",
name.c_str(), json_value(payload, "error", std::string("unknown error")).c_str());
}
if (ok) {
info.status = SERVER_MODEL_MEMORY_MEASURED;
calibrate(*info.max, ""); // latest view of the devices
const auto & mn = *info.min;
const auto & mx = *info.max;
for (size_t i = 0; i < mn.devices.size() && i < mx.devices.size(); i++) {
if (!mn.devices[i].is_device() || mn.devices[i].name != mx.devices[i].name) {
continue;
}
SRV_INF("model name=%s needs on %s: %zu MiB at n_ctx=%u (min), %zu MiB at n_ctx=%u (max), %zu MiB free\n",
name.c_str(), mn.devices[i].name.c_str(),
mn.devices[i].self() / (1024 * 1024), mn.n_ctx,
mx.devices[i].self() / (1024 * 1024), mx.n_ctx,
mx.devices[i].free / (1024 * 1024));
}
} else {
info.status = SERVER_MODEL_MEMORY_FAILED;
}
models.notify_sse("model_memory", name, info.to_json());
models.cv.notify_all();
}
void server_models_sched::calibrate(const server_model_memory & mem, const std::string & reporter) {
for (const auto & d : mem.devices) {
if (!d.is_device() || d.total == 0) {
continue;
}
// other instances are tracked, so they are not external (CUDA counts them in free, Metal does not)
int64_t tracked = 0;
for (const auto & [name, devs] : held) {
if (name == reporter) {
continue;
}
auto h = devs.find(d.name);
if (h != devs.end()) {
tracked += (int64_t) h->second;
}
}
// a measure child allocates nothing, its free does not include self
const int64_t self = reporter.empty() ? 0 : (int64_t) d.self();
auto & dm = device_memory[d.name];
dm.total = (int64_t) d.total;
dm.external = std::max<int64_t>(0, dm.total - (int64_t) d.free - self - tracked);
SRV_DBG("device %s: total=%" PRId64 " MiB, external=%" PRId64 " MiB, available=%" PRId64 " MiB\n",
d.name.c_str(), dm.total / (1024 * 1024), dm.external / (1024 * 1024), available(d.name) / (1024 * 1024));
}
}
void server_models_sched::reserve(const std::string & name) {
auto it = models.mapping.find(name);
if (it == models.mapping.end() || held.count(name)) {
return;
}
const auto & info = it->second.meta.memory;
// prefer real usage over the estimate
const auto & mem = info.effective ? info.effective : info.min;
if (!mem) {
return;
}
auto & devs = held[name];
for (const auto & d : mem->devices) {
if (d.is_device() && d.total > 0) {
devs[d.name] = d.self();
}
}
}
void server_models_sched::release(const std::string & name) {
held.erase(name);
}
int64_t server_models_sched::available(const std::string & dev) const {
auto dm = device_memory.find(dev);
if (dm == device_memory.end()) {
return INT64_MAX; // never reported, nothing to check against
}
int64_t n_held = 0;
for (const auto & [name, devs] : held) {
auto h = devs.find(dev);
if (h != devs.end()) {
n_held += (int64_t) h->second;
}
}
return dm->second.total - dm->second.external - n_held;
}
std::map<std::string, size_t> server_models_sched::deficit(const std::string & name) const {
std::map<std::string, size_t> res;
auto it = models.mapping.find(name);
if (it == models.mapping.end() || !it->second.meta.memory.min) {
return res;
}
const int64_t margin = (int64_t) models.base_params.models_memory_margin * 1024 * 1024;
for (const auto & d : it->second.meta.memory.min->devices) {
// host memory is assumed unlimited, same as --fit
if (!d.is_device() || d.total == 0) {
continue;
}
if (device_memory.find(d.name) == device_memory.end()) {
continue;
}
const int64_t avail = available(d.name) - margin;
if ((int64_t) d.self() > avail) {
res[d.name] = (size_t) ((int64_t) d.self() - avail);
}
}
return res;
}