Ctrl K
ComunidadGuías…

internal/autogen/generate_sizing.go

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

Archivo original ↗Preguntar a KiBot
package autogen

// Sizing math for the config emitter: pick the largest context that fits the
// VRAM budget for a profile, then derive the offload split (-ngl / --n-cpu-moe),
// KV checkpoint reserve, compute-buffer overhead, and mmproj/draft costs that
// the chosen context implies. Pure arithmetic over Metadata + Settings — no
// YAML, no command rendering.

import (
	"math"
)

const (
	// longCtxThreshold is the context at which a profile counts as long: the KV
	// cache dominates the footprint and the compute buffer is exercised over a
	// far bigger window, so the plan needs slack the short profiles don't.
	longCtxThreshold = 65536
	// longCtxHeadroomGB is the TOTAL safety slack a long profile should hold back.
	// Fitting a long context to the last megabyte is exactly the case where WDDM
	// demotes the overflow to shared memory instead of failing (see the package
	// doc), so the budget is shaved rather than spent. It is a floor, not an
	// addition — see longCtxTarget.
	longCtxHeadroomGB = 0.5
)

// longCtxTarget applies that headroom to a profile's budget, TOPPING UP whatever
// slack vramOverheadGB already holds back rather than stacking on top of it.
//
// Both knobs exist for the same reason (allocator/runtime slack so a tight fit
// doesn't get demoted to shared memory), and charging both cost a long profile a
// full extra 0.5 GB it never spent: on a 22.8 GB target with vramOverheadGB 0.5,
// a 100k dense profile sized against 22.3, then GetLoadPlan subtracted the 0.5
// again as cudaOverhead — 1.0 GB of pad, of which the estimate reported ~0.5 as
// used VRAM that the launch never allocated. So the card measured ~1 GB below the
// target and -ngl dropped a layer (and that layer's KV to RAM) for nothing.
//
// vramOverheadGB is already charged into every plan via prof.Overhead, so a long
// profile only needs the difference. With the default 0.5 this is now a no-op;
// raising vramOverheadGB shrinks it to zero rather than compounding.
//
// The headroom is charged HERE rather than inline at the ctx-tier call site in
// Generate, which is why the editor's preview and the baked config used to
// disagree: EstimatePlan flags IsLong on the same threshold but sized against the
// full target. Charging it here means every long profile — ctx tier, named
// variant, an explicit long Ctx on the model itself, and the preview — sizes
// identically.
func longCtxTarget(prof profile, vramOverheadGB float64) float64 {
	headroom := longCtxHeadroomGB - vramOverheadGB
	if headroom <= 0 {
		return prof.Target
	}
	if prof.IsLong && prof.Target > headroom {
		return prof.Target - headroom
	}
	return prof.Target
}

