// ===================================================== // SIMPLE Law analytical model // ===================================================== // Fitted parameters (thetas.csv) const THETA0 = -1.86626365e-03; const THETA1 = 3.30843561e-11; const THETA2 = 1.04066816e-09; const THETA3 = 2.02013691e-07; const THETAS = [THETA0, THETA1, THETA2, THETA3]; const COMPONENT_LABELS = [ "Prefill Amortization", "Decode Accum. KV-cache", "Decode Accum. Next-token" ]; const COMPONENT_COLORS = ["#55A868", "#E2C044", "#DD8452"]; const COMPONENT_HATCHES = ["x", "/", "-"]; // bottom -> top of the stack (indices into the component arrays) const STACK_ORDER = [2, 1, 0]; const GRID = "#e1e0d9"; // matplotlib RdYlGn_r const RDYLGN_R = [ "#006837", "#1a9850", "#66bd63", "#a6d96a", "#d9ef8b", "#ffffbf", "#fee08b", "#fdae61", "#f46d43", "#d73027", "#a50026" ]; // Validated models, from models.json (up to ~9B) const PRESET_MODELS = { "Llama 3.2 1B": { params:1.24e+09, d:2048, L:16, nq:32, nkv:8 }, "Llama 3.2 3B": { params:3.21e+09, d:3072, L:28, nq:24, nkv:8 }, "Llama 3.1 8B": { params:8.03e+09, d:4096, L:32, nq:32, nkv:8 }, "Gemma 2 2B": { params:2.61e+09, d:2304, L:26, nq:8, nkv:4 }, "Gemma 2 9B": { params:9.24e+09, d:3584, L:42, nq:16, nkv:8 }, "Granite 3B": { params:3.48e+09, d:2560, L:32, nq:32, nkv:32 }, "Granite-8B": { params:8.05e+09, d:4096, L:36, nq:32, nkv:8 }, "Qwen2 1.5B": { params:1.54e+09, d:1536, L:28, nq:12, nkv:2 }, "Qwen2 7B": { params:7.62e+09, d:3584, L:28, nq:28, nkv:4 }, "OPT 1.3B": { params:1.32e+09, d:2048, L:24, nq:32, nkv:32 }, "OPT 2.7B": { params:2.75e+09, d:2560, L:32, nq:32, nkv:32 }, "OPT 6.7B": { params:6.71e+09, d:4096, L:32, nq:32, nkv:32 }, "Falcon 7B": { params:7.78e+09, d:4096, L:36, nq:64, nkv:64 } }; // Size classes for the preset dropdown, by parameter count const PRESET_GROUPS = [ {label:"XS · ~1B", max:2e9}, {label:"S · ~2B–3B", max:5e9}, {label:"M · ~6B–9B", max:Infinity} ]; window.onload = function() { let selector = document.getElementById("preset"); let names = Object.keys(PRESET_MODELS) .sort((a,b)=>PRESET_MODELS[a].params-PRESET_MODELS[b].params); let min=0; for(let group of PRESET_GROUPS) { let optgroup = document.createElement("optgroup"); optgroup.label=group.label; for(let name of names.filter(n=>PRESET_MODELS[n].params>=min && PRESET_MODELS[n].params{ const width=entries[0].contentRect.width; if(width!==formulaWidth) { formulaWidth=width; fitFormula(); } }).observe(document.querySelector(".formula")); loadPreset(); } // kind: "ok" | "loading" | "error" | "custom" function setStatus(kind,html) { let status = document.getElementById("status"); status.className = "status "+kind; status.innerHTML = html; // The plots keep showing the previous model while loading or on error if(kind==="ok" || kind==="custom") document.querySelectorAll(".plotModel").forEach( el=>el.innerHTML = html ); } // n_kv / n_q reduced to lowest terms, e.g. 8/32 -> "1/4" function ratioFraction(nkv,nq) { let gcd=(a,b)=>b ? gcd(b,a%b) : a; let g=gcd(nkv,nq) || 1; let num=nkv/g, den=nq/g; return den===1 ? String(num) : `${num}/${den}`; } function attentionLabel(nq,nkv) { if(nq===nkv) return "MHA"; if(nkv===1) return "MQA"; return `GQA (${+(nq/nkv).toFixed(1)}x)`; } // Derived quantities shown under the architecture fields function updateSummary(d,L,nq,nkv) { document.getElementById("archSummary").innerHTML = ` ${attentionLabel(nq,nkv)} R = ${ratioFraction(nkv,nq)} `; } function loadPreset() { let name = document.getElementById("preset").value; let model = PRESET_MODELS[name]; if (!model) { console.error("Unknown preset:", name); return; } document.getElementById("d").value=model.d; document.getElementById("L").value=model.L; document.getElementById("nq").value=model.nq; document.getElementById("nkv").value=model.nkv; setStatus("ok", `${name} · validated`); draw(); } // Returns the three additive terms, as in the notebook's compute_components function computeComponents(d,L,nq,nkv,nin,nout) { const ratio = nkv/nq; let prefill=[]; let kvCache=[]; let nextTok=[]; for(let n of nout) { prefill.push( THETA1*d*d*L*nin/n ); kvCache.push( THETA2*d*L*ratio*(nin + n/2) ); nextTok.push( THETA0 + THETA3*d*L ); } return [ prefill,kvCache,nextTok ]; } function energyPerToken(d,L,nq,nkv,nin,nout) { return THETA1*d*d*L*nin/nout + THETA2*d*L*(nkv/nq)*(nin + nout/2) + THETA0 + THETA3*d*L; } function logspace2(start,end,points) { let arr=[]; let a=Math.log2(start); let b=Math.log2(end); for(let i=0;i components.reduce( (a,b)=>a+b[i], 0 ) ); let traces=[]; for(let i of STACK_ORDER) { traces.push({ x:nout, y:components[i], mode:"lines", name:COMPONENT_LABELS[i], legendrank:i+1, stackgroup:"one", line:{ color:"rgba(0,0,0,0.2)", width:0.5 }, fillcolor:COMPONENT_COLORS[i], fillpattern:{ shape:COMPONENT_HATCHES[i], bgcolor:COMPONENT_COLORS[i], fgcolor:"rgba(0,0,0,0.6)", size:14, solidity:0.05 }, hovertemplate:"%{y:.3g} J/tok"+COMPONENT_LABELS[i]+"" }); } traces.push({ x:nout, y:total, mode:"lines", name:"Total Etok", legendrank:4, line:{ color:"black", width:2.5 }, hovertemplate:"%{y:.3g} J/tokTotal" }); // Every other power of two, so the ticks fit the narrower panel let ticks=[]; for(let e=0;e<=14;e+=2) ticks.push(2**e); let layout={ height:450, font:{ family:"sans-serif", size:13 }, xaxis:{ title: `Output Tokens τout (τin=${nin}) [log2]`, type:"log", range:[0,Math.log10(16384)], tickmode:"array", tickvals:ticks, ticktext:ticks.map(String), tickangle:-45, gridcolor:GRID, showline:true, linecolor:"black", ticks:"outside" }, yaxis:{ title: "Energy-Per-Token Etok [J/tok]", rangemode:"tozero", gridcolor:GRID, showline:true, linecolor:"black", ticks:"outside", zeroline:false }, hovermode:"x unified", legend:{ x:0.98, xanchor:"right", y:1, traceorder:"normal", bgcolor:"rgba(255,255,255,0)" }, plot_bgcolor:"white", margin:{ l:70, r:20, t:20, b:80 } }; Plotly.newPlot( "plot", traces, layout, { responsive:true } ); } // Same grid and level scheme as plot_E_tok_contour in the notebook. // Axes are plotted in log2 units and E in log10, so evenly spaced // Plotly contour levels match np.geomspace in the notebook. const CONTOUR_NIN_RANGE = [16, 65536]; const CONTOUR_NOUT_RANGE = [4, 16384]; const CONTOUR_POINTS = 200; const CONTOUR_LEVELS = 24; function drawContour(d,L,nq,nkv,nin) { let xs = logspace2(...CONTOUR_NIN_RANGE, CONTOUR_POINTS).map(Math.log2); let ys = logspace2(...CONTOUR_NOUT_RANGE, CONTOUR_POINTS).map(Math.log2); let z=[]; let text=[]; for(let y of ys) { let zrow=[]; let trow=[]; for(let x of xs) { let e = energyPerToken(d,L,nq,nkv,2**x,2**y); zrow.push(Math.log10(e)); trow.push( `τin = ${Math.round(2**x)}
` +`τout = ${Math.round(2**y)}
` +`Etok = ${e.toPrecision(3)} J/tok` ); } z.push(zrow); text.push(trow); } let flat = z.flat(); let zmin = Math.min(...flat); let zmax = Math.max(...flat); let step = (zmax-zmin)/(CONTOUR_LEVELS-1); let levels=[]; for(let i=0;i[i/(RDYLGN_R.length-1),c]); let fill = { type:"contour", x:xs, y:ys, z:z, text:text, hovertemplate:"%{text}", colorscale:colorscale, zmin:zmin, zmax:zmax, autocontour:false, contours:{ start:zmin, end:zmax, size:step, coloring:"fill" }, line:{ width:0 }, colorbar:{ title:{ text:"Energy-Per-Token Etok [J/tok]", side:"right" }, tickvals:levels.filter((_,i)=>i%3===0), ticktext:levels.filter((_,i)=>i%3===0).map(v=>formatEnergy(10**v)), outlinewidth:0.8 } }; let lines = { type:"contour", x:xs, y:ys, z:z, hoverinfo:"skip", showscale:false, autocontour:false, contours:{ start:zmin, end:zmax, size:2*step, coloring:"none" }, line:{ color:"black", width:0.5 } }; let xticks=[]; for(let e=Math.log2(CONTOUR_NIN_RANGE[0]);e<=Math.log2(CONTOUR_NIN_RANGE[1]);e++) xticks.push(e); let yticks=[]; for(let e=Math.log2(CONTOUR_NOUT_RANGE[0]);e<=Math.log2(CONTOUR_NOUT_RANGE[1]);e++) yticks.push(e); let annotations = contourLabels(xs,ys,z,levels.filter((_,i)=>i%2===0),colorscale,zmin,zmax); // Mark the input-token count currently selected with the slider let shapes=[]; let ninLog = Math.log2(nin); if(ninLog>=xs[0] && ninLog<=xs[xs.length-1]) { shapes.push({ type:"line", x0:ninLog, x1:ninLog, y0:ys[0], y1:ys[ys.length-1], line:{ color:"black", width:1.5, dash:"dash" } }); } let layout={ height:450, font:{ family:"sans-serif", size:13 }, xaxis:{ title:"Input Tokens τin [log2]", range:[xs[0],xs[xs.length-1]], tickmode:"array", tickvals:xticks, ticktext:xticks.map(e=>String(2**e)), tickangle:-45, showline:true, linecolor:"black", ticks:"outside", showgrid:false, zeroline:false }, yaxis:{ title:"Output Tokens τout [log2]", range:[ys[0],ys[ys.length-1]], tickmode:"array", tickvals:yticks, ticktext:yticks.map(e=>String(2**e)), showline:true, linecolor:"black", ticks:"outside", showgrid:false, zeroline:false }, annotations:annotations, shapes:shapes, margin:{ l:80, r:20, t:20, b:90 } }; Plotly.newPlot( "contour", [fill,lines], layout, { responsive:true } ); } // Place one label per contour line where it is farthest from every plot // edge, skipping lines that never get far enough (as in the notebook). function contourLabels(xs,ys,z,lineLevels,colorscale,zmin,zmax) { const MARGIN_THRESHOLD = 0.09; let xmin=xs[0], xmax=xs[xs.length-1]; let ymin=ys[0], ymax=ys[ys.length-1]; let annotations=[]; for(let level of lineLevels) { let best=null; let bestMargin=-1; let consider=(x,y)=> { let nx=(x-xmin)/(xmax-xmin); let ny=(y-ymin)/(ymax-ymin); let m=Math.min(nx,1-nx,ny,1-ny); if(m>bestMargin) { bestMargin=m; best=[x,y]; } }; for(let i=0;iMARGIN_THRESHOLD) { annotations.push({ x:best[0], y:best[1], text:formatEnergy(10**level), showarrow:false, font:{ size:11, color:"black" }, bgcolor:colorAt(colorscale,(level-zmin)/(zmax-zmin)), borderpad:1 }); } } return annotations; } function colorAt(colorscale,t) { t=Math.min(1,Math.max(0,t)); for(let i=1;iMath.round(v+(rgb1[k]-v)*f)); return `rgb(${mix.join(",")})`; } } return colorscale[colorscale.length-1][1]; } function hexToRgb(hex) { return [1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)); } function formatEnergy(v) { return String(Number(v.toPrecision(2))); } // Shrinks the formula so it always fits on one line. // All its inner sizes are in em, so the width scales linearly with font-size. function fitFormula() { const formula=document.querySelector(".formula"); // Back to the CSS size, then scale down only if it overflows formula.style.fontSize=""; const available=formula.clientWidth; const needed=formula.scrollWidth; if(needed>available) { const base=parseFloat(getComputedStyle(formula).fontSize); formula.style.fontSize=(base*available/needed*0.98)+"px"; } } // Renders every element carrying a data-tex attribute function renderFormula() { document.querySelectorAll("[data-tex]").forEach( el=>katex.render( el.dataset.tex, el, { throwOnError:false } ) ); } // Component each theta belongs to (indices into COMPONENT_LABELS) const THETA_COMPONENT = [2, 0, 1, 2]; const COMPONENT_CLASSES = ["prefill", "kvcache", "nexttok"]; function renderThetas() { let body = document.getElementById("thetaBody"); body.innerHTML = THETAS.map( (t,i)=> { let [mantissa,exponent] = t.toExponential(4).split("e"); let c = THETA_COMPONENT[i]; return ` ${COMPONENT_LABELS[c]} `; } ).join(""); } // Reads d, L, n_q, n_kv from a config.json. // Multimodal models (e.g. Gemma 3, Qwen-VL) nest the language model // settings under text_config, so that is used when present. function readArchitecture(config) { let cfg = config.text_config || config; let d = cfg.hidden_size || cfg.d_model || cfg.n_embd; let L = cfg.num_hidden_layers || cfg.num_layers || cfg.n_layer; let nq = cfg.num_attention_heads || cfg.n_head; let nkv = cfg.num_key_value_heads || nq; return {cfg,d,L,nq,nkv}; } function validateModel(config) { let {cfg,d,L} = readArchitecture(config); // ------------------------------ // Reject Mixture-of-Experts models // ------------------------------ const moeKeys = [ "num_experts", "num_local_experts", "num_experts_per_tok", "num_sparse_layers", "router_aux_loss_coef", "moe_layer_freq" ]; for (let key of moeKeys) { if (cfg[key] !== undefined) { throw new Error( "MoE models are not supported. " +"Please select a dense Transformer model." ); } } // ------------------------------ // Reject hybrid / linear-attention models // (e.g. Qwen3.8: only 1 layer in 4 keeps a growing KV-cache) // ------------------------------ const attentionTypes = [ "full_attention", "sliding_attention" ]; let layerTypes = cfg.layer_types || []; if(layerTypes.some(t=>!attentionTypes.includes(t))) { throw new Error( "Hybrid / linear-attention models are not supported. " +"Please select a standard Transformer model." ); } // ------------------------------ // Estimate parameter count // ------------------------------ if(!d || !L) { throw new Error( "Cannot estimate model size from config.json" ); } // Dense Transformer approximation let params = 12 * L * d * d; let paramsB = params / 1e9; // ------------------------------ // Size restriction // ------------------------------ const MAX_PARAMS_B = 20; if(paramsB > MAX_PARAMS_B) { throw new Error( `Model too large (~${paramsB.toFixed(1)}B parameters). ` + `Only dense models below ${MAX_PARAMS_B}B are allowed.` ); } return paramsB; } async function loadModel() { let repo = document.getElementById("repo").value.trim(); await loadRepo(repo); } async function loadRepo(repo) { if(!repo) { setStatus("error", "Enter a repository id, e.g. deepseek-ai/DeepSeek-R1-Distill-Llama-8B"); return; } let url = `https://huggingface.co/${repo}/resolve/main/config.json`; setStatus("loading", `Loading ${repo}…`); try { let response = await fetch(url); if(!response.ok) throw Error( "Could not load config.json. Some models (e.g. meta-llama models) require licenses." ); let cfg = await response.json(); validateModel(cfg); let {d,L,nq,nkv} = readArchitecture(cfg); document.getElementById("d").value=d; document.getElementById("L").value=L; document.getElementById("nq").value=nq; document.getElementById("nkv").value=nkv; setStatus("ok", `${repo} · Hugging Face`); draw(); } catch(e) { setStatus("error", e.message); } } // redraw when the architecture fields change document .querySelectorAll("input[type=number]") .forEach( x=>x.addEventListener("change",()=> { setStatus("custom", "Custom architecture"); draw(); }) ); document .getElementById("repo") .addEventListener("keydown",e=> { if(e.key==="Enter") loadModel(); }); draw();