File size: 14,592 Bytes
3e2ce32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afcfd33
 
 
 
 
 
 
 
 
 
3e2ce32
 
 
 
 
 
 
 
 
 
 
 
 
afcfd33
3e2ce32
 
 
 
 
 
 
afcfd33
 
3e2ce32
afcfd33
 
 
 
 
3e2ce32
 
 
 
afcfd33
 
 
 
 
 
 
 
 
3e2ce32
afcfd33
 
 
 
 
 
 
 
3e2ce32
 
 
 
 
 
 
 
 
 
afcfd33
 
 
3e2ce32
 
 
 
 
 
 
 
 
 
 
 
 
afcfd33
3e2ce32
afcfd33
 
 
3e2ce32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afcfd33
 
 
3e2ce32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
/* Vramwise core (pure, no DOM): LLM memory + speed estimates. © 2026 CyberMax. All rights reserved. */
(function (root) {
  "use strict";

  var GB = 1e9;

  // Average bits per weight. GGUF values are llama.cpp's published averages for its k-quant/i-quant mixes (approximate:
  // they vary a little by architecture). MLX 4-bit = 4 bits + fp16 scale and bias per group of 64.
  var QUANTS = [
    { id: "bf16", label: "BF16 / FP16", bits: 16, note: "full precision" },
    { id: "q8_0", label: "Q8_0 / FP8 / MLX 8-bit", bits: 8.5, note: "near-lossless" },
    { id: "q6_k", label: "Q6_K", bits: 6.56, note: "excellent" },
    { id: "q5_k_m", label: "Q5_K_M", bits: 5.69, note: "very good" },
    { id: "q4_k_m", label: "Q4_K_M / MLX 4-bit", bits: 4.85, note: "the usual sweet spot" },
    { id: "iq4_xs", label: "IQ4_XS / AWQ / GPTQ 4-bit", bits: 4.25, note: "good" },
    { id: "q3_k_m", label: "Q3_K_M", bits: 3.91, note: "noticeable quality loss" },
    { id: "q2_k", label: "Q2_K", bits: 2.96, note: "large quality loss" },
    { id: "iq2_xxs", label: "IQ2_XXS", bits: 2.06, note: "last resort" }
  ];

  var KV_TYPES = { f16: 16, q8_0: 8.5, q4_0: 4.5 };

  // Spec-sheet memory size (GB) and bandwidth (GB/s).
  var GPUS = [
    ["RTX 3060 12GB", 12, 360], ["RTX 3070 8GB", 8, 448], ["RTX 3080 10GB", 10, 760], ["RTX 3090 24GB", 24, 936],
    ["RTX 4060 8GB", 8, 272], ["RTX 4060 Ti 16GB", 16, 288], ["RTX 4070 12GB", 12, 504], ["RTX 4070 Ti Super 16GB", 16, 672],
    ["RTX 4080 16GB", 16, 717], ["RTX 4090 24GB", 24, 1008], ["RTX 5060 Ti 16GB", 16, 448], ["RTX 5070 12GB", 12, 672],
    ["RTX 5070 Ti 16GB", 16, 896], ["RTX 5080 16GB", 16, 960], ["RTX 5090 32GB", 32, 1792],
    ["RX 7900 XT 20GB", 20, 800], ["RX 7900 XTX 24GB", 24, 960], ["RX 9070 XT 16GB", 16, 640],
    ["Tesla T4 16GB", 16, 320], ["L4 24GB", 24, 300], ["A10G 24GB", 24, 600], ["L40S 48GB", 48, 864],
    ["RTX 6000 Ada 48GB", 48, 960], ["RTX PRO 6000 96GB", 96, 1792], ["A100 40GB", 40, 1555], ["A100 80GB", 80, 2039],
    ["H100 80GB (SXM)", 80, 3350], ["H200 141GB", 141, 4800]
  ].map(function (g) { return { name: g[0], vram: g[1], bw: g[2] }; });

  // Apple silicon: memory sizes offered and bandwidth. Binned M3 Max / M4 Max chips have lower bandwidth at their smallest size.
  var APPLE = [
    { name: "M1", mem: [8, 16], bw: 68 }, { name: "M1 Pro", mem: [16, 32], bw: 200 }, { name: "M1 Max", mem: [32, 64], bw: 400 },
    { name: "M1 Ultra", mem: [64, 128], bw: 800 }, { name: "M2", mem: [8, 16, 24], bw: 100 }, { name: "M2 Pro", mem: [16, 32], bw: 200 },
    { name: "M2 Max", mem: [32, 64, 96], bw: 400 }, { name: "M2 Ultra", mem: [64, 128, 192], bw: 800 },
    { name: "M3", mem: [8, 16, 24], bw: 100 }, { name: "M3 Pro", mem: [18, 36], bw: 150 },
    { name: "M3 Max", mem: [36, 48, 64, 96, 128], bw: 400, bwByMem: { 36: 300, 96: 300 } },
    { name: "M3 Ultra", mem: [96, 256, 512], bw: 819 }, { name: "M4", mem: [16, 24, 32], bw: 120 },
    { name: "M4 Pro", mem: [24, 48, 64], bw: 273 }, { name: "M4 Max", mem: [36, 48, 64, 128], bw: 546, bwByMem: { 36: 410 } }
  ];

  // Other unified-memory machines: total memory, how much the GPU may use by default, bandwidth.
  var UNIFIED = [
    { name: "NVIDIA DGX Spark 128GB", mem: 128, usable: 120, bw: 273 },
    { name: "AMD Ryzen AI Max+ 395 128GB", mem: 128, usable: 96, bw: 256 }
  ];

  function num(v) { var n = Number(v); return isFinite(n) ? n : 0; }

  // Flatten nested configs (Gemma 3, Llama 4, Mistral 3 put the LM under text_config).
  function flatConfig(cfg) {
    cfg = cfg || {};
    var out = {};
    Object.keys(cfg).forEach(function (k) { out[k] = cfg[k]; });
    var t = cfg.text_config || cfg.llm_config || cfg.language_config;
    if (t && typeof t === "object") Object.keys(t).forEach(function (k) { if (t[k] !== undefined && t[k] !== null) out[k] = t[k]; });
    return out;
  }

  // Build a model description from HF API metadata (/api/models/{id}) and config.json.
  function describe(meta, config) {
    meta = meta || {};
    var c = flatConfig(config || meta.config);
    var st = meta.safetensors || {};
    var params = num(st.total) || num(meta.gguf && meta.gguf.total) || num(c.num_parameters);
    var p = st.parameters || {};
    var qc = c.quantization_config || {};
    var quant = qc.quant_method || "";
    // Packed FP4 (NVFP4 / E2M1 with FP8 block scales per 16): the Hub counts each U8 byte, which holds two weights, as one
    // parameter and the FP8 scales as parameters. Count the real weights instead.
    var u8 = num(p.U8), f8 = num(p.F8_E4M3);
    var fp4 = u8 > 0 && (/nvfp4/i.test(qc.quant_algo || "") || (f8 > 0 && Math.abs(f8 - u8 * 2 / 16) <= u8 * 0.01));
    if (fp4 && num(st.total)) {
      params = num(st.total) + u8 - (/nvfp4/i.test(qc.quant_algo || "") ? 0 : f8);
      quant = /nvfp4/i.test(qc.quant_algo || "") ? "nvfp4" : quant && quant !== "modelopt" ? quant : "fp4";
    }
    var nativeBytes = 0;
    Object.keys(p).forEach(function (k) {
      var n = num(p[k]);
      var b = /^(F64|I64)$/.test(k) ? 8 : /^(F32|I32)$/.test(k) ? 4 : /^(BF16|F16|I16)$/.test(k) ? 2 : 1;
      if (k === "U8" && quant === "mxfp4") b = 17 / 32; // 4-bit values + one 8-bit scale per 32
      nativeBytes += n * b;
    });
    var layers = num(c.num_hidden_layers || c.n_layer || c.num_layers);
    var heads = num(c.num_attention_heads || c.n_head);
    var kvHeads = num(c.num_key_value_heads || c.num_kv_heads || c.multi_query_group_num) || heads;
    var hidden = num(c.hidden_size || c.n_embd || c.d_model);
    var headDim = num(c.head_dim) || (heads ? hidden / heads : 0);
    var nExperts = num(c.num_local_experts || c.num_experts || c.n_routed_experts);
    var topK = num(c.num_experts_per_tok || c.experts_per_token || c.moe_topk || c.top_k_experts);
    var active = params;
    if (nExperts > 1 && topK > 0 && hidden) {
      var moeInter = num(c.moe_intermediate_size) || num(c.intermediate_size);
      var moeLayers = layers - num(c.first_k_dense_replace);
      var inactive = (nExperts - topK) * 3 * hidden * moeInter * moeLayers;
      active = Math.max(params * 0.03, params - inactive);
    }
    // Sliding-window layers only keep `window` tokens of cache. Linear-attention layers (Qwen3.5 / Qwen3-Next style
    // gated delta-net) keep a fixed-size state that does not grow with context.
    var win = num(c.sliding_window);
    var fullLayers = layers, linearLayers = 0;
    var types = Array.isArray(c.layer_types) && c.layer_types.length === layers ? c.layer_types : null;
    if (types) linearLayers = types.filter(function (t) { return /linear|mamba|recurrent|ssm/.test(t); }).length;
    if (win && types) {
      fullLayers = types.filter(function (t) { return !/sliding|linear|mamba|recurrent|ssm/.test(t); }).length;
    } else if (win && num(c.sliding_window_pattern) > 1) {
      fullLayers = Math.floor(layers / num(c.sliding_window_pattern));
    } else {
      win = 0;
      fullLayers = layers - linearLayers;
    }
    var slidingLayers = layers - fullLayers - linearLayers;
    // Fixed recurrent state per linear layer: value heads x key dim x value dim (state dtype) + a short conv cache.
    var stateBytes = 0;
    if (linearLayers) {
      var lvh = num(c.linear_num_value_heads), lkh = num(c.linear_num_key_heads), lkd = num(c.linear_key_head_dim), lvd = num(c.linear_value_head_dim);
      var sb = /float32|fp32/.test(c.mamba_ssm_dtype || "float32") ? 4 : 2;
      stateBytes = linearLayers * (lvh * lkd * lvd * sb + num(c.linear_conv_kernel_dim || 4) * (2 * lkh * lkd + lvh * lvd) * 2);
    }
    // Gemma 4 style: full-attention layers use their own (fewer, wider) KV heads, optionally with K and V shared.
    var gKv = num(c.num_global_key_value_heads), gHd = num(c.global_head_dim);
    var fullKvPerTok = gKv && gHd ? (c.attention_k_eq_v ? 1 : 2) * gKv * gHd : 0;
    // One-pass models (classifiers, decision/scoring models) read the prompt once and generate nothing.
    var tags = Array.isArray(meta.tags) ? meta.tags : [];
    var pipe = meta.pipeline_tag || "";
    var decision = tags.indexOf("decision-model") >= 0 ||
      /^(text-classification|zero-shot-classification|token-classification|feature-extraction|sentence-similarity)$/.test(pipe);
    var mla = num(c.kv_lora_rank) ? num(c.kv_lora_rank) + num(c.qk_rope_head_dim) : 0;
    return {
      id: meta.id || meta.modelId || "",
      params: params,
      activeParams: active,
      moe: active < params * 0.97,
      experts: nExperts, topK: topK,
      nativeBytes: nativeBytes,
      nativeLabel: quant ? quant.toUpperCase() : Object.keys(p).sort(function (a, b) { return num(p[b]) - num(p[a]); })[0] || "",
      layers: layers, kvHeads: kvHeads, headDim: headDim,
      fullLayers: fullLayers, slidingLayers: slidingLayers, window: win,
      linearLayers: linearLayers, stateBytes: stateBytes, fullKvPerTok: fullKvPerTok,
      decision: decision, pipeline: pipe,
      mla: mla,
      maxContext: num(c.max_position_embeddings) || num(meta.gguf && meta.gguf.context_length) || 0,
      gated: !!meta.gated,
      hasShape: !!(layers && (mla || (kvHeads && headDim)))
    };
  }

  // KV cache bytes for `ctx` tokens (batch 1) at `bits` per element.
  function kvBytes(m, ctx, bits) {
    if (!m.hasShape) return 0;
    var bytes = (bits || 16) / 8;
    if (m.mla) return m.layers * m.mla * bytes * ctx;
    var perLayerTok = 2 * m.kvHeads * m.headDim * bytes;
    var fullTok = m.fullKvPerTok ? m.fullKvPerTok * bytes : perLayerTok;
    var slidingCtx = m.window ? Math.min(ctx, m.window) : ctx;
    var sliding = m.slidingLayers || 0;
    var full = m.fullLayers !== undefined ? m.fullLayers : m.layers - sliding;
    return fullTok * full * ctx + perLayerTok * sliding * slidingCtx + (m.stateBytes || 0);
  }

  function weightBytes(m, bits) { return m.params * bits / 8; }

  var OVERHEAD = 1.0 * GB; // runtime context + compute buffers (rough)

  // hw: { usable: GB the GPU can use, bw: GB/s, ramGB: extra system RAM for offload (0 = none), ramBw: GB/s }
  function estimate(m, hw, q, ctx, kvBits) {
    var w = q.bytes !== undefined ? q.bytes : weightBytes(m, q.bits);
    var kv = kvBytes(m, ctx, kvBits);
    var total = w + kv + OVERHEAD;
    var usable = hw.usable * GB;
    var activeW = m.params ? w * (m.activeParams / m.params) : w;
    var perToken = activeW + kv; // bytes read per generated token (batch 1)
    var r = { weights: w, kv: kv, total: total, usable: usable, fit: "no", tpsLo: 0, tpsHi: 0 };
    if (total <= usable) {
      r.fit = total <= usable * 0.9 ? "yes" : "tight";
      var ceil = hw.bw * GB / perToken;
      r.tpsLo = ceil * 0.5; r.tpsHi = ceil * 0.8;
      // One-pass models: each call reads the weights once, so weights / bandwidth is the shortest possible call (batch 1).
      // Long prompts add compute time on top; this is a floor, not a prediction.
      r.minMs = activeW / (hw.bw * GB) * 1000;
    } else if (hw.ramGB > 0 && total <= usable + hw.ramGB * GB * 0.85) {
      r.fit = "offload";
      var onGpu = Math.max(0, usable - kv - OVERHEAD);
      var share = w ? Math.min(1, onGpu / w) : 1;
      var t = (activeW * share + kv) / (hw.bw * GB) + (activeW * (1 - share)) / ((hw.ramBw || 50) * GB);
      r.gpuShare = share;
      r.tpsLo = 0.5 / t; r.tpsHi = 0.8 / t;
    }
    // Longest context that still fits entirely in usable memory.
    var free = usable - w - OVERHEAD;
    var per = kvBytes(m, 1, kvBits);
    if (free <= 0) r.maxCtx = 0;
    else if (!per) r.maxCtx = m.maxContext || 0;
    else {
      var lo = 0, hi = Math.max(m.maxContext || 131072, 1024);
      if (kvBytes(m, hi, kvBits) <= free) lo = hi;
      else while (hi - lo > 256) { var mid = Math.floor((lo + hi) / 2); if (kvBytes(m, mid, kvBits) <= free) lo = mid; else hi = mid; }
      r.maxCtx = Math.floor(lo / 256) * 256;
    }
    return r;
  }

  function quantRows(m) {
    var rows = QUANTS.slice();
    if (m.nativeBytes && m.nativeLabel && !/^(BF16|F16)$/.test(m.nativeLabel)) {
      rows.unshift({ id: "native", label: "As published (" + m.nativeLabel + ")", bytes: m.nativeBytes, bits: m.params ? m.nativeBytes * 8 / m.params : 16, note: "the original files" });
    }
    return rows;
  }

  // Apple: macOS lets the GPU wire about 2/3 of RAM up to 36 GB and about 3/4 above (raisable with iogpu.wired_limit_mb).
  // raised = the user lifted the limit, leaving ~8 GB for macOS.
  function appleHardware(chip, memGB, raised) {
    var c = APPLE.filter(function (a) { return a.name === chip; })[0] || APPLE[0];
    var bw = (c.bwByMem && c.bwByMem[memGB]) || c.bw;
    var usable = raised ? Math.max(memGB - 8, memGB * 0.75) : memGB * (memGB <= 36 ? 2 / 3 : 0.75);
    return { label: c.name + " " + memGB + "GB", usable: usable, bw: bw, ramGB: 0, unified: true };
  }

  function gpuHardware(name, count, ramGB, ramBw) {
    var g = GPUS.filter(function (x) { return x.name === name; })[0] || GPUS[0];
    count = Math.max(1, Math.floor(num(count)) || 1);
    // Split across cards, per-token speed is closer to one card's bandwidth than the sum (conservative).
    return { label: (count > 1 ? count + "× " : "") + g.name, usable: g.vram * count, bw: g.bw, ramGB: num(ramGB), ramBw: num(ramBw) || 50 };
  }

  function unifiedHardware(name) {
    var u = UNIFIED.filter(function (x) { return x.name === name; })[0] || UNIFIED[0];
    return { label: u.name, usable: u.usable, bw: u.bw, ramGB: 0, unified: true };
  }

  function fmtGB(bytes) { var g = bytes / GB; return g >= 100 ? g.toFixed(0) : g >= 10 ? g.toFixed(1) : g.toFixed(2); }
  function fmtParams(n) { return n >= 1e12 ? (n / 1e12).toFixed(2) + "T" : n >= 1e9 ? (n / 1e9).toFixed(n >= 1e10 ? 0 : 1) + "B" : (n / 1e6).toFixed(0) + "M"; }
  function fmtCtx(n) { return n >= 1024 ? Math.round(n / 1024) + "K" : String(n); }

  // "owner/name" from a pasted URL or id.
  function parseModelId(s) {
    s = String(s || "").trim();
    var m = s.match(/huggingface\.co\/(?:models\/)?([\w.-]+\/[\w.-]+)/);
    if (m) return m[1];
    m = s.match(/^([\w.-]+\/[\w.-]+)$/);
    return m ? m[1] : "";
  }

  var api = { QUANTS: QUANTS, KV_TYPES: KV_TYPES, GPUS: GPUS, APPLE: APPLE, UNIFIED: UNIFIED, GB: GB,
    describe: describe, kvBytes: kvBytes, weightBytes: weightBytes, estimate: estimate, quantRows: quantRows,
    appleHardware: appleHardware, gpuHardware: gpuHardware, unifiedHardware: unifiedHardware,
    fmtGB: fmtGB, fmtParams: fmtParams, fmtCtx: fmtCtx, parseModelId: parseModelId, flatConfig: flatConfig };
  root.Vramwise = api;
  if (typeof module !== "undefined") module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);