// sizeProfile computes the context window and load plan for one profile,
// mirroring the dense / MoE / kv-in-ram / no-attn branches of Generate-Config.
//
// ckptGB is the context-checkpoint reserve this profile charged into the plan
// (0 when KV lives in RAM or the model has no attention dims). It is returned
// separately from kvReserve — which stays the pure KV cost the UI reports — so
// a caller that RE-derives the estimate for a pinned placement (estForOffload)
// charges the same checkpoint bytes GetLoadPlan already did. Dropping it there
// made a pinned/forced-offload estimate read ~ckptGB lower than the auto one
// for the same placement, so the numbers didn't reconcile (22.4 auto vs
// 21.8 + 0.3 pinned).
func sizeProfile(meta Metadata, s Settings, prof profile, perTokGB, kvConstGB float64, modelMax int, kvInRam bool) (ctx int, plan LoadPlan, kvReserve, ckptGB float64, err error) {
	target := longCtxTarget(prof, s.VramOverheadGB)
	overhead := prof.Overhead

	switch {
	case kvInRam && perTokGB > 0:
		kvReserve = 0.1
		plan, err = GetLoadPlan(meta, planOpt(target, s.MaxRamGB, kvReserve, overhead))
		if err != nil {
			return
		}
		ctxBudgetRam := s.MaxRamGB - plan.EstRamGB
		if ctxBudgetRam < 0.5 {
			ctxBudgetRam = 0.5
		}
		maxCtxRam := MaxCtxForBudget(ctxBudgetRam, perTokGB, kvConstGB)
		if prof.CtxExact && prof.Ctx > 0 {
			// A pinned window is what the launch really runs: charge the RAM budget
			// in GetLoadPlan below instead of sizing a smaller cache under a -c the
			// process will honor anyway.
			ctx = min(modelMax, prof.Ctx)
		} else {
			ctx = RoundedCtx(float64(min(modelMax, maxCtxRam)))
			if prof.Ctx != 0 {
				ctx = min(ctx, prof.Ctx)
			}
		}
		kvReserve = KvReserveGB(ctx, perTokGB, kvConstGB)

	case perTokGB > 0:
		// The baked-in MTP drafter's KV is a second cache over the same window, so
		// every ctx-vs-VRAM decision below solves against slope+draftSlope, not the
		// main slope alone. Checkpoints keep the pure perTokGB: a checkpoint is a
		// snapshot of the MAIN KV cache only, the drafter has none.
		//
		// kvReserve therefore comes back including the drafter's share. Callers
		// that report a separate "Draft" figure subtract DraftSlopeGB*ctx again
		// (see EstimatePlan); the sizing itself only cares about the total.
		sizeSlope := perTokGB + prof.DraftSlopeGB
		ckptCtxCeil := modelMax
		if prof.Ctx != 0 {
			ckptCtxCeil = min(ckptCtxCeil, prof.Ctx)
		}
		ckpt := checkpointReserveGB(prof, perTokGB, kvConstGB, ckptCtxCeil, meta.FullAttnInterval > 0)
		ckptGB = ckpt
		// Checkpoints live wherever the KV cache does. MoE keeps KV (and thus its
		// checkpoints) VRAM-resident even when expert weights spill to CPU via
		// --n-cpu-moe, so they're a flat VRAM overhead. Dense models keep the KV
		// of any CPU-offloaded layer in RAM, so the checkpoint cost is folded into
		// the per-layer KV reserve (placementCkpt) and split across the GPU/CPU
		// layers by densePlacement instead of charged whole to VRAM up front
		// (which drove -ngl toward 0 at partial offload).
		placementCkpt := 0.0
		if meta.IsMoE {
			overhead += ckpt
			if prof.Ctx != 0 {
				// Explicit ctx (a custom ctx tier / variant / pin) is HARD: honor it
				// and let GetLoadPlan below trade expert layers (--n-cpu-moe) for the
				// larger KV reserve, instead of shrinking ctx to whatever VRAM is
				// free. "64k variant" means 64k context, capped only by modelMax.
				if prof.CtxExact {
					ctx = min(modelMax, prof.Ctx)
				} else {
					ctx = RoundedCtx(float64(min(modelMax, prof.Ctx)))
				}
			} else {
				share := effectiveShare(meta, genMoeShareFor)
				nonExpert := meta.FileSizeGB * (1.0 - share)
				usableBase := target - 0.25 - overhead
				if meta.FileSizeGB <= usableBase {
					kvBudget := target - meta.FileSizeGB - overhead
					if kvBudget < 0.1 {
						kvBudget = 0.1
					}
					ctx = RoundedCtx(float64(min(modelMax, MaxCtxForBudget(kvBudget, sizeSlope, kvConstGB))))
				} else {
					maxKvVram := target - nonExpert - overhead
					if maxKvVram < 0.1 {
						maxKvVram = 0.1
					}
					maxCtxVram := MaxCtxForBudget(maxKvVram, sizeSlope, kvConstGB)
					ctx = RoundedCtx(float64(min(min(modelMax, s.MoeCtxTarget), maxCtxVram)))
				}
			}
		} else if prof.CtxExact && prof.Ctx > 0 {
			// A pinned window skips the ladder: GetDenseCtx rounds every pick to a
			// 4096 multiple, so it would size `-c 5000` at 4096.
			ctx = min(modelMax, prof.Ctx)
			placementCkpt = ckpt
		} else {
			ladder := s.DenseCtxLadder
			minCtx := s.DenseMinCtx
			if prof.Ctx != 0 {
				ladder = []int{prof.Ctx}
				minCtx = prof.Ctx
			}
			// Size ctx conservatively against the checkpoint cost (overhead+ckpt),
			// but keep ckpt out of overhead so placement can split it per-layer.
			d := GetDenseCtx(DenseCtxParams{
				ModelMax: modelMax, PerTokGB: sizeSlope, KvConstGB: kvConstGB,
				FileSizeGB: meta.FileSizeGB, TargetVramGB: target, Overhead: overhead + ckpt,
				Ladder: ladder, MinCtx: minCtx, AllowOffload: prof.Ctx != 0,
			})
			ctx = d.Ctx
			if prof.Ctx != 0 {
				ctx = min(ctx, prof.Ctx)
			}
			placementCkpt = ckpt
		}
		kvReserve = KvReserveGB(ctx, sizeSlope, kvConstGB)
		plan, err = GetLoadPlan(meta, planOpt(target, s.MaxRamGB, kvReserve+placementCkpt, overhead))
		if err != nil {
			return
		}

	default:
		if prof.CtxExact && prof.Ctx > 0 {
			ctx = min(modelMax, prof.Ctx)
		} else {
			ctx = RoundedCtx(float64(min(modelMax, 32768)))
			if prof.Ctx != 0 {
				ctx = min(ctx, prof.Ctx)
			}
		}
		kvReserve = 0
		// No attention dims: planner uses its flat 1.0GB KV reserve default.
		plan, err = GetLoadPlan(meta, PlanOptions{TargetVramGB: target, MaxRamGB: s.MaxRamGB, CudaOverheadGB: overhead, cudaSet: true})
		if err != nil {
			return
		}
	}
	return
}

