Showcase

Will it run? A GPU shop for local models, built by Underdog Saluki 27B

IQ2_XSneeds 10.2 GB at 32Kvia OpenCodellama.cpp
Running live in your browserNew tab
Memory to run
10.2GB
At 32K context, from its benchmark run
Show the code the model wrote
HTML44,952 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GPU Shop Board</title>
<style>
:root {
  --bg: #0f1115;
  --panel-bg: #16181d;
  --card-bg: #1c1f26;
  --card-border: #2a2e37;
  --text: #e6e8ee;
  --dim: #9aa0b0;
  --accent: #4f9dff;
  --weights: #4f9dff;
  --context: #3dd68a;
  --overhead: #f2a33d;
  --ram: #8b93a7;
  --ram-overflow: #e05353;
  --fit: #3dd68a;
  --over: #e05353;
  --selected: #4f9dff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: "SF Pro Display", "Inter", "Helvetica Neue", system-ui, sans-serif;
  font-size: 13px;
  overflow: hidden;
}
#app {
  display: flex;
  flex-direction: column;
  height: 100vh;
  padding: 8px 12px;
  gap: 8px;
}
#board {
  display: flex;
  gap: 12px;
  flex: 1;
  min-height: 0;
}
#models-col {
  width: 240px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  overflow: hidden;
}
#models-col h2, .machine h2 {
  font-size: 11px;
  color: var(--dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: 1px;
}
.model-card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 6px;
  padding: 4px 6px;
  cursor: grab;
  user-select: none;
  transition: border-color .15s;
}
.model-card:hover { border-color: var(--accent); }
.model-card.dragging { opacity: .4; }
.model-card .mc-name { font-weight: 600; font-size: 11px; }
.model-card .mc-name .mc-tag { font-weight: 400; font-size: 10px; color: var(--dim); }
.model-card .mc-name .mc-tag.moe { color: var(--context); font-weight: 600; }
.model-card .mc-line {
  margin-top: 2px;
  font-size: 9.5px;
  line-height: 1.25;
  color: var(--dim);
  white-space: nowrap;
  overflow: hidden;
}
.model-card .mc-line b { color: var(--text); font-weight: 500; }
#machines-col {
  flex: 1;
  display: flex;
  gap: 12px;
  min-width: 0;
}
.machine {
  flex: 1;
  min-width: 0;
  background: var(--panel-bg);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  overflow: hidden;
}
.machine.drag-over { border-color: var(--accent); }
.machine .m-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}
.machine .m-card-name { font-weight: 700; font-size: 14px; }
.machine .m-count { color: var(--dim); font-size: 12px; }
.machine .m-price { color: var(--dim); font-size: 12px; }
.machine .m-controls {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 6px;
}
.spill-toggle {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  color: var(--dim);
  white-space: nowrap;
}
.spill-toggle input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
.ram-size {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--card-border);
  border-radius: 5px;
  padding: 2px 5px;
  font-size: 11px;
  cursor: pointer;
}
.btn {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--card-border);
  border-radius: 5px;
  padding: 3px 8px;
  font-size: 11px;
  cursor: pointer;
  white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.danger { color: var(--over); }
.btn.danger:hover { border-color: var(--over); }
.mem-bars { display: flex; flex-direction: column; gap: 3px; }
.mem-bar {
  display: flex;
  height: 20px;
  border-radius: 4px;
  overflow: hidden;
  background: #0a0b0e;
  border: 1px solid var(--card-border);
}
.mem-bar .seg {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  color: #fff;
  overflow: hidden;
  white-space: nowrap;
}
.seg.weights { background: var(--weights); }
.seg.context { background: var(--context); }
.seg.overhead { background: var(--overhead); }
.seg.ram-used { background: var(--ram); }
.seg.ram-overflow { background: var(--ram-overflow); }
.fit-line {
  font-size: 13px;
  font-weight: 600;
  padding: 4px 8px;
  border-radius: 5px;
  background: var(--card-bg);
}
.fit-line.ok { color: var(--fit); }
.fit-line.bad { color: var(--over); }
.cards {
  display: flex;
  flex-direction: column;
  gap: 5px;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 6px;
  padding: 6px 8px;
  cursor: grab;
  user-select: none;
  transition: border-color .15s;
}
.card:hover { border-color: var(--accent); }
.card.dragging { opacity: .4; }
.card.picked { border-color: var(--selected); }
.card .c-name { font-weight: 600; font-size: 13px; }
.card .c-tags {
  display: flex; gap: 6px; margin-top: 1px;
  font-size: 10px; color: var(--dim);
}
.card .c-tags .moe { color: var(--context); font-weight: 600; }
.card .c-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 4px;
  align-items: center;
}
.switch {
  display: flex;
  align-items: center;
  gap: 3px;
  font-size: 11px;
  color: var(--dim);
  white-space: nowrap;
}
.switch input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
.ctx-slider {
  display: flex;
  align-items: center;
  gap: 5px;
  flex: 1;
  min-width: 100px;
}
.ctx-slider input[type="range"] {
  flex: 1;
  accent-color: var(--accent);
  cursor: pointer;
}
.ctx-slider .ctx-val {
  font-size: 11px;
  color: var(--text);
  min-width: 50px;
  text-align: right;
}
.ctx-slider .max-ctx {
  font-size: 10px;
  color: var(--dim);
}
.card .c-speed {
  margin-top: 4px;
  font-size: 13px;
  font-weight: 600;
}
.card .c-speed.ok { color: var(--fit); }
.card .c-speed.bad { color: var(--over); }
.card .c-ram-share { color: var(--dim); font-size: 11px; }
.add-machine {
  flex: 1;
  min-width: 0;
  border: 1px dashed var(--card-border);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--dim);
  cursor: pointer;
  font-size: 13px;
}
.add-machine:hover { border-color: var(--accent); color: var(--accent); }
#panel {
  background: var(--panel-bg);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 8px 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  height: 200px;
}
#panel .p-header {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
#panel .p-name { font-weight: 700; font-size: 14px; }
#panel .p-speed { color: var(--fit); font-weight: 600; font-size: 13px; }
#panel .p-speed.bad { color: var(--over); }
#panel .p-verdict {
  font-size: 12px;
  padding: 2px 6px;
  border-radius: 5px;
  background: var(--card-bg);
}
#panel .p-verdict.ok { color: var(--fit); }
#panel .p-verdict.bad { color: var(--over); }
#panel .p-controls {
  margin-left: auto;
  display: flex;
  gap: 6px;
  align-items: center;
}
#panel .p-body {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  overflow: hidden;
}
#panel .p-stream {
  display: flex;
  gap: 10px;
  font-size: 12px;
  color: var(--dim);
}
#panel .p-stream b { color: var(--text); }
#panel .p-answer {
  flex: 1;
  min-height: 0;
  overflow: auto;
  font-size: 12px;
  line-height: 1.4;
  color: var(--text);
  white-space: pre-wrap;
}
#panel .p-answer .done { color: var(--fit); font-weight: 600; }
#panel .p-changes {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
#panel .p-changes h4 {
  font-size: 11px;
  color: var(--dim);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
