webgpu-spike / index.html
tinkersnot's picture
Upload index.html with huggingface_hub
bdde0c0 verified
Raw
History Blame Contribute Delete
11.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MiniCPM5-1B WebGPU spike — think-on tool-calling</title>
<style>
:root { color-scheme: dark; }
body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background:#0d1017; color:#c9d1d9; margin:0; padding:16px; }
h1 { font-size:16px; margin:0 0 4px; }
.sub { color:#8b949e; margin:0 0 16px; font-size:12px; }
button { font:inherit; background:#238636; color:#fff; border:0; border-radius:6px; padding:8px 16px; cursor:pointer; }
button:disabled { background:#30363d; color:#8b949e; cursor:default; }
#env { margin:8px 0; padding:8px 12px; border-radius:6px; background:#161b22; border:1px solid #30363d; }
.ok { color:#3fb950; } .bad { color:#f85149; } .warn { color:#d29922; }
#metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; margin:12px 0; }
.m { background:#161b22; border:1px solid #30363d; border-radius:6px; padding:8px 12px; }
.m .k { color:#8b949e; font-size:11px; } .m .v { font-size:18px; color:#58a6ff; }
.test { border:1px solid #30363d; border-radius:6px; margin:10px 0; overflow:hidden; }
.test h3 { margin:0; padding:8px 12px; background:#161b22; font-size:13px; display:flex; justify-content:space-between; }
.test pre { margin:0; padding:10px 12px; white-space:pre-wrap; word-break:break-word; font-size:12px; max-height:280px; overflow:auto; }
.badge { border-radius:10px; padding:1px 8px; font-size:11px; }
.badge.pass { background:#1a3d24; color:#3fb950; } .badge.fail { background:#3d1a1a; color:#f85149; }
#log { color:#8b949e; font-size:11px; white-space:pre-wrap; margin-top:12px; max-height:160px; overflow:auto; }
</style>
</head>
<body>
<h1>MiniCPM5-1B WebGPU spike</h1>
<p class="sub" id="sub">Lens C + think-on tool-calling proof · web-llm/WebGPU · @0.3</p>
<div id="env">checking environment…</div>
<button id="run" disabled>Run spike</button>
<div id="metrics"></div>
<div id="tests"></div>
<div id="log"></div>
<script type="module">
import * as webllm from "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.84/lib/index.js";
const $ = (id) => document.getElementById(id);
const log = (m) => { $("log").textContent += m + "\n"; $("log").scrollTop = 1e9; };
// Default = OUR clean conversion (mlc-convert Space, ffi-0.1.10 toolchain).
// ?model=christophdet switches to the community build (known-garbage weights) for A/B.
const VARIANTS = {
tinkersnot: {
repo: "https://huggingface.co/tinkersnot/minicpm5-1b-q4f16-MLC/resolve/main",
modelId: "minicpm5-1b-q4f16-MLC",
modelDir: "", // files at repo root
wasm: "minicpm5-1b-q4f16-MLC-ctx4096-webgpu.wasm",
label: "tinkersnot/minicpm5-1b-q4f16-MLC (own clean conversion)",
},
r24: {
repo: "https://huggingface.co/tinkersnot/minicpm5-1b-r24-merged-MLC/resolve/main",
modelId: "minicpm5-1b-r24-merged-MLC",
modelDir: "",
wasm: "minicpm5-1b-r24-merged-MLC-ctx4096-webgpu.wasm",
label: "tinkersnot/minicpm5-1b-r24-merged-MLC (R24 turn-pred — No-Think instrument; think-on tests are NOT its exam)",
},
christophdet: {
repo: "https://huggingface.co/christophdet/MiniCPM5-1B-q4f16_1-MLC/resolve/main",
modelId: "MiniCPM5-1B-q4f16_1-MLC",
modelDir: "MiniCPM5-1B-q4f16_1-MLC",
wasm: "MiniCPM5-1B-q4f16_1-ctx4k-webgpu.wasm",
label: "christophdet/MiniCPM5-1B-q4f16_1-MLC (community, garbage weights)",
},
};
const V = VARIANTS[new URLSearchParams(location.search).get("model")] || VARIANTS.tinkersnot;
const REPO = V.repo;
const MODEL_ID = V.modelId;
document.getElementById("sub").textContent =
`Lens C + think-on tool-calling proof · model: ${V.label} · web-llm/WebGPU · @0.3`;
// ── native MiniCPM5 render (the config's conv_template is generic chatml with
// function-calling OFF, so we render the model's OWN tool/think format by hand —
// same shape proved in the training harvest) ──────────────────────────────────
function renderPrompt(system, user, tools, think) {
const toolBlock = tools ? (
"# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n" +
tools.map((t) => JSON.stringify(t)).join("\n") +
"\n</tools>\n\nWhen calling a function, return an XML object within <function ... </function> using:\n" +
'<function name="function-name"><param name="param-name">param-value</param></function>'
) : "";
const sys = [system, toolBlock].filter(Boolean).join("\n\n");
// literal BOS: web-llm raw completions encode with add_special_tokens=false —
// no post-processor BOS. "<s>" matches as added token id 0; without it the
// model degenerates (echo/repetition — the earlier "garbage weights" signature).
let p = "<s>";
if (sys) p += `<|im_start|>system\n${sys}<|im_end|>\n`;
p += `<|im_start|>user\n${user}<|im_end|>\n`;
p += `<|im_start|>assistant\n`;
p += think ? "<think>\n" : "<think>\n\n</think>\n\n";
return p;
}
function parseEmission(text) {
let think = "", rest = text;
if (text.includes("</think>")) { const i = text.indexOf("</think>"); think = text.slice(0, i).replace("<think>", "").trim(); rest = text.slice(i + 8); }
const calls = [];
const re = /<function name="([^"]+)">([\s\S]*?)<\/function>/g; let m;
while ((m = re.exec(rest))) {
const args = {}; const pr = /<param name="([^"]+)">([\s\S]*?)<\/param>/g; let pm;
while ((pm = pr.exec(m[2]))) args[pm[1]] = pm[2].replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "").trim();
calls.push({ name: m[1], args });
}
return { think, calls, content: rest.replace(re, "").trim() };
}
const QUICK = new URLSearchParams(location.search).has("quick");
const MAXTOK = Number(new URLSearchParams(location.search).get("tok")) || (QUICK ? 320 : 1024);
const TESTS_ALL = [
{ id: "tool-invoke", think: true, expect: (r) => r.calls[0]?.name === "execute",
system: "You are the CORTEX agent inside Nexus Terminal. Use the available tools.",
user: "Open an NVDA chart window.",
tools: [{ type:"function", function:{ name:"execute", description:"Open windows via terminal commands", parameters:{ type:"object", properties:{ command:{type:"string"} }, required:["command"] } }}] },
{ id: "consume-ground", think: true, expect: (r) => r.content.includes("8443"),
system: "You are the CORTEX agent. Ground answers in tool results.",
// pre-seed the tool result in the prompt so the model must READ it
user: "What port is the gateway on? I ran the check and got:\n<tool_response>\nlisten_port = 8443\nmode = tls\n</tool_response>\nAnswer from that.",
tools: null },
{ id: "plain-chat", think: true, expect: (r) => r.content.length > 0 && r.calls.length === 0,
system: "You are a helpful assistant.", user: "In one sentence, what is a terminal workspace?", tools: null },
];
const TESTS = QUICK ? TESTS_ALL.slice(0, 1) : TESTS_ALL;
async function main() {
// env probe
const gpu = navigator.gpu;
let adapter = null, info = "";
if (gpu) { try {
adapter = await Promise.race([ gpu.requestAdapter(), new Promise((r) => setTimeout(() => r("TIMEOUT"), 4000)) ]);
if (adapter === "TIMEOUT") { $("env").innerHTML = '<span class="bad">✗ requestAdapter() timed out</span> — WebGPU blocked by frame permissions. Open this page in a top-level browser tab (not an embedded preview/iframe).'; return; }
const a = (adapter && adapter.info) || {}; info = `${a.vendor||""} ${a.architecture||""}`.trim();
} catch(e) { info = "probe-err:" + e.message; } }
if (!gpu) { $("env").innerHTML = '<span class="bad">✗ navigator.gpu ABSENT</span> — this browser/context has no WebGPU. Use Chrome 121+/Edge, or Safari 18+ (iOS 18+), over https or localhost.'; return; }
if (!adapter) {
try { adapter = await gpu.requestAdapter({ forceFallbackAdapter: true }); if (adapter) info = "FALLBACK/software"; } catch {}
}
if (!adapter) { $("env").innerHTML = '<span class="bad">✗ WebGPU present but no adapter</span> — GPU blocked (headless/software). Needs real GPU hardware.'; return; }
if (adapter.isFallbackAdapter || info === "FALLBACK/software") info += " [SOFTWARE — correctness only, tok/s NOT representative]";
$("env").innerHTML = `<span class="ok">✓ WebGPU adapter OK</span> ${info ? "— " + info : ""}`;
$("run").disabled = false;
if (QUICK) setTimeout(() => $("run").click(), 500);
$("run").onclick = async () => {
$("run").disabled = true; $("tests").innerHTML = ""; $("metrics").innerHTML = ""; $("log").textContent = "";
const t0 = performance.now();
const appConfig = { model_list: [{ model: REPO + (V.modelDir ? "/" + V.modelDir : ""), model_id: MODEL_ID, model_lib: REPO + "/" + V.wasm }] };
const engine = new webllm.MLCEngine({ appConfig, initProgressCallback: (p) => { $("env").innerHTML = `<span class="warn">loading… ${(p.progress*100|0)}%</span> ${p.text||""}`; } });
log("loading model (first run downloads ~700MB to browser cache)…");
await engine.reload(MODEL_ID);
const loadMs = performance.now() - t0;
$("env").innerHTML = `<span class="ok">✓ model loaded</span> in ${(loadMs/1000).toFixed(1)}s`;
let prefillTps = [], decodeTps = [], passes = 0;
for (const test of TESTS) {
const prompt = renderPrompt(test.system, test.user, test.tools, test.think);
const c0 = performance.now();
const res = await engine.completions.create({ prompt, max_tokens: MAXTOK, temperature: 0.3, stop: ["<|im_end|>"] });
const ms = performance.now() - c0;
const text = res.choices?.[0]?.text ?? "";
const parsed = parseEmission(text);
const ok = test.expect(parsed);
if (ok) passes++;
const u = res.usage || {};
const extra = res.usage?.extra || {};
if (extra.prefill_tokens_per_s) prefillTps.push(extra.prefill_tokens_per_s);
if (extra.decode_tokens_per_s) decodeTps.push(extra.decode_tokens_per_s);
const div = document.createElement("div"); div.className = "test";
div.innerHTML = `<h3><span>${test.id}${test.think?" · think-on":""}</span><span class="badge ${ok?"pass":"fail"}">${ok?"PASS":"FAIL"}</span></h3>` +
`<pre>THINK: ${escapeHtml(parsed.think||"(none)").slice(0,400)}\n\nTOOL CALL: ${parsed.calls.length?escapeHtml(JSON.stringify(parsed.calls[0])):"(none)"}\n\nCONTENT: ${escapeHtml(parsed.content||"(none)").slice(0,400)}\n\n[${(ms/1000).toFixed(1)}s · decode ${(extra.decode_tokens_per_s||0).toFixed(1)} tok/s]</pre>`;
$("tests").appendChild(div);
log(`${test.id}: ${ok?"PASS":"FAIL"} (${(ms/1000).toFixed(1)}s)`);
}
const avg = (a) => a.length ? (a.reduce((s,x)=>s+x,0)/a.length) : 0;
const mem = engine.runtimeStatsText ? await engine.runtimeStatsText().catch(()=>"") : "";
const cards = {
"load time": (loadMs/1000).toFixed(1)+"s",
"decode tok/s": avg(decodeTps).toFixed(1),
"prefill tok/s": avg(prefillTps).toFixed(0),
"tool tests": `${passes}/${TESTS.length}`,
"quant": "q4f16_1",
};
$("metrics").innerHTML = Object.entries(cards).map(([k,v]) => `<div class="m"><div class="k">${k}</div><div class="v">${v}</div></div>`).join("");
log("\nVERDICT: " + (passes===TESTS.length ? "WebGPU serving VIABLE — think-on tool-calling works in-browser" : `${passes}/${TESTS.length} passed — inspect failures`));
if (mem) log("\n" + mem);
};
}
function escapeHtml(s){ return String(s).replace(/[&<>]/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c])); }
main().catch((e)=>{ $("env").innerHTML = '<span class="bad">✗ error: '+escapeHtml(e.message)+'</span>'; log(String(e.stack||e)); });
</script>
</body>
</html>