gpulock: GPU arbitration proxy for Ollama + ComfyUI

Implements SPEC.md: two listeners, one writer-preferring two-mode lock,
Ollama unload before image jobs, ComfyUI history polling + VRAM free,
optional model warm-up, healthz/metrics endpoints, streaming-safe
reverse proxies, Dockerfile and Gitea Actions CI.
This commit is contained in:
mram
2026-09-20 18:05:20 +02:00
commit 065c294a96
14 changed files with 2027 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
// Package lock implements the two-mode GPU arbitration lock: any number of
// concurrent LLM requests ("readers") or exactly one image job ("writer"),
// with image jobs taking priority over newly arriving LLM requests.
package lock
import (
"context"
"log/slog"
"sync"
)
// State is the current GPU occupancy state.
type State string
const (
StateIdle State = "idle"
StateLLM State = "llm"
StateImage State = "image"
)
type imageWaiter struct{ id uint64 }
// Lock is a writer-preferring two-mode lock. The zero value is not usable;
// construct with New.
type Lock struct {
mu sync.Mutex
change chan struct{} // closed and replaced on every state change
n int // LLM requests in flight
imageActive bool // an image job holds the GPU
imageQ []imageWaiter
nextID uint64
log *slog.Logger
}
// New returns a ready-to-use Lock. log may be nil; if set, every state
// transition is logged at debug level.
func New(log *slog.Logger) *Lock {
return &Lock{change: make(chan struct{}), log: log}
}
// broadcast wakes all waiters. Call with mu held.
func (l *Lock) broadcast() {
close(l.change)
l.change = make(chan struct{})
}
func (l *Lock) logTransition(msg string, args ...any) {
if l.log != nil {
l.log.Debug(msg, args...)
}
}
// AcquireLLM blocks until no image job is active or pending, then registers
// one in-flight LLM request. Returns ctx.Err() if the context is cancelled
// while waiting; no state is changed in that case.
func (l *Lock) AcquireLLM(ctx context.Context) error {
l.mu.Lock()
for l.imageActive || len(l.imageQ) > 0 {
ch := l.change
l.mu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
}
l.mu.Lock()
}
l.n++
n := l.n
l.mu.Unlock()
l.logTransition("lock transition", "state", StateLLM, "llm_inflight", n)
return nil
}
// ReleaseLLM marks one LLM request as finished.
func (l *Lock) ReleaseLLM() {
l.mu.Lock()
l.n--
n := l.n
if l.n == 0 {
l.broadcast()
}
l.mu.Unlock()
if n == 0 {
l.logTransition("lock transition", "state", StateIdle, "llm_inflight", 0)
}
}
// AcquireImage queues the caller FIFO behind other image jobs, blocks new
// LLM requests immediately, and waits for in-flight LLM requests to drain
// before granting exclusive GPU access. Returns ctx.Err() if the context is
// cancelled while waiting; the caller is removed from the queue.
func (l *Lock) AcquireImage(ctx context.Context) error {
l.mu.Lock()
l.nextID++
w := imageWaiter{id: l.nextID}
l.imageQ = append(l.imageQ, w)
pending := len(l.imageQ)
l.broadcast() // new LLM requests must block from now on
l.mu.Unlock()
l.logTransition("image job pending", "image_queue", pending)
for {
l.mu.Lock()
if l.imageQ[0].id == w.id && l.n == 0 && !l.imageActive {
l.imageQ = l.imageQ[1:]
l.imageActive = true
l.mu.Unlock()
l.logTransition("lock transition", "state", StateImage)
return nil
}
ch := l.change
l.mu.Unlock()
select {
case <-ctx.Done():
l.mu.Lock()
for i, q := range l.imageQ {
if q.id == w.id {
l.imageQ = append(l.imageQ[:i], l.imageQ[i+1:]...)
break
}
}
l.broadcast()
l.mu.Unlock()
l.logTransition("image job wait cancelled", "image_queue", len(l.imageQ))
return ctx.Err()
case <-ch:
}
}
}
// ReleaseImage frees the GPU after an image job.
func (l *Lock) ReleaseImage() {
l.mu.Lock()
l.imageActive = false
l.broadcast()
l.mu.Unlock()
l.logTransition("lock transition", "state", StateIdle)
}
// Snapshot reports the current state, the number of in-flight LLM requests,
// and whether an image job is active or waiting.
func (l *Lock) Snapshot() (state State, llmInflight int, imagePending bool) {
l.mu.Lock()
defer l.mu.Unlock()
switch {
case l.imageActive:
state = StateImage
case l.n > 0:
state = StateLLM
default:
state = StateIdle
}
return state, l.n, l.imageActive || len(l.imageQ) > 0
}
+228
View File
@@ -0,0 +1,228 @@
package lock
import (
"context"
"sync"
"testing"
"time"
)
func waitFor(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
func assertBlocked(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
t.Fatalf("%s should still be blocked", what)
case <-time.After(50 * time.Millisecond):
}
}
func TestLLMConcurrency(t *testing.T) {
lk := New(nil)
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := lk.AcquireLLM(ctx); err != nil {
t.Fatal(err)
}
}
state, n, pending := lk.Snapshot()
if state != StateLLM || n != 3 || pending {
t.Fatalf("got state=%s n=%d pending=%v", state, n, pending)
}
lk.ReleaseLLM()
lk.ReleaseLLM()
if state, _, _ := lk.Snapshot(); state != StateLLM {
t.Fatalf("state=%s, want llm", state)
}
lk.ReleaseLLM()
if state, n, _ := lk.Snapshot(); state != StateIdle || n != 0 {
t.Fatalf("got state=%s n=%d, want idle", state, n)
}
}
func TestImageWaitsForLLMDrainAndBlocksNewLLM(t *testing.T) {
lk := New(nil)
ctx := context.Background()
if err := lk.AcquireLLM(ctx); err != nil {
t.Fatal(err)
}
if err := lk.AcquireLLM(ctx); err != nil {
t.Fatal(err)
}
imageAcquired := make(chan struct{})
go func() {
if err := lk.AcquireImage(ctx); err != nil {
t.Error(err)
}
close(imageAcquired)
}()
assertBlocked(t, imageAcquired, "image acquire while LLMs in flight")
// While an image job is pending, new LLM requests must block even
// though LLM concurrency is otherwise allowed.
llmAcquired := make(chan struct{})
go func() {
if err := lk.AcquireLLM(ctx); err != nil {
t.Error(err)
}
close(llmAcquired)
}()
assertBlocked(t, llmAcquired, "LLM acquire while image pending")
lk.ReleaseLLM()
assertBlocked(t, imageAcquired, "image acquire with one LLM still in flight")
lk.ReleaseLLM()
waitFor(t, imageAcquired, "image acquire after drain")
assertBlocked(t, llmAcquired, "LLM acquire while image active")
state, _, pending := lk.Snapshot()
if state != StateImage || !pending {
t.Fatalf("got state=%s pending=%v", state, pending)
}
lk.ReleaseImage()
waitFor(t, llmAcquired, "LLM acquire after image release")
lk.ReleaseLLM()
}
func TestImageFIFO(t *testing.T) {
lk := New(nil)
ctx := context.Background()
if err := lk.AcquireImage(ctx); err != nil {
t.Fatal(err)
}
order := make(chan int, 2)
for i, id := range []int{1, 2} {
go func() {
if err := lk.AcquireImage(ctx); err != nil {
t.Error(err)
return
}
order <- id
}()
// Ensure this waiter is queued before the next one starts.
for deadline := time.Now().Add(2 * time.Second); ; {
lk.mu.Lock()
qlen := len(lk.imageQ)
lk.mu.Unlock()
if qlen == i+1 {
break
}
if time.Now().After(deadline) {
t.Fatalf("image waiter %d never queued", id)
}
time.Sleep(time.Millisecond)
}
}
lk.ReleaseImage()
if got := <-order; got != 1 {
t.Fatalf("first image job = %d, want 1", got)
}
select {
case got := <-order:
t.Fatalf("second image job %d acquired while first active", got)
case <-time.After(50 * time.Millisecond):
}
lk.ReleaseImage()
if got := <-order; got != 2 {
t.Fatalf("second image job = %d, want 2", got)
}
lk.ReleaseImage()
}
func TestContextCancelRemovesLLMWaiter(t *testing.T) {
lk := New(nil)
if err := lk.AcquireImage(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- lk.AcquireLLM(ctx) }()
time.Sleep(20 * time.Millisecond)
cancel()
if err := <-done; err == nil {
t.Fatal("expected context error")
}
lk.ReleaseImage()
if state, n, _ := lk.Snapshot(); state != StateIdle || n != 0 {
t.Fatalf("got state=%s n=%d, want idle", state, n)
}
}
func TestContextCancelRemovesImageWaiter(t *testing.T) {
lk := New(nil)
ctx := context.Background()
if err := lk.AcquireLLM(ctx); err != nil {
t.Fatal(err)
}
wctx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- lk.AcquireImage(wctx) }()
time.Sleep(20 * time.Millisecond)
cancel()
if err := <-done; err == nil {
t.Fatal("expected context error")
}
lk.ReleaseLLM()
// The cancelled waiter must be gone: a new LLM request acquires
// immediately instead of blocking behind it.
if err := lk.AcquireLLM(ctx); err != nil {
t.Fatal(err)
}
if _, _, pending := lk.Snapshot(); pending {
t.Fatal("image_pending=true after waiter was cancelled")
}
lk.ReleaseLLM()
}
// TestRace hammers the lock from many goroutines; run with -race.
func TestRace(t *testing.T) {
lk := New(nil)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 25; j++ {
if i%4 == 3 {
if err := lk.AcquireImage(context.Background()); err != nil {
t.Error(err)
return
}
lk.Snapshot()
lk.ReleaseImage()
} else {
if err := lk.AcquireLLM(context.Background()); err != nil {
t.Error(err)
return
}
lk.Snapshot()
lk.ReleaseLLM()
}
}
}(i)
}
wg.Wait()
state, n, pending := lk.Snapshot()
if state != StateIdle || n != 0 || pending {
t.Fatalf("leaked lock state: state=%s n=%d pending=%v", state, n, pending)
}
}