#panel .change {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 6px;
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 5px;
  cursor: pointer;
  font-size: 12px;
}
#panel .change:hover { border-color: var(--accent); }
#panel .change .c-label { font-weight: 600; }
#panel .change .c-num { color: var(--dim); }
#panel .math-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
}
#panel .math-table th, #panel .math-table td {
  padding: 3px 6px;
  border-bottom: 1px solid var(--card-border);
  text-align: right;
}
#panel .math-table th { color: var(--dim); font-weight: 500; }
#panel .math-table th:first-child, #panel .math-table td:first-child {
  text-align: left;
}
@media (min-height: 950px) {
  #models-col { width: 270px; gap: 6px; }
  #panel { height: 320px; }
  .model-card { padding: 5px 8px; }
  .model-card .mc-name { font-size: 12px; }
  .model-card .mc-line { font-size: 10px; }
}
</style>
</head>
<body>
<div id="app">
  <div id="board">
    <div id="models-col">
      <h2>Models</h2>
      <div id="model-cards"></div>
    </div>
    <div id="machines-col"></div>
  </div>
  <div id="panel"></div>
</div>

<script>
"use strict";

// ---------- Data ----------
const GIB = 1073741824; // 2^30 bytes
const KIB = 1024;       // bytes
const GBPS = 1e9;        // bytes per second

const MODELS = [
  { name: "Gemma 4 12B",      total: 11.95e9,  active: 11.95e9, perTok: 16,      maxCtx: 262144 },
  { name: "gpt-oss-20b",     total: 20.9e9,   active: 3.6e9,   perTok: 24,      maxCtx: 131072 },
  { name: "Gemma 4 26B A4B", total: 25.2e9,   active: 3.8e9,   perTok: 20,      maxCtx: 262144 },
  { name: "Qwen3.8 27B",     total: 27.8e9,   active: 27.8e9,  perTok: 64,      maxCtx: 262144 },
  { name: "Gemma 4 31B",     total: 30.7e9,   active: 30.7e9,  perTok: 80,      maxCtx: 262144 },
  { name: "Qwen3.6 35B-A3B", total: 35.0e9,   active: 3.0e9,   perTok: 20,      maxCtx: 262144 },
  { name: "gpt-oss-120b",    total: 116.8e9,  active: 5.1e9,   perTok: 36,      maxCtx: 131072 },
  { name: "GLM-5.3-Flash",   total: 320.0e9,  active: 18.0e9,  perTok: 11,      maxCtx: 1048576 },
  { name: "GLM-5.3",         total: 753.0e9,  active: 40.0e9,  perTok: 87.75,   maxCtx: 1048576 },
  { name: "Kimi K2.6",       total: 1.0e12,   active: 32.0e9,  perTok: 68.625,  maxCtx: 262144 },
];

const CARDS = [
  { name: "RTX 3060 12GB",        mem: 12, bw: 360,  price: 280 },
  { name: "RTX 3090 24GB (used)", mem: 24, bw: 936,  price: 700 },
  { name: "RTX 4090 24GB",        mem: 24, bw: 1008, price: 1999 },
  { name: "RTX 5090 32GB",        mem: 32, bw: 1792, price: 2599 },
  { name: "RTX A6000 48GB (used)",mem: 48, bw: 768,  price: 3300 },
];

const QUANTS = { Q4_0: 4.5, Q8_0: 8.5 };
const RAM_BANDWIDTH = 80 * GBPS;
const RAM_SIZES = [32, 64, 128, 256, 512];

// ---------- State ----------
let machines = [
  { cardId: 2, count: 1, spill: false, ramSize: 64,
    cards: [{ modelId: 3, quant: "Q4_0", cache: 16, context: 32768 }] },
  { cardId: 0, count: 1, spill: false, ramSize: 64, cards: [] },
];
let picked = { machineIdx: 0, cardIdx: 0 };
let playing = false;
let mathMode = false;
let drag = null;
let stream = { wordCount: 0, frac: 0, runningTime: 0, lastFrame: 0, finished: false, finishTime: 0, avgSpeed: 0 };

// ---------- Computation ----------
function weightBytes(model, quant) {
  return model.total * QUANTS[quant] / 8;
}
function ctxBytes(model, tokens, cache) {
  return tokens * model.perTok * KIB / (cache === 8 ? 2 : 1);
}
function perTokBytes(model, cache) {
  return model.perTok * KIB / (cache === 8 ? 2 : 1);
}

