Spaces:
Running
Running
Add Phase 5 benchmark and runtime provenance
Browse files- .gitattributes +0 -2
- README.md +32 -2
- bench/index.html +18 -0
- public/wasm/wllama-compat.wasm +2 -2
- public/wasm/wllama.wasm +2 -2
- scripts/verify-wllama-assets.mjs +18 -0
- src/app/App.test.ts +20 -1
- src/app/App.tsx +47 -3
- src/app/styles.css +606 -0
- src/bench/BenchApp.tsx +589 -0
- src/bench/report.test.ts +259 -0
- src/bench/report.ts +304 -0
- src/engine/device-gate.test.ts +22 -0
- src/engine/device-gate.ts +13 -5
- src/engine/protocol.ts +20 -0
- src/engine/runtime-download.test.ts +27 -0
- src/engine/runtime-generate.test.ts +48 -0
- src/engine/runtime.ts +89 -8
- src/main.tsx +7 -1
- vendor/wllama-bonsai/PATCH.diff +181 -0
- vendor/wllama-bonsai/SOURCE.json +29 -4
- vendor/wllama-bonsai/esm/index.cjs +1 -0
- vendor/wllama-bonsai/esm/index.js +1 -0
- vendor/wllama-bonsai/esm/index.min.js +0 -0
- vendor/wllama-bonsai/esm/index.min.js.map +0 -0
- vendor/wllama-bonsai/esm/types/types.d.ts +1 -0
- vendor/wllama-bonsai/esm/wasm/source-map.d.ts +1 -0
- vite.config.ts +8 -0
.gitattributes
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
public/wasm/*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
dist/wasm/wllama-compat.wasm filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
dist/wasm/wllama.wasm filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
public/wasm/*.wasm filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
README.md
CHANGED
|
@@ -62,6 +62,32 @@ path. Edge/Windows, Safari, and Firefox remain explicit release-matrix gaps;
|
|
| 62 |
dense CPU fallback is offered only when that browser exposes the required
|
| 63 |
cross-origin-isolated WASM features.
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
## DFlash research lane
|
| 66 |
|
| 67 |
DFlash is visible only as an inspection-only experiment. Its BF16 source and
|
|
@@ -88,6 +114,9 @@ stay in the browser; only model/static asset downloads contact Hugging Face.
|
|
| 88 |
- Dawn native WebGPU validation: `18eb229ef5f707c1464cc581252e7603c73a3ef0`
|
| 89 |
- custom wllama source: `912c18b75d4358c1405a64646b8dbe43a205943b`
|
| 90 |
- custom nested llama.cpp: `00fa7cb284cbf133fc426733bd64238a3588a33e`
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
The vendored runtime exposes the validated token-embedding WebGPU placement
|
| 93 |
needed for single-graph Bonsai inference and fails closed when placement or the
|
|
@@ -96,8 +125,9 @@ backend tripwire disagrees. Its exact JS/WASM hashes are checked during
|
|
| 96 |
`vendor/wllama-bonsai/SOURCE.json`. Third-party notices are in
|
| 97 |
`THIRD_PARTY_NOTICES.md` and `public/licenses/`.
|
| 98 |
|
| 99 |
-
|
| 100 |
-
combinations are not
|
|
|
|
| 101 |
|
| 102 |
## Licenses and attribution
|
| 103 |
|
|
|
|
| 62 |
dense CPU fallback is offered only when that browser exposes the required
|
| 63 |
cross-origin-isolated WASM features.
|
| 64 |
|
| 65 |
+
## Growth benchmark
|
| 66 |
+
|
| 67 |
+
[`/bench/`](https://wavecut-bonsai-webgpu-chat.static.hf.space/bench/) is a
|
| 68 |
+
separate, shareable benchmark surface. It loads nothing until **Run benchmark**
|
| 69 |
+
is pressed, then records first-load and verified-cache reload wall time, TTFT,
|
| 70 |
+
prefill/decode throughput, graph placement, device limits, engine revisions,
|
| 71 |
+
the exact runtime artifact hash, and a fixed temperature-0 prompt. The report
|
| 72 |
+
exports as JSON; generated model text is deliberately excluded.
|
| 73 |
+
|
| 74 |
+
`n_batch` and `n_ubatch` are available there as explicitly experimental shape
|
| 75 |
+
controls. The browser result reports both requested and actual llama.cpp
|
| 76 |
+
values. A pinned 1.7B WebGPU smoke proved `256/128` end to end with one graph,
|
| 77 |
+
29/29 GPU layers, and zero CPU ops. Flash Attention remains locked off and KV
|
| 78 |
+
remains F16/F16 on this page until candidate settings pass graph and output
|
| 79 |
+
parity checks.
|
| 80 |
+
|
| 81 |
+
The public prewarmed-cache baseline below is one Apple `metal-3` / headless
|
| 82 |
+
Chrome sample, not a cross-device study and not a first-download claim:
|
| 83 |
+
|
| 84 |
+
| Tier | Backend | Load | TTFT | Decode |
|
| 85 |
+
| --- | --- | ---: | ---: | ---: |
|
| 86 |
+
| 1.7B | WebGPU | 3,314 ms | 300 ms | 71.9 tok/s |
|
| 87 |
+
| 1.7B | CPU-WASM | 3,844 ms | 6,092 ms | 10.6 tok/s |
|
| 88 |
+
| 8B | WebGPU | 9,870 ms | 998 ms | 33.8 tok/s |
|
| 89 |
+
| 27B | WebGPU | 29,488 ms | 3,348 ms | 11.9 tok/s |
|
| 90 |
+
|
| 91 |
## DFlash research lane
|
| 92 |
|
| 93 |
DFlash is visible only as an inspection-only experiment. Its BF16 source and
|
|
|
|
| 114 |
- Dawn native WebGPU validation: `18eb229ef5f707c1464cc581252e7603c73a3ef0`
|
| 115 |
- custom wllama source: `912c18b75d4358c1405a64646b8dbe43a205943b`
|
| 116 |
- custom nested llama.cpp: `00fa7cb284cbf133fc426733bd64238a3588a33e`
|
| 117 |
+
- custom patch set: `62efebdb3071ba49b87c835c1b45716257fe5ea6cb7eec6ee82f6a2f33bc581c`
|
| 118 |
+
- JSPI WASM: `dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa`
|
| 119 |
+
- compat WASM: `eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e`
|
| 120 |
|
| 121 |
The vendored runtime exposes the validated token-embedding WebGPU placement
|
| 122 |
needed for single-graph Bonsai inference and fails closed when placement or the
|
|
|
|
| 125 |
`vendor/wllama-bonsai/SOURCE.json`. Third-party notices are in
|
| 126 |
`THIRD_PARTY_NOTICES.md` and `public/licenses/`.
|
| 127 |
|
| 128 |
+
The pinned WebGPU source implements Flash Attention with Q4_0/Q8_0 K/V, but
|
| 129 |
+
those combinations are not yet validated for Bonsai browser parity. Flash
|
| 130 |
+
Attention therefore remains off for the release path.
|
| 131 |
|
| 132 |
## Licenses and attribution
|
| 133 |
|
bench/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="theme-color" content="#111512" />
|
| 7 |
+
<link rel="icon" href="data:," />
|
| 8 |
+
<meta
|
| 9 |
+
name="description"
|
| 10 |
+
content="Bonsai browser-local benchmark — repeatable cold load, warm load, prefill, and decode evidence."
|
| 11 |
+
/>
|
| 12 |
+
<title>Bonsai Growth Bench</title>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 17 |
+
</body>
|
| 18 |
+
</html>
|
public/wasm/wllama-compat.wasm
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e
|
| 3 |
+
size 14864188
|
public/wasm/wllama.wasm
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa
|
| 3 |
+
size 8017169
|
scripts/verify-wllama-assets.mjs
CHANGED
|
@@ -12,9 +12,20 @@ if (source.wllamaRevision !== '912c18b75d4358c1405a64646b8dbe43a205943b') {
|
|
| 12 |
if (source.llamaCppRevision !== '00fa7cb284cbf133fc426733bd64238a3588a33e') {
|
| 13 |
throw new Error(`Unexpected llama.cpp revision: ${String(source.llamaCppRevision)}`);
|
| 14 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
for (const file of source.files) {
|
| 17 |
const bytes = await readFile(resolve(root, file.path));
|
|
|
|
|
|
|
|
|
|
| 18 |
const digest = createHash('sha256').update(bytes).digest('hex');
|
| 19 |
if (digest !== file.sha256) {
|
| 20 |
throw new Error(`SHA-256 mismatch for ${file.path}: ${digest}`);
|
|
@@ -26,3 +37,10 @@ const declarations = await readFile(resolve(root, 'vendor/wllama-bonsai/esm/type
|
|
| 26 |
if (!declarations.includes('offload_token_embedding?: boolean')) {
|
| 27 |
throw new Error('Vendored Bonsai wllama declarations lack offload_token_embedding');
|
| 28 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if (source.llamaCppRevision !== '00fa7cb284cbf133fc426733bd64238a3588a33e') {
|
| 13 |
throw new Error(`Unexpected llama.cpp revision: ${String(source.llamaCppRevision)}`);
|
| 14 |
}
|
| 15 |
+
if (source.schemaVersion !== 2 || !source.patchSet?.sha256 || !source.build?.dawnArchiveSha256) {
|
| 16 |
+
throw new Error('Vendored Bonsai wllama provenance is incomplete');
|
| 17 |
+
}
|
| 18 |
+
const patchBytes = await readFile(resolve(root, source.patchSet.path));
|
| 19 |
+
const patchDigest = createHash('sha256').update(patchBytes).digest('hex');
|
| 20 |
+
if (patchBytes.byteLength !== source.patchSet.bytes || patchDigest !== source.patchSet.sha256) {
|
| 21 |
+
throw new Error(`Bonsai wllama patch-set mismatch: ${patchDigest}`);
|
| 22 |
+
}
|
| 23 |
|
| 24 |
for (const file of source.files) {
|
| 25 |
const bytes = await readFile(resolve(root, file.path));
|
| 26 |
+
if (bytes.byteLength !== file.bytes) {
|
| 27 |
+
throw new Error(`Byte-size mismatch for ${file.path}: ${bytes.byteLength}`);
|
| 28 |
+
}
|
| 29 |
const digest = createHash('sha256').update(bytes).digest('hex');
|
| 30 |
if (digest !== file.sha256) {
|
| 31 |
throw new Error(`SHA-256 mismatch for ${file.path}: ${digest}`);
|
|
|
|
| 37 |
if (!declarations.includes('offload_token_embedding?: boolean')) {
|
| 38 |
throw new Error('Vendored Bonsai wllama declarations lack offload_token_embedding');
|
| 39 |
}
|
| 40 |
+
if (!declarations.includes('n_ubatch?: number')) {
|
| 41 |
+
throw new Error('Vendored Bonsai wllama declarations lack n_ubatch');
|
| 42 |
+
}
|
| 43 |
+
const moduleSource = await readFile(resolve(root, 'vendor/wllama-bonsai/esm/index.js'), 'utf8');
|
| 44 |
+
if (!moduleSource.includes('n_ubatch: params.n_ubatch')) {
|
| 45 |
+
throw new Error('Vendored Bonsai wllama module does not forward n_ubatch');
|
| 46 |
+
}
|
src/app/App.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { describe, expect, it } from 'vitest';
|
| 2 |
import { EngineClientError } from '../engine';
|
| 3 |
-
import { requiresModelReload } from './App';
|
| 4 |
|
| 5 |
function engineError(code: string): EngineClientError {
|
| 6 |
return new EngineClientError({ code, message: code });
|
|
@@ -19,3 +19,22 @@ describe('App model invalidation policy', () => {
|
|
| 19 |
expect(requiresModelReload(engineError('ABORTED'))).toBe(false);
|
| 20 |
});
|
| 21 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { describe, expect, it } from 'vitest';
|
| 2 |
import { EngineClientError } from '../engine';
|
| 3 |
+
import { reconcileCompletionTokens, reconcileTotalTokens, requiresModelReload } from './App';
|
| 4 |
|
| 5 |
function engineError(code: string): EngineClientError {
|
| 6 |
return new EngineClientError({ code, message: code });
|
|
|
|
| 19 |
expect(requiresModelReload(engineError('ABORTED'))).toBe(false);
|
| 20 |
});
|
| 21 |
});
|
| 22 |
+
|
| 23 |
+
describe('App streamed completion telemetry', () => {
|
| 24 |
+
it('uses observed stream events when final engine usage is incomplete', () => {
|
| 25 |
+
expect(reconcileCompletionTokens(5, 64)).toBe(64);
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it('keeps the engine count when one stream event contains multiple tokens', () => {
|
| 29 |
+
expect(reconcileCompletionTokens(12, 8)).toBe(12);
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it('fails closed to the observed count for invalid engine usage', () => {
|
| 33 |
+
expect(reconcileCompletionTokens(Number.NaN, 7)).toBe(7);
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
it('keeps context usage consistent with a reconciled completion count', () => {
|
| 37 |
+
expect(reconcileTotalTokens(5, 5, 64)).toBe(69);
|
| 38 |
+
expect(reconcileTotalTokens(80, 5, 64)).toBe(80);
|
| 39 |
+
});
|
| 40 |
+
});
|
src/app/App.tsx
CHANGED
|
@@ -148,6 +148,39 @@ export function requiresModelReload(error: unknown): error is EngineClientError
|
|
| 148 |
].includes(error.code);
|
| 149 |
}
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
function appendSegment(current: string, next: string): string {
|
| 152 |
const trimmed = next.trim();
|
| 153 |
if (!trimmed) return current;
|
|
@@ -659,8 +692,19 @@ export function App() {
|
|
| 659 |
},
|
| 660 |
});
|
| 661 |
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
tokensPerSecond = result.timings?.predictedTokensPerSecond ?? tokensPerSecond;
|
| 665 |
const thought = parseThoughtStream(result.text);
|
| 666 |
const textual = parseToolCalls(thought.content);
|
|
@@ -752,7 +796,7 @@ export function App() {
|
|
| 752 |
}));
|
| 753 |
setTelemetry((current) => ({
|
| 754 |
...current,
|
| 755 |
-
contextUsed: Math.min(current.contextLimit, lastTotalTokens
|
| 756 |
tokensPerSecond,
|
| 757 |
timeToFirstTokenMs: ttft,
|
| 758 |
graphSplits: report.nGraphSplits,
|
|
|
|
| 148 |
].includes(error.code);
|
| 149 |
}
|
| 150 |
|
| 151 |
+
export function reconcileCompletionTokens(
|
| 152 |
+
engineCompletionTokens: number | undefined,
|
| 153 |
+
streamedTokenEvents: number,
|
| 154 |
+
): number {
|
| 155 |
+
const engineCount = typeof engineCompletionTokens === 'number'
|
| 156 |
+
&& Number.isSafeInteger(engineCompletionTokens)
|
| 157 |
+
&& engineCompletionTokens >= 0
|
| 158 |
+
? engineCompletionTokens
|
| 159 |
+
: 0;
|
| 160 |
+
const streamCount = Number.isSafeInteger(streamedTokenEvents) && streamedTokenEvents >= 0
|
| 161 |
+
? streamedTokenEvents
|
| 162 |
+
: 0;
|
| 163 |
+
return Math.max(engineCount, streamCount);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export function reconcileTotalTokens(
|
| 167 |
+
engineTotalTokens: number | undefined,
|
| 168 |
+
enginePromptTokens: number | undefined,
|
| 169 |
+
completionTokens: number,
|
| 170 |
+
): number {
|
| 171 |
+
const totalCount = typeof engineTotalTokens === 'number'
|
| 172 |
+
&& Number.isSafeInteger(engineTotalTokens)
|
| 173 |
+
&& engineTotalTokens >= 0
|
| 174 |
+
? engineTotalTokens
|
| 175 |
+
: 0;
|
| 176 |
+
const promptCount = typeof enginePromptTokens === 'number'
|
| 177 |
+
&& Number.isSafeInteger(enginePromptTokens)
|
| 178 |
+
&& enginePromptTokens >= 0
|
| 179 |
+
? enginePromptTokens
|
| 180 |
+
: 0;
|
| 181 |
+
return Math.max(totalCount, promptCount + completionTokens);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
function appendSegment(current: string, next: string): string {
|
| 185 |
const trimmed = next.trim();
|
| 186 |
if (!trimmed) return current;
|
|
|
|
| 692 |
},
|
| 693 |
});
|
| 694 |
|
| 695 |
+
const roundCompletionTokens = reconcileCompletionTokens(
|
| 696 |
+
result.usage?.completionTokens,
|
| 697 |
+
streamedTokenEvents,
|
| 698 |
+
);
|
| 699 |
+
totalCompletionTokens += roundCompletionTokens;
|
| 700 |
+
lastTotalTokens = Math.max(
|
| 701 |
+
lastTotalTokens,
|
| 702 |
+
reconcileTotalTokens(
|
| 703 |
+
result.usage?.totalTokens,
|
| 704 |
+
result.usage?.promptTokens,
|
| 705 |
+
roundCompletionTokens,
|
| 706 |
+
),
|
| 707 |
+
);
|
| 708 |
tokensPerSecond = result.timings?.predictedTokensPerSecond ?? tokensPerSecond;
|
| 709 |
const thought = parseThoughtStream(result.text);
|
| 710 |
const textual = parseToolCalls(thought.content);
|
|
|
|
| 796 |
}));
|
| 797 |
setTelemetry((current) => ({
|
| 798 |
...current,
|
| 799 |
+
contextUsed: Math.min(current.contextLimit, Math.max(lastTotalTokens, totalCompletionTokens)),
|
| 800 |
tokensPerSecond,
|
| 801 |
timeToFirstTokenMs: ttft,
|
| 802 |
graphSplits: report.nGraphSplits,
|
src/app/styles.css
CHANGED
|
@@ -2155,3 +2155,609 @@ button:disabled {
|
|
| 2155 |
transition-duration: 0.01ms !important;
|
| 2156 |
}
|
| 2157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2155 |
transition-duration: 0.01ms !important;
|
| 2156 |
}
|
| 2157 |
}
|
| 2158 |
+
|
| 2159 |
+
/* Growth bench: a companion field instrument, served independently at /bench/. */
|
| 2160 |
+
.bench-page {
|
| 2161 |
+
min-height: 100dvh;
|
| 2162 |
+
color: var(--ink);
|
| 2163 |
+
background: var(--paper);
|
| 2164 |
+
}
|
| 2165 |
+
|
| 2166 |
+
.bench-masthead {
|
| 2167 |
+
min-height: 86px;
|
| 2168 |
+
display: grid;
|
| 2169 |
+
grid-template-columns: minmax(310px, 1fr) minmax(360px, 1.2fr) auto;
|
| 2170 |
+
align-items: stretch;
|
| 2171 |
+
color: var(--paper);
|
| 2172 |
+
border-bottom: 1px solid var(--phosphor);
|
| 2173 |
+
background: var(--ink);
|
| 2174 |
+
}
|
| 2175 |
+
|
| 2176 |
+
.bench-masthead .brand-block h1 {
|
| 2177 |
+
color: var(--paper);
|
| 2178 |
+
}
|
| 2179 |
+
|
| 2180 |
+
.bench-masthead-note {
|
| 2181 |
+
display: flex;
|
| 2182 |
+
flex-direction: column;
|
| 2183 |
+
justify-content: center;
|
| 2184 |
+
gap: 5px;
|
| 2185 |
+
padding: 14px 28px;
|
| 2186 |
+
border-inline: 1px solid var(--line-light);
|
| 2187 |
+
background: linear-gradient(110deg, rgba(185, 255, 69, 0.09), transparent 72%);
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
.bench-masthead-note strong {
|
| 2191 |
+
color: var(--phosphor);
|
| 2192 |
+
font: 700 9px/1 var(--mono);
|
| 2193 |
+
letter-spacing: 0.11em;
|
| 2194 |
+
text-transform: uppercase;
|
| 2195 |
+
}
|
| 2196 |
+
|
| 2197 |
+
.bench-masthead-note span {
|
| 2198 |
+
max-width: 520px;
|
| 2199 |
+
color: rgba(232, 223, 202, 0.55);
|
| 2200 |
+
font: 10px/1.35 var(--body);
|
| 2201 |
+
}
|
| 2202 |
+
|
| 2203 |
+
.bench-return {
|
| 2204 |
+
display: flex;
|
| 2205 |
+
align-items: center;
|
| 2206 |
+
gap: 8px;
|
| 2207 |
+
padding: 0 25px;
|
| 2208 |
+
color: var(--paper);
|
| 2209 |
+
font: 700 8px/1 var(--mono);
|
| 2210 |
+
letter-spacing: 0.08em;
|
| 2211 |
+
text-decoration: none;
|
| 2212 |
+
text-transform: uppercase;
|
| 2213 |
+
}
|
| 2214 |
+
|
| 2215 |
+
.bench-return:hover {
|
| 2216 |
+
color: var(--phosphor);
|
| 2217 |
+
}
|
| 2218 |
+
|
| 2219 |
+
.bench-layout {
|
| 2220 |
+
min-height: calc(100dvh - 86px);
|
| 2221 |
+
display: grid;
|
| 2222 |
+
grid-template-columns: minmax(275px, 330px) minmax(0, 1fr);
|
| 2223 |
+
}
|
| 2224 |
+
|
| 2225 |
+
.bench-controls {
|
| 2226 |
+
padding: 28px 22px;
|
| 2227 |
+
color: var(--paper);
|
| 2228 |
+
border-right: 1px solid var(--phosphor);
|
| 2229 |
+
background:
|
| 2230 |
+
repeating-radial-gradient(ellipse at -12% 102%, transparent 0 24px, rgba(185, 255, 69, 0.045) 25px 26px),
|
| 2231 |
+
var(--ink);
|
| 2232 |
+
}
|
| 2233 |
+
|
| 2234 |
+
.bench-controls h2 {
|
| 2235 |
+
margin: 0;
|
| 2236 |
+
font: 400 30px/1 var(--display);
|
| 2237 |
+
letter-spacing: -0.03em;
|
| 2238 |
+
}
|
| 2239 |
+
|
| 2240 |
+
.bench-control-intro {
|
| 2241 |
+
margin: 9px 0 22px;
|
| 2242 |
+
color: rgba(232, 223, 202, 0.5);
|
| 2243 |
+
font-size: 11px;
|
| 2244 |
+
line-height: 1.45;
|
| 2245 |
+
}
|
| 2246 |
+
|
| 2247 |
+
.bench-controls form {
|
| 2248 |
+
display: grid;
|
| 2249 |
+
gap: 15px;
|
| 2250 |
+
}
|
| 2251 |
+
|
| 2252 |
+
.bench-field {
|
| 2253 |
+
min-width: 0;
|
| 2254 |
+
display: grid;
|
| 2255 |
+
gap: 6px;
|
| 2256 |
+
}
|
| 2257 |
+
|
| 2258 |
+
.bench-field > span {
|
| 2259 |
+
color: rgba(232, 223, 202, 0.78);
|
| 2260 |
+
font: 700 8px/1 var(--mono);
|
| 2261 |
+
letter-spacing: 0.08em;
|
| 2262 |
+
text-transform: uppercase;
|
| 2263 |
+
}
|
| 2264 |
+
|
| 2265 |
+
.bench-field select,
|
| 2266 |
+
.bench-field input {
|
| 2267 |
+
width: 100%;
|
| 2268 |
+
min-width: 0;
|
| 2269 |
+
height: 39px;
|
| 2270 |
+
padding: 0 10px;
|
| 2271 |
+
color: var(--paper);
|
| 2272 |
+
border: 1px solid rgba(232, 223, 202, 0.22);
|
| 2273 |
+
border-radius: 0;
|
| 2274 |
+
outline: 0;
|
| 2275 |
+
background: var(--ink-raised);
|
| 2276 |
+
font: 10px/1 var(--mono);
|
| 2277 |
+
}
|
| 2278 |
+
|
| 2279 |
+
.bench-field input::placeholder {
|
| 2280 |
+
color: rgba(232, 223, 202, 0.25);
|
| 2281 |
+
}
|
| 2282 |
+
|
| 2283 |
+
.bench-field select:disabled,
|
| 2284 |
+
.bench-field input:disabled {
|
| 2285 |
+
opacity: 0.5;
|
| 2286 |
+
}
|
| 2287 |
+
|
| 2288 |
+
.bench-field small {
|
| 2289 |
+
color: rgba(232, 223, 202, 0.35);
|
| 2290 |
+
font: 8px/1.2 var(--mono);
|
| 2291 |
+
}
|
| 2292 |
+
|
| 2293 |
+
.bench-field-pair {
|
| 2294 |
+
display: grid;
|
| 2295 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2296 |
+
gap: 10px;
|
| 2297 |
+
}
|
| 2298 |
+
|
| 2299 |
+
.bench-experimental {
|
| 2300 |
+
padding: 11px;
|
| 2301 |
+
border: 1px dashed rgba(201, 111, 67, 0.48);
|
| 2302 |
+
background: rgba(201, 111, 67, 0.05);
|
| 2303 |
+
}
|
| 2304 |
+
|
| 2305 |
+
.bench-experimental summary {
|
| 2306 |
+
color: #e19a76;
|
| 2307 |
+
font: 700 8px/1 var(--mono);
|
| 2308 |
+
letter-spacing: 0.07em;
|
| 2309 |
+
text-transform: uppercase;
|
| 2310 |
+
cursor: pointer;
|
| 2311 |
+
}
|
| 2312 |
+
|
| 2313 |
+
.bench-experimental > p {
|
| 2314 |
+
margin: 10px 0;
|
| 2315 |
+
color: rgba(232, 223, 202, 0.46);
|
| 2316 |
+
font-size: 9px;
|
| 2317 |
+
line-height: 1.4;
|
| 2318 |
+
}
|
| 2319 |
+
|
| 2320 |
+
.bench-locks {
|
| 2321 |
+
display: grid;
|
| 2322 |
+
gap: 5px;
|
| 2323 |
+
margin: 10px 0 0;
|
| 2324 |
+
color: rgba(232, 223, 202, 0.44);
|
| 2325 |
+
font: 7px/1.35 var(--mono);
|
| 2326 |
+
}
|
| 2327 |
+
|
| 2328 |
+
.bench-locks div {
|
| 2329 |
+
display: flex;
|
| 2330 |
+
justify-content: space-between;
|
| 2331 |
+
gap: 8px;
|
| 2332 |
+
}
|
| 2333 |
+
|
| 2334 |
+
.bench-locks dt,
|
| 2335 |
+
.bench-locks dd {
|
| 2336 |
+
margin: 0;
|
| 2337 |
+
}
|
| 2338 |
+
|
| 2339 |
+
.bench-locks dt {
|
| 2340 |
+
color: rgba(232, 223, 202, 0.72);
|
| 2341 |
+
}
|
| 2342 |
+
|
| 2343 |
+
.bench-gate {
|
| 2344 |
+
display: grid;
|
| 2345 |
+
gap: 5px;
|
| 2346 |
+
padding: 10px;
|
| 2347 |
+
border-left: 2px solid var(--rust);
|
| 2348 |
+
background: rgba(201, 111, 67, 0.08);
|
| 2349 |
+
}
|
| 2350 |
+
|
| 2351 |
+
.bench-gate.pass {
|
| 2352 |
+
border-color: var(--phosphor);
|
| 2353 |
+
background: var(--phosphor-dim);
|
| 2354 |
+
}
|
| 2355 |
+
|
| 2356 |
+
.bench-gate strong {
|
| 2357 |
+
color: #ff9a68;
|
| 2358 |
+
font: 700 8px/1 var(--mono);
|
| 2359 |
+
letter-spacing: 0.06em;
|
| 2360 |
+
text-transform: uppercase;
|
| 2361 |
+
}
|
| 2362 |
+
|
| 2363 |
+
.bench-gate.pass strong {
|
| 2364 |
+
color: var(--phosphor);
|
| 2365 |
+
}
|
| 2366 |
+
|
| 2367 |
+
.bench-gate span {
|
| 2368 |
+
color: rgba(232, 223, 202, 0.5);
|
| 2369 |
+
font: 8px/1.4 var(--mono);
|
| 2370 |
+
overflow-wrap: anywhere;
|
| 2371 |
+
}
|
| 2372 |
+
|
| 2373 |
+
.bench-run-actions {
|
| 2374 |
+
display: grid;
|
| 2375 |
+
grid-template-columns: 1fr auto;
|
| 2376 |
+
gap: 8px;
|
| 2377 |
+
}
|
| 2378 |
+
|
| 2379 |
+
.bench-run-button,
|
| 2380 |
+
.bench-stop-button,
|
| 2381 |
+
.bench-section-heading button {
|
| 2382 |
+
min-height: 38px;
|
| 2383 |
+
padding: 0 13px;
|
| 2384 |
+
border: 1px solid var(--ink);
|
| 2385 |
+
border-radius: 0;
|
| 2386 |
+
background: var(--phosphor);
|
| 2387 |
+
font: 800 8px/1 var(--mono);
|
| 2388 |
+
letter-spacing: 0.08em;
|
| 2389 |
+
text-transform: uppercase;
|
| 2390 |
+
cursor: pointer;
|
| 2391 |
+
}
|
| 2392 |
+
|
| 2393 |
+
.bench-run-button:disabled {
|
| 2394 |
+
opacity: 0.35;
|
| 2395 |
+
}
|
| 2396 |
+
|
| 2397 |
+
.bench-stop-button {
|
| 2398 |
+
color: #ffb08a;
|
| 2399 |
+
border-color: var(--rust);
|
| 2400 |
+
background: transparent;
|
| 2401 |
+
}
|
| 2402 |
+
|
| 2403 |
+
.bench-workbench {
|
| 2404 |
+
min-width: 0;
|
| 2405 |
+
padding: clamp(26px, 4vw, 58px);
|
| 2406 |
+
background:
|
| 2407 |
+
linear-gradient(90deg, rgba(17, 21, 18, 0.045) 1px, transparent 1px) 0 0 / 22px 22px,
|
| 2408 |
+
linear-gradient(rgba(17, 21, 18, 0.04) 1px, transparent 1px) 0 0 / 22px 22px,
|
| 2409 |
+
var(--paper);
|
| 2410 |
+
}
|
| 2411 |
+
|
| 2412 |
+
.bench-hero {
|
| 2413 |
+
display: grid;
|
| 2414 |
+
grid-template-columns: minmax(320px, 1.3fr) minmax(260px, 0.7fr);
|
| 2415 |
+
align-items: end;
|
| 2416 |
+
gap: 34px;
|
| 2417 |
+
padding-bottom: 29px;
|
| 2418 |
+
border-bottom: 1px solid var(--line-dark);
|
| 2419 |
+
}
|
| 2420 |
+
|
| 2421 |
+
.bench-hero h2 {
|
| 2422 |
+
max-width: 690px;
|
| 2423 |
+
margin: 0;
|
| 2424 |
+
font: 400 clamp(42px, 6vw, 82px)/0.84 var(--display);
|
| 2425 |
+
letter-spacing: -0.065em;
|
| 2426 |
+
}
|
| 2427 |
+
|
| 2428 |
+
.bench-hero h2 em {
|
| 2429 |
+
color: #4f7125;
|
| 2430 |
+
font-weight: 400;
|
| 2431 |
+
}
|
| 2432 |
+
|
| 2433 |
+
.bench-hero > p {
|
| 2434 |
+
max-width: 430px;
|
| 2435 |
+
margin: 0;
|
| 2436 |
+
color: rgba(17, 21, 18, 0.58);
|
| 2437 |
+
font-size: 11px;
|
| 2438 |
+
line-height: 1.55;
|
| 2439 |
+
}
|
| 2440 |
+
|
| 2441 |
+
.bench-progress {
|
| 2442 |
+
display: grid;
|
| 2443 |
+
gap: 8px;
|
| 2444 |
+
padding: 16px 0;
|
| 2445 |
+
}
|
| 2446 |
+
|
| 2447 |
+
.bench-progress > div {
|
| 2448 |
+
display: flex;
|
| 2449 |
+
justify-content: space-between;
|
| 2450 |
+
gap: 20px;
|
| 2451 |
+
font: 8px/1 var(--mono);
|
| 2452 |
+
letter-spacing: 0.05em;
|
| 2453 |
+
text-transform: uppercase;
|
| 2454 |
+
}
|
| 2455 |
+
|
| 2456 |
+
.bench-progress > div span {
|
| 2457 |
+
color: rgba(17, 21, 18, 0.45);
|
| 2458 |
+
}
|
| 2459 |
+
|
| 2460 |
+
.bench-progress-track {
|
| 2461 |
+
height: 3px;
|
| 2462 |
+
background: rgba(17, 21, 18, 0.13);
|
| 2463 |
+
}
|
| 2464 |
+
|
| 2465 |
+
.bench-progress-track i {
|
| 2466 |
+
display: block;
|
| 2467 |
+
height: 100%;
|
| 2468 |
+
background: #527426;
|
| 2469 |
+
transition: width 180ms ease;
|
| 2470 |
+
}
|
| 2471 |
+
|
| 2472 |
+
.bench-error {
|
| 2473 |
+
display: grid;
|
| 2474 |
+
gap: 5px;
|
| 2475 |
+
margin-bottom: 18px;
|
| 2476 |
+
padding: 12px 14px;
|
| 2477 |
+
color: var(--paper);
|
| 2478 |
+
border-left: 3px solid var(--rust);
|
| 2479 |
+
background: var(--ink);
|
| 2480 |
+
}
|
| 2481 |
+
|
| 2482 |
+
.bench-error strong {
|
| 2483 |
+
color: #ff9a68;
|
| 2484 |
+
font: 700 8px/1 var(--mono);
|
| 2485 |
+
letter-spacing: 0.08em;
|
| 2486 |
+
text-transform: uppercase;
|
| 2487 |
+
}
|
| 2488 |
+
|
| 2489 |
+
.bench-error span {
|
| 2490 |
+
font: 9px/1.4 var(--mono);
|
| 2491 |
+
overflow-wrap: anywhere;
|
| 2492 |
+
}
|
| 2493 |
+
|
| 2494 |
+
.bench-core-section {
|
| 2495 |
+
margin-top: 13px;
|
| 2496 |
+
}
|
| 2497 |
+
|
| 2498 |
+
.bench-section-heading {
|
| 2499 |
+
display: flex;
|
| 2500 |
+
align-items: end;
|
| 2501 |
+
justify-content: space-between;
|
| 2502 |
+
gap: 20px;
|
| 2503 |
+
margin-bottom: 13px;
|
| 2504 |
+
}
|
| 2505 |
+
|
| 2506 |
+
.bench-section-heading h3,
|
| 2507 |
+
.bench-output h3 {
|
| 2508 |
+
margin: 0;
|
| 2509 |
+
font: 500 24px/1 var(--display);
|
| 2510 |
+
}
|
| 2511 |
+
|
| 2512 |
+
.bench-section-heading button {
|
| 2513 |
+
min-height: 31px;
|
| 2514 |
+
color: var(--paper);
|
| 2515 |
+
border-color: var(--ink);
|
| 2516 |
+
background: var(--ink);
|
| 2517 |
+
}
|
| 2518 |
+
|
| 2519 |
+
.bench-core-sample {
|
| 2520 |
+
position: relative;
|
| 2521 |
+
overflow: hidden;
|
| 2522 |
+
display: grid;
|
| 2523 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2524 |
+
border: 1px solid var(--ink);
|
| 2525 |
+
background: var(--paper-deep);
|
| 2526 |
+
}
|
| 2527 |
+
|
| 2528 |
+
.bench-core-sample::before {
|
| 2529 |
+
position: absolute;
|
| 2530 |
+
inset: -130% -5%;
|
| 2531 |
+
content: "";
|
| 2532 |
+
pointer-events: none;
|
| 2533 |
+
opacity: 0.35;
|
| 2534 |
+
background: repeating-radial-gradient(ellipse at center, transparent 0 34px, rgba(17, 21, 18, 0.17) 35px 36px);
|
| 2535 |
+
}
|
| 2536 |
+
|
| 2537 |
+
.bench-core-sample article {
|
| 2538 |
+
position: relative;
|
| 2539 |
+
min-height: 128px;
|
| 2540 |
+
display: flex;
|
| 2541 |
+
flex-direction: column;
|
| 2542 |
+
justify-content: space-between;
|
| 2543 |
+
padding: 17px;
|
| 2544 |
+
border-right: 1px solid rgba(17, 21, 18, 0.26);
|
| 2545 |
+
background: rgba(232, 223, 202, 0.57);
|
| 2546 |
+
}
|
| 2547 |
+
|
| 2548 |
+
.bench-core-sample article:nth-child(even) {
|
| 2549 |
+
background: rgba(216, 205, 180, 0.6);
|
| 2550 |
+
}
|
| 2551 |
+
|
| 2552 |
+
.bench-core-sample article:last-child {
|
| 2553 |
+
color: var(--paper);
|
| 2554 |
+
border-right: 0;
|
| 2555 |
+
background: rgba(17, 21, 18, 0.91);
|
| 2556 |
+
}
|
| 2557 |
+
|
| 2558 |
+
.bench-core-sample article > span {
|
| 2559 |
+
font: 800 8px/1 var(--mono);
|
| 2560 |
+
letter-spacing: 0.09em;
|
| 2561 |
+
text-transform: uppercase;
|
| 2562 |
+
}
|
| 2563 |
+
|
| 2564 |
+
.bench-core-sample article strong {
|
| 2565 |
+
margin-block: auto;
|
| 2566 |
+
font: 400 clamp(28px, 3vw, 43px)/1 var(--display);
|
| 2567 |
+
letter-spacing: -0.04em;
|
| 2568 |
+
}
|
| 2569 |
+
|
| 2570 |
+
.bench-core-sample article small {
|
| 2571 |
+
color: rgba(17, 21, 18, 0.5);
|
| 2572 |
+
font: 7px/1.2 var(--mono);
|
| 2573 |
+
text-transform: uppercase;
|
| 2574 |
+
}
|
| 2575 |
+
|
| 2576 |
+
.bench-core-sample article:last-child small {
|
| 2577 |
+
color: rgba(232, 223, 202, 0.48);
|
| 2578 |
+
}
|
| 2579 |
+
|
| 2580 |
+
.bench-detail-grid {
|
| 2581 |
+
display: grid;
|
| 2582 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2583 |
+
gap: 12px;
|
| 2584 |
+
margin-top: 12px;
|
| 2585 |
+
}
|
| 2586 |
+
|
| 2587 |
+
.bench-data-card {
|
| 2588 |
+
padding: 16px;
|
| 2589 |
+
border: 1px solid var(--line-dark);
|
| 2590 |
+
background: rgba(232, 223, 202, 0.68);
|
| 2591 |
+
}
|
| 2592 |
+
|
| 2593 |
+
.bench-runtime-card {
|
| 2594 |
+
grid-column: span 2;
|
| 2595 |
+
}
|
| 2596 |
+
|
| 2597 |
+
.bench-data-card dl {
|
| 2598 |
+
display: grid;
|
| 2599 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2600 |
+
gap: 12px 26px;
|
| 2601 |
+
margin: 0;
|
| 2602 |
+
}
|
| 2603 |
+
|
| 2604 |
+
.bench-runtime-card dl {
|
| 2605 |
+
grid-template-columns: minmax(180px, 1.5fr) repeat(3, minmax(100px, 0.7fr));
|
| 2606 |
+
}
|
| 2607 |
+
|
| 2608 |
+
.bench-data-card dl div {
|
| 2609 |
+
min-width: 0;
|
| 2610 |
+
display: grid;
|
| 2611 |
+
gap: 4px;
|
| 2612 |
+
border-top: 1px solid rgba(17, 21, 18, 0.13);
|
| 2613 |
+
padding-top: 8px;
|
| 2614 |
+
}
|
| 2615 |
+
|
| 2616 |
+
.bench-data-card dt {
|
| 2617 |
+
color: rgba(17, 21, 18, 0.45);
|
| 2618 |
+
font: 7px/1 var(--mono);
|
| 2619 |
+
letter-spacing: 0.07em;
|
| 2620 |
+
text-transform: uppercase;
|
| 2621 |
+
}
|
| 2622 |
+
|
| 2623 |
+
.bench-data-card dd {
|
| 2624 |
+
min-width: 0;
|
| 2625 |
+
overflow: hidden;
|
| 2626 |
+
margin: 0;
|
| 2627 |
+
font: 500 16px/1.2 var(--body);
|
| 2628 |
+
text-overflow: ellipsis;
|
| 2629 |
+
white-space: nowrap;
|
| 2630 |
+
}
|
| 2631 |
+
|
| 2632 |
+
.bench-output {
|
| 2633 |
+
display: grid;
|
| 2634 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2635 |
+
gap: 34px;
|
| 2636 |
+
margin-top: 12px;
|
| 2637 |
+
padding: 21px;
|
| 2638 |
+
border: 1px dashed rgba(17, 21, 18, 0.3);
|
| 2639 |
+
}
|
| 2640 |
+
|
| 2641 |
+
.bench-output > div:last-child {
|
| 2642 |
+
border-left: 1px solid var(--line-dark);
|
| 2643 |
+
padding-left: 34px;
|
| 2644 |
+
}
|
| 2645 |
+
|
| 2646 |
+
.bench-output p:not(.eyebrow) {
|
| 2647 |
+
margin: 8px 0 0;
|
| 2648 |
+
color: rgba(17, 21, 18, 0.6);
|
| 2649 |
+
font-size: 11px;
|
| 2650 |
+
line-height: 1.5;
|
| 2651 |
+
white-space: pre-wrap;
|
| 2652 |
+
}
|
| 2653 |
+
|
| 2654 |
+
@media (max-width: 1050px) {
|
| 2655 |
+
.bench-masthead {
|
| 2656 |
+
grid-template-columns: minmax(310px, 1fr) auto;
|
| 2657 |
+
}
|
| 2658 |
+
|
| 2659 |
+
.bench-masthead-note {
|
| 2660 |
+
display: none;
|
| 2661 |
+
}
|
| 2662 |
+
|
| 2663 |
+
.bench-layout {
|
| 2664 |
+
grid-template-columns: 285px minmax(0, 1fr);
|
| 2665 |
+
}
|
| 2666 |
+
|
| 2667 |
+
.bench-core-sample {
|
| 2668 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2669 |
+
}
|
| 2670 |
+
|
| 2671 |
+
.bench-core-sample article:nth-child(2) {
|
| 2672 |
+
border-right: 0;
|
| 2673 |
+
}
|
| 2674 |
+
|
| 2675 |
+
.bench-core-sample article:nth-child(-n + 2) {
|
| 2676 |
+
border-bottom: 1px solid rgba(17, 21, 18, 0.26);
|
| 2677 |
+
}
|
| 2678 |
+
|
| 2679 |
+
.bench-runtime-card dl {
|
| 2680 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2681 |
+
}
|
| 2682 |
+
}
|
| 2683 |
+
|
| 2684 |
+
@media (max-width: 760px) {
|
| 2685 |
+
.bench-masthead {
|
| 2686 |
+
display: flex;
|
| 2687 |
+
align-items: center;
|
| 2688 |
+
}
|
| 2689 |
+
|
| 2690 |
+
.bench-masthead .brand-block {
|
| 2691 |
+
flex: 1;
|
| 2692 |
+
min-width: 0;
|
| 2693 |
+
}
|
| 2694 |
+
|
| 2695 |
+
.bench-return {
|
| 2696 |
+
padding: 0 15px;
|
| 2697 |
+
font-size: 0;
|
| 2698 |
+
}
|
| 2699 |
+
|
| 2700 |
+
.bench-return span {
|
| 2701 |
+
font-size: 17px;
|
| 2702 |
+
}
|
| 2703 |
+
|
| 2704 |
+
.bench-layout {
|
| 2705 |
+
display: block;
|
| 2706 |
+
}
|
| 2707 |
+
|
| 2708 |
+
.bench-controls {
|
| 2709 |
+
border-right: 0;
|
| 2710 |
+
border-bottom: 1px solid var(--phosphor);
|
| 2711 |
+
}
|
| 2712 |
+
|
| 2713 |
+
.bench-workbench {
|
| 2714 |
+
padding: 28px 16px;
|
| 2715 |
+
}
|
| 2716 |
+
|
| 2717 |
+
.bench-hero,
|
| 2718 |
+
.bench-output {
|
| 2719 |
+
grid-template-columns: 1fr;
|
| 2720 |
+
gap: 18px;
|
| 2721 |
+
}
|
| 2722 |
+
|
| 2723 |
+
.bench-hero h2 {
|
| 2724 |
+
font-size: clamp(42px, 14vw, 68px);
|
| 2725 |
+
}
|
| 2726 |
+
|
| 2727 |
+
.bench-detail-grid {
|
| 2728 |
+
display: block;
|
| 2729 |
+
}
|
| 2730 |
+
|
| 2731 |
+
.bench-data-card + .bench-data-card {
|
| 2732 |
+
margin-top: 10px;
|
| 2733 |
+
}
|
| 2734 |
+
|
| 2735 |
+
.bench-output > div:last-child {
|
| 2736 |
+
border-top: 1px solid var(--line-dark);
|
| 2737 |
+
border-left: 0;
|
| 2738 |
+
padding-top: 18px;
|
| 2739 |
+
padding-left: 0;
|
| 2740 |
+
}
|
| 2741 |
+
}
|
| 2742 |
+
|
| 2743 |
+
@media (max-width: 480px) {
|
| 2744 |
+
.bench-masthead .brand-mark {
|
| 2745 |
+
display: none;
|
| 2746 |
+
}
|
| 2747 |
+
|
| 2748 |
+
.bench-core-sample,
|
| 2749 |
+
.bench-data-card dl,
|
| 2750 |
+
.bench-runtime-card dl {
|
| 2751 |
+
grid-template-columns: 1fr;
|
| 2752 |
+
}
|
| 2753 |
+
|
| 2754 |
+
.bench-core-sample article {
|
| 2755 |
+
min-height: 105px;
|
| 2756 |
+
border-right: 0;
|
| 2757 |
+
border-bottom: 1px solid rgba(17, 21, 18, 0.26);
|
| 2758 |
+
}
|
| 2759 |
+
|
| 2760 |
+
.bench-core-sample article:last-child {
|
| 2761 |
+
border-bottom: 0;
|
| 2762 |
+
}
|
| 2763 |
+
}
|
src/bench/BenchApp.tsx
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
BrowserEngineClient,
|
| 4 |
+
EngineClientError,
|
| 5 |
+
evaluateModelGate,
|
| 6 |
+
loadModelManifestV2,
|
| 7 |
+
type EngineCapabilities,
|
| 8 |
+
type ModelManifestV2,
|
| 9 |
+
type ModelTierId,
|
| 10 |
+
type RequestedBackend,
|
| 11 |
+
} from '../engine';
|
| 12 |
+
import { formatBytes } from '../lib/format';
|
| 13 |
+
import {
|
| 14 |
+
BENCHMARK_PROMPT,
|
| 15 |
+
BENCHMARK_PROMPT_ID,
|
| 16 |
+
benchmarkReportFilename,
|
| 17 |
+
buildBenchmarkReport,
|
| 18 |
+
serializeBenchmarkReport,
|
| 19 |
+
type BenchmarkReport,
|
| 20 |
+
} from './report';
|
| 21 |
+
|
| 22 |
+
const MANIFEST_PATH = 'manifest/models.json';
|
| 23 |
+
const DEFAULT_MODEL: ModelTierId = '1_7b';
|
| 24 |
+
|
| 25 |
+
interface RunProgress {
|
| 26 |
+
label: string;
|
| 27 |
+
loadedBytes: number;
|
| 28 |
+
totalBytes: number;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
let sharedBenchClient: BrowserEngineClient | null = null;
|
| 32 |
+
|
| 33 |
+
function getBenchClient(): BrowserEngineClient {
|
| 34 |
+
sharedBenchClient ??= new BrowserEngineClient();
|
| 35 |
+
return sharedBenchClient;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function manifestUrl(): string {
|
| 39 |
+
return new URL(`${import.meta.env.BASE_URL}${MANIFEST_PATH}`, document.baseURI).href;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function errorMessage(error: unknown): string {
|
| 43 |
+
if (error instanceof EngineClientError) return `${error.code}: ${error.message}`;
|
| 44 |
+
return error instanceof Error ? error.message : String(error);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function isAbort(error: unknown, signal: AbortSignal): boolean {
|
| 48 |
+
return signal.aborted
|
| 49 |
+
|| (error instanceof DOMException && error.name === 'AbortError')
|
| 50 |
+
|| (error instanceof EngineClientError && ['ABORTED', 'SHARD_DOWNLOAD_ABORTED'].includes(error.code));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function throwIfAborted(signal: AbortSignal): void {
|
| 54 |
+
if (signal.aborted) throw new DOMException('Benchmark stopped by operator.', 'AbortError');
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function parseOptionalInteger(value: string): number | null {
|
| 58 |
+
if (value.trim() === '') return null;
|
| 59 |
+
const parsed = Number(value);
|
| 60 |
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function formatDuration(value: number | null | undefined): string {
|
| 64 |
+
if (value === null || value === undefined) return '—';
|
| 65 |
+
return value >= 1_000 ? `${(value / 1_000).toFixed(2)} s` : `${value.toFixed(1)} ms`;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function formatRate(value: number | null | undefined): string {
|
| 69 |
+
return value === null || value === undefined ? '—' : `${value.toFixed(2)} t/s`;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function downloadReport(report: BenchmarkReport): void {
|
| 73 |
+
const blob = new Blob([serializeBenchmarkReport(report)], { type: 'application/json' });
|
| 74 |
+
const url = URL.createObjectURL(blob);
|
| 75 |
+
const anchor = document.createElement('a');
|
| 76 |
+
anchor.href = url;
|
| 77 |
+
anchor.download = benchmarkReportFilename(report);
|
| 78 |
+
document.body.append(anchor);
|
| 79 |
+
anchor.click();
|
| 80 |
+
anchor.remove();
|
| 81 |
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function BenchApp() {
|
| 85 |
+
const [client] = useState(getBenchClient);
|
| 86 |
+
const [manifest, setManifest] = useState<ModelManifestV2 | null>(null);
|
| 87 |
+
const [capabilities, setCapabilities] = useState<EngineCapabilities | null>(null);
|
| 88 |
+
const [modelId, setModelId] = useState<ModelTierId>(DEFAULT_MODEL);
|
| 89 |
+
const [backend, setBackend] = useState<RequestedBackend>('auto');
|
| 90 |
+
const [contextSize, setContextSize] = useState(4_096);
|
| 91 |
+
const [maxTokens, setMaxTokens] = useState(64);
|
| 92 |
+
const [nBatchInput, setNBatchInput] = useState('');
|
| 93 |
+
const [nUbatchInput, setNUbatchInput] = useState('');
|
| 94 |
+
const [running, setRunning] = useState(false);
|
| 95 |
+
const [progress, setProgress] = useState<RunProgress>({
|
| 96 |
+
label: 'Waiting for an operator-initiated run',
|
| 97 |
+
loadedBytes: 0,
|
| 98 |
+
totalBytes: 0,
|
| 99 |
+
});
|
| 100 |
+
const [report, setReport] = useState<BenchmarkReport | null>(null);
|
| 101 |
+
const [output, setOutput] = useState('');
|
| 102 |
+
const [error, setError] = useState<string | null>(null);
|
| 103 |
+
const operationRef = useRef<AbortController | null>(null);
|
| 104 |
+
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
let cancelled = false;
|
| 107 |
+
const controller = new AbortController();
|
| 108 |
+
setProgress((current) => ({ ...current, label: 'Inspecting manifest and browser runtime' }));
|
| 109 |
+
void Promise.all([
|
| 110 |
+
loadModelManifestV2(manifestUrl(), controller.signal),
|
| 111 |
+
client.capabilities(),
|
| 112 |
+
]).then(([loadedManifest, loadedCapabilities]) => {
|
| 113 |
+
if (cancelled) return;
|
| 114 |
+
const initial = loadedManifest.models.find((model) => model.id === DEFAULT_MODEL)
|
| 115 |
+
?? loadedManifest.models[0];
|
| 116 |
+
if (!initial) throw new Error('The benchmark manifest contains no model tiers.');
|
| 117 |
+
setManifest(loadedManifest);
|
| 118 |
+
setCapabilities(loadedCapabilities);
|
| 119 |
+
setModelId(initial.id);
|
| 120 |
+
setContextSize(initial.defaultContext);
|
| 121 |
+
setProgress({
|
| 122 |
+
label: 'Ready · no model is loaded until Run benchmark is pressed',
|
| 123 |
+
loadedBytes: 0,
|
| 124 |
+
totalBytes: 0,
|
| 125 |
+
});
|
| 126 |
+
}).catch((bootstrapError) => {
|
| 127 |
+
if (cancelled || controller.signal.aborted) return;
|
| 128 |
+
setError(errorMessage(bootstrapError));
|
| 129 |
+
setProgress((current) => ({ ...current, label: 'Runtime inspection failed' }));
|
| 130 |
+
});
|
| 131 |
+
return () => {
|
| 132 |
+
cancelled = true;
|
| 133 |
+
controller.abort();
|
| 134 |
+
};
|
| 135 |
+
}, [client]);
|
| 136 |
+
|
| 137 |
+
const model = useMemo(
|
| 138 |
+
() => manifest?.models.find((candidate) => candidate.id === modelId) ?? null,
|
| 139 |
+
[manifest, modelId],
|
| 140 |
+
);
|
| 141 |
+
const deviceGate = useMemo(() => {
|
| 142 |
+
if (!model || !capabilities) return null;
|
| 143 |
+
return evaluateModelGate(model, backend, capabilities.webgpu, {
|
| 144 |
+
usageBytes: null,
|
| 145 |
+
quotaBytes: null,
|
| 146 |
+
persisted: capabilities.storage.persisted,
|
| 147 |
+
});
|
| 148 |
+
}, [backend, capabilities, model]);
|
| 149 |
+
const storageGate = useMemo(() => {
|
| 150 |
+
if (!model || !capabilities) return null;
|
| 151 |
+
return evaluateModelGate(model, backend, capabilities.webgpu, capabilities.storage);
|
| 152 |
+
}, [backend, capabilities, model]);
|
| 153 |
+
|
| 154 |
+
const contextLimit = model?.id === '27b'
|
| 155 |
+
? model.defaultContext
|
| 156 |
+
: model?.contextLength ?? 4_096;
|
| 157 |
+
const nBatch = parseOptionalInteger(nBatchInput);
|
| 158 |
+
const nUbatch = parseOptionalInteger(nUbatchInput);
|
| 159 |
+
const tuningValid = (nBatchInput.trim() === '' || nBatch !== null)
|
| 160 |
+
&& (nUbatchInput.trim() === '' || nUbatch !== null);
|
| 161 |
+
const configurationValid = Number.isSafeInteger(contextSize)
|
| 162 |
+
&& contextSize > 0
|
| 163 |
+
&& contextSize <= contextLimit
|
| 164 |
+
&& Number.isSafeInteger(maxTokens)
|
| 165 |
+
&& maxTokens >= 8
|
| 166 |
+
&& maxTokens <= 512
|
| 167 |
+
&& tuningValid;
|
| 168 |
+
const canRun = Boolean(manifest && capabilities && model && deviceGate?.allowed && configurationValid);
|
| 169 |
+
|
| 170 |
+
const handleModelChange = (nextId: ModelTierId) => {
|
| 171 |
+
if (running || !manifest) return;
|
| 172 |
+
const next = manifest.models.find((candidate) => candidate.id === nextId);
|
| 173 |
+
if (!next) return;
|
| 174 |
+
setModelId(nextId);
|
| 175 |
+
setContextSize(next.defaultContext);
|
| 176 |
+
if (!next.cpuFallback && backend === 'wasm') setBackend('auto');
|
| 177 |
+
setReport(null);
|
| 178 |
+
setOutput('');
|
| 179 |
+
setError(null);
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const runBenchmark = () => {
|
| 183 |
+
if (!canRun || !manifest || !capabilities || !model || operationRef.current) return;
|
| 184 |
+
const controller = new AbortController();
|
| 185 |
+
operationRef.current = controller;
|
| 186 |
+
setRunning(true);
|
| 187 |
+
setReport(null);
|
| 188 |
+
setOutput('');
|
| 189 |
+
setError(null);
|
| 190 |
+
const startedAt = new Date().toISOString();
|
| 191 |
+
|
| 192 |
+
const run = async () => {
|
| 193 |
+
let storagePersistent = capabilities.storage.persisted;
|
| 194 |
+
setProgress({ label: 'Requesting persistent browser storage', loadedBytes: 0, totalBytes: 0 });
|
| 195 |
+
try {
|
| 196 |
+
const storage = await client.storagePersist();
|
| 197 |
+
storagePersistent = storage.persisted;
|
| 198 |
+
setCapabilities((current) => current ? { ...current, storage } : current);
|
| 199 |
+
} catch {
|
| 200 |
+
// Persistence is a browser policy choice. The runtime storage gate remains authoritative.
|
| 201 |
+
}
|
| 202 |
+
throwIfAborted(controller.signal);
|
| 203 |
+
|
| 204 |
+
await client.unload();
|
| 205 |
+
throwIfAborted(controller.signal);
|
| 206 |
+
let coldCachedBytes: number | null = null;
|
| 207 |
+
const loadParams = {
|
| 208 |
+
manifest,
|
| 209 |
+
modelId: model.id,
|
| 210 |
+
backend,
|
| 211 |
+
contextSize,
|
| 212 |
+
...(nBatch === null ? {} : { nBatch }),
|
| 213 |
+
...(nUbatch === null ? {} : { nUbatch }),
|
| 214 |
+
flashAttention: false as const,
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const coldStarted = performance.now();
|
| 218 |
+
const coldLoad = await client.loadModel(loadParams, {
|
| 219 |
+
signal: controller.signal,
|
| 220 |
+
onProgress: (event) => {
|
| 221 |
+
if (event.phase === 'download' && coldCachedBytes === null) {
|
| 222 |
+
coldCachedBytes = event.loadedBytes;
|
| 223 |
+
}
|
| 224 |
+
const shard = event.shardIndex === null ? '' : ` · shard ${event.shardIndex + 1}/${event.shardCount}`;
|
| 225 |
+
setProgress({
|
| 226 |
+
label: `Cold load · ${event.phase}${shard}`,
|
| 227 |
+
loadedBytes: event.loadedBytes,
|
| 228 |
+
totalBytes: event.totalBytes,
|
| 229 |
+
});
|
| 230 |
+
},
|
| 231 |
+
});
|
| 232 |
+
const coldLoadMs = performance.now() - coldStarted;
|
| 233 |
+
throwIfAborted(controller.signal);
|
| 234 |
+
|
| 235 |
+
setProgress({ label: 'Unloading model before verified-cache reload', loadedBytes: 0, totalBytes: 0 });
|
| 236 |
+
await client.unload();
|
| 237 |
+
throwIfAborted(controller.signal);
|
| 238 |
+
|
| 239 |
+
const warmStarted = performance.now();
|
| 240 |
+
const warmLoad = await client.loadModel(loadParams, {
|
| 241 |
+
signal: controller.signal,
|
| 242 |
+
onProgress: (event) => {
|
| 243 |
+
const shard = event.shardIndex === null ? '' : ` · shard ${event.shardIndex + 1}/${event.shardCount}`;
|
| 244 |
+
setProgress({
|
| 245 |
+
label: `Warm load · ${event.phase}${shard}`,
|
| 246 |
+
loadedBytes: event.loadedBytes,
|
| 247 |
+
totalBytes: event.totalBytes,
|
| 248 |
+
});
|
| 249 |
+
},
|
| 250 |
+
});
|
| 251 |
+
const warmLoadMs = performance.now() - warmStarted;
|
| 252 |
+
if (coldLoad.backend !== warmLoad.backend) {
|
| 253 |
+
throw new Error(`Backend changed between cold and warm loads (${coldLoad.backend} → ${warmLoad.backend}).`);
|
| 254 |
+
}
|
| 255 |
+
throwIfAborted(controller.signal);
|
| 256 |
+
|
| 257 |
+
let firstTokenAt: number | null = null;
|
| 258 |
+
let streamedTokenEvents = 0;
|
| 259 |
+
const generationStarted = performance.now();
|
| 260 |
+
setProgress({ label: `Generating fixed prompt · ${BENCHMARK_PROMPT_ID}`, loadedBytes: 0, totalBytes: maxTokens });
|
| 261 |
+
const generationResult = await client.generate({
|
| 262 |
+
messages: [{ role: 'user', content: BENCHMARK_PROMPT }],
|
| 263 |
+
maxTokens,
|
| 264 |
+
temperature: 0,
|
| 265 |
+
topK: 1,
|
| 266 |
+
topP: 1,
|
| 267 |
+
seed: 42,
|
| 268 |
+
toolChoice: 'none',
|
| 269 |
+
cachePrompt: false,
|
| 270 |
+
}, {
|
| 271 |
+
signal: controller.signal,
|
| 272 |
+
onToken: (text, reasoningDelta) => {
|
| 273 |
+
if (!text && !reasoningDelta) return;
|
| 274 |
+
if (firstTokenAt === null) firstTokenAt = performance.now();
|
| 275 |
+
streamedTokenEvents += 1;
|
| 276 |
+
setProgress({
|
| 277 |
+
label: `Generating fixed prompt · ${streamedTokenEvents} stream event${streamedTokenEvents === 1 ? '' : 's'}`,
|
| 278 |
+
loadedBytes: streamedTokenEvents,
|
| 279 |
+
totalBytes: maxTokens,
|
| 280 |
+
});
|
| 281 |
+
},
|
| 282 |
+
});
|
| 283 |
+
const generationCompleted = performance.now();
|
| 284 |
+
throwIfAborted(controller.signal);
|
| 285 |
+
const backendReport = await client.backendReport();
|
| 286 |
+
throwIfAborted(controller.signal);
|
| 287 |
+
|
| 288 |
+
const nextReport = buildBenchmarkReport({
|
| 289 |
+
startedAt,
|
| 290 |
+
completedAt: new Date().toISOString(),
|
| 291 |
+
manifest,
|
| 292 |
+
model,
|
| 293 |
+
capabilities,
|
| 294 |
+
requestedBackend: backend,
|
| 295 |
+
contextSize: warmLoad.context.size,
|
| 296 |
+
maxTokens,
|
| 297 |
+
nBatch,
|
| 298 |
+
nUbatch,
|
| 299 |
+
coldLoadMs,
|
| 300 |
+
warmLoadMs,
|
| 301 |
+
coldCachedBytes,
|
| 302 |
+
storagePersistent,
|
| 303 |
+
loadResult: warmLoad,
|
| 304 |
+
generationResult,
|
| 305 |
+
generationElapsedMs: generationCompleted - generationStarted,
|
| 306 |
+
timeToFirstTokenMs: firstTokenAt === null ? null : firstTokenAt - generationStarted,
|
| 307 |
+
streamedTokenEvents,
|
| 308 |
+
backendReport,
|
| 309 |
+
});
|
| 310 |
+
setReport(nextReport);
|
| 311 |
+
setOutput(generationResult.text);
|
| 312 |
+
setProgress({
|
| 313 |
+
label: 'Benchmark complete · JSON report is ready',
|
| 314 |
+
loadedBytes: 1,
|
| 315 |
+
totalBytes: 1,
|
| 316 |
+
});
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
void run().catch((runError) => {
|
| 320 |
+
if (isAbort(runError, controller.signal)) {
|
| 321 |
+
setProgress({ label: 'Benchmark stopped by operator', loadedBytes: 0, totalBytes: 0 });
|
| 322 |
+
return;
|
| 323 |
+
}
|
| 324 |
+
setError(errorMessage(runError));
|
| 325 |
+
setProgress({ label: 'Benchmark failed safely · no report exported', loadedBytes: 0, totalBytes: 0 });
|
| 326 |
+
}).finally(() => {
|
| 327 |
+
if (operationRef.current === controller) operationRef.current = null;
|
| 328 |
+
setRunning(false);
|
| 329 |
+
});
|
| 330 |
+
};
|
| 331 |
+
|
| 332 |
+
const progressPercent = progress.totalBytes > 0
|
| 333 |
+
? Math.min(100, Math.max(0, progress.loadedBytes / progress.totalBytes * 100))
|
| 334 |
+
: 0;
|
| 335 |
+
const fullGateStorageOnly = storageGate && !storageGate.allowed && deviceGate?.allowed;
|
| 336 |
+
|
| 337 |
+
return (
|
| 338 |
+
<div className="bench-page station-frame">
|
| 339 |
+
<header className="bench-masthead">
|
| 340 |
+
<div className="brand-block" aria-label="Bonsai benchmark field station">
|
| 341 |
+
<span className="brand-mark" aria-hidden="true"><span /></span>
|
| 342 |
+
<div>
|
| 343 |
+
<p className="eyebrow">Repeatable local inference assay</p>
|
| 344 |
+
<h1>Bonsai <i>growth bench</i></h1>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
<div className="bench-masthead-note">
|
| 348 |
+
<strong>Nothing runs on arrival</strong>
|
| 349 |
+
<span>One click performs two loads, one fixed prompt, and no network call beyond model assets.</span>
|
| 350 |
+
</div>
|
| 351 |
+
<a className="bench-return" href={import.meta.env.BASE_URL}>Open chat station <span aria-hidden="true">↗</span></a>
|
| 352 |
+
</header>
|
| 353 |
+
|
| 354 |
+
<div className="bench-layout">
|
| 355 |
+
<aside className="bench-controls">
|
| 356 |
+
<p className="eyebrow">Assay controls</p>
|
| 357 |
+
<h2>Shape one run</h2>
|
| 358 |
+
<p className="bench-control-intro">The fixed prompt and temperature stay locked so exported runs remain comparable.</p>
|
| 359 |
+
|
| 360 |
+
<form onSubmit={(event) => { event.preventDefault(); runBenchmark(); }}>
|
| 361 |
+
<label className="bench-field">
|
| 362 |
+
<span>Model tier</span>
|
| 363 |
+
<select
|
| 364 |
+
value={modelId}
|
| 365 |
+
disabled={running || !manifest}
|
| 366 |
+
onChange={(event) => handleModelChange(event.target.value as ModelTierId)}
|
| 367 |
+
>
|
| 368 |
+
{(manifest?.models ?? []).map((candidate) => (
|
| 369 |
+
<option key={candidate.id} value={candidate.id}>{candidate.displayName}</option>
|
| 370 |
+
))}
|
| 371 |
+
</select>
|
| 372 |
+
<small>{model ? `${formatBytes(model.downloadBytes)} verified weights` : 'Loading manifest…'}</small>
|
| 373 |
+
</label>
|
| 374 |
+
|
| 375 |
+
<label className="bench-field">
|
| 376 |
+
<span>Backend</span>
|
| 377 |
+
<select
|
| 378 |
+
value={backend}
|
| 379 |
+
disabled={running}
|
| 380 |
+
onChange={(event) => {
|
| 381 |
+
setBackend(event.target.value as RequestedBackend);
|
| 382 |
+
setReport(null);
|
| 383 |
+
setOutput('');
|
| 384 |
+
}}
|
| 385 |
+
>
|
| 386 |
+
<option value="auto">Auto · gated</option>
|
| 387 |
+
<option value="webgpu">WebGPU · explicit</option>
|
| 388 |
+
<option value="wasm" disabled={model?.cpuFallback === false}>CPU-WASM</option>
|
| 389 |
+
</select>
|
| 390 |
+
<small>{deviceGate?.selectedBackend ? `Preflight selects ${deviceGate.selectedBackend}` : 'No backend selected'}</small>
|
| 391 |
+
</label>
|
| 392 |
+
|
| 393 |
+
<div className="bench-field-pair">
|
| 394 |
+
<label className="bench-field">
|
| 395 |
+
<span>Context</span>
|
| 396 |
+
<input
|
| 397 |
+
type="number"
|
| 398 |
+
min="256"
|
| 399 |
+
max={contextLimit}
|
| 400 |
+
step="256"
|
| 401 |
+
value={contextSize}
|
| 402 |
+
disabled={running}
|
| 403 |
+
onChange={(event) => {
|
| 404 |
+
setContextSize(Number(event.target.value));
|
| 405 |
+
setReport(null);
|
| 406 |
+
}}
|
| 407 |
+
/>
|
| 408 |
+
<small>Max {contextLimit.toLocaleString()}</small>
|
| 409 |
+
</label>
|
| 410 |
+
<label className="bench-field">
|
| 411 |
+
<span>Max tokens</span>
|
| 412 |
+
<input
|
| 413 |
+
type="number"
|
| 414 |
+
min="8"
|
| 415 |
+
max="512"
|
| 416 |
+
step="8"
|
| 417 |
+
value={maxTokens}
|
| 418 |
+
disabled={running}
|
| 419 |
+
onChange={(event) => {
|
| 420 |
+
setMaxTokens(Number(event.target.value));
|
| 421 |
+
setReport(null);
|
| 422 |
+
}}
|
| 423 |
+
/>
|
| 424 |
+
<small>8–512</small>
|
| 425 |
+
</label>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
<details className="bench-experimental">
|
| 429 |
+
<summary>Experimental batch shape</summary>
|
| 430 |
+
<p>Blank uses the pinned runtime defaults. Values are exported as experiments, not release recommendations.</p>
|
| 431 |
+
<div className="bench-field-pair">
|
| 432 |
+
<label className="bench-field">
|
| 433 |
+
<span>nBatch</span>
|
| 434 |
+
<input
|
| 435 |
+
type="number"
|
| 436 |
+
min="1"
|
| 437 |
+
step="1"
|
| 438 |
+
placeholder="runtime default"
|
| 439 |
+
value={nBatchInput}
|
| 440 |
+
disabled={running}
|
| 441 |
+
onChange={(event) => {
|
| 442 |
+
setNBatchInput(event.target.value);
|
| 443 |
+
setReport(null);
|
| 444 |
+
}}
|
| 445 |
+
/>
|
| 446 |
+
</label>
|
| 447 |
+
<label className="bench-field">
|
| 448 |
+
<span>nUbatch</span>
|
| 449 |
+
<input
|
| 450 |
+
type="number"
|
| 451 |
+
min="1"
|
| 452 |
+
step="1"
|
| 453 |
+
placeholder="runtime default"
|
| 454 |
+
value={nUbatchInput}
|
| 455 |
+
disabled={running}
|
| 456 |
+
onChange={(event) => {
|
| 457 |
+
setNUbatchInput(event.target.value);
|
| 458 |
+
setReport(null);
|
| 459 |
+
}}
|
| 460 |
+
/>
|
| 461 |
+
</label>
|
| 462 |
+
</div>
|
| 463 |
+
<dl className="bench-locks">
|
| 464 |
+
<div><dt>flashAttention</dt><dd>false · locked, unvalidated</dd></div>
|
| 465 |
+
<div><dt>KV cache K / V</dt><dd>f16 / f16 · locked release default</dd></div>
|
| 466 |
+
</dl>
|
| 467 |
+
</details>
|
| 468 |
+
|
| 469 |
+
<div className={`bench-gate ${deviceGate?.allowed ? 'pass' : 'fail'}`}>
|
| 470 |
+
<strong>{deviceGate?.allowed ? 'Device preflight passed' : 'Device preflight blocked'}</strong>
|
| 471 |
+
<span>
|
| 472 |
+
{deviceGate?.reasons[0]
|
| 473 |
+
?? (fullGateStorageOnly
|
| 474 |
+
? 'Storage cannot be proven before cache inspection; the worker repeats the authoritative gate.'
|
| 475 |
+
: deviceGate?.warnings[0] ?? 'Waiting for capability inspection.')}
|
| 476 |
+
</span>
|
| 477 |
+
</div>
|
| 478 |
+
|
| 479 |
+
<div className="bench-run-actions">
|
| 480 |
+
<button className="bench-run-button" type="submit" disabled={!canRun || running}>
|
| 481 |
+
{running ? 'Run in progress' : 'Run benchmark'}
|
| 482 |
+
</button>
|
| 483 |
+
{running && (
|
| 484 |
+
<button
|
| 485 |
+
className="bench-stop-button"
|
| 486 |
+
type="button"
|
| 487 |
+
onClick={() => {
|
| 488 |
+
setProgress((current) => ({ ...current, label: 'Stopping benchmark…' }));
|
| 489 |
+
operationRef.current?.abort();
|
| 490 |
+
}}
|
| 491 |
+
>
|
| 492 |
+
Stop
|
| 493 |
+
</button>
|
| 494 |
+
)}
|
| 495 |
+
</div>
|
| 496 |
+
</form>
|
| 497 |
+
</aside>
|
| 498 |
+
|
| 499 |
+
<main className="bench-workbench">
|
| 500 |
+
<section className="bench-hero">
|
| 501 |
+
<div>
|
| 502 |
+
<p className="eyebrow ink">Phase 5 · operator evidence</p>
|
| 503 |
+
<h2>Measure the whole <em>growth cycle.</em></h2>
|
| 504 |
+
</div>
|
| 505 |
+
<p>Cold load includes cache inspection and any missing download. Warm load repeats initialization after unload with verified shards retained. Generation uses one public, fixed prompt.</p>
|
| 506 |
+
</section>
|
| 507 |
+
|
| 508 |
+
<section className="bench-progress" aria-live="polite">
|
| 509 |
+
<div><strong>{progress.label}</strong><span>{progress.totalBytes > 1 ? `${Math.round(progressPercent)}%` : ''}</span></div>
|
| 510 |
+
<span className="bench-progress-track"><i style={{ width: `${progressPercent}%` }} /></span>
|
| 511 |
+
</section>
|
| 512 |
+
|
| 513 |
+
{error && <div className="bench-error" role="alert"><strong>Run rejected</strong><span>{error}</span></div>}
|
| 514 |
+
|
| 515 |
+
<section className="bench-core-section" aria-label="Benchmark measurements">
|
| 516 |
+
<div className="bench-section-heading">
|
| 517 |
+
<div><p className="eyebrow ink">Core sample</p><h3>Four boundaries, one run</h3></div>
|
| 518 |
+
{report && <button type="button" onClick={() => downloadReport(report)}>Export JSON</button>}
|
| 519 |
+
</div>
|
| 520 |
+
<div className="bench-core-sample">
|
| 521 |
+
<article>
|
| 522 |
+
<span>Cold load</span>
|
| 523 |
+
<strong>{formatDuration(report?.load.cold.durationMs)}</strong>
|
| 524 |
+
<small>{report ? `${report.load.cold.cacheState} at start` : 'first boundary'}</small>
|
| 525 |
+
</article>
|
| 526 |
+
<article>
|
| 527 |
+
<span>Warm load</span>
|
| 528 |
+
<strong>{formatDuration(report?.load.warm.durationMs)}</strong>
|
| 529 |
+
<small>verified cache retained</small>
|
| 530 |
+
</article>
|
| 531 |
+
<article>
|
| 532 |
+
<span>TTFT</span>
|
| 533 |
+
<strong>{formatDuration(report?.generation.timeToFirstTokenMs)}</strong>
|
| 534 |
+
<small>request to first stream</small>
|
| 535 |
+
</article>
|
| 536 |
+
<article>
|
| 537 |
+
<span>Decode</span>
|
| 538 |
+
<strong>{formatRate(report?.generation.decodeTokensPerSecond)}</strong>
|
| 539 |
+
<small>{report ? `${report.generation.completionTokens} completion tokens` : 'engine timing'}</small>
|
| 540 |
+
</article>
|
| 541 |
+
</div>
|
| 542 |
+
</section>
|
| 543 |
+
|
| 544 |
+
<section className="bench-detail-grid">
|
| 545 |
+
<article className="bench-data-card">
|
| 546 |
+
<p className="eyebrow ink">Throughput</p>
|
| 547 |
+
<dl>
|
| 548 |
+
<div><dt>Prompt</dt><dd>{formatRate(report?.generation.promptTokensPerSecond)}</dd></div>
|
| 549 |
+
<div><dt>Decode</dt><dd>{formatRate(report?.generation.decodeTokensPerSecond)}</dd></div>
|
| 550 |
+
<div><dt>Token count</dt><dd>{report?.generation.completionTokens ?? '—'}</dd></div>
|
| 551 |
+
<div><dt>Finish</dt><dd>{report?.generation.finishReason ?? '—'}</dd></div>
|
| 552 |
+
</dl>
|
| 553 |
+
</article>
|
| 554 |
+
<article className="bench-data-card">
|
| 555 |
+
<p className="eyebrow ink">Graph tripwire</p>
|
| 556 |
+
<dl>
|
| 557 |
+
<div><dt>Graph splits</dt><dd>{report?.execution.graphSplits ?? '—'}</dd></div>
|
| 558 |
+
<div><dt>CPU ops</dt><dd>{report?.execution.opsOnCpu ?? '—'}</dd></div>
|
| 559 |
+
<div><dt>GPU layers</dt><dd>{report?.execution.gpuLayers ? `${report.execution.gpuLayers.offloaded}/${report.execution.gpuLayers.total}` : '—'}</dd></div>
|
| 560 |
+
<div><dt>Backend</dt><dd>{report?.execution.selectedBackend.toUpperCase() ?? '—'}</dd></div>
|
| 561 |
+
</dl>
|
| 562 |
+
</article>
|
| 563 |
+
<article className="bench-data-card bench-runtime-card">
|
| 564 |
+
<p className="eyebrow ink">Runtime pins</p>
|
| 565 |
+
<dl>
|
| 566 |
+
<div><dt>Device</dt><dd>{report?.execution.device ?? capabilities?.webgpu.name ?? 'Inspecting…'}</dd></div>
|
| 567 |
+
<div><dt>wllama</dt><dd title={report?.runtime.wllamaRevision}>{report?.runtime.wllamaRevision.slice(0, 12) ?? '—'}</dd></div>
|
| 568 |
+
<div><dt>llama.cpp</dt><dd title={report?.runtime.llamaCppRevision}>{report?.runtime.llamaCppRevision.slice(0, 12) ?? '—'}</dd></div>
|
| 569 |
+
<div><dt>Weights</dt><dd title={report?.runtime.manifestRevision ?? undefined}>{report?.runtime.manifestRevision?.slice(0, 12) ?? '—'}</dd></div>
|
| 570 |
+
</dl>
|
| 571 |
+
</article>
|
| 572 |
+
</section>
|
| 573 |
+
|
| 574 |
+
<section className="bench-output">
|
| 575 |
+
<div>
|
| 576 |
+
<p className="eyebrow ink">Fixed specimen</p>
|
| 577 |
+
<h3>{BENCHMARK_PROMPT_ID}</h3>
|
| 578 |
+
<p>{BENCHMARK_PROMPT}</p>
|
| 579 |
+
</div>
|
| 580 |
+
<div>
|
| 581 |
+
<p className="eyebrow ink">Generated text · excluded from export</p>
|
| 582 |
+
<p>{output || 'Run the assay to inspect the generated specimen.'}</p>
|
| 583 |
+
</div>
|
| 584 |
+
</section>
|
| 585 |
+
</main>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
);
|
| 589 |
+
}
|
src/bench/report.test.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest';
|
| 2 |
+
import type {
|
| 3 |
+
EngineCapabilities,
|
| 4 |
+
LoadModelResult,
|
| 5 |
+
ManifestModelV2,
|
| 6 |
+
ModelManifestV2,
|
| 7 |
+
} from '../engine';
|
| 8 |
+
import {
|
| 9 |
+
benchmarkReportFilename,
|
| 10 |
+
buildBenchmarkReport,
|
| 11 |
+
classifyCacheState,
|
| 12 |
+
serializeBenchmarkReport,
|
| 13 |
+
type BenchmarkObservation,
|
| 14 |
+
} from './report';
|
| 15 |
+
|
| 16 |
+
const model = {
|
| 17 |
+
id: '8b',
|
| 18 |
+
displayName: 'Bonsai 8B',
|
| 19 |
+
architecture: 'qwen3next',
|
| 20 |
+
downloadBytes: 1_000,
|
| 21 |
+
} as ManifestModelV2;
|
| 22 |
+
|
| 23 |
+
const manifest = {
|
| 24 |
+
repository: {
|
| 25 |
+
id: 'WaveCut/Bonsai-web-GGUF',
|
| 26 |
+
revision: '112ea7a1a6229bde132b176b9a72477a7ecfde64',
|
| 27 |
+
},
|
| 28 |
+
dflash: { status: 'conditional' },
|
| 29 |
+
} as ModelManifestV2;
|
| 30 |
+
|
| 31 |
+
const capabilities = {
|
| 32 |
+
crossOriginIsolated: true,
|
| 33 |
+
sharedArrayBuffer: true,
|
| 34 |
+
hardwareConcurrency: 12,
|
| 35 |
+
storage: { usageBytes: 1_000, quotaBytes: 10_000, persisted: true },
|
| 36 |
+
webgpu: {
|
| 37 |
+
available: true,
|
| 38 |
+
name: 'Apple M4 Max',
|
| 39 |
+
vendor: 'Apple',
|
| 40 |
+
architecture: 'metal-3',
|
| 41 |
+
device: null,
|
| 42 |
+
description: 'Apple M4 Max',
|
| 43 |
+
limits: { maxStorageBufferBindingSize: 1_073_741_824 },
|
| 44 |
+
features: ['shader-f16'],
|
| 45 |
+
},
|
| 46 |
+
browser: {
|
| 47 |
+
userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
| 48 |
+
platform: 'MacIntel',
|
| 49 |
+
language: 'en-GB',
|
| 50 |
+
},
|
| 51 |
+
runtime: {
|
| 52 |
+
implementation: 'bonsai-wllama',
|
| 53 |
+
wllamaRevision: '912c18b75d4358c1405a64646b8dbe43a205943b',
|
| 54 |
+
llamaCppRevision: '00fa7cb284cbf133fc426733bd64238a3588a33e',
|
| 55 |
+
patchSetSha256: 'f'.repeat(64),
|
| 56 |
+
moduleSha256: 'e'.repeat(64),
|
| 57 |
+
wasmFlavor: 'jspi',
|
| 58 |
+
wasmSha256: 'd'.repeat(64),
|
| 59 |
+
compatWorkerSha256: null,
|
| 60 |
+
tokenEmbeddingOnWebGPU: true,
|
| 61 |
+
tensorPlacementOverrides: false,
|
| 62 |
+
},
|
| 63 |
+
} satisfies EngineCapabilities;
|
| 64 |
+
|
| 65 |
+
const loadResult = {
|
| 66 |
+
backend: 'webgpu',
|
| 67 |
+
context: { batchSize: 2_048, microBatchSize: 512 },
|
| 68 |
+
tuning: { flashAttention: false, cacheTypeK: 'f16', cacheTypeV: 'f16' },
|
| 69 |
+
} as LoadModelResult;
|
| 70 |
+
|
| 71 |
+
function observation(overrides: Partial<BenchmarkObservation> = {}): BenchmarkObservation {
|
| 72 |
+
return {
|
| 73 |
+
startedAt: '2026-07-15T10:00:00.000Z',
|
| 74 |
+
completedAt: '2026-07-15T10:00:10.123Z',
|
| 75 |
+
manifest,
|
| 76 |
+
model,
|
| 77 |
+
capabilities,
|
| 78 |
+
requestedBackend: 'auto',
|
| 79 |
+
contextSize: 4_096,
|
| 80 |
+
maxTokens: 64,
|
| 81 |
+
nBatch: null,
|
| 82 |
+
nUbatch: null,
|
| 83 |
+
coldLoadMs: 1_234.56,
|
| 84 |
+
warmLoadMs: 234.54,
|
| 85 |
+
coldCachedBytes: 250,
|
| 86 |
+
storagePersistent: true,
|
| 87 |
+
loadResult,
|
| 88 |
+
generationResult: {
|
| 89 |
+
text: 'fixture',
|
| 90 |
+
finishReason: 'stop',
|
| 91 |
+
toolCalls: [],
|
| 92 |
+
usage: { promptTokens: 20, completionTokens: 8, totalTokens: 28 },
|
| 93 |
+
timings: { promptTokensPerSecond: 123.456, predictedTokensPerSecond: 45.678 },
|
| 94 |
+
},
|
| 95 |
+
generationElapsedMs: 900.04,
|
| 96 |
+
timeToFirstTokenMs: 123.45,
|
| 97 |
+
streamedTokenEvents: 5,
|
| 98 |
+
backendReport: {
|
| 99 |
+
backends: ['WebGPU'],
|
| 100 |
+
nGraphSplits: 1,
|
| 101 |
+
opsOnCpu: 0,
|
| 102 |
+
layersGpu: { offloaded: 49, total: 49 },
|
| 103 |
+
},
|
| 104 |
+
...overrides,
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
describe('benchmark report', () => {
|
| 109 |
+
it('classifies the shard cache observed at the first load boundary', () => {
|
| 110 |
+
expect(classifyCacheState(null, 1_000)).toBe('unknown');
|
| 111 |
+
expect(classifyCacheState(0, 1_000)).toBe('empty');
|
| 112 |
+
expect(classifyCacheState(1, 1_000)).toBe('partial');
|
| 113 |
+
expect(classifyCacheState(1_000, 1_000)).toBe('cached');
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it('builds a shareable report with pinned runtime evidence and fixed-safe policy', () => {
|
| 117 |
+
const report = buildBenchmarkReport(observation());
|
| 118 |
+
|
| 119 |
+
expect(report.load).toMatchObject({
|
| 120 |
+
tuning: {
|
| 121 |
+
requestedNBatch: null,
|
| 122 |
+
requestedNUbatch: null,
|
| 123 |
+
nBatch: 2_048,
|
| 124 |
+
nUbatch: 512,
|
| 125 |
+
flashAttention: false,
|
| 126 |
+
cacheTypeK: 'f16',
|
| 127 |
+
cacheTypeV: 'f16',
|
| 128 |
+
status: 'release-defaults',
|
| 129 |
+
},
|
| 130 |
+
cold: { durationMs: 1_234.6, cacheState: 'partial' },
|
| 131 |
+
warm: { durationMs: 234.5 },
|
| 132 |
+
});
|
| 133 |
+
expect(report.generation).toMatchObject({
|
| 134 |
+
promptTokensPerSecond: 123.46,
|
| 135 |
+
decodeTokensPerSecond: 45.68,
|
| 136 |
+
engineCompletionTokens: 8,
|
| 137 |
+
streamedTokenEvents: 5,
|
| 138 |
+
completionTokens: 8,
|
| 139 |
+
tokenCountSource: 'engine-usage',
|
| 140 |
+
});
|
| 141 |
+
expect(report.execution).toMatchObject({
|
| 142 |
+
selectedBackend: 'webgpu',
|
| 143 |
+
device: 'Apple M4 Max',
|
| 144 |
+
graphSplits: 1,
|
| 145 |
+
opsOnCpu: 0,
|
| 146 |
+
});
|
| 147 |
+
expect(report.runtime.manifestRevision).toBe('112ea7a1a6229bde132b176b9a72477a7ecfde64');
|
| 148 |
+
expect(report.runtime).toMatchObject({
|
| 149 |
+
patchSetSha256: 'f'.repeat(64),
|
| 150 |
+
moduleSha256: 'e'.repeat(64),
|
| 151 |
+
wasmFlavor: 'jspi',
|
| 152 |
+
wasmSha256: 'd'.repeat(64),
|
| 153 |
+
compatWorkerSha256: null,
|
| 154 |
+
});
|
| 155 |
+
expect(report.environment).toEqual({
|
| 156 |
+
browser: {
|
| 157 |
+
userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
| 158 |
+
platform: 'MacIntel',
|
| 159 |
+
language: 'en-GB',
|
| 160 |
+
},
|
| 161 |
+
webgpu: {
|
| 162 |
+
available: true,
|
| 163 |
+
name: 'Apple M4 Max',
|
| 164 |
+
vendor: 'Apple',
|
| 165 |
+
architecture: 'metal-3',
|
| 166 |
+
device: null,
|
| 167 |
+
description: 'Apple M4 Max',
|
| 168 |
+
limits: { maxStorageBufferBindingSize: 1_073_741_824 },
|
| 169 |
+
features: ['shader-f16'],
|
| 170 |
+
},
|
| 171 |
+
});
|
| 172 |
+
expect(report.releasePolicy).toEqual({
|
| 173 |
+
flashAttention: {
|
| 174 |
+
enabled: false,
|
| 175 |
+
operatorConfigurable: false,
|
| 176 |
+
status: 'fixed-off-unvalidated-for-release',
|
| 177 |
+
},
|
| 178 |
+
kvCache: {
|
| 179 |
+
key: 'f16',
|
| 180 |
+
value: 'f16',
|
| 181 |
+
operatorConfigurable: false,
|
| 182 |
+
status: 'fixed-release-default-alternatives-unvalidated',
|
| 183 |
+
},
|
| 184 |
+
dflash: {
|
| 185 |
+
enabled: false,
|
| 186 |
+
runtimeAvailable: false,
|
| 187 |
+
status: 'conditional',
|
| 188 |
+
},
|
| 189 |
+
});
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
it('records experimental batch shape by the runtime field names without enabling flash attention', () => {
|
| 193 |
+
const report = buildBenchmarkReport(observation({ nBatch: 512, nUbatch: 128 }));
|
| 194 |
+
|
| 195 |
+
expect(report.load.tuning).toEqual({
|
| 196 |
+
requestedNBatch: 512,
|
| 197 |
+
requestedNUbatch: 128,
|
| 198 |
+
nBatch: 2_048,
|
| 199 |
+
nUbatch: 512,
|
| 200 |
+
flashAttention: false,
|
| 201 |
+
cacheTypeK: 'f16',
|
| 202 |
+
cacheTypeV: 'f16',
|
| 203 |
+
status: 'experimental-batch-shape',
|
| 204 |
+
});
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
it('uses streamed events when final engine usage is present but incomplete', () => {
|
| 208 |
+
const report = buildBenchmarkReport(observation({
|
| 209 |
+
streamedTokenEvents: 64,
|
| 210 |
+
generationResult: {
|
| 211 |
+
text: 'fixture',
|
| 212 |
+
finishReason: 'length',
|
| 213 |
+
toolCalls: [],
|
| 214 |
+
usage: { promptTokens: 5, completionTokens: 0, totalTokens: 5 },
|
| 215 |
+
timings: { promptTokensPerSecond: 20, predictedTokensPerSecond: 30 },
|
| 216 |
+
},
|
| 217 |
+
}));
|
| 218 |
+
|
| 219 |
+
expect(report.generation).toMatchObject({
|
| 220 |
+
engineCompletionTokens: 0,
|
| 221 |
+
streamedTokenEvents: 64,
|
| 222 |
+
completionTokens: 64,
|
| 223 |
+
engineTotalTokens: 5,
|
| 224 |
+
totalTokens: 69,
|
| 225 |
+
tokenCountSource: 'stream-events-fallback',
|
| 226 |
+
});
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
it('falls back to streamed events without emitting non-finite JSON metrics', () => {
|
| 230 |
+
const report = buildBenchmarkReport(observation({
|
| 231 |
+
generationElapsedMs: Number.NaN,
|
| 232 |
+
timeToFirstTokenMs: Number.POSITIVE_INFINITY,
|
| 233 |
+
generationResult: {
|
| 234 |
+
text: 'fixture',
|
| 235 |
+
finishReason: 'length',
|
| 236 |
+
toolCalls: [],
|
| 237 |
+
usage: null,
|
| 238 |
+
timings: null,
|
| 239 |
+
},
|
| 240 |
+
}));
|
| 241 |
+
|
| 242 |
+
expect(report.generation).toMatchObject({
|
| 243 |
+
elapsedMs: null,
|
| 244 |
+
timeToFirstTokenMs: null,
|
| 245 |
+
decodeTokensPerSecond: null,
|
| 246 |
+
completionTokens: 5,
|
| 247 |
+
tokenCountSource: 'stream-events-fallback',
|
| 248 |
+
});
|
| 249 |
+
expect(serializeBenchmarkReport(report)).not.toContain('NaN');
|
| 250 |
+
expect(serializeBenchmarkReport(report)).toMatch(/\n$/);
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it('uses the model, selected backend, and UTC completion time in the export name', () => {
|
| 254 |
+
const report = buildBenchmarkReport(observation());
|
| 255 |
+
expect(benchmarkReportFilename(report)).toBe(
|
| 256 |
+
'bonsai-bench-8b-webgpu-2026-07-15T10-00-10-123Z.json',
|
| 257 |
+
);
|
| 258 |
+
});
|
| 259 |
+
});
|
src/bench/report.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
BackendReport,
|
| 3 |
+
EngineCapabilities,
|
| 4 |
+
GenerateResult,
|
| 5 |
+
LoadModelResult,
|
| 6 |
+
ManifestModelV2,
|
| 7 |
+
ModelManifestV2,
|
| 8 |
+
RequestedBackend,
|
| 9 |
+
} from '../engine';
|
| 10 |
+
|
| 11 |
+
export const BENCHMARK_PROMPT_ID = 'bonsai-field-core-v1';
|
| 12 |
+
export const BENCHMARK_PROMPT = 'Describe how a tree survives one dry season in exactly eight short, factual sentences.';
|
| 13 |
+
|
| 14 |
+
export type CacheState = 'empty' | 'partial' | 'cached' | 'unknown';
|
| 15 |
+
|
| 16 |
+
export interface BenchmarkObservation {
|
| 17 |
+
startedAt: string;
|
| 18 |
+
completedAt: string;
|
| 19 |
+
manifest: ModelManifestV2;
|
| 20 |
+
model: ManifestModelV2;
|
| 21 |
+
capabilities: EngineCapabilities;
|
| 22 |
+
requestedBackend: RequestedBackend;
|
| 23 |
+
contextSize: number;
|
| 24 |
+
maxTokens: number;
|
| 25 |
+
nBatch: number | null;
|
| 26 |
+
nUbatch: number | null;
|
| 27 |
+
coldLoadMs: number;
|
| 28 |
+
warmLoadMs: number;
|
| 29 |
+
coldCachedBytes: number | null;
|
| 30 |
+
storagePersistent: boolean;
|
| 31 |
+
loadResult: LoadModelResult;
|
| 32 |
+
generationResult: GenerateResult;
|
| 33 |
+
generationElapsedMs: number;
|
| 34 |
+
timeToFirstTokenMs: number | null;
|
| 35 |
+
streamedTokenEvents: number;
|
| 36 |
+
backendReport: BackendReport;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export interface BenchmarkReport {
|
| 40 |
+
schemaVersion: 1;
|
| 41 |
+
kind: 'bonsai-browser-benchmark';
|
| 42 |
+
startedAt: string;
|
| 43 |
+
completedAt: string;
|
| 44 |
+
model: {
|
| 45 |
+
id: ManifestModelV2['id'];
|
| 46 |
+
displayName: string;
|
| 47 |
+
architecture: string;
|
| 48 |
+
downloadBytes: number;
|
| 49 |
+
contextSize: number;
|
| 50 |
+
};
|
| 51 |
+
load: {
|
| 52 |
+
tuning: {
|
| 53 |
+
requestedNBatch: number | null;
|
| 54 |
+
requestedNUbatch: number | null;
|
| 55 |
+
nBatch: number;
|
| 56 |
+
nUbatch: number;
|
| 57 |
+
flashAttention: boolean;
|
| 58 |
+
cacheTypeK: LoadModelResult['tuning']['cacheTypeK'];
|
| 59 |
+
cacheTypeV: LoadModelResult['tuning']['cacheTypeV'];
|
| 60 |
+
status: 'release-defaults' | 'experimental-batch-shape';
|
| 61 |
+
};
|
| 62 |
+
cold: {
|
| 63 |
+
durationMs: number | null;
|
| 64 |
+
cachedBytesAtStart: number | null;
|
| 65 |
+
cacheState: CacheState;
|
| 66 |
+
definition: string;
|
| 67 |
+
};
|
| 68 |
+
warm: {
|
| 69 |
+
durationMs: number | null;
|
| 70 |
+
definition: string;
|
| 71 |
+
};
|
| 72 |
+
};
|
| 73 |
+
generation: {
|
| 74 |
+
promptId: typeof BENCHMARK_PROMPT_ID;
|
| 75 |
+
prompt: typeof BENCHMARK_PROMPT;
|
| 76 |
+
maxTokens: number;
|
| 77 |
+
temperature: 0;
|
| 78 |
+
seed: 42;
|
| 79 |
+
elapsedMs: number | null;
|
| 80 |
+
timeToFirstTokenMs: number | null;
|
| 81 |
+
promptTokensPerSecond: number | null;
|
| 82 |
+
decodeTokensPerSecond: number | null;
|
| 83 |
+
promptTokens: number | null;
|
| 84 |
+
engineCompletionTokens: number | null;
|
| 85 |
+
streamedTokenEvents: number;
|
| 86 |
+
completionTokens: number;
|
| 87 |
+
engineTotalTokens: number | null;
|
| 88 |
+
totalTokens: number | null;
|
| 89 |
+
tokenCountSource: 'engine-usage' | 'stream-events-fallback';
|
| 90 |
+
finishReason: GenerateResult['finishReason'];
|
| 91 |
+
};
|
| 92 |
+
execution: {
|
| 93 |
+
requestedBackend: RequestedBackend;
|
| 94 |
+
selectedBackend: LoadModelResult['backend'];
|
| 95 |
+
device: string;
|
| 96 |
+
graphSplits: number | null;
|
| 97 |
+
opsOnCpu: number;
|
| 98 |
+
gpuLayers: BackendReport['layersGpu'];
|
| 99 |
+
confirmedBackends: string[];
|
| 100 |
+
};
|
| 101 |
+
runtime: {
|
| 102 |
+
implementation: EngineCapabilities['runtime']['implementation'];
|
| 103 |
+
wllamaRevision: string;
|
| 104 |
+
llamaCppRevision: string;
|
| 105 |
+
patchSetSha256: string;
|
| 106 |
+
moduleSha256: string;
|
| 107 |
+
wasmFlavor: EngineCapabilities['runtime']['wasmFlavor'];
|
| 108 |
+
wasmSha256: string;
|
| 109 |
+
compatWorkerSha256: string | null;
|
| 110 |
+
manifestRepository: string;
|
| 111 |
+
manifestRevision: string | null;
|
| 112 |
+
crossOriginIsolated: boolean;
|
| 113 |
+
sharedArrayBuffer: boolean;
|
| 114 |
+
hardwareConcurrency: number;
|
| 115 |
+
persistentStorage: boolean;
|
| 116 |
+
};
|
| 117 |
+
environment: {
|
| 118 |
+
browser: EngineCapabilities['browser'];
|
| 119 |
+
webgpu: EngineCapabilities['webgpu'];
|
| 120 |
+
};
|
| 121 |
+
releasePolicy: {
|
| 122 |
+
flashAttention: {
|
| 123 |
+
enabled: false;
|
| 124 |
+
operatorConfigurable: false;
|
| 125 |
+
status: 'fixed-off-unvalidated-for-release';
|
| 126 |
+
};
|
| 127 |
+
kvCache: {
|
| 128 |
+
key: 'f16';
|
| 129 |
+
value: 'f16';
|
| 130 |
+
operatorConfigurable: false;
|
| 131 |
+
status: 'fixed-release-default-alternatives-unvalidated';
|
| 132 |
+
};
|
| 133 |
+
dflash: {
|
| 134 |
+
enabled: false;
|
| 135 |
+
runtimeAvailable: false;
|
| 136 |
+
status: ModelManifestV2['dflash']['status'];
|
| 137 |
+
};
|
| 138 |
+
};
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function roundedNonNegative(value: number | null, digits: number): number | null {
|
| 142 |
+
if (value === null || !Number.isFinite(value) || value < 0) return null;
|
| 143 |
+
const scale = 10 ** digits;
|
| 144 |
+
return Math.round(value * scale) / scale;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export function classifyCacheState(observedBytes: number | null, totalBytes: number): CacheState {
|
| 148 |
+
if (observedBytes === null || !Number.isFinite(observedBytes) || totalBytes <= 0) return 'unknown';
|
| 149 |
+
if (observedBytes <= 0) return 'empty';
|
| 150 |
+
if (observedBytes >= totalBytes) return 'cached';
|
| 151 |
+
return 'partial';
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export function buildBenchmarkReport(observation: BenchmarkObservation): BenchmarkReport {
|
| 155 |
+
const { generationResult, capabilities, backendReport, loadResult } = observation;
|
| 156 |
+
const usage = generationResult.usage;
|
| 157 |
+
const engineCompletionTokens = usage
|
| 158 |
+
&& Number.isFinite(usage.completionTokens)
|
| 159 |
+
&& usage.completionTokens >= 0
|
| 160 |
+
? usage.completionTokens
|
| 161 |
+
: null;
|
| 162 |
+
const streamedTokenEvents = Number.isFinite(observation.streamedTokenEvents)
|
| 163 |
+
? Math.floor(Math.max(0, observation.streamedTokenEvents))
|
| 164 |
+
: 0;
|
| 165 |
+
const completionTokens = Math.max(engineCompletionTokens ?? 0, streamedTokenEvents);
|
| 166 |
+
const promptTokens = usage && Number.isFinite(usage.promptTokens) && usage.promptTokens >= 0
|
| 167 |
+
? Math.floor(usage.promptTokens)
|
| 168 |
+
: null;
|
| 169 |
+
const engineTotalTokens = usage && Number.isFinite(usage.totalTokens) && usage.totalTokens >= 0
|
| 170 |
+
? Math.floor(usage.totalTokens)
|
| 171 |
+
: null;
|
| 172 |
+
const totalTokens = usage
|
| 173 |
+
? Math.max(engineTotalTokens ?? 0, (promptTokens ?? 0) + completionTokens)
|
| 174 |
+
: null;
|
| 175 |
+
const decodeRate = generationResult.timings?.predictedTokensPerSecond
|
| 176 |
+
?? (completionTokens > 0 && observation.generationElapsedMs > 0
|
| 177 |
+
? completionTokens / (observation.generationElapsedMs / 1000)
|
| 178 |
+
: null);
|
| 179 |
+
const device = loadResult.backend === 'webgpu'
|
| 180 |
+
? capabilities.webgpu.name || 'WebGPU adapter'
|
| 181 |
+
: `CPU · ${capabilities.hardwareConcurrency} logical cores`;
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
schemaVersion: 1,
|
| 185 |
+
kind: 'bonsai-browser-benchmark',
|
| 186 |
+
startedAt: observation.startedAt,
|
| 187 |
+
completedAt: observation.completedAt,
|
| 188 |
+
model: {
|
| 189 |
+
id: observation.model.id,
|
| 190 |
+
displayName: observation.model.displayName,
|
| 191 |
+
architecture: observation.model.architecture,
|
| 192 |
+
downloadBytes: observation.model.downloadBytes,
|
| 193 |
+
contextSize: observation.contextSize,
|
| 194 |
+
},
|
| 195 |
+
load: {
|
| 196 |
+
tuning: {
|
| 197 |
+
requestedNBatch: observation.nBatch,
|
| 198 |
+
requestedNUbatch: observation.nUbatch,
|
| 199 |
+
nBatch: loadResult.context.batchSize,
|
| 200 |
+
nUbatch: loadResult.context.microBatchSize,
|
| 201 |
+
flashAttention: loadResult.tuning.flashAttention,
|
| 202 |
+
cacheTypeK: loadResult.tuning.cacheTypeK,
|
| 203 |
+
cacheTypeV: loadResult.tuning.cacheTypeV,
|
| 204 |
+
status: observation.nBatch === null && observation.nUbatch === null
|
| 205 |
+
? 'release-defaults'
|
| 206 |
+
: 'experimental-batch-shape',
|
| 207 |
+
},
|
| 208 |
+
cold: {
|
| 209 |
+
durationMs: roundedNonNegative(observation.coldLoadMs, 1),
|
| 210 |
+
cachedBytesAtStart: roundedNonNegative(observation.coldCachedBytes, 0),
|
| 211 |
+
cacheState: classifyCacheState(observation.coldCachedBytes, observation.model.downloadBytes),
|
| 212 |
+
definition: 'First load in this benchmark run; previously verified browser shards may already be cached.',
|
| 213 |
+
},
|
| 214 |
+
warm: {
|
| 215 |
+
durationMs: roundedNonNegative(observation.warmLoadMs, 1),
|
| 216 |
+
definition: 'Second load after an explicit unload, with the verified shard cache retained.',
|
| 217 |
+
},
|
| 218 |
+
},
|
| 219 |
+
generation: {
|
| 220 |
+
promptId: BENCHMARK_PROMPT_ID,
|
| 221 |
+
prompt: BENCHMARK_PROMPT,
|
| 222 |
+
maxTokens: observation.maxTokens,
|
| 223 |
+
temperature: 0,
|
| 224 |
+
seed: 42,
|
| 225 |
+
elapsedMs: roundedNonNegative(observation.generationElapsedMs, 1),
|
| 226 |
+
timeToFirstTokenMs: roundedNonNegative(observation.timeToFirstTokenMs, 1),
|
| 227 |
+
promptTokensPerSecond: roundedNonNegative(
|
| 228 |
+
generationResult.timings?.promptTokensPerSecond ?? null,
|
| 229 |
+
2,
|
| 230 |
+
),
|
| 231 |
+
decodeTokensPerSecond: roundedNonNegative(decodeRate, 2),
|
| 232 |
+
promptTokens,
|
| 233 |
+
engineCompletionTokens,
|
| 234 |
+
streamedTokenEvents,
|
| 235 |
+
completionTokens,
|
| 236 |
+
engineTotalTokens,
|
| 237 |
+
totalTokens,
|
| 238 |
+
tokenCountSource: engineCompletionTokens !== null && engineCompletionTokens >= streamedTokenEvents
|
| 239 |
+
? 'engine-usage'
|
| 240 |
+
: 'stream-events-fallback',
|
| 241 |
+
finishReason: generationResult.finishReason,
|
| 242 |
+
},
|
| 243 |
+
execution: {
|
| 244 |
+
requestedBackend: observation.requestedBackend,
|
| 245 |
+
selectedBackend: loadResult.backend,
|
| 246 |
+
device,
|
| 247 |
+
graphSplits: backendReport.nGraphSplits,
|
| 248 |
+
opsOnCpu: backendReport.opsOnCpu,
|
| 249 |
+
gpuLayers: backendReport.layersGpu,
|
| 250 |
+
confirmedBackends: [...backendReport.backends],
|
| 251 |
+
},
|
| 252 |
+
runtime: {
|
| 253 |
+
implementation: capabilities.runtime.implementation,
|
| 254 |
+
wllamaRevision: capabilities.runtime.wllamaRevision,
|
| 255 |
+
llamaCppRevision: capabilities.runtime.llamaCppRevision,
|
| 256 |
+
patchSetSha256: capabilities.runtime.patchSetSha256,
|
| 257 |
+
moduleSha256: capabilities.runtime.moduleSha256,
|
| 258 |
+
wasmFlavor: capabilities.runtime.wasmFlavor,
|
| 259 |
+
wasmSha256: capabilities.runtime.wasmSha256,
|
| 260 |
+
compatWorkerSha256: capabilities.runtime.compatWorkerSha256,
|
| 261 |
+
manifestRepository: observation.manifest.repository.id,
|
| 262 |
+
manifestRevision: observation.manifest.repository.revision,
|
| 263 |
+
crossOriginIsolated: capabilities.crossOriginIsolated,
|
| 264 |
+
sharedArrayBuffer: capabilities.sharedArrayBuffer,
|
| 265 |
+
hardwareConcurrency: capabilities.hardwareConcurrency,
|
| 266 |
+
persistentStorage: observation.storagePersistent,
|
| 267 |
+
},
|
| 268 |
+
environment: {
|
| 269 |
+
browser: { ...capabilities.browser },
|
| 270 |
+
webgpu: {
|
| 271 |
+
...capabilities.webgpu,
|
| 272 |
+
limits: { ...capabilities.webgpu.limits },
|
| 273 |
+
features: [...capabilities.webgpu.features],
|
| 274 |
+
},
|
| 275 |
+
},
|
| 276 |
+
releasePolicy: {
|
| 277 |
+
flashAttention: {
|
| 278 |
+
enabled: false,
|
| 279 |
+
operatorConfigurable: false,
|
| 280 |
+
status: 'fixed-off-unvalidated-for-release',
|
| 281 |
+
},
|
| 282 |
+
kvCache: {
|
| 283 |
+
key: 'f16',
|
| 284 |
+
value: 'f16',
|
| 285 |
+
operatorConfigurable: false,
|
| 286 |
+
status: 'fixed-release-default-alternatives-unvalidated',
|
| 287 |
+
},
|
| 288 |
+
dflash: {
|
| 289 |
+
enabled: false,
|
| 290 |
+
runtimeAvailable: false,
|
| 291 |
+
status: observation.manifest.dflash.status,
|
| 292 |
+
},
|
| 293 |
+
},
|
| 294 |
+
};
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
export function serializeBenchmarkReport(report: BenchmarkReport): string {
|
| 298 |
+
return `${JSON.stringify(report, null, 2)}\n`;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
export function benchmarkReportFilename(report: BenchmarkReport): string {
|
| 302 |
+
const timestamp = report.completedAt.replace(/[:.]/g, '-');
|
| 303 |
+
return `bonsai-bench-${report.model.id}-${report.execution.selectedBackend}-${timestamp}.json`;
|
| 304 |
+
}
|
src/engine/device-gate.test.ts
CHANGED
|
@@ -80,6 +80,28 @@ describe('inspectWebGpuAdapter', () => {
|
|
| 80 |
expect(requestAdapter).toHaveBeenCalledWith();
|
| 81 |
expect(snapshot.name).toBe('Default test GPU');
|
| 82 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
});
|
| 84 |
|
| 85 |
describe('supportsWllamaWebGpuRuntime', () => {
|
|
|
|
| 80 |
expect(requestAdapter).toHaveBeenCalledWith();
|
| 81 |
expect(snapshot.name).toBe('Default test GPU');
|
| 82 |
});
|
| 83 |
+
|
| 84 |
+
it('normalizes empty adapter-info strings instead of exporting an empty device name', async () => {
|
| 85 |
+
vi.stubGlobal('navigator', {
|
| 86 |
+
gpu: {
|
| 87 |
+
requestAdapter: vi.fn().mockResolvedValue({
|
| 88 |
+
limits: {},
|
| 89 |
+
features: new Set(),
|
| 90 |
+
info: { vendor: ' Apple ', architecture: '', device: '', description: ' ' },
|
| 91 |
+
}),
|
| 92 |
+
},
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const snapshot = await inspectWebGpuAdapter();
|
| 96 |
+
|
| 97 |
+
expect(snapshot).toMatchObject({
|
| 98 |
+
name: 'Apple',
|
| 99 |
+
vendor: 'Apple',
|
| 100 |
+
architecture: null,
|
| 101 |
+
device: null,
|
| 102 |
+
description: null,
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
});
|
| 106 |
|
| 107 |
describe('supportsWllamaWebGpuRuntime', () => {
|
src/engine/device-gate.ts
CHANGED
|
@@ -113,14 +113,22 @@ export async function inspectWebGpuAdapter(): Promise<WebGpuAdapterSnapshot> {
|
|
| 113 |
};
|
| 114 |
}
|
| 115 |
const info = adapter.info ?? await adapter.requestAdapterInfo?.() ?? {};
|
| 116 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return {
|
| 118 |
available: true,
|
| 119 |
name,
|
| 120 |
-
vendor
|
| 121 |
-
architecture
|
| 122 |
-
device
|
| 123 |
-
description
|
| 124 |
limits: readLimits(adapter.limits),
|
| 125 |
features: [...adapter.features].sort(),
|
| 126 |
};
|
|
|
|
| 113 |
};
|
| 114 |
}
|
| 115 |
const info = adapter.info ?? await adapter.requestAdapterInfo?.() ?? {};
|
| 116 |
+
const clean = (value: string | undefined): string | null => {
|
| 117 |
+
const trimmed = value?.trim();
|
| 118 |
+
return trimmed ? trimmed : null;
|
| 119 |
+
};
|
| 120 |
+
const vendor = clean(info.vendor);
|
| 121 |
+
const architecture = clean(info.architecture);
|
| 122 |
+
const device = clean(info.device);
|
| 123 |
+
const description = clean(info.description);
|
| 124 |
+
const name = description ?? device ?? architecture ?? vendor ?? 'WebGPU adapter';
|
| 125 |
return {
|
| 126 |
available: true,
|
| 127 |
name,
|
| 128 |
+
vendor,
|
| 129 |
+
architecture,
|
| 130 |
+
device,
|
| 131 |
+
description,
|
| 132 |
limits: readLimits(adapter.limits),
|
| 133 |
features: [...adapter.features].sort(),
|
| 134 |
};
|
src/engine/protocol.ts
CHANGED
|
@@ -27,10 +27,20 @@ export interface EngineCapabilities {
|
|
| 27 |
hardwareConcurrency: number;
|
| 28 |
webgpu: WebGpuAdapterSnapshot;
|
| 29 |
storage: StorageEstimate;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
runtime: {
|
| 31 |
implementation: 'bonsai-wllama';
|
| 32 |
wllamaRevision: string;
|
| 33 |
llamaCppRevision: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
tokenEmbeddingOnWebGPU: true;
|
| 35 |
tensorPlacementOverrides: false;
|
| 36 |
};
|
|
@@ -96,6 +106,9 @@ export type LoadModelParams = ManifestSource & {
|
|
| 96 |
backend: RequestedBackend;
|
| 97 |
contextSize?: number;
|
| 98 |
threads?: number;
|
|
|
|
|
|
|
|
|
|
| 99 |
cacheTypeK?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
|
| 100 |
cacheTypeV?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
|
| 101 |
tensorPlacement?: FutureTensorPlacement;
|
|
@@ -124,6 +137,13 @@ export interface LoadModelResult {
|
|
| 124 |
trainingSize: number;
|
| 125 |
layerCount: number;
|
| 126 |
vocabularySize: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
};
|
| 128 |
chatTemplate: {
|
| 129 |
bytes: number;
|
|
|
|
| 27 |
hardwareConcurrency: number;
|
| 28 |
webgpu: WebGpuAdapterSnapshot;
|
| 29 |
storage: StorageEstimate;
|
| 30 |
+
browser: {
|
| 31 |
+
userAgent: string;
|
| 32 |
+
platform: string;
|
| 33 |
+
language: string;
|
| 34 |
+
};
|
| 35 |
runtime: {
|
| 36 |
implementation: 'bonsai-wllama';
|
| 37 |
wllamaRevision: string;
|
| 38 |
llamaCppRevision: string;
|
| 39 |
+
patchSetSha256: string;
|
| 40 |
+
moduleSha256: string;
|
| 41 |
+
wasmFlavor: 'jspi' | 'compat';
|
| 42 |
+
wasmSha256: string;
|
| 43 |
+
compatWorkerSha256: string | null;
|
| 44 |
tokenEmbeddingOnWebGPU: true;
|
| 45 |
tensorPlacementOverrides: false;
|
| 46 |
};
|
|
|
|
| 106 |
backend: RequestedBackend;
|
| 107 |
contextSize?: number;
|
| 108 |
threads?: number;
|
| 109 |
+
nBatch?: number;
|
| 110 |
+
nUbatch?: number;
|
| 111 |
+
flashAttention?: boolean;
|
| 112 |
cacheTypeK?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
|
| 113 |
cacheTypeV?: 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0';
|
| 114 |
tensorPlacement?: FutureTensorPlacement;
|
|
|
|
| 137 |
trainingSize: number;
|
| 138 |
layerCount: number;
|
| 139 |
vocabularySize: number;
|
| 140 |
+
batchSize: number;
|
| 141 |
+
microBatchSize: number;
|
| 142 |
+
};
|
| 143 |
+
tuning: {
|
| 144 |
+
flashAttention: boolean;
|
| 145 |
+
cacheTypeK: NonNullable<LoadModelParams['cacheTypeK']>;
|
| 146 |
+
cacheTypeV: NonNullable<LoadModelParams['cacheTypeV']>;
|
| 147 |
};
|
| 148 |
chatTemplate: {
|
| 149 |
bytes: number;
|
src/engine/runtime-download.test.ts
CHANGED
|
@@ -312,3 +312,30 @@ describe('BrowserEngineRuntime per-shard retry', () => {
|
|
| 312 |
expect(progress[progress.length - 1]).toBe(0);
|
| 313 |
});
|
| 314 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
expect(progress[progress.length - 1]).toBe(0);
|
| 313 |
});
|
| 314 |
});
|
| 315 |
+
|
| 316 |
+
describe('BrowserEngineRuntime benchmark load controls', () => {
|
| 317 |
+
const invalidLoad = (extra: { nBatch?: number; nUbatch?: number }) => new BrowserEngineRuntime().loadModel(
|
| 318 |
+
'request-invalid-batch',
|
| 319 |
+
{
|
| 320 |
+
manifest: { schemaVersion: 2 } as never,
|
| 321 |
+
modelId: '1_7b',
|
| 322 |
+
backend: 'webgpu',
|
| 323 |
+
...extra,
|
| 324 |
+
},
|
| 325 |
+
new AbortController().signal,
|
| 326 |
+
() => undefined,
|
| 327 |
+
);
|
| 328 |
+
|
| 329 |
+
it('rejects invalid n_batch before touching the manifest or model cache', async () => {
|
| 330 |
+
await expect(invalidLoad({ nBatch: 0 })).rejects.toMatchObject({ code: 'INVALID_BATCH_SIZE' });
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
it('rejects invalid n_ubatch before touching the manifest or model cache', async () => {
|
| 334 |
+
await expect(invalidLoad({ nUbatch: 1.5 })).rejects.toMatchObject({ code: 'INVALID_UBATCH_SIZE' });
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
it('rejects batch values that overflow the signed glue integer', async () => {
|
| 338 |
+
await expect(invalidLoad({ nBatch: 2_147_483_648 })).rejects.toMatchObject({ code: 'INVALID_BATCH_SIZE' });
|
| 339 |
+
await expect(invalidLoad({ nUbatch: Number.MAX_SAFE_INTEGER })).rejects.toMatchObject({ code: 'INVALID_UBATCH_SIZE' });
|
| 340 |
+
});
|
| 341 |
+
});
|
src/engine/runtime-generate.test.ts
CHANGED
|
@@ -93,4 +93,52 @@ describe('BrowserEngineRuntime generation telemetry', () => {
|
|
| 93 |
expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 1, totalTokens: 10 });
|
| 94 |
expect(result.timings).toEqual({ promptTokensPerSecond: 100, predictedTokensPerSecond: 50 });
|
| 95 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
});
|
|
|
|
| 93 |
expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 1, totalTokens: 10 });
|
| 94 |
expect(result.timings).toEqual({ promptTokensPerSecond: 100, predictedTokensPerSecond: 50 });
|
| 95 |
});
|
| 96 |
+
|
| 97 |
+
it('reconciles incomplete streamed usage with llama.cpp timing counts', async () => {
|
| 98 |
+
const runtime = new BrowserEngineRuntime();
|
| 99 |
+
const internals = runtime as unknown as RuntimeInternals;
|
| 100 |
+
internals.loaded = {
|
| 101 |
+
manifest: {},
|
| 102 |
+
backend: 'wasm',
|
| 103 |
+
model: {
|
| 104 |
+
id: '1_7b',
|
| 105 |
+
displayName: 'Fixture Bonsai',
|
| 106 |
+
cpuFallback: true,
|
| 107 |
+
runtimePolicy: {
|
| 108 |
+
flashAttention: false,
|
| 109 |
+
tokenEmbeddingOnWebGPU: true,
|
| 110 |
+
requireSingleWebGPUGraph: false,
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
};
|
| 114 |
+
internals.wllama = {
|
| 115 |
+
isModelLoaded: () => true,
|
| 116 |
+
createChatCompletion: async ({ onData }) => {
|
| 117 |
+
onData(chunk('one, two, three', {
|
| 118 |
+
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
|
| 119 |
+
timings: {
|
| 120 |
+
cache_n: 0,
|
| 121 |
+
prompt_n: 9,
|
| 122 |
+
prompt_ms: 90,
|
| 123 |
+
prompt_per_token_ms: 10,
|
| 124 |
+
prompt_per_second: 100,
|
| 125 |
+
predicted_n: 64,
|
| 126 |
+
predicted_ms: 1_280,
|
| 127 |
+
predicted_per_token_ms: 20,
|
| 128 |
+
predicted_per_second: 50,
|
| 129 |
+
},
|
| 130 |
+
}));
|
| 131 |
+
onData(chunk(''));
|
| 132 |
+
},
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const result = await runtime.generate(
|
| 136 |
+
'request-incomplete-usage',
|
| 137 |
+
{ messages: [{ role: 'user', content: 'Count.' }] },
|
| 138 |
+
new AbortController().signal,
|
| 139 |
+
() => undefined,
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
expect(result.usage).toEqual({ promptTokens: 9, completionTokens: 64, totalTokens: 73 });
|
| 143 |
+
});
|
| 144 |
});
|
src/engine/runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
| 6 |
type ChatCompletionUsage,
|
| 7 |
type ResultTimings,
|
| 8 |
} from '../../vendor/wllama-bonsai/esm/index.js';
|
|
|
|
| 9 |
import {
|
| 10 |
assertBackendPolicy,
|
| 11 |
estimateStorage,
|
|
@@ -46,8 +47,27 @@ const LLAMA_CPP_REVISION = '00fa7cb284cbf133fc426733bd64238a3588a33e';
|
|
| 46 |
const WLLAMA_WASM_PATH = '/wasm/wllama.wasm';
|
| 47 |
const WLLAMA_COMPAT_WASM_PATH = '/wasm/wllama-compat.wasm';
|
| 48 |
const WLLAMA_COMPAT_WORKER_PATH = '/wasm/wllama-compat.js';
|
|
|
|
| 49 |
const DEVICE_LOST_EXIT_GRACE_MS = 100;
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
type EventSink = (event: EngineEvent) => void;
|
| 52 |
|
| 53 |
interface LoadedModelState {
|
|
@@ -145,14 +165,31 @@ function emitProgress(
|
|
| 145 |
});
|
| 146 |
}
|
| 147 |
|
| 148 |
-
function
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return null;
|
| 151 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return {
|
| 153 |
-
promptTokens
|
| 154 |
-
completionTokens
|
| 155 |
-
totalTokens: usage.total_tokens,
|
| 156 |
};
|
| 157 |
}
|
| 158 |
|
|
@@ -290,16 +327,32 @@ export class BrowserEngineRuntime {
|
|
| 290 |
inspectWebGpuAdapter(),
|
| 291 |
estimateStorage(),
|
| 292 |
]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
return {
|
| 294 |
crossOriginIsolated: globalThis.crossOriginIsolated,
|
| 295 |
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
|
| 296 |
hardwareConcurrency: navigator.hardwareConcurrency || 1,
|
| 297 |
webgpu,
|
| 298 |
storage,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
runtime: {
|
| 300 |
implementation: 'bonsai-wllama',
|
| 301 |
wllamaRevision: WLLAMA_REVISION,
|
| 302 |
llamaCppRevision: LLAMA_CPP_REVISION,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
tokenEmbeddingOnWebGPU: true,
|
| 304 |
tensorPlacementOverrides: false,
|
| 305 |
},
|
|
@@ -488,6 +541,26 @@ export class BrowserEngineRuntime {
|
|
| 488 |
params.tensorPlacement,
|
| 489 |
);
|
| 490 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
await this.unload();
|
| 492 |
this.nativeLog.clear();
|
| 493 |
emitProgress(sink, requestId, {
|
|
@@ -551,15 +624,16 @@ export class BrowserEngineRuntime {
|
|
| 551 |
if (!Number.isSafeInteger(threads) || threads <= 0) {
|
| 552 |
throw new EngineRuntimeError('INVALID_THREAD_COUNT', 'Thread count must be a positive integer.');
|
| 553 |
}
|
| 554 |
-
|
| 555 |
try {
|
| 556 |
await this.raceWebGpuDeviceLoss('load', gate.selectedBackend, model, wllama.loadModel(blobs, {
|
| 557 |
n_gpu_layers: gate.selectedBackend === 'webgpu' ? 99999 : 0,
|
| 558 |
offload_token_embedding: gate.selectedBackend === 'webgpu',
|
| 559 |
n_ctx: contextSize,
|
| 560 |
n_threads: threads,
|
|
|
|
|
|
|
| 561 |
seed: 42,
|
| 562 |
-
flash_attn: false,
|
| 563 |
warmup: false,
|
| 564 |
cache_type_k: params.cacheTypeK ?? 'f16',
|
| 565 |
cache_type_v: params.cacheTypeV ?? 'f16',
|
|
@@ -597,6 +671,13 @@ export class BrowserEngineRuntime {
|
|
| 597 |
trainingSize: context.n_ctx_train,
|
| 598 |
layerCount: context.n_layer,
|
| 599 |
vocabularySize: context.n_vocab,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
},
|
| 601 |
chatTemplate: {
|
| 602 |
bytes: new TextEncoder().encode(template).byteLength,
|
|
@@ -667,7 +748,7 @@ export class BrowserEngineRuntime {
|
|
| 667 |
});
|
| 668 |
}
|
| 669 |
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
|
| 670 |
-
usage = mapUsage(chunk.usage) ?? usage;
|
| 671 |
timings = mapTimings(chunk.timings) ?? timings;
|
| 672 |
},
|
| 673 |
}));
|
|
|
|
| 6 |
type ChatCompletionUsage,
|
| 7 |
type ResultTimings,
|
| 8 |
} from '../../vendor/wllama-bonsai/esm/index.js';
|
| 9 |
+
import runtimeSource from '../../vendor/wllama-bonsai/SOURCE.json';
|
| 10 |
import {
|
| 11 |
assertBackendPolicy,
|
| 12 |
estimateStorage,
|
|
|
|
| 47 |
const WLLAMA_WASM_PATH = '/wasm/wllama.wasm';
|
| 48 |
const WLLAMA_COMPAT_WASM_PATH = '/wasm/wllama-compat.wasm';
|
| 49 |
const WLLAMA_COMPAT_WORKER_PATH = '/wasm/wllama-compat.js';
|
| 50 |
+
const MAX_GLUE_INT = 2_147_483_647;
|
| 51 |
const DEVICE_LOST_EXIT_GRACE_MS = 100;
|
| 52 |
|
| 53 |
+
function runtimeArtifact(path: string): { bytes: number; sha256: string } {
|
| 54 |
+
const artifact = runtimeSource.files.find((file) => file.path === path);
|
| 55 |
+
if (!artifact) throw new Error(`Runtime provenance is missing ${path}`);
|
| 56 |
+
return artifact;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function needsCompatWasm(): boolean {
|
| 60 |
+
if (!(WebAssembly as typeof WebAssembly & { Suspending?: unknown }).Suspending) return true;
|
| 61 |
+
try {
|
| 62 |
+
new WebAssembly.Memory(
|
| 63 |
+
{ address: 'i64', initial: 1n } as unknown as WebAssembly.MemoryDescriptor,
|
| 64 |
+
);
|
| 65 |
+
return false;
|
| 66 |
+
} catch {
|
| 67 |
+
return true;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
type EventSink = (event: EngineEvent) => void;
|
| 72 |
|
| 73 |
interface LoadedModelState {
|
|
|
|
| 165 |
});
|
| 166 |
}
|
| 167 |
|
| 168 |
+
function tokenCount(value: number | null | undefined): number {
|
| 169 |
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
| 170 |
+
? Math.floor(value)
|
| 171 |
+
: 0;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function mapUsage(
|
| 175 |
+
usage: ChatCompletionUsage | null | undefined,
|
| 176 |
+
timings: ResultTimings | undefined,
|
| 177 |
+
): GenerateResult['usage'] {
|
| 178 |
+
if (!usage && !timings) {
|
| 179 |
return null;
|
| 180 |
}
|
| 181 |
+
const promptTokens = Math.max(
|
| 182 |
+
tokenCount(usage?.prompt_tokens),
|
| 183 |
+
tokenCount(timings?.prompt_n),
|
| 184 |
+
);
|
| 185 |
+
const completionTokens = Math.max(
|
| 186 |
+
tokenCount(usage?.completion_tokens),
|
| 187 |
+
tokenCount(timings?.predicted_n),
|
| 188 |
+
);
|
| 189 |
return {
|
| 190 |
+
promptTokens,
|
| 191 |
+
completionTokens,
|
| 192 |
+
totalTokens: Math.max(tokenCount(usage?.total_tokens), promptTokens + completionTokens),
|
| 193 |
};
|
| 194 |
}
|
| 195 |
|
|
|
|
| 327 |
inspectWebGpuAdapter(),
|
| 328 |
estimateStorage(),
|
| 329 |
]);
|
| 330 |
+
const wasmFlavor = needsCompatWasm() ? 'compat' : 'jspi';
|
| 331 |
+
const wasmArtifact = runtimeArtifact(
|
| 332 |
+
wasmFlavor === 'compat' ? 'public/wasm/wllama-compat.wasm' : 'public/wasm/wllama.wasm',
|
| 333 |
+
);
|
| 334 |
return {
|
| 335 |
crossOriginIsolated: globalThis.crossOriginIsolated,
|
| 336 |
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
|
| 337 |
hardwareConcurrency: navigator.hardwareConcurrency || 1,
|
| 338 |
webgpu,
|
| 339 |
storage,
|
| 340 |
+
browser: {
|
| 341 |
+
userAgent: navigator.userAgent,
|
| 342 |
+
platform: navigator.platform,
|
| 343 |
+
language: navigator.language,
|
| 344 |
+
},
|
| 345 |
runtime: {
|
| 346 |
implementation: 'bonsai-wllama',
|
| 347 |
wllamaRevision: WLLAMA_REVISION,
|
| 348 |
llamaCppRevision: LLAMA_CPP_REVISION,
|
| 349 |
+
patchSetSha256: runtimeSource.patchSet.sha256,
|
| 350 |
+
moduleSha256: runtimeArtifact('vendor/wllama-bonsai/esm/index.js').sha256,
|
| 351 |
+
wasmFlavor,
|
| 352 |
+
wasmSha256: wasmArtifact.sha256,
|
| 353 |
+
compatWorkerSha256: wasmFlavor === 'compat'
|
| 354 |
+
? runtimeArtifact('public/wasm/wllama-compat.js').sha256
|
| 355 |
+
: null,
|
| 356 |
tokenEmbeddingOnWebGPU: true,
|
| 357 |
tensorPlacementOverrides: false,
|
| 358 |
},
|
|
|
|
| 541 |
params.tensorPlacement,
|
| 542 |
);
|
| 543 |
}
|
| 544 |
+
if (params.nBatch !== undefined && (
|
| 545 |
+
!Number.isSafeInteger(params.nBatch)
|
| 546 |
+
|| params.nBatch <= 0
|
| 547 |
+
|| params.nBatch > MAX_GLUE_INT
|
| 548 |
+
)) {
|
| 549 |
+
throw new EngineRuntimeError(
|
| 550 |
+
'INVALID_BATCH_SIZE',
|
| 551 |
+
`n_batch must be a positive signed 32-bit integer (maximum ${MAX_GLUE_INT}).`,
|
| 552 |
+
);
|
| 553 |
+
}
|
| 554 |
+
if (params.nUbatch !== undefined && (
|
| 555 |
+
!Number.isSafeInteger(params.nUbatch)
|
| 556 |
+
|| params.nUbatch <= 0
|
| 557 |
+
|| params.nUbatch > MAX_GLUE_INT
|
| 558 |
+
)) {
|
| 559 |
+
throw new EngineRuntimeError(
|
| 560 |
+
'INVALID_UBATCH_SIZE',
|
| 561 |
+
`n_ubatch must be a positive signed 32-bit integer (maximum ${MAX_GLUE_INT}).`,
|
| 562 |
+
);
|
| 563 |
+
}
|
| 564 |
await this.unload();
|
| 565 |
this.nativeLog.clear();
|
| 566 |
emitProgress(sink, requestId, {
|
|
|
|
| 624 |
if (!Number.isSafeInteger(threads) || threads <= 0) {
|
| 625 |
throw new EngineRuntimeError('INVALID_THREAD_COUNT', 'Thread count must be a positive integer.');
|
| 626 |
}
|
|
|
|
| 627 |
try {
|
| 628 |
await this.raceWebGpuDeviceLoss('load', gate.selectedBackend, model, wllama.loadModel(blobs, {
|
| 629 |
n_gpu_layers: gate.selectedBackend === 'webgpu' ? 99999 : 0,
|
| 630 |
offload_token_embedding: gate.selectedBackend === 'webgpu',
|
| 631 |
n_ctx: contextSize,
|
| 632 |
n_threads: threads,
|
| 633 |
+
n_batch: params.nBatch,
|
| 634 |
+
n_ubatch: params.nUbatch,
|
| 635 |
seed: 42,
|
| 636 |
+
flash_attn: params.flashAttention ?? false,
|
| 637 |
warmup: false,
|
| 638 |
cache_type_k: params.cacheTypeK ?? 'f16',
|
| 639 |
cache_type_v: params.cacheTypeV ?? 'f16',
|
|
|
|
| 671 |
trainingSize: context.n_ctx_train,
|
| 672 |
layerCount: context.n_layer,
|
| 673 |
vocabularySize: context.n_vocab,
|
| 674 |
+
batchSize: context.n_batch,
|
| 675 |
+
microBatchSize: context.n_ubatch,
|
| 676 |
+
},
|
| 677 |
+
tuning: {
|
| 678 |
+
flashAttention: params.flashAttention ?? false,
|
| 679 |
+
cacheTypeK: params.cacheTypeK ?? 'f16',
|
| 680 |
+
cacheTypeV: params.cacheTypeV ?? 'f16',
|
| 681 |
},
|
| 682 |
chatTemplate: {
|
| 683 |
bytes: new TextEncoder().encode(template).byteLength,
|
|
|
|
| 748 |
});
|
| 749 |
}
|
| 750 |
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
|
| 751 |
+
usage = mapUsage(chunk.usage, chunk.timings) ?? usage;
|
| 752 |
timings = mapTimings(chunk.timings) ?? timings;
|
| 753 |
},
|
| 754 |
}));
|
src/main.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { StrictMode } from 'react';
|
| 2 |
import { createRoot } from 'react-dom/client';
|
| 3 |
import { App } from './app/App';
|
|
|
|
| 4 |
import './app/styles.css';
|
| 5 |
|
| 6 |
const root = document.getElementById('root');
|
|
@@ -9,8 +10,13 @@ if (!root) {
|
|
| 9 |
throw new Error('Bonsai shell root element is missing');
|
| 10 |
}
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
createRoot(root).render(
|
| 13 |
<StrictMode>
|
| 14 |
-
<App />
|
| 15 |
</StrictMode>,
|
| 16 |
);
|
|
|
|
| 1 |
import { StrictMode } from 'react';
|
| 2 |
import { createRoot } from 'react-dom/client';
|
| 3 |
import { App } from './app/App';
|
| 4 |
+
import { BenchApp } from './bench/BenchApp';
|
| 5 |
import './app/styles.css';
|
| 6 |
|
| 7 |
const root = document.getElementById('root');
|
|
|
|
| 10 |
throw new Error('Bonsai shell root element is missing');
|
| 11 |
}
|
| 12 |
|
| 13 |
+
export function isBenchPath(pathname: string): boolean {
|
| 14 |
+
const normalized = pathname.replace(/\/+$/, '');
|
| 15 |
+
return /(?:^|\/)bench(?:\/index\.html|\.html)?$/.test(normalized);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
createRoot(root).render(
|
| 19 |
<StrictMode>
|
| 20 |
+
{isBenchPath(window.location.pathname) ? <BenchApp /> : <App />}
|
| 21 |
</StrictMode>,
|
| 22 |
);
|
vendor/wllama-bonsai/PATCH.diff
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
| 2 |
+
index ba95350..33fcbbf 100644
|
| 3 |
+
--- a/CMakeLists.txt
|
| 4 |
+
+++ b/CMakeLists.txt
|
| 5 |
+
@@ -103,6 +103,7 @@ set(LLAMA_SERVER_SRC llama.cpp/tools/server/server-context.cpp
|
| 6 |
+
llama.cpp/tools/server/server-task.cpp
|
| 7 |
+
llama.cpp/tools/server/server-chat.cpp
|
| 8 |
+
llama.cpp/tools/server/server-common.cpp
|
| 9 |
+
+ llama.cpp/tools/server/server-schema.cpp
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
set(WLLAMA_SRC ${LLAMA_COMMON_SRC} ${LLAMA_SERVER_SRC}
|
| 13 |
+
diff --git a/cpp/glue.hpp b/cpp/glue.hpp
|
| 14 |
+
index f1496a4..8883324 100644
|
| 15 |
+
--- a/cpp/glue.hpp
|
| 16 |
+
+++ b/cpp/glue.hpp
|
| 17 |
+
@@ -502,6 +502,7 @@ struct glue_msg_load_req
|
| 18 |
+
GLUE_FIELD(bool, use_mmap)
|
| 19 |
+
GLUE_FIELD(bool, use_mlock)
|
| 20 |
+
GLUE_FIELD(int, n_gpu_layers)
|
| 21 |
+
+ GLUE_FIELD_NULLABLE(bool, offload_token_embedding)
|
| 22 |
+
GLUE_FIELD(int, n_ctx)
|
| 23 |
+
GLUE_FIELD(int, n_threads)
|
| 24 |
+
GLUE_FIELD_NULLABLE(str, model_alias)
|
| 25 |
+
diff --git a/cpp/wllama-context.h b/cpp/wllama-context.h
|
| 26 |
+
index 0dafe6d..c4b46f9 100644
|
| 27 |
+
--- a/cpp/wllama-context.h
|
| 28 |
+
+++ b/cpp/wllama-context.h
|
| 29 |
+
@@ -19,6 +19,7 @@
|
| 30 |
+
|
| 31 |
+
#include "server-context.h"
|
| 32 |
+
#include "server-queue.h"
|
| 33 |
+
+#include "server-schema.h"
|
| 34 |
+
|
| 35 |
+
#include "ggml-cpu.h"
|
| 36 |
+
#include "ggml-backend.h"
|
| 37 |
+
@@ -300,7 +301,7 @@ struct wllama_context
|
| 38 |
+
server_task task = server_task(SERVER_TASK_TYPE_COMPLETION);
|
| 39 |
+
task.id = rd->get_new_id();
|
| 40 |
+
task.index = 0;
|
| 41 |
+
- task.params = server_task::params_from_json_cmpl(
|
| 42 |
+
+ task.params = server_schema::eval_llama_cmpl_schema(
|
| 43 |
+
vocab,
|
| 44 |
+
params,
|
| 45 |
+
meta->slot_n_ctx,
|
| 46 |
+
@@ -415,6 +416,8 @@ struct wllama_context
|
| 47 |
+
params.embedding = req.embeddings.value;
|
| 48 |
+
if (req.n_batch.not_null())
|
| 49 |
+
params.n_batch = req.n_batch.value;
|
| 50 |
+
+ if (req.n_ubatch.not_null())
|
| 51 |
+
+ params.n_ubatch = req.n_ubatch.value;
|
| 52 |
+
if (req.n_parallel.not_null())
|
| 53 |
+
params.n_parallel = req.n_parallel.value;
|
| 54 |
+
if (req.pooling_type.not_null())
|
| 55 |
+
@@ -568,6 +571,24 @@ struct wllama_context
|
| 56 |
+
// load model
|
| 57 |
+
llama_backend_init();
|
| 58 |
+
llama_numa_init(params.numa);
|
| 59 |
+
+ if (req.offload_token_embedding.not_null() && req.offload_token_embedding.value)
|
| 60 |
+
+ {
|
| 61 |
+
+ auto *webgpu_dev = ggml_backend_dev_by_name("WebGPU");
|
| 62 |
+
+ if (webgpu_dev == nullptr)
|
| 63 |
+
+ {
|
| 64 |
+
+ throw app_exception("offload_token_embedding was requested, but the WebGPU device is unavailable");
|
| 65 |
+
+ }
|
| 66 |
+
+
|
| 67 |
+
+ auto *webgpu_buft = ggml_backend_dev_buffer_type(webgpu_dev);
|
| 68 |
+
+ if (webgpu_buft == nullptr)
|
| 69 |
+
+ {
|
| 70 |
+
+ throw app_exception("offload_token_embedding was requested, but the WebGPU buffer type is unavailable");
|
| 71 |
+
+ }
|
| 72 |
+
+
|
| 73 |
+
+ params.tensor_buft_overrides.push_back({"^token_embd\\.weight$", webgpu_buft});
|
| 74 |
+
+ params.tensor_buft_overrides.push_back({nullptr, nullptr});
|
| 75 |
+
+ fprintf(stderr, "@@INFO@@offload_token_embedding: token_embd.weight -> %s\n", ggml_backend_buft_name(webgpu_buft));
|
| 76 |
+
+ }
|
| 77 |
+
if (!ctx_server.load_model(params))
|
| 78 |
+
{
|
| 79 |
+
glue_msg_load_res res;
|
| 80 |
+
diff --git a/src/glue/messages.ts b/src/glue/messages.ts
|
| 81 |
+
index 08f0d87..c1001da 100644
|
| 82 |
+
--- a/src/glue/messages.ts
|
| 83 |
+
+++ b/src/glue/messages.ts
|
| 84 |
+
@@ -53,6 +53,11 @@ export const GLUE_MESSAGE_PROTOTYPES: { [name: string]: GlueMessageProto } = {
|
| 85 |
+
"name": "n_gpu_layers",
|
| 86 |
+
"isNullable": false
|
| 87 |
+
},
|
| 88 |
+
+ {
|
| 89 |
+
+ "type": "bool",
|
| 90 |
+
+ "name": "offload_token_embedding",
|
| 91 |
+
+ "isNullable": true
|
| 92 |
+
+ },
|
| 93 |
+
{
|
| 94 |
+
"type": "int",
|
| 95 |
+
"name": "n_ctx",
|
| 96 |
+
@@ -618,6 +623,7 @@ export interface GlueMsgLoadReq {
|
| 97 |
+
use_mmap: boolean;
|
| 98 |
+
use_mlock: boolean;
|
| 99 |
+
n_gpu_layers: number;
|
| 100 |
+
+ offload_token_embedding?: boolean | undefined;
|
| 101 |
+
n_ctx: number;
|
| 102 |
+
n_threads: number;
|
| 103 |
+
model_alias?: string | undefined;
|
| 104 |
+
diff --git a/src/types/types.ts b/src/types/types.ts
|
| 105 |
+
index 2b3cca7..e91b395 100644
|
| 106 |
+
--- a/src/types/types.ts
|
| 107 |
+
+++ b/src/types/types.ts
|
| 108 |
+
@@ -4,8 +4,12 @@ export interface LoadModelParams {
|
| 109 |
+
seed?: number;
|
| 110 |
+
n_ctx?: number;
|
| 111 |
+
n_batch?: number;
|
| 112 |
+
+ n_ubatch?: number;
|
| 113 |
+
// by default, all layers are offloaded if WebGPU is available
|
| 114 |
+
n_gpu_layers?: number;
|
| 115 |
+
+ // keeps token lookup on WebGPU, avoiding a CPU GET_ROWS graph split;
|
| 116 |
+
+ // defaults to true when n_gpu_layers is non-zero and WebGPU supports shader-f16
|
| 117 |
+
+ offload_token_embedding?: boolean;
|
| 118 |
+
// by default, on multi-thread build, we take half number of available threads (hardwareConcurrency / 2)
|
| 119 |
+
n_threads?: number;
|
| 120 |
+
embeddings?: boolean;
|
| 121 |
+
diff --git a/src/wllama.ts b/src/wllama.ts
|
| 122 |
+
index ce74b2b..8057028 100644
|
| 123 |
+
--- a/src/wllama.ts
|
| 124 |
+
+++ b/src/wllama.ts
|
| 125 |
+
@@ -478,6 +478,27 @@ export class Wllama {
|
| 126 |
+
if (this.proxy) {
|
| 127 |
+
throw new WllamaError('Module is already initialized', 'load_error');
|
| 128 |
+
}
|
| 129 |
+
+ const nGpuLayers = params.n_gpu_layers ?? 99999;
|
| 130 |
+
+ const wantsTokenEmbeddingOffload =
|
| 131 |
+
+ params.offload_token_embedding === true ||
|
| 132 |
+
+ (params.offload_token_embedding === undefined && nGpuLayers !== 0);
|
| 133 |
+
+ let compatibleWebGPU = false;
|
| 134 |
+
+ if (wantsTokenEmbeddingOffload && isSupportWebGPU()) {
|
| 135 |
+
+ try {
|
| 136 |
+
+ const adapter = await (navigator as any).gpu.requestAdapter();
|
| 137 |
+
+ compatibleWebGPU = !!adapter?.features?.has('shader-f16');
|
| 138 |
+
+ } catch {
|
| 139 |
+
+ compatibleWebGPU = false;
|
| 140 |
+
+ }
|
| 141 |
+
+ }
|
| 142 |
+
+ if (params.offload_token_embedding === true && !compatibleWebGPU) {
|
| 143 |
+
+ throw new WllamaError(
|
| 144 |
+
+ 'offload_token_embedding was requested, but WebGPU is unavailable',
|
| 145 |
+
+ 'load_error'
|
| 146 |
+
+ );
|
| 147 |
+
+ }
|
| 148 |
+
+ const offloadTokenEmbedding =
|
| 149 |
+
+ params.offload_token_embedding ?? compatibleWebGPU;
|
| 150 |
+
// detect if we can use multi-thread and webgpu
|
| 151 |
+
const supportMultiThread = await isSupportMultiThread();
|
| 152 |
+
const hwConccurency = Math.floor((navigator.hardwareConcurrency || 1) / 2);
|
| 153 |
+
@@ -518,7 +539,8 @@ export class Wllama {
|
| 154 |
+
// if async read is not supported, use mmap; refer to README-dev.md for more details
|
| 155 |
+
use_mmap: !canUseAsyncFileRead(workerResources.compat),
|
| 156 |
+
use_mlock: false,
|
| 157 |
+
- n_gpu_layers: params.n_gpu_layers ?? 99999,
|
| 158 |
+
+ n_gpu_layers: nGpuLayers,
|
| 159 |
+
+ offload_token_embedding: offloadTokenEmbedding,
|
| 160 |
+
n_ctx: params.n_ctx ?? 1024,
|
| 161 |
+
n_threads: this.useMultiThread ? nbThreads : 1,
|
| 162 |
+
n_ctx_auto: false, // not supported for now
|
| 163 |
+
@@ -529,6 +551,7 @@ export class Wllama {
|
| 164 |
+
embeddings: params.embeddings,
|
| 165 |
+
offload_kqv: params.offload_kqv,
|
| 166 |
+
n_batch: params.n_batch,
|
| 167 |
+
+ n_ubatch: params.n_ubatch,
|
| 168 |
+
pooling_type: params.pooling_type as string,
|
| 169 |
+
rope_scaling_type: params.rope_scaling_type as string,
|
| 170 |
+
rope_freq_base: params.rope_freq_base,
|
| 171 |
+
@@ -579,6 +602,10 @@ export class Wllama {
|
| 172 |
+
skip_chat_parsing: params.skip_chat_parsing,
|
| 173 |
+
prefill_assistant: params.prefill_assistant,
|
| 174 |
+
});
|
| 175 |
+
+ if (!loadResult.success) {
|
| 176 |
+
+ await this.exit();
|
| 177 |
+
+ throw new WllamaError('Failed to load model', 'load_error');
|
| 178 |
+
+ }
|
| 179 |
+
const loadedCtxInfo: LoadedContextInfo & GlueMsgLoadRes = {
|
| 180 |
+
...loadResult,
|
| 181 |
+
metadata: {},
|
vendor/wllama-bonsai/SOURCE.json
CHANGED
|
@@ -1,24 +1,49 @@
|
|
| 1 |
{
|
|
|
|
| 2 |
"upstream": "https://github.com/ngxson/wllama",
|
| 3 |
"wllamaRevision": "912c18b75d4358c1405a64646b8dbe43a205943b",
|
| 4 |
"llamaCppRevision": "00fa7cb284cbf133fc426733bd64238a3588a33e",
|
| 5 |
"license": "MIT",
|
| 6 |
-
"patch": "Expose offload_token_embedding and place token_embd.weight on WebGPU whenever GPU layers are requested; fail loudly when the requested WebGPU buffer is unavailable.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"files": [
|
| 8 |
{
|
| 9 |
"path": "vendor/wllama-bonsai/esm/index.js",
|
| 10 |
-
"
|
|
|
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"path": "public/wasm/wllama.wasm",
|
| 14 |
-
"
|
|
|
|
| 15 |
},
|
| 16 |
{
|
| 17 |
"path": "public/wasm/wllama-compat.wasm",
|
| 18 |
-
"
|
|
|
|
| 19 |
},
|
| 20 |
{
|
| 21 |
"path": "public/wasm/wllama-compat.js",
|
|
|
|
| 22 |
"sha256": "0b667db536815fb9e5dc9ffbfbd6ba29affdda38a4ba892d5372ddb41ce9a8c8"
|
| 23 |
}
|
| 24 |
]
|
|
|
|
| 1 |
{
|
| 2 |
+
"schemaVersion": 2,
|
| 3 |
"upstream": "https://github.com/ngxson/wllama",
|
| 4 |
"wllamaRevision": "912c18b75d4358c1405a64646b8dbe43a205943b",
|
| 5 |
"llamaCppRevision": "00fa7cb284cbf133fc426733bd64238a3588a33e",
|
| 6 |
"license": "MIT",
|
| 7 |
+
"patch": "Expose offload_token_embedding and n_ubatch; place token_embd.weight on WebGPU whenever GPU layers are requested; fail loudly when the requested WebGPU buffer is unavailable.",
|
| 8 |
+
"patchSet": {
|
| 9 |
+
"format": "git-diff-binary",
|
| 10 |
+
"path": "vendor/wllama-bonsai/PATCH.diff",
|
| 11 |
+
"bytes": 6896,
|
| 12 |
+
"sha256": "62efebdb3071ba49b87c835c1b45716257fe5ea6cb7eec6ee82f6a2f33bc581c",
|
| 13 |
+
"files": [
|
| 14 |
+
"CMakeLists.txt",
|
| 15 |
+
"cpp/glue.hpp",
|
| 16 |
+
"cpp/wllama-context.h",
|
| 17 |
+
"src/glue/messages.ts",
|
| 18 |
+
"src/types/types.ts",
|
| 19 |
+
"src/wllama.ts"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
"build": {
|
| 23 |
+
"emsdkImage": "emscripten/emsdk:4.0.20-arm64",
|
| 24 |
+
"builderImageId": "sha256:1143696588c47ddcfa796d7a84663a1fd1d8f28e2e1db62d50cac618776d11dd",
|
| 25 |
+
"dawnTag": "v20260317.182325",
|
| 26 |
+
"dawnArchiveSha256": "8dcae86c630d76b6794b271c6572becba36f4237d132bee121a56638c3d76575"
|
| 27 |
+
},
|
| 28 |
"files": [
|
| 29 |
{
|
| 30 |
"path": "vendor/wllama-bonsai/esm/index.js",
|
| 31 |
+
"bytes": 364498,
|
| 32 |
+
"sha256": "67eb16e67297b05e8fc7424d9b273d1586ad8097f8551216b55fa4b77d5e5c51"
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"path": "public/wasm/wllama.wasm",
|
| 36 |
+
"bytes": 8017169,
|
| 37 |
+
"sha256": "dd71f58c75f32c677a64eeb2e56efab7dedbe7395f730bb08bc80021bd182caa"
|
| 38 |
},
|
| 39 |
{
|
| 40 |
"path": "public/wasm/wllama-compat.wasm",
|
| 41 |
+
"bytes": 14864188,
|
| 42 |
+
"sha256": "eca9754d0d8a490b5c7b58cb384d67e31aa8ab26dd330e4ad1460982df6dd82e"
|
| 43 |
},
|
| 44 |
{
|
| 45 |
"path": "public/wasm/wllama-compat.js",
|
| 46 |
+
"bytes": 166895,
|
| 47 |
"sha256": "0b667db536815fb9e5dc9ffbfbd6ba29affdda38a4ba892d5372ddb41ce9a8c8"
|
| 48 |
}
|
| 49 |
]
|
vendor/wllama-bonsai/esm/index.cjs
CHANGED
|
@@ -2919,6 +2919,7 @@ var Wllama = class {
|
|
| 2919 |
embeddings: params.embeddings,
|
| 2920 |
offload_kqv: params.offload_kqv,
|
| 2921 |
n_batch: params.n_batch,
|
|
|
|
| 2922 |
pooling_type: params.pooling_type,
|
| 2923 |
rope_scaling_type: params.rope_scaling_type,
|
| 2924 |
rope_freq_base: params.rope_freq_base,
|
|
|
|
| 2919 |
embeddings: params.embeddings,
|
| 2920 |
offload_kqv: params.offload_kqv,
|
| 2921 |
n_batch: params.n_batch,
|
| 2922 |
+
n_ubatch: params.n_ubatch,
|
| 2923 |
pooling_type: params.pooling_type,
|
| 2924 |
rope_scaling_type: params.rope_scaling_type,
|
| 2925 |
rope_freq_base: params.rope_freq_base,
|
vendor/wllama-bonsai/esm/index.js
CHANGED
|
@@ -2884,6 +2884,7 @@ var Wllama = class {
|
|
| 2884 |
embeddings: params.embeddings,
|
| 2885 |
offload_kqv: params.offload_kqv,
|
| 2886 |
n_batch: params.n_batch,
|
|
|
|
| 2887 |
pooling_type: params.pooling_type,
|
| 2888 |
rope_scaling_type: params.rope_scaling_type,
|
| 2889 |
rope_freq_base: params.rope_freq_base,
|
|
|
|
| 2884 |
embeddings: params.embeddings,
|
| 2885 |
offload_kqv: params.offload_kqv,
|
| 2886 |
n_batch: params.n_batch,
|
| 2887 |
+
n_ubatch: params.n_ubatch,
|
| 2888 |
pooling_type: params.pooling_type,
|
| 2889 |
rope_scaling_type: params.rope_scaling_type,
|
| 2890 |
rope_freq_base: params.rope_freq_base,
|
vendor/wllama-bonsai/esm/index.min.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/wllama-bonsai/esm/index.min.js.map
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/wllama-bonsai/esm/types/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface LoadModelParams {
|
|
| 3 |
seed?: number;
|
| 4 |
n_ctx?: number;
|
| 5 |
n_batch?: number;
|
|
|
|
| 6 |
n_gpu_layers?: number;
|
| 7 |
offload_token_embedding?: boolean;
|
| 8 |
n_threads?: number;
|
|
|
|
| 3 |
seed?: number;
|
| 4 |
n_ctx?: number;
|
| 5 |
n_batch?: number;
|
| 6 |
+
n_ubatch?: number;
|
| 7 |
n_gpu_layers?: number;
|
| 8 |
offload_token_embedding?: boolean;
|
| 9 |
n_threads?: number;
|
vendor/wllama-bonsai/esm/wasm/source-map.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export declare const WASM_SOURCE_MAP: Record<string, string>;
|
vite.config.ts
CHANGED
|
@@ -3,6 +3,14 @@ import react from '@vitejs/plugin-react';
|
|
| 3 |
|
| 4 |
export default defineConfig({
|
| 5 |
plugins: [react()],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
server: {
|
| 7 |
headers: {
|
| 8 |
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
|
|
| 3 |
|
| 4 |
export default defineConfig({
|
| 5 |
plugins: [react()],
|
| 6 |
+
build: {
|
| 7 |
+
rollupOptions: {
|
| 8 |
+
input: {
|
| 9 |
+
main: 'index.html',
|
| 10 |
+
bench: 'bench/index.html',
|
| 11 |
+
},
|
| 12 |
+
},
|
| 13 |
+
},
|
| 14 |
server: {
|
| 15 |
headers: {
|
| 16 |
'Cross-Origin-Embedder-Policy': 'require-corp',
|