File size: 13,269 Bytes
e2ac5c3
4721a6e
 
 
 
 
 
 
 
 
 
2dc46fb
 
 
4721a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dc46fb
 
 
4721a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1683f65
 
e2ac5c3
 
 
 
e6a49d5
e2ac5c3
 
 
1683f65
4721a6e
df975ba
 
4721a6e
df975ba
 
 
 
 
 
 
 
 
 
 
 
4721a6e
 
 
 
 
 
 
 
 
 
df975ba
 
 
4721a6e
 
2dc46fb
 
 
 
df975ba
2dc46fb
 
 
 
4721a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dc46fb
 
 
 
 
 
 
 
 
 
 
ecef386
 
2dc46fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4721a6e
 
 
 
 
2dc46fb
 
df975ba
 
2dc46fb
 
 
df975ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5047636
 
e2ac5c3
 
 
5047636
df975ba
 
 
 
 
 
 
 
2dc46fb
df975ba
 
 
 
 
ed5d4b6
df975ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dc46fb
 
 
 
 
 
 
 
 
 
 
 
 
 
4721a6e
 
 
 
df975ba
4721a6e
 
 
2dc46fb
 
 
 
4721a6e
 
 
 
 
 
 
 
2dc46fb
 
4721a6e
 
 
 
 
df975ba
 
 
 
 
 
 
 
2dc46fb
 
 
 
4721a6e
 
2dc46fb
 
 
df975ba
 
 
 
2dc46fb
 
df975ba
 
2dc46fb
 
 
 
 
 
 
 
 
df975ba
 
4721a6e
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import { loadData, filterResults, selectBestResults, expandCpuRows, attachCpuBaselineFromCpuRecords, mergeDepthPairs } from './data.js';
import { initFilters, populateQuantOptions, getFilters, resetFilters } from './filters.js';
import { renderDecodeChart, renderPrefillChart, renderSizeChart, renderMachineChart, renderCpuGpuChart, renderSpeedupChart } from './charts.js';
import { renderResultsTable, renderErrorTable, renderMachineInfo, renderCpuGpuTable } from './tables.js';

let appData = null;

async function init() {
  try {
    appData = await loadData();
  } catch (e) {
    const loading = document.getElementById('loading');
    loading.className = 'loading-state';
    loading.innerHTML = `
      <div class="loading-content">
        <p class="loading-error">Failed to load data</p>
        <p class="loading-hint">Run: <code>node scripts/build-site.js</code></p>
      </div>
    `;
    return;
  }

  // Hide loading, show dashboard with entrance animation
  const loading = document.getElementById('loading');
  const dashboard = document.getElementById('dashboard');
  loading.style.display = 'none';
  dashboard.style.display = '';
  requestAnimationFrame(() => dashboard.classList.add('animate-in'));

  // Populate quant options from actual data
  populateQuantOptions(appData.results);

  // Surface the dataset's last-updated time so users know data freshness.
  renderHeroMeta(appData);

  // Init filter dropdowns
  initFilters(appData.meta, () => render());

  // Wire theme toggle
  document.getElementById('theme-toggle')?.addEventListener('click', () => {
    const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);
    if (appData) render();
  });

  // Wire reset button
  const resetBtn = document.getElementById('filter-reset');
  if (resetBtn) {
    resetBtn.addEventListener('click', () => {
      resetFilters();
      render();
    });
  }

  // Wire metric selector for CPU vs GPU section
  const metricSelect = document.getElementById('cpu-gpu-metric');
  if (metricSelect) {
    metricSelect.addEventListener('change', () => render());
  }

  // Init section navigation
  initSectionNav();

  // Initial render
  render();
}