function machineLayout(machine) {
  const card = CARDS[machine.cardId];
  const count = machine.count;
  const cardMemBytes = card.mem * GIB;
  const n = machine.cards.length;

  let totalCtx = 0, totalOverhead = count * GIB, totalWeight = 0;
  for (const c of machine.cards) {
    const m = MODELS[c.modelId];
    totalCtx += ctxBytes(m, c.context, c.cache);
    totalWeight += weightBytes(m, c.quant);
  }

  if (n === 0) return { fits: true, type: "spare", val: 0, weights: null };
  if (!machine.spill) {
    const perCardTotal = (totalWeight + totalCtx + totalOverhead) / count;
    if (perCardTotal > cardMemBytes) {
      return { fits: false, type: "over", val: (perCardTotal - cardMemBytes) / GIB, weights: null };
    }
    return { fits: true, type: "spare", val: (cardMemBytes * count - totalWeight - totalCtx - totalOverhead) / GIB, weights: null };
  } else {
    const perCardCtx = totalCtx / count;
    const perCardOverhead = totalOverhead / count;
    const perCardCap = cardMemBytes - perCardCtx - perCardOverhead;
    if (perCardCap < 0) {
      return { fits: false, type: "over-card", val: (perCardCtx + perCardOverhead - cardMemBytes) / GIB, weights: null };
    }
    let remainingCap = count * perCardCap;
    const weights = [];
    for (const c of machine.cards) {
      const m = MODELS[c.modelId];
      const w = weightBytes(m, c.quant);
      const onCards = Math.min(w, remainingCap);
      remainingCap -= onCards;
      weights.push({ weightBytes: w, onCards, inRAM: w - onCards });
    }
    const totalInRAM = weights.reduce((s, w) => s + w.inRAM, 0);
    if (totalInRAM > machine.ramSize * GIB) {
      return { fits: false, type: "over-ram", val: (totalInRAM - machine.ramSize * GIB) / GIB, weights };
    }
    return { fits: true, type: "ram", val: totalInRAM / GIB, weights };
  }
}

function cardSpeed(machine, cardIdx) {
  const card = CARDS[machine.cardId];
  const c = machine.cards[cardIdx];
  const m = MODELS[c.modelId];
  const activeBytes = m.active * QUANTS[c.quant] / 8;
  const bw = card.bw * GBPS;
  if (!machine.spill) return bw / activeBytes;
  const layout = machineLayout(machine);
  if (!layout.weights) return null;
  const w = layout.weights[cardIdx];
  const shareInRAM = w.weightBytes > 0 ? w.inRAM / w.weightBytes : 0;
  const activeOnCards = activeBytes * (1 - shareInRAM);
  const activeInRAM = activeBytes * shareInRAM;
  return 1 / (activeOnCards / bw + activeInRAM / RAM_BANDWIDTH);
}

function maxContext(machine, cardIdx) {
  const card = CARDS[machine.cardId];
  const count = machine.count;
  const cardMemBytes = card.mem * GIB;
  const c = machine.cards[cardIdx];
  const m = MODELS[c.modelId];
  const ptb = perTokBytes(m, c.cache);
  let othersCtx = 0, totalOverhead = machine.count * GIB, totalWeight = 0;
  for (const cc of machine.cards) {
    const mm = MODELS[cc.modelId];
    if (cc !== c) othersCtx += ctxBytes(mm, cc.context, cc.cache);
    totalWeight += weightBytes(mm, cc.quant);
  }
  if (!machine.spill) {
    const w = weightBytes(m, c.quant);
    const maxCtxBytes = cardMemBytes * count - w - othersCtx - totalOverhead;
    if (maxCtxBytes <= 0) return 1024;
    return Math.min(Math.max(Math.floor(maxCtxBytes / ptb / 1024) * 1024, 1024), m.maxCtx);
  } else {
    const maxCtx1 = cardMemBytes * count - othersCtx - totalOverhead;
    const maxCtx2 = cardMemBytes * count - othersCtx - totalOverhead + machine.ramSize * GIB - totalWeight;
    const maxCtxBytes = Math.min(maxCtx1, maxCtx2);
    if (maxCtxBytes <= 0) return 1024;
    return Math.min(Math.max(Math.floor(maxCtxBytes / ptb / 1024) * 1024, 1024), m.maxCtx);
  }
}

function fitLine(layout) {
  const v = layout.val.toFixed(2);
  switch (layout.type) {
    case "spare": return { text: `Fits, ${v} GiB to spare`, ok: true };
    case "over": return { text: `Over by ${v} GiB`, ok: false };
    case "ram": return { text: `Fits, ${v} GiB in RAM`, ok: true };
    case "over-card": return { text: `Over by ${v} GiB`, ok: false };
    case "over-ram": return { text: `Over by ${v} GiB of RAM`, ok: false };
  }
  return { text: "?", ok: false };
}

function cardVerdict(machine, cardIdx) {
  const layout = machineLayout(machine);
  if (!layout.fits) return { text: fitLine(layout).text, ok: false, speed: null };
  const speed = cardSpeed(machine, cardIdx);
  if (machine.spill) {
    const w = layout.weights[cardIdx];
    const share = w.weightBytes > 0 ? (w.inRAM / w.weightBytes * 100) : 0;
    return { text: `${speed.toFixed(1)} tok/s · ${share.toFixed(0)}% in RAM`, ok: true, speed };
  }
  return { text: `${speed.toFixed(1)} tok/s`, ok: true, speed };
}

// ---------- Rendering ----------
function fmtGiB(b) { return (b / GIB).toFixed(2); }
function fmtTokens(n) { return n.toLocaleString("en-US"); }
function fmtPrice(n) { return "$" + n.toLocaleString("en-US"); }

function renderModelCards() {
  const el = document.getElementById("model-cards");
  el.innerHTML = "";
  MODELS.forEach((m, i) => {
    const div = document.createElement("div");
    div.className = "model-card";
    div.dataset.model = i;
    const isMoE = m.active < m.total;
    div.innerHTML = `
      <div class="mc-name">${m.name} <span class="mc-tag ${isMoE ? "moe" : ""}">${isMoE ? "MoE" : "dense"}</span> <span class="mc-tag">${(m.total / 1e9).toFixed(2)}B</span></div>
      <div class="mc-line">ctx/tok <b>${m.perTok} KiB</b> · max ctx <b>${fmtTokens(m.maxCtx)}</b></div>
      <div class="mc-line">Q4_0 <b>${fmtGiB(weightBytes(m, "Q4_0"))} GiB</b> · Q8_0 <b>${fmtGiB(weightBytes(m, "Q8_0"))} GiB</b></div>
    `;
    el.appendChild(div);
  });
}