// LlamaDefaultCtxCheckpoints is llama-server's --ctx-checkpoints default when
// the flag is omitted (PR #15293). Tuned for a multi-slot server; overkill for
// local single-user serving, so we override it with defaultCtxCheckpoints. The
// estimate paths charge it when an argv carries no --ctx-checkpoints, since that
// is what such a launch really reserves.
const LlamaDefaultCtxCheckpoints = 32

// defaultCtxCheckpoints picks a sane checkpoint count for a model that doesn't
// set ctxCheckpoints itself.
//
// recurrent (FullAttnInterval>0: GatedDeltaNet/SSM hybrids) => 2. Checkpoint
// restore on these archs USED to land at the wrong position and spam
// "non-consecutive token position" + reprocess the whole prompt (upstream
// llama.cpp #21831), so we disabled them. That's now fixed upstream — measured on
// qwen3.6-27b: after clobbering the slot, returning to a prior 3524-token prompt
// restored 3520 tokens from a checkpoint (4 reprocessed), zero spam. Their KV is
// cheap (only the ~1/4 full-attn layers carry it), so a couple is nearly free.
//
// Otherwise kvConstGB > 0 means SWA: the KV window rolls, so prefix-cache reuse
// breaks on a context shift and checkpoints (which DO restore on SWA) are the
// only reuse path — worth a few, though each is pricey. Plain full-attention
// models keep a persistent KV that already covers linear chat, so they need only
// a couple for the occasional edit/branch.
//
// SWA gets 3, not more: a checkpoint costs kvConstGB + perTokGB*minStep, and on
// SWA the kvConstGB term (every local layer's whole window) dominates and is paid
// per snapshot — 6 snapshots reserved GBs on gemma-class models and crowded out
// the context. Its rewind coverage comes from spacing wider than the other archs
// (defaultCheckpointMinStep), which only scales the cheap perTokGB term.
func defaultCtxCheckpoints(kvConstGB float64, recurrent bool) int {
	if recurrent {
		return 2
	}
	if kvConstGB > 0 {
		return 3
	}
	return 3
}

// defaultCheckpointMinStep picks the -cms (minimum prompt-token spacing between
// context checkpoints) for a model that doesn't pin one.
//
// Both values are far below llama's own 8192 default (llamaDefaultCheckpointMinStep),
// which is what makes the spacing worth emitting at all: upstream creates a
// checkpoint at every user message (#24176) and then evicts any checkpoint within
// min-step of an earlier one (#25472), so at 8192 each new turn's checkpoint
// deletes the previous turn's on any normal 1-3k-token exchange and a nominal 2-3
// snapshots collapse to ~1 usable rewind point.
//
// SWA models get 1024 rather than 256: with only 3 snapshots retained, spacing is
// what decides how far back a divergent prompt can be restored from, and on SWA
// the per-snapshot cost is dominated by the ctx-independent window state
// (kvConstGB), so quadrupling the step adds only perTokGB*768 per snapshot — far
// cheaper than paying kvConstGB again for extra snapshots. Everything else gets
// 256: their kvConstGB is ~0, so snapshots are cheap and tight spacing keeps a
// checkpoint per turn instead of letting eviction thin them out.
func defaultCheckpointMinStep(kvConstGB float64, recurrent bool) int {
	if !recurrent && kvConstGB > 0 {
		return swaCheckpointMinStep
	}
	return checkpointMinStep
}

// swaCheckpointMinStep is the widened checkpoint spacing used for SWA models.
const swaCheckpointMinStep = 1024

// effectiveCheckpointMinStep resolves the spacing a profile will actually run
// with: an explicit pin wins, else the arch default.
func effectiveCheckpointMinStep(prof profile, kvConstGB float64, recurrent bool) int {
	if prof.CheckpointMinStep > 0 {
		return prof.CheckpointMinStep
	}
	return defaultCheckpointMinStep(kvConstGB, recurrent)
}

