| <!doctype html> |
| <html lang="ru"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Falcon-Perception · бенчмарк</title> |
| <link rel="icon" href="favicon.svg" /> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" /> |
| <style> |
| :root { |
| --bg: #0b0908; |
| --bg2: #12100d; |
| --line: #262019; |
| --text: #e8e3db; |
| --dim: #8b857b; |
| --faint: #55504a; |
| --amber: #ffb454; |
| --amber-dim: #96703a; |
| --ok: #9ece6a; |
| --err: #e0645c; |
| --mono: "IBM Plex Mono", ui-monospace, monospace; |
| --serif: "Instrument Serif", serif; |
| } |
| * { box-sizing: border-box; margin: 0; } |
| body { |
| background: var(--bg); color: var(--text); |
| font-family: var(--mono); font-size: 13px; line-height: 1.6; |
| min-height: 100vh; padding: 2.2rem 1.2rem 4rem; |
| display: flex; justify-content: center; |
| } |
| body::before { |
| content: ""; |
| position: fixed; inset: 0; z-index: 10; pointer-events: none; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E"); |
| } |
| body::after { |
| content: ""; |
| position: fixed; inset: 0; z-index: 9; pointer-events: none; |
| background: radial-gradient(ellipse 120% 90% at 50% 0%, transparent 60%, rgba(0,0,0,0.5)); |
| } |
| main { width: min(760px, 100%); position: relative; z-index: 1; } |
| h1 { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 28px; margin-bottom: 4px; } |
| .sub { font-size: 11.5px; color: var(--faint); letter-spacing: 0.04em; margin-bottom: 1.6rem; } |
| .sub a { color: var(--dim); } |
| .sub a:hover { color: var(--amber); } |
| .card { border: 1px solid var(--line); background: var(--bg2); padding: 1rem 1.1rem; margin-bottom: 1.2rem; } |
| .card h2 { |
| font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; |
| color: var(--faint); margin-bottom: 0.7rem; |
| } |
| table { width: 100%; border-collapse: collapse; font-size: 12px; } |
| th, td { text-align: left; padding: 0.25rem 0.6rem 0.25rem 0; } |
| th { color: var(--faint); font-weight: 400; } |
| td { color: var(--text); font-variant-numeric: tabular-nums; } |
| td small, th small { color: var(--faint); } |
| .controls { display: flex; gap: 0.9rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.9rem; } |
| button { |
| background: none; border: 1px solid var(--line); color: var(--dim); |
| font: inherit; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; |
| padding: 0.5rem 1.3rem; cursor: pointer; transition: all 0.2s; |
| } |
| button:hover:not(:disabled) { color: var(--amber); border-color: var(--amber-dim); } |
| button:disabled { opacity: 0.35; cursor: default; } |
| label { font-size: 11.5px; color: var(--faint); display: inline-flex; gap: 0.45rem; align-items: center; cursor: pointer; } |
| label input { accent-color: var(--amber); } |
| #console { |
| height: 260px; overflow-y: auto; |
| font-size: 11.5px; line-height: 1.55; color: var(--dim); |
| white-space: pre-wrap; |
| border: 1px solid var(--line); padding: 0.7rem 0.8rem; |
| background: rgba(0, 0, 0, 0.3); |
| scrollbar-width: thin; scrollbar-color: var(--line) transparent; |
| } |
| #console .ok { color: var(--ok); } |
| #console .hl { color: var(--amber); } |
| #console .err { color: var(--err); } |
| #results { display: none; } |
| .dl { display: flex; gap: 0.8rem; margin-top: 0.9rem; } |
| #stagewrap { height: 0; overflow: hidden; } |
| </style> |
| </head> |
| <body> |
| <main> |
| <h1>Falcon-Perception · бенчмарк</h1> |
| <p class="sub"> |
| Стандартный перф-набор движка на <em>вашем</em> GPU + шарабельный .log: |
| загрузка весов (724 МБ, кешируются), полная фото-детекция, |
| видео-кейфреймы со спекулятивным декодом и трекер. Нужен WebGPU с |
| shader-f16 + subgroups (Chrome/Edge 125+). |
| · <a href="index.html">демо</a> |
| · <a href="https://huggingface.co/borkiss/falcon-perception-webgpu">веса</a> |
| </p> |
|
|
| <div class="card"> |
| <h2>Референс — Mac mini M4, 10-core GPU, Chrome</h2> |
| <table> |
| <tr><th></th><th>фото-запрос</th><th>декод</th><th>кейфреймы статика / пан</th><th>трек</th></tr> |
| <tr><td>q8 + q4-lm</td><td>~1.25 с (8 боксов)</td><td>~80 ток/с</td><td>~2.5 / ~1.8 Гц @256</td><td>~28 fps</td></tr> |
| </table> |
| </div> |
|
|
| <div class="card"> |
| <h2>Запуск</h2> |
| <div class="controls"> |
| <button id="run">▶ бенчмарк</button> |
| <label><input type="checkbox" id="withVideo" checked /> видеофазы (статика + пан, ~25 с)</label> |
| </div> |
| <div id="console">ожидание — нажмите «бенчмарк»</div> |
| </div> |
|
|
| <div class="card" id="results"> |
| <h2>Результаты</h2> |
| <table id="rtable"></table> |
| <div class="dl"> |
| <button id="download">↓ скачать .log</button> |
| <button id="copy">копировать JSON</button> |
| </div> |
| </div> |
|
|
| <div id="stagewrap"><video id="v" muted playsinline></video></div> |
| </main> |
|
|
| <script type="module"> |
| const $ = (id) => document.getElementById(id); |
| const median = (a) => a.slice().sort((x, y) => x - y)[a.length >> 1]; |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| |
| function log(text, cls = '') { |
| const el = document.createElement('div'); |
| if (cls) el.className = cls; |
| el.textContent = text; |
| if ($('console').textContent === 'ожидание — нажмите «бенчмарк»') $('console').textContent = ''; |
| $('console').appendChild(el); |
| $('console').scrollTop = $('console').scrollHeight; |
| } |
| |
| const HF_WEIGHTS = 'https://huggingface.co/borkiss/falcon-perception-webgpu/resolve/main/falcon-q8-lm4.bin'; |
| async function resolveWeights() { |
| try { |
| const local = new URL('engine/weights/falcon-q8-lm4.bin', location.href).href; |
| if ((await fetch(local, { method: 'HEAD' })).ok) return { url: local, source: 'local' }; |
| } catch {} |
| return { url: HF_WEIGHTS, source: 'huggingface' }; |
| } |
| |
| let worker = null, onResult = null, onLoad = null, onProgress = null; |
| function initWorker() { |
| worker = new Worker('engine/src/worker.js', { type: 'module' }); |
| worker.onmessage = (e) => { |
| const d = e.data; |
| if (d.type === 'progress') { onProgress?.(d); return; } |
| if (d.type === 'loaded') { onLoad?.(); return; } |
| if (d.type === 'error') { log('ошибка воркера: ' + d.message, 'err'); onLoad?.(new Error(d.message)); return; } |
| if (d.type === 'result') onResult?.(d); |
| }; |
| } |
| |
| let reqId = 0; |
| const detect = (src, query, video) => createImageBitmap(src).then((bmp) => |
| worker.postMessage({ type: 'detect', image: bmp, query, video, |
| requestId: reqId++, capturedAtMs: performance.now() }, [bmp])); |
| const detectAwait = (src, query) => new Promise((res) => { |
| onResult = (d) => { if (d.source === 'detect' && !d.partial) { onResult = null; res(d); } }; |
| detect(src, query, false); |
| }); |
| const config = (o) => worker.postMessage({ type: 'config', |
| spec: true, specTol: 6, gate: false, videoRes: 256, decodeCap: 0, ...o }); |
| |
| |
| async function videoPhase(img, { seconds, panSpeed, label }) { |
| const c = document.createElement('canvas'); c.width = 512; c.height = 288; |
| const cx = c.getContext('2d'); |
| const k = img.width / 1280, span = 1280 - 512; |
| const draw = (t) => { |
| const x = panSpeed ? Math.min(t * panSpeed, span) : span / 2; |
| cx.drawImage(img, x * k, (img.height - 288 * k) / 2, 512 * k, 288 * k, 0, 0, 512, 288); |
| }; |
| draw(0); |
| const M = { det: 0, trk: 0, boxes: [], lat: [], prefill: [], decode: [], |
| specOk: 0, specRuns: 0, seq: 0 }; |
| onResult = (d) => { |
| if (d.partial) return; |
| if (d.source === 'track') { M.trk++; M.lat.push(performance.now() - d.capturedAtMs); } |
| else if (d.source === 'detect') { |
| M.det++; M.boxes.push(d.detections.length); |
| if (d.stats) { |
| M.prefill.push(d.stats.prefillMs || 0); M.decode.push(d.stats.decodeMs || 0); |
| M.specOk += d.stats.specAccepted || 0; M.specRuns += d.stats.specRuns || 0; |
| M.seq += d.stats.seqSteps || 0; |
| } |
| } |
| }; |
| const t0 = performance.now(); |
| const pump = setInterval(() => { |
| draw((performance.now() - t0) / 1000); |
| detect(c, 'person', true); |
| }, 33); |
| |
| for (let s = 5; s < seconds; s += 5) { |
| await sleep(5000); |
| log(` ${label}: ${s} с — кейфреймов ${M.det}, трек-кадров ${M.trk}`); |
| } |
| await sleep((seconds % 5 || 5) * 1000); |
| clearInterval(pump); |
| await sleep(1500); |
| onResult = null; |
| const dur = seconds; |
| return { |
| hz: +(M.det / dur).toFixed(2), |
| trkFps: +(M.trk / dur).toFixed(1), |
| latMs: M.lat.length ? Math.round(median(M.lat)) : null, |
| boxes: M.boxes.length ? median(M.boxes) : 0, |
| prefillMs: M.prefill.length ? Math.round(median(M.prefill)) : null, |
| decodeMs: M.decode.length ? Math.round(median(M.decode)) : null, |
| specAccepted: M.specOk, specRuns: M.specRuns, seqSteps: M.seq, |
| }; |
| } |
| |
| async function bench() { |
| const R = { meta: {}, results: {} }; |
| R.meta.date = new Date().toISOString(); |
| R.meta.ua = navigator.userAgent; |
| |
| const adapter = await navigator.gpu?.requestAdapter({ powerPreference: 'high-performance' }); |
| if (!adapter) throw new Error('в этом браузере нет WebGPU'); |
| R.meta.gpu = `${adapter.info?.vendor ?? ''} ${adapter.info?.architecture ?? ''} ${adapter.info?.device ?? ''}`.trim() || 'unknown'; |
| R.meta.features = ['shader-f16', 'subgroups', 'chromium-experimental-subgroup-matrix'] |
| .filter((f) => adapter.features.has(f)); |
| log(`gpu: ${R.meta.gpu}`, 'hl'); |
| log(`features: ${R.meta.features.join(', ')}`); |
| if (!adapter.features.has('shader-f16') || !adapter.features.has('subgroups')) |
| throw new Error('движку нужны shader-f16 + subgroups'); |
| |
| const w = await resolveWeights(); |
| R.meta.weights_source = w.source; |
| log(`веса: falcon-q8-lm4.bin (${w.source})`); |
| |
| |
| initWorker(); |
| let dlBytes = 0, dlEnd = 0, lastPct = -10; |
| const t0 = performance.now(); |
| onProgress = (d) => { |
| dlBytes = d.loaded; dlEnd = performance.now(); |
| const pct = d.progress || 0; |
| if (pct >= lastPct + 10) { lastPct = pct; log(` скачивание ${pct}%`); } |
| }; |
| await new Promise((res, rej) => { onLoad = (e) => e ? rej(e) : res(); worker.postMessage({ type: 'load', weightsUrl: w.url }); }); |
| onLoad = null; onProgress = null; |
| const loadS = (performance.now() - t0) / 1000; |
| const dlS = (dlEnd - t0) / 1000; |
| R.results.load_s = +loadS.toFixed(1); |
| R.results.download_mb_s = dlS > 0.5 ? +(dlBytes / 1048576 / dlS).toFixed(0) : null; |
| log(`загрузка: ${R.results.load_s} с всего` + |
| (R.results.download_mb_s ? ` (скачивание ${R.results.download_mb_s} МБ/с, инициализация ${(loadS - dlS).toFixed(1)} с)` : ''), 'ok'); |
| config({ spec: false }); |
| |
| |
| const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'examples/sprinters.png'; |
| await img.decode(); |
| log('фото-детекция «athletes» на референс-сцене (1 прогрев + 3 замера)…'); |
| await detectAwait(img, 'athletes'); |
| const photo = []; |
| for (let r = 0; r < 3; r++) photo.push(await detectAwait(img, 'athletes')); |
| const q = photo.map((d) => d.inferenceMs); |
| const st = photo[photo.length - 1].stats || {}; |
| R.results.photo_query_ms = Math.round(median(q)); |
| R.results.photo_boxes = median(photo.map((d) => d.detections.length)); |
| R.results.photo_prefill_ms = st.prefillMs ?? null; |
| R.results.photo_decode_ms = st.decodeMs ?? null; |
| R.results.photo_decode_steps = st.seqSteps ?? null; |
| if (st.decodeMs && st.seqSteps) |
| R.results.decode_tok_s = +(st.seqSteps / st.decodeMs * 1000).toFixed(1); |
| log(`фото: ${R.results.photo_query_ms} мс/запрос · ${R.results.photo_boxes} боксов · ` + |
| `префилл ${st.prefillMs} мс · декод ${st.decodeMs} мс (${st.seqSteps} шагов` + |
| (R.results.decode_tok_s ? ` = ${R.results.decode_tok_s} ток/с` : '') + ')', 'ok'); |
| |
| |
| if ($('withVideo').checked) { |
| |
| |
| config({ spec: true, decodeCap: 60 }); |
| log('видео: статичная сцена 10 с @256 (спекуляция по драфту трекера)…'); |
| const stat = await videoPhase(img, { seconds: 10, panSpeed: 0, label: 'статика' }); |
| R.results.video_static = stat; |
| log(`статика: ${stat.hz} Гц кейфреймы · трек ${stat.trkFps} fps (латентность ${stat.latMs} мс) · ` + |
| `спекуляция приняла ${stat.specAccepted} токенов за ${stat.specRuns} verify`, 'ok'); |
| |
| log('видео: пан 10 с @256 (спекуляция сама отключается)…'); |
| const pan = await videoPhase(img, { seconds: 10, panSpeed: 40, label: 'пан' }); |
| R.results.video_pan = pan; |
| log(`пан: ${pan.hz} Гц кейфреймы · трек ${pan.trkFps} fps · префилл ${pan.prefillMs} мс · декод ${pan.decodeMs} мс`, 'ok'); |
| } |
| return R; |
| } |
| |
| function render(R) { |
| const rows = [ |
| ['gpu', R.meta.gpu], |
| ['загрузка', `${R.results.load_s} с` + (R.results.download_mb_s ? ` <small>(${R.results.download_mb_s} МБ/с)</small>` : '')], |
| ['фото-запрос', `${R.results.photo_query_ms} мс <small>(${R.results.photo_boxes} боксов)</small>`], |
| ['префилл (фото)', `${R.results.photo_prefill_ms} мс`], |
| ['декод', R.results.decode_tok_s ? `${R.results.decode_tok_s} ток/с` : `${R.results.photo_decode_ms} мс`], |
| ]; |
| if (R.results.video_static) { |
| rows.push(['кейфреймы (статика)', `${R.results.video_static.hz} Гц <small>· спекуляция ${R.results.video_static.specAccepted} ток</small>`]); |
| rows.push(['кейфреймы (пан)', `${R.results.video_pan.hz} Гц`]); |
| rows.push(['трек', `${R.results.video_pan.trkFps} fps <small>· ${R.results.video_pan.latMs} мс</small>`]); |
| } |
| $('rtable').innerHTML = rows |
| .map(([k, v]) => `<tr><th>${k}</th><td>${v}</td></tr>`).join(''); |
| $('results').style.display = 'block'; |
| } |
| |
| function makeLog(R) { |
| const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) => |
| v && typeof v === 'object' ? flat(v, `${p}${k}.`) : [[p + k, v]]); |
| return [ |
| 'falcon-perception-webgpu benchmark', |
| '==================================', |
| `date: ${R.meta.date}`, |
| `gpu: ${R.meta.gpu}`, |
| `ua: ${R.meta.ua}`, |
| `features: ${R.meta.features.join(', ')}`, |
| `weights: falcon-q8-lm4.bin 724MB q8+q4lm (${R.meta.weights_source})`, |
| '', |
| 'results', |
| '-------', |
| ...flat(R.results).map(([k, v]) => `${k.padEnd(28)} ${v}`), |
| '', |
| '=== RAW JSON ===', |
| JSON.stringify(R, null, 2), |
| '', |
| ].join('\n'); |
| } |
| |
| let lastLog = null; |
| $('run').addEventListener('click', async () => { |
| $('run').disabled = true; |
| try { |
| const R = await bench(); |
| render(R); |
| lastLog = { text: makeLog(R), R }; |
| log('готово — скачайте .log ниже и поделитесь', 'hl'); |
| window.__done = { ok: true, ...R.results, gpu: R.meta.gpu }; |
| } catch (e) { |
| log(`ОШИБКА: ${e.message}`, 'err'); |
| window.__done = { ok: false, error: String(e.message ?? e) }; |
| } |
| $('run').disabled = false; |
| }); |
| |
| $('download').addEventListener('click', () => { |
| if (!lastLog) return; |
| const arch = (lastLog.R.meta.gpu || 'gpu').replace(/[^a-z0-9-]+/gi, '-'); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(new Blob([lastLog.text], { type: 'text/plain' })); |
| a.download = `falcon-bench-${arch}-${lastLog.R.meta.date.slice(0, 10)}.log`; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| }); |
| $('copy').addEventListener('click', () => { |
| if (lastLog) navigator.clipboard.writeText(JSON.stringify(lastLog.R, null, 2)); |
| }); |
| |
| |
| if (new URLSearchParams(location.search).has('auto')) $('run').click(); |
| </script> |
| </body> |
| </html> |
|
|