Ctrl K
ComunidadGuías…

internal/autogen/gpuset.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}