// checkpointMinStep is OUR default -cms — the minimum prompt-token spacing
// between context checkpoints for recurrent and plain-attention models. It is NOT
// llama-server's default (that is llamaDefaultCheckpointMinStep, 32x larger); the
// value is always emitted so the launch runs at the spacing checkpointReserveGB
// charged for. The spacing actually emitted (and charged) comes from
// effectiveCheckpointMinStep.
const checkpointMinStep = 256

// LlamaDefaultCheckpointMinStep is llama-server's own --checkpoint-min-step
// default when the flag is omitted (verified on b10483: "default: 8192"). We
// never launch at it — buildCmdLines always emits an explicit -cms — but an
// argv that predates that (or one a user hand-wrote) does, so the estimate paths
// that read a spacing back off a command line have to charge this when no -cms is
// present, or they under-reserve the per-checkpoint global-KV term by 32x the
// spacing they assumed.
const LlamaDefaultCheckpointMinStep = 8192

// effectiveCtxCheckpoints resolves the checkpoint count a profile will actually
// run with: an explicit value (incl. 0 = disabled) when set, else the
// llama-server default.
func effectiveCtxCheckpoints(prof profile, def int) int {
	if prof.CtxCheckpoints != nil {
		return *prof.CtxCheckpoints
	}
	return def
}

// checkpointReserveGB estimates the extra VRAM a profile's context checkpoints
// consume. llama-server keeps up to --ctx-checkpoints KV snapshots so a diverging
// prompt can be restored instead of reprocessed. Each snapshot holds the
// ctx-independent window/recurrent state (kvConstGB) plus roughly one
// checkpoint-min-step worth of global KV. Left unaccounted, the default 32
// snapshots silently overflow VRAM into sysmem and tank decode speed.
//
// The count is capped by how many checkpoints can actually exist: at min-step
// spacing a context of ctxCeil tokens holds at most ctxCeil/step snapshots, so a
// small pinned ctx (e.g. a 4k judge) reserves far fewer than the 32 default.
// Returns 0 when checkpoints are disabled or the model has no VRAM-resident KV.
func checkpointReserveGB(prof profile, perTokGB, kvConstGB float64, ctxCeil int, recurrent bool) float64 {
	n := effectiveCtxCheckpoints(prof, defaultCtxCheckpoints(kvConstGB, recurrent))
	if n <= 0 || (perTokGB <= 0 && kvConstGB <= 0) {
		return 0
	}
	step := effectiveCheckpointMinStep(prof, kvConstGB, recurrent)
	if ctxCeil > 0 {
		if maxN := ctxCeil / step; maxN < n {
			n = maxN
		}
	}
	if n <= 0 {
		return 0
	}
	perCheckpoint := kvConstGB + perTokGB*float64(step)
	return float64(n) * perCheckpoint
}

// planOpt builds PlanOptions with explicit reserve + overhead set.
func planOpt(target, maxRam, kvReserve, overhead float64) PlanOptions {
	return PlanOptions{
		TargetVramGB:     target,
		MaxRamGB:         maxRam,
		KvCacheReserveGB: kvReserve,
		CudaOverheadGB:   overhead,
		kvReserveSet:     true,
		cudaSet:          true,
	}
}

// forceLowActiveMoE recomputes the expert split for low-active MoE models that
// the planner's PCIe-thrash crossover wrongly fell back to naive -ngl on. Keeps
// dense+attention on GPU (-ngl 99) with experts on CPU.
func forceLowActiveMoE(meta Metadata, plan LoadPlan, prof profile, kvReserve float64) (ngl, ncpuMoe int) {
	ngl, ncpuMoe = plan.Ngl, plan.NCpuMoe
	if !(meta.IsMoE && ncpuMoe == 0 && ngl < 99) {
		return
	}
	share := effectiveShare(meta, genMoeShareFor)
	reserve := 1.0
	if kvReserve > 0 {
		reserve = kvReserve
	}
	usable := prof.Target - reserve - prof.Overhead
	nonExpert := meta.FileSizeGB * (1.0 - share)
	perMoeLayer := (meta.FileSizeGB * share) / float64(meta.BlockCount)
	moeOnGpu := math.Floor((usable - nonExpert) / perMoeLayer)
	if moeOnGpu > float64(meta.BlockCount) {
		moeOnGpu = float64(meta.BlockCount)
	}
	if moeOnGpu < 0 {
		moeOnGpu = 0
	}
	ncpuMoe = int(math.Max(0, float64(meta.BlockCount)-moeOnGpu))
	ngl = 99
	return
}

