Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ has a matching GitHub release whose body comes from its section below.

### Added

- `berth unpark ENGINE` and `berth unpark --all`: drop a parked engine from RAM now instead of at its ttl, for a job
that needs the machine's RAM. Only parked engines are touched; nothing parked is no error. `berth park off|on|status`
switches RAM parking off for every card (evicting what is parked) and back on; `berth status` carries `park_off`
while it is off. The switch lives in the daemon, so a restart starts with parking on. docs/gateway.md has the section.

- `needs_mib = [18000, 6000]` on an `[[engine]]` entry with `gpu = "gpu0,gpu1"`, and `berth run --needs-mib 18000,6000`:
a size per card, in the order the cards are named, for a model split unevenly across cards (llama.cpp
`--tensor-split`). Each card's share of the lease reserves its own size, so the room left on the lighter card can
Expand Down
20 changes: 20 additions & 0 deletions docs/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,26 @@ pressure = 10 # PSI "some avg10" that stops one parked copy (default 10
`berth stop` or an eviction therefore kills it at once, so a parked engine
never stays alive holding its weights in RAM after llama-swap unloads it.

### Unpark now, or switch parking off

Parking is automatic, but you decide when RAM matters more than a warm switch:

```bash
berth unpark ninfer # drop one parked engine from RAM now (by name or family)
berth unpark --all # drop every parked engine
berth park off # stop parking: evicts what is parked, every later drain stops its engine
berth park on # parking back on
berth park # the switch, as now (--format json for scripts)
```

`berth unpark` only touches parked engines: one that is running on the card is
`berth stop`'s. Each evicted engine stops through its own wrapper, as a ttl
eviction stops it, and the next request starts it cold. Nothing parked is no
error, so a long job that needs the machine's RAM (a two-hour ComfyUI video,
say) can run `berth unpark --all` before it starts. The switch lives in the
daemon: a restart of berthd starts with parking on. `berth status --format json`
carries `park_off` while it is off.

## Engines that need the whole machine