function barsFor(machine) {
  const card = CARDS[machine.cardId];
  const layout = machineLayout(machine);
  const cardMem = card.mem * GIB;
  const bars = document.createElement("div");
  bars.className = "mem-bars";
  for (let ci = 0; ci < machine.count; ci++) {
    const bar = document.createElement("div");
    bar.className = "mem-bar";
    bar.dataset.card = ci;
    let html = "";
    let used = 0;
    const addSeg = (cls, bytes) => {
      const pct = Math.min(bytes / cardMem * 100, 100 - used / cardMem * 100);
      if (pct > 0) html += `<div class="seg ${cls}" style="width:${pct}%"></div>`;
      used += Math.min(bytes, cardMem - used);
    };
    for (const c of machine.cards) {
      const m = MODELS[c.modelId];
      const w = weightBytes(m, c.quant) / machine.count;
      const ctx = ctxBytes(m, c.context, c.cache) / machine.count;
      addSeg("weights", w);
      addSeg("context", ctx);
    }
    if (machine.cards.length > 0) addSeg("overhead", GIB);
    if (!layout.fits && (layout.type === "over" || layout.type === "over-card")) {
      const overPct = Math.min(layout.val / cardMem * 100, 100 - used / cardMem * 100);
      if (overPct > 0) html += `<div class="seg ram-overflow" style="width:${overPct}%"></div>`;
    }
    bar.innerHTML = html;
    bars.appendChild(bar);
  }
  if (machine.spill) {
    const ramBar = document.createElement("div");
    ramBar.className = "mem-bar";
    ramBar.dataset.ram = "1";
    const totalInRAM = layout.weights ? layout.weights.reduce((s, w) => s + w.inRAM, 0) : 0;
    const ramSizeBytes = machine.ramSize * GIB;
    const usedPct = Math.min(totalInRAM / ramSizeBytes * 100, 100);
    const overPct = Math.max(0, (totalInRAM - ramSizeBytes) / ramSizeBytes * 100);
    ramBar.innerHTML = `
      <div class="seg ram-used" style="width:${usedPct}%"></div>
      ${overPct > 0 ? `<div class="seg ram-overflow" style="width:${overPct}%"></div>` : ""}
    `;
    bars.appendChild(ramBar);
  }
  return bars;
}

function renderMachine(machine, idx) {
  const card = CARDS[machine.cardId];
  const layout = machineLayout(machine);
  const fl = fitLine(layout);
  const div = document.createElement("div");
  div.className = "machine";
  div.dataset.mach = idx;
  if (drag && drag.source.type === "card" && drag.over && drag.over.machineIdx === idx) {
    div.classList.add("drag-over");
  }

  // Header
  const header = document.createElement("div");
  header.className = "m-header";
  header.innerHTML = `
    <span class="m-card-name">${card.name}</span>
    <span class="m-count">×${machine.count}</span>
    <span class="m-price">${fmtPrice(card.price * machine.count)}</span>
    <div class="m-controls">
      <label class="spill-toggle">
        <input type="checkbox" class="spill-switch" ${machine.spill ? "checked" : ""}>
        Spill to
        <select class="ram-size">
          ${RAM_SIZES.map(s => `<option value="${s}" ${s === machine.ramSize ? "selected" : ""}>${s} GiB</option>`).join("")}
        </select>
      </label>
      <button class="btn cheapest-fit">Cheapest fit</button>
      <button class="btn danger remove-machine">Remove</button>
    </div>`;
  div.appendChild(header);

  // Memory bars
  div.appendChild(barsFor(machine));

  // Fit line
  const fitDiv = document.createElement("div");
  fitDiv.className = `fit-line ${fl.ok ? "ok" : "bad"}`;
  fitDiv.textContent = fl.text;
  div.appendChild(fitDiv);

  // Cards
  const cardsEl = document.createElement("div");
  cardsEl.className = "cards";
  machine.cards.forEach((c, ci) => {
    const m = MODELS[c.modelId];
    const isMoE = m.active < m.total;
    const maxCtx = maxContext(machine, ci);
    const cv = cardVerdict(machine, ci);
    const cardDiv = document.createElement("div");
    cardDiv.className = "card";
    cardDiv.dataset.mach = idx;
    cardDiv.dataset.card = ci;
    if (picked && picked.machineIdx === idx && picked.cardIdx === ci) cardDiv.classList.add("picked");
    cardDiv.innerHTML = `
      <div class="c-name">${m.name}</div>
      <div class="c-tags">
        <span class="${isMoE ? "moe" : ""}">${isMoE ? "MoE" : "dense"}</span>
        <span>${(m.total / 1e9).toFixed(2)}B</span>
      </div>
      <div class="c-controls">
        <label class="switch"><input type="checkbox" class="quant-switch" ${c.quant === "Q8_0" ? "checked" : ""}> Q8_0</label>
        <label class="switch"><input type="checkbox" class="cache-switch" ${c.cache === 8 ? "checked" : ""}> 8-bit cache</label>
        <div class="ctx-slider">
          <input type="range" class="ctx-range" min="1024" max="${m.maxCtx}" step="1024" value="${c.context}">
          <span class="ctx-val">${fmtTokens(c.context)}</span>
          <span class="max-ctx">max ${fmtTokens(maxCtx)}</span>
        </div>
      </div>
      <div class="c-speed ${cv.ok ? "ok" : "bad"}">${cv.ok ? cv.text : "Won't run"}</div>
      ${!cv.ok ? `<div class="c-ram-share">${cv.text}</div>` : ""}
    `;
    cardsEl.appendChild(cardDiv);
  });
  div.appendChild(cardsEl);
  return div;
}

function renderMachines() {
  const el = document.getElementById("machines-col");
  el.innerHTML = "";
  machines.forEach((m, i) => el.appendChild(renderMachine(m, i)));
  if (machines.length < 3) {
    const add = document.createElement("div");
    add.className = "add-machine";
    add.textContent = "Add machine";
    el.appendChild(add);
  }
}

function renderBoard() {
  renderModelCards();
  renderMachines();
  renderPanel();
}