// applyForcedOffload overrides the auto placement with a user-pinned number of
// layers pushed to CPU (Override.CpuOffload). MoE models offload expert layers
// (--n-cpu-moe n, GPU stays -ngl 99); dense models drop GPU layers
// (-ngl = blocks-n). n is clamped to [0, blockCount].
func applyForcedOffload(meta Metadata, n int) (ngl, ncpuMoe int) {
	blocks := int(meta.BlockCount)
	if n < 0 {
		n = 0
	}
	if blocks > 0 && n > blocks {
		n = blocks
	}
	if meta.IsMoE {
		return 99, n
	}
	ngl = blocks - n
	if ngl < 0 {
		ngl = 0
	}
	return ngl, 0
}

// estForOffload recomputes the VRAM/RAM estimate for a forced placement so the
// generated header comment (and the editor preview) reflect the pinned offload
// rather than the auto sizer's numbers. Mirrors the cost model in plan.go.
//
// ckptGB is sizeProfile's checkpoint reserve and MUST be charged here too, the
// same way sizeProfile charged it into the plan: flat VRAM on MoE (KV and its
// snapshots stay GPU-resident under --n-cpu-moe), folded into the per-layer
// reserve on dense (a CPU layer keeps its KV — and its checkpoint share — in
// RAM). Omitting it silently under-reported every pinned placement by the
// checkpoint reserve, so estVram + estRam no longer summed to the auto plan's
// total and the config baked an optimistic estVramGB for the router to admit
// against.
func estForOffload(meta Metadata, prof profile, kvReserve, ckptGB float64, ngl, ncpuMoe int) (estVram, estRam float64) {
	size := meta.FileSizeGB
	blocks := float64(meta.BlockCount)
	overhead := prof.Overhead
	if blocks <= 0 {
		return prof.Target, 0
	}
	if meta.IsMoE {
		share := effectiveShare(meta, genMoeShareFor)
		nonExpert := size * (1.0 - share)
		expertGpuFrac := (blocks - float64(ncpuMoe)) / blocks
		estVram = nonExpert + size*share*expertGpuFrac + kvReserve + ckptGB + overhead
		estRam = size * share * (float64(ncpuMoe) / blocks)
		return round(estVram, 2), round(estRam, 2)
	}
	gpuFrac := float64(ngl) / blocks
	if gpuFrac > 1 {
		gpuFrac = 1
	}
	estVram = gpuFrac*(size+kvReserve+ckptGB) + overhead
	estRam = (1 - gpuFrac) * (size + kvReserve + ckptGB)
	return round(estVram, 2), round(estRam, 2)
}

// Empirical compute-graph constants. With flash attention on (the default), the
// CUDA compute buffer is dominated by the logits/output tensor plus a handful of
// n_ubatch*n_embd activation copies; computeCudaCtxGB covers the fixed CUDA
// runtime + cuBLAS workspace. The activation-copy count is a coarse fit, so
// Settings.ComputeBufFactor scales the whole analytic term for per-build/arch
// calibration against the "compute buffer size" llama logs.
//
// computeLogitsTokens caps the vocab-scaled term at n_vocab*THIS*4. The output
// TENSOR is sized by n_outputs (~1 in prefill) — but empirically the CUDA compute
// buffer for a large-vocab model still grows with the physical batch (output-
// projection / cuBLAS workspace tiled over the ubatch). Measured on Qwen3.6-35B-A3B
// (vocab 248320, embd 2048) on an 8GB card: at ub=512 the model fits its budget, at
// ub=1024 it spills ~0.5GB into shared memory. A 256 cap made the estimate nearly
// ub-blind (+31MB from 512->1024) so the sizer never charged the extra ub cost and
// over-committed VRAM. Cap at 1024 so the term scales with ub across the useful
// range (vocab*1024*4 ~1.0GB, giving the observed +0.5GB from 512->1024) and stops
// the overfill. ponytail: ceiling is 1024 (== the common max ub); revisit if ub>1024
// is used, and dial Settings.ComputeBufFactor down if it over-offloads other models.
const (
	computeActCopies    = 8.0
	computeCudaCtxGB    = 0.3
	computeVulkanCtxGB  = 0.4
	computeRocmCtxGB    = 0.8
	computeLogitsTokens = 1024.0
	computeFallbackGB   = 0.17 // vocab/embd dims missing => prior flat estimate
)

