mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 18:38:23 -04:00
One audio.cpp model is loaded per backend process and its sessions are not reentrant, so concurrent gRPC handlers have to take turns. Serialization alone is not enough: a wedged GPU call cannot be cancelled from userspace, so an unbounded queue behind one stuck run would swallow every gRPC worker thread until the process is useless. InferenceLane gives handlers a lane with room for one runner. LaneEntry occupies it for a scope and gives it back on every exit, including an exception, and is the only way to take the lane at all: occupy/vacate are private with LaneEntry as the sole friend, so a caller cannot acquire without holding something that releases. LaneEntry is immovable on purpose, because a moved-from entry would have to stop releasing while the lane still recorded it as occupied. A caller either waits indefinitely or brings a millisecond budget. A bounded caller that cannot get in fails instead of waiting on, and a bounded caller whose budget is already shorter than the age of the run in the lane fails immediately, which is what stops a queue forming behind a wedged run. The two failures carry different text: one names the wait it exhausted, the other states the measured age of the run without claiming to know why it is long, since a short budget meeting a legitimately long run lands there too. The run's age is stamped only after acquisition. A waiter that published itself as holder would restart the measurement and hide a genuinely stuck holder from every caller behind it. Budget negotiation and the overrun decision are pure functions taking their inputs explicitly, so both are covered without threads or sleeping. The per-model ceiling arrives as an int of milliseconds; a request may tighten it and may never loosen it. Replaces the previous run_guard unit, which was a derivative of an Apache-2.0 file upstream and could not stay in an MIT tree. Written from a behaviour contract with no reference to the removed code. Tests: 65 checks, standard library only, single translation unit, clean under -Wall -Wextra. Mutation tested at 23/23 killed; two of those mutants exposed missing coverage and the tests were extended until they died. ThreadSanitizer clean. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
115 lines
4.2 KiB
C++
115 lines
4.2 KiB
C++
#include "inference_lane.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
|
|
namespace audiocpp_backend {
|
|
|
|
std::int64_t monotonic_millis() {
|
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch())
|
|
.count();
|
|
}
|
|
|
|
int resolve_wait_budget_ms(int policy_ceiling_ms, int request_hint_ms) {
|
|
// Both sides normalise to 0 for "not specified", which is also the value
|
|
// that means unbounded on the way out, so the unspecified cases fall out of
|
|
// the arithmetic instead of needing their own branches.
|
|
const int ceiling = policy_ceiling_ms > 0 ? policy_ceiling_ms : 0;
|
|
const int hint = request_hint_ms > 0 ? request_hint_ms : 0;
|
|
|
|
if (ceiling == 0) {
|
|
return hint;
|
|
}
|
|
if (hint == 0) {
|
|
return ceiling;
|
|
}
|
|
// The hint can tighten the ceiling but never loosen it.
|
|
return std::min(ceiling, hint);
|
|
}
|
|
|
|
bool run_exceeds_budget(bool lane_occupied, std::int64_t run_started_ms,
|
|
std::int64_t now_ms, int budget_ms) {
|
|
if (!lane_occupied || budget_ms <= 0) {
|
|
return false;
|
|
}
|
|
return now_ms - run_started_ms > static_cast<std::int64_t>(budget_ms);
|
|
}
|
|
|
|
namespace {
|
|
|
|
std::string busy_prefix(const std::string &model_label) {
|
|
return "inference lane for model '" + model_label + "' is busy: ";
|
|
}
|
|
|
|
// States the measurement and nothing else. A short-budget caller meeting a
|
|
// legitimately long run lands here too, so this text must not declare the run
|
|
// broken; the numbers let a reader decide that for themselves.
|
|
std::string overrun_message(const std::string &model_label,
|
|
std::int64_t run_age_ms, int budget_ms) {
|
|
return busy_prefix(model_label) + "the in-flight run has been running for " +
|
|
std::to_string(run_age_ms) + " ms, longer than this request's " +
|
|
std::to_string(budget_ms) + " ms wait budget";
|
|
}
|
|
|
|
std::string wait_exhausted_message(const std::string &model_label,
|
|
int budget_ms) {
|
|
return busy_prefix(model_label) + "timed out after " +
|
|
std::to_string(budget_ms) +
|
|
" ms waiting for the in-flight run to finish";
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void InferenceLane::occupy(int budget_ms) {
|
|
std::unique_lock<std::mutex> lock(state_mutex_);
|
|
|
|
// Checked once, on arrival: if the run already in the lane has outlived what
|
|
// this caller brought, no amount of waiting can help it, and queueing here
|
|
// is exactly how a wedged run swallows every handler thread.
|
|
const std::int64_t arrived_ms = monotonic_millis();
|
|
if (run_exceeds_budget(occupied_, run_started_ms_, arrived_ms, budget_ms)) {
|
|
throw LaneUnavailable(overrun_message(
|
|
model_label_, arrived_ms - run_started_ms_, budget_ms));
|
|
}
|
|
|
|
if (budget_ms > 0) {
|
|
if (!vacated_.wait_for(lock, std::chrono::milliseconds(budget_ms),
|
|
[this] { return !occupied_; })) {
|
|
throw LaneUnavailable(
|
|
wait_exhausted_message(model_label_, budget_ms));
|
|
}
|
|
} else {
|
|
vacated_.wait(lock, [this] { return !occupied_; });
|
|
}
|
|
|
|
// Only now, holding both the mutex and the lane. Stamping any earlier would
|
|
// restart the age of the run for every caller behind this one and make a
|
|
// genuinely wedged holder look freshly started forever.
|
|
occupied_ = true;
|
|
run_started_ms_ = monotonic_millis();
|
|
}
|
|
|
|
void InferenceLane::vacate() {
|
|
{
|
|
std::lock_guard<std::mutex> lock(state_mutex_);
|
|
occupied_ = false;
|
|
}
|
|
// notify_all, not notify_one: a waiter that times out concurrently with a
|
|
// notification can consume it, and losing the only wakeup would park the
|
|
// remaining waiters for the rest of the run's lifetime. Waking all of them
|
|
// still admits exactly one, since the rest re-test occupancy under the mutex
|
|
// and go back to waiting, and ordering among waiters is not a requirement.
|
|
vacated_.notify_all();
|
|
}
|
|
|
|
LaneEntry::LaneEntry(InferenceLane &lane, int budget_ms) : lane_(lane) {
|
|
// If this throws, the object never existed, so ~LaneEntry does not run and
|
|
// cannot hand back a lane this caller never held.
|
|
lane_.occupy(budget_ms);
|
|
}
|
|
|
|
LaneEntry::~LaneEntry() { lane_.vacate(); }
|
|
|
|
} // namespace audiocpp_backend
|