-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprobe.ts
More file actions
331 lines (310 loc) · 12.1 KB
/
Copy pathprobe.ts
File metadata and controls
331 lines (310 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
/**
* Debug shim: the smallest possible ONNX workload, runnable from the devtools
* console (or over CDP via tools/obsidian-drive.mjs).
*
* The point is to separate "does onnxruntime-web initialize at all inside
* Obsidian's Electron renderer" from "does the docling pipeline work" — the
* first is where every failure so far has been, and it needs no model download,
* no PDF, and about a second to answer.
*
* Exposed as `window.__reflow` while the plugin is loaded.
*/
import * as ort from "onnxruntime-web";
import { loadPdfBrowser, createBrowserVlm } from "../engine-js/src/browser/engine.js";
import { configureOrt } from "./ort-env.js";
/** C = A + B over a 1x4 float tensor. 94 bytes, opset 13. */
const TINY_ADD_ONNX =
"CAk6VAoOCgFBCgFCEgFDIgNBZGQSA2FkZFoTCgFBEg4KDAgBEggKAggBCgIIBFoTCgFCEg4KDAgB" +
"EggKAggBCgIIBGITCgFDEg4KDAgBEggKAggBCgIIBEIECgAQDQ==";
/**
* Sidecar URLs resolved by transformers.js for *its* ORT instance.
*
* The smoke test deliberately uses a second, standalone `onnxruntime-web` so it
* can be run without touching the real pipeline — but that instance has no
* `wasmPaths` of its own and can't infer one ("cannot determine the script
* source URL": there is no usable `import.meta.url` in this CJS bundle). Borrow
* the paths transformers already computed so both instances load the same
* matching glue + binary.
*/
let sharedWasmPaths: unknown = null;
export function setOrtWasmPaths(paths: unknown): void {
sharedWasmPaths = paths;
// Apply immediately, not just on the ortSmoke path: ad-hoc probes reach for
// `__reflow.ort` directly, and without this they hit "cannot determine the
// script source URL" before any execution provider gets a chance.
try {
if (paths) (ort as any).env.wasm.wasmPaths = paths;
} catch {
/* ignore */
}
}
function modelBytes(): Uint8Array {
const bin = atob(TINY_ADD_ONNX);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** What the renderer looks like to the libraries that probe for Node. */
export function envReport() {
const p: any = typeof process !== "undefined" ? process : null;
return {
obsidian: (window as any).app?.appId ? "loaded" : "?",
location: location.origin,
processType: p?.type ?? null,
processVersionsNode: p?.versions?.node ?? null,
processReleaseName: p?.release?.name ?? null,
processVersionsNodeWritable: (() => {
if (!p?.versions) return null;
const d = Object.getOwnPropertyDescriptor(p.versions, "node");
return d ? !!(d.writable ?? d.set) : null;
})(),
webgpu: typeof navigator !== "undefined" && "gpu" in navigator,
sharedArrayBuffer: typeof SharedArrayBuffer !== "undefined",
crossOriginIsolated: (globalThis as any).crossOriginIsolated ?? null,
wasmSimd: WebAssembly.validate(
// (module (func (result v128) i32.const 0 i8x16.splat))
new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0,
10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11]),
),
ortVersion: (ort as any).env?.versions?.common ?? null,
};
}
/**
* Configure ORT, then build and run the tiny model on `ep`.
*
* `strategy: "baseline"` deliberately reproduces the pre-fix setup (the jsdelivr
* prefix transformers.js installs) so the failure can be demonstrated on demand.
* ORT initializes its wasm module once per page, so only the first smoke test
* after a reload actually exercises a strategy.
*/
export async function ortSmoke(
{ ep = "webgpu", strategy = "patched-glue" as "patched-glue" | "baseline" } = {},
) {
const t0 = performance.now();
const config =
strategy === "baseline"
? (() => {
(ort as any).env.wasm.wasmPaths =
"https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5/dist/";
(ort as any).env.wasm.numThreads = 1;
return { wasmPaths: (ort as any).env.wasm.wasmPaths, baseline: true };
})()
: (() => {
const cfg = configureOrt((ort as any).env);
if (sharedWasmPaths) (ort as any).env.wasm.wasmPaths = sharedWasmPaths;
return { ...cfg, wasmPaths: (ort as any).env.wasm.wasmPaths };
})();
try {
const session = await ort.InferenceSession.create(modelBytes(), {
executionProviders: [ep as any],
});
const tSession = performance.now();
const out = await session.run({
A: new ort.Tensor("float32", Float32Array.from([1, 2, 3, 4]), [1, 4]),
B: new ort.Tensor("float32", Float32Array.from([10, 20, 30, 40]), [1, 4]),
});
const values = Array.from(out.C.data as Float32Array);
const correct = JSON.stringify(values) === JSON.stringify([11, 22, 33, 44]);
await session.release();
return {
ok: correct,
ep,
strategy,
values,
config,
sessionMs: Math.round(tSession - t0),
totalMs: Math.round(performance.now() - t0),
};
} catch (e: any) {
return {
ok: false,
ep,
strategy,
config,
error: String(e?.message ?? e),
totalMs: Math.round(performance.now() - t0),
};
}
}
/**
* Time a single page with a **token** cap instead of a wall-clock cap.
*
* The per-page timeout tells you a page was cut off, but not *why*: a page that
* stops early could be slow inference or a model that never emits EOS. Capping
* by tokens separates the two — it yields a tokens/sec figure, and the
* `truncated` reason says whether the cap or a guard ended it. `head`/`tail` of
* the DocTags show at a glance whether the output degenerated.
*/
export async function benchPage(
deps: { transformers: any; pdfjs: any; data: Uint8Array },
{
page = 1,
maxNewTokens = 256,
device = "webgpu",
dtype = undefined as Record<string, string> | undefined,
} = {},
) {
const t0 = performance.now();
const pages = await loadPdfBrowser(deps.pdfjs, deps.data);
const tPdf = performance.now();
const vlm = await createBrowserVlm(deps.transformers, {
device,
dtype,
maxNewTokens,
// Effectively disabled — this run is bounded by maxNewTokens, so the
// wall-clock guard would only confuse the measurement.
perPageTimeoutMs: 3_600_000,
});
const tModel = performance.now();
try {
const rendered = await pages.renderPage(page);
const tRender = performance.now();
const { docTags, truncated, genTokens, promptTokens } = await vlm.pageToDocTags(
rendered.rgba,
rendered.width,
rendered.height,
);
const tGen = performance.now();
const genSec = (tGen - tRender) / 1000;
return {
page,
device,
/** What ORT actually ran on — not what was requested. */
executionProviders: vlm.executionProviders,
wasmPaths: (deps.transformers as any).env?.backends?.onnx?.wasm?.wasmPaths ?? null,
dtype: vlm.modelLabel,
maxNewTokens,
pdfLoadSec: +((tPdf - t0) / 1000).toFixed(2),
modelLoadSec: +((tModel - tPdf) / 1000).toFixed(2),
renderSec: +((tRender - tModel) / 1000).toFixed(2),
genSec: +genSec.toFixed(2),
genTokens,
/** Prompt length incl. image tokens — the per-step attention cost driver. */
promptTokens,
renderedPx: `${rendered.width}x${rendered.height}`,
tokensPerSec: +(genTokens / genSec).toFixed(2),
/** null = hit EOS cleanly; else "max_tokens" | "repetition" | "timeout". */
truncated,
docTagsChars: docTags.length,
head: docTags.slice(0, 300),
tail: docTags.slice(-300),
};
} finally {
vlm.dispose();
await pages.destroy();
}
}
/**
* The multi-page twin of `benchPage`: **one** VLM instance, N pages, each capped
* at the same token count.
*
* `benchPage` builds and disposes a model per call, so it cannot see anything
* that accumulates *within* a document — which is exactly the reported failure
* (a 2-page run looks fine, a 15-page run does not). Holding the model across
* pages and giving every page identical work (a token cap, not a whole page)
* makes degradation legible: with constant work per page, a rising `genSec` is
* accumulation, not a denser page.
*
* Mirrors `engine-js/tools/bench.ts` field-for-field so the Node/CPU curve and
* this one can be compared directly.
*/
export async function benchPages(
deps: { transformers: any; pdfjs: any; data: Uint8Array },
{
pages: pageList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
maxNewTokens = 128,
device = "webgpu",
dtype = undefined as Record<string, string> | undefined,
} = {},
) {
/** Renderer RSS (Node integration) + JS heap. GPU buffers show up in neither. */
const mem = () => {
const p: any = typeof process !== "undefined" ? process : null;
const rss = p?.memoryUsage ? p.memoryUsage().rss : null;
const heap = (performance as any).memory?.usedJSHeapSize ?? null;
return {
rssMb: rss == null ? null : Math.round(rss / 1048576),
heapMb: heap == null ? null : Math.round(heap / 1048576),
};
};
// Phase logs, not just a final blob: a run that never returns has to be
// diagnosable from the console alone (a stall in `from_pretrained` and a stall
// in `generate` are the same silence otherwise).
const say = (m: string) => console.log(`[reflow bench] ${m}`);
const t0 = performance.now();
say(`start: ${pageList.length} pages, ${maxNewTokens} tok cap, device=${device}`);
const pdf = await loadPdfBrowser(deps.pdfjs, deps.data);
const tPdf = performance.now();
say(`pdf loaded (${pdf.pageCount} pp) in ${((tPdf - t0) / 1000).toFixed(1)}s`);
/** Heartbeat: every 16th decode step, so a stall is visible within seconds. */
let lastStep = { tokens: 0, ms: 0 };
const vlm = await createBrowserVlm(deps.transformers, {
device,
dtype,
maxNewTokens,
perPageTimeoutMs: 3_600_000, // token-capped run; keep the clock guard out of it
onStep: (tokens, ms) => {
lastStep = { tokens, ms };
if (tokens === 1 || tokens % 16 === 0) say(` step ${tokens} @ ${(ms / 1000).toFixed(1)}s`);
},
});
const tModel = performance.now();
say(`model loaded in ${((tModel - tPdf) / 1000).toFixed(1)}s`);
const rows: Array<Record<string, unknown>> = [];
try {
for (const page of pageList) {
const tR0 = performance.now();
const rendered = await pdf.renderPage(page);
const tR1 = performance.now();
say(`page ${page}: rendered ${rendered.width}x${rendered.height} in ${((tR1 - tR0) / 1000).toFixed(1)}s`);
const { docTags, truncated, genTokens, promptTokens } = await vlm.pageToDocTags(
rendered.rgba,
rendered.width,
rendered.height,
);
const genSec = (performance.now() - tR1) / 1000;
const row = {
page,
...mem(),
renderSec: +((tR1 - tR0) / 1000).toFixed(2),
genSec: +genSec.toFixed(2),
genTokens,
promptTokens,
tokensPerSec: +(genTokens / genSec).toFixed(2),
truncated,
docTagsChars: docTags.length,
head: docTags.slice(0, 120),
};
rows.push(row);
// Streamed to the driver's console tail: a long run should be watchable,
// not a single JSON blob at the end.
console.log(
`[reflow bench] page ${page}: ${genTokens} tok in ${genSec.toFixed(1)}s = ` +
`${(genTokens / genSec).toFixed(2)} tok/s (${truncated ?? "EOS"}) ` +
`rss ${row.rssMb}MB heap ${row.heapMb}MB`,
);
}
} finally {
vlm.dispose();
await pdf.destroy();
}
return {
device,
dtype: vlm.modelLabel,
maxNewTokens,
pdfLoadSec: +((tPdf - t0) / 1000).toFixed(2),
modelLoadSec: +((tModel - tPdf) / 1000).toFixed(2),
rows,
};
}
/** Install the shim. Returns the object for convenience. */
export function installProbe(extra: Record<string, unknown> = {}) {
// Expose ORT itself so ad-hoc probes (tools/webnn-probe.js) can try execution
// provider configurations without a plugin rebuild.
// loadPdfBrowser too: the worker renders without a `document`, and the only
// way to check that costs nothing is to render the same pages both ways
// (tools/raster-diff-probe.js) — which needs the adapter on this side.
const api = { envReport, ortSmoke, ort, loadPdfBrowser, ...extra };
(window as any).__reflow = api;
return api;
}