function renderPanel() {
  const el = document.getElementById("panel");
  if (!picked || !machines[picked.machineIdx] || !machines[picked.machineIdx].cards[picked.cardIdx]) {
    el.innerHTML = `<div class="p-header"><span class="p-name">No card picked</span></div>`;
    return;
  }
  const machine = machines[picked.machineIdx];
  const c = machine.cards[picked.cardIdx];
  const m = MODELS[c.modelId];
  const cv = cardVerdict(machine, picked.cardIdx);
  const layout = machineLayout(machine);
  const fl = fitLine(layout);

  el.innerHTML = `
    <div class="p-header">
      <span class="p-name">${m.name}</span>
      <span class="p-speed ${cv.ok ? "ok" : "bad"}">${cv.ok ? cv.text : "Won't run"}</span>
      <span class="p-verdict ${fl.ok ? "ok" : "bad"}">${fl.text}</span>
      <div class="p-controls">
        <button class="btn play-btn">Play</button>
        <label class="switch"><input type="checkbox" class="math-switch" ${mathMode ? "checked" : ""}> Math (M)</label>
      </div>
    </div>
    <div class="p-body">
      <div class="p-stream">
        <span><b>${stream.wordCount}</b> / 300 words</span>
        <span><b>${stream.runningTime.toFixed(1)}</b> s elapsed</span>
        ${stream.finished ? `<span class="done">300 words in ${stream.finishTime.toFixed(1)} s · ${stream.avgSpeed.toFixed(1)} tok/s average</span>` : ""}
      </div>
      <div class="p-answer">${mathMode ? renderMath(m, c, machine, picked.cardIdx) : (stream.wordCount > 0 ? ANSWER_WORDS.slice(0, stream.wordCount).join(" ") : "Press Play to stream the answer.")}</div>
      ${!cv.ok ? renderChanges(machine, picked.cardIdx) : ""}
    </div>`;
}

function renderMath(m, c, machine, cardIdx) {
  const card = CARDS[machine.cardId];
  const count = machine.count;
  const cardMemBytes = card.mem * GIB;
  const bits = QUANTS[c.quant];
  const w = weightBytes(m, c.quant);
  const ctx = ctxBytes(m, c.context, c.cache);
  const oh = GIB;
  const activeBytes = m.active * bits / 8;
  const layout = machineLayout(machine);
  const fl = fitLine(layout);
  const maxCtx = maxContext(machine, cardIdx);
  const speed = cardSpeed(machine, cardIdx);

  let totalWeight = 0, totalCtx = 0;
  for (const cc of machine.cards) {
    const mm = MODELS[cc.modelId];
    totalWeight += weightBytes(mm, cc.quant);
    totalCtx += ctxBytes(mm, cc.context, cc.cache);
  }
  const totalPerCard = (totalWeight + totalCtx + count * GIB) / count;

  let rows = [
    ["Weights", `${(w / GIB).toFixed(2)} GiB (${(w / 1e9).toFixed(2)} GB)`],
    ["Context", `${(ctx / GIB).toFixed(2)} GiB (${(ctx / 1e9).toFixed(2)} GB)`],
    ["Overhead", `${(oh / GIB).toFixed(2)} GiB per card`],
  ];
  for (let ci = 0; ci < count; ci++) {
    rows.push([`Card ${ci + 1} total`, `${(totalPerCard / GIB).toFixed(2)} GiB`]);
  }
  rows.push(["Fit line", fl.text]);
  rows.push(["Bytes read per token", `${(activeBytes / 1e9).toFixed(3)} GB`]);
  rows.push(["Speed ceiling", speed ? `${speed.toFixed(1)} tok/s` : "n/a"]);
  rows.push(["Most context", fmtTokens(maxCtx)]);

  return `<table class="math-table">${rows.map(r => `<tr><th>${r[0]}</th><td>${r[1]}</td></tr>`).join("")}</table>`;
}

function renderChanges(machine, cardIdx) {
  const changes = computeFitChanges(machine, cardIdx);
  if (changes.length === 0) return "";
  return `<div class="p-changes"><h4>Single changes that would fit</h4>${changes.map(ch =>
    `<div class="change" data-change="${ch.key}"><span class="c-label">${ch.label}</span><span class="c-num">${ch.num}</span><span class="c-num">${ch.fit}</span></div>`
  ).join("")}</div>`;
}

function computeFitChanges(machine, cardIdx) {
  const changes = [];
  const base = { ...machine, cards: machine.cards.map(c => ({ ...c })) };

  // 1. Most context that fits
  const maxCtx = maxContext(machine, cardIdx);
  if (maxCtx < machine.cards[cardIdx].context) {
    const test = { ...base, cards: base.cards.map((c, i) => i === cardIdx ? { ...c, context: maxCtx } : c) };
    const layout = machineLayout(test);
    if (layout.fits) {
      const cv = cardVerdict(test, cardIdx);
      changes.push({ key: "ctx", label: `Context → ${fmtTokens(maxCtx)}`, num: "", fit: fitLine(layout).text, speed: cv.speed });
    }
  }

  // 2. 8-bit cache
  if (machine.cards[cardIdx].cache === 16) {
    const test = { ...base, cards: base.cards.map((c, i) => i === cardIdx ? { ...c, cache: 8 } : c) };
    const layout = machineLayout(test);
    if (layout.fits) {
      const cv = cardVerdict(test, cardIdx);
      changes.push({ key: "cache", label: "8-bit cache", num: "", fit: fitLine(layout).text, speed: cv.speed });
    }
  }

  // 3. Q4_0
  if (machine.cards[cardIdx].quant === "Q8_0") {
    const test = { ...base, cards: base.cards.map((c, i) => i === cardIdx ? { ...c, quant: "Q4_0" } : c) };
    const layout = machineLayout(test);
    if (layout.fits) {
      const cv = cardVerdict(test, cardIdx);
      changes.push({ key: "quant", label: "Q4_0", num: "", fit: fitLine(layout).text, speed: cv.speed });
    }
  }

  // 4. Second card
  if (machine.count === 1) {
    const test = { ...base, count: 2 };
    const layout = machineLayout(test);
    if (layout.fits) {
      const cv = cardVerdict(test, cardIdx);
      changes.push({ key: "count", label: "Second card", num: `×2 · ${fmtPrice(CARDS[base.cardId].price * 2)}`, fit: fitLine(layout).text, speed: cv.speed });
    }
  }

  // 5. Spill to chosen RAM size
  if (!machine.spill) {
    const test = { ...base, spill: true };
    const layout = machineLayout(test);
    if (layout.fits) {
      const cv = cardVerdict(test, cardIdx);
      changes.push({ key: "spill", label: `Spill to ${machine.ramSize} GiB`, num: "", fit: fitLine(layout).text, speed: cv.speed });
    }
  }

  return changes;
}

// ---------- Drag & Drop ----------
let dragGhost = null;

