b975716759
CW2 is a ROCm process, so it registers on the KFD like any contender. Running it as its own systemd unit made mavgpud yield llama-server to it every few seconds. The gemma-4-12b arm was down for eight minutes on 2026-08-09 and routing had silently fallen back to the resident model. So mavgpud takes an `stt` block and runs the transcriber itself. `foreign` now excludes every child rather than one pid, which is the fix. Yielding is all or nothing, because a job that wants the card wants all of it. Idle unloading stays llama-server's alone: CW2 holds 1.6GB and unloading it would only send the next voice turn to the homesrv floor. Maven still talks to the transcriber directly on 8081. There is no proxy, because with no idle timer there is nothing for one to measure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
321 lines
9.8 KiB
Go
321 lines
9.8 KiB
Go
// mavgpud — the workstation's GPU supervisor.
|
|
//
|
|
// It runs on the workstation (an AMD 7900 GRE, 16GB), not on homesrv, and it is
|
|
// deployed separately from the Maven daemons. Maven does not participate in any
|
|
// of this and never asks for a start: it reads /health through internal/llm.Pair
|
|
// and either gets the big model or falls back to the resident 1.7B.
|
|
//
|
|
// The rule, from Vikunja #488: keep llama-server loaded whenever the card is
|
|
// free, unload it when it has been idle too long or when another process needs
|
|
// the card. Not on demand, because a 7-14B takes tens of seconds to load and a
|
|
// world question would be answered by a gap every time the card had been quiet.
|
|
// Not always on, because that holds 16GB against the owner's own jobs.
|
|
//
|
|
// It supervises a second child since 09-08-2026, the CW2 transcriber, and for
|
|
// one reason only: it is a ROCm process on the same card. Any GPU service the
|
|
// owner leaves running beside this daemon reads as a contender and evicts the
|
|
// model, so the card needs one owner rather than two neighbours.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type config struct {
|
|
Listen string `json:"listen"` // what Maven talks to
|
|
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
|
LlamaBin string `json:"llama_bin"`
|
|
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
|
// through untouched so the model, context size and layer count stay the
|
|
// owner's business and not this daemon's schema.
|
|
LlamaArgs []string `json:"llama_args"`
|
|
|
|
// Stt is optional. Without it mavgpud supervises llama-server alone, which
|
|
// is everything it did before 09-08-2026.
|
|
Stt *sttConfig `json:"stt,omitempty"`
|
|
|
|
KFDRoot string `json:"kfd_root"`
|
|
DRMDevice string `json:"drm_device"`
|
|
|
|
Poll duration `json:"poll"`
|
|
IdleTimeout duration `json:"idle_timeout"`
|
|
StopGrace duration `json:"stop_grace"`
|
|
MinFreeVRAM int64 `json:"min_free_vram_bytes"`
|
|
// EvictAfter and StartAfter are counted in polls, not seconds. Both exist
|
|
// to damp flapping: a one-tick blip from a short-lived rocm process must
|
|
// not evict the model, and a card that has just been released must not be
|
|
// grabbed before the previous job has finished unmapping.
|
|
EvictAfter int `json:"evict_after_polls"`
|
|
StartAfter int `json:"start_after_polls"`
|
|
}
|
|
|
|
// sttConfig is the CW2 transcriber, which mavgpud runs for one reason: it is a
|
|
// ROCm process on this card. Left to its own systemd unit it registers on the
|
|
// KFD, the supervisor reads it as a contender, and llama-server is evicted
|
|
// within two polls and restarted five polls later, forever. That thrash was
|
|
// observed on 2026-08-09 and it is what folded the service in here.
|
|
//
|
|
// Maven talks to it directly, not through this daemon. There is no proxy and no
|
|
// idle timer: at 1.6GB it denies the card to nobody, and unloading it would only
|
|
// send the next voice turn to the homesrv floor for no gain.
|
|
type sttConfig struct {
|
|
// Addr is where the service binds, and it is read only to probe /health.
|
|
Addr string `json:"addr"`
|
|
Bin string `json:"bin"`
|
|
Args []string `json:"args"`
|
|
}
|
|
|
|
func defaults() config {
|
|
return config{
|
|
Listen: ":8080",
|
|
LlamaAddr: "127.0.0.1:8081",
|
|
KFDRoot: "/sys/class/kfd/kfd/proc",
|
|
DRMDevice: "/sys/class/drm/card1/device",
|
|
Poll: duration(time.Second),
|
|
IdleTimeout: duration(15 * time.Minute),
|
|
StopGrace: duration(20 * time.Second),
|
|
MinFreeVRAM: 15 << 30,
|
|
EvictAfter: 2,
|
|
StartAfter: 5,
|
|
}
|
|
}
|
|
|
|
// duration lets the config file say "15m" instead of counting nanoseconds.
|
|
type duration time.Duration
|
|
|
|
func (d *duration) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
v, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = duration(v)
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
path := flag.String("config", "/etc/mavgpud.json", "config file")
|
|
flag.Parse()
|
|
|
|
cfg := defaults()
|
|
b, err := os.ReadFile(*path)
|
|
if err != nil {
|
|
log.Fatalf("mavgpud: read config: %v", err)
|
|
}
|
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
log.Fatalf("mavgpud: parse config: %v", err)
|
|
}
|
|
if cfg.LlamaBin == "" {
|
|
log.Fatal("mavgpud: llama_bin is required")
|
|
}
|
|
|
|
base := "http://" + cfg.LlamaAddr
|
|
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
|
sup := &supervisor{
|
|
cfg: cfg,
|
|
probe: probe{kfdRoot: cfg.KFDRoot, drmDev: cfg.DRMDevice},
|
|
run: run,
|
|
}
|
|
if s := cfg.Stt; s != nil {
|
|
if s.Bin == "" || s.Addr == "" {
|
|
log.Fatal("mavgpud: stt needs both bin and addr")
|
|
}
|
|
sup.stt = newRunner("cw2", s.Bin, s.Args, "http://"+s.Addr+"/health")
|
|
}
|
|
sup.touch()
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
target, err := url.Parse(base)
|
|
if err != nil {
|
|
log.Fatalf("mavgpud: llama_addr: %v", err)
|
|
}
|
|
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
|
|
go func() {
|
|
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("mavgpud: listen: %v", err)
|
|
}
|
|
}()
|
|
|
|
sup.loop(ctx)
|
|
|
|
// The card must come back before we do. A supervisor that exits leaving
|
|
// llama-server holding 14GB is worse than one that never ran.
|
|
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer done()
|
|
_ = srv.Shutdown(shut)
|
|
for _, r := range sup.children() {
|
|
r.stop(time.Duration(cfg.StopGrace))
|
|
}
|
|
}
|
|
|
|
type supervisor struct {
|
|
cfg config
|
|
probe probe
|
|
run *runner
|
|
// stt is the CW2 transcriber, or nil when the config names none.
|
|
stt *runner
|
|
|
|
lastReq atomic.Int64 // unix nanos of the last request Maven sent
|
|
|
|
foreignStreak int
|
|
clearStreak int
|
|
}
|
|
|
|
func (s *supervisor) touch() { s.lastReq.Store(time.Now().UnixNano()) }
|
|
|
|
func (s *supervisor) idle() time.Duration {
|
|
return time.Since(time.Unix(0, s.lastReq.Load()))
|
|
}
|
|
|
|
// handler serves the two things the workstation exposes.
|
|
//
|
|
// /health is answered locally and always, with no GPU cost and no round trip,
|
|
// because it is the only thing Maven reads and Maven reads it on a timer
|
|
// forever. Everything else is llama-server's API, reverse-proxied. Proxying
|
|
// rather than pointing Maven straight at llama-server is what makes the idle
|
|
// window measurable: the supervisor cannot otherwise know when the model was
|
|
// last used.
|
|
func (s *supervisor) handler(target *url.URL) http.Handler {
|
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.run.isReady() {
|
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.run.isReady() {
|
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
s.touch()
|
|
proxy.ServeHTTP(w, r)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
func (s *supervisor) loop(ctx context.Context) {
|
|
t := time.NewTicker(time.Duration(s.cfg.Poll))
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
s.tick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tick is the whole decision. Yielding is checked before starting, and presence
|
|
// on the KFD is what triggers it — not a VRAM threshold. A ROCm process
|
|
// registers under /sys/class/kfd/kfd/proc when it initialises HIP, before it
|
|
// allocates, so we see a contender during its startup rather than after it has
|
|
// already failed to get the memory it wanted.
|
|
func (s *supervisor) tick(ctx context.Context) {
|
|
var pids []int
|
|
for _, r := range s.children() {
|
|
pids = append(pids, r.pid())
|
|
}
|
|
others := s.probe.foreign(pids...)
|
|
if len(others) > 0 {
|
|
s.foreignStreak++
|
|
s.clearStreak = 0
|
|
} else {
|
|
s.foreignStreak = 0
|
|
s.clearStreak++
|
|
}
|
|
|
|
// Yielding is all or nothing. A CPT run wants the whole card, and handing
|
|
// back 8GB while holding 1.6GB is the shape of a failed allocation.
|
|
if s.foreignStreak >= s.cfg.EvictAfter && s.anyRunning() {
|
|
log.Printf("mavgpud: yielding the card to %s", describe(others))
|
|
for _, r := range s.children() {
|
|
r.stop(time.Duration(s.cfg.StopGrace))
|
|
}
|
|
return
|
|
}
|
|
|
|
clear := s.clearStreak >= s.cfg.StartAfter
|
|
|
|
if s.run.running() {
|
|
s.run.refreshReady(ctx)
|
|
if s.idle() > time.Duration(s.cfg.IdleTimeout) {
|
|
log.Printf("mavgpud: idle for %s, unloading", s.idle().Round(time.Second))
|
|
s.run.stop(time.Duration(s.cfg.StopGrace))
|
|
}
|
|
} else if clear && s.probe.freeVRAM() >= s.cfg.MinFreeVRAM {
|
|
s.touch() // the idle clock starts at load, not at the last request before it
|
|
if err := s.run.start(); err != nil {
|
|
log.Printf("mavgpud: start llama-server: %v", err)
|
|
}
|
|
}
|
|
|
|
if s.stt == nil {
|
|
return
|
|
}
|
|
if s.stt.running() {
|
|
s.stt.refreshReady(ctx)
|
|
return
|
|
}
|
|
// No VRAM precondition here, unlike llama-server. That check exists because
|
|
// a 12B refuses to load when the card is short, and 1.6GB fits wherever the
|
|
// KFD is clear. Reading free VRAM would also block the transcriber for good
|
|
// once the language model was resident, since it holds more than the floor.
|
|
if clear {
|
|
if err := s.stt.start(); err != nil {
|
|
log.Printf("mavgpud: start cw2: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *supervisor) children() []*runner {
|
|
if s.stt == nil {
|
|
return []*runner{s.run}
|
|
}
|
|
return []*runner{s.run, s.stt}
|
|
}
|
|
|
|
func (s *supervisor) anyRunning() bool {
|
|
for _, r := range s.children() {
|
|
if r.running() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// describe names the contenders in the log. This log is the instrument for the
|
|
// open question in #488: whether polling the KFD misses a job that wants the
|
|
// card without registering there.
|
|
func describe(procs []gpuProc) string {
|
|
out := ""
|
|
for i, p := range procs {
|
|
if i > 0 {
|
|
out += ", "
|
|
}
|
|
out += p.Comm
|
|
}
|
|
return out
|
|
}
|