function render() {
  // Sync Chart.js defaults with current theme
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
  Chart.defaults.color = isDark ? '#a1a1aa' : '#71717a';
  Chart.defaults.plugins.tooltip.backgroundColor = isDark ? 'rgba(15,15,18,0.95)' : 'rgba(255,255,255,0.95)';
  Chart.defaults.plugins.tooltip.borderColor = isDark ? '#27272a' : '#e4e4e7';
  Chart.defaults.plugins.tooltip.titleColor = isDark ? '#e4e4e7' : '#09090b';
  Chart.defaults.plugins.tooltip.bodyColor = isDark ? '#a1a1aa' : '#71717a';

  const filters = getFilters();
  // Filter, attach CPU baseline values (folds CLI-flow CPU records onto
  // their GPU sibling so both submission paths produce one row per cell),
  // fold the (d=0, d=N) study pair into a single GPU row carrying both
  // depths, collapse to one canonical row per (machine, browser, model,
  // variant, backend), then drop the now-redundant CPU rows. CPU numbers
  // stay visible via the cpu_baseline_* columns on each GPU row.
  const filtered = selectBestResults(
    mergeDepthPairs(
      attachCpuBaselineFromCpuRecords(filterResults(appData.results, filters)),
    ),
  ).filter(r => r.nGpuLayers !== 0);

  // Summary cards — counts tween from previous value to new on filter changes
  // and from 0 on first paint (since `data-value` defaults to "0").
  const passed = filtered.filter(r => r.status === 'done');
  animateCount(document.getElementById('stat-machines'), appData.meta.machines.length, { decimals: 0 });
  animateCount(document.getElementById('stat-benchmarks'), filtered.length, { decimals: 0 });
  const passRate = filtered.length > 0 ? (passed.length / filtered.length) * 100 : 0;
  animateCount(document.getElementById('stat-pass-rate'), passRate, { decimals: 0 });

  const decodeVals = passed.map(r => r.decode_tok_s).filter(v => v != null);
  const bestDecode = decodeVals.length ? Math.max(...decodeVals) : 0;
  animateCount(document.getElementById('stat-best-decode'), bestDecode, { decimals: 1 });

  const sizes = passed.map(r => r.sizeMB).filter(v => v != null);
  const largest = sizes.length ? Math.max(...sizes) : 0;
  animateCount(document.getElementById('stat-largest'), largest, { decimals: 0 });

  // Results count
  const countEl = document.getElementById('results-count');
  if (countEl) {
    const total = appData.results.length;
    countEl.textContent = filtered.length === total
      ? `${total} total`
      : `${filtered.length} of ${total}`;
  }

  // Reset button — only present when at least one filter is active. Hiding
  // (rather than disabling) removes a permanent ghost button from the bar
  // and makes the appearance signal "you can undo your filter."
  const resetBtn = document.getElementById('filter-reset');
  if (resetBtn) {
    const activeCount = (filters.machine !== 'all' ? 1 : 0) + (filters.browser !== 'all' ? 1 : 0) +
      (filters.model !== 'all' ? 1 : 0) + (filters.backend !== 'all' ? 1 : 0) +
      (filters.status !== 'all' ? 1 : 0) + (filters.quants.size > 0 ? 1 : 0);
    resetBtn.disabled = activeCount === 0;
    resetBtn.hidden = activeCount === 0;
    const label = resetBtn.querySelector('.filter-reset-label') || resetBtn;
    if (label !== resetBtn) {
      label.textContent = activeCount ? `Reset (${activeCount})` : 'Reset';
    }
  }

  // Tables
  renderResultsTable(filtered);
  renderErrorTable(filtered);
  renderMachineInfo(appData.meta.machines);

  // Charts
  renderDecodeChart(filtered);
  renderPrefillChart(filtered);
  renderSizeChart(filtered);
  renderMachineChart(filtered, appData.meta.machines);

  // CPU vs GPU comparison
  const metric = document.getElementById('cpu-gpu-metric')?.value || 'decode_tok_s';
  renderCpuGpuSection(filtered, metric);
}

/* Consolidate the 3-part CPU-vs-GPU block (two charts + table). When there
   is no CPU baseline or no overlapping GPU data, render a single inline
   empty state and hide the charts+table so the user doesn't see the same
   message repeated three times. */
function renderCpuGpuSection(filtered, metric) {
  const chartsGrid = document.querySelector('#performance-section .charts-grid:nth-of-type(2)');
  const table = document.getElementById('cpu-gpu-table');
  const passed = filtered.filter(r => r.status === 'done');
  // Same expansion the chart/table renderers do — see expandCpuRows().
  const cpuResults = expandCpuRows(passed);
  const gpuResults = passed.filter(r => r.nGpuLayers !== 0);

  if (!chartsGrid || !table) {
    renderCpuGpuChart(filtered, metric);
    renderSpeedupChart(filtered, metric);
    renderCpuGpuTable(filtered);
    return;
  }

  if (cpuResults.length === 0 || gpuResults.length === 0) {
    chartsGrid.hidden = true;
    const reason = cpuResults.length === 0
      ? 'No CPU baseline in the current filter. Select "All Backends" or enable CPU baselines when benchmarking with <code>--consistency</code>.'
      : 'No WebGPU runs in the current filter. Adjust the Backend filter to include WebGPU.';
    table.innerHTML = `<div class="empty-state"><p>${reason}</p></div>`;
    return;
  }

  chartsGrid.hidden = false;
  renderCpuGpuChart(filtered, metric);
  renderSpeedupChart(filtered, metric);
  renderCpuGpuTable(filtered);
}

