cybermax / fitcheck /vramwise.js
CyberMax-tools's picture
Vramwise: decision models (MATILDA-jev, JEV, Decider), linear-attention + packed FP4 support
afcfd33 verified
Raw History Blame Contribute Delete
14.6 kB
/* 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);