function getDragTarget(x, y) {
  const el = document.elementFromPoint(x, y);
  if (!el) return null;
  const machineEl = el.closest(".machine");
  if (machineEl) {
    const machIdx = parseInt(machineEl.dataset.mach);
    const cardEl = el.closest(".card");
    const cardIdx = cardEl ? parseInt(cardEl.dataset.card) : null;
    return { machineIdx: machIdx, cardIdx };
  }
  if (el.closest("#models-col")) return { modelCol: true };
  return null;
}

function startDrag(e, source) {
  if (e.target.closest("input, select, button, label")) return;
  e.preventDefault();
  drag = { source, isCopy: e.altKey, over: null, prevMach: null };
  dragGhost = document.createElement("div");
  dragGhost.className = "model-card";
  dragGhost.style.position = "fixed";
  dragGhost.style.left = e.clientX + "px";
  dragGhost.style.top = e.clientY + "px";
  dragGhost.style.pointerEvents = "none";
  dragGhost.style.zIndex = 1000;
  dragGhost.style.opacity = "0.8";
  const m = MODELS[source.modelId];
  dragGhost.innerHTML = `<div class="mc-name">${m.name}</div>`;
  document.body.appendChild(dragGhost);
  document.addEventListener("mousemove", onDragMove);
  document.addEventListener("mouseup", onDragEnd);
}

function onDragMove(e) {
  if (!drag) return;
  dragGhost.style.left = e.clientX + "px";
  dragGhost.style.top = e.clientY + "px";
  const target = getDragTarget(e.clientX, e.clientY);
  drag.over = (target && !target.modelCol)
    ? { machineIdx: target.machineIdx, cardIdx: target.cardIdx }
    : null;

  document.querySelectorAll(".machine").forEach(mel => {
    mel.classList.remove("drag-over");
    const pv = mel.querySelector(".drag-preview");
    if (pv) pv.remove();
    if (drag.prevMach != null && parseInt(mel.dataset.mach) === drag.prevMach) {
      const layout = machineLayout(machines[drag.prevMach]);
      const f = fitLine(layout);
      const fl = mel.querySelector(".fit-line");
      if (fl) { fl.textContent = f.text; fl.className = `fit-line ${f.ok ? "ok" : "bad"}`; }
    }
  });
  drag.prevMach = drag.over ? drag.over.machineIdx : null;

  if (drag.over) {
    const mel = document.querySelector(`.machine[data-mach="${drag.over.machineIdx}"]`);
    if (mel) {
      mel.classList.add("drag-over");
      updateDragPreview(mel, drag.over);
    }
  }
}

function onDragEnd(e) {
  if (!drag) return;
  const target = getDragTarget(e.clientX, e.clientY);
  applyDrag(target);
  cleanupDrag();
}

function applyDrag(target) {
  const src = drag.source;
  if (src.type === "model") {
    if (target && target.machineIdx != null) {
      const card = { modelId: src.modelId, quant: "Q4_0", cache: 16, context: 32768 };
      const pos = target.cardIdx != null ? target.cardIdx : machines[target.machineIdx].cards.length;
      machines[target.machineIdx].cards.splice(pos, 0, card);
      picked = { machineIdx: target.machineIdx, cardIdx: pos };
      resetStream();
    }
  } else {
    const { machineIdx, cardIdx } = src;
    if (target && target.modelCol) {
      machines[machineIdx].cards.splice(cardIdx, 1);
      if (picked && picked.machineIdx === machineIdx && picked.cardIdx === cardIdx) picked = null;
    } else if (target && target.machineIdx != null) {
      const card = machines[machineIdx].cards[cardIdx];
      let pos;
      if (drag.isCopy || src.machineIdx !== machineIdx) {
        pos = target.cardIdx != null ? target.cardIdx : machines[target.machineIdx].cards.length;
        machines[target.machineIdx].cards.splice(pos, 0, { ...card });
      } else {
        pos = target.cardIdx != null ? target.cardIdx : machines[target.machineIdx].cards.length;
        machines[machineIdx].cards.splice(cardIdx, 1);
        machines[target.machineIdx].cards.splice(pos, 0, card);
      }
      picked = { machineIdx: target.machineIdx, cardIdx: pos };
      resetStream();
    }
  }
  renderBoard();
}

function cleanupDrag() {
  if (drag && drag.prevMach != null) {
    const mi = drag.prevMach;
    const mel = document.querySelector(`.machine[data-mach="${mi}"]`);
    if (mel) {
      const pv = mel.querySelector(".drag-preview");
      if (pv) pv.remove();
      const layout = machineLayout(machines[mi]);
      const f = fitLine(layout);
      const fl = mel.querySelector(".fit-line");
      if (fl) { fl.textContent = f.text; fl.className = `fit-line ${f.ok ? "ok" : "bad"}`; }
    }
  }
  drag = null;
  if (dragGhost) { dragGhost.remove(); dragGhost = null; }
  document.removeEventListener("mousemove", onDragMove);
  document.removeEventListener("mouseup", onDragEnd);
  document.querySelectorAll(".machine").forEach(mel => mel.classList.remove("drag-over"));
}

function updateDragPreview(mel, over) {
  const { machineIdx, cardIdx } = over;
  const src = drag.source;
  const card = src.type === "card" ? machines[src.machineIdx].cards[src.cardIdx] : { modelId: src.modelId, quant: "Q4_0", cache: 16, context: 32768 };
  const test = { ...machines[machineIdx], cards: machines[machineIdx].cards.map((c, i) => i === cardIdx ? card : c) };
  if (src.type === "card" && (src.machineIdx !== machineIdx || drag.isCopy)) {
    const pos = cardIdx != null ? cardIdx : test.cards.length;
    test.cards.splice(pos, 0, { ...card });
  }
  const layout = machineLayout(test);
  const fl = fitLine(layout);
  const cv = cardVerdict(test, cardIdx != null ? cardIdx : test.cards.length - 1);
  const fitDiv = mel.querySelector(".fit-line");
  if (fitDiv) {
    fitDiv.textContent = fl.text;
    fitDiv.className = `fit-line ${fl.ok ? "ok" : "bad"}`;
  }
  const barsEl = mel.querySelector(".mem-bars");
  if (barsEl) barsEl.replaceWith(barsFor(test));
  let previewEl = mel.querySelector(".drag-preview");
  if (!previewEl) {
    previewEl = document.createElement("div");
    previewEl.className = "drag-preview";
    previewEl.style.cssText = "font-size:11px;color:var(--dim);";
    mel.appendChild(previewEl);
  }
  previewEl.textContent = cv.ok ? cv.text : "Won't run · " + fl.text;
}