function renderHeroMeta(data) {
  const el = document.getElementById('hero-meta');
  const liveEl = document.getElementById('hero-live');
  const liveText = document.getElementById('hero-live-text');
  const generated = data?.meta?.generatedAt;
  const machineCount = data?.meta?.machines?.length || 0;
  const resultCount = data?.results?.length || 0;

  if (el) {
    const parts = [];
    if (machineCount > 0) parts.push(`${machineCount} machine${machineCount === 1 ? '' : 's'}`);
    if (resultCount > 0) parts.push(`${resultCount} benchmark${resultCount === 1 ? '' : 's'}`);
    if (generated) parts.push(`updated ${formatRelativeTime(new Date(generated))}`);
    if (parts.length > 0) {
      el.textContent = parts.join(' · ');
      el.hidden = false;
      if (generated) el.title = new Date(generated).toLocaleString();
    }
  }

  if (liveEl && liveText && generated) {
    liveText.textContent = `Live · ${formatRelativeTime(new Date(generated))}`;
    liveEl.hidden = false;
  }

  // Hero stat: top decode tok/s with machine + model context. Uses the
  // canonical set (best per cell) so a noisy 1-iteration outlier can't
  // hijack the headline number. Depth-merge first so a Study cell counts
  // once at its d=N number, not twice.
  const canonical = selectBestResults(mergeDepthPairs(data?.results || []));
  const passed = canonical.filter(r => r.status === 'done' && r.decode_tok_s != null);
  const heroStatEl = document.getElementById('hero-stat');
  const heroNumEl = document.getElementById('hero-top-decode');
  const heroMetaEl = document.getElementById('hero-top-meta');
  if (heroStatEl && heroNumEl && heroMetaEl && passed.length > 0) {
    const top = passed.reduce((a, b) => (a.decode_tok_s > b.decode_tok_s ? a : b));
    heroStatEl.hidden = false;
    heroMetaEl.textContent = `${top.machineSlug || top.machine || '—'} · ${top.model || ''} ${top.variant || ''}`.trim();
    animateCount(heroNumEl, top.decode_tok_s, { decimals: 1, duration: 800 });
  }
}

/* Tween numeric content from 0 to a target. CSS-only via @property would
   need server-side @property registration to work in older Safari; keep
   this 12-line JS tween for predictability. */
function animateCount(el, target, { decimals = 0, duration = 600 } = {}) {
  if (!el) return;
  const start = parseFloat(el.dataset.value || '0') || 0;
  const end = Number(target) || 0;
  if (start === end) {
    el.textContent = end.toFixed(decimals);
    return;
  }
  const startTime = performance.now();
  const ease = (t) => 1 - Math.pow(1 - t, 3);
  function step(now) {
    const t = Math.min(1, (now - startTime) / duration);
    const v = start + (end - start) * ease(t);
    el.textContent = v.toFixed(decimals);
    if (t < 1) requestAnimationFrame(step);
    else el.dataset.value = String(end);
  }
  requestAnimationFrame(step);
}

function formatRelativeTime(date) {
  const now = Date.now();
  const diff = Math.max(0, now - date.getTime());
  const min = 60_000, hr = 60 * min, day = 24 * hr;
  if (diff < min) return 'just now';
  if (diff < hr) return `${Math.floor(diff / min)} min ago`;
  if (diff < day) return `${Math.floor(diff / hr)} h ago`;
  const days = Math.floor(diff / day);
  if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
  return date.toISOString().slice(0, 10);
}

function initSectionNav() {
  const nav = document.getElementById('section-nav');
  if (!nav) return;

  const track = nav.querySelector('.section-nav-track');
  const buttons = nav.querySelectorAll('.section-nav-item');
  const sections = [];

  // Prefer the sticky wrapper height so the jumped-to section isn't
  // obscured by the sticky head.
  const stickyHead = document.querySelector('.sticky-head') || nav;

  buttons.forEach(btn => {
    const sectionId = btn.dataset.section;
    const section = document.getElementById(sectionId);
    if (section) sections.push({ btn, section });

    btn.addEventListener('click', (e) => {
      e.preventDefault();
      if (section) {
        const offset = stickyHead.offsetHeight + 8;
        const top = section.getBoundingClientRect().top + window.scrollY - offset;
        window.scrollTo({ top, behavior: 'smooth' });
      }
    });
  });

  // Drive the sliding indicator from the active button's geometry. Track
  // is the positioned ancestor; offsetLeft/offsetWidth are relative to it.
  const moveIndicator = (btn) => {
    if (!track || !btn) return;
    track.style.setProperty('--indicator-x', `${btn.offsetLeft}px`);
    track.style.setProperty('--indicator-w', `${btn.offsetWidth}px`);
  };

  // Scroll spy: instead of IntersectionObserver (which fires inconsistently
  // when multiple sections overlap the observer band), compute the
  // currently-active section on scroll by comparing each section's top to
  // the bottom of the sticky head. Cheaper and predictable.
  if (sections.length === 0) return;

  let ticking = false;
  const updateActive = () => {
    const anchor = stickyHead.offsetHeight + 16;
    let active = sections[0];
    for (const entry of sections) {
      const top = entry.section.getBoundingClientRect().top;
      if (top - anchor <= 0) active = entry;
      else break;
    }
    buttons.forEach(b => b.classList.toggle('active', b === active.btn));
    moveIndicator(active.btn);
  };
  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { updateActive(); ticking = false; });
  };
  window.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', onScroll);
  updateActive();
  // Re-measure once fonts settle — Bricolage Grotesque shifts widths.
  document.fonts?.ready?.then(() => updateActive()).catch(() => {});
}

init();