From 5ba3b0c4d82999f89a6239e1478ba966df1b9f76 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 17 Jul 2026 15:40:26 +0530 Subject: [PATCH 1/3] initial version of cost metrics in cli --- README.md | 45 ++- ai-studio-cli/cmd/bench.go | 34 +- ai-studio-cli/cmd/cost.go | 64 ++++ ai-studio-cli/cmd/metadata.go | 8 + ai-studio-cli/internal/benchui/ui/app.js | 118 +++++++ ai-studio-cli/internal/benchui/ui/index.css | 2 +- ai-studio-cli/internal/benchui/ui/index.html | 54 +++ ai-studio-cli/internal/cost/engine.go | 347 +++++++++++++++++++ ai-studio-cli/internal/cost/engine_test.go | 200 +++++++++++ ai-studio-cli/internal/cost/pricing.json | 89 +++++ ai-studio-cli/internal/power/sampler.go | 106 ++++++ 11 files changed, 1063 insertions(+), 4 deletions(-) create mode 100644 ai-studio-cli/cmd/cost.go create mode 100644 ai-studio-cli/internal/cost/engine.go create mode 100644 ai-studio-cli/internal/cost/engine_test.go create mode 100644 ai-studio-cli/internal/cost/pricing.json create mode 100644 ai-studio-cli/internal/power/sampler.go diff --git a/README.md b/README.md index 6f1ad97..d387a04 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,48 @@ Results are automatically saved to a structured directory in the following forma | `--compose-file`| | Custom docker-compose.yml; overrides the built-in default | | `--keep-server` | `false` | Leave the vLLM server running after the benchmark | | `--server-timeout`| `900` | Seconds to wait for the vLLM server to become ready | +| `--cost` | `true` | Compute rent-vs-buy cost metrics and embed them in the result JSON | +| `--system-bundle` | | Named system bundle to price the owned node as an all-in system (e.g. `PRU2500_8x5090`) | +| `--pue` | *(config default)* | Power Usage Effectiveness for owned-side electricity | +| `--avg-gpu-watts` | *(sampled)* | Override GPU power draw (W/GPU); `0` samples live via `nvidia-smi` | +| `--pricing-file` | *(embedded)* | External `hardware_pricing.json` to override the embedded pricing config | + +--- + +### Cost Metrics (Rent vs Buy) + +When `--cost` is enabled (default), each benchmark run is enriched with a `cost_metrics` +block in `benchmark_result.json` and surfaced in the dashboard. It answers "is it cheaper +to own the CoreSpan node or rent equivalent GPUs?" using the run's real duration, token +counts, GPU count (TP×PP), and GPU power. + +**Owned cost is fully loaded** — amortized hardware CapEx **plus electricity** (PUE-adjusted, +US industrial rate) — so it is a fair comparison against rental rates, which already bundle power. + +- **Power** is the **total node draw measured live** during the run via `nvidia-smi` (busy + idle + GPUs = what you actually pay at the wall). If sampling is unavailable it falls back to the + per-GPU default in the pricing config, or to `--avg-gpu-watts`. +- **System bundles** contribute a **per-GPU** all-in price scaled by the GPUs the run actually + used, so a 4-GPU run (`TP1×PP4`, the default compose) against the 8-GPU `PRU2500_8x5090` bundle + prices 4 GPUs — while a full 8-GPU run reproduces the whole-node bundle price exactly. +- Output includes cost-per-1M-tokens (3:1 output/input weighting) and a rent-vs-buy ladder across + market tiers (spot → premium), each with a `buy`/`rent` verdict. + +**Supported GPUs** (auto-detected from `nvidia-smi`): RTX 5090, RTX 5070, Tesla T4, A100, H100 +(others fall back to `DEFAULT` pricing). The RTX 5090 + `PRU2500_8x5090` bundle reproduces the +published [rent-or-buy blog](https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500) +figures; RTX 5090 / 5070 / T4 carry a full rent-vs-buy tier ladder. + +```bash +# Price the owned side as the all-in PRU-2500 + 8x RTX 5090 system +ai-studio-cli bench --requests 500 --concurrency 32 --system-bundle PRU2500_8x5090 + +# Use a fixed power figure and a custom pricing file instead of live sampling +ai-studio-cli bench --avg-gpu-watts 550 --pricing-file ./hardware_pricing.json +``` + +> Cost dollars scale with the run window, so a short benchmark shows small amounts; the +> cost-per-1M-tokens and the buy/rent verdict are the run-scoped signals to read. --- @@ -234,8 +276,9 @@ ai-studio-cli bench-ui --result-dir /path/to/bench-results ``` The dashboard provides: -- **KPI cards** — Total throughput, output throughput, TTFT, and TPOT at a glance +- **KPI cards** — Total throughput, output throughput, TTFT, TPOT, and the rent-vs-buy verdict (at the market-median tier) at a glance - **Performance trend chart** — Visualise metrics across runs with configurable Y-axis +- **Cost analysis panel** — Owned (fully-loaded) cost, electricity, blended $/1M tokens, avg GPU power, and the full rent-vs-buy tier ladder with per-tier `buy`/`rent` verdicts - **Model config bar** — Shows TP/PP, quantization, dtype, max model length, GPU memory utilization - **Filterable table** — Filter by model, GPU, and precision; search across all runs - **Sidebar navigation** — Quickly switch between benchmark runs diff --git a/ai-studio-cli/cmd/bench.go b/ai-studio-cli/cmd/bench.go index 5ad491b..5060869 100644 --- a/ai-studio-cli/cmd/bench.go +++ b/ai-studio-cli/cmd/bench.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/corespan/ai-studio-cli/internal/power" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -52,6 +53,17 @@ type benchRunner struct { gpuTag string openUI bool uiPort int + + // cost metrics + costEnabled bool + systemBundle string + pue float64 + pricingFile string + avgGPUWattsOverride float64 + + // populated at runtime + measuredNodeWatts float64 // total node draw sampled during the run + resolvedGPU string } var bench = &benchRunner{} @@ -103,6 +115,12 @@ func (r *benchRunner) registerFlags(cmd *cobra.Command) { // Dashboard cmd.Flags().BoolVar(&r.openUI, "ui", true, "On an interactive terminal, launch the results dashboard when the run finishes and print a clickable link (blocks until Ctrl+C); auto-skipped for non-interactive/CI runs, or pass --ui=false") cmd.Flags().IntVar(&r.uiPort, "ui-port", 9090, "Port for the dashboard launched by --ui") + // Cost metrics + cmd.Flags().BoolVar(&r.costEnabled, "cost", true, "Compute rent-vs-buy cost metrics and embed them in the result JSON") + cmd.Flags().StringVar(&r.systemBundle, "system-bundle", "", "Named system bundle from the pricing config (e.g. PRU2500_8x5090) to price the owned node as an all-in system") + cmd.Flags().Float64Var(&r.pue, "pue", 0, "Power Usage Effectiveness for owned-side electricity (0 = use pricing config default)") + cmd.Flags().StringVar(&r.pricingFile, "pricing-file", "", "Path to an external hardware_pricing.json to override the embedded pricing config") + cmd.Flags().Float64Var(&r.avgGPUWattsOverride, "avg-gpu-watts", 0, "Override GPU power draw (watts per GPU) for cost energy; 0 = sample live via nvidia-smi") } // entry point @@ -155,6 +173,7 @@ func (r *benchRunner) runBenchmark(cmd *cobra.Command, composePath string) error if gpu == "" { gpu = detectGPUTag() } + r.resolvedGPU = gpu timestamp := time.Now().Format("2006-01-02T15-04-05") structuredDir := filepath.Join(r.resultDir, sanitizePath(modelName), sanitizePath(gpu), timestamp) @@ -174,8 +193,19 @@ func (r *benchRunner) runBenchmark(cmd *cobra.Command, composePath string) error return fmt.Errorf("creating result directory: %w", err) } - if err := r.execute(cmd.Context(), args, structuredDir); err != nil { - return err + // Sample GPU power during the run so the cost engine can charge realistic + // owned-side electricity (no telemetry stack exists otherwise). + var sampler *power.Sampler + if r.costEnabled && r.avgGPUWattsOverride <= 0 { + sampler = power.NewSampler(2 * time.Second) + sampler.Start() + } + runErr := r.execute(cmd.Context(), args, structuredDir) + if sampler != nil { + r.measuredNodeWatts = sampler.Stop() + } + if runErr != nil { + return runErr } resultPath := filepath.Join(structuredDir, r.resultFilename) diff --git a/ai-studio-cli/cmd/cost.go b/ai-studio-cli/cmd/cost.go new file mode 100644 index 0000000..30bb113 --- /dev/null +++ b/ai-studio-cli/cmd/cost.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + + "github.com/corespan/ai-studio-cli/internal/cost" +) + +// injectCostMetrics computes rent-vs-buy cost metrics from the benchmark result +// and adds them under raw["cost_metrics"]. It reads token/duration figures from +// the vLLM result, GPU count from the model config (TP*PP), and owned-side power +// from the live sample (falling back to the pricing config's default wattage). +func (r *benchRunner) injectCostMetrics(raw map[string]interface{}, cfg ModelConfig) error { + engine, err := cost.NewEngine(r.pricingFile) + if err != nil { + return fmt.Errorf("loading pricing config: %w", err) + } + + duration := toFloat(raw["duration"]) + inTok := toFloat(raw["total_input_tokens"]) + outTok := toFloat(raw["total_output_tokens"]) + if duration <= 0 || (inTok+outTok) <= 0 { + return fmt.Errorf("result missing duration/token fields; skipping cost metrics") + } + + numGPUs := cfg.TensorParallel * cfg.PipelineParallel + if numGPUs < 1 { + numGPUs = 1 + } + + gpuName := r.resolvedGPU + if gpuName == "" { + gpuName = detectGPUTag() + } + + // Power precedence: explicit per-GPU override > live-measured node total > + // per-GPU default from the pricing config. + var perGPUWatts, measuredNodeWatts float64 + switch { + case r.avgGPUWattsOverride > 0: + perGPUWatts = r.avgGPUWattsOverride + case r.measuredNodeWatts > 0: + measuredNodeWatts = r.measuredNodeWatts + default: + perGPUWatts = engine.DefaultWatts(gpuName) + } + + result := engine.CalculateLLM(cost.LLMInput{ + GPUName: gpuName, + NumGPUs: numGPUs, + DurationSeconds: duration, + AvgPowerWatts: perGPUWatts, + MeasuredNodeWatts: measuredNodeWatts, + TotalInputTokens: inTok, + TotalOutputTokens: outTok, + Infra: cost.InfraConfig{ + SystemBundle: r.systemBundle, + PUE: r.pue, + }, + }) + + raw["cost_metrics"] = result + return nil +} diff --git a/ai-studio-cli/cmd/metadata.go b/ai-studio-cli/cmd/metadata.go index 990f2a8..5e49465 100644 --- a/ai-studio-cli/cmd/metadata.go +++ b/ai-studio-cli/cmd/metadata.go @@ -32,6 +32,14 @@ func (r *benchRunner) patchResultJSON(resultPath, composePath string) error { raw["total_token_throughput"] = outTP + inTP } + if r.costEnabled { + if err := r.injectCostMetrics(raw, cfg); err != nil { + fmt.Printf(" [cost] warning: %v\n", err) + } else { + fmt.Println(" [cost] rent-vs-buy metrics added to result JSON") + } + } + out, err := json.MarshalIndent(raw, "", " ") if err != nil { return fmt.Errorf("marshalling patched result: %w", err) diff --git a/ai-studio-cli/internal/benchui/ui/app.js b/ai-studio-cli/internal/benchui/ui/app.js index 2f1d376..6e6bc08 100644 --- a/ai-studio-cli/internal/benchui/ui/app.js +++ b/ai-studio-cli/internal/benchui/ui/app.js @@ -37,6 +37,7 @@ valOutputThroughput: $('#val-output-throughput'), valTtft: $('#val-ttft'), valTpot: $('#val-tpot'), + valVerdict: $('#val-verdict'), }; // ----- API ----- @@ -233,6 +234,7 @@ state.selectedRun = await fetchRunDetail(model, gpu, timestamp, signal); renderKPIs(); renderConfigBar(); + renderCost(); } catch (err) { if (err.name === 'AbortError') return; console.error('Error loading run detail:', err); @@ -248,6 +250,24 @@ dom.valOutputThroughput.textContent = fmtNum(r.output_throughput); dom.valTtft.textContent = fmtNum(r.mean_ttft_ms); dom.valTpot.textContent = fmtNum(r.mean_tpot_ms); + renderVerdictKPI(); + } + + // Rent-vs-buy verdict at the market-median tier, shown in the top KPI row. + function renderVerdictKPI() { + const r = state.selectedRun; + const cm = r && r.cost_metrics; + const ladder = cm && cm.rent_vs_buy_analysis; + const tiers = ladder && Array.isArray(ladder.tiers) ? ladder.tiers : []; + const median = tiers.find(t => t.tier === 'dedicated_median'); + if (!median) { + dom.valVerdict.textContent = '—'; + dom.valVerdict.style.color = ''; + return; + } + const buy = median.verdict === 'buy'; + dom.valVerdict.textContent = buy ? 'BUY' : 'RENT'; + dom.valVerdict.style.color = buy ? 'hsl(142, 60%, 55%)' : 'hsl(38, 90%, 60%)'; } // ----- Config Bar ----- @@ -278,6 +298,59 @@ dom.cfgGmem.textContent = mc.gpu_memory_utilization ? `mem ${mc.gpu_memory_utilization}` : ''; } + // ----- Cost Analysis ----- + function renderCost() { + const r = state.selectedRun; + const cm = r && r.cost_metrics; + const sec = $('#cost-section'); + if (!sec) return; + + const ladder = cm && cm.rent_vs_buy_analysis; + if (!cm || !ladder || !Array.isArray(ladder.tiers) || ladder.tiers.length === 0) { + sec.classList.add('hidden'); + return; + } + sec.classList.remove('hidden'); + + $('#cost-owned').textContent = fmtUSD(ladder.owned_fully_loaded_usd); + $('#cost-energy').textContent = fmtUSD(ladder.owned_energy_cost_usd); + $('#cost-blended').textContent = cm.corespan_infra + ? fmtUSD(cm.corespan_infra.blended_cost_per_1m_tokens_usd, 2) + : '—'; + $('#cost-watts').textContent = fmtNum(cm.avg_gpu_power_watts); + + $('#cost-ladder-body').innerHTML = ladder.tiers.map(t => { + const buy = t.verdict === 'buy'; + const cls = buy ? 'verdict-buy' : 'verdict-rent'; + const sign = Number(t.savings_usd_vs_buy) >= 0 ? '+' : '−'; + const mag = fmtUSD(Math.abs(Number(t.savings_usd_vs_buy))); + return ` + ${esc(t.label || t.tier)} + $${fmtNum(t.rental_hourly_usd)} + ${fmtUSD(t.rental_total_usd)} + ${sign}${mag} + ${buy ? 'BUY' : 'RENT'} + `; + }).join(''); + + const median = ladder.tiers.find(t => t.tier === 'dedicated_median'); + const vb = $('#cost-verdict'); + if (median) { + const buy = median.verdict === 'buy'; + vb.textContent = buy ? 'BUY wins at market median' : 'RENT wins at market median'; + vb.className = 'cost-verdict-badge ' + (buy ? 'verdict-buy' : 'verdict-rent'); + } else { + vb.textContent = ''; + vb.className = 'cost-verdict-badge'; + } + + const fn = $('#cost-footnote'); + if (fn) { + const capex = fmtUSD(ladder.owned_capex_only_usd); + fn.textContent = `Owned = amortized CapEx (${capex}) + electricity over a ${fmtNum(ladder.window_hours)}h window · ${ladder.effective_num_gpus} GPU(s) · pricing as of ${cm.pricing_source_date || 'n/a'}.`; + } + } + // ----- Chart ----- function renderChart() { const metricKey = dom.filterMetric.value; @@ -459,6 +532,17 @@ return isNaN(num) ? '—' : num.toFixed(2); } + // fmtUSD formats a dollar amount; whole dollars by default, or `decimals` places. + function fmtUSD(val, decimals) { + if (val == null) return '—'; + const num = Number(val); + if (isNaN(num)) return '—'; + const opts = decimals != null + ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } + : { maximumFractionDigits: 0 }; + return '$' + num.toLocaleString(undefined, opts); + } + function formatTimestamp(ts) { if (!ts) return '—'; return ts.replace(/T/, ' ').replace(/-(\d{2})-(\d{2})$/, ':$1:$2'); @@ -496,6 +580,40 @@ color: var(--accent); border: 1px solid var(--border-accent); } + #cost-section { margin-top: var(--space-lg, 16px); } + .cost-kpi-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-md, 12px); + margin-bottom: var(--space-lg, 16px); + } + .cost-ladder-table td { vertical-align: middle; } + .verdict-buy { color: hsl(142, 60%, 55%); } + .verdict-rent { color: hsl(38, 90%, 60%); } + .verdict-chip { + display: inline-flex; + align-items: center; + padding: 0.15em 0.6em; + font-size: 0.72rem; + font-weight: 700; + font-family: var(--font-mono); + border-radius: 999px; + border: 1px solid currentColor; + } + .cost-verdict-badge { + font-size: 0.75rem; + font-weight: 700; + font-family: var(--font-mono); + padding: 0.2em 0.7em; + border-radius: 999px; + border: 1px solid currentColor; + } + .cost-footnote { + margin-top: var(--space-md, 12px); + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-sans); + } .error-toast { position: fixed; top: var(--space-lg); diff --git a/ai-studio-cli/internal/benchui/ui/index.css b/ai-studio-cli/internal/benchui/ui/index.css index 2e86b6a..8ad5070 100644 --- a/ai-studio-cli/internal/benchui/ui/index.css +++ b/ai-studio-cli/internal/benchui/ui/index.css @@ -331,7 +331,7 @@ body { #kpi-row { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: var(--space-md); flex-shrink: 0; } diff --git a/ai-studio-cli/internal/benchui/ui/index.html b/ai-studio-cli/internal/benchui/ui/index.html index 4e81280..d10186a 100644 --- a/ai-studio-cli/internal/benchui/ui/index.html +++ b/ai-studio-cli/internal/benchui/ui/index.html @@ -77,6 +77,11 @@