// ---------- Keyboard ----------
document.addEventListener("keydown", (e) => {
  if (e.key === "Enter") {
    const el = document.elementFromPoint(e.clientX, e.clientY);
    const cardEl = el && el.closest(".card");
    if (cardEl) {
      picked = { machineIdx: parseInt(cardEl.dataset.mach), cardIdx: parseInt(cardEl.dataset.card) };
    }
    renderBoard();
  } else if (e.key >= "1" && e.key <= "3" && picked) {
    const mi = parseInt(e.key) - 1;
    if (machines[mi]) {
      const card = machines[picked.machineIdx].cards[picked.cardIdx];
      machines[picked.machineIdx].cards.splice(picked.cardIdx, 1);
      machines[mi].cards.push(card);
      picked = { machineIdx: mi, cardIdx: machines[mi].cards.length - 1 };
      resetStream();
      renderBoard();
    }
  } else if (e.key === "Delete" && picked) {
    machines[picked.machineIdx].cards.splice(picked.cardIdx, 1);
    picked = null;
    resetStream();
    renderBoard();
  } else if (e.key === "ArrowUp" && e.altKey && picked) {
    if (picked.cardIdx > 0) {
      const m = machines[picked.machineIdx];
      [m.cards[picked.cardIdx], m.cards[picked.cardIdx - 1]] = [m.cards[picked.cardIdx - 1], m.cards[picked.cardIdx]];
      picked.cardIdx--;
      resetStream();
      renderBoard();
    }
  } else if (e.key === "ArrowDown" && e.altKey && picked) {
    const m = machines[picked.machineIdx];
    if (picked.cardIdx < m.cards.length - 1) {
      [m.cards[picked.cardIdx], m.cards[picked.cardIdx + 1]] = [m.cards[picked.cardIdx + 1], m.cards[picked.cardIdx]];
      picked.cardIdx++;
      resetStream();
      renderBoard();
    }
  }
});

// ---------- Stream ----------
const ANSWER = "Why memory bandwidth sets a local model's speed. A transformer decodes one token at a time. Each token reads the model's weights from memory; the weights are loaded, used, and discarded, not computed. For a dense model, every token reads all the active weights. A 27B dense model at Q4_0 has about 15.6 GB of active weights. On a 1008 GB/s card, that is 1008 divided by 15.6, or about 64.5 tokens per second. MoE models change the picture significantly. Only the active experts are read each token, so the active weight size is much smaller than the total. gpt-oss-120b has 116.8B total parameters but only 5.1B active. At Q4_0, that is 2.9 GB of active weights, giving 351 tokens per second on the same card. Context memory adds a second load. Each token stores a key-value vector, so the context grows linearly with the number of tokens. A 64-KiB-per-token model at 32,768 tokens needs 2 GiB of context, which competes with the weights for the same memory. Halving the cache to 8 bits halves that load and frees memory for more context or weights. Spilling to system RAM extends the reach. The weights that do not fit on the card go to RAM, which reads at 80 GB/s. The speed becomes a weighted average of the card and RAM bandwidth, weighted by the share of active weights in each. The fill order matters: the top model gets the card space first, so moving it to the top changes its speed noticeably. Quantization trades precision for speed. Q8_0 is 8.5 bits per weight, almost twice the bytes of Q4_0's 4.5 bits. In the end, the speed of a local model is set by memory bandwidth, not compute. The model with the smallest active weight size that fits in memory wins.";
const ANSWER_WORDS = ANSWER.split(/\s+/);

function resetStream() {
  stream.wordCount = 0;
  stream.frac = 0;
  stream.runningTime = 0;
  stream.finished = false;
  stream.finishTime = 0;
  stream.avgSpeed = 0;
  playing = false;
}

function startStream() {
  if (!picked || !machines[picked.machineIdx] || !machines[picked.machineIdx].cards[picked.cardIdx]) return;
  playing = true;
  stream.wordCount = 0;
  stream.frac = 0;
  stream.runningTime = 0;
  stream.finished = false;
  stream.finishTime = 0;
  stream.avgSpeed = 0;
  stream.lastFrame = performance.now();
}

function tick() {
  if (!playing) return;
  const now = performance.now();
  if (!picked || !machines[picked.machineIdx] || !machines[picked.machineIdx].cards[picked.cardIdx]) {
    playing = false;
    return;
  }
  const machine = machines[picked.machineIdx];
  const cv = cardVerdict(machine, picked.cardIdx);
  if (!cv.ok) {
    // Card won't run: pause the clock but keep the loop alive so it can resume.
    stream.lastFrame = now;
    const streamDiv = document.querySelector(".p-stream");
    if (streamDiv && !stream.finished) {
      streamDiv.innerHTML = `
        <span><b>${stream.wordCount}</b> / 300 words</span>
        <span><b>${stream.runningTime.toFixed(1)}</b> s elapsed</span>
        <span class="paused">Paused — card won't run</span>
      `;
    }
    requestAnimationFrame(tick);
    return;
  }
  const dt = (now - stream.lastFrame) / 1000;
  stream.lastFrame = now;
  stream.runningTime += dt;
  stream.frac += cv.speed * dt;
  const words = Math.floor(stream.frac);
  stream.frac -= words;
  stream.wordCount += words;
  if (stream.wordCount >= 300) {
    stream.wordCount = 300;
    stream.finished = true;
    stream.finishTime = stream.runningTime;
    stream.avgSpeed = 300 / stream.finishTime;
    playing = false;
  }
  const streamDiv = document.querySelector(".p-stream");
  if (streamDiv) {
    streamDiv.innerHTML = `
      <span><b>${stream.wordCount}</b> / 300 words</span>
      <span><b>${stream.runningTime.toFixed(1)}</b> s elapsed</span>
      ${stream.finished ? `<span class="done">300 words in ${stream.finishTime.toFixed(1)} s · ${stream.avgSpeed.toFixed(1)} tok/s average</span>` : ""}
    `;
  }
  const answerDiv = document.querySelector(".p-answer");
  if (answerDiv && !mathMode) {
    const shown = Math.min(stream.wordCount, ANSWER_WORDS.length);
    answerDiv.textContent = ANSWER_WORDS.slice(0, shown).join(" ");
  }
  requestAnimationFrame(tick);
}

