baberu-ocr-webgpu / webgpu-e2e.html
ameraino11's picture
Add optimized unified Gather WebGPU models
8020c75 verified
Raw
History Blame Contribute Delete
15.9 kB
<!doctype html>
<meta charset="utf-8" />
<title>Baberu end-to-end WebGPU OCR</title>
<style>
body { background: #111; color: #eee; font: 14px/1.45 ui-monospace, monospace; margin: 24px; }
.controls { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; }
button, input { font: inherit; }
canvas { border: 1px solid #555; height: 224px; width: 224px; }
pre { white-space: pre-wrap; }
</style>
<h1>Baberu end-to-end WebGPU OCR</h1>
<div class="controls">
<input id="file" type="file" accept="image/*" />
<button id="run" type="button">Run selected crop</button>
</div>
<canvas id="preview" width="224" height="224"></canvas>
<pre id="log">Starting…</pre>
<script type="module">
import * as ort from "./.cache/ort/ort.webgpu.min.mjs";
const VOCAB_SIZE = 14630;
const BOS = 1;
const EOS = 2;
const MEAN = [0.485, 0.456, 0.406];
const STD = [0.229, 0.224, 0.225];
const cacheNames = [
...Array.from({ length: 6 }, (_, index) => `present_k${index}`),
...Array.from({ length: 6 }, (_, index) => `present_v${index}`),
];
const output = document.querySelector("#log");
const preview = document.querySelector("#preview");
const context = preview.getContext("2d", { willReadFrequently: true });
const lines = [];
const log = (message) => {
lines.push(`${new Date().toISOString()} ${message}`);
output.textContent = lines.join("\n");
console.log(message);
};
const timed = async (label, operation) => {
const started = performance.now();
const result = await operation();
log(`PASS ${label} (${(performance.now() - started).toFixed(1)} ms)`);
return result;
};
window.addEventListener("error", (event) => log(`FAIL ${event.error?.stack ?? event.message}`));
window.addEventListener("unhandledrejection", (event) => log(`FAIL ${event.reason?.stack ?? event.reason}`));
ort.env.wasm.numThreads = 1;
ort.env.wasm.wasmPaths = "/.cache/ort/";
if (!navigator.gpu) throw new Error("WebGPU is unavailable");
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) throw new Error("No WebGPU adapter is available");
const gpuDevice = await adapter.requestDevice();
ort.env.webgpu.device = gpuDevice;
log(`navigator.gpu=true secure=${window.isSecureContext}`);
const createSession = async (path, preferredOutputLocation) => {
const response = await fetch(path);
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
return ort.InferenceSession.create(bytes, {
executionProviders: ["webgpu"],
preferredOutputLocation,
logSeverityLevel: 2,
});
};
const parameters = new URLSearchParams(location.search);
const bundle = parameters.get("bundle") ?? "lossless";
const compactVision = bundle.startsWith("compact-");
const compact = bundle === "compact-qdq";
const compactFp16 = bundle === "compact-fp16";
const gatherTokens = bundle === "compact-unified-gather" || bundle === "balanced-unified-gather";
const unifiedQdq = bundle === "compact-unified-qdq" || bundle === "balanced-unified-qdq" || gatherTokens;
const qdq = compact || bundle === "balanced-qdq";
const publishedLayout = parameters.get("layout") === "hf";
const publishedRoot = compactVision ? "./variants/webgpu-121" : "./variants/webgpu-242";
const visionPath = publishedLayout
? `${publishedRoot}/${compactVision ? "vision_int4.onnx" : "vision_fp16.onnx"}`
: compactVision
? "./model/onnx/vision_int4.onnx"
: parameters.get("vision") === "fp32"
? "./output/vision_fp32.onnx"
: "./model/onnx/vision_fp16.onnx";
const prefillPath = publishedLayout
? `${publishedRoot}/decoder_prefill_qdq_int8.onnx`
: compactFp16
? "./output/decoder_prefill_fp16.onnx"
: qdq
? "./output/decoder_prefill_qdq_int8.onnx"
: "./output/decoder_prefill_fp32.onnx";
const stepPath = publishedLayout
? `${publishedRoot}/decoder_step_qdq_int8.onnx`
: compactFp16
? "./output/decoder_step_fp16.onnx"
: qdq
? "./output/decoder_step_qdq_int8.onnx"
: "./output/decoder_step_fp32.onnx";
const maxTokens = Number(parameters.get("tokens") ?? 128);
const vision = await timed("vision session creation", () =>
createSession(visionPath, { vision_embeds: "gpu-buffer" })
);
const decoderOutputs = Object.fromEntries([
["logits", "cpu"],
...cacheNames.map((name) => [name, "gpu-buffer"]),
]);
const unifiedFile = gatherTokens
? "decoder_unified_gather_qdq_int8.onnx"
: "decoder_unified_qdq_int8.onnx";
const unifiedPath = publishedLayout
? `${publishedRoot}/${unifiedFile}`
: `./output/${unifiedFile}`;
const activePrefillPath = unifiedQdq ? unifiedPath : prefillPath;
const activeStepPath = unifiedQdq ? unifiedPath : stepPath;
const prefill = await timed("prefill session creation", () =>
createSession(activePrefillPath, decoderOutputs)
);
const step = unifiedQdq ? prefill : await timed("step session creation", () =>
createSession(stepPath, decoderOutputs)
);
let sessionsReleased = false;
const releaseSessions = async () => {
if (sessionsReleased) return;
sessionsReleased = true;
const sessions = step === prefill ? [vision, prefill] : [vision, prefill, step];
const results = await Promise.allSettled(sessions.map((session) => session.release()));
gpuDevice.destroy();
const failed = results.filter((result) => result.status === "rejected");
if (failed.length) {
throw new Error(`Failed to release ${failed.length}/${sessions.length} model sessions`);
}
document.querySelector("#run").disabled = true;
log(`PASS released ${sessions.length} model sessions`);
};
window.releaseSessions = releaseSessions;
window.addEventListener("pagehide", () => void releaseSessions(), { once: true });
const vocabPath = publishedLayout ? "./tokenizer/vocab.json" : "./model/tokenizer/vocab.json";
const charset = await (await fetch(vocabPath)).json();
const idToCharacter = ["", "", "", "", ...charset];
const contentIds = new Set();
for (let index = 0; index < charset.length; index += 1) {
const character = charset[index];
if (character.length === 1 && !"ーー〜~".includes(character) && /[\p{Letter}\p{Number}]/u.test(character)) {
contentIds.add(index + 4);
}
}
log(`models ready bundle=${bundle} vision=${visionPath} prefill=${activePrefillPath} step=${activeStepPath} sharedDecoder=${unifiedQdq} gatherTokens=${gatherTokens} vocab=${idToCharacter.length}`);
const loadImage = (source) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = source;
});
const parseCrop = (image) => {
const raw = parameters.get("crop");
if (!raw) return [0, 0, image.naturalWidth, image.naturalHeight];
const values = raw.split(",").map(Number);
if (values.length !== 4 || values.some((value) => !Number.isFinite(value))) {
throw new Error("crop must be x,y,width,height");
}
return values;
};
const preprocess = (image) => {
const crop = parseCrop(image);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.clearRect(0, 0, 224, 224);
context.drawImage(image, ...crop, 0, 0, 224, 224);
const rgba = context.getImageData(0, 0, 224, 224).data;
const values = new Float32Array(3 * 224 * 224);
for (let pixel = 0; pixel < 224 * 224; pixel += 1) {
for (let channel = 0; channel < 3; channel += 1) {
values[channel * 224 * 224 + pixel] =
(rgba[pixel * 4 + channel] / 255 - MEAN[channel]) / STD[channel];
}
}
log(`crop=${crop.join(",")} source=${image.naturalWidth}x${image.naturalHeight}`);
return new ort.Tensor("float32", values, [1, 3, 224, 224]);
};
const oneHot = (token) => {
const values = new Float32Array(VOCAB_SIZE);
values[token] = 1;
return new ort.Tensor("float32", values, [1, 1, VOCAB_SIZE]);
};
const tokenId = (token) => new ort.Tensor("int32", Int32Array.of(token), [1, 1]);
const emptyVision = () => new ort.Tensor("float32", new Float32Array(0), [1, 0, 512]);
const emptyCache = () => new ort.Tensor("float32", new Float32Array(0), [1, 2, 0, 64]);
const prefillPositions = () => new ort.Tensor(
"int32",
Int32Array.from({ length: 257 }, (_, index) => index),
[1, 257],
);
const shouldBlockToken = (tokens) => {
if (!tokens.length || !contentIds.has(tokens.at(-1))) return false;
const last = tokens.at(-1);
let run = 0;
for (let index = tokens.length - 1; index >= 0 && tokens[index] === last; index -= 1) run += 1;
return run >= 12;
};
const chooseToken = (source, sequence, tokens) => {
const seen = new Set(sequence);
const blocked = shouldBlockToken(tokens) ? tokens.at(-1) : -1;
const adjusted = (index) => {
if (index === blocked) return Number.NEGATIVE_INFINITY;
const value = source[index];
if (!seen.has(index)) return value;
return value < 0 ? value * 1.2 : value / 1.2;
};
let result = 0;
let best = adjusted(0);
for (let index = 1; index < source.length; index += 1) {
const value = adjusted(index);
if (value > best) {
best = value;
result = index;
}
}
return result;
};
const disposeResult = (result) => {
for (const name of cacheNames) result[name].dispose();
result.logits.dispose();
};
const recognize = async (image) => {
const pixels = preprocess(image);
const totalStarted = performance.now();
const visionStarted = performance.now();
const visionResult = await timed("vision execution", () => vision.run({ pixel_values: pixels }));
const visionMs = performance.now() - visionStarted;
log(`vision output=${visionResult.vision_embeds.location} ${JSON.stringify(visionResult.vision_embeds.dims)}`);
const prefillStarted = performance.now();
const prefillFeeds = unifiedQdq
? {
vision_embeds: visionResult.vision_embeds,
[gatherTokens ? "token_ids" : "token_one_hot"]: gatherTokens ? tokenId(BOS) : oneHot(BOS),
position_ids: prefillPositions(),
...Object.fromEntries(cacheNames.map((name) => [name.replace("present_", "past_"), emptyCache()])),
}
: { vision_embeds: visionResult.vision_embeds };
let cacheResult = await timed("decoder prefill", () => prefill.run(prefillFeeds));
if (unifiedQdq) {
prefillFeeds[gatherTokens ? "token_ids" : "token_one_hot"].dispose();
prefillFeeds.position_ids.dispose();
for (const name of cacheNames) prefillFeeds[name.replace("present_", "past_")].dispose();
}
const prefillMs = performance.now() - prefillStarted;
log(`KV output=${cacheResult.present_k0.location} ${JSON.stringify(cacheResult.present_k0.dims)}`);
visionResult.vision_embeds.dispose();
pixels.dispose();
const sequence = [BOS];
const tokens = [];
const decodeStarted = performance.now();
for (let iteration = 0; iteration < maxTokens; iteration += 1) {
const next = chooseToken(cacheResult.logits.data, sequence, tokens);
if (next === EOS) break;
tokens.push(next);
sequence.push(next);
if (tokens.length >= maxTokens) break;
const feeds = {
[gatherTokens ? "token_ids" : "token_one_hot"]: gatherTokens ? tokenId(next) : oneHot(next),
position_ids: new ort.Tensor("int32", new Int32Array([257 + iteration]), [1, 1]),
};
if (unifiedQdq) feeds.vision_embeds = emptyVision();
for (let layer = 0; layer < 6; layer += 1) {
feeds[`past_k${layer}`] = cacheResult[`present_k${layer}`];
feeds[`past_v${layer}`] = cacheResult[`present_v${layer}`];
}
const nextResult = await step.run(feeds);
disposeResult(cacheResult);
feeds[gatherTokens ? "token_ids" : "token_one_hot"].dispose();
feeds.position_ids.dispose();
if (unifiedQdq) feeds.vision_embeds.dispose();
cacheResult = nextResult;
}
const decodeMs = performance.now() - decodeStarted;
const text = tokens.map((token) => idToCharacter[token] ?? "").join("");
log(`PASS decode ${tokens.length} tokens (${decodeMs.toFixed(1)} ms)`);
log(`TEXT ${text}`);
disposeResult(cacheResult);
return {
text,
tokens: tokens.length,
visionMs,
prefillMs,
decodeMs,
totalMs: performance.now() - totalStarted,
};
};
const normalizeText = (text) => text.normalize("NFKC").replace(/\s+/g, "");
const editDistance = (left, right) => {
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
const current = [leftIndex];
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
current.push(Math.min(
current[rightIndex - 1] + 1,
previous[rightIndex] + 1,
previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
));
}
previous = current;
}
return previous.at(-1);
};
const hayai13 = [
["01", "知らない世界で見つけたイメージを"],
["02", "カナデトモスソラ(Kanadetomosusora)"],
["03", "建設会社社員行方"],
["04", "だとしてもこのレベルがウロつくなんて...おそらく2級の呪い"],
["05", "パチパチパチパチ"],
["06", "バビュン"],
["07", "僕の過去とか未来とか"],
["08", "くらべられっ子"],
["09", "そうだクラス分けがあるんだった!!"],
["10", "脇役よ、主役を超えよ!"],
["11", "Eh~Idon'treallywantto~"],
["12", "「Sorryforthewait~!Didyouwaitlong?」"],
["13", "YamateAreaNewresidentialdistrictforforeigners"],
];
const runHayai13 = async () => {
const results = [];
for (const [id, expected] of hayai13) {
log(`CASE ${id}/13`);
const result = await recognize(await loadImage(`./.cache/hayai13/${id}.png`));
const normalizedExpected = normalizeText(expected);
const normalizedActual = normalizeText(result.text);
results.push({
id,
expected,
actual: result.text,
distance: editDistance(normalizedExpected, normalizedActual),
characters: normalizedExpected.length,
exact: normalizedExpected === normalizedActual,
...result,
});
}
const distance = results.reduce((sum, result) => sum + result.distance, 0);
const characters = results.reduce((sum, result) => sum + result.characters, 0);
const exact = results.filter((result) => result.exact).length;
const sortedTimes = results.map((result) => result.totalMs).sort((a, b) => a - b);
const summary = {
bundle,
cases: results.length,
nCER: distance / characters,
distance,
characters,
exact,
medianMs: sortedTimes[Math.floor(sortedTimes.length / 2)],
peakCaseMs: sortedTimes.at(-1),
results,
};
log(`SUITE_RESULT ${JSON.stringify(summary)}`);
window.__suiteResult = summary;
await fetch("/benchmark-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(summary),
});
log(`PASS persisted benchmark result for ${bundle}`);
};
window.runHayai13 = runHayai13;
const fileInput = document.querySelector("#file");
document.querySelector("#run").addEventListener("click", async () => {
const [file] = fileInput.files;
if (!file) throw new Error("Choose a speech-bubble crop first");
try {
await recognize(await loadImage(URL.createObjectURL(file)));
} finally {
await releaseSessions();
}
});
const suite = parameters.get("suite");
const imagePath = parameters.get("image");
if (suite === "hayai13" || imagePath) {
try {
if (suite === "hayai13") {
await runHayai13();
} else {
const image = await loadImage(imagePath);
const runs = Number(parameters.get("runs") ?? 1);
for (let run = 1; run <= runs; run += 1) {
log(`RUN ${run}/${runs}`);
await recognize(image);
}
}
} finally {
await releaseSessions();
}
}
</script>