// computeVulkanCtxGB / computeRocmCtxGB are the same fixed per-PROCESS runtime
// cost for a non-CUDA build: the runtime's own context, its kernel code objects
// and the BLAS workspace, none of which the analytic graph term below covers.
// It used to be charged as 0, on the reasoning that the CUDA figure would not
// transfer. It transfers and then some.
//
// The two are SPLIT because they are not the same number, and lumping them under
// one "non-CUDA" constant made the sizer wrong on exactly one of the two:
//
//   - Vulkan, 0.4. Measured at PEAK on an RX 7900 XTX (b10405-vulkan,
//     Qwen3.6-27B UD-Q4_K_XL, ctx 102400, -ngl 99): 20.27 GB dedicated + 2.11 GB
//     shared = 22.38 GB real against a ~22.4 GB estimate charging 0.4. Residual
//     ~0, so this side is calibrated.
//   - ROCm/HIP, 0.8. Measured at PEAK on the same card (lemonade
//     b1328-llama-windows-rocm-gfx110x, Qwen3.8-27B-GSQ-RCO IQ3_S + baked MTP,
//     ctx 151552, -ngl 99): ~0.4 GB MORE than a 19.12 GB estimate that already
//     charged 0.4. Same card, same measurement method, twice the constant.
//
// The mechanism is why the split is a model and not a fudge: hipBLASLt/rocBLAS
// load Tensile kernel code objects for the target arch into VRAM, and Vulkan has
// no equivalent (its shader pipelines are far smaller). The ROCm libraries load
// them LAZILY, on the first real GEMM, which is also why the earlier ROCm
// numbers that produced the 0.4 figure came in low: they were taken on an idle
// process, before any kernel library had been paged in. See the PEAK warning
// below - it applies to this constant's own derivation, not just to the graph
// term.
//
// Each side is one measurement. A third data point on either runtime should
// move the number rather than be explained away.

// Do NOT scale this model down on Vulkan/ROCm without measuring PEAK, not idle.
// An idle process (post-load, one short prompt) holds ~1.9GB less than the same
// process mid-generation: context checkpoints, the exercised compute buffer, and
// the MTP draft context only materialize under a real prompt. Measured on an RX
// 7900 XTX (b10405-vulkan, Qwen3.6-27B UD-Q4_K_XL, ctx 102400, -ngl 99): idle
// 20.51GB dedicated, but generating 20.27GB dedicated + 2.11GB SHARED = 22.38GB
// real footprint against a ~22.4GB estimate. Comparing the idle figure against a
// peak-modeling estimate makes the compute term look ~1.6GB fat when it is not,
// and a factor derived that way plans straight into a driver spill.

// computeGraphGB is the per-CONTEXT half of the compute buffer: the logits
// tensor plus a handful of n_ubatch*n_embd activation copies. It is split out
// from computeBufferGB because a second llama_context over the same model (the
// baked-in MTP drafter, see mtpDraftComputeGB) allocates its own graph while
// sharing the process-wide runtime constant. ok=false means the gguf carried no
// vocab/embd dims to model it with.
func computeGraphGB(meta Metadata, ub int, factor float64) (float64, bool) {
	if factor <= 0 {
		factor = 1.0
	}
	embd := float64(meta.EmbeddingLength)
	vocab := float64(meta.VocabSize)
	if embd <= 0 || vocab <= 0 || ub <= 0 {
		return 0, false
	}
	logits := vocab * math.Min(float64(ub), computeLogitsTokens) * 4.0
	acts := float64(ub) * embd * computeActCopies * 4.0
	return factor * (logits + acts) / gib, true
}

// runtimeCtxGB is the fixed per-PROCESS GPU-runtime cost, picked by the backend
// actually in use: the three runtimes reserve different amounts, and all three
// were measured rather than assumed. Charged once per llama-server, NOT once per
// llama_context - a second context (the MTP drafter) shares the process.
//
// CUDA is decided by the GPU VENDOR (DetectGpuCompute), ROCm-vs-Vulkan by the
// BACKEND BINARY (NoteBackendRuntime), because the same AMD card runs either and
// they differ by 0.4 GB. Vulkan is the non-CUDA default: it is what the installer
// picks for a discrete GPU, and it is the cheaper of the two, so an unrecognised
// path never silently inflates every model's estimate.
func runtimeCtxGB() float64 {
	if usingCudaGPU() {
		return computeCudaCtxGB
	}
	if usingRocmBackend() {
		return computeRocmCtxGB
	}
	return computeVulkanCtxGB
}

