One owner for the card, not two neighbours (V-486)

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
This commit is contained in:
2026-08-09 01:07:46 +04:00
parent 4b1edb0617
commit b975716759
5 changed files with 168 additions and 37 deletions
+90 -17
View File
@@ -10,6 +10,11 @@
// 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 (
@@ -36,6 +41,10 @@ type config struct {
// 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"`
@@ -51,6 +60,22 @@ type config struct {
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",
@@ -99,12 +124,18 @@ func main() {
}
base := "http://" + cfg.LlamaAddr
run := newRunner(cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
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)
@@ -129,13 +160,17 @@ func main() {
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
defer done()
_ = srv.Shutdown(shut)
run.stop(time.Duration(cfg.StopGrace))
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
@@ -198,7 +233,11 @@ func (s *supervisor) loop(ctx context.Context) {
// 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) {
others := s.probe.foreign(s.run.pid())
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
@@ -207,31 +246,65 @@ func (s *supervisor) tick(ctx context.Context) {
s.clearStreak++
}
if s.run.running() {
s.run.refreshReady(ctx)
switch {
case s.foreignStreak >= s.cfg.EvictAfter:
log.Printf("mavgpud: yielding the card to %s", describe(others))
s.run.stop(time.Duration(s.cfg.StopGrace))
case 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))
// 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
}
if s.clearStreak < s.cfg.StartAfter {
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 free := s.probe.freeVRAM(); free < s.cfg.MinFreeVRAM {
if s.stt.running() {
s.stt.refreshReady(ctx)
return
}
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)
// 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.