Some engines use more than the card. Strata fills the card and pins 40 GiB of RAM for its experts, and it
Expand Down
1 change: 1 addition & 0 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ type Broker struct {

verdicts map[string]verdict // loser engine key -> the juggle it lost
cordoned bool // berth cordon: grant only holds
parkOff bool // berth park off: drains stop their engine, nothing parks
// tracked is the claim watcher's last scan: a process (and every child)
// a track claim names. Its card memory is the claim's, never a stowaway.
tracked map[int]string
Expand Down
5 changes: 5 additions & 0 deletions internal/broker/park.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ func (b *Broker) Park(id string) (bool, string) {
if !ok || l.state != draining {
return false, fmt.Sprintf("lease %s is not draining", id)
}
if b.parkOff {
why := "park refused: parking is switched off (berth park on turns it back on)"
b.emit(proto.Event{Kind: proto.EvParkRefused, Lease: id, Engine: l.engine, Detail: why})
return false, why
}
if b.snap.APU {
why := "park refused: APU VRAM/GTT offload accounting is not yet supported; using normal drain"
b.emit(proto.Event{Kind: proto.EvParkRefused, Lease: id, Engine: l.engine, Detail: why})
Expand Down
1 change: 1 addition & 0 deletions internal/broker/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func (b *Broker) Status() proto.Status {
Priority: b.priorityStatus(now),
Footprints: b.footprintStatus(),
ParkedBytes: b.parkedRAM(),
ParkOff: b.parkOff,
}
if b.probeErr != nil {
st.ProbeErr = b.probeErr.Error()
Expand Down
64 changes: 64 additions & 0 deletions internal/broker/unpark.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 Trevin Tindall (T-Crypt)
// SPDX-License-Identifier: Apache-2.0

package broker

// Unpark and the park switch: a person's own say over RAM parking. Parking
// stays automatic; berth unpark drops a parked engine from RAM now, and
// berth park off stops parking altogether (every drain stops its engine, as
// before parking existed) until berth park on. The switch lives in the
// daemon: a restart starts with parking on.

import (
"sort"
)

// Unpark evicts the parked and parking leases of engine, by its name or
// its group ("" = every one): each stops the way a ttl eviction stops it,
// through its own runner, and the RAM it held is freed. The reply names
// the leases asked; none parked is no error, so a job can unpark before it
// starts without first asking what is parked.
func (b *Broker) Unpark(engine string) []string {
b.mu.Lock()
defer b.mu.Unlock()
return b.unparkLocked(engine, "unparked by berth unpark")
}

// SetParkOff turns parking off (true) or back on (false) for this card.
// Turning it off also evicts every lease parked here now. Returns the
// leases evicted.
func (b *Broker) SetParkOff(off bool) []string {
b.mu.Lock()
defer b.mu.Unlock()
b.parkOff = off
if !off {
return nil
}
return b.unparkLocked("", "parking switched off by berth park off")
}

// ParkOff is whether parking is switched off on this card.
func (b *Broker) ParkOff() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.parkOff
}

// unparkLocked evicts the matching parked or parking leases. A parking
// lease hears the drain once its park call ends, as berth stop's does.
// Called with b.mu held.
func (b *Broker) unparkLocked(engine, why string) []string {
ids := []string{}
for _, l := range b.leases {
if l.state != parked && l.state != parking {
continue
}
if engine != "" && l.engine != engine && l.group != engine {
continue
}
b.evict(l, why)
ids = append(ids, l.id)
}
sort.Strings(ids)
return ids
}
99 changes: 99 additions & 0 deletions internal/broker/unpark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2026 Trevin Tindall (T-Crypt)
// SPDX-License-Identifier: Apache-2.0

package broker

// berth unpark and berth park off, through the lease table: unpark evicts
// only parked leases (by name or all), a running engine is never touched,
// park off refuses new parks and evicts what is parked, park on restores.

import (
"context"
"strings"
"testing"

"github.com/T-Crypt/berth/internal/gpu"
"github.com/T-Crypt/berth/internal/proto"
)

func drainReason(t *testing.T, l *Lease) string {
t.Helper()
select {
case why := <-l.Drains():
return why
default:
t.Fatal("no drain asked of the parked lease")
}
return ""
}

func TestUnparkEvictsTheNamedParkedEngine(t *testing.T) {
tree := parkTree(100, 101)
b, c, _ := parkSetup(t, tree, 32*GiB, 0)
l := warmLease(t, b, c, "ninfer", 8*GiB, 100, 101)
if got := b.Unpark("someone-else"); len(got) != 0 {
t.Fatalf("unpark of another engine = %v, want nothing", got)
}
got := b.Unpark("ninfer")
if len(got) != 1 || got[0] != l.ID() {
t.Fatalf("unpark = %v, want [%s]", got, l.ID())
}
if why := drainReason(t, l); !strings.Contains(why, "unparked by berth unpark") {
t.Fatalf("drain reason = %q", why)
}
if st := b.Status(); st.Leases[0].State != "draining" {
t.Fatalf("state = %s, want draining", st.Leases[0].State)
}
evs, _, _ := b.Events(0, false)
if evs[len(evs)-1].Kind != proto.EvParkEvicted {
t.Fatalf("last event = %+v, want park_evicted", evs[len(evs)-1])
}
}

func TestUnparkLeavesARunningEngineAlone(t *testing.T) {
tree := parkTree(100, 101)
b, c, _ := parkSetup(t, tree, 32*GiB, 0)
l := must(t, acquire(b, Request{Engine: "comfyui", Needs: 8 * GiB}))
l.Attach(100)
c.set(gpu.Proc{PID: 101, Name: "comfyui-engine", Used: 8 * GiB})
b.Tick(context.Background())
if got := b.Unpark(""); len(got) != 0 {
t.Fatalf("unpark --all = %v, want nothing (the engine is active, not parked)", got)
}
select {
case why := <-l.Drains():
t.Fatalf("active lease was asked to drain: %q", why)
default:
}
}

func TestParkOffRefusesAndEvictsAndOnRestores(t *testing.T) {
tree := parkTree(100, 101)
b, c, _ := parkSetup(t, tree, 32*GiB, 0)
l := warmLease(t, b, c, "ninfer", 8*GiB, 100, 101)
got := b.SetParkOff(true)
if len(got) != 1 || got[0] != l.ID() || !b.ParkOff() || !b.Status().ParkOff {
t.Fatalf("park off = %v (off %v)", got, b.ParkOff())
}
if why := drainReason(t, l); !strings.Contains(why, "parking switched off") {
t.Fatalf("drain reason = %q", why)
}

// A new drain while parking is off is refused a park.
tree.Parents[102], tree.Parents[103] = 103, 1
tree.Starts[102], tree.Starts[103] = 1, 1
m := must(t, acquire(b, Request{Engine: "sglang", Needs: 4 * GiB}))
m.Attach(103)
c.set(gpu.Proc{PID: 102, Name: "sglang-engine", Used: 4 * GiB})
b.Tick(context.Background())
w := acquire(b, Request{Engine: "next2", Priority: Foreground})
pending(t, w)
drained(t, m)
if ok, why := m.Park(); ok || !strings.Contains(why, "parking is switched off") {
t.Fatalf("park = %v %q, want refused while off", ok, why)
}

if got := b.SetParkOff(false); len(got) != 0 || b.ParkOff() {
t.Fatalf("park on = %v (off %v)", got, b.ParkOff())
}
}
26 changes: 26 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,32 @@ signal and skips a mismatch, so a reused PID is never signalled.`,
Formats: []Format{FormatHuman, FormatJSON, FormatYAML},
Run: runReclaim,
},
{
Name: "park",
Summary: "RAM parking: on, off, or status",
Args: "[on|off|status] [--socket P] [--format F]",
Help: `Parking is automatic: a drained engine that fits in RAM parks there, so it
comes back in seconds. berth park off switches it off for every card: it
evicts what is parked now, and every drain after it stops its engine the way
it did before parking existed. berth park on switches it back on. With no
argument, or status, it reports the switch. The switch lives in the daemon:
a restart of berthd starts with parking on.`,
Formats: []Format{FormatHuman, FormatJSON, FormatYAML},
Run: runPark,
},
{
Name: "unpark",
Summary: "drop a parked engine from RAM now",
Args: "ENGINE | --all [--socket P] [--format F]",
Help: `Evict the parked engine (by name or engine family), or every parked engine
with --all, so the RAM it holds is free now instead of at its ttl. Each one
stops through its own wrapper, as a ttl eviction stops it; the next request
for it starts it cold. An engine that is running on the card is not touched:
berth stop is for that. Nothing parked is no error, so a long job can run
berth unpark --all before it starts.`,
Formats: []Format{FormatHuman, FormatJSON, FormatYAML},
Run: runUnpark,
},
{
Name: "resume",
Summary: "put a parked engine back on the card",
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func (v statusView) Human(c *Context, ok bool) error {
if st.ParkedBytes > 0 {
c.Printf("warm parked engines hold %s of RAM\n", server.MiB(st.ParkedBytes))
}
if st.ParkOff {
c.Printf("park off: a drain stops its engine (berth park on turns parking back on)\n")
}
if c.Verbose {
hostLine(c, st.Host)
alertsLine(c, st.Alerts)
Expand Down
Loading
Loading