Benchmarks

ms +
+ Rent vs Buy + + at market median +
@@ -138,6 +143,55 @@

Configuration

+ + +
diff --git a/ai-studio-cli/internal/cost/engine.go b/ai-studio-cli/internal/cost/engine.go new file mode 100644 index 0000000..c5aa65f --- /dev/null +++ b/ai-studio-cli/internal/cost/engine.go @@ -0,0 +1,347 @@ +// Package cost ports the AI-ML workbench CostEngine to Go for the aistudio-cli +// vLLM benchmark flow. It computes LLM cost-per-1M-tokens (3:1 output weighting) +// and a rent-vs-buy ladder that reproduces the CoreSpan RTX 5090 / PRU-2500 blog +// findings. The owned side is "fully loaded" (amortized CapEx + electricity) so it +// is a fair comparison against rental rates, which already bundle power. +package cost + +import ( + _ "embed" + "encoding/json" + "fmt" + "math" + "os" + "sort" + "strings" +) + +//go:embed pricing.json +var embeddedPricing []byte + +// ---- Config schema (mirrors hardware_pricing.json) ---- + +type HWSpec struct { + CapexUSD float64 `json:"capex_usd"` + LifespanYears float64 `json:"lifespan_years"` + DefaultWatts float64 `json:"default_watts,omitempty"` +} + +type BundleSpec struct { + Description string `json:"description"` + CapexUSD float64 `json:"capex_usd"` + LifespanYears float64 `json:"lifespan_years"` + NumGPUs int `json:"num_gpus"` + GPUModel string `json:"gpu_model"` +} + +type Tier struct { + USDHr float64 `json:"usd_hr"` + Label string `json:"label"` +} + +type MarketRate struct { + HyperscalerSKU string `json:"hyperscaler_sku"` + RentalHourlyUSD float64 `json:"rental_hourly_usd"` + NeocloudBlendedUSD float64 `json:"neocloud_blended_hourly_usd"` + HyperscalerHourlyUSD float64 `json:"hyperscaler_hourly_usd"` + RentalTiers map[string]Tier `json:"rental_tiers"` +} + +type Config struct { + Meta struct { + LastUpdatedUTC string `json:"last_updated_utc"` + ElectricityKWhUSD float64 `json:"electricity_kwh_usd"` + PUE float64 `json:"pue"` + ElectricitySource string `json:"electricity_source"` + } `json:"meta"` + CorespanInventory struct { + GPUs map[string]HWSpec `json:"gpus"` + SystemBundles map[string]BundleSpec `json:"system_bundles"` + } `json:"corespan_inventory"` + MarketRates struct { + GPUs map[string]MarketRate `json:"gpus"` + } `json:"market_rates"` +} + +// ---- Inputs / outputs ---- + +type InfraConfig struct { + SystemBundle string + PUE float64 // 0 => use config default +} + +type LLMInput struct { + GPUName string + NumGPUs int // GPUs the workload actually used (e.g. TP*PP) + DurationSeconds float64 + AvgPowerWatts float64 // per-GPU estimate; used when no measured total is available + MeasuredNodeWatts float64 // total node draw measured live; when > 0 it wins over AvgPowerWatts + TotalInputTokens float64 + TotalOutputTokens float64 + Infra InfraConfig +} + +type TokenCost struct { + Blended float64 `json:"blended_cost_per_1m_tokens_usd"` + Input float64 `json:"input_cost_per_1m_tokens_usd"` + Output float64 `json:"output_cost_per_1m_tokens_usd"` +} + +type LadderTier struct { + Tier string `json:"tier"` + Label string `json:"label"` + RentalHourly float64 `json:"rental_hourly_usd"` + RentalTotal float64 `json:"rental_total_usd"` + SavingsVsBuy float64 `json:"savings_usd_vs_buy"` + Verdict string `json:"verdict"` +} + +type Ladder struct { + OwnedCapexOnly float64 `json:"owned_capex_only_usd"` + OwnedEnergyCost float64 `json:"owned_energy_cost_usd"` + OwnedFullyLoaded float64 `json:"owned_fully_loaded_usd"` + EffectiveNumGPUs int `json:"effective_num_gpus"` + WindowHours float64 `json:"window_hours"` + Tiers []LadderTier `json:"tiers"` +} + +type LLMResult struct { + GPUDetected string `json:"gpu_detected"` + PricingSourceDate string `json:"pricing_source_date"` + WorkloadDurationHours float64 `json:"workload_duration_hours"` + AvgGPUPowerWatts float64 `json:"avg_gpu_power_watts"` + OwnedCapexOnlyUSD float64 `json:"owned_capex_only_usd"` + OwnedEnergyCostUSD float64 `json:"owned_energy_cost_usd"` + OwnedFullyLoadedUSD float64 `json:"owned_fully_loaded_usd"` + BaselineSavingsUSD float64 `json:"baseline_savings_usd"` + CorespanInfra TokenCost `json:"corespan_infra"` + Rental TokenCost `json:"rental"` + Hyperscaler TokenCost `json:"hyperscaler"` + RentVsBuy Ladder `json:"rent_vs_buy_analysis"` +} + +// ---- Engine ---- + +type Engine struct { + cfg Config +} + +// NewEngine loads the embedded pricing config, or an external file when path is non-empty. +func NewEngine(externalPath string) (*Engine, error) { + raw := embeddedPricing + if externalPath != "" { + b, err := os.ReadFile(externalPath) + if err != nil { + return nil, fmt.Errorf("reading pricing file %q: %w", externalPath, err) + } + raw = b + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parsing pricing config: %w", err) + } + if len(cfg.CorespanInventory.GPUs) == 0 { + return nil, fmt.Errorf("pricing config has no GPU inventory") + } + return &Engine{cfg: cfg}, nil +} + +func (e *Engine) gpuKey(name string) string { + u := strings.ToUpper(name) + switch { + case strings.Contains(u, "A100"): + return "A100" + case strings.Contains(u, "H100"): + return "H100" + case strings.Contains(u, "5090"): + return "RTX 5090" + case strings.Contains(u, "5070"): + return "RTX 5070" + case strings.Contains(u, "T4"): + return "Tesla T4" + default: + return "DEFAULT" + } +} + +func amortizeHourly(capex, lifespanYears float64) float64 { + if lifespanYears <= 0 { + return 0 + } + return capex / (lifespanYears * 365 * 24) +} + +// DefaultWatts returns the fallback per-GPU wattage for a GPU when live sampling +// is unavailable. +func (e *Engine) DefaultWatts(gpuName string) float64 { + if spec, ok := e.cfg.CorespanInventory.GPUs[e.gpuKey(gpuName)]; ok && spec.DefaultWatts > 0 { + return spec.DefaultWatts + } + if spec, ok := e.cfg.CorespanInventory.GPUs["DEFAULT"]; ok && spec.DefaultWatts > 0 { + return spec.DefaultWatts + } + return 400 +} + +func (e *Engine) resolvePUE(infra InfraConfig) float64 { + if infra.PUE > 0 { + return infra.PUE + } + if e.cfg.Meta.PUE > 0 { + return e.cfg.Meta.PUE + } + return 1.0 +} + +// ownedEnergyCost is charged only to the owned side (rental rates already bundle +// power). It takes the *total* node watts, not per-GPU, so live measurements of a +// partially-used node are billed exactly as drawn. +func (e *Engine) ownedEnergyCost(totalWatts, hours float64, infra InfraConfig) float64 { + return (totalWatts / 1000.0) * e.resolvePUE(infra) * hours * e.cfg.Meta.ElectricityKWhUSD +} + +// resolveCorespanCapex returns the amortized owned CapEx for the window and the +// effective GPU count. A named system bundle contributes its *per-GPU* all-in +// price, scaled by the GPUs the workload actually used — so a 4-GPU run against +// an 8-GPU bundle prices 4 GPUs, while a full 8-GPU run reproduces the whole-node +// bundle price exactly. +func (e *Engine) resolveCorespanCapex(gpuKey string, numGPUs int, hours float64, infra InfraConfig) (float64, int) { + if numGPUs < 1 { + numGPUs = 1 + } + if infra.SystemBundle != "" { + if b, ok := e.cfg.CorespanInventory.SystemBundles[infra.SystemBundle]; ok { + perGPU := b.CapexUSD + if b.NumGPUs > 0 { + perGPU = b.CapexUSD / float64(b.NumGPUs) + } + return amortizeHourly(perGPU, b.LifespanYears) * float64(numGPUs) * hours, numGPUs + } + } + gpu, ok := e.cfg.CorespanInventory.GPUs[gpuKey] + if !ok { + gpu = e.cfg.CorespanInventory.GPUs["DEFAULT"] + } + return amortizeHourly(gpu.CapexUSD, gpu.LifespanYears) * float64(numGPUs) * hours, numGPUs +} + +func (e *Engine) marketRate(gpuKey string) MarketRate { + if m, ok := e.cfg.MarketRates.GPUs[gpuKey]; ok { + return m + } + return e.cfg.MarketRates.GPUs["DEFAULT"] +} + +// coreLadder builds the rent-vs-buy ladder from already-resolved owned CapEx and +// energy. Tiers are ordered ascending by rate for a stable, readable ladder. +func (e *Engine) coreLadder(gpuKey string, eff int, hours, capex, energy float64) Ladder { + fullyLoaded := capex + energy + market := e.marketRate(gpuKey) + + keys := make([]string, 0, len(market.RentalTiers)) + for k := range market.RentalTiers { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + return market.RentalTiers[keys[i]].USDHr < market.RentalTiers[keys[j]].USDHr + }) + + tiers := make([]LadderTier, 0, len(keys)) + for _, k := range keys { + t := market.RentalTiers[k] + rentalTotal := t.USDHr * float64(eff) * hours + savings := rentalTotal - fullyLoaded + verdict := "rent" + if savings > 0 { + verdict = "buy" + } + tiers = append(tiers, LadderTier{ + Tier: k, + Label: t.Label, + RentalHourly: t.USDHr, + RentalTotal: round(rentalTotal, 2), + SavingsVsBuy: round(savings, 2), + Verdict: verdict, + }) + } + + return Ladder{ + OwnedCapexOnly: round(capex, 2), + OwnedEnergyCost: round(energy, 2), + OwnedFullyLoaded: round(fullyLoaded, 2), + EffectiveNumGPUs: eff, + WindowHours: round(hours, 4), + Tiers: tiers, + } +} + +// RentVsBuyLadder is the per-GPU convenience entrypoint: pass average watts per +// GPU and it charges avgWattsPerGPU * (GPUs used) of power. +func (e *Engine) RentVsBuyLadder(gpuKey string, numGPUs int, hours, avgWattsPerGPU float64, infra InfraConfig) Ladder { + capex, eff := e.resolveCorespanCapex(gpuKey, numGPUs, hours, infra) + energy := e.ownedEnergyCost(avgWattsPerGPU*float64(eff), hours, infra) + return e.coreLadder(gpuKey, eff, hours, capex, energy) +} + +// CalculateLLM computes token-cost economics plus the rent-vs-buy ladder for a run. +func (e *Engine) CalculateLLM(in LLMInput) LLMResult { + gpuKey := e.gpuKey(in.GPUName) + hours := in.DurationSeconds / 3600.0 + + capex, eff := e.resolveCorespanCapex(gpuKey, in.NumGPUs, hours, in.Infra) + + // Prefer measured total node power; otherwise estimate from per-GPU watts. + totalWatts := in.MeasuredNodeWatts + if totalWatts <= 0 { + totalWatts = in.AvgPowerWatts * float64(eff) + } + energy := e.ownedEnergyCost(totalWatts, hours, in.Infra) + corespanTotal := capex + energy + + market := e.marketRate(gpuKey) + rentalTotal := market.RentalHourlyUSD * float64(eff) * hours + hyperTotal := market.HyperscalerHourlyUSD * float64(eff) * hours + + totalTokens := in.TotalInputTokens + in.TotalOutputTokens + weighted := func(totalCost float64) TokenCost { + if totalTokens <= 0 { + return TokenCost{} + } + blended := totalCost / totalTokens * 1e6 + wd := in.TotalInputTokens + 3*in.TotalOutputTokens // 3:1 output-to-input weighting + if wd <= 0 { + return TokenCost{Blended: round(blended, 4)} + } + baseInput := totalCost / wd + return TokenCost{ + Blended: round(blended, 4), + Input: round(baseInput*1e6, 4), + Output: round(baseInput*3*1e6, 4), + } + } + + perGPUWatts := 0.0 + if eff > 0 { + perGPUWatts = totalWatts / float64(eff) + } + + return LLMResult{ + GPUDetected: in.GPUName, + PricingSourceDate: e.cfg.Meta.LastUpdatedUTC, + WorkloadDurationHours: round(hours, 6), + AvgGPUPowerWatts: round(perGPUWatts, 2), + OwnedCapexOnlyUSD: round(capex, 6), + OwnedEnergyCostUSD: round(energy, 6), + OwnedFullyLoadedUSD: round(corespanTotal, 6), + BaselineSavingsUSD: round(hyperTotal-corespanTotal, 6), + CorespanInfra: weighted(corespanTotal), + Rental: weighted(rentalTotal), + Hyperscaler: weighted(hyperTotal), + RentVsBuy: e.coreLadder(gpuKey, eff, hours, capex, energy), + } +} + +func round(v float64, places int) float64 { + p := math.Pow(10, float64(places)) + return math.Round(v*p) / p +} diff --git a/ai-studio-cli/internal/cost/engine_test.go b/ai-studio-cli/internal/cost/engine_test.go new file mode 100644 index 0000000..7d4d623 --- /dev/null +++ b/ai-studio-cli/internal/cost/engine_test.go @@ -0,0 +1,200 @@ +package cost + +import ( + "math" + "testing" +) + +// Golden test: the Go cost engine must reproduce the published rent-vs-buy blog +// findings. Blog: https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500 +// Scenario: PRU 2500 + 8x RTX 5090, 24/7 over 3 years (210,240 GPU-hours), $99,999 all-in. + +const ( + years = 3.0 + windowHours = years * 365 * 24 // 26,280 + avgWatts = 550.0 // ~96% of the 575W TDP under sustained inference + bundleCapex = 99999.0 + bundle = "PRU2500_8x5090" +) + +// Published savings (positive = buying wins), CapEx-only basis, to the dollar. +var blogSavingsCapexOnly = map[string]float64{ + "spot": -43234, // rent wins + "reserved": -18005, // rent wins + "dedicated_median": 36657, // buy wins + "dedicated_high": 85012, // buy wins + "premium_secure": 108139, // buy wins +} + +func testEngine(t *testing.T) *Engine { + t.Helper() + e, err := NewEngine("") + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return e +} + +func testLadder(t *testing.T) Ladder { + t.Helper() + e := testEngine(t) + if got := e.gpuKey("NVIDIA GeForce RTX 5090"); got != "RTX 5090" { + t.Fatalf("gpuKey = %q, want RTX 5090", got) + } + return e.RentVsBuyLadder("RTX 5090", 8, windowHours, avgWatts, InfraConfig{SystemBundle: bundle}) +} + +func tierByKey(l Ladder) map[string]LadderTier { + m := make(map[string]LadderTier, len(l.Tiers)) + for _, t := range l.Tiers { + m[t.Tier] = t + } + return m +} + +func TestBundleCapexAndGPUCount(t *testing.T) { + l := testLadder(t) + if l.EffectiveNumGPUs != 8 { + t.Errorf("effective GPUs = %d, want 8", l.EffectiveNumGPUs) + } + if math.Abs(l.OwnedCapexOnly-bundleCapex) > 0.01 { + t.Errorf("owned capex = %v, want %v", l.OwnedCapexOnly, bundleCapex) + } + perGPUHr := bundleCapex / (8 * windowHours) + if math.Abs(perGPUHr-0.4756) > 0.001 { + t.Errorf("per-GPU-hr = %v, want ~0.4756", perGPUHr) + } +} + +func TestCapexOnlyReproducesBlogToTheDollar(t *testing.T) { + l := testLadder(t) + byTier := tierByKey(l) + for tier, want := range blogSavingsCapexOnly { + lt, ok := byTier[tier] + if !ok { + t.Errorf("tier %q missing from ladder", tier) + continue + } + capexOnlySavings := lt.RentalTotal - bundleCapex + if math.Abs(capexOnlySavings-want) > 1.0 { + t.Errorf("%s: capex-only savings = %.2f, want ~%.0f", tier, capexOnlySavings, want) + } + } +} + +func TestOwnedElectricityIsAdded(t *testing.T) { + l := testLadder(t) + // 550W x8, PUE 1.15, $0.087/kWh over the window. + wantEnergy := (avgWatts * 8 / 1000.0) * 1.15 * windowHours * 0.087 + if math.Abs(l.OwnedEnergyCost-wantEnergy) > 1.0 { + t.Errorf("owned energy = %v, want ~%v", l.OwnedEnergyCost, wantEnergy) + } + if math.Abs(l.OwnedFullyLoaded-(bundleCapex+wantEnergy)) > 1.0 { + t.Errorf("fully-loaded = %v, want ~%v", l.OwnedFullyLoaded, bundleCapex+wantEnergy) + } +} + +func TestFullyLoadedMedianSavings(t *testing.T) { + l := testLadder(t) + median := tierByKey(l)["dedicated_median"] + if median.Verdict != "buy" { + t.Errorf("median verdict = %q, want buy", median.Verdict) + } + // Blog's $36,657 minus ~$11,569 electricity ≈ $25,088. + if math.Abs(median.SavingsVsBuy-25088) > 5 { + t.Errorf("median fully-loaded savings = %v, want ~25088", median.SavingsVsBuy) + } +} + +func TestConclusionHoldsBuyWinsAtProductionTiers(t *testing.T) { + byTier := tierByKey(testLadder(t)) + want := map[string]string{ + "spot": "rent", + "reserved": "rent", + "dedicated_median": "buy", + "dedicated_high": "buy", + "premium_secure": "buy", + } + for tier, verdict := range want { + if got := byTier[tier].Verdict; got != verdict { + t.Errorf("%s verdict = %q, want %q", tier, got, verdict) + } + } +} + +func TestTiersSortedAscending(t *testing.T) { + l := testLadder(t) + for i := 1; i < len(l.Tiers); i++ { + if l.Tiers[i].RentalHourly < l.Tiers[i-1].RentalHourly { + t.Errorf("tiers not ascending at %d: %v < %v", i, l.Tiers[i].RentalHourly, l.Tiers[i-1].RentalHourly) + } + } +} + +// Fix #1: a run that uses fewer GPUs than the bundle (e.g. the default compose's +// TP1xPP4 = 4 GPUs against the 8-GPU bundle) must price the GPUs actually used — +// the bundle contributes a per-GPU rate, not the whole-node count. +func TestSubNodeBundleScalesByGPUsUsed(t *testing.T) { + e := testEngine(t) + l := e.RentVsBuyLadder("RTX 5090", 4, windowHours, avgWatts, InfraConfig{SystemBundle: bundle}) + if l.EffectiveNumGPUs != 4 { + t.Errorf("effective GPUs = %d, want 4 (must not inherit the bundle's 8)", l.EffectiveNumGPUs) + } + // 4 of 8 GPUs => half the all-in node CapEx. + wantCapex := bundleCapex / 2.0 + if math.Abs(l.OwnedCapexOnly-wantCapex) > 0.01 { + t.Errorf("owned capex = %v, want %v", l.OwnedCapexOnly, wantCapex) + } + // Electricity should scale to 4 GPUs, not 8. + wantEnergy := (avgWatts * 4 / 1000.0) * 1.15 * windowHours * 0.087 + if math.Abs(l.OwnedEnergyCost-wantEnergy) > 1.0 { + t.Errorf("owned energy = %v, want ~%v", l.OwnedEnergyCost, wantEnergy) + } +} + +// Fix #2: a live-measured total node power is billed directly (busy + idle), +// independent of per-GPU estimates. +func TestMeasuredNodeWattsUsedDirectly(t *testing.T) { + e := testEngine(t) + const measured = 2320.0 // e.g. 4 busy @ ~550W + 4 idle @ ~30W + res := e.CalculateLLM(LLMInput{ + GPUName: "NVIDIA GeForce RTX 5090", + NumGPUs: 4, + DurationSeconds: windowHours * 3600, + AvgPowerWatts: 550, // should be ignored in favour of MeasuredNodeWatts + MeasuredNodeWatts: measured, + TotalInputTokens: 1_000_000, + TotalOutputTokens: 1_000_000, + Infra: InfraConfig{SystemBundle: bundle}, + }) + wantEnergy := (measured / 1000.0) * 1.15 * windowHours * 0.087 + if math.Abs(res.OwnedEnergyCostUSD-wantEnergy) > 1.0 { + t.Errorf("owned energy = %v, want ~%v (measured total must win)", res.OwnedEnergyCostUSD, wantEnergy) + } + if math.Abs(res.AvgGPUPowerWatts-(measured/4.0)) > 0.5 { + t.Errorf("reported per-GPU watts = %v, want ~%v", res.AvgGPUPowerWatts, measured/4.0) + } +} + +func TestCalculateLLMTokenCosts(t *testing.T) { + e := testEngine(t) + res := e.CalculateLLM(LLMInput{ + GPUName: "NVIDIA GeForce RTX 5090", + NumGPUs: 8, + DurationSeconds: windowHours * 3600, + AvgPowerWatts: avgWatts, + TotalInputTokens: 1_000_000, + TotalOutputTokens: 1_000_000, + Infra: InfraConfig{SystemBundle: bundle}, + }) + if res.RentVsBuy.EffectiveNumGPUs != 8 { + t.Errorf("effective GPUs = %d, want 8", res.RentVsBuy.EffectiveNumGPUs) + } + // Output tokens are weighted 3x input. + if res.CorespanInfra.Output <= res.CorespanInfra.Input { + t.Errorf("output cost %.4f should exceed input cost %.4f (3:1 weighting)", res.CorespanInfra.Output, res.CorespanInfra.Input) + } + if res.CorespanInfra.Blended <= 0 { + t.Errorf("blended cost should be positive, got %v", res.CorespanInfra.Blended) + } +} diff --git a/ai-studio-cli/internal/cost/pricing.json b/ai-studio-cli/internal/cost/pricing.json new file mode 100644 index 0000000..35f413b --- /dev/null +++ b/ai-studio-cli/internal/cost/pricing.json @@ -0,0 +1,89 @@ +{ + "meta": { + "last_updated_utc": "2026-07-17T00:00:00", + "electricity_kwh_usd": 0.087, + "pue": 1.15, + "electricity_source": "US industrial average, EIA (Apr 2026 ~8.66c/kWh)" + }, + "corespan_inventory": { + "gpus": { + "Tesla T4": { "capex_usd": 650, "lifespan_years": 4, "default_watts": 70 }, + "A100": { "capex_usd": 15000, "lifespan_years": 4, "default_watts": 400 }, + "H100": { "capex_usd": 30000, "lifespan_years": 4, "default_watts": 700 }, + "RTX 5070": { "capex_usd": 599, "lifespan_years": 3, "default_watts": 250 }, + "RTX 5090": { "capex_usd": 12499, "lifespan_years": 3, "default_watts": 550 }, + "DEFAULT": { "capex_usd": 5000, "lifespan_years": 4, "default_watts": 400 } + }, + "system_bundles": { + "PRU2500_8x5090": { + "description": "PRU 2500 + 8x RTX 5090, direct liquid cooling included. All-in list price amortized over 3 years.", + "capex_usd": 99999, + "lifespan_years": 3, + "num_gpus": 8, + "gpu_model": "RTX 5090" + } + } + }, + "market_rates": { + "gpus": { + "Tesla T4": { + "hyperscaler_sku": "Standard_NC4as_T4_v3", + "rental_hourly_usd": 0.13, + "neocloud_blended_hourly_usd": 0.2, + "hyperscaler_hourly_usd": 0.526, + "rental_tiers": { + "spot": { "usd_hr": 0.08, "label": "Spot (Vast.ai low)" }, + "peer": { "usd_hr": 0.13, "label": "Peer marketplace (Vast.ai)" }, + "reserved": { "usd_hr": 0.15, "label": "Reserved / spot (AWS g4dn)" }, + "dedicated_median": { "usd_hr": 0.35, "label": "Dedicated datacenter median" }, + "premium_secure": { "usd_hr": 0.526, "label": "Hyperscaler on-demand (AWS g4dn / Azure NC4as T4 v3)" } + } + }, + "A100": { + "hyperscaler_sku": "Standard_NC24ads_A100_v4", + "rental_hourly_usd": 2.0, + "neocloud_blended_hourly_usd": 1.5, + "hyperscaler_hourly_usd": 0.746721 + }, + "H100": { + "hyperscaler_sku": "Standard_ND96isr_H100_v5", + "rental_hourly_usd": 4.5, + "neocloud_blended_hourly_usd": 3.0, + "hyperscaler_hourly_usd": 12.29 + }, + "RTX 5070": { + "hyperscaler_sku": "Generic", + "rental_hourly_usd": 0.09, + "neocloud_blended_hourly_usd": 0.15, + "hyperscaler_hourly_usd": 0.67, + "rental_tiers": { + "spot": { "usd_hr": 0.06, "label": "Spot (Vast.ai floor)" }, + "peer": { "usd_hr": 0.09, "label": "Peer marketplace (Vast.ai)" }, + "reserved": { "usd_hr": 0.12, "label": "Reserved / committed" }, + "dedicated_median": { "usd_hr": 0.27, "label": "On-demand median (Vast.ai high)" }, + "premium_secure": { "usd_hr": 0.67, "label": "Premium / dedicated high" } + } + }, + "RTX 5090": { + "hyperscaler_sku": "Generic", + "rental_hourly_usd": 0.36, + "neocloud_blended_hourly_usd": 0.65, + "hyperscaler_hourly_usd": 0.99, + "rental_tiers": { + "spot": { "usd_hr": 0.27, "label": "Spot / Batch (Salad)" }, + "peer": { "usd_hr": 0.36, "label": "Peer marketplace (Vast.ai on-demand median)" }, + "reserved": { "usd_hr": 0.39, "label": "Reserved / committed (Runcrate)" }, + "dedicated_median": { "usd_hr": 0.65, "label": "Dedicated datacenter median" }, + "dedicated_high": { "usd_hr": 0.88, "label": "On-demand high (Vast.ai)" }, + "premium_secure": { "usd_hr": 0.99, "label": "Premium secure (RunPod Secure)" } + } + }, + "DEFAULT": { + "hyperscaler_sku": "Generic", + "rental_hourly_usd": 1.25, + "neocloud_blended_hourly_usd": 1.0, + "hyperscaler_hourly_usd": 3.0 + } + } + } +} diff --git a/ai-studio-cli/internal/power/sampler.go b/ai-studio-cli/internal/power/sampler.go new file mode 100644 index 0000000..f68251f --- /dev/null +++ b/ai-studio-cli/internal/power/sampler.go @@ -0,0 +1,106 @@ +// Package power samples GPU power draw via nvidia-smi during a benchmark run so +// the cost engine can charge realistic owned-side electricity. aistudio-cli has +// no Prometheus/telemetry stack, so this lightweight poller stands in for it. +package power + +import ( + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// Sampler periodically records the total power draw across all visible GPUs so +// the cost engine can bill exactly what the node consumes at the wall. +type Sampler struct { + interval time.Duration + stopCh chan struct{} + doneCh chan struct{} + + mu sync.Mutex + sum float64 + count int +} + +// NewSampler returns a sampler that polls every interval (default 2s). +func NewSampler(interval time.Duration) *Sampler { + if interval <= 0 { + interval = 2 * time.Second + } + return &Sampler{ + interval: interval, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Start begins sampling in the background. Call Stop exactly once afterwards. +func (s *Sampler) Start() { + go func() { + defer close(s.doneCh) + s.sampleOnce() // immediate first sample + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.sampleOnce() + } + } + }() +} + +func (s *Sampler) sampleOnce() { + w, ok := queryTotalWatts() + if !ok { + return + } + s.mu.Lock() + s.sum += w + s.count++ + s.mu.Unlock() +} + +// Stop halts sampling and returns the time-averaged TOTAL node power in watts +// (sum across all visible GPUs), or 0 if nothing could be sampled — e.g. +// nvidia-smi absent. +func (s *Sampler) Stop() float64 { + close(s.stopCh) + <-s.doneCh + s.mu.Lock() + defer s.mu.Unlock() + if s.count == 0 { + return 0 + } + return s.sum / float64(s.count) +} + +// queryTotalWatts returns the summed power.draw across all visible GPUs at one +// instant (total node GPU power, busy + idle). +func queryTotalWatts() (float64, bool) { + out, err := exec.Command("nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits").Output() + if err != nil { + return 0, false + } + var sum float64 + var n int + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + v, err := strconv.ParseFloat(line, 64) + if err != nil { + continue + } + sum += v + n++ + } + if n == 0 { + return 0, false + } + return sum, true +} From 636999a7685dc36148a3f7808f8d2ead9968e088 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 21 Jul 2026 16:52:51 +0530 Subject: [PATCH 2/3] fixes with changes requested after the first commit --- README.md | 15 +++--- ai-studio-cli/cmd/bench.go | 6 +-- ai-studio-cli/cmd/cost.go | 38 +++++++++---- ai-studio-cli/internal/cost/engine.go | 23 ++------ ai-studio-cli/internal/cost/engine_test.go | 24 --------- ai-studio-cli/internal/cost/pricing.json | 40 +++++++------- ai-studio-cli/internal/power/sampler.go | 62 +++++++++++++--------- 7 files changed, 100 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index d387a04..bd5a78b 100644 --- a/README.md +++ b/README.md @@ -235,19 +235,20 @@ counts, GPU count (TP×PP), and GPU power. **Owned cost is fully loaded** — amortized hardware CapEx **plus electricity** (PUE-adjusted, US industrial rate) — so it is a fair comparison against rental rates, which already bundle power. -- **Power** is the **total node draw measured live** during the run via `nvidia-smi` (busy + idle - GPUs = what you actually pay at the wall). If sampling is unavailable it falls back to the - per-GPU default in the pricing config, or to `--avg-gpu-watts`. +- **Power** is measured live during the run via `nvidia-smi`, scoped to the **GPUs in use** + (the busiest `TP×PP` GPUs by draw) so electricity stays consistent with the per-GPU CapEx. + If sampling is unavailable it falls back to the per-GPU default in the pricing config, or to + `--avg-gpu-watts`. - **System bundles** contribute a **per-GPU** all-in price scaled by the GPUs the run actually used, so a 4-GPU run (`TP1×PP4`, the default compose) against the 8-GPU `PRU2500_8x5090` bundle prices 4 GPUs — while a full 8-GPU run reproduces the whole-node bundle price exactly. - Output includes cost-per-1M-tokens (3:1 output/input weighting) and a rent-vs-buy ladder across market tiers (spot → premium), each with a `buy`/`rent` verdict. -**Supported GPUs** (auto-detected from `nvidia-smi`): RTX 5090, RTX 5070, Tesla T4, A100, H100 -(others fall back to `DEFAULT` pricing). The RTX 5090 + `PRU2500_8x5090` bundle reproduces the -published [rent-or-buy blog](https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500) -figures; RTX 5090 / 5070 / T4 carry a full rent-vs-buy tier ladder. +**Supported GPUs** (auto-detected from `nvidia-smi`): RTX 5090, RTX 5070, Tesla T4, A100, H100 — +each carries a full rent-vs-buy tier ladder (others fall back to `DEFAULT` pricing). The +RTX 5090 + `PRU2500_8x5090` bundle reproduces the published +[rent-or-buy blog](https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500) figures. ```bash # Price the owned side as the all-in PRU-2500 + 8x RTX 5090 system diff --git a/ai-studio-cli/cmd/bench.go b/ai-studio-cli/cmd/bench.go index 5060869..898db28 100644 --- a/ai-studio-cli/cmd/bench.go +++ b/ai-studio-cli/cmd/bench.go @@ -62,8 +62,8 @@ type benchRunner struct { avgGPUWattsOverride float64 // populated at runtime - measuredNodeWatts float64 // total node draw sampled during the run - resolvedGPU string + gpuWattReadings []float64 // per-GPU draw sampled during the run + resolvedGPU string } var bench = &benchRunner{} @@ -202,7 +202,7 @@ func (r *benchRunner) runBenchmark(cmd *cobra.Command, composePath string) error } runErr := r.execute(cmd.Context(), args, structuredDir) if sampler != nil { - r.measuredNodeWatts = sampler.Stop() + r.gpuWattReadings = sampler.Stop() } if runErr != nil { return runErr diff --git a/ai-studio-cli/cmd/cost.go b/ai-studio-cli/cmd/cost.go index 30bb113..f488ca9 100644 --- a/ai-studio-cli/cmd/cost.go +++ b/ai-studio-cli/cmd/cost.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "sort" "github.com/corespan/ai-studio-cli/internal/cost" ) @@ -33,15 +34,13 @@ func (r *benchRunner) injectCostMetrics(raw map[string]interface{}, cfg ModelCon gpuName = detectGPUTag() } - // Power precedence: explicit per-GPU override > live-measured node total > - // per-GPU default from the pricing config. - var perGPUWatts, measuredNodeWatts float64 - switch { - case r.avgGPUWattsOverride > 0: - perGPUWatts = r.avgGPUWattsOverride - case r.measuredNodeWatts > 0: - measuredNodeWatts = r.measuredNodeWatts - default: + // Per-GPU power precedence: explicit override > live sample of the GPUs in + // use > per-GPU default from the pricing config. + perGPUWatts := r.avgGPUWattsOverride + if perGPUWatts <= 0 { + perGPUWatts = avgWattsOfUsedGPUs(r.gpuWattReadings, numGPUs) + } + if perGPUWatts <= 0 { perGPUWatts = engine.DefaultWatts(gpuName) } @@ -50,7 +49,6 @@ func (r *benchRunner) injectCostMetrics(raw map[string]interface{}, cfg ModelCon NumGPUs: numGPUs, DurationSeconds: duration, AvgPowerWatts: perGPUWatts, - MeasuredNodeWatts: measuredNodeWatts, TotalInputTokens: inTok, TotalOutputTokens: outTok, Infra: cost.InfraConfig{ @@ -62,3 +60,23 @@ func (r *benchRunner) injectCostMetrics(raw map[string]interface{}, cfg ModelCon raw["cost_metrics"] = result return nil } + +// avgWattsOfUsedGPUs returns the mean per-GPU draw of the busiest `used` GPUs. +// The GPUs doing the work draw the most power, so ranking by draw and taking the +// top N approximates "the GPUs in use" without needing device-ID mapping — and +// keeps electricity scoped to the same GPUs the CapEx is scoped to. +func avgWattsOfUsedGPUs(readings []float64, used int) float64 { + if len(readings) == 0 || used <= 0 { + return 0 + } + sorted := append([]float64(nil), readings...) + sort.Sort(sort.Reverse(sort.Float64Slice(sorted))) + if used > len(sorted) { + used = len(sorted) + } + var sum float64 + for i := 0; i < used; i++ { + sum += sorted[i] + } + return sum / float64(used) +} diff --git a/ai-studio-cli/internal/cost/engine.go b/ai-studio-cli/internal/cost/engine.go index c5aa65f..adeb06e 100644 --- a/ai-studio-cli/internal/cost/engine.go +++ b/ai-studio-cli/internal/cost/engine.go @@ -31,7 +31,6 @@ type BundleSpec struct { CapexUSD float64 `json:"capex_usd"` LifespanYears float64 `json:"lifespan_years"` NumGPUs int `json:"num_gpus"` - GPUModel string `json:"gpu_model"` } type Tier struct { @@ -40,9 +39,7 @@ type Tier struct { } type MarketRate struct { - HyperscalerSKU string `json:"hyperscaler_sku"` RentalHourlyUSD float64 `json:"rental_hourly_usd"` - NeocloudBlendedUSD float64 `json:"neocloud_blended_hourly_usd"` HyperscalerHourlyUSD float64 `json:"hyperscaler_hourly_usd"` RentalTiers map[string]Tier `json:"rental_tiers"` } @@ -52,7 +49,6 @@ type Config struct { LastUpdatedUTC string `json:"last_updated_utc"` ElectricityKWhUSD float64 `json:"electricity_kwh_usd"` PUE float64 `json:"pue"` - ElectricitySource string `json:"electricity_source"` } `json:"meta"` CorespanInventory struct { GPUs map[string]HWSpec `json:"gpus"` @@ -74,8 +70,7 @@ type LLMInput struct { GPUName string NumGPUs int // GPUs the workload actually used (e.g. TP*PP) DurationSeconds float64 - AvgPowerWatts float64 // per-GPU estimate; used when no measured total is available - MeasuredNodeWatts float64 // total node draw measured live; when > 0 it wins over AvgPowerWatts + AvgPowerWatts float64 // per-GPU draw for the GPUs in use (measured or estimated) TotalInputTokens float64 TotalOutputTokens float64 Infra InfraConfig @@ -290,12 +285,9 @@ func (e *Engine) CalculateLLM(in LLMInput) LLMResult { capex, eff := e.resolveCorespanCapex(gpuKey, in.NumGPUs, hours, in.Infra) - // Prefer measured total node power; otherwise estimate from per-GPU watts. - totalWatts := in.MeasuredNodeWatts - if totalWatts <= 0 { - totalWatts = in.AvgPowerWatts * float64(eff) - } - energy := e.ownedEnergyCost(totalWatts, hours, in.Infra) + // Power is scoped to the GPUs in use — per-GPU draw × the used count — so it + // stays consistent with the per-GPU CapEx (idle GPUs are not billed here). + energy := e.ownedEnergyCost(in.AvgPowerWatts*float64(eff), hours, in.Infra) corespanTotal := capex + energy market := e.marketRate(gpuKey) @@ -320,16 +312,11 @@ func (e *Engine) CalculateLLM(in LLMInput) LLMResult { } } - perGPUWatts := 0.0 - if eff > 0 { - perGPUWatts = totalWatts / float64(eff) - } - return LLMResult{ GPUDetected: in.GPUName, PricingSourceDate: e.cfg.Meta.LastUpdatedUTC, WorkloadDurationHours: round(hours, 6), - AvgGPUPowerWatts: round(perGPUWatts, 2), + AvgGPUPowerWatts: round(in.AvgPowerWatts, 2), OwnedCapexOnlyUSD: round(capex, 6), OwnedEnergyCostUSD: round(energy, 6), OwnedFullyLoadedUSD: round(corespanTotal, 6), diff --git a/ai-studio-cli/internal/cost/engine_test.go b/ai-studio-cli/internal/cost/engine_test.go index 7d4d623..c5c989a 100644 --- a/ai-studio-cli/internal/cost/engine_test.go +++ b/ai-studio-cli/internal/cost/engine_test.go @@ -152,30 +152,6 @@ func TestSubNodeBundleScalesByGPUsUsed(t *testing.T) { } } -// Fix #2: a live-measured total node power is billed directly (busy + idle), -// independent of per-GPU estimates. -func TestMeasuredNodeWattsUsedDirectly(t *testing.T) { - e := testEngine(t) - const measured = 2320.0 // e.g. 4 busy @ ~550W + 4 idle @ ~30W - res := e.CalculateLLM(LLMInput{ - GPUName: "NVIDIA GeForce RTX 5090", - NumGPUs: 4, - DurationSeconds: windowHours * 3600, - AvgPowerWatts: 550, // should be ignored in favour of MeasuredNodeWatts - MeasuredNodeWatts: measured, - TotalInputTokens: 1_000_000, - TotalOutputTokens: 1_000_000, - Infra: InfraConfig{SystemBundle: bundle}, - }) - wantEnergy := (measured / 1000.0) * 1.15 * windowHours * 0.087 - if math.Abs(res.OwnedEnergyCostUSD-wantEnergy) > 1.0 { - t.Errorf("owned energy = %v, want ~%v (measured total must win)", res.OwnedEnergyCostUSD, wantEnergy) - } - if math.Abs(res.AvgGPUPowerWatts-(measured/4.0)) > 0.5 { - t.Errorf("reported per-GPU watts = %v, want ~%v", res.AvgGPUPowerWatts, measured/4.0) - } -} - func TestCalculateLLMTokenCosts(t *testing.T) { e := testEngine(t) res := e.CalculateLLM(LLMInput{ diff --git a/ai-studio-cli/internal/cost/pricing.json b/ai-studio-cli/internal/cost/pricing.json index 35f413b..d9e48ca 100644 --- a/ai-studio-cli/internal/cost/pricing.json +++ b/ai-studio-cli/internal/cost/pricing.json @@ -2,8 +2,7 @@ "meta": { "last_updated_utc": "2026-07-17T00:00:00", "electricity_kwh_usd": 0.087, - "pue": 1.15, - "electricity_source": "US industrial average, EIA (Apr 2026 ~8.66c/kWh)" + "pue": 1.15 }, "corespan_inventory": { "gpus": { @@ -19,17 +18,14 @@ "description": "PRU 2500 + 8x RTX 5090, direct liquid cooling included. All-in list price amortized over 3 years.", "capex_usd": 99999, "lifespan_years": 3, - "num_gpus": 8, - "gpu_model": "RTX 5090" + "num_gpus": 8 } } }, "market_rates": { "gpus": { "Tesla T4": { - "hyperscaler_sku": "Standard_NC4as_T4_v3", "rental_hourly_usd": 0.13, - "neocloud_blended_hourly_usd": 0.2, "hyperscaler_hourly_usd": 0.526, "rental_tiers": { "spot": { "usd_hr": 0.08, "label": "Spot (Vast.ai low)" }, @@ -40,21 +36,29 @@ } }, "A100": { - "hyperscaler_sku": "Standard_NC24ads_A100_v4", - "rental_hourly_usd": 2.0, - "neocloud_blended_hourly_usd": 1.5, - "hyperscaler_hourly_usd": 0.746721 + "rental_hourly_usd": 0.72, + "hyperscaler_hourly_usd": 3.67, + "rental_tiers": { + "spot": { "usd_hr": 0.6, "label": "Spot (Spheron)" }, + "peer": { "usd_hr": 0.72, "label": "Peer marketplace (Vast.ai)" }, + "reserved": { "usd_hr": 1.07, "label": "On-demand community (Spheron)" }, + "dedicated_median": { "usd_hr": 1.64, "label": "Dedicated on-demand (RunPod)" }, + "premium_secure": { "usd_hr": 2.06, "label": "Premium SLA (Lambda)" } + } }, "H100": { - "hyperscaler_sku": "Standard_ND96isr_H100_v5", - "rental_hourly_usd": 4.5, - "neocloud_blended_hourly_usd": 3.0, - "hyperscaler_hourly_usd": 12.29 + "rental_hourly_usd": 1.99, + "hyperscaler_hourly_usd": 6.98, + "rental_tiers": { + "spot": { "usd_hr": 1.55, "label": "Spot (Vast.ai)" }, + "peer": { "usd_hr": 1.99, "label": "Community pool (RunPod)" }, + "reserved": { "usd_hr": 2.2, "label": "Reserved / committed" }, + "dedicated_median": { "usd_hr": 2.99, "label": "Dedicated on-demand median" }, + "premium_secure": { "usd_hr": 3.99, "label": "Premium secure on-demand" } + } }, "RTX 5070": { - "hyperscaler_sku": "Generic", "rental_hourly_usd": 0.09, - "neocloud_blended_hourly_usd": 0.15, "hyperscaler_hourly_usd": 0.67, "rental_tiers": { "spot": { "usd_hr": 0.06, "label": "Spot (Vast.ai floor)" }, @@ -65,9 +69,7 @@ } }, "RTX 5090": { - "hyperscaler_sku": "Generic", "rental_hourly_usd": 0.36, - "neocloud_blended_hourly_usd": 0.65, "hyperscaler_hourly_usd": 0.99, "rental_tiers": { "spot": { "usd_hr": 0.27, "label": "Spot / Batch (Salad)" }, @@ -79,9 +81,7 @@ } }, "DEFAULT": { - "hyperscaler_sku": "Generic", "rental_hourly_usd": 1.25, - "neocloud_blended_hourly_usd": 1.0, "hyperscaler_hourly_usd": 3.0 } } diff --git a/ai-studio-cli/internal/power/sampler.go b/ai-studio-cli/internal/power/sampler.go index f68251f..21f847b 100644 --- a/ai-studio-cli/internal/power/sampler.go +++ b/ai-studio-cli/internal/power/sampler.go @@ -1,6 +1,9 @@ // Package power samples GPU power draw via nvidia-smi during a benchmark run so // the cost engine can charge realistic owned-side electricity. aistudio-cli has // no Prometheus/telemetry stack, so this lightweight poller stands in for it. +// +// It records per-GPU draw so the caller can bill only the GPUs actually in use +// (see cost integration), keeping electricity consistent with the per-GPU CapEx. package power import ( @@ -11,16 +14,15 @@ import ( "time" ) -// Sampler periodically records the total power draw across all visible GPUs so -// the cost engine can bill exactly what the node consumes at the wall. +// Sampler periodically records per-GPU power draw across visible GPUs. type Sampler struct { interval time.Duration stopCh chan struct{} doneCh chan struct{} - mu sync.Mutex - sum float64 - count int + mu sync.Mutex + sums []float64 // running sum of watts per GPU index + counts []int // samples counted per GPU index } // NewSampler returns a sampler that polls every interval (default 2s). @@ -54,39 +56,48 @@ func (s *Sampler) Start() { } func (s *Sampler) sampleOnce() { - w, ok := queryTotalWatts() + watts, ok := queryPerGPUWatts() if !ok { return } s.mu.Lock() - s.sum += w - s.count++ + if s.sums == nil { + s.sums = make([]float64, len(watts)) + s.counts = make([]int, len(watts)) + } + for i, w := range watts { + if i < len(s.sums) { + s.sums[i] += w + s.counts[i]++ + } + } s.mu.Unlock() } -// Stop halts sampling and returns the time-averaged TOTAL node power in watts -// (sum across all visible GPUs), or 0 if nothing could be sampled — e.g. -// nvidia-smi absent. -func (s *Sampler) Stop() float64 { +// Stop halts sampling and returns the time-averaged watts for each visible GPU +// (one entry per GPU index). Empty if nothing could be sampled — e.g. +// nvidia-smi absent. The caller decides which GPUs count as "in use". +func (s *Sampler) Stop() []float64 { close(s.stopCh) <-s.doneCh s.mu.Lock() defer s.mu.Unlock() - if s.count == 0 { - return 0 + out := make([]float64, 0, len(s.sums)) + for i := range s.sums { + if s.counts[i] > 0 { + out = append(out, s.sums[i]/float64(s.counts[i])) + } } - return s.sum / float64(s.count) + return out } -// queryTotalWatts returns the summed power.draw across all visible GPUs at one -// instant (total node GPU power, busy + idle). -func queryTotalWatts() (float64, bool) { +// queryPerGPUWatts returns one power.draw reading per visible GPU at one instant. +func queryPerGPUWatts() ([]float64, bool) { out, err := exec.Command("nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits").Output() if err != nil { - return 0, false + return nil, false } - var sum float64 - var n int + var watts []float64 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) if line == "" { @@ -96,11 +107,10 @@ func queryTotalWatts() (float64, bool) { if err != nil { continue } - sum += v - n++ + watts = append(watts, v) } - if n == 0 { - return 0, false + if len(watts) == 0 { + return nil, false } - return sum, true + return watts, true } From 71ec43b3ee553c171a33ba8ba6bc0db571b73694 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 22 Jul 2026 14:55:00 +0530 Subject: [PATCH 3/3] removing round off to show small cost values --- ai-studio-cli/internal/benchui/ui/app.js | 18 +++++++++++++----- ai-studio-cli/internal/cost/engine.go | 10 +++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ai-studio-cli/internal/benchui/ui/app.js b/ai-studio-cli/internal/benchui/ui/app.js index 6e6bc08..660a887 100644 --- a/ai-studio-cli/internal/benchui/ui/app.js +++ b/ai-studio-cli/internal/benchui/ui/app.js @@ -532,15 +532,23 @@ return isNaN(num) ? '—' : num.toFixed(2); } - // fmtUSD formats a dollar amount; whole dollars by default, or `decimals` places. + // fmtUSD formats a dollar amount. Pass `decimals` to force a fixed precision; + // otherwise it adapts — whole dollars for large sums, and enough decimals to + // reveal sub-cent values (so short-run costs show the real number, not "$0"). function fmtUSD(val, decimals) { if (val == null) return '—'; const num = Number(val); if (isNaN(num)) return '—'; - const opts = decimals != null - ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } - : { maximumFractionDigits: 0 }; - return '$' + num.toLocaleString(undefined, opts); + let d = decimals; + if (d == null) { + const abs = Math.abs(num); + if (abs === 0) d = 2; + else if (abs >= 1000) d = 0; // $111,568 + else if (abs >= 1) d = 2; // $25.09 + // sub-dollar: ~2 significant figures so small costs show the real value + else d = Math.min(8, Math.max(2, 1 - Math.floor(Math.log10(abs)))); + } + return '$' + num.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }); } function formatTimestamp(ts) { diff --git a/ai-studio-cli/internal/cost/engine.go b/ai-studio-cli/internal/cost/engine.go index adeb06e..7142ea6 100644 --- a/ai-studio-cli/internal/cost/engine.go +++ b/ai-studio-cli/internal/cost/engine.go @@ -254,16 +254,16 @@ func (e *Engine) coreLadder(gpuKey string, eff int, hours, capex, energy float64 Tier: k, Label: t.Label, RentalHourly: t.USDHr, - RentalTotal: round(rentalTotal, 2), - SavingsVsBuy: round(savings, 2), + RentalTotal: round(rentalTotal, 6), + SavingsVsBuy: round(savings, 6), Verdict: verdict, }) } return Ladder{ - OwnedCapexOnly: round(capex, 2), - OwnedEnergyCost: round(energy, 2), - OwnedFullyLoaded: round(fullyLoaded, 2), + OwnedCapexOnly: round(capex, 6), + OwnedEnergyCost: round(energy, 6), + OwnedFullyLoaded: round(fullyLoaded, 6), EffectiveNumGPUs: eff, WindowHours: round(hours, 4), Tiers: tiers,