781 lines
26 KiB
HTML
781 lines
26 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>AMD Ryzen AI MAX+ 395 "Strix Halo" — Llama.cpp Backend Performance Comparison</title>
|
||
<style>
|
||
:root {
|
||
--bg: #ffffff;
|
||
--ink: #000000;
|
||
--muted: #555555;
|
||
--accent: #0645ad;
|
||
--card: #f9f9f9;
|
||
--border: #cccccc;
|
||
--sticky: #f4f4f4;
|
||
--pill: #e0e0e0;
|
||
--roc: #d3caff;
|
||
--roc-text: #000000;
|
||
--warn: #d88f00;
|
||
--bad: #d32f2f;
|
||
--model-w: 300px;
|
||
--winner-w: 220px;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Inter;
|
||
}
|
||
|
||
a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
header {
|
||
padding: 14px 20px 0;
|
||
}
|
||
|
||
header h1 {
|
||
margin: 0 0 6px;
|
||
font-size: 22px;
|
||
}
|
||
|
||
header p {
|
||
margin: 4px 0 12px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.controls {
|
||
display: grid;
|
||
grid-template-columns: 1fr 160px 260px 180px;
|
||
gap: 10px;
|
||
align-items: end;
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin: 0 0 6px;
|
||
}
|
||
|
||
input[type="text"],
|
||
select,
|
||
input[type="number"] {
|
||
width: 100%;
|
||
background: #fff;
|
||
color: var(--ink);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 8px 10px;
|
||
outline: none;
|
||
}
|
||
|
||
.slider {
|
||
display: grid;
|
||
grid-template-columns: 1fr 90px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
|
||
.range-wrap {
|
||
position: relative;
|
||
height: 30px;
|
||
}
|
||
|
||
.range-wrap input[type="range"] {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
margin: 0;
|
||
background: transparent;
|
||
pointer-events: none;
|
||
appearance: none;
|
||
}
|
||
|
||
.range-wrap input[type="range"]::-webkit-slider-thumb {
|
||
pointer-events: auto;
|
||
appearance: none;
|
||
height: 16px;
|
||
width: 16px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||
cursor: pointer;
|
||
transition: background 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.range-wrap input[type="range"]::-webkit-slider-thumb:hover {
|
||
background: #0451cc;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.range-wrap input[type="range"]::-moz-range-thumb {
|
||
pointer-events: auto;
|
||
height: 16px;
|
||
width: 16px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||
cursor: pointer;
|
||
transition: background 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.range-wrap input[type="range"]::-moz-range-thumb:hover {
|
||
background: #0451cc;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.range-track {
|
||
position: absolute;
|
||
top: 50%;
|
||
height: 6px;
|
||
width: 100%;
|
||
transform: translateY(-50%);
|
||
border-radius: 999px;
|
||
background: #e5e5e5;
|
||
}
|
||
|
||
.range-values {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
}
|
||
|
||
|
||
input[type="range"] {
|
||
width: 100%;
|
||
}
|
||
|
||
.ticks {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.cols {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
padding: 0 20px 8px;
|
||
}
|
||
|
||
.colbox {
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 8px 10px;
|
||
min-width: 220px;
|
||
background: var(--card);
|
||
}
|
||
|
||
.colbox label {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin: 0;
|
||
}
|
||
|
||
.colbox input {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
background: var(--pill);
|
||
font-size: 11px;
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.roc {
|
||
background: var(--roc);
|
||
color: var(--roc-text);
|
||
border: 1px solid #b19cff55;
|
||
}
|
||
|
||
.faall {
|
||
background: #cfe9ff;
|
||
/* light blue chip */
|
||
color: #000000;
|
||
border: 1px solid #9bc9ff55;
|
||
}
|
||
|
||
.meta {
|
||
padding: 0 20px 14px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.section {
|
||
padding: 6px 20px 0;
|
||
}
|
||
|
||
.section h2 {
|
||
margin: 6px 0 8px;
|
||
font-size: 16px;
|
||
color: var(--ink);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tablewrap {
|
||
margin: 0 0 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.scroller {
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
|
||
.scroller::-webkit-scrollbar {
|
||
height: 10px;
|
||
}
|
||
|
||
.scroller::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.scroller-top {
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
height: 12px;
|
||
/* slim */
|
||
margin: 0 0 6px;
|
||
/* a little gap above the table */
|
||
}
|
||
|
||
.scroller-top .scroller-spacer {
|
||
height: 1px;
|
||
/* tiny content so the bar renders */
|
||
}
|
||
|
||
|
||
table {
|
||
width: max-content;
|
||
min-width: 100vw;
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
}
|
||
|
||
thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--card);
|
||
z-index: 2;
|
||
}
|
||
|
||
th,
|
||
td {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
}
|
||
|
||
th {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
td {
|
||
white-space: normal;
|
||
}
|
||
|
||
.col-model {
|
||
position: sticky;
|
||
left: 0;
|
||
background: var(--sticky);
|
||
z-index: 3;
|
||
max-width: var(--model-w);
|
||
width: var(--model-w);
|
||
}
|
||
|
||
.col-winner {
|
||
position: sticky;
|
||
left: calc(var(--model-w));
|
||
background: var(--sticky);
|
||
z-index: 3;
|
||
max-width: var(--winner-w);
|
||
width: var(--winner-w);
|
||
}
|
||
|
||
.model-title {
|
||
font-weight: 600;
|
||
word-wrap: break-word;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.model-meta {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.cell {
|
||
min-width: 120px;
|
||
text-align: center;
|
||
}
|
||
|
||
.tps {
|
||
display: block;
|
||
text-align: center;
|
||
}
|
||
|
||
.std {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.warn {
|
||
color: var(--warn);
|
||
}
|
||
|
||
.err {
|
||
color: var(--bad);
|
||
}
|
||
|
||
.winner-pill {
|
||
display: inline-block;
|
||
background: var(--pill);
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
margin: 4px 4px 4px 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.winner-wrap {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.env-sub {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
</style>
|
||
|
||
</head>
|
||
|
||
<body>
|
||
<header>
|
||
<h1>AMD Ryzen AI MAX+ 395 "Strix Halo" — Llama.cpp Backend Performance Comparison</h1>
|
||
<p class="muted">
|
||
Compare model throughput across backends (pp512 & tg128).
|
||
Repo: <a href="https://github.com/kyuz0/amd-strix-halo-toolboxes" target="_blank"
|
||
rel="noreferrer">kyuz0/amd-strix-halo-toolboxes</a>
|
||
</p>
|
||
<p class="muted">Platform: Framework Desktop, 128GB Unified RAM (accelerator-performance tuned profile)</p>
|
||
<p id="meta-line">Loading meta…</p>
|
||
</header>
|
||
|
||
|
||
<div class="controls">
|
||
<div>
|
||
<label for="search">Search model</label>
|
||
<input id="search" type="text" placeholder="e.g. llama, qwen, 30B, Q8_K…">
|
||
</div>
|
||
<div>
|
||
<label for="quant">Quant</label>
|
||
<select id="quant">
|
||
<option value="">Any</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Model params</label>
|
||
|
||
<div class="range-wrap" id="sizeRange">
|
||
<input type="range" id="sizeLo" step="1">
|
||
<input type="range" id="sizeHi" step="1">
|
||
<div class="range-track" id="sizeTrack"></div>
|
||
</div>
|
||
|
||
<div class="ticks">
|
||
<span>4B</span><span>50B</span><span>96B</span><span>143B</span><span>189B</span><span>235B</span>
|
||
</div>
|
||
|
||
<div class="range-values">
|
||
<span id="sizeLoVal">4B</span> – <span id="sizeHiVal">235B</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label for="faMode">Flash Attention</label>
|
||
<select id="faMode">
|
||
<option value="off">FA off</option>
|
||
<option value="on" selected>FA on</option> <!-- default ON -->
|
||
<option value="both">Both</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cols" id="columns"></div>
|
||
<div class="meta">Winner = every selected backend within the best’s uncertainty range, combining ± errors from both
|
||
results.</div>
|
||
|
||
<div class="section">
|
||
<h2>Prompt Processing (pp512) — tokens/second</h2>
|
||
</div>
|
||
<section class="tablewrap">
|
||
<div class="scroller scroller-top" data-for="pp">
|
||
<div class="scroller-spacer"></div>
|
||
</div>
|
||
<div class="scroller" id="scroller-pp">
|
||
<table id="tbl-pp">
|
||
<thead>
|
||
<tr></tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<div class="section">
|
||
<h2>Text Generation (tg128) — tokens/second</h2>
|
||
</div>
|
||
<section class="tablewrap">
|
||
<div class="scroller scroller-top" data-for="tg">
|
||
<div class="scroller-spacer"></div>
|
||
</div>
|
||
<div class="scroller" id="scroller-tg">
|
||
<table id="tbl-tg">
|
||
<thead>
|
||
<tr></tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<script>
|
||
(async function () {
|
||
const K_SIGMA = 1.0;
|
||
const MIN_TOL = 0.25;
|
||
|
||
const res = await fetch('results.json');
|
||
const data = await res.json();
|
||
const allRuns = data.runs || [];
|
||
const perfRuns = allRuns.filter(r => r.test === 'pp512' || r.test === 'tg128');
|
||
|
||
const metaLine = document.getElementById('meta-line');
|
||
const search = document.getElementById('search');
|
||
const quantSel = document.getElementById('quant');
|
||
const colPicker = document.getElementById('columns');
|
||
const faMode = document.getElementById('faMode');
|
||
|
||
// NEW dual slider elements
|
||
const sizeLo = document.getElementById('sizeLo');
|
||
const sizeHi = document.getElementById('sizeHi');
|
||
const sizeTrack = document.getElementById('sizeTrack');
|
||
const sizeLoVal = document.getElementById('sizeLoVal');
|
||
const sizeHiVal = document.getElementById('sizeHiVal');
|
||
|
||
metaLine.textContent = `${data.meta?.os_kernel || ''} — llama.cpp ${(data.meta?.llamacpp_builds || []).map(b => b.hash).join(', ') || 'unknown'} — generated ${data.meta?.generated_at || ''}`;
|
||
|
||
const models = [...new Set(perfRuns.map(r => r.model_clean || r.model))].sort((a, b) => a.localeCompare(b));
|
||
const byModel = {};
|
||
for (const name of models) { byModel[name] = { pp512: {}, tg128: {}, quant: null, sizeB: null }; }
|
||
for (const r of perfRuns) {
|
||
const name = r.model_clean || r.model;
|
||
const cfg = `${r.env}|fa=${r.fa ? 1 : 0}`;
|
||
byModel[name][r.test][cfg] = { mean: r.tps_mean, std: r.tps_std, err: r.error, et: r.error_type };
|
||
if (!byModel[name].quant && r.quant) byModel[name].quant = r.quant;
|
||
if (byModel[name].sizeB == null) byModel[name].sizeB = (r.name_params_b ?? r.params_b);
|
||
}
|
||
for (const r of allRuns) {
|
||
if (r.test || !r.error) continue;
|
||
const name = r.model_clean || r.model; if (!byModel[name]) continue;
|
||
const cfg = `${r.env}|fa=${r.fa ? 1 : 0}`;
|
||
for (const t of ['pp512', 'tg128']) {
|
||
if (!byModel[name][t][cfg]) byModel[name][t][cfg] = { mean: null, std: null, err: true, et: r.error_type || 'error' };
|
||
}
|
||
}
|
||
|
||
const envs = [...new Set(allRuns.map(r => r.env))].sort();
|
||
function envBox(env) {
|
||
const roc = env.includes('rocwmma');
|
||
const hipBLASTt_off = env.includes('hblt0');
|
||
const fa_all_quants = env.includes('fa_all_quants');
|
||
const id = `env_${env.replace(/[^a-z0-9_-]/gi, '_')}`;
|
||
return `
|
||
<div class="colbox">
|
||
<label for="${id}">
|
||
<input id="${id}" type="checkbox" data-env="${env}"
|
||
${/(vulkan_amdvlk|vulkan_radv|rocm6_4_4-rocwmma|rocm7_rc-rocwmma)(?![-\w])/.test(env.trim()) ? 'checked' : ''}>
|
||
<span>
|
||
<strong>${env}</strong>
|
||
${roc ? '<span class="badge roc">rocWMMA</span>' : ''}
|
||
${fa_all_quants ? '<span class="badge faall">FA all quants</span>' : ''}
|
||
${hipBLASTt_off ? '<span class="badge roc">hipBLASTt OFF</span>' : ''}
|
||
</span>
|
||
</label>
|
||
</div>`;
|
||
}
|
||
|
||
colPicker.innerHTML = envs.map(envBox).join('');
|
||
const selectedEnvs = () => [...colPicker.querySelectorAll('input[type="checkbox"]')].filter(i => i.checked).map(i => i.dataset.env);
|
||
|
||
const quants = [...new Set(Object.values(byModel).map(m => (m.quant || 'Unknown').toUpperCase()))].sort();
|
||
quants.forEach(q => { const o = document.createElement('option'); o.value = (q === 'UNKNOWN' ? '' : q); o.textContent = q; quantSel.appendChild(o); });
|
||
|
||
// --- Dual range slider setup ---
|
||
const sizes = Object.values(byModel)
|
||
.map(m => m.sizeB)
|
||
.filter(v => typeof v === 'number')
|
||
.sort((a, b) => a - b);
|
||
|
||
// force clean integer min/max
|
||
const MIN_B = Math.floor(sizes[0] ?? 0);
|
||
const MAX_B = Math.ceil(sizes[sizes.length - 1] ?? 100);
|
||
|
||
[sizeLo, sizeHi].forEach(inp => {
|
||
inp.min = MIN_B;
|
||
inp.max = MAX_B;
|
||
inp.step = 1; // integers only
|
||
});
|
||
|
||
sizeLo.value = MIN_B;
|
||
sizeHi.value = MAX_B;
|
||
|
||
const filters = { sizeLo: MIN_B, sizeHi: MAX_B };
|
||
|
||
function fmtB(n) {
|
||
return `${Number(n).toFixed(0)}B`;
|
||
}
|
||
|
||
function clampRange() {
|
||
if (+sizeLo.value > +sizeHi.value) {
|
||
if (document.activeElement === sizeLo) {
|
||
sizeHi.value = sizeLo.value;
|
||
} else {
|
||
sizeLo.value = sizeHi.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
function paintTrack() {
|
||
const a = (+sizeLo.value - MIN_B) / (MAX_B - MIN_B) * 100;
|
||
const b = (+sizeHi.value - MIN_B) / (MAX_B - MIN_B) * 100;
|
||
sizeTrack.style.background = `
|
||
linear-gradient(to right,
|
||
#e5e5e5 ${a}%,
|
||
var(--accent) ${a}%,
|
||
var(--accent) ${b}%,
|
||
#e5e5e5 ${b}%)`;
|
||
}
|
||
|
||
function updateSizeUI(pushRender = true) {
|
||
clampRange();
|
||
|
||
// Fix rounding: snap to min/max if slider is at extremes
|
||
if (Math.abs(sizeLo.value - MIN_B) < 0.0001) sizeLo.value = MIN_B;
|
||
if (Math.abs(sizeHi.value - MAX_B) < 0.0001) sizeHi.value = MAX_B;
|
||
|
||
sizeLoVal.textContent = fmtB(sizeLo.value);
|
||
sizeHiVal.textContent = fmtB(sizeHi.value);
|
||
filters.sizeLo = +sizeLo.value;
|
||
filters.sizeHi = +sizeHi.value;
|
||
|
||
paintTrack();
|
||
if (pushRender) render();
|
||
}
|
||
|
||
sizeLo.addEventListener('input', () => updateSizeUI(true));
|
||
sizeHi.addEventListener('input', () => updateSizeUI(true));
|
||
updateSizeUI(false);
|
||
|
||
function setupScrollSync(prefix) {
|
||
const top = document.querySelector(`.scroller-top[data-for="${prefix}"]`);
|
||
const bottom = document.getElementById(`scroller-${prefix}`);
|
||
const spacer = top.querySelector('.scroller-spacer');
|
||
if (!top || !bottom || !spacer) return () => { };
|
||
|
||
function syncWidth() {
|
||
|
||
// Match the table’s full scrollable width
|
||
spacer.style.width = bottom.scrollWidth + 'px';
|
||
// Keep positions aligned after rerender
|
||
top.scrollLeft = bottom.scrollLeft;
|
||
}
|
||
|
||
// Two-way sync
|
||
let lock = false;
|
||
top.addEventListener('scroll', () => {
|
||
if (lock) return; lock = true;
|
||
bottom.scrollLeft = top.scrollLeft;
|
||
lock = false;
|
||
});
|
||
bottom.addEventListener('scroll', () => {
|
||
if (lock) return; lock = true;
|
||
top.scrollLeft = bottom.scrollLeft;
|
||
lock = false;
|
||
});
|
||
|
||
// Resize/refresh on content changes
|
||
const ro = new ResizeObserver(syncWidth);
|
||
ro.observe(bottom);
|
||
syncWidth();
|
||
|
||
// Return a refresher for manual calls after rerender
|
||
return syncWidth;
|
||
}
|
||
|
||
// --- Filters ---
|
||
function matchesFilters(name) {
|
||
const q = (search.value || '').toLowerCase();
|
||
if (q && !name.toLowerCase().includes(q)) return false;
|
||
|
||
const info = byModel[name];
|
||
if (quantSel.value && (info.quant || '').toUpperCase() !== quantSel.value.toUpperCase()) return false;
|
||
|
||
if (info.sizeB != null && (info.sizeB < filters.sizeLo - 1e-9 || info.sizeB > filters.sizeHi + 1e-9)) return false;
|
||
return true;
|
||
}
|
||
|
||
function cleanTitle(name, quant) {
|
||
const qTokens = ['BF16', 'F16', 'F32', 'FP16', 'MXFP2', 'MXFP4', 'MXFP8'];
|
||
if (quant) qTokens.push(quant.toUpperCase());
|
||
let t = name;
|
||
t = t.replace(/[-_](Q\d+(?:_[A-Z0-9]+)*)/gi, '');
|
||
t = t.replace(new RegExp(`[-_]?(${qTokens.join('|')})\\b`, 'gi'), '');
|
||
t = t.replace(/-UD\b/gi, '');
|
||
t = t.replace(/[-_]{2,}/g, '-')
|
||
.replace(/\s{2,}/g, ' ')
|
||
.replace(/[-_]+$/, '')
|
||
.trim();
|
||
return t;
|
||
}
|
||
|
||
function buildColDefs(envSel, mode) {
|
||
const cols = [];
|
||
for (const e of envSel) {
|
||
if (mode === 'both') {
|
||
cols.push({ env: e, key: `${e}|fa=0`, label: `${e}<div class="env-sub">FA off</div>` });
|
||
cols.push({ env: e, key: `${e}|fa=1`, label: `${e}<div class="env-sub">FA on</div>` });
|
||
} else {
|
||
const k = `${e}|fa=${mode === 'on' ? 1 : 0}`;
|
||
cols.push({ env: e, key: k, label: `${e}<div class="env-sub">FA ${mode === 'on' ? 'on' : 'off'}</div>` });
|
||
}
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
function headerHTML(test, colDefs) {
|
||
return `<th class="col-model">Model</th><th class="col-winner">Winner (${test})</th>` +
|
||
colDefs.map(c => `<th>${c.label}</th>`).join('');
|
||
}
|
||
|
||
function cellHTML(name, test, key) {
|
||
const v = byModel[name][test][key];
|
||
if (!v) return '<div class="cell"><span class="muted">—</span></div>';
|
||
if (v.err || v.mean == null) return `<div class="cell"><span class="warn">⚠ ${v.et || 'error'}</span></div>`;
|
||
return `<div class="cell"><span class="tps">${v.mean.toFixed(2)}</span><span class="std">± ${v.std?.toFixed(2) ?? '—'}</span></div>`;
|
||
}
|
||
|
||
function winnersHTML(name, test, colDefs) {
|
||
const vals = [];
|
||
for (const c of colDefs) {
|
||
const v = byModel[name][test][c.key];
|
||
if (v && !v.err && typeof v.mean === 'number') {
|
||
vals.push({
|
||
env: c.env,
|
||
fa: c.key.endsWith('|fa=1') ? 'on' : 'off',
|
||
mean: v.mean,
|
||
std: (typeof v.std === 'number' ? v.std : 0)
|
||
});
|
||
}
|
||
}
|
||
if (!vals.length) return '<span class="muted">—</span>';
|
||
let best = vals[0];
|
||
for (const v of vals) if (v.mean > best.mean) best = v;
|
||
const winners = [];
|
||
for (const v of vals) {
|
||
const pooled = Math.sqrt((best.std || 0) ** 2 + (v.std || 0) ** 2);
|
||
const tol = Math.max(MIN_TOL, K_SIGMA * pooled);
|
||
if ((best.mean - v.mean) <= tol) winners.push(v);
|
||
}
|
||
if (faMode.value !== 'both') {
|
||
const envs = [...new Set(winners.map(w => w.env))];
|
||
return `<div class="winner-wrap">${envs.map(env =>
|
||
`<span class="winner-pill">🏆 ${env}</span>`).join('')}</div>`;
|
||
} else {
|
||
const byEnv = {};
|
||
for (const w of winners) (byEnv[w.env] ||= new Set()).add(w.fa);
|
||
return `<div class="winner-wrap">${Object.entries(byEnv).map(([env, fas]) => {
|
||
const list = [...fas].sort().map(x => `FA ${x}`).join(' & ');
|
||
return `<span class="winner-pill">🏆 ${env} (${list})</span>`;
|
||
}).join('')
|
||
}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderTable(prefix, test) {
|
||
const envSel = selectedEnvs(); const mode = faMode.value;
|
||
const colDefs = buildColDefs(envSel, mode);
|
||
const h = document.querySelector(`#tbl-${prefix} thead tr`) || document.getElementById(`${prefix}-h`);
|
||
if (!h) return; // nothing to render into; bail safely
|
||
h.innerHTML = headerHTML(test, colDefs);
|
||
const tbody = document.querySelector(`#tbl-${prefix} tbody`);
|
||
tbody.innerHTML = '';
|
||
for (const name of models.filter(matchesFilters)) {
|
||
const tr = document.createElement('tr');
|
||
const tdM = document.createElement('td');
|
||
tdM.className = 'col-model';
|
||
const info = byModel[name];
|
||
tdM.innerHTML = `<div class="model-title">${cleanTitle(name, info.quant)}</div><div class="model-meta">${info.quant || '—'} · ${(info.sizeB ?? '—')}B</div>`;
|
||
tr.appendChild(tdM);
|
||
const tdW = document.createElement('td');
|
||
tdW.className = 'col-winner';
|
||
tdW.innerHTML = winnersHTML(name, test, colDefs);
|
||
tr.appendChild(tdW);
|
||
for (const c of colDefs) {
|
||
const td = document.createElement('td');
|
||
td.innerHTML = cellHTML(name, test, c.key);
|
||
tr.appendChild(td);
|
||
}
|
||
tbody.appendChild(tr);
|
||
}
|
||
}
|
||
let refreshScrollPP = null, refreshScrollTG = null;
|
||
function render() {
|
||
renderTable('pp', 'pp512');
|
||
renderTable('tg', 'tg128');
|
||
// Update top scrollbar widths after content changes
|
||
if (typeof refreshScrollPP === 'function') refreshScrollPP();
|
||
if (typeof refreshScrollTG === 'function') refreshScrollTG();
|
||
}
|
||
|
||
[search, quantSel, faMode].forEach(el => el.addEventListener('input', render));
|
||
colPicker.addEventListener('change', render);
|
||
render();
|
||
|
||
refreshScrollPP = setupScrollSync('pp');
|
||
refreshScrollTG = setupScrollSync('tg');
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html> |