package autogen // budgets.go derives the VRAM/RAM budgets a box should START from, by measuring // it, instead of handing every machine the same hardcoded pair. // // The old constants (7 GB VRAM / 24 GB RAM) were the author's desktop written // down. On a 12 GB card the wizard opened at 7 — 5 GB of the card simply unused, // with every model sized down to fit a budget nothing measured — and 24 GB of // RAM on a 16 GB box is a budget the machine cannot honour at all, so the RAM // ceiling that is supposed to stop a plan from swapping never bound anything. // // The rule is the obvious one: budget what is free, i.e. total minus what the OS // and desktop already hold. That is exactly what a free-memory reading IS, so // both probes are one call each — no separate "system usage" model to keep // calibrated. vramOverheadGB (0.5) is the only pad on top, charged inside the // plan; see resolveAutoVram for why it is not deducted here as well. // // A probe is a SNAPSHOT, and its honest failure mode is a box that was busy when // it was taken: run the wizard with a game open and the install seeds a budget // that stays too small until the user raises it. Seeding beats not seeding // anyway — a measured-low number is a number the Settings page can show against // the card's real total, where the old constant was wrong on every box equally // and looked deliberate. Users who want it re-measured every boot have autoVram. import ( "math" "sync" "sync/atomic" "github.com/shirou/gopsutil/v4/mem" ) // seedMultiGpu carries the settings' pooling decision into hardwareBudgets, // which is a process-wide OnceValues and so cannot take an argument. Defaults // to true, matching Settings.MultiGpuEnabled's nil default. var seedMultiGpu atomic.Bool func init() { seedMultiGpu.Store(true) } const ( // recommendedVramFloorGB / recommendedRamFloorGB are the readings below which // a probe is treated as unusable rather than as a budget. A card reporting // under a gigabyte free is one that something else has taken (or one whose // driver is lying); seeding that would emit a catalog where nothing fits. recommendedVramFloorGB = 1.0 recommendedRamFloorGB = 2.0 ) // RecommendedVramGB is the VRAM budget a fresh install should start from: the // POOLED free VRAM across every eligible adapter right now. ok is false with no // GPU telemetry or a reading below the floor, and the caller then keeps its // static default. // // Pooled, because the wizard's seed is what a two-card box lives with until // someone edits it: seeding the largest card alone is how a 12 GB + 16 GB // machine ended up with targetVramGB 11.9 and half its VRAM unused (issue #4). func RecommendedVramGB() (float64, bool) { return recommendedVramGB(true) } // recommendedVramGB pools when multi is set, and otherwise reports only the // device --main-gpu would pick. A user who has turned multiGpu off must not be // handed a budget that describes memory no single card has. func recommendedVramGB(multi bool) (float64, bool) { policy := currentProbePolicy() if !multi { set, ok := SampleGpuSet(autoVramSampleTimeout, policy) if !ok { return 0, false } main := set.MainIndex() for _, d := range set { if d.Index == main { if d.FreeGB < recommendedVramFloorGB { return 0, false } return floorGB(d.FreeGB), true } } return 0, false } gb, ok := SampleFreeVramGB(autoVramSampleTimeout, policy) if !ok || gb < recommendedVramFloorGB { return 0, false } return floorGB(gb), true } // RecommendedRamGB is the RAM budget a fresh install should start from: system // RAM minus what is already in use. ok is false when the OS reading fails or // falls below the floor. // // Available, not Free: on every modern OS "free" excludes the page/file cache, // which is reclaimable and routinely most of RAM on a box that has been up a // while. Budgeting against Free would hand a 32 GB machine that has read a few // model files a 2 GB ceiling. func RecommendedRamGB() (float64, bool) { vm, err := mem.VirtualMemory() if err != nil || vm == nil || vm.Total == 0 { return 0, false } avail := vm.Available if avail == 0 || avail > vm.Total { avail = vm.Total - vm.Used } gb := float64(avail) / gib if gb < recommendedRamFloorGB { return 0, false } return floorGB(gb), true } // floorGB truncates to one decimal. Rounding UP would claim a sliver of memory // the probe did not actually see free. func floorGB(gb float64) float64 { return math.Floor(gb*10) / 10 } // hardwareBudgets caches the pair for the process. Both probes are one-shot and // LoadGenerateFile runs on every hot reload and on some API requests, so without // this a box with no budgets in its generate file would re-probe the GPU on each // settings save. var hardwareBudgets = sync.OnceValues(func() (float64, float64) { vram, vok := recommendedVramGB(seedMultiGpu.Load()) if !vok { vram = 0 } ram, rok := RecommendedRamGB() if !rok { ram = 0 } return vram, ram }) // seedHardwareBudgets replaces the placeholder constants applyDefaults wrote // with measured ones, but ONLY for a knob that neither the generate file nor the // UI sidecar set. An explicit value — including one the wizard measured and // wrote at install time — is the user's and is never re-derived behind their // back; that is what autoVram is for. func seedHardwareBudgets(s *Settings, vramUnset, ramUnset bool) { if !vramUnset && !ramUnset { return } // The probe is cached for the process, so the pooling decision has to be // visible to it before it runs. A box with multiGpu off must be seeded from // one card: a pooled figure is memory no single adapter has, and the sizer // would plan every model past what fits. seedMultiGpu.Store(s.MultiGpuEnabled()) // Same reason, same ordering: the probe also needs the shared-memory policy, // or an APU would be seeded from its BIOS carveout alone (issue #37). setProbePolicy(s.DevicePolicy()) vram, ram := hardwareBudgets() if vramUnset && vram > 0 { s.TargetVramGB = vram } if ramUnset && ram > 0 { s.MaxRamGB = ram } }