File size: 37,902 Bytes
8bbafb9 fe4bca7 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 fe4bca7 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 0cb14a1 8bbafb9 fe4bca7 8bbafb9 fe4bca7 8bbafb9 0cb14a1 8bbafb9 0cb14a1 8bbafb9 | 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 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 | /*
* VRAM & GPU cost calculator (FastGPU's Hugging Face Space).
*
* Runs entirely in the visitor's browser. Two public, keyless reads:
* 1. the Hugging Face Hub API, for the model's own metadata (parameter count from its safetensors or GGUF
* header, its weight format, its task) and, for a GGUF repo, the size of each build it holds;
* 2. FastGPU's match API (https://fastgpu.co/api/v1/match), which sizes the job and ranks the GPU setups
* that hold it at live prices. All sizing is FastGPU's, so this page and fastgpu.co never disagree.
* The model list (catalog.js with the page, catalog-more.js right after the first answer) only powers search
* and quick picks; every selection is re-read live. A name the list does not hold is searched on the Hub.
*/
(function () {
"use strict";
const SITE = "https://fastgpu.co";
const API = SITE + "/api/v1/match";
const GPUS_API = SITE + "/api/v1/gpus";
const HUB = "https://huggingface.co";
const SPACE_PAGE = HUB + "/spaces/fastgpu/vram-and-gpu-cost-calculator";
const UTM = { utm_source: "huggingface", utm_medium: "space", utm_campaign: "gpu-cost-calculator" };
const DEFAULT_MODEL = "Qwen/Qwen3-32B";
const PICKS = window.FASTGPU_PICKS || [];
const CATALOG = (window.FASTGPU_CATALOG || []).map(function (r) {
return { id: r[0], p: r[1], task: r[2], dl: r[3], q: r[4], low: r[0].toLowerCase() };
});
const BY_ID = new Map(CATALOG.map(function (m) { return [m.low, m]; }));
const MORE_JOBS = ["inference", "generate", "transcribe", "embed"];
// The rest of the list arrives most-downloaded first without a downloads column, and every row in it has
// fewer downloads than the rows above, so its place in the file ranks it (a negative, falling "dl").
function addModels(rows) {
(rows || []).forEach(function (r, i) {
const low = r[0].toLowerCase();
if (BY_ID.has(low)) return;
const m = { id: r[0], p: r[1], task: MORE_JOBS[r[2]] || "inference", dl: -1 - i, q: r[3] || null, low: low };
CATALOG.push(m);
BY_ID.set(low, m);
});
}
const TASKS = {
inference: "Run / serve it",
"finetune-lora": "Fine-tune with LoRA",
"finetune-full": "Full fine-tune",
generate: "Generate images or video",
transcribe: "Transcribe speech",
embed: "Embed text",
};
const PIPELINE_TASK = {
"text-generation": "inference", "image-text-to-text": "inference", "any-to-any": "inference",
"text2text-generation": "inference", "visual-question-answering": "inference", "text-to-speech": "inference",
"text-to-image": "generate", "image-to-image": "generate", "text-to-video": "generate", "image-to-video": "generate",
"automatic-speech-recognition": "transcribe",
"feature-extraction": "embed", "sentence-similarity": "embed", "text-ranking": "embed",
};
const PRECISION_TEXT = { fp16: "16-bit", int8: "8-bit", int4: "4-bit" };
const BYTES = { fp16: 2, int8: 1, int4: 0.5 };
const $ = function (id) { return document.getElementById(id); };
const el = {
form: $("calc"), input: $("model"), list: $("suggestions"), picks: $("picks"),
task: $("task"), prec: $("precision"), region: $("region"), spot: $("spot"),
out: $("result"), status: $("status"),
};
const state = {
sel: null, // { id, paramsB, paramsSource, bits, format, pipeline, gated, builds } or { size: N }
result: null, // last match API answer
sort: "price",
gpuMemory: null, // { "H100 SXM": 80, ... } from FastGPU's GPU list
seq: 0, // request counter, so a slow answer never overwrites a newer one
active: -1, // highlighted suggestion
options: [],
hubSeq: 0, // Hub search counter, so a late answer never lands on newer text
hubTimer: null,
};
// ---------------------------------------------------------------- helpers
function link(url) {
const u = new URL(url, SITE);
if (u.hostname.endsWith("fastgpu.co")) Object.keys(UTM).forEach(function (k) { u.searchParams.set(k, UTM[k]); });
return u.toString();
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; });
}
function usd(n) {
if (n == null || !isFinite(n)) return "n/a";
const digits = n < 100 ? 2 : 0;
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function gb(n) {
if (n >= 100) return Math.round(n).toLocaleString("en-US");
if (n >= 1) return n.toFixed(1).replace(/\.0$/, "");
return n.toFixed(2).replace(/0$/, "");
}
// The parameter count as shown (32.76 -> 32.8), so every figure derived on screen uses the number on screen.
function shownParams(p) {
if (p >= 1000) return Math.round(p / 10) * 10;
if (p >= 10) return Math.round(p * 10) / 10;
return Math.round(p * 100) / 100;
}
function sizeText(p) {
const v = shownParams(p);
return v >= 1000 ? (v / 1000).toString() + "T" : v.toString() + "B";
}
function ago(iso) {
const t = Date.parse(iso);
if (!isFinite(t)) return null;
const min = Math.max(0, Math.round((Date.now() - t) / 60000));
if (min < 1) return "just now";
if (min < 60) return min + " min ago";
const h = Math.round(min / 60);
return h + (h === 1 ? " hour ago" : " hours ago");
}
function timeout(ms) {
const c = new AbortController();
setTimeout(function () { c.abort(); }, ms);
return c.signal;
}
async function getJson(url, ms) {
const r = await fetch(url, { signal: timeout(ms) });
if (!r.ok) {
const e = new Error("HTTP " + r.status);
e.status = r.status;
throw e;
}
return r.json();
}
// The total size a repo name states ("Qwen3-30B-A3B" -> 30), skipping the active ("A3B") and expert ("8x7B") forms.
function nameSizeB(id) {
const name = (id.split("/").pop() || "").replace(/_/g, "-");
const m = name.match(/(?<![\w.])(\d+(?:\.\d+)?)[Bb](?![a-zA-Z0-9])/);
return m ? parseFloat(m[1]) : null;
}
// Bits the published weights are stored at (4 or 8), from the repo's quantization config, its tensors, or its name.
function weightBits(meta, id) {
const qc = (meta.config && meta.config.quantization_config) || {};
const method = String(qc.quant_method || "").toLowerCase();
if (["awq", "gptq", "hqq", "aqlm", "quark"].indexOf(method) >= 0) return (qc.bits || qc.w_bit || qc.nbits) === 8 ? 8 : 4;
if (method === "bitsandbytes") return qc.load_in_4bit ? 4 : qc.load_in_8bit ? 8 : null;
if (method === "mxfp4" || method === "nvfp4") return 4;
if (["fp8", "fbgemm_fp8", "eetq"].indexOf(method) >= 0) return 8;
if (method === "compressed-tensors") {
const bits = Object.values(qc.config_groups || {}).map(function (g) { return g && g.weights && g.weights.num_bits; }).filter(Boolean);
if (bits.length) { const b = Math.min.apply(null, bits); if (b === 4 || b === 8) return b; }
}
const top = dominantDtype(meta);
if (top && top.indexOf("F8") === 0) return 8;
if (/(^|[-_./])(fp16|f16|bf16|fp32|f32)([-_./]|$)/i.test(id)) return null;
if (meta.gguf && !meta.safetensors) return 4;
const n = id.toLowerCase();
if (/(^|[-_./])(4bit|4-bit|int4|w4a16|awq|gptq|nf4|mxfp4|nvfp4|q4)/.test(n)) return 4;
if (/(^|[-_./])(8bit|8-bit|int8|w8a8|w8a16|fp8)/.test(n)) return 8;
return null;
}
function dominantDtype(meta) {
const p = meta.safetensors && meta.safetensors.parameters;
if (!p) return null;
let best = null;
Object.keys(p).forEach(function (k) { if (!best || p[k] > p[best]) best = k; });
return best;
}
function formatName(meta, bits) {
const qc = (meta.config && meta.config.quantization_config) || {};
const method = String(qc.quant_method || "").toUpperCase();
if (meta.gguf && !meta.safetensors) return "GGUF";
if (method && method !== "COMPRESSED-TENSORS" && method !== "BITSANDBYTES") return method + (bits ? " " + bits + "-bit" : "");
if (bits) return bits + "-bit";
const top = dominantDtype(meta);
return top || null;
}
// ---------------------------------------------------------------- GGUF builds
// A GGUF repo holds one file (or one set of split files) per build: Q4_K_M, Q5_K_M, Q8_0... Each has its
// own size, and that size is the weights a GPU has to hold, so a GGUF repo is sized from the build's file
// rather than from a nominal bit count (a "4-bit" Q4_K_M stores close to 5 bits per parameter).
const BUILD_TOKEN = /(?:^|[-_.])((?:I?Q\d+(?:_[A-Z0-9]+)*)|BF16|FP?16|FP?32)(?=[-_.]|$)/i;
// The build most people load, then the nearest ones, when the visitor has not picked.
const BUILD_PREFERENCE = ["Q4_K_M", "Q4_K_S", "Q4_0", "Q4_K_L", "Q4_K_XL", "IQ4_XS", "Q5_K_M", "Q5_K_S", "Q3_K_M", "Q6_K", "Q8_0"];
const STORED_BITS = [1, 40]; // what a published weight file can hold per parameter; FastGPU's API checks the same range
function ggufBuilds(files, paramsB) {
const byFile = new Map();
(files || []).forEach(function (f) {
if (!f || f.type !== "file" || !/\.gguf$/i.test(f.path)) return;
const parts = f.path.split("/");
const name = parts.pop().replace(/\.gguf$/i, "");
if (/mmproj|imatrix|vocab|tokenizer/i.test(name)) return; // projectors and calibration data, not weights
const base = name.replace(/-\d{5}-of-\d{5}$/, ""); // a split build is one build
const token = (base.match(BUILD_TOKEN) || parts.join("/").match(BUILD_TOKEN) || [])[1];
if (!token) return;
const key = parts.join("/") + "/" + base;
const size = (f.lfs && f.lfs.size) || f.size || 0;
const b = byFile.get(key) || { quant: token.toUpperCase(), file: base, bytes: 0 };
b.bytes += size;
byFile.set(key, b);
});
// Keep the builds that can be this model's weights (another model's file, or an empty pointer, is not).
const real = [];
byFile.forEach(function (b) {
const gbSize = b.bytes / 1e9;
const bits = paramsB ? (gbSize * 8) / paramsB : 0;
if (bits >= STORED_BITS[0] && bits <= STORED_BITS[1]) real.push({ quant: b.quant, file: b.file, gb: gbSize, bits: bits });
});
// Two real files with the same build name are two models in one repo: tell them apart by file name.
const seen = new Map();
real.forEach(function (b) { seen.set(b.quant, (seen.get(b.quant) || 0) + 1); });
const out = real.map(function (b) {
return { key: seen.get(b.quant) > 1 ? b.file : b.quant, quant: b.quant, gb: b.gb, bits: b.bits };
});
out.sort(function (a, b) { return a.gb - b.gb; });
return out.slice(0, 60);
}
function defaultBuild(builds) {
for (let i = 0; i < BUILD_PREFERENCE.length; i++) {
const hit = builds.find(function (b) { return b.quant === BUILD_PREFERENCE[i]; });
if (hit) return hit;
}
return builds[0] || null;
}
function chosenBuild() {
const s = state.sel;
if (!s || !s.builds || !s.builds.length || el.prec.value.indexOf("build:") !== 0) return null;
const key = el.prec.value.slice(6);
return s.builds.find(function (b) { return b.key === key; }) || null;
}
// The "Weights" menu: a GGUF repo's own builds with their sizes, otherwise the precision classes.
const PRECISION_OPTIONS = el.prec.innerHTML;
function setWeightOptions(builds, wanted) {
if (!builds || !builds.length) {
if (el.prec.getAttribute("data-kind") === "builds") { el.prec.innerHTML = PRECISION_OPTIONS; el.prec.value = "auto"; }
el.prec.removeAttribute("data-kind");
return;
}
el.prec.innerHTML = builds.map(function (b) {
return '<option value="build:' + esc(b.key) + '">' + esc(b.key) + " · " + esc(gb(b.gb)) + " GB</option>";
}).join("");
el.prec.setAttribute("data-kind", "builds");
const pick = (wanted && builds.find(function (b) { return b.key.toLowerCase() === wanted.toLowerCase(); })) || defaultBuild(builds);
el.prec.value = "build:" + pick.key;
}
async function readBuilds(id, paramsB) {
const path = id.split("/").map(encodeURIComponent).join("/");
try {
const files = await getJson(HUB + "/api/models/" + path + "/tree/main?recursive=true&expand=false&limit=1000", 8000);
return ggufBuilds(files, paramsB);
} catch (e) {
return []; // the repo is then sized by its parameter count, as a non-GGUF repo is
}
}
// ---------------------------------------------------------------- model metadata
async function readModel(id) {
const path = id.split("/").map(encodeURIComponent).join("/");
const q = ["safetensors", "gguf", "config", "pipeline_tag", "library_name", "gated", "downloads", "likes"]
.map(function (k) { return "expand[]=" + k; }).join("&");
const meta = await getJson(HUB + "/api/models/" + path + "?" + q, 8000);
const total = ((meta.safetensors && meta.safetensors.total) || (meta.gguf && meta.gguf.total) || 0) / 1e9;
const stated = nameSizeB(meta.id || id);
let paramsB = null, source = null;
if (total && stated && total < 0.6 * stated) { paramsB = stated; source = "name"; }
else if (total) { paramsB = total; source = meta.safetensors && meta.safetensors.total ? "safetensors" : "gguf"; }
else if (stated) { paramsB = stated; source = "name"; }
const bits = weightBits(meta, meta.id || id);
// Builds are read for a repo that is GGUF only, whose size came from its GGUF header (the files then
// belong to the model that count describes).
const builds = meta.gguf && !meta.safetensors && source === "gguf" ? await readBuilds(meta.id || id, paramsB) : [];
return {
id: meta.id || id,
paramsB: paramsB,
paramsSource: source,
bits: bits,
format: formatName(meta, bits),
pipeline: meta.pipeline_tag || null,
library: meta.library_name || null,
gated: !!meta.gated,
builds: builds,
};
}
// ---------------------------------------------------------------- search box
// "https://huggingface.co/org/name/tree/main" (the address bar of a model page) names the repo org/name.
const HUB_PAGES = /^(models|spaces|datasets|docs|blog|posts|collections|settings|organizations|new|join|login|pricing|tasks|papers|learn|chat|api)$/i;
function repoFromText(text) {
const m = text.trim().match(/^(?:https?:\/\/)?(?:www\.)?(?:huggingface\.co|hf\.co)\/([\w.-]+)\/([\w.-]+)/i);
return m && !HUB_PAGES.test(m[1]) ? m[1] + "/" + m[2] : null;
}
function sizeQuery(text) {
const m = text.trim().match(/^~?(\d+(?:\.\d+)?)\s*([bt])?(?:\s*(?:params?|parameters?))?$/i);
if (!m) return null;
const n = parseFloat(m[1]) * (m[2] && m[2].toLowerCase() === "t" ? 1000 : 1);
return n >= 0.05 && n <= 5000 ? n : null;
}
function suggest(text) {
const pasted = repoFromText(text);
if (pasted) text = pasted;
const t = text.trim().toLowerCase();
const out = [];
if (!t) {
PICKS.forEach(function (id) { const m = BY_ID.get(id.toLowerCase()); if (m) out.push({ kind: "model", m: m }); });
return out.slice(0, 8);
}
const size = sizeQuery(t);
if (size) out.push({ kind: "size", size: size });
const words = t.split(/[\s/]+/).filter(Boolean);
const scored = [];
for (let i = 0; i < CATALOG.length; i++) {
const m = CATALOG[i];
if (!words.every(function (w) { return m.low.indexOf(w) >= 0; })) continue;
const name = m.low.split("/").pop();
const score = (m.low === t ? 4 : 0) + (name.indexOf(t) === 0 ? 2 : 0) + (m.low.indexOf(t) >= 0 ? 1 : 0);
scored.push({ m: m, score: score });
}
scored.sort(function (a, b) { return b.score - a.score || b.m.dl - a.m.dl; });
scored.slice(0, 8).forEach(function (s) { out.push({ kind: "model", m: s.m }); });
if (/^[\w.-]+\/[\w.-]+$/.test(text.trim()) && !BY_ID.has(t)) out.unshift({ kind: "hub", id: text.trim() });
else if (pasted && BY_ID.has(t)) { const hit = BY_ID.get(t); return [{ kind: "model", m: hit }]; }
return out.slice(0, 9);
}
// Names the list does not hold are searched on the Hub itself, a moment after typing stops (or at once on
// Enter). Its answers join the open list under the ones already there; picking one reads that repo like
// any other. `asked` is set when the visitor pressed Enter with nothing to pick, so an empty answer says so.
function hubSearch(text, asked) {
clearTimeout(state.hubTimer);
const seq = ++state.hubSeq;
const q = (repoFromText(text) || text).trim();
if (q.length < 3 || sizeQuery(q) || state.options.filter(function (o) { return o.kind === "model"; }).length >= 8) {
if (asked) el.status.textContent = "";
return;
}
const none = "Hugging Face has no public model matching " + esc(q) + ". Paste the model's org/name or its page address, or type a size such as 70B.";
state.hubTimer = setTimeout(function () {
getJson(HUB + "/api/models?search=" + encodeURIComponent(q) + "&sort=downloads&direction=-1&limit=8", 6000).then(function (rows) {
if (seq !== state.hubSeq || !Array.isArray(rows)) return;
if (!asked && document.activeElement !== el.input) return; // the visitor has left the box
const shown = new Set(state.options.map(function (o) { return (o.kind === "model" ? o.m.id : o.id || "").toLowerCase(); }));
rows.forEach(function (r) {
if (!r || !r.id || r.id.indexOf("/") < 0 || shown.has(r.id.toLowerCase()) || state.options.length >= 9) return;
const known = BY_ID.get(r.id.toLowerCase());
state.options.push(known ? { kind: "model", m: known } : { kind: "hub", id: r.id, found: true });
shown.add(r.id.toLowerCase());
});
if (!state.options.length) { if (asked) showError(none); return; }
if (asked) el.status.textContent = "";
if (state.active < 0) state.active = 0;
renderSuggestions();
}).catch(function () {
// The list already shows what this page knows; only a visitor who asked is told the Hub did not answer.
if (asked && seq === state.hubSeq) showError("Hugging Face's search did not answer just now. Paste the model's org/name, or type a size such as 70B.");
});
}, asked ? 0 : 250);
}
function renderSuggestions() {
const opts = state.options;
if (!opts.length) { closeSuggestions(); return; }
el.list.innerHTML = opts.map(function (o, i) {
const sel = i === state.active ? ' aria-selected="true"' : "";
if (o.kind === "size") return '<li role="option" id="opt-' + i + '"' + sel + ' data-i="' + i + '"><span class="opt-name">A ' + esc(sizeText(o.size)) + "-parameter model</span><span class=\"opt-meta\">size only</span></li>";
if (o.kind === "hub") return '<li role="option" id="opt-' + i + '"' + sel + ' data-i="' + i + '"><span class="opt-name">' + esc(o.id) + '</span><span class="opt-meta">' + (o.found ? "on Hugging Face" : "look it up on Hugging Face") + "</span></li>";
return '<li role="option" id="opt-' + i + '"' + sel + ' data-i="' + i + '"><span class="opt-name">' + esc(o.m.id) + '</span><span class="opt-meta">' + esc(sizeText(o.m.p)) + " · " + esc(TASKS[o.m.task] || o.m.task) + "</span></li>";
}).join("");
el.list.hidden = false;
el.input.setAttribute("aria-expanded", "true");
el.input.setAttribute("aria-activedescendant", state.active >= 0 ? "opt-" + state.active : "");
}
function closeSuggestions() {
el.list.hidden = true;
el.input.setAttribute("aria-expanded", "false");
el.input.removeAttribute("aria-activedescendant");
state.active = -1;
}
function cancelHubSearch() {
clearTimeout(state.hubTimer);
state.hubSeq++;
}
function choose(o) {
cancelHubSearch();
closeSuggestions();
if (!o) return;
if (o.kind === "size") { el.input.value = sizeText(o.size) + " model"; selectSize(o.size); return; }
const id = o.kind === "hub" ? o.id : o.m.id;
el.input.value = id;
selectModel(id, true);
}
el.input.addEventListener("input", function () {
state.options = suggest(el.input.value);
state.active = state.options.length ? 0 : -1;
renderSuggestions();
hubSearch(el.input.value);
});
el.input.addEventListener("focus", function () {
state.options = suggest(el.input.value === (state.sel && state.sel.id) ? "" : el.input.value);
state.active = -1;
renderSuggestions();
});
el.input.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
if (el.list.hidden) { state.options = suggest(el.input.value); }
const n = state.options.length;
if (!n) return;
e.preventDefault();
state.active = e.key === "ArrowDown" ? (state.active + 1) % n : (state.active - 1 + n) % n;
renderSuggestions();
} else if (e.key === "Enter") {
e.preventDefault();
if (!el.list.hidden && state.active >= 0) { choose(state.options[state.active]); return; }
const opts = suggest(el.input.value);
if (opts.length) { choose(opts[0]); return; }
// Nothing in the list: ask the Hub now and offer what it finds.
state.options = [];
el.status.textContent = "Searching Hugging Face…";
hubSearch(el.input.value, true);
} else if (e.key === "Escape") {
closeSuggestions();
}
});
el.list.addEventListener("mousedown", function (e) {
const li = e.target.closest("li[data-i]");
if (!li) return;
e.preventDefault();
choose(state.options[Number(li.getAttribute("data-i"))]);
});
el.input.addEventListener("blur", function () { setTimeout(closeSuggestions, 120); });
el.picks.innerHTML = PICKS.filter(function (id) { return BY_ID.has(id.toLowerCase()); }).map(function (id) {
return '<button type="button" class="chip" data-id="' + esc(id) + '">' + esc(id.split("/").pop()) + "</button>";
}).join("");
el.picks.addEventListener("click", function (e) {
const b = e.target.closest("button[data-id]");
if (!b) return;
cancelHubSearch();
el.input.value = b.getAttribute("data-id");
selectModel(b.getAttribute("data-id"), true);
});
[el.task, el.prec, el.region, el.spot].forEach(function (c) {
c.addEventListener("change", function () { saveHash(); run(); });
});
el.form.addEventListener("submit", function (e) { e.preventDefault(); });
// ---------------------------------------------------------------- selection
async function selectModel(id, resetTask, wantedBuild) {
const seq = ++state.seq;
const known = BY_ID.get(id.toLowerCase());
setStatus("Reading " + id + " from Hugging Face…");
let sel;
try {
sel = await readModel(id);
} catch (err) {
if (seq !== state.seq) return;
if (known) {
sel = { id: known.id, paramsB: known.p, paramsSource: "catalog", bits: known.q, format: known.q ? known.q + "-bit" : null, pipeline: null, gated: false };
} else {
showError(err && err.status === 404
? "Hugging Face has no public model called " + esc(id) + ". Check the spelling, or type a size such as 70B."
: "Could not read " + esc(id) + " from Hugging Face just now. Try again, or type a size such as 70B.");
return;
}
}
if (seq !== state.seq) return;
state.sel = sel;
if (resetTask) {
const t = (sel.pipeline && PIPELINE_TASK[sel.pipeline]) || (known && known.task) || "inference";
el.task.value = t;
el.prec.value = "auto";
}
setWeightOptions(sel.builds, wantedBuild);
saveHash();
run();
}
function selectSize(size) {
state.sel = { size: size };
setWeightOptions(null);
el.prec.value = el.prec.value || "auto";
saveHash();
run();
}
function autoPrecision() {
const s = state.sel;
if (!s || !s.bits) return undefined;
return s.bits === 4 ? "int4" : "int8";
}
// ---------------------------------------------------------------- match
async function run() {
const s = state.sel;
if (!s) return;
const seq = ++state.seq;
const params = new URLSearchParams();
if (s.id) params.set("model", s.id);
const paramsB = s.id ? s.paramsB : s.size;
if (paramsB) params.set("params_b", String(Math.round(paramsB * 100) / 100));
params.set("task", el.task.value);
const build = chosenBuild();
if (build) {
// The build's size on the Hub is the weights to hold. Its precision class goes with it (the same split
// FastGPU's API makes: under 6 bits per parameter is a 4-bit class build, under 12 an 8-bit one).
params.set("weights_gb", String(Math.round(build.gb * 100) / 100));
params.set("precision", build.bits < 6 ? "int4" : build.bits < 12 ? "int8" : "fp16");
} else {
const prec = el.prec.value === "auto" ? autoPrecision() : el.prec.value;
if (prec) params.set("precision", prec);
}
if (el.region.value) params.set("region", el.region.value);
if (el.spot.checked) params.set("spot", "true");
if (s.id && !paramsB) {
showError("The repo " + esc(s.id) + " does not publish a parameter count. Type its size instead, for example 70B.");
return;
}
setStatus("Pricing the GPUs that fit…");
let res = null;
try {
const both = await Promise.all([
fetch(API + "?" + params.toString(), { signal: timeout(15000) }).then(function (r) {
return r.json().catch(function () { return null; }).then(function (body) { return { ok: r.ok, body: body }; });
}),
loadGpuMemory(),
]);
res = both[0];
} catch (err) {
res = null;
}
if (seq !== state.seq) return;
if (res && res.body && (res.ok || res.body.error)) {
state.result = res.body;
render();
return;
}
showError("FastGPU's price feed did not answer in time. <button type=\"button\" class=\"linklike\" id=\"retry\">Try again</button>");
const b = $("retry");
if (b) b.addEventListener("click", run);
}
async function loadGpuMemory() {
if (state.gpuMemory) return;
try {
const d = await getJson(GPUS_API, 10000);
const map = {};
(d.gpus || []).forEach(function (g) { if (g.gpu && g.vram_gb) map[g.gpu] = g.vram_gb; });
state.gpuMemory = map;
} catch (e) {
state.gpuMemory = {};
}
}
// ---------------------------------------------------------------- render
function setStatus(text) {
el.status.textContent = text;
el.out.setAttribute("aria-busy", "true");
el.out.classList.add("loading");
}
function showError(html) {
el.out.classList.remove("loading");
el.out.removeAttribute("aria-busy");
el.status.textContent = "";
el.out.innerHTML = '<div class="notice">' + html + "</div>";
}
function needSentence(task, precText, build) {
if (build && task !== "finetune-full") {
const what = "its " + build.key + " build";
return task === "finetune-lora" ? "of GPU memory to fine-tune " + what + " with LoRA" : "of GPU memory to run " + what;
}
switch (task) {
case "finetune-lora": return "of GPU memory to fine-tune it with LoRA at " + precText;
case "finetune-full": return "of GPU memory for a full fine-tune";
case "generate": return "of GPU memory to generate with it at " + precText;
case "transcribe": return "of GPU memory to transcribe with it at " + precText;
case "embed": return "of GPU memory to embed with it at " + precText;
default: return "of GPU memory to run it at " + precText;
}
}
function derivation(task, rawParamsB, precision, need, build) {
const bytes = BYTES[precision];
const paramsB = shownParams(rawParamsB);
const weights = build ? build.gb : paramsB * bytes;
const w = build
? "Weights: the " + build.key + " build in this repo is " + gb(build.gb) + " GB (" + gb(build.gb) + " GB × 8 ÷ " + sizeText(paramsB) + " parameters = " + (Math.round(((build.gb * 8) / paramsB) * 100) / 100) + " bits per parameter)."
: "Weights: " + sizeText(paramsB) + " parameters × " + bytes + (bytes === 1 ? " byte" : " bytes") + " = " + gb(weights) + " GB.";
const rest = need - weights;
if (task === "finetune-full") {
return "Full fine-tuning holds the weights, their gradients and Adam optimizer states, about 16 bytes per parameter (" + sizeText(paramsB) + " × 16 = " + gb(paramsB * 16) + " GB), plus activations.";
}
if (rest < 0.5) return w;
if (task === "inference") return w + " The other ~" + gb(rest) + " GB is the KV cache for one request at a 4K-token context, plus runtime headroom.";
if (task === "finetune-lora") return w + " The other ~" + gb(rest) + " GB is the LoRA adapters, their optimizer state and activations for a 4K-token batch.";
if (task === "generate") return w + " The other ~" + gb(rest) + " GB covers the text encoders, VAE and working memory an image or video pipeline loads with it.";
return w + " The other ~" + gb(rest) + " GB is activations and runtime headroom.";
}
function render() {
const res = state.result;
const s = state.sel;
el.out.classList.remove("loading");
el.out.removeAttribute("aria-busy");
el.status.textContent = "";
if (!res || res.error) { showError(esc((res && res.error) || "No answer from FastGPU.")); return; }
const w = res.workload || {};
// FastGPU decides the job's kind for a model it knows (an image model is sized as a pipeline), so the
// sentences follow its answer rather than the menu.
const task = TASKS[w.task] ? w.task : el.task.value;
const precision = w.precision || "fp16";
const precText = PRECISION_TEXT[precision] || precision;
const paramsB = s.id ? s.paramsB : s.size;
const need = w.vram_required_gb;
// The build the answer was sized by: only when FastGPU's answer says it used a measured weights size, so
// the sentences never describe a sizing the number does not come from.
const picked = chosenBuild();
const build = picked && w.weights_gb ? picked : null;
let head = "";
if (s.id) {
const facts = [sizeText(paramsB) + " parameters"];
if (s.format) facts.push(esc(s.format) + " weights");
if (s.pipeline) facts.push(esc(s.pipeline));
const src = { safetensors: "the repo's safetensors metadata", gguf: "the repo's GGUF header", name: "the model's name (the Hub's count of its packed weights is lower)", catalog: "this Space's model list (Hugging Face did not answer just now)" }[s.paramsSource] || "the repo";
head = '<div class="model"><a class="model-id" href="' + HUB + "/" + esc(s.id) + '" target="_blank" rel="noopener">' + esc(s.id) + "</a>" +
'<p class="facts">' + facts.join(" · ") + "</p>" +
'<p class="fineprint">Parameter count from ' + src + "." + (s.library === "mlx" ? " MLX builds run on Apple silicon; sized here as the same model on a rented GPU." : "") + "</p></div>";
} else {
head = '<div class="model"><span class="model-id">A ' + esc(sizeText(paramsB)) + "-parameter model</span></div>";
}
const needHtml = need
? '<div class="need"><p class="need-num"><span class="mono">~' + esc(Math.round(need).toLocaleString("en-US")) + "</span> GB</p>" +
'<p class="need-what">' + esc(needSentence(task, precText, build)) + "</p>" +
'<p class="fineprint">' + esc(derivation(task, paramsB, precision, need, build)) +
' <a href="' + link(SITE + "/methodology") + '" target="_blank" rel="noopener">How FastGPU sizes and ranks</a></p></div>'
: "";
let rows = (res.matches || []).slice();
if (state.sort === "price") rows.sort(function (a, b) { return a.effective_usd_hr - b.effective_usd_hr; });
const cheapest = rows.reduce(function (min, r) { return Math.min(min, r.effective_usd_hr); }, Infinity);
const mem = state.gpuMemory || {};
let table;
if (!rows.length) {
table = '<div class="notice">No GPU setup in FastGPU\'s live feed holds this ' + (need ? "(~" + esc(Math.round(need)) + " GB) " : "") + "right now with these filters. Try 8-bit or 4-bit weights, another region, or include spot capacity.</div>";
} else {
table = '<div class="table-head"><h2 id="setups">GPU setups that hold it, priced live</h2>' +
'<div class="sort" role="group" aria-label="Order">' +
'<button type="button" data-sort="price" aria-pressed="' + (state.sort === "price") + '">Lowest price</button>' +
'<button type="button" data-sort="match" aria-pressed="' + (state.sort === "match") + '">Best match</button></div></div>' +
'<table aria-labelledby="setups"><thead><tr><th scope="col">Setup</th><th scope="col" class="num">Per hour</th><th scope="col" class="num">Per month</th><th scope="col">Provider</th></tr></thead><tbody>' +
rows.map(function (r) {
const card = mem[r.gpu];
const setup = (r.gpu_count > 1 ? r.gpu_count + "× " : "1× ") + r.gpu;
const sub = (card ? card + " GB " + (r.gpu_count > 1 ? "each, " : "") : "") + (r.fits_single_card ? "fits on one card" : "split across " + r.gpu_count + " cards");
const kind = [r.offer_type, r.reliability].filter(function (x, i, a) {
return x && a.findIndex(function (y) { return y && y.toLowerCase() === x.toLowerCase(); }) === i;
}).join(" · ");
return "<tr" + (r.effective_usd_hr === cheapest ? ' class="cheapest"' : "") + ">" +
'<th scope="row"><span class="setup">' + esc(setup) + '</span><span class="sub">' + esc(sub) + "</span></th>" +
'<td class="num"><a class="price" href="' + link(r.page_url) + '" target="_blank" rel="noopener" title="Every live offer for the ' + esc(r.gpu) + ' on FastGPU">' + esc(usd(r.effective_usd_hr)) + "</a>" +
(r.effective_usd_hr === cheapest ? '<span class="sub">cheapest</span>' : "") + "</td>" +
'<td class="num mono">' + esc(usd(r.monthly_usd)) + "</td>" +
"<td>" + esc(r.provider_label) + (r.partner ? ' <span class="star" title="Pays FastGPU a referral fee">★</span>' : "") + '<span class="sub">' + esc(kind) + "</span></td></tr>";
}).join("") + "</tbody></table>";
}
const fresh = res.updated_at ? ago(res.updated_at) : null;
const foot = '<p class="fineprint">' +
(fresh ? "Prices from FastGPU's live feed, refreshed " + esc(fresh) + ". " : "") +
"Each price is the whole setup per hour (per-GPU rate × GPUs), per month at 730 hours; it links to every live offer for that GPU. " +
"Best match orders by FastGPU's score, led by price and weighing reliability and availability; providers marked ★ pay FastGPU a referral fee and can only win a near-tie there, within a few percent of the cheapest. " +
"Some providers bill CPU, RAM or disk on top of the GPU rate; the GPU page says which.</p>" +
'<p class="cta"><a class="button" href="' + link(SITE + "/") + '" target="_blank" rel="noopener">Compare every GPU on FastGPU</a>' +
'<a href="' + link(SITE + "/gpus") + '" target="_blank" rel="noopener">All live GPU prices</a></p>' +
'<p class="fineprint thanks">Saved you a search? A like on <a href="' + SPACE_PAGE + '" target="_blank" rel="noopener">this Space</a> helps other people on the Hub find it.</p>';
el.out.innerHTML = head + needHtml + table + foot;
el.out.querySelectorAll("button[data-sort]").forEach(function (b) {
b.addEventListener("click", function () { state.sort = b.getAttribute("data-sort"); render(); });
});
}
// ---------------------------------------------------------------- shareable state
function saveHash() {
const s = state.sel;
if (!s) return;
const h = new URLSearchParams();
if (s.id) h.set("model", s.id); else h.set("size", String(s.size));
if (el.task.value !== "inference") h.set("task", el.task.value);
if (el.prec.value.indexOf("build:") === 0) h.set("build", el.prec.value.slice(6));
else if (el.prec.value !== "auto") h.set("precision", el.prec.value);
if (el.region.value) h.set("region", el.region.value);
if (el.spot.checked) h.set("spot", "1");
try { history.replaceState(null, "", "#" + h.toString()); } catch (e) { /* sandboxed frame */ }
}
function start() {
const raw = (location.hash || "").replace(/^#/, "") || (location.search || "").replace(/^\?/, "");
const h = new URLSearchParams(raw);
const task = h.get("task");
if (task && TASKS[task]) el.task.value = task;
const prec = h.get("precision");
if (prec && ["fp16", "int8", "int4"].indexOf(prec) >= 0) el.prec.value = prec;
const region = h.get("region");
if (region && ["US", "EU", "ASIA"].indexOf(region) >= 0) el.region.value = region;
el.spot.checked = h.get("spot") === "1";
const size = h.get("size") ? parseFloat(h.get("size")) : NaN;
if (isFinite(size) && size > 0) { el.input.value = sizeText(size) + " model"; selectSize(size); return; }
const model = h.get("model") || DEFAULT_MODEL;
el.input.value = model;
// A link that states the job, the weights or the build is opened as it was saved; a bare model link
// gets the job its kind of model does.
const build = h.get("build") || undefined;
selectModel(model, !(task || prec || build), build);
}
// The rest of the model list, fetched after the first answer is under way so it never delays it.
function loadMore() {
const tag = document.createElement("script");
tag.src = "catalog-more.js";
tag.async = true;
tag.onload = function () {
addModels(window.FASTGPU_CATALOG_MORE);
window.FASTGPU_CATALOG_MORE = null;
if (!el.list.hidden && document.activeElement === el.input) {
state.options = suggest(el.input.value === (state.sel && state.sel.id) ? "" : el.input.value);
renderSuggestions();
}
};
document.head.appendChild(tag);
}
start();
loadMore();
})();
|