GitHub Actions commited on
Commit
4721a6e
·
1 Parent(s): cfcfe07

sync from abhijitramesh/webgpu-bench@9730846cc0

Browse files
README.md CHANGED
@@ -29,4 +29,4 @@ Per-variant: prefill tokens/sec, decode tokens/sec, wall clock, optional CPU-vs-
29
  - **Bonsai-1.7B Q1_0** needs `Q1_0` quantization support. The base (non-Q1_0) variant loads regardless.
30
 
31
  ## Privacy
32
- No data is sent anywhere unless you click **Submit to leaderboard dataset**, which pushes to the dataset configured in `bench-config.js`. Models and logs stay in your browser.
 
29
  - **Bonsai-1.7B Q1_0** needs `Q1_0` quantization support. The base (non-Q1_0) variant loads regardless.
30
 
31
  ## Privacy
32
+ No data is sent anywhere unless you click **Submit to leaderboard dataset**, which pushes to the dataset configured in `site/js/run/config.js` (`js/run/config.js` on the Space). Models and logs stay in your browser.
bench.css DELETED
@@ -1,217 +0,0 @@
1
- :root {
2
- color-scheme: dark;
3
- --bg: #0f1115;
4
- --bg-raised: #161922;
5
- --fg: #e6e8ec;
6
- --muted: #8892a0;
7
- --border: #252a33;
8
- --border-strong: #3a414d;
9
- --accent: #5eead4;
10
- --accent-fg: #0a0b0e;
11
- --warn: #f5a524;
12
- --err: #ef4444;
13
- --ok: #22c55e;
14
- }
15
-
16
- * { box-sizing: border-box; }
17
-
18
- body {
19
- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
20
- background: var(--bg);
21
- color: var(--fg);
22
- margin: 0;
23
- padding: 24px;
24
- max-width: 1200px;
25
- margin-inline: auto;
26
- font-size: 14px;
27
- line-height: 1.45;
28
- }
29
-
30
- h1 { font-size: 20px; margin: 0; font-weight: 600; }
31
- h2 { font-size: 14px; margin: 16px 0 8px; font-weight: 600; }
32
- .muted { color: var(--muted); font-size: 13px; }
33
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg-raised); padding: 1px 4px; border-radius: 3px; }
34
-
35
- header {
36
- border-bottom: 1px solid var(--border);
37
- padding-bottom: 16px;
38
- margin-bottom: 16px;
39
- }
40
- .title-row { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
41
- #device-line, #budget-line { font-size: 12px; }
42
-
43
- .mode-badge {
44
- display: inline-block;
45
- padding: 2px 8px;
46
- border: 1px solid var(--border-strong);
47
- border-radius: 4px;
48
- font-size: 11px;
49
- text-transform: uppercase;
50
- color: var(--accent);
51
- letter-spacing: 0.04em;
52
- }
53
-
54
- .filters {
55
- display: flex;
56
- gap: 14px;
57
- align-items: center;
58
- margin: 12px 0;
59
- font-size: 12px;
60
- color: var(--muted);
61
- flex-wrap: wrap;
62
- }
63
- .filters label { display: inline-flex; gap: 4px; align-items: center; cursor: pointer; }
64
- .filter-label { font-weight: 600; }
65
- .filter-hint { font-size: 11px; opacity: 0.7; }
66
-
67
- details.family {
68
- border: 1px solid var(--border);
69
- border-radius: 6px;
70
- margin-bottom: 8px;
71
- padding: 8px 12px;
72
- background: var(--bg-raised);
73
- }
74
- details.family > summary {
75
- font-weight: 600;
76
- cursor: pointer;
77
- list-style: none;
78
- display: flex;
79
- align-items: center;
80
- gap: 8px;
81
- }
82
- details.family > summary::marker { display: none; }
83
- details.family > summary::before { content: "▸ "; color: var(--muted); font-weight: 400; }
84
- details.family[open] > summary::before { content: "▾ "; }
85
- .family-stats { color: var(--muted); font-size: 12px; font-weight: 400; margin-left: 4px; }
86
- .family-warnings { color: var(--warn); font-size: 11px; margin-left: auto; }
87
-
88
- .variant-row {
89
- display: grid;
90
- grid-template-columns: 24px minmax(0, 1fr) 70px auto;
91
- gap: 10px;
92
- align-items: center;
93
- padding: 4px 4px;
94
- font-size: 13px;
95
- cursor: pointer;
96
- border-radius: 4px;
97
- }
98
- .variant-row:hover { background: rgba(94, 234, 212, 0.04); }
99
- .variant-row.non-fit { opacity: 0.4; }
100
- .variant-row .variant-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
101
- .variant-row .size { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
102
- .variant-row .badges { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
103
-
104
- .cache-badge {
105
- font-size: 10px;
106
- padding: 1px 6px;
107
- border-radius: 3px;
108
- background: rgba(34, 197, 94, 0.15);
109
- color: var(--ok);
110
- font-weight: 500;
111
- }
112
- .warn-badge {
113
- font-size: 10px;
114
- padding: 1px 6px;
115
- border-radius: 3px;
116
- background: rgba(245, 165, 36, 0.12);
117
- color: var(--warn);
118
- font-weight: 500;
119
- }
120
-
121
- .controls {
122
- display: flex;
123
- gap: 8px;
124
- align-items: center;
125
- margin: 20px 0;
126
- flex-wrap: wrap;
127
- }
128
-
129
- .iterations-control {
130
- display: inline-flex;
131
- align-items: center;
132
- gap: 6px;
133
- font-size: 12px;
134
- color: var(--muted);
135
- margin-right: 4px;
136
- }
137
- .iterations-control input[type="number"] {
138
- width: 56px;
139
- background: var(--bg-raised);
140
- color: var(--fg);
141
- border: 1px solid var(--border-strong);
142
- border-radius: 4px;
143
- padding: 4px 6px;
144
- font-family: inherit;
145
- font-size: 13px;
146
- }
147
-
148
- button {
149
- background: var(--accent);
150
- color: var(--accent-fg);
151
- border: none;
152
- padding: 8px 16px;
153
- border-radius: 4px;
154
- font-weight: 600;
155
- font-size: 13px;
156
- cursor: pointer;
157
- font-family: inherit;
158
- }
159
- button:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
160
- button:hover:not(:disabled) { filter: brightness(1.08); }
161
- button.secondary {
162
- background: transparent;
163
- color: var(--fg);
164
- border: 1px solid var(--border-strong);
165
- }
166
- button.danger { background: var(--err); color: white; }
167
- #queue-status { font-size: 12px; }
168
-
169
- #progress-table {
170
- width: 100%;
171
- border-collapse: collapse;
172
- font-size: 12px;
173
- font-variant-numeric: tabular-nums;
174
- }
175
- #progress-table th, #progress-table td {
176
- text-align: left;
177
- padding: 6px 8px;
178
- border-bottom: 1px solid var(--border);
179
- }
180
- #progress-table th.num, #progress-table td.num { text-align: right; }
181
- #progress-table tr.row-queued { color: var(--muted); }
182
- #progress-table tr.row-running { background: rgba(94, 234, 212, 0.06); }
183
- #progress-table tr.row-ok { color: var(--fg); }
184
- #progress-table tr.row-error { color: var(--err); }
185
-
186
- #output-panel { margin-top: 24px; }
187
- .output-actions { margin-bottom: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
188
- #output-textarea {
189
- width: 100%;
190
- height: 320px;
191
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
192
- font-size: 12px;
193
- background: var(--bg-raised);
194
- color: var(--fg);
195
- border: 1px solid var(--border);
196
- border-radius: 4px;
197
- padding: 10px;
198
- resize: vertical;
199
- }
200
- .output-buttons { margin-top: 8px; display: flex; gap: 8px; }
201
-
202
- #log-panel {
203
- margin-top: 24px;
204
- border-top: 1px solid var(--border);
205
- padding-top: 12px;
206
- }
207
- #log-panel summary { cursor: pointer; color: var(--muted); font-size: 12px; }
208
- #log-output {
209
- max-height: 280px;
210
- overflow-y: auto;
211
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
212
- font-size: 11px;
213
- color: var(--muted);
214
- margin: 8px 0 0;
215
- white-space: pre-wrap;
216
- word-break: break-word;
217
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bench.html DELETED
@@ -1,99 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <title>WebGPU Benchmark — One Click</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <link rel="stylesheet" href="bench.css">
8
- <!-- Import map so `@huggingface/hub` resolves in the browser via esm.sh.
9
- Must appear before any <script type="module">. -->
10
- <script type="importmap">
11
- {
12
- "imports": {
13
- "@huggingface/hub": "https://esm.sh/@huggingface/hub"
14
- }
15
- }
16
- </script>
17
- </head>
18
- <body>
19
- <header>
20
- <div class="title-row">
21
- <h1>WebGPU Benchmark <span class="muted">— One Click</span></h1>
22
- <span class="mode-badge" id="mode-badge" title="Detection source">…</span>
23
- </div>
24
- <div id="device-line" class="muted">Detecting device…</div>
25
- <div id="budget-line" class="muted"></div>
26
- </header>
27
-
28
- <section class="filters" aria-label="Variant visibility filters">
29
- <span class="filter-label">Hide:</span>
30
- <label><input type="checkbox" id="hide-ud"> UD</label>
31
- <label><input type="checkbox" id="hide-iq"> IQ</label>
32
- <label><input type="checkbox" id="hide-hifp"> BF16/F16</label>
33
- <span class="filter-hint">(visual only, does not uncheck)</span>
34
- </section>
35
-
36
- <section id="models-panel" aria-label="Models">
37
- <p id="models-loading" class="muted">Loading models…</p>
38
- </section>
39
-
40
- <section id="hub-section" class="output-actions" hidden aria-label="Hugging Face dataset">
41
- <button id="btn-signin" class="secondary">Sign in with Hugging Face</button>
42
- <button id="btn-submit" disabled>Submit to leaderboard dataset</button>
43
- <span id="hf-user" class="muted"></span>
44
- </section>
45
-
46
- <section class="controls">
47
- <label class="iterations-control">
48
- Iterations per variant:
49
- <input type="number" id="iterations-input" value="5" min="1" max="50" step="1">
50
- <span class="filter-hint">min 5 to submit</span>
51
- </label>
52
- <button id="btn-download" disabled>Download selected</button>
53
- <button id="btn-run" disabled>Run benchmarks</button>
54
- <button id="btn-abort" class="danger" disabled>Abort</button>
55
- <button id="btn-purge" class="secondary" hidden>Purge OPFS cache</button>
56
- <span id="queue-status" class="muted"></span>
57
- </section>
58
-
59
- <section id="progress-panel" hidden aria-label="Run progress">
60
- <h2>Progress</h2>
61
- <table id="progress-table">
62
- <thead>
63
- <tr>
64
- <th>Model</th>
65
- <th>Variant</th>
66
- <th>Status</th>
67
- <th class="num">Prefill tok/s</th>
68
- <th class="num">Decode tok/s</th>
69
- <th class="num">Wall s</th>
70
- <th>Error</th>
71
- </tr>
72
- </thead>
73
- <tbody></tbody>
74
- </table>
75
- </section>
76
-
77
- <section id="output-panel" hidden aria-label="Output">
78
- <h2>Output</h2>
79
- <div id="output-actions-local" class="output-actions">
80
- <label>
81
- <input type="checkbox" id="save-local" checked>
82
- Save to <code>results/results.json</code> on this server
83
- </label>
84
- </div>
85
- <textarea id="output-textarea" readonly spellcheck="false"></textarea>
86
- <div class="output-buttons">
87
- <button id="btn-copy" class="secondary">Copy</button>
88
- <button id="btn-download-json" class="secondary">Download JSON</button>
89
- </div>
90
- </section>
91
-
92
- <details id="log-panel">
93
- <summary>Run log</summary>
94
- <pre id="log-output"></pre>
95
- </details>
96
-
97
- <script type="module" src="bench-app.js"></script>
98
- </body>
99
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
css/style.css ADDED
@@ -0,0 +1,1242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================
2
+ WebGPU Bench Dashboard
3
+ Design: Precision Dark (shadcn-inspired)
4
+ ============================================================= */
5
+
6
+ /* === RESET === */
7
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+
9
+ /* === DESIGN TOKENS === */
10
+ :root {
11
+ /* Light theme (default) */
12
+ color-scheme: light;
13
+
14
+ --background: #ffffff;
15
+ --surface-0: #f4f4f5;
16
+ --surface-1: #fafafa;
17
+ --surface-2: #f0f0f1;
18
+ --surface-3: #e4e4e7;
19
+
20
+ --border: #e4e4e7;
21
+ --border-hover: #d4d4d8;
22
+ --ring: #a1a1aa;
23
+
24
+ --foreground: #09090b;
25
+ --foreground-secondary: #3f3f46;
26
+ --foreground-muted: #71717a;
27
+ --foreground-subtle: #a1a1aa;
28
+
29
+ --success: #16a34a;
30
+ --success-bg: rgba(22, 163, 74, 0.1);
31
+ --error: #dc2626;
32
+ --error-bg: rgba(220, 38, 38, 0.1);
33
+ --warning: #d97706;
34
+ --info: #2563eb;
35
+
36
+ /* Accent colors (theme-aware) */
37
+ --accent-blue: #2563eb;
38
+ --accent-blue-bg: rgba(37, 99, 235, 0.1);
39
+ --accent-purple: #7c3aed;
40
+ --accent-purple-bg: rgba(124, 58, 237, 0.1);
41
+ --accent-amber: #d97706;
42
+ --accent-amber-bg: rgba(217, 119, 6, 0.1);
43
+ --accent-violet: #6d28d9;
44
+ --accent-violet-bg: rgba(109, 40, 217, 0.1);
45
+
46
+ --shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.12);
47
+
48
+ /* Non-color tokens (same in both themes) */
49
+ --font-sans: 'Manrope', system-ui, -apple-system, sans-serif;
50
+ --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
51
+ --radius-sm: 6px;
52
+ --radius-md: 8px;
53
+ --radius-lg: 12px;
54
+ --transition-fast: 150ms ease;
55
+ --transition-base: 200ms ease;
56
+ }
57
+
58
+ [data-theme="dark"] {
59
+ color-scheme: dark;
60
+
61
+ --background: #09090b;
62
+ --surface-0: #0f0f12;
63
+ --surface-1: #18181b;
64
+ --surface-2: #1c1c20;
65
+ --surface-3: #27272a;
66
+
67
+ --border: #27272a;
68
+ --border-hover: #3f3f46;
69
+ --ring: #52525b;
70
+
71
+ --foreground: #fafafa;
72
+ --foreground-secondary: #d4d4d8;
73
+ --foreground-muted: #a1a1aa;
74
+ --foreground-subtle: #71717a;
75
+
76
+ --success: #22c55e;
77
+ --success-bg: rgba(34, 197, 94, 0.12);
78
+ --error: #ef4444;
79
+ --error-bg: rgba(239, 68, 68, 0.12);
80
+ --warning: #eab308;
81
+ --info: #3b82f6;
82
+
83
+ --accent-blue: #60a5fa;
84
+ --accent-blue-bg: rgba(59, 130, 246, 0.12);
85
+ --accent-purple: #a78bfa;
86
+ --accent-purple-bg: rgba(139, 92, 246, 0.12);
87
+ --accent-amber: #fbbf24;
88
+ --accent-amber-bg: rgba(245, 158, 11, 0.12);
89
+ --accent-violet: #c084fc;
90
+ --accent-violet-bg: rgba(168, 85, 247, 0.12);
91
+
92
+ --shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.4);
93
+ }
94
+
95
+ /* === GLOBAL === */
96
+ html { scroll-behavior: smooth; }
97
+
98
+ body {
99
+ background: var(--background);
100
+ color: var(--foreground);
101
+ font-family: var(--font-sans);
102
+ font-size: 14px;
103
+ line-height: 1.6;
104
+ -webkit-font-smoothing: antialiased;
105
+ -moz-osx-font-smoothing: grayscale;
106
+ }
107
+
108
+ a {
109
+ color: var(--info);
110
+ text-decoration: none;
111
+ transition: color var(--transition-fast);
112
+ }
113
+ a:hover { color: #60a5fa; }
114
+
115
+ /* === SCROLLBAR === */
116
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
117
+ ::-webkit-scrollbar-track { background: transparent; }
118
+ ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 4px; }
119
+ ::-webkit-scrollbar-thumb:hover { background: var(--ring); }
120
+
121
+ /* =============================================================
122
+ HEADER
123
+ ============================================================= */
124
+ .header {
125
+ border-bottom: 1px solid var(--border);
126
+ background: var(--background);
127
+ position: relative;
128
+ z-index: 50;
129
+ }
130
+ .header-inner {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ padding: 0 32px;
135
+ height: 56px;
136
+ }
137
+ .header-brand {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 10px;
141
+ color: var(--foreground);
142
+ text-decoration: none;
143
+ }
144
+ .header-brand:hover { color: var(--foreground); text-decoration: none; }
145
+ .header-logo { color: var(--foreground-muted); flex-shrink: 0; }
146
+ .header-title {
147
+ font-size: 15px;
148
+ font-weight: 700;
149
+ letter-spacing: -0.01em;
150
+ }
151
+ .header-nav {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 4px;
155
+ }
156
+ .header-link {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ padding: 6px 12px;
161
+ border-radius: var(--radius-sm);
162
+ font-size: 13px;
163
+ font-weight: 500;
164
+ color: var(--foreground-muted);
165
+ transition: color var(--transition-fast), background var(--transition-fast);
166
+ }
167
+ .header-link:hover {
168
+ color: var(--foreground);
169
+ background: var(--surface-2);
170
+ text-decoration: none;
171
+ }
172
+
173
+ /* =============================================================
174
+ LOADING STATE
175
+ ============================================================= */
176
+ .loading-state {
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ min-height: 60vh;
181
+ }
182
+ .loading-content { text-align: center; }
183
+ .loading-spinner {
184
+ width: 28px;
185
+ height: 28px;
186
+ margin: 0 auto 16px;
187
+ border: 2.5px solid var(--surface-3);
188
+ border-top-color: var(--foreground);
189
+ border-radius: 50%;
190
+ animation: spin 0.7s linear infinite;
191
+ }
192
+ @keyframes spin { to { transform: rotate(360deg); } }
193
+ .loading-state p {
194
+ color: var(--foreground-muted);
195
+ font-size: 14px;
196
+ }
197
+ .loading-error {
198
+ color: var(--error);
199
+ font-weight: 600;
200
+ margin-bottom: 8px;
201
+ }
202
+ .loading-hint {
203
+ color: var(--foreground-muted);
204
+ font-size: 13px;
205
+ }
206
+ .loading-hint code {
207
+ background: var(--surface-2);
208
+ padding: 2px 8px;
209
+ border-radius: var(--radius-sm);
210
+ font-family: var(--font-mono);
211
+ font-size: 12px;
212
+ }
213
+
214
+ /* =============================================================
215
+ FILTER BAR
216
+ ============================================================= */
217
+ .filter-bar {
218
+ background: var(--surface-0);
219
+ border-bottom: 1px solid var(--border);
220
+ padding: 16px 32px;
221
+ position: relative;
222
+ z-index: 45;
223
+ }
224
+ .filter-bar-inner {
225
+ display: flex;
226
+ align-items: flex-end;
227
+ gap: 16px;
228
+ flex-wrap: wrap;
229
+ }
230
+ .filter-group {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 6px;
234
+ }
235
+ .filter-label {
236
+ font-size: 11px;
237
+ font-weight: 600;
238
+ color: var(--foreground-subtle);
239
+ text-transform: uppercase;
240
+ letter-spacing: 0.05em;
241
+ }
242
+ .filter-select {
243
+ appearance: none;
244
+ background: var(--surface-1);
245
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
246
+ background-repeat: no-repeat;
247
+ background-position: right 10px center;
248
+ background-size: 12px;
249
+ border: 1px solid var(--border);
250
+ border-radius: var(--radius-md);
251
+ color: var(--foreground);
252
+ padding: 0 32px 0 12px;
253
+ font-family: var(--font-sans);
254
+ font-size: 13px;
255
+ font-weight: 500;
256
+ cursor: pointer;
257
+ height: 36px;
258
+ min-width: 150px;
259
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
260
+ }
261
+ .filter-select:hover { border-color: var(--border-hover); }
262
+ .filter-select:focus {
263
+ outline: none;
264
+ border-color: var(--ring);
265
+ box-shadow: 0 0 0 2px rgba(82, 82, 91, 0.3);
266
+ }
267
+ .filter-select option {
268
+ background: var(--surface-1);
269
+ color: var(--foreground);
270
+ }
271
+
272
+ [data-theme="dark"] .filter-select {
273
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
274
+ }
275
+
276
+ /* Theme toggle button */
277
+ .theme-toggle-btn { padding: 6px 10px; }
278
+ .icon-sun { display: none; }
279
+ .icon-moon { display: block; }
280
+ [data-theme="dark"] .icon-sun { display: block; }
281
+ [data-theme="dark"] .icon-moon { display: none; }
282
+
283
+ .filter-actions { display: flex; align-items: flex-end; }
284
+ .filter-reset-btn {
285
+ display: inline-flex;
286
+ align-items: center;
287
+ gap: 6px;
288
+ height: 36px;
289
+ padding: 0 14px;
290
+ background: transparent;
291
+ border: 1px solid var(--border);
292
+ border-radius: var(--radius-md);
293
+ color: var(--foreground-muted);
294
+ font-family: var(--font-sans);
295
+ font-size: 13px;
296
+ font-weight: 500;
297
+ cursor: pointer;
298
+ transition: all var(--transition-fast);
299
+ }
300
+ .filter-reset-btn:hover {
301
+ background: var(--surface-2);
302
+ color: var(--foreground);
303
+ border-color: var(--border-hover);
304
+ }
305
+
306
+ /* =============================================================
307
+ QUANT MULTI-SELECT DROPDOWN
308
+ ============================================================= */
309
+ .quant-dropdown { position: relative; }
310
+ .quant-dropdown-btn {
311
+ display: inline-flex;
312
+ align-items: center;
313
+ justify-content: space-between;
314
+ gap: 8px;
315
+ height: 36px;
316
+ min-width: 150px;
317
+ padding: 0 12px;
318
+ background: var(--surface-1);
319
+ border: 1px solid var(--border);
320
+ border-radius: var(--radius-md);
321
+ color: var(--foreground);
322
+ font-family: var(--font-sans);
323
+ font-size: 13px;
324
+ font-weight: 500;
325
+ cursor: pointer;
326
+ text-align: left;
327
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
328
+ }
329
+ .quant-dropdown-btn:hover { border-color: var(--border-hover); }
330
+ .quant-dropdown-btn:focus {
331
+ outline: none;
332
+ border-color: var(--ring);
333
+ box-shadow: 0 0 0 2px rgba(82, 82, 91, 0.3);
334
+ }
335
+ .quant-dropdown-btn svg {
336
+ flex-shrink: 0;
337
+ opacity: 0.6;
338
+ }
339
+
340
+ .quant-dropdown-list {
341
+ display: none;
342
+ position: absolute;
343
+ top: calc(100% + 4px);
344
+ left: 0;
345
+ z-index: 100;
346
+ background: var(--surface-1);
347
+ border: 1px solid var(--border);
348
+ border-radius: var(--radius-md);
349
+ box-shadow: var(--shadow-dropdown);
350
+ max-height: 320px;
351
+ overflow-y: auto;
352
+ min-width: 180px;
353
+ padding: 4px;
354
+ animation: dropdownIn 0.15s ease-out;
355
+ }
356
+ @keyframes dropdownIn {
357
+ from { opacity: 0; transform: translateY(-4px); }
358
+ to { opacity: 1; transform: translateY(0); }
359
+ }
360
+ .quant-dropdown.open .quant-dropdown-list { display: block; }
361
+
362
+ .quant-option {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ padding: 6px 10px;
367
+ cursor: pointer;
368
+ font-size: 13px;
369
+ border-radius: var(--radius-sm);
370
+ transition: background var(--transition-fast);
371
+ color: var(--foreground-secondary);
372
+ }
373
+ .quant-option:hover { background: var(--surface-2); }
374
+ .quant-option.select-all {
375
+ border-bottom: 1px solid var(--border);
376
+ margin-bottom: 4px;
377
+ padding-bottom: 8px;
378
+ border-radius: 0;
379
+ font-weight: 600;
380
+ color: var(--foreground);
381
+ }
382
+
383
+ /* Custom checkbox */
384
+ .quant-option input[type="checkbox"] {
385
+ appearance: none;
386
+ -webkit-appearance: none;
387
+ width: 16px;
388
+ height: 16px;
389
+ border: 1.5px solid var(--ring);
390
+ border-radius: 4px;
391
+ background: var(--surface-0);
392
+ cursor: pointer;
393
+ position: relative;
394
+ flex-shrink: 0;
395
+ transition: all var(--transition-fast);
396
+ }
397
+ .quant-option input[type="checkbox"]:checked {
398
+ background: var(--foreground);
399
+ border-color: var(--foreground);
400
+ }
401
+ .quant-option input[type="checkbox"]:checked::after {
402
+ content: '';
403
+ position: absolute;
404
+ top: 1px;
405
+ left: 4.5px;
406
+ width: 4.5px;
407
+ height: 8px;
408
+ border: solid var(--background);
409
+ border-width: 0 2px 2px 0;
410
+ transform: rotate(45deg);
411
+ }
412
+ .quant-option input[type="checkbox"]:indeterminate {
413
+ background: var(--foreground);
414
+ border-color: var(--foreground);
415
+ }
416
+ .quant-option input[type="checkbox"]:indeterminate::after {
417
+ content: '';
418
+ position: absolute;
419
+ top: 5.5px;
420
+ left: 3px;
421
+ width: 8px;
422
+ height: 2px;
423
+ background: var(--background);
424
+ border-radius: 1px;
425
+ }
426
+
427
+ /* =============================================================
428
+ SECTION NAVIGATION (Sticky Tabs)
429
+ ============================================================= */
430
+ .section-nav {
431
+ position: sticky;
432
+ top: 0;
433
+ z-index: 40;
434
+ background: var(--background);
435
+ border-bottom: 1px solid var(--border);
436
+ }
437
+ .section-nav-track {
438
+ display: flex;
439
+ padding: 0 32px;
440
+ overflow-x: auto;
441
+ scrollbar-width: none;
442
+ -webkit-overflow-scrolling: touch;
443
+ }
444
+ .section-nav-track::-webkit-scrollbar { display: none; }
445
+
446
+ .section-nav-item {
447
+ padding: 10px 16px;
448
+ background: none;
449
+ border: none;
450
+ border-bottom: 2px solid transparent;
451
+ color: var(--foreground-muted);
452
+ font-family: var(--font-sans);
453
+ font-size: 13px;
454
+ font-weight: 500;
455
+ cursor: pointer;
456
+ white-space: nowrap;
457
+ transition: color var(--transition-fast), border-color var(--transition-fast);
458
+ }
459
+ .section-nav-item:hover { color: var(--foreground); }
460
+ .section-nav-item.active {
461
+ color: var(--foreground);
462
+ border-bottom-color: var(--foreground);
463
+ }
464
+
465
+ /* =============================================================
466
+ SECTIONS & LAYOUT
467
+ ============================================================= */
468
+ .container { padding: 0 32px; }
469
+
470
+ .dash-section { padding: 32px 0; }
471
+ .dash-section + .dash-section { border-top: 1px solid var(--border); }
472
+
473
+ .section-header {
474
+ display: flex;
475
+ align-items: baseline;
476
+ justify-content: space-between;
477
+ margin-bottom: 20px;
478
+ }
479
+ .section-header h2 {
480
+ font-size: 16px;
481
+ font-weight: 700;
482
+ color: var(--foreground);
483
+ letter-spacing: -0.01em;
484
+ }
485
+ .results-count {
486
+ font-size: 13px;
487
+ color: var(--foreground-muted);
488
+ font-weight: 500;
489
+ font-family: var(--font-mono);
490
+ }
491
+
492
+ /* =============================================================
493
+ SUMMARY / STAT CARDS
494
+ ============================================================= */
495
+ .summary-grid {
496
+ display: grid;
497
+ grid-template-columns: repeat(3, 1fr);
498
+ gap: 16px;
499
+ }
500
+ .stat-card {
501
+ display: flex;
502
+ align-items: flex-start;
503
+ gap: 16px;
504
+ background: var(--surface-1);
505
+ border: 1px solid var(--border);
506
+ border-radius: var(--radius-lg);
507
+ padding: 20px 24px;
508
+ transition: border-color var(--transition-base);
509
+ }
510
+ .stat-card:hover { border-color: var(--border-hover); }
511
+
512
+ .stat-card-icon {
513
+ display: flex;
514
+ align-items: center;
515
+ justify-content: center;
516
+ width: 40px;
517
+ height: 40px;
518
+ border-radius: var(--radius-md);
519
+ flex-shrink: 0;
520
+ }
521
+ .stat-card-icon--machines { background: var(--accent-blue-bg); color: var(--accent-blue); }
522
+ .stat-card-icon--benchmarks { background: var(--accent-violet-bg); color: var(--accent-violet); }
523
+ .stat-card-icon--pass { background: var(--success-bg); color: var(--success); }
524
+
525
+ .stat-card-label {
526
+ display: block;
527
+ font-size: 12px;
528
+ font-weight: 500;
529
+ color: var(--foreground-muted);
530
+ margin-bottom: 2px;
531
+ }
532
+ .stat-card-value {
533
+ display: block;
534
+ font-size: 28px;
535
+ font-weight: 800;
536
+ font-family: var(--font-mono);
537
+ color: var(--foreground);
538
+ letter-spacing: -0.02em;
539
+ line-height: 1.1;
540
+ }
541
+
542
+ /* =============================================================
543
+ TABLES (Results + Error)
544
+ ============================================================= */
545
+ .table-card {
546
+ border: 1px solid var(--border);
547
+ border-radius: var(--radius-lg);
548
+ overflow: hidden;
549
+ }
550
+ .results-wrapper {
551
+ overflow: auto;
552
+ max-height: 600px;
553
+ }
554
+
555
+ .results-table,
556
+ .data-table {
557
+ width: 100%;
558
+ border-collapse: separate;
559
+ border-spacing: 0;
560
+ font-size: 13px;
561
+ }
562
+
563
+ /* Table headers */
564
+ .results-table thead th,
565
+ .data-table thead th {
566
+ position: sticky;
567
+ top: 0;
568
+ z-index: 10;
569
+ background: var(--surface-1);
570
+ text-align: left;
571
+ padding: 10px 12px;
572
+ color: var(--foreground-muted);
573
+ font-family: var(--font-sans);
574
+ font-weight: 600;
575
+ font-size: 11px;
576
+ text-transform: uppercase;
577
+ letter-spacing: 0.04em;
578
+ white-space: nowrap;
579
+ border-bottom: 1px solid var(--border);
580
+ user-select: none;
581
+ }
582
+
583
+ /* Sortable headers (results table only) */
584
+ .results-table thead th {
585
+ cursor: pointer;
586
+ transition: color var(--transition-fast), background var(--transition-fast);
587
+ }
588
+ .results-table thead th:hover {
589
+ color: var(--foreground);
590
+ background: var(--surface-2);
591
+ }
592
+ .results-table thead th.sorted {
593
+ color: var(--foreground);
594
+ }
595
+
596
+ /* Table cells */
597
+ .results-table td,
598
+ .data-table td {
599
+ padding: 8px 12px;
600
+ border-bottom: 1px solid var(--border);
601
+ white-space: nowrap;
602
+ color: var(--foreground-secondary);
603
+ }
604
+ .results-table tbody tr {
605
+ transition: background var(--transition-fast);
606
+ }
607
+ .results-table tbody tr:hover,
608
+ .data-table tbody tr:hover {
609
+ background: var(--surface-2);
610
+ }
611
+ .results-table tbody tr:last-child td,
612
+ .data-table tbody tr:last-child td {
613
+ border-bottom: none;
614
+ }
615
+
616
+ /* Row status indicator */
617
+ .row-pass td:first-child { box-shadow: inset 3px 0 0 var(--success); }
618
+ .row-fail td:first-child { box-shadow: inset 3px 0 0 var(--error); }
619
+
620
+ /* =============================================================
621
+ BADGES
622
+ ============================================================= */
623
+ .badge {
624
+ display: inline-flex;
625
+ align-items: center;
626
+ padding: 2px 8px;
627
+ border-radius: 9999px;
628
+ font-size: 11px;
629
+ font-weight: 600;
630
+ text-transform: uppercase;
631
+ letter-spacing: 0.02em;
632
+ }
633
+ .badge--pass { background: var(--success-bg); color: var(--success); }
634
+ .badge--fail { background: var(--error-bg); color: var(--error); }
635
+ .badge--yes { background: var(--accent-blue-bg); color: var(--accent-blue); }
636
+ .badge--no { background: var(--surface-3); color: var(--foreground-subtle); }
637
+ .badge--webgpu { background: var(--accent-purple-bg); color: var(--accent-purple); }
638
+ .badge--cpu { background: var(--accent-amber-bg); color: var(--accent-amber); }
639
+
640
+ /* =============================================================
641
+ ERROR CELLS & CATEGORIES
642
+ ============================================================= */
643
+ .error-cell {
644
+ color: var(--error);
645
+ font-size: 12px;
646
+ }
647
+ .error-cat {
648
+ display: inline-block;
649
+ padding: 1px 6px;
650
+ border-radius: 4px;
651
+ background: var(--error-bg);
652
+ color: var(--error);
653
+ font-size: 10px;
654
+ font-weight: 600;
655
+ text-transform: uppercase;
656
+ margin-right: 4px;
657
+ vertical-align: middle;
658
+ }
659
+
660
+ /* =============================================================
661
+ CHARTS
662
+ ============================================================= */
663
+ .charts-grid {
664
+ display: grid;
665
+ grid-template-columns: 1fr 1fr;
666
+ gap: 16px;
667
+ }
668
+ .chart-box {
669
+ background: var(--surface-1);
670
+ border: 1px solid var(--border);
671
+ border-radius: var(--radius-lg);
672
+ padding: 20px;
673
+ position: relative;
674
+ height: 340px;
675
+ transition: border-color var(--transition-base);
676
+ }
677
+ .chart-box:hover { border-color: var(--border-hover); }
678
+
679
+ .subsection-title { font-size: 16px; font-weight: 600; color: var(--foreground); margin: 0; }
680
+ .metric-selector { display: flex; align-items: center; gap: 8px; }
681
+ .metric-selector-label { font-size: 13px; color: var(--foreground-subtle); }
682
+ .col-sublabel { font-size: 10px; font-weight: 400; color: var(--foreground-subtle); }
683
+ .th-group { text-align: center; font-weight: 700; color: var(--foreground); background: var(--surface-2); }
684
+ .th-sub { font-size: 11px; font-weight: 500; color: var(--foreground-subtle); white-space: nowrap; }
685
+ .th-group-border { border-right: 1px solid var(--border-hover); }
686
+ .results-table td:has(+ .th-group-border) { border-right: 1px solid var(--border-hover); }
687
+
688
+ .chart-empty {
689
+ position: absolute;
690
+ top: 50%;
691
+ left: 50%;
692
+ transform: translate(-50%, -50%);
693
+ color: var(--foreground-subtle);
694
+ font-size: 13px;
695
+ }
696
+
697
+ /* =============================================================
698
+ MACHINE CARDS
699
+ ============================================================= */
700
+ .machine-grid {
701
+ display: grid;
702
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
703
+ gap: 16px;
704
+ }
705
+ .machine-card {
706
+ background: var(--surface-1);
707
+ border: 1px solid var(--border);
708
+ border-radius: var(--radius-lg);
709
+ padding: 20px 24px;
710
+ transition: border-color var(--transition-base);
711
+ }
712
+ .machine-card:hover { border-color: var(--border-hover); }
713
+
714
+ .machine-card-header {
715
+ display: flex;
716
+ align-items: center;
717
+ gap: 10px;
718
+ margin-bottom: 16px;
719
+ padding-bottom: 12px;
720
+ border-bottom: 1px solid var(--border);
721
+ }
722
+ .machine-card-header h3 {
723
+ font-size: 14px;
724
+ font-weight: 600;
725
+ color: var(--foreground);
726
+ line-height: 1.3;
727
+ }
728
+ .machine-card-icon { color: var(--foreground-muted); flex-shrink: 0; }
729
+
730
+ .machine-card-specs {
731
+ display: flex;
732
+ flex-direction: column;
733
+ gap: 8px;
734
+ }
735
+ .spec-row {
736
+ display: flex;
737
+ justify-content: space-between;
738
+ align-items: center;
739
+ font-size: 13px;
740
+ }
741
+ .spec-label { color: var(--foreground-muted); }
742
+ .spec-value { color: var(--foreground-secondary); font-weight: 500; font-family: var(--font-mono); font-size: 12px; }
743
+
744
+ /* =============================================================
745
+ EMPTY STATES
746
+ ============================================================= */
747
+ .empty-state {
748
+ padding: 48px 24px;
749
+ text-align: center;
750
+ color: var(--foreground-subtle);
751
+ font-size: 14px;
752
+ }
753
+
754
+ /* =============================================================
755
+ UTILITY CLASSES
756
+ ============================================================= */
757
+ .mono { font-family: var(--font-mono); font-size: 12px; }
758
+ .text-success { color: var(--success); }
759
+ .text-error { color: var(--error); }
760
+ .text-muted { color: var(--foreground-subtle); }
761
+
762
+ /* =============================================================
763
+ ENTRANCE ANIMATIONS
764
+ ============================================================= */
765
+ @keyframes fadeInUp {
766
+ from { opacity: 0; transform: translateY(10px); }
767
+ to { opacity: 1; transform: translateY(0); }
768
+ }
769
+
770
+ #dashboard.animate-in .filter-bar { animation: fadeInUp 0.35s ease-out both; }
771
+ #dashboard.animate-in .section-nav { animation: fadeInUp 0.35s ease-out 0.04s both; }
772
+ #dashboard.animate-in .dash-section:nth-of-type(1) { animation: fadeInUp 0.4s ease-out 0.08s both; }
773
+ #dashboard.animate-in .dash-section:nth-of-type(2) { animation: fadeInUp 0.4s ease-out 0.14s both; }
774
+ #dashboard.animate-in .dash-section:nth-of-type(3) { animation: fadeInUp 0.4s ease-out 0.20s both; }
775
+ #dashboard.animate-in .dash-section:nth-of-type(4) { animation: fadeInUp 0.4s ease-out 0.26s both; }
776
+ #dashboard.animate-in .dash-section:nth-of-type(5) { animation: fadeInUp 0.4s ease-out 0.32s both; }
777
+
778
+ /* =============================================================
779
+ RESPONSIVE
780
+ ============================================================= */
781
+ @media (max-width: 1024px) {
782
+ .charts-grid { grid-template-columns: 1fr; }
783
+ .summary-grid { grid-template-columns: 1fr 1fr; }
784
+ }
785
+
786
+ @media (max-width: 768px) {
787
+ .header-inner { padding: 0 16px; height: 48px; }
788
+ .header-title { font-size: 14px; }
789
+
790
+ .filter-bar { padding: 16px; }
791
+ .filter-bar-inner { flex-direction: column; align-items: stretch; gap: 12px; }
792
+ .filter-select { width: 100%; min-width: auto; }
793
+ .quant-dropdown-btn { width: 100%; }
794
+ .filter-actions { justify-content: flex-start; }
795
+
796
+ .section-nav-track { padding: 0 16px; }
797
+
798
+ .container { padding: 0 16px; }
799
+ .section-header { margin-bottom: 16px; }
800
+ .table-card { border-radius: var(--radius-md); }
801
+ .results-wrapper { max-height: 450px; }
802
+
803
+ .summary-grid { grid-template-columns: 1fr; gap: 12px; }
804
+ .stat-card { padding: 16px 20px; }
805
+ .stat-card-value { font-size: 24px; }
806
+
807
+ .charts-grid { gap: 12px; }
808
+ .chart-box { height: 280px; padding: 16px; }
809
+
810
+ .machine-grid { grid-template-columns: 1fr; }
811
+
812
+ .dash-section { padding: 24px 0; }
813
+ }
814
+
815
+ @media (max-width: 480px) {
816
+ .stat-card { flex-direction: column; gap: 10px; }
817
+ .stat-card-icon { width: 32px; height: 32px; }
818
+ .stat-card-icon svg { width: 16px; height: 16px; }
819
+ }
820
+
821
+ /* =============================================================
822
+ METHODOLOGY PAGE
823
+ ============================================================= */
824
+ .methodology-content {
825
+ max-width: 800px;
826
+ margin: 0 auto;
827
+ padding: 40px 32px 80px;
828
+ }
829
+ .methodology-content .back-link {
830
+ display: inline-flex;
831
+ align-items: center;
832
+ gap: 6px;
833
+ margin-bottom: 32px;
834
+ font-size: 13px;
835
+ font-weight: 500;
836
+ color: var(--foreground-muted);
837
+ transition: color var(--transition-fast);
838
+ }
839
+ .methodology-content .back-link:hover { color: var(--foreground); text-decoration: none; }
840
+
841
+ .methodology-content h2 {
842
+ margin-top: 48px;
843
+ margin-bottom: 16px;
844
+ font-size: 20px;
845
+ font-weight: 700;
846
+ color: var(--foreground);
847
+ letter-spacing: -0.01em;
848
+ }
849
+ .methodology-content h2:first-of-type { margin-top: 0; }
850
+
851
+ .methodology-content h3 {
852
+ margin-top: 28px;
853
+ margin-bottom: 8px;
854
+ font-size: 15px;
855
+ font-weight: 600;
856
+ color: var(--foreground-muted);
857
+ }
858
+ .methodology-content p,
859
+ .methodology-content li {
860
+ line-height: 1.8;
861
+ margin-bottom: 8px;
862
+ color: var(--foreground-secondary);
863
+ font-size: 14px;
864
+ }
865
+ .methodology-content ol,
866
+ .methodology-content ul {
867
+ padding-left: 24px;
868
+ }
869
+ .methodology-content strong {
870
+ color: var(--foreground);
871
+ font-weight: 600;
872
+ }
873
+ .methodology-content code {
874
+ background: var(--surface-2);
875
+ padding: 2px 8px;
876
+ border-radius: var(--radius-sm);
877
+ font-family: var(--font-mono);
878
+ font-size: 12px;
879
+ color: var(--foreground-secondary);
880
+ }
881
+ .methodology-content table {
882
+ width: 100%;
883
+ margin: 16px 0;
884
+ border-collapse: separate;
885
+ border-spacing: 0;
886
+ border: 1px solid var(--border);
887
+ border-radius: var(--radius-md);
888
+ overflow: hidden;
889
+ }
890
+ .methodology-content th {
891
+ text-align: left;
892
+ padding: 10px 16px;
893
+ background: var(--surface-1);
894
+ color: var(--foreground-muted);
895
+ font-size: 11px;
896
+ font-weight: 600;
897
+ text-transform: uppercase;
898
+ letter-spacing: 0.04em;
899
+ border-bottom: 1px solid var(--border);
900
+ }
901
+ .methodology-content td {
902
+ padding: 10px 16px;
903
+ border-bottom: 1px solid var(--border);
904
+ font-size: 13px;
905
+ color: var(--foreground-secondary);
906
+ }
907
+ .methodology-content tbody tr:last-child td { border-bottom: none; }
908
+ .methodology-content tbody tr:hover { background: var(--surface-2); }
909
+
910
+ /* =============================================================
911
+ RUN TAB (one-click benchmark)
912
+ ============================================================= */
913
+ /* Global [hidden] override — the attribute's UA default (display:none) loses
914
+ to any component rule that sets display explicitly (e.g. .btn → inline-flex,
915
+ .run-pages-banner → flex). Restore the invariant with one rule. */
916
+ [hidden] { display: none !important; }
917
+
918
+ .card {
919
+ background: var(--surface-1);
920
+ border: 1px solid var(--border);
921
+ border-radius: var(--radius-lg);
922
+ padding: 16px 20px;
923
+ }
924
+
925
+ /* Shared button primitives. Stay consistent with .filter-reset-btn height. */
926
+ .btn {
927
+ display: inline-flex;
928
+ align-items: center;
929
+ gap: 6px;
930
+ height: 36px;
931
+ padding: 0 14px;
932
+ border-radius: var(--radius-md);
933
+ border: 1px solid transparent;
934
+ font-family: var(--font-sans);
935
+ font-size: 13px;
936
+ font-weight: 600;
937
+ cursor: pointer;
938
+ transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast);
939
+ white-space: nowrap;
940
+ }
941
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
942
+
943
+ .btn-primary {
944
+ background: var(--foreground);
945
+ color: var(--background);
946
+ }
947
+ .btn-primary:hover:not(:disabled) { background: var(--foreground-secondary); }
948
+
949
+ .btn-secondary {
950
+ background: transparent;
951
+ color: var(--foreground-secondary);
952
+ border-color: var(--border);
953
+ }
954
+ .btn-secondary:hover:not(:disabled) {
955
+ background: var(--surface-2);
956
+ color: var(--foreground);
957
+ border-color: var(--border-hover);
958
+ }
959
+
960
+ .btn-danger {
961
+ background: var(--error-bg);
962
+ color: var(--error);
963
+ border-color: var(--error);
964
+ }
965
+ .btn-danger:hover:not(:disabled) { background: var(--error); color: var(--background); }
966
+
967
+ /* Run-mode badge colored by surface. */
968
+ .run-mode-badge {
969
+ text-transform: lowercase;
970
+ letter-spacing: 0.04em;
971
+ font-weight: 600;
972
+ }
973
+ .run-mode-localhost { background: var(--accent-blue-bg); color: var(--accent-blue); }
974
+ .run-mode-space { background: var(--accent-purple-bg); color: var(--accent-purple); }
975
+ .run-mode-pages { background: var(--surface-3); color: var(--foreground-subtle); }
976
+ .run-mode-file { background: var(--accent-amber-bg); color: var(--accent-amber); }
977
+
978
+ /* Pages-mode read-only banner. */
979
+ .run-pages-banner {
980
+ display: flex;
981
+ align-items: center;
982
+ gap: 12px;
983
+ margin-bottom: 20px;
984
+ padding: 12px 16px;
985
+ background: var(--accent-blue-bg);
986
+ color: var(--foreground-secondary);
987
+ border: 1px solid var(--border);
988
+ border-radius: var(--radius-md);
989
+ font-size: 13px;
990
+ }
991
+ .run-pages-banner a { font-weight: 600; }
992
+
993
+ /* HF hub sign-in/submit row (space surface only). */
994
+ .hub-row { margin-bottom: 20px; }
995
+ .hub-row-inner {
996
+ display: flex;
997
+ align-items: center;
998
+ justify-content: space-between;
999
+ gap: 16px;
1000
+ flex-wrap: wrap;
1001
+ }
1002
+ .hub-row-info {
1003
+ display: flex;
1004
+ align-items: center;
1005
+ gap: 8px;
1006
+ color: var(--foreground-muted);
1007
+ font-size: 13px;
1008
+ font-family: var(--font-mono);
1009
+ }
1010
+ .hub-row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
1011
+
1012
+ /* Device / budget stat cards (reuses .summary-grid + .stat-card shells). */
1013
+ .run-device-grid {
1014
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1015
+ margin-bottom: 24px;
1016
+ }
1017
+ .run-device-card { flex-direction: column; align-items: stretch; gap: 10px; }
1018
+ .run-device-card .stat-card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
1019
+ .run-device-rows { display: flex; flex-direction: column; gap: 4px; }
1020
+ .run-device-row {
1021
+ display: flex;
1022
+ justify-content: space-between;
1023
+ font-size: 13px;
1024
+ font-family: var(--font-mono);
1025
+ }
1026
+ .run-device-row-label { color: var(--foreground-muted); }
1027
+ .run-device-row-value { color: var(--foreground); font-weight: 600; }
1028
+ .run-device-note { font-size: 11px; color: var(--foreground-subtle); margin-top: 2px; }
1029
+
1030
+ /* Hide filters + iterations + action buttons — stacks with .filter-bar tokens. */
1031
+ .run-filters { align-items: center; }
1032
+ .run-filters-checks {
1033
+ display: flex;
1034
+ align-items: center;
1035
+ gap: 12px;
1036
+ flex-wrap: wrap;
1037
+ }
1038
+ .run-hide-label {
1039
+ display: inline-flex;
1040
+ align-items: center;
1041
+ gap: 6px;
1042
+ font-size: 13px;
1043
+ color: var(--foreground-secondary);
1044
+ cursor: pointer;
1045
+ }
1046
+ .run-hide-label input[type="checkbox"] { accent-color: var(--foreground); }
1047
+ .run-iter-input {
1048
+ width: 72px;
1049
+ min-width: 0;
1050
+ padding: 0 10px;
1051
+ background: var(--surface-1);
1052
+ background-image: none;
1053
+ }
1054
+ .run-actions {
1055
+ display: flex;
1056
+ gap: 8px;
1057
+ align-items: center;
1058
+ flex-wrap: wrap;
1059
+ margin-left: auto;
1060
+ }
1061
+ #queue-status { font-size: 12px; color: var(--foreground-muted); font-family: var(--font-mono); }
1062
+
1063
+ /* Family cards + variant rows. */
1064
+ .run-models-stack {
1065
+ display: flex;
1066
+ flex-direction: column;
1067
+ gap: 12px;
1068
+ margin-bottom: 24px;
1069
+ }
1070
+ .run-family { padding: 0; overflow: hidden; }
1071
+ .run-family > summary {
1072
+ list-style: none;
1073
+ cursor: pointer;
1074
+ display: flex;
1075
+ align-items: center;
1076
+ gap: 10px;
1077
+ padding: 12px 16px;
1078
+ background: var(--surface-1);
1079
+ border-bottom: 1px solid transparent;
1080
+ transition: background var(--transition-fast), border-color var(--transition-fast);
1081
+ }
1082
+ .run-family[open] > summary {
1083
+ border-bottom-color: var(--border);
1084
+ background: var(--surface-2);
1085
+ }
1086
+ .run-family > summary::-webkit-details-marker { display: none; }
1087
+ .run-family-chevron {
1088
+ display: inline-block;
1089
+ width: 10px;
1090
+ height: 10px;
1091
+ border-right: 1.5px solid var(--foreground-muted);
1092
+ border-bottom: 1.5px solid var(--foreground-muted);
1093
+ transform: rotate(-45deg);
1094
+ transition: transform var(--transition-fast);
1095
+ flex-shrink: 0;
1096
+ }
1097
+ .run-family[open] > summary .run-family-chevron { transform: rotate(45deg); }
1098
+ .run-family-select-all { margin: 0; accent-color: var(--foreground); cursor: pointer; }
1099
+ .run-family-name { font-size: 14px; font-weight: 700; color: var(--foreground); }
1100
+ .run-family-stats {
1101
+ color: var(--foreground-muted);
1102
+ font-size: 12px;
1103
+ font-family: var(--font-mono);
1104
+ }
1105
+ .run-family-warning {
1106
+ margin-left: auto;
1107
+ color: var(--accent-amber);
1108
+ font-size: 11px;
1109
+ font-weight: 600;
1110
+ }
1111
+
1112
+ .run-variant-list {
1113
+ display: flex;
1114
+ flex-direction: column;
1115
+ }
1116
+ .run-variant-row {
1117
+ display: grid;
1118
+ grid-template-columns: 20px minmax(60px, max-content) minmax(0, 1fr) 80px auto;
1119
+ gap: 12px;
1120
+ align-items: center;
1121
+ padding: 8px 16px;
1122
+ font-size: 13px;
1123
+ cursor: pointer;
1124
+ border-bottom: 1px solid var(--border);
1125
+ transition: background var(--transition-fast);
1126
+ }
1127
+ .run-variant-row:last-child { border-bottom: none; }
1128
+ .run-variant-row:hover { background: var(--surface-2); }
1129
+ .run-variant-row.is-non-fit { opacity: 0.45; }
1130
+ .run-variant-select { margin: 0; accent-color: var(--foreground); cursor: pointer; }
1131
+ .run-variant-quant {
1132
+ font-weight: 700;
1133
+ color: var(--foreground);
1134
+ font-family: var(--font-mono);
1135
+ font-size: 12px;
1136
+ }
1137
+ .run-variant-file {
1138
+ background: transparent;
1139
+ padding: 0;
1140
+ font-size: 12px;
1141
+ color: var(--foreground-muted);
1142
+ overflow: hidden;
1143
+ text-overflow: ellipsis;
1144
+ white-space: nowrap;
1145
+ }
1146
+ .run-variant-size {
1147
+ color: var(--foreground-muted);
1148
+ font-variant-numeric: tabular-nums;
1149
+ font-family: var(--font-mono);
1150
+ font-size: 12px;
1151
+ text-align: right;
1152
+ }
1153
+ .run-variant-badges {
1154
+ display: inline-flex;
1155
+ gap: 4px;
1156
+ flex-wrap: wrap;
1157
+ justify-content: flex-end;
1158
+ }
1159
+
1160
+ .badge--cached { background: var(--success-bg); color: var(--success); }
1161
+ .badge--warn { background: var(--accent-amber-bg); color: var(--accent-amber); }
1162
+
1163
+ /* Progress table — piggybacks on .results-table styling. */
1164
+ .run-progress-table { font-variant-numeric: tabular-nums; }
1165
+ .run-progress-table th.num,
1166
+ .run-progress-table td.num { text-align: right; }
1167
+ .run-row-queued { color: var(--foreground-subtle); }
1168
+ .run-row-running td { background: var(--accent-blue-bg); }
1169
+ .run-row-ok { color: var(--foreground-secondary); }
1170
+ .run-row-error td { color: var(--error); }
1171
+ .run-row-error td.err { font-family: var(--font-mono); font-size: 12px; }
1172
+
1173
+ /* Output block. */
1174
+ .run-output { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
1175
+ .run-output-toggle {
1176
+ display: inline-flex;
1177
+ align-items: center;
1178
+ gap: 8px;
1179
+ font-size: 13px;
1180
+ color: var(--foreground-secondary);
1181
+ cursor: pointer;
1182
+ }
1183
+ .run-output-toggle input[type="checkbox"] { accent-color: var(--foreground); }
1184
+ .run-output-textarea {
1185
+ width: 100%;
1186
+ min-height: 320px;
1187
+ padding: 12px;
1188
+ background: var(--surface-0);
1189
+ color: var(--foreground);
1190
+ border: 1px solid var(--border);
1191
+ border-radius: var(--radius-md);
1192
+ font-family: var(--font-mono);
1193
+ font-size: 12px;
1194
+ line-height: 1.6;
1195
+ resize: vertical;
1196
+ }
1197
+ .run-output-textarea:focus {
1198
+ outline: none;
1199
+ border-color: var(--ring);
1200
+ box-shadow: 0 0 0 2px rgba(82, 82, 91, 0.3);
1201
+ }
1202
+ .run-output-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
1203
+
1204
+ /* Log panel — collapsible details inside a .card. */
1205
+ .run-log { padding: 0; }
1206
+ .run-log > summary {
1207
+ list-style: none;
1208
+ cursor: pointer;
1209
+ padding: 12px 16px;
1210
+ color: var(--foreground-muted);
1211
+ font-size: 13px;
1212
+ font-weight: 500;
1213
+ transition: color var(--transition-fast);
1214
+ }
1215
+ .run-log > summary::-webkit-details-marker { display: none; }
1216
+ .run-log > summary:hover { color: var(--foreground); }
1217
+ .run-log[open] > summary { border-bottom: 1px solid var(--border); }
1218
+ .run-log-pre {
1219
+ max-height: 300px;
1220
+ overflow-y: auto;
1221
+ margin: 0;
1222
+ padding: 12px 16px;
1223
+ background: var(--surface-0);
1224
+ color: var(--foreground-muted);
1225
+ font-family: var(--font-mono);
1226
+ font-size: 11px;
1227
+ line-height: 1.6;
1228
+ white-space: pre-wrap;
1229
+ word-break: break-word;
1230
+ }
1231
+
1232
+ @media (max-width: 768px) {
1233
+ .run-actions { margin-left: 0; width: 100%; }
1234
+ .run-variant-row {
1235
+ grid-template-columns: 20px minmax(50px, max-content) minmax(0, 1fr);
1236
+ row-gap: 4px;
1237
+ }
1238
+ .run-variant-size { grid-column: 2; text-align: left; }
1239
+ .run-variant-badges { grid-column: 3; justify-content: flex-start; }
1240
+ .hub-row-inner { flex-direction: column; align-items: stretch; }
1241
+ .hub-row-actions { justify-content: flex-start; }
1242
+ }
harness.js CHANGED
@@ -1,9 +1,9 @@
1
  // Thin adapter: reads URL params, calls runBenchmarkCore(), writes to
2
  // window.__BENCH so runner.js (Playwright) can poll. Inference logic lives
3
- // in bench-core.js, shared with the interactive bench-app.js page.
4
 
5
- import { runBenchmarkCore } from './bench-core.js';
6
- import { localSource } from './bench-source.js';
7
 
8
  // Global error handlers — catch Emscripten abort() which may not throw.
9
  window.addEventListener('error', (e) => {
 
1
  // Thin adapter: reads URL params, calls runBenchmarkCore(), writes to
2
  // window.__BENCH so runner.js (Playwright) can poll. Inference logic lives
3
+ // in site/js/run/core.js, shared with the interactive Run-tab controller.
4
 
5
+ import { runBenchmarkCore } from './js/run/core.js';
6
+ import { localSource } from './js/run/source.js';
7
 
8
  // Global error handlers — catch Emscripten abort() which may not throw.
9
  window.addEventListener('error', (e) => {
index.html CHANGED
@@ -1,99 +1,202 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
  <head>
4
- <meta charset="utf-8">
5
- <title>WebGPU Benchmark One Click</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <link rel="stylesheet" href="bench.css">
8
- <!-- Import map so `@huggingface/hub` resolves in the browser via esm.sh.
9
- Must appear before any <script type="module">. -->
10
- <script type="importmap">
11
- {
12
- "imports": {
13
- "@huggingface/hub": "https://esm.sh/@huggingface/hub"
14
- }
15
- }
16
- </script>
17
  </head>
18
  <body>
19
- <header>
20
- <div class="title-row">
21
- <h1>WebGPU Benchmark <span class="muted">— One Click</span></h1>
22
- <span class="mode-badge" id="mode-badge" title="Detection source"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  </div>
24
- <div id="device-line" class="muted">Detecting device…</div>
25
- <div id="budget-line" class="muted"></div>
26
  </header>
27
 
28
- <section class="filters" aria-label="Variant visibility filters">
29
- <span class="filter-label">Hide:</span>
30
- <label><input type="checkbox" id="hide-ud"> UD</label>
31
- <label><input type="checkbox" id="hide-iq"> IQ</label>
32
- <label><input type="checkbox" id="hide-hifp"> BF16/F16</label>
33
- <span class="filter-hint">(visual only, does not uncheck)</span>
34
- </section>
35
 
36
- <section id="models-panel" aria-label="Models">
37
- <p id="models-loading" class="muted">Loading models…</p>
38
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- <section id="hub-section" class="output-actions" hidden aria-label="Hugging Face dataset">
41
- <button id="btn-signin" class="secondary">Sign in with Hugging Face</button>
42
- <button id="btn-submit" disabled>Submit to leaderboard dataset</button>
43
- <span id="hf-user" class="muted"></span>
44
- </section>
 
 
 
 
 
45
 
46
- <section class="controls">
47
- <label class="iterations-control">
48
- Iterations per variant:
49
- <input type="number" id="iterations-input" value="5" min="1" max="50" step="1">
50
- <span class="filter-hint">min 5 to submit</span>
51
- </label>
52
- <button id="btn-download" disabled>Download selected</button>
53
- <button id="btn-run" disabled>Run benchmarks</button>
54
- <button id="btn-abort" class="danger" disabled>Abort</button>
55
- <button id="btn-purge" class="secondary" hidden>Purge OPFS cache</button>
56
- <span id="queue-status" class="muted"></span>
57
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- <section id="progress-panel" hidden aria-label="Run progress">
60
- <h2>Progress</h2>
61
- <table id="progress-table">
62
- <thead>
63
- <tr>
64
- <th>Model</th>
65
- <th>Variant</th>
66
- <th>Status</th>
67
- <th class="num">Prefill tok/s</th>
68
- <th class="num">Decode tok/s</th>
69
- <th class="num">Wall s</th>
70
- <th>Error</th>
71
- </tr>
72
- </thead>
73
- <tbody></tbody>
74
- </table>
75
- </section>
76
 
77
- <section id="output-panel" hidden aria-label="Output">
78
- <h2>Output</h2>
79
- <div id="output-actions-local" class="output-actions">
80
- <label>
81
- <input type="checkbox" id="save-local" checked>
82
- Save to <code>results/results.json</code> on this server
83
- </label>
84
- </div>
85
- <textarea id="output-textarea" readonly spellcheck="false"></textarea>
86
- <div class="output-buttons">
87
- <button id="btn-copy" class="secondary">Copy</button>
88
- <button id="btn-download-json" class="secondary">Download JSON</button>
89
- </div>
90
- </section>
91
 
92
- <details id="log-panel">
93
- <summary>Run log</summary>
94
- <pre id="log-output"></pre>
95
- </details>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- <script type="module" src="bench-app.js"></script>
98
  </body>
99
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="color-scheme" content="light dark">
7
+ <script>(function(){var t=localStorage.getItem('theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
8
+ <title>WebGPU Bench</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="css/style.css">
13
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
 
 
 
14
  </head>
15
  <body>
16
+ <header class="header">
17
+ <div class="header-inner">
18
+ <a href="/" class="header-brand">
19
+ <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
20
+ <span class="header-title">WebGPU Bench</span>
21
+ </a>
22
+ <nav class="header-nav">
23
+ <a href="run/" class="header-link">Run</a>
24
+ <a href="methodology.html" class="header-link">Methodology</a>
25
+ <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
26
+ <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
27
+ <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
28
+ </button>
29
+ <a href="https://github.com/abhijitramesh/webgpu-bench" target="_blank" rel="noopener" class="header-link">
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.39.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.82.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
31
+ GitHub
32
+ </a>
33
+ </nav>
34
  </div>
 
 
35
  </header>
36
 
37
+ <main>
38
+ <div id="loading" class="loading-state">
39
+ <div class="loading-content">
40
+ <div class="loading-spinner"></div>
41
+ <p>Loading benchmark data...</p>
42
+ </div>
43
+ </div>
44
 
45
+ <div id="dashboard" style="display: none;">
46
+ <!-- Filter Bar -->
47
+ <div class="filter-bar">
48
+ <div class="filter-bar-inner">
49
+ <div class="filter-group">
50
+ <label class="filter-label" for="filter-machine">Machine</label>
51
+ <select id="filter-machine" class="filter-select"></select>
52
+ </div>
53
+ <div class="filter-group">
54
+ <label class="filter-label" for="filter-browser">Browser</label>
55
+ <select id="filter-browser" class="filter-select"></select>
56
+ </div>
57
+ <div class="filter-group">
58
+ <label class="filter-label" for="filter-model">Model</label>
59
+ <select id="filter-model" class="filter-select"></select>
60
+ </div>
61
+ <div class="filter-group">
62
+ <label class="filter-label" for="filter-backend">Backend</label>
63
+ <select id="filter-backend" class="filter-select"></select>
64
+ </div>
65
+ <div class="filter-group">
66
+ <label class="filter-label" for="filter-status">Status</label>
67
+ <select id="filter-status" class="filter-select"></select>
68
+ </div>
69
+ <div class="filter-group">
70
+ <label class="filter-label">Quantization</label>
71
+ <div class="quant-dropdown" id="quant-dropdown">
72
+ <button class="quant-dropdown-btn" id="quant-dropdown-btn" type="button">
73
+ <span id="quant-dropdown-text">All Quants</span>
74
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
75
+ </button>
76
+ <div class="quant-dropdown-list" id="quant-options"></div>
77
+ </div>
78
+ </div>
79
+ <div class="filter-actions">
80
+ <button class="filter-reset-btn" id="filter-reset" type="button" style="display: none;" title="Reset all filters">
81
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
82
+ Reset
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
 
88
+ <!-- Section Navigation -->
89
+ <nav class="section-nav" id="section-nav">
90
+ <div class="section-nav-track">
91
+ <button class="section-nav-item active" data-section="overview">Overview</button>
92
+ <button class="section-nav-item" data-section="results-section">Results</button>
93
+ <button class="section-nav-item" data-section="performance-section">Performance</button>
94
+ <button class="section-nav-item" data-section="errors-section">Errors</button>
95
+ <button class="section-nav-item" data-section="machines-section">Machines</button>
96
+ </div>
97
+ </nav>
98
 
99
+ <!-- Overview -->
100
+ <section id="overview" class="dash-section">
101
+ <div class="container">
102
+ <div class="summary-grid">
103
+ <div class="stat-card">
104
+ <div class="stat-card-icon stat-card-icon--machines">
105
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
106
+ </div>
107
+ <div class="stat-card-content">
108
+ <span class="stat-card-label">Machines Tested</span>
109
+ <span class="stat-card-value" id="stat-machines">0</span>
110
+ </div>
111
+ </div>
112
+ <div class="stat-card">
113
+ <div class="stat-card-icon stat-card-icon--benchmarks">
114
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
115
+ </div>
116
+ <div class="stat-card-content">
117
+ <span class="stat-card-label">Benchmarks</span>
118
+ <span class="stat-card-value" id="stat-benchmarks">0</span>
119
+ </div>
120
+ </div>
121
+ <div class="stat-card">
122
+ <div class="stat-card-icon stat-card-icon--pass">
123
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
124
+ </div>
125
+ <div class="stat-card-content">
126
+ <span class="stat-card-label">Pass Rate</span>
127
+ <span class="stat-card-value" id="stat-pass-rate">0%</span>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </section>
133
 
134
+ <!-- Results -->
135
+ <section id="results-section" class="dash-section">
136
+ <div class="container">
137
+ <div class="section-header">
138
+ <h2>Results</h2>
139
+ <span class="results-count" id="results-count"></span>
140
+ </div>
141
+ <div class="table-card">
142
+ <div class="results-wrapper" id="results-table"></div>
143
+ </div>
144
+ </div>
145
+ </section>
 
 
 
 
 
146
 
147
+ <!-- Performance Charts -->
148
+ <section id="performance-section" class="dash-section">
149
+ <div class="container">
150
+ <div class="section-header">
151
+ <h2>Performance</h2>
152
+ </div>
153
+ <div class="charts-grid">
154
+ <div class="chart-box"><canvas id="chart-decode"></canvas></div>
155
+ <div class="chart-box"><canvas id="chart-prefill"></canvas></div>
156
+ <div class="chart-box"><canvas id="chart-size"></canvas></div>
157
+ <div class="chart-box" id="machine-chart-section"><canvas id="chart-machine"></canvas></div>
158
+ </div>
 
 
159
 
160
+ <div class="section-header" style="margin-top: 32px;">
161
+ <h3 class="subsection-title">CPU vs WebGPU</h3>
162
+ <div class="metric-selector">
163
+ <span class="metric-selector-label">Metric</span>
164
+ <select id="cpu-gpu-metric" class="filter-select">
165
+ <option value="decode_tok_s">Decode tok/s</option>
166
+ <option value="prefill_tok_s">Prefill tok/s</option>
167
+ </select>
168
+ </div>
169
+ </div>
170
+ <div class="charts-grid">
171
+ <div class="chart-box"><canvas id="chart-cpu-gpu"></canvas></div>
172
+ <div class="chart-box"><canvas id="chart-speedup"></canvas></div>
173
+ </div>
174
+ <div id="cpu-gpu-table" style="margin-top: 16px;"></div>
175
+ </div>
176
+ </section>
177
+
178
+ <!-- Error Analysis -->
179
+ <section id="errors-section" class="dash-section">
180
+ <div class="container">
181
+ <div class="section-header">
182
+ <h2>Error Analysis</h2>
183
+ </div>
184
+ <div id="error-table"></div>
185
+ </div>
186
+ </section>
187
+
188
+ <!-- Machines -->
189
+ <section id="machines-section" class="dash-section">
190
+ <div class="container">
191
+ <div class="section-header">
192
+ <h2>Machines</h2>
193
+ </div>
194
+ <div id="machine-info"></div>
195
+ </div>
196
+ </section>
197
+ </div>
198
+ </main>
199
 
200
+ <script type="module" src="js/app.js"></script>
201
  </body>
202
  </html>
js/app.js ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { loadData, filterResults } from './data.js';
2
+ import { initFilters, populateQuantOptions, getFilters, resetFilters } from './filters.js';
3
+ import { renderDecodeChart, renderPrefillChart, renderSizeChart, renderMachineChart, renderCpuGpuChart, renderSpeedupChart } from './charts.js';
4
+ import { renderResultsTable, renderErrorTable, renderMachineInfo, renderCpuGpuTable } from './tables.js';
5
+
6
+ let appData = null;
7
+
8
+ async function init() {
9
+ try {
10
+ appData = await loadData();
11
+ } catch (e) {
12
+ document.getElementById('loading').innerHTML = `
13
+ <div class="loading-content">
14
+ <p class="loading-error">Failed to load data</p>
15
+ <p class="loading-hint">Run: <code>node scripts/build-site.js</code></p>
16
+ </div>
17
+ `;
18
+ return;
19
+ }
20
+
21
+ // Hide loading, show dashboard with entrance animation
22
+ const loading = document.getElementById('loading');
23
+ const dashboard = document.getElementById('dashboard');
24
+ loading.style.display = 'none';
25
+ dashboard.style.display = '';
26
+ requestAnimationFrame(() => dashboard.classList.add('animate-in'));
27
+
28
+ // Populate quant options from actual data
29
+ populateQuantOptions(appData.results);
30
+
31
+ // Init filter dropdowns
32
+ initFilters(appData.meta, () => render());
33
+
34
+ // Wire theme toggle
35
+ document.getElementById('theme-toggle')?.addEventListener('click', () => {
36
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
37
+ document.documentElement.setAttribute('data-theme', next);
38
+ localStorage.setItem('theme', next);
39
+ if (appData) render();
40
+ });
41
+
42
+ // Wire reset button
43
+ const resetBtn = document.getElementById('filter-reset');
44
+ if (resetBtn) {
45
+ resetBtn.addEventListener('click', () => {
46
+ resetFilters();
47
+ render();
48
+ });
49
+ }
50
+
51
+ // Wire metric selector for CPU vs GPU section
52
+ const metricSelect = document.getElementById('cpu-gpu-metric');
53
+ if (metricSelect) {
54
+ metricSelect.addEventListener('change', () => render());
55
+ }
56
+
57
+ // Init section navigation
58
+ initSectionNav();
59
+
60
+ // Initial render
61
+ render();
62
+ }
63
+
64
+ function render() {
65
+ // Sync Chart.js defaults with current theme
66
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
67
+ Chart.defaults.color = isDark ? '#a1a1aa' : '#71717a';
68
+ Chart.defaults.plugins.tooltip.backgroundColor = isDark ? 'rgba(15,15,18,0.95)' : 'rgba(255,255,255,0.95)';
69
+ Chart.defaults.plugins.tooltip.borderColor = isDark ? '#27272a' : '#e4e4e7';
70
+ Chart.defaults.plugins.tooltip.titleColor = isDark ? '#e4e4e7' : '#09090b';
71
+ Chart.defaults.plugins.tooltip.bodyColor = isDark ? '#a1a1aa' : '#71717a';
72
+
73
+ const filters = getFilters();
74
+ const filtered = filterResults(appData.results, filters);
75
+
76
+ // Summary cards
77
+ const passed = filtered.filter(r => r.status === 'done');
78
+ document.getElementById('stat-machines').textContent = appData.meta.machines.length;
79
+ document.getElementById('stat-benchmarks').textContent = filtered.length;
80
+ const passRate = filtered.length > 0 ? ((passed.length / filtered.length) * 100).toFixed(0) : '0';
81
+ document.getElementById('stat-pass-rate').textContent = `${passRate}%`;
82
+
83
+ // Results count
84
+ const countEl = document.getElementById('results-count');
85
+ if (countEl) {
86
+ const total = appData.results.length;
87
+ countEl.textContent = filtered.length === total
88
+ ? `${total} total`
89
+ : `${filtered.length} of ${total}`;
90
+ }
91
+
92
+ // Reset button visibility
93
+ const resetBtn = document.getElementById('filter-reset');
94
+ if (resetBtn) {
95
+ const active = filters.machine !== 'all' || filters.browser !== 'all' ||
96
+ filters.model !== 'all' || filters.backend !== 'all' ||
97
+ filters.status !== 'all' || filters.quants.size > 0;
98
+ resetBtn.style.display = active ? '' : 'none';
99
+ }
100
+
101
+ // Tables
102
+ renderResultsTable(filtered);
103
+ renderErrorTable(filtered);
104
+ renderMachineInfo(appData.meta.machines);
105
+
106
+ // Charts
107
+ renderDecodeChart(filtered);
108
+ renderPrefillChart(filtered);
109
+ renderSizeChart(filtered);
110
+ renderMachineChart(filtered, appData.meta.machines);
111
+
112
+ // CPU vs GPU comparison
113
+ const metric = document.getElementById('cpu-gpu-metric')?.value || 'decode_tok_s';
114
+ renderCpuGpuChart(filtered, metric);
115
+ renderSpeedupChart(filtered, metric);
116
+ renderCpuGpuTable(filtered);
117
+ }
118
+
119
+ function initSectionNav() {
120
+ const nav = document.getElementById('section-nav');
121
+ if (!nav) return;
122
+
123
+ const buttons = nav.querySelectorAll('.section-nav-item');
124
+ const sections = [];
125
+
126
+ buttons.forEach(btn => {
127
+ const sectionId = btn.dataset.section;
128
+ const section = document.getElementById(sectionId);
129
+ if (section) sections.push({ btn, section });
130
+
131
+ btn.addEventListener('click', (e) => {
132
+ e.preventDefault();
133
+ if (section) {
134
+ const navHeight = nav.offsetHeight;
135
+ const top = section.getBoundingClientRect().top + window.scrollY - navHeight;
136
+ window.scrollTo({ top, behavior: 'smooth' });
137
+ }
138
+ });
139
+ });
140
+
141
+ // Scroll spy with IntersectionObserver
142
+ if (sections.length === 0) return;
143
+
144
+ const observer = new IntersectionObserver(
145
+ (entries) => {
146
+ for (const entry of entries) {
147
+ if (entry.isIntersecting) {
148
+ const id = entry.target.id;
149
+ buttons.forEach(b => b.classList.toggle('active', b.dataset.section === id));
150
+ }
151
+ }
152
+ },
153
+ { rootMargin: '-20% 0px -60% 0px' }
154
+ );
155
+
156
+ sections.forEach(({ section }) => observer.observe(section));
157
+ }
158
+
159
+ init();
js/charts.js ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BROWSER_COLORS, quantSortKey, groupBy, formatTokS } from './utils.js';
2
+
3
+ // Global Chart.js theme
4
+ Chart.defaults.font.family = "'Manrope', system-ui, -apple-system, sans-serif";
5
+ Chart.defaults.color = '#a1a1aa';
6
+ Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(15, 15, 18, 0.95)';
7
+ Chart.defaults.plugins.tooltip.borderColor = '#27272a';
8
+ Chart.defaults.plugins.tooltip.borderWidth = 1;
9
+ Chart.defaults.plugins.tooltip.cornerRadius = 8;
10
+ Chart.defaults.plugins.tooltip.padding = { top: 8, bottom: 8, left: 12, right: 12 };
11
+ Chart.defaults.plugins.tooltip.titleFont = { weight: '600', size: 13 };
12
+ Chart.defaults.plugins.tooltip.bodyFont = { family: "'JetBrains Mono', monospace", size: 12 };
13
+ Chart.defaults.plugins.legend.labels.boxWidth = 12;
14
+ Chart.defaults.plugins.legend.labels.boxHeight = 12;
15
+ Chart.defaults.plugins.legend.labels.padding = 16;
16
+
17
+ const chartInstances = new Map();
18
+
19
+ function destroyChart(id) {
20
+ if (chartInstances.has(id)) {
21
+ chartInstances.get(id).destroy();
22
+ chartInstances.delete(id);
23
+ }
24
+ }
25
+
26
+ function themeColors() {
27
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark';
28
+ return {
29
+ grid: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
30
+ text: dark ? '#a1a1aa' : '#71717a',
31
+ title: dark ? '#e4e4e7' : '#09090b',
32
+ };
33
+ }
34
+
35
+ function darkScales(xTitle, yTitle) {
36
+ const c = themeColors();
37
+ return {
38
+ x: {
39
+ ticks: { color: c.text },
40
+ grid: { color: c.grid },
41
+ title: xTitle ? { display: true, text: xTitle, color: c.text } : undefined,
42
+ },
43
+ y: {
44
+ ticks: { color: c.text },
45
+ grid: { color: c.grid },
46
+ title: yTitle ? { display: true, text: yTitle, color: c.text } : undefined,
47
+ beginAtZero: true,
48
+ },
49
+ };
50
+ }
51
+
52
+ function darkLegend() {
53
+ const c = themeColors();
54
+ return { labels: { color: c.text, usePointStyle: true, pointStyle: 'circle' } };
55
+ }
56
+
57
+ function titleConfig(text) {
58
+ const c = themeColors();
59
+ return { display: true, text, color: c.title, font: { size: 14, weight: '600' } };
60
+ }
61
+
62
+ export function renderDecodeChart(results) {
63
+ const canvasId = 'chart-decode';
64
+ destroyChart(canvasId);
65
+ const canvas = document.getElementById(canvasId);
66
+ if (!canvas) return;
67
+
68
+ const passed = results.filter(r => r.status === 'done' && r.decode_tok_s != null);
69
+ if (passed.length === 0) {
70
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
71
+ const msg = document.createElement('div');
72
+ msg.className = 'chart-empty';
73
+ msg.textContent = 'No data';
74
+ canvas.parentElement.appendChild(msg);
75
+ return;
76
+ }
77
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
78
+
79
+ const byBrowser = groupBy(passed, 'browser');
80
+ const allQuants = [...new Set(passed.map(r => r.variant))].sort((a, b) => quantSortKey(a) - quantSortKey(b));
81
+
82
+ const datasets = Object.entries(byBrowser).map(([browser, items]) => {
83
+ const byQuant = groupBy(items, 'variant');
84
+ return {
85
+ label: browser,
86
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
87
+ data: allQuants.map(q => {
88
+ const group = byQuant[q];
89
+ if (!group) return null;
90
+ const vals = group.map(r => r.decode_tok_s).filter(v => v != null);
91
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
92
+ }),
93
+ };
94
+ });
95
+
96
+ chartInstances.set(canvasId, new Chart(canvas, {
97
+ type: 'bar',
98
+ data: { labels: allQuants, datasets },
99
+ options: {
100
+ responsive: true,
101
+ maintainAspectRatio: false,
102
+ plugins: {
103
+ title: titleConfig('Decode Throughput by Quantization'),
104
+ legend: darkLegend(),
105
+ tooltip: {
106
+ callbacks: { label: ctx => `${ctx.dataset.label}: ${formatTokS(ctx.raw)} tok/s` },
107
+ },
108
+ },
109
+ scales: darkScales('Quantization', 'Decode tok/s'),
110
+ },
111
+ }));
112
+ }
113
+
114
+ export function renderPrefillChart(results) {
115
+ const canvasId = 'chart-prefill';
116
+ destroyChart(canvasId);
117
+ const canvas = document.getElementById(canvasId);
118
+ if (!canvas) return;
119
+
120
+ const passed = results.filter(r => r.status === 'done' && r.prefill_tok_s != null);
121
+ if (passed.length === 0) {
122
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
123
+ const msg = document.createElement('div');
124
+ msg.className = 'chart-empty';
125
+ msg.textContent = 'No data';
126
+ canvas.parentElement.appendChild(msg);
127
+ return;
128
+ }
129
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
130
+
131
+ const byBrowser = groupBy(passed, 'browser');
132
+ const allQuants = [...new Set(passed.map(r => r.variant))].sort((a, b) => quantSortKey(a) - quantSortKey(b));
133
+
134
+ const datasets = Object.entries(byBrowser).map(([browser, items]) => {
135
+ const byQuant = groupBy(items, 'variant');
136
+ return {
137
+ label: browser,
138
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
139
+ data: allQuants.map(q => {
140
+ const group = byQuant[q];
141
+ if (!group) return null;
142
+ const vals = group.map(r => r.prefill_tok_s).filter(v => v != null);
143
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
144
+ }),
145
+ };
146
+ });
147
+
148
+ chartInstances.set(canvasId, new Chart(canvas, {
149
+ type: 'bar',
150
+ data: { labels: allQuants, datasets },
151
+ options: {
152
+ responsive: true,
153
+ maintainAspectRatio: false,
154
+ plugins: {
155
+ title: titleConfig('Prefill Throughput by Quantization'),
156
+ legend: darkLegend(),
157
+ tooltip: {
158
+ callbacks: { label: ctx => `${ctx.dataset.label}: ${formatTokS(ctx.raw)} tok/s` },
159
+ },
160
+ },
161
+ scales: darkScales('Quantization', 'Prefill tok/s'),
162
+ },
163
+ }));
164
+ }
165
+
166
+ export function renderSizeChart(results) {
167
+ const canvasId = 'chart-size';
168
+ destroyChart(canvasId);
169
+ const canvas = document.getElementById(canvasId);
170
+ if (!canvas) return;
171
+
172
+ const passed = results.filter(r => r.status === 'done' && r.decode_tok_s != null && r.sizeMB);
173
+ if (passed.length === 0) {
174
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
175
+ const msg = document.createElement('div');
176
+ msg.className = 'chart-empty';
177
+ msg.textContent = 'No data';
178
+ canvas.parentElement.appendChild(msg);
179
+ return;
180
+ }
181
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
182
+
183
+ const byBrowser = groupBy(passed, 'browser');
184
+
185
+ const datasets = Object.entries(byBrowser).map(([browser, items]) => {
186
+ const sorted = [...items].sort((a, b) => a.sizeMB - b.sizeMB);
187
+ return {
188
+ label: browser,
189
+ borderColor: BROWSER_COLORS[browser] || '#888',
190
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
191
+ data: sorted.map(r => ({ x: r.sizeMB, y: r.decode_tok_s })),
192
+ showLine: true,
193
+ pointRadius: 4,
194
+ tension: 0.2,
195
+ };
196
+ });
197
+
198
+ chartInstances.set(canvasId, new Chart(canvas, {
199
+ type: 'scatter',
200
+ data: { datasets },
201
+ options: {
202
+ responsive: true,
203
+ maintainAspectRatio: false,
204
+ plugins: {
205
+ title: titleConfig('Throughput vs Model Size'),
206
+ legend: darkLegend(),
207
+ tooltip: {
208
+ callbacks: {
209
+ label: ctx => `${ctx.dataset.label}: ${ctx.parsed.x}MB \u2192 ${formatTokS(ctx.parsed.y)} tok/s`,
210
+ },
211
+ },
212
+ },
213
+ scales: darkScales('Model Size (MB)', 'Decode tok/s'),
214
+ },
215
+ }));
216
+ }
217
+
218
+ const CPU_COLOR = 'rgba(245, 158, 11, 0.75)';
219
+
220
+ function showEmptyState(canvas, msg) {
221
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
222
+ const el = document.createElement('div');
223
+ el.className = 'chart-empty';
224
+ el.textContent = msg || 'No data';
225
+ canvas.parentElement.appendChild(el);
226
+ }
227
+
228
+ function clearEmptyState(canvas) {
229
+ canvas.parentElement.querySelector('.chart-empty')?.remove();
230
+ }
231
+
232
+ const METRIC_LABELS = {
233
+ decode_tok_s: 'Decode tok/s',
234
+ prefill_tok_s: 'Prefill tok/s',
235
+ };
236
+
237
+ function avgBy(items, field) {
238
+ const vals = items.map(r => r[field]).filter(v => v != null);
239
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
240
+ }
241
+
242
+ export function renderCpuGpuChart(results, metric = 'decode_tok_s') {
243
+ const canvasId = 'chart-cpu-gpu';
244
+ destroyChart(canvasId);
245
+ const canvas = document.getElementById(canvasId);
246
+ if (!canvas) return;
247
+
248
+ const passed = results.filter(r => r.status === 'done' && r[metric] != null);
249
+ const cpuResults = passed.filter(r => r.nGpuLayers === 0);
250
+ const gpuResults = passed.filter(r => r.nGpuLayers !== 0);
251
+
252
+ if (cpuResults.length === 0 || gpuResults.length === 0) {
253
+ showEmptyState(canvas, cpuResults.length === 0 ? 'No CPU baseline data in current filter' : 'No GPU data in current filter');
254
+ return;
255
+ }
256
+ clearEmptyState(canvas);
257
+
258
+ const cpuVariants = new Set(cpuResults.map(r => r.variant));
259
+ const allQuants = [...new Set(gpuResults.map(r => r.variant))]
260
+ .filter(q => cpuVariants.has(q))
261
+ .sort((a, b) => quantSortKey(a) - quantSortKey(b));
262
+
263
+ if (allQuants.length === 0) {
264
+ showEmptyState(canvas, 'No overlapping variants between CPU and GPU');
265
+ return;
266
+ }
267
+
268
+ const cpuByVariant = groupBy(cpuResults, 'variant');
269
+ const cpuData = allQuants.map(q => avgBy(cpuByVariant[q] || [], metric));
270
+
271
+ const gpuByBrowser = groupBy(gpuResults, 'browser');
272
+ const gpuDatasets = Object.entries(gpuByBrowser).map(([browser, items]) => {
273
+ const byVariant = groupBy(items, 'variant');
274
+ return {
275
+ label: browser,
276
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
277
+ data: allQuants.map(q => avgBy(byVariant[q] || [], metric)),
278
+ };
279
+ });
280
+
281
+ const metricLabel = METRIC_LABELS[metric] || metric;
282
+ chartInstances.set(canvasId, new Chart(canvas, {
283
+ type: 'bar',
284
+ data: { labels: allQuants, datasets: [{ label: 'CPU', backgroundColor: CPU_COLOR, data: cpuData }, ...gpuDatasets] },
285
+ options: {
286
+ responsive: true,
287
+ maintainAspectRatio: false,
288
+ plugins: {
289
+ title: titleConfig(`CPU vs WebGPU: ${metricLabel}`),
290
+ legend: darkLegend(),
291
+ tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatTokS(ctx.raw)} tok/s` } },
292
+ },
293
+ scales: darkScales('Quantization', metricLabel),
294
+ },
295
+ }));
296
+ }
297
+
298
+ export function renderSpeedupChart(results, metric = 'decode_tok_s') {
299
+ const canvasId = 'chart-speedup';
300
+ destroyChart(canvasId);
301
+ const canvas = document.getElementById(canvasId);
302
+ if (!canvas) return;
303
+
304
+ const passed = results.filter(r => r.status === 'done' && r[metric] != null);
305
+ const cpuResults = passed.filter(r => r.nGpuLayers === 0);
306
+ const gpuResults = passed.filter(r => r.nGpuLayers !== 0);
307
+
308
+ if (cpuResults.length === 0 || gpuResults.length === 0) {
309
+ showEmptyState(canvas, cpuResults.length === 0 ? 'No CPU baseline data in current filter' : 'No GPU data in current filter');
310
+ return;
311
+ }
312
+ clearEmptyState(canvas);
313
+
314
+ const cpuAvgByVariant = {};
315
+ for (const [q, items] of Object.entries(groupBy(cpuResults, 'variant'))) {
316
+ cpuAvgByVariant[q] = avgBy(items, metric);
317
+ }
318
+
319
+ const allQuants = [...new Set(gpuResults.map(r => r.variant))]
320
+ .filter(q => cpuAvgByVariant[q] != null)
321
+ .sort((a, b) => quantSortKey(a) - quantSortKey(b));
322
+
323
+ if (allQuants.length === 0) {
324
+ showEmptyState(canvas, 'No overlapping variants between CPU and GPU');
325
+ return;
326
+ }
327
+
328
+ const gpuByBrowser = groupBy(gpuResults, 'browser');
329
+ const barDatasets = Object.entries(gpuByBrowser).map(([browser, items]) => {
330
+ const byVariant = groupBy(items, 'variant');
331
+ return {
332
+ label: browser,
333
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
334
+ data: allQuants.map(q => {
335
+ const cpuAvg = cpuAvgByVariant[q];
336
+ const gpuAvg = avgBy(byVariant[q] || [], metric);
337
+ return cpuAvg && gpuAvg ? gpuAvg / cpuAvg : null;
338
+ }),
339
+ };
340
+ });
341
+
342
+ const refLine = {
343
+ label: '1\u00d7',
344
+ type: 'line',
345
+ data: allQuants.map(() => 1),
346
+ borderColor: 'rgba(255,255,255,0.3)',
347
+ borderDash: [4, 4],
348
+ borderWidth: 1.5,
349
+ pointRadius: 0,
350
+ fill: false,
351
+ order: -1,
352
+ };
353
+
354
+ const metricLabel = METRIC_LABELS[metric] || metric;
355
+ chartInstances.set(canvasId, new Chart(canvas, {
356
+ type: 'bar',
357
+ data: { labels: allQuants, datasets: [...barDatasets, refLine] },
358
+ options: {
359
+ responsive: true,
360
+ maintainAspectRatio: false,
361
+ plugins: {
362
+ title: titleConfig(`WebGPU Speedup over CPU (${metricLabel})`),
363
+ legend: {
364
+ ...darkLegend(),
365
+ labels: { ...darkLegend().labels, filter: item => item.text !== '1\u00d7' },
366
+ },
367
+ tooltip: {
368
+ filter: item => item.dataset.label !== '1\u00d7',
369
+ callbacks: { label: ctx => `${ctx.dataset.label}: ${ctx.raw != null ? ctx.raw.toFixed(2) + '\u00d7' : '\u2014'}` },
370
+ },
371
+ },
372
+ scales: {
373
+ x: { ticks: { color: themeColors().text }, grid: { color: themeColors().grid }, title: { display: true, text: 'Quantization', color: themeColors().text } },
374
+ y: {
375
+ ticks: { color: themeColors().text, callback: v => `${v.toFixed(1)}\u00d7` },
376
+ grid: { color: themeColors().grid },
377
+ title: { display: true, text: 'Speedup (\u00d7)', color: themeColors().text },
378
+ beginAtZero: true,
379
+ },
380
+ },
381
+ },
382
+ }));
383
+ }
384
+
385
+ export function renderMachineChart(results, machines) {
386
+ const canvasId = 'chart-machine';
387
+ destroyChart(canvasId);
388
+ const canvas = document.getElementById(canvasId);
389
+ const container = document.getElementById('machine-chart-section');
390
+ if (!canvas || !container) return;
391
+
392
+ if (machines.length <= 1) {
393
+ container.style.display = 'none';
394
+ return;
395
+ }
396
+ container.style.display = '';
397
+
398
+ const passed = results.filter(r => r.status === 'done' && r.decode_tok_s != null);
399
+ const quantCounts = {};
400
+ for (const r of passed) quantCounts[r.variant] = (quantCounts[r.variant] || 0) + 1;
401
+ const targetQuant = quantCounts['Q4_K_M'] ? 'Q4_K_M' : Object.keys(quantCounts).sort((a, b) => quantCounts[b] - quantCounts[a])[0];
402
+
403
+ if (!targetQuant) {
404
+ container.style.display = 'none';
405
+ return;
406
+ }
407
+
408
+ const forQuant = passed.filter(r => r.variant === targetQuant);
409
+ const byMachine = groupBy(forQuant, 'machineSlug');
410
+ const machineLabels = Object.keys(byMachine);
411
+ const browsers = [...new Set(forQuant.map(r => r.browser))].sort();
412
+
413
+ const datasets = browsers.map(browser => ({
414
+ label: browser,
415
+ backgroundColor: BROWSER_COLORS[browser] || '#888',
416
+ data: machineLabels.map(slug => {
417
+ const items = byMachine[slug].filter(r => r.browser === browser);
418
+ if (!items.length) return null;
419
+ return items.reduce((s, r) => s + r.decode_tok_s, 0) / items.length;
420
+ }),
421
+ }));
422
+
423
+ chartInstances.set(canvasId, new Chart(canvas, {
424
+ type: 'bar',
425
+ data: { labels: machineLabels, datasets },
426
+ options: {
427
+ indexAxis: 'y',
428
+ responsive: true,
429
+ maintainAspectRatio: false,
430
+ plugins: {
431
+ title: titleConfig(`Machine Comparison (${targetQuant})`),
432
+ legend: darkLegend(),
433
+ tooltip: {
434
+ callbacks: { label: ctx => `${ctx.dataset.label}: ${formatTokS(ctx.raw)} tok/s` },
435
+ },
436
+ },
437
+ scales: darkScales('Decode tok/s', 'Machine'),
438
+ },
439
+ }));
440
+ }
js/data.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let cachedData = null;
2
+
3
+ export async function loadData() {
4
+ if (cachedData) return cachedData;
5
+ const resp = await fetch('data/combined.json');
6
+ cachedData = await resp.json();
7
+ return cachedData;
8
+ }
9
+
10
+ export function filterResults(results, filters) {
11
+ return results.filter(r => {
12
+ if (filters.machine && filters.machine !== 'all' && r.machineSlug !== filters.machine) return false;
13
+ if (filters.browser && filters.browser !== 'all' && r.browser !== filters.browser) return false;
14
+ if (filters.model && filters.model !== 'all' && r.model !== filters.model) return false;
15
+ if (filters.backend && filters.backend !== 'all') {
16
+ if (filters.backend === 'cpu' && r.nGpuLayers !== 0) return false;
17
+ if (filters.backend === 'webgpu' && r.nGpuLayers === 0) return false;
18
+ }
19
+ if (filters.status && filters.status !== 'all') {
20
+ if (filters.status === 'pass' && r.status !== 'done') return false;
21
+ if (filters.status === 'fail' && r.status === 'done') return false;
22
+ }
23
+ if (filters.quants && filters.quants.size > 0 && !filters.quants.has(r.variant)) return false;
24
+ return true;
25
+ });
26
+ }
js/filters.js ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QUANT_ORDER, quantSortKey } from './utils.js';
2
+
3
+ let selectedQuants = new Set();
4
+ let onChange = null;
5
+
6
+ export function initFilters(meta, onChangeCallback) {
7
+ onChange = onChangeCallback;
8
+
9
+ // Machine select
10
+ const machineSelect = document.getElementById('filter-machine');
11
+ machineSelect.innerHTML = '<option value="all">All Machines</option>';
12
+ for (const m of meta.machines) {
13
+ const opt = document.createElement('option');
14
+ opt.value = m.slug;
15
+ opt.textContent = `${m.cpus} (${m.totalMemoryGB}GB)`;
16
+ machineSelect.appendChild(opt);
17
+ }
18
+
19
+ // Browser select
20
+ const browserSelect = document.getElementById('filter-browser');
21
+ browserSelect.innerHTML = '<option value="all">All Browsers</option>';
22
+ for (const b of meta.browsers) {
23
+ const opt = document.createElement('option');
24
+ opt.value = b;
25
+ opt.textContent = b.charAt(0).toUpperCase() + b.slice(1);
26
+ browserSelect.appendChild(opt);
27
+ }
28
+
29
+ // Model select
30
+ const modelSelect = document.getElementById('filter-model');
31
+ modelSelect.innerHTML = '<option value="all">All Models</option>';
32
+ for (const m of meta.models) {
33
+ const opt = document.createElement('option');
34
+ opt.value = m;
35
+ opt.textContent = m;
36
+ modelSelect.appendChild(opt);
37
+ }
38
+
39
+ // Backend select
40
+ const backendSelect = document.getElementById('filter-backend');
41
+ backendSelect.innerHTML = `
42
+ <option value="all">All Backends</option>
43
+ <option value="webgpu">WebGPU</option>
44
+ <option value="cpu">CPU</option>
45
+ `;
46
+
47
+ // Status select
48
+ const statusSelect = document.getElementById('filter-status');
49
+ statusSelect.innerHTML = `
50
+ <option value="all">All Status</option>
51
+ <option value="pass">Pass</option>
52
+ <option value="fail">Fail</option>
53
+ `;
54
+
55
+ // Quant multi-select
56
+ initQuantMultiSelect(meta);
57
+
58
+ // Wire up change events for single selects
59
+ for (const sel of [machineSelect, browserSelect, modelSelect, backendSelect, statusSelect]) {
60
+ sel.addEventListener('change', fireChange);
61
+ }
62
+ }
63
+
64
+ function initQuantMultiSelect(meta) {
65
+ const btn = document.getElementById('quant-dropdown-btn');
66
+ const dropdown = document.getElementById('quant-dropdown');
67
+
68
+ // Toggle dropdown
69
+ btn.addEventListener('click', (e) => {
70
+ e.stopPropagation();
71
+ dropdown.classList.toggle('open');
72
+ });
73
+
74
+ // Close on outside click
75
+ document.addEventListener('click', (e) => {
76
+ if (!dropdown.contains(e.target) && e.target !== btn) {
77
+ dropdown.classList.remove('open');
78
+ }
79
+ });
80
+ }
81
+
82
+ export function populateQuantOptions(results) {
83
+ const quantsInData = new Set(results.map(r => r.variant));
84
+ const sorted = [...quantsInData].sort((a, b) => quantSortKey(a) - quantSortKey(b));
85
+
86
+ const list = document.getElementById('quant-options');
87
+ list.innerHTML = '';
88
+
89
+ // Select All
90
+ const selectAllLabel = document.createElement('label');
91
+ selectAllLabel.className = 'quant-option select-all';
92
+ const selectAllCb = document.createElement('input');
93
+ selectAllCb.type = 'checkbox';
94
+ selectAllCb.checked = true;
95
+ selectAllCb.id = 'quant-select-all';
96
+ selectAllLabel.appendChild(selectAllCb);
97
+ selectAllLabel.appendChild(document.createTextNode(' Select All'));
98
+ list.appendChild(selectAllLabel);
99
+
100
+ const checkboxes = [];
101
+
102
+ for (const q of sorted) {
103
+ const label = document.createElement('label');
104
+ label.className = 'quant-option';
105
+ const cb = document.createElement('input');
106
+ cb.type = 'checkbox';
107
+ cb.checked = true;
108
+ cb.value = q;
109
+ cb.className = 'quant-cb';
110
+ label.appendChild(cb);
111
+ label.appendChild(document.createTextNode(` ${q}`));
112
+ list.appendChild(label);
113
+ checkboxes.push(cb);
114
+
115
+ cb.addEventListener('change', () => {
116
+ updateQuantSelection(checkboxes, selectAllCb);
117
+ fireChange();
118
+ });
119
+ }
120
+
121
+ selectAllCb.addEventListener('change', () => {
122
+ const checked = selectAllCb.checked;
123
+ for (const cb of checkboxes) cb.checked = checked;
124
+ updateQuantSelection(checkboxes, selectAllCb);
125
+ fireChange();
126
+ });
127
+
128
+ // Update button text
129
+ updateQuantButtonText(sorted.length, sorted.length);
130
+ }
131
+
132
+ function updateQuantSelection(checkboxes, selectAllCb) {
133
+ selectedQuants = new Set();
134
+ let checkedCount = 0;
135
+ for (const cb of checkboxes) {
136
+ if (cb.checked) {
137
+ checkedCount++;
138
+ }
139
+ }
140
+
141
+ if (checkedCount === checkboxes.length) {
142
+ selectedQuants = new Set();
143
+ selectAllCb.checked = true;
144
+ selectAllCb.indeterminate = false;
145
+ } else if (checkedCount === 0) {
146
+ selectedQuants = new Set(['__none__']);
147
+ selectAllCb.checked = false;
148
+ selectAllCb.indeterminate = false;
149
+ } else {
150
+ selectedQuants = new Set();
151
+ for (const cb of checkboxes) {
152
+ if (cb.checked) selectedQuants.add(cb.value);
153
+ }
154
+ selectAllCb.checked = false;
155
+ selectAllCb.indeterminate = true;
156
+ }
157
+
158
+ updateQuantButtonText(checkedCount, checkboxes.length);
159
+ }
160
+
161
+ function updateQuantButtonText(checked, total) {
162
+ const textEl = document.getElementById('quant-dropdown-text');
163
+ if (!textEl) return;
164
+ if (checked === total) {
165
+ textEl.textContent = 'All Quants';
166
+ } else if (checked === 0) {
167
+ textEl.textContent = 'No Quants';
168
+ } else {
169
+ textEl.textContent = `${checked}/${total} Quants`;
170
+ }
171
+ }
172
+
173
+ function fireChange() {
174
+ if (onChange) onChange(getFilters());
175
+ }
176
+
177
+ export function resetFilters() {
178
+ // Reset all select dropdowns
179
+ document.getElementById('filter-machine').value = 'all';
180
+ document.getElementById('filter-browser').value = 'all';
181
+ document.getElementById('filter-model').value = 'all';
182
+ document.getElementById('filter-backend').value = 'all';
183
+ document.getElementById('filter-status').value = 'all';
184
+
185
+ // Reset quant checkboxes
186
+ const selectAllCb = document.getElementById('quant-select-all');
187
+ const checkboxes = [...document.querySelectorAll('#quant-options .quant-cb')];
188
+
189
+ if (selectAllCb) {
190
+ selectAllCb.checked = true;
191
+ selectAllCb.indeterminate = false;
192
+ }
193
+ for (const cb of checkboxes) cb.checked = true;
194
+
195
+ // Reset internal state
196
+ selectedQuants = new Set();
197
+ updateQuantButtonText(checkboxes.length, checkboxes.length);
198
+ }
199
+
200
+ export function getFilters() {
201
+ return {
202
+ machine: document.getElementById('filter-machine').value,
203
+ browser: document.getElementById('filter-browser').value,
204
+ model: document.getElementById('filter-model').value,
205
+ backend: document.getElementById('filter-backend').value,
206
+ status: document.getElementById('filter-status').value,
207
+ quants: selectedQuants,
208
+ };
209
+ }
bench-config.js → js/run/config.js RENAMED
File without changes
bench-app.js → js/run/controller.js RENAMED
@@ -1,16 +1,15 @@
1
- // Interactive benchmark page controller.
2
- // Mode detection (local vs hosted), model list rendering with device-fit
3
- // check + filter chips, Download / Run / Abort orchestration, results output.
4
- // Inference logic lives in bench-core.js this file drives the UI and
5
- // sequences one runBenchmarkCore() call per selected variant.
6
-
7
- import { runBenchmarkCore } from './bench-core.js';
8
- import { localSource, hostedSource, inventoryOpfs, purgeOpfs } from './bench-source.js';
9
- import { getDeviceBudgetMB, variantFits, describeDevice } from './bench-device.js';
10
  import {
11
  resumeHFSession, beginHFSignIn, signOutHF, submitResultsToDataset,
12
- } from './bench-hub.js';
13
- import { isHubConfigured, HF_DATASET_REPO } from './bench-config.js';
14
 
15
  const OVERHEAD = 1.5;
16
  const DEFAULT_PROMPT =
@@ -25,42 +24,69 @@ const DEFAULT_ITERATIONS = 5;
25
  const MIN_ITERATIONS_FOR_SUBMIT = 5;
26
 
27
  const state = {
28
- mode: 'local', // 'local' | 'hosted'
29
- source: null, // localSource() | hostedSource()
30
- models: null, // parsed models.json
31
- budget: null, // { budgetMB, memGB, quotaMB, source }
32
- device: null, // describeDevice() output
33
- cacheStatus: {}, // { 'repo/file': { cachedBytes } }
34
- variants: [], // flat variant rows with metadata
35
  running: false,
36
  aborted: false,
37
- results: [], // result records from the current session
38
- hfSession: null, // { accessToken, expiresAt, userName } when signed in
39
  iterations: DEFAULT_ITERATIONS,
 
40
  };
41
 
42
- // ──────────────── mode / data loading ────────────────
43
 
44
- async function detectMode() {
45
  const params = new URLSearchParams(location.search);
46
- if (params.get('mode') === 'hosted') return 'hosted';
47
- if (params.get('mode') === 'local') return 'local';
48
- try {
49
- const r = await fetch('/api/models', { method: 'HEAD' });
50
- if (r.ok) return 'local';
51
- } catch { /* fall through */ }
52
- return 'hosted';
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
 
 
55
  async function loadModels() {
56
- const url = state.mode === 'local' ? '/api/models' : './models.json';
57
- const r = await fetch(url);
58
- if (!r.ok) throw new Error(`${url} ${r.status}`);
59
- return r.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
 
62
  async function loadCacheStatus() {
63
- if (state.mode === 'local') {
64
  try {
65
  const r = await fetch('/api/cache-status');
66
  if (r.ok) return r.json();
@@ -124,31 +150,54 @@ function $(id) { return document.getElementById(id); }
124
  function renderHeader() {
125
  const d = state.device;
126
  const b = state.budget;
127
- $('mode-badge').textContent = state.mode;
 
 
 
 
 
128
 
129
  const uaShort = d.userAgent.match(/(Firefox|Chrome|CriOS|Edg|Safari)\/[\d.]+/)?.[0] || 'browser';
130
  const gpuStr = d.gpu
131
  ? [d.gpu.vendor, d.gpu.architecture, d.gpu.device].filter(Boolean).join(' ').trim()
132
- : 'no WebGPU';
133
- $('device-line').textContent = `${uaShort} · ${d.platform || 'unknown'} · ${gpuStr || 'WebGPU info unavailable'}`;
 
 
 
 
 
 
134
 
135
- const pieces = [];
136
- if (b.memGB !== null) pieces.push(`deviceMemory ${b.memGB} GB`);
137
- if (b.quotaMB !== null) pieces.push(`storage quota ${(b.quotaMB / 1024).toFixed(1)} GB`);
138
  const budgetGB = (b.budgetMB / 1024).toFixed(1);
139
- $('budget-line').textContent =
140
- `Model budget: ~${budgetGB} GB · ${pieces.join(' · ') || 'using default'} · source: ${b.source}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
- $('output-actions-local').hidden = state.mode !== 'local';
143
- const hubSection = $('hub-section');
144
- if (hubSection) hubSection.hidden = state.mode !== 'hosted';
145
  const purgeBtn = $('btn-purge');
146
- if (purgeBtn) purgeBtn.hidden = state.mode !== 'hosted';
 
147
  renderHfSection();
148
  }
149
 
150
  function renderHfSection() {
151
- if (state.mode !== 'hosted') return;
152
  const signinBtn = $('btn-signin');
153
  const submitBtn = $('btn-submit');
154
  const userEl = $('hf-user');
@@ -157,7 +206,7 @@ function renderHfSection() {
157
  if (!isHubConfigured()) {
158
  signinBtn.disabled = true;
159
  signinBtn.textContent = 'HF hub not configured';
160
- signinBtn.title = 'Set HF_DATASET_REPO in bench-config.js';
161
  submitBtn.hidden = true;
162
  userEl.textContent = '';
163
  return;
@@ -186,7 +235,7 @@ function renderHfSection() {
186
  }
187
 
188
  function renderModels() {
189
- const panel = $('models-panel');
190
  panel.innerHTML = '';
191
 
192
  const groups = groupByFamily(state.variants);
@@ -195,71 +244,81 @@ function renderModels() {
195
  const allFit = fitsCount === variants.length;
196
 
197
  const familyEl = document.createElement('details');
198
- familyEl.className = 'family';
199
  familyEl.dataset.family = family;
200
  familyEl.open = true;
201
 
202
  const summary = document.createElement('summary');
 
203
  summary.innerHTML = `
204
- <input type="checkbox" class="family-select-all" data-family="${escapeAttr(family)}"${allFit ? ' checked' : ''}>
205
- <span class="family-name">${escapeText(family)}</span>
206
- <span class="family-stats">(${variants.length} variants, ${fitsCount} fit)</span>
 
207
  `;
208
  if (/^granite-4/i.test(family)) {
209
  const w = document.createElement('span');
210
- w.className = 'family-warnings';
211
  w.textContent = '⚠ needs SSM_SCAN in llama.cpp';
212
  summary.appendChild(w);
213
  }
214
  familyEl.appendChild(summary);
215
 
 
 
 
216
  for (const v of variants) {
217
  const row = document.createElement('label');
218
- row.className = 'variant-row';
219
- if (!variantFitsDevice(v)) row.classList.add('non-fit');
220
  row.dataset.key = cacheKey(v);
221
 
222
  const cb = document.createElement('input');
223
  cb.type = 'checkbox';
224
- cb.className = 'variant-select';
225
  cb.dataset.key = cacheKey(v);
226
  cb.checked = variantFitsDevice(v);
227
 
228
- const label = document.createElement('span');
229
- label.className = 'variant-label';
230
- label.innerHTML = `<b>${escapeText(v.quant)}</b> · <code>${escapeText(v.filename)}</code>`;
 
 
 
 
231
 
232
  const size = document.createElement('span');
233
- size.className = 'size';
234
  size.textContent = v.sizeMB > 0 ? formatSize(v.sizeMB) : '?';
235
 
236
  const badges = document.createElement('span');
237
- badges.className = 'badges';
238
  updateBadgesForVariant(badges, v);
239
 
240
- row.append(cb, label, size, badges);
241
- familyEl.appendChild(row);
242
  }
 
243
  panel.appendChild(familyEl);
244
  }
245
  }
246
 
247
  function updateBadgesForVariant(badgesEl, v) {
248
  badgesEl.innerHTML = '';
249
- if (isCached(v)) badgesEl.appendChild(makeBadge('cached', 'cache-badge'));
250
- for (const w of v.warnings) badgesEl.appendChild(makeBadge(w, 'warn-badge'));
251
  }
252
 
253
  function refreshCacheBadge(v) {
254
- const row = document.querySelector(`.variant-row[data-key="${cssEscape(cacheKey(v))}"]`);
255
  if (!row) return;
256
- const badges = row.querySelector('.badges');
257
  if (badges) updateBadgesForVariant(badges, v);
258
  }
259
 
260
  function makeBadge(text, cls) {
261
  const el = document.createElement('span');
262
- el.className = cls;
263
  el.textContent = text;
264
  return el;
265
  }
@@ -281,25 +340,32 @@ function cssEscape(s) {
281
  // ──────────────── selection / filters ────────────────
282
 
283
  function wireSelectionHandlers() {
284
- document.querySelectorAll('.family-select-all').forEach(el => {
285
- el.addEventListener('change', () => {
286
- const family = el.dataset.family;
287
- const rows = document.querySelectorAll(
288
- `details.family[data-family="${cssEscape(family)}"] .variant-select`,
 
 
289
  );
290
- rows.forEach(cb => { cb.checked = el.checked; });
291
  updateButtons();
292
- });
293
- el.addEventListener('click', e => e.stopPropagation());
 
294
  });
295
- document.querySelectorAll('.variant-select').forEach(el => {
296
- el.addEventListener('change', updateButtons);
 
 
 
297
  });
298
  }
299
 
300
  function wireFilters() {
301
  ['hide-ud', 'hide-iq', 'hide-hifp'].forEach(id => {
302
- $(id).addEventListener('change', applyFilters);
 
303
  });
304
  }
305
 
@@ -321,10 +387,10 @@ function submittableResults() {
321
  }
322
 
323
  function applyFilters() {
324
- const hideUd = $('hide-ud').checked;
325
- const hideIq = $('hide-iq').checked;
326
- const hideHifp = $('hide-hifp').checked;
327
- document.querySelectorAll('.variant-row').forEach(row => {
328
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
329
  if (!v) return;
330
  const isUd = v.quant.startsWith('UD-');
@@ -336,7 +402,7 @@ function applyFilters() {
336
  }
337
 
338
  function getCheckedVariants() {
339
- return Array.from(document.querySelectorAll('.variant-select:checked'))
340
  .map(cb => state.variants.find(v => cacheKey(v) === cb.dataset.key))
341
  .filter(Boolean);
342
  }
@@ -344,26 +410,54 @@ function getCheckedVariants() {
344
  function updateButtons() {
345
  const checked = getCheckedVariants();
346
  const cachedChecked = checked.filter(isCached);
347
- $('btn-download').disabled = state.running || checked.length === 0;
348
- $('btn-run').disabled = state.running || cachedChecked.length === 0;
349
- $('btn-abort').disabled = !state.running;
350
- $('queue-status').textContent = checked.length
351
- ? `${checked.length} selected · ${cachedChecked.length} cached`
352
- : '';
 
 
 
353
  }
354
 
355
  // ──────────────── progress table ────────────────
356
 
357
- function showProgressPanel() { $('progress-panel').hidden = false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
  function progressRowFor(v) {
360
  const key = cacheKey(v);
361
- const tbody = $('progress-table').querySelector('tbody');
 
362
  let tr = tbody.querySelector(`tr[data-key="${cssEscape(key)}"]`);
363
  if (!tr) {
364
  tr = document.createElement('tr');
365
  tr.dataset.key = key;
366
- tr.className = 'row-queued';
367
  tr.innerHTML = `
368
  <td>${escapeText(v.modelName)}</td>
369
  <td>${escapeText(v.quant)}</td>
@@ -377,7 +471,7 @@ function progressRowFor(v) {
377
  }
378
  return {
379
  setStatus(status, msg) {
380
- tr.className = `row-${rowClassFor(status)}`;
381
  tr.querySelector('.status').textContent = msg ? `${status} — ${msg}` : status;
382
  },
383
  setProgress(fraction, downloaded, total) {
@@ -388,7 +482,7 @@ function progressRowFor(v) {
388
  tr.querySelector('.status').textContent = detail ? `downloading ${detail}` : 'downloading';
389
  },
390
  fillFromRecord(record) {
391
- tr.className = `row-${record.status === 'done' ? 'ok' : 'error'}`;
392
  tr.querySelector('.status').textContent = record.status;
393
  tr.querySelector('.prefill').textContent = record.metrics?.prefill_tok_s ?? '—';
394
  tr.querySelector('.decode').textContent = record.metrics?.decode_tok_s ?? '—';
@@ -480,7 +574,6 @@ async function onDownloadClick() {
480
  state.running = true;
481
  state.aborted = false;
482
  updateButtons();
483
- showProgressPanel();
484
 
485
  for (const v of variants) {
486
  if (state.aborted) break;
@@ -508,9 +601,9 @@ async function onDownloadClick() {
508
  }
509
  }
510
 
511
- // Refresh cache inventory from server to reconcile any partial downloads.
512
  state.cacheStatus = await loadCacheStatus();
513
- document.querySelectorAll('.variant-row').forEach(row => {
514
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
515
  if (v) refreshCacheBadge(v);
516
  });
@@ -529,7 +622,6 @@ async function onRunClick() {
529
  state.aborted = false;
530
  state.results = [];
531
  updateButtons();
532
- showProgressPanel();
533
 
534
  const machine = await machineInfo();
535
  const browser = browserInfo();
@@ -551,7 +643,7 @@ async function onRunClick() {
551
  localStorage.setItem('webgpu-bench:lastRun', JSON.stringify(state.results));
552
  } catch { /* quota */ }
553
 
554
- if (state.mode === 'local' && $('save-local')?.checked) {
555
  fetch('/api/results', {
556
  method: 'POST',
557
  headers: { 'Content-Type': 'application/json' },
@@ -627,9 +719,6 @@ async function runVariantWithIterations(v, row) {
627
  nPredict: DEFAULT_N_PREDICT,
628
  nCtx: DEFAULT_N_CTX,
629
  nGpuLayers: DEFAULT_N_GPU_LAYERS,
630
- // Only the first iteration runs the consistency check — the result
631
- // is deterministic with greedy decoding, so subsequent iterations
632
- // would just repeat the same check.
633
  refTokenIds: i === 0 ? (refTokenIds || null) : null,
634
  onStatus: (s, m) => row.setStatus(`gpu${i + 1}/${s}`, m),
635
  onProgress: (fr, d, t) => row.setProgress(fr, d, t),
@@ -699,7 +788,6 @@ function makeRecord(v, vr, machine, browser, wallTimeMs) {
699
  prefill_samples: vr.gpuSamples.map(s => round2(s.prefill_tok_s)),
700
  decode_samples: vr.gpuSamples.map(s => round2(s.decode_tok_s)),
701
  iterations: vr.iterations,
702
- // Retain first-iteration detail for backward-compat with dashboard tables.
703
  n_p_eval: first.n_p_eval,
704
  n_eval: first.n_eval,
705
  t_p_eval_ms: first.t_p_eval_ms,
@@ -733,7 +821,7 @@ function makeRecord(v, vr, machine, browser, wallTimeMs) {
733
  cpu_baseline: cpuBaseline,
734
  output: vr.gpuCore?.output || '',
735
  machine,
736
- source: 'bench.html',
737
  };
738
  }
739
 
@@ -742,8 +830,8 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
742
  // ──────────────── Output ────────────────
743
 
744
  function renderOutput() {
745
- $('output-textarea').value = generateMarkdown(state.results);
746
- $('output-panel').hidden = false;
747
  }
748
 
749
  function generateMarkdown(results) {
@@ -791,7 +879,7 @@ function generateMarkdown(results) {
791
  }
792
 
793
  function wireOutputHandlers() {
794
- $('btn-copy').addEventListener('click', async () => {
795
  const text = $('output-textarea').value;
796
  try {
797
  await navigator.clipboard.writeText(text);
@@ -802,7 +890,7 @@ function wireOutputHandlers() {
802
  }
803
  });
804
 
805
- $('btn-download-json').addEventListener('click', () => {
806
  if (state.results.length === 0) return;
807
  const blob = new Blob([JSON.stringify(state.results, null, 2)], { type: 'application/json' });
808
  const url = URL.createObjectURL(blob);
@@ -821,12 +909,13 @@ function flashButton(el, msg) {
821
  setTimeout(() => { el.textContent = original; }, 1200);
822
  }
823
 
824
- // ──────────────── Abort ────────────────
825
 
826
  function wireAbortHandler() {
827
- $('btn-abort').addEventListener('click', () => {
828
  state.aborted = true;
829
- $('btn-abort').disabled = true;
 
830
  logLine('Abort requested — will stop between variants.');
831
  });
832
  }
@@ -835,12 +924,12 @@ function wirePurgeHandler() {
835
  const btn = $('btn-purge');
836
  if (!btn) return;
837
  btn.addEventListener('click', async () => {
838
- if (state.mode !== 'hosted') return;
839
  if (!confirm('Delete all cached GGUF files from OPFS? This frees browser storage but re-downloads will be needed.')) return;
840
  try {
841
  await purgeOpfs();
842
  state.cacheStatus = {};
843
- document.querySelectorAll('.variant-row').forEach(row => {
844
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
845
  if (v) refreshCacheBadge(v);
846
  });
@@ -902,23 +991,34 @@ function wireHubHandlers() {
902
  }
903
 
904
  function wireRunHandlers() {
905
- $('btn-download').addEventListener('click', onDownloadClick);
906
- $('btn-run').addEventListener('click', onRunClick);
907
  }
908
 
909
- // ──────────────── Init ────────────────
 
 
 
 
910
 
911
- async function init() {
912
- state.mode = await detectMode();
913
- state.source = state.mode === 'local' ? localSource() : hostedSource();
914
  state.budget = await getDeviceBudgetMB();
915
  state.device = await describeDevice();
916
- state.models = await loadModels();
 
 
 
 
 
 
 
 
 
917
  state.cacheStatus = await loadCacheStatus();
918
  state.variants = flattenVariants(state.models);
919
 
920
- // Resume any existing HF OAuth session / complete redirect flow.
921
- if (state.mode === 'hosted') {
922
  try { state.hfSession = await resumeHFSession(); } catch { /* ignore */ }
923
  }
924
 
@@ -935,8 +1035,8 @@ async function init() {
935
  updateButtons();
936
  }
937
 
938
- init().catch(err => {
939
- const el = $('models-loading');
940
- if (el) el.textContent = `Error loading models: ${err.message}`;
941
- console.error(err);
942
- });
 
1
+ // Run-tab controller. Mounts into the existing #run-section subtree and
2
+ // drives the one-click benchmark UI using the dashboard's design-system
3
+ // classes. Detects `surface` (localhost / space / pages) to gate the
4
+ // server save checkbox and the HF hub sign-in/submit row.
5
+
6
+ import { runBenchmarkCore } from './core.js';
7
+ import { localSource, hostedSource, inventoryOpfs, purgeOpfs } from './source.js';
8
+ import { getDeviceBudgetMB, variantFits, describeDevice } from './device.js';
 
9
  import {
10
  resumeHFSession, beginHFSignIn, signOutHF, submitResultsToDataset,
11
+ } from './hub.js';
12
+ import { isHubConfigured, HF_DATASET_REPO } from './config.js';
13
 
14
  const OVERHEAD = 1.5;
15
  const DEFAULT_PROMPT =
 
24
  const MIN_ITERATIONS_FOR_SUBMIT = 5;
25
 
26
  const state = {
27
+ surface: 'pages', // 'localhost' | 'space' | 'pages' | 'file'
28
+ source: null, // localSource() | hostedSource()
29
+ models: null, // parsed models.json
30
+ budget: null, // { budgetMB, memGB, quotaMB, source }
31
+ device: null, // describeDevice() output
32
+ cacheStatus: {}, // { 'repo/file': { cachedBytes } }
33
+ variants: [], // flat variant rows with metadata
34
  running: false,
35
  aborted: false,
36
+ results: [], // result records from the current session
37
+ hfSession: null, // { accessToken, expiresAt, userName } when signed in
38
  iterations: DEFAULT_ITERATIONS,
39
+ mounted: false,
40
  };
41
 
42
+ // ──────────────── surface detection ────────────────
43
 
44
+ async function detectSurface() {
45
  const params = new URLSearchParams(location.search);
46
+ if (params.get('mode') === 'local') return 'localhost';
47
+ if (params.get('mode') === 'hosted') return 'space';
48
+ if (/\.static\.hf\.space$/.test(location.hostname)) return 'space';
49
+ if (/\.github\.io$/.test(location.hostname)) return 'pages';
50
+ if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
51
+ try {
52
+ const r = await fetch('/api/models', { method: 'HEAD' });
53
+ if (r.ok) return 'localhost';
54
+ } catch { /* no backend */ }
55
+ }
56
+ if (location.protocol === 'file:') return 'file';
57
+ return 'pages';
58
+ }
59
+
60
+ function canSubmit() {
61
+ return state.surface === 'localhost'
62
+ || (state.surface === 'space' && isHubConfigured());
63
  }
64
 
65
+ // ──────────────── data loading ────────────────
66
+
67
  async function loadModels() {
68
+ // Candidates in order the Run page lives at /site/run/ locally but may be
69
+ // flattened to Space root. Relative `../models.json` catches the local case;
70
+ // `./models.json` catches the flattened case; absolute `/models.json` catches
71
+ // Space root too; `/api/models` is the Express backend.
72
+ const candidates = state.surface === 'localhost'
73
+ ? ['/api/models', '../models.json', './models.json', '/models.json']
74
+ : ['./models.json', '../models.json', '/models.json', './data/models.json'];
75
+ let lastErr = null;
76
+ for (const url of candidates) {
77
+ try {
78
+ const r = await fetch(url);
79
+ if (r.ok) return await r.json();
80
+ lastErr = new Error(`${url} → ${r.status}`);
81
+ } catch (err) {
82
+ lastErr = err;
83
+ }
84
+ }
85
+ throw lastErr || new Error('Could not load models.json');
86
  }
87
 
88
  async function loadCacheStatus() {
89
+ if (state.surface === 'localhost') {
90
  try {
91
  const r = await fetch('/api/cache-status');
92
  if (r.ok) return r.json();
 
150
  function renderHeader() {
151
  const d = state.device;
152
  const b = state.budget;
153
+
154
+ const badge = $('run-mode-badge');
155
+ if (badge) {
156
+ badge.textContent = state.surface;
157
+ badge.className = `badge run-mode-badge run-mode-${state.surface}`;
158
+ }
159
 
160
  const uaShort = d.userAgent.match(/(Firefox|Chrome|CriOS|Edg|Safari)\/[\d.]+/)?.[0] || 'browser';
161
  const gpuStr = d.gpu
162
  ? [d.gpu.vendor, d.gpu.architecture, d.gpu.device].filter(Boolean).join(' ').trim()
163
+ : '';
164
+
165
+ $('device-browser').textContent = uaShort;
166
+ $('device-platform').textContent = d.platform || 'unknown';
167
+ $('device-gpu').textContent = gpuStr || (d.webgpu ? 'WebGPU (no info)' : 'no WebGPU');
168
+
169
+ const memStr = b.memGB !== null ? `${b.memGB} GB` : '—';
170
+ $('device-memory').textContent = memStr;
171
 
 
 
 
172
  const budgetGB = (b.budgetMB / 1024).toFixed(1);
173
+ $('device-budget').textContent = `${budgetGB} GB`;
174
+ $('device-budget-source').textContent = `source: ${b.source}`;
175
+
176
+ const webgpuCell = $('device-webgpu');
177
+ if (webgpuCell) {
178
+ webgpuCell.textContent = d.webgpu ? 'yes' : 'no';
179
+ webgpuCell.classList.toggle('text-success', d.webgpu);
180
+ webgpuCell.classList.toggle('text-error', !d.webgpu);
181
+ }
182
+
183
+ // Surface-dependent UI gating.
184
+ const hubRow = $('hub-row');
185
+ if (hubRow) hubRow.hidden = state.surface !== 'space';
186
+
187
+ const saveLocalRow = $('save-local-row');
188
+ if (saveLocalRow) saveLocalRow.hidden = state.surface !== 'localhost';
189
+
190
+ const pagesBanner = $('run-pages-banner');
191
+ if (pagesBanner) pagesBanner.hidden = state.surface !== 'pages';
192
 
 
 
 
193
  const purgeBtn = $('btn-purge');
194
+ if (purgeBtn) purgeBtn.hidden = state.surface === 'localhost';
195
+
196
  renderHfSection();
197
  }
198
 
199
  function renderHfSection() {
200
+ if (state.surface !== 'space') return;
201
  const signinBtn = $('btn-signin');
202
  const submitBtn = $('btn-submit');
203
  const userEl = $('hf-user');
 
206
  if (!isHubConfigured()) {
207
  signinBtn.disabled = true;
208
  signinBtn.textContent = 'HF hub not configured';
209
+ signinBtn.title = 'Set HF_DATASET_REPO in site/js/run/config.js';
210
  submitBtn.hidden = true;
211
  userEl.textContent = '';
212
  return;
 
235
  }
236
 
237
  function renderModels() {
238
+ const panel = $('run-models');
239
  panel.innerHTML = '';
240
 
241
  const groups = groupByFamily(state.variants);
 
244
  const allFit = fitsCount === variants.length;
245
 
246
  const familyEl = document.createElement('details');
247
+ familyEl.className = 'run-family card';
248
  familyEl.dataset.family = family;
249
  familyEl.open = true;
250
 
251
  const summary = document.createElement('summary');
252
+ summary.className = 'run-family-summary';
253
  summary.innerHTML = `
254
+ <span class="run-family-chevron" aria-hidden="true"></span>
255
+ <input type="checkbox" class="run-family-select-all" data-family="${escapeAttr(family)}"${allFit ? ' checked' : ''}>
256
+ <span class="run-family-name">${escapeText(family)}</span>
257
+ <span class="run-family-stats">${variants.length} variants · ${fitsCount} fit</span>
258
  `;
259
  if (/^granite-4/i.test(family)) {
260
  const w = document.createElement('span');
261
+ w.className = 'run-family-warning';
262
  w.textContent = '⚠ needs SSM_SCAN in llama.cpp';
263
  summary.appendChild(w);
264
  }
265
  familyEl.appendChild(summary);
266
 
267
+ const list = document.createElement('div');
268
+ list.className = 'run-variant-list';
269
+
270
  for (const v of variants) {
271
  const row = document.createElement('label');
272
+ row.className = 'run-variant-row';
273
+ if (!variantFitsDevice(v)) row.classList.add('is-non-fit');
274
  row.dataset.key = cacheKey(v);
275
 
276
  const cb = document.createElement('input');
277
  cb.type = 'checkbox';
278
+ cb.className = 'run-variant-select';
279
  cb.dataset.key = cacheKey(v);
280
  cb.checked = variantFitsDevice(v);
281
 
282
+ const quant = document.createElement('span');
283
+ quant.className = 'run-variant-quant';
284
+ quant.textContent = v.quant;
285
+
286
+ const filename = document.createElement('code');
287
+ filename.className = 'run-variant-file';
288
+ filename.textContent = v.filename;
289
 
290
  const size = document.createElement('span');
291
+ size.className = 'run-variant-size';
292
  size.textContent = v.sizeMB > 0 ? formatSize(v.sizeMB) : '?';
293
 
294
  const badges = document.createElement('span');
295
+ badges.className = 'run-variant-badges';
296
  updateBadgesForVariant(badges, v);
297
 
298
+ row.append(cb, quant, filename, size, badges);
299
+ list.appendChild(row);
300
  }
301
+ familyEl.appendChild(list);
302
  panel.appendChild(familyEl);
303
  }
304
  }
305
 
306
  function updateBadgesForVariant(badgesEl, v) {
307
  badgesEl.innerHTML = '';
308
+ if (isCached(v)) badgesEl.appendChild(makeBadge('cached', 'badge--cached'));
309
+ for (const w of v.warnings) badgesEl.appendChild(makeBadge(w, 'badge--warn'));
310
  }
311
 
312
  function refreshCacheBadge(v) {
313
+ const row = document.querySelector(`.run-variant-row[data-key="${cssEscape(cacheKey(v))}"]`);
314
  if (!row) return;
315
+ const badges = row.querySelector('.run-variant-badges');
316
  if (badges) updateBadgesForVariant(badges, v);
317
  }
318
 
319
  function makeBadge(text, cls) {
320
  const el = document.createElement('span');
321
+ el.className = `badge ${cls}`;
322
  el.textContent = text;
323
  return el;
324
  }
 
340
  // ──────────────── selection / filters ────────────────
341
 
342
  function wireSelectionHandlers() {
343
+ const panel = $('run-models');
344
+ panel.addEventListener('change', (e) => {
345
+ const t = e.target;
346
+ if (t.classList?.contains('run-family-select-all')) {
347
+ const family = t.dataset.family;
348
+ const rows = panel.querySelectorAll(
349
+ `details.run-family[data-family="${cssEscape(family)}"] .run-variant-select`,
350
  );
351
+ rows.forEach(cb => { cb.checked = t.checked; });
352
  updateButtons();
353
+ } else if (t.classList?.contains('run-variant-select')) {
354
+ updateButtons();
355
+ }
356
  });
357
+ // Prevent the select-all toggling expand/collapse.
358
+ panel.addEventListener('click', (e) => {
359
+ if (e.target.classList?.contains('run-family-select-all')) {
360
+ e.stopPropagation();
361
+ }
362
  });
363
  }
364
 
365
  function wireFilters() {
366
  ['hide-ud', 'hide-iq', 'hide-hifp'].forEach(id => {
367
+ const el = $(id);
368
+ if (el) el.addEventListener('change', applyFilters);
369
  });
370
  }
371
 
 
387
  }
388
 
389
  function applyFilters() {
390
+ const hideUd = $('hide-ud')?.checked;
391
+ const hideIq = $('hide-iq')?.checked;
392
+ const hideHifp = $('hide-hifp')?.checked;
393
+ document.querySelectorAll('.run-variant-row').forEach(row => {
394
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
395
  if (!v) return;
396
  const isUd = v.quant.startsWith('UD-');
 
402
  }
403
 
404
  function getCheckedVariants() {
405
+ return Array.from(document.querySelectorAll('.run-variant-select:checked'))
406
  .map(cb => state.variants.find(v => cacheKey(v) === cb.dataset.key))
407
  .filter(Boolean);
408
  }
 
410
  function updateButtons() {
411
  const checked = getCheckedVariants();
412
  const cachedChecked = checked.filter(isCached);
413
+ const dl = $('btn-download'); if (dl) dl.disabled = state.running || checked.length === 0;
414
+ const rn = $('btn-run'); if (rn) rn.disabled = state.running || cachedChecked.length === 0;
415
+ const ab = $('btn-abort'); if (ab) { ab.disabled = !state.running; ab.hidden = !state.running; }
416
+ const status = $('queue-status');
417
+ if (status) {
418
+ status.textContent = checked.length
419
+ ? `${checked.length} selected · ${cachedChecked.length} cached`
420
+ : '';
421
+ }
422
  }
423
 
424
  // ──────────────── progress table ────────────────
425
 
426
+ function ensureProgressTable() {
427
+ const wrap = $('run-progress-wrapper');
428
+ if (!wrap) return null;
429
+ let table = wrap.querySelector('table');
430
+ if (!table) {
431
+ table = document.createElement('table');
432
+ table.className = 'results-table run-progress-table';
433
+ table.innerHTML = `
434
+ <thead>
435
+ <tr>
436
+ <th>Model</th>
437
+ <th>Variant</th>
438
+ <th>Status</th>
439
+ <th class="num">Prefill tok/s</th>
440
+ <th class="num">Decode tok/s</th>
441
+ <th class="num">Wall s</th>
442
+ <th>Error</th>
443
+ </tr>
444
+ </thead>
445
+ <tbody></tbody>
446
+ `;
447
+ wrap.appendChild(table);
448
+ }
449
+ return table;
450
+ }
451
 
452
  function progressRowFor(v) {
453
  const key = cacheKey(v);
454
+ const table = ensureProgressTable();
455
+ const tbody = table.querySelector('tbody');
456
  let tr = tbody.querySelector(`tr[data-key="${cssEscape(key)}"]`);
457
  if (!tr) {
458
  tr = document.createElement('tr');
459
  tr.dataset.key = key;
460
+ tr.className = 'run-row-queued';
461
  tr.innerHTML = `
462
  <td>${escapeText(v.modelName)}</td>
463
  <td>${escapeText(v.quant)}</td>
 
471
  }
472
  return {
473
  setStatus(status, msg) {
474
+ tr.className = `run-row-${rowClassFor(status)}`;
475
  tr.querySelector('.status').textContent = msg ? `${status} — ${msg}` : status;
476
  },
477
  setProgress(fraction, downloaded, total) {
 
482
  tr.querySelector('.status').textContent = detail ? `downloading ${detail}` : 'downloading';
483
  },
484
  fillFromRecord(record) {
485
+ tr.className = `run-row-${record.status === 'done' ? 'ok' : 'error'}`;
486
  tr.querySelector('.status').textContent = record.status;
487
  tr.querySelector('.prefill').textContent = record.metrics?.prefill_tok_s ?? '—';
488
  tr.querySelector('.decode').textContent = record.metrics?.decode_tok_s ?? '—';
 
574
  state.running = true;
575
  state.aborted = false;
576
  updateButtons();
 
577
 
578
  for (const v of variants) {
579
  if (state.aborted) break;
 
601
  }
602
  }
603
 
604
+ // Refresh cache inventory to reconcile any partial downloads.
605
  state.cacheStatus = await loadCacheStatus();
606
+ document.querySelectorAll('.run-variant-row').forEach(row => {
607
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
608
  if (v) refreshCacheBadge(v);
609
  });
 
622
  state.aborted = false;
623
  state.results = [];
624
  updateButtons();
 
625
 
626
  const machine = await machineInfo();
627
  const browser = browserInfo();
 
643
  localStorage.setItem('webgpu-bench:lastRun', JSON.stringify(state.results));
644
  } catch { /* quota */ }
645
 
646
+ if (state.surface === 'localhost' && $('save-local')?.checked) {
647
  fetch('/api/results', {
648
  method: 'POST',
649
  headers: { 'Content-Type': 'application/json' },
 
719
  nPredict: DEFAULT_N_PREDICT,
720
  nCtx: DEFAULT_N_CTX,
721
  nGpuLayers: DEFAULT_N_GPU_LAYERS,
 
 
 
722
  refTokenIds: i === 0 ? (refTokenIds || null) : null,
723
  onStatus: (s, m) => row.setStatus(`gpu${i + 1}/${s}`, m),
724
  onProgress: (fr, d, t) => row.setProgress(fr, d, t),
 
788
  prefill_samples: vr.gpuSamples.map(s => round2(s.prefill_tok_s)),
789
  decode_samples: vr.gpuSamples.map(s => round2(s.decode_tok_s)),
790
  iterations: vr.iterations,
 
791
  n_p_eval: first.n_p_eval,
792
  n_eval: first.n_eval,
793
  t_p_eval_ms: first.t_p_eval_ms,
 
821
  cpu_baseline: cpuBaseline,
822
  output: vr.gpuCore?.output || '',
823
  machine,
824
+ source: `webgpu-bench/site (${state.surface})`,
825
  };
826
  }
827
 
 
830
  // ──────────────── Output ────────────────
831
 
832
  function renderOutput() {
833
+ const ta = $('output-textarea');
834
+ if (ta) ta.value = generateMarkdown(state.results);
835
  }
836
 
837
  function generateMarkdown(results) {
 
879
  }
880
 
881
  function wireOutputHandlers() {
882
+ $('btn-copy')?.addEventListener('click', async () => {
883
  const text = $('output-textarea').value;
884
  try {
885
  await navigator.clipboard.writeText(text);
 
890
  }
891
  });
892
 
893
+ $('btn-download-json')?.addEventListener('click', () => {
894
  if (state.results.length === 0) return;
895
  const blob = new Blob([JSON.stringify(state.results, null, 2)], { type: 'application/json' });
896
  const url = URL.createObjectURL(blob);
 
909
  setTimeout(() => { el.textContent = original; }, 1200);
910
  }
911
 
912
+ // ──────────────── Abort / Purge / Hub ────────────────
913
 
914
  function wireAbortHandler() {
915
+ $('btn-abort')?.addEventListener('click', () => {
916
  state.aborted = true;
917
+ const ab = $('btn-abort');
918
+ if (ab) ab.disabled = true;
919
  logLine('Abort requested — will stop between variants.');
920
  });
921
  }
 
924
  const btn = $('btn-purge');
925
  if (!btn) return;
926
  btn.addEventListener('click', async () => {
927
+ if (state.surface === 'localhost') return;
928
  if (!confirm('Delete all cached GGUF files from OPFS? This frees browser storage but re-downloads will be needed.')) return;
929
  try {
930
  await purgeOpfs();
931
  state.cacheStatus = {};
932
+ document.querySelectorAll('.run-variant-row').forEach(row => {
933
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
934
  if (v) refreshCacheBadge(v);
935
  });
 
991
  }
992
 
993
  function wireRunHandlers() {
994
+ $('btn-download')?.addEventListener('click', onDownloadClick);
995
+ $('btn-run')?.addEventListener('click', onRunClick);
996
  }
997
 
998
+ // ──────────────── Public API ────────────────
999
+
1000
+ export async function mountRunSection() {
1001
+ if (state.mounted) return;
1002
+ state.mounted = true;
1003
 
1004
+ state.surface = await detectSurface();
1005
+ state.source = state.surface === 'localhost' ? localSource() : hostedSource();
 
1006
  state.budget = await getDeviceBudgetMB();
1007
  state.device = await describeDevice();
1008
+
1009
+ try {
1010
+ state.models = await loadModels();
1011
+ } catch (err) {
1012
+ const panel = $('run-models');
1013
+ if (panel) panel.innerHTML = `<div class="empty-state">Could not load models.json — ${escapeText(err.message)}</div>`;
1014
+ console.error(err);
1015
+ return;
1016
+ }
1017
+
1018
  state.cacheStatus = await loadCacheStatus();
1019
  state.variants = flattenVariants(state.models);
1020
 
1021
+ if (state.surface === 'space') {
 
1022
  try { state.hfSession = await resumeHFSession(); } catch { /* ignore */ }
1023
  }
1024
 
 
1035
  updateButtons();
1036
  }
1037
 
1038
+ export function teardownRunSection() {
1039
+ // Placeholder no explicit teardown today. Future: abort in-flight runs,
1040
+ // detach listeners. For now the Run tab just sits idle.
1041
+ state.aborted = true;
1042
+ }
bench-core.js → js/run/core.js RENAMED
@@ -1,6 +1,6 @@
1
  // Benchmark core: load GGUF via a source adapter, init llama.cpp WASM,
2
  // run inference, collect metrics. Used by harness.js (URL-param driven, for
3
- // runner.js) and by bench-app.js (UI driven).
4
 
5
  const DEFAULT_PROMPT = 'Hello, how are you?';
6
 
@@ -31,7 +31,7 @@ export async function runBenchmarkCore({
31
  onProgress = () => {},
32
  onLog = () => {},
33
  }) {
34
- if (!source) throw new Error('No source provided (see bench-source.js).');
35
  if (!modelFile) throw new Error('No model file specified.');
36
  if (!hfRepo) throw new Error('No hfRepo specified.');
37
 
 
1
  // Benchmark core: load GGUF via a source adapter, init llama.cpp WASM,
2
  // run inference, collect metrics. Used by harness.js (URL-param driven, for
3
+ // runner.js) and by the Run-tab controller (UI driven).
4
 
5
  const DEFAULT_PROMPT = 'Hello, how are you?';
6
 
 
31
  onProgress = () => {},
32
  onLog = () => {},
33
  }) {
34
+ if (!source) throw new Error('No source provided (see run/source.js).');
35
  if (!modelFile) throw new Error('No model file specified.');
36
  if (!hfRepo) throw new Error('No hfRepo specified.');
37
 
bench-device.js → js/run/device.js RENAMED
File without changes
bench-hub.js → js/run/hub.js RENAMED
@@ -1,8 +1,8 @@
1
  // Hugging Face OAuth + dataset push for the bench page (hosted mode).
2
  //
3
  // Flow:
4
- // 1. On page load, bench-app.js calls `resumeHFSession()` to handle the
5
- // OAuth redirect (if the URL has the expected query params) and to
6
  // reuse any previously-stored access token.
7
  // 2. Clicking [Sign in] calls `beginHFSignIn()` which redirects the
8
  // browser to HF; after consent, HF redirects back to the page with
@@ -24,7 +24,7 @@ import {
24
  HF_OAUTH_SCOPES,
25
  HF_DATASET_REPO,
26
  isHubConfigured,
27
- } from './bench-config.js';
28
 
29
  const TOKEN_STORAGE_KEY = 'webgpu-bench:hfOauth';
30
 
@@ -73,7 +73,7 @@ function readStoredSession() {
73
 
74
  export async function beginHFSignIn() {
75
  if (!isHubConfigured()) {
76
- throw new Error('HF hub is not configured. Set HF_DATASET_REPO in bench-config.js.');
77
  }
78
  // When served from an HF Space with `hf_oauth: true`, HF injects
79
  // `window.huggingface.variables` with OAUTH_CLIENT_ID + OAUTH_SCOPES.
 
1
  // Hugging Face OAuth + dataset push for the bench page (hosted mode).
2
  //
3
  // Flow:
4
+ // 1. On page load, the run controller calls `resumeHFSession()` to handle
5
+ // the OAuth redirect (if the URL has the expected query params) and to
6
  // reuse any previously-stored access token.
7
  // 2. Clicking [Sign in] calls `beginHFSignIn()` which redirects the
8
  // browser to HF; after consent, HF redirects back to the page with
 
24
  HF_OAUTH_SCOPES,
25
  HF_DATASET_REPO,
26
  isHubConfigured,
27
+ } from './config.js';
28
 
29
  const TOKEN_STORAGE_KEY = 'webgpu-bench:hfOauth';
30
 
 
73
 
74
  export async function beginHFSignIn() {
75
  if (!isHubConfigured()) {
76
+ throw new Error('HF hub is not configured. Set HF_DATASET_REPO in run/config.js.');
77
  }
78
  // When served from an HF Space with `hf_oauth: true`, HF injects
79
  // `window.huggingface.variables` with OAUTH_CLIENT_ID + OAUTH_SCOPES.
bench-source.js → js/run/source.js RENAMED
File without changes
js/tables.js ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { formatTokS, formatMs, categorizeError, groupBy, quantSortKey } from './utils.js';
2
+
3
+ let lastResults = [];
4
+ let sortState = { key: null, dir: 'asc' };
5
+
6
+ const NUM_KEYS = new Set([
7
+ 'sizeMB', 'decode_tok_s', 'prefill_tok_s', 'n_eval', 't_eval_ms',
8
+ 'n_p_eval', 't_p_eval_ms', 'wallTimeMs', 'consistency_rate', 'nGpuLayers',
9
+ ]);
10
+
11
+ function sortResults(results, key, dir) {
12
+ const isNum = NUM_KEYS.has(key);
13
+ return [...results].sort((a, b) => {
14
+ let va = a[key], vb = b[key];
15
+ if (va == null && vb == null) return 0;
16
+ if (va == null) return 1;
17
+ if (vb == null) return -1;
18
+
19
+ let cmp;
20
+ if (isNum) {
21
+ cmp = Number(va) - Number(vb);
22
+ } else if (key === 'webgpuAvailable') {
23
+ cmp = (va === vb) ? 0 : va ? -1 : 1;
24
+ } else {
25
+ cmp = String(va).localeCompare(String(vb));
26
+ }
27
+ return dir === 'desc' ? -cmp : cmp;
28
+ });
29
+ }
30
+
31
+ function handleSort(key) {
32
+ if (sortState.key === key) {
33
+ sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
34
+ } else {
35
+ sortState.key = key;
36
+ // Default to descending for performance metrics
37
+ sortState.dir = NUM_KEYS.has(key) ? 'desc' : 'asc';
38
+ }
39
+ renderResultsTable(lastResults);
40
+ }
41
+
42
+ export function renderResultsTable(results) {
43
+ lastResults = results;
44
+ const container = document.getElementById('results-table');
45
+ if (!container) return;
46
+
47
+ if (results.length === 0) {
48
+ container.innerHTML = '<div class="empty-state"><p>No results match the current filters.</p></div>';
49
+ return;
50
+ }
51
+
52
+ const sorted = sortState.key ? sortResults(results, sortState.key, sortState.dir) : results;
53
+
54
+ const cols = [
55
+ { key: 'machineSlug', label: 'Machine' },
56
+ { key: 'model', label: 'Model' },
57
+ { key: 'variant', label: 'Quant' },
58
+ { key: 'sizeMB', label: 'Size (MB)' },
59
+ { key: 'browser', label: 'Browser' },
60
+ { key: 'nGpuLayers', label: 'Backend' },
61
+ { key: 'status', label: 'Status' },
62
+ { key: 'buildType', label: 'Build' },
63
+ { key: 'webgpuAvailable', label: 'WebGPU' },
64
+ { key: 'decode_tok_s', label: 'Decode tok/s' },
65
+ { key: 'prefill_tok_s', label: 'Prefill tok/s' },
66
+ { key: 'n_eval', label: 'n_eval' },
67
+ { key: 't_eval_ms', label: 't_eval (ms)' },
68
+ { key: 'n_p_eval', label: 'n_p_eval' },
69
+ { key: 't_p_eval_ms', label: 't_p_eval (ms)' },
70
+ { key: 'wallTimeMs', label: 'Wall (s)' },
71
+ { key: 'consistency_rate', label: 'CPU Match' },
72
+ { key: 'llamaCppCommit', label: 'llama.cpp' },
73
+ { key: 'error', label: 'Error' },
74
+ ];
75
+
76
+ let html = '<table class="results-table"><thead><tr>';
77
+ for (const col of cols) {
78
+ const isActive = sortState.key === col.key;
79
+ const arrow = isActive ? (sortState.dir === 'asc' ? ' \u2191' : ' \u2193') : '';
80
+ const cls = isActive ? ' class="sorted"' : '';
81
+ html += `<th data-key="${col.key}"${cls}>${col.label}${arrow}</th>`;
82
+ }
83
+ html += '</tr></thead><tbody>';
84
+
85
+ for (const r of sorted) {
86
+ const rowClass = r.status === 'done' ? 'row-pass' : 'row-fail';
87
+ html += `<tr class="${rowClass}">`;
88
+ for (const col of cols) {
89
+ html += '<td>';
90
+ switch (col.key) {
91
+ case 'status':
92
+ html += r.status === 'done'
93
+ ? '<span class="badge badge--pass">PASS</span>'
94
+ : '<span class="badge badge--fail">FAIL</span>';
95
+ break;
96
+ case 'nGpuLayers':
97
+ if (r.nGpuLayers != null) {
98
+ const isCpu = r.nGpuLayers === 0;
99
+ html += `<span class="badge ${isCpu ? 'badge--cpu' : 'badge--webgpu'}">${isCpu ? 'CPU' : 'WebGPU'}</span>`;
100
+ } else {
101
+ html += '<span class="text-muted">\u2014</span>';
102
+ }
103
+ break;
104
+ case 'webgpuAvailable':
105
+ html += r.webgpuAvailable
106
+ ? '<span class="badge badge--yes">Yes</span>'
107
+ : '<span class="badge badge--no">No</span>';
108
+ break;
109
+ case 'decode_tok_s':
110
+ case 'prefill_tok_s':
111
+ html += `<span class="mono">${formatTokS(r[col.key])}</span>`;
112
+ break;
113
+ case 't_eval_ms':
114
+ case 't_p_eval_ms':
115
+ html += `<span class="mono">${formatMs(r[col.key])}</span>`;
116
+ break;
117
+ case 'wallTimeMs':
118
+ html += `<span class="mono">${r.wallTimeMs != null ? (r.wallTimeMs / 1000).toFixed(1) : '\u2014'}</span>`;
119
+ break;
120
+ case 'consistency_rate':
121
+ if (r.consistency_rate != null) {
122
+ const pct = (r.consistency_rate * 100).toFixed(1);
123
+ const cls = r.consistency_rate >= 0.95 ? 'text-success' : r.consistency_rate >= 0.90 ? '' : 'text-error';
124
+ const diverge = r.consistency_first_disagree >= 0 ? ` (diverge@${r.consistency_first_disagree})` : '';
125
+ html += `<span class="mono ${cls}">${pct}%${diverge}</span>`;
126
+ } else {
127
+ html += '<span class="text-muted">\u2014</span>';
128
+ }
129
+ break;
130
+ case 'llamaCppCommit':
131
+ if (r.llamaCppCommit) {
132
+ const short = r.llamaCppCommit.slice(0, 10);
133
+ html += `<a class="mono" href="https://github.com/ggml-org/llama.cpp/commit/${r.llamaCppCommit}" target="_blank" rel="noopener">${short}</a>`;
134
+ } else {
135
+ html += '<span class="text-muted">\u2014</span>';
136
+ }
137
+ break;
138
+ case 'error':
139
+ if (r.error) {
140
+ const cat = categorizeError(r.error);
141
+ const short = r.error.length > 60 ? r.error.slice(0, 60) + '\u2026' : r.error;
142
+ html += `<span class="error-cell" title="${escapeHtml(r.error)}"><span class="error-cat">${cat}</span>${escapeHtml(short)}</span>`;
143
+ } else {
144
+ html += '<span class="text-muted">\u2014</span>';
145
+ }
146
+ break;
147
+ case 'sizeMB':
148
+ case 'n_eval':
149
+ case 'n_p_eval':
150
+ html += `<span class="mono">${r[col.key] != null ? r[col.key] : '\u2014'}</span>`;
151
+ break;
152
+ default:
153
+ html += escapeHtml(String(r[col.key] ?? '\u2014'));
154
+ }
155
+ html += '</td>';
156
+ }
157
+ html += '</tr>';
158
+ }
159
+
160
+ html += '</tbody></table>';
161
+ container.innerHTML = html;
162
+
163
+ // Wire sort click handlers
164
+ container.querySelectorAll('th[data-key]').forEach(th => {
165
+ th.addEventListener('click', () => handleSort(th.dataset.key));
166
+ });
167
+ }
168
+
169
+ export function renderErrorTable(results) {
170
+ const container = document.getElementById('error-table');
171
+ if (!container) return;
172
+
173
+ const errors = results.filter(r => r.status !== 'done' && r.error);
174
+ if (errors.length === 0) {
175
+ container.innerHTML = '<div class="empty-state"><p>No errors found.</p></div>';
176
+ return;
177
+ }
178
+
179
+ const grouped = groupBy(errors, r => categorizeError(r.error));
180
+
181
+ let html = '<div class="table-card"><table class="data-table"><thead><tr><th>Category</th><th>Count</th><th>Variants</th><th>Browsers</th></tr></thead><tbody>';
182
+ for (const [cat, items] of Object.entries(grouped).sort((a, b) => b[1].length - a[1].length)) {
183
+ const variants = [...new Set(items.map(i => i.variant))].join(', ');
184
+ const browsers = [...new Set(items.map(i => i.browser))].join(', ');
185
+ html += `<tr><td><span class="error-cat">${cat}</span></td><td><span class="mono">${items.length}</span></td><td>${variants}</td><td>${browsers}</td></tr>`;
186
+ }
187
+ html += '</tbody></table></div>';
188
+ container.innerHTML = html;
189
+ }
190
+
191
+ export function renderMachineInfo(machines) {
192
+ const container = document.getElementById('machine-info');
193
+ if (!container) return;
194
+
195
+ if (machines.length === 0) {
196
+ container.innerHTML = '<div class="empty-state"><p>No machine data.</p></div>';
197
+ return;
198
+ }
199
+
200
+ let html = '<div class="machine-grid">';
201
+ for (const m of machines) {
202
+ const failCount = m.resultCount - m.passCount;
203
+ html += `
204
+ <div class="machine-card">
205
+ <div class="machine-card-header">
206
+ <svg class="machine-card-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
207
+ <h3>${escapeHtml(m.cpus)}</h3>
208
+ </div>
209
+ <div class="machine-card-specs">
210
+ <div class="spec-row"><span class="spec-label">Platform</span><span class="spec-value">${m.platform}</span></div>
211
+ <div class="spec-row"><span class="spec-label">Arch</span><span class="spec-value">${m.arch}</span></div>
212
+ <div class="spec-row"><span class="spec-label">RAM</span><span class="spec-value">${m.totalMemoryGB} GB</span></div>
213
+ <div class="spec-row"><span class="spec-label">Results</span><span class="spec-value">${m.resultCount}</span></div>
214
+ <div class="spec-row"><span class="spec-label">Passed</span><span class="spec-value text-success">${m.passCount}</span></div>
215
+ <div class="spec-row"><span class="spec-label">Failed</span><span class="spec-value text-error">${failCount}</span></div>
216
+ ${m.llamaCppCommit ? `<div class="spec-row"><span class="spec-label">llama.cpp</span><span class="spec-value"><a href="https://github.com/ggml-org/llama.cpp/commit/${m.llamaCppCommit}" target="_blank" rel="noopener">${m.llamaCppCommit.slice(0, 10)}</a></span></div>` : ''}
217
+ </div>
218
+ </div>`;
219
+ }
220
+ html += '</div>';
221
+ container.innerHTML = html;
222
+ }
223
+
224
+ function escapeHtml(str) {
225
+ const div = document.createElement('div');
226
+ div.textContent = str;
227
+ return div.innerHTML;
228
+ }
229
+
230
+ export function renderCpuGpuTable(results) {
231
+ const container = document.getElementById('cpu-gpu-table');
232
+ if (!container) return;
233
+
234
+ const METRICS = [
235
+ { field: 'decode_tok_s', label: 'Decode tok/s' },
236
+ { field: 'prefill_tok_s', label: 'Prefill tok/s' },
237
+ ];
238
+
239
+ const passed = results.filter(r => r.status === 'done');
240
+ const cpuResults = passed.filter(r => r.nGpuLayers === 0);
241
+ const gpuResults = passed.filter(r => r.nGpuLayers !== 0);
242
+
243
+ if (cpuResults.length === 0 || gpuResults.length === 0) {
244
+ container.innerHTML = '<div class="empty-state"><p>Select "All Backends" to see CPU vs GPU comparison.</p></div>';
245
+ return;
246
+ }
247
+
248
+ function avg(items, field) {
249
+ const vals = items.map(r => r[field]).filter(v => v != null);
250
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
251
+ }
252
+
253
+ const gpuBrowsers = [...new Set(gpuResults.map(r => r.browser))].sort();
254
+
255
+ const cpuByModelVariant = groupBy(cpuResults, r => `${r.model}::${r.variant}`);
256
+ const gpuByModelVariant = groupBy(gpuResults, r => `${r.model}::${r.variant}`);
257
+
258
+ const keys = [...new Set([...Object.keys(cpuByModelVariant), ...Object.keys(gpuByModelVariant)])]
259
+ .filter(k => cpuByModelVariant[k] && gpuByModelVariant[k]);
260
+
261
+ if (keys.length === 0) {
262
+ container.innerHTML = '<div class="empty-state"><p>No matching model+variant pairs between CPU and GPU results.</p></div>';
263
+ return;
264
+ }
265
+
266
+ keys.sort((a, b) => {
267
+ const [aModel, aVar] = a.split('::');
268
+ const [bModel, bVar] = b.split('::');
269
+ if (aModel !== bModel) return aModel.localeCompare(bModel);
270
+ return quantSortKey(aVar) - quantSortKey(bVar);
271
+ });
272
+
273
+ // Two-row grouped header: row1 = group labels (CPU, Chromium, …), row2 = metric sub-labels
274
+ // CPU gets colspan = METRICS.length, each GPU browser gets colspan = METRICS.length * 2 (value + speedup per metric)
275
+ const gpuColspan = METRICS.length * 2;
276
+ let html = '<div class="table-card"><div class="results-wrapper"><table class="results-table"><thead>';
277
+
278
+ // Row 1: group headers
279
+ html += '<tr>';
280
+ html += '<th rowspan="2" class="th-group-border">Model</th><th rowspan="2" class="th-group-border">Quant</th>';
281
+ html += `<th colspan="${METRICS.length}" class="th-group th-group-border">CPU</th>`;
282
+ for (const b of gpuBrowsers) {
283
+ html += `<th colspan="${gpuColspan}" class="th-group th-group-border">${escapeHtml(b.charAt(0).toUpperCase() + b.slice(1))}</th>`;
284
+ }
285
+ html += '</tr>';
286
+
287
+ // Row 2: metric sub-headers
288
+ html += '<tr>';
289
+ for (const m of METRICS) {
290
+ html += `<th class="th-sub">${m.label}</th>`;
291
+ }
292
+ for (const b of gpuBrowsers) {
293
+ for (const m of METRICS) {
294
+ html += `<th class="th-sub">${m.label}</th><th class="th-sub">Speedup</th>`;
295
+ }
296
+ }
297
+ html += '</tr></thead><tbody>';
298
+
299
+ for (const key of keys) {
300
+ const [model, variant] = key.split('::');
301
+ const cpuItems = cpuByModelVariant[key] || [];
302
+ const gpuByBrowser = groupBy(gpuByModelVariant[key] || [], 'browser');
303
+
304
+ html += '<tr>';
305
+ html += `<td>${escapeHtml(model)}</td>`;
306
+ html += `<td><span class="mono">${escapeHtml(variant)}</span></td>`;
307
+
308
+ // CPU columns
309
+ for (const m of METRICS) {
310
+ const val = avg(cpuItems, m.field);
311
+ html += `<td><span class="mono">${formatTokS(val)}</span></td>`;
312
+ }
313
+
314
+ // GPU columns per browser
315
+ for (const b of gpuBrowsers) {
316
+ const gpuItems = gpuByBrowser[b] || [];
317
+ for (const m of METRICS) {
318
+ const cpuVal = avg(cpuItems, m.field);
319
+ const gpuVal = avg(gpuItems, m.field);
320
+ const speedup = cpuVal && gpuVal ? gpuVal / cpuVal : null;
321
+ const cls = speedup == null ? '' : speedup >= 3 ? 'text-success' : speedup >= 1.5 ? '' : speedup >= 1 ? 'text-muted' : 'text-error';
322
+ html += `<td><span class="mono">${formatTokS(gpuVal)}</span></td>`;
323
+ html += `<td><span class="mono ${cls}">${speedup != null ? speedup.toFixed(2) + '\u00d7' : '\u2014'}</span></td>`;
324
+ }
325
+ }
326
+
327
+ html += '</tr>';
328
+ }
329
+
330
+ html += '</tbody></table></div></div>';
331
+ container.innerHTML = html;
332
+ }
js/utils.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const BROWSER_COLORS = {
2
+ chromium: '#60a5fa',
3
+ firefox: '#fb923c',
4
+ webkit: '#a78bfa',
5
+ };
6
+
7
+ // Quantization types sorted by approximate bit-width (low -> high)
8
+ export const QUANT_ORDER = [
9
+ 'IQ1_S', 'IQ1_M',
10
+ 'IQ2_XXS', 'IQ2_XS', 'IQ2_S', 'IQ2_M', 'Q2_K', 'Q2_K_S',
11
+ 'IQ3_XXS', 'IQ3_XS', 'IQ3_S', 'Q3_K_S', 'Q3_K_M', 'Q3_K_L',
12
+ 'IQ4_NL', 'IQ4_XS', 'Q4_0', 'Q4_K_S', 'Q4_K_M',
13
+ 'Q5_0', 'Q5_K_S', 'Q5_K_M',
14
+ 'Q6_K',
15
+ 'Q8_0',
16
+ 'F16',
17
+ 'F32',
18
+ 'BF16',
19
+ ];
20
+
21
+ export function quantSortKey(q) {
22
+ const idx = QUANT_ORDER.indexOf(q);
23
+ return idx >= 0 ? idx : QUANT_ORDER.length;
24
+ }
25
+
26
+ export function formatTokS(v) {
27
+ if (v == null || isNaN(v)) return '\u2014';
28
+ return v.toFixed(1);
29
+ }
30
+
31
+ export function formatMs(v) {
32
+ if (v == null || isNaN(v)) return '\u2014';
33
+ return v.toFixed(1);
34
+ }
35
+
36
+ export function categorizeError(err) {
37
+ if (!err) return null;
38
+ const e = err.toLowerCase();
39
+ if (e.includes('out of memory') || e.includes('oom') || e.includes('memory allocation')) return 'OOM';
40
+ if (e.includes('wasm') || e.includes('abort') || e.includes('unreachable')) return 'WASM Abort';
41
+ if (e.includes('timeout') || e.includes('timed out')) return 'Timeout';
42
+ if (e.includes('download') || e.includes('fetch') || e.includes('404') || e.includes('network')) return 'Download Failed';
43
+ return 'Other';
44
+ }
45
+
46
+ export function groupBy(arr, keyFn) {
47
+ const map = {};
48
+ for (const item of arr) {
49
+ const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn];
50
+ if (!map[key]) map[key] = [];
51
+ map[key].push(item);
52
+ }
53
+ return map;
54
+ }
methodology.html ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>Methodology — WebGPU Bench</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="css/style.css">
12
+ </head>
13
+ <body>
14
+ <header class="header">
15
+ <div class="header-inner">
16
+ <a href="index.html" class="header-brand">
17
+ <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
18
+ <span class="header-title">WebGPU Bench</span>
19
+ </a>
20
+ <nav class="header-nav">
21
+ <a href="index.html" class="header-link">Dashboard</a>
22
+ <a href="run/" class="header-link">Run</a>
23
+ </nav>
24
+ </div>
25
+ </header>
26
+
27
+ <div class="methodology-content">
28
+ <a href="index.html" class="back-link">
29
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
30
+ Back to Dashboard
31
+ </a>
32
+
33
+ <h2>How Benchmarks Work</h2>
34
+ <ol>
35
+ <li><code>build.sh</code> compiles llama.cpp to WebAssembly with WebGPU support via Emscripten + emdawnwebgpu, producing two WASM variants: JSPI (Chrome) and Asyncify (Firefox, Safari).</li>
36
+ <li><code>runner.js</code> launches Playwright browsers and navigates to <code>harness.html</code>.</li>
37
+ <li><code>harness.js</code> detects JSPI support and loads the correct WASM variant.</li>
38
+ <li>The GGUF model is downloaded from HuggingFace directly in the browser.</li>
39
+ <li>Inference runs via WebGPU (or CPU fallback) using llama.cpp's C API with greedy sampling for deterministic output.</li>
40
+ <li>Performance metrics are collected via <code>llama_perf_context()</code> and returned to Playwright.</li>
41
+ <li>A fresh browser instance is launched for each variant to prevent WASM memory accumulation (OOM fix).</li>
42
+ </ol>
43
+
44
+ <h2>Dashboard Columns</h2>
45
+ <table>
46
+ <thead>
47
+ <tr><th>Column</th><th>Description</th></tr>
48
+ </thead>
49
+ <tbody>
50
+ <tr><td>Machine</td><td>Machine slug identifying the hardware (e.g. <code>apple-m3-16gb-darwin</code>)</td></tr>
51
+ <tr><td>Model</td><td>Model name (e.g. Llama-3.2-1B-Instruct)</td></tr>
52
+ <tr><td>Quant</td><td>Quantization variant (e.g. Q4_K_M, Q8_0)</td></tr>
53
+ <tr><td>Size (MB)</td><td>Model file size in megabytes</td></tr>
54
+ <tr><td>Browser</td><td>Browser used for the benchmark (chromium, firefox, webkit)</td></tr>
55
+ <tr><td>Status</td><td>PASS if inference completed successfully, FAIL otherwise</td></tr>
56
+ <tr><td>Build</td><td><code>jspi</code> or <code>asyncify</code> — which WASM variant was used. Chrome supports JSPI; Firefox and Safari use Asyncify.</td></tr>
57
+ <tr><td>WebGPU</td><td>Whether WebGPU was available in the browser. If not, inference falls back to CPU.</td></tr>
58
+ <tr><td>Decode tok/s</td><td>Token generation speed (tokens/sec) — main performance metric</td></tr>
59
+ <tr><td>Prefill tok/s</td><td>Prompt processing speed (tokens/sec)</td></tr>
60
+ <tr><td>n_eval</td><td>Number of tokens generated during decode</td></tr>
61
+ <tr><td>t_eval (ms)</td><td>Total decode time in milliseconds</td></tr>
62
+ <tr><td>n_p_eval</td><td>Number of prompt tokens processed during prefill</td></tr>
63
+ <tr><td>t_p_eval (ms)</td><td>Total prefill time in milliseconds</td></tr>
64
+ <tr><td>Wall (s)</td><td>Total wall-clock time for the benchmark run in seconds (includes model download, load, and inference)</td></tr>
65
+ <tr><td>CPU Match</td><td>Consistency with CPU baseline — percentage of token positions where WebGPU and CPU agree on the top-1 token. Only present when benchmarks are run with <code>--consistency</code>. See Consistency Measurement below.</td></tr>
66
+ <tr><td>Error</td><td>Error message and category (OOM, WASM Abort, Timeout, etc.) when status is FAIL</td></tr>
67
+ </tbody>
68
+ </table>
69
+
70
+ <h2>Error Categories</h2>
71
+ <table>
72
+ <thead>
73
+ <tr><th>Category</th><th>Pattern</th><th>Typical Cause</th></tr>
74
+ </thead>
75
+ <tbody>
76
+ <tr><td>OOM</td><td>out of memory, memory allocation</td><td>Model too large for available WASM memory</td></tr>
77
+ <tr><td>WASM Abort</td><td>wasm, abort, unreachable</td><td>WASM execution error, often from unsupported operations</td></tr>
78
+ <tr><td>Timeout</td><td>timeout, timed out</td><td>Benchmark exceeded time limit (model download or inference)</td></tr>
79
+ <tr><td>Download Failed</td><td>download, fetch, 404, network</td><td>Model file not found or network error</td></tr>
80
+ <tr><td>Other</td><td>everything else</td><td>Uncategorized errors</td></tr>
81
+ </tbody>
82
+ </table>
83
+
84
+ <h2>Consistency Measurement</h2>
85
+ <p>The <code>--consistency</code> flag measures how faithfully the WebGPU backend reproduces the CPU computation for each quantization type.</p>
86
+
87
+ <h3>How it works</h3>
88
+ <p>For each variant, two runs are performed:</p>
89
+ <ol>
90
+ <li><strong>CPU baseline</strong> (<code>n_gpu_layers=0</code>): greedy-decodes 128 tokens and records the token ID sequence. Cached to <code>results/cpu_baselines.json</code>. When testing multiple browsers, the baseline is collected once on the first browser and shared across all browsers (CPU output is identical regardless of JSPI vs Asyncify). When testing a single browser, the baseline runs in that same browser.</li>
91
+ <li><strong>WebGPU run</strong> (<code>n_gpu_layers=999</code>): performs a forced-decoding pass — feeds the CPU's token sequence one token at a time and checks whether the WebGPU backend independently predicts the same top-1 token at each position.</li>
92
+ </ol>
93
+
94
+ <h3>Why forced decoding</h3>
95
+ <p>Naively comparing generated text suffers from cascading divergence: a single token difference changes the KV cache context for all subsequent tokens. Forced decoding evaluates each position independently, giving a clean per-token accuracy signal.</p>
96
+
97
+ <h3>Interpreting CPU Match</h3>
98
+ <table>
99
+ <thead>
100
+ <tr><th>CPU Match</th><th>Interpretation</th></tr>
101
+ </thead>
102
+ <tbody>
103
+ <tr><td><code>100.0%</code></td><td>Numerically identical to CPU — no precision issues</td></tr>
104
+ <tr><td><code>95–99%</code></td><td>A few tokens differ due to near-equal logits — expected for lower-precision quants</td></tr>
105
+ <tr><td><code>&lt; 90%</code></td><td>Systematic precision issues — GPU kernel may need investigation</td></tr>
106
+ <tr><td><code>0.0%</code></td><td>First token wrong — quantization kernel likely broken</td></tr>
107
+ <tr><td><code>—</code></td><td>No consistency data — benchmarks were run without <code>--consistency</code></td></tr>
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ </body>
112
+ </html>
run/index.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="color-scheme" content="light dark">
7
+ <script>(function(){var t=localStorage.getItem('theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
8
+ <title>Run — WebGPU Bench</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="../css/style.css">
13
+ <!-- Import map so `@huggingface/hub` resolves in the browser via esm.sh.
14
+ Must appear before any <script type="module">. -->
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "@huggingface/hub": "https://esm.sh/@huggingface/hub"
19
+ }
20
+ }
21
+ </script>
22
+ </head>
23
+ <body>
24
+ <header class="header">
25
+ <div class="header-inner">
26
+ <a href="../index.html" class="header-brand">
27
+ <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
28
+ <span class="header-title">WebGPU Bench</span>
29
+ </a>
30
+ <nav class="header-nav">
31
+ <a href="../index.html" class="header-link">Dashboard</a>
32
+ <a href="../methodology.html" class="header-link">Methodology</a>
33
+ <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
34
+ <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
35
+ <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
36
+ </button>
37
+ <a href="https://github.com/abhijitramesh/webgpu-bench" target="_blank" rel="noopener" class="header-link">
38
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.39.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.82.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
39
+ GitHub
40
+ </a>
41
+ </nav>
42
+ </div>
43
+ </header>
44
+
45
+ <main>
46
+ <section id="run-section" class="dash-section">
47
+ <div class="container">
48
+ <div class="section-header">
49
+ <h2>Run a benchmark</h2>
50
+ <span id="run-mode-badge" class="badge run-mode-badge">…</span>
51
+ </div>
52
+
53
+ <!-- Read-only banner (shown on GH Pages — no backend, no OAuth) -->
54
+ <div id="run-pages-banner" class="run-pages-banner" hidden>
55
+ <span>Read-only mode — run benchmarks locally but submit via the <a href="https://abhijitramesh-webgpu-bench.static.hf.space/run/" target="_blank" rel="noopener">HF Space</a>.</span>
56
+ </div>
57
+
58
+ <!-- HF sign-in + submit (space surface only) -->
59
+ <div id="hub-row" class="card hub-row" hidden>
60
+ <div class="hub-row-inner">
61
+ <div class="hub-row-info">
62
+ <span id="hf-user"></span>
63
+ </div>
64
+ <div class="hub-row-actions">
65
+ <button id="btn-signin" class="btn btn-secondary" type="button">Sign in with Hugging Face</button>
66
+ <button id="btn-submit" class="btn btn-primary" type="button" disabled hidden>Submit to leaderboard</button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Device & budget -->
72
+ <div class="summary-grid run-device-grid">
73
+ <div class="stat-card run-device-card">
74
+ <span class="stat-card-label">Device</span>
75
+ <div class="run-device-rows">
76
+ <div class="run-device-row"><span class="run-device-row-label">Browser</span><span class="run-device-row-value" id="device-browser">—</span></div>
77
+ <div class="run-device-row"><span class="run-device-row-label">Platform</span><span class="run-device-row-value" id="device-platform">—</span></div>
78
+ <div class="run-device-row"><span class="run-device-row-label">GPU</span><span class="run-device-row-value" id="device-gpu">—</span></div>
79
+ </div>
80
+ </div>
81
+ <div class="stat-card run-device-card">
82
+ <span class="stat-card-label">Capability</span>
83
+ <div class="run-device-rows">
84
+ <div class="run-device-row"><span class="run-device-row-label">deviceMemory</span><span class="run-device-row-value" id="device-memory">—</span></div>
85
+ <div class="run-device-row"><span class="run-device-row-label">WebGPU</span><span class="run-device-row-value" id="device-webgpu">—</span></div>
86
+ </div>
87
+ </div>
88
+ <div class="stat-card run-device-card">
89
+ <span class="stat-card-label">Model budget</span>
90
+ <div class="run-device-rows">
91
+ <div class="run-device-row"><span class="run-device-row-label">Max size</span><span class="run-device-row-value" id="device-budget">—</span></div>
92
+ <div class="run-device-note" id="device-budget-source"></div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Hide filters, iterations, actions -->
98
+ <div class="filter-bar">
99
+ <div class="filter-bar-inner run-filters">
100
+ <div class="filter-group">
101
+ <span class="filter-label">Hide</span>
102
+ <div class="run-filters-checks">
103
+ <label class="run-hide-label"><input type="checkbox" id="hide-ud"> UD</label>
104
+ <label class="run-hide-label"><input type="checkbox" id="hide-iq"> IQ</label>
105
+ <label class="run-hide-label"><input type="checkbox" id="hide-hifp"> BF16/F16</label>
106
+ </div>
107
+ </div>
108
+ <div class="filter-group">
109
+ <label class="filter-label" for="iterations-input">Iterations</label>
110
+ <input type="number" id="iterations-input" class="filter-select run-iter-input" value="5" min="1" max="50" step="1">
111
+ </div>
112
+ <div class="run-actions">
113
+ <span id="queue-status"></span>
114
+ <button class="btn btn-secondary" id="btn-download" type="button" disabled>Download selected</button>
115
+ <button class="btn btn-primary" id="btn-run" type="button" disabled>Run benchmarks</button>
116
+ <button class="btn btn-danger" id="btn-abort" type="button" hidden>Abort</button>
117
+ <button class="btn btn-secondary" id="btn-purge" type="button" hidden>Purge OPFS cache</button>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <!-- Family / variant list -->
123
+ <div id="run-models" class="run-models-stack">
124
+ <div class="empty-state">Loading models…</div>
125
+ </div>
126
+
127
+ <!-- Progress -->
128
+ <div class="section-header">
129
+ <h3 class="subsection-title">Progress</h3>
130
+ </div>
131
+ <div class="table-card">
132
+ <div id="run-progress-wrapper" class="results-wrapper"></div>
133
+ </div>
134
+
135
+ <!-- Output -->
136
+ <div class="section-header" style="margin-top: 32px;">
137
+ <h3 class="subsection-title">Output</h3>
138
+ </div>
139
+ <div class="card run-output">
140
+ <label id="save-local-row" class="run-output-toggle" hidden>
141
+ <input type="checkbox" id="save-local" checked>
142
+ Save to <code>results/results.json</code> on this server
143
+ </label>
144
+ <textarea id="output-textarea" class="run-output-textarea" readonly spellcheck="false"></textarea>
145
+ <div class="run-output-buttons">
146
+ <button class="btn btn-secondary" id="btn-copy" type="button">Copy</button>
147
+ <button class="btn btn-secondary" id="btn-download-json" type="button">Download JSON</button>
148
+ </div>
149
+ </div>
150
+
151
+ <!-- Log -->
152
+ <details id="run-log" class="card run-log" style="margin-top: 16px;">
153
+ <summary>Run log</summary>
154
+ <pre id="log-output" class="run-log-pre"></pre>
155
+ </details>
156
+ </div>
157
+ </section>
158
+ </main>
159
+
160
+ <script type="module">
161
+ import { mountRunSection } from '../js/run/controller.js';
162
+ // Theme toggle wiring (kept here since app.js no longer runs on this page).
163
+ document.getElementById('theme-toggle')?.addEventListener('click', () => {
164
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
165
+ document.documentElement.setAttribute('data-theme', next);
166
+ localStorage.setItem('theme', next);
167
+ });
168
+ mountRunSection();
169
+ </script>
170
+ </body>
171
+ </html>