// ---------- Event Handlers ----------
document.addEventListener("mousedown", (e) => {
  const modelEl = e.target.closest(".model-card");
  const cardEl = e.target.closest(".card");
  if (modelEl && !cardEl) {
    startDrag(e, { type: "model", modelId: parseInt(modelEl.dataset.model) });
  } else if (cardEl) {
    startDrag(e, { type: "card", machineIdx: parseInt(cardEl.dataset.mach), cardIdx: parseInt(cardEl.dataset.card) });
  }
});

document.addEventListener("click", (e) => {
  const addEl = e.target.closest(".add-machine");
  if (addEl) {
    machines.push({ cardId: 0, count: 1, spill: false, ramSize: 64, cards: [] });
    renderBoard();
    return;
  }
  const spillSwitch = e.target.closest(".spill-switch");
  if (spillSwitch) {
    const mi = parseInt(spillSwitch.closest(".machine").dataset.mach);
    machines[mi].spill = spillSwitch.checked;
    renderBoard();
    return;
  }
  const quantSwitch = e.target.closest(".quant-switch");
  if (quantSwitch) {
    const cardEl = quantSwitch.closest(".card");
    const mi = parseInt(cardEl.dataset.mach);
    const ci = parseInt(cardEl.dataset.card);
    machines[mi].cards[ci].quant = quantSwitch.checked ? "Q8_0" : "Q4_0";
    renderBoard();
    return;
  }
  const cacheSwitch = e.target.closest(".cache-switch");
  if (cacheSwitch) {
    const cardEl = cacheSwitch.closest(".card");
    const mi = parseInt(cardEl.dataset.mach);
    const ci = parseInt(cardEl.dataset.card);
    machines[mi].cards[ci].cache = cacheSwitch.checked ? 8 : 16;
    renderBoard();
    return;
  }
  const cardEl = e.target.closest(".card");
  if (cardEl) {
    const mi = parseInt(cardEl.dataset.mach);
    const ci = parseInt(cardEl.dataset.card);
    const cv = cardVerdict(machines[mi], ci);
    if (cv.ok) {
      picked = { machineIdx: mi, cardIdx: ci };
      renderBoard();
    }
    return;
  }
  const playBtn = e.target.closest(".play-btn");
  if (playBtn) {
    startStream();
    requestAnimationFrame(tick);
    return;
  }
  const mathSwitch = e.target.closest(".math-switch");
  if (mathSwitch) {
    mathMode = mathSwitch.checked;
    renderPanel();
    return;
  }
  const changeEl = e.target.closest(".change");
  if (changeEl) {
    applyChange(changeEl.dataset.change);
    return;
  }
  const cheapestBtn = e.target.closest(".cheapest-fit");
  if (cheapestBtn) {
    const mi = parseInt(cheapestBtn.closest(".machine").dataset.mach);
    applyCheapestFit(mi);
    return;
  }
  const removeBtn = e.target.closest(".remove-machine");
  if (removeBtn) {
    const mi = parseInt(removeBtn.closest(".machine").dataset.mach);
    machines.splice(mi, 1);
    if (picked && picked.machineIdx === mi) picked = null;
    resetStream();
    renderBoard();
    return;
  }
});

document.addEventListener("change", (e) => {
  const ramSize = e.target.closest(".ram-size");
  if (ramSize) {
    const mi = parseInt(ramSize.closest(".machine").dataset.mach);
    machines[mi].ramSize = parseInt(ramSize.value);
    renderBoard();
    return;
  }
  const ctxRange = e.target.closest(".ctx-range");
  if (ctxRange) {
    const cardEl = ctxRange.closest(".card");
    const mi = parseInt(cardEl.dataset.mach);
    const ci = parseInt(cardEl.dataset.card);
    machines[mi].cards[ci].context = parseInt(ctxRange.value);
    renderBoard();
    return;
  }
});

function applyChange(key) {
  if (!picked) return;
  const mi = picked.machineIdx;
  const ci = picked.cardIdx;
  const m = machines[mi];
  const c = m.cards[ci];
  if (key === "ctx") c.context = maxContext(m, ci);
  else if (key === "cache") c.cache = 8;
  else if (key === "quant") c.quant = "Q4_0";
  else if (key === "count") m.count = 2;
  else if (key === "spill") m.spill = true;
  resetStream();
  renderBoard();
}

function applyCheapestFit(mi) {
  const machine = machines[mi];
  const best = { price: Infinity, slowest: -1, cardId: null, count: null };
  for (const cardId of CARDS.map((_, i) => i)) {
    for (const count of [1, 2]) {
      const test = { ...machine, cardId, count };
      const layout = machineLayout(test);
      if (!layout.fits) continue;
      let slowest = Infinity;
      for (let ci = 0; ci < test.cards.length; ci++) {
        const s = cardSpeed(test, ci);
        if (s != null) slowest = Math.min(slowest, s);
      }
      const price = CARDS[cardId].price * count;
      if (price < best.price || (price === best.price && slowest > best.slowest)) {
        best.price = price;
        best.slowest = slowest;
        best.cardId = cardId;
        best.count = count;
      }
    }
  }
  if (best.cardId != null) {
    machine.cardId = best.cardId;
    machine.count = best.count;
    resetStream();
    renderBoard();
  } else {
    let total = 0;
    for (const c of machine.cards) {
      const m = MODELS[c.modelId];
      total += weightBytes(m, c.quant) + ctxBytes(m, c.context, c.cache) + GIB;
    }
    const mel = document.querySelector(`.machine[data-mach="${mi}"]`);
    if (mel) {
      const fitDiv = mel.querySelector(".fit-line");
      if (fitDiv) {
        fitDiv.textContent = `Needs ${fmtGiB(total)} GiB total`;
        fitDiv.className = "fit-line bad";
      }
    }
  }
}

// ---------- Init ----------
renderBoard();
</script>
</body>
</html>

Same prompt, other models

1 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.