// computeBufferGB estimates the GPU compute buffer for a given physical batch
// (ub): the main context's graph plus the process-wide runtime constant. This
// lives on the GPU regardless of CPU expert offload, so it is charged as flat
// VRAM overhead.
func computeBufferGB(meta Metadata, ub int, factor float64) float64 {
	graph, ok := computeGraphGB(meta, ub, factor)
	if !ok {
		return computeFallbackGB
	}
	return runtimeCtxGB() + graph
}

// clipComputeBufferGB models the CLIP vision tower's peak GPU compute buffer from
// the mmproj's own hparams, so a "-vision" twin's reserve scales per projector
// rather than a flat pad. The vision graph runs standard (non-flash) attention, so
// the n_head × n_patches² score matrix dominates, plus per-patch FFN+hidden
// activations, × a graph factor for ggml's intermediate copies. n_patches is the
// base image_size/patch_size grid. Qwen-VL dynamic resolution can exceed the base
// tile on a large image, but the spawn-time offload guard REFUSES an over-budget
// load rather than OOMing, so sizing for the base (typical) tile trades a little
// big-image headroom for not permanently over-reserving. Returns 0 when the gguf
// carries no vision dims (caller falls back to the flat VisionOverheadGB).
func clipComputeBufferGB(m Metadata) float64 {
	if m.VisionImageSize <= 0 || m.VisionPatchSize <= 0 || m.VisionEmbd <= 0 {
		return 0
	}
	grid := m.VisionImageSize / m.VisionPatchSize
	nPatch := float64(grid * grid)
	heads := float64(m.VisionHeads)
	if heads <= 0 {
		heads = 1
	}
	ffn := float64(m.VisionFFN)
	if ffn <= 0 {
		ffn = 4 * float64(m.VisionEmbd)
	}
	const bytesF32, graphFactor = 4.0, 1.3
	kq := heads * nPatch * nPatch * bytesF32                 // attention score matrix (peak)
	act := nPatch * (ffn + float64(m.VisionEmbd)) * bytesF32 // ffn + hidden activations
	return graphFactor * (kq + act) / gib
}

// MmprojVramGB is a "-vision" twin's total projector VRAM footprint: the projector
// gguf's own weights (fileSizeGB — resident on GPU by default) plus its CLIP
// compute buffer. The buffer is modeled per-projector from the mmproj hparams
// (clipComputeBufferGB, read via ReadGgufMetadataCached) when mmprojPath is
// available; otherwise it falls back to the flat s.VisionOverheadGB. Charged at
// BOTH generate time (baked plan) and spawn time (LiveOffloadArgs via
// EstimateInput.MmprojGB), so the live guard sizes the twin against the same
// footprint the config assumed.
func MmprojVramGB(mmprojPath string, fileSizeGB float64, s Settings) float64 {
	buf := s.VisionOverheadGB
	if mmprojPath != "" {
		if mm, err := ReadGgufMetadataCached(mmprojPath); err == nil {
			if b := clipComputeBufferGB(mm); b > 0 {
				buf = b
			}
		}
	}
	return fileSizeGB + buf
}

// mtpDraftPadGB is what is left of a baked-in MTP drafter's footprint once the
// two modeled parts are taken out: its KV is ctx-scaled by mtpDraftSlopeGB and
// carried in profile.DraftSlopeGB, its compute graph is modeled from the gguf
// dims by mtpDraftComputeGB. This pad covers only the second llama_context's
// remaining fixed allocations (its scheduler, its state buffers). The flat
// 0.34 GB it replaced tried to be all three at once and was ~3x too fat at a 32k
// window while under-charging by half at 160k.
const mtpDraftPadGB = 0.15

// draftOverheadGB returns the CTX-INDEPENDENT VRAM overhead to charge for the
// active spec chain's draft model. A baked-in MTP nextn layer with no separate
// weights file costs only the compute pad here (mtpDraftPadGB); its KV rides on
// the ctx-scaled slope instead, see mtpDraftSlopeFor. A separate draft gguf — an MTP
// sidecar (Gemma-4) or any DFlash block-diffusion drafter, which is always a
// separate file — charges its real on-disk weight size plus a small
// KV/compute pad instead, so large drafts scale up rather than under-counting.
func draftOverheadGB(spec string, draftSizeGB float64) float64 {
	switch {
	case specHas(spec, "draft-dflash"):
		if draftSizeGB > 0 {
			return draftSizeGB + 0.1
		}
		// No sidecar attached (broken config: --spec-type draft-dflash with no
		// -md). Nothing loads, so charge nothing rather than a bare pad.
		return 0
	case specHas(spec, "draft-mtp"):
		if draftSizeGB > 0 {
			return draftSizeGB + 0.1
		}
		return mtpDraftPadGB
	default:
		return 0
	}
}

// matchedDraftSizeGB returns a paired sidecar's on-disk size only when its kind
// is the one the active spec chain will actually load as -md, mirroring the
// mdMatches rule in buildCmdLines; 0 otherwise.
//
// Both conditions matter: a model dir can hold a DFlash drafter while the model
// runs on a baked-in MTP head (Qwen3.8-27B does). Charging the sidecar there
// reserved ~1 GB of VRAM for a draft the emitted cmd never attaches, which cost
// real -ngl/ctx. Feeding 0 through drops the charge back to the baked-in head
// (draftOverheadGB's pad plus the ctx-scaled mtpDraftSlopeFor), which is what
// actually loads.
func matchedDraftSizeGB(spec, draftKind string, draftSizeGB float64) float64 {
	if (specHas(spec, "draft-mtp") && draftKind == "mtp") ||
		(specHas(spec, "draft-dflash") && draftKind == "dflash") {
		return draftSizeGB
	}
	return 0
}

// mtpDraftSlopeFor returns the per-token VRAM slope (GB/token) that the active
// spec chain's BAKED-IN MTP drafter adds on top of the main KV cache, or 0 when
// no such drafter loads.
//
// Only the baked-in case scales this way: a separate draft gguf is a different
// model with its own dims, charged its on-disk weights plus a pad by
// draftOverheadGB, so a non-zero draftSizeGB means "not our case".
//
// kvKDraft/kvVDraft are the DRAFT cache types (-ctkd/-ctvd), which buildCmdLines
// now emits defaulted to the model's own -ctk/-ctv. Pass what will actually be
// emitted rather than the main quant: llama's own default for the draft context
// is f16 whatever -ctk says, so a hand-written argv with no -ctkd really pays f16.
func mtpDraftSlopeFor(meta Metadata, spec, kvKDraft, kvVDraft string, draftSizeGB float64) float64 {
	if !specHas(spec, "draft-mtp") || draftSizeGB > 0 {
		return 0
	}
	return mtpDraftSlopeGB(meta, kvKDraft, kvVDraft)
}

// mtpDraftComputeGB is the compute-graph VRAM the BAKED-IN MTP drafter's own
// llama_context allocates, on top of the main model's. Same gate as
// mtpDraftSlopeFor: only the baked-in nextn head runs a second context over the
// target model, and only when no separate draft gguf is attached.
//
// It is charged the FULL graph term, not a share of it. The drafter runs one
// nextn layer instead of sixty-five, but the graph is dominated by the logits
// tensor (n_vocab * ub * 4), and the draft context inherits params_base, so it
// projects over the SAME vocabulary at the SAME ubatch as the target: on
// Qwen3.8-27B (vocab 248320, ub 512) that term alone is 0.47 GiB of the 0.55 GiB
// buffer. The activation half is layer-independent too (ggml-alloc reuses one
// set of n_ubatch*n_embd scratch tensors down the stack), so the only thing this
// over-charges is a rounding error's worth of per-layer scratch.
//
// What this replaces: mtpDraftPadGB alone, a flat 0.15 GB that was meant to
// cover exactly this and was ~4x short. Measured against PDH per-process
// dedicated VRAM on an RX 7900 XTX (ROCm), Qwen3.8-27B at ctx 114688 held
// 15.82 GiB against 13.95 GiB of modeled components; ~0.4 of that gap is the
// process runtime constant (runtimeCtxGB) and the drafter's graph is the
// largest single piece of what is left. The pad stays on top for the second
// context's non-graph allocations (its scheduler, its own state buffers).
//
// Returns 0 when the dims are missing, leaving draftOverheadGB's flat pad as the
// whole charge, exactly as computeBufferGB falls back to computeFallbackGB.
func mtpDraftComputeGB(meta Metadata, spec string, draftSizeGB float64, ub int, factor float64) float64 {
	if !specHas(spec, "draft-mtp") || draftSizeGB > 0 {
		return 0
	}
	graph, ok := computeGraphGB(meta, ub, factor)
	if !ok {
		return 0
	}
	return graph
}

// draftKvPair resolves the -ctkd/-ctvd pair a launch will run with: the model's
// own K/V quant unless the override pins the draft side separately.
// buildCmdLines emits exactly this pair, so sizing and emission cannot drift.
func draftKvPair(ovKvKDraft, ovKvVDraft, kvK, kvV string) (string, string) {
	return resolveKvPair(ovKvKDraft, ovKvVDraft, kvK, kvV)
}