internal/autogen/gpuset.go
Rama main · réplica de lectura del repositorio original.
package autogen
// gpuset.go is the multi-GPU half of the VRAM budget: the set of adapters a
// plan may actually use, instead of the single largest card every other budget
// path used to pick.
//
// The old rule (freeVramGBFromStats, largestGPU) was not a simplification, it
// was a hard assumption: on a two-card box the second card's VRAM was invisible
// to the sizer AND absent from the emitted argv, so llama.cpp got no
// --tensor-split and did whatever its own default was with memory quartermaster
// had never counted. See issue #4.
//
// The naive fix (pool the cards into one number) is worse than the bug. With
// `-sm layer` llama.cpp splits LAYERS and their KV by the tensor-split ratio,
// but the fixed costs do not split: the logits/output buffer and the runtime
// context land on --main-gpu alone. Pool 12+16 into "28 GB", plan a 26 GB
// footprint, and the 12 GB card OOMs while the sizer reports a comfortable fit.
//
// The rule that makes a scalar budget correct again:
//
// splittable_i <= FreeGB_i - fixed_i (per device)
// ratio_i = (FreeGB_i - fixed_i) / sum_j(FreeGB_j - fixed_j)
//
// With that ratio every per-device constraint binds at the same moment, so the
// pooled budget sum(FreeGB) is exactly reachable as long as the fixed costs are
// charged as overhead, which is what the existing scalar sizer already does
// with prof.Overhead. So multi-GPU sizing needs no vector solver: hand the
// sizer the summed budget, add the extra devices' fixed cost to Overhead, and
// derive the split from the same numbers. TensorSplit is that derivation.
//
// FreeGB_i means two different things depending on who is asking, which is why
// the derivation comes in a pair. The spawn-time retune wants the live reading,
// occupancy included, so it stops sending layers to a card another model is
// sitting on. A config being GENERATED wants stable capacity instead: it is a
// long-lived artifact planned off a single cold sample, and a card that happens
// to be busy for that one sample must not bake a plan with no --tensor-split in
// it, because spawn time can retune a ratio but can never add one. Plan* are
// the generate-time twins; see splitPlanIdleFrac.
import (
"context"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/quartermaster-labs/quartermaster/internal/logmon"
"github.com/quartermaster-labs/quartermaster/internal/perf"
)
// GpuDevice is one inference-eligible adapter.
//
// Index is the adapter's telemetry ordinal (nvidia-smi / DXGI order), which is
// what the emitted --main-gpu and --tensor-split positions mean. It is NOT
// automatically the ordinal a CUDA build addresses: the CUDA runtime defaults
// to CUDA_DEVICE_ORDER=FASTEST_FIRST while nvidia-smi enumerates by PCI bus id,
// so on a mismatched pair (a 3060 beside a 4070 Ti SUPER) the two orders
// disagree and a split derived from one applied in the other silently hands the
// small card the big share. cudaOrderEnv is emitted alongside the flags to pin
// the runtime to bus order; do not emit the flags without it.
type GpuDevice struct {
Index int
Name string
TotalGB float64
FreeGB float64
// Integrated marks a device that allocates out of SYSTEM memory — an APU's
// GPU — as opposed to a card with its own VRAM. Topology, not policy: it is
// set even when the shared pool is not counted (sharedMemory: off), because
// the rule it feeds is about which devices may share a split.
Integrated bool
// SharedTotalGB is the system-memory pool the device can address as reported
// by the platform (0 when it reports none). SharedFreeGB is how much of that
// pool is COUNTED in FreeGB: zero both for a discrete card's host aperture
// and for an integrated device whose pool the settings excluded. The two
// differ exactly when shared memory was reported but not budgeted, which is
// what makes "%.1fGB of this budget is really RAM" answerable.
SharedTotalGB float64
SharedFreeGB float64
}
// GpuSet is the eligible adapters, ordered by Index.
type GpuSet []GpuDevice
// FreeGB is the pooled free VRAM across the set.
func (g GpuSet) FreeGB() float64 {
var sum float64
for _, d := range g {
sum += d.FreeGB
}
return sum
}
// TotalGB is the pooled physical VRAM across the set.
func (g GpuSet) TotalGB() float64 {
var sum float64
for _, d := range g {
sum += d.TotalGB
}
return sum
}
// Multi reports whether this set is worth splitting across at all.
func (g GpuSet) Multi() bool { return len(g) > 1 }
// MainIndex is the device that carries the non-splittable costs: the logits /
// output buffer, the runtime context, and (in llama.cpp) the KV of any layer it
// keeps. The card with the most FREE memory, not the most total: the fixed
// costs are what a busy card has least room for, and on a desktop the card
// driving the displays is routinely the larger one.
//
// Returns -1 for an empty set.
//
// This is the LIVE pick, for the spawn-time retune. A generate-time plan wants
// PlanMainIndex, which applies the same rule to the planning capacity.
func (g GpuSet) MainIndex() int { return g.mainIndexBy(GpuDevice.liveCapacityGB) }
// PlanMainIndex is MainIndex for a config being generated: the device with the
// most PLANNING capacity. A cold generate takes a single telemetry sample, and
// the card that happens to be busy for that one moment must not hand the fixed
// costs to the smaller card for the life of the config.
func (g GpuSet) PlanMainIndex() int { return g.mainIndexBy(GpuDevice.planCapacityGB) }
func (g GpuSet) mainIndexBy(capOf func(GpuDevice) float64) int {
best := -1
var bestCap float64
for _, d := range g {
c := capOf(d)
if best < 0 || c > bestCap {
best, bestCap = d.Index, c
}
}
return best
}
// splitPlanIdleFrac is the share of a card's own VRAM a GENERATE-time plan
// assumes it can reach, even when the card reads busy at the moment we sample.
//
// The idle high-water FreeGB is the right number once this process has watched
// a card sit idle, but a config is generated at startup off a single sample, so
// on a cold run the high-water IS that sample. A card occupied just then would
// bake a plan that places nothing on it, and that bake is long-lived: the config
// is regenerated only when the inputs hash changes, while the occupancy that
// produced the reading is momentary.
//
// The recovery at spawn time is one-sided. dynoffload can lower -ngl and raise
// --n-cpu-moe, and retuneTensorSplit can re-derive an existing ratio from the
// live per-device reading, but nothing can ADD a --tensor-split to an argv that
// has none: an optimistic bake is recoverable and a pessimistic one is not. So
// the plan floors each device at the share of its own VRAM a card in ordinary
// use (a desktop, a driver, someone else's app) still leaves free, and lets the
// spawn-time retune deal with whatever is actually resident.
const splitPlanIdleFrac = 0.85
// planCapacityGB is the capacity a generate-time plan budgets this device at:
// its idle high-water free reading, floored at splitPlanIdleFrac of its own
// VRAM. A floor, never a cap, so a card genuinely watched idle keeps its
// measured figure and a card sampled mid-load is not written off.
func (d GpuDevice) planCapacityGB() float64 {
floor := d.TotalGB * splitPlanIdleFrac
if d.FreeGB > floor {
return d.FreeGB
}
return floor
}
// liveCapacityGB is the capacity a spawn-time retune plans against: what the
// card reports free right now, with whatever is resident on it counted.
func (d GpuDevice) liveCapacityGB() float64 { return d.FreeGB }
// perDeviceFixedGB is the runtime context each ADDITIONAL device costs. It is
// the same per-device constant computeBufferGB charges the main device, so it
// tracks whichever backend is in use (runtimeCtxGB).
//
// It used to return 0 on anything but CUDA, on the reasoning that the constant
// was a CUDA-runtime figure that would not transfer. Measurement said otherwise:
// the Vulkan/ROCm runtime reserves MORE than CUDA does, not nothing, so a
// non-CUDA two-card box was silently handed a whole extra device's runtime as
// free budget. The main-device charge was corrected in #24; this is the same
// constant seen from the extra devices' side.
func perDeviceFixedGB() float64 { return runtimeCtxGB() }
// ExtraDeviceOverheadGB is what the SIZER must add to a profile's overhead
// before budgeting against FreeGB(): every device past the main one pays its
// own runtime context, and none of it is splittable.
func (g GpuSet) ExtraDeviceOverheadGB() float64 {
if len(g) < 2 {
return 0
}
return float64(len(g)-1) * perDeviceFixedGB()
}
// TensorSplit is the --tensor-split ratio, one entry per device in Index order.
//
// mainFixedGB is the non-splittable footprint the MAIN device carries: the
// compute buffer, the runtime context, the spec/draft overhead and the
// projector. Each other device is charged perDeviceFixedGB here, so mainFixedGB
// must NOT include ExtraDeviceOverheadGB: that term is the other devices'
// runtime seen from the pooled budget's side, and passing it in bills the same
// bytes to both cards, shifting layers off the main GPU onto one with no room.
//
// The remainder is what layers and KV may occupy, and the ratio is that
// remainder normalised, which is precisely the ratio under which the pooled
// budget is reachable without any single card going over.
//
// A device with no room left after its fixed cost gets 0, and llama.cpp will
// place nothing on it. Returns nil for a set that isn't worth splitting or when
// no device has room, so the caller emits no flags and the single-GPU path
// stands.
func (g GpuSet) TensorSplit(mainFixedGB float64) []float64 {
if len(g) < 2 {
return nil
}
return g.splitBy(GpuDevice.liveCapacityGB, g.MainIndex(), mainFixedGB)
}
// PlanTensorSplit is the ratio a GENERATED config carries: the same derivation
// over the planning capacity instead of the live reading, and, for a set worth
// splitting at all, never nil.
//
// The nil is the point. The ratio itself barely matters, because the spawn-time
// retune re-derives it from live telemetry on every load; what cannot be
// recovered is the split's EXISTENCE, since retuneTensorSplit rewrites a baked
// --tensor-split and has no way to add one. A generate pass that caught a card
// mid-load used to bake a single-device plan that the runtime then outgrew
// silently: dynoffload would raise the offload to the pooled budget while the
// argv still carried no placement instruction at all, leaving llama.cpp to
// split by its own default and put more on a card than the sizer ever counted.
// See issue #4.
func (g GpuSet) PlanTensorSplit(mainFixedGB float64) []float64 {
if len(g) < 2 {
return nil
}
if split := g.splitBy(GpuDevice.planCapacityGB, g.PlanMainIndex(), mainFixedGB); split != nil {
return split
}
// Every device swallowed whole by its own fixed cost, which means the plan
// does not fit on this box in any arrangement. Fall back to the physical
// ratio (llama.cpp's own default placement) rather than to no split: the
// flags have to exist for the retune to have something to rewrite.
total := g.TotalGB()
if total <= 0 {
return nil
}
out := make([]float64, len(g))
for i, d := range g {
out[i] = math.Round(d.TotalGB/total*100) / 100
}
return out
}
// splitBy is the shared derivation: each device's capacity less its fixed cost,
// normalised. main is the device index carrying mainFixedGB (-1 for none).
// Returns nil when no device has room left, which the two callers read
// differently.
func (g GpuSet) splitBy(capOf func(GpuDevice) float64, main int, mainFixedGB float64) []float64 {
rem := make([]float64, len(g))
var sum float64
for i, d := range g {
fixed := perDeviceFixedGB()
if d.Index == main {
fixed = mainFixedGB
}
r := capOf(d) - fixed
if r < 0 {
r = 0
}
rem[i] = r
sum += r
}
if sum <= 0 {
return nil
}
out := make([]float64, len(g))
for i, r := range rem {
// Two decimals: llama.cpp normalises the vector itself, and a long
// mantissa in the config buys nothing but an unreadable command line.
out[i] = math.Round(r/sum*100) / 100
}
return out
}
// MainLastOrder returns the positions of g in the order the devices must be
// handed to llama.cpp: every other device first, in their existing relative
// order, and the main device LAST. Returns nil when mainIndex names no device
// in g.
//
// Last is not arbitrary. Measured on a Vulkan build (llama-server -lv 10,
// -sm layer, -ts 0.5,0.5, two devices): the device listed LAST carries the
// non-splittable output weight on top of its layer share, and the surplus moves
// with the list rather than with --main-gpu. Reversing --device swapped a
// ~146MiB surplus from one card's model buffer to the other's, while
// --main-gpu 0 and --main-gpu 1 at a fixed list produced byte-identical model,
// KV and compute buffers.
//
// So the ordering IS the placement instruction, and mainFixedGB is only charged
// to the right card if that card is last. See splitBy, which prices the main
// device differently from the rest.
func (g GpuSet) MainLastOrder(mainIndex int) []int {
pos := -1
for i, d := range g {
if d.Index == mainIndex {
pos = i
break
}
}
if pos < 0 {
return nil
}
out := make([]int, 0, len(g))
for i := range g {
if i != pos {
out = append(out, i)
}
}
return append(out, pos)
}
// PermuteSplit reorders a ratio vector to match an order from MainLastOrder, so
// --tensor-split keeps addressing the same devices as the --device list beside
// it. A mismatched length is returned unchanged: a split that cannot be mapped
// must not be silently rearranged onto the wrong cards.
func PermuteSplit(split []float64, order []int) []float64 {
if len(order) != len(split) {
return split
}
out := make([]float64, len(split))
for i, p := range order {
out[i] = split[p]
}
return out
}
// FormatSplit renders a ratio vector as llama.cpp's comma-separated argument.
func FormatSplit(split []float64) string {
parts := make([]string, len(split))
for i, v := range split {
parts[i] = strconv.FormatFloat(v, 'g', -1, 64)
}
return strings.Join(parts, ",")
}
// cudaOrderEnv pins the CUDA runtime to PCI-bus enumeration so a device ordinal
// means the same thing to the sizer, to nvidia-smi and to llama.cpp. Without it
// the runtime's FASTEST_FIRST default can reverse the pair and apply the split
// backwards. Harmless on a Vulkan/ROCm build, which ignores it.
const cudaOrderEnv = "CUDA_DEVICE_ORDER=PCI_BUS_ID"
// minInferenceVramGB is the default eligibility floor. An iGPU reports a small
// slice of system memory as dedicated VRAM, and pooling that into the budget
// invents memory the sizer will then plan a model into, while a split that
// hands real layers to an iGPU is slower than not splitting at all. Settings
// override it via MinGpuVramGB.
const minInferenceVramGB = 3.0
// idleFreeByDevice is the per-device twin of idleFreeVramGB: the highest free
// reading ever seen for each device index. Same reason, per card: a sample
// taken while one of our own models is resident describes that model's
// leftovers, and using it would size the next plan into the scraps AND skew the
// split toward whichever card happened to be empty.
var (
idleFreeMu sync.Mutex
idleFreeByDev = map[int]float64{}
lastResolvedSet GpuSet
lastResolvedAt time.Time
)
// gpuSetCacheTTL is how long a resolved set is reused instead of re-probed.
// ResolveGpuSet is now on the estimate-preview path, which the config editor
// calls on every edit, and a cold nvidia-smi probe is seconds. Caching costs
// nothing in accuracy: the per-device figures are idle HIGH-WATER marks, which
// only ever rise, and the spawn guard re-reads live VRAM anyway.
const gpuSetCacheTTL = 60 * time.Second
// noteDeviceFreeGB records a reading and returns the device's idle high-water mark.
func noteDeviceFreeGB(index int, gb float64) float64 {
idleFreeMu.Lock()
defer idleFreeMu.Unlock()
if cur, ok := idleFreeByDev[index]; ok && cur >= gb {
return cur
}
idleFreeByDev[index] = gb
return gb
}
// ResetIdleFreeByDevice clears the per-device high-water marks. Tests only.
func ResetIdleFreeByDevice() {
idleFreeMu.Lock()
defer idleFreeMu.Unlock()
idleFreeByDev = map[int]float64{}
lastResolvedSet = nil
lastResolvedAt = time.Time{}
}
// SharedMemory modes. The system-memory pool a GPU can address is the whole
// story on an APU and a host-side aperture on a discrete card, and the telemetry
// carries no flag that tells the two apart — a 780M and a 6600 both report
// "VRAM" plus a GTT several times larger. So the user gets the last word.
const (
// SharedMemoryAuto counts the shared pool only for a device that looks like
// an APU: a name marker, or a dedicated pool small enough and a shared pool
// large enough that no discrete card has that shape. Default.
SharedMemoryAuto = "auto"
// SharedMemoryOff never counts it: the pre-#37 behaviour, and the escape
// hatch if auto guesses wrong on a discrete card.
SharedMemoryOff = "off"
// SharedMemoryOn counts it for every device that reports one.
SharedMemoryOn = "on"
)
// GpuPolicy is the user's device-selection policy, passed to the set builders
// explicitly rather than read from a package global, so the sizer, the OOM
// guard and the spawn-time retune cannot disagree about which devices count.
// Build it from Settings with DevicePolicy.
type GpuPolicy struct {
// MinTotalGB is the eligibility floor, applied to the device's TOTAL after
// the shared pool (when counted) is folded in. 0 => minInferenceVramGB.
MinTotalGB float64
// SharedMemory is SharedMemoryAuto / Off / On.
SharedMemory string
// PoolIntegrated allows an integrated GPU to be a split target beside a
// dedicated card. See Settings.PoolIntegratedGpu.
PoolIntegrated bool
}
// minTotalGB resolves the floor, with the same default the wizard writes out.
func (p GpuPolicy) minTotalGB() float64 {
if p.MinTotalGB <= 0 {
return minInferenceVramGB
}
return p.MinTotalGB
}
// probePolicy is the device-selection policy for the one-shot probes that cannot
// take a Settings argument: the hardware-budget seed (a process-wide OnceValues)
// and vllm's per-process card probe. Stored from the settings before any of them
// run — seedHardwareBudgets and ResolveGpuSet both do it — and the zero value
// means auto with the default floor, i.e. exactly the behaviour these probes had
// before the policy existed.
var probePolicy atomic.Value // holds GpuPolicy
// setProbePolicy records the policy the one-shot probes should use.
func setProbePolicy(p GpuPolicy) { probePolicy.Store(p) }
// currentProbePolicy returns the last recorded policy.
func currentProbePolicy() GpuPolicy {
if v := probePolicy.Load(); v != nil {
if p, ok := v.(GpuPolicy); ok {
return p
}
}
return GpuPolicy{}
}
// latestGpuStats is the newest reading per device id, ordered by id. The server
// hands us a sample HISTORY, the one-shot probe a single sample; keeping the
// newest per id is correct for both.
func latestGpuStats(stats []perf.GpuStat) []perf.GpuStat {
latest := make(map[int]perf.GpuStat, len(stats))
for _, g := range stats {
if prev, seen := latest[g.ID]; !seen || g.Timestamp.After(prev.Timestamp) {
latest[g.ID] = g
}
}
out := make([]perf.GpuStat, 0, len(latest))
for _, g := range latest {
out = append(out, g)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// gpuSetFromStats builds the eligible set from a telemetry sample: newest
// reading per device id, adapters below the floor dropped, ordered by index.
// Split out for unit testing without a real GPU.
func gpuSetFromStats(stats []perf.GpuStat, policy GpuPolicy) GpuSet {
minTotalGB := policy.minTotalGB()
var out GpuSet
for _, g := range latestGpuStats(stats) {
integrated := detectedIntegrated(g)
counted := sharedPoolCounts(g, policy.SharedMemory)
eff := effectiveGpuStat(g, counted)
if eff.MemTotalMB <= 0 {
continue
}
totalGB := float64(eff.MemTotalMB) / 1024.0
if totalGB < minTotalGB {
continue
}
free := eff.MemTotalMB - eff.MemUsedMB
if free < 0 {
free = 0
}
dev := GpuDevice{
Index: g.ID,
Name: g.Name,
TotalGB: totalGB,
FreeGB: float64(free) / 1024.0,
Integrated: integrated,
}
if g.SharedTotalMB > 0 {
dev.SharedTotalGB = float64(g.SharedTotalMB) / 1024.0
}
if counted {
sharedFree := g.SharedTotalMB - g.SharedUsedMB
if sharedFree > 0 {
dev.SharedFreeGB = float64(sharedFree) / 1024.0
}
}
out = append(out, dev)
}
return dropUnpooledIntegrated(out, policy.PoolIntegrated)
}
// dropUnpooledIntegrated keeps an integrated GPU out of a set that also holds a
// dedicated card, unless the user has explicitly allowed the pairing.
//
// The eligibility floor used to carry this rule, by making the device invisible
// outright — which on an APU box also hid the only GPU in the machine, from
// every budget in the program (issue #37). The rule was never about the device,
// though: it is about PAIRING one with a card that does the job better, so it is
// applied to the set. A set of nothing but integrated devices is returned
// unchanged, which is the APU-only box.
func dropUnpooledIntegrated(set GpuSet, allow bool) GpuSet {
if allow || len(set) < 2 {
return set
}
hasDedicated := false
for _, d := range set {
if !d.Integrated {
hasDedicated = true
break
}
}
if !hasDedicated {
return set
}
out := make(GpuSet, 0, len(set))
for _, d := range set {
if !d.Integrated {
out = append(out, d)
}
}
return out
}
// maxIntegratedDedicatedGB and integratedSharedRatio bound AUTO's shape test:
// a device is treated as integrated when its own pool is small AND the shared
// pool dwarfs it. A discrete card in a large-RAM machine reports a big GTT too,
// so the ratio carries the weight — an 8GB card with a 16GB aperture is 2x.
// Exceeding either bound only means the pool is not counted, which is the
// conservative answer.
const (
maxIntegratedDedicatedGB = 8.0
integratedSharedRatio = 3.0
)
// apuNameMarkers name an APU where the shape test cannot. "Ryzen" appears in
// rocm-smi's Card Series on an APU and never on a discrete card; "Radeon
// Graphics" is the generic name an APU's iGPU reports (a card reports "Radeon
// RX ...").
var apuNameMarkers = []string{"ryzen", "athlon", "radeon graphics"}
// detectedIntegrated reports the device's TOPOLOGY: does it allocate out of
// system memory? Independent of whether the pool is counted, so the pooling
// rule and the budget rule can differ (a user may budget a discrete card's host
// aperture without wanting it treated as an iGPU).
func detectedIntegrated(g perf.GpuStat) bool {
if g.SharedTotalMB <= 0 {
return false
}
// The kernel said so: KFD's local_mem_size is zero, meaning the device has no
// memory of its own. That outranks every guess below, and it is the only
// signal that catches an APU whose carve-out is big enough to look like a
// card's VRAM (Strix Halo) or whose name matches nothing (Linux sysfs has no
// marketing name to match, so it reports a PCI id).
if g.Integrated {
return true
}
if g.MemTotalMB <= 0 {
return false
}
name := strings.ToLower(g.Name)
for _, marker := range apuNameMarkers {
if strings.Contains(name, marker) {
return true
}
}
dedicated := float64(g.MemTotalMB)
return dedicated <= maxIntegratedDedicatedGB*1024 &&
float64(g.SharedTotalMB) >= integratedSharedRatio*dedicated
}
// sharedPoolCounts reports whether this device's shared pool is budget under
// the given mode. Only AUTO guesses, and it guesses conservatively: see
// detectedIntegrated, which refuses to call a large card integrated even when it
// reports a large aperture.
func sharedPoolCounts(g perf.GpuStat, mode string) bool {
if g.SharedTotalMB <= 0 {
return false
}
switch mode {
case SharedMemoryOff:
return false
case SharedMemoryOn:
return true
default:
return detectedIntegrated(g)
}
}
// effectiveGpuStat is the device with its counted pool folded into the memory
// figures every budget computation reads. The RAW stat keeps the two pools
// apart for display and for the platform readers; only the folded one is
// allowed to answer "how much can this device hold".
func effectiveGpuStat(g perf.GpuStat, counted bool) perf.GpuStat {
if !counted {
return g
}
// Both pools are summed, including USED. On an APU a driver may report a
// shared allocation under both headings, which overstates used and so
// understates free: the sizer then offloads a layer or two more than it had
// to, and the model still loads. Understating free is the safe direction to
// be wrong in, and the alternative (taking the larger pool instead of the
// sum) would hide real memory on Linux, where GTT genuinely grows past the
// carve-out.
g.MemTotalMB += g.SharedTotalMB
g.MemUsedMB += g.SharedUsedMB
return g
}
// DescribeGpuStats renders one telemetry reading for a human: what each adapter
// reported, what the policy made of it, and what the eligible set adds up to.
// cmd/monitor-test prints it, so a report from a machine we do not have arrives
// with the classification already attached -- which is what issue #37 (an APU
// whose only GPU was invisible) needed and could not get.
func DescribeGpuStats(stats []perf.GpuStat, multi bool, policy GpuPolicy) []string {
set := gpuSetFromStats(stats, policy)
kept := make(map[int]bool, len(set))
for _, d := range set {
kept[d.Index] = true
}
var lines []string
for _, g := range latestGpuStats(stats) {
integrated := detectedIntegrated(g)
counted := sharedPoolCounts(g, policy.SharedMemory)
eff := effectiveGpuStat(g, counted)
topology := "dedicated"
if integrated {
topology = "integrated"
}
line := fmt.Sprintf(" [%d] %-28s %s %6d MB used / %6d MB total",
g.ID, truncName(g.Name, 28), topology, g.MemUsedMB, g.MemTotalMB)
if g.SharedTotalMB > 0 {
line += fmt.Sprintf(" + shared %6d MB used / %6d MB total (%s)",
g.SharedUsedMB, g.SharedTotalMB, onOff(counted))
}
usableGB := float64(eff.MemTotalMB) / 1024.0
if counted {
line += fmt.Sprintf(" => %5.1f GB usable", usableGB)
}
switch {
case usableGB < policy.minTotalGB():
line += fmt.Sprintf(" DROPPED (under the %.1f GB floor)", policy.minTotalGB())
case !kept[g.ID]:
line += " EXCLUDED (an integrated GPU is not a split target unless poolIntegratedGpu is on)"
}
lines = append(lines, line)
}
if len(set) == 0 {
return append(lines, " eligible budget: none (no GPU counts; the sizer keeps its static budget)")
}
mode := "single"
if multi {
mode = "multi"
}
lines = append(lines, fmt.Sprintf(" eligible budget (%s): %.1f GB total, %.1f GB free over %d device(s)",
mode, set.TotalGB(), set.FreeGB(), len(set)))
return lines
}
// truncName keeps the diagnostic table aligned on a long adapter name.
func truncName(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
func onOff(b bool) string {
if b {
return "counted"
}
return "not counted"
}
// SampleGpuSet takes one telemetry snapshot and returns the eligible devices.
// ok is false when no GPU telemetry is available within timeout, and the caller
// keeps whatever static budget it had.
func SampleGpuSet(timeout time.Duration, policy GpuPolicy) (GpuSet, bool) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
gpuCh, err := perf.GetGpuStats(ctx, time.Second, logmon.NewWriter(io.Discard))
if err != nil || gpuCh == nil {
return nil, false
}
select {
case stats := <-gpuCh:
set := gpuSetFromStats(stats, policy)
if len(set) == 0 {
return nil, false
}
// Budget each card against ITS idle high-water mark, never the raw
// sample: a probe taken while one of our own models is resident
// describes that model's leftovers, and on a split plan it would also
// skew the ratio toward whichever card happened to be empty.
for i := range set {
set[i].FreeGB = noteDeviceFreeGB(set[i].Index, set[i].FreeGB)
}
idleFreeMu.Lock()
lastResolvedSet = set
lastResolvedAt = time.Now()
idleFreeMu.Unlock()
return set, true
case <-ctx.Done():
return nil, false
}
}
// LastGpuSet is the most recently resolved set, for callers on a path that must
// not re-probe (the spawn guard runs per model load). nil when nothing has been
// sampled yet, which every consumer treats as single-GPU.
func LastGpuSet() GpuSet {
idleFreeMu.Lock()
defer idleFreeMu.Unlock()
return lastResolvedSet
}
// ResolveGpuSet samples the host's adapters and stores the eligible set on the
// settings, so every downstream sizing decision in this generate pass talks
// about the same cards with the same numbers. No-op when the user has turned
// multi-GPU off, which pins the set to the single main device and reproduces
// the pre-multi-GPU behaviour exactly.
func ResolveGpuSet(s *Settings, logf func(string)) {
// The one-shot probes a generate pass makes on its own (vllm's card total)
// cannot see the Settings, so seed them with the same policy this resolve is
// using. Idempotent: same source, same value.
setProbePolicy(s.DevicePolicy())
idleFreeMu.Lock()
cached := lastResolvedSet
fresh := len(cached) > 0 && time.Since(lastResolvedAt) < gpuSetCacheTTL
idleFreeMu.Unlock()
set, ok := cached, fresh
if !ok {
set, ok = SampleGpuSet(autoVramSampleTimeout, s.DevicePolicy())
}
if !ok {
return
}
if !s.MultiGpuEnabled() && len(set) > 1 {
main := set.PlanMainIndex()
for _, d := range set {
if d.Index == main {
set = GpuSet{d}
break
}
}
}
s.Gpus = set
if logf == nil {
return
}
// An APU box has ONE device, which is exactly the box this fork could not
// see at all before #37 — and the box a bug report is about. Say what the
// budget is and how much of it is host memory, or "the GPU is still
// invisible" arrives with nothing to check.
if len(set) == 1 {
d := set[0]
if d.Integrated && d.SharedFreeGB > 0 {
logf(fmt.Sprintf("gpu: %s is an integrated GPU; free %.2fGB (%.2fGB dedicated + %.2fGB shared system memory)",
d.Name, d.FreeGB, d.FreeGB-d.SharedFreeGB, d.SharedFreeGB))
}
return
}
names := make([]string, len(set))
for i, d := range set {
names[i] = fmt.Sprintf("%d:%s %.1f/%.1fGB free", d.Index, d.Name, d.FreeGB, d.TotalGB)
if d.Integrated && d.SharedFreeGB > 0 {
names[i] += fmt.Sprintf(" (integrated, %.1fGB shared)", d.SharedFreeGB)
}
// A card busy at this instant is planned against its stable capacity,
// so say so: otherwise the emitted --tensor-split reads as wrong
// against the free figure on the same line, and this log is what a bug
// report pastes.
if c := d.planCapacityGB(); c > d.FreeGB {
names[i] += fmt.Sprintf(" (plan %.1fGB)", c)
}
}
// The main device the PLAN picks, which is the one the config will name.
logf(fmt.Sprintf("multi-gpu: %d devices [%s] -> pooled budget %.2fGB, main gpu %d",
len(set), strings.Join(names, ", "), set.FreeGB(), set.PlanMainIndex()))
}
// EligibleGpuStats is the exported eligibility rule, for callers outside this
// package that hold a raw perf sample history and must describe the SAME cards
// the sizer planned against: newest sample per device id, adapters under the
// inference floor dropped, integrated devices kept out of a mixed set unless the
// policy allows it, and (when multi is false) everything but the device
// --main-gpu would pick removed.
//
// The returned stats are the FOLDED ones — an integrated device's shared pool is
// already inside MemTotalMB/MemUsedMB — because the callers pool those figures
// and compare the result against a budget the sizer derived from the same fold.
// Returning the raw stats here is how the guard and the gauge would end up
// describing a card the plan never used.
//
// The server's OOM guard and idle-VRAM tracker call it. Before issue #4 they
// each open-coded "largest adapter", which is how a two-card box got a ceiling
// describing one card while the models were sized for both.
func EligibleGpuStats(stats []perf.GpuStat, multi bool, policy GpuPolicy) []perf.GpuStat {
set := gpuSetFromStats(stats, policy)
if len(set) == 0 {
return nil
}
keep := make(map[int]bool, len(set))
if multi {
for _, d := range set {
keep[d.Index] = true
}
} else {
// Same pick as the split's main device, so a multiGpu:false install
// budgets, guards and loads on one and the same card. It is chosen by
// free memory, so a foreign app filling the big card can move it; that
// is deliberate, the alternative is guarding a card we would not load on.
keep[set.MainIndex()] = true
}
latest := make(map[int]perf.GpuStat, len(stats))
for _, g := range stats {
if !keep[g.ID] {
continue
}
if prev, seen := latest[g.ID]; !seen || g.Timestamp.After(prev.Timestamp) {
latest[g.ID] = g
}
}
out := make([]perf.GpuStat, 0, len(latest))
for _, g := range latest {
out = append(out, effectiveGpuStat(g, sharedPoolCounts(g, policy.SharedMemory)))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// LiveGpuSet builds a device set from a raw perf sample, WITHOUT the idle
// high-water smoothing SampleGpuSet applies. The two answer different questions:
// the sizer plans against an IDLE budget (what the box could give a model), the
// spawn guard needs the truth (what each card has left right now, with whatever
// is already resident on it counted).
//
// That distinction is what makes a stale --tensor-split dangerous: a ratio built
// from idle capacity keeps sending a third of the layers to a card another model
// is already sitting on. See retuneTensorSplit.
func LiveGpuSet(stats []perf.GpuStat, multi bool, policy GpuPolicy) GpuSet {
return gpuSetFromStats(EligibleGpuStats(stats, multi, policy), policy)
}
// writeSingleDeviceEnv emits the env block a SINGLE-DEVICE backend needs on a
// multi-GPU box. sd-server, tts-server and whisper have no split of their own:
// left alone the CUDA runtime hands them whatever it calls device 0, which under
// its FASTEST_FIRST default is not necessarily the card the sizer budgeted, and
// not necessarily the same card twice. Pin them to the same main device the LLM
// split pins to, enumerated by PCI bus so the ordinal means here what it means
// in nvidia-smi.
//
// Vulkan and ROCm have the same problem and their own variables for it, but the
// ordinal those take is the BACKEND's, which telemetry does not supply: it used
// to be a no-op there, leaving a two-card Vulkan box to whichever adapter ggml
// enumerated first. singleDeviceEnvFor asks the binary instead, so the pin is
// derived rather than assumed.
//
// It is NOT a no-op on a single-GPU plan, because "single GPU" is telemetry's
// count, not the backend's. Telemetry drops an integrated adapter (no dedicated
// VRAM worth planning against), while ROCm and Vulkan enumerate it happily: an
// RX 7900 XTX box lists one GPU here and TWO to sd-server, the second being a
// gfx1036 iGPU advertising 12 GiB of SHARED SYSTEM memory as if it were VRAM.
// That phantom card is worse than a wasted entry. sd-server's --auto-fit planner
// sizes placements against the pair, and any work that lands on it runs kernels
// a gfx1100-only build never compiled, which surfaces as a HIP fault naming no
// device at all ("unspecified launch failure, current device: -1") rather than
// as a fit error. So pin whenever the BACKEND sees more adapters than the plan
// does, single-GPU or not.
//
// A no-op when the device set was never resolved, when the backend agrees with
// telemetry about the device count, and when neither the probe nor the CUDA
// fallback can name the device.
func writeSingleDeviceEnv(b *strings.Builder, s Settings, exe string) {
set := s.GpuSetOrEmpty()
if len(set) == 0 {
return
}
pin, isCuda := singleDeviceEnvFor(exe, set)
if pin == "" {
return
}
b.WriteString(" env:\n")
// CUDA_DEVICE_ORDER only means anything alongside CUDA_VISIBLE_DEVICES, and
// it is what makes the ordinal in it mean what nvidia-smi means.
if isCuda {
fmt.Fprintf(b, " - %q\n", cudaOrderEnv)
}
fmt.Fprintf(b, " - %q\n", pin)
}
// singleDeviceEnvFor returns the NAME=VALUE pin for the plan's main device as
// this backend enumerates it, and whether it is the CUDA one.
//
// Preferred path: ask the binary what it calls its devices and read the ordinal
// off the id it gives the main card ("Vulkan1" -> 1). That is the only way to
// get a Vulkan or ROCm ordinal right, because the telemetry index is a different
// enumeration and using it directly would pin the wrong adapter, silently.
//
// Fallback: CUDA alone, where CUDA_DEVICE_ORDER=PCI_BUS_ID makes the runtime's
// ordinal agree with nvidia-smi's, so the telemetry index IS the right value.
// Everything else refuses, which is the behaviour that shipped.
func singleDeviceEnvFor(exe string, set GpuSet) (string, bool) {
main := set.PlanMainIndex()
if devs, err := ListBackendDevices(exe); err == nil && (set.Multi() || len(devs) > len(set)) {
if ids := set.BackendIDs(devs); len(ids) == len(set) {
for i, d := range set {
if d.Index != main {
continue
}
if name, ord, ok := visibleDevicesEnvFor(ids[i]); ok {
return name + "=" + ord, name == "CUDA_VISIBLE_DEVICES"
}
break
}
}
}
// The CUDA fallback stays multi-only. It trusts the TELEMETRY index, which
// is the right ordinal only because CUDA_DEVICE_ORDER=PCI_BUS_ID rides along
// with it; on a single-GPU plan there is nothing it could disambiguate that
// the probe above did not already, and firing it would put a pin on every
// single-card CUDA box for no reason.
if set.Multi() && usingCudaGPU() {
return fmt.Sprintf("CUDA_VISIBLE_DEVICES=%d", main), true
}
return "", false
}
// visibleDevicesEnvFor splits a backend device id into the variable that filters
// that backend's device list and the ordinal to give it. The id carries both:
// ggml names devices "<backend><ordinal>", and each backend's filter variable
// indexes the same enumeration the id was numbered from.
//
// An unrecognised backend gets nothing rather than a guess. A filter variable
// aimed at the wrong enumeration does not fail loudly, it just runs the model on
// a card the sizer did not budget.
func visibleDevicesEnvFor(id string) (string, string, bool) {
cut := strings.IndexFunc(id, func(r rune) bool { return r >= '0' && r <= '9' })
if cut <= 0 {
return "", "", false
}
ord := id[cut:]
switch strings.ToLower(id[:cut]) {
case "cuda":
return "CUDA_VISIBLE_DEVICES", ord, true
case "vulkan", "vk":
return "GGML_VK_VISIBLE_DEVICES", ord, true
case "rocm", "hip":
return "HIP_VISIBLE_DEVICES", ord, true
}
return "", "", false
}