server: replace the VRAM doorbell with a want-lock

The doorbell was edge-triggered: a waiter wrote <arena>/doorbell/<pid> and the
holder's warden turned the inotify event into a queue flag. A ring carried no
notion of "still wanted", so it was lost whenever the holder was not already warm
and listening - during its model load (the watch does not exist yet, and the
kernel does not queue events for a watch that is not there) or inside
restore_device - and any ring that survived past the point its sender had been
satisfied caused a spurious release. Lost rings wedged the waiter permanently,
because flock(LOCK_EX) never times out and, with idle-sleep disabled, the holder
had no other reason to release. The wait blocks under mutex_tasks, so the whole
server stopped answering while /health still returned 200.

Express the request as kernel state instead. A waiter holds <arena>/want.lock
shared while it waits and drops it once it owns the token; the sleep decision
probes that lock non-blocking and releases the GPU while anyone is waiting. The
probe needs its own fd - flock treats two open file descriptions of one file
independently, so probing on the waiter's fd would convert our own lock rather
than conflict with it. Nothing can be missed, nothing goes stale, and a waiter
that dies is cleaned up by the kernel.

Probe from should_sleep() on the loop thread rather than from a warden thread.
Routing it through a flag is what made the first attempts fail: start_loop()
holds mutex_tasks from should_sleep() through the callbacks to the wait, so a
warden's request_yield() blocks on that mutex and is admitted only after the flag
has been consumed, latching a release for the next wake. Reading live state where
the decision is made has no edge to latch, and drops the warden's poll latency.

Two sleep-path bugs this exposed: wait_until_no_sleep() waited on !sleeping but
the loop clears req_stop_sleeping on the way in, so a loop that slept again
before the waiter ran stranded it forever - re-ask on every wake. And a task
queued after the waiter saw us awake could not wake us by itself, so sleep now
also breaks on a non-empty queue. Hold the GPU for 100 ms after a wake: the
request that woke us is not queued yet, and yielding at once only sends it round
again.

Measured on the RX 580 pod, two servers contending, 60 alternating handoffs:
0 stranded, median 0.309 s against the doorbell's 0.314 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZz44SLQvTXMyWGio6t9DZ
This commit is contained in:
2026-09-10 23:36:23 +02:00
co-authored by Claude Opus 5
parent f9a5c231ed
commit 48f0221c98
3 changed files with 146 additions and 115 deletions
+9 -6
View File
@@ -18,7 +18,6 @@ private:
bool running = false;
bool sleeping = false;
bool req_stop_sleeping = false;
bool yield_requested = false; // set by request_yield() when another process wants the VRAM token
int64_t time_last_task = 0;
// queues
@@ -46,6 +45,7 @@ private:
std::function<bool(server_task &&, bool)> callback_new_task;
std::function<void(void)> callback_update_slots;
std::vector<std::function<void(bool)>> callback_sleeping_state;
std::function<bool()> should_yield_cb;
public:
~server_queue() { worker_stop(); }
@@ -70,11 +70,6 @@ public:
// returns immediately if not sleeping
void wait_until_no_sleep();
// request that the loop go to sleep (release VRAM) as soon as it is idle - called from the
// VRAM-arbiter warden thread when another process rings the doorbell for the GPU token.
// Thread-safe; wakes the loop so it releases promptly instead of at the next idle poll.
void request_yield();
bool is_sleeping() {
std::unique_lock<std::mutex> lock(mutex_tasks);
return sleeping;
@@ -133,6 +128,14 @@ public:
callback_update_slots = std::move(callback);
}
// Register a predicate asking the loop to sleep (release VRAM) as soon as it is idle - used by
// the VRAM arbiter to check whether another process waits for the GPU token.
// Called on the start_loop() thread while holding mutex_tasks, so it must not block or post
// tasks. While it is set, the idle wait polls faster, so the loop reacts without a notify.
void on_should_yield(std::function<bool()> callback) {
should_yield_cb = std::move(callback);
}
// Register callback for sleeping state change; multiple callbacks are allowed
// for example: register order cb0, cb1, cb2
// entering sleep: queue.sleeping = true --> cb0(true) --> cb1(true) --> cb2(true)