fix(gpu): harden multi-user GPU contention — gate /health, align Ollama, jittered desk retry (ADR-0040) - #61
Conversation
…ma, jittered desk retry (ADR-0040) Shed-not-queue is kept (it is right for an interactive desk on one 8B GPU), but the audit found three sharp edges under a crowd: 1. /health bypassed the LlmGate — it fired a real 1-token completion on every probe with no slot, adding uncontrolled concurrent GPU calls and able to steal a slot from a live request. Now it acquires a slot NON-BLOCKING (LlmGate.TryRunAsync / WaitAsync(0)): runs the probe only if a slot is free, and treats a saturated gate as healthy (busy => the model is demonstrably loaded and generating) without a completion. A genuine failure still 503s. 2. Ollama ran at defaults (OLLAMA_MAX_QUEUE=512) UNDER the app's fast-shed gate, so an admitted call could stall for minutes in a hidden deep queue. Compose now sets OLLAMA_NUM_PARALLEL=2 (both admitted slots run in parallel) and a shallow OLLAMA_MAX_QUEUE=8, so the two layers express the same policy. This is why we do NOT lean on Ollama's queue: we want no deep queue at all, and the app gate carries per-app policy Ollama can't. 3. A desk 503 dropped straight to the busy message with no resilience. Interpret now retries a bounded few times with JITTERED backoff so transient sheds self-heal and a roomful of retries desynchronize; a 429 (shared rate bucket, not the GPU) shows a distinct wait message and is never retried. Unit-tested (LlmGate.TryRunAsync: runs when free, skips-without-blocking when saturated, releases on throw; RunAsync sheds). Runtime-verified on the failure path (Ollama down => /health 503 llm_unavailable across repeated probes, proving the slot is released each time; /interpret 503; /live/feedback still 200). The real-GPU idle-probe path was not driven live because the sibling RAG stack is up and owns the GPU (isolation rule). Restructure per-item slotting and the shared browser rate bucket are documented as accepted tradeoffs.
Live real-GPU verification (deferred item in the PR body, now completed)Ran master (post-merge) against the recreated Ollama container on the freed GPU (sibling RAG down). Fix #2 — Ollama aligned: recreated container reports Fix #1 —
Shed-not-queue (the original question): with the 2 slots held, a 3rd concurrent Cold-model note: right after the container recreate, |
What
Answers "what happens when many users hit the shared GPU at once — do we have a queue?" and fixes the three sharp edges an audit surfaced. The design answer stays shed, not queue (right for an interactive desk on one 8B GPU); this PR makes the shed sound under a crowd.
Why not just use Ollama's queue?
We don't want a deep queue at all. Ollama's default
OLLAMA_MAX_QUEUE=512on one 8B GPU means an interactive "Tulkitse" click could wait minutes — a fast "busy, try again" beats a spinner that resolves in three minutes. The app-side gate also carries policy Ollama can't: cap this app at 2 concurrent so it stays a good neighbour to the sibling RAG, and degrade per-endpoint (ingest sheds and stores nothing so the client retries; the report swallows the shed and renders its deterministic layer). So the fix isn't to adopt Ollama's queue — it's to make Ollama agree with the gate.Fixes (ADR-0040)
/healthwas the one ungated LLM path — it fired a real completion on every probe with no slot, adding concurrent GPU load and able to steal a slot from a live request. NowLlmGate.TryRunAsync(non-blockingWaitAsync(0)): probe only if a slot is free; a saturated gate is healthy (busy ⇒ the model is demonstrably loaded and generating) with no completion; a real failure still503 llm_unavailable./healthconsumers readstatus == "ok"and need no change.docker-compose.yml):OLLAMA_NUM_PARALLEL=2(both admitted slots run in parallel, not serialized) + shallowOLLAMA_MAX_QUEUE=8(no hidden 512-deep backlog under the fast-shed gate). Env-overridable; safe on VRAM since this container runs only while the RAG is down.desk.html): a 503 retries a bounded few times with jittered backoff so transient sheds self-heal and a roomful of clerks desynchronize; a 429 (shared rate bucket, not the GPU) shows a distinct wait message and is never retried.Verified
LlmGateTests, 4 new; 170/170 API tests green):TryRunAsyncruns when a slot is free; returns false without blocking or running when both slots are held (proving/healthadds no GPU load); releases the slot even when work throws;RunAsyncshedsLlmBusyExceptionwhen no slot frees within the acquire timeout./health→503 {"status":"llm_unavailable"}across three repeated probes — a leaked slot would have flipped the third to200 ok, so this proves the slot is released each time;/interpret→503(the desk retry loop's trigger);/live/feedback→200(feedback stays visible with no LLM).200 okthrough the new wrapping) — the sibling RAG stack is up and owns the GPU, so the isolation rule blocks starting this project's Ollama. The completion call itself is byte-identical to before; only the slot wrapping is new, and that is unit-tested + exercised on the failure path.Accepted, not fixed (documented in ADR-0040)
POST /live/restructureholds a slot per item with no priority over live visitors — but it's operator-only (loopback-exempt, not in the public proxy allowlist).Docs
AI-first docs updated: yes — ADR-0040 (+ index row),
operations.mdcontainment table.Deploy note
OLLAMA_NUM_PARALLEL/OLLAMA_MAX_QUEUEtake effect on the nextdocker compose up -d ollama(container recreate) — a runningretail-rag-ollamakeeps its old env until then.