Skip to content

fix(gpu): harden multi-user GPU contention — gate /health, align Ollama, jittered desk retry (ADR-0040) - #61

Merged
MikkoNumminen merged 1 commit into
masterfrom
fix/gpu-contention-hardening
Jul 16, 2026
Merged

fix(gpu): harden multi-user GPU contention — gate /health, align Ollama, jittered desk retry (ADR-0040)#61
MikkoNumminen merged 1 commit into
masterfrom
fix/gpu-contention-hardening

Conversation

@MikkoNumminen

Copy link
Copy Markdown
Owner

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=512 on 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)

  1. /health was 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. Now LlmGate.TryRunAsync (non-blocking WaitAsync(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 still 503 llm_unavailable. /health consumers read status == "ok" and need no change.
  2. Ollama aligned with the gate (docker-compose.yml): OLLAMA_NUM_PARALLEL=2 (both admitted slots run in parallel, not serialized) + shallow OLLAMA_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.
  3. Desk resilience (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

  • Unit (LlmGateTests, 4 new; 170/170 API tests green): TryRunAsync runs when a slot is free; returns false without blocking or running when both slots are held (proving /health adds no GPU load); releases the slot even when work throws; RunAsync sheds LlmBusyException when no slot frees within the acquire timeout.
  • Runtime, failure path (scratch API, Ollama unreachable): /health503 {"status":"llm_unavailable"} across three repeated probes — a leaked slot would have flipped the third to 200 ok, so this proves the slot is released each time; /interpret503 (the desk retry loop's trigger); /live/feedback200 (feedback stays visible with no LLM).
  • Not driven live: the real-GPU idle probe (200 ok through 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/restructure holds a slot per item with no priority over live visitors — but it's operator-only (loopback-exempt, not in the public proxy allowlist).
  • The browser-path rate bucket is shared across visitors (one Azure egress IP); at demo scale (240/60 s ≈ 4 req/s) it's ample, and the 429 message + jittered retry are the mitigation.

Docs

AI-first docs updated: yes — ADR-0040 (+ index row), operations.md containment table.

Deploy note

OLLAMA_NUM_PARALLEL / OLLAMA_MAX_QUEUE take effect on the next docker compose up -d ollama (container recreate) — a running retail-rag-ollama keeps its old env until then.

…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.
@MikkoNumminen
MikkoNumminen merged commit a4c598d into master Jul 16, 2026
3 checks passed
@MikkoNumminen
MikkoNumminen deleted the fix/gpu-contention-hardening branch July 16, 2026 13:54
@MikkoNumminen

Copy link
Copy Markdown
Owner Author

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 OLLAMA_NUM_PARALLEL=2, OLLAMA_MAX_QUEUE=8; Poro persisted through the recreate. Two concurrent /interpret completed in 1265 ms total ≈ 1× a single call (1511 ms), not the ~3 s of serialized execution — so both admitted slots genuinely run in parallel.

Fix #1/health gated:

  • Idle: 200 {"status":"ok"} via a real 1-token probe (265 ms warm).
  • Gate saturated (both slots held by 2 in-flight interprets): /health200 {"status":"ok"} in ~60 ms — no completion run, so it adds zero GPU load and steals no slot. The latency drop (60 ms vs 265 ms) is the short-circuit doing its job.

Shed-not-queue (the original question): with the 2 slots held, a 3rd concurrent /interpret returned 503 — the fast shed the desk now retries with jittered backoff.

Cold-model note: right after the container recreate, /health returned 503 llm_unavailable until the 4.9 GB model finished its ~45 s first load — pre-existing "warming?" behavior (the 10 s HealthTimeoutSeconds is shorter than a